Skip to content

fix(driver-sql): carry an over-long UNIQUE index on a hash-shadow column (MySQL utf8mb4) - #12198

Merged
os-warren merged 4 commits into
mainfrom
claude/issue-11627-hash-shadow-key-utf8mb4
Aug 25, 2026
Merged

fix(driver-sql): carry an over-long UNIQUE index on a hash-shadow column (MySQL utf8mb4)#12198
os-warren merged 4 commits into
mainfrom
claude/issue-11627-hash-shadow-key-utf8mb4

Conversation

@os-warren

@os-warrenos-warren commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes#11627
Part of #11701 — see "What #11701 keeps open", below. That sub-issue does not close here.

Implements the C half of the maintainer's 2026-08-24 ruling on #11374 (verbatim 「四维分析一致的,接手你的建议。」 — A + C(hash), B rejected). A is landed and closed; this is the hash-shadow key.

The defect

On MySQL utf8mb4 InnoDB a key part holds at most 3072 bytes — 768 characters — so a full-value UNIQUE index over a longer column is inexpressible, not merely expensive. An OAuth access token may legitimately be a multi-KB JWT, so no declared bound rescues these columns. syncSchema refused the object outright, leaving it registered with its declared uniqueness absent.

Population — re-measured, not carried forward

Measured through this driver against live MySQL 8.0.46 (utf8mb4/InnoDB) and PostgreSQL 16.13, over all 45 platform-object exports (44 distinct — sys_metadata is exported twice):

beforeafter (d651ac3c32)
MySQL distinct objects failing syncSchema7 (6 ER_BLOB_KEY_WITHOUT_LENGTH + 1 ER_TOO_LONG_KEY)2
Postgres control00

The 7 reproduce the PM's independently-verified count exactly. What that count did not capture is that the population is not homogeneous — it splits by index kind, and that split decides what is fixable here:

