Skip to content

Commit 70e1a82

Browse files
os-billclaude
andauthored
feat(spec): the liveness gate reads the retiredKey() tombstone marker — a tombstoned key may not be graded live (#19092)
Fixes #19062 Clause-②: no ## What this closes The liveness gate graded a schema property from two sources: the ledger row's `status`, or — with no row — a `[experimental` / `[planned` marker read by `markerStatus()`. It had **no reading of the `retiredKey()` tombstone marker at all**, so `tombstone + status: live` was a *structurally permitted* combination rather than an oversight: - the forward pass is satisfied — a tombstone keeps the key in the walked shape, so the row exists and is classified; - the orphan pass is satisfied for the same reason — the property has not left the schema; - nothing constrained what that row was allowed to **say**. This adds the join both directions lacked, as a fourth direction beside the orphan scan: walk the tombstones the gate already reaches, and ask each one's ledger row what it claims. ## Ordering — discharged This section used to read "this PR must not merge before #19059", and that constraint is now **spent**: #19059 landed as `a362e0e425`, `main` is merged into this branch, and the single offending row it named — `agent/tools`, #18304's — is `dead` on the real ledgers. Re-measured by the seat on this PR's head `3d49d7a199` (2026-09-18T22:08Z): - `check:liveness` against the shipped ledgers: **exit 0**, printing `79 graded with a status the tombstone allows`. - The 16 control cases that were red for that one cause are green. The whole `scripts/liveness/` suite: **333 passed (333)** over 12 files, exit 0. ⛔ No landing order remains on this PR. Read the population table below as the measurement that *motivated* the rule, not as a description of today's tree. ## The population, measured — the card's first deliverable The card states its own evidence limit: "the population is unmeasured, and measuring it is the first piece of the work — it needs exactly the join the gate lacks". Measured over all 39 governed types by the gate's own walk, at two commits — the tree the rule was written against, and this PR's head `3d49d7a199` (the right-hand column re-measured by the seat, 2026-09-18T22:08Z): | reading | tree the rule was written against | this PR's head | |---|---|---| | `[REMOVED]` tombstones the gate's walk reaches | **79** | **79** | | …graded `dead` | **78** | **79** | | …graded `live` (the defective state) | **1** — `agent/tools` | **0** — #19059 repaired that row | | …whose status came from an explicit ledger row | **79 / 79** | **79 / 79** (none from a marker, none from `childrenDefault`, none UNCLASSIFIED) | The full list of 79 is in the report on the card. **Drift control on the instrument**: the census walk reproduces the gate's own per-type verdict counts for all 39 types and 1092 verdict points exactly (compared against `check:liveness --json`'s `classified + unclassified`), so it is the gate's walk and not a copy of it. **Reachable radius of that census** — it is the gate's walk, so it is the ledger-driven drill, nothing wider. **Known targets necessarily outside it**: a full-depth walk of the same governed roots finds **59 further `[REMOVED]` tombstones the gate never reaches** — `object/fields.conditionalRequired`, `object/listViews.performance`, `job/retryPolicy.retryDelayMs` among them — because they sit under containers carrying a blanket verdict (the 601 child keys the gate already reports as classified nowhere). No rule placed in this gate can grade a key the gate does not walk; closing that is the undrilled-container worklist, not this card. ## Why remedy (2), measured rather than argued Triage named two shapes and refused to pick. Their side-effect surfaces were measured, each applied alone to the base gate with the mutation proven on disk before any verdict was read: - **(1) a third `[REMOVED]` arm in `markerStatus()`** — on the acceptance sample this leaves the gate at **exit 0**. `markerStatus()` is consulted only when `led?.status` is falsy, and all 79 tombstones the walk reaches take their status from an explicit row, so this arm changes the verdict of **0 of the 79** today. What it does change is the no-row case, which the gate currently reports as UNCLASSIFIED and fails on, and which `ORPHAN_GUIDANCE` tells authors never to create — so (1) alone would *silence* an existing loud failure. It cannot satisfy the acceptance. - **(2) a rule beside the tombstone-row rule** — implemented here. The forbidden set is a named constant holding exactly `live`, which is what was ruled. `planned` / `experimental` / `live-elsewhere` on a tombstone are equally false, and their population today is zero, so widening the set is a separate measurement rather than a rider on this one. ## Acceptance Every leg runs the real gate the way CI invokes it, via `--ledger-root` against a copied ledger. The carrier the legs flip is **derived per run from the gate's own `report.tombstones` enumeration**, not named: top-level, not a `BOUND_PROOF_PATHS` coordinate, and currently `dead`. A fixture pinned to one row's *current verdict* is pinned to a moving fact, which is how this block first broke — it named `agent/tools`, that row went `dead` upstream, and the "this would be a no-op" guard took four cases red. The seat re-derived the same filter chain independently (2026-09-18T22:08Z): **79 enumerated → 42 top-level → 42 unbound → 42 `dead`**, carrier `action/bulkEnabled`. ⚠️ Today only the top-level filter bites — the bound-path and `dead` filters each drop **0** — so those two are shape guards holding a property, not measured reductions, and the block's own assertion is on *eligible > 0*, never on a filter biting. The red and green samples differ in **exactly one status value** (plus the arithmetic the generated count artifact requires, so a stale count cannot mask the verdict), and "one value apart" is now checked as bytes by its own case rather than asserted in prose. | leg | before | after | |---|---|---| | **main** — tombstone + row `status: live` | exit **0** (green — the gate had no reading) | exit **1**, `<derived carrier> -> "live"`, with exactly one `✗` block in the output | | **lit control** — same sample, legal status | exit 0 | exit **0** | | **dark control** — ordinary non-tombstoned `live` rows | exit 0 | exit **0**, and the whole gate output is identical except the two lines added here | The dark control is the strong form: a full diff of the gate's output on a legal ledger, before versus after, differs only by the new `tombstoned keys:` census line and the new clause in the summary sentence. All 39 per-type verdict lines and every other check's output are byte-identical, so nothing else was tightened. The before-legs were taken by reverting both files to the base commit after the fix was committed, with the mutation proven on disk (blob hashes moved, the new symbols counted 0 occurrences) and the restore proven by `git diff HEAD` being empty and the blob hash matching `HEAD`'s. ## Non-vacuity `0 forbidden` reads identically whether every row is honest or the marker drifted and the scan reached nothing, so the gate prints the population it asked on every run, pass or fail. On this PR's head `3d49d7a199` against the shipped ledgers (2026-09-18T22:08Z) it prints the green form: ``` tombstoned keys: 79 `[REMOVED]` tombstone(s) reached by the walk, 79 graded with a status the tombstone allows. ``` and on the red leg the same line carries the finding: ``` tombstoned keys: 79 `[REMOVED]` tombstone(s) reached by the walk, 78 graded with a status the tombstone allows, 1 FORBIDDEN. ``` and `orphans.test.ts` holds `TOMBSTONE_MARKER` equal to what `retiredKey()` actually writes, read from the producer rather than restated. ## Changeset `skip-changeset`, measured rather than assumed. `@objectstack/spec`'s `files[]` is `dist · json-schema · liveness · prompts · llms.txt · README.md · src/**/*.zod.ts · CHANGELOG.md · api-surface · api-surface-declarations · spec-changes.json`; `scripts/` is not in it, and the build's entries are src modules. Each of the three symbols this PR adds occurs in **0** files under those shipped paths; positive controls through the same instrument and the same path set: `retiredKey` in 102, `ObjectSchema` in 30. Nothing this diff moves is shipped by any package. ## Acceptance notes Noted, not filed — observations, with no reproducible defect, contract violation or authoring trap behind them: - PR #19059 landed `scripts/liveness/tombstoned-row-status.test.ts` as `a362e0e425` (2026-09-18T22:08Z), a contract test asserting the same invariant against the **real** ledgers at **top-level granularity only**. It is complementary rather than duplicative — that one pins today's ledgers in `Test Core`, this one is a gate rule over every depth the walk reaches and over any ledger root. Left untouched here, per triage's `⛔ 不折叠`. This PR is the second of the two, and both files were written without sight of the other. - The gate's `markerStatus()` still has no `[REMOVED]` arm, deliberately: with a row present it is never consulted, and without one the UNCLASSIFIED failure is the louder answer. Recorded so the next reader does not read its absence as an oversight twice. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aadea24 commit 70e1a82

4 files changed

Lines changed: 558 additions & 6 deletions

File tree

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

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,14 @@ import {
217217
type UnanchoredCitation,
218218
} from './key-mention.mts';
219219
import { buildProducerReport, type ProducerEntry, type ProducerReport } from './producer.mts';
220-
import { ORPHAN_GUIDANCE, findOrphanEntries, type Orphan } from './orphans.mts';
220+
import {
221+
ORPHAN_GUIDANCE,
222+
TOMBSTONE_STATUS_GUIDANCE,
223+
findOrphanEntries,
224+
scanTombstonedRows,
225+
type GradedProperty,
226+
type Orphan,
227+
} from './orphans.mts';
221228
import {
222229
STALE_UNDRILLED_GUIDANCE,
223230
UNDRILLED_GUIDANCE,
@@ -678,6 +685,8 @@ const report: any = {
678685
proofMissing: [] as string[], // a bound high-risk `live` entry with no proof at all
679686
orphanProofs: [] as string[], // a dogfood `@proof:` tag not registered in proof-registry.mts
680687
orphanEntries: [] as string[], // a ledger row whose property is gone from the schema (the reverse direction)
688+
tombstonedLive: [] as string[], // a `retiredKey()` tombstone whose row still claims a forbidden status (#19062)
689+
tombstones: [] as string[], // every `[REMOVED]` tombstone the walk reached — enumerated, not totalled, so the population is checkable (#18133's reason)
681690
authorable: [] as string[], // the governance DENOMINATOR itself — printed and emitted so "never looked" cannot pass for "nothing to report" (#18133)
682691
authorableRegistered: 0, // how many of it are registered KINDS (listMetadataTypeSchemaTypes)
683692
authorableUnregisteredKinds: [] as string[], // …and which are unregistered-kind stack collections (#6245/#6931)
@@ -971,6 +980,13 @@ const observedContainers: ContainerCoverage[] = [];
971980
// So every key below the cut is reported UNCLASSIFIED, which FAILS the gate.
972981
// A depth limit the instrument does not announce is prose wearing the shape of
973982
// data (#4956), and the boundary of a check is the last place that is affordable.
983+
// Every property the walk GRADES, with the description it graded it from —
984+
// the left-hand side of the tombstone join (#19062). Collected here rather than
985+
// asked of the schema a second time because the two readings must be the same
986+
// reading: a separate walk is a copy that can drift until it stops seeing
987+
// tombstones, and a scan that sees none reports a clean tree.
988+
const gradedProps: GradedProperty[] = [];
989+
974990
const MAX_DRILL_DEPTH = 8;
975991

976992
/**
@@ -1029,8 +1045,10 @@ function drillChildren(
10291045
drillChildren(type, childPath, cs[ck], cled, cat, depth + 1);
10301046
continue;
10311047
}
1032-
const status = cled?.status || markerStatus(descOf(cs[ck])) || led.childrenDefault;
1048+
const childDescription = descOf(cs[ck]);
1049+
const status = cled?.status || markerStatus(childDescription) || led.childrenDefault;
10331050
if (!status) { cat.unclassified++; report.unclassified.push(`${type}/${childPath}`); continue; }
1051+
gradedProps.push({ key: `${type}/${childPath}`, description: childDescription, status });
10341052
// A drilled child that is ITSELF a container carries a blanket verdict over
10351053
// its own subtree, exactly as a top-level one does — so it owes the same
10361054
// declared disposition (drill / defer / record).
@@ -1070,13 +1088,18 @@ for (const type of GOVERNED) {
10701088
for (const o of orphans) report.orphanEntries.push(o.key);
10711089

10721090
for (const { key, node, description } of walked) {
1073-
if (FRAMEWORK_FIELDS.has(key)) { classify(type, key, 'live', null, cat); continue; }
1091+
if (FRAMEWORK_FIELDS.has(key)) {
1092+
gradedProps.push({ key: `${type}/${key}`, description, status: 'live' });
1093+
classify(type, key, 'live', null, cat);
1094+
continue;
1095+
}
10741096
const led = props[key];
10751097
if (led?.children) {
10761098
drillChildren(type, key, node, led, cat, 1);
10771099
} else {
10781100
const status = led?.status || markerStatus(description);
10791101
if (!status) { cat.unclassified++; report.unclassified.push(`${type}/${key}`); continue; }
1102+
gradedProps.push({ key: `${type}/${key}`, description, status });
10801103
// One verdict standing in for a whole subtree. Legal, but it must be
10811104
// declared rather than inherited by default — record it for the
10821105
// post-walk reconcile (drill.mts, #4956).
@@ -1089,6 +1112,15 @@ for (const type of GOVERNED) {
10891112

10901113
scanOrphanProofs();
10911114

1115+
// ── the tombstone join: what does the ledger claim about a key nobody can write? ──
1116+
// The gate's FOURTH direction (#19062). Schema to ledger catches an undeclared
1117+
// property; ledger to schema catches a row that outlived its property; container
1118+
// coverage catches a row that silently covers a subtree nobody classified; this
1119+
// catches a row that is present, classified, covered — and false.
1120+
const tombstones = scanTombstonedRows(gradedProps);
1121+
report.tombstones = tombstones.scanned;
1122+
report.tombstonedLive = tombstones.findings.map((t) => `${t.key} -> "${t.status}"`);
1123+
10921124
// ── container coverage: is every blanket verdict a DECLARED one? ──
10931125
// The gate's third direction (#4956). Schema → ledger catches an undeclared
10941126
// property; ledger → schema catches a row that outlived its property; this
@@ -1353,6 +1385,15 @@ const failed =
13531385
// the class, and for the zero-census that lets this start green.
13541386
report.orphanProofs.length > 0 ||
13551387
report.orphanEntries.length > 0 ||
1388+
// A tombstoned key whose row still claims a runtime consumer (#19062). Red
1389+
// rather than a warning, on the census that designed it: measured across all
1390+
// 39 governed types at the commit this landed against, the walk reached 79
1391+
// `[REMOVED]` tombstones and 78 already said `dead`. The single outlier is
1392+
// #18304's `agent/tools`, whose repair is in flight as its own card — so this
1393+
// starts from a population of one that is already being closed, and after it
1394+
// only a NEW false claim can red the gate. A check that starts at (nearly)
1395+
// zero can be red; that is why the census came first.
1396+
report.tombstonedLive.length > 0 ||
13561397
report.verification.errors.length > 0 ||
13571398
report.producers.errors.length > 0 ||
13581399
report.producerMissing.length > 0 ||
@@ -1640,6 +1681,14 @@ if (asJson) {
16401681
console.log('');
16411682
ORPHAN_GUIDANCE.forEach((line) => console.log(line ? ` ${line}` : ''));
16421683
}
1684+
if (report.tombstonedLive.length) {
1685+
console.log(
1686+
`\n✗ ${report.tombstonedLive.length} TOMBSTONED key(s) whose ledger row still claims a forbidden status:`,
1687+
);
1688+
report.tombstonedLive.forEach((s: string) => console.log(` ${s}`));
1689+
console.log('');
1690+
TOMBSTONE_STATUS_GUIDANCE.forEach((line) => console.log(line ? ` ${line}` : ''));
1691+
}
16431692
if (report.undrilledNew.length) {
16441693
console.log(`\n✗ ${report.undrilledNew.length} UNDECLARED container inheritance — a blanket verdict covers keys nothing classified:`);
16451694
report.undrilledNew.forEach((s: string) => console.log(` ${s}`));
@@ -1797,6 +1846,16 @@ if (asJson) {
17971846
} else if (pr.withoutProducer.length) {
17981847
console.log(' run with --producer-gap for the producer-evidence worklist.');
17991848
}
1849+
// ── tombstones: asked, and how many claim something the tombstone forbids ──
1850+
// Two numbers for the reason the evidence and citation lines above print two:
1851+
// "0 forbidden" alone reads identically whether every row is honest or the
1852+
// marker drifted and the scan reached nothing at all.
1853+
console.log(
1854+
`\ntombstoned keys: ${report.tombstones.length} \`[REMOVED]\` tombstone(s) reached by the walk, ` +
1855+
`${report.tombstones.length - report.tombstonedLive.length} graded with a status the tombstone allows` +
1856+
(report.tombstonedLive.length ? `, ${report.tombstonedLive.length} FORBIDDEN` : '') + '.',
1857+
);
1858+
18001859
// ── container coverage: how much rides on inheritance? ──
18011860
// Printed every run, pass or fail. The gate used to say "all properties are
18021861
// classified" while hundreds of child keys had never been asked about; a
@@ -1853,7 +1912,8 @@ if (asJson) {
18531912
console.log(
18541913
'\n✓ every governed-type property, at every depth the ledger drills, is classified, every ' +
18551914
'authorable type — registered kind or unregistered-kind stack collection — is governed or ' +
1856-
'explicitly pending, no ledger row outlives its property, ' +
1915+
'explicitly pending, no ledger row outlives its property, no tombstoned key\'s row claims a ' +
1916+
'status the tombstone forbids, ' +
18571917
`every container inheritance is declared, every ${EVIDENCE_SCANNED_LABEL} entry's repo-local evidence path ` +
18581918
'resolves, every `path:NNN` citation names a line that file actually has, every ' +
18591919
'`path#symbol` anchor names a symbol its file contains, and every cited ' +

0 commit comments

Comments
 (0)