Skip to content

fix(platform-objects): make the last two non-unique keyed text indexes expressible on MySQL - #12314

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-11701-non-unique-keyed-text-remainders
Aug 25, 2026
Merged

fix(platform-objects): make the last two non-unique keyed text indexes expressible on MySQL#12314
os-warren merged 1 commit into
mainfrom
claude/issue-11701-non-unique-keyed-text-remainders

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #11701

Implements both halves of the maintainer's 2026-08-25 ruling on this card. #12198 closed the 5 UNIQUE members of the >768-char keyed-text class by hash shadow (MySQL 7 → 2 failing objects); these are the 2 NON-UNIQUE remainders, which a hash shadow structurally cannot serve — a UNIQUE constraint is an equality-only predicate that survives hashing exactly, but a non-unique index exists for an access path, and an index over a digest accelerates no WHERE col = ? the planner can reach.

The two are ruled separately, for different reasons, and this PR keeps them apart.

1. sys_verification.value — the declared index is removed

The column is genuinely unboundable (better-auth's oauth-provider writes OIDC authorization-code payloads there as a JSON blob), so no bound rescues it and no shadow carries it. The index was measured dead, re-verified here rather than carried forward from the card:

  • better-auth 1.7.1 keys every verification lookup on identifier, id, or expiresAtinternal-adapter.mjs's findByIdentifier / consumeByIdentifier; a grep for field: "value" across better-auth and @better-auth/oauth-provider returns nothing.
  • Upstream declares the field unindexed and unbounded.
  • No in-repo query filters sys_verification by value.

A measured consequence the card did not anticipate: removing this index did not merely delete an index — it restored two live ones. Because initObjects aborted at the [value] index, sys_verification on MySQL previously carried only PRIMARY. Read from the catalog, before vs after:

before after
sys_verification indexes (MySQL) PRIMARY only PRIMARY, idx_sys_verification_identifier, idx_sys_verification_expires_at

The dead index was suppressing the object's two real access paths, including the one better-auth actually queries on.

2. sys_oauth_client_resource.resource_id — the declared bound narrows 1024 → 768

⚠️ The ruling's condition was not discharged, and it was the first job here. Narrowing a referring column below its referent (sys_oauth_resource.identifier, declared 1024) needs evidence that no legitimate resource identifier exceeds 768 characters. Measured, not assumed:

  • The value is an RFC 8707 resource-indicator URI, and upstream @better-auth/oauth-provider 1.7.1 declares oauthClientResource.resourceId as references: { model: "oauthResource", field: "identifier" } — so the column really does hold the identifier itself, not an opaque id.
  • better-auth is the sole writer (managedBy: 'better-auth', protection.lock: 'full'), and its own MySQL migration generator (better-auth/dist/db/get-migration.mjs, getType) emits the referent oauthResource.identifier as varchar(255) (the field.unique branch) and this referring column as varchar(36) (the field.references branch). A resource whose identifier exceeded 768 characters could never have been registered upstream in the first place.

So the discarded (768, 1024] band holds nothing the producing contract can emit, and route A is available. 768 rather than upstream's 255 deliberately: it is the smallest narrowing that makes the index expressible, so it rejects the least of the referent's declared domain — guessing a tighter number to make a key fit is what sys_account.issuer explicitly refuses to do.

The index is kept because it is a live access path: upstream reads it as a predicate (findOne({ clientId, resourceId }) on the client-registration collision path). That is why "just remove the index" was not taken here.

⚠️ The referent's own maxLength: 1024 cites no producer and now over-declares relative to every column referencing it. Out of scope and not touched — narrowing it would also move sys_oauth_resource off #12198's hash shadow. Filed unassigned as #12313.

Measured on live servers — clause ②

Live MySQL 8.0.46 (utf8mb4 / InnoDB / STRICT_TRANS_TABLES) and PostgreSQL 16.13, every distinct exported platform object driven through initObjects one at a time. Every physical claim is read back from information_schema.COLUMNS / .STATISTICS in a separate query — never from the DDL this change causes to be emitted.

before after
MySQL distinct objects failing syncSchema 2 / 45 0 / 45
Postgres control 0 / 45 0 / 45

The 2 reproduce #12198's stated remainder exactly, by name and error code — sys_oauth_client_resource and sys_verification, both ER_BLOB_KEY_WITHOUT_LENGTH. (Population is 45 rather than #12198's 44 because sys_metadata_activation landed in between, via #12185; both legs here use the same enumeration, so the delta is unaffected.)

Catalog readings

before after
sys_oauth_client_resource.resource_id text (65535) varchar(768)
idx_sys_oauth_client_resource_resource_id absent present, NON_UNIQUE=1, SUB_PART = NULL
sys_verification.value text (65535) text (65535) — unchanged
idx_sys_verification_value absent (refused) absent (not declared)

SUB_PART = NULL is load-bearing: it is what distinguishes a whole key part from the prefix index the ruling rejected, which would report a sub-part.

Accept/reject transitions, boundary included

write verdict
resource_id at 768 chars accepted, storedLength = 768
resource_id at 769 chars refused, ER_DATA_TOO_LONG
sys_verification.value at 5000 chars accepted, storedLength = 5000

The last row is the control that this change narrowed nothing: removing the index left the column as wide as it ever was.

The pin moved rather than being deleted

sys_verification.value was the sole entry in the UNBOUNDABLE allowlist in platform-keyed-text-bounds.test.ts, with a reason explaining why the column could not be bounded. The ruling did not bound it — it removed the index — and an unindexed column is not a keyed column, so that entry stopped describing anything and would have gone red by name on the allowlist's own rot check. It is removed, with the comment above the now-empty list recording what emptied it.

Emptying an allowlist is a silent weakening, so two things replace it:

  • The rule is extracted into unboundedKeyedColumns(objects, allowlist) and a synthetic control drives both of its outcomes, so the excusing branch the real objects no longer reach stays exercised rather than rotting.
  • A new #11701 pin is the executable form of "the class is closed": it enumerates the whole package and rejects any non-unique index over a text column MySQL cannot key. It does not name these two columns in the rule — a third member arriving later fails at test time instead of on a live server months on. Its positive control pins both dispositions by name (not.toContain('sys_verification.value'), toContain('sys_oauth_client_resource.resource_id')) and asserts the enumeration still sees ≥50 columns (measured: 55).

Verification, at dab5649c7e

Gate union derived, not recallednode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-derived after the changeset existed: 13 path-matched + 8 convention-triggered families. All run, every exit code captured before any pipe, all green. Quoting each gate's own verdict line where it decides something:

  • check-adr-0087-registration: "this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)" — this is what settles whether removing a declared index and narrowing a declared bound are declared-breaking; the gate ruled, not an assumption.
  • check-type-check-coverage --re-measure: "32 ledger entr(ies) re-measured in 238.0s, 1843 raw tsc error(s) total, none above its recorded number … surplus: none", on a fully built workspace closure (70/70 turbo tasks).
  • check-nul-bytes: "scanned 6803 text file(s) … no raw ASCII control bytes".
  • check-i18n: "OK (9 package(s) — all bundles in sync)"; check-i18n-stale-fill: "0 stale-fill leaf/leaves".
  • pnpm lint (eslint . --no-inline-config), whole repo: exit 0 — run in full, so no narrowing is claimed.
  • @objectstack/platform-objects: typecheck exit 0; test 493 passed (493).

Ablation — direction predicted in writing first

Predicted before running: reverting only the two object definitions to the branch point must turn the new pin red, 3 of 7 tests, naming exactly sys_verification.value (maxLength: undefined) and sys_oauth_client_resource.resource_id (maxLength: 1024).

Measured: 3 failed | 4 passed, the same three by name, offender lists exactly as predicted. The 4 that stayed green include the allowlist rot check — vacuously, because the list is now empty, which is precisely the hole the synthetic control was added to fill.

Mutation proven on disk by single-line anchored counts read before any test result (value-index 0→1, maxLength: 1024 0→1, maxLength: 768 1→0), and the restore leg re-verified the same way back to 0/0/1 with git status empty. The mutation script carried a trap … EXIT INT TERM restore. No rebuild was owed and none was done: the test imports its subject as import * as PlatformObjects from './index' — a relative specifier vitest resolves to src/index.ts, so no dist participates in either leg.

⚠️ Worth noting for others: os-verify-lock.sh reported VERDICT command-exit 0 for the ablation batch while the vitest inside it exited 1 — the #12288 behaviour. The verdict read here is the script's own ABLATION-TEST-EXIT=1.

⚠️ Channel fact: GitHub REST returned 403 for issue listing from this seat, authenticated and unauthenticated (the agent proxy denies the CONNECT — #12123), so the duplicate search for #12313 used the MCP channel plus a local grep, with #11835's under-return risk declared.

Generated by Claude Code


Generated by Claude Code

…s expressible on MySQL

A non-unique index over a text column MySQL cannot key is refused outright
(ER_BLOB_KEY_WITHOUT_LENGTH), failing the whole object's syncSchema and leaving it
registered with its declared index absent. #11627's hash shadow closed the UNIQUE
half of this class; it structurally cannot serve the non-unique half, because an
index over a digest accelerates no WHERE col = ? the planner can reach.

Two members remained, ruled separately by the maintainer on 2026-08-25:

- sys_verification.value: the declared index is REMOVED. The column is genuinely
  unboundable (oauth-provider writes OIDC authorization-code payloads there as a
  JSON blob) and the index was measured dead — better-auth 1.7.1 keys verification
  lookups on identifier/id/expiresAt, upstream declares the field unindexed and
  unbounded, and no in-repo query filters by value.

- sys_oauth_client_resource.resource_id: the declared bound narrows 1024 -> 768.
  This is a live access path (FK side of sys_oauth_resource.identifier), so it
  keeps its index and becomes keyable instead. Narrowing below the referent's 1024
  is safe because upstream better-auth stores that same identifier as varchar(255)
  on MySQL and this referring column as varchar(36), so nothing the producing
  contract can emit lives in the discarded band.

The package pin gains the executable form of "the class is closed": it now rejects
a non-unique index over any text column MySQL cannot key, enumerated across the
whole package rather than naming these two. Its UNBOUNDABLE allowlist, which
existed to excuse sys_verification.value, moves with the change and is empty; a
synthetic control keeps its excusing branch exercised.

Part of #11701

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 2 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 5ce5f8c12479f40d548b9f3ba9f8725c14c9bf9apackageMentionDocs.

Which tree this was computed on

This run read content/docs from 159c15fd44803130aba1f4d02847b4feb58d6a4c — the merge of head dab5649c7e96ecd48492cbdba2dce483fc54fe3e into base 5ce5f8c12479f40d548b9f3ba9f8725c14c9bf9a, 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 159c15fd44803130aba1f4d02847b4feb58d6a4c && git checkout 159c15fd44803130aba1f4d02847b4feb58d6a4c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5ce5f8c12479f40d548b9f3ba9f8725c14c9bf9a dab5649c7e96ecd48492cbdba2dce483fc54fe3e && git checkout -B drift-repro 5ce5f8c12479f40d548b9f3ba9f8725c14c9bf9a && git merge --no-ff dab5649c7e96ecd48492cbdba2dce483fc54fe3e

node scripts/docs-audit/affected-docs.mjs --json 5ce5f8c12479f40d548b9f3ba9f8725c14c9bf9a

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 25, 2026
@os-warren
os-warren marked this pull request as ready for review August 25, 2026 23:30
@os-warren
os-warren added this pull request to the merge queue Aug 25, 2026
Merged via the queue into main with commit f6344e7 Aug 25, 2026
34 checks passed
@os-warren
os-warren deleted the claude/issue-11701-non-unique-keyed-text-remainders branch August 25, 2026 23:45
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/m tests tooling

Projects

None yet

2 participants