Uh oh!
There was an error while loading. Please reload this page.
feat(platform-objects): declare sourced maxLength bounds on the unbounded keyed identity columns (#11374 route A) - #11699
Conversation
…nded keyed identity columns (#11374 route A) Maintainer ruling 2026-08-24 (A + C(hash), B rejected): declare maxLength on the identity columns whose declared indexes MySQL refuses today because the column is unbounded TEXT. Every bound is derived from a named source — better-auth 1.7.1's own schema/migration mapping, the plugin's hard runtime caps, IdP norms (OIDC Core sub <= 255, SAML Core NameID <= 256), the in-repo producer (sha-256 hex, 64), or the referenced/sibling column's landed bound. Measured on live MySQL 8.0.46 (+08:00, STRICT): syncSchema failures 12/44 -> 8/44, declared indexes physically present 89/128 -> 104/128; Postgres 16 control 0/44 both legs. sys_verification.value stays deliberately unbounded (better-auth stores JSON blobs there — no defensible bound exists); sys_account.issuer is bounded at 2048 (the landed sys_sso_provider.issuer contract) which exceeds the 768-char key ceiling, so its composite unique stays for #11627's hash-shadow route. A pin test enumerates every keyed text-family identity column and refuses new unbounded ones by name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn
…chet — no type predicate against the typed export union Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn
📓 Docs Drift Check9 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ What this run could not seeCoarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b36794bfe4e6f995d9b8ebe3bb0b778c7ac1cc8d && git checkout b36794bfe4e6f995d9b8ebe3bb0b778c7ac1cc8d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4ceae8ab0b4c6649e0fa6faf591b4307b17c021d 368f1622541117c69eaf39cbe32d1d082523b704 && git checkout -B drift-repro 4ceae8ab0b4c6649e0fa6faf591b4307b17c021d && git merge --no-ff 368f1622541117c69eaf39cbe32d1d082523b704
node scripts/docs-audit/affected-docs.mjs --json 4ceae8ab0b4c6649e0fa6faf591b4307b17c021d |
Part of #11374 — route A of the maintainer's 2026-08-24 ruling (「四维分析一致的,接手你的建议。」: A + C(hash), B rejected).
Part of, not a closing keyword: two columns in A's measured scope are deliberately left unbounded (table below), and the(issuer, account_id)composite moves to the C card's class — #11374 stays open for that remainder. #11627 is not addressed here and remains open.What this PR does
Declares a sourced
maxLengthon the 13 keyed text-family identity columns that declared none, sodriver-sql(which since #11430 honours a keyed field's declared bound) can emit them asvarchar(n)and MySQL can create their declared indexes. No driver change, no spec change, no prefix index anywhere — field declarations plus one pin test plus a changeset.⚖️ Proposed bounds — the veto surface
Every value below is derived from a named source; none is guessed. Veto or narrow any row in review.
sys_session.tokenuniquestring field emitsvarchar(255)(better-auth/dist/db/get-migration.mjs, string typeMap). Producer writesgenerateId(32)— 32 chars (db/internal-adapter.mjscreateSession).sys_account.provider_idsys_sso_provider.provider_iddeclaresmaxLength: 255(landed). Built-in social providers are short fixed slugs. Upstream 1.7.1 leaves it unindexed/unbounded.sys_account.account_idsubat 255 ASCII. 256 = the wider. Above the 191 better-auth's own MySQL schema enforces via its(issuer, accountId)unique.unspecified-format SAML NameID has no normative cap — flagged, not covered.sys_account.issuersys_sso_provider.issuerdeclaresmaxLength: 2048, and@better-auth/ssowrites the verified token's rawissclaim — orprovider.issuer— verbatim into this column (dist/index.mjs,accountKey). SAML entityIDs are capped at 1024 by SAML metadata. Anything tighter refuses a sign-in the platform's own SSO contract admits. 2048 > 768 (utf8mb4 key ceiling) ⇒ column stays TEXT and(issuer, account_id)UNIQUE still cannot exist on MySQL — that composite joins the C-card class (see the sub-issue filed under #11627).local:oauth:{urlencoded provider_id}can exceed 2048 only for a >225-char non-ASCII provider id — pathological but expressible.sys_api_key.keypackages/core/src/security/api-key.tshashApiKey— "sha256(raw)hex — store this insys_api_key.key" — a fixed 64-char digest. better-auth'sapiKeyplugin is not loaded.sys_device_code.device_codevalidateGeneratedCode), custom generators included;deviceCodeLengthoption schema ismax(191), default 40.sys_device_code.user_codeuserCodeLengthismax(191), default 8.sys_device_code.statussys_verification.identifierverification.identifierdeclaresindex: true⇒varchar(255)— every upstream flow that writes this table, oauth-provider included, already lives inside 255 on upstream MySQL.sys_oauth_access_token.client_idsys_oauth_application.client_id, which declaresmaxLength: 255(landed); upstreamoauthClient.clientIdis a unique string ⇒varchar(255)on MySQL.sys_oauth_refresh_token.client_idsys_oauth_consent.client_idsys_oauth_client_resource.client_idLeft out — reported, not guessed (the ruling's escape clause):
sys_verification.value[value]index therefore still cannot exist on MySQL — recorded in the sub-issue under #11627.sys_import_job.created_bypackages/platform-objects/src/audit/, notidentity/). Left unedited per the surface constraint; reported in the dev report. It is the only remainingER_BLOB_KEY_WITHOUT_LENGTHobject outside the >768 class.Measurements (live servers, CI parity)
MySQL 8.0.46 (
@@global.time_zone='+08:00',STRICT_TRANS_TABLES), Postgres 16.13 (timezone='Asia/Shanghai'), processTZ=America/New_York. All 44 distinct exported platform objects throughSqlDriver.syncSchema, at HEAD 368f162 (clean tree; probe imports the freshly builtdistof both packages):ER_BLOB_KEY_WITHOUT_LENGTH, 1 ×ER_TOO_LONG_KEY)ER_BLOB_KEY_WITHOUT_LENGTH, 1 ×ER_TOO_LONG_KEY)Objects now syncing completely clean:
sys_session,sys_api_key,sys_device_code,sys_oauth_consent— the session-token UNIQUE that sign-in assumes physically exists on MySQL for the first time. Column read-back frominformation_schemamatches the table above exactly (token → varchar(255),account_id → varchar(256),key → varchar(64), codes →varchar(191),client_id × 4 → varchar(255),issuer/value→textby design).The remaining 8:
sys_import_job(out-of-surface column above),sys_metadata+ 3 × oauthmaxLength: 1024(already ruled to #11627),sys_oauth_client_resource(itsresource_id: 1024— a >768 case missing from #11627's list, filed),sys_verification(value, above),sys_account(issuercomposite, above).Accept/reject flip measured (why this is an enforcement change): a 300-char
sys_session.tokenINSERT on the strict server now failsER 1406 Data too long, 0 rows — refused, not truncated; a 255-char boundary value lands. Every bound admits everything its named producer can write, so only writes the producing contracts already forbid are refused.Pin test + ablation
identity-keyed-text-bounds.test.tsenumerates every keyed text-family column on the identity exports and fails by name on any new unbounded one (allowlist:sys_verification.value, with reason; a vacuity control asserts the enumeration sees ≥30 keyed columns; an allowlist-hygiene test refuses stale entries). Ablation: removingsys_session.token's bound (mutation proven on disk — anchored grep 1→0) turned exactly the presence assertion red namingsys_session.token (maxLength: undefined), 1 failed | 2 passed; restore verified (anchor 1, clean tree). The suite imports package source relatively under vitest — no dist in the path, red appeared with no build step between mutation and run.Verification
pnpm --filter @objectstack/platform-objects test— 30 files, 474 passed;typecheckclean (> tsc --noEmitechoed).pnpm --filter @objectstack/plugin-auth test— 73 files, 1480 passed against the rebuilt platform-objects dist.pnpm lint(eslint . --no-inline-config) — exit 0 in 2m23s, a FULL run, no narrowing claimed.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, answer stamped for this repo) and run at HEAD 368f162: all 12 path-matched + 7 convention-triggered families green, exits captured before any pipe — includingcheck:i18n(check-i18n-bundles: OK (9 package(s))after building the CLI; the first run was a measured "CLI not built" refusal, not a pass) andcheck:type-check-debt --re-measure(its own verdict line:check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 645.3s, 1897 raw tsc error(s) total, none above its recorded number; the first run caught my test file adding +2 raw tsc errors to the frozen TEST_DEBT layer — fixed the test's typing rather than touching the ledger).Generated by Claude Code