@@ -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 ;
9851046if ( 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
0 commit comments