fix(platform-objects): source sys_oauth_resource.identifier's bound from its producer — 1024 → 255, and the referring column with it - #12595
Conversation
…rom its producer — 1024 -> 255, and the referring column with it sys_oauth_resource.identifier declared maxLength: 1024 citing no producer. better-auth 1.7.1 — the sole writer (managedBy: 'better-auth', protection.lock: 'full') — emits it as varchar(255) on MySQL, measured by running its own migration generator against live MySQL 8.0.46 and reading information_schema.COLUMNS as its own query. Both halves of the foreign key now declare 255, closing the silent register-then-never-authorize dead end: on PG/SQLite a 900-char identifier could be registered by the referent and then never granted, because the referrer (narrowed to 768 by #11701) refused it. Measured consequence: 255 x 4 = 1020 bytes is under the 3072-byte key-part ceiling, so sys_oauth_resource LEAVES the hash-shadow route — the varbinary(32) shadow column is gone and the UNIQUE index is carried directly on identifier with SUB_PART NULL. Also corrects the #11701 citation: upstream emits the referring column as varchar(191), not varchar(36) — the field.references arm never runs because resourceId participates in table-level indexes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
…und narrowing no-migration-prescription: the change narrows two field bounds on objects whose protection.lock is 'full'. No metadata key is removed or renamed, so no authored metadata can name the discarded band, and schema sync applies the physical column change itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
PM review — accepted, flipped ready, auto-merge armed. Two things need the maintainer's eye and neither blocks the merge.Measured: 5 files, +255/−30, all in-scope. Verified independently: the ADR-0087 disposition is present in the changeset ( ⭐ The precondition came back clean, and the dev refused to let that stand unqualifiedThis is the part worth reading twice. The stock reading found zero over-255 values — with a positive control firing in the same scan: a fifth schema cloned from the real table shapes and planted with 256/300/900/1024-character identifiers, then the identical query run over all five in one pass. It found 4 planted over-255 identifiers and 1 planted And then:
A clean reading over a population of zero is not the same evidence as a clean reading over a populated corpus, and it does not disprove the self-hosted-bypass possibility the precondition exists for — no customer or production deployment is reachable from this container. Postgres was measured absent ( Merge posture: the ruling said "a hit forks back; a clean reading proceeds". The reading was clean, so by the ruling's letter this proceeds, and it is armed. But the caveat is real and it is yours:
If you want B, disarm auto-merge — I have not treated an empty scan as if it were a populated one, and neither did the dev.
|
Fixes #12313
Implements the maintainer ruling of 2026-08-26 (verbatim 「同意」 on option B, with its stock-reading precondition):
sys_oauth_resource.identifierandsys_oauth_client_resource.resource_idboth narrow to a sourced 255. Options A (768, unsourced alignment) and C (keep 1024 + citation) were weighed and rejected upstream of this PR and are not taken here.Step 1 — the binding precondition, run before anything changed
Verdict: clean — but vacuously so, and that distinction is load-bearing.
Method. Enumerated every reachable engine, not just the convenient one. MySQL 8.0.46 is live in this container;
information_schemawas queried directly rather than inferred. Postgres:pg_isready→ no response, TCP connect refused. SQLite: no ObjectStack database file anywhere under/home,/root,/var,/opt,/srv(the only.dbfiles present are Docker's own boltdb/containerd internals). So MySQL was the whole reachable population.Across all four schemas holding these tables (
os11374probe,os11627,os11627b,os11627f— prior agents' probe databases):sys_oauth_resource.identifiersys_oauth_client_resource.resource_idControl. A zero-hit is not a reading until a positive control fires in the same scan, so one did. A fifth schema was created with the same table shapes and planted with identifiers of 256, 300, 900 and 1024 characters plus one short value, then the identical scan was run over all five schemas in one pass:
os12313probe(control)sys_oauth_resource.identifieros12313probe(control)sys_oauth_client_resource.resource_idThe scan finds long values when they exist, and correctly excludes the short one. The four real schemas are clean.
Step 2 — the narrowing, each column with a producer citation
The card's whole complaint is that 1024 cites no producer, so a narrowing that landed without a citation would reproduce the defect at a smaller number. Both citations were measured firsthand, by running better-auth 1.7.1's own migration generator against live MySQL 8.0.46 and then reading
information_schema.COLUMNSas its own query:oauthResource.identifiervarchar(255)(1020 octets){ unique: true }→getType'sfield.uniquearm;oauthResourcedeclares no table-levelindexes, sotableIndexStringLengthis undefinedoauthClient.clientIdvarchar(255)client_idcitationoauthClientResource.resourceIdvarchar(191)sys_oauth_resource.identifiertherefore takes 255 directly from its producer.sys_oauth_client_resource.resource_idtakes 255 from the referent, by the same referring-column derivationclient_idalready uses — which theoauthClient.clientIdmeasurement above independently confirms is this family's established rule.The in-tree comment claimed upstream emits the referring column as
varchar(36)viagetType'sfield.referencesarm. It does not. Measured:varchar(191). That arm never runs for this column —resourceIdparticipates in table-level indexes, sogetTypereceives atableIndexStringLengthargument, which takes precedence over everyfield.*arm, andgetDatabaseIndexStringLengthseeds its reduce at MySQL's 191-character default and can only shrink from there.That 191 is an artifact of upstream's index budget on upstream's own physical schema, and is deliberately not inherited: ObjectStack emits its own schema. Taking the referent's 255 is also what keeps the pair symmetric. (Upstream's own 255/191 split means a native better-auth deployment has a (191, 255] band it can register but never link — an upstream defect, noted in the field comment, not reproduced here.)
Hash-shadow route — measured entry and exit, not predicted
The ruling requires this be stated rather than discovered from a migration diff.
sys_oauth_resourcewas synced onto live MySQL by the real driver at each declaration, and the physical facts read frominformation_schemaas their own query:identifierphysicaltext(65535 octets)varchar(255)(1020 octets)uniq_sys_oauth_resource_identifier__hash varbinary(32)presentidentifierdirectly,SUB_PART NULL255 × 4 = 1020 bytes sits under the 3072-byte key-part ceiling, so the object leaves the #11627/#12198 hash shadow, as the ruling anticipated. Uniqueness is unchanged and still enforced over the full value — the replacement is a direct full-value UNIQUE, not a prefix index. The referrer was measured too:
resource_idis nowvarchar(255)/1020 octets with its non-unique index direct andSUB_PART NULL.Clause ② — yes
An accept/reject narrowing on a published object, in both directions of a referring pair.
git diff --statbehind it:Precisely what the narrowing rejects that today's tree accepts: values in (255, 768] move from "the referrer accepts" to "both refuse". Values in (768, 1024] were already refused by the referrer and are now refused by the referent too. Nothing upstream can emit either band.
Tests
New pin
sys-oauth-resource-sourced-bounds.test.ts(4 tests). The third is the one that matters: it asserts the referrer admits exactly the referent's domain, because any gap between them is a register-then-never-authorize dead end by construction. Pinning two integers pins two integers; pinning the invariant is what a future re-narrowing has to stay honest against.Ablations — direction and exact failure count predicted in writing first, mutation proved on disk with anchored
grep -cFcounts before any result was read, restored undertrap … EXIT INT TERM, restore verified with an emptygit diff. No rebuild needed, justified by import form: the pin imports./sys-oauth-resource.object— a relative path inside the same package, which vitest resolves tosrc/*.ts, never through the packageexportsmap ordist/.No existing assertion was changed.
platform-keyed-text-bounds.test.tscarries comment-only edits recording that #11701's 768 is now 255; its assertions are untouched and still pass (255 ≤ 768, so the column stays in its enumerated set).Gates
Every exit captured before any pipe; each verdict quoted from the gate's own output, never from a wrapper's
$?(the verify-lock wrapper printed0for a batch in which two gates exited1— caught exactly this way).Green (own verdict lines):
check:nul-bytes,check:changeset-gate-self-tests,check:cross-package-test-inputs(+ the ci.yml variant),check:objectql-double-limit,check:objectui-changeset,check:page-declaration-shape,check:published-files,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check-changeset-no-major,check-ci-filter-parity,check-comment-mask-adoption,check-empty-changeset,check-plugin-teardown-shape,release-rehearsal-clone --self-test,check:query-options-erasure,check:engine-double-contract,check:where-matcher,check:i18n-stale-fill,check:type-check-coverage,check:i18n, plus@objectstack/platform-objectstypecheckandtest(32 files / 519 tests).Two needed real work rather than a green tick:
check-adr-0087-registrationexited 1 — a genuine red. The changeset declares BREAKING and carried no ledger disposition. Answered withnot-required (no-migration-prescription); the gate now reports "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition" and accepts the exemption (its prescription detector does not refuse the body).check:i18nexited 1 saying "PREREQUISITE NOT MET … Nothing was checked" — not measured, not red. The CLI was built and it was re-run for a real result: "check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys)."check:type-check-debt --re-measurewas narrowed, and the narrowing is a measurement. platform-objects hides its tests from tsc and carriesTEST_DEBT = 3, so the ratchet is load-bearing for the new test file. Rather than rebuild the whole workspace closure, the entry was re-measured the way the gate does — the package's own config with the test exclusion lifted — with the exclusion-lift confirmed by a positive control (32 test files in the program). Result: 3 raw errors, TS2339 ×2 + TS7006 ×1, matching the ledger to the unit, all in the pre-existingfeature-gate-guard.test.tsand 0 in the new file. The ratchet cannot move for this card.Gate union derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackover the real changeset (merge-base semantics, 5 paths). The union was re-run on the final head3fda8cb404for the changeset family — the only family the last commit could move.Generated by Claude Code