Repository navigation
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, so this run has nothing to list — not 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 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 540fd24f3f39e2ccbaaa033788c2268a5f76bd99 && git checkout 540fd24f3f39e2ccbaaa033788c2268a5f76bd99
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 589758d22ccacf9cc56b5bc8a9f9766cb7e2a93a 970604024b38859f4475aad0bca74cf1592fb97d && git checkout -B drift-repro 589758d22ccacf9cc56b5bc8a9f9766cb7e2a93a && git merge --no-ff 970604024b38859f4475aad0bca74cf1592fb97d
node scripts/docs-audit/affected-docs.mjs --json 589758d22ccacf9cc56b5bc8a9f9766cb7e2a93a |
…ounds Base was 76 commits behind. Clean automatic merge, zero conflicts. Brings the branch onto current main so CI re-runs against it before landing. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn
Ready for triage review — ⛔ deliberately NOT enqueued
What changed since this PR last sat still: its base was 76 commits behind CI on the merged base: 31/31, zero failures. Flipped to ready; What a reviewer should actually look at
Two rows the author explicitly flagged as judgment calls rather than derivations:
⛔ This is Result if it lands: MySQL Generated by Claude Code |
….created_by (#11374 route A, last column) Route A's remaining column. `sys_import_job.created_by` is keyed by `(created_by, created_at)` and declared no bound, so driver-sql emitted it TEXT and MySQL refused the index (`ER_BLOB_KEY_WITHOUT_LENGTH`), landing the object registered-but-broken. Per #11699's own measurement it was the only remaining such object outside the >768-character class. The bound is 255, derived by referenced-column transitivity from three converging in-repo producers rather than chosen: the column holds a `sys_user.id` (stamped from `context.userId` by the rest-server import route), and driver-sql creates every primary key as `table.string('id').primary()` = knex's varchar(255); a sibling declared as `Field.lookup('sys_user')` emits `DEFAULT_STRING_VARCHAR_CHARS` = 255; and the landed text declarations for the same value class (sys_metadata_audit.actor, sys_metadata_commit.actor, sys_view_definition.owner) are all 255. A minted platform id is 26 characters, so the floor is cleared with 229 characters of headroom, and 255 is within the 768-character utf8mb4 key ceiling so this stays out of the hash-shadow class. The route-A pin moves out of `identity/` and now enumerates every platform object the package exports. That directory scoping is exactly how this column escaped the first pass, so a new control asserts the enumeration reaches `audit/`, `metadata/` and `system/` columns by name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
….created_by (objectstack-ai#11374 route A, last column) (objectstack-ai#12058) Route A's remaining column. `sys_import_job.created_by` is keyed by `(created_by, created_at)` and declared no bound, so driver-sql emitted it TEXT and MySQL refused the index (`ER_BLOB_KEY_WITHOUT_LENGTH`), landing the object registered-but-broken. Per objectstack-ai#11699's own measurement it was the only remaining such object outside the >768-character class. The bound is 255, derived by referenced-column transitivity from three converging in-repo producers rather than chosen: the column holds a `sys_user.id` (stamped from `context.userId` by the rest-server import route), and driver-sql creates every primary key as `table.string('id').primary()` = knex's varchar(255); a sibling declared as `Field.lookup('sys_user')` emits `DEFAULT_STRING_VARCHAR_CHARS` = 255; and the landed text declarations for the same value class (sys_metadata_audit.actor, sys_metadata_commit.actor, sys_view_definition.owner) are all 255. A minted platform id is 26 characters, so the floor is cleared with 229 characters of headroom, and 255 is within the 768-character utf8mb4 key ceiling so this stays out of the hash-shadow class. The route-A pin moves out of `identity/` and now enumerates every platform object the package exports. That directory scoping is exactly how this column escaped the first pass, so a new control asserts the enumeration reaches `audit/`, `metadata/` and `system/` columns by name. Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o Co-authored-by: Claude <noreply@anthropic.com>
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