Skip to content

refactor(spec): step 18 rationale as key-sorted fragments, conversionIds derived, so two retirements merge clean - #20572

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20535-step18-rationale-per-line
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20535-step18-rationale-per-line

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #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 #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 #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

…ersionIds derived

Every major-18 retirement appended to two tails of `step18` in
migrations/registry.ts: the `+`-chained rationale (rewriting its closing
line) and the `conversionIds` list. Two retirements in flight therefore
conflicted in GitHub's driver-free merge. A plain array appended at its end
does not fix that: git conflicts on any two insertions into the same gap.

The rationale is now 46 fragments `{ id, order, text }` kept SORTED BY KEY,
so two retirements insert at different lines, and rendered by `order`
(joined with one space). `conversionIds` is read off
`CONVERSIONS_BY_MAJOR[18]`, of which it was a value-identical copy.

The rendered rationale, the conversion ids, the chain hop and the whole
MIGRATIONS_BY_MAJOR value are byte-identical to the parent.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
…ith no driver

A repo-project test beside count-shards-merge: the real registry keeps
STEP18_RATIONALE sorted by key and renders it by `order`, conversionIds is
derived, and two retirement-shaped insertions into the REAL file (one
existing fragment apart, same next `order`) merge clean under
`git merge-tree` and equal both insertions applied together. Lit controls:
a same-gap pair, the same pair appended at the list's end, and the old
`+`-chain tail rewrite each conflict.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/vitest.repo-tests.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/vitest.repo-tests.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 f572a7eb3c272a5be85e3ec64d2516c944c3b243 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3dfb2ee2ea494039df1df15f69e7c89de0be979b — the merge of head bcb255881a805a006a3255c2b5b85fb4e405e341 into base f572a7eb3c272a5be85e3ec64d2516c944c3b243, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3dfb2ee2ea494039df1df15f69e7c89de0be979b && git checkout 3dfb2ee2ea494039df1df15f69e7c89de0be979b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f572a7eb3c272a5be85e3ec64d2516c944c3b243 bcb255881a805a006a3255c2b5b85fb4e405e341 && git checkout -B drift-repro f572a7eb3c272a5be85e3ec64d2516c944c3b243 && git merge --no-ff bcb255881a805a006a3255c2b5b85fb4e405e341

node scripts/docs-audit/affected-docs.mjs --json f572a7eb3c272a5be85e3ec64d2516c944c3b243

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: bcb255881a805a006a3255c2b5b85fb4e405e341
Local-runs: probe — evaluated MIGRATIONS_BY_MAJOR[18].rationale and .conversionIds at base 6154165484 and at this head from git show output alone (pure literal expressions in node:vm, in the scratch dir) to confirm byte-identity; nothing built, tested or re-gated.

Inputs: card #20535 (body; triage 5882257093; claim 5882653738; os-dev-report 5883564848), PR #20572 (body, its one comment, the 4-file list, the net diff against merge-base 6154165484; origin/main is f572a7eb3c), and the 32 check-runs on the head, read once at 2026-09-29T04:22Z.

① Derived judgments

Rendered text — byte-identical: RIGHT. Probe: base rationale 48,953 chars, sha256 797afbe924eef1858a764a2c161e504f88bd030bc6b14a6845f812ea75828e10; head joinRationale(STEP18_RATIONALE) (46 fragments sorted ascending by order, ties by id, joined with one space) gives the same length and the same digest. Every fragment's text sits at its cumulative offset in the base string (position identity, all 46); no fragment carries a leading or trailing space; the 45 seams in the base + chain were each exactly one space; neither string contains a doubled space. Orders are 1..46 and unique; ids are strictly sorted. The dev's digests are reproduced exactly.

conversionIds — value- and order-identical: RIGHT. Base literal list: 45 ids, sha256 of the JSON 55d56175bf7c109cc75b5dec36dbfd8a5be968863b61bc4f6131e10eeeb1788f; head CONVERSIONS_BY_MAJOR[18]!.map(…) (each conversion to its id) resolves (identifier to its id: field in conversions/registry.ts, a file this diff does not touch) to the same 45 ids in the same order, same digest. The base list was already a value-identical copy of CONVERSIONS_BY_MAJOR[18]. Order IS load-bearing: applyMetaMigrations (chain.ts:85) applies step.conversionIds sequentially, and the loader list carries the ordering comment (connectorResilienceKeysRemoved AFTER the duration renames). Deriving preserves exactly that order and now curates it in one place.

