Skip to content

Commit c9eb773

Browse files
os-billclaude
andauthored
fix(spec): drill the liveness ledger to the depth it declares, and report what the walk did not reach (#17810)
Fixes #17424 `packages/spec/scripts/liveness/check-liveness.mts` read `led.children[ck]` and never recursed into a child's own `children`. A `children` map written at **depth two** was therefore accepted by the file format and then ignored: no evidence path resolved, no key reported unclassified, no container reconcile, and **no line of output saying any of it was missing**. Because the enforce-or-remove channel acts on this gate's `dead` verdicts, a silently skipped subtree could retire a key that was alive. Triage asked for two things and said the second matters as much as the first. Both are here. ## 1. The walk recurses `drillChildren` descends wherever the ledger declares a nested `children` map. The working depth limit is therefore the **ledger's own nesting** — a bound the author can read in the file — not a constant in the gate. Two things follow it down: - **The reverse (orphan) direction.** `findOrphanEntries` now takes a PATH rather than a key and recurses with the walk. A reverse direction that only ever asked about depth one would have stopped asking exactly where the forward pass started looking. - **The container reconcile.** A drilled child that is itself a container carries a blanket verdict over its own subtree, so it now owes the same declared disposition its top-level peers already owed: drilled, deferred, or recorded. ## 2. Nothing the walk does not reach is silent `MAX_DRILL_DEPTH` (8) is a **tripwire, not the working limit**. Every key below it is reported UNCLASSIFIED, which fails the gate. A depth limit the instrument does not announce would rebuild this card's own defect one level lower, at the one place a check can least afford it. Each of the three ways an entry goes unclassified at depth one is now pinned at depth two as well: no verdict anywhere, a `children` map on a non-container, and a row naming a key its container never had. ## Which verdicts changed: none The full report was captured before and after and diffed field by field. ``` pnpm exec tsx scripts/liveness/check-liveness.mts --json (pre-fix, at 6548118) pnpm exec tsx scripts/liveness/check-liveness.mts --json (post-fix) python3 -c "import json;a=json.load(open('prefix.json'));b=json.load(open('post.json')); [print('MOVED:',k) for k in sorted(set(a)|set(b)) if a.get(k)!=b.get(k)]" ``` **Exactly one field moved: `undrilledNew`, 0 to 54.** `types` (the per-type `byStatus` map for all 36 governed types) and `totals` are byte-identical objects, and `unclassified`, `orphanEntries` and `unknownStatus` are empty on both sides. ``` live 850 · planned 10 · dead 93 · experimental 5 · live-elsewhere 1 (before) live 850 · planned 10 · dead 93 · experimental 5 · live-elsewhere 1 (after) ``` **No `dead` verdict flipped, so no retirement is in question** and there is no card to hand back. The reason is structural rather than lucky, and the control is lit: a scan of all 36 shipped ledger files finds **zero** depth-2 `children` maps today, so the new recursion branch is unreachable on shipped data. Had any existed, that count would have been non-zero and the diff above is what would have caught it. ## The baseline migration 54 containers became visible at once, and the jump is a **denominator correction, not new debt**. Every one of them was already riding on a blanket verdict *below a drilled container*, where a one-level walk could not see it and therefore never counted it. - **3 are genuinely classified elsewhere** and resolve as deferrals to `app/navigation`: `app/navigation.children`, `app/areas.navigation`, `manifest/navigationContributions.items` are all the same NavigationItem surface, 18 keys each. Recording them as "classified nowhere" would have been the baseline's own false claim in the other direction. The gate's equality check is what makes the deferral hold. - **51 are recorded** in the `containers` list, with the migration and its reading stated in that file's own `_containers` header. - Census: 58 containers / 302 child keys, to 109 containers / 555 child keys, plus 7 to 10 resolved deferrals covering 277 to 331. `dashboard/widgets.chartConfig` — the coordinate #17385's per-key drill is blocked on — is now in that population. **That drill is not in this PR.** A previous round correctly refused to write it because the table would have looked classified and been read by nothing; this unblocks it. ## Ablation, not a green A gate that would have passed either way is not evidence, so the pre-fix tool was materialised from the branch point and run against the same fixture. **On-disk proof before reading either run.** The pre-fix copy was verified to be the old walk (`drill one level` comment present at 1, `drillChildren` at 0, against 4 in HEAD's copy, and its baseline carrying the pre-migration 58 rows). The fixture was verified to carry the depth-2 map (`neverWasAKeyOnChartConfig` at 1 in the fixture, 0 in the shipped ledger). Both legs ran under `trap ... EXIT INT TERM`. The fixture nests a `children` map on `dashboard.widgets.chartConfig` carrying a rotted evidence pointer and a key that has never existed on ChartConfig. ``` PRE-FIX exit 0 mentions chartConfig.title ................. 0 mentions neverWasAKeyOnChartConfig ......... 0 mentions the rotted sql-driver.ts pointer .. 0 and printed: "all governed-type properties ... are classified" POST-FIX exit 1 dashboard/widgets.chartConfig.title -> packages/plugins/driver-sql/src/sql-driver.ts dashboard/widgets.chartConfig.neverWasAKeyOnChartConfig (orphan, not guessed) dashboard/widgets.chartConfig.type ... .series ... (13 unclassified) ``` **Restore proven, not assumed.** No repo byte was mutated — the pre-fix tool lived in an untracked sibling directory and the fixture in a temp dir — and the restore was verified by `git hash-object` on every touched path against its HEAD blob, with `git status --porcelain` and `git diff HEAD --stat` both empty. The six hashes are in the round report. The ablation is one-time and leaves nothing behind. The permanent regression cover is 9 new cases in `check-liveness.test.ts`, each spawning the real gate the way CI does and reading a named report BUCKET rather than searching the prose — because "reported somewhere" was never the question. `undrilledStale` is deliberately not used as evidence of recursion: a coordinate the walk cannot see is reported stale too, so it does not discriminate. The ceiling case uses `app.navigation`, whose `children` are NavigationItems, so the ledger can be nested past the constant against a real recursive shape. ## Changeset: owed, and measured `packages/spec/scripts/**` — the tool, its tests and the baseline — contributes **0 of 2011 published entries** in the packed tarball, and neither `drillChildren` nor `MAX_DRILL_DEPTH` appears in any of the 216 built `dist/` files. Positive controls both lit: `liveness/` ships at 38 entries, and a real shipped symbol resolves in 40 dist files. Negative control: `scripts/liveness/check-liveness.test.ts`, 0. But `files[]` ships the `liveness` directory **whole**, and `liveness/README.md` is one of those 2011 entries. It is the ledger's authoring contract, and its "Granularity — drill one level" section is what an author reads before writing a `children` map. So the published bytes that change are documentation, and they change because this PR made them false — which is why five per-type rooting rationales and two present-tense claims elsewhere in that file are corrected in place rather than left. The rooting decisions themselves are untouched and still stand; only their tense moved. `.changeset/17424-liveness-depth-two-recursion.md`, `@objectstack/spec: patch`. No ledger verdict file was edited. ## Verification - `pnpm --filter @objectstack/spec test` — **473 test files, 13450 tests, all passed**. - `pnpm --filter @objectstack/spec typecheck` — exit 0, and `check:test-typecheck` compiles the test layer separately (54 files under `tsconfig.test.json`), so the usual "typecheck excludes the tests" hole is closed here by a second gate rather than assumed away. - `pnpm lint` (`eslint . --no-inline-config`, the whole repo) — exit 0. No narrowing claimed; the full declared population ran. - **66 of the 67 gate families** derived by `scripts/pm/dispatch-gates.mjs` for these 8 paths — all exit 0. The one exception and the two NOT MEASURED readings are named in the round report rather than folded into that count. - `pnpm check:liveness` green on the final head; the success line now reads "at every depth the ledger drills". ## Notes for review - **Clause-②: no** — this PR puts no new key on any published payload. - No governed surface is touched (`docs/adr/**`, `.claude/**`, `skills/**`, `AGENTS.md`, `CLAUDE.md` — 0 path hits, checked against the `GOVERNED_SURFACES` register). - Ledger verdict data is untouched: the only file under `packages/spec/liveness/` in this diff is `README.md`. - The derivation of the gate list was made on this branch's base; `origin/main` has advanced since, so a family added in that range would not appear in it. CI derives against the real merge state. --- _Generated by [Claude Code](https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 48203ff commit c9eb773

8 files changed

Lines changed: 584 additions & 61 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
The liveness ledger's published README no longer declares a one-level drill — the walk follows a nested `children` map as deep as the ledger declares, and says so
6+
7+
`check-liveness.mts` read `led.children[ck]` and never recursed into a child's
8+
own `children`. A `children` map written at **depth two** was therefore accepted
9+
by the file format and then ignored in silence: no evidence path resolved, no
10+
key reported unclassified, no container reconcile, and no line of output saying
11+
any of it was missing. Because the enforce-or-remove channel acts on this gate's
12+
`dead` verdicts, a silently skipped subtree could retire a key that was alive.
13+
14+
The walk now descends as far as the ledger nests, the reverse (orphan) direction
15+
follows it down, and a drilled child that is itself a container owes the same
16+
declared disposition — drilled, deferred or recorded — that its top-level peers
17+
already owed. `MAX_DRILL_DEPTH` is a tripwire rather than the working limit:
18+
every key below it is reported **UNCLASSIFIED**, which fails the gate, because a
19+
depth limit the instrument does not announce would rebuild the same defect one
20+
level lower.
21+
22+
**No verdict moved.** Before and after: live 850, planned 10, dead 93,
23+
experimental 5, live-elsewhere 1 — the full per-type `byStatus` map is
24+
byte-identical. Nothing flipped to or from `dead`, so no retirement is in
25+
question. What did move is the census the gate publishes about its own
26+
completeness: 54 containers became visible at once, every one of them already
27+
riding on a blanket verdict below a drilled container where a one-level walk
28+
could not see it. Three are genuinely classified elsewhere (`app/navigation`'s
29+
NavigationItem keys) and resolve as deferrals; the other 51 are recorded debt.
30+
31+
**Why this carries a changeset rather than `skip-changeset`.** The tool, its
32+
tests and its baseline all live under `packages/spec/scripts/`, which is absent
33+
from the package's published `files[]` — measured at 0 entries in the packed
34+
tarball, against `liveness/` ships at 38 as the lit positive control. But
35+
`files[]` ships the `liveness` directory whole, and `liveness/README.md` is the
36+
ledger's authoring contract: its "Granularity — drill one level" section is what
37+
an author reads before writing a `children` map, and that sentence is now wrong.
38+
The published bytes that change are that section, the depth rule that replaces
39+
it, and the re-stated census. No ledger verdict file changed.

packages/spec/liveness/README.md

Lines changed: 39 additions & 14 deletions
Large diffs are not rendered by default.

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

Lines changed: 121 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,17 @@
1515
//
1616
// Governed types must declare every authorable property's liveness status with
1717
// evidence in packages/spec/liveness/<type>.json, or CI fails (the ratchet — no new
18-
// undeclared surface). Property granularity is one level: a container property
18+
// undeclared surface). Property granularity follows the LEDGER: a container property
1919
// (object / record / array-of-object) may be drilled into via `"children"` so e.g.
20-
// `permission.objects.allowCreate` stays distinguishable from a blanket `objects`.
20+
// `permission.objects.allowCreate` stays distinguishable from a blanket `objects`,
21+
// and a child that is itself a container may be drilled again, to any depth the
22+
// ledger actually declares. The walk used to stop after ONE level: a `children`
23+
// map written at depth two parsed, was accepted by the file format, and was then
24+
// ignored in silence — no evidence resolved, no key reported unclassified, no
25+
// container reconcile, and nothing printed to say so. Since a `dead` verdict from
26+
// this gate is what the enforce-or-remove channel acts on, a silently skipped
27+
// subtree could retire a key that was alive. See `drillChildren` and
28+
// `MAX_DRILL_DEPTH` for the depth rule and for why its boundary is never silent.
2129
//
2230
// A container that is NOT drilled inherits its parent's single verdict for every
2331
// key beneath it, and that inheritance must be DECLARED, not assumed: it is
@@ -904,6 +912,90 @@ function scanOrphanProofs() {
904912
// the shrink-only baseline after the walk (drill.mts).
905913
const observedContainers: ContainerCoverage[] = [];
906914

915+
// ── HOW DEEP THE DRILL GOES ──
916+
//
917+
// The walk descends exactly as far as the LEDGER declares `children`, so the
918+
// working depth limit is the ledger's own nesting, not a number written here.
919+
// That is a real bound: a ledger file is finite acyclic JSON, so the descent
920+
// terminates on data the author can see, rather than on a constant they cannot.
921+
//
922+
// `MAX_DRILL_DEPTH` is therefore a TRIPWIRE, not the working limit — and the
923+
// only reason it exists is that a limit must not be able to hide anything.
924+
// Truncating there silently would rebuild, one level lower, exactly the defect
925+
// this recursion removes: before it, a `children` map written at depth two was
926+
// accepted by the file format and then ignored — no evidence resolved, no
927+
// container reconcile, and no line of output saying any of that was missing.
928+
// So every key below the cut is reported UNCLASSIFIED, which FAILS the gate.
929+
// A depth limit the instrument does not announce is prose wearing the shape of
930+
// data (#4956), and the boundary of a check is the last place that is affordable.
931+
const MAX_DRILL_DEPTH = 8;
932+
933+
/**
934+
* Record a blanket verdict that stands in for a subtree, at ANY depth.
935+
*
936+
* Hoisted out of the walk because the reconcile is no longer a top-level-only
937+
* question: a DRILLED child can itself be a container, and before the walk
938+
* recursed, such a child was classified and its own children were never counted
939+
* as covered by anything. That is #4956's silence at depth two — the child keys
940+
* were neither drilled, nor deferred, nor recorded as debt, and no run said so.
941+
*/
942+
function recordContainerCoverage(type: string, propPath: string, node: any): void {
943+
const cs = childShape(node);
944+
const childKeys = cs ? Object.keys(cs) : [];
945+
if (childKeys.length > 0) observedContainers.push({ key: `${type}/${propPath}`, childKeys });
946+
}
947+
948+
/**
949+
* Classify one drilled container's children, recursing wherever the ledger
950+
* declares a nested `children` map.
951+
*
952+
* `depth` is the level being drilled INTO: 1 for a top-level property's own
953+
* children. Every exit from this function either classifies a key, reports it
954+
* UNCLASSIFIED, or hands it to a deeper call — there is deliberately no branch
955+
* that returns without accounting for the keys it saw.
956+
*/
957+
function drillChildren(
958+
type: string,
959+
propPath: string,
960+
node: any,
961+
led: any,
962+
cat: { classified: number; unclassified: number; byStatus: Record<string, number> },
963+
depth: number,
964+
): void {
965+
const cs = childShape(node);
966+
if (!cs) {
967+
cat.unclassified++;
968+
report.unclassified.push(`${type}/${propPath} (declared children but property is not a container)`);
969+
return;
970+
}
971+
if (depth > MAX_DRILL_DEPTH) {
972+
// The tripwire. Report every key it covers rather than dropping the subtree:
973+
// an unwalked entry that says nothing is the failure mode, not the depth.
974+
for (const ck of Object.keys(cs)) {
975+
cat.unclassified++;
976+
report.unclassified.push(
977+
`${type}/${propPath}.${ck} (nested past the ${MAX_DRILL_DEPTH}-level drill ceiling — NOT walked, so nothing here is classified)`,
978+
);
979+
}
980+
return;
981+
}
982+
for (const ck of Object.keys(cs)) {
983+
const cled = led.children[ck];
984+
const childPath = `${propPath}.${ck}`;
985+
if (cled?.children) {
986+
drillChildren(type, childPath, cs[ck], cled, cat, depth + 1);
987+
continue;
988+
}
989+
const status = cled?.status || markerStatus(descOf(cs[ck])) || led.childrenDefault;
990+
if (!status) { cat.unclassified++; report.unclassified.push(`${type}/${childPath}`); continue; }
991+
// A drilled child that is ITSELF a container carries a blanket verdict over
992+
// its own subtree, exactly as a top-level one does — so it owes the same
993+
// declared disposition (drill / defer / record).
994+
recordContainerCoverage(type, childPath, cs[ck]);
995+
classify(type, childPath, status, cled, cat);
996+
}
997+
}
998+
907999
for (const type of GOVERNED) {
9081000
const ledger = loadLedger(type);
9091001
const props = ledger.props || {};
@@ -912,14 +1004,23 @@ for (const type of GOVERNED) {
9121004

9131005
// ── reverse direction: a row whose property is gone (see orphans.mts) ──
9141006
// Runs off the SAME walk the forward pass classifies against, so the two
915-
// directions can never disagree about what the schema contains.
1007+
// directions can never disagree about what the schema contains — including
1008+
// how deep it goes, which is why the resolver takes a PATH and not a key.
9161009
const nodeOf = new Map(walked.map((p) => [p.key, p.node]));
9171010
const orphans: Orphan[] = findOrphanEntries({
9181011
type,
9191012
props,
9201013
shapeKeys: walked.map((p) => p.key),
921-
childKeysOf: (key) => {
922-
const cs = childShape(nodeOf.get(key));
1014+
childKeysOf: (segments) => {
1015+
let node = nodeOf.get(segments[0]);
1016+
if (node === undefined) return null;
1017+
let cs = childShape(node);
1018+
for (let i = 1; i < segments.length; i++) {
1019+
if (!cs) return null;
1020+
node = cs[segments[i]];
1021+
if (node === undefined) return null;
1022+
cs = childShape(node);
1023+
}
9231024
return cs ? Object.keys(cs) : null;
9241025
},
9251026
});
@@ -929,24 +1030,14 @@ for (const type of GOVERNED) {
9291030
if (FRAMEWORK_FIELDS.has(key)) { classify(type, key, 'live', null, cat); continue; }
9301031
const led = props[key];
9311032
if (led?.children) {
932-
// drill one level
933-
const cs = childShape(node);
934-
if (!cs) { cat.unclassified++; report.unclassified.push(`${type}/${key} (declared children but property is not a container)`); continue; }
935-
for (const ck of Object.keys(cs)) {
936-
const cled = led.children[ck];
937-
const status = cled?.status || markerStatus(descOf(cs[ck])) || led.childrenDefault;
938-
if (!status) { cat.unclassified++; report.unclassified.push(`${type}/${key}.${ck}`); continue; }
939-
classify(type, `${key}.${ck}`, status, cled, cat);
940-
}
1033+
drillChildren(type, key, node, led, cat, 1);
9411034
} else {
9421035
const status = led?.status || markerStatus(description);
9431036
if (!status) { cat.unclassified++; report.unclassified.push(`${type}/${key}`); continue; }
9441037
// One verdict standing in for a whole subtree. Legal, but it must be
9451038
// declared rather than inherited by default — record it for the
9461039
// post-walk reconcile (drill.mts, #4956).
947-
const cs = childShape(node);
948-
const childKeys = cs ? Object.keys(cs) : [];
949-
if (childKeys.length > 0) observedContainers.push({ key: `${type}/${key}`, childKeys });
1040+
recordContainerCoverage(type, key, node);
9501041
classify(type, key, status, led, cat);
9511042
}
9521043
}
@@ -970,6 +1061,10 @@ const undrilledBaseline = parseUndrilledBaseline(
9701061
* `field` — a governed type root; its walked top-level keys all carry a
9711062
* verdict (the type is governed, so the forward pass proved it).
9721063
* `view/list` — a drilled ledger coordinate; its `children` keys are verdicts.
1064+
* Dotted (`dashboard/widgets.chartConfig`) for a coordinate the
1065+
* walk reaches by recursion — a deferral must be expressible at
1066+
* every depth the walk can classify, or the deeper containers
1067+
* would have only two of the three dispositions available.
9731068
* Anything else dangles, which is the failure this resolution exists to produce.
9741069
*/
9751070
function classifiedKeysAt(target: string): readonly string[] | null {
@@ -981,7 +1076,10 @@ function classifiedKeysAt(target: string): readonly string[] | null {
9811076
}
9821077
const [type, prop] = target.split('/');
9831078
if (!GOVERNED.includes(type)) return null;
984-
const children = loadLedger(type).props?.[prop]?.children;
1079+
const segments = prop.split('.');
1080+
let entry: any = loadLedger(type).props?.[segments[0]];
1081+
for (let i = 1; i < segments.length; i++) entry = entry?.children?.[segments[i]];
1082+
const children = entry?.children;
9851083
return children ? Object.keys(children) : null;
9861084
}
9871085

@@ -1626,8 +1724,12 @@ if (asJson) {
16261724
// are classified" — was the instrument's own false claim: it counted a
16271725
// blanket container verdict as one classified property and said nothing
16281726
// about the keys underneath, which is exactly how #4956 stayed invisible.
1727+
// The qualifier names the LEDGER because that is what now sets the depth
1728+
// (#17424): it used to say "one-level granularity", which stopped being
1729+
// true the moment the walk started following a nested `children` map — and
1730+
// a success line that overstates its own reach is the defect it qualifies.
16291731
console.log(
1630-
'\n✓ every governed-type property at the walk\'s one-level granularity is classified, every ' +
1732+
'\n✓ every governed-type property, at every depth the ledger drills, is classified, every ' +
16311733
'registered type is governed or explicitly pending, no ledger row outlives its property, ' +
16321734
`every container inheritance is declared, every ${EVIDENCE_SCANNED_LABEL} entry's repo-local evidence path ` +
16331735
'resolves, every `path:NNN` citation names a line that file actually has, every ' +

0 commit comments

Comments
 (0)