Skip to content

refactor(spec): major 18's conversions as identifier-sorted entries with an explicit application order, so two retirements merge clean (#20574) - #20685

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20574-conversions-merge-clean
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20574-conversions-merge-clean

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 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 #20535 (PR #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 refactor(spec): step 18 rationale as key-sorted fragments, conversionIds derived, so two retirements merge clean #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 #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


Generated by Claude Code

…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>
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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 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
  • 2 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 6981abfd26cd518174db432766580e37b29a6566 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f3500e547bd489bcbea3b23d51e2642f938c0ebe — the merge of head 63af2cb5f62051dd433904eeff8aac01bf947620 into base 6981abfd26cd518174db432766580e37b29a6566, 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 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

⚠️ 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: 63af2cb5f62051dd433904eeff8aac01bf947620
Local-runs: none

Inputs: card #20574 (body, triage 5884668731, claim 5891509188, dev report 5895025812), #20535 and PR #20572 (the worked example), PR #20685 (body, its one comment, the four-file list, the diff against main at the merge base 9a4b2bb38f), and the head's check-runs, read once at 2026-09-29T17:20:26Z. Everything below is judged from the diff text and the pin's source, not from the dev's prose. The replay comparison is the parent's 18: [ array read from origin/main against the head's entries ordered by order, both read from git objects; nothing was built, run or re-run.

① Derived judgments

  • Application order: right. The parent's 18: [ array (46 identifiers) and MAJOR_18_CONVERSIONS sorted by ascending order are the same 46 identifiers in the same sequence. The orders are exactly the integers 1 to 46, each used once, so the id tie-break is never exercised by the existing entries and the order did not move. The identifiers are strictly sorted in code-unit order, as the doc comment says and the pin checks. The diff has two hunks in registry.ts: one inserts OrderedConversion, inApplicationOrder, the entries list and a doc comment on CONVERSIONS_BY_MAJOR; the other replaces the 18: [ array with 18: inApplicationOrder(MAJOR_18_CONVERSIONS). No conversion id, body or definition moved: all 113 definitions are untouched, and the last one, flowDecisionModeInclusiveExplicit, still precedes the new declarations.
  • Consumers: right. CONVERSIONS_BY_MAJOR[18] keeps its explicit readonly MetadataConversion[] annotation and is a plain array computed at module load, so every reader gets the old shape and value: ALL_CONVERSIONS (flatMap by ascending major), the loader (apply.ts iterates ALL_CONVERSIONS), step 18's conversionIds (the ids mapped off CONVERSIONS_BY_MAJOR[18], an expression untouched in migrations/registry.ts and pinned verbatim by the refactor(spec): step 18 rationale as key-sorted fragments, conversionIds derived, so two retirements merge clean #20572 test), spec-changes.ts, build-upgrade-guide.ts (prints majors up to 17, so no projection moves), and every test that iterates or .finds over the record. MAJOR_18_CONVERSIONS is declared after every definition and before CONVERSIONS_BY_MAJOR, so no TDZ. Nothing consumes order outside the module.
  • Public surface: right. OrderedConversion and inApplicationOrder are module-private; api-surface/root.json and export-origins/root.json are unchanged; the only declaration delta is the new JSDoc on CONVERSIONS_BY_MAJOR. Type Check · consumer gates, which hosts check:api-surface, concluded green on this head.
  • Non-integer order: right. The comparator (order difference, then id) is a total order over finite numbers with a deterministic tie-break; the pin admits any finite positive number and its entry regex accepts N or N.N only. Determinism does not depend on where an entry lands textually. The deviation from refactor(spec): step 18 rationale as key-sorted fragments, conversionIds derived, so two retirements merge clean #20572's integers-only lets an entry apply before an existing one without renumbering its neighbours, which is a smaller diff for a sibling to merge against. Sound.
  • The definitions' insertion point: right, and inside the claim. The card's body names both spots a retirement edits ("appends its conversion there, and adds its definition at the insertion point above"), and the claim's proof is "two synthetic appends exit 0 after the change". The dev measured that a retirement's whole edit still exits 1 when only the list is reshaped, so answering the second spot is what makes the card's proof true of a real retirement, in the same file and the same CONVERSIONS_BY_MAJOR neighbourhood. The answer is a placement RULE (define a new conversion directly above its list successor's definition), stated in the list's doc comment and enforced by the pin for every entry outside a closed exemption set; no existing code moved. Soundness from the code: two retirements with different list successors define above two different definitions, and a definition spans several lines, so git sees two separate insertions; adjacent list gaps are likewise separated by the one existing entry line, and the pin's pair is exactly the two closest different gaps. Two retirements with the SAME successor are already the same-gap pair in the list, so the rule adds no residue beyond the one pinned as a lit control. The rule stays consistent as entries accumulate: an entry inserted between a non-exempt predecessor and its successor lands directly below the predecessor's definition, so the predecessor's own rule still holds. The pin's placementFindings reports a non-exempt entry whose next definition is not its list successor, the lit control asserts the exact message for a tail-defined side, and the exemption set is the 46 pre-existing identifiers exactly (checked name by name against the entries), with its size pinned at 46 and a never-add-a-name note.
  • The pin: right, a test and not a gate. packages/spec/scripts/conversions-major18-merge.test.ts reads the REAL file through maskComments, parses every byte of the entries list (residue fails), and in a hermetic scratch repo with no attributes and no driver runs git merge-tree --write-tree: the sorted pair exits 0, the merged bytes equal both edits applied together, both rules hold on the merged text, and the merged application order is the old 46 followed by the two new ids in id order (both took the same next order, as two PRs cut from one base do). Four lit controls must exit 1 with registry.ts as the conflicted path: same gap, list end, definitions end, and a synthetic old array-plus-tail shape. A regression to the old shape fails the wiring check (18: inApplicationOrder(...) present and no 18: [), an end-append fails the sortedness check, and a tail definition fails the placement check. It is listed in vitest.repo-tests.json (the repo project), which is also where check:cross-package-test-inputs requires it, since it imports scripts/git-env.mjs and scripts/js-comment-mask.mjs from the repo root in the same spelling as the refactor(spec): step 18 rationale as key-sorted fragments, conversionIds derived, so two retirements merge clean #20572 pin. The file list is four paths: no workflow, no scripts/pm or check:* registry, no package.json changed.
  • Gate coverage on the head, read once at 17:20:26Z: 23 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke: roster skips), 7 in progress, 0 failed. The dev's four NOT MEASURED are all answered green by concluded runs: check:doc-formula-expressions and check:api-surface (Type Check · consumer gates), check:dual-build-cjs-loads and check:lean-entry-closure (Build Core), check:type-check-debt (Type Check · debt ledger). Also green: Check Changeset, Governed Surface Queue Guard, Spec property liveness, Type Check · source gates (hosts check:generated --reconcile-only), Test Core 2/6, the dogfood and temporal gates. Not concluded at read time, and not presumed green: Lint & Repo Gates (pnpm lint, check:migration-registry, check:cross-package-test-inputs, check:comment-mask-adoption, check:issue-citations, check:doc-authoring), Test Core 1/6, 3/6, 4/6, 5/6 and 6/6 (spec's test and test:repo, so the pin's own CI run), and Type Check · workspace (spec typecheck, including check:scripts-typecheck over the pin). The dev reports each of these green locally (spec local 575 files / 16,946 passed; repo 44 files / 773 passed with the pin 12 of 12; typecheck exit 0; 80 of 84 dispatch gates exit 0). Landing waits for every check on this head to conclude green, as it always does.

