Skip to content

Commit 736058d

Browse files
os-trumpclaude
andauthored
fix(spec): refuse an unrecognized liveness status, and make the fold preserve the walk's total (#13183)
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. Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 44ea21e commit 736058d

5 files changed

Lines changed: 439 additions & 2 deletions

File tree

packages/spec/scripts/liveness/build-state-counts.mts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,10 @@ import { fileURLToPath } from 'node:url';
6161
import {
6262
STATE_COUNTS_FILE,
6363
STATE_COUNTS_PATH,
64+
STATE_COUNTS_TOTALS_GUIDANCE,
6465
foldStateCounts,
6566
parseStateTable,
67+
reconcileStateCountTotals,
6668
renderStateCounts,
6769
} from './readme-table.mts';
6870

@@ -85,7 +87,10 @@ const run = spawnSync(process.execPath, [tsxCli, gate, '--json'], {
8587

8688
// A crash is fatal; a red verdict is not. See the header — the gate is red
8789
// precisely when this artifact needs rewriting.
88-
let report: { types?: Record<string, { byStatus?: Record<string, number> }>; readmeMissingRows?: string[] };
90+
let report: {
91+
types?: Record<string, { byStatus?: Record<string, number>; classified?: number }>;
92+
readmeMissingRows?: string[];
93+
};
8994
try {
9095
report = JSON.parse(run.stdout || '');
9196
} catch {
@@ -104,6 +109,28 @@ const rows = foldStateCounts(Object.keys(types), Object.fromEntries(
104109
Object.entries(types).map(([t, v]) => [t, v.byStatus ?? {}]),
105110
));
106111

112+
// ── refuse to publish a total the fold under-counted (#13083) ──
113+
// The header's rule is that a RED gate is not fatal here — the gate is red
114+
// precisely when this artifact needs rewriting. This failure is the exception,
115+
// and it is the same exception the unparseable report above already carves out:
116+
// there is nothing to rewrite. The fold that produced `rows` reads four status
117+
// names and drops everything else, so writing now would publish an understated
118+
// `classified` — and the gate's freshness leg would then compare those bytes
119+
// against a re-render of the SAME understated fold and call it current. A stale
120+
// artifact is the safer state; a fresh wrong one is unfalsifiable.
121+
const totalErrors = reconcileStateCountTotals({
122+
governed: Object.keys(types),
123+
byStatus: Object.fromEntries(Object.entries(types).map(([t, v]) => [t, v.byStatus ?? {}])),
124+
classified: Object.fromEntries(Object.entries(types).map(([t, v]) => [t, v.classified])),
125+
});
126+
if (totalErrors.length) {
127+
console.error(`✗ refusing to write ${STATE_COUNTS_FILE} — the fold does not preserve the walk's total:\n`);
128+
totalErrors.forEach((s) => console.error(` ${s}`));
129+
console.error('');
130+
STATE_COUNTS_TOTALS_GUIDANCE.forEach((line) => console.error(line ? ` ${line}` : ''));
131+
process.exit(1);
132+
}
133+
107134
const rendered = renderStateCounts(rows);
108135
writeFileSync(join(ledgerRoot, STATE_COUNTS_FILE), rendered);
109136

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

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,12 @@ import {
186186
STATE_COUNTS_FILE,
187187
STATE_COUNTS_GUIDANCE,
188188
STATE_COUNTS_PATH,
189+
STATE_COUNTS_TOTALS_GUIDANCE,
189190
STATUS_COLUMNS,
190191
foldStateCounts,
191192
parseStateTable,
192193
reconcileReadmeTable,
194+
reconcileStateCountTotals,
193195
reconcileStateCounts,
194196
renderStateCounts,
195197
} from './readme-table.mts';
@@ -393,6 +395,36 @@ for (const s of STATUS_COLUMNS) {
393395
}
394396
}
395397

398+
// ── THE SAME PARTITION, ASKED OF THE DATA (#13083) ──
399+
//
400+
// The loop above holds the CODE to the published vocabulary. Nothing held the
401+
// LEDGERS to it. `classify()` accepts any truthy string and counts it, so a row
402+
// written `"status": "planed"` is classified (the forward pass is satisfied, no
403+
// UNCLASSIFIED finding), counted into a `byStatus` bucket named after the typo,
404+
// and then dropped by `foldStateCounts` — which reads four names and nothing
405+
// else. The artifact publishes a `classified` total short by exactly the typo'd
406+
// population, and every reconciliation in this gate compares that number against
407+
// itself, so it stays green.
408+
//
409+
// After #13041 the same unvalidated string carries a second consequence: a
410+
// status in neither evidence-scan set has its `evidence` pointer counted by the
411+
// census and read by no check. A typo lands in neither set BY CONSTRUCTION,
412+
// which is the defect that loop exists to prevent — reachable through the data
413+
// instead of through the code.
414+
//
415+
// So the vocabulary is read from `STATUS_COLUMNS` rather than written out again:
416+
// the guard and the fold that drops the value must not be able to disagree about
417+
// what the four names are. That is the same reason `EVIDENCE_SCANNED_LABEL`
418+
// below is derived from its set rather than restated.
419+
//
420+
// Population measured before switching this on, across all 31 ledgers on this
421+
// commit: live 819, planned 10, dead 80, experimental 5 — 914 classified, no
422+
// fifth value. So it starts GREEN and only a NEW typo can red it, which is the
423+
// zero-census argument the orphan-proof and key-mention flips were switched on
424+
// under. A check that starts at zero can be red; that is why the census came
425+
// first.
426+
const KNOWN_STATUSES = new Set<string>(STATUS_COLUMNS);
427+
396428
/**
397429
* The scanned population, rendered for the gate's own output. Derived from the
398430
* set rather than written out again, so the numbers and the population they
@@ -523,6 +555,8 @@ const report: any = {
523555
countsArtifactErrors: [] as string[], // state-counts.md is missing, or its bytes are not what the gate measures (#7377)
524556
countsRowSetErrors: [] as string[], // the README's row set and the artifact's disagree
525557
countsHandEdited: [] as string[], // a count column is back in the README — a hand-maintained number in the merge path
558+
countsTotalErrors: [] as string[], // the four columns and the walk's own `classified` disagree — the fold dropped a status (#13083)
559+
unknownStatus: [] as string[], // a ledger `status` outside STATUS_COLUMNS — counted by the walk, dropped by the fold (#13083)
526560
verification: null as VerificationReport | null, // `verifiedAt` ages — the re-verification worklist
527561
producers: null as ProducerReport | null, // `producer` / `evidenceScope` — the #4837 / #4895 worklists
528562
producerMissing: [] as string[], // a `producer` pointer into thin air — FAILS, like a rotted `evidence`
@@ -635,6 +669,13 @@ function classify(type: string, path: string, status: string, led: any, cat: any
635669
cat.classified++;
636670
cat.byStatus[status] = (cat.byStatus[status] || 0) + 1;
637671
report.totals.byStatus[status] = (report.totals.byStatus[status] || 0) + 1;
672+
// #13083 — an unrecognized value is still COUNTED here, deliberately. Dropping
673+
// it would keep `cat.classified` and the `byStatus` buckets in agreement and
674+
// hide the row from the totals reconciliation downstream, which is the very
675+
// silence this names. It is counted, and it is reported.
676+
if (!KNOWN_STATUSES.has(status)) {
677+
report.unknownStatus.push(`${type}/${path} → "${status}"`);
678+
}
638679
// Framework-auto entries (`led === null`) have no ledger row to date-stamp.
639680
if (led !== null) {
640681
verificationEntries.push({ key: `${type}/${path}`, status, verifiedAt: led?.verifiedAt });
@@ -897,6 +938,19 @@ if (!existsSync(readmeFile)) {
897938
report.countsHandEdited = counts.handCountErrors;
898939
}
899940

941+
// ── the fold's arithmetic (#13083) ──
942+
// Outside the README block above on purpose: the three legs there all read the
943+
// README or the artifact, and every one of them is satisfied by a fold that
944+
// silently dropped a status. This one reads the WALK — `types.<type>.classified`,
945+
// counted by its own `++` and never through `byStatus` — so it is the only
946+
// comparison here whose two sides are not the same measurement twice. It must
947+
// therefore run even when the README is gone, which is why it is not nested.
948+
report.countsTotalErrors = reconcileStateCountTotals({
949+
governed: GOVERNED,
950+
byStatus: Object.fromEntries(Object.entries<any>(report.types).map(([t, v]) => [t, v.byStatus])),
951+
classified: Object.fromEntries(Object.entries<any>(report.types).map(([t, v]) => [t, v.classified])),
952+
});
953+
900954
// ── verifiedAt: how old is each claim? ──
901955
// Age never fails the gate — re-verification is a worklist, not a merge gate.
902956
// A MALFORMED value does fail: it silently disables the staleness check for
@@ -981,7 +1035,14 @@ const failed =
9811035
report.readmeMalformedRows.length > 0 ||
9821036
report.countsArtifactErrors.length > 0 ||
9831037
report.countsRowSetErrors.length > 0 ||
984-
report.countsHandEdited.length > 0;
1038+
report.countsHandEdited.length > 0 ||
1039+
// A ledger `status` outside the published vocabulary, and the arithmetic that
1040+
// proves the artifact under-counted because of it (#13083). Red rather than ⚠
1041+
// on the zero-census argument stated at KNOWN_STATUSES: measured across all 31
1042+
// ledgers on the commit that switched this on, every value was one of the
1043+
// four, so the gate starts green and only a NEW typo can red it.
1044+
report.unknownStatus.length > 0 ||
1045+
report.countsTotalErrors.length > 0;
9851046
if (asJson) {
9861047
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
9871048
} else {
@@ -1169,6 +1230,29 @@ if (asJson) {
11691230
console.log(`\n✗ ${totalUnclassified} UNCLASSIFIED — classify in packages/spec/liveness/<type>.json:`);
11701231
report.unclassified.forEach((s: string) => console.log(` ${s}`));
11711232
}
1233+
if (report.unknownStatus.length) {
1234+
console.log(
1235+
`\n✗ ${report.unknownStatus.length} ledger row(s) whose \`status\` is not one of ` +
1236+
`${STATUS_COLUMNS.join(' / ')}:`,
1237+
);
1238+
report.unknownStatus.forEach((s: string) => console.log(` ${s}`));
1239+
console.log(
1240+
'\n This is the shape UNCLASSIFIED above cannot catch, and it is worse than\n' +
1241+
' UNCLASSIFIED because it looks DONE: the row has a verdict, the forward pass is\n' +
1242+
` satisfied, the walk counts it — and then ${STATE_COUNTS_FILE} drops it, because\n` +
1243+
` the fold reads ${STATUS_COLUMNS.join(' / ')} and nothing else. The published\n` +
1244+
' total comes out short by exactly these rows, and every other check in this gate\n' +
1245+
' compares that total against itself and agrees (#13083).\n\n' +
1246+
' Since #13041 the same value costs a second check: the evidence scan reads a\n' +
1247+
' declared population, and a status in neither the scanned nor the unscanned set\n' +
1248+
" has its `evidence` pointer counted by the census and READ BY NOTHING. A typo is\n" +
1249+
' in neither set by construction.\n\n' +
1250+
' Fix the VALUE in packages/spec/liveness/<type>.json — it is almost always a\n' +
1251+
" misspelling of the verdict the author meant. ⛔ Never widen STATUS_COLUMNS to\n" +
1252+
' accept it: that vocabulary is what the generated artifact publishes as columns,\n' +
1253+
' and a fifth name there changes the artifact (see the totals failure below).',
1254+
);
1255+
}
11721256
if (report.ungoverned.length) {
11731257
console.log(`\n✗ ${report.ungoverned.length} REGISTERED metadata type(s) governed by nothing:`);
11741258
report.ungoverned.forEach((t: string) => console.log(` ${t}`));
@@ -1288,6 +1372,15 @@ if (asJson) {
12881372
' cell; the Notes prose is what this table is for.',
12891373
);
12901374
}
1375+
if (report.countsTotalErrors.length) {
1376+
console.log(
1377+
`\n✗ ${report.countsTotalErrors.length} governed type(s) where ${STATE_COUNTS_FILE}'s columns ` +
1378+
"do not add up to the walk's own count:",
1379+
);
1380+
report.countsTotalErrors.forEach((s: string) => console.log(` ${s}`));
1381+
console.log('');
1382+
STATE_COUNTS_TOTALS_GUIDANCE.forEach((line) => console.log(line ? ` ${line}` : ''));
1383+
}
12911384
// ── re-verification clock ──
12921385
// Annotated at the boundary: `report` is deliberately `any` (see its
12931386
// declaration), so without this every `v.*` below is `any` too — which is how

packages/spec/scripts/liveness/check-liveness.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,3 +702,99 @@ describe('check:liveness — the manifest is inside the governed universe (#1072
702702
expect(src).toContain("const schema = SPEC_ONLY_SCHEMAS[type] ?? getMetadataTypeSchema(type);");
703703
});
704704
});
705+
706+
// A ledger `status` was free text: any truthy string was classified and counted,
707+
// then dropped by `foldStateCounts`, which reads four names and nothing else. The
708+
// gate stayed GREEN over an understated total, because `state-counts.md` computes
709+
// its `classified` column as the sum of those four columns and the freshness leg
710+
// compares it against a re-render of the same fold — every reconciliation in the
711+
// gate comparing that number against itself.
712+
//
713+
// Measured across all 31 ledgers on the commit that switched this on: live 819,
714+
// planned 10, dead 80, experimental 5 — 914 classified, no fifth value. So the
715+
// population is ZERO and a green `pnpm check:liveness` proves nothing about
716+
// whether either guard can fire. `--ledger-root` is what answers that, for the
717+
// #5623 reason every block above states: the REAL gate, a COPY with one status
718+
// misspelled, and a real exit code.
719+
describe('check:liveness — an unrecognized ledger `status` (#13083)', () => {
720+
let tmp: string;
721+
722+
beforeAll(() => {
723+
tmp = mkdtempSync(path.join(tmpdir(), 'os-liveness-status-'));
724+
});
725+
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
726+
727+
/** Rewrite one property's `status` in a copied ledger. */
728+
function setStatus(root: string, type: string, prop: string, status: string): void {
729+
const file = path.join(root, `${type}.json`);
730+
const ledger = JSON.parse(readFileSync(file, 'utf8'));
731+
ledger.props[prop].status = status;
732+
writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`);
733+
}
734+
735+
/** A copy of the real ledger root with `field.useGrouping` misspelled. */
736+
function typodRoot(name: string): string {
737+
const root = path.join(tmp, name);
738+
cpSync(LEDGERS, root, { recursive: true });
739+
// `field.useGrouping` is `planned` and carries no evidence, so the misspelling
740+
// is the only thing in the copy that can move a verdict — no evidence-scan
741+
// finding can be confused for it.
742+
setStatus(root, 'field', 'useGrouping', 'planed');
743+
return root;
744+
}
745+
746+
// DISPOSITION 1. The row is named, with its coordinate and the offending value.
747+
it("FAILS and names the row when a ledger `status` is misspelled", () => {
748+
const { status, output } = runGate(typodRoot('d1-names-the-row'));
749+
expect(status, output).toBe(1);
750+
expect(output).toContain('whose `status` is not one of live / experimental / dead / planned');
751+
expect(output).toContain('field/useGrouping → "planed"');
752+
});
753+
754+
// The misspelled row is still COUNTED, deliberately. Dropping it would keep
755+
// `classified` and the `byStatus` buckets in agreement and hide the row from
756+
// the arithmetic below — silencing the second guard with the first.
757+
it('still counts the misspelled row, under its own bucket name', () => {
758+
const { output } = runGate(typodRoot('d1-still-counted'));
759+
expect(output).toMatch(/^ {2}field {2,}\d+ classified \(.*\bplaned 1\b/m);
760+
});
761+
762+
// DISPOSITION 2, through the real gate. The walk counted the row; the four
763+
// columns did not; the artifact would have published the smaller number. This
764+
// is the leg that fires even if the vocabulary itself grows — see
765+
// readme-table.test.ts for that case, which no ledger typo can produce.
766+
it('FAILS the totals arithmetic, because the fold cannot name that bucket', () => {
767+
const { status, output } = runGate(typodRoot('d2-arithmetic'));
768+
expect(status, output).toBe(1);
769+
expect(output).toContain("do not add up to the walk's own count");
770+
expect(output).toContain('1 in `planed`');
771+
expect(output).toContain('is not the repair');
772+
});
773+
774+
// The two are not one check reported twice: disposition 1 is the only one that
775+
// can say WHICH row, and disposition 2 is the only one that reads a number the
776+
// artifact actually publishes. A repair that satisfied one and not the other
777+
// would leave the class open, so the split is pinned rather than assumed.
778+
it('reports the two failures separately — one names the row, one names the number', () => {
779+
const { output } = runGate(typodRoot('d1-d2-separate'));
780+
const rowLine = output.split('\n').find((l) => l.includes('field/useGrouping → "planed"'));
781+
const sumLine = output.split('\n').find((l) => l.includes("publishes") && l.includes('the walk counted'));
782+
expect(rowLine, output).toBeTruthy();
783+
expect(sumLine, output).toBeTruthy();
784+
// The arithmetic is per TYPE — it cannot name the property, which is exactly
785+
// why disposition 1 is not redundant with it.
786+
expect(sumLine).not.toContain('useGrouping');
787+
expect(sumLine).toContain('field');
788+
});
789+
790+
// The quiet half, and the reason the whole thing could be switched on: the four
791+
// real statuses are the entire population today, so an unmutated run must be
792+
// green AND must show neither heading. "Exits 0" alone would also be satisfied
793+
// by a guard wired to nothing.
794+
it('stays GREEN on the real ledgers, where every status is one of the four', () => {
795+
const { status, output } = runGate();
796+
expect(status, output).toBe(0);
797+
expect(output).not.toContain('whose `status` is not one of');
798+
expect(output).not.toContain("do not add up to the walk's own count");
799+
});
800+
});

0 commit comments

Comments
 (0)