Skip to content

sys_oauth_resource.identifier declares an uncited maxLength: 1024, and now over-declares relative to every column that references it #12313

Description

@os-warren

Filed unassigned by the #11701 dev while discharging that card's narrowing condition. Not fixed there: narrowing this column is a contract change on a different published object and would move it off #12198's hash shadow, so it needs its own ruling.

The observation

sys_oauth_resource.identifier (packages/platform-objects/src/identity/sys-oauth-resource.object.ts) declares maxLength: 1024. Two things are true of that number:

  1. It cites no producer. Every bound driver-sql: the platform-objects schema does not sync onto MySQL — unbounded string fields become TEXT, which MySQL refuses to index #11374 declared carries a [#11374] comment naming where it came from (sys_session.token → "better-auth 1.7.1's own MySQL schema … varchar(255)"; sys_oauth_consent.client_id → "a referencing column takes the referenced column's bound"; sys_account.issuer → "transitively from sys_sso_provider.issuer"). This one has no comment at all. git log -S puts it in 4109153 (fix(plugin-auth,platform-objects): close @better-auth/oauth-provider 1.7 schema drift — restore platform SSO #3080), the @better-auth/oauth-provider 1.7 schema-drift close — a parity fix that added the object wholesale, not a sourced-bound pass. It reads as generous slack chosen for "a URI", never derived.

  2. Measured, the producing contract cannot fill it. better-auth 1.7.1 is the sole writer (managedBy: 'better-auth', protection.lock: 'full'). Its own MySQL migration generator (better-auth/dist/db/get-migration.mjs, getType) emits this column as varchar(255) (the field.unique branch — oauthResource.identifier is { type: "string", required: true, unique: true }), and the referring oauthClientResource.resourceId as varchar(36) (the field.references branch). On an upstream MySQL deployment a resource identifier longer than 255 characters cannot be registered at all.

Why it matters now rather than before

#11701 narrowed sys_oauth_client_resource.resource_id from 1024 to 768 so its declared non-unique index could exist on MySQL at all. That leaves the pair asymmetric in the declaration, not just in practice:

