Skip to content

fix(spec): refuse an unrecognized liveness status, and make the fold preserve the walk's total - #13183

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-13083-liveness-status-validation
Aug 29, 2026
Merged

fix(spec): refuse an unrecognized liveness status, and make the fold preserve the walk's total#13183
os-trump merged 2 commits into
mainfrom
claude/issue-13083-liveness-status-validation

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

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.mts and lib/readme-table.mts. Both live in packages/spec/scripts/liveness/ now; there is no scripts/lib/. The substance is unchanged and confirmed on origin/main: classify() counts any truthy status, foldStateCounts reads exactly four names, and the artifact's classified column 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:

live 819 · planned 10 · dead 80 · experimental 5 = 914 classified
sum(types.classified) = 914 sum(four fold columns) = 914 governed types = 31

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 status strings — 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 declares children (the children carry the verdicts). Exactly two entries are in that shape — field.inlineColumns (live) and translation.flows (planned) — which is the whole of the raw planned 11 vs gate planned 10 delta.

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.json planned 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 status

check-liveness.mts, beside the existing STATUS_COLUMNS partition loop:

constKNOWN_STATUSES=newSet(STATUS_COLUMNS);

(The Set construction 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 reason EVIDENCE_SCANNED_LABEL is derived from its set rather than written out again.

Two deliberate choices worth review attention:

Disposition 2 — the fold preserves the walk's total

reconcileStateCountTotals in readme-table.mts compares, per governed type, the four columns' sum against the walk's own types.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:

  1. An "other" column changes what the artifact publishes — a fifth column, new bytes, every row re-rendered. That is an artifact-shape decision (spec(liveness): 9 rows of the README state table have count columns that disagree with the gate's --json report #7377), and the dispatch says to stop rather than take it. This leg leaves renderStateCounts byte-identical.
  2. The file states its own idiom twice. 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 a reconcile*. And StateCountsReconciliation's: "one population per failure heading" — hence a separate error array, not a widened artifactErrors.
  3. The defect is that the gate cannot see a dropped status, not that the table should carry one.

Why this is the fix and not just another check.renderStateCounts derives classified from the four columns beside it, so that number cannot disagree with them — it is a reading that cannot come back wrong. cat.classified is counted by its own ++ in the walk, one per classified property, never through the byStatus map. 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_COLUMNS is published vocabulary, so disposition 1 accepts it — and StateCountsRow still 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-counts would 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 HEAD that actually contains it. Absolute paths, trap ... EXIT INT TERM, restore pinned to HEAD (never a bare git 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.

BEFORE guard-form: 1 marker: 0 blob aec5fa17ecb8145786e3bc9e039df1c8aa7276f9 (== HEAD blob)
AFTER guard-form: 0 marker: 1 blob ac55bff522babe340762758b5c57df622944998a
RESTORED guard-form: 1 marker: 0 blob aec5fa17ecb8145786e3bc9e039df1c8aa7276f9 (== HEAD blob)
git diff HEAD -- target: EMPTY

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.mts runs from source via tsx and the test spawns that same source file by absolute path; readme-table.mts is imported as a relative .mts specifier. There is no dist/ 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.

ObservedTests 2 failed | 35 passed (37):

× FAILS and names the row when a ledger `status` is misspelled
× reports the two failures separately — one names the row, one names the number

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 bucket passed on the mutated tree, so the two dispositions are not one check reported twice. still counts the misspelled row also passed, and the mutated run's own summary line shows it:

field 91 classified (live 88, planed 1, planned 1, dead 1)

Tests

  • check-liveness.test.ts — new describe('check:liveness — an unrecognized ledger status (#13083)'), every case running the REAL gate through --ledger-root against a copy with field.useGrouping misspelled (it is planned and 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.tsreconcileStateCountTotals unit 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 from STATUS_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/main merged 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 -l for the edited script names across test files returns exactly these five, and all five ran.
  • tsc --noEmit -p tsconfig.scripts.json --listFiles — exit 0, 0 lines matching error TS; coverage proven rather than assumed — all five edited files appear in the program's --listFiles output, 1 hit each, the two .test.ts files 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 script
  • pnpm check:cross-package-test-inputs — exit 0, OK: 23 package(s) read outside themselves, all declared
  • pnpm 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 0

The published artifact is unchanged, and that is the intended outcome, not an omission: git diff over packages/spec/liveness/ against the merge base is empty, check:liveness independently reports state-counts.md current, 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 and check: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 the check:test-typecheck run above, green with the ledger count unchanged.

Changeset — measured, and the precedent is this card's own predecessor

packages/spec's files array is ["dist", "json-schema", "liveness", "prompts", "llms.txt", "README.md", "src/**/*.zod.ts", "CHANGELOG.md", "api-surface", "spec-changes.json"]. scripts is not in it, and check:published-files independently reports that the whitelists admit "no test, test-harness config or build script". This diff is entirely inside packages/spec/scripts/liveness/ — five files, none of which reach a consumer's node_modules — and no byte under the shipping liveness/ directory changed. Nothing is released, so this takes the skip-changeset label rather than a patch changeset.

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 labels size/m, tests, tooling, skip-changeset and no changeset, on this same measured reasoning.

Honest residuals

  • Disposition 2 is per-type arithmetic. A byStatus key that is not a governed type would be dropped whole, and this leg would not see it — but both callers build governed from the same report they build byStatus from, so that case cannot arise here without arising in the row-set reconciliation (spec(liveness): the README state table has no rows for api / 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.
  • The guard validates the vocabulary of a status, not its truthfulness. A row that says live about a property nobody reads is still a lie this gate cannot catch by counting; that is what the evidence scan and verifiedAt are for.

Generated by Claude Code

…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
@os-trumpos-trump added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 29, 2026 — with Claude
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json dc729f3585830b8c71550245e6d883afbfcea94epackageMentionDocs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gateteststooling

Projects

None yet

2 participants

@os-trump@claude