From 70809f749540d0943e6fc9687bfabf9e13715e5f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 13:08:29 +0000 Subject: [PATCH] feat(spec): the liveness state table's counts become a generated artifact, and eleven drifted rows are reconciled (#7377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PJn5BD9LY9ZCECPs25oiDb --- .changeset/liveness-state-counts-generated.md | 44 +++ .gitattributes | 9 + packages/spec/liveness/README.md | 148 +++++---- packages/spec/liveness/state-counts.md | 60 ++++ packages/spec/package.json | 1 + packages/spec/scripts/check-generated.ts | 22 +- .../scripts/liveness/build-state-counts.mts | 141 +++++++++ .../spec/scripts/liveness/check-liveness.mts | 71 ++++- .../scripts/liveness/check-liveness.test.ts | 96 ++++++ .../spec/scripts/liveness/readme-table.mts | 282 +++++++++++++++++- .../scripts/liveness/readme-table.test.ts | 159 ++++++++++ scripts/regen-artifacts.mjs | 21 ++ 12 files changed, 983 insertions(+), 71 deletions(-) create mode 100644 .changeset/liveness-state-counts-generated.md create mode 100644 packages/spec/liveness/state-counts.md create mode 100644 packages/spec/scripts/liveness/build-state-counts.mts diff --git a/.changeset/liveness-state-counts-generated.md b/.changeset/liveness-state-counts-generated.md new file mode 100644 index 0000000000..260949b608 --- /dev/null +++ b/.changeset/liveness-state-counts-generated.md @@ -0,0 +1,44 @@ +--- +"@objectstack/spec": patch +--- + +The liveness ledger's "Current state" table stops hand-maintaining its counts: the numbers +move into a generated `packages/spec/liveness/state-counts.md` carrying `merge=os-regen`, +and the eleven rows that had drifted from the gate are reconciled — each with the Notes +prose beside it re-read against the new measurement. + +**Why it was a card and not a `sed`.** The table declares its own counting method (the +gate's `--json` report, fixed in #4488) and says the count columns are never hand-edited. +Nobody re-ran the snippet, and 9 of 30 rows disagreed with the gate by the time #7377 was +filed — two more (`job`, `translation`) joined when PR #7425 re-graded four docs-shaped +rows. Several Notes cells enumerate their own dead sets BY HAND, so regenerating the +numbers alone would have left a row reading `dead 6` next to a sentence naming four, which +is worse than the drift: the prose is the part a reader believes. + +**Every delta is explained, not absorbed.** Six rows moved for one structural reason — +`field`, `action`, `hook`, `page`, `seed`, `webhook` picked up the ADR-0010 protection +envelope as the #4001 strictness campaign closed each schema (#4514/#4530/#4531/#4533/#4974), +and the gate auto-classifies those keys `live`. The rest are verdict-shaped: `flow`'s sixth +dead is `errorHandling.retryDelayMs`, tombstoned by the #4964 rename to `backoffMs`; +`view` gained three container-level keys in #4001 batch 6e (`object` live, `name`/`label` +dead) that its Note never mentioned; `app` gained `_unpublished` (#4829, a `live` key no +author may write) and its first `planned`, `navigation.runAction` (#4848); `action` gained +`description` (#7367); `job` and `translation` reached zero dead under #7425's ruling that +designer previews count as consumers. + +**`job` is the first row in the table with zero dead where the ADR-0033 exemption is still +in force**, and the row now says so out loud: the keys are still docs-shaped, still +deliberately kept, still not `authorWarn`'d — what changed is that the measurement, not the +exemption, now carries the verdict. + +**The split follows #5107.** Hand-maintained counts merge clean and WRONG: two PRs each move +a different row by their own correct delta, the rows do not overlap, and git composes a +table nobody wrote. The Notes prose stays hand-written in `README.md` — regenerating a Note +would manufacture a verdict, which that README calls worse than a missing row. +`check:liveness` gains three legs over the split (`scripts/liveness/readme-table.mts`, +unit-tested for the usual reason: on a green tree none of them can fire): the artifact must +equal what the gate measures right now, its row set and the README's must agree in both +directions, and a count column reappearing in the README fails — that last one is invisible +to the other two, and would let the table publish two sets of numbers with only one +enforced. `gen:liveness-counts` regenerates, spawning the gate rather than re-implementing +its walk, and keeps #7257's skeleton row for a governed type with no Note. diff --git a/.gitattributes b/.gitattributes index 650e181131..8b81a363fd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -42,8 +42,17 @@ # cleanly because they do not overlap, and the subtotal merges clean and WRONG # (seven cases in one day). Note it is the counts file, not the ledger — the # ledger's prose is hand-written and must never be resolved by regenerating. +# +# The liveness state table's counts joined at #7377 for the same reason, one file +# over — 9 of its 30 rows had drifted from the gate before anyone re-ran the +# documented snippet. Same split and the same caveat: `liveness/state-counts.md` +# is the numbers and is driver-managed; `liveness/README.md` is the Notes prose — +# hand-written measurement of how each type got where it is — and is NOT. +# Regenerating a Note would fabricate a verdict, which that README calls worse +# than a missing row. packages/spec/spec-changes.json merge=os-regen +packages/spec/liveness/state-counts.md merge=os-regen packages/spec/authorable-surface/** merge=os-regen packages/spec/authorable-surface.base.json merge=os-regen packages/spec/authorable-defaults/** merge=os-regen diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index b38a1156c7..7204669b5f 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -721,73 +721,101 @@ The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type > foot of the table. What the gate deliberately does **not** check is the part > that made #7257 worth filing rather than fixing on the spot: the Notes cell is > hand-written measurement, and a manufactured one is worse than a missing row. +> +> **The count columns are gone from this table** (#7377). #7257 scoped them out +> because 9 of 30 rows disagreed with the gate and reconciling each one meant +> re-reading the Note beside it; that reconciliation is done, and the numbers now +> live in the generated [`state-counts.md`](./state-counts.md). `check:liveness` +> proves that artifact fresh, holds its row set to this table's in both +> directions, and fails a count column reappearing here. + +**The counts are no longer in this table** (#7377). They live in +[`state-counts.md`](./state-counts.md), which is GENERATED and carries +`merge=os-regen`; this table holds the **Notes prose only**, which is +hand-written measurement and must never be regenerated. Regenerate the numbers +with: -**The counting method for this table is the gate's own report** — +```bash +pnpm --filter @objectstack/spec gen:liveness-counts +``` + +**The counting method is unchanged and is still the gate's own report** — `check-liveness.mts --json`, `types..byStatus` — decided in #4488 after two methods spent a release disagreeing. The alternative (a python snippet that counted ledger JSON rows) systematically undercounted: it missed statuses resolved from `describe()` markers, the ADR-0010 framework overlay fields the gate auto-classifies `live`, and `childrenDefault` fan-outs — and a mechanical rewrite with it produced two regressions while #4487 was being written. The -gate's numbers are what CI actually enforces, so they are what the table -mirrors. Two corollaries: counts are at the gate's **one-level walk -granularity** (a Notes cell may annotate finer detail, e.g. `query`'s -marker-experimental search sub-keys, without the counts reflecting it), and the -count columns are **never hand-edited** — regenerate: - -```bash -cd packages/spec && npx tsx scripts/liveness/check-liveness.mts --json | python3 -c " -import json,re,sys -r = json.load(sys.stdin) -have = set(re.findall(r'^\|\s*([a-z][a-z0-9_]*)\s*\|', open('liveness/README.md').read().split('## Current state')[-1], re.M)) -NEW = ' **NO ROW YET (#7257) — write this Notes cell from the seeding PR measurement, never from a guess** |' -for t, v in r['types'].items(): - b = v['byStatus'] - row = f\"| {t} | {b.get('live',0)} | {b.get('experimental',0)} | {b.get('dead',0)} | {b.get('planned',0)} |\" - print(row + ('' if t in have else NEW))" -``` - -The snippet reads the table back as well as the report, so **a governed type with -no row prints a skeleton** instead of silently not being printed next to 29 -siblings that are. That is the omission made visible at REGENERATION time; the -gate above makes it visible at CI time. Both were needed: `api` and `capability` -survived because the only reader who could have noticed was a human comparing two -lists by eye, and the count columns get regenerated far more often than the row -set gets audited. The skeleton stops at the count columns on purpose — it prints -a marker where the Notes cell goes, never a guess at what belongs there. - -| Type | live | exp | dead | planned | Notes | -|---|---|---|---|---|---| -| object | 49 | – | 0 | 1 | aspirational tier (versioning/softDelete/search/recordName/keyPrefix) + tags/active/abstract REMOVED (#2377) — tombstoned in UNKNOWN_KEY_GUIDANCE; `enable.trash`/`mru` REMOVED (#2377 close-out) — tombstoned in the now-`.strict()` ObjectCapabilities; `isSystem` + `enable.searchable` CORRECTED to live (#2377 — sharing default-model + global-search opt-out; 2026-06 audit missed both readers); `tenancy.strategy`/`crossTenantAccess` REMOVED post-15.0 (#2763) | -| field | 59 | – | 0 | – | healthy — full dead set (vectorConfig/fileAttachmentConfig/dependencies, then referenceFilters/columnName/index) REMOVED (#2377); columnName also dropped the ADR-0062 D7 lint + StorageNameMapping column helpers | -| flow | 34 | – | 5 | – | dead count = 4 tombstone entries + the kept docs field: `active`/`template`/nodes.`outputSchema`/errorHandling.`fallbackNodeId` REMOVED 2026-07-30 (#3896 close-out sweep — `active: false` never stopped a flow, `status` is the enforced lifecycle; faults route via per-node fault edges); remaining dead = `description`, KEPT deliberately: docs-shaped, exempt from enforce-or-remove | -| action | 34 | 0 | 2 | – | `type:'form'` CORRECTED to live (objectui ActionRunner.executeForm, #2377); dead `timeout` REMOVED (#2377); `disabled` live since objectui#2863; `undoable` CORRECTED to live (#3714); `shortcut` + `bulkEnabled` REMOVED 2026-07-30 (#3896 close-out sweep — no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions) | -| hook | 11 | – | 2 | – | model-healthy; label/description dead but KEPT deliberately (2026-07-30 sweep) — docs-shaped annotation fields, exempt from enforce-or-remove | -| permission | 38 | – | 4 | – | CRUD/FLS/RLS live; dead `contextVariables` REMOVED (ADR-0105 D11 — RLS resolves only the `current_user.*` built-ins plus runtime-staged `rlsMembership` sets). 2026-07-30 security-subset re-verification (all 33 entries `verifiedAt`-stamped): `rowLevelSecurity.enabled` was live-with-wrong-evidence and UNREAD — a disabled policy kept contributing its OR-branch grant; ENFORCED same day in rls-compiler (`getApplicablePolicies`), the `positions` ADR-0049 resolution repeated. `rowLevelSecurity.priority` CORRECTED to dead+authorWarn — semantically void under OR-combination (no conflict exists to order), a REMOVE candidate. `rls.label`/`description`/`tags` CORRECTED to dead (benign display, no consumer in either repo). `tabPermissions` was UNDERSTATED ("only hidden read" → the rank merge reads all four values; me-apps dogfood test exercises it). `allowExport` re-verified TRUE end-to-end (server-side 403 gate, not just the /me projection) | -| position | 12 | – | – | – | (role's ADR-0090 successor) fully live; all 4 `verifiedAt`-stamped 2026-07-30 | -| agent | 21 | 4 | 1 | – | dead `tenantId` + `planning.strategy`/`allowReplan` REMOVED (#2377); autonomy tier experimental; `knowledge` REMOVED 2026-07-30 (#3896 close-out sweep — declaring sources never scoped retrieval; AIKnowledgeSchema removed with it, the topics→sources rename absorbed pre-release) | -| tool | 13 | 1 | 0 | – | the inert authoring surface is now REMOVED, not merely marked: `category`/`permissions`/`active`/`builtIn` retired 2026-07-30 (#3896 close-out) after `requiresConfirmation` set the precedent (#3715, ADR-0033 §2). `permissions` promised an invocation gate nothing enforced and `active:false` withdrew nothing — false compliance, same shape as rls.enabled. The `.strict()` ToolSchema rejects each retired key with its prescription; the `tool-inert-authoring-keys-removed` conversion strips them from authored sources | -| skill | 16 | – | 1 | – | `permissions` REMOVED 2026-07 (#3704); `triggerPhrases` REMOVED 2026-07-30 (#3896 close-out sweep — phrases were never matched; activation is `triggerConditions` + the agent's `skills[]` + /skill-name pinning) | -| dataset | 27 | – | 0 | – | `measures.certified` (declared-but-unenforced governance flag) REMOVED in 16.0 (#2377) | -| page | 16 | – | – | 1 | fully live + one planned | -| view | 79 | 0 | 4 | – | list/form drilled via `children` (#2998 Track B); list.{responsive,performance} + form.{defaultSort,aria} REMOVED 2026-07-30 (#3896 close-out sweep — list aria/data stay live); **form.data was that sweep's one CORRECTION** — the removal attempt broke the build (`defineForm` writes `data.provider='schema'` onto every metadata form, `metadata-protocol` serves it), so it stands `live` with re-verified evidence; form.{buttons,defaults} live (framework#1894 / #2998); audit-era DEAD lines superseded by re-verification; level-2 dead residue (userActions.buttons, addRecord.mode/formView, tabs[].order) noted on parents — one drill level only | -| report | 21 | 0 | 0 | – | dataset-bound (ADR-0021); the aria/performance LEDGER entries were stale — the keys left the schema in the report-liveness close-out; deleted 2026-07-30 as hygiene. Audit-era `chart` DEAD superseded (framework#1890 / #3441) | -| dashboard | 34 | 0 | 7 | – | ADR-0021 dataset widgets (#3251; DashboardWidgetSchema `.strict()`); `aria`/`performance` (and widget `performance` + PerformanceConfigSchema) REMOVED 2026-07-30 (#3896 close-out sweep — no renderer applied any of them); audit-era `globalFilters`/`dateRange` DEAD superseded (framework#2501) | **#4956**: `widgets` DRILLED — the row jumps 20 → 41 classified because all 22 widget-level keys enter the count at once. They had never been classified at all: the entry carried one blanket `live` plus a `note` asserting they were classified "in the DashboardWidgetSchema subtree", and no such subtree existed in any of the 28 ledger files. That gap, not any evidence, is what carried `widgets[].responsive` through the #3896 sweep that removed both its sibling `widgets[].performance` and its literal namesake `view.responsive` — `view` is drilled, so `list.responsive` got asked and went out. New dead 6 = `responsive` (retired #4876/#4995, tombstone keeps the row) + `colorVariant` + `actionUrl`/`actionType`/`actionIcon` + `aria`. The action trio is the sharpest: no renderer draws a per-widget action button at all (every `actionUrl` read in DashboardRenderer is scoped to `header.actions[]`), yet `validate-dashboard-action-refs.ts` enforces reference integrity on it and its docblock calls it "the per-widget button" — a lint guarding an affordance that does not exist. `requiresService` is the counter-example worth remembering: dead by every objectui measurement, and LIVE server-side (`filterDashboardForUser`, ADR-0057 D10) — judging a widget key from the renderer repo alone would have retired an enforced gate. `compareTo` is `live` on ONE path only (inline object-provider charts); on the ADR-0021 dataset path the string arms are dropped and `{ offset }` throws in the executor. **#6774** moves the row 33/8 → 34/7: `colorVariant` CORRECTED dead → live 2026-08-09, the enforce leg of #5010 ruling B landing from the renderer side (objectui#3359 / PR objectui#3799, absorbed by pin `09987b68`). Worth reading beside `requiresService` above, because it is the same lesson from the other end — that row warns against judging a widget key from the renderer repo alone, and this one is a `dead` verdict that was correct in this repo AND correct in the renderer repo on the day it was measured, and stopped being either when a cross-repo decision was implemented. A ledger row is a claim with a timestamp; `verifiedAt` is what makes the claim re-askable. It also empties the dashboard warn set, so the author-side lint now says nothing about any widget key — `dashboard` stays in the lint's TYPE_COLLECTIONS all the same (the `webhook`/`email_template` resolved state) | -| query | 15 | 1 | 5 | 0 | **not a metadata type** — the REQUEST surface (`QuerySchema`: client SDK QueryBuilder output; the `POST /data/:object/query` body), governed via `SPEC_ONLY_SCHEMAS` (#4286). The gate's one-level walk resolves 1 experimental; the 7 marker-experimental search affordances sit one level deeper, below the walk — resolved from `[EXPERIMENTAL — not enforced]` describe markers, not ledger entries (search `fuzzy`/`operator`/`boost`/`minScore`/`language`/`highlight` + `aggregations[].filter` — declared engine affordances no executor receives). The #4286 sweep closed out same-release: `having` ENFORCED 2026-07-31 (engine-side post-aggregation filter, both paths; was finding 1); dead 4 = the tombstoned removals `joins`/`windowFunctions`/`cursor`/`distinct` — REMOVED 2026-07-31 (retiredKey keeps each in the walked shape so the rows stay; protocol-17 semantic migrations; the JoinNode + WindowFunctionNode clusters and the `QueryBuilder.cursor()`/`.distinct()` producers deleted with their keys; `distinct`'s mis-wired REST count suppression deleted too — finding 2). **#6815** adds the 5th dead: `aggregations[].distinct` REMOVED 2026-08-09 (live → dead, `-1` live). It is the one member of this ledger the #4286 sweep could not have caught with the question it asked — that sweep looked for keys NO executor reads, and this one had a reader: the objectql in-memory fallback deduplicated before applying the function while all five other faces (driver-sql, driver-turso, driver-mongodb, driver-memory, service-analytics' `AGGREGATE_SQL`) ignored it, so one query answered two plausible NUMBERS depending on which backend served it. The lesson for the next audit is the question, not the key: a per-key `live` verdict is only as good as the count of faces it was measured across, and this row's 2026-07-31 evidence (`in-memory-aggregation.ts:167,204-206`) was TRUE and still the wrong verdict. `count_distinct` is the surviving spelling (enforce leg, #6409) | -| datasource | 30 | 0 | 0 | 0 | seeded 2026-08-01 (#4487) — the **highest dead ratio of any governed type** (20 of 43), and it was ungoverned until now, which is not a coincidence: #4410/#4465/#4481 found six inert keys here by hand, two security-shaped (`schemaMode` left an external DB constructible as `managed` with DDL ungated; `ssl` configured nothing while looking configured). Dead set = `capabilities.*` (all 11 — the engine gates pushdown on the runtime driver's `supports.*` object, a non-overlapping vocabulary), `healthCheck.*` (3 — nothing schedules a datasource probe; the 20 `healthCheck` hits in the repo all belong to the PLUGIN health monitor and other surfaces), `retryPolicy.*` (4 — `retryPolicy` IS enforced on `hook` and `job`, which is what makes this one read alive; the shapes differ), `external.label`, `external.requirePermission`. **`capabilities.readOnly` is the one to know**: it reads as a safety switch, gates nothing, and two shipped prescriptions pointed authors at it until #4487 — `external.allowWrites: false` is the enforced write gate. `config` is a `z.record`, so its per-driver keys sit outside the walk (recorded in the entry's note, not silently skipped) **批 A CLOSED 2026-08-02 (#4583)**: the `capabilities` block — 11 flags, every one dead and authorWarn'd — was REMOVED rather than bridged; pushdown comes from the runtime driver's own `supports.*`, so there was nothing to connect it to. Its rows are deleted (strict-removal route), which is why dead falls 20 → 9. `readOnly` was the reason the audit was worth doing: it read as a safety switch, gated nothing, and had already been MOVED twice toward somewhere it might be enforced (#4410, #4465) — the shipped CRM example called a datasource a read replica on the strength of it while the datasource took writes. Removing it does NOT hand the author a working alternative: `external.allowWrites` only gates FEDERATED datasources, so a managed one has no read-only gate at all (#4584). Remaining 9 = healthCheck ×3 + retryPolicy ×4 + external ×2, batches B/C/D of #4583 **BATCHES B/C/D CLOSED 2026-08-02 — datasource now has ZERO dead properties**, down from the 20 it was seeded with (the highest dead ratio of any governed type). `retryPolicy` ×4 and `healthCheck` ×3 went as whole blocks, `external.label` / `external.requirePermission` as keys. None was bridgeable: each already had a different LIVE mechanism doing the job — the boot policy, the driver handle's on-demand `ping()`/`checkHealth()`, the top-level `label`, and ordinary permission sets + RLS. The `retryPolicy` rejection deliberately refuses to offer a rename: `hook`/`job` retryPolicy ARE enforced but spell the delay `backoffMs`, and that inconsistency is itself the evidence nothing read the datasource one (#4488's sharpest trap) | -| webhook | 11 | 0 | 0 | – | **not a registered metadata type** — governed via the gate's spec-only schema override (`SPEC_ONLY_SCHEMAS`), not `getMetadataTypeSchema`; folding it onto the registry is the #3490 reassessment. This row once read 0/1/16 ("the ENTIRE authoring surface is dead", #3461) and both halves of that were CLOSED same-quarter: #3489 built the materializer bridge (authored `webhooks:` entries now land as `sys_webhook` dispatcher rows) and #3494 pruned the aspirational props outright — so the surviving surface is fully live. Kept in the table as the worked example that a dead verdict is a worklist entry, not a tombstone: enforce-or-remove resolved this one by ENFORCING | -| app | 45 | – | 9 | – | seeded 2026-08-01 (#4488). Dead 9 = the seven #4142 `retiredKey` tombstones (version/aria/objects/apis/sharing/embed/mobileNavigation — rows stay while the tombstones hold the keys in the walked shape) + `homePageId` (#4667 tombstone — the landing IS the first nav item; root landing follows `isDefault` routing) + `areas.description` (benign, docs-shaped, kept and not warned). RETIRED 17.0.0 (#4509, rows deleted — the selector schema is strict): selector `includeAll` (deliberately DISOBEYED, not merely unread — selectors are mandatory-scope and an "All" row would clear the scope, leaking system metadata through Studio's package filter; STUDIO_APP authored it against a renderer that ignored it) and `placement` (no renderer read it; "topbar" placed nothing). Nav walk covers the union's `object` variant; other variants hand-verified live, and the `actionDef` dispatch gap closed in #4509 | **#4651**: the **fail-open area gates** `areas.visible` / `areas.requiredPermissions` — this ledger's most important app finding — are REMOVED, rows DELETED (strict removal; retained rows would report ORPHAN). They were not merely unread: `filterAppForUser` never reads `item.areas` at all and the shell renders every area, so a "hidden" or permission-gated area was served to everyone, while the identically named per-ITEM and per-APP keys ARE enforced. Route B (remove) over route A (enforce): enforcing needs semantics decided first (does filtering an area remove its items everywhere? does the server bind `user` for area CEL?), which the 17.0.0 window could not hold. Boundary unchanged and still recorded on `areas.navigation`: per-item gating inside an area is shell-side only. **#4667**: `homePageId` TOMBSTONED (row stays — retiredKey keeps it in the walked shape) and `areas.order` row DELETED (strict removal); `areas.order` read alive because the per-ITEM `order` really is sorted (NavigationRenderer.tsx:1154) while no renderer ever sorted areas. | -| book | 20 | – | 1 | – | seeded 2026-08-01 (#4488). ADR-0046 §6 spine; `audience` is ENFORCED and fail-closed (tree 401/403 + per-doc effective-audience union on both list and tree). Dead 2 = BOTH inline `translations` maps (book-level and per-group): no resolver reads them and the bundle translator doesn't cover `book` — the trap is that `doc.translations` two files over works on every read path. Also recorded: the `include: { tag }` rule variant can never match (DocSchema declares no `tags`) | **#4667**: both inline translation maps retired — book-level row DELETED (BookSchema is strictObject), group-level row KEPT as a tombstone (BookGroupSchema is a plain z.object with no .strict(), so a bare delete would have zod silently strip it). No resolver read either; the trap was proximity to `doc.translations`, which is live on every doc render path. | -| doc | 15 | – | 0 | – | seeded 2026-08-01 (#4488). Fully live: the kernel stores `content` unparsed, but the REST read layer localizes (resolveDocLocale), audience-gates, list-strips `content`, and the book resolver consumes name/label/description/order/group — plus the objectui console portal renders it all. The schema's own "docs are inert data" header describes the kernel, not the type. **`tags` DECLARED in 17.0.0 (#4509)** — the enforce half of enforce-or-remove: the book resolver's `include: { tag }` matcher, the REST transport and `ResolverDoc.tags` all already existed, but DocSchema is strict and had no `tags` key, so authoring one was a parse error and the variant could never match. Live on arrival | -| email_template | 21 | 0 | 0 | 0 | this row read 8/–/13/– for one day (seeded 2026-08-01, #4488: "every authorable property is dead", the webhook shape on AUTH mail) and #4509 CLOSED it by ENFORCING — the second worked example, after `webhook`, that a dead verdict is a worklist entry rather than a tombstone. `bootstrapDeclaredEmailTemplates` materializes declared items into the `sys_email_template` rows `sendTemplate` reads, sharing `mapTemplateToRow` with the built-in seeder so the two doors cannot drift, and re-materializes on live metadata writes (`email_template` is `allowRuntimeCreate: true`, so boot-only would have left Studio saves inert). Three breaks had to close, not one: the engine never registered `emailTemplates:` into the registry, built-in seeds masqueraded as `managed_by: admin` and outranked declared templates, and nothing materialized. ADR-0054 proof bound on `subject` (`email-template-materialization`) | -| job | 13 | 0 | 2 | 0 | seeded 2026-08-01 (#4488). The file-authored path is fully enforced: all three schedule shapes honored by the adapters, `retryPolicy`/`timeout` enforced since #3494 (this is the retryPolicy the datasource ledger warns about confusing with its dead namesake), `enabled: false` skips scheduling. Dead 3 = `id` (authorWarn — `name` is the identity everywhere) + label/description (docs-kept). The type-level gap CLOSED 2026-08-02 (#4509) by closing the door rather than bridging it: `handler` names a function in the compiled bundle's function table, which a runtime writer cannot name, so `allowRuntimeCreate` **and** `allowOrgOverride` are now false and `*.job.ts` / `defineStack({ jobs })` are the supported doors. The kind stays registered — its file loader is genuinely consumed (ADR-0088 admission test) | **#4667**: `id` REMOVED (row deleted, strict removal) — nothing read it and its own describe() ("defaults to `name` when omitted") advertised an identity override that never existed; `name` is the scheduling key, the sys_job row key and the JobExecution.jobId stamp, so two jobs differing only in `id` were one job. Remaining dead = label/description, KEPT deliberately (docs-shaped, ADR-0033). | -| mapping | 14 | – | 0 | – | seeded 2026-08-01 (#4488) at 8/11 live; **0 dead since #4509** retired the three that were not. The import half (#2611) is loudly enforced — unsupported transforms/formats are 400s, `mode`/`upsertKey` default the request, the wizard picker renders `label`. RETIRED 17.0.0: `extractQuery` (authorWarn — "for export only" promised an export path no exporter implements) + `errorPolicy`/`batchSize`, which were dead AND **unwarnable** (schema defaults materialize at parse, so presence ≠ authored — `_authorWarnSkipped`, the non-boolean instance of the default(true) rule). That unwarnability is why they went out in the 17.0.0 window rather than after a deprecation cycle: removal was the only channel that could ever reach the author. Rows DELETED, not tombstoned — MappingSchema is strict, so the keys left the walked shape | -| seed | 5 | – | 0 | – | seeded 2026-08-01 (#4488). Fully live via SeedLoaderService on both doors (boot/per-org replay + runtime-draft publish). `records` is the z.record walk boundary: the keys an author writes are the target object's fields, governed by that object's own definitions — recorded in the entry, not silently skipped | -| translation | 17 | – | 2 | – | seeded 2026-08-01 (#4488) — after fixing the walker: the registered schema is a z.preprocess pipe (#3778 retired-dialect guard) whose transform side the unwrap always took, so the type was literally unwalkable. 10 of 11 groups live across spec resolvers, REST localization, objectui client resolvers and plugin-audit (whose composed-key `t()` calls make `messages` easy to mis-verify as dead). Dead 1 = `validationMessages` (authorWarn): nothing resolves it, and #3778's own legacy-key migration table steers `errors:` authors into it — a shipped false signpost, the capabilities.readOnly shape | **#4667**: `validationMessages` REMOVED (row deleted) — removed from the shared translationDataShape(), so it retired at BOTH doors at once, closing the item-only asymmetry #3778's original guard had. #3778's own `errors` guidance was rewritten in the same change: it had been steering authors INTO this dead group. | -| qa | 4 | – | 5 | – | seeded 2026-08-10 (#6247) — **not a metadata type**: `TestSuiteSchema` is the FILE surface of the shipped `os test` command (`qa/*.test.json`), governed through the same `SPEC_ONLY_SCHEMAS` override as `query`/`webhook`/`validation`. It is in the table as the clearest worked example of a **false `dead` measurement**: #6247 reported the whole domain declared-but-inert on a grep that scanned only `*Schema` identifiers, and every consumer here reads the **type** names (`QA.TestSuite`, `QA.TestStep`, `QA.TestAction`) — so an entire execution chain (core's `TestRunner` + `HttpTestAdapter`, published via `export * as QA`, driven by a documented CLI command) read as zero consumers, and a retire ruling was issued on it before being withdrawn. The `evidenceScope` table one section up says no amount of specifier matching is sufficient for a negative claim; this is the same lesson for **identifier** matching. What was really wrong was narrower and real: the type was the contract and the schema had no `parse` site, so the CLI's `JSON.parse(content) as QA.TestSuite` cast admitted anything — ENFORCED in the same change (`TestSuiteSchema.safeParse` at the load site, pinned). Dead 5 = `name` (the file name is the suite identity; the CLI prints `path.basename`), `scenarios.name` (describe() says "for test reports"; every report carries `scenarioId` instead), `scenarios.description` (docs-shaped, kept), and the two on the enforce-or-remove worklist — `scenarios.tags` promises filtering that `os test`'s two flags cannot express, and `scenarios.requires` declares param/plugin preconditions nothing checks, so a suite naming a missing plugin runs anyway and fails as an unexplained HTTP error. Neither carries `authorWarn` and the omission is deliberate (`_authorWarnSkipped`): the lint walks stack **collections**, a QA suite is a loose file in no stack, so a warn flag here would emit nothing — a silent no-op inside the mechanism built to catch silent no-ops | -| validation | 15 | 0 | 3 | 0 | seeded 2026-08-01 (#4488). The ADR-0020 carrier: the evaluator honors active/events/priority/severity/type/condition/message (the zod header's "only reads type/condition/…" prose is STALE — trust the ledger). Dead 3 = label/description/tags, declared governance metadata, kept unmarked. Union walk boundary recorded: only base + `script` keys walked; per-variant keys are governed by the evaluator's tests, not ledger rows. **No longer a registered metadata kind** — #4509 retired it under ADR-0088 (a standalone rule had no object-binding key and every variant is `.strict()`, so it bound to nothing and gated no write; a state machine authored that way saved cleanly and did nothing). The rule VOCABULARY is untouched and fully live via `object.validations[]`, so the ledger keeps governing it through the gate's spec-only override, alongside `webhook` and `query`. The contrast with the two bridges in the same batch is the point: enforce-or-remove picked ENFORCE where the feature existed and only the wiring was missing, and REMOVE where the shape itself could not carry the feature | -| api | 25 | 0 | 0 | 2 | seeded 2026-08-04 (#5271, part of #5206; PR #5312) — **not a metadata type until that same change made it one**, which is the row's point: governance and registration landed together, the treatment `datasource` did not get (#4487) and paid for with six inert keys found by hand. What #5206 measured before the fix: `api` was in neither `DEFAULT_METADATA_TYPE_REGISTRY` nor `BUILTIN_METADATA_TYPE_SCHEMAS`, so `saveMetaItem`'s `resolveOverlaySchema('api', …)` → `getMetadataTypeSchema('api')` returned `undefined` and took its own documented branch — an unregistered type is stored **unvalidated** — while `getMetaTypes()` could not enumerate the type at all, so Studio rendered neither list nor form. That issue names the shape precisely and it is the inverse of this ledger's usual one: **enforced but undeclared** (the matcher was already indexing these entries, #5089), where `dead` is declared-but-unenforced. The seeding pass classified 27 keys — live 25 / planned 2 / dead 0 — each cited `file:line` at the consumer layer that reads it: the MATCHER (`packages/metadata/src/endpoint-matcher.ts`) indexes `name`/`path`/`method`; the EXECUTOR (`packages/runtime/src/endpoint-executor.ts`) dispatches on `type` and reads `target`/`objectParams`; the POLICY chain (`packages/runtime/src/endpoint-policy.ts` + `security/inbound-rate-limit.ts`) enforces `authRequired`/`rateLimit`/`cacheTtl`; the MAPPING layer (`packages/runtime/src/api-mapping.ts`) applies `inputMapping`/`outputMapping`; and OpenAPI enrichment (`packages/rest/src/openapi-endpoints.ts`) emits `summary`/`description`. Timing was the reason it was cheap: #5040's E-series had built every one of those consumers and all of it was on main, so each key had a real evidence path rather than a promise. **Planned 2 = `inputMapping.transform` + `outputMapping.transform`, and `planned` rather than `dead` is load-bearing**: `dead` here means parsed with no consumer — a silent no-op — and these are the opposite, parsed and then LOUDLY REFUSED at publish (`endpoint-publish-gate.ts` mappingGate) and again at runtime, because no transformation-function registry exists anywhere in the platform. An author who writes one is told so and told what to do instead, so there is nothing for enforce-or-remove to chase; they stay in the vocabulary because admitting them needs a function registry **and** a sandbox ruling (#5040 §3.4), which is a design decision, not a key to quietly delete. Zero dead | -| capability | 12 | 0 | 0 | 0 | seeded 2026-08-08 (#5961; PR #6540) — `CapabilityDeclarationSchema`, the DECLARATION side of ADR-0066 D1's three-way separation: packages DEFINE a capability, permission sets GRANT it via `systemPermissions`, resources REQUIRE it via `requiredPermissions`. **The gate's 12 and the seeding PR's 5 are the same measurement at two granularities** — PR #6540 call-graph-closed **5 authorable properties**, every one to a real reader in `packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts` (the one consumer that turns a declaration into a `sys_capability` row), all `live`, with no `PENDING_GOVERNANCE` debt recorded; the other 7 are the ADR-0010 protection-envelope keys the gate auto-classifies `live` and which carry `null` verdicts in the file, exactly as on `permission`/`position`. The same worked example as `api` above and PR #6540 says so in those words — **enforced but undeclared**, the mirror of the hole #5271 closed. What #5961 measured: absent from `DEFAULT_METADATA_TYPE_REGISTRY`, `BUILTIN_METADATA_TYPE_SCHEMAS` and `HAND_CRAFTED_SCHEMAS`, so `isRuntimeCreateAllowed()` took its no-static-entry fallback (permanently true) and `saveMetaItem` its no-schema branch — `PUT /api/v1/meta/capability/:name` accepted **arbitrary JSON** onto an authorization surface whose names `systemPermissions`/`requiredPermissions` resolve by string, while `/meta/types` synthesised a false `allowRuntimeCreate: true` descriptor Studio drew a raw-JSON create form from. #5870 did not open that path (the write gate reads the registry, not the item store); it only made the type visible in `getMetaTypes()`, and both the issue and this row say so to stop the next reader filing it as a regression. Landed as ruling A on ADR-0066 D1's own authority: `allowRuntimeCreate: false` **and** `allowOrgOverride: false`, the second self-judged inside the ruling's rationale and flagged for veto — a tenant overlay of a package declaration would lift `scope` from `org` to `platform`, which is the one field on this type that is an escalation rather than display. Its reverse verification is worth copying: deleting the registry entry gave 7 red / 3 green and measured something **sharper than predicted** — a garbage payload turned 422 rather than resolving, i.e. the schema binding is a real second line of defence behind the registry row, not a restatement of it; deleting the schema binding alone gave exactly 3 red. `packageId` is the one key that reads oddly: deliberately a FALLBACK, not the primary, since #5870 added `capabilities` to the ObjectQL stamped-collection list so `_packageId` now reaches a declaration and wins — it stays `live` because the fallback branch still decides materialization for any declaration arriving unstamped. Zero dead | +gate's numbers are what CI actually enforces, so they are what the artifact +mirrors. Three corollaries: + +- Counts are at the gate's **one-level walk granularity**. A Notes cell may + annotate finer detail — `query`'s marker-experimental search sub-keys, + `view`'s level-2 dead residue — without the counts reflecting it. +- Counts **include the ADR-0010 protection envelope** (`protection`, `_lock*`, + `_provenance`, `_packageId`, `_packageVersion`), which the gate + auto-classifies `live` on every type that spreads `MetadataProtectionFields`. + Six rows moved by 7 or 8 `live` for that reason alone during the #4001 + strictness campaign, as each schema closed and picked the envelope up. It is + a real part of the walked shape, so it is counted; it is never a Notes-cell + subject, because no author writes it. +- The Notes cell is **not** a place to restate a number. A sentence that + enumerates a dead set is a claim about *which keys*, and the day it disagrees + with the count beside it the prose is what a reader believes — see the + `translation` row for this table's own worked example of that failing. + +**Why the split, and not a check in place** (#5107's precedent, adopted here): +hand-maintained counts merge clean and wrong. Two PRs each move a different +row's number by their own correct delta, git merges the rows without complaint +because they do not overlap, and nothing in the result was ever written down by +anyone. The strictness ledger measured seven such cases in one day, and its +resolution was always the same three words — recompute from the merged tree — +so its numbers moved into an artifact the `merge=os-regen` driver owns and the +prose stayed hand-written. This table is the same shape one file over. What the +split must not lose is the re-read the hand-edit used to force: `check:liveness` +goes red on a stale artifact and its failure says so in those words. + +The generator prints a **skeleton row for any governed type with no row here**, +which is the #7257 behaviour preserved (it used to live in the python snippet +this replaces). That is the omission made visible at REGENERATION time; the gate +above makes it visible at CI time. Both were needed: `api` and `capability` +survived because the only reader who could have noticed was a human comparing +two lists by eye, and the numbers get regenerated far more often than the row +set gets audited. The skeleton stops at the type name on purpose — it prints a +marker where the Notes cell goes, never a guess at what belongs there. + +| Type | Notes | +|---|---| +| object | aspirational tier (versioning/softDelete/search/recordName/keyPrefix) + tags/active/abstract REMOVED (#2377) — tombstoned in UNKNOWN_KEY_GUIDANCE; `enable.trash`/`mru` REMOVED (#2377 close-out) — tombstoned in the now-`.strict()` ObjectCapabilities; `isSystem` + `enable.searchable` CORRECTED to live (#2377 — sharing default-model + global-search opt-out; 2026-06 audit missed both readers); `tenancy.strategy`/`crossTenantAccess` REMOVED post-15.0 (#2763) | +| field | healthy — full dead set (vectorConfig/fileAttachmentConfig/dependencies, then referenceFilters/columnName/index) REMOVED (#2377); columnName also dropped the ADR-0062 D7 lint + StorageNameMapping column helpers | +| flow | dead count = **5 tombstone entries** + the kept docs field: `active`/`template`/nodes.`outputSchema`/errorHandling.`fallbackNodeId` REMOVED 2026-07-30 (#3896 close-out sweep — `active: false` never stopped a flow, `status` is the enforced lifecycle; faults route via per-node fault edges), plus errorHandling.`retryDelayMs` RENAMED to `backoffMs` 2026-08-04 (#4964). The rename is why the dead column moved while live did not: a rename is a removal on this ledger, so the old spelling is tombstoned (`retiredKey` keeps it in the walked shape) and the new spelling enters as its own `live` row. Read it beside the four above as the one entry here that cost an author nothing — the block was a THIRD encoding of the retry policy #4661 converged, invisible to that pass because it is an anonymous inline block with no exported name, and #4964 spelled its base delay `backoffMs` to match `job.retryPolicy` and a `try_catch` node's `retry`. Remaining dead = `description`, KEPT deliberately: docs-shaped, exempt from enforce-or-remove | +| action | `type:'form'` CORRECTED to live (objectui ActionRunner.executeForm, #2377); dead `timeout` REMOVED (#2377); `disabled` live since objectui#2863; `undoable` CORRECTED to live (#3714); `shortcut` + `bulkEnabled` REMOVED 2026-07-30 (#3896 close-out sweep — no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions), and they are still the whole dead set. **#7367** (PR #7430) adds `description` as an authorable key, `live` on arrival — the only row this type has gained since that sweep | +| hook | model-healthy; label/description dead but KEPT deliberately (2026-07-30 sweep) — docs-shaped annotation fields, exempt from enforce-or-remove | +| permission | CRUD/FLS/RLS live; dead `contextVariables` REMOVED (ADR-0105 D11 — RLS resolves only the `current_user.*` built-ins plus runtime-staged `rlsMembership` sets). 2026-07-30 security-subset re-verification (all 33 entries `verifiedAt`-stamped): `rowLevelSecurity.enabled` was live-with-wrong-evidence and UNREAD — a disabled policy kept contributing its OR-branch grant; ENFORCED same day in rls-compiler (`getApplicablePolicies`), the `positions` ADR-0049 resolution repeated. `rowLevelSecurity.priority` CORRECTED to dead+authorWarn — semantically void under OR-combination (no conflict exists to order), a REMOVE candidate. `rls.label`/`description`/`tags` CORRECTED to dead (benign display, no consumer in either repo). `tabPermissions` was UNDERSTATED ("only hidden read" → the rank merge reads all four values; me-apps dogfood test exercises it). `allowExport` re-verified TRUE end-to-end (server-side 403 gate, not just the /me projection) | +| position | (role's ADR-0090 successor) fully live; all 4 `verifiedAt`-stamped 2026-07-30 | +| agent | dead `tenantId` + `planning.strategy`/`allowReplan` REMOVED (#2377); autonomy tier experimental; `knowledge` REMOVED 2026-07-30 (#3896 close-out sweep — declaring sources never scoped retrieval; AIKnowledgeSchema removed with it, the topics→sources rename absorbed pre-release) | +| tool | the inert authoring surface is now REMOVED, not merely marked: `category`/`permissions`/`active`/`builtIn` retired 2026-07-30 (#3896 close-out) after `requiresConfirmation` set the precedent (#3715, ADR-0033 §2). `permissions` promised an invocation gate nothing enforced and `active:false` withdrew nothing — false compliance, same shape as rls.enabled. The `.strict()` ToolSchema rejects each retired key with its prescription; the `tool-inert-authoring-keys-removed` conversion strips them from authored sources | +| skill | `permissions` REMOVED 2026-07 (#3704); `triggerPhrases` REMOVED 2026-07-30 (#3896 close-out sweep — phrases were never matched; activation is `triggerConditions` + the agent's `skills[]` + /skill-name pinning) | +| dataset | `measures.certified` (declared-but-unenforced governance flag) REMOVED in 16.0 (#2377) | +| page | fully live + one planned | +| view | list/form drilled via `children` (#2998 Track B); list.{responsive,performance} + form.{defaultSort,aria} REMOVED 2026-07-30 (#3896 close-out sweep — list aria/data stay live); **form.data was that sweep's one CORRECTION** — the removal attempt broke the build (`defineForm` writes `data.provider='schema'` onto every metadata form, `metadata-protocol` serves it), so it stands `live` with re-verified evidence; form.{buttons,defaults} live (framework#1894 / #2998); audit-era DEAD lines superseded by re-verification. **The dead set is six, not the four removals above**: #4534 (the last #4001 batch, batch 6e) declared three CONTAINER-level keys this row had never classified — `name` and `label`, both `dead`, and `object`, `live`. All three are properties of the `views: [...]` *container*, not of a view: `name` is dead as a BODY key because the live one is the `sys_metadata` row column the door supplies, and `label` is container display metadata with no reader. Neither is `authorWarn`'d and both are deliberately KEPT — the platform's own writers send `name` (artifact-shipped containers, the metadata-validation sweep), so tombstoning it would reject shapes we write ourselves. `object` is the container's object binding, and it was *stripped on every parse* until #4534 declared it. Separately, the level-2 dead residue (userActions.buttons, addRecord.mode/formView, tabs[].order) is noted on parents and is **not** in the counts — one drill level only | +| report | dataset-bound (ADR-0021); the aria/performance LEDGER entries were stale — the keys left the schema in the report-liveness close-out; deleted 2026-07-30 as hygiene. Audit-era `chart` DEAD superseded (framework#1890 / #3441) | +| dashboard | ADR-0021 dataset widgets (#3251; DashboardWidgetSchema `.strict()`); `aria`/`performance` (and widget `performance` + PerformanceConfigSchema) REMOVED 2026-07-30 (#3896 close-out sweep — no renderer applied any of them); audit-era `globalFilters`/`dateRange` DEAD superseded (framework#2501) **#4956**: `widgets` DRILLED — the row jumps 20 → 41 classified because all 22 widget-level keys enter the count at once. They had never been classified at all: the entry carried one blanket `live` plus a `note` asserting they were classified "in the DashboardWidgetSchema subtree", and no such subtree existed in any of the 28 ledger files. That gap, not any evidence, is what carried `widgets[].responsive` through the #3896 sweep that removed both its sibling `widgets[].performance` and its literal namesake `view.responsive` — `view` is drilled, so `list.responsive` got asked and went out. New dead 6 = `responsive` (retired #4876/#4995, tombstone keeps the row) + `colorVariant` + `actionUrl`/`actionType`/`actionIcon` + `aria`. The action trio is the sharpest: no renderer draws a per-widget action button at all (every `actionUrl` read in DashboardRenderer is scoped to `header.actions[]`), yet `validate-dashboard-action-refs.ts` enforces reference integrity on it and its docblock calls it "the per-widget button" — a lint guarding an affordance that does not exist. `requiresService` is the counter-example worth remembering: dead by every objectui measurement, and LIVE server-side (`filterDashboardForUser`, ADR-0057 D10) — judging a widget key from the renderer repo alone would have retired an enforced gate. `compareTo` is `live` on ONE path only (inline object-provider charts); on the ADR-0021 dataset path the string arms are dropped and `{ offset }` throws in the executor. **#6774** moves the row 33/8 → 34/7: `colorVariant` CORRECTED dead → live 2026-08-09, the enforce leg of #5010 ruling B landing from the renderer side (objectui#3359 / PR objectui#3799, absorbed by pin `09987b68`). Worth reading beside `requiresService` above, because it is the same lesson from the other end — that row warns against judging a widget key from the renderer repo alone, and this one is a `dead` verdict that was correct in this repo AND correct in the renderer repo on the day it was measured, and stopped being either when a cross-repo decision was implemented. A ledger row is a claim with a timestamp; `verifiedAt` is what makes the claim re-askable. It also empties the dashboard warn set, so the author-side lint now says nothing about any widget key — `dashboard` stays in the lint's TYPE_COLLECTIONS all the same (the `webhook`/`email_template` resolved state) | +| query | **not a metadata type** — the REQUEST surface (`QuerySchema`: client SDK QueryBuilder output; the `POST /data/:object/query` body), governed via `SPEC_ONLY_SCHEMAS` (#4286). The gate's one-level walk resolves 1 experimental; the 7 marker-experimental search affordances sit one level deeper, below the walk — resolved from `[EXPERIMENTAL — not enforced]` describe markers, not ledger entries (search `fuzzy`/`operator`/`boost`/`minScore`/`language`/`highlight` + `aggregations[].filter` — declared engine affordances no executor receives). The #4286 sweep closed out same-release: `having` ENFORCED 2026-07-31 (engine-side post-aggregation filter, both paths; was finding 1); dead 4 = the tombstoned removals `joins`/`windowFunctions`/`cursor`/`distinct` — REMOVED 2026-07-31 (retiredKey keeps each in the walked shape so the rows stay; protocol-17 semantic migrations; the JoinNode + WindowFunctionNode clusters and the `QueryBuilder.cursor()`/`.distinct()` producers deleted with their keys; `distinct`'s mis-wired REST count suppression deleted too — finding 2). **#6815** adds the 5th dead: `aggregations[].distinct` REMOVED 2026-08-09 (live → dead, `-1` live). It is the one member of this ledger the #4286 sweep could not have caught with the question it asked — that sweep looked for keys NO executor reads, and this one had a reader: the objectql in-memory fallback deduplicated before applying the function while all five other faces (driver-sql, driver-turso, driver-mongodb, driver-memory, service-analytics' `AGGREGATE_SQL`) ignored it, so one query answered two plausible NUMBERS depending on which backend served it. The lesson for the next audit is the question, not the key: a per-key `live` verdict is only as good as the count of faces it was measured across, and this row's 2026-07-31 evidence (`in-memory-aggregation.ts:167,204-206`) was TRUE and still the wrong verdict. `count_distinct` is the surviving spelling (enforce leg, #6409) | +| datasource | seeded 2026-08-01 (#4487) — the **highest dead ratio of any governed type** (20 of 43), and it was ungoverned until now, which is not a coincidence: #4410/#4465/#4481 found six inert keys here by hand, two security-shaped (`schemaMode` left an external DB constructible as `managed` with DDL ungated; `ssl` configured nothing while looking configured). Dead set = `capabilities.*` (all 11 — the engine gates pushdown on the runtime driver's `supports.*` object, a non-overlapping vocabulary), `healthCheck.*` (3 — nothing schedules a datasource probe; the 20 `healthCheck` hits in the repo all belong to the PLUGIN health monitor and other surfaces), `retryPolicy.*` (4 — `retryPolicy` IS enforced on `hook` and `job`, which is what makes this one read alive; the shapes differ), `external.label`, `external.requirePermission`. **`capabilities.readOnly` is the one to know**: it reads as a safety switch, gates nothing, and two shipped prescriptions pointed authors at it until #4487 — `external.allowWrites: false` is the enforced write gate. `config` is a `z.record`, so its per-driver keys sit outside the walk (recorded in the entry's note, not silently skipped) **批 A CLOSED 2026-08-02 (#4583)**: the `capabilities` block — 11 flags, every one dead and authorWarn'd — was REMOVED rather than bridged; pushdown comes from the runtime driver's own `supports.*`, so there was nothing to connect it to. Its rows are deleted (strict-removal route), which is why dead falls 20 → 9. `readOnly` was the reason the audit was worth doing: it read as a safety switch, gated nothing, and had already been MOVED twice toward somewhere it might be enforced (#4410, #4465) — the shipped CRM example called a datasource a read replica on the strength of it while the datasource took writes. Removing it does NOT hand the author a working alternative: `external.allowWrites` only gates FEDERATED datasources, so a managed one has no read-only gate at all (#4584). Remaining 9 = healthCheck ×3 + retryPolicy ×4 + external ×2, batches B/C/D of #4583 **BATCHES B/C/D CLOSED 2026-08-02 — datasource now has ZERO dead properties**, down from the 20 it was seeded with (the highest dead ratio of any governed type). `retryPolicy` ×4 and `healthCheck` ×3 went as whole blocks, `external.label` / `external.requirePermission` as keys. None was bridgeable: each already had a different LIVE mechanism doing the job — the boot policy, the driver handle's on-demand `ping()`/`checkHealth()`, the top-level `label`, and ordinary permission sets + RLS. The `retryPolicy` rejection deliberately refuses to offer a rename: `hook`/`job` retryPolicy ARE enforced but spell the delay `backoffMs`, and that inconsistency is itself the evidence nothing read the datasource one (#4488's sharpest trap) | +| webhook | **not a registered metadata type** — governed via the gate's spec-only schema override (`SPEC_ONLY_SCHEMAS`), not `getMetadataTypeSchema`; folding it onto the registry is the #3490 reassessment. This row once read 0/1/16 ("the ENTIRE authoring surface is dead", #3461) and both halves of that were CLOSED same-quarter: #3489 built the materializer bridge (authored `webhooks:` entries now land as `sys_webhook` dispatcher rows) and #3494 pruned the aspirational props outright — so the surviving surface is fully live. Kept in the table as the worked example that a dead verdict is a worklist entry, not a tombstone: enforce-or-remove resolved this one by ENFORCING | +| app | seeded 2026-08-01 (#4488). Dead 9 = the seven #4142 `retiredKey` tombstones (version/aria/objects/apis/sharing/embed/mobileNavigation — rows stay while the tombstones hold the keys in the walked shape) + `homePageId` (#4667 tombstone — the landing IS the first nav item; root landing follows `isDefault` routing) + `areas.description` (benign, docs-shaped, kept and not warned). RETIRED 17.0.0 (#4509, rows deleted — the selector schema is strict): selector `includeAll` (deliberately DISOBEYED, not merely unread — selectors are mandatory-scope and an "All" row would clear the scope, leaking system metadata through Studio's package filter; STUDIO_APP authored it against a renderer that ignored it) and `placement` (no renderer read it; "topbar" placed nothing). Nav walk covers the union's `object` variant; other variants hand-verified live, and the `actionDef` dispatch gap closed in #4509 **#4651**: the **fail-open area gates** `areas.visible` / `areas.requiredPermissions` — this ledger's most important app finding — are REMOVED, rows DELETED (strict removal; retained rows would report ORPHAN). They were not merely unread: `filterAppForUser` never reads `item.areas` at all and the shell renders every area, so a "hidden" or permission-gated area was served to everyone, while the identically named per-ITEM and per-APP keys ARE enforced. Route B (remove) over route A (enforce): enforcing needs semantics decided first (does filtering an area remove its items everywhere? does the server bind `user` for area CEL?), which the 17.0.0 window could not hold. Boundary unchanged and still recorded on `areas.navigation`: per-item gating inside an area is shell-side only. **#4667**: `homePageId` TOMBSTONED (row stays — retiredKey keeps it in the walked shape) and `areas.order` row DELETED (strict removal); `areas.order` read alive because the per-ITEM `order` really is sorted (NavigationRenderer.tsx:1154) while no renderer ever sorted areas. **The dead nine above still reconcile exactly** — nothing has left or joined that set since #4667; what moved after it are two additions on the other side of the ledger. **#4829** (PR #6942) declares `_unpublished`, `live`: a MACHINE-MANAGED publish gate, never authored, written by the AI additive-materialization path and cleared by `POST /packages/:id/publish-drafts`, and server-enforced in `filterAppForUser`. It is declared on AppSchema rather than omitted because the write path validates against that schema, so the flip itself would otherwise be unwritable — a `live` row for a key no author may set, which is the inverse of every dead row here. **#4848** (PR #7253) gives this type its **first `planned`**: `navigation.runAction`, the contract-first half of the SDUI deep-link promotion. `planned` and not `dead` is load-bearing, the same distinction `api`'s two mapping transforms draw — the reference IS validated at authoring (framework `validateCrossReferences`, the lint's nav `runAction` arm), and it carries `authorWarn` saying the shell does not read the declared slot yet; auto-run still fires only via the transitional `?runAction=` query param. Flip to `live` with a NavigationRenderer pointer when the objectui half lands. | +| book | seeded 2026-08-01 (#4488). ADR-0046 §6 spine; `audience` is ENFORCED and fail-closed (tree 401/403 + per-doc effective-audience union on both list and tree). Dead 2 = BOTH inline `translations` maps (book-level and per-group): no resolver reads them and the bundle translator doesn't cover `book` — the trap is that `doc.translations` two files over works on every read path. Also recorded: the `include: { tag }` rule variant can never match (DocSchema declares no `tags`) **#4667**: both inline translation maps retired — book-level row DELETED (BookSchema is strictObject), group-level row KEPT as a tombstone (BookGroupSchema is a plain z.object with no .strict(), so a bare delete would have zod silently strip it). No resolver read either; the trap was proximity to `doc.translations`, which is live on every doc render path. | +| doc | seeded 2026-08-01 (#4488). Fully live: the kernel stores `content` unparsed, but the REST read layer localizes (resolveDocLocale), audience-gates, list-strips `content`, and the book resolver consumes name/label/description/order/group — plus the objectui console portal renders it all. The schema's own "docs are inert data" header describes the kernel, not the type. **`tags` DECLARED in 17.0.0 (#4509)** — the enforce half of enforce-or-remove: the book resolver's `include: { tag }` matcher, the REST transport and `ResolverDoc.tags` all already existed, but DocSchema is strict and had no `tags` key, so authoring one was a parse error and the variant could never match. Live on arrival | +| email_template | this row read 8/–/13/– for one day (seeded 2026-08-01, #4488: "every authorable property is dead", the webhook shape on AUTH mail) and #4509 CLOSED it by ENFORCING — the second worked example, after `webhook`, that a dead verdict is a worklist entry rather than a tombstone. `bootstrapDeclaredEmailTemplates` materializes declared items into the `sys_email_template` rows `sendTemplate` reads, sharing `mapTemplateToRow` with the built-in seeder so the two doors cannot drift, and re-materializes on live metadata writes (`email_template` is `allowRuntimeCreate: true`, so boot-only would have left Studio saves inert). Three breaks had to close, not one: the engine never registered `emailTemplates:` into the registry, built-in seeds masqueraded as `managed_by: admin` and outranked declared templates, and nothing materialized. ADR-0054 proof bound on `subject` (`email-template-materialization`) | +| job | seeded 2026-08-01 (#4488). The file-authored path is fully enforced: all three schedule shapes honored by the adapters, `retryPolicy`/`timeout` enforced since #3494 (this is the retryPolicy the datasource ledger warns about confusing with its dead namesake), `enabled: false` skips scheduling. Dead 3 = `id` (authorWarn — `name` is the identity everywhere) + label/description (docs-kept). The type-level gap CLOSED 2026-08-02 (#4509) by closing the door rather than bridging it: `handler` names a function in the compiled bundle's function table, which a runtime writer cannot name, so `allowRuntimeCreate` **and** `allowOrgOverride` are now false and `*.job.ts` / `defineStack({ jobs })` are the supported doors. The kind stays registered — its file loader is genuinely consumed (ADR-0088 admission test) **#4667**: `id` REMOVED (row deleted, strict removal) — nothing read it and its own describe() ("defaults to `name` when omitted") advertised an identity override that never existed; `name` is the scheduling key, the sys_job row key and the JobExecution.jobId stamp, so two jobs differing only in `id` were one job. **#7131** (PR #7425) takes the remaining two: `label` and `description` re-grade `dead` → `live` under the 2026-08-10 maintainer ruling that **designer previews count as consumers** — objectui's `JobPreview` had been reading `d.label` and `d.description` and rendering them as the preview card's title and subtitle the whole time, so the old "no runtime consumer" was a true statement about the *scheduler* and a false one about the system. **This row now has zero dead and the ADR-0033 exemption is still in force**, which is worth saying out loud because it is the first row in this table where those two facts hold together: the keys are still docs-shaped, still deliberately KEPT, still not `authorWarn`'d, and enforce-or-remove still has nothing to chase here. What changed is only that the exemption no longer has to carry the verdict — the measurement does. | +| mapping | seeded 2026-08-01 (#4488) at 8/11 live; **0 dead since #4509** retired the three that were not. The import half (#2611) is loudly enforced — unsupported transforms/formats are 400s, `mode`/`upsertKey` default the request, the wizard picker renders `label`. RETIRED 17.0.0: `extractQuery` (authorWarn — "for export only" promised an export path no exporter implements) + `errorPolicy`/`batchSize`, which were dead AND **unwarnable** (schema defaults materialize at parse, so presence ≠ authored — `_authorWarnSkipped`, the non-boolean instance of the default(true) rule). That unwarnability is why they went out in the 17.0.0 window rather than after a deprecation cycle: removal was the only channel that could ever reach the author. Rows DELETED, not tombstoned — MappingSchema is strict, so the keys left the walked shape | +| seed | seeded 2026-08-01 (#4488). Fully live via SeedLoaderService on both doors (boot/per-org replay + runtime-draft publish). `records` is the z.record walk boundary: the keys an author writes are the target object's fields, governed by that object's own definitions — recorded in the entry, not silently skipped | +| translation | seeded 2026-08-01 (#4488) — after fixing the walker: the registered schema is a z.preprocess pipe (#3778 retired-dialect guard) whose transform side the unwrap always took, so the type was literally unwalkable. 10 of 11 groups live across spec resolvers, REST localization, objectui client resolvers and plugin-audit (whose composed-key `t()` calls make `messages` easy to mis-verify as dead). Dead 1 = `validationMessages` (authorWarn) at seeding: nothing resolved it, and #3778's own legacy-key migration table steered `errors:` authors into it — a shipped false signpost, the capabilities.readOnly shape. **#4667**: `validationMessages` REMOVED (row deleted) — removed from the shared translationDataShape(), so it retired at BOTH doors at once, closing the item-only asymmetry #3778's original guard had. #3778's own `errors` guidance was rewritten in the same change: it had been steering authors INTO this dead group. ⚠️ **What that left behind is this table's own worked example of the defect it warns about** (#7377): the same commit that deleted the `validationMessages` row wrote a count column of `dead 2` beside a sentence that named exactly one dead key — and that one was the key it had just removed. The real two were `name` and `label`, which the cell never mentioned. Measured at that commit, not inferred: the ledger's dead set there is `{name, label}` and `validationMessages` is absent from `props`. The number was right and the prose was false, in the same cell, on the day it was written — which is why the counts are now generated and this cell holds prose only. **#7131** (PR #7425) resolves it: `name` and `label` re-grade `dead` → `live` under the designer-previews-count-as-consumers ruling (objectui `TranslationPreview.tsx:67` reads `label` first and falls back to `name`, both rendering at `:100`), so the dead set is empty and there is no dead-set sentence left to keep true. As on `job`, the ADR-0033 docs-shaped exemption is untouched — nothing about enforce-or-remove moved. | +| qa | seeded 2026-08-10 (#6247) — **not a metadata type**: `TestSuiteSchema` is the FILE surface of the shipped `os test` command (`qa/*.test.json`), governed through the same `SPEC_ONLY_SCHEMAS` override as `query`/`webhook`/`validation`. It is in the table as the clearest worked example of a **false `dead` measurement**: #6247 reported the whole domain declared-but-inert on a grep that scanned only `*Schema` identifiers, and every consumer here reads the **type** names (`QA.TestSuite`, `QA.TestStep`, `QA.TestAction`) — so an entire execution chain (core's `TestRunner` + `HttpTestAdapter`, published via `export * as QA`, driven by a documented CLI command) read as zero consumers, and a retire ruling was issued on it before being withdrawn. The `evidenceScope` table one section up says no amount of specifier matching is sufficient for a negative claim; this is the same lesson for **identifier** matching. What was really wrong was narrower and real: the type was the contract and the schema had no `parse` site, so the CLI's `JSON.parse(content) as QA.TestSuite` cast admitted anything — ENFORCED in the same change (`TestSuiteSchema.safeParse` at the load site, pinned). Dead 5 = `name` (the file name is the suite identity; the CLI prints `path.basename`), `scenarios.name` (describe() says "for test reports"; every report carries `scenarioId` instead), `scenarios.description` (docs-shaped, kept), and the two on the enforce-or-remove worklist — `scenarios.tags` promises filtering that `os test`'s two flags cannot express, and `scenarios.requires` declares param/plugin preconditions nothing checks, so a suite naming a missing plugin runs anyway and fails as an unexplained HTTP error. Neither carries `authorWarn` and the omission is deliberate (`_authorWarnSkipped`): the lint walks stack **collections**, a QA suite is a loose file in no stack, so a warn flag here would emit nothing — a silent no-op inside the mechanism built to catch silent no-ops | +| validation | seeded 2026-08-01 (#4488). The ADR-0020 carrier: the evaluator honors active/events/priority/severity/type/condition/message (the zod header's "only reads type/condition/…" prose is STALE — trust the ledger). Dead 3 = label/description/tags, declared governance metadata, kept unmarked. Union walk boundary recorded: only base + `script` keys walked; per-variant keys are governed by the evaluator's tests, not ledger rows. **No longer a registered metadata kind** — #4509 retired it under ADR-0088 (a standalone rule had no object-binding key and every variant is `.strict()`, so it bound to nothing and gated no write; a state machine authored that way saved cleanly and did nothing). The rule VOCABULARY is untouched and fully live via `object.validations[]`, so the ledger keeps governing it through the gate's spec-only override, alongside `webhook` and `query`. The contrast with the two bridges in the same batch is the point: enforce-or-remove picked ENFORCE where the feature existed and only the wiring was missing, and REMOVE where the shape itself could not carry the feature | +| api | seeded 2026-08-04 (#5271, part of #5206; PR #5312) — **not a metadata type until that same change made it one**, which is the row's point: governance and registration landed together, the treatment `datasource` did not get (#4487) and paid for with six inert keys found by hand. What #5206 measured before the fix: `api` was in neither `DEFAULT_METADATA_TYPE_REGISTRY` nor `BUILTIN_METADATA_TYPE_SCHEMAS`, so `saveMetaItem`'s `resolveOverlaySchema('api', …)` → `getMetadataTypeSchema('api')` returned `undefined` and took its own documented branch — an unregistered type is stored **unvalidated** — while `getMetaTypes()` could not enumerate the type at all, so Studio rendered neither list nor form. That issue names the shape precisely and it is the inverse of this ledger's usual one: **enforced but undeclared** (the matcher was already indexing these entries, #5089), where `dead` is declared-but-unenforced. The seeding pass classified 27 keys — live 25 / planned 2 / dead 0 — each cited `file:line` at the consumer layer that reads it: the MATCHER (`packages/metadata/src/endpoint-matcher.ts`) indexes `name`/`path`/`method`; the EXECUTOR (`packages/runtime/src/endpoint-executor.ts`) dispatches on `type` and reads `target`/`objectParams`; the POLICY chain (`packages/runtime/src/endpoint-policy.ts` + `security/inbound-rate-limit.ts`) enforces `authRequired`/`rateLimit`/`cacheTtl`; the MAPPING layer (`packages/runtime/src/api-mapping.ts`) applies `inputMapping`/`outputMapping`; and OpenAPI enrichment (`packages/rest/src/openapi-endpoints.ts`) emits `summary`/`description`. Timing was the reason it was cheap: #5040's E-series had built every one of those consumers and all of it was on main, so each key had a real evidence path rather than a promise. **Planned 2 = `inputMapping.transform` + `outputMapping.transform`, and `planned` rather than `dead` is load-bearing**: `dead` here means parsed with no consumer — a silent no-op — and these are the opposite, parsed and then LOUDLY REFUSED at publish (`endpoint-publish-gate.ts` mappingGate) and again at runtime, because no transformation-function registry exists anywhere in the platform. An author who writes one is told so and told what to do instead, so there is nothing for enforce-or-remove to chase; they stay in the vocabulary because admitting them needs a function registry **and** a sandbox ruling (#5040 §3.4), which is a design decision, not a key to quietly delete. Zero dead | +| capability | seeded 2026-08-08 (#5961; PR #6540) — `CapabilityDeclarationSchema`, the DECLARATION side of ADR-0066 D1's three-way separation: packages DEFINE a capability, permission sets GRANT it via `systemPermissions`, resources REQUIRE it via `requiredPermissions`. **The gate's 12 and the seeding PR's 5 are the same measurement at two granularities** — PR #6540 call-graph-closed **5 authorable properties**, every one to a real reader in `packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts` (the one consumer that turns a declaration into a `sys_capability` row), all `live`, with no `PENDING_GOVERNANCE` debt recorded; the other 7 are the ADR-0010 protection-envelope keys the gate auto-classifies `live` and which carry `null` verdicts in the file, exactly as on `permission`/`position`. The same worked example as `api` above and PR #6540 says so in those words — **enforced but undeclared**, the mirror of the hole #5271 closed. What #5961 measured: absent from `DEFAULT_METADATA_TYPE_REGISTRY`, `BUILTIN_METADATA_TYPE_SCHEMAS` and `HAND_CRAFTED_SCHEMAS`, so `isRuntimeCreateAllowed()` took its no-static-entry fallback (permanently true) and `saveMetaItem` its no-schema branch — `PUT /api/v1/meta/capability/:name` accepted **arbitrary JSON** onto an authorization surface whose names `systemPermissions`/`requiredPermissions` resolve by string, while `/meta/types` synthesised a false `allowRuntimeCreate: true` descriptor Studio drew a raw-JSON create form from. #5870 did not open that path (the write gate reads the registry, not the item store); it only made the type visible in `getMetaTypes()`, and both the issue and this row say so to stop the next reader filing it as a regression. Landed as ruling A on ADR-0066 D1's own authority: `allowRuntimeCreate: false` **and** `allowOrgOverride: false`, the second self-judged inside the ruling's rationale and flagged for veto — a tenant overlay of a package declaration would lift `scope` from `org` to `platform`, which is the one field on this type that is an escalation rather than display. Its reverse verification is worth copying: deleting the registry entry gave 7 red / 3 green and measured something **sharper than predicted** — a garbage payload turned 422 rather than resolving, i.e. the schema binding is a real second line of defence behind the registry row, not a restatement of it; deleting the schema binding alone gave exactly 3 red. `packageId` is the one key that reads oddly: deliberately a FALLBACK, not the primary, since #5870 added `capabilities` to the ObjectQL stamped-collection list so `_packageId` now reaches a declaration and wins — it stays `live` because the fallback branch still decides materialization for any declaration arriving unstamped. Zero dead | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every misleading entry carries `authorWarn` so authors hear about it at compile time diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md new file mode 100644 index 0000000000..e275c3cb2d --- /dev/null +++ b/packages/spec/liveness/state-counts.md @@ -0,0 +1,60 @@ + + + +# Liveness state table — the counts (generated) + +Every number the [liveness ledger README](./README.md)'s "Current state" table +used to publish, computed by the gate that enforces them — +`scripts/liveness/check-liveness.mts --json`, `types..byStatus`, the +counting method fixed in #4488. The Notes prose, which is hand-written +measurement of how each type got where it is, stays in the README and is never +regenerated. + +Split out at #7377 on #5107's precedent. Nine of the thirty rows had drifted +from the gate by the time anyone re-ran the documented snippet, and +hand-maintained counts merge in the one way that hides: two PRs each move a +different row by their own correct delta, the rows do not overlap, git merges +them without complaint, and the result is a table nobody wrote down. The +correct resolution was always "recompute from the merged tree", so this path +carries `merge=os-regen` (#4675) and the recomputation is mandatory rather than +remembered. **Never hand-patch a number here** — fix the ledger or the schema +and regenerate. + +Counts are at the gate's one-level walk granularity and include the ADR-0010 +protection envelope, which the gate auto-classifies `live` on every type that +spreads `MetadataProtectionFields`. See the README's counting-method section +for both corollaries. + +| Type | live | exp | dead | planned | classified | +|---|---|---|---|---|---| +| `object` | 49 | 0 | 0 | 1 | 50 | +| `field` | 66 | 0 | 0 | 0 | 66 | +| `flow` | 34 | 0 | 6 | 0 | 40 | +| `action` | 42 | 0 | 2 | 0 | 44 | +| `hook` | 18 | 0 | 2 | 0 | 20 | +| `permission` | 38 | 0 | 4 | 0 | 42 | +| `position` | 12 | 0 | 0 | 0 | 12 | +| `agent` | 21 | 4 | 1 | 0 | 26 | +| `tool` | 13 | 1 | 0 | 0 | 14 | +| `skill` | 16 | 0 | 1 | 0 | 17 | +| `dataset` | 27 | 0 | 0 | 0 | 27 | +| `page` | 23 | 0 | 0 | 1 | 24 | +| `view` | 80 | 0 | 6 | 0 | 86 | +| `report` | 21 | 0 | 0 | 0 | 21 | +| `dashboard` | 34 | 0 | 7 | 0 | 41 | +| `webhook` | 19 | 0 | 0 | 0 | 19 | +| `query` | 15 | 1 | 5 | 0 | 21 | +| `datasource` | 30 | 0 | 0 | 0 | 30 | +| `app` | 46 | 0 | 9 | 1 | 56 | +| `book` | 20 | 0 | 1 | 0 | 21 | +| `doc` | 15 | 0 | 0 | 0 | 15 | +| `email_template` | 21 | 0 | 0 | 0 | 21 | +| `job` | 15 | 0 | 0 | 0 | 15 | +| `mapping` | 14 | 0 | 0 | 0 | 14 | +| `seed` | 12 | 0 | 0 | 0 | 12 | +| `translation` | 19 | 0 | 0 | 0 | 19 | +| `validation` | 15 | 0 | 3 | 0 | 18 | +| `api` | 25 | 0 | 0 | 2 | 27 | +| `capability` | 12 | 0 | 0 | 0 | 12 | +| `qa` | 4 | 0 | 5 | 0 | 9 | +| **total** | **776** | **6** | **52** | **5** | **839** | diff --git a/packages/spec/package.json b/packages/spec/package.json index 8f0035c419..25562259c4 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -212,6 +212,7 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "check:liveness": "tsx scripts/liveness/check-liveness.mts", + "gen:liveness-counts": "tsx scripts/liveness/build-state-counts.mts", "check:empty-state": "tsx scripts/liveness/check-empty-state.mts", "check:variant-docs": "tsx scripts/check-variant-docs.mts", "gen:strictness-ledger": "tsx scripts/build-strictness-ledger-counts.mts", diff --git a/packages/spec/scripts/check-generated.ts b/packages/spec/scripts/check-generated.ts index 5ee6175ca3..8fb8e63f45 100644 --- a/packages/spec/scripts/check-generated.ts +++ b/packages/spec/scripts/check-generated.ts @@ -117,6 +117,23 @@ const GATED: ReadonlyArray<{ gen: 'gen:strictness-ledger', artifact: 'docs/audits/2026-07-unknown-key-strictness-ledger.counts.md', }, + // Moved out of NO_GENERATOR at #7377, by the same precedent as its neighbour + // above and for the same measured reason: the liveness README's "Current state" + // table published its counts by hand, 9 of its 30 rows had drifted from the gate + // before anyone re-ran the documented snippet, and hand-maintained counts merge + // clean and wrong. The numbers are now an artifact this gate proves fresh; the + // Notes prose stays hand-written, so — exactly like the strictness ledger — + // `gen:` repairs only the half of what this reports that is arithmetic. The + // other half is an unclassified property, a rotted evidence pointer or a row set + // that no longer matches GOVERNED, and the failure says which. + // + // Last among the non-`ratchet` entries on the cheapest-first rule: it eagerly + // loads every Zod schema and walks all 30 governed types. + { + check: 'check:liveness', + gen: 'gen:liveness-counts', + artifact: 'liveness/state-counts.md', + }, // GATED by the definition above — it compares a checked-in artifact // (test-typecheck-debt.json) against what `tsc -p tsconfig.test.json` measures // right now, and `gen:test-typecheck-debt` is that artifact's writer. It is NOT @@ -151,7 +168,10 @@ const GATED: ReadonlyArray<{ * so a failure is a code change, not a `gen:` command. */ const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [ - { check: 'check:liveness', why: 'audits whether declared spec properties have a reader — no artifact' }, + // `check:liveness` used to sit here — "audits whether declared spec properties + // have a reader — no artifact". #7377 gave it one (the state table's NUMBERS + // became an artifact; its Notes prose stayed hand-written), so it moved to GATED + // above. The audit half is unchanged and is still the bulk of what it reports. { check: 'check:empty-state', why: 'audits empty-state coverage — no artifact' }, { check: 'check:skill-examples', why: 'validates skill examples parse — no artifact' }, // #7319. Reads `src/` and the shipped template trees and writes nothing: a diff --git a/packages/spec/scripts/liveness/build-state-counts.mts b/packages/spec/scripts/liveness/build-state-counts.mts new file mode 100644 index 0000000000..b6487b1bfa --- /dev/null +++ b/packages/spec/scripts/liveness/build-state-counts.mts @@ -0,0 +1,141 @@ +#!/usr/bin/env tsx +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Writes `packages/spec/liveness/state-counts.md` — every number the liveness + * ledger's "Current state" table used to publish by hand (#7377). + * + * The table's Notes prose merged cleanly for a year; its NUMBERS drifted from the + * gate on 9 of 30 rows and nothing could see it, because the count columns were + * hand-maintained and hand-maintained counts merge in the one way that hides. Two + * PRs each move a different row by their own correct delta, the rows do not + * overlap, git composes them without complaint, and the table publishes a state + * nobody measured. #5107 met the identical shape in the strictness ledger and + * counted seven cases in a single day; its resolution — the numbers become a + * generated artifact carrying `merge=os-regen`, the prose stays hand-written — is + * the one adopted here. + * + * ## Where the numbers come from, and why this script does not compute them + * + * From `check-liveness.mts --json`, run as a child process. That is the counting + * method the README has declared since #4488 ("the gate's own report"), and it is + * deliberately not re-implemented here: a second walker would be a second + * definition of "what is classified", and when two definitions disagree the one + * that wins is whichever the artifact happens to be rendered from. The gate is + * what CI enforces, so the gate is what gets published. + * + * Its exit code is ignored ON PURPOSE. The gate exits 1 while this very artifact + * is stale — which is the state a regeneration is run FROM — so honouring it would + * make the fix unreachable from the failure. The JSON is emitted before the + * verdict, so a non-zero exit still carries a complete report. What is never + * ignored is a crash: no parseable JSON means no write, because an artifact + * written from a half-measurement is worse than a stale one. + * + * ## The skeleton rows + * + * #7257 gave the old python snippet one behaviour a plain regenerate does not + * have: it read the README back and printed a SKELETON row for any governed type + * that had none, so the omission was visible at regeneration time and not only at + * CI time. `api` and `capability` were governed, ledgered and counted for days + * with no row, because the only reader who could have noticed was a human + * comparing two lists by eye. That behaviour moves here, unchanged in substance: + * the skeleton stops at the type name and prints a marker where the Notes cell + * goes, never a guess at what belongs there. + * + * Regeneration is WHOLESALE — this script never patches a number in place, and + * neither should you. + * + * Usage: + * tsx build-state-counts.mts # rewrite the artifact + * + * Freshness is proved by `check:liveness`, which renders the same model and + * compares bytes — deliberately not a second parser. + */ + +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + STATE_COUNTS_FILE, + STATE_COUNTS_PATH, + foldStateCounts, + parseStateTable, + renderStateCounts, +} from './readme-table.mts'; + +const here = dirname(fileURLToPath(import.meta.url)); +const specRoot = resolve(here, '../..'); // packages/spec +const ledgerRoot = join(specRoot, 'liveness'); +const gate = join(here, 'check-liveness.mts'); + +// `tsx/cli` rather than the `.bin/tsx` shim: the shim is a shell script, so it is +// not spawnable by `process.execPath` and its resolution depends on which +// node_modules/.bin happens to be on PATH. The module export is the same CLI and +// resolves through the package graph. +const tsxCli = createRequire(import.meta.url).resolve('tsx/cli'); + +const run = spawnSync(process.execPath, [tsxCli, gate, '--json'], { + cwd: specRoot, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, +}); + +// A crash is fatal; a red verdict is not. See the header — the gate is red +// precisely when this artifact needs rewriting. +let report: { types?: Record }>; readmeMissingRows?: string[] }; +try { + report = JSON.parse(run.stdout || ''); +} catch { + console.error(`✗ ${gate} --json produced no parseable report — refusing to write ${STATE_COUNTS_FILE}.`); + console.error(' Nothing is written from a half-measurement; a stale artifact is the safer state.\n'); + if (run.error) console.error(` ${run.error.message}`); + if (run.stderr) console.error(run.stderr); + process.exit(1); +} + +const types = report.types ?? {}; +// The gate reports one entry per GOVERNED type, in GOVERNED order, so the report's +// own key order IS the artifact's row order. Reading it back from the report keeps +// this script from carrying a second copy of the governed list. +const rows = foldStateCounts(Object.keys(types), Object.fromEntries( + Object.entries(types).map(([t, v]) => [t, v.byStatus ?? {}]), +)); + +const rendered = renderStateCounts(rows); +writeFileSync(join(ledgerRoot, STATE_COUNTS_FILE), rendered); + +const total = rows.reduce((a, r) => a + r.live + r.experimental + r.dead + r.planned, 0); +console.log(`✓ wrote ${STATE_COUNTS_PATH}`); +console.log(` ${rows.length} governed type(s), ${total} classified propert(ies).`); + +// ── the #7257 skeleton, preserved ── +// Prefer the gate's own reconciliation when the report carries it; fall back to a +// direct read only if an older report shape is being parsed, so the two can never +// answer differently on a report that has the field. +const readmeFile = join(ledgerRoot, 'README.md'); +const missing = report.readmeMissingRows + ?? (existsSync(readmeFile) + ? (() => { + const have = new Set(parseStateTable(readFileSync(readmeFile, 'utf8')).rows.map((r) => r.type)); + return rows.map((r) => r.type).filter((t) => !have.has(t)); + })() + : []); + +if (missing.length) { + console.log( + `\n⚠ ${missing.length} governed type(s) counted above have NO row in README.md's ` + + '"Current state" table.\n' + + ' Paste the skeleton(s) below into the table and write the Notes cell BY\n' + + ' MEASUREMENT — the seeding PR, what it measured, which keys are dead and why.\n' + + ' Never infer one from the counts or from the type\'s name.\n', + ); + for (const t of missing) { + console.log( + `| ${t} | **NO ROW YET (#7257) — write this Notes cell from the seeding PR measurement, never from a guess** |`, + ); + } + console.log('\n check:liveness will fail until every governed type has a row.'); +} diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index cb8cfbbcba..14255f29a4 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -134,8 +134,14 @@ import { import { README_ORPHAN_ROW_GUIDANCE, README_TABLE_GUIDANCE, + STATE_COUNTS_FILE, + STATE_COUNTS_GUIDANCE, + STATE_COUNTS_PATH, + foldStateCounts, parseStateTable, reconcileReadmeTable, + reconcileStateCounts, + renderStateCounts, } from './readme-table.mts'; const here = dirname(fileURLToPath(import.meta.url)); @@ -380,6 +386,9 @@ const report: any = { readmeHeadingErrors: [] as string[], // "N governed types" disagrees with the rows / with GOVERNED readmeMalformedRows: [] as string[], // a table line the row parser could not read — never silently skipped readmeRowCount: 0, // rows the parser found, printed every run so the number is visible rather than believed + countsArtifactErrors: [] as string[], // state-counts.md is missing, or its bytes are not what the gate measures (#7377) + countsRowSetErrors: [] as string[], // the README's row set and the artifact's disagree + countsHandEdited: [] as string[], // a count column is back in the README — a hand-maintained number in the merge path verification: null as VerificationReport | null, // `verifiedAt` ages — the re-verification worklist producers: null as ProducerReport | null, // `producer` / `evidenceScope` — the #4837 / #4895 worklists producerMissing: [] as string[], // a `producer` pointer into thin air — FAILS, like a rotted `evidence` @@ -606,6 +615,26 @@ if (!existsSync(readmeFile)) { report.readmeHeadingErrors = readme.headingErrors; report.readmeMalformedRows = readme.malformed; report.readmeRowCount = stateTable.rows.length; + + // ── the count columns, now a generated artifact (#7377) ── + // Read from `ledgerRoot` for the same reason the table above is: it is what + // lets the self-test point the REAL gate at a copy with one number skewed and + // read the exit code. An artifact the gate could only ever find in its own + // green state is an artifact whose check is unproven. + const countsFile = join(ledgerRoot, STATE_COUNTS_FILE); + const counts = reconcileStateCounts({ + table: stateTable, + rendered: renderStateCounts( + foldStateCounts( + GOVERNED, + Object.fromEntries(Object.entries(report.types).map(([t, v]) => [t, v.byStatus])), + ), + ), + onDisk: existsSync(countsFile) ? readFileSync(countsFile, 'utf8') : null, + }); + report.countsArtifactErrors = counts.artifactErrors; + report.countsRowSetErrors = counts.rowSetErrors; + report.countsHandEdited = counts.handCountErrors; } // ── verifiedAt: how old is each claim? ── @@ -663,7 +692,10 @@ const failed = report.readmeMissingRows.length > 0 || report.readmeOrphanRows.length > 0 || report.readmeHeadingErrors.length > 0 || - report.readmeMalformedRows.length > 0; + report.readmeMalformedRows.length > 0 || + report.countsArtifactErrors.length > 0 || + report.countsRowSetErrors.length > 0 || + report.countsHandEdited.length > 0; if (asJson) { process.stdout.write(JSON.stringify(report, null, 2) + '\n'); } else { @@ -810,6 +842,39 @@ if (asJson) { ); report.readmeMalformedRows.forEach((s: string) => console.log(` ${s}`)); } + if (report.countsArtifactErrors.length) { + console.log(`\n✗ the generated count artifact is not current:`); + report.countsArtifactErrors.forEach((s: string) => console.log(` ${s}`)); + console.log(''); + STATE_COUNTS_GUIDANCE.forEach((line) => console.log(line ? ` ${line}` : '')); + } + if (report.countsRowSetErrors.length) { + console.log( + `\n✗ ${report.countsRowSetErrors.length} row(s) where README.md and ${STATE_COUNTS_FILE} disagree:`, + ); + report.countsRowSetErrors.forEach((s: string) => console.log(` ${s}`)); + console.log( + '\n The prose and the numbers are two halves of one table (#7377). A type with\n' + + ' counts and no Note publishes a measurement nobody explained; a Note with no\n' + + ' counts publishes an explanation of nothing. Both halves, or neither.', + ); + } + if (report.countsHandEdited.length) { + console.log( + `\n✗ ${report.countsHandEdited.length} README state-table row(s) carrying a COUNT COLUMN — ` + + 'the numbers are generated:', + ); + report.countsHandEdited.forEach((s: string) => console.log(` ${s}`)); + console.log( + `\n The count columns moved to ${STATE_COUNTS_PATH} at #7377, on #5107's\n` + + ' precedent: a hand-maintained number is a number in the merge path, and these\n' + + ' merge clean and WRONG — two PRs each move a different row by their own correct\n' + + ' delta, the rows do not overlap, and git composes a table nobody wrote. A column\n' + + ' re-added here would be invisible to the freshness check above, so the table\n' + + ' would publish two sets of numbers with only one of them enforced. Delete the\n' + + ' cell; the Notes prose is what this table is for.', + ); + } // ── re-verification clock ── // Annotated at the boundary: `report` is deliberately `any` (see its // declaration), so without this every `v.*` below is `any` too — which is how @@ -908,6 +973,10 @@ if (asJson) { 'resolves, all bound high-risk proofs resolve, and the README state table carries a row ' + `for each of the ${report.readmeRowCount} governed type(s) it claims to index.`, ); + console.log( + `✓ ${STATE_COUNTS_PATH} is current — the same ${report.readmeRowCount} row(s), ` + + 'no count column left in the README.', + ); if (report.undrilledChildKeys) { console.log( ` (not a completeness claim about the ${report.undrilledChildKeys} child key(s) under the ` + diff --git a/packages/spec/scripts/liveness/check-liveness.test.ts b/packages/spec/scripts/liveness/check-liveness.test.ts index 61d25d80df..a2a881a950 100644 --- a/packages/spec/scripts/liveness/check-liveness.test.ts +++ b/packages/spec/scripts/liveness/check-liveness.test.ts @@ -207,6 +207,102 @@ describe('check:liveness — the README state table (#7257)', () => { }); }); +// The generated count artifact (#7377). Same argument as the block above and the +// same mechanism: on a green tree the artifact is current and the README carries +// no numbers, so `pnpm check:liveness` passing says nothing about whether these +// legs can fire. `--ledger-root` points the REAL gate at a copy — which `cpSync` +// carries `state-counts.md` into alongside README.md — so a case can delete the +// artifact, skew one number, or put a column back and read the real exit code. +describe('check:liveness — the generated count artifact (#7377)', () => { + let tmp: string; + + beforeAll(() => { + tmp = mkdtempSync(path.join(tmpdir(), 'os-liveness-counts-')); + }); + afterAll(() => rmSync(tmp, { recursive: true, force: true })); + + /** Copy the real ledger root (README + artifact included) and mutate one file in the copy. */ + function withCopy(name: string, edit: (root: string) => void): string { + const root = path.join(tmp, name); + cpSync(LEDGERS, root, { recursive: true }); + edit(root); + return root; + } + + it('FAILS when the artifact is gone — the numbers are published by nothing', () => { + const root = withCopy('missing', (r) => rmSync(path.join(r, 'state-counts.md'))); + + const { status, output } = runGate(root); + expect(status, output).toBe(1); + expect(output).toContain('the generated count artifact is not current'); + expect(output).toContain('is MISSING'); + expect(output).toContain('gen:liveness-counts'); + }); + + // The leg that replaces what the hand-edit used to buy. It must name the line + // that moved: "the file is stale" sends the next reader to diff 30 rows, and + // the point of the failure is the ONE row whose Note may no longer hold. + it('FAILS on a single skewed count, and names the line', () => { + const root = withCopy('skewed', (r) => { + const f = path.join(r, 'state-counts.md'); + const md = readFileSync(f, 'utf8'); + const before = md.match(/^\| `view` \| (\d+) \|/m); + expect(before, 'the view row moved — repoint this case').not.toBeNull(); + writeFileSync(f, md.replace(before![0], `| \`view\` | ${Number(before![1]) + 1} |`)); + }); + + const { status, output } = runGate(root); + expect(status, output).toBe(1); + expect(output).toContain('is STALE'); + expect(output).toContain('first difference at line'); + expect(output).toContain('`view`'); + // The half of the hand-edit worth keeping — regenerate AND re-read the Note. + expect(output).toContain('READ the diff'); + }); + + // The leg neither of the others can see: a re-added column leaves the artifact + // fresh and the row sets equal, so the table would publish two sets of numbers + // with only one of them enforced. + it('FAILS when a count column comes back into the README', () => { + const root = withCopy('hand-count', (r) => { + const f = path.join(r, 'README.md'); + writeFileSync(f, readFileSync(f, 'utf8').replace(/^\| object \| /m, '| object | 49 | – | 0 | 1 | ')); + }); + + const { status, output } = runGate(root); + expect(status, output).toBe(1); + expect(output).toContain('carrying a COUNT COLUMN'); + expect(output).toMatch(/^ {4}line \d+ \(object\)/m); + }); + + // Row-set drift between the two halves. `qa` is the most recently added row, so + // deleting it reproduces the #7257 state with the artifact still complete — + // both headings must fire, because they say different things: one that the + // index fell behind GOVERNED, one that a measurement is published with no + // explanation beside it. + it('FAILS when a type has counts and no README row', () => { + const root = withCopy('row-set', (r) => { + const f = path.join(r, 'README.md'); + writeFileSync(f, readFileSync(f, 'utf8').split('\n').filter((l) => !l.startsWith('| qa | ')).join('\n')); + }); + + const { status, output } = runGate(root); + expect(status, output).toBe(1); + expect(output).toContain('where README.md and state-counts.md disagree'); + expect(output).toContain('qa — counted in state-counts.md, no row in the README table'); + }); + + // The control for all four: the same copy, unedited, is green and says so. + // Without it every "exit 1" above is also satisfied by the copy being unusable. + it('is green against a verbatim copy, and says the artifact is current', () => { + const root = path.join(tmp, 'verbatim'); + cpSync(LEDGERS, root, { recursive: true }); + const { status, output } = runGate(root); + expect(status, output).toBe(0); + expect(output).toMatch(/state-counts\.md is current — the same \d+ row\(s\), no count column left/); + }); +}); + describe('check:liveness — the evidence summary line (#5623)', () => { let tmp: string; diff --git a/packages/spec/scripts/liveness/readme-table.mts b/packages/spec/scripts/liveness/readme-table.mts index 7515aa4583..01f99d849c 100644 --- a/packages/spec/scripts/liveness/readme-table.mts +++ b/packages/spec/scripts/liveness/readme-table.mts @@ -29,13 +29,32 @@ // re-create the original defect one layer up: this README's own verdict is that a // permanently-noisy check is a check nobody reads. // -// WHAT IT DOES NOT CHECK, deliberately: the count COLUMNS and the Notes cell. The -// counts are regenerated from `check-liveness.mts --json` (the method fixed in -// #4488) and the Notes cell is hand-written measurement — "how this type got where -// it is", the one part of the table a script cannot author. Holding the numbers to -// the gate is a separate, larger job than holding the ROW SET to `GOVERNED`, and -// conflating them would have made this check unlandable. Presence is the claim the -// heading makes; presence is what this resolves. +// WHAT IT DID NOT CHECK, and why that changed at #7377. The count COLUMNS were +// scoped out here on purpose: holding the numbers to the gate was a larger job +// than holding the ROW SET to `GOVERNED`, and conflating them would have made +// this check unlandable — 9 of 30 rows disagreed with `--json` at the time, and +// several Notes cells enumerate their own dead sets BY HAND, so regenerating the +// numbers without re-reading each Note would have left a row saying `dead 6` next +// to a sentence naming four. That is worse than the drift, because the prose is +// the part a reader believes. +// +// #7377 did that per-row reconciliation and then removed the columns from the +// README altogether, on #5107's precedent: hand-maintained counts merge CLEAN AND +// WRONG. Two PRs each move a different row by their own correct delta, the rows +// do not overlap, and git composes a table nobody wrote. So the numbers are a +// generated artifact carrying `merge=os-regen` and the README keeps the Notes +// prose — hand-written measurement, "how this type got where it is", the one part +// of the table a script cannot author. +// +// This module therefore serves two reconciliations over one parse: +// +// - `reconcileReadmeTable` — the row set against `GOVERNED` (#7257, unchanged); +// - `reconcileStateCounts` — the artifact against the gate's own report, the +// README's row set against the artifact's, and the README against a count +// column coming back (#7377). +// +// STILL NOT CHECKED, and it must stay that way: the Notes cell's CONTENT. A +// manufactured Note is worse than a missing row. /** One parsed row of the "Current state" table. */ export interface StateTableRow { @@ -43,6 +62,12 @@ export interface StateTableRow { type: string; /** 1-based line number in the README — so a failure can be opened, not hunted. */ line: number; + /** + * Every cell of the row, trimmed, first cell included. Kept so the count-column + * pin below can see a number coming back into the README without a second parse + * of the same line (#7377). + */ + cells: string[]; } /** The "Current state" section, as data. */ @@ -121,10 +146,11 @@ export function parseStateTable(markdown: string): ParsedStateTable { if (line.startsWith('## ')) break; // the section ended if (!line.startsWith('|')) continue; - const first = line.slice(1).split('|')[0].trim(); + const cells = line.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim()); + const first = cells[0]; if (first === 'Type') continue; // the header row if (SEPARATOR_CELL_RE.test(first)) continue; // the `|---|` rule - if (TYPE_CELL_RE.test(first)) { result.rows.push({ type: first, line: i + 1 }); continue; } + if (TYPE_CELL_RE.test(first)) { result.rows.push({ type: first, line: i + 1, cells }); continue; } result.malformed.push(`line ${i + 1}: ${line.slice(0, 80)}`); } @@ -225,3 +251,241 @@ export const README_ORPHAN_ROW_GUIDANCE = [ 'and it inflates the row count the heading is checked against. Either govern the', 'type (add it to GOVERNED and seed its ledger) or delete the row.', ]; + +/* ══════════════════════════════════════════════════════════════════════════ + * The count columns, as a generated artifact (#7377) + * ══════════════════════════════════════════════════════════════════════════ */ + +/** Where the generated counts live, relative to the ledger root. */ +export const STATE_COUNTS_FILE = 'state-counts.md'; + +/** Its repo-relative path, for failure messages a reader can open. */ +export const STATE_COUNTS_PATH = `packages/spec/liveness/${STATE_COUNTS_FILE}`; + +/** The one command that rewrites it. Named in every failure below. */ +export const STATE_COUNTS_GEN_COMMAND = 'pnpm --filter @objectstack/spec gen:liveness-counts'; + +/** The four status columns the table published, in the order it published them. */ +export const STATUS_COLUMNS = ['live', 'experimental', 'dead', 'planned'] as const; +export type StatusColumn = (typeof STATUS_COLUMNS)[number]; + +/** One governed type's counts, exactly as `types..byStatus` reports them. */ +export interface StateCountsRow { + type: string; + live: number; + experimental: number; + dead: number; + planned: number; +} + +/** + * Fold the gate's `types..byStatus` into one row per governed type, in + * `GOVERNED` order. + * + * A type the report does not carry becomes a row of zeroes rather than being + * skipped: a missing row would make the artifact silently shorter than + * `GOVERNED`, and "not measured" and "measured as nothing" must not render the + * same. The row-set reconciliation above is what catches the governance gap; this + * function must not also hide it. + */ +export function foldStateCounts( + governed: readonly string[], + byStatus: Readonly> | undefined>>, +): StateCountsRow[] { + return governed.map((type) => { + const b = byStatus[type] ?? {}; + return { + type, + live: b.live ?? 0, + experimental: b.experimental ?? 0, + dead: b.dead ?? 0, + planned: b.planned ?? 0, + }; + }); +} + +/** + * Render the whole artifact. The generator writes this; the gate renders it again + * and compares BYTES. + * + * Byte comparison, deliberately not a second parser — #5107's rule, and the + * reason it is a rule: two implementations of the same truth eventually disagree, + * and the one that wins is whichever the gate happens to call, which is how a + * green check ends up standing over a wrong file. Regeneration is WHOLESALE; this + * function never patches a number in place and neither should anyone. + */ +export function renderStateCounts(rows: readonly StateCountsRow[]): string { + const total = rows.reduce( + (a, r) => ({ + type: 'total', + live: a.live + r.live, + experimental: a.experimental + r.experimental, + dead: a.dead + r.dead, + planned: a.planned + r.planned, + }), + { type: 'total', live: 0, experimental: 0, dead: 0, planned: 0 }, + ); + + const body = rows.map( + (r) => `| \`${r.type}\` | ${r.live} | ${r.experimental} | ${r.dead} | ${r.planned} | ${r.live + r.experimental + r.dead + r.planned} |`, + ); + + return [ + '', + ``, + '', + '# Liveness state table — the counts (generated)', + '', + 'Every number the [liveness ledger README](./README.md)\'s "Current state" table', + 'used to publish, computed by the gate that enforces them —', + '`scripts/liveness/check-liveness.mts --json`, `types..byStatus`, the', + 'counting method fixed in #4488. The Notes prose, which is hand-written', + 'measurement of how each type got where it is, stays in the README and is never', + 'regenerated.', + '', + 'Split out at #7377 on #5107\'s precedent. Nine of the thirty rows had drifted', + 'from the gate by the time anyone re-ran the documented snippet, and', + 'hand-maintained counts merge in the one way that hides: two PRs each move a', + 'different row by their own correct delta, the rows do not overlap, git merges', + 'them without complaint, and the result is a table nobody wrote down. The', + 'correct resolution was always "recompute from the merged tree", so this path', + 'carries `merge=os-regen` (#4675) and the recomputation is mandatory rather than', + 'remembered. **Never hand-patch a number here** — fix the ledger or the schema', + 'and regenerate.', + '', + 'Counts are at the gate\'s one-level walk granularity and include the ADR-0010', + 'protection envelope, which the gate auto-classifies `live` on every type that', + 'spreads `MetadataProtectionFields`. See the README\'s counting-method section', + 'for both corollaries.', + '', + '| Type | live | exp | dead | planned | classified |', + '|---|---|---|---|---|---|', + ...body, + `| **total** | **${total.live}** | **${total.experimental}** | **${total.dead}** | **${total.planned}** | **${total.live + total.experimental + total.dead + total.planned}** |`, + '', + ].join('\n'); +} + +/** What `reconcileStateCounts` found. Separate from `ReadmeReconciliation` on purpose — one population per failure heading. */ +export interface StateCountsReconciliation { + /** The artifact is absent, or its bytes are not what the gate renders right now. */ + artifactErrors: string[]; + /** The README's row set and the artifact's disagree, in either direction. */ + rowSetErrors: string[]; + /** A count column has come back into the README — a hand-maintained number in the merge path again. */ + handCountErrors: string[]; +} + +/** A cell that is a bare count, or the `–` this table used for "none of these". */ +const COUNT_CELL_RE = /^(\d+|[–—-])$/; + +/** + * Reconcile the generated artifact against the gate, and the README against the + * artifact. + * + * Three legs, and each fails for a reason the other two cannot see: + * + * A. FRESHNESS — the artifact equals what the gate measures right now. This is + * the leg the hand-edit used to buy for free: touching a schema forced you + * back through the table to confirm the Note beside the number still held. + * It still does, and the failure says so — `gen:` then READ the diff. + * B. ROW SET — every artifact row has a README row and back. The README's rows + * are reconciled against `GOVERNED` separately (#7257) and the artifact is + * generated FROM `GOVERNED`, so in a green tree this is implied; it is + * checked anyway because "implied by two other checks" is how the heading's + * completeness claim survived unfalsifiable for a year. + * C. NO HAND COUNTS — a README row whose cells beyond the type name include a + * bare number. The whole point of the split is that a number in that file is + * back in the merge path, and a re-added column would be *invisible* to legs + * A and B: both would stay green while the table published two sets of + * numbers, which is strictly worse than the drift #7377 started from. + */ +export function reconcileStateCounts({ + table, + rendered, + onDisk, +}: { + /** The parsed README section — rows and their cells. */ + table: ParsedStateTable; + /** What `renderStateCounts` produces from the gate's report right now. */ + rendered: string; + /** The artifact's bytes, or `null` when the file does not exist. */ + onDisk: string | null; +}): StateCountsReconciliation { + const artifactErrors: string[] = []; + const rowSetErrors: string[] = []; + const handCountErrors: string[] = []; + + if (onDisk === null) { + artifactErrors.push(`${STATE_COUNTS_PATH} is MISSING — the table's numbers are published by nothing.`); + } else if (onDisk !== rendered) { + artifactErrors.push( + `${STATE_COUNTS_PATH} is STALE — it does not match what the gate measures right now.\n` + + ` ${firstStateCountsDifference(onDisk, rendered)}`, + ); + } + + // Leg B reads the artifact the gate just RENDERED, not the copy on disk: on a + // stale artifact leg A has already fired, and reconciling against a file we + // know to be wrong would report the same defect twice under two headings. + const artifactTypes = parseRenderedCountRows(rendered); + const readmeTypes = table.rows.map((r) => r.type); + const readmeSet = new Set(readmeTypes); + const artifactSet = new Set(artifactTypes); + for (const t of artifactTypes) { + if (!readmeSet.has(t)) rowSetErrors.push(`${t} — counted in ${STATE_COUNTS_FILE}, no row in the README table`); + } + for (const t of readmeTypes) { + if (!artifactSet.has(t)) rowSetErrors.push(`${t} — a README row with no counts in ${STATE_COUNTS_FILE}`); + } + + for (const row of table.rows) { + const counts = row.cells.slice(1).filter((c) => COUNT_CELL_RE.test(c)); + if (counts.length) { + handCountErrors.push( + `line ${row.line} (${row.type}) — ${counts.length} count cell(s): ${counts.join(', ')}`, + ); + } + } + + return { artifactErrors, rowSetErrors, handCountErrors }; +} + +/** The type names the rendered artifact publishes, in its own order. */ +function parseRenderedCountRows(rendered: string): string[] { + const out: string[] = []; + for (const line of rendered.split('\n')) { + const m = line.match(/^\|\s*`([a-z][a-z0-9_]*)`\s*\|/); + if (m) out.push(m[1]); + } + return out; +} + +/** The first differing line pair, as `- on disk` / `+ expected`. */ +function firstStateCountsDifference(actual: string, expected: string): string { + const a = actual.split('\n'); + const b = expected.split('\n'); + for (let i = 0; i < Math.max(a.length, b.length); i++) { + if (a[i] === b[i]) continue; + return `first difference at line ${i + 1}:\n - ${a[i] ?? '(end of file)'}\n + ${b[i] ?? '(end of file)'}`; + } + return 'the files differ but no line does — a trailing-newline difference.'; +} + +/** The prescription printed under a stale or missing artifact. */ +export const STATE_COUNTS_GUIDANCE = [ + `The count columns are GENERATED (#7377). Regenerate them, wholesale:`, + '', + ` ${STATE_COUNTS_GEN_COMMAND}`, + '', + 'Then READ the diff. A count that moved means a property entered or left the', + 'walked shape, or a ledger verdict changed — and the Notes cell beside that row', + 'in README.md may now describe a set it no longer has. That re-read is exactly', + 'what the hand-edited number used to force, and it is the half of it worth', + 'keeping: #7377 found `translation` publishing `dead 2` next to a sentence', + 'naming one key, and that key had already been removed.', + '', + '⛔ Never hand-patch a number in the artifact, and never put a count column back', + 'into the README table. Both put the numbers back in the merge path, where they', + 'merge clean and wrong (#5107).', +]; diff --git a/packages/spec/scripts/liveness/readme-table.test.ts b/packages/spec/scripts/liveness/readme-table.test.ts index 1ece6e7893..ce41aeeea9 100644 --- a/packages/spec/scripts/liveness/readme-table.test.ts +++ b/packages/spec/scripts/liveness/readme-table.test.ts @@ -14,8 +14,14 @@ import { describe, it, expect } from 'vitest'; import { README_ORPHAN_ROW_GUIDANCE, README_TABLE_GUIDANCE, + STATE_COUNTS_GEN_COMMAND, + STATE_COUNTS_GUIDANCE, + STATE_COUNTS_PATH, + foldStateCounts, parseStateTable, reconcileReadmeTable, + reconcileStateCounts, + renderStateCounts, } from './readme-table.mts'; /** A miniature README with the same section shape as the real one. */ @@ -203,3 +209,156 @@ describe('the prescriptions', () => { expect(text).toContain('delete the row'); }); }); + +/* ── the count columns as a generated artifact (#7377) ────────────────────── + * + * Same argument as the block above, one direction over: on a green tree the + * artifact is current and the README carries no numbers, so `check:liveness` + * passing proves nothing about whether these three legs can fire. That proof is + * here (the logic) and in check-liveness.test.ts (the real gate, against a + * mutated copy of the real ledger root, reaching a real `process.exit(1)`). + */ + +const COUNTS = [ + { type: 'object', live: 49, experimental: 0, dead: 0, planned: 1 }, + { type: 'field', live: 66, experimental: 0, dead: 0, planned: 0 }, + { type: 'api', live: 25, experimental: 0, dead: 0, planned: 2 }, +]; + +/** The 2-column README the split produced — prose only, no numbers. */ +function proseReadme(rows = ['| object | notes |', '| field | notes |', '| api | notes |']) { + return readme({ rows }); +} + +describe('foldStateCounts', () => { + it('reads one row per governed type, in GOVERNED order', () => { + const rows = foldStateCounts(['field', 'object'], { + object: { live: 49, planned: 1 }, + field: { live: 66 }, + }); + expect(rows.map((r) => r.type)).toEqual(['field', 'object']); + expect(rows[1]).toEqual({ type: 'object', live: 49, experimental: 0, dead: 0, planned: 1 }); + }); + + // "Not measured" and "measured as nothing" must not render the same. A skipped + // row would make the artifact silently shorter than GOVERNED, which is the + // completeness shape #7257 was filed about. + it('renders a type the report does not carry as zeroes rather than skipping it', () => { + const rows = foldStateCounts(['object', 'ghost'], { object: { live: 49 } }); + expect(rows.map((r) => r.type)).toEqual(['object', 'ghost']); + expect(rows[1]).toEqual({ type: 'ghost', live: 0, experimental: 0, dead: 0, planned: 0 }); + }); +}); + +describe('renderStateCounts', () => { + it('publishes a row per type, a classified column, and a total', () => { + const out = renderStateCounts(COUNTS); + expect(out).toContain('| `object` | 49 | 0 | 0 | 1 | 50 |'); + expect(out).toContain('| **total** | **140** | **0** | **0** | **3** | **143** |'); + }); + + it('says it is generated and names the one command that rewrites it', () => { + const out = renderStateCounts(COUNTS); + expect(out).toContain('GENERATED — DO NOT EDIT BY HAND'); + expect(out).toContain(STATE_COUNTS_GEN_COMMAND); + }); + + // The whole scheme rests on the generator and the gate rendering the same + // bytes from the same model. A renderer that varied by call would make the + // freshness check fail on a file it had itself just written. + it('is deterministic — the same model renders the same bytes', () => { + expect(renderStateCounts(COUNTS)).toBe(renderStateCounts(COUNTS)); + }); +}); + +describe('reconcileStateCounts — what it must catch', () => { + const rendered = renderStateCounts(COUNTS); + + it('is quiet when the artifact is current and the README carries prose only', () => { + const r = reconcileStateCounts({ table: parseStateTable(proseReadme()), rendered, onDisk: rendered }); + expect(r).toEqual({ artifactErrors: [], rowSetErrors: [], handCountErrors: [] }); + }); + + it('catches a MISSING artifact', () => { + const r = reconcileStateCounts({ table: parseStateTable(proseReadme()), rendered, onDisk: null }); + expect(r.artifactErrors).toHaveLength(1); + expect(r.artifactErrors[0]).toContain('MISSING'); + expect(r.artifactErrors[0]).toContain(STATE_COUNTS_PATH); + }); + + // The leg that replaces what the hand-edit used to buy: touching a schema + // forced you back through the table. A stale artifact must be loud, and it must + // point at the line that moved rather than at the file. + it('catches a SKEWED count and names the first differing line', () => { + const onDisk = rendered.replace('| `field` | 66 |', '| `field` | 67 |'); + const r = reconcileStateCounts({ table: parseStateTable(proseReadme()), rendered, onDisk }); + expect(r.artifactErrors).toHaveLength(1); + expect(r.artifactErrors[0]).toContain('STALE'); + expect(r.artifactErrors[0]).toContain('- | `field` | 67 |'); + expect(r.artifactErrors[0]).toContain('+ | `field` | 66 |'); + }); + + it('catches a type with counts and no README row', () => { + const table = parseStateTable(proseReadme(['| object | notes |', '| field | notes |'])); + const r = reconcileStateCounts({ table, rendered, onDisk: rendered }); + expect(r.rowSetErrors).toEqual([expect.stringContaining('api')]); + }); + + it('catches the mirror — a README row with no counts', () => { + const table = parseStateTable(proseReadme([...['| object | n |', '| field | n |', '| api | n |'], '| ghost | n |'])); + const r = reconcileStateCounts({ table, rendered, onDisk: rendered }); + expect(r.rowSetErrors).toEqual([expect.stringContaining('ghost')]); + }); + + // The leg neither of the other two can see. A re-added column leaves the + // artifact fresh and the row sets equal, so the table would publish two sets of + // numbers with only one of them enforced — strictly worse than the drift #7377 + // started from. + it('catches a count column coming back into the README', () => { + const table = parseStateTable(proseReadme(['| object | 49 | – | 0 | 1 | notes |', '| field | n |', '| api | n |'])); + const r = reconcileStateCounts({ table, rendered, onDisk: rendered }); + expect(r.handCountErrors).toHaveLength(1); + expect(r.handCountErrors[0]).toContain('object'); + // `–` counts: it is the spelling the old table used for "none of these", so + // treating it as prose would let half a column back in. + expect(r.handCountErrors[0]).toContain('–'); + expect(r.artifactErrors).toEqual([]); + expect(r.rowSetErrors).toEqual([]); + }); + + // A Notes cell is prose about measurements and says numbers constantly + // ("Dead 9 = the seven #4142 tombstones"). Only a cell that is NOTHING BUT a + // number is a column; anything looser would make the pin unsatisfiable. + it('stays quiet on a Notes cell that merely mentions numbers', () => { + const table = parseStateTable(proseReadme(['| object | Dead 9 = the seven #4142 tombstones + 2 | ', '| field | n |', '| api | n |'])); + const r = reconcileStateCounts({ table, rendered, onDisk: rendered }); + expect(r.handCountErrors).toEqual([]); + }); + + it('reconciles the row set against the RENDERED artifact, not the stale copy on disk', () => { + // Otherwise a stale artifact missing a row would report the same defect twice, + // under two headings, and the second one would be a lie about the row set. + const onDisk = rendered.split('\n').filter((l) => !l.startsWith('| `api` |')).join('\n'); + const r = reconcileStateCounts({ table: parseStateTable(proseReadme()), rendered, onDisk }); + expect(r.artifactErrors).toHaveLength(1); + expect(r.rowSetErrors).toEqual([]); + }); +}); + +describe('the counts prescription', () => { + it('names the generator and forbids both ways of hand-writing a number', () => { + const text = STATE_COUNTS_GUIDANCE.join('\n'); + expect(text).toContain(STATE_COUNTS_GEN_COMMAND); + expect(text).toContain('Never hand-patch a number'); + expect(text).toContain('never put a count column back'); + }); + + // The half of the hand-edit worth keeping: a moved number means a Note beside + // it may now describe a set it no longer has. If that sentence leaves, the + // regeneration becomes the mechanical rewrite #7377 refused to do. + it('tells the author to READ the diff, not just run the command', () => { + const text = STATE_COUNTS_GUIDANCE.join('\n'); + expect(text).toContain('READ the diff'); + expect(text).toContain('Notes cell'); + }); +}); diff --git a/scripts/regen-artifacts.mjs b/scripts/regen-artifacts.mjs index 6ff233f5ce..d733dbfc17 100644 --- a/scripts/regen-artifacts.mjs +++ b/scripts/regen-artifacts.mjs @@ -106,6 +106,27 @@ export const REGEN_ARTIFACTS = Object.freeze([ gen: 'gen:strictness-ledger', check: 'check:strictness-ledger', }, + // #7377. The liveness ledger's "Current state" table, split the way its + // strictness neighbour above was: the NUMBERS here, the Notes prose left in + // `packages/spec/liveness/README.md`, which is emphatically NOT driver-managed — + // a Note is hand-written measurement, and regenerating one would manufacture a + // verdict. The drift that forced the split was 9 of 30 rows disagreeing with the + // gate, several beside Notes cells that enumerate their dead sets by hand. + // + // Two things distinguish it from the entry above. Its input is not the AST but + // the LIVENESS GATE's own report (`check-liveness.mts --json`, the counting + // method fixed in #4488), spawned by the generator rather than re-implemented — + // a second walker would be a second definition of "classified", and the one that + // wins would be whichever the artifact happened to be rendered from. And its + // `check` is that same gate, so freshness is proven by the instrument that + // produces the numbers rather than by a parser reading them back. No + // `readsDist`: the gate walks `src/` Zod schemas through tsx, so a merge that + // moved sources is all it needs to be re-run against. + { + path: 'packages/spec/liveness/state-counts.md', + gen: 'gen:liveness-counts', + check: 'check:liveness', + }, ]); /**