Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/liveness-state-counts-generated.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
148 changes: 88 additions & 60 deletions packages/spec/liveness/README.md

Large diffs are not rendered by default.

60 changes: 60 additions & 0 deletions packages/spec/liveness/state-counts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<!-- GENERATED — DO NOT EDIT BY HAND. -->
<!-- Regenerate: pnpm --filter @objectstack/spec gen:liveness-counts -->

# 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.<type>.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** |
1 change: 1 addition & 0 deletions packages/spec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 21 additions & 1 deletion packages/spec/scripts/check-generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
141 changes: 141 additions & 0 deletions packages/spec/scripts/liveness/build-state-counts.mts
Original file line number Diff line number Diff line change
@@ -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<string, { byStatus?: Record<string, number> }>; 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.');
}
Loading
Loading