Deriving conversionIds (dev open question 1, option A) — sound: RIGHT, answered from the code. Two existing gates already forced set equality from both sides: migrations.test.ts "a graduated conversion belongs to the step for its own major" (step 18 is a subset of the toMajor-18 conversions) and the chain-replay gate (every ALL_CONVERSIONS entry above the floor must reach its fixture.after through the chain, so every major-18 conversion must already be in step 18 or the replay reds). A conversion added to CONVERSIONS_BY_MAJOR[18] later therefore had to be added to step 18 anyway; derivation removes only the hand step in which a misspelled id was silently skipped at replay (chain.ts:88, if (!conversion) continue). Retired-from-load-path entries were in the base list and stay in the chain — semantics unchanged. No import cycle: conversions/registry.ts imports from types, walk, data, shared, ui and automation only; the sole non-migrations module importing from migrations/ is the src/index.ts barrel. Module-init order holds (STEP18_RATIONALE at :4939, step18 at :5821, joinRationale a hoisted function declaration).

Route change — the delivered shape merges in the common case: RIGHT, with a named residue. Git conflicts on two insertions into one gap; an end-appended array is one gap (triage's example mechanism, falsified by the pin's end-append control at exit 1). Key-sorted fragments put two retirements into different gaps whenever one existing fragment separates them — the pin's clean case (exit 0, merged bytes equal both insertions applied together; both take the same next order and render last, in id order). Residue: two keys in the same gap (adjacent in sort order, including both before the first or both after the last key) still conflict — pinned as a lit control, and a keep-both hand resolution. Equal order values are by design (ties by id, deterministic).

The pin — a real proof, and a test rather than a gate: RIGHT. packages/spec/scripts/step18-rationale-merge.test.ts spawns real git merge-tree --write-tree in a hermetic throwaway repo holding the REAL registry.ts text (it asserts no merge.os-regen.driver is configured), one clean case plus three lit controls that must exit 1 (same gap, end-append, the old +-chain tail model). It also pins the shape: sorted unique kebab ids, positive integer order, trimmed text, render equal to MIGRATIONS_BY_MAJOR[18].rationale, conversionIds spelled as the derived expression with no literal list left. Not a gate: the file list adds no check:* script, no workflow, no job; vitest.repo-tests.json gains one row — the declaration check:cross-package-test-inputs requires, with the same shape and the same ../../../scripts/git-env.mjs import as the precedent count-shards-merge.test.ts — and CI runs the repo project through turbo run test test:repo in Test Core.

Authoring burden — loud and actionable. An end-append (unsorted) fails the sortedness assertion, which names the out-of-place pair and the remedy ("A fragment added at the END puts every retirement in one gap … Move it to where its id sorts."). A non-kebab id, a leading or trailing space, a non-positive order — each fails its own named assertion. A reused order value is NOT refused (ties render by id): two in-flight PRs taking the same next number is the designed case; reusing a lower number only moves the new sentence earlier in the paragraph — prose placement, no value at risk. id is a sort key, not a foreign key: nothing joins it to a D3 entry (40 of 46 match one; 6 are kebab names), so a mistyped id changes sort position only. A fragment spelled outside the ELEMENT shape fails as "unparsed text … at offset N" — loud, offset-addressed.

Generated regions — untouched: RIGHT. Three hunks only: the header comment and the new import (:36–57); the new block between the close of the semantic:17 generated region (:4888) and const step18 (:5821); and step18's two properties (:5820–5833), which end before semantic: [ (:5831) and the semantic:18 generated marker (:5835). check:migration-registry runs in Lint & Repo Gates (not concluded at the read); check:upgrade-guide, check:spec-changes and check:generated --reconcile-only run in Type Check · source gates — success. The upgrade guide prints majors up to PROTOCOL_MAJOR (17), so no committed artifact embeds step 18's prose (grep on origin/main finds none).

