fix(spec): shard the generated liveness and strictness counts so PRs moving different units stop conflicting on a committed total - #20532
Conversation
The generated liveness counts were one file with a row per type and a shared total row. Every PR that moved a verdict rewrote that total, and GitHub's server-side merge runs no custom driver, so any two in-flight liveness PRs conflicted on it and each landing left the others dirty. gen:liveness-counts now writes packages/spec/liveness/state-counts/<type>.md, one shard per governed type carrying only its own row, rewrites only the shards whose bytes moved, prunes strays and deletes the retired single file. No total is committed: check:liveness sums the shards at read time (success line and --json countsTotal). check:liveness reconciles each shard, a stray shard and the retired file; check:generated, the regen table and .gitattributes route the directory. WIP: tests follow in the next commit. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
state-counts-merge.test.ts builds a throwaway repository carrying the real attribute line and no merge driver (the server-side shape), commits the renderer's shards, and asks git merge-tree: two moves of different types (different deltas, equal deltas, adjacent rows) merge clean and equal the regeneration of both; a same-type pair still conflicts on that type's shard. readme-table and check-liveness tests follow the shard layout, cover a stray shard and the retired single file, and pin parity: the total the gate prints equals the sum of the shards on disk. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…ectory The strictness ledger's generated counts file had the same shape as the liveness counts: per-directory rows plus a global section and a posture total row that every schema-touching PR rewrote. Measured on the base in a driver-free probe clone, one strict site added in ui/ against two in data/ conflicted on the counts file while both sources merged clean. gen:strictness-ledger now writes docs/audits/2026-07-unknown-key-strictness-ledger.counts/<dir>.md, one shard per packages/spec/src directory with sites (triaged dirs carry their posture row, per-file sites, open files and buckets; untriaged dirs their site total). The cross-directory totals are summed at read time by check:strictness-ledger and gen:strictness-ledger and committed nowhere. The check reconciles missing, stale and stray shards and the retired single file; the ledger's links point at the shards. The text-shard read/write/reconcile helpers move to scripts/lib/sharded-artifacts.ts and serve both count artifacts, and the driver-free merge pin now covers both (count-shards-merge.test.ts). The liveness README and two ledger notes that named the retired file follow. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…ard-liveness-counts
Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…ard-liveness-counts # Conflicts: # packages/spec/liveness/state-counts.md
Main moved qa's requires row to live (the single-file table it edited is retired here, so the merge kept the deletion); the regeneration carries that move into state-counts/qa.md. Sum of the shards equals main's last committed total: 941 live, 5 experimental, 1 live-elsewhere, 147 dead, 9 planned, 1103 classified. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…ard-liveness-counts # Conflicts: # packages/spec/liveness/state-counts.md
Main moved rest_api rows to live while editing the retired single-file table; the merge kept the deletion and this regeneration carries the move into state-counts/rest_api.md. Sum of the shards equals main's last committed total: 949 live, 5 experimental, 1 live-elsewhere, 139 dead, 9 planned, 1103 classified. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check
What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Contract reviewServed-tier: Inputs read: card #20361 (body; comments ① Derived judgmentsAccept set: unchanged — right. No Public surface: the tarball layout of Gate soundness — Gate soundness — Parity — verified from git objects, not from the report. Instrument: node over Merge routing — right, on an existing precedent. Readers left behind — no functional reader remains. Whole-tree grep at the head for Scope — inside the claim's surface; no new gate. The claim admitted the strictness counts "only if a measurement shows the same shared-total shape". The dev's driver-free probe at The card's pin is delivered. ② Semver levelClause-②: no ③ Boundary flags
Residue, not blocking: (a) the strictness gate's drift legs are pinned through the shared helper and a green-tree equality, not through a spawned exit-1 on a skewed shard (same standing as the base); (b) the NOT_DRIVER_MANAGED Implemented-by: VERDICT: PASS Generated by Claude Code |
… commits that decided them (stage 4) (objectstack-ai#20548) Part of objectstack-ai#20234 Clause-②: no ## What changed This is stage 4 of the staged sweep: the `data/` remainder. It covers the six `packages/spec/src/data/` files stage 3 (PR objectstack-ai#20533, landed `03b19d9cfd`) left out because an open PR held them, and nothing else. They are `object.zod.ts`, `filter-logic-conformance.ts`, `object.form.ts`, `data-engine.zod.ts`, `data-engine.test.ts` and `hook.form.ts`. Later stages cover the other areas, so this PR says `Part of`. The census below measured all six. Three of them carry comment or docblock sites that cite a tracker number answering 404. `data-engine.zod.ts`, `data-engine.test.ts` and `hook.form.ts` carry none, so they are not in the diff. Every such site has been rewritten in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123). That is **19 sites on 19 lines in 3 files, covering 9 numbers**. Each rewritten line now cites the commit in `origin/main` history that decided what the line describes, and it says in its own words what that commit decided. Where a PR number was already on the line (`PR objectstack-ai#13529`), it stays beside the commit as the link. No ADR or ruling-record file in `docs/adr/` or `scripts/adr-anchors/` records the decision behind any of the 9 numbers: a search for each number, with and without `#`, finds nothing there. So every anchor is a commit: **9 distinct shas**. Stage 3 had already read these commits and recorded them in PR objectstack-ai#20533's body. They were not copied from there. Each one was re-read against the current line it anchors: its own message or diff names the number it replaces, and it made the change the line describes. `object.zod.ts` and `filter-logic-conformance.ts` moved on `main` after stage 3 read them (PRs objectstack-ai#20521 and objectstack-ai#20523). Each site was therefore re-read at this base, `03b19d9cfd`. Only comments changed. Every source file keeps its line count (20 lines out, 20 in, over 3 files), so no line citation into these files moves. One of the 20 lines held no dead citation: `filter-logic-conformance.ts:249`, the first half of a sentence reflowed onto `:250`. No code token moves (see the guard below). **No tracker number is added.** Every tracker number on an added line was already in the hunk it replaces. `PR objectstack-ai#13529` stands on three added lines, and on the three removed lines of the same hunks. It is the link beside commit `9dac1ae01`, which stage 3 recorded the same way. No reference page under `content/docs/references/` moved: none of the rewritten docblocks projects into one (`check:docs` at the head: `226 generated files in sync`). The PR adds one `patch` changeset for `@objectstack/spec` (see Changeset below). ## Census: the six files, before and after **Instrument.** This is the instrument of stages 1 to 3. It sends REST `GET /repos/objectstack-ai/objectstack/issues/N` without following redirects, for every distinct number cited in `packages/spec/src/data`. The population is: - the citation gate's own exported `CITATION_RE` and `NON_CITATION_HEADS`, kept when the qualifier is none, `objectstack`, `objectstack-ai/objectstack`, `framework`, `pre-` or `post-`; - widened case-insensitively to `Pre-`, `POST-` and `Framework`, as in stage 3; - N of 100 or more, excluding `summon` heads. Each site is classified by the TypeScript parser as a line comment, a docblock, a block comment or a string. Two cross-checks close the population. First, a raw `#N` count in each of the six files equals the census rows plus the cross-repo rows in five files. In the other two it is one higher, and the extra is a second number after a slash inside a string (`objectstack-ai#5322/objectstack-ai#5134` in a `note`, `objectstack-ai#6262/objectstack-ai#6433` in a test title). Both answer 200. Second, no spelled citation (`issue N`, `PR N`, `card N`) occurs in any of the six. **Controls.** The lit controls were `objectstack-ai#16862`, `objectstack-ai#16847` and `objectstack-ai#17698`. The dead controls were `objectstack-ai#16714`, `objectstack-ai#16715` and `objectstack-ai#16697`. They were probed at the start, after every 100 numbers and at the end: 24 of 24 lit (200) and 24 of 24 dead (404) over 8 checkpoints in the base run, and 21 of 21 lit and 21 of 21 dead over 7 checkpoints in the head run. | reading | tree | numbers probed | 200 | 404 | 301 or other | dead sites, all of `data/` | dead sites, the six files | lines | files | numbers | |---|---|---|---|---|---|---|---|---|---|---| | before | base `03b19d9cfd`, probed 2026-09-29T01:11:59Z to 01:15:49Z | 601 | 572 | 29 | 0 | **77** | 19 | 19 | 3 | 9 | | after | head `53c9070dfd`, probed 2026-09-29T01:25:55Z to 01:29:35Z | 597 | 572 | 25 | 0 | **58** | 0 | 0 | 0 | 0 | The head probe found no number newly dead since the base probe: the same 572 numbers answer 200. The base reading of 77 equals stage 3's after reading at `96fd49caa2`. **Per file.** Cited sites here are every in-repo citation the population reads, live or dead. | file | cited sites (base) | dead sites before | by class | dead sites after | |---|---|---|---|---| | `object.zod.ts` | 120 | 15 | 8 docblock, 7 line comment | 0 | | `filter-logic-conformance.ts` | 97 | 3 | 2 docblock, 1 line comment | 0 | | `object.form.ts` | 31 | 1 | 1 line comment | 0 | | `data-engine.zod.ts` | 48 | 0 | | 0 | | `data-engine.test.ts` | 29 | 0 | | 0 | | `hook.form.ts` | 0 | 0 | | 0 | None of the 19 sites is a string, so this stage leaves no string token behind. ## Per-number table | number | sites / lines | anchor: what it decided | |---|---|---| | `objectstack-ai#8772` | 4 / 4, `object.zod.ts:2718`, `:2731`, `:2744`, `:2910` | `75b7c240a`: Direction 2 of the 2026-08-16 maintainer ruling. `ObjectSchema.create()` forces `required: true` on a `master_detail` reference under `controlled_by_parent` and refuses an explicit `required: false`. Raw parse stays tolerant, and runtime tolerance is the ruling's other half. Its changeset records the measurement that only the security gate closed that shape while the declaration surface accepted it (`:2731`). ADR-0055 stays cited beside it. It is the same anchor stage 3 gave `object.test.ts` | | `objectstack-ai#10165` | 2 / 2, `object.zod.ts:818`, `:1036` | `801296050`: `ttl.onlyWhen` with the canonical null predicate (maintainer ruling 2026-08-20, option A). One shared `onlyWhen` union, and both of `retention.onlyWhen`'s conflicts mirrored. Its diff wrote both `[objectstack-ai#10165]` blocks | | `objectstack-ai#10347` | 3 / 3, `object.zod.ts:1006`, `:1042`, `:1049` | `530c1df65`: the Archiver honours a declared `ttl`. It selects by the ttl cutoff on `ttl.field` when `ttl` is declared, and by `created_at` / `archive.after` otherwise (maintainer ruling 2026-08-20) | | `objectstack-ai#10527` | 1 / 1, `object.zod.ts:1005` | `5649efbf9`: refuses a diverging retention + ttl + archive triple at parse time. Its diff wrote this very paragraph | | `objectstack-ai#11195` | 1 / 1, `object.zod.ts:1791` | `b37231883`: `UserActionsConfigSchema` adopts `group` / `hideFields` / `rowColor` (the "last three" the line names) | | `objectstack-ai#11408` | 1 / 1, `object.zod.ts:2189` | `f11fc61c5`: declares `editMode` on the object document (maintainer ruling 2026-08-24, the `objectstack-ai#10144` declare-or-rule-out family, which stays cited) | | `objectstack-ai#13608` | 3 / 3, `object.zod.ts:2317`, `:2354`, `:2366` | `fc9ba76a5`: `publicSharing.eligibility` is held at redemption, not only at mint, fail-closed, with the undifferentiated `null` refusal. Its changeset heads with objectstack-ai#13608. It is the same anchor stage 1 gave `contracts/share-link-service.ts` | | `objectstack-ai#13195` | 3 / 3, `filter-logic-conformance.ts:190`, `:250`, `:525` | `9dac1ae01`, PR objectstack-ai#13529's squash commit, which stays as the link: `$exists` means has-a-value on driver-memory's live mingo path, its analytics face and driver-mongodb's `translateFilter` (the "last three key-presence exits") | | `objectstack-ai#12868` | 1 / 1, `object.form.ts:256` | `c459da6bc`: narrows the per-option `default` key out of the form-view options vocabulary, which offered a key nothing on that surface read. Commit `e808890958`, which wrote this line, names objectstack-ai#12868 as the same offer-vs-door class | The shas were checked at the base and again at `origin/main` `288611e3e5`. Every one matches exactly one commit (`git rev-parse --disambiguate`, count 1). Every one is an ancestor (`git merge-base --is-ancestor`, exit 0 for 9 of 9). The control leg `e9584681a4` also exits 0, and the repository is not shallow. For each commit, a grep of its own message or diff finds the number it replaces. Seven of the nine name it in the message. `fc9ba76a5` names it in its diff (20 lines, including its changeset heading), and so does `c459da6bc` (8 lines, including its changeset heading). Wordings to check, each true of its commit: - `object.zod.ts:2731` now reads 「closes that shape, and commit 75b7c24 records that the declaration and the enforcement disagree」. The measurement was the card's. The commit's changeset records it: "only the security gate closed that shape while the declaration surface accepted it". - `object.zod.ts:2189` reads 「Declared here by commit f11fc61's maintainer ruling」, and `:2744` reads 「the other half of commit 75b7c24's ruling」. This is stage 3's wording for the same relation (`object.test.ts`, 「the other half of commit 75b7c24's ruling」): the commit that landed the ruling and quotes it. - `object.zod.ts:1049` reads 「That is the whole of what [commit 530c1df] changed here」. Commit `52db1d1f2a` wrote the paragraph. `530c1df65` is the change it describes. ## Mechanical guard: no code token moves The check compares leaf tokens with comments stripped, base `03b19d9cfd` against head `53c9070dfd`. It uses the TypeScript parser's leaf tokens (TypeScript from the head's lockfile), so template literals are scanned in context, and it excludes JSDoc nodes. It ran over all 3 touched `.ts` files. It is the stage-3 instrument, unchanged. - Real run: 13,624 base tokens (object.zod.ts 8,774, object.form.ts 3,226, filter-logic-conformance.ts 1,624), **0 files with a token change** (exit 0). - Comment-insertion control (`object.form.ts`): 0 files changed, as expected (exit 0). - Positive control (a declaration inserted into `object.zod.ts`): 1 file reads DIFFER at token 1629 (exit 1). - Positive control (one digit changed inside the `objectstack-ai#5322/objectstack-ai#5134` `note` string in `filter-logic-conformance.ts`): 1 file reads DIFFER at token 889 (exit 1). Line balance: `object.zod.ts` +15 / -15, `filter-logic-conformance.ts` +4 / -4, `object.form.ts` +1 / -1. Line counts are equal at base and head: 3,240, 621 and 751. ## Changeset This change ships bytes, so a `patch` changeset for `@objectstack/spec` is included. It says only that the provenance comments were re-anchored. `Clause-②: no`: no export, key, value or type moves (the guard above). Measured on the head's built package: `object.zod.ts` is `src/**/*.zod.ts`, which `files[]` ships verbatim. The rewritten comments also reach `dist`: - `9dac1ae01` appears in `dist/data/index.d.ts` (the `filter-logic-conformance.ts` docblock) and in 4 bundled `.js` files; - `fc9ba76a5`, `f11fc61c5` and `b37231883` each appear in 22 bundled `.js` files, and `c459da6bc` in 12; - the positive control, the pre-existing `object.zod.ts` sentence 「Fail-CLOSED at both points」, appears in 11 bundled `.js` files. ## Gates (head `53c9070dfd`) - **Citation judging pass, run as CI runs it:** `pnpm check:issue-citations && node scripts/check-issue-citations.mjs` exits 0. The self-test passes 73 cases in 7 batteries. The live run judged 6 citations across 3 files: 3 resolve (`objectstack-ai#9138` twice, `objectstack-ai#11410`) and 3 resolve as a pull request (`objectstack-ai#13529`, the link). - **Doc authoring:** `pnpm check:doc-authoring` exits 0. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at the head derived 79 families, and all 79 exit 0. `--ran` reports 79 run, 0 NOT MEASURED, 0 unrun, and exits 0. A full `turbo run build` of `./packages/*` ran first, under the shared verify lock: 71 of 71 tasks, VERDICT command-exit 0. So no gate met an unbuilt prerequisite. - `pnpm --filter @objectstack/spec run check:generated`: under the lock against that build, `All 15 generated artifacts are up to date`, VERDICT command-exit 0. - **Tests and typecheck:** - `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/data` under the lock: Test Files 107 passed (107), Tests 3527 passed, 1 todo (3528), VERDICT command-exit 0. It covers every test in `data/`, among them `object.test.ts`, which reads these schemas. - The 13 spec suites outside `src/data` that read the touched files' source text or pin their line numbers, under the lock: Test Files 13 passed (13), Tests 544 passed (544). They are stage 3's 12 (`scripts/{file-description,root-index,skill-map-guards,strictness-ledger}.test.ts`, `src/api/api-entry-graph.pin.test.ts`, `src/contracts/scoped-context.test.ts`, `src/shared/{alias-integrity,evaluated-slot-population,retired-key-migrate-sentence}.test.ts`, `src/system/constants/platform-object-names.test.ts`, `src/type-alias-convention.pin.test.ts`, `src/ui/dashboard.test.ts`) plus `src/shared/union-author-message-pins.test.ts`, which pins `data/object.zod.ts:855`. - `pnpm --filter @objectstack/spec typecheck` under the lock exits 0, including `check:test-typecheck` (53 files, 251 errors, 138 pinned signatures held). - **Lint, as a proven narrowing at the head:** `eslint --no-inline-config --format json` over the 3 touched `.ts` files gives 3 files, 0 errors and 0 warnings. All 3 are in eslint's own population (`isPathIgnored` is false for each). `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, which its own line 328 states), so a comment edit here cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's run. ## Acceptance notes - **Base.** The branch forked from `03b19d9cfd`, stage 3's landing. `origin/main` then moved two commits (`05077d4c26`, PR objectstack-ai#20532, and `288611e3e5`, PR objectstack-ai#20536), and neither touches `data/`. `dispatch-gates` flagged its derivation as stale because `scripts/regen-artifacts.mjs` had moved, so `origin/main` was merged in (`53c9070dfd`, a clean merge with no driver-deferred path) before the gates ran. The PR's delta against `origin/main` is exactly its 4 files. `origin/main` has since moved two more commits: `7e36a3cd7c` (PR objectstack-ai#20531) and `ba5927f714` (PR objectstack-ai#20460). Neither touches `data/` or anything the gate derivation reads, and a re-derivation prints the same 79 commands. A no-driver `merge-tree` of the head onto `ba5927f714`, from a bare shared clone, exits 0. So there is no second merge. - **Open PRs, re-read at 2026-09-29T02:01Z:** 9 open PRs, and none touches any of the six files. The `data/` files open PRs touch are objectstack-ai#20458's `analytics*` files, objectstack-ai#20504's `driver/turso.*`, and objectstack-ai#20545's `filter-number-comparand-declared-type.*`, which is disjoint. Since the claim, PR objectstack-ai#20460 has landed (`ba5927f714`) without touching `filter-subtree-provenance.ts`. That file's 3 dead sites are outside this claim's fence, so they are left for a later stage. - **The rung.** Two anchored changes also have ADR-0087 entries in `packages/spec/src/migrations`: `cbp-master-detail-required-forced` for objectstack-ai#8772, and `form-view-option-default-retired` for objectstack-ai#12868. The second entry's own header names commit `c459da6bc`. This PR takes the commit rung, as stages 1 to 3 did. The D3 id is the more durable in-repo record, if the ruling's first rung is later read to include those entries. - **What stays in `data/` after this stage: 58 dead sites.** - **12 comment sites in files other open work still holds.** `analytics.zod.ts`, `analytics-strictness-batchd.test.ts` and `analytics-date-range-two-bound-window.test.ts` hold 5 (objectstack-ai#20300, PR objectstack-ai#20458). `driver/turso.zod.ts` and `driver/turso.test.ts` hold 4 (objectstack-ai#20437, PR objectstack-ai#20504). `filter-subtree-provenance.ts` holds 3. It was held by objectstack-ai#20367 and is now free (see above). - **3 comment sites stage 3 left on purpose.** They are the test-read `[objectstack-ai#6259]` marker at `api-derivation.ts:163`, the test comment at `api-derivation.test.ts:232` that names it, and `field.zod.ts:370`, whose `objectstack-ai#6111` is objectui's number. - **43 string sites**, left as tokens: 41 test strings (2 of them in the held analytics and turso test files) and the 2 exported `AGGREGATION_CASES` note strings in `aggregation-conformance.ts` (`:398`, `:407`, objectstack-ai#11065), which objectstack-ai#20489's claim holds. - **Outside `data/`,** the card's other remaining items are unchanged: the migrations and ui areas, the `liveness/**` notes, the `why` strings, the `PROVENANCE_WAIVERS` reason, and `rest-server.zod.ts`. - **The citation gate's reach.** It defers `packages/**/*.test.ts`. No test file is touched here, so all 3 touched files are in its judging population. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…Ids derived, so two retirements merge clean (objectstack-ai#20572) Fixes objectstack-ai#20535 Clause-②: no Every major-18 retirement appended to two tails of `step18` in `packages/spec/src/migrations/registry.ts`: the `+`-chained `rationale` (by rewriting its closing line) and the `conversionIds` list. So any two retirement PRs in flight conflicted in GitHub's driver-free merge. This PR reshapes both tails so that two retirements no longer touch the same line. Nothing a consumer reads changes: the values are byte-identical. ## What changed - **`rationale` is now `STEP18_RATIONALE`.** It holds 46 fragments of the form `{ id, order, text }`, one per retirement. The list is kept **sorted by `id`**, and the rationale renders by `order` (ties broken by `id`), joined with one space (`joinRationale`). Of the 46 keys, 40 are the retirement's own D3 semantic entry id. The other 6 are kebab-case names for retirements with no entry of their own: `compliance-deadline-keys-retired`, `cron-positions-deleted`, `duration-keys-unit-in-key`, `element-filter-retired`, `element-form-retired`, `page-component-filter-record-to-rule-array`. The fragments keep the original literal source bytes; only the 45 boundary spaces moved into the join. - **`conversionIds` is derived:** the ids of `CONVERSIONS_BY_MAJOR[18]`, in its order. It was a value-identical copy of that list (same 45 ids, same order), so a retirement now adds its conversion in one place only. This adds a second value import to `registry.ts`. It creates no cycle: `conversions/registry.ts` imports nothing from `migrations/`, and it was already in the migrations barrel's graph through `chain.ts`. - The header note and the import comment now describe step 18's shape. `MigrationStep`'s type is unchanged, and no reader of `rationale` changed. ## Why sorted, and not the plain array the triage sketched (mechanism measured, then the route changed) Git reports a conflict whenever two branches insert into the **same gap** between unchanged lines, whatever they insert. A list appended at its end is a single gap, so a plain array conflicts exactly as the old tail did. I measured this on a toy file and again on the real file (the pin's end-append control below): exit 1. With the list kept sorted by key, two retirements insert into different gaps and merge clean. One existing fragment between them is enough, the same property `.gitattributes` records for the sorted generated tables. Two keys that land in the same gap still conflict. That residue is pinned as a lit control. ## Rendered-text proof (byte-identical) | value | parent `6154165484` | head | |---|---|---| | `MIGRATIONS_BY_MAJOR[18].rationale` | 48,953 chars, sha256 `797afbe924eef185…75828e10` | identical | | `MIGRATIONS_BY_MAJOR[18].conversionIds` | 45 ids, sha256 `55d56175bf7c109c…` | identical | | chain hop 17 → 18 `rationale` (what `migrate meta --step` prints) | `797afbe924eef185…` | identical | | the whole `MIGRATIONS_BY_MAJOR` value as JSON | `d989a2b827fd7f93…` | identical | | built `dist/index.js` + `dist/browser/index.js` (CJS) and `dist/index.mjs` (ESM) | n/a | load; `797afbe9…` / 45 ids `55d56175…` | No committed artifact embeds step 18's rationale: the upgrade guide prints majors up to `PROTOCOL_MAJOR` (17). `check:upgrade-guide`, `check:spec-changes` and `check:migration-registry` are green. A closure check on the built bundles: all four bundles that carry step 18 (`dist/index.{js,mjs}` and `dist/browser/index.{js,mjs}`) already carried the conversions registry. The marker was `page-kind-jsx-to-html`, which no other non-test `src` module contains. So the new import widens no entry's closure. ## Merge measurement: the card's instrument A one-shot run on the **parent** `6154165484`, with git 2.43.0, in a scratch repo holding the real file with no attributes and no driver. Each side makes the edit a retirement PR makes: | pair | `git merge-tree --write-tree` | |---|---| | two rationale-tail rewrites (closing line rewritten, sentence appended) | **exit 1**, CONFLICT (content) | | two `conversionIds` tail appends | **exit 1**, CONFLICT (content) | | both edits on each side | **exit 1**, CONFLICT (content) | The **permanent pin** is `packages/spec/scripts/step18-rationale-merge.test.ts`, in the repo project beside `count-shards-merge.test.ts` (PR objectstack-ai#20532), and it works against the REAL file. It asserts: - The premise: fragments are strictly sorted and kebab-case; the step renders them by `order`, joined with one space (so this compares the join, not the list); and `conversionIds` is the derived expression. - The card's reproduction, now clean: two retirement-shaped insertions one existing fragment apart, both taking the same next `order` → **exit 0**. The merged bytes equal both insertions applied together, and the two render last, in key order. - Lit controls, all **exit 1** with conflicted path `registry.ts`: a same-gap pair; the same two fragments appended at the list's END; and the old `+`-chain tail rewrite (a synthetic model of the parent shape). The one open PR on this file, PR objectstack-ai#20504 (a step-18 semantic entry in a generated region), merges clean with this head: bare shared-clone probe with no driver, `merge-tree` exit 0. ## How a retirement adds its sentence once this lands Add ONE element to `STEP18_RATIONALE`: - `id` is the retirement's D3 semantic entry id. - Insert it where that `id` sorts, **never at the end**. - `order` is one more than the highest present. Two PRs in flight may take the same number; they then render in `id` order. - `text` has no leading or trailing space. Add the D2 conversion to `CONVERSIONS_BY_MAJOR[18]` only. A branch cut before this lands meets the change once, on its next base merge: its appended sentence becomes one new fragment, and its `conversionIds` line is dropped. ## Tests and gates (final commit `bcb255881a`; `registry.ts` blob `2f010628be9a` unchanged since `2e6251af0c`) - `@objectstack/spec` `local` project: 574 files, 16,879 passed, 1 todo (exit 0). `repo` project: 41 files, 725 passed (exit 0). Both ran through `os-verify-lock`, `--maxWorkers=2`, on a shared box. - `pnpm --filter @objectstack/spec typecheck`: exit 0 (includes `check:scripts-typecheck` and `check:test-typecheck`). `check:generated`: 15 of 15 artifacts up to date, measured against the `dist` built at this head. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, reconciled with `--ran` (exit codes recorded): 88 derived, **84 exit 0**, 4 NOT MEASURED, 0 unrun. - NOT MEASURED (exit 3, `PREREQUISITE NOT MET`: they need the whole-repo build closure, which CI builds): - `check:doc-formula-expressions`: needs `@objectstack/formula` and `@objectstack/lint` built. - `check:dual-build-cjs-loads`: needs all packages built. Narrowed reading: spec's own built CJS entries load (table above). - `check:lean-entry-closure`: needs `@objectstack/objectql` built. - `check:type-check-debt`: needs the whole-repo build closure. Spec's own typecheck is green. - Declared to CI: `packages/cli/test/migrate-meta-default-range.test.ts`. It spawns the CLI (integration tier, and this diff touches no CLI file), it passes no `--step`, and it reads the spec values proven identical above. Repo-level `pnpm lint` is CI-owned. - **Ablations** (one-shot; each through `scripts/ablation-replace.mjs` with the anchor proven to hit, and restored to the HEAD blob with `git diff HEAD` empty): 1. Deleting the `order` sort in `joinRationale` (render by position): the pin went **red**, 1 failed / 8 passed, on the render-order assertion. 2. Renaming the first key `action-aria-retired` to `zz-action-aria-retired`: **red**, 3 failed / 6 passed. The sortedness assertion failed, plus the two that depend on a sorted list. The first attempt was a no-op the tool refused, because the anchor also matched the D3 entry of the same id and nothing was written. It was re-run with a longer anchor. ## Acceptance notes - **Governed wording to route to the skills lane (not edited here):** `.claude/skills/spec-property-retirement/SKILL.md:215-216` reads 「把 id 加进 `MIGRATIONS_BY_MAJOR[N].conversionIds`,扩写该步的 `rationale`。」. For N = 18 that becomes: add a `STEP18_RATIONALE` fragment at its sorted position, and add the conversion only to `CONVERSIONS_BY_MAJOR[18]`. Lines 217-219 (a misspelled step id is silently skipped at replay) no longer apply to step 18, whose ids are derived. - **Same-family residue outside this card's file surface:** `packages/spec/src/conversions/registry.ts` has the same tail. Every retirement with a D2 conversion appends to `CONVERSIONS_BY_MAJOR[18]` (and usually defines its conversion just above the previous last one). Two synthetic appends to that tail, on parent `6154165484`: `merge-tree` **exit 1**, CONFLICT (content). So after this lands, such PRs still conflict in that file. Only the migrations-registry half is removed here. The order of that list is application order, so the shape there is its own decision. Reported to the seat, not filed. - **Choice surfaced for review:** I derived `conversionIds` instead of giving it the keyed fragment treatment. A keyed copy would keep a second hand-kept order, which can drift from the loader's: step 17's copy names the same 57 ids in a different order from index 21 on. It would also let two concurrent conversions tie-break by key instead of by the author's chosen application order. - The sortedness check is an assertion in the new repo-project test, not a `check:*` gate. It is what makes an end-append fail loudly instead of quietly bringing the conflict back. - A side effect, not claimed as a goal: step 18's rationale was one `+` chain of 614 literals, and its longest chain is now 28. Step 17's 970-literal chain (the `eslint.config.mjs` stack-size note) is untouched. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20361
Clause-②: no
What changed
gen:liveness-countswritespackages/spec/liveness/state-counts/TYPE.md, one shard per governed type (40). Each shard carries only its own row.packages/spec/liveness/state-counts.mdis deleted. No total is committed anywhere.check:livenesssums the shards at read time, prints the sum on its success line, and carries it in--jsonascountsTotal. The generator prints the sum too.gen:strictness-ledgerwritesdocs/audits/2026-07-unknown-key-strictness-ledger.counts/DIR.md, one shard perpackages/spec/srcdirectory with object sites (5 triaged, 9 untriaged). The global section, the posture total row and the global bucket split are summed at read time bycheck:strictness-ledgerandgen:strictness-ledger, and committed nowhere. The single….counts.mdis deleted.check:script was added. The text-shard read/write/reconcile helpers live inpackages/spec/scripts/lib/sharded-artifacts.tsand serve both artifacts..gitattributes,scripts/regen-artifacts.mjsandpackages/spec/scripts/check-generated.tsroute the two directories (…/**withmerge=os-regen), so the local driver still owns a same-unit collision.connectorNotes cell that named it), the_noteofliveness/book.jsonand ofliveness/translation.json, and seven links in the strictness ledger. Those links now point at the shards; the per-directory anchors (#ui--openand the rest) keep working inside each shard.Measured before the change
Dispatch base
2b24b8b823. Probe: a bare--sharedclone with no merge driver registered (git config --get merge.os-regen.driverexits 1). Each side was regenerated with the real generator and committed, thengit merge-tree --write-tree --name-onlywas run on the pair.field.useGroupingplanned→dead vssharing_rule.typeplanned→livestate-counts.md. The two rows are 36 lines apart; the only overlap is the total row.field.useGroupingplanned→dead vssharing_rule.typeplanned→dead (equal delta)dead 149 / planned 8; the two moves make 150 / 7.ui/vs 2 added indata/….counts.md. Both.zod.tssources merged clean.Measured after the change
The same probe on branch head
1be2134dc1, with the real generators.check:livenessexits 0 on it, and its read-time total is150 dead · 7 planned, which is correct.fieldstate-counts/field.mdonly. This residue is expected and the local driver owns it.ui/vsdata/ui/….counts/ui.md(plus the shared source file)The pin is
packages/spec/scripts/count-shards-merge.test.ts. It builds a throwaway repository with the real attribute line and no driver, commits each real renderer's output, and asksgit merge-tree:Parity
qamove and itsrest_apimove. At mainfb386074f5both sides read 949/5/1/139/9/1103, and every per-type row is byte-equal to main's last single-file table.….counts.md. The only rows dropped are the cross-directory ones:The gate's read-time sums print exactly those numbers.
check-liveness.test.tsasserts that the printed total equals the sum of the shards on disk, read back row by row.strictness-ledger-doc.test.tsasserts that the read-time totals equal the sums of the shard rows on disk.Verification
The full union ran at
ac64401fec. That commit is the head after the first main merge that met the retired file, plus its regeneration.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 93 families, and I ran each one with its exit code captured before any pipe.--ranreconciliation: 93 derived, 91 run, 2 NOT-MEASURED, 0 UNRUN.check:liveness,check:strictness-ledger,check:generated(15/15 up to date),check:merge-driver,check:cross-package-test-inputs,check:nul-bytes,check:adr-0087-registration --base origin/main,check:api-surfaceandcheck:pm-dispatch-gates(1976/1976).check:platform-checklist. It reports one ✗, the anchorpackages/plugins/plugin-auth/src/auth-plugin.ts#twoFactorindocs/qa/platform-checklist/areas/identity-auth.json(ABSENT SYMBOL). The same single ✗ appears on origin/mainfb194c70e5and4a1df19656, and on the dispatch base2b24b8b823. This diff does not touch it.check:dual-build-cjs-loadsandcheck:type-check-debt. Both exit 3 (PREREQUISITE NOT MET) because they need a whole-workspace build. They are declared to CI.vitest run --project local --maxWorkers=2, then--project repo): local 573 files, 16820 passed and 1 todo; repo 40 files, 709 passed.pnpm --filter @objectstack/spec typecheck: tsc, the scripts program and the test program): exit 0.tsc -p tsconfig.scripts.json --listFilesincludes all 10 changed script and test files.eslint --no-inline-config --format jsonover the 13 changed.ts/.mts/.mjsfiles: 13 files linted, 0 errors, 0 warnings.--print-configresolves a config for each of the 13 files.eslint.config.mjsnever enables type-aware linting (noparserOptions.project, no typed rules), so this diff cannot move the verdict on any untouched file. The repo-widepnpm lintis CI's.scripts/ablation-replace.mjs. Each run exited 1 with exactly one drift, named RETIRED, STRAY and STALE respectively. Restoration was proven by blob equals HEAD,git diff HEADempty andgit status --porcelainempty. The liveness legs are pinned permanently incheck-liveness.test.ts(missing, skewed, retired, row-set, hand-count, and the verbatim-copy control).Re-run at the final head
0190e0e55f, after the second main merge (main'srest_apimove) and its regeneration:check:liveness,check:strictness-ledger,check:generated(after a fresh spec build, 15/15),check:merge-driver,check:nul-bytes,check:cross-package-test-inputs;A driver-free
merge-treeof0190e0e55fagainst origin/main0368a336dbexits 0.Acceptance notes
state-counts.mdor….counts.mdmeets a modify/delete on its next base merge. The settlement is to keep the deletion (git rm) and run the generator. Both gates refuse the retired file while it is present, and both generators delete it. This branch met exactly that twice, with main'sqaandrest_apimoves, and settled it that way.connectorNotes cell named the retired file, so this branch updates it. That cell sits directly above theanalytics_cuberow, which PR 20458 edits. A driver-freemerge-treeof this head against that PR's headf639af5f3dconflicts onpackages/spec/liveness/README.mdonly. Whichever of the two lands second keeps both lines. The file is hand-written and not driver-managed.scripts/pm/dispatch-gates.mjsdocblock namesliveness/state-counts.mdinside a measurement pinned atad54eb342.packages/spec/CHANGELOG.mdand earlier.changesetentries are release-owned. The strictness ledger's merge-queue narrative (it sayscounts.mdwas recorded as pending) is past tense.@objectstack/specgets a patch changeset.npm pack --dry-runmeasured 40liveness/state-counts/*files shipped,liveness/state-counts.mdabsent (control:liveness/README.mdandliveness/book.jsonpresent), and nothing underdocs/auditsshipped.step18.rationaletail inpackages/spec/src/migrations/registry.ts. Two synthetic appends at the dispatch base still conflict driver-free. 16 of 101 registry-touching first-parent commits in the 14 days to2b24b8b823rewrote that closing line.Generated by Claude Code