Skip to content

fix(spec): shard the generated liveness and strictness counts so PRs moving different units stop conflicting on a committed total - #20532

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-20361-shard-liveness-counts
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-20361-shard-liveness-counts

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20361
Clause-②: no

What changed

  • Liveness counts are sharded. gen:liveness-counts writes packages/spec/liveness/state-counts/TYPE.md, one shard per governed type (40). Each shard carries only its own row. packages/spec/liveness/state-counts.md is deleted. No total is committed anywhere. check:liveness sums the shards at read time, prints the sum on its success line, and carries it in --json as countsTotal. The generator prints the sum too.
  • Strictness-ledger counts are sharded the same way, because a measurement showed the same shared-total shape (see below). gen:strictness-ledger writes docs/audits/2026-07-unknown-key-strictness-ledger.counts/DIR.md, one shard per packages/spec/src directory with object sites (5 triaged, 9 untriaged). The global section, the posture total row and the global bucket split are summed at read time by check:strictness-ledger and gen:strictness-ledger, and committed nowhere. The single ….counts.md is deleted.
  • Both gates reconcile shard by shard. They name a MISSING directory or shard, a STALE shard (with the shard's path and its first differing line), a STRAY file that no unit renders, and the RETIRED single file if a merge brings it back. Both generators rewrite only the shards whose bytes moved, prune strays, and delete the retired file. These are new legs inside the two existing gates; ⛔ no new gate or check: script was added. The text-shard read/write/reconcile helpers live in packages/spec/scripts/lib/sharded-artifacts.ts and serve both artifacts.
  • Routing. .gitattributes, scripts/regen-artifacts.mjs and packages/spec/scripts/check-generated.ts route the two directories (…/** with merge=os-regen), so the local driver still owns a same-unit collision.
  • Readers of the retired names. Updated: the liveness README (the two paragraphs that linked the file, and the connector Notes cell that named it), the _note of liveness/book.json and of liveness/translation.json, and seven links in the strictness ledger. Those links now point at the shards; the per-directory anchors (#ui--open and the rest) keep working inside each shard.

Measured before the change

Dispatch base 2b24b8b823. Probe: a bare --shared clone with no merge driver registered (git config --get merge.os-regen.driver exits 1). Each side was regenerated with the real generator and committed, then git merge-tree --write-tree --name-only was run on the pair.

pair result
liveness: field.useGrouping planned→dead vs sharing_rule.type planned→live exit 1, CONFLICT (content) in state-counts.md. The two rows are 36 lines apart; the only overlap is the total row.
liveness: field.useGrouping planned→dead vs sharing_rule.type planned→dead (equal delta) exit 0 and WRONG. The merged total reads dead 149 / planned 8; the two moves make 150 / 7.
strictness: 1 strict site added in ui/ vs 2 added in data/ exit 1, CONFLICT (content) in ….counts.md. Both .zod.ts sources merged clean.

Measured after the change

The same probe on branch head 1be2134dc1, with the real generators.

pair result
liveness: planned→dead vs planned→live (the conflicting pair above) exit 0. Each side touched only its own shard.
liveness: equal delta (the clean-but-wrong pair above) exit 0. The merged tree was materialised: check:liveness exits 0 on it, and its read-time total is 150 dead · 7 planned, which is correct.
liveness: two moves of field exit 1, conflict on state-counts/field.md only. This residue is expected and the local driver owns it.
strictness: ui/ vs data/ exit 0
strictness: two moves in ui/ exit 1 on ….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 asks git merge-tree:

  • pairs that move different units (different deltas, equal deltas, adjacent liveness rows, adjacent untriaged directories) merge clean, and the merged tree equals the regeneration of both moves;
  • a same-unit pair conflicts on that unit's shard and nowhere else. This is the lit control.

Parity

  • Liveness. The sum of the 40 shards equals the total the single file committed: 940/5/1/148/9/1103 at the dispatch base. This branch then merged main twice, taking in main's qa move and its rest_api move. At main fb386074f5 both sides read 949/5/1/139/9/1103, and every per-type row is byte-equal to main's last single-file table.
  • Strictness. All 96 per-directory rows are byte-equal to main's ….counts.md. The only rows dropped are the cross-directory ones:
    • the posture total 461/331/4/1/125;
    • the global buckets 1/0/120/3/0/1;
    • the global measures: 5 dirs, 461 sites, 125 strip, 22 files.
      The gate's read-time sums print exactly those numbers.
  • Pinned. check-liveness.test.ts asserts that the printed total equals the sum of the shards on disk, read back row by row. strictness-ledger-doc.test.ts asserts 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.

  • Gates. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 93 families, and I ran each one with its exit code captured before any pipe. --ran reconciliation: 93 derived, 91 run, 2 NOT-MEASURED, 0 UNRUN.
    • 90 exit 0. This includes 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-surface and check:pm-dispatch-gates (1976/1976).
    • 1 red, already red on main: check:platform-checklist. It reports one ✗, the anchor packages/plugins/plugin-auth/src/auth-plugin.ts#twoFactor in docs/qa/platform-checklist/areas/identity-auth.json (ABSENT SYMBOL). The same single ✗ appears on origin/main fb194c70e5 and 4a1df19656, and on the dispatch base 2b24b8b823. This diff does not touch it.
    • NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt. Both exit 3 (PREREQUISITE NOT MET) because they need a whole-workspace build. They are declared to CI.
  • Spec tests (vitest run --project local --maxWorkers=2, then --project repo): local 573 files, 16820 passed and 1 todo; repo 40 files, 709 passed.
  • Spec typecheck (pnpm --filter @objectstack/spec typecheck: tsc, the scripts program and the test program): exit 0. tsc -p tsconfig.scripts.json --listFiles includes all 10 changed script and test files.
  • Narrowed lint. eslint --no-inline-config --format json over the 13 changed .ts/.mts/.mjs files: 13 files linted, 0 errors, 0 warnings. --print-config resolves a config for each of the 13 files. eslint.config.mjs never enables type-aware linting (no parserOptions.project, no typed rules), so this diff cannot move the verdict on any untouched file. The repo-wide pnpm lint is CI's.
  • One-shot reverse verification of the strictness gate's new legs. I put the retired file back, dropped in a stray shard, and skewed one shard's posture row through 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 HEAD empty and git status --porcelain empty. The liveness legs are pinned permanently in check-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's rest_api move) and its regeneration:

  • the ratchet family, all exit 0: 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;
  • the liveness and strictness tests: local 11 files, 271 passed; repo 4 files, 80 passed.

A driver-free merge-tree of 0190e0e55f against origin/main 0368a336db exits 0.

Acceptance notes

  • The one-time transition for other in-flight PRs. A PR that edits state-counts.md or ….counts.md meets 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's qa and rest_api moves, and settled it that way.
  • An adjacent-line README overlap with PR 20458. The connector Notes cell named the retired file, so this branch updates it. That cell sits directly above the analytics_cube row, which PR 20458 edits. A driver-free merge-tree of this head against that PR's head f639af5f3d conflicts on packages/spec/liveness/README.md only. Whichever of the two lands second keeps both lines. The file is hand-written and not driver-managed.
  • Historical prose left alone (carrier: none). The scripts/pm/dispatch-gates.mjs docblock names liveness/state-counts.md inside a measurement pinned at ad54eb342. packages/spec/CHANGELOG.md and earlier .changeset entries are release-owned. The strictness ledger's merge-queue narrative (it says counts.md was recorded as pending) is past tense.
  • AGENTS.md is still accurate. Its sentence that the three hottest artifacts are sharded stays true: five directories are sharded now. It is governed and is not edited here.
  • Changeset. @objectstack/spec gets a patch changeset. npm pack --dry-run measured 40 liveness/state-counts/* files shipped, liveness/state-counts.md absent (control: liveness/README.md and liveness/book.json present), and nothing under docs/audits shipped.
  • A second hot spot, reported to the seat and not changed here: the hand-written step18.rationale tail in packages/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 to 2b24b8b823 rewrote that closing line.

Generated by Claude Code

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

# 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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️ 45 changed file(s) yielded no anchor (packages/spec/liveness/README.md, packages/spec/liveness/book.json, packages/spec/liveness/state-counts.md, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)).

What this run could not see
  • 45 changed file(s) yielded no anchor (packages/spec/liveness/README.md, packages/spec/liveness/book.json, packages/spec/liveness/state-counts.md, …) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0368a336dbd6935385b442503b043d937357f4df → packageMentionDocs.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0190e0e55fa1a1838420545d0265b5b260da8d6a
Local-runs: none

Inputs read: card #20361 (body; comments 5862528282 triage, 5864175448 second hot spot, 5877297491 claim, 5881200824 os-dev-report), PR #20532 (body, its one bot comment, 76-file list, net diff of the head against merge base fb386074f5, read from git objects in the shared checkout via refs/os-seat2/pr20532), and the 32 check-runs on the head, read once at 2026-09-29T00:22Z. origin/main has moved three commits past the merge base (b43a814d49, 0368a336db, 1378ec7c0c); none touches packages/spec or docs/audits, so the shards at the head are still what the merged tree renders.

① Derived judgments

Accept set: unchanged — right. No .zod.ts, no src/ schema, no export, no authorable key and no conversion/migration entry is in the diff. The 76 files are 54 generated shards, 2 retired single files, 2 hand-written ledgers/README prose, 2 ledger _note strings, 12 scripts/tests/config rows, 1 changeset and .gitattributes. Type Check · source gates (success) ran check:authorable-surface, check:spec-changes, check:upgrade-guide, check:export-origins, check:docs and check:generated --reconcile-only on this head with nothing to report.

Public surface: the tarball layout of liveness/ moves — right, and declared. @objectstack/spec files[] ships liveness; docs/audits is not shipped. The tarball loses liveness/state-counts.md, gains forty liveness/state-counts/TYPE.md shards, and the prose in liveness/README.md, liveness/book.json and liveness/translation.json that named the removed file is repointed. The changeset names all of that (see ②).

Gate soundness — check:liveness at the head. reconcileStateCounts now takes the shard directory (readTextShardDir) plus a legacyOnDisk flag and routes through the shared reconcileTextShardDir. Read off the code, the legs are: MISSING directory; MISSING shard (a GOVERNED type with no file); STALE shard, naming the shard and its first differing line; STRAY file or subdirectory (a type that left GOVERNED, or a hand-written file); RETIRED single file present; row set README-vs-shards in both directions (leg B, reads the rendered shards, not disk); count column back in the README (leg C, unchanged). The total is summed at read time from the same fold the shards are rendered from (sumStateCounts), printed on the success line and carried in --json as countsTotal; no file carries a total row. Pins that assert the FAILURE through the real gate (check-liveness.test.ts, spawning the gate against a mutated copy via --ledger-root, each expecting exit 1): directory gone; one skewed count in state-counts/view.md (message names the shard and the line); the retired state-counts.md put back beside the shards; a count column back in the README; a type with counts and no README row; and the verbatim control at exit 0 whose printed total is re-summed by the test from the shard rows on disk. A single missing shard and a stray shard/subdirectory are pinned at unit level on reconcileStateCounts itself (readme-table.test.ts: MISSING names state-counts/api.md, STRAY names ghost.md and nested/), not through a spawned exit code; the artifactErrors-to-exit-1 path those legs share is the one the four spawned cases exercise. The locality claim (a shard carries exactly its own row, no total, no sibling name) is asserted on the rendered bytes. Judged sound: every drift the single file used to catch is still a red, and each is asserted as a failure, not merely as an absent assertion.

Gate soundness — check:strictness-ledger at the head. Leg A is now reconcileTextShardDir over renderCountShards: MISSING directory, MISSING shard, STALE shard with first differing lines, STRAY, and RETIRED ….counts.md present; leg B (prose consistent with code) is unchanged. The global section, posture total row and global bucket split are summed at read time (formatGlobalCounts) and printed by both gen: and check:. Pins: strictness-ledger-doc.test.ts asserts the on-disk shard map equals a fresh render and nothing else is there, asserts the retired file is absent, asserts each shard names only its own directory, asserts the read-time posture totals and untriaged total equal the sums of the shard rows read back from disk, and asserts no heading carries a number. What is NOT pinned as an exit-code assertion is the strictness gate's own failure path on drift: no test spawns check-strictness-ledger.mts against a skewed copy (none did at the merge base either, so this is the base's standing, not a regression); the dev's RETIRED/STRAY/STALE exit-1 readings were one-shot. The shared helper's legs are pinned through the liveness unit cases above. Judged sound, with that boundary named.

Parity — verified from git objects, not from the report. Instrument: node over git show fb386074f5:packages/spec/liveness/state-counts.md, git ls-tree 0190e0e55f packages/spec/liveness/state-counts/ and git show 0190e0e55f:PATH per shard (same for the strictness file and its 14 shards). Liveness: 40 shards for the 40 rows the retired file carried; every shard's one table row is byte-equal to a line of the retired file (40 of 40); no shard carries a total row; the sum of the shards is 949 live · 5 experimental · 1 live-elsewhere · 139 dead · 9 planned = 1103 classified, equal to the retired file's committed **total** row at the merge base. Strictness: 14 shards (5 triaged, 9 untriaged); all 129 table rows across them appear verbatim in the retired file; the posture rows sum to 461 / 331 / 4 / 1 / 125, equal to the retired posture **total** row; the untriaged site rows sum to 1200, equal to the retired untriaged table; the per-directory bucket rows sum to authorable 1 · unresolved 0 · wire/open 120 · no door 3 · no gate 0 · covered 1, equal to the retired global bucket table. Spec property liveness (success on this head) is the CI reading that both gates find the committed shards fresh.

Merge routing — right, on an existing precedent. .gitattributes rows packages/spec/liveness/state-counts/** and docs/audits/2026-07-unknown-key-strictness-ledger.counts/** have the same shape as the routed packages/spec/authorable-surface/**, json-schema.manifest/**, api-surface/**, export-origins/**, declaration-map/** and content/docs/references/** rows. scripts/regen-artifacts.mjs entryForPath resolves a trailing /** by prefix (pathMatches), so the driver owns a shard path; check:merge-driver's self-test reconciles the two lists in both directions and pins entryForPath against git check-attr over every tracked file. scripts/pm/os-regen-merge.sh reads the merge=os-regen rows from .gitattributes at run time and hands them to git as pathspecs, partitioning per FILE (its own header says the hot patterns are directory globs over dozens of shards), so the new rows are handled by the machinery that already handles the sharded artifacts. The NOT_DRIVER_MANAGED docs/audits/** row coexists with the specific routed row exactly as it did with the single .counts.md (the self-test's overlap check is exact-string). check:merge-driver on this head: NOT concluded at read time — it runs inside Lint & Repo Gates, which was in_progress. The dev's exit 0 at the final head is the dev's reading, not CI's.

Readers left behind — no functional reader remains. Whole-tree grep at the head for state-counts.md and strictness-ledger.counts.md: hits are (a) the retirement leg by design — LEGACY_STATE_COUNTS_FILE, LEGACY_COUNTS_PATH, the RETIRED test case; (b) historical, not readers — nine earlier .changeset/*.md entries, eight lines in the release-owned packages/spec/CHANGELOG.md, the strictness ledger's past-tense merge-queue narrative at line 1329, the count-shards-merge.test.ts header, and the scripts/pm/dispatch-gates.mjs docblock at lines 6178 and 6260 (a measurement pinned at ad54eb342, inside a comment). No workflow, no scripts/pm/** path-to-family rule, no .claude/** skill, no skills/** entry and no content/docs page names either file; the only path rule in that family is spec-liveness-check.yml's docs/audits/** and packages/spec/** triggers, which the shard directories still satisfy. One stale prose string, not a reader: the NOT_DRIVER_MANAGED docs/audits/** row's why in scripts/regen-artifacts.mjs still says the one exception is the ledger's .counts.md — worth a one-word correction on a later pass, carrier: none.

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 2b24b8b823 (one strict site in ui/ against two in data/, CONFLICT on the counts file while both sources merged clean) is a local reading; I re-measured the shape from history instead of trusting it: the retired file carries a ## Global section and a posture **total** row, and of the last twelve main commits that touched it, six (75b2169243, 6a4aec71d5, 4db1bf1715, ccccdcc35e, adabccf5fb, e233db9dbb) each moved ONE directory's rows and rewrote both the Global lines and the posture total line — the same shared-total shape the card measured on the liveness file. The condition held; the strictness half is inside the claim. No new gate: packages/spec/package.json and the root package.json gain no check:*/gen:* script; no workflow or job is added; check-generated.ts's GATED roster is unchanged in count (two artifact strings edited); the one new file under scripts/ is a vitest test registered in the existing repo project (vitest.repo-tests.json). The migrations-registry step18.rationale tail (5864175448) is untouched, as the claim required, and reported as an out-of-scope finding with its own measurement.

The card's pin is delivered. count-shards-merge.test.ts builds a throwaway repository carrying the real attribute row and NO driver, commits each real renderer's output, and asks git merge-tree --write-tree: different-type pairs (different deltas, the equal-delta pair that merged clean and WRONG before, adjacent rows, two untriaged directories) merge clean and the merged tree equals the regeneration of both moves; the lit control (same-unit pair) still conflicts, on that unit's shard and nowhere else.

② Semver level

Clause-②: no
The changeset .changeset/20361-liveness-counts-sharded.md is @objectstack/spec: patch carrying Clause-②: no with no arm, and the PR body carries the same line. That is right: nothing widens or narrows an accept set, no export moves, no authorable key is removed, so no ADR-0087 disposition is owed. What publishes is a tarball layout change inside shipped ledger data, and the changeset states the FROM-to-TO for a reader of the old file (a type's row is now liveness/state-counts/TYPE.md, byte-for-byte; the total is summed by check:liveness and printed, and was 940/5/1/148/9/1103 at the dispatch base). Precedent agrees: the five earlier changesets that regenerated state-counts.md (14640, 18582, 19187, 20296, agent-tools-liveness-row-dead) are all patch, and the file's own introduction (#7377) landed under 17.0.0's Patch Changes. docs/audits is not in files[], so the strictness half publishes nothing. Check Changeset is success on this head.

③ Boundary flags

open_questions: the report lists none. Dev deviations, each answered:

  1. Strictness sharded in the same PR. Answered above under Scope: the measurement condition held (re-measured from history, not only from the probe), so it is inside the claim's conditional bullet. Accepted.
  2. The connector Notes cell edit in packages/spec/liveness/README.md, one line above the analytics_cube row PR feat(spec)!: retire the inner name on cube measures and dimensions — the record key is the member's name (#20300) #20458 edits. The edit is NEEDED: the cell said the 29/1/30 split is "read from the generated state-counts.md row" and that 30 is "the dead count the generated state-counts.md row carries", and after this diff no such file exists in the package, so the sentence would ship false. The overlap is an ordinary same-file text conflict: feat(spec)!: retire the inner name on cube measures and dimensions — the record key is the member's name (#20300) #20458's only README hunk is the analytics_cube line directly below, git conflicts on adjacent edits, liveness/README.md is hand-written and not in .gitattributes, so nobody hand-resolves a generated file; whichever lands second keeps both lines. feat(spec)!: retire the inner name on cube measures and dimensions — the record key is the member's name (#20300) #20458 touches no state-counts path (its liveness files are README.md and analytics_cube.json), so it meets no modify/delete. Keep the edit.
  3. Full 93-family union at ac64401fec, ratchet family only at 0190e0e55f. Between the two heads the branch-side change is one line in state-counts/rest_api.md (the regeneration after merging main's rest_api move); the rest is main's own content. The check-runs on the final head are the readings that count. At read time (2026-09-29T00:22Z): 13 concluded success (Spec property liveness, Type Check · source gates, Check Changeset, Governed Surface Queue Guard, Check Documentation Links, Check PR Size, Auto Label, filter, Flag docs affected by code changes, both No other open PR may claim… guards, Part-of PR must not also close its card, The card this PR closes must claim this branch), 3 skipped by path filter or opt-in (Build Docs, Console Pin Gate, Packed-tarball smoke), and 16 NOT concluded: Lint & Repo Gates (carries pnpm lint, check:merge-driver, check:cross-package-test-inputs, check:nul-bytes, check:pm-dispatch-gates), Type Check · workspace (the turbo typecheck that compiles the spec scripts and test programs holding the 10 changed script/test files), Type Check · debt ledger, Type Check · consumer gates, Test Core 1–6 (turbo run test test:repo, which is where count-shards-merge.test.ts, check-liveness.test.ts, readme-table.test.ts and strictness-ledger-doc.test.ts run), Build Core, Dogfood Regression Gate 1–3, Dogfood Verify CLI, Temporal Conformance. So the families with no CI answer yet on this head are check:merge-driver, check:cross-package-test-inputs, lint, the scripts/test typecheck program and the vitest suites; the two gates this diff rewires (check:liveness, check:strictness-ledger) and the artifact/changeset gates ARE answered green. No concluded run failed, so there is nothing to attribute to this diff. This verdict is a contract verdict and does not vouch for the 16 unconcluded runs; the landing waits for them under the every-check-green rule, which is the adopting seat's to read.
  4. One check:pm-dispatch-gates flake from committing mid-run, rerun 1976/1976. Not a property of the diff. Noted.
  5. --no-verify on throwaway synthetic commits in a scratch worktree, never pushed. Nothing of it is in the diff; the branch's own eight commits are hook-checked. Noted.
  6. Attribution. All six branch commits carry Claude-Session: plus Co-authored-by: Claude with no model identifier; the PR body carries the single session-URL footer. Conforms.

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 docs/audits/** why string still names .counts.md. Neither changes what the gates catch.

Implemented-by: claude/issue-20361-shard-liveness-counts
Reviewed-by: session_014EJ1ED8X4MMrT18BhVx4tx

VERDICT: PASS


Generated by Claude Code

veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
… 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>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants