Skip to content

feat(platform-objects): declare sourced maxLength bounds on the unbounded keyed identity columns (#11374 route A) - #11699

Draft
huangyiirene wants to merge 3 commits into
mainfrom
claude/issue-11374-declare-identity-column-bounds
Draft

feat(platform-objects): declare sourced maxLength bounds on the unbounded keyed identity columns (#11374 route A)#11699
huangyiirene wants to merge 3 commits into
mainfrom
claude/issue-11374-declare-identity-column-bounds

Conversation

@huangyiirene

@huangyiirenehuangyiirene commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

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 sourcedmaxLength on the 13 keyed text-family identity columns that declared none, so driver-sql (which since #11430 honours a keyed field's declared bound) can emit them as varchar(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.

ColumnBoundSource
sys_session.token255better-auth 1.7.1's own MySQL schema: a unique string field emits varchar(255) (better-auth/dist/db/get-migration.mjs, string typeMap). Producer writes generateId(32) — 32 chars (db/internal-adapter.mjscreateSession).
sys_account.provider_id255Transitive from the widest producer: sys_sso_provider.provider_id declares maxLength: 255 (landed). Built-in social providers are short fixed slugs. Upstream 1.7.1 leaves it unindexed/unbounded.
sys_account.account_id256IdP norms: SAML Core 2.0 §8.3.7/§8.3.8 cap persistent/transient NameID at 256; OIDC Core §2 caps sub at 255 ASCII. 256 = the wider. Above the 191 better-auth's own MySQL schema enforces via its (issuer, accountId) unique. ⚠️ An unspecified-format SAML NameID has no normative cap — flagged, not covered.
sys_account.issuer2048Transitive from the landed sibling contract: sys_sso_provider.issuer declares maxLength: 2048, and @better-auth/sso writes the verified token's raw iss claim — or provider.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). ⚠️ Corner flagged for veto: the synthetic issuer local:oauth:{urlencoded provider_id} can exceed 2048 only for a >225-char non-ASCII provider id — pathological but expressible.
sys_api_key.key64Exact producer bound: the only writer is packages/core/src/security/api-key.tshashApiKey — "sha256(raw) hex — store this in sys_api_key.key" — a fixed 64-char digest. better-auth's apiKey plugin is not loaded.
sys_device_code.device_code191Upstream hard runtime cap: better-auth 1.7.1 device-authorization refuses ANY generated code > 191 chars (validateGeneratedCode), custom generators included; deviceCodeLength option schema is max(191), default 40.
sys_device_code.user_code191Same runtime cap; userCodeLength is max(191), default 8.
sys_device_code.status64Closed literal set written by the plugin's own routes — `'pending'
sys_verification.identifier255better-auth 1.7.1's own MySQL schema: verification.identifier declares index: truevarchar(255) — every upstream flow that writes this table, oauth-provider included, already lives inside 255 on upstream MySQL.
sys_oauth_access_token.client_id255Referenced column's bound: FK to sys_oauth_application.client_id, which declares maxLength: 255 (landed); upstream oauthClient.clientId is a unique string ⇒ varchar(255) on MySQL.
sys_oauth_refresh_token.client_id255Same referenced-column derivation.
sys_oauth_consent.client_id255Same referenced-column derivation.
sys_oauth_client_resource.client_id255Same referenced-column derivation.

Left out — reported, not guessed (the ruling's escape clause):

ColumnWhy no bound
sys_verification.valuebetter-auth's oauth-provider stores OIDC authorization-code payloads here as a JSON blob (the field's own index comment says so); upstream declares it unindexed and unbounded. No bound provably admits every value better-auth may write. Its ObjectStack-declared [value] index therefore still cannot exist on MySQL — recorded in the sub-issue under #11627.
sys_import_job.created_byIn A's measured scope but outside this card's dispatched file surface (packages/platform-objects/src/audit/, not identity/). Left unedited per the surface constraint; reported in the dev report. It is the only remaining ER_BLOB_KEY_WITHOUT_LENGTH object 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'), process TZ=America/New_York. All 44 distinct exported platform objects through SqlDriver.syncSchema, at HEAD 368f162 (clean tree; probe imports the freshly built dist of both packages):

beforeafter
MySQL syncSchema failures12 / 44 (11 × ER_BLOB_KEY_WITHOUT_LENGTH, 1 × ER_TOO_LONG_KEY)8 / 44 (7 × ER_BLOB_KEY_WITHOUT_LENGTH, 1 × ER_TOO_LONG_KEY)
MySQL declared indexes physically present89 / 128104 / 128 (+15)
Postgres control0 / 440 / 44 (128/128 indexes, both legs)

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 from information_schema matches the table above exactly (token → varchar(255), account_id → varchar(256), key → varchar(64), codes → varchar(191), client_id × 4 → varchar(255), issuer/valuetext by design).

The remaining 8: sys_import_job (out-of-surface column above), sys_metadata + 3 × oauth maxLength: 1024 (already ruled to #11627), sys_oauth_client_resource (its resource_id: 1024 — a >768 case missing from #11627's list, filed), sys_verification (value, above), sys_account (issuer composite, above).

Accept/reject flip measured (why this is an enforcement change): a 300-char sys_session.token INSERT on the strict server now fails ER 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.ts enumerates 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: removing sys_session.token's bound (mutation proven on disk — anchored grep 1→0) turned exactly the presence assertion red naming sys_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; typecheck clean (> tsc --noEmit echoed).
  • Downstream consumer control: pnpm --filter @objectstack/plugin-auth test — 73 files, 1480 passed against the rebuilt platform-objects dist.
  • Full-repo pnpm lint (eslint . --no-inline-config) — exit 0 in 2m23s, a FULL run, no narrowing claimed.
  • Gate union re-derived from the actual diff (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 — including check: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) and check: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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

9 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see

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 4ceae8ab0b4c6649e0fa6faf591b4307b17c021dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from b36794bfe4e6f995d9b8ebe3bb0b778c7ac1cc8d — the merge of head 368f1622541117c69eaf39cbe32d1d082523b704 into base 4ceae8ab0b4c6649e0fa6faf591b4307b17c021d, 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 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

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 24, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@huangyiirene@claude