Uh oh!
There was an error while loading. Please reload this page.
fix(spec): refuse an unrecognized liveness status, and make the fold preserve the walk's total - #13183
Merged
Merged
Conversation
…d preserve the walk's total A ledger `status` was free text. `classify()` accepted any truthy string and counted it; `foldStateCounts` then read four names and nothing else, so a row written `"status": "planed"` was classified, counted, and dropped — and `state-counts.md` published a `classified` total short by exactly that population while the gate stayed green, because the artifact computes that column as the sum of the four columns beside it and the freshness leg compares it against a re-render of the same understated fold. Disposition 1 — the data-side half of the #13041 partition. `KNOWN_STATUSES` is read from `STATUS_COLUMNS`, never restated, so the guard and the fold that drops the value cannot disagree about the four names. An unrecognized value is still COUNTED, deliberately: dropping it would keep `classified` and the `byStatus` buckets in agreement and hide the row from the arithmetic below. Disposition 2 — `reconcileStateCountTotals` binds the artifact's total to `cat.classified`, which the walk counts with its own `++` and never through `byStatus`. That is the one comparison here whose two sides are not the same measurement twice. Not an "other" column: that would change what the artifact publishes, and the defect is that the gate cannot SEE a dropped status. The generator refuses to write rather than publish an understated total. Population measured across all 31 ledgers on this commit: live 819, planned 10, dead 80, experimental 5 — 914 classified, no fifth value. Both guards start green and only a new typo can red them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4
…veness-status-validation
Contributor
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
os-trump
marked this pull request as ready for review
August 29, 2026 08:30
os-trump
enabled auto-merge
August 29, 2026 08:30
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Aug 29, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#13083
Both dispositions, as the triage grading directed (comment 5460331003): 1 names the offending row, 2 makes the artifact structurally unable to understate. They are independent legs, and the ablation below proves each fires on its own.
Premise re-verified — and the card's file paths were stale
The card names
check-liveness.mtsandlib/readme-table.mts. Both live inpackages/spec/scripts/liveness/now; there is noscripts/lib/. The substance is unchanged and confirmed onorigin/main:classify()counts any truthy status,foldStateCountsreads exactly four names, and the artifact'sclassifiedcolumn is computed as the sum of the four columns beside it — so every reconciliation in the gate compares that number against itself.The re-measured population
Measured on this branch's own ref, not inherited from the card.
Instrument A — the gate's own census (
check-liveness.mts --json,totals.byStatus), 31 ledgers:No fifth value. Identical to the card's numbers, re-derived rather than copied.
Instrument B — a raw scan of the ledger JSON, independent of the gate: 706 explicit
statusstrings — live 610, dead 80, planned 11, experimental 5. The two instruments differ by design, and the difference is accounted for rather than waved at: the gate additionally counts framework-auto entries and marker-derived statuses, and it deliberately does not count a container entry's own status when that entry declareschildren(the children carry the verdicts). Exactly two entries are in that shape —field.inlineColumns(live) andtranslation.flows(planned) — which is the whole of the rawplanned 11vs gateplanned 10delta.Positive control. A census that reports "only the four" is worthless if the instrument can only ever see four. So the same scanner was run over a copy of the ledger root with one
"status": "planned"rewritten to"planed"— mutation confirmed on disk first (api.jsonplanned 2 to 1, typo x1) — and it reported{live:610, dead:80, planned:10, experimental:5, planed:1}. The instrument does see a fifth value. No repo ledger byte was touched.So the population is zero today, both guards start green, and only a new typo can red them — the zero-census argument the orphan-proof and key-mention flips were switched on under.
Disposition 1 — refuse an unrecognized
statuscheck-liveness.mts, beside the existingSTATUS_COLUMNSpartition loop:(The
Setconstruction carries an explicit string type parameter in the source; it is elided here because an angle-bracket fragment does not survive this body's sanitizer, in a fence or out of one — the same elision PR #13081's body had to make.)The loop above it holds the code to the published vocabulary; this holds the data to it. The vocabulary is read from
STATUS_COLUMNS, never restated — the guard and the fold that drops the value must not be able to disagree about what the four names are, which is the same reasonEVIDENCE_SCANNED_LABELis derived from its set rather than written out again.Two deliberate choices worth review attention:
cat.classifiedand thebyStatusbuckets in agreement and hide the row from disposition 2's arithmetic — silencing the second guard with the first. It is counted, and it is reported.evidenceonly when status islive— aplannedentry's refusal evidence is verified by nothing, measured on api.json's two transform entries #13041 this string carries a second consequence. A status in neither evidence-scan set has itsevidencepointer counted by the census and read by nothing. A typo lands in neither set by construction, which is the defect that PR's exhaustiveness loop exists to prevent — reachable through the data instead of through the code. The failure output says so.Disposition 2 — the fold preserves the walk's total
reconcileStateCountTotalsinreadme-table.mtscompares, per governed type, the four columns' sum against the walk's owntypes.TYPE.classified.Shape choice, argued. The card offered an "other" bucket or an assertion. The assertion is the one this file's idiom supports, and it is also the only one in scope:
--jsonreport #7377), and the dispatch says to stop rather than take it. This leg leavesrenderStateCountsbyte-identical.foldStateCounts's doc-comment: "The row-set reconciliation above is what catches the governance gap; this function must not also hide it" — the fold stays pure and detection lives in areconcile*. AndStateCountsReconciliation's: "one population per failure heading" — hence a separate error array, not a widenedartifactErrors.Why this is the fix and not just another check.
renderStateCountsderivesclassifiedfrom the four columns beside it, so that number cannot disagree with them — it is a reading that cannot come back wrong.cat.classifiedis counted by its own++in the walk, one per classified property, never through thebyStatusmap. Binding the two is the only comparison in this file whose two sides are not the same measurement twice.It catches strictly more than a typo. A status added to
STATUS_COLUMNSis published vocabulary, so disposition 1 accepts it — andStateCountsRowstill names four fields by hand, so the fold drops it anyway. Nothing else in either file catches that, and the message does not claim a typo when there is none.The generator (
build-state-counts.mts) refuses to write when the fold does not reconcile, on the precedent of its existing refusal for an unparseable report. Without it,gen:liveness-countswould happily publish the understated total that the gate then rejects.Ablation — the guards are live, not phantom
Implementation committed first, so the restore leg points at a
HEADthat actually contains it. Absolute paths,trap ... EXIT INT TERM, restore pinned toHEAD(never a baregit checkout --, which reads from a possibly-poisoned index).Mutation: the disposition-1 condition rewritten to
if (false), anchored at the text being replaced with its own hit count — an editor's exit code is not evidence.The run below was taken under blob
ac55bff5, so the reading and the byte proof are about one tree.No build/dist leg, stated rather than skipped:
check-liveness.mtsruns from source viatsxand the test spawns that same source file by absolute path;readme-table.mtsis imported as a relative.mtsspecifier. There is nodist/artefact a stale build could mask, so the dist preflight does not apply.Prediction, written before the run: exactly 2 RED of the 5 new gate cases — the two that read the disposition-1 message — with the totals case staying GREEN, because that is what proves disposition 2 fires on its own leg.
Observed —
Tests 2 failed | 35 passed (37):Both failures are on
toContain, not on the exit code: the mutated gate still exits 1, because disposition 2 reds it. That is the point.FAILS the totals arithmetic, because the fold cannot name that bucketpassed on the mutated tree, so the two dispositions are not one check reported twice.still counts the misspelled rowalso passed, and the mutated run's own summary line shows it:Tests
check-liveness.test.ts— newdescribe('check:liveness — an unrecognized ledger status (#13083)'), every case running the REAL gate through--ledger-rootagainst a copy withfield.useGroupingmisspelled (it isplannedand carries no evidence, so nothing else in the copy can move a verdict): the row is named; the row is still counted under its own bucket; the totals arithmetic fires; the two failures stay separate (the per-type arithmetic must not name the property — that is why disposition 1 is not redundant); and the real ledgers stay GREEN with neither heading present.readme-table.test.ts—reconcileStateCountTotalsunit cases including the ones it must stay QUIET on (an honest fold, a governed type the report does not carry), multiple unnamed buckets, per-type rather than grand-total reporting, the vocabulary read fromSTATUS_COLUMNS, and the columns-do-not-add-up case that no ledger typo can produce. Plus the prescription pins: regeneration is not the repair, widening the vocabulary is forbidden, and the deliberate fifth status is reachable from the message as an artifact-shape decision.Verification
All exit codes captured before any pipe; each verdict quoted from the gate's own output. Union re-run after the final commit, at
5317b2cd0(origin/mainmerged in first).pnpm --filter @objectstack/spec check:liveness— exit 0.✓ every governed-type property at the walk's one-level granularity is classified … and the README state table carries a row for each of the 31 governed type(s) it claims to index.and✓ packages/spec/liveness/state-counts.md is current — the same 31 row(s), no count column left in the README.pnpm exec vitest run --maxWorkers=2 scripts/liveness/ scripts/zod-graph.test.ts src/system/metadata-form-zod-reconciliation.test.ts— exit 0,Test Files 12 passed (12) · Tests 331 passed (331). The file set is the gate-script rule, not the derived family:git grep -lfor the edited script names across test files returns exactly these five, and all five ran.tsc --noEmit -p tsconfig.scripts.json --listFiles— exit 0,0lines matchingerror TS; coverage proven rather than assumed — all five edited files appear in the program's--listFilesoutput, 1 hit each, the two.test.tsfiles included.pnpm --filter @objectstack/spec check:test-typecheck— exit 0,55 file(s) / 263 error(s) held in test-typecheck-debt.json— unchanged, so this diff drifts the debt ledger by nothing.pnpm --filter @objectstack/spec check:empty-state— exit 0,✓ all classified (1 closed, 2 open, 4 output, 9 scope)pnpm --filter @objectstack/spec check:variant-docs— exit 0,✓ variant/doc gate: 18 discriminated union(s) — 8 governed …, 10 exempt.pnpm --filter @objectstack/spec check:strictness-ledger— exit 0,✓ strictness ledger: 60 file(s) across 5 triaged director(ies) …pnpm check:nul-bytes— exit 0,scanned 7306 text file(s) … no raw ASCII control bytes; plus a direct control-byte scan of the five edited files, no hits.pnpm check:published-files— exit 0,69 publishable package(s) … admits no test, test-harness config or build scriptpnpm check:cross-package-test-inputs— exit 0,OK: 23 package(s) read outside themselves, all declaredpnpm check:engine-double-contract— exit 0,delete doubles: 266 in 245 test file(s) — 200 pinned …pnpm check:where-matcher— exit 0 ·pnpm check:query-options-erasure— exit 0The published artifact is unchanged, and that is the intended outcome, not an omission:
git diffoverpackages/spec/liveness/against the merge base is empty,check:livenessindependently reportsstate-counts.mdcurrent, and the census is byte-identical to the card's. Disposition 2 adds a reading of that artifact's total; it changes nothing the artifact contains. Nothing was regenerated, and nothing needed to be.Gate family derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no hand-fed paths — it read the change set from the merge base itself): 26 path-derived families plus the convention triggers for a test-file edit and a gate-script edit.Declared narrowing: the repo-wide scans in the derived list —
pnpm lint, the docs-drift pair, the CI shard/attestation gates andcheck:type-check-debt --re-measure(which refuses an unbuilt worktree and needs the whole workspace closure built) — were not run locally. CI runs the farm exactly once regardless. The one with a real chance of moving is the test-typecheck ratchet, and its per-package half for the only package this diff touches is thecheck:test-typecheckrun above, green with the ledger count unchanged.Changeset — measured, and the precedent is this card's own predecessor
packages/spec'sfilesarray is["dist", "json-schema", "liveness", "prompts", "llms.txt", "README.md", "src/**/*.zod.ts", "CHANGELOG.md", "api-surface", "spec-changes.json"].scriptsis not in it, andcheck:published-filesindependently reports that the whitelists admit "no test, test-harness config or build script". This diff is entirely insidepackages/spec/scripts/liveness/— five files, none of which reach a consumer'snode_modules— and no byte under the shippingliveness/directory changed. Nothing is released, so this takes theskip-changesetlabel rather than apatchchangeset.The precedent is not inferred: PR #13081 — the immediately preceding card on this same file surface, four files all under
packages/spec/scripts/liveness/— merged with labelssize/m, tests, tooling, skip-changesetand no changeset, on this same measured reasoning.Honest residuals
byStatuskey that is not a governed type would be dropped whole, and this leg would not see it — but both callers buildgovernedfrom the same report they buildbyStatusfrom, so that case cannot arise here without arising in the row-set reconciliation (spec(liveness): the README state table has no rows forapi/capability— the table silently fell behind its own registry #7257) first. Stated in the code rather than guarded twice, on the file's "one population per heading" rule.liveabout a property nobody reads is still a lie this gate cannot catch by counting; that is what the evidence scan andverifiedAtare for.Generated by Claude Code