Skip to content

Commit bf03d33

Browse files
committed
feat(spec): add prove-it-runs proof field + ratchet to liveness gate (ADR-0054 #1)
ADR-0054 follow-up (1): the liveness ledger's `live` meant only a static consumer pointer — necessary but not sufficient, since a property can be live at every layer yet broken end-to-end. This adds the third leg: high-risk authorable properties must carry a `proof` (a dogfood test reference) that asserts the runtime outcome. - proof-registry.mts: the authoritative high-risk-class list (field types, analytics, RLS, flow nodes, form widgets) + which classes the ratchet enforces this phase. Field types and RLS are bound (matrix exists AND surface governed); analytics/flow/form are listed-but-blocked with honest reasons (their surface isn't governed yet / no proof yet — Phase 2). - check-liveness.mts: a bound `live` entry must carry a valid `proof` of its own class. Validation is STATIC (file exists + declares the `@proof: <id>` tag) so the gate stays seconds-cheap; running the proof remains the dogfood gate's job. Reverse check flags unregistered `@proof:` tags. - Ledger: field.type → field-zoo proof; permission.rowLevelSecurity.using → #1994 RLS proof. Dogfood proofs self-declare their `@proof:` tag. - Gate now also triggers on packages/dogfood/** so deleting/renaming a proof re-runs the check and the dangling reference is caught. - 15 unit + wiring tests; README documents the contract and ratchet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
1 parent d1129c4 commit bf03d33

11 files changed

Lines changed: 503 additions & 8 deletions

.github/workflows/spec-liveness-check.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,18 @@ name: Spec Liveness Check
66
# property of a GOVERNED type to declare a liveness status with evidence in
77
# packages/spec/liveness/<type>.json. A new unclassified property fails the check (the
88
# ratchet: no new undeclared surface). See packages/spec/liveness/README.md.
9+
#
10+
# ADR-0054 (prove-it-runs): bound high-risk 'live' properties must carry a `proof`
11+
# pointing to a dogfood test that declares the matching `@proof:` tag. The gate also
12+
# triggers on packages/dogfood/** so deleting/renaming a proof re-runs this check and
13+
# the dangling reference is caught (the proof files live outside packages/spec/).
914

1015
on:
1116
pull_request:
1217
types: [opened, synchronize, reopened]
1318
paths:
1419
- 'packages/spec/**'
20+
- 'packages/dogfood/**'
1521

1622
permissions:
1723
contents: read

packages/dogfood/test/analytics-timezone.dogfood.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
// GOLDEN REGRESSION for #1982 / #2018 — "organization timezone drives analytics
44
// date bucketing", exercised end-to-end through the real HTTP + service stack.
55
//
6+
// @proof: analytics-tz-bucketing
7+
// ADR-0054 runtime proof for the analytics high-risk class. Registered in
8+
// proof-registry.mts but NOT yet ledger-bound: the authorable surface
9+
// (dataset/report dimensions+measures) is not a GOVERNED liveness type yet, so
10+
// there is no entry to carry the proof. Binds once dataset/report are governed.
11+
//
612
// This is the test that would have caught #2018 before merge. That bug passed
713
// every static gate: it lived in the *integration* of NativeSQLStrategy routing
814
// + in-memory count + REST execution-context resolution. Only booting the app

packages/dogfood/test/field-zoo-roundtrip.dogfood.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
// CAPABILITY-MATRIX golden test — every authorable field type must survive a
44
// real HTTP write → read round-trip.
55
//
6+
// @proof: field-type-roundtrip
7+
// ADR-0054 runtime proof for the field-type high-risk class. Referenced by the
8+
// liveness ledger entry `field.type` (packages/spec/liveness/field.json); the
9+
// spec liveness gate fails if this tag is removed. See proof-registry.mts.
10+
//
611
// `showcase_field_zoo` carries one field of (almost) every protocol FieldType.
712
// Until now it was only *static*-checked (the metadata bundle registers it);
813
// nothing wrote a record and read it back. But the platform's value is that an

packages/dogfood/test/rls-fixture.dogfood.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
//
33
// The HARD, revert-provable #1994 gate.
44
//
5+
// @proof: rls-by-id-write
6+
// ADR-0054 runtime proof for the RLS / sharing high-risk class. Referenced by the
7+
// liveness ledger entry `permission.rowLevelSecurity.using`
8+
// (packages/spec/liveness/permission.json); the spec liveness gate fails if this
9+
// tag is removed. See proof-registry.mts.
10+
//
511
// `auto-verify-rls.dogfood.test.ts` runs the cross-owner runner over the real
612
// apps, but single-tenant boot strips the tenant policy so every object is
713
// `member-visible` — the by-id-write path is never exercised. This test boots a

