Skip to content

fix(driver-sql): a plain unique index over duplicate rows is loud and non-fatal, and os migrate plan stops calling it safe - #15477

Merged
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-14902-plain-unique-index-duplicate-preflight
Sep 4, 2026
Merged

zhuangjianguo merged 4 commits into
mainfrom
claude/issue-14902-plain-unique-index-duplicate-preflight

Conversation

@zhuangjianguo

@zhuangjianguo zhuangjianguo commented Sep 4, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #14902

A plain unique index over existing duplicate rows no longer kills the boot with the database's raw error, and os migrate plan no longer classifies that op safe.

The defect, in the two shapes the card measured side by side

Same fixture both times — a crm_quote table already holding two rows with the same quote_number, then initObjects declaring that column unique.

Both reproduced on today's origin/main (6ed4b811a, sqlite / better-sqlite3) before a line was written, so this is not inherited from the card's 2026-09-03 reading.

Path A — the NULL-safe organization composite. The boot CONTINUES.

[sql-driver] cannot create NULL-safe unique index 'uniq_crm_quote_organization_id_quote_number' on "crm_quote" — existing rows violate it (duplicates the previous NULL-distinct index admitted, #5030). The constraint 'organization_id, quote_number' is NOT enforced until the data is deduplicated: run "os migrate plan" for the conflicting rows (ADR-0120 D4).

…and detectManagedDrift() — what os migrate plan reports — grades it category: 'destructive', severity: 'error', naming both conflicting key groups with their row counts.

Path B — a PLAIN unique, no organization key part. The boot DIED.

PATH B initObjects threw: create unique index `uniq_crm_quote_quote_number` on `crm_quote` (`quote_number`) - UNIQUE constraint failed: crm_quote.quote_number  (code=SQLITE_CONSTRAINT_UNIQUE)
PATH B logs: []

Nothing on the durability channel, no rows, no remedy — and the same op classified as harmless:

{ "kind": "index_mismatch", "expected": "UNIQUE (quote_number)", "actual": "(absent)",
  "severity": "warning", "category": "safe",
  "message": "crm_quote: metadata declares index 'uniq_crm_quote_quote_number' UNIQUE (quote_number) but the database has no such index — run \"os migrate apply\" to create it." }

Three properties stack, and it is the combination that graded this p1: the boot is down, not degraded; the message is unactionable; and the one instrument an operator would reach for says safe about the op that just killed the boot.

Reachability — precisely, not the card's own wider framing

⛔ NOT "every autonumber field". Since #13894 an autonumber field that omits unique defaults to unique: 'organization', and on an object carrying a tenant column that lands on path A — loud, non-fatal, rows named. Path B is reached by an object with tenancy: { enabled: false }, or by any explicit unique: 'global'. Narrower than the card says, and live: it is the self-hosted upgrade path — a deployment with legacy duplicate rows and a tenancy-disabled object. Both spellings are pinned.

The change

Two body-only edits in packages/drivers/driver-sql/src/sql-driver.ts, plus one module-local string helper.

  • syncDeclaredIndexes absorbs the plain arm the way it already absorbed the NULL-safe one. A uniqueness violation on a plain unique index is logged on the durability channel (error) naming the index, the conflicting key groups with their row counts, the constraint that is NOT enforced, and os migrate plan as the way out — instead of throw e. The unique limb is load-bearing, not decoration: a non-unique index cannot raise a uniqueness violation, so a failure that reads as one while creating one is something else and keeps failing loudly.
  • The ADR-0120 D4 pre-flight covers it. The guard nullSafeColumns.length > 0 moved rather than a second copy of the check appearing beside the first — probeNullSafeUniqueDuplicates already groups by the bare columns when the NULL-safe set is empty, so both key shapes now share one pre-flight, which is what stops them drifting apart again. A plain unique create_index over dirty data grades destructive / error with the same row report. A plain unique recreate_index stays unprobed and is stated as such in the code: it has no tightenNullSafeOnly shape and diffManagedIndexes already grades a unique recreate destructive, so it never carried the safe claim this pre-flight exists to withdraw.

After, same fixture, same tree:

PATH B initObjects threw: NO — boot continued
[sql-driver] cannot create unique index 'uniq_crm_quote_quote_number' on "crm_quote" — existing rows violate it. Conflicting group(s): (quote_number="QUO-00009") × 2 rows. The constraint 'quote_number' is NOT enforced until the data is deduplicated: run "os migrate plan" for the conflicting rows.
{ "expected": "UNIQUE (quote_number)", "actual": "(absent)", "severity": "error", "category": "destructive",
  "message": "crm_quote: cannot create 'uniq_crm_quote_quote_number' as UNIQUE (quote_number) — existing rows already violate it: (quote_number=\"QUO-00009\") × 2 rows. The op is BLOCKED: neither \"os migrate apply\" nor dev autoMigrate: 'safe' will create it, so the constraint is NOT enforced. Deduplicate the listed rows, then re-run \"os migrate plan\" (ADR-0120 D4)." }

The plain message deliberately does not borrow path A's story: no earlier index admitted these rows, so #5030 is not what happened here, and the pins assert its absence.

Path A is pinned as the CONTROL, and measured unchanged

A change that quietly moved path A while making path B loud would read as success. Path A's log and its drift entry were captured before and after on the same fixture and are byte-identical (md5 9cee6d7603bd154b390b893bf4ab8a82 both runs), and the last block of the new suite asserts path A's own message, its #5030 / ADR-0120 D4 framing and both of its key groups.

A retired pin, re-authored rather than deleted

sql-driver-unique-violation-predicate.test.ts carried leaves the nullSafe.size > 0 guard intact — a plain unique still fails the sync, on the reasoning that absorbing it "would silently ship an unenforced constraint the drift pre-flight was never told about". That reasoning was right, and its premise is exactly what this PR removes — the pre-flight is now told. So the invariant it defended survives verbatim (never absorb SILENTLY) and only its disposition moved; the block now asserts the loud half, against a REAL table so the row report is a measurement, and a new sibling pins that a non-unique index failure is still never absorbed.

Docs round — one page was genuinely falsified, seven were noise

The docs-drift bot listed eight hand-written pages. Audited rather than skipped:

  • Seven dismissed as a group — data-modeling/drivers.mdx, data-modeling/index.mdx, permissions/tenant-audit-census.mdx, plugins/packages.mdx, protocol/kernel/index.mdx, protocol/kernel/lifecycle.mdx, protocol/objectql/query-syntax.mdx. All match on the bare class name SqlDriver (1 to 20 hits each) and carry zero occurrences of create_index between them; query-syntax.mdx's one category hit is category_id in a filter example, and drivers.mdx's one NULL-safe hit is about platform-table index migrations, not this classification.
  • content/docs/releases/v17.mdx is release-owned and read-only. Checked anyway: its category hits are all tool.category, a retired spec key. Nothing to escalate, and nothing edited there.
  • content/docs/deployment/cli.mdx was falsified, in the page an operator reads before running the command. Three cells changed, table not restructured:
    • the safe row listed "create a declared index" flat, promising auto-apply for exactly the case this branch now blocks — carved out to the UNIQUE sub-case;
    • create_index's row said only that the index is missing, and now carries the pre-flight sentence, matched to recreate_index's existing wording one row down ("block the op with a report instead of failing a boot") rather than given a new shape — the doc edit is the same parity edit as the code;
    • the destructive row gains the counterpart example, because that row is where an operator looks to find out why the op they expected to be safe is not.

Clause ② — enumerated, not assumed

The standing check is "doesn't touch packages/spec" is not "doesn't widen the public surface" — enumerate every published package the surface touches and diff its entry point.

  • Published packages touched: exactly one, @objectstack/driver-sql. Its files ships dist only, so the two test files are not published; .changeset/*.md and content/docs/** release nothing.
  • Entry point: packages/drivers/driver-sql/dist/index.d.ts, built from this branch and from origin/main 6ed4b811a and diffed. SqlDriver's .d.ts carries its protected members, so this was a real risk — which is why the row formatter is a module-local function rather than a method.
  • Result: the declaration inventory is identical, 830 lines to 830 lines, diff clean. The only .d.ts delta is JSDoc prose carried through by tsup. The filter that produced that empty result was fired against a deliberately re-signed copy of the same file first, so the emptiness is a measurement and not a dead filter.

⇒ Clause ② stays no. No published symbol added, removed or re-signed. Draft PR, no needs:contract-review.

What a consumer of the classification now sees

Measured by reading every in-repo consumer of ManagedDriftEntry.category, not assumed:

consumer before (plain unique over duplicates) after
os migrate plan / os diff (groupByCategory, renderPlan) printed green under "Safe (loosening…)" printed red under "Destructive (requires --allow-destructive)"
os migrate apply (intended / deferred) attempted it, died on the raw driver error deferred, and even --allow-destructive reports it skipped with no index created
artifact boot gate (artifact-boot-migration.ts) attempted it refuses with the named destructive-drift refusal
dev autoMigrate: 'safe' (reconcileAndWarnDrift) attempted it leaves it alone

os migrate plan does not set a non-zero exit on destructive entries, so no exit status changes. Clean data is entirely unaffected: the probe finds nothing, the index is created exactly as before, and the pre-flight adds one grouped SELECT only for an op that would create a unique index the database does not have.

Verification

Union re-run on the final commit — git rev-parse --short HEAD = 2721a7e2b (origin/main merged in twice as this landed).

  • pnpm --filter @objectstack/driver-sql exec vitest run — 154 passed, 9 skipped, 2357 tests passed, 0 failed.
  • pnpm --filter @objectstack/driver-sql typecheck — exit 0, and tsc --listFiles confirms all three edited source files are in that program (1 hit each), so "typecheck is clean" actually covers the new tests.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 73 families once the docs edit landed — up from 46 before it, which is exactly why the derivation was re-run against the real diff rather than trusted from the first pass. Asserted against the Reconciliation line's 73. All 73 run at 2721a7e2b: 73/73 exit 0, each exit code captured before any pipe. Two of them (check:dual-build-cjs-loads, check:type-check-debt) first answered exit 3 = PREREQUISITE NOT MET = NOT MEASURED; the closure was built and they were re-run to a real green.
  • pnpm check:durability-log-level — exit 0, run although the derivation does not name it, because this diff adds a logDurabilityFailure call site: "29 durability-critical catch seam(s), all loud".
  • pnpm lint — the full repo-wide eslint . --no-inline-config, exit 0. No narrowing claimed and none needed.
  • Ablation: sql-driver.ts reverted to origin/main's blob (mutation confirmed on disk by git hash-object equality with the base blob and inequality with the pre-mutation hash, plus marker counts falling to 0 against a firing control), the package rebuilt on both legs, 7 of the new assertions turn RED, then restored — git diff HEAD empty, on-disk hash equal to the HEAD blob, rebuilt dist/index.d.ts byte-identical to the pre-ablation build. The suite resolves the driver through ../src/index.js, and the ablation is itself the proof: source-only edits changed the verdict with no rebuild in between.

sqlite (better-sqlite3) is a real driver, not a double — the duplicate rows, the failing CREATE UNIQUE INDEX and the row report are all measured against a live database. ⛔ Nothing pins the database's raw error text: the catch shape is dialect-independent, that text is not.

Known gap, deliberately not fixed here and filed as #15479: the MySQL hash-shadow arm of the same catch (#11627 / #12998) still guards on nullSafe.size > 0, so a plain unique whose shadow ALTER finds duplicates keeps taking the boot down. It is the same defect class, but it is reachable only on a live MySQL with a key part over the 768-char ceiling, and this container has none — an unmeasurable change to a durability path is worse than a named gap.

… non-fatal, and `os migrate plan` stops calling it `safe`

Declaring a column unique over a table that already holds duplicates had two
outcomes depending on one branch, and only one of them was survivable.

An organization-scoped unique (the NULL-safe COALESCE composite) kept the boot
up, logged at `error` naming the index, the unenforced constraint and the
remedy, and the ADR-0120 D4 pre-flight reported the blocked `create_index` as
`destructive`/`error` with the conflicting key groups and row counts.

A PLAIN unique — no organization key part, reached by `tenancy: { enabled:
false }` or an explicit `unique: 'global'` — took the process down:
`initObjects` threw the database's own error, naming the index and the column
and no rows and no remedy, nothing reached the durability channel, and
`detectManagedDrift` classified the same op `safe`/`warning`, so `os migrate
apply` and dev `autoMigrate: 'safe'` walked into the raw failure.

The plain path now reaches parity:

- `syncDeclaredIndexes` absorbs a uniqueness violation on a plain unique the
  way it already absorbed one on the NULL-safe composite — durability channel,
  conflicting groups with row counts, the constraint named as NOT enforced, and
  `os migrate plan` as the way out. The `unique` limb is load-bearing: a
  non-unique index cannot raise a uniqueness violation, so a failure that reads
  as one there is something else and keeps failing loudly.
- The D4 pre-flight no longer skips ops with an empty NULL-safe column set, so
  a plain unique `create_index` over dirty data grades `destructive`/`error`
  with the same row report. Nothing new probes it: the existing probe already
  groups by the bare columns when there is no NULL-safe key part, so the guard
  MOVED rather than a second copy of the check appearing beside the first.

Path A is pinned unchanged as the control. The retired assertion in
`sql-driver-unique-violation-predicate.test.ts` is re-authored, not deleted: its
reasoning was "absorbing it would silently ship an unenforced constraint the
drift pre-flight was never told about", and the pre-flight is now told — so the
invariant it defended (never absorb SILENTLY) is asserted on the loud half.

Closes #14902

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
@github-actions github-actions Bot added the size/l label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 5 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/deployment/cli.mdx (via create_index (literal, a string literal in applyNullSafeUniquePreflight))
  • content/docs/permissions/tenant-audit-census.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx (via SqlDriver (symbol, a top-level class))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx (via SqlDriver (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 10 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 845d767c47b0784e5b839132c7608b1d48bd8c60 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9c77e847d18236baf9bd426386ef8a270204b742 — the merge of head 2721a7e2bdd98bf78b544612008728360eebb976 into base 845d767c47b0784e5b839132c7608b1d48bd8c60, 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 9c77e847d18236baf9bd426386ef8a270204b742 && git checkout 9c77e847d18236baf9bd426386ef8a270204b742
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 845d767c47b0784e5b839132c7608b1d48bd8c60 2721a7e2bdd98bf78b544612008728360eebb976 && git checkout -B drift-repro 845d767c47b0784e5b839132c7608b1d48bd8c60 && git merge --no-ff 2721a7e2bdd98bf78b544612008728360eebb976

node scripts/docs-audit/affected-docs.mjs --json 845d767c47b0784e5b839132c7608b1d48bd8c60

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 845d767c47b0784e5b839132c7608b1d48bd8c60 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…for a UNIQUE index create the data blocks

The docs-drift bot listed eight hand-written pages against this branch. Seven
are noise — they match on the bare class name `SqlDriver` and carry no drift
classification at all (0 hits for `create_index` between them) — and
`content/docs/releases/v17.mdx` is release-owned and read-only; its `category`
hits are `tool.category`, a spec key, not this one.

`content/docs/deployment/cli.mdx` is genuinely falsified, and in the one place
an operator reads before running the command:

- The `safe` row of the category table listed "create a declared index" flat, so
  it promised auto-apply for exactly the case this branch now blocks. Carved out
  to the UNIQUE sub-case, in the row's existing voice.
- `create_index`'s "what it means" row said only that the index is missing. It
  now owes the pre-flight sentence, and the page already had the right words one
  row down: `recreate_index` documents the tightening's probe as "block the op
  with a report instead of failing a boot". `create_index` is matched to that
  sentence rather than given a new shape — the doc edit is the same parity edit
  as the code.
- The `destructive` row gains the counterpart example, because that row is where
  an operator looks to find out why the op they expected to be safe is not.

The table is not restructured; three cells changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ

Copy link
Copy Markdown
Collaborator Author

Attribution, recorded here once because the body edit dropped it. The description was created with the session-URL footer and read back intact; the single PATCH that added the docs-round section removed the whole footer block — rule line included — rather than downgrading it to the bare form. ⛔ Not re-posted in a loop against the body; the durable record lives in this comment instead.

Authored by the domain:engine execution seat on #14902 — session session_01ARYe3yQTQCUFm5qPYNgKaJ, branch claude/issue-14902-plain-unique-index-duplicate-preflight, verified at 2721a7e2b. Out-of-scope finding filed as #15479.


Generated by Claude Code

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

ACCEPT — landing. Clause-② no is now grounded in the written negative boundary, not in the report's word

Reviewed by the domain:engine execution seat (card #14902, priority:p1, claim 5543373126).

The one thing I re-derived rather than inherited

The report grades Clause-②: no on a published-symbol measurement. I did not take that as the
whole question, because this PR does change accept/reject behaviour — an artifact boot that
used to crash now refuses, and os migrate apply defers an op it used to attempt. So I read the
rule instead of recalling it. .claude/skills/pm-dispatch/SKILL.md carries an explicit negative
boundary (维护者 2026-08-28 裁定):

运行时权限/安全行为变更不是条款② …… 条款②只指已发布契约面(两独立席位曾同错读,故明写)

⭐ "Two independent seats once read this wrong, which is why it is written down." The clause is
bounded to the published contract surface; a runtime behaviour change is not it. packages/spec/src/**
is untouched by this diff (path limb), and the declaration limb was measured, not asserted:
packages/drivers/driver-sql/dist/index.d.ts built from this branch vs from 6ed4b811a is
830 lines vs 830 lines, diff clean, the only delta being JSDoc prose. ⇒ Clause-②: no stands
on both limbs.

I corroborated the mechanism behind that identical .d.ts from the diff itself rather than from
the built artifact alone: SqlDriver's declaration carries its protected members, so a new
method would have moved a published entry point. The only declaration this PR adds is
function formatDuplicateGroups(...) — module-local, not exported, not a class member. Nothing
else at declaration level is added anywhere in sql-driver.ts.

⚠️ And the emptiness that proves the .d.ts claim is a measurement, not a dead filter: the
report fired the comment-stripping filter against a deliberately re-signed copy of the same file
first, and it did report the changed signature. A filter that cannot fail proves nothing.

Landing preconditions, each measured now and not recalled

precondition reading
CI green, all checks not a required subset 37/37 completed at 2721a7e2b — 35 success, 2 skipped, 0 failing, 0 in flight
legacy commit statuses (a check_suite rollup does not cover these) queried separately: state: success, Vercel success
needs:contract-review clear on both carriers card #14902 and PR #15477 — absent on each, read directly
governed-surface predicate, on the FINAL file list 0 of 5 path(s) hit the register (5 surfaces) ⇒ ✅ NOT governed
⭐ that gate's firing positive control --test docs/adr/0094-x.md ⇒ 1 of 1, ⛔ GOVERNED, exit 3 — the gate can say no, so its "yes" carries information
mergeable_state mergeable: true / clean
generated-artifact sync (landing-operations §A) does not apply: git check-attr merge on all five files returns unspecified, against a firing control (content/docs/permissions/system-context.mdx ⇒ merge: os-regen)
content/docs/releases/** not in the diff — read-only, as it must be

The gate ran before the flip and on the final five paths, because the register has grown
several times in two days and an earlier reading would be recall.

What I checked in the work itself

  • The ablation is a real one. Mutation proven on disk — the restored blob hash equals the
    base blob and differs from the pre-mutation hash, so a silent no-op would have failed loudly —
    and marker counts fell to 0 against a firing control (probeNullSafeUniqueDuplicates: 4 on the
    same file). 7 assertions turn red; the restore leg is proven by an empty git diff HEAD, an
    equal on-disk hash, and a byte-identical rebuilt .d.ts.
  • Path A is a control, not a hope. Its whole block — durability log plus drift entry — is
    byte-identical before vs after (md5 9cee6d76… on both captures). The change is confined to the
    arm it claims.
  • Two gates first answered exit 3 and were not read as passes. check:dual-build-cjs-loads
    and check:type-check-debt both said PREREQUISITE NOT MET; the closure was built and both
    re-run to a real green (14 ledger entr(ies) re-measured … none above its recorded number).
    exit 3 is a NOT-MEASURED, never a pass, and it was handled that way.
  • The typecheck green was shown to cover the new tests rather than assumed to:
    tsc --noEmit --listFiles puts all three edited source files in the program.
  • The pre-existing assertion that pinned the old behaviour was re-authored, not deleted. Its
    invariant was "never absorb silently"; the pre-flight is now told, so the invariant is
    re-asserted on the loud half, with a new sibling pinning that a non-unique index failure is
    still never absorbed. Deleting it would have been the wrong move and it was not made.
  • A live driver, not a double, for the reproduction: real duplicate rows, a real failing
    CREATE UNIQUE INDEX, a row report read back from a real GROUP BY. Nothing pins any dialect's
    raw error text — the catch shape is dialect-independent, the text is not.

⚠️ One consequence I am recording rather than burying

The dispatch asked whether anything gates on os migrate plan's classification. The answer is
not "presentational" — five consumers read .category, and four read it behaviourally.
The sharpest is packages/cli/src/utils/artifact-boot-migration.ts: an artifact boot over a dirty
table now returns ok: false with a named destructive-drift refusal where it previously attempted
the create and died on the raw error.

That is the direction the card asks for — the instrument stops calling safe the thing that kills
the boot — and clean data is untouched in all five (the probe finds nothing, the entry keeps its
default safe). ⭐ But anyone watching a crash-count metric will see it go to zero for a reason
other than the data being fixed
, and that is worth knowing before it is discovered from a graph.
The changeset states the consumer-visible move explicitly, which is where it belongs.

⛔ This is not a design fork and does not go to the decision box: refusing with a named refusal is
what the already-ruled scoped path does, and matching it is the fix.

Bump level

@objectstack/driver-sql: patch. Under the level rule landed as b337a1308, a fix( that changes
no public surface stays patch, and the surface was measured identical above. No published symbol
is added, removed or re-signed, so nothing here reaches minor.

Disposition

Flipping ready and arming auto-merge. Closes #14902 is correct and intended — the card's whole
ask is discharged, with the MySQL hash-shadow arm of the same catch filed separately as #15479
rather than fixed blind: that branch is reachable only on a live MySQL past the 768-char key
ceiling, this container has none, and an unmeasurable change to a durability path is worse than a
named gap.
That call was right.


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 4, 2026 18:06
@zhuangjianguo
zhuangjianguo added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit 61821e5 Sep 4, 2026
39 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14902-plain-unique-index-duplicate-preflight branch September 4, 2026 18:32
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… and ADR that decided them (stage 4 of objectstack-ai#20595) (objectstack-ai#21357)

Part of objectstack-ai#20595
Clause-②: no

## What changed

Stage 4 of the `domain:engine` lane of the dead-citation sweep:
`packages/drivers/driver-sql/**`, comment and docblock prose only, per
the claim (`5946343111`). Stages 1 to 3 landed as `a7d9768ec`,
`d150c3039` and `4bf4e7e70`; objectstack-ai#20595 stays open for the next stage.

Every comment or docblock site in the package that cited a tracker
number answering 404 is rewritten in ruling C+D's form C (record
`5749154545` on objectstack-ai#19123): the ADR when one records the decision,
otherwise the commit in this repository's history that made it. That is
**291 sites on 287 lines in 54 files, covering 39 numbers**, plus one
dead comment-id citation on two lines:

- **126 census sites** (122 lines, 6 files under `src/`): the whole
`allocated-but-absent` population of the gate's own census in this
package at the base, the slash-joined `objectstack-ai#7737/objectstack-ai#10629` and
`objectstack-ai#14079/objectstack-ai#15683/objectstack-ai#17343` from the post-landing census (`5923084795`, now
at `sql-driver.ts:12236` and `:16167`) included. That census's third
driver-sql site, the URL-spelled `objectstack-ai#17590` at `:3933`, left the file in
`58a77dbde2` before this base;
- **165 test-comment sites** (165 lines, 48 test files), which the
census defers. They carry 37 numbers: 24 the census itself reads as dead
in this package's `src`, 5 more it reads as dead elsewhere in the
repository, and 8 it never judges (they stand only in test files), which
the board and a single read each settle;
- **the dead comment id `5448627494`** (on objectstack-ai#11152, which is live), on
two test-comment lines.

**Anchors: 38 numbers by commit, 1 by ADR (ADR-0104's 2026-09-05
addendum), 0 by words alone; the comment id by commit.** 16 numbers
reuse the anchor another lane or stage already measured for them, 23
were measured here (one of them split across two commits), and the
comment id was measured here. 39 distinct shas.

Only comments changed. Every file keeps its line count (291 lines out,
291 in, plus the changeset), so no line citation into any of them moves.
No code token moves (the guard below). **No citation number is added**:
on every changed line, the numbers on the new text are a subset of those
on the old, and the diff-scoped gate judged the 10 citations left on
changed lines: all 10 resolve.

**A `patch` changeset**: 57 of the 122 rewritten non-test lines are in
the published `dist` (the `.d.ts` keeps JSDoc on exported members, and
esbuild keeps some comments in the JS), and `dist` is not byte-identical
with the base text (see Changeset).

## Census: `driver-sql`, before and after

**Instrument (A1).** The gate's own `node
scripts/check-issue-citations.mjs --census --json`, read-only and
unchanged. The count is its `allocated-but-absent` findings under
`packages/drivers/driver-sql/`.

| reading | tree | board | whole-repo `allocated-but-absent` | sites |
lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `222ecc27f`, run 05:52:18Z to 05:55:43Z | enumerated,
192 pages, frontier objectstack-ai#21345, 19,166 records (newest number read before
and after the run: objectstack-ai#21345) | 456 | **126** | 122 | 6 | 26 |
| after | `286316ebd`, run 06:17:50Z to 06:21:10Z | enumerated, 192
pages, frontier objectstack-ai#21352, 19,173 records (newest before: objectstack-ai#21351, after:
objectstack-ai#21352) | 330 | **0** | 0 | 0 | 0 |

The whole-repo drop is 126, and the two finding sets differ by exactly
the 126 rows of this package, removed; none was added. `resolves`
(34,789), `resolves-as-pull-request` (2,378) and `cross-repo-unjudged`
(1,155) did not move. The card's 128 was taken at `f11b5f20a2` with the
older extractor; the base here reads 126. The head's later commits are a
merge of `main` that touches no file under `packages/drivers/driver-sql`
and the changeset (outside the census surface).

**Supplementary instrument, the whole package.** The census reads
neither test files nor strings nor files outside `src`. A second reading
runs the gate's own exported `extractCitations` (whole-file and
comment-prose projections) over every tracked file in the package (238
`.ts`, `package.json`, `tsconfig.json`, `README.md`, `CHANGELOG.md`,
`LICENSE`), and classifies each citation with the gate's
`classifyCitation` against one board enumerated by the gate's
`enumerateBoard` (192 pages, frontier objectstack-ai#21346, 19,167 records, 05:56:41Z
to 05:59:55Z). Every one of the 39 numbers in the population was then
read on its own over the issues endpoint: **all 39 answer 404**, and the
lit controls `objectstack-ai#5286` and `objectstack-ai#12624` answer 200.

| reading | citations | dead | src comment | test comment | test string
| changelog |
|---|---|---|---|---|---|---|
| before, `222ecc27f` | 4,920 | **411** | 126 | 165 | 51 | 69 |
| after, working tree at `286316ebd` | 4,629 | **120** | 0 | 0 | 51 | 69
|

The drop of 291 citations is exactly the rewritten sites, and the live
counts did not move (src comment: 1,352 resolve, 24 as pull requests;
test comment: 1,545 and 69). A third, raw reading (every `#` followed by
2 to 6 digits, whatever surrounds it) counts 4,939 before and 4,654
after: a drop of 285, which is the 291 less the 6 URL-spelled sites that
carry no `#`.

**Comment ids.** Nine distinct comment-id citations stand on 19 sites in
this package (`CHANGELOG.md` aside). Each was read over the
issue-comments endpoint: `5448627494` answers 404 (it was a comment on
objectstack-ai#11152, which itself answers 200), and the other eight answer 200
(`5302931807`, `5404884704`, `5556979386`, `5618311630`, `5861435168`,
`5865693155`, `5881556735`, `5934879010`; control `5946343111`, 200). So
the one dead id is in the population. `5642107998`, the ruling record
`e04a0aff2`'s message names for objectstack-ai#17590, also answers 404; it is not
cited in this package.

## Per-number table

`src` counts census sites, `test` counts test-comment sites. Every sha
below matches exactly one commit (`git rev-parse --disambiguate`, count
1) and is an ancestor of the merge base `04f0cc499` (`git merge-base
--is-ancestor`, exit 0 for all 39 shas; the clone is not shallow). The
`+` lines introduce exactly these 39 nine-hex spans and no other. The
message or the diff of each one names the number it replaces: 23 in the
message and the diff, 11 in the diff alone (`objectstack-ai#11065`, `objectstack-ai#11374` for
`d0e3a885b`, `objectstack-ai#12978`, `objectstack-ai#12999`, `objectstack-ai#13015`, `objectstack-ai#13324`, `objectstack-ai#14438`, `objectstack-ai#14628`,
`objectstack-ai#16649`, `objectstack-ai#16711`, `objectstack-ai#17586`), 3 only in the subject's squash suffix
(`objectstack-ai#6076`, `objectstack-ai#14434`, `objectstack-ai#17876`, where the dead number was that pull
request's own and the commit is its squash), and one exception,
`objectstack-ai#10629`, explained under Wordings to check. `f6fa22ce1`'s diff names
the comment id three times. Where a sentence credits a ruling, a
measurement, a review or a note to the number, the anchor's own message
or diff carries it (checked per site; the ones that needed a reworded
sentence are listed below). `source` says whether another lane or stage
already used this anchor for this number (`reused`) or it was measured
here (`measured`).

| number | src | test | anchor | kind | source | what it decided |
|---|---|---|---|---|---|---|
| `objectstack-ai#6075` | 1 | 3 | `d367f03d6` | commit | measured | five drivers'
query parameters follow `DriverQuery` |
| `objectstack-ai#6076` | 0 | 1 | `6513c1749` | commit | measured | `IDataDriver`'s
query parameter becomes `DriverQuery` (that pull request's squash) |
| `objectstack-ai#8778` | 0 | 2 | `7901b2dd2` | commit | reused | stamp-only
`tenancy.organizationField` |
| `objectstack-ai#8823` | 0 | 3 | `4dfa369a9` | commit | reused | drop the caller
value MySQL inlines in its duplicate-entry diagnostic |
| `objectstack-ai#9542` | 5 | 6 | `8bbf45947` | commit | measured | bound the
metadata-lock wait on boot schema-sync's MySQL widening ALTER too,
keeping boot's swallow |
| `objectstack-ai#10165` | 0 | 1 | `801296050` | commit | reused | lifecycle
`ttl.onlyWhen` row filter with the canonical null predicate |
| `objectstack-ai#10629` | 1 | 0 | `199ec4712` | commit | reused | bind federated
objects whatever the boot order (stage 3's anchor; the live objectstack-ai#7737
carries the citation) |
| `objectstack-ai#10836` | 0 | 2 | `7ab286e44` | commit | measured | live pg + mysql
legs for the `ttl.onlyWhen` `$null` suite |
| `objectstack-ai#11065` | 0 | 1 | `20950404c` | commit | reused | count a boolean
aggregand as 1/0 in avg and sum |
| `objectstack-ai#11067` | 10 | 10 | `479fba50d` | commit | measured | stamp
`updated_at` when the driver never ran DDL |
| `objectstack-ai#11374` | 16 | 7 | `d0e3a885b` + `107bb4ba4` | commit | measured |
emit `varchar(maxLength)` for a text field a declared index keys on /
carry an over-long UNIQUE index on a hash-shadow column, the route the
2026-08-24 ruling chose |
| `objectstack-ai#12380` | 9 | 13 | `4045b954d` | commit | reused | make the SQLite
`Field.json` codec injective, one encoding across all three dialects |
| `objectstack-ai#12978` | 0 | 2 | `e4902d2b9` | commit | reused | declare sourced
`maxLength` on the keyed text columns of the `sys_notification_*`
objects |
| `objectstack-ai#12998` | 19 | 7 | `df1c75c4b` | commit | measured | hash-shadow
UNIQUE indexes carry the NULL-safe organization key part (ADR-0120 D3) |
| `objectstack-ai#12999` | 3 | 1 | `ebcc34e89` | commit | measured | name the real
remedy when a bounded field sits over a stale TEXT column |
| `objectstack-ai#13015` | 17 | 6 | `cd1348802` | commit | measured | a shadow-carried
UNIQUE is not index drift, and its remedy dropped the constraint |
| `objectstack-ai#13279` | 1 | 1 | `6a180e42d` | commit | reused | fail loud when a
permission-store read fails; it moved the classifier pins into
`driver-error-classification.test.ts` |
| `objectstack-ai#13324` | 1 | 4 | `4cda78c9b` | commit | reused | require a
missing-table error to name the table that was read |
| `objectstack-ai#14434` | 0 | 1 | `93940d492` | commit | measured | declare the
not-found arm on `IDataDriver.update()` (that pull request's squash) |
| `objectstack-ai#14438` | 1 | 3 | `2200f8ec8` | commit | measured | `update()`
publishes the contract's record-or-null, not `any` (the squash of PR
objectstack-ai#15280) |
| `objectstack-ai#14628` | 1 | 1 | `6392b9c2b` | commit | measured | budget the 8th
live-cell hook, reached through `rawDriver()` |
| `objectstack-ai#14902` | 7 | 9 | `61821e54c` | commit | reused | a plain unique
index over duplicate rows is loud and non-fatal (the squash of PR
objectstack-ai#15477) |
| `objectstack-ai#15041` | 2 | 5 | ADR-0104, 2026-09-05 addendum | ADR | reused | the
media family's column holds the bare `sys_file` id; the step's abort
requirement, its SQL sketch, and the generator as the side that does not
move |
| `objectstack-ai#16570` | 0 | 5 | `b72226f48` | commit | measured | declare the
`indexes` key `initObjects` / `registerObjectMetadata` already read |
| `objectstack-ai#16609` | 1 | 5 | `78bc4ad58` | commit | measured |
`findWithWindowFunctions` presents its rows like every other read door,
and the alias-collision ruling |
| `objectstack-ai#16619` | 2 | 1 | `45cfa1b88` | commit | measured | canonical ISO-Z
read presentation (that pull request's squash; its message records the
contract review) |
| `objectstack-ai#16649` | 1 | 0 | `613bfbd3d` | commit | reused | register the
remaining `door: 'none'` codes, re-registering
`MONGODB_MULTI_TENANT_UNSUPPORTED` |
| `objectstack-ai#16657` | 0 | 1 | `5a95b0e93` | commit | reused | read the dialect
text out of `cause` for operator-facing records |
| `objectstack-ai#16711` | 2 | 5 | `7862fb711` | commit | reused | object-definition
parameters declare the keys they are read for |
| `objectstack-ai#16729` | 1 | 1 | `0f38ab084` | commit | reused | an explicit tenancy
opt-out survives a partial `syncSchema` re-registration |
| `objectstack-ai#17343` | 3 | 8 | `82cb69fed` | commit | measured | a `multiple:
true` boolean column keeps its `$contains` membership filter |
| `objectstack-ai#17586` | 4 | 2 | `d46deba19` | commit | measured | keep multi-valued
boolean/toggle columns out of the read-coercion registry |
| `objectstack-ai#17590` | 5 | 20 | `e04a0aff2` | commit | reused | compile
`$contains` on a JSON column as a per-dialect MEMBERSHIP test; its
message records the director's 2026-09-12 Ruling A |
| `objectstack-ai#17639` | 5 | 10 | `7c2c5aedd` | commit | measured | envelope the
`distinct()` backend fault |
| `objectstack-ai#17690` | 4 | 8 | `be5c60291` | commit | measured | eight more
`IDataDriver` doors publish their declared return type |
| `objectstack-ai#17857` | 4 | 2 | `9ccc4179e` | commit | measured | attribute an
unresolvable `distinct()` column to the clause the caller named |
| `objectstack-ai#17876` | 0 | 2 | `be5c60291` | commit | measured | that pull
request's squash, which installed the `ContainsAny` detector |
| `objectstack-ai#17879` | 0 | 4 | `eb9334915` | commit | measured | measure the
`ContainsAny` phantom-leg sweep across eight door pins |
| `objectstack-ai#17970` | 0 | 2 | `47e6601c5` | commit | measured | collapse
`ContainsAny`'s distributivity so union-shaped doors assert |
| comment `5448627494` | 0 | 2 | `f6fa22ce1` | commit | measured | the
2026-08-28 maintainer ruling: min/max over booleans answer numbers on
every face, superseding objectstack-ai#11249's false/true |

## Wordings to check

Most rewrites swap a tag in place (`[#N]` to `[commit SHA]`, `(#N)` to
`(commit SHA)`, `#N's X` to `commit SHA's X`, `PR #N` to its squash
commit, a URL `@see` to `@see commit SHA`), the form the landed stages
use. These say more than the tag:

- **`objectstack-ai#10629`, one site** (`sql-driver.ts:12236`): 「the same ruling
objectstack-ai#7737/objectstack-ai#10629 already made for FEDERATED objects」 became 「the same ruling
objectstack-ai#7737 already made for FEDERATED objects (commit 199ec47)」, stage 3's
judged form (contract review `5943182373`): `199ec4712` is objectstack-ai#7737's fix
and its message states the ruling; it names objectstack-ai#7737, not objectstack-ai#10629.
- **`objectstack-ai#11374`, split by subject.** 20 sites describe the keyed-text rule
(`varchar(maxLength)` for a field a declared index keys on, the shard
path, `boundedObject()`, the named refusal): `d0e3a885b`, the driver
commit whose diff names objectstack-ai#11374 fourteen times and introduced
`explainUnkeyableTextColumn` and `boundedObject()`. 3 sites credit 「the
maintainer's 2026-08-24 ruling」 that chose the hash route: `107bb4ba4`,
whose message moves 「objectstack-ai#11374's refusal pins」 and records 「the ruling
deliberately replaced」 and 「the prefix constraint the ruling rejected」.
Its message does not carry the date, so those sites keep their date and
read 「landed as commit 107bb4b」 / 「recorded in commit 107bb4b」
(stage 1's `objectstack-ai#9741` precedent). Other lanes anchored objectstack-ai#11374 to their own
packages' bound declarations (`e4902d2b9`, `f64668d3c`, `3954fb7df`), a
different subject; the driver's own rule is `d0e3a885b`.
- **`objectstack-ai#15041`, seven sites**: 「the objectstack-ai#15041 addendum」 / 「the ruling on
objectstack-ai#15041」 became 「the ADR-0104 2026-09-05 addendum」 / 「the ruling in
ADR-0104's 2026-09-05 addendum」, the spelling stage 3 and the cli lane
used. The addendum names objectstack-ai#15041 as its provenance and carries each claim
the sites make: the abort-on-the-first-non-JSON-string requirement, the
`USING (col #>> '{}')` sketch, and 「the driver is the side that moves」
for the generator. `sql-driver-15989-file-family-bare-id.test.ts:7`
keeps the maintainer's verbatim quote untouched (it carries no `#`).
- **The comment id**
(`sql-driver-11635-boolean-aggregand-answers.test.ts:9`,
`sql-driver-11782-boolean-row-read-presentation.test.ts:36`-`:37`):
「applied in its comment 5448627494」 became 「landed as commit f6fa22c」.
`f6fa22ce1` applies 「the 2026-08-28 maintainer ruling (option A,
superseding objectstack-ai#11249's false/true)」 to driver-sql's result presentation,
and its diff names the comment id. objectstack-ai#11152 stays.
- **`objectstack-ai#17590` where it was written while the card was open** (the `17639`
and `17857` suites, `sql-driver.ts:11060`,
`sql-driver-17586-…test.ts:101`): those sentences park the json-column
`distinct()` question 「with objectstack-ai#17590」. `e04a0aff2` closed that card with
Ruling A on `$contains` only, so a bare swap would credit it with a
`distinct()` verdict it never made. They now read 「the card commit
e04a0af closed, which owned the sibling divergence」, 「if a card after
commit e04a0af rules that a json column should ANSWER a distinct read」
(both retirement clauses), 「the ruling commit e04a0af records cannot
move it」, 「that question belonged to the card commit e04a0af closed」,
「Nothing here touches the card commit e04a0af closed」 and 「the
divergence commit e04a0af ruled on the filter side」.
`sql-driver-17639-distinct-fault-envelope.test.ts:206` is the **one
changed line that carries no number**: its verb (「owns」 to 「owned」) had
the number on `:205` as its subject.
- **Where the number named the defect, not the change**: 「## Not objectstack-ai#11067」
became 「## Not the defect commit 479fba5 fixed」; 「objectstack-ai#13015 was the price
of the split」 became 「The defect commit cd13488 fixed was the price of
the split」; 「objectstack-ai#17343: a `multiple: true` BOOLEAN column lost …」 became
「The defect commit 82cb69f fixed: …」; 「The defect that produced the
table (objectstack-ai#12380)」 became 「(fixed in commit 4045b95)」 and 「let objectstack-ai#12380
survive」 became 「let the defect commit 4045b95 fixed survive」; 「family
as objectstack-ai#11067 / objectstack-ai#11176 / objectstack-ai#11223」 became 「family as objectstack-ai#11176 / objectstack-ai#11223 / the one
commit 479fba5 fixed」.
- **`pre-` / `post-` spellings**: 「a pre-objectstack-ai#12998 shadow」 became 「a shadow
from before commit df1c75c」 (and the same shape for `objectstack-ai#12380` and
`objectstack-ai#17590`); 「post-objectstack-ai#12998」 became 「since commit df1c75c」; 「The
pre-objectstack-ai#12998 shadow over RAW columns」 became 「The older (before commit
df1c75c) shadow over RAW columns」.
- **Reviews and reports**: 「the contract review of PR objectstack-ai#16619」 became
「the contract review recorded in commit 45cfa1b」 (and 「measured in the
contract review commit 45cfa1b records」, 「commit 45cfa1b's contract
review's finding」); `45cfa1b88` is that pull request's squash, and its
message records FINDING-1 to FINDING-3 and the hand-made TEXT-affinity
measurement. 「the objectstack-ai#17879 report」 became 「commit eb93349's message」
(two sites); that message records that both repair candidates were
measured.
- **`objectstack-ai#16649`** (`dialect-emission-refusal.ts:66`): 「a removal objectstack-ai#16649
reversed」 became 「a removal that commit 613bfbd reversed」. `613bfbd3d`
re-registered `MONGODB_MULTI_TENANT_UNSUPPORTED` 「under the ruling」; the
runtime lane's `44c917a47` is objectstack-ai#16649's vocabulary-gate half, a different
subject.
- **`objectstack-ai#14628`** (`live-dialect-matrix.testkit.ts:451`,
`sql-driver-datetime-mysql-storage.test.ts:57`): `6392b9c2b` budgeted
the eighth live-cell hook and wrote the `(objectstack-ai#14628)` line itself; the
seven before it were `13b520069` (PR objectstack-ai#14629), which cites objectstack-ai#14213, the
number kept beside it.
- **Squash rewrites**: 「after objectstack-ai#6076 merged」 became 「after commit
6513c17 landed」; 「objectstack-ai#13878 / PR objectstack-ai#14434」 became 「objectstack-ai#13878 / commit
93940d4」; 「`ContainsAny` (objectstack-ai#17876)」 became 「(commit be5c602)」, the
squash that installed it; 「objectstack-ai#14438 (PR objectstack-ai#15280)」 became 「Commit 2200f8e
(PR objectstack-ai#15280)」, the live pull-request number kept beside its squash as a
convenience.
- **Slash pairs**: 「[objectstack-ai#9542/objectstack-ai#9609]」 became 「[commit 8bbf459, objectstack-ai#9609]」;
「objectstack-ai#5181/objectstack-ai#6075」 became 「objectstack-ai#5181 and commit d367f03」; 「objectstack-ai#11374/objectstack-ai#11627 exist
to end」 became 「objectstack-ai#11627 and commit d0e3a88 exist to end」; 「the
objectstack-ai#11627/objectstack-ai#12998 suites」 became 「the objectstack-ai#11627 and commit df1c75c suites」;
「[objectstack-ai#14079/objectstack-ai#15683/objectstack-ai#17343]」 became 「[objectstack-ai#14079/objectstack-ai#15683, commit 82cb69f]」;
「(objectstack-ai#11374, objectstack-ai#12999)」 became 「(commits d0e3a88 and ebcc34e)」.
- **Other single rewrites**: 「(objectstack-ai#16711 验收口径 item 4)」 became 「(验收口径 item 4
of the card commit 7862fb7 closed)」, whose message records the TS2353
negative control; 「the defect objectstack-ai#8287 removed and objectstack-ai#8778 must not
reintroduce」 became 「… and commit 7901b2d's stamp-only key must not
reintroduce」; 「the gap objectstack-ai#17639 left FILED」 became 「the gap commit
7c2c5ae left FILED」 (`9ccc4179e`'s message: 「the attribution arm
objectstack-ai#17639 deliberately left filed」); 「See the ruling recorded on objectstack-ai#12380.」
became 「See the decision recorded in commit 4045b95's message.」 (that
message records the posture, 「refuses to guess at the two that the
pre-fix encoding made ambiguous」, and calls it no ruling).
- **Capitalisation**: where the number opened a sentence, the new text
opens with 「Commit」.
- No line was reflowed, so some are longer than their block's wrap
(`eslint.config.mjs` declares no line-length rule, and a reflow would
move neighbouring lines and every line citation into the file).

## Sites left

- **In comments (src, test, `vitest.config.ts`): none.**
- **String literals: 51 test-string sites, 24 numbers, 26 files**
(`describe` and `it` titles, assertion arguments): `objectstack-ai#11374` 5, `objectstack-ai#12380`
4, `objectstack-ai#17590` 4, `objectstack-ai#17639` 4, `objectstack-ai#12998` 3, `objectstack-ai#13015` 3, `objectstack-ai#16609` 3, `objectstack-ai#17343`
3, `objectstack-ai#17586` 3, `objectstack-ai#17857` 3, `objectstack-ai#11067` 2, `objectstack-ai#14902` 2, and 12 more once
each. Every one of the 24 is in this stage's population, so the table
above holds an anchor for each. Non-test strings carry none. Strings are
outside this stage's surface.
- **Outside `src`:** the release-owned `CHANGELOG.md` names dead numbers
on 69 sites (31 numbers); left. `package.json`, `tsconfig.json`,
`vitest.config.ts`, `README.md` and `LICENSE` cite no dead number.

## Mechanical guard: no code token moves

The guard (stages 2 and 3's) compares base `222ecc27f` against the
working tree over all 55 touched files, with TypeScript 6.0.3:

- **Reading 1**: the parser's leaf nodes, from a `forEachChild` walk.
Comments are trivia there, and JSDoc is never visited. A leaf that is
not itself a token (an empty block) is re-scanned with trivia skipped.
- **Reading 2**: the full token stream in parser context, from a
`getChildren` walk, JSDoc nodes skipped. String, template and numeric
literals are compared in full on both readings.

Results:

- Real run at the head: 165,798 base tokens, **0 files with a token
change** (exit 0).
- Comment control (「which IS its value」 to 「which IS ITS value」,
`sql-driver.ts`): 0 files changed (exit 0).
- Positive control, an identifier (`hashShadowColumnFor` to
`hashShadowColumnForX`, `schema-drift.ts`): DIFFER on both readings
(exit 1).
- Positive control, a string literal (`'storage'` to `'storageX'` in
`FieldKeyClass`, `builtin-column-collision.ts`): DIFFER on both readings
(exit 1).
- Positive control, a numeric literal (`MEDIA_ID_MOVE_WIDTH = 2048` to
`2049`, `media-column-move.ts`): DIFFER on both readings (exit 1).

Each mutation went through `scripts/ablation-replace.mjs` (wrap mode,
anchor hit 1 to 0, replacement 0 to 1) under a shell trap that restores
by absolute path from `HEAD`. Each restore was proven equal to its
`HEAD` blob (`cd55a4ca44f3`, `72e45a83968f`, `86ebb324c1b6`,
`fe0ce2c4d5bd`), with `git diff HEAD` empty and a clean tree afterwards.

## Changeset: `patch` (`dist` measured)

`files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is
not private. The whole workspace was built first (`turbo run build` over
`./packages/*` and `./packages/*/*`, 71 of 71 tasks, VERDICT
command-exit 0). Then the package's own `build` (tsup plus
`check-dts-emitted`) ran three times in one script under the shared
verify lock (VERDICT command-exit 0):

- **Leg 1**, at `02d2a034a`: 6 `dist` files hashed. Of the 122 rewritten
non-test lines, 57 appear verbatim in `dist`: 52 from `sql-driver.ts`, 3
from `schema-drift.ts`, 1 each from `media-column-move.ts` and
`dialect-emission-refusal.ts`; in `index.d.ts` / `index.d.mts` (51) and
`index.js` / `index.mjs` (52).
- **Leg 2**, the base text put back in the 6 non-test files (6 of 6
proven equal to their base blob): 4 of the 6 files differ from leg 1
(`index.d.ts`, `index.d.mts`, `index.js`, `index.mjs`).
- **Leg 3**, after the proven restore (6 of 6 equal to their `HEAD`
blob, `git diff HEAD` empty): all 6 files are byte-identical to leg 1,
so the build is deterministic and the difference is the rewrite.

So the rewrite ships, and
`.changeset/20595-driver-sql-provenance-anchors.md` declares a `patch`
for `@objectstack/driver-sql`, comment text only, with the claim's
`Clause-②: no` line.

## Gates (head `fad95aff7`)

- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` at `fad95aff7` (56 paths against
merge base `04f0cc499`) derived 63 commands. All 63 ran, each exit code
captured before any pipe: 63 exit 0. `--ran` reports 「63 derived, 63
run, 0 NOT-MEASURED, 0 UNRUN」 (a derived zero) and exits 0. The roster
families the derivation flags under a touched directory also ran, each
exit 0: `node scripts/check-changeset-fixed.mjs`, `pnpm
check:authz-resolver`, `pnpm check:error-code-casing`, `pnpm
check:filter-alias-parity`.
- **Against the newer `main`:** `main` moved six commits after the
merge, none touching a file here. A probe tree at `origin/main`
`f9bcd08be` with this diff applied derived 64 commands, the one addition
being `node scripts/check-dts-emitted.mjs --self-test` (that script is
unchanged across the range). It ran, exit 0, and `--ran` on the probe
reports 「64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN」.
- **Named in the dispatch:** `node scripts/check-issue-citations.mjs`
exits 0 (「every citation this change adds resolves」, 10 judged across 6
files); `pnpm check:issue-citations` exits 0 (self-test, 173 cases, 9
batteries); `pnpm check:doc-authoring` exits 0 (the sibling-package
prose-id baseline holds, no growth); `pnpm check:nul-bytes` exits 0
(9,641 files, no raw control bytes).
- **Tests and typecheck, under the verify lock, at `fad95aff7`:** `pnpm
--filter @objectstack/driver-sql test`: 215 test files pass and 11 skip
(226); 3,594 tests pass and 202 skip. The 11 skipped files are the
live-dialect suites (PostgreSQL and MySQL cells), declared un-run
locally; CI's `Temporal Conformance (live PG + MySQL)` job runs them.
`pnpm --filter @objectstack/driver-sql typecheck` exits 0; `tsc
--listFiles` puts all 226 tracked test files and all 55 changed files in
its program.
- **Lint, as a proven narrowing:** eslint with inline config disabled,
over the 55 touched `.ts` files plus `dist/index.js` as the control: 56
results, 0 errors and 1 warning, the control's ignore notice; none of
the 55 is reported ignored. `eslint.config.mjs` never enables type-aware
linting (its lines 327-328 say so), so a comment edit cannot move the
verdict on an untouched file. The repo-wide `pnpm lint` is CI's run.

## Acceptance notes

- **Base.** The branch was cut at `222ecc27f` and merged with `main`
once, at `04f0cc499`, before the gates (merge `02d2a034a`, no conflict,
no file under `packages/drivers/driver-sql`, and neither
`check-issue-citations.mjs` nor `dispatch-gates.mjs`). Tests, typecheck
and gates ran on the head after it. The net diff against `main` is the
55 rewritten files (+291/−291) and the changeset.
- **Two sentences now state something that is no longer true, and this
stage changes only their citation.**
`sql-driver-12998-shadow-null-safe-key.test.ts:266` says `initObjects`'
parameter type does not declare `indexes`; `b72226f48` declared it, and
the citation now reads 「(the gap commit b72226f closed)」.
`sql-driver.ts:18142` says 「objectstack-ai#11374's remaining half may still reshape
the text side」; it was written on 2026-08-24 (`c49afd0886`), a day
before `107bb4ba4` settled that half, and now reads 「the half commit
d0e3a88 left open」. Comment accuracy, outside a citation sweep.
- **The token guard's first reading-1 run was wrong, and was replaced.**
It reported `sql-driver.ts` as DIFFER on reading 1 alone: an empty
`catch { }` block has no child nodes, so its leaf text carried the
comment inside it (`// Pre-objectstack-ai#12380 row: …`). Reading 2 agreed with no
change throughout. Reading 1 now re-scans such a leaf with trivia
skipped; the real run and all four controls above are from the corrected
guard.
- **Wording only:** no line without a number was changed, except
`sql-driver-17639-distinct-fault-envelope.test.ts:206`, listed above.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp)_

---------

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

Development

Successfully merging this pull request may close these issues.

2 participants