column declared physical (MySQL, measured 8.0.46)
sys_oauth_resource.identifier (referent) 1024 text + UNIQUE on a varbinary(32) hash shadow (#12198)
sys_oauth_client_resource.resource_id (referrer) 768 varchar(768), index with SUB_PART = NULL

So on PostgreSQL or SQLite an operator can register a resource whose identifier is 900 characters — the referent's declared contract admits it — and then no client can ever be granted that resource, because the referring column refuses it. Nothing upstream can produce such a value, which is exactly why #11701's narrowing was ruled safe; but the two declarations still disagree about what a legitimate resource identifier is, and only one of them is sourced.

Dispositions worth weighing (not ruled)

  • A — narrow identifier to 768 to match its referrer. Cheapest; keeps the pair consistent; but ⚠️ it also takes sys_oauth_resource off fix(driver-sql): carry an over-long UNIQUE index on a hash-shadow column (MySQL utf8mb4) #12198's hash shadow (768 × 4 = 3072 bytes is exactly the last direct width), changing physical schema on an object that just landed on that route, and reducing the population that route demonstrates.
  • B — narrow both to a sourced 255, matching what upstream can actually store. Most honest against the producer; largest declared-domain reduction, and it rejects values in (255, 768] that the current referrer would accept.
  • C — leave 1024 and document why, i.e. accept the asymmetry and add the citation this column is missing. Cheapest by far, and defensible if the band is agreed unreachable — but it keeps a number nothing derives.

No recommendation carried here — #11701's ruling deliberately scoped itself to the referring column, and picking among these is a fresh decision.

Evidence trail

  • Upstream field declarations: @better-auth/oauth-provider@1.7.1/dist/authorize-*.mjs (resourceId.references = { model: "oauthResource", field: "identifier" }) and dist/oauth-*.d.mts.
  • Upstream width emission: better-auth@1.7.1/dist/db/get-migration.mjs, getType's mysql string branch.
  • Physical readings above are from information_schema.COLUMNS / .STATISTICS on live MySQL 8.0.46 (utf8mb4/InnoDB, STRICT_TRANS_TABLES), read as a separate query rather than from emitted DDL.

Related to #11701 (which narrowed the referrer) and #12198 (which put the referent on a hash shadow).

Generated by Claude Code

Activity

  1. os-steve commented on Aug 26, 2026

    @os-steve
    Collaborator

    Maintainer ruling recorded — B: narrow both columns to the sourced 255

    Provenance: maintainer, 2026-08-26, live PM chat (decision-inbox batch 2, session session_01JANH3y7qe3MD8aLaLXci8N), verbatim: 「同意」 — accepting the presented recommendation B with the stock-reading precondition.

    • sys_oauth_resource.identifier and sys_oauth_client_resource.resource_id both narrow to 255, each carrying a producer citation (better-auth 1.7.1 get-migration.mjs's varchar branch — the sole writer, protection.lock: full).
    • Binding precondition, step 1 of the dispatch: measure existing rows — any stored identifier/resource_id value longer than 255 in reachable deployments' system tables (the self-hosted-bypass possibility this seat could not see). A hit forks back to this box; a clean reading proceeds.
    • A (768, unsourced alignment) and C (keep 1024 + citation, dead-end preserved) are not taken.
    • Discipline: Clause-② yes (accept/reject narrowing on a published object) ⇒ contract-review tier at dispatch; the PR re-tests the fix(driver-sql): carry an over-long UNIQUE index on a hash-shadow column (MySQL utf8mb4) #12198 hash-shadow route's entry/exit on MySQL for this object (255×4 = 1020 bytes sits under the direct-index width, so the object is expected to leave the shadow — state it in the changeset rather than let it be discovered).

    State: needs-user-decision → pm:queue (domain:engine stands).


    Generated by Claude Code

  2. self-assigned this
    on Aug 26, 2026
  3. os-warren commented on Aug 26, 2026

    @os-warren
    CollaboratorAuthor

    Claimed and dispatched — precondition first, fork-back armed

    • Session: session_01W6HFzyH98W1YaQXhJUJt6o (seat post [PM seat] domain:engine — ⏳ vacant #6367)
    • Branch: claude/issue-12313-oauth-identifier-sourced-255
    • Comments re-read in the same action as the claim: three — the four-facet decision block, its escaped-marker anchor, and the maintainer ruling. No prior claim by any other session.

    Ruled B, verbatim 「同意」 on the recommendation with its stock-reading precondition: both sys_oauth_resource.identifier and sys_oauth_client_resource.resource_id narrow to 255, each carrying a producer citation (better-auth 1.7.1 get-migration.mjs's varchar branch — the sole writer, protection.lock: full). A (768, unsourced alignment) and C (keep 1024 + citation, dead-end preserved) are not taken.

    The precondition is step 1, and the fork-back is armed

    measure existing rows — any stored identifier / resource_id value longer than 255 in reachable deployments' system tables. A hit forks back to this box; a clean reading proceeds.

    That is the self-hosted-bypass possibility the analysis explicitly could not see, and it is the reason this ruling is conditional rather than flat. The dev is told, in these words, that reporting a hit is a successful outcome — the same shape that just paid for itself an hour ago on #12131, where a premise-first dispatch discovered the ruled option was literally unexecutable and stopped instead of improvising. ⛔ No silent fallback to A or C: both were considered and rejected, and choosing one unilaterally would convert a maintainer ruling into a dev's judgement.

    The hash-shadow consequence is to be stated, not discovered

    255 × 4 = 1020 bytes, which sits under the direct-index width — so sys_oauth_resource is expected to leave #12198's hash shadow. The ruling requires the PR to re-test that route's entry and exit on live MySQL for this object and say so in the changeset. A physical-schema move that a reader finds out about from a migration diff is a worse outcome than the same move announced.

    Live MySQL is available in this container — another dev started the service earlier and deliberately left it running rather than stopping a shared service under a parallel agent. So the information_schema readings this card's evidence trail is built on are reproducible here rather than deferred to CI.

    needs:contract-review applied at dispatch: clause ② is yes by the ruling's own words — an accept/reject narrowing on a published object, and in both directions of a referring pair.

    Serial check: lands in packages/platform-objects/src/identity/. The two engine cards in flight — #12166 (objectql delete path) and #12395 (metadata-protocol backfill) — share no file. sql-driver.ts is untouched by this card, so the still-open #12591 does not bind it.


    Generated by Claude Code

  4. os-warren commented on Aug 26, 2026

    @os-warren
    CollaboratorAuthor
    {
      "issue": 12313,
      "branch": "claude/issue-12313-oauth-identifier-sourced-255",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12595",
      "status": "done",
      "preconditionClean": true,
      "preconditionMeasurement": {
        "method": "Enumerated every reachable engine before touching anything. MySQL 8.0.46 live in-container: scanned all four schemas holding the two tables (os11374probe, os11627, os11627b, os11627f) via information_schema + CHAR_LENGTH, reading row counts alongside max-length so an empty table could not be mistaken for a clean corpus. Postgres: pg_isready 'no response', TCP connect refused. SQLite: no ObjectStack database file under /home, /root, /var, /opt, /srv (only Docker's own boltdb/containerd .db internals). MySQL was therefore the entire reachable population.",
        "controls": "Positive control fired IN THE SAME SCAN: a fifth schema (os12313probe) was cloned from the real table shapes and planted with identifiers of 256, 300, 900 and 1024 chars plus one short value, then the identical query ran over all five schemas in one pass. It reported 4 over-255 identifiers and 1 over-255 resource_id in the control and 0 in the four real schemas, correctly excluding the planted short value. So a long value would have been found had one existed.",
        "verdict": "CLEAN — but VACUOUSLY so, and the distinction is load-bearing. All eight real tables hold ZERO rows, so this is a clean reading over a population of zero, not over a populated corpus. It does NOT disprove the self-hosted-bypass possibility the decision analysis flagged: no customer or production deployment is reachable from this container. It establishes only that nothing reachable here stores such a value. Proceeded to step 2 per the ruling, with this caveat surfaced rather than buried."
      },
      "clause2": "yes",
      "clause2_evidence": "An accept/reject narrowing on a published object, in both directions of a referring pair. git diff --stat b72db01985..3fda8cb404: 5 files changed, 255 insertions(+), 30 deletions(-) — .changeset/oauth-resource-identifier-sourced-255.md (+66), sys-oauth-client-resource.object.ts (62 +/-), sys-oauth-resource-sourced-bounds.test.ts (+111, new), sys-oauth-resource.object.ts (+28), platform-keyed-text-bounds.test.ts (18 +/-, comment-only). 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.",
      "hashShadowOutcome": "MEASURED entry and exit on live MySQL 8.0.46, driver-synced at each declaration, physical facts read from information_schema as their own query. BEFORE (1024): identifier = text (65535 octets); shadow column uniq_sys_oauth_resource_identifier__hash varbinary(32) PRESENT; UNIQUE index keyed on the shadow. AFTER (255): identifier = varchar(255) (1020 octets); NO __hash column; UNIQUE index keyed directly on identifier with SUB_PART NULL. So sys_oauth_resource LEAVES the #11627/#12198 hash shadow, as the ruling anticipated — 255 x 4 = 1020 bytes sits under the 3072-byte key-part ceiling. Uniqueness is unchanged and still enforced over the full value: the replacement is a direct full-value UNIQUE, never a prefix index. Referrer measured too: resource_id = varchar(255)/1020 octets, non-unique index direct, SUB_PART NULL. Stated explicitly in the changeset per the ruling.",
      "gates": {
        "green": [
          "check:nul-bytes", "check:changeset-gate-self-tests", "check:cross-package-test-inputs (+ 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 (after building the CLI)", "check-adr-0087-registration (after adding the disposition)",
          "@objectstack/platform-objects typecheck", "@objectstack/platform-objects test (32 files / 519 tests)"
        ],
        "red": [],
        "notMeasured": [
          "check:type-check-debt --re-measure — DECLARED NARROWING, and the narrowing is itself a measurement. platform-objects hides its tests from tsc (tsconfig excludes **/*.test.ts) and carries TEST_DEBT = 3, so the ratchet IS load-bearing for the new test file. Rather than rebuild the whole workspace closure, the ledger entry was re-measured the way the gate does — the package's own config with the test exclusion lifted — with a positive control confirming the lift (32 test files in the program). Result: 3 raw errors, TS2339 x2 + TS7006 x1, matching the ledger to the unit, all in the pre-existing feature-gate-guard.test.ts and 0 in the new file. The ratchet cannot move for this card. CI runs the full gate regardless."
        ],
        "note": "Every exit captured BEFORE any pipe; every verdict quoted from the gate's own output, never a wrapper's $?. This caught a live instance of the warned-about trap: the verify-lock wrapper printed VERDICT command-exit 0 for a batch in which check-adr-0087-registration and check:i18n had each exited 1. Two gates needed real work rather than a tick — adr-0087 was a GENUINE RED (breaking changeset with no ledger disposition; answered with not-required (no-migration-prescription), gate now reports '1 declared-breaking changeset(s), each carrying an ADR-0087 disposition'), while check:i18n exited 1 saying 'PREREQUISITE NOT MET ... Nothing was checked' — NOT MEASURED, not red; the CLI was built and it was re-run for a real verdict: 'check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).'"
      },
      "ablations": [
        {
          "name": "A — referent reverted to the pre-fix 1024 (referrer left at 255)",
          "predictedBeforeRunning": "RED; exactly 3 failures, all in the new pin: referent-width, dead-end invariant, keyable-bound. platform-keyed-text-bounds.test.ts fully green (it polices only NON-unique keyed text; identifier's index is UNIQUE). Totals 3 failed / 8 passed.",
          "mutationProvedOnDisk": "anchored grep -cF BEFORE reading any result: 'maxLength: 1024,' 0 -> 1 and 'maxLength: 255,' 3 -> 2 in sys-oauth-resource.object.ts",
          "observed": "EXACT MATCH — 3 failed / 8 passed, and precisely the three predicted test names",
          "rebuild": "none needed, justified by IMPORT FORM: the pin imports './sys-oauth-resource.object', a relative path inside the same package, which vitest resolves to src/*.ts — never through the package exports map or dist/",
          "restore": "trap '<git checkout branch -- path>' EXIT INT TERM; restore verified with an empty git diff (0 lines)"
        },
        {
          "name": "B — referrer reverted to #11701's 768 (referent left at 255)",
          "predictedBeforeRunning": "RED, and deliberately NARROWER: exactly 1 failure, the dead-end invariant only. The referent-width test, the keyable test (768 <= 768) and platform-keyed-text-bounds.test.ts all stay green. Totals 1 failed / 10 passed.",
          "mutationProvedOnDisk": "anchored grep -cF BEFORE reading any result: 'maxLength: 768,' 0 -> 1 and 'maxLength: 255,' 2 -> 1 in sys-oauth-client-resource.object.ts",
          "observed": "EXACT MATCH — 1 failed / 10 passed, only 'the referrer admits exactly the referent's domain — no dead-end band'. This is the ablation that matters: it proves the invariant assertion catches the asymmetry on its own rather than riding on the referent-width assertion.",
          "rebuild": "none needed, same import-form justification",
          "restore": "trap ... EXIT INT TERM; restore verified with an empty git diff (0 lines)"
        }
      ],
      "changedExistingAssertions": "NONE. platform-keyed-text-bounds.test.ts received COMMENT-ONLY edits recording that #11701's 768 is now 255; its assertions are untouched and still pass, because 255 <= 768 keeps the column in the enumerated set and inside the keyable rule.",
      "openQuestions": [
        {
          "question": "The precondition came back clean, but over a population of ZERO rows — every reachable table is empty, and no customer or self-hosted deployment is reachable from this container. Does the maintainer consider the ruling's precondition discharged by a vacuous clean reading, or does it want a reading against a real deployment before this merges?",
          "options": [
            "A — treat it as discharged: the band is unreachable by construction anyway, since the sole writer (protection.lock: full) stores the identifier in varchar(255), so no better-auth-managed row can exceed 255 regardless of stock",
            "B — hold the PR until someone runs the same scan against a real deployment's system tables (the scan is a single portable query and is reproduced verbatim in the PR body)"
          ],
          "recommendation": "A, because the self-hosted-bypass risk the precondition exists for requires a writer OTHER than better-auth, and the objects declare managedBy: 'better-auth' with protection.lock: 'full' — no supported path writes them. But this is the maintainer's call, not mine, which is why it is reported rather than assumed: I measured what was reachable and it was empty, and an empty scan is not the same evidence as a populated one."
        },
        {
          "question": "The ruling's parenthetical sourced BOTH columns to 'get-migration.mjs's varchar branch'. Measured, that is exactly right for the referent (varchar(255)) but NOT for the referrer: upstream emits oauthClientResource.resourceId as varchar(191), not varchar(36) and not varchar(255). I implemented the ruling's NUMBER (255) via the referring-column derivation already established for client_id — which the oauthClient.clientId = varchar(255) measurement independently confirms — rather than copying upstream's 191, because 191 is an artifact of upstream's index budget on its own physical schema. Flagging so the ruling's rationale and the implementation stay reconciled in the record.",
          "options": [
            "A — keep 255/255 as implemented: referent sourced directly, referrer sourced from the referent, pair symmetric, dead end fully closed",
            "B — take upstream's 191 for the referrer too, which would REOPEN a dead end at (191, 255] inside ObjectStack for no gain"
          ],
          "recommendation": "A — it is what the ruling's number says, it is what the tree's own referring-column precedent says, and it is the only one of the two that leaves no gap between referent and referrer. B would import an upstream defect we do not have."
        }
      ],
      "out_of_scope_findings": [],
      "out_of_scope_note": "None filed, deliberately. The one defect found in passing — the in-tree #11701 citation asserting upstream emits the referring column as varchar(36) via getType's field.references arm, when it measurably emits varchar(191) — was fixed in place under the bounded-exemption test: same defect class as this card (a miscited bound on this exact field pair), mechanical with the correct form pinned by my own measurement, in a file this card already holds and already rewrites, and inside the same gate family. It is named in the PR body and in the changeset rather than slipped in. Separately: upstream better-auth 1.7.1 has its OWN (191, 255] register-but-never-link band, since it stores oauthResource.identifier in varchar(255) but oauthClientResource.resourceId in varchar(191). That is an upstream defect with no ObjectStack-side consequence — we emit our own schema and both columns are 255 — so it is documented in the field comment rather than filed as an ObjectStack issue.",
      "summary": "Ran the binding precondition first: clean, with a positive control firing in the same scan, but vacuously clean (every reachable table empty; no production deployment reachable) — reported rather than glossed. Then implemented ruling B: sys_oauth_resource.identifier 1024 -> 255 and sys_oauth_client_resource.resource_id 768 -> 255, each carrying a producer citation measured firsthand by running better-auth 1.7.1's own migration generator against live MySQL 8.0.46 and reading information_schema as its own query. That measurement also corrected the existing #11701 citation, which claimed varchar(36) where upstream actually emits varchar(191). Re-tested the #12198 hash-shadow route on live MySQL and measured the object LEAVING the shadow (text + varbinary(32) shadow -> varchar(255) with a direct full-value UNIQUE, SUB_PART NULL), stated explicitly in the changeset. New pin asserts the referent/referrer symmetry as an invariant, not just two integers; both ablations matched their written predictions exactly (3/8 and 1/10)."
    }

    Generated by Claude Code


    Generated by Claude Code

  5. os-warren commented on Aug 26, 2026

    @os-warren
    CollaboratorAuthor

    os-dev-report — machine-findable anchor (plain-text spelling)

    The dev report for this card is the immediately preceding comment, #issuecomment-5427374419, which carries the full JSON.

    Its intended first-line marker — the HTML-comment spelling of os-dev-report — was eaten by the sanitizer on write, exactly as happened to the os-decision-facets marker earlier in this same thread (comment 5414442497). Read back after posting: the stored body begins with the bare fenced json block and the literal does not appear anywhere in it, so a PM scan keyed on that marker would not find the report. This comment carries the greppable literal in plain text, which survives the sanitizer. The report itself stands unchanged.

    Summary line for a scan that gets this far: status done, precondition clean but vacuously so (every reachable table empty; positive control fired in the same scan), PR #12595 (draft), sys_oauth_resource measured leaving the #12198 hash shadow. Two open questions for the maintainer are recorded in the JSON.


    Generated by Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions