Skip to content

Commit 70809f7

Browse files
committed
feat(spec): the liveness state table's counts become a generated artifact, and eleven drifted rows are reconciled (#7377)
Closes #7377. Two stages, in order — the reconciliation first, because doing the mechanism first would have made CI red with no path to green. STAGE 1 — per-row reconciliation. The table declares its own counting method (`check-liveness.mts --json`, fixed in #4488) and says the count columns are never hand-edited. Nobody re-ran the snippet: 9 of 30 rows disagreed with the gate when #7377 was filed, and PR #7425 added two more. Every delta is measured and explained rather than absorbed: - field/action/hook/page/seed/webhook — the ADR-0010 protection envelope entered each schema's walked shape as the #4001 campaign closed it (#4514/#4530/#4531/#4533/#4974); the gate auto-classifies those keys `live`. - flow — +1 dead: errorHandling.retryDelayMs, tombstoned by #4964's rename to `backoffMs`. The Note said "4 tombstone entries"; it says 5. - action — +1 live beyond the envelope: `description` (#7367 / PR #7430). - view — #4001 batch 6e (#4534) declared three container-level keys the Note never mentioned: `object` live, `name`/`label` dead. The hand-enumerated dead set was 4 against a real 6. - app — dead 9 still reconciles exactly; +1 live `_unpublished` (#4829 / PR #6942) and the type's first `planned`, `navigation.runAction` (#4848 / PR #7253). - job — zero dead under #7425. First row in the table where that holds WITH the ADR-0033 exemption still in force; the Note says so. - translation — zero dead under #7425. Its cell had published `dead 2` beside a sentence naming one key, and that key had already been removed in #4667: the number was right and the prose false, in the same cell, on the day it was written. Recorded as measured history. The other 24 Notes are byte-identical. Five rows carried a SEVENTH cell against a six-column header, which GFM drops — that prose rendered nowhere, and the 2-column table folds it back into the Notes cell losslessly. STAGE 2 — the mechanism, on #5107's precedent. The numbers move to `packages/spec/liveness/state-counts.md` (`merge=os-regen`, registered in `scripts/regen-artifacts.mjs` and `.gitattributes`); the Notes prose stays hand-written, because regenerating a Note manufactures a verdict. `gen:liveness-counts` spawns the gate rather than re-implementing its walk, and keeps #7257's skeleton row. `check:liveness` gains three legs in `readme-table.mts`: artifact freshness by byte comparison, README-vs-artifact row sets in both directions, and a count column reappearing in the README — which the other two cannot see. Reverse verification (predicted, then run against a mutated `--ledger-root` copy): artifact deleted => MISSING, red; `view` live skewed 80->81 => STALE naming that line, red; a count column re-added to the `object` row => red naming the line; the `qa` row deleted => red on both the #7257 heading and the new row-set heading. Verbatim copy green. Gates: liveness script tests 9 files / 186 tests; check-generated ledger tests 8/8; `check:liveness` green; `check:generated --reconcile-only` green; `git-merge-regen.mjs --self-test` green (12 paths); scripts typecheck clean; check-nul-bytes OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PJn5BD9LY9ZCECPs25oiDb
1 parent d13ce33 commit 70809f7

12 files changed