Public surface: none changes. RationaleFragment, joinRationale and STEP18_RATIONALE are module-private; MigrationStep is unchanged; no schema, tombstone, conversion, export, route or env var is touched. check:api-surface (Type Check · consumer gates) — success.

Gate coverage on this head (read once, 04:22Z). Success: Build Core (hosts check:dual-build-cjs-loads and check:lean-entry-closure as unconditional steps — two of the dev's four NOT MEASURED), Type Check · debt ledger (hosts check:type-check-debt — the third), Type Check · consumer gates (hosts check:doc-formula-expressions and check:api-surface — the fourth), Type Check · source gates, Governed Surface Queue Guard, Check Changeset, Check PR Size, Spec property liveness, the four claim and card guards, Auto Label, filter, Check Documentation Links, Flag docs affected. Skipped by path filter: Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in). NOT CONCLUDED (in_progress) at the read: Lint & Repo Gates (check:migration-registry, check:cross-package-test-inputs, eslint), Type Check · workspace, Test Core 1–6 (where the pin runs), Dogfood Regression Gate 1–3, Dogfood Verify CLI, Temporal Conformance. No check-run failed. Of the seven required contexts, Build Core and Governed Surface Queue Guard are green; the other five are among the unconcluded — the owning seat reads them before arming, and this record presumes nothing about them.

② Semver level

.changeset/20535-step18-rationale-fragments.md declares '@objectstack/spec': patch — RIGHT. The diff changes published source of a released package (the dist now computes the rationale at module init), so skip-changeset would be wrong; it adds nothing to the public surface, so minor would be wrong; nothing an author can write is removed or renamed, so no ADR-0087 disposition marker is owed and none is present. Check Changeset — success.

Clause-②: no (PR body) — RIGHT: no accept set widens or narrows, and every value a consumer reads is byte-identical (probe above).

③ Boundary flags

  • Dev deviation 1 (route change from triage's example array to key-sorted fragments with an explicit order): ANSWERED — the example mechanism was measured false; the delivered shape meets every constraint triage set (own line per retirement, byte-identical text proven, no new gate, two synthetic appends under merge-tree exit 0 with the parent shape at exit 1).
  • Dev deviation 2 and open question 1 (conversionIds derived rather than keyed): ANSWERED from the code — option A is right (①); B would reintroduce a second hand-kept order that step 17's copy already shows drifting.
  • Dev deviation 3 (sortedness is a vitest assertion, not a check:*): ANSWERED — that is what "no new gate" requires, and it runs in CI's Test Core via test:repo.
  • Dev deviation 4 (four gates NOT MEASURED locally): ANSWERED — all four are hosted by check-runs that concluded success on this head (Build Core, Type Check · debt ledger, Type Check · consumer gates).
  • Open question 2 and out-of-scope finding 1 (conversions/registry.ts CONVERSIONS_BY_MAJOR[18] tail still conflicts; measured exit 1 on the parent): ESCALATED to the seat — outside this claim's file surface; the dev recommends folding it into this family as a sibling card. That list's order is application order, so the shape there is its own decision; nothing in this PR is wrong because of it.
  • Out-of-scope finding 2 (governed wording): .claude/skills/spec-property-retirement/SKILL.md is UNTOUCHED by this diff (zero .claude/** paths in the file list; Governed Surface Queue Guard success). Its lines 215–216 still tell a step-18 author to add the id to MIGRATIONS_BY_MAJOR[N].conversionIds and to extend the step's rationale; lines 217–219 still warn that a misspelled step id is silently skipped at replay. For N = 18 both are now stale: there is no list to add to (the value is derived from CONVERSIONS_BY_MAJOR[18]), the rationale is extended by inserting ONE STEP18_RATIONALE fragment at its sorted id position with order one more than the highest present, and the silent-skip trap is closed for 18. An author who follows the stale text and appends at the end is refused by the pin with the remedy in the failure message, so the trap is loud, not silent. ESCALATED: the skills lane owes a Tier S follow-up on SKILL.md; not this PR.
  • Out-of-scope finding 3 (step 17's conversionIds names the same 57 ids as CONVERSIONS_BY_MAJOR[17] in a different order from index 21): ESCALATED as a question for the seat — the chain applies step.conversionIds in list order while the loader applies the registry's, so the divergence matters only if two major-17 conversions are order-dependent; fixtures are green and this PR does not touch step 17. Whether it takes a card (derive step 17 the same way, or pin order equality) is the seat's call.
  • Governed, release, size: no governed path; not the Version Packages PR; 1,876 changed lines, under 5,000. The PR is a draft with no auto-merge armed — the seat lands it once the unconcluded checks conclude green.

Implemented-by: claude/issue-20535-step18-rationale-per-line
Reviewed-by: session_014EJ1ED8X4MMrT18BhVx4tx

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 04:46
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 24d521e Sep 29, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20535-step18-rationale-per-line branch September 29, 2026 05:18
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ith an explicit application order, so two retirements merge clean (objectstack-ai#20574) (objectstack-ai#20685)

Fixes objectstack-ai#20574
Clause-②: no

Every major-18 retirement that carries a D2 conversion touched two tails
of `packages/spec/src/conversions/registry.ts`: it appended its
conversion to the end of `CONVERSIONS_BY_MAJOR[18]`, and most of them
defined the conversion at the end of the definitions, directly above
that table. So any two such retirement PRs in flight conflicted in
GitHub's driver-free merge. This PR reshapes both spots so that two
retirements no longer insert into the same gap. Nothing a consumer reads
changes: the list the loader replays is value-identical.

This is the sibling of objectstack-ai#20535 (PR objectstack-ai#20572), which reshaped
`step18.rationale` in `migrations/registry.ts`. It uses the same
instrument.

## What changed

- **`CONVERSIONS_BY_MAJOR[18]` is
`inApplicationOrder(MAJOR_18_CONVERSIONS)`.** `MAJOR_18_CONVERSIONS`
holds one `{ conversion, order }` entry per conversion. The list is kept
**sorted by the conversion's identifier** (code-unit order). `order` is
the application order: `inApplicationOrder` sorts by ascending `order`,
ties broken by the conversion's `id`, and returns the plain `readonly
MetadataConversion[]` it always was. Orders 1 to 46 are the 46 entries'
old positions, so the replayed sequence is unchanged.
- **The definitions' insertion point, the other shared tail.** I
measured it. On `origin/main` `9a4b2bb38f`, 57 of the last 80
first-parent commits that touched this file added a conversion
definition. Together they added 66 definitions, and 42 of them were
added after every other definition, in that one gap. The list's doc
comment now says where a new definition goes: directly above the
definition of the entry that follows it in `MAJOR_18_CONVERSIONS`. A
conversion that sorts last goes after every other conversion. Two
retirements in different list gaps therefore also define their
conversions in different gaps. No existing definition moved.
- `OrderedConversion` and `inApplicationOrder` are module-private, and
`CONVERSIONS_BY_MAJOR` keeps its explicit type annotation.
`api-surface/` and `export-origins/` are unchanged, and
`check:api-surface` is green. The released majors (11 to 17) stay plain
arrays. The next major takes the same shape when it opens.
- Consumers are untouched. `ALL_CONVERSIONS`, step 18's `conversionIds`
(`CONVERSIONS_BY_MAJOR[18]!.map((c) => c.id)`, pinned verbatim by the
objectstack-ai#20572 test), `spec-changes.ts` and `build-upgrade-guide.ts` all read
`CONVERSIONS_BY_MAJOR[18]` as before. No generated projection moved:
`check:generated` reports 15 of 15 up to date and regenerated nothing.

## Why sorted by identifier, with an explicit order

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 one gap. An explicit `order` alone would not change that: the
end-append lit control below conflicts with the order key present. Kept
sorted by identifier, two retirements insert into different gaps, and
one existing entry between them is enough. The application order cannot
depend on where an insertion lands, so it lives in `order`. Two PRs in
flight may both take the next number. They then apply in `id` order,
which is deterministic whatever the textual positions are.

## Verification record

### Lit controls on the parent (`c6b37cd08d`)

This was a one-shot run with git 2.43.0 in a scratch repo that held the
real file, with no attributes and no driver. Each side made the edit a
major-18 retirement makes:

| pair | `git merge-tree --write-tree` |
|---|---|
| two list-tail appends (a new identifier after `viewListTabsRemoved,`)
| **exit 1**, CONFLICT (content) in `registry.ts` |
| two definitions added directly above `export const
CONVERSIONS_BY_MAJOR` | **exit 1**, CONFLICT (content) |
| both edits on each side | **exit 1**, CONFLICT (content) |

### Dark control and the permanent pin

The pin is `packages/spec/scripts/conversions-major18-merge.test.ts`. It
is in the repo project beside `step18-rationale-merge.test.ts`, and it
works against the REAL file. It is a test, not a new gate. It asserts:

- **The premise.** `CONVERSIONS_BY_MAJOR` wires `18:
inApplicationOrder(MAJOR_18_CONVERSIONS)`, with no `18: [` array. The
entries are strictly sorted by identifier. Each entry names a major-18
conversion defined in the file, and the list is all of them. `order` is
positive, and the application order parsed from the entries (by `order`,
ties by `id`) equals `CONVERSIONS_BY_MAJOR[18]`, the tail of
`ALL_CONVERSIONS`, and `MIGRATIONS_BY_MAJOR[18].conversionIds`. The
anti-vacuity check: that order differs from key order. A conversion
added after this change must be defined directly above its list
successor. The 46 entries that predate the rule are exempt, and that set
is closed: its size is pinned.
- **The card's reproduction, now clean.** Two retirement-shaped edits,
each adding a definition above its successor's and an entry where its
identifier sorts, one existing entry apart, both taking the same next
`order`: **exit 0**. The merged bytes equal both edits applied together.
The merged file still satisfies both rules, and its application order is
the old 46 followed by the two new ids in `id` order.
- **Lit controls**, all **exit 1** with conflicted path `registry.ts`:
- two identifiers in the same gap, the residue a key sort cannot remove;
  - the same two entries appended at the list's END;
  - the same two conversions defined after every other conversion;
  - a synthetic model of the old array-plus-tail shape.

The pin also proves that its two rule checks fire. The end-appended side
breaks the sort rule, and the tail-defined side breaks the placement
rule with the expected message.

### Byte-identical replay

| value | parent `c6b37cd08d` | after the restructure |
|---|---|---|
| `CONVERSIONS_BY_MAJOR[18]` ids | 46, sha256 `9e9666c56a5e6314…` |
identical |
| majors 11, 13, 14, 15, 17 ids | 4 / 3 / 1 / 2 / 57 | identical, each |
| `ALL_CONVERSIONS` ids | 113, `c0dee67fbecf705e…` | identical |
| every conversion's body (JSON with function source) |
`fbba8d0066fb121c…` | identical |
| `MIGRATIONS_BY_MAJOR[18].conversionIds` | `9e9666c56a5e6314…` |
identical |
| the whole `MIGRATIONS_BY_MAJOR` value as JSON | `72f0c698a5bfcc09…` |
identical |

The two fingerprint files, from tsx over `src` at `c6b37cd08d` and at
`ed1c54db8d` (the restructure commit), are byte-identical.

After merging `origin/main` `9a4b2bb38f`, which carries objectstack-ai#20305's change
to one conversion's body, I compared head `63af2cb5f6` with main's plain
arrays, read from main's source text. Every major's id sequence,
`ALL_CONVERSIONS` (113) and step 18's `conversionIds` are identical. The
built CJS and ESM bundles (`dist/index.{js,mjs}` and
`dist/browser/index.{js,mjs}`) all load with the same id hashes. This
branch's delta to `registry.ts` against main has the same `git patch-id
--stable` as the restructure commit's own diff.

### Ablations (one-shot, at `d316f458a6`, registry blob `f9d797be1e80`)

Each ablation went through `scripts/ablation-replace.mjs`, with the
anchor proven to hit (1 → 0). Each was restored to the HEAD blob with
`git diff HEAD` empty.

1. Replacing the `order` sort in `inApplicationOrder` with `.slice()`
(apply in list position) turned the pin **red**: 2 failed, 10 passed.
The two failures were the application-order assertion and the
merged-order assertion.
2. Swapping the first two entries turned the pin **red**: 3 failed, 9
passed. The failures were the sortedness assertion and the two
assertions that need a sorted base.

The observed direction was the usual one: the pin turns red. No build or
dist leg was needed, because the pin imports `../src` directly and reads
the source text.

## Tests and gates (final commit `63af2cb5f6`)

- `@objectstack/spec` `local` project: 575 files, 16,946 passed, 1 todo.
`repo` project: 44 files, 773 passed. `pnpm --filter @objectstack/spec
typecheck`: exit 0. That covers `tsc`, `check:scripts-typecheck` (the
pin is in that program) and `check:test-typecheck`. All ran through
`os-verify-lock` with `--maxWorkers=2` on a shared box.
- `check:generated`: 15 of 15 up to date, against the `dist` built at
this head. `check:migration-registry`: exit 0.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` was reconciled with `--ran`: 84 derived, **80 exit 0**, 4
NOT MEASURED, 0 unrun. The 4 exited 3 with `PREREQUISITE NOT MET`
because they need the whole-repo build closure, which CI builds:
`check:doc-formula-expressions`, `check:dual-build-cjs-loads`,
`check:lean-entry-closure` and `check:type-check-debt`.
- The first gate round, at `b4f0179259`, caught
`check:comment-mask-adoption` red. It flagged the pin's own line-comment
regex as a private comment stripper. Commit `0000e4ab13` fixed it: the
pin now reads the list and the definitions through
`scripts/js-comment-mask.mjs`'s `maskComments`.
- Declared to CI:
  - the CLI integration tier (this diff touches no CLI file);
- the tests in other packages that read `ALL_CONVERSIONS`
(`metadata-core`, `metadata-protocol`, `metadata`,
`service-automation`). The public surface is byte-unchanged, and the
values are proven identical above.
  - Repo-level `pnpm lint` is CI-owned.
- Changeset: `@objectstack/spec` `patch`. I measured this rather than
assumed it. After the build, `inApplicationOrder` appears in 6 files
under `dist/` and `MAJOR_18_CONVERSIONS` in 8, with the positive control
`view-list-tabs-removed` also present. So the published bundle moves,
while no value it exports does.

## How a retirement adds its conversion once this lands

1. Define the conversion directly above the definition of the entry that
will follow it in `MAJOR_18_CONVERSIONS`. If it sorts last, define it
after every other conversion, directly above `OrderedConversion`.
2. Add ONE line, `{ conversion: IDENT, order: N }`, where `IDENT` sorts.
Never add it at the end.
3. `N` is one more than the highest present. A conversion that must
apply before an existing one takes a number between its neighbours' (for
example `23.5`) instead of renumbering them.

A branch cut before this lands meets the change once, on its next base
merge: its appended `18: [` line becomes one entry at its sorted
position.

## Acceptance notes

- **Governed wording, routed to the skills lane (not edited here).**
`.claude/skills/spec-property-retirement/SKILL.md`'s registration
checklist tells a retirement author to add a `MetadataConversion` in
this file. For major 18 that now means the entry-and-placement rule
above. objectstack-ai#20575 already carries the step-18 rewording of the same
checklist, and its body asks to fold this sibling's wording in at the
same time.
- **Measured residue, not reshaped here.** 8 of the same 80 commits also
changed the file's import block (its first 60 lines). Two retirements
that each add an import at the same point would still conflict there.
The import block is outside the two spots this card names, and the
frequency is about one in ten.
- **Same-gap residue.** Two identifiers that sort into the same gap
still conflict. That is pinned as a lit control, as in objectstack-ai#20572.
- The pin copies the small hermetic `FileRepo` harness from
`step18-rationale-merge.test.ts` rather than extracting a shared helper.
That keeps the objectstack-ai#20572 pin untouched.
- The sortedness and placement checks are assertions in a repo-project
test, not a `check:*` gate. They are what make an end-append, or a tail
definition, fail loudly instead of quietly bringing the conflict back.

---
_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