② Semver level

  • .changeset/20574-major18-conversions-sorted-entries.md: '@objectstack/spec': patch. Right. The diff publishes: the source is bundled, so the released tarball's JS moves (the dev measured inApplicationOrder and MAJOR_18_CONVERSIONS in the built dist), while no exported value, type or name changes. skip-changeset is for a diff that publishes nothing from a released package, which this is not; minor would claim a feature that does not exist. The wording states the invariant a consumer cares about (46 conversions, ALL_CONVERSIONS 113, conversionIds value-identical) and then the authoring rule, the same form as the accepted [finding] migrations/registry.ts: every major-18 retirement PR rewrites the closing line of step18.rationale, so any two in flight conflict in GitHub's merge #20535 changeset. No model identifier in it.
  • Clause-②: no. Right, on the PR body's second line, with no arm. No accept set and no reject set moves: the same conversions apply in the same sequence, and the spec's schemas are untouched. Check Changeset concluded green on this head.

③ Boundary flags

Dev deviations, each answered:

  1. Two spots changed (list entries plus the definition placement rule): answered in ①, inside the claim and the card's stated cost, sound, no existing code moved.
  2. order admits non-integers, unlike refactor(spec): step 18 rationale as key-sorted fragments, conversionIds derived, so two retirements merge clean #20572: answered in ①, sound and deterministic, module-private.
  3. The PR body writes plain Clause-②: no although the claim's line carried a parenthetical: right, the parenthetical was the claim's reasoning and not an arm, and clause2-line.mjs would read one as an arm.
  4. Container restart, readings re-run rather than recalled: process note, no diff consequence; CI answers the same gates.
  5. origin/main merged three times, and main has since moved to 6981abfd26 (six commits): checked, none of the six touches conversions/registry.ts, the pin, the repo-tests list or the changeset; GitHub reports the PR mergeable; the three-dot diff is what was reviewed.
  6. Ablations at d316f458a6, not repeated at the head: acceptable, the last merge changed only spec: retire the inline-row decline in page-component-filter-record-to-rule-array once the objectui pin carries objectui#10767 #20305's conversion-body region, which the ablations do not touch, and the pin imports ../src and reads source text, so no dist leg.
  7. Lock hold and background commands: process note.
  8. Push 503 retried: process note.
  9. Commit trailers are the model-free pair AGENTS.md requires: right, the pre-push hook refuses a model identifier there.

open_questions: none.

Out-of-scope findings, dispositions:

  • SKILL.md registration checklist wording (governed, .claude/skills/spec-property-retirement): routed to [finding] spec-property-retirement SKILL.md tells a step-18 author to append to conversionIds and extend the rationale string: both change shape when PR #20572 lands #20575, which already asks to fold this sibling's wording in. Right not to edit it here.
  • The import block of conversions/registry.ts as a third shared spot (8 of 80 commits): leaving it is within the claim, whose surface is CONVERSIONS_BY_MAJOR and its consumers, and the card names the list tail and the definition spot. ESCALATED to the seat as a filing decision, not a defect of this diff: it has a named landing site and a measured reach, so it meets the finding gate's shape if the seat judges the residue worth a card.
  • The pure-schema-construction tsup plugin rewriting strictObject( inside a string literal of one step-18 D3 entry (dashboard-widget-stage-order-non-funnel-refused), so dist text differs from src: unrelated to this diff. ESCALATED to the seat for filing (class a, reach: release text).

No new gate: nothing in the diff adds a workflow step or a check:* script; the two rule checks are assertions in a repo-project test.

Implemented-by: claude/issue-20574-conversions-merge-clean
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 17:38
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit f379f57 Sep 29, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20574-conversions-merge-clean branch September 29, 2026 18:13
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… 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>
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/l tests tooling

Projects

None yet

2 participants