packages/spec/liveness/README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,57 @@ Resolution per property: **ledger entry → spec `.describe()` marker → UNCLAS
3535
Framework provenance/lock fields (`_lock*`, `_provenance`, `_packageId/Version`,
3636
`protection` — ADR-0010) are auto-classified `live`.
3737

38+
## Runtime proofs — prove-it-runs (ADR-0054)
39+
40+
`live` today means only *a static pointer to a consumer* — proof that something
41+
*reads* the property. That is necessary but not sufficient: a property can be live
42+
at every layer yet **broken end-to-end** (the break lives in the integration —
43+
engine ↔ driver ↔ service ↔ HTTP). [ADR-0054](../../../docs/adr/0054-runtime-proof-for-authorable-surface.md)
44+
adds the third leg: for a defined class of **high-risk** authorable properties, a
45+
`live` entry must carry a **`proof`** — a reference to a `@objectstack/dogfood` test
46+
that authors the property against the real in-process stack and asserts the runtime
47+
outcome.
48+
49+
```jsonc
50+
"type": {
51+
"status": "live",
52+
"evidence": "packages/objectql/src/engine.ts",
53+
"proof": "packages/dogfood/test/field-zoo-roundtrip.dogfood.test.ts#field-type-roundtrip"
54+
}
55+
```
56+
57+
**The contract.** A `proof` is `"<repo-relative-file>#<proof-id>"`. The dogfood test
58+
self-declares the id with a greppable tag near its top:
59+
60+
```ts
61+
// @proof: field-type-roundtrip
62+
```
63+
64+
The gate validates **statically** (it never runs the test — that's the dogfood
65+
gate's job, keeping this gate seconds-cheap): the file must exist **and** declare the
66+
`@proof: <id>` tag. A bound entry must point at *its own class's* proof. The reverse
67+
is also checked: a `@proof:` tag under `packages/dogfood/test/**` that isn't
68+
registered in `../scripts/liveness/proof-registry.mts` is flagged (warning) so a new
69+
proof gets wired in.
70+
71+
**The ratchet (the authoritative high-risk-class list).** Defined in
72+
`../scripts/liveness/proof-registry.mts`. A class is **CI-enforced** (`bound`) only
73+
once it has *both* a runtime proof *and* a governed ledger entry to carry it — the
74+
binding lands one class at a time (ADR-0054 §3), never as a big-bang backfill.
75+
76+
| High-risk class | Bound? | Ledger binding | Proof |
77+
|---|---|---|---|
78+
| Field types | ✅ enforced | `field.type` | `field-zoo-roundtrip.dogfood.test.ts#field-type-roundtrip` |
79+
| RLS / sharing | ✅ enforced | `permission.rowLevelSecurity.using` | `rls-fixture.dogfood.test.ts#rls-by-id-write` |
80+
| Analytics dims/measures | ⛔ pending || `analytics-timezone.dogfood.test.ts#analytics-tz-bucketing` (proof exists; surface `dataset`/`report` not yet governed) |
81+
| Flow nodes | ⛔ pending | `flow.nodes.type` (candidate) | none yet (Phase 2) |
82+
| Form layout/section/widget | ⛔ pending || none yet (Phase 2) |
83+
84+
To bind a pending class: add its dogfood proof + `@proof:` tag, set `bound: true` and
85+
its `ledgerBindings` in `proof-registry.mts`, add the `proof` to the ledger entry, and
86+
confirm the gate is green. Because the gate also triggers on `packages/dogfood/**`,
87+
deleting or renaming a proof re-runs this check and the dangling reference is caught.
88+
3889
## Author warnings — closing the loop (`authorWarn`)
3990

4091
Classification is also fed back to the *author* at build time. The CLI `compile`

packages/spec/liveness/field.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
},
1313
"type": {
1414
"status": "live",
15-
"evidence": "packages/objectql/src/engine.ts"
15+
"evidence": "packages/objectql/src/engine.ts",
16+
"proof": "packages/dogfood/test/field-zoo-roundtrip.dogfood.test.ts#field-type-roundtrip",
17+
"note": "ADR-0054 high-risk class (field types): the proof writes one record of (almost) every field type over the real HTTP API and asserts each reads back with type fidelity (rating/slider→number, toggle→boolean) — guarding the persistence + read-coercion integration that #2025 fixed."
1618
},
1719
"description": {
1820
"status": "live",

packages/spec/liveness/permission.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@
9292
"using": {
9393
"status": "live",
9494
"evidence": "packages/plugins/plugin-security/src/rls-compiler.ts",
95-
"note": "compiled into find + analytics SQL."
95+
"proof": "packages/dogfood/test/rls-fixture.dogfood.test.ts#rls-by-id-write",
96+
"note": "compiled into find + analytics SQL. ADR-0054 high-risk class (RLS): the proof boots an owner-isolated fixture so a fresh member cannot read an admin-created row, then asserts the runner's verdict in both directions — `rls-consistent` when the owner predicate also gates the by-id write (#1994 pre-image check) and `rls-hole` when it doesn't. Guards read AND by-id-write enforcement, not just the read predicate."
9697
},
9798
"check": {
9899
"status": "live",

packages/spec/scripts/liveness/check-liveness.mts

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,30 @@
2222
// Statuses: live | experimental | planned | dead. Resolution per property:
2323
// ledger entry → spec `.describe()` marker ([EXPERIMENTAL — not enforced]) → UNCLASSIFIED
2424
//
25+
// PROVE-IT-RUNS (ADR-0054): a `live` entry may carry a `proof` (a dogfood test
26+
// reference `<file>#<proof-id>`). For the HIGH-RISK classes bound this phase
27+
// (see proof-registry.mts), a `live` classification MUST carry a valid proof —
28+
// the file must exist and declare the `@proof: <id>` tag. CI fails otherwise.
29+
//
2530
// Usage:
2631
// tsx check-liveness.mts # check all governed types
2732
// tsx check-liveness.mts --dump <type> # inventory a type's properties (seeding aid)
2833
// tsx check-liveness.mts --json # machine-readable report
2934

3035
process.env.OS_EAGER_SCHEMAS = '1';
3136

32-
import { readFileSync, existsSync } from 'node:fs';
37+
import { readFileSync, existsSync, readdirSync } from 'node:fs';
3338
import { fileURLToPath } from 'node:url';
3439
import { dirname, join, resolve } from 'node:path';
3540
import { getMetadataTypeSchema, listMetadataTypeSchemaTypes } from '../../src/kernel/metadata-type-schemas';
41+
import {
42+
BOUND_PROOF_PATHS,
43+
HIGH_RISK_CLASSES,
44+
KNOWN_PROOF_IDS,
45+
extractProofTags,
46+
parseProofRef,
47+
validateProofRef,
48+
} from './proof-registry.mts';
3649

3750
const here = dirname(fileURLToPath(import.meta.url));
3851
const specRoot = resolve(here, '../..'); // packages/spec
@@ -131,7 +144,17 @@ if (dumpIdx !== -1) {
131144

132145
// ---- check ----
133146
const asJson = args.includes('--json');
134-
const report: any = { types: {}, totals: { byStatus: {} as Record<string, number> }, unclassified: [] as string[], staleEvidence: [] as string[] };
147+
const report: any = {
148+
types: {},
149+
totals: { byStatus: {} as Record<string, number> },
150+
unclassified: [] as string[],
151+
staleEvidence: [] as string[],
152+
proofErrors: [] as string[], // a `proof` ref that doesn't resolve (missing file / missing tag / malformed)
153+
proofMissing: [] as string[], // a bound high-risk `live` entry with no proof at all
154+
orphanProofs: [] as string[], // a dogfood `@proof:` tag not registered in proof-registry.mts
155+
};
156+
157+
const proofFs = { existsSync, readFileSync };
135158

136159
function classify(type: string, path: string, status: string, led: any, cat: any) {
137160
cat.classified++;
@@ -141,6 +164,50 @@ function classify(type: string, path: string, status: string, led: any, cat: any
141164
const file = String(led.evidence).split(':')[0];
142165
if (/\//.test(file) && !existsSync(join(repoRoot, file))) report.staleEvidence.push(`${type}/${path}${led.evidence}`);
143166
}
167+
// ── ADR-0054 prove-it-runs ──
168+
const boundClass = BOUND_PROOF_PATHS.get(`${type}/${path}`);
169+
if (led?.proof !== undefined) {
170+
// Any declared proof is validated (no silent rot), bound or not.
171+
const v = validateProofRef(led.proof, { repoRoot, fs: proofFs, join });
172+
if (!v.ok) report.proofErrors.push(`${type}/${path}${v.error}`);
173+
else if (boundClass) {
174+
// A bound entry must point at ITS class's proof, not just any valid proof.
175+
const id = parseProofRef(led.proof)?.id;
176+
if (id !== boundClass.proofId) {
177+
report.proofErrors.push(
178+
`${type}/${path} → proof "${id}" is not the bound ${boundClass.label} proof ("${boundClass.proofId}")`,
179+
);
180+
}
181+
}
182+
} else if (boundClass && status === 'live') {
183+
report.proofMissing.push(
184+
`${type}/${path} (${boundClass.label}) — high-risk live property requires a proof: expected "${boundClass.proofRef}"`,
185+
);
186+
}
187+
}
188+
189+
// Reverse integrity: every `@proof:` tag declared under the dogfood proof tree
190+
// must be registered in proof-registry.mts. An orphan tag means a proof was
191+
// written but never wired into the high-risk-class list — flag it (warning).
192+
function scanOrphanProofs() {
193+
const proofDir = join(repoRoot, 'packages/dogfood/test');
194+
if (!existsSync(proofDir)) return; // spec may be consumed standalone (published)
195+
const walk = (dir: string): string[] => {
196+
const out: string[] = [];
197+
for (const ent of readdirSync(dir, { withFileTypes: true })) {
198+
const full = join(dir, ent.name);
199+
if (ent.isDirectory()) out.push(...walk(full));
200+
else if (ent.isFile() && ent.name.endsWith('.ts')) out.push(full);
201+
}
202+
return out;
203+
};
204+
for (const file of walk(proofDir)) {
205+
for (const tag of extractProofTags(readFileSync(file, 'utf8'))) {
206+
if (!KNOWN_PROOF_IDS.has(tag)) {
207+
report.orphanProofs.push(`@proof: ${tag} (in ${file.slice(repoRoot.length + 1)}) — not registered in proof-registry.mts`);
208+
}
209+
}
210+
}
144211
}
145212

146213
for (const type of GOVERNED) {
@@ -169,7 +236,11 @@ for (const type of GOVERNED) {
169236
report.types[type] = cat;
170237
}
171238

239+
scanOrphanProofs();
240+
172241
const totalUnclassified = report.unclassified.length;
242+
const totalProofFailures = report.proofErrors.length + report.proofMissing.length;
243+
const failed = totalUnclassified > 0 || totalProofFailures > 0;
173244
if (asJson) {
174245
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
175246
} else {
@@ -178,15 +249,30 @@ if (asJson) {
178249
const parts = Object.entries(v.byStatus).map(([s, n]) => `${s} ${n}`).join(', ');
179250
console.log(` ${t.padEnd(11)} ${v.classified} classified (${parts || '—'})${v.unclassified ? `, ${v.unclassified} UNCLASSIFIED` : ''}`);
180251
}
252+
const boundClasses = HIGH_RISK_CLASSES.filter((c) => c.bound).map((c) => c.label);
253+
console.log(`\nprove-it-runs (ADR-0054): proof REQUIRED for bound high-risk classes — ${boundClasses.join(', ') || 'none'}`);
181254
if (report.staleEvidence.length) {
182255
console.log(`\n⚠ ${report.staleEvidence.length} 'live' entr(ies) cite a missing file:`);
183256
report.staleEvidence.forEach((s: string) => console.log(` ${s}`));
184257
}
258+
if (report.orphanProofs.length) {
259+
console.log(`\n⚠ ${report.orphanProofs.length} unregistered dogfood proof tag(s) — add to proof-registry.mts:`);
260+
report.orphanProofs.forEach((s: string) => console.log(` ${s}`));
261+
}
262+
if (report.proofMissing.length) {
263+
console.log(`\n✗ ${report.proofMissing.length} high-risk 'live' propert(ies) missing a runtime proof:`);
264+
report.proofMissing.forEach((s: string) => console.log(` ${s}`));
265+
}
266+
if (report.proofErrors.length) {
267+
console.log(`\n✗ ${report.proofErrors.length} proof reference(s) do not resolve:`);
268+
report.proofErrors.forEach((s: string) => console.log(` ${s}`));
269+
}
185270
if (totalUnclassified) {
186271
console.log(`\n✗ ${totalUnclassified} UNCLASSIFIED — classify in packages/spec/liveness/<type>.json:`);
187272
report.unclassified.forEach((s: string) => console.log(` ${s}`));
188-
} else {
189-
console.log('\n✓ all governed-type properties are classified.');
273+
}
274+
if (!failed) {
275+
console.log('\n✓ all governed-type properties are classified; all bound high-risk proofs resolve.');
190276
}
191277
}
192-
process.exit(totalUnclassified > 0 ? 1 : 0);
278+
process.exit(failed ? 1 : 0);

0 commit comments

Comments
 (0)