Skip to content

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

Merged
hotlong merged 4 commits into
mainfrom
claude/issue-11374-declare-identity-column-bounds
Aug 25, 2026
Merged

hotlong merged 4 commits into
mainfrom
claude/issue-11374-declare-identity-column-bounds

Conversation

@huangyiirene

@huangyiirene huangyiirene 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 sourced maxLength 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.

Column Bound Source
sys_session.token 255 better-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.mjs createSession).
sys_account.provider_id 255 Transitive 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_id 256 IdP 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.issuer 2048 Transitive 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.key 64 Exact producer bound: the only writer is packages/core/src/security/api-key.ts hashApiKey — "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_code 191 Upstream 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_code 191 Same runtime cap; userCodeLength is max(191), default 8.
sys_device_code.status 64 Closed literal set written by the plugin's own routes — `'pending'
sys_verification.identifier 255 better-auth 1.7.1's own MySQL schema: verification.identifier declares index: 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_id 255 Referenced 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_id 255 Same referenced-column derivation.
sys_oauth_consent.client_id 255 Same referenced-column derivation.
sys_oauth_client_resource.client_id 255 Same referenced-column derivation.

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

Column Why no bound
sys_verification.value better-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_by In 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):

before after
MySQL syncSchema failures 12 / 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 present 89 / 128 104 / 128 (+15)
Postgres control 0 / 44 0 / 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/value → text 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

claude added 3 commits August 24, 2026 11:57
…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

github-actions Bot commented Aug 24, 2026 •

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, 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 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 589758d22ccacf9cc56b5bc8a9f9766cb7e2a93a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 540fd24f3f39e2ccbaaa033788c2268a5f76bd99 — the merge of head 970604024b38859f4475aad0bca74cf1592fb97d into base 589758d22ccacf9cc56b5bc8a9f9766cb7e2a93a, 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 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

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

…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
@huangyiirene
huangyiirene marked this pull request as ready for review August 24, 2026 22:20

Copy link
Copy Markdown
Collaborator Author

Ready for triage review — ⛔ deliberately NOT enqueued

domain:engine PM seat, session session_01VK8rFDtg8eREaxBGX99Csn.

What changed since this PR last sat still: its base was 76 commits behind main. Merged main in (970604024b) — clean automatic merge, zero conflicts — so CI has now run against current main rather than a stale tree.

CI on the merged base: 31/31, zero failures. ⚠️ Including Test Core (1/6), the shard #11808 kills — it passed at 21:51:19Z, so that hazard did not touch this run.

Flipped to ready; draft: false read back (⛔ raw REST answers 200 and does nothing, so the flip is only real once read back). ⛔ Auto-merge deliberately not enabled — the maintainer directed that this go to triage review rather than being enqueued by this seat.

What a reviewer should actually look at

⚠️ The bounds table in the body is the veto surface, and it is the point of this review. The #11374 ruling made "the maintainer's PR review the veto window" on those 13 sourced bounds, which is why this PR sat draft with CI green for nine hours rather than landing. Every value is derived from a named source — none is guessed — but they are enforcement changes: a 300-char sys_session.token INSERT now fails ER 1406 Data too long with 0 rows (refused, not truncated). Veto or narrow any row.

Two rows the author explicitly flagged as judgment calls rather than derivations:

  • sys_device_code.status → 64 — the closed literal set is 8 chars wide; 64 follows the landed sys_session.revoke_reason precedent. Headroom is taste.
  • sys_account.issuer → 2048 — anything tighter refuses a sign-in the platform's own SSO contract admits. ⚠️ 2048 > 768 (utf8mb4 key ceiling), so the column stays TEXT and the (issuer, account_id) UNIQUE still cannot exist on MySQL; that composite deliberately moves to the C-card class.

⛔ This is Part of #11374, not Closes — two columns in route A's measured scope are deliberately left unbounded, with reasons stated, and sys_import_job.created_by is out of the dispatched file surface. The card stays open for that remainder.

Result if it lands: MySQL syncSchema failures 12/44 → 8/44; declared indexes physically present 89/128 → 104/128. The session-token UNIQUE that sign-in assumes exists on MySQL for the first time.


Generated by Claude Code

@hotlong
hotlong added this pull request to the merge queue Aug 25, 2026
Merged via the queue into main with commit 3954fb7 Aug 25, 2026
32 checks passed
@hotlong
hotlong deleted the claude/issue-11374-declare-identity-column-bounds branch August 25, 2026 05:40
os-warren pushed a commit that referenced this pull request Aug 25, 2026
….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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
….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>
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

Development

Successfully merging this pull request may close these issues.

3 participants