Repository navigation
refactor(spec): major 18's conversions as identifier-sorted entries with an explicit application order, so two retirements merge clean (#20574) - #20685
Conversation
…ith an explicit application order
CONVERSIONS_BY_MAJOR[18] was an end-appended array: every major-18
retirement with a D2 conversion inserted into its one tail gap, so any
two in flight conflicted in GitHub's driver-free merge. It is now
inApplicationOrder(MAJOR_18_CONVERSIONS): one { conversion, order }
entry per conversion, kept sorted by identifier, applied by ascending
order (ties by conversion id). Orders 1..46 are the old positions, so
the replayed sequence is unchanged.
Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…ge probe A repo-project test that parses MAJOR_18_CONVERSIONS from the real registry, holds it sorted by identifier, holds a new conversion's definition directly above its list successor's, checks the replayed order against CONVERSIONS_BY_MAJOR[18], ALL_CONVERSIONS and step 18's conversionIds, and merges two retirement-shaped edits of the real file with git merge-tree (exit 0), beside four lit controls that must still conflict: the same gap, the list's end, the definitions' end, and the old array shape. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…s shape Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…nversions-merge-clean
check:comment-mask-adoption flagged the pin's own line-comment regex as a private comment stripper. The list and the definitions are now read through scripts/js-comment-mask.mjs maskComments, which keeps offsets, so a comment between entries is a blank line to the parser. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…nversions-merge-clean
…nversions-merge-clean
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f3500e547bd489bcbea3b23d51e2642f938c0ebe && git checkout f3500e547bd489bcbea3b23d51e2642f938c0ebe
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6981abfd26cd518174db432766580e37b29a6566 63af2cb5f62051dd433904eeff8aac01bf947620 && git checkout -B drift-repro 6981abfd26cd518174db432766580e37b29a6566 && git merge --no-ff 63af2cb5f62051dd433904eeff8aac01bf947620
node scripts/docs-audit/affected-docs.mjs --json 6981abfd26cd518174db432766580e37b29a6566 |
Contract reviewServed-tier: Inputs: card #20574 (body, triage ① Derived judgments
② Semver level
③ Boundary flagsDev deviations, each answered:
Out-of-scope findings, dispositions:
No new gate: nothing in the diff adds a workflow step or a Implemented-by: VERDICT: PASS Generated by Claude Code |
… commits that decided them (objectstack-ai#20693) Part of objectstack-ai#20596 Clause-②: no ## What changed This is the fifth stage of the `domain:services` lane of the dead-citation sweep. It covers `packages/services/service-datasource/src/**` and nothing else. By the seat's fresh census at the claim (`5894843429`), it is the largest package in the lane that no in-flight work holds. Later stages cover the other packages, so this PR says `Part of` and the card stays open. Every comment or docblock site in scope that cited a tracker number answering 404 has been rewritten in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123), by the method of stages 1 to 4 (PR objectstack-ai#20609 as `422db788a`, PR objectstack-ai#20626 as `b80ab579d`, PR objectstack-ai#20634 as `4d04b6be3`, PR objectstack-ai#20658 as `9a4b2bb38`). That is **75 sites on 74 lines in 23 files, covering 16 numbers**: - 44 census sites (every census site this package has); - 31 sites in test comments, which the census defers. Each rewritten line now cites the commit in `origin/main` history that decided what the line describes, and says in its own words what was decided: **17 distinct shas**. `objectstack-ai#8696` was one card fixed in two halves, so its lines cite the half they describe: the mysql DSN branch (`72050cc47`) or the mongodb DSN branch (`90a12fb18`). `PR objectstack-ai#8588` was itself a pull request, and it now cites its squash commit `3dede582b`. No number in this package has an ADR or ruling record of its own in the repository (a grep of `docs/adr/` for all 16 finds none), so every anchor is a commit, per ruling C's order. No number was dropped. Only comments changed. Every touched source file keeps its line count (78 lines out, 78 in, over 23 files), so no line citation into these files moves. 4 of those 78 lines hold no dead citation; they are reflow, listed under Wordings below. No code token moves (see the guard below). **No citation number is added.** Every tracker number on an added line was already on the line it replaces: the only one is `objectstack-ai#12482`, which resolves and stood on `datasource-connection-service.ts:101` before. Over the whole diff, added minus removed is 0 or negative for every number, and no number is new to the diff. No PR number stands on an added line. Thirteen dead sites are left on purpose, all of them test titles (see the list below). One more file: a `patch` changeset for `@objectstack/service-datasource`, because the rewritten docblocks ship (see Changeset below). ## Census: `service-datasource`, before and after **Instrument (A1).** The gate's own `node scripts/check-issue-citations.mjs --census --json`, read-only and unchanged. The count below is its `allocated-but-absent` findings under `packages/services/service-datasource/`. Each run counts as a reading only because its board frontier equals the newest issue number, read by a separate request just before and just after the run. | reading | tree | board | whole-repo `allocated-but-absent` | service-datasource sites | lines | files | numbers | |---|---|---|---|---|---|---|---| | before | base `6981abfd2`, run 2026-09-29T17:02:34Z to 17:06:27Z | enumerated, 186 pages, frontier objectstack-ai#20684 (newest objectstack-ai#20684 before and after), 18,511 numbers | 1,318 | **44** | 43 | 9 | 14 | | after | head `f5ec6bacd`, run 17:18:37Z to 17:22:33Z | enumerated, 186 pages, frontier objectstack-ai#20686 (newest objectstack-ai#20686 before and after), 18,513 numbers | 1,274 | **0** | 0 | 0 | 0 | The before count matches the seat's census at the claim (44 sites in 9 files, at `6bff748b`). The whole-repo drop is 44, exactly this diff's census sites. The `resolves` tally is 32,909 in both runs, and `resolves-as-pull-request` (1,984) and `cross-repo-unjudged` (994) did not move either. The after run was taken on `f5ec6bacd`; the head `265dc6861` adds only the changeset. No run was truncated or discarded: all four enumerations in this stage (two census runs and the two supplementary boards below) read 186 pages at the newest frontier. **Supplementary instrument, the whole scope.** The census does not read test files or strings, and this stage's scope includes test comments. So a second reading runs the gate's own exported `extractCitations` (whole-file and comment-prose projections) and `classifyCitation` over every `.ts` file under `service-datasource/src` (61 files). It uses one board for both trees, enumerated by the gate's own `enumerateBoard` at 17:22:42Z (186 pages, frontier objectstack-ai#20686, equal to the newest). | reading | citations | dead | src comment | test comment | src string | test string | |---|---|---|---|---|---|---| | before, `6981abfd2` | 791 | **88** | 44 | 31 | 0 | 13 | | after, `f5ec6bacd` | 716 | **13** | 0 | 0 | 0 | 13 | Its src-comment column equals the census's 44, which is the control on the second instrument. The 646 resolving and 57 pull-request citations are the same in both readings, and the drop of 75 citations is exactly the rewritten sites. An earlier board (17:07:08Z, frontier objectstack-ai#20685) gave the same base reading. A third, raw reading (every `#` followed by digits, judged against the same board, whatever surrounds it) finds 88 dead occurrences before and 13 after, and its residue equals the gate's residue site for site. ## Per-number table Sites and files count every dead occurrence in scope at the base (comments and strings, tests included). `rewritten / left` counts the sites rewritten and the sites left. Each anchor was read in its message and diff, not only its subject. | number | sites / files | rewritten / left | anchor: what it decided | |---|---|---|---| | `objectstack-ai#6268` | 6/4 | 6/0 | `68f5eccb1`: the libSQL/Turso host loader gets one owner (`@objectstack/runtime`), and `MissingDriverPackageError` becomes one class across both hosts, because `serve.ts` decides fatality with `instanceof`. The cli and runtime stages' anchor | | `objectstack-ai#6345` | 9/5 | 9/0 | `e2798fab7`: one driver vocabulary; `mongo` renamed to `mongodb`, `turso` made a full builtin with a contract, and this factory's dispatch made exhaustive. The spec stages' anchor | | `objectstack-ai#8588` | 1/1 | 1/0 | `3dede582b`: `external.credentialsRef` (and only it) allowed on `schemaMode: 'managed'`; `objectstack-ai#8588` was that pull request, and this is its squash commit | | `objectstack-ai#8696` | 13/4 | 10/3 | two halves: `72050cc47`, a bound `credentialsRef` reaches the mysql client on the DSN branch as `{ uri, password }` (5 sites); `90a12fb18`, the mongodb DSN branch carries it in `options.auth` beside an unmodified url (5 sites). The spec stage's anchor for the mongo half | | `objectstack-ai#8873` | 8/3 | 7/1 | `096106522`: a bound `credentialsRef` reaches the postgres SERVER on the DSN branch; `connectionString` is dropped and `pg` gets its own parse of the url with the credential attached. The spec stage's anchor | | `objectstack-ai#8874` | 9/2 | 7/2 | `d70428ae7`: a declared `ssl` reaches the mysql client on both branches, in the spelling `mysql2` accepts (`{}`, never `true`). The spec stage's anchor | | `objectstack-ai#8876` | 1/1 | 1/0 | `d634e665b`: `urlUserinfoUsername` exported, the username half of the shared URL userinfo grammar. The spec stage's anchor | | `objectstack-ai#9040` | 4/3 | 2/2 | `24206416a`: a credential in the mongo options passthrough (`config.options.auth.password`) is refused at publish. The spec stage's anchor | | `objectstack-ai#9041` | 2/2 | 2/0 | `d491625c1`: a bound `credentialsRef` with a user-less mongo `config.url` is refused at the one door that sees both halves. The spec stage's anchor | | `objectstack-ai#10537` | 3/2 | 3/0 | `e634ecf6a`: `POST /external/validate` scoped to the URL's datasource; it adds `validateDatasource`. The rest and runtime stages' anchor | | `objectstack-ai#10962` | 5/2 | 4/1 | `29d067646`: one live introspection per datasource per validation sweep, memoised per call and never per instance (its message names `objectstack-ai#10962`) | | `objectstack-ai#11166` | 5/1 | 4/1 | `735f5c709`: an unreachable remote is the new `unreachable` diff kind, not `missing_table`. The runtime stage's anchor | | `objectstack-ai#12010` | 9/5 | 8/1 | `77b91bdb4`: `ConnectionEngineLike` derived from the engine contract, and `registerDriver` stops promising it accepts any value. The runtime stage's anchor | | `objectstack-ai#12248` | 1/1 | 1/0 | `8425c17cc`: the ruled engine members adopted onto `IDataEngine`, the datasource-lifecycle trio among them. The spec stage's anchor | | `objectstack-ai#12943` | 3/2 | 3/0 | `090f2302e`: the guarded optional-driver loads declared as optional peers of this package. The cli and runtime stages' anchor | | `objectstack-ai#13279` | 9/4 | 7/2 | `6a180e42d`: a failed permission-store read raises `AuthzStoreUnavailableError` (503) instead of reading as zero grants; its message carries the 2026-08-30 ruling, and it moved `driver-error-classification.ts` into `@objectstack/types`. The anchor of stage 2 and of the rest, runtime and types stages | Every cited sha matches exactly one commit (`git rev-parse --disambiguate`, count 1 for each of the 17), and every one is an ancestor of the base (`merge-base --is-ancestor`, exit 0 for all 17; the history is complete, `--is-shallow-repository` false, 15,110 commits). Where an earlier stage already anchored a number, this stage reuses that anchor after checking it against this package's lines. New to the sweep here: `72050cc47` (the mysql half of `objectstack-ai#8696`), `3dede582b` and `29d067646`. ## Wordings to check - **`objectstack-ai#8696`'s two halves.** The mysql arm's lines (`default-datasource-driver-factory.ts:718`, `:824`, and `bound-secret-dsn-branches.test.ts:4`, `mysql-dsn-ssl.test.ts:189`, `:263`) cite `72050cc47`; the mongo arm's lines (`default-datasource-driver-factory.ts:926`, `:954`, `:1243`, `datasource-credential-migration.ts:182`, and the heading `bound-secret-dsn-branches.test.ts:72`, 「the mongodb half, added second」) cite `90a12fb18`. - **A referent, `datasource-connection-service.ts:95-96`.** 「the inventory that filed that card」 lost its referent with the number, so it now says 「the inventory that filed its card」, the card behind `77b91bdb4` (1 reflow line). - **`datasource-connection-service.ts:100-101`.** 「objectstack-ai#12248 adjudicated all three onto IDataEngine」 became 「Commit 8425c17 adopted all three onto IDataEngine per the ruling」: the ruling decided and the commit carried it out, as its changeset says (1 reflow line). - **What the card described, `default-datasource-driver-factory.ts:412` and `mysql-dsn-ssl.test.ts:40`.** 「the one objectstack-ai#8874 describes as honouring」 became 「the one commit d70428a's card describes as honouring」, since the words quote the card, not the commit. - **A cross-reference, `default-datasource-driver-factory.ts:736`.** 「the falsy-value note under objectstack-ai#8874 below」 points at the heading at `:767`, which now carries `commit d70428a`, so the pointer names the same anchor. - **A future tense made past, `postgres-dsn-bound-secret.test.ts:223`.** 「the authoring door (objectstack-ai#9041), which this card lands before」 became 「(commit d491625), which landed after this pin」. `096106522` (this file's commit) is an ancestor of `d491625c1`, both on 2026-08-16. - **`external-datasource-service.test.ts:444`.** 「The card's measured defect」 became 「Its card's measured defect」, the card behind `735f5c709`; `:588` 「the pre-objectstack-ai#10537 route」 became 「the route … before commit e634ecf」. - **`datasource-admin-service.test.ts:674`.** 「Before PR objectstack-ai#8588」 became 「Before commit 3dede58」, the squash commit of that pull request, which answers 404. - **Reflow, 4 lines with no dead site** (every file keeps its line count): `datasource-connection-service.ts:96`, `:101`, `default-datasource-driver-factory.ts:825`, `:826`. ## The 13 sites left - **Test titles, 13 sites.** `describe` / `it` titles, which are string tokens, left as stages 1 to 4 left theirs: `admin-routes-authz-outage-envelope.test.ts:158` (`objectstack-ai#13279`); `admin-routes-tenancy-posture-admission.test.ts:557` (`objectstack-ai#13279`); `bound-secret-dsn-branches.test.ts:136`, `:244` (`objectstack-ai#8696`); `connection-engine-like-contract.test.ts:21` (`objectstack-ai#12010`); `datasource-config-redaction.test.ts:406` (`objectstack-ai#9040`); `datasource-credential-migration.test.ts:226` (`objectstack-ai#9040`); `external-datasource-service.test.ts:453` (`objectstack-ai#11166`), `:690` (`objectstack-ai#10962`); `mysql-dsn-ssl.test.ts:165`, `:324` (`objectstack-ai#8874`), `:260` (`objectstack-ai#8696`); `postgres-dsn-bound-secret.test.ts:160` (`objectstack-ai#8873`). - There is no operator string, assertion message, generated header or quoted ruling carrying a dead number in this package. The verbatim maintainer quotations in scope (「同意」 and 「同意所有」, on 8 lines) carry no dead number and are untouched. ## Mechanical guard: no code token moves The guard compares the TypeScript parser's leaf nodes, with comments as trivia and JSDoc nodes never visited, base `6981abfd2` against head. Template literals are therefore read in context. It ran over all 23 touched `.ts` files. - Real run: 24,055 base leaf tokens, **0 files with a token change** (exit 0). - Comment control in `default-datasource-driver-factory.ts` (`Lazy + caught exactly like` to `Lazy and caught exactly like`): 0 files changed, as expected (exit 0). - Positive control, a code token added in `default-datasource-driver-factory.ts` (`const url = resolveTursoUrl(spec);` given a trailing `?? undefined`): DIFFER (exit 1). - Positive control, one digit changed inside a kept test title (`mysql-dsn-ssl.test.ts:165`): DIFFER (exit 1). Every mutation went through `scripts/ablation-replace.mjs`, and each landed (anchor 1 to 0, blob changed). Each restore was proven byte-identical to the HEAD blob (`8c164aa6178f`, `2feeaeb0411d`), with `git diff HEAD` empty and a clean tree afterwards. A first draft of the guard used the bare scanner, which loses template context and reported token changes inside comments; it was replaced by the parser walk before any reading was taken from it. ## Changeset This change ships bytes, so a `patch` changeset for `@objectstack/service-datasource` (`.changeset/20596-service-datasource-provenance-anchors.md`) is included. It says only that the provenance comments were re-anchored, in stages 3 and 4's words. Measured on the built package (A3): `files[]` is `dist`, `README.md` and `CHANGELOG.md`. After the build, the rewritten comments reach `dist`: `6a180e42d`, `e2798fab7` and `e634ecf6a` once, and `29d067646` three times, in each of `dist/index.d.ts`, `index.d.cts`, `index.js` and `index.cjs`; `68f5eccb1` 4 times, `090f2302e` twice, and `77b91bdb4` and `8425c17cc` once each, in both declaration files. Positive control: the unchanged line 「`registerDatasourceDef`, `markDatasourceUnavailable`,」 beside the shipped rewrite at `datasource-connection-service.ts:95` is found once in `index.d.ts`. A never-written negative phrase appears nowhere in `dist`. No dead number of the 16 is left anywhere in `dist`. ## Gates (head `265dc6861`) - **Citation judging, as CI runs it:** `pnpm check:issue-citations` (self-test) exits 0. `node scripts/check-issue-citations.mjs` exits 0: the diff-scoped run judged 1 citation (`objectstack-ai#12482`), and it resolves. - **Doc authoring:** `pnpm check:doc-authoring` exits 0, with the sibling-package prose ids at their baseline and no growth. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `265dc6861` derived 63 commands: all 54 derived at dispatch, plus `check:duration-unit-keys`, `check:dispatcher-error-vocabulary`, `check:engine-double-contract`, `check:logger-receiver-detach`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. Each ran with its exit code captured before any pipe, and all 63 exit 0. `--ran`, fed each command with its exit code, reports 63 run, 0 NOT MEASURED (a derived zero), 0 unrun, and exits 0. A full `turbo run build` of `./packages/*` and `./packages/*/*` ran first under the shared verify lock (71 of 71 tasks, exit 0), so no gate hit an unbuilt workspace. - **Roster families the derivation lists outside its commands** (their rosters sit in directories this diff touches): `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing` and `pnpm check:filter-alias-parity`, each exit 0. - **Tests and typecheck, under the verify lock:** - `pnpm --filter @objectstack/service-datasource test`: 34 files pass and 693 tests pass. That is every test file in the package, the 14 touched ones included. - `pnpm --filter @objectstack/service-datasource typecheck` exits 0. Its `tsconfig.json` includes all of `src`, and `--listFiles` shows all 61 files under `src/`, the 34 test files included, and all 23 touched files in the program. - **Lint, as a proven narrowing:** `eslint --no-inline-config --format json` over the 23 touched `.ts` files gives 23 files, 0 errors and 0 warnings. All 23 are in eslint's own population (`isPathIgnored` is false for each). `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, as its own lines 327-328 state), so a comment edit here cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's run. - **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of the 24 changed files for control bytes finds none. ## Acceptance notes - **The gate-invisible spellings, grepped as the claim asked.** `CITATION_RE` refuses a hyphen after the digits and a `/` before the `#` (objectstack-ai#20636). In this package there is no `#N-word` spelling at all. There are 7 `#A/#B` lines (`admin-routes.ts:28`, `datasource-route-ledger.ts:159`, `turso-driver-config.ts:132`, `external-introspection-seam.test.ts:14`, `:102`, `:163`, `turso-bound-secret-authoring.test.ts:8`), and every second number on them is live: `objectstack-ai#10998`, `objectstack-ai#4251` and `objectstack-ai#4249` are issues, and `objectstack-ai#8078`, `objectstack-ai#4176` and `objectstack-ai#4202` are pull requests. So nothing there needed rewriting. The raw scan above, which sees both spellings, agrees. - **The census instrument did not truncate in this stage.** Four enumerations read 186 pages each at the newest frontier. - **Anchors the next stages can reuse**, each checked here: `objectstack-ai#13279` → `6a180e42d`; `objectstack-ai#12010` → `77b91bdb4`; `objectstack-ai#6345` → `e2798fab7`; `objectstack-ai#6268` → `68f5eccb1`; `objectstack-ai#12943` → `090f2302e`; `objectstack-ai#8696` → `72050cc47` (mysql) or `90a12fb18` (mongodb); `objectstack-ai#8873` → `096106522`; `objectstack-ai#8874` → `d70428ae7`; `objectstack-ai#10962` → `29d067646`. - **Base.** The branch is 5 commits behind `origin/main` (`14f80e239`, read at 17:27Z). None touches `service-datasource`, `scripts/` or `.changeset/config.json`, so there was no merge. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #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 ofCONVERSIONS_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 #20535 (PR #20572), which reshaped
step18.rationaleinmigrations/registry.ts. It uses the same instrument.What changed
CONVERSIONS_BY_MAJOR[18]isinApplicationOrder(MAJOR_18_CONVERSIONS).MAJOR_18_CONVERSIONSholds one{ conversion, order }entry per conversion. The list is kept sorted by the conversion's identifier (code-unit order).orderis the application order:inApplicationOrdersorts by ascendingorder, ties broken by the conversion'sid, and returns the plainreadonly MetadataConversion[]it always was. Orders 1 to 46 are the 46 entries' old positions, so the replayed sequence is unchanged.origin/main9a4b2bb38f, 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 inMAJOR_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.OrderedConversionandinApplicationOrderare module-private, andCONVERSIONS_BY_MAJORkeeps its explicit type annotation.api-surface/andexport-origins/are unchanged, andcheck:api-surfaceis green. The released majors (11 to 17) stay plain arrays. The next major takes the same shape when it opens.ALL_CONVERSIONS, step 18'sconversionIds(CONVERSIONS_BY_MAJOR[18]!.map((c) => c.id), pinned verbatim by the refactor(spec): step 18 rationale as key-sorted fragments, conversionIds derived, so two retirements merge clean #20572 test),spec-changes.tsandbuild-upgrade-guide.tsall readCONVERSIONS_BY_MAJOR[18]as before. No generated projection moved:check:generatedreports 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
orderalone 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 inorder. Two PRs in flight may both take the next number. They then apply inidorder, 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:
git merge-tree --write-treeviewListTabsRemoved,)registry.tsexport const CONVERSIONS_BY_MAJORDark control and the permanent pin
The pin is
packages/spec/scripts/conversions-major18-merge.test.ts. It is in the repo project besidestep18-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_MAJORwires18: inApplicationOrder(MAJOR_18_CONVERSIONS), with no18: [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.orderis positive, and the application order parsed from the entries (byorder, ties byid) equalsCONVERSIONS_BY_MAJOR[18], the tail ofALL_CONVERSIONS, andMIGRATIONS_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 inidorder.Lit controls, all exit 1 with conflicted path
registry.ts: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
c6b37cd08dCONVERSIONS_BY_MAJOR[18]ids9e9666c56a5e6314…ALL_CONVERSIONSidsc0dee67fbecf705e…fbba8d0066fb121c…MIGRATIONS_BY_MAJOR[18].conversionIds9e9666c56a5e6314…MIGRATIONS_BY_MAJORvalue as JSON72f0c698a5bfcc09…The two fingerprint files, from tsx over
srcatc6b37cd08dand ated1c54db8d(the restructure commit), are byte-identical.After merging
origin/main9a4b2bb38f, which carries #20305's change to one conversion's body, I compared head63af2cb5f6with main's plain arrays, read from main's source text. Every major's id sequence,ALL_CONVERSIONS(113) and step 18'sconversionIdsare identical. The built CJS and ESM bundles (dist/index.{js,mjs}anddist/browser/index.{js,mjs}) all load with the same id hashes. This branch's delta toregistry.tsagainst main has the samegit patch-id --stableas the restructure commit's own diff.Ablations (one-shot, at
d316f458a6, registry blobf9d797be1e80)Each ablation went through
scripts/ablation-replace.mjs, with the anchor proven to hit (1 → 0). Each was restored to the HEAD blob withgit diff HEADempty.ordersort ininApplicationOrderwith.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.The observed direction was the usual one: the pin turns red. No build or dist leg was needed, because the pin imports
../srcdirectly and reads the source text.Tests and gates (final commit
63af2cb5f6)@objectstack/speclocalproject: 575 files, 16,946 passed, 1 todo.repoproject: 44 files, 773 passed.pnpm --filter @objectstack/spec typecheck: exit 0. That coverstsc,check:scripts-typecheck(the pin is in that program) andcheck:test-typecheck. All ran throughos-verify-lockwith--maxWorkers=2on a shared box.check:generated: 15 of 15 up to date, against thedistbuilt at this head.check:migration-registry: exit 0.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandswas reconciled with--ran: 84 derived, 80 exit 0, 4 NOT MEASURED, 0 unrun. The 4 exited 3 withPREREQUISITE NOT METbecause they need the whole-repo build closure, which CI builds:check:doc-formula-expressions,check:dual-build-cjs-loads,check:lean-entry-closureandcheck:type-check-debt.b4f0179259, caughtcheck:comment-mask-adoptionred. It flagged the pin's own line-comment regex as a private comment stripper. Commit0000e4ab13fixed it: the pin now reads the list and the definitions throughscripts/js-comment-mask.mjs'smaskComments.ALL_CONVERSIONS(metadata-core,metadata-protocol,metadata,service-automation). The public surface is byte-unchanged, and the values are proven identical above.pnpm lintis CI-owned.@objectstack/specpatch. I measured this rather than assumed it. After the build,inApplicationOrderappears in 6 files underdist/andMAJOR_18_CONVERSIONSin 8, with the positive controlview-list-tabs-removedalso present. So the published bundle moves, while no value it exports does.How a retirement adds its conversion once this lands
MAJOR_18_CONVERSIONS. If it sorts last, define it after every other conversion, directly aboveOrderedConversion.{ conversion: IDENT, order: N }, whereIDENTsorts. Never add it at the end.Nis one more than the highest present. A conversion that must apply before an existing one takes a number between its neighbours' (for example23.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
.claude/skills/spec-property-retirement/SKILL.md's registration checklist tells a retirement author to add aMetadataConversionin this file. For major 18 that now means the entry-and-placement rule above. [finding]spec-property-retirementSKILL.md tells a step-18 author to append toconversionIdsand extend the rationale string: both change shape when PR #20572 lands #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.FileRepoharness fromstep18-rationale-merge.test.tsrather than extracting a shared helper. That keeps the refactor(spec): step 18 rationale as key-sorted fragments, conversionIds derived, so two retirements merge clean #20572 pin untouched.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