5 UNIQUE — fixed by this PR (all four of #11627's ruled cases, plus #11701's sys_account):

2 NON-UNIQUE — deliberately still refused (#11701 items 1 and 2): sys_oauth_client_resource.resource_id, sys_verification.value.

The route

A UNIQUE index MySQL refuses is carried by a driver-owned shadow column named after the index — uniq_sys_oauth_access_token_token__hash, for example. It is a STORED GENERATEDVARBINARY(32) holding UNHEX(SHA2(key, 256)) over the index's key columns, and the unique index moves onto it.

A generated column rather than an application-computed one (the _objectstack_sequences.key_hash precedent hashes in app code because it is a cross-dialect PK). Three properties app hashing cannot buy: every writer maintains it — including os migrate, a DBA, replication; existing rows are hashed by the ALTER itself, so there is no backfill that could partially complete; and nothing can write a shadow that disagrees with its source columns.

The exact expression, as the catalog reports it back for the 4-column composite:

unhex(sha2(concat(`type`,0x1f,`name`,0x1f,`organization_id`,0x1f,`package_id`),256))

Selected by the server's own error code, after the direct index is refused — never by a dialect check. A pre-flight would have to reproduce MySQL's 3072-byte arithmetic and, wrong in the strict direction, would move an object onto a shadow key on a server that would have taken the real index. Postgres and SQLite never refuse, so they are byte-identical to before (verified: 0 shadow columns on Postgres).

Measured semantics — information_schema, never the emitted DDL

Every physical claim is read back in a separate catalog query. SUB_PART IS NULL is the load-bearing one: it is what distinguishes this from the rejected prefix index, which reports a sub-part.

  • Distinct values sharing a 191-char prefix are both accepted. This is the assertion a prefix-unique index fails — the measurement that disqualified that route (a valid sys_session.token sign-in refused as a duplicate).
  • A genuine duplicate is still rejected; a 4000-char JWT inserts.
  • NULL stays distinctSHA2(NULL) is NULL, matching a direct UNIQUE.
  • Composites use CONCAT(a, 0x1f, b, …); CONCAT returning NULL for any NULL argument is MySQL's composite-UNIQUE semantics. The separator keeps the encoding injective (('xy','')('x','y')).

Boundary, from the catalog

declared maxLengthcolumnindex
767varchar(767)direct on v
768varchar(768)direct on v — last direct width
769textvarbinary(32) shadow — first shadow width
1024textshadow

Collision bound (clause ②)

  • Hash: SHA-256. Digest width actually stored: 256 bits / 32 bytes, untruncatedCHARACTER_MAXIMUM_LENGTH = 32, and a test compares the stored bytes against Node's createHash('sha256') digest.
  • At n = 10⁹ rows in one index the birthday bound is n²/2²⁵⁷ ≈ 4.3 × 10⁻⁶⁰ — some 45 orders of magnitude below the silent-corruption rate of the storage layer underneath it.

Is a write-time collision distinguishable from a genuine uniqueness violation? From the server error alone, no. Both are ER_DUP_ENTRY on the same shadow index, and MySQL quotes the raw digest, not the value — measured: Duplicate entry '\xA0\x02\x13\xC1…' for key 'proto.uniq_token'. An operator reading that has been told nothing — a user-visible wrong answer, not a crash.

So the driver resolves it with one read on the failure path: re-select by the source columns. A matching row is a real duplicate, reported in the declared terms (constraint + source columns). No matching row, with the shadow still conflicting, is a collision — named as such, with the values, and asked to be reported.

⚠️ Wired into create only. update issues through three paths with no shared catch, and every flow here that writes these columns inserts. An UPDATE that collides still fails correctly; it just still reports the binary digest. Named in the docblock rather than left to be discovered.

Drift

The differ learns the shadow is driver-owned (isHashShadowColumn). Without it the orphan pass reports the shadow as unmapped_column and proposes drop_column — which would take the UNIQUE index with it and silently return the object to "registered, uniqueness unenforced" via the migration tool. It adds no new drift op, so applyMigrationEntries' applied/skipped partition (#11722) is untouched.

What #11701 keeps open — and why this is a decision, not an omission

A hash shadow answers uniqueness, which is an equality-only predicate. A non-unique index exists for an access path, and an index over a digest serves no lookup the planner can reach: WHERE col = ? cannot use it without rewriting the read side to filter on the digest too. Creating one would trade a loud refusal for a table that syncs, costs a write on every row, and accelerates nothing.

#11701 itself asked for a liveness measurement before adding a shadow column there. That measurement is now made, and sys_verification.value looks removable rather than shadowable — sys-verification.object.ts states in its own index comment that "better-auth keys verification lookups on identifier, not value". But removing a declared index is a contract change, so it is escalated rather than guessed. sys_oauth_client_resource.resource_id needs the same call.

sys_verification.value therefore stays in the UNBOUNDABLE allowlist in platform-keyed-text-bounds.test.ts, and its reason (which tracks the debt to #11627) remains accurate — this route does not make it boundable.

Verification

  • Gate union derived, not recalled: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at d651ac3c32 — 14 path-matched + 6 convention-triggered families, all run, all green, plus check:type-check-debt --re-measure on a built workspace closure ("surplus: none — every entry sits exactly at its measurement").
  • pnpm lint (eslint . --no-inline-config) — full repo, exit 0.
  • @objectstack/driver-sql: 141/141 files, 2851 passed, 1 skipped, with live MySQL + Postgres attached and TZ=America/New_York (the matrix's own three-way zone-skew guard).
  • Ablation, direction predicted in writing first: disabling the UNIQUE branch predicted 6 red / 5 green — measured exactly 6 red / 5 green, the same six by name. Mutation proven on disk by anchored counts (anchor 1→0, mutant 0→1) before any result was read. Suite resolves through source (relative imports; the package's only vitest aliases are @objectstack/spec package-name rules), so no rebuild was owed.
  • Two driver-sql: the platform-objects schema does not sync onto MySQL — unbounded string fields become TEXT, which MySQL refuses to index #11374 pins asserted "unkeyable ⇒ refused" for UNIQUE objects — the branch this ruling replaces. Rewritten, not silenced: both keep a live subject via new non-unique fixtures, and a new pin asserts the unique cases land on a shadow reporting no sub_part.

Generated by Claude Code

…umn (MySQL utf8mb4)
On utf8mb4 InnoDB a key part holds at most 3072 bytes (768 characters), so a
full-value UNIQUE index over a longer column is inexpressible rather than merely
expensive. Measured on live MySQL 8.0.46: 7 of 44 exported platform objects
failed syncSchema (6 ER_BLOB_KEY_WITHOUT_LENGTH + 1 ER_TOO_LONG_KEY); Postgres
16.13 took all 44.
Such a UNIQUE index is now carried by a driver-owned `<index>__hash` column: a
STORED GENERATED VARBINARY(32) holding the full, untruncated SHA-256 of the key
values. A generated column rather than an application-computed one so every
writer maintains it, existing rows are hashed by the ALTER itself, and no write
can disagree with its source columns.
Selected by the server's own error code, only after the direct index is refused:
Postgres and SQLite never refuse and are byte-identical to before. Non-unique
indexes stay refused — an index over a digest accelerates no lookup the planner
can reach without rewriting the read side.
The drift differ learns the shadow is driver-owned, so its orphan pass cannot
propose dropping the column that carries a live constraint.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
#11627 made a UNIQUE index over an unkeyable column expressible — carried on a
hash-shadow column — so the two pins that asserted "unkeyable ⇒ refused" for
UNIQUE objects were pinning a branch the ruling deliberately replaced. Rewritten
rather than deleted or silenced.
The refusal is not gone: it is the disposition for a NON-UNIQUE unkeyable index,
where a digest serves no lookup the planner can reach. Both refusal pins keep a
live subject via new non-unique fixtures, and a new pin asserts the unique cases
land on a shadow whose key part reports no sub_part — so the constraint moving
onto a shadow cannot quietly become the prefix constraint the ruling rejected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
…inary digest
Once uniqueness is enforced over a SHA-256 shadow, ER_DUP_ENTRY quotes the raw
digest and names the shadow index, so a genuine duplicate and a digest collision
are indistinguishable from the error alone and neither says which value
conflicted. `create` now resolves it with one read on the failure path: a row
matching the source columns is a real duplicate, named in the declared terms; no
such row is a collision, named as such and asked to be reported.
Wired into `create` only — `update` issues through three paths with no shared
catch, and every flow in this repo that writes these columns inserts. Named in
the docblock rather than left to be discovered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Aug 25, 2026
@github-actions

github-actionsBot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

6 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))
  • content/docs/data-modeling/index.mdx(via SqlDriver (symbol))
  • content/docs/plugins/packages.mdx(via SqlDriver (symbol))
  • content/docs/protocol/kernel/index.mdx(via SqlDriver (symbol))
  • content/docs/protocol/kernel/lifecycle.mdx(via SqlDriver (symbol))
  • content/docs/protocol/objectql/query-syntax.mdx(via SqlDriver (symbol))

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

  • content/docs/releases/v17.mdx(via SqlDriver (symbol))

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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: 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 — 9 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 494279cb31f1d92adab959763085e19c923a8652packageMentionDocs.

Which tree this was computed on

This run read content/docs from d03e13826464191eb5bf071100e33d5b2c689465 — the merge of head 686ffedf79af1d05f83cacb3dcae619b2d1f91d8 into base 494279cb31f1d92adab959763085e19c923a8652, 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 d03e13826464191eb5bf071100e33d5b2c689465 && git checkout d03e13826464191eb5bf071100e33d5b2c689465
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 494279cb31f1d92adab959763085e19c923a8652 686ffedf79af1d05f83cacb3dcae619b2d1f91d8 && git checkout -B drift-repro 494279cb31f1d92adab959763085e19c923a8652 && git merge --no-ff 686ffedf79af1d05f83cacb3dcae619b2d1f91d8
node scripts/docs-audit/affected-docs.mjs --json 494279cb31f1d92adab959763085e19c923a8652

⚠️ 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 494279cb31f1d92adab959763085e19c923a8652 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-warren@claude