Skip to content

#11627 population re-measured after the A half: three additional members of the >768/unboundable keyed-text class (resource_id 1024, sys_verification [value], sys_account [issuer+account_id]) #11701

Description

@huangyiirene

Parent card #11627 says its final population is decided by #11374's A PR ("the A PR decides the exact remaining population this card must cover; re-verify the four cases against that ref"). Measured against that PR's head (368f162254, PR #11699, live MySQL 8.0.46): the four ruled cases still stand, and three more members of the same class exist that the parent's list does not name.

The three additions (all measured, none speculative)

  1. sys_oauth_client_resource.resource_id — declares maxLength: 1024 (landed, FK to sys_oauth_resource.identifier which is one of the four ruled cases), keyed by the declared non-unique index [resource_id]. 1024 > 768 ⇒ column stays TEXT and the index fails ER_BLOB_KEY_WITHOUT_LENGTH on MySQL. Same class, same fix family as the parent's sys_oauth_resource.identifier; a shadow-key or the parent's chosen mechanism must cover the referencing column too or the object keeps failing sync.

  2. sys_verification [value] index — value is unboundable, not just >768: better-auth's oauth-provider stores OIDC authorization-code payloads there as a JSON blob (the field's own index comment in sys-verification.object.ts documents this, and upstream better-auth 1.7.1 declares the field unindexed and unbounded). driver-sql: the platform-objects schema does not sync onto MySQL — unbounded string fields become TEXT, which MySQL refuses to index #11374's A half deliberately left it undeclared under the ruling's escape clause, so its ObjectStack-declared non-unique [value] index can never exist on MySQL via a bound. Two candidate dispositions for this card to weigh: hash-shadow key (the parent's route), or measuring whether anything actually queries by value — upstream keys verification lookups on identifier, so the index may be removable; that liveness measurement has not been made and should be, before adding a shadow column.

  3. sys_account [issuer, account_id] UNIQUE — new member created by the A half itself: issuer now declares maxLength: 2048, transitively from the landed sys_sso_provider.issuer contract (2048) whose values (iss claim / registered issuer URL) are written verbatim into the column, so no keyable (≤768) bound is defensible. The composite unique that better-auth 1.7 resolves account identity by therefore still cannot exist on MySQL and needs the parent's hash-shadow treatment. (The provider-scoped [provider_id, account_id] UNIQUE does exist physically after the A half — 255+256 chars = 2044 bytes, under the 3072-byte ceiling.)

Post-A measurement (context for re-verification)