Lines changed: 983 additions & 71 deletions
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
The liveness ledger's "Current state" table stops hand-maintaining its counts: the numbers
6+
move into a generated `packages/spec/liveness/state-counts.md` carrying `merge=os-regen`,
7+
and the eleven rows that had drifted from the gate are reconciled — each with the Notes
8+
prose beside it re-read against the new measurement.
9+
10+
**Why it was a card and not a `sed`.** The table declares its own counting method (the
11+
gate's `--json` report, fixed in #4488) and says the count columns are never hand-edited.
12+
Nobody re-ran the snippet, and 9 of 30 rows disagreed with the gate by the time #7377 was
13+
filed — two more (`job`, `translation`) joined when PR #7425 re-graded four docs-shaped
14+
rows. Several Notes cells enumerate their own dead sets BY HAND, so regenerating the
15+
numbers alone would have left a row reading `dead 6` next to a sentence naming four, which
16+
is worse than the drift: the prose is the part a reader believes.
17+
18+
**Every delta is explained, not absorbed.** Six rows moved for one structural reason —
19+
`field`, `action`, `hook`, `page`, `seed`, `webhook` picked up the ADR-0010 protection
20+
envelope as the #4001 strictness campaign closed each schema (#4514/#4530/#4531/#4533/#4974),
21+
and the gate auto-classifies those keys `live`. The rest are verdict-shaped: `flow`'s sixth
22+
dead is `errorHandling.retryDelayMs`, tombstoned by the #4964 rename to `backoffMs`;
23+
`view` gained three container-level keys in #4001 batch 6e (`object` live, `name`/`label`
24+
dead) that its Note never mentioned; `app` gained `_unpublished` (#4829, a `live` key no
25+
author may write) and its first `planned`, `navigation.runAction` (#4848); `action` gained
26+
`description` (#7367); `job` and `translation` reached zero dead under #7425's ruling that
27+
designer previews count as consumers.
28+
29+
**`job` is the first row in the table with zero dead where the ADR-0033 exemption is still
30+
in force**, and the row now says so out loud: the keys are still docs-shaped, still
31+
deliberately kept, still not `authorWarn`'d — what changed is that the measurement, not the
32+
exemption, now carries the verdict.
33+
34+
**The split follows #5107.** Hand-maintained counts merge clean and WRONG: two PRs each move
35+
a different row by their own correct delta, the rows do not overlap, and git composes a
36+
table nobody wrote. The Notes prose stays hand-written in `README.md` — regenerating a Note
37+
would manufacture a verdict, which that README calls worse than a missing row.
38+
`check:liveness` gains three legs over the split (`scripts/liveness/readme-table.mts`,
39+
unit-tested for the usual reason: on a green tree none of them can fire): the artifact must
40+
equal what the gate measures right now, its row set and the README's must agree in both
41+
directions, and a count column reappearing in the README fails — that last one is invisible
42+
to the other two, and would let the table publish two sets of numbers with only one
43+
enforced. `gen:liveness-counts` regenerates, spawning the gate rather than re-implementing
44+
its walk, and keeps #7257's skeleton row for a governed type with no Note.

.gitattributes

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,17 @@
4242
# cleanly because they do not overlap, and the subtotal merges clean and WRONG
4343
# (seven cases in one day). Note it is the counts file, not the ledger — the
4444
# ledger's prose is hand-written and must never be resolved by regenerating.
45+
#
46+
# The liveness state table's counts joined at #7377 for the same reason, one file
47+
# over — 9 of its 30 rows had drifted from the gate before anyone re-ran the
48+
# documented snippet. Same split and the same caveat: `liveness/state-counts.md`
49+
# is the numbers and is driver-managed; `liveness/README.md` is the Notes prose —
50+
# hand-written measurement of how each type got where it is — and is NOT.
51+
# Regenerating a Note would fabricate a verdict, which that README calls worse
52+
# than a missing row.
4553

4654
packages/spec/spec-changes.json merge=os-regen
55+
packages/spec/liveness/state-counts.md merge=os-regen
4756
packages/spec/authorable-surface/** merge=os-regen
4857
packages/spec/authorable-surface.base.json merge=os-regen
4958
packages/spec/authorable-defaults/** merge=os-regen

packages/spec/liveness/README.md

Lines changed: 88 additions & 60 deletions
Large diffs are not rendered by default.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<!-- GENERATED — DO NOT EDIT BY HAND. -->
2+
<!-- Regenerate: pnpm --filter @objectstack/spec gen:liveness-counts -->
3+
4+
# Liveness state table — the counts (generated)
5+
6+
Every number the [liveness ledger README](./README.md)'s "Current state" table
7+
used to publish, computed by the gate that enforces them —
8+
`scripts/liveness/check-liveness.mts --json`, `types.<type>.byStatus`, the
9+
counting method fixed in #4488. The Notes prose, which is hand-written
10+
measurement of how each type got where it is, stays in the README and is never
11+
regenerated.
12+
13+
Split out at #7377 on #5107's precedent. Nine of the thirty rows had drifted
14+
from the gate by the time anyone re-ran the documented snippet, and
15+
hand-maintained counts merge in the one way that hides: two PRs each move a
16+
different row by their own correct delta, the rows do not overlap, git merges
17+
them without complaint, and the result is a table nobody wrote down. The
18+
correct resolution was always "recompute from the merged tree", so this path
19+
carries `merge=os-regen` (#4675) and the recomputation is mandatory rather than
20+
remembered. **Never hand-patch a number here** — fix the ledger or the schema
21+
and regenerate.
22+
23+
Counts are at the gate's one-level walk granularity and include the ADR-0010
24+
protection envelope, which the gate auto-classifies `live` on every type that
25+
spreads `MetadataProtectionFields`. See the README's counting-method section
26+
for both corollaries.
27+
28+
| Type | live | exp | dead | planned | classified |
29+
|---|---|---|---|---|---|
30+
| `object` | 49 | 0 | 0 | 1 | 50 |
31+
| `field` | 66 | 0 | 0 | 0 | 66 |
32+
| `flow` | 34 | 0 | 6 | 0 | 40 |
33+
| `action` | 42 | 0 | 2 | 0 | 44 |
34+
| `hook` | 18 | 0 | 2 | 0 | 20 |
35+
| `permission` | 38 | 0 | 4 | 0 | 42 |
36+
| `position` | 12 | 0 | 0 | 0 | 12 |
37+
| `agent` | 21 | 4 | 1 | 0 | 26 |
38+
| `tool` | 13 | 1 | 0 | 0 | 14 |
39+
| `skill` | 16 | 0 | 1 | 0 | 17 |
40+
| `dataset` | 27 | 0 | 0 | 0 | 27 |
41+
| `page` | 23 | 0 | 0 | 1 | 24 |
42+
| `view` | 80 | 0 | 6 | 0 | 86 |
43+
| `report` | 21 | 0 | 0 | 0 | 21 |
44+
| `dashboard` | 34 | 0 | 7 | 0 | 41 |
45+
| `webhook` | 19 | 0 | 0 | 0 | 19 |
46+
| `query` | 15 | 1 | 5 | 0 | 21 |
47+
| `datasource` | 30 | 0 | 0 | 0 | 30 |
48+
| `app` | 46 | 0 | 9 | 1 | 56 |
49+
| `book` | 20 | 0 | 1 | 0 | 21 |
50+
| `doc` | 15 | 0 | 0 | 0 | 15 |
51+
| `email_template` | 21 | 0 | 0 | 0 | 21 |
52+
| `job` | 15 | 0 | 0 | 0 | 15 |
53+
| `mapping` | 14 | 0 | 0 | 0 | 14 |
54+
| `seed` | 12 | 0 | 0 | 0 | 12 |
55+
| `translation` | 19 | 0 | 0 | 0 | 19 |
56+
| `validation` | 15 | 0 | 3 | 0 | 18 |
57+
| `api` | 25 | 0 | 0 | 2 | 27 |
58+
| `capability` | 12 | 0 | 0 | 0 | 12 |
59+
| `qa` | 4 | 0 | 5 | 0 | 9 |
60+
| **total** | **776** | **6** | **52** | **5** | **839** |

packages/spec/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@
212212
"test:watch": "vitest",
213213
"test:coverage": "vitest run --coverage",
214214
"check:liveness": "tsx scripts/liveness/check-liveness.mts",
215+
"gen:liveness-counts": "tsx scripts/liveness/build-state-counts.mts",
215216
"check:empty-state": "tsx scripts/liveness/check-empty-state.mts",
216217
"check:variant-docs": "tsx scripts/check-variant-docs.mts",
217218
"gen:strictness-ledger": "tsx scripts/build-strictness-ledger-counts.mts",

packages/spec/scripts/check-generated.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,23 @@ const GATED: ReadonlyArray<{
117117
gen: 'gen:strictness-ledger',
118118
artifact: 'docs/audits/2026-07-unknown-key-strictness-ledger.counts.md',
119119
},
120+
// Moved out of NO_GENERATOR at #7377, by the same precedent as its neighbour
121+
// above and for the same measured reason: the liveness README's "Current state"
122+
// table published its counts by hand, 9 of its 30 rows had drifted from the gate
123+
// before anyone re-ran the documented snippet, and hand-maintained counts merge
124+
// clean and wrong. The numbers are now an artifact this gate proves fresh; the
125+
// Notes prose stays hand-written, so — exactly like the strictness ledger —
126+
// `gen:` repairs only the half of what this reports that is arithmetic. The
127+
// other half is an unclassified property, a rotted evidence pointer or a row set
128+
// that no longer matches GOVERNED, and the failure says which.
129+
//
130+
// Last among the non-`ratchet` entries on the cheapest-first rule: it eagerly
131+
// loads every Zod schema and walks all 30 governed types.
132+
{
133+
check: 'check:liveness',
134+
gen: 'gen:liveness-counts',
135+
artifact: 'liveness/state-counts.md',
136+
},
120137
// GATED by the definition above — it compares a checked-in artifact
121138
// (test-typecheck-debt.json) against what `tsc -p tsconfig.test.json` measures
122139
// right now, and `gen:test-typecheck-debt` is that artifact's writer. It is NOT
@@ -151,7 +168,10 @@ const GATED: ReadonlyArray<{
151168
* so a failure is a code change, not a `gen:` command.
152169
*/
153170
const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [
154-
{ check: 'check:liveness', why: 'audits whether declared spec properties have a reader — no artifact' },
171+
// `check:liveness` used to sit here — "audits whether declared spec properties
172+
// have a reader — no artifact". #7377 gave it one (the state table's NUMBERS
173+
// became an artifact; its Notes prose stayed hand-written), so it moved to GATED
174+
// above. The audit half is unchanged and is still the bulk of what it reports.
155175
{ check: 'check:empty-state', why: 'audits empty-state coverage — no artifact' },
156176
{ check: 'check:skill-examples', why: 'validates skill examples parse — no artifact' },
157177
// #7319. Reads `src/` and the shipped template trees and writes nothing: a
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
#!/usr/bin/env tsx
2+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
3+
4+
/**
5+
* Writes `packages/spec/liveness/state-counts.md` — every number the liveness
6+
* ledger's "Current state" table used to publish by hand (#7377).
7+
*
8+
* The table's Notes prose merged cleanly for a year; its NUMBERS drifted from the
9+
* gate on 9 of 30 rows and nothing could see it, because the count columns were
10+
* hand-maintained and hand-maintained counts merge in the one way that hides. Two
11+
* PRs each move a different row by their own correct delta, the rows do not
12+
* overlap, git composes them without complaint, and the table publishes a state
13+
* nobody measured. #5107 met the identical shape in the strictness ledger and
14+
* counted seven cases in a single day; its resolution — the numbers become a
15+
* generated artifact carrying `merge=os-regen`, the prose stays hand-written — is
16+
* the one adopted here.
17+
*
18+
* ## Where the numbers come from, and why this script does not compute them
19+
*
20+
* From `check-liveness.mts --json`, run as a child process. That is the counting
21+
* method the README has declared since #4488 ("the gate's own report"), and it is
22+
* deliberately not re-implemented here: a second walker would be a second
23+
* definition of "what is classified", and when two definitions disagree the one
24+
* that wins is whichever the artifact happens to be rendered from. The gate is
25+
* what CI enforces, so the gate is what gets published.
26+
*
27+
* Its exit code is ignored ON PURPOSE. The gate exits 1 while this very artifact
28+
* is stale — which is the state a regeneration is run FROM — so honouring it would
29+
* make the fix unreachable from the failure. The JSON is emitted before the
30+
* verdict, so a non-zero exit still carries a complete report. What is never
31+
* ignored is a crash: no parseable JSON means no write, because an artifact
32+
* written from a half-measurement is worse than a stale one.
33+
*
34+
* ## The skeleton rows
35+
*
36+
* #7257 gave the old python snippet one behaviour a plain regenerate does not
37+
* have: it read the README back and printed a SKELETON row for any governed type
38+
* that had none, so the omission was visible at regeneration time and not only at
39+
* CI time. `api` and `capability` were governed, ledgered and counted for days
40+
* with no row, because the only reader who could have noticed was a human
41+
* comparing two lists by eye. That behaviour moves here, unchanged in substance:
42+
* the skeleton stops at the type name and prints a marker where the Notes cell
43+
* goes, never a guess at what belongs there.
44+
*
45+
* Regeneration is WHOLESALE — this script never patches a number in place, and
46+
* neither should you.
47+
*
48+
* Usage:
49+
* tsx build-state-counts.mts # rewrite the artifact
50+
*
51+
* Freshness is proved by `check:liveness`, which renders the same model and
52+
* compares bytes — deliberately not a second parser.
53+
*/
54+
55+
import { spawnSync } from 'node:child_process';
56+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
57+
import { createRequire } from 'node:module';
58+
import { dirname, join, resolve } from 'node:path';
59+
import { fileURLToPath } from 'node:url';
60+
61+
import {
62+
STATE_COUNTS_FILE,
63+
STATE_COUNTS_PATH,
64+
foldStateCounts,
65+
parseStateTable,
66+
renderStateCounts,
67+
} from './readme-table.mts';
68+
69+
const here = dirname(fileURLToPath(import.meta.url));
70+
const specRoot = resolve(here, '../..'); // packages/spec
71+
const ledgerRoot = join(specRoot, 'liveness');
72+
const gate = join(here, 'check-liveness.mts');
73+
74+
// `tsx/cli` rather than the `.bin/tsx` shim: the shim is a shell script, so it is
75+
// not spawnable by `process.execPath` and its resolution depends on which
76+
// node_modules/.bin happens to be on PATH. The module export is the same CLI and
77+
// resolves through the package graph.
78+
const tsxCli = createRequire(import.meta.url).resolve('tsx/cli');
79+
80+
const run = spawnSync(process.execPath, [tsxCli, gate, '--json'], {
81+
cwd: specRoot,
82+
encoding: 'utf8',
83+
maxBuffer: 64 * 1024 * 1024,
84+
});
85+
86+
// A crash is fatal; a red verdict is not. See the header — the gate is red
87+
// precisely when this artifact needs rewriting.
88+
let report: { types?: Record<string, { byStatus?: Record<string, number> }>; readmeMissingRows?: string[] };
89+
try {
90+
report = JSON.parse(run.stdout || '');
91+
} catch {
92+
console.error(`✗ ${gate} --json produced no parseable report — refusing to write ${STATE_COUNTS_FILE}.`);
93+
console.error(' Nothing is written from a half-measurement; a stale artifact is the safer state.\n');
94+
if (run.error) console.error(` ${run.error.message}`);
95+
if (run.stderr) console.error(run.stderr);
96+
process.exit(1);
97+
}
98+
99+
const types = report.types ?? {};
100+
// The gate reports one entry per GOVERNED type, in GOVERNED order, so the report's
101+
// own key order IS the artifact's row order. Reading it back from the report keeps
102+
// this script from carrying a second copy of the governed list.
103+
const rows = foldStateCounts(Object.keys(types), Object.fromEntries(
104+
Object.entries(types).map(([t, v]) => [t, v.byStatus ?? {}]),
105+
));
106+
107+
const rendered = renderStateCounts(rows);
108+
writeFileSync(join(ledgerRoot, STATE_COUNTS_FILE), rendered);
109+
110+
const total = rows.reduce((a, r) => a + r.live + r.experimental + r.dead + r.planned, 0);
111+
console.log(`✓ wrote ${STATE_COUNTS_PATH}`);
112+
console.log(` ${rows.length} governed type(s), ${total} classified propert(ies).`);
113+
114+
// ── the #7257 skeleton, preserved ──
115+
// Prefer the gate's own reconciliation when the report carries it; fall back to a
116+
// direct read only if an older report shape is being parsed, so the two can never
117+
// answer differently on a report that has the field.
118+
const readmeFile = join(ledgerRoot, 'README.md');
119+
const missing = report.readmeMissingRows
120+
?? (existsSync(readmeFile)
121+
? (() => {
122+
const have = new Set(parseStateTable(readFileSync(readmeFile, 'utf8')).rows.map((r) => r.type));
123+
return rows.map((r) => r.type).filter((t) => !have.has(t));
124+
})()
125+
: []);
126+
127+
if (missing.length) {
128+
console.log(
129+
`\n⚠ ${missing.length} governed type(s) counted above have NO row in README.md's ` +
130+
'"Current state" table.\n' +
131+
' Paste the skeleton(s) below into the table and write the Notes cell BY\n' +
132+
' MEASUREMENT — the seeding PR, what it measured, which keys are dead and why.\n' +
133+
' Never infer one from the counts or from the type\'s name.\n',
134+
);
135+
for (const t of missing) {
136+
console.log(
137+
`| ${t} | **NO ROW YET (#7257) — write this Notes cell from the seeding PR measurement, never from a guess** |`,
138+
);
139+
}
140+
console.log('\n check:liveness will fail until every governed type has a row.');
141+
}

0 commit comments

Comments
 (0)