MySQL 8.0.46, all 44 platform objects: 8/44 still fail sync — sys_metadata + the 3 ruled maxLength: 1024 columns (parent's four), plus the three members above, plus sys_import_job.created_by (unbounded, in #11374's remaining A scope — not this card's). Postgres 16.13 control: 0/44.

Filed unassigned by the #11374 route-A dev for the parent card's triage; not to be dispatched independently of the parent's sequencing (parent is pm:blocked behind #11374).

Generated by Claude Code

Activity

  1. added theissue type on Aug 24, 2026
  2. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    Contributor

    Triage (session session_01LrqnyLhY3kYBBRird4peBT, 2026-08-24): domain:engine, pm:blocked, type Task — population data for parent #11627, filed by the #11374 route-A dev with the explicit instruction not to dispatch independently of the parent's sequencing.

    Blocked-by: #11374

    Unlock-action: when #11374 closes, do NOT return this card to queue on its own — fold its three members into #11627's population (the parent's own text says the A PR decides its final population and this card is that re-measurement), re-verify the three members against the merged ref, then close this card as absorbed or keep it only if #11627's owner splits it out deliberately. The sys_verification [value] member carries an unmade liveness measurement (does anything query by value?) that should precede any shadow-column work — cheaper than building one.


    Generated by Claude Code

  3. os-warren commented on Aug 25, 2026

    @os-warren
    Collaborator

    Maintainer ruling — both non-unique remainders, 2026-08-25

    Source: maintainer, 2026-08-25, live PM chat (session session_01W6HFzyH98W1YaQXhJUJt6o), answering the two questions escalated from #11627's ACCEPT.

    PR #12198 closed the 5 UNIQUE members of this class by hash shadow (MySQL 7 → 2 failing objects). These are the 2 NON-UNIQUE remainders, which a hash shadow structurally cannot serve — a digest index accelerates no WHERE col = ? unless the read side is rewritten to filter on the digest too.

    1. sys_verification.value → remove the declared index

    Ruled: A. MySQL then syncs the object with no shadow column and no write cost.

    The liveness evidence that carried it, measured under #11627 rather than assumed:

    • sys-verification.object.ts's own index comment states "better-auth keys verification lookups on identifier, not value";
    • upstream better-auth 1.7.1 declares the field unindexed and unbounded;
    • no in-repo query filters sys_verification by value.

    The reasoning is honesty, not convenience: an index that silently does not exist on one dialect is the worst of both worlds, and removing it makes the metadata match reality — which is the declared = enforced property this whole card family exists to restore. Route B (hash shadow anyway) was rejected as paying permanent write amplification for an access path nobody requests; route C (rewrite the read side to add digest predicates) as a query-planner project with no measured demand.

    ⚠️ Carried obligation: sys_verification.value is named in the UNBOUNDABLE allowlist in packages/platform-objects/src/platform-keyed-text-bounds.test.ts, with its reason. That entry must move with this change rather than being left stale — it was written to explain why the column could not be bounded, and the reason changes when the index goes.

    2. sys_oauth_client_resource.resource_id → narrow the declared bound to ≤768, conditionally

    Ruled: A, and it is the only option that keeps the access path and makes it expressible. It travels #11374's A half — the route already ruled and landed for 13 sibling columns — so it introduces no new mechanism.

    ⚠️ The condition is NOT discharged and is an implementation obligation, not a formality. resource_id mirrors sys_oauth_resource.identifier, declared at 1024. Narrowing the referring column below its referent needs evidence that no legitimate resource identifier exceeds 768 characters (in practice a URI, far under it).

    ⛔ If the two must stay equal-width, route A is unavailable — stop and report rather than narrowing anyway. A bound that rejects a value the producing contract can emit is the defect this family exists to remove, not a smaller version of it. The fallback is then B (drop the index, accept a table scan on this join) or a ruling on C, and that is a fresh decision.

    ⚠️ Unlike sys_verification.value, this column is plausibly queried — it is the FK side of sys_oauth_resource.identifier — so "just remove the index" was explicitly not taken here. Do not travel the two rulings together.

    State

    #11701 stays open and carries both. #11627 is closed by PR #12198 for the UNIQUE half; this card is the non-unique remainder and is now pm:queue with a ruled route for each half.


    Generated by Claude Code

  4. self-assigned this
    on Aug 25, 2026
  5. os-warren commented on Aug 25, 2026

    @os-warren
    Collaborator

    os-dev-report

    {
      "issue": 11701,
      "status": "done",
      "branch": "claude/issue-11701-non-unique-keyed-text-remainders",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12314",
      "premise_still_valid": true,
      "summary": "Both ruled halves implemented, travelled separately. (1) sys_verification.value: the declared non-unique index is REMOVED; liveness re-verified independently rather than carried forward — better-auth 1.7.1 keys every verification lookup on identifier/id/expiresAt (internal-adapter.mjs), a grep for 'field: \"value\"' across better-auth and @better-auth/oauth-provider returns nothing, and no in-repo query filters by value. (2) sys_oauth_client_resource.resource_id: bound narrowed 1024 -> 768, and THE CONDITION IS DISCHARGED, not waived — upstream better-auth is the sole writer (managedBy better-auth, protection lock full) and its own MySQL migration generator (get-migration.mjs getType) emits the referent oauthResource.identifier as varchar(255) and this referring column as varchar(36), so the discarded (768,1024] band holds nothing the producing contract can emit; 768 chosen over upstream's 255 as the SMALLEST narrowing that makes the index expressible. The carried obligation was honoured: the UNBOUNDABLE allowlist entry for sys_verification.value moved with the change (an unindexed column is not a keyed column, so it would have gone red by name on the list's own rot check), and because that emptied the list, the rule was extracted into unboundedKeyedColumns(objects, allowlist) with a synthetic control driving both outcomes so the excusing branch cannot rot. A new #11701 pin enumerates the whole package and rejects ANY non-unique index over a text column MySQL cannot key — the executable form of 'the class is closed', so a third member fails at test time rather than on a live server. UNEXPECTED MEASURED RESULT worth PM attention: removing the dead [value] index did not merely delete an index, it RESTORED two live ones — sys_verification on MySQL previously carried only PRIMARY because initObjects aborted at the [value] index, so idx_sys_verification_identifier and idx_sys_verification_expires_at now exist for the first time, including the one better-auth actually queries on.",
      "tests": "All at HEAD dab5649c7e (git rev-parse --short HEAD from the same tree the union ran on; tree clean vs HEAD, verified). CLAUSE 2 LIVE MEASUREMENT — self-provisioned MySQL 8.0.46 (utf8mb4/InnoDB/STRICT_TRANS_TABLES, my own port 33701) and PostgreSQL 16.13 (my own port 55701; never touched the sibling agent's instance). Every distinct exported platform object driven through initObjects one at a time; before-leg is the exact structural inverse of this diff applied in memory (proved in the harness output: verification indexes [value,identifier,expires_at] vs [identifier,expires_at]; resource_id 1024 vs 768). MySQL failing syncSchema: 2/45 -> 0/45, the 2 being sys_oauth_client_resource and sys_verification, both ER_BLOB_KEY_WITHOUT_LENGTH — reproducing #12198's stated remainder exactly by name and code. Postgres control 0/45 -> 0/45 throughout. Population is 45 not 44 because sys_metadata_activation landed between #12198's base and my branch point via #12185; both legs use the same enumeration so the delta is unaffected. PHYSICAL CATALOG, read via a SEPARATE information_schema query, never the emitted DDL: resource_id text(65535) -> varchar(768); idx_sys_oauth_client_resource_resource_id absent -> present with NON_UNIQUE=1 and SUB_PART=NULL (the discriminator that separates a whole key part from the REJECTED prefix index); sys_verification.value text(65535) unchanged; no index on value in either state. BOUNDARY both sides: resource_id at 768 accepted (storedLength 768), at 769 REFUSED ER_DATA_TOO_LONG; sys_verification.value at 5000 chars still accepted — the control proving the index removal narrowed nothing. GATES: union derived not recalled via 'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack', re-derived after the changeset existed — 13 path-matched + 8 convention-triggered. All 21 run, every exit captured BEFORE any pipe (GATE-EXIT n=0 for every one). Verdict lines quoted from the gates themselves: check-adr-0087-registration 'this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)' — this is what SETTLES the declared-breaking question the dispatch posed, by the gate's ruling not my assumption; check-type-check-coverage --re-measure '32 ledger entr(ies) re-measured in 238.0s, 1843 raw tsc error(s) total, none above its recorded number ... surplus: none' on a fully built closure (turbo 70/70); check-nul-bytes 'scanned 6803 text file(s) ... no raw ASCII control bytes'; check-i18n 'OK (9 package(s) — all bundles in sync)'; check-i18n-stale-fill '0 stale-fill leaf/leaves'. pnpm lint (eslint . --no-inline-config) whole repo exit 0 — run IN FULL, so no narrowing is claimed. @objectstack/platform-objects typecheck exit 0; test 493 passed (493). ABLATION — direction predicted IN WRITING FIRST (prediction file written before the run): reverting only the two object definitions to the branch point must go RED 3-of-7 naming exactly sys_verification.value (maxLength: undefined) and sys_oauth_client_resource.resource_id (maxLength: 1024). MEASURED: 3 failed | 4 passed, the same three by name, offender lists exactly as predicted. Mutation PROVEN ON DISK by single-line anchored grep counts read BEFORE any test result (value-index 0->1, 'maxLength: 1024' 0->1, 'maxLength: 768' 1->0); restore leg re-verified the same way back to 0/0/1 with git status empty; the script carried trap ... EXIT INT TERM. NO REBUILD OWED and none done — the test imports its subject as a RELATIVE specifier ('./index'), which vitest resolves to src/index.ts, so no dist participates in either leg and ablation-dist-preflight does not apply. CAVEAT worth PM attention: os-verify-lock.sh printed 'VERDICT command-exit 0' for the ablation batch while the vitest inside exited 1 — the #12288 behaviour; the reading used is the script's own ABLATION-TEST-EXIT=1, not the wrapper verdict.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #12313 (unassigned, unlabelled for PM triage): sys_oauth_resource.identifier declares an UNCITED maxLength: 1024 — introduced wholesale by #3080's schema-parity fix, carrying none of the [#11374] provenance comment every sourced bound has — and after this card it over-declares relative to every column that references it. Reachable on PG/SQLite: an operator can register a 900-char resource identifier the referent's declared contract admits, which no client can then be granted because the referrer now refuses it. Deliberately NOT fixed here: narrowing it is a contract change on a different published object AND would move sys_oauth_resource off #12198's hash shadow (768x4 = 3072 bytes is exactly the last direct width), so it is a fresh decision with three weighed dispositions in the card."
      ]
    }

    Notes on channel facts, declared per the dispatch:

    Generated by Claude Code


    Generated by Claude Code

  6. os-warren commented on Aug 25, 2026

    @os-warren
    Collaborator

    ACCEPT — PR #12314. Both ruled halves implemented, travelled separately as required.

    The condition on resource_id is discharged, not waived — which was the hard fence

    I wrote: "⛔ If the two must stay equal-width, route A is UNAVAILABLE — stop and report. Do not narrow anyway." It was discharged with evidence rather than argued around:

    • upstream better-auth is the sole writer (managedBy better-auth, protection lock full);
    • its own MySQL migration generator (get-migration.mjs getType) emits the referent oauthResource.identifier as varchar(255) and this referring column as varchar(36);
    • ⇒ the discarded (768, 1024] band holds nothing the producing contract can emit.

    ⭐ And the choice of 768 over upstream's own 255 is the part I want to highlight: 768 is the smallest narrowing that makes the index expressible, chosen deliberately because "guessing tighter to make a key fit is what sys_account.issuer refuses to do." That is the family's own discipline applied to itself — a bound is derived from the producer, not from what would be convenient for the index.

    ⭐ The unexpected result: the dead index was suppressing two live ones

    Removing the [value] index did not merely delete something inert:

    sys_verification on MySQL previously carried only PRIMARY, because initObjects aborted at the [value] index. So idx_sys_verification_identifier and idx_sys_verification_expires_at now exist for the first time — including the one better-auth actually queries on.

    So this half is not a cleanup, it is a performance fix: an unkeyable index was failing the whole initObjects pass and taking two working indexes down with it, on the exact column better-auth reads by. Nobody asked for that and it was not in the ruling's reasoning — it is what measuring on a live server buys you.

    MySQL 2/45 → 0/45, Postgres control 0/45 → 0/45. The population is 45 rather than 44 because sys_metadata_activation landed between #12198's base and this branch point via #12185 — and both legs use the same enumeration, so the delta is unaffected. That is the right way to report a moving denominator.

    Boundary measured both sides from the physical catalog: resource_id 768 accepted / 769 refused ER_DATA_TOO_LONG; NON_UNIQUE=1 with SUB_PART=NULL — the discriminator separating a whole key part from the rejected prefix index. And a control that matters: sys_verification.value still accepts 5000 chars, proving the index removal narrowed nothing.

    The allowlist obligation was honoured, and then hardened

    The UNBOUNDABLE entry moved with the change rather than being left stale — and "an unindexed column is not a keyed column, so it would have gone red by name on the list's own rot check." Good: the rot check did its job.

    ⭐ Because that emptied the list, the rule was extracted into unboundedKeyedColumns(objects, allowlist) with a synthetic control driving both outcomes, so the excusing branch cannot rot. An allowlist with zero entries never exercises its own excusing path — that path would have decayed silently. Not asked for; correct.

    A new pin enumerates the whole package and rejects any non-unique index over a text column MySQL cannot key — the executable form of "the class is closed", so a third member fails at test time rather than on a live server.

    ⚠️ My dispatch was wrong about the pins, and this is the third time this shift

    I wrote: "PR #12198 landed pins asserting the post-fix MySQL state — 2 of 44 objects still failing syncSchema. If your change takes that to 0, those pins go red. ⛔ Move them with the change; do not delete them."

    No such pin exists. The dev read them first (as instructed) and found: #12198's tests are synthetic-fixture driver tests asserting the driver still refuses a non-unique unkeyable index — unchanged by this card. The "2 of 44" figure lives only in PR prose and docblocks. I verified: the sole of 44 occurrence anywhere in packages/ is a docblock comment line, not an assertion.

    That is the third unmeasured assertion I have put in a dispatch brief today, and they are one shape, not three slips:

    1. "fix(driver-sql): name the declared field a builtin column discards, on all three DDL paths (#12015) #12109/fix(devx): give the driver-conformance census a dialect axis #12134/docs(tests): route A for the three memory-driver fixtures — the console-level capture captures zero, measured #12145 are all green and armed" — two of the three were still drafts I had said I would flip and never did.
    2. "no branch exists, nothing to resume" (driver-sql (MySQL): full-value UNIQUE on >768-char token columns is inexpressible on utf8mb4 — hash-shadow-key route for the four ruled cases (C half of the #11374 ruling) #11627) — the branch did exist.
    3. "fix(driver-sql): carry an over-long UNIQUE index on a hash-shadow column (MySQL utf8mb4) #12198 landed pins asserting 2 of 44" — prose only.

    All three are §7 of my own seat post: a brief's factual assertions must be measured in the same action that writes them. I have been measuring the card's claims rigorously and asserting the repo's state from memory. The saving grace each time was an instruction to the dev to verify rather than trust — but a brief built on an unmeasured premise can send a dev hunting for something that does not exist, or invent one to satisfy me. Recording it here and on the seat post.

    #12288 was immediately useful

    os-verify-lock.sh printed VERDICT command-exit 0 for the ablation batch while the vitest inside exited 1 — the #12288 behaviour; the reading used is the script's own ABLATION-TEST-EXIT=1, not the wrapper verdict.

    Filed an hour ago, hit again within the hour, and correctly worked around. That is a card earning its keep.

    Carried out

    #12313 — sys_oauth_resource.identifier declares an uncited maxLength: 1024, carrying none of the [#11374] provenance every sourced bound has, and after this card it over-declares relative to every column that references it. Reachable on PG/SQLite: an operator can register a 900-char resource identifier the referent's contract admits, which no client can then be granted because the referrer now refuses it. Deliberately not fixed here — narrowing it is a contract change on a different published object and would move sys_oauth_resource off #12198's hash shadow (768 × 4 = 3072 bytes is exactly the last direct width). Triaging separately.

    Flipping to ready and enqueueing once CI converges.


    Generated by Claude Code

  7. os-warren commented on Aug 26, 2026

    @os-warren
    Collaborator

    LANDED — PR #12314 merged as f6344e7fee.

    Verified by content on origin/main: the { fields: ['value'], unique: false } index on sys_verification is gone (0 occurrences; it was present at :90 before).

    ⚠️ My first probe for this was under-anchored — I counted 'value', which matches the field definition itself and would have read 1 either way. Re-anchored on the index entry.

    Both ruled halves are in, travelled separately:

    • sys_verification.value — declared index removed. Liveness re-verified independently rather than carried forward (better-auth 1.7.1 keys every verification lookup on identifier/id/expiresAt; no in-repo query filters by value).
    • sys_oauth_client_resource.resource_id — bound narrowed 1024 → 768, and the condition was discharged, not waived: upstream's own MySQL migration generator emits the referent at varchar(255) and this column at varchar(36), so the discarded (768, 1024] band holds nothing the producing contract can emit. 768 chosen as the smallest narrowing that makes the index expressible — deliberately not upstream's 255, because "guessing tighter to make a key fit is what sys_account.issuer refuses to do."

    MySQL 2/45 → 0/45. The >768 keyed-text class is closed. Postgres control 0/45 throughout.

    ⭐ The result nobody predicted: removing the dead [value] index restored two live ones. sys_verification on MySQL previously carried only PRIMARY, because initObjects aborted at the unkeyable index and took the rest of the pass with it — so idx_sys_verification_identifier and idx_sys_verification_expires_at now exist for the first time, including the one better-auth actually queries on. A cleanup that turned out to be a performance fix.

    The UNBOUNDABLE allowlist entry moved with the change, and because that emptied the list, the rule was extracted with a synthetic control so the excusing branch cannot rot unexercised.

    ⚠️ My dispatch warned that #12198's "2 of 44" pins would go red and must be moved. No such pin existed — the dev read them first and found the figure lives only in PR prose and docblocks. Recorded in-thread; that was one of four unmeasured assertions I made in briefs this shift.

    Carried out: #12313 (sys_oauth_resource.identifier's uncited maxLength: 1024, now over-declaring relative to every column referencing it) — graded needs-user-decision, since option A there would take that object off #12198's hash shadow.

    Closing as completed; pm:dispatched stripped in the same stroke.


    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

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions