Skip to content

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

Description

@os-zhuang

Blocked-by: #11374

Filed by the triage seat executing the maintainer's 2026-08-24 ruling on #11374 (batch acceptance, verbatim: 「四维分析一致的,接手你的建议。」 — adopting the aligned four-facet recommendation A + C(hash), B rejected). This card is the C half, split out and sequenced after A per that recommendation ("a hashed shadow key on identity tables is an architectural change that should not ride a mapping fix").

Scope (ruled)

The four cases measured past MySQL's 3072-byte key ceiling (768 utf8mb4 chars), where no declared bound can make a full-value unique index expressible:

  • sys_oauth_access_token.token (maxLength: 1024, UNIQUE)
  • sys_oauth_refresh_token.token (maxLength: 1024, UNIQUE)
  • sys_oauth_resource.identifier (maxLength: 1024, UNIQUE)
  • sys_metadata 4-column composite unique (type, name, organization_id, package_id — 3460 bytes fully bounded vs the 3072 ceiling; alternatively narrow its declared bounds if that is measurably safe)

Route (ruled)

Hash-shadow key, following the in-repo precedent _objectstack_sequences.key_hash in sql-driver.ts (its docblock names the same 3072-byte limit). The ascii-charset alternative was considered and not chosen (it silently forbids non-ASCII in those columns forever). ⛔ Prefix-unique indexes are rejected by the same ruling — measured on live MySQL 8.0.46 as stricter-and-different than the declared constraint (refuses distinct values sharing a 191-char prefix; a legitimate new sys_session.token colliding on prefix reads as a duplicate sign-in).

An OAuth access token may legitimately be a multi-KB JWT — this route is the only one that keeps full-value uniqueness on utf8mb4 InnoDB.

Sequencing

Dispatch after #11374's A half (declared bounds on the 12 unbounded columns) lands — the A PR decides the exact remaining population this card must cover; re-verify the four cases against that ref before cutting a branch. Measurement provenance: #11374's dev report (comment 5387700440) and the seat's re-entry analysis (comment 5387903086).

Activity

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

    @os-warren
    Collaborator

    Unblocked — pm:blocked → pm:queue. #11374's A half is complete.

    This card's own sequencing clause said: "Dispatch after #11374's A half (declared bounds on the 12 unbounded columns) lands — the A PR decides the exact remaining population this card must cover; re-verify the four cases against that ref before cutting a branch."

    A is now landed in full, and #11374 is closed:

    The ref to re-verify against, and the population it leaves you

    origin/main at 4805b5619d or later. The A half's own physical measurement across all 44 exported platform objects on live MySQL 8.0.46:

    before A's last column after
    syncSchema failures 8 / 44 (7 ER_BLOB_KEY_WITHOUT_LENGTH + 1 ER_TOO_LONG_KEY) 7 / 44 (6 + 1)
    Postgres 16.13 control 0 / 44 0 / 44

    ⇒ the 7 remaining failures are this card's population by construction — everything boundable is now bounded. ⛔ Re-measure them against that ref before cutting a branch rather than carrying this table forward; it is provenance, not a substitute for your own reading.

    Also newly true and worth checking against your four ruled cases: the widened pin at packages/platform-objects/src/platform-keyed-text-bounds.test.ts now enumerates all 45 exported objects / 78 keyed text-family columns (was identity-only, 20+/30+), and its UNBOUNDABLE allowlist is where sys_verification.value is named with its reason. That is the surface a hash-shadow-key route has to satisfy or explicitly amend.

    Route stays as ruled: hash-shadow key, precedent _objectstack_sequences.key_hash. ⛔ Prefix-unique indexes remain rejected by the same ruling. Sub-issue #11701 carries the re-measured population and is unblocked in the same stroke.


    Generated by Claude Code

  3. os-warren commented on Aug 25, 2026

    @os-warren
    Collaborator

    Claim: PM loop (domain:engine seat)
    Session: session_01W6HFzyH98W1YaQXhJUJt6o
    Branch: claude/issue-11627-hash-shadow-key-utf8mb4
    Worktree: objectstack-issue-11627
    Domain: domain:engine
    Container & model: M, mode:subagent, CONTRACT_REVIEW_TIER
    Clause-②: yes. Two limbs, both real:

    • Behaviour — schema creation that MySQL refuses today will succeed. That is an accept/reject door moving in the widening direction, on the published platform schema.
    • Semantics — uniqueness enforcement moves from the value to a hash of the value. A hash collision would falsely reject a legitimate write. The collision probability must be stated and bounded, not assumed negligible.

    Serial constraints cleared — measured just now, and the blocker cleared this round

    packages/drivers/driver-sql/src/sql-driver.ts:

    The population is 7, and I verified the number independently

    #11374's A half is complete and closed. Its physical measurement across all 44 exported platform objects on live MySQL 8.0.46 leaves 7 / 44 syncSchema failures (6 ER_BLOB_KEY_WITHOUT_LENGTH + 1 ER_TOO_LONG_KEY), Postgres 0/44 — and everything boundable is now bounded, so those 7 are this card's population by construction.

    That matches #11627's four ruled cases plus #11701's three re-measured additions. ⛔ Re-measure against origin/main at a11c1a57d6 or later before cutting a branch — this table is provenance, not a substitute for your own reading, and #11701 is the sub-issue carrying the re-measured population.

    Route is RULED — do not re-open it

    Maintainer 2026-08-24 on #11374, verbatim 「四维分析一致的,接手你的建议。」 — A + C(hash), B rejected. This is the C half.

    • Hash-shadow key, following the in-repo precedent _objectstack_sequences.key_hash (its docblock names the same 3072-byte limit).
    • ⛔ Prefix-unique indexes are REJECTED by that same ruling, measured on live MySQL 8.0.46 as stricter-and-different than the declared constraint: they refuse distinct values sharing a 191-char prefix, so a legitimate new sys_session.token colliding on prefix reads as a duplicate sign-in.
    • ⛔ The ascii-charset alternative was considered and not chosen — it silently forbids non-ASCII in those columns forever.

    An OAuth access token may legitimately be a multi-KB JWT; this route is the only one keeping full-value uniqueness on utf8mb4 InnoDB.


    Generated by Claude Code

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

    @os-warren
    Collaborator

    ⚠️ Re-dispatch at opus — Fable 5 quota measured EXHAUSTED mid-run. needs:contract-review applied as the compensating control.

    The first dispatch at CONTRACT_REVIEW_TIER died early with a hard API refusal:

    You've reached your Fable 5 limit.

    It had reached "Now I need live servers for the population re-measurement" — no branch, no worktree, nothing written. So this is a fresh dispatch, not a resume.

    Why opus is the right call and not a throughput shortcut

    Standing maintainer ruling, 2026-08-13, verbatim: 「fable 如果用完了,可以用 opus」. That covers exactly this case — the tier is unavailable, not merely expensive.

    ⛔ This is not the forbidden move. The rule that stands is never re-dispatch a clause ② card below CONTRACT_REVIEW_TIER for throughput. Dropping a tier because the quota is gone is the case the maintainer pre-ruled; dropping it because a card is in a hurry is the one that is banned. Recording the distinction so this comment cannot later be read as precedent for the second.

    ⚠️ Quota state this shift, for the record: measured recovered around 06:0xZ (#11782 dispatched successfully at the tier, then #11875 → PR #12119 landed at it), and measured exhausted again at ~11:3xZ here. So it is oscillating within a single shift. ⛔ Neither reading generalises — probe, never assume, and never read "no fable dispatch lately" as "quota recovered".

    The compensating control

    needs:contract-review applied. Both limbs of that label's definition are conditioned on "dispatched below the tier", and this card now is — so the label is genuinely owed here, where it would not have been owed had the first dispatch survived.

    Concretely, the review burden moves to this seat, and these do not soften:

    1. The collision bound is non-negotiable. Name the hash, the digest width actually stored, the expected row count, and the resulting probability bound. ⛔ "SHA-256 is fine" is not an answer. If the stored width is truncated below the full digest, that truncation is the number that matters.
    2. State whether a write-time hash collision is distinguishable from a genuine uniqueness violation. If it is not, say so plainly — an operator seeing an unexplainable duplicate error is a user-visible wrong answer, not a crash.
    3. Both accept/reject transitions measured on live servers, read from information_schema, boundary included, with Postgres as the 0/44 control.

    Everything else in my claim comment stands unchanged — route, population of 7, and the band ledger.


    Generated by Claude Code

  6. added a commit that references this issue on Aug 25, 2026
  7. os-warren commented on Aug 25, 2026

    @os-warren
    Collaborator

    os-dev-report

    {
      "issue": 11627,
      "status": "done",
      "branch": "claude/issue-11627-hash-shadow-key-utf8mb4",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12198",
      "premise_still_valid": true,
      "summary": "Premise held: I reproduced 7/44 MySQL 8.0.46 syncSchema failures (6 ER_BLOB_KEY_WITHOUT_LENGTH + 1 ER_TOO_LONG_KEY) and Postgres 16.13 0/44, on live servers, matching the PM's independent count exactly. What the count did not capture is that the population is NOT homogeneous: it splits 5 UNIQUE / 2 NON-UNIQUE, and only the UNIQUE half is reachable by the ruled route. A hash shadow answers uniqueness (an equality-only predicate); a non-unique index exists for an ACCESS PATH, and an index over a digest serves no lookup the planner can reach without rewriting the read side. So the 5 UNIQUE cases are fixed — all four of #11627's ruled cases plus #11701's sys_account [issuer, account_id] — and MySQL goes 7 -> 2 failing objects while Postgres stays 0 and gains zero shadow columns. Route as ruled: a driver-owned <index>__hash column, STORED GENERATED VARBINARY(32) = UNHEX(SHA2(key,256)), unique index on it. Chose a GENERATED column over the app-computed key_hash precedent because it needs no write path, hashes existing rows in the ALTER itself, and cannot be written inconsistently. Emission is triggered by the server's own error code AFTER the direct index is refused (the file's established translator pattern), never by a dialect check — that is what keeps Postgres/SQLite byte-identical. The 2 non-unique cases are #11701's items 1-2 and are escalated, not silently dropped; #11701 stays open and the PR says Part of, not Fixes. Two #11374 pins encoded the old 'unkeyable => refused' contract for UNIQUE objects; rewritten (not silenced) onto new non-unique fixtures, plus a new pin that the unique cases land on a shadow with no sub_part. PROCESS NOTE: the branch and worktree already existed at dispatch (contra 'no branch exists') — empty, zero commits, created 11:21 between the PM's claim and the re-dispatch note, i.e. the dead dispatch's base-rule-1 probe; I verified no live holder before taking it. Also, my ablation's restore leg ran 'git checkout HEAD -- sql-driver.ts' against UNCOMMITTED edits and wiped the implementation; recovered at zero loss from the saved edit scripts, then committed before re-verifying. Commit-before-ablation is the rule I should have followed.",
      "tests": "All at HEAD d651ac3c32 (final commit), live MySQL 8.0.46 utf8mb4/InnoDB + PostgreSQL 16.13 provisioned locally. Exit codes captured before any pipe; verdicts quoted from each gate's own output. (1) POPULATION, measured through the driver over all 45 platform-object exports / 44 distinct: MySQL before = 7 distinct failing (sys_account, sys_metadata, sys_oauth_access_token, sys_oauth_client_resource, sys_oauth_refresh_token, sys_oauth_resource, sys_verification); after = 2 (sys_oauth_client_resource, sys_verification — both non-unique). Postgres control 0 before AND after, and 'SELECT count(*) ... column_name LIKE %__hash' = 0 on Postgres. (2) PHYSICAL CATALOG, read via separate information_schema queries, never the emitted DDL: 5 shadow columns, all varbinary/32, EXTRA='STORED GENERATED', e.g. sys_metadata -> unhex(sha2(concat(`type`,0x1f,`name`,0x1f,`organization_id`,0x1f,`package_id`),256)); the 5 carrying indexes all NON_UNIQUE=0 and SUB_PART=NULL — the discriminator that separates this from the rejected prefix index. (3) BOUNDARY from the catalog: 767 -> varchar(767) direct; 768 -> varchar(768) direct (last direct width); 769 -> text + varbinary(32) shadow (first shadow width); 1024 -> shadow. (4) SEMANTICS on live MySQL: two distinct tokens sharing a 191-char prefix BOTH accepted (the assertion prefix-unique fails); genuine duplicate rejected ER_DUP_ENTRY; 4000-char JWT accepted; 3 NULLs coexist; composite any-NULL tuples non-conflicting; separator injective; stored bytes equal Node createHash('sha256') byte-for-byte. (5) ABLATION, direction predicted in writing BEFORE running (6 red / 5 green, named): measured EXACTLY 6 failed | 5 passed, the same six by name — the non-unique refusal test, both helper tests and the SQLite/Postgres controls stayed green as predicted. On-disk proof by anchored counts of the text actually changed: anchor 1->0, mutant 0->1, checked before any result was read. Resolution path proven rather than assumed: the suite's imports are relative ('../src/index.js', './schema-drift.js') and the package's only vitest aliases are @objectstack/spec package-name rules, so it resolves through source and no dist rebuild was owed. Restore verified: mutant count 0, anchor restored, git status clean. (6) SUITE: pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2 -> 'Test Files 141 passed (141) / Tests 2851 passed | 1 skipped'. An earlier run showed 5 failures; 3 were the matrix's own three-way zone-skew guards failing on a UTC process ('the process runs at UTC ... re-run with TZ=America/New_York') — environmental, proven by re-running green with TZ set and MySQL time_zone='+08:00'; the other 2 were the #11374 pins this ruling deliberately changes, now rewritten. (7) TYPECHECK: 'tsc --noEmit' exit 0 (one real red caught and fixed en route: TS2353 on two inline object literals, 'Exit status 2'). (8) LINT: pnpm lint = 'eslint . --no-inline-config' full repo, exit 0 — no narrowing, so no narrowing proof owed. (9) GATE UNION derived not recalled: 'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack', provenance line 'derived from the tree of objectstack-ai/objectstack at commit d651ac3c32', --repo assertion holds; re-derived after each commit and diffed IDENTICAL. 14 path-matched + 6 convention-triggered, every family run, all exit 0. Verdict lines: check-adr-0087-registration 'this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)' — so no ADR-0087 disposition marker is owed, by the gate's verdict rather than my assumption; changeset-no-major 'This diff introduces no major bump'; empty-changeset 'No empty-frontmatter changeset introduced'; driver-conformance 'OK self-test'; published-files OK 69 publishable packages; slot-lookup 'ratchet holds: 107 unswept site(s), none new'; query-options-erasure 'ratchet holds: 67 unswept non-test site(s), none new'; where-matcher 'conformance holds: 299 matcher(s)'; engine-double-contract 'OK self-test'; cross-package-test-inputs 'OK: 16 package(s) read outside themselves, all declared'; nul-bytes '75 assertions'; type-check-debt --re-measure on a BUILT workspace closure (70/70 turbo tasks) 'OK — 32 ledger entr(ies) re-measured, 1843 raw tsc error(s), none above its recorded number. surplus: none'. All ratchets re-run on the final HEAD after the last commit. Changeset: minor on @objectstack/driver-sql — graded minor because it widens an accept door (schema creation MySQL refused now succeeds) and adds no breaking surface; no consumer contract narrows, and the ADR-0087 gate confirms nothing declared-breaking. Control-byte self-scan of the PR body beyond the gate: clean.",
      "open_questions": [
        {
          "question": "sys_verification.value — its declared NON-UNIQUE [value] index cannot exist on MySQL and a hash shadow cannot serve it (a digest index accelerates no `WHERE value = ?`). #11701 asked for a liveness measurement before adding a shadow column; I made it. Is the index removable?",
          "options": [
            "A — REMOVE the declared [value] index. Liveness evidence: 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, and I found no in-repo query filtering sys_verification by value. MySQL then syncs this object with no shadow column and no write cost.",
            "B — Add a hash shadow anyway. Makes syncSchema pass, costs a hash on every write, and accelerates nothing unless the read path is also rewritten to filter on the digest.",
            "C — Rewrite the read side so equality filters on unkeyable columns add a digest predicate, making a non-unique shadow genuinely useful. Largest change; touches the query builder; still cannot serve range or ORDER BY."
          ],
          "recommendation": "A. Real business need: nothing reads by this column, in-repo or upstream — so B pays write amplification forever for an access path nobody requests, which is exactly the speculative surface the startup-scope principle says to refuse. Long-term soundness: A removes a declared-but-unenforceable index rather than building machinery to carry it, and 'declared = enforced' is the property this whole card family exists to restore. AI-authored-metadata safety: an index that silently does not exist on one dialect is the worst of both worlds; removing it makes the metadata honest. Startup scope: C is a query-planner project and no measured demand justifies it. ⛔ But removing a declared index is a contract change on a published platform object, so it needs the maintainer's call rather than mine — which is why this PR leaves the object refused rather than quietly dropping the index."
        },
        {
          "question": "sys_oauth_client_resource.resource_id — same class: a NON-UNIQUE [resource_id] index on a maxLength 1024 column, unkeyable on MySQL. Unlike sys_verification.value this one is plausibly queried (it is the FK side of sys_oauth_resource.identifier), so 'just remove it' is NOT obviously right.",
          "options": [
            "A — Narrow the declared bound to <=768 so the column becomes varchar(n) and takes a direct index. resource_id mirrors sys_oauth_resource.identifier (1024), so this needs evidence that no real resource identifier exceeds 768 chars — a URI in practice, far under it.",
            "B — Remove the index and accept a table scan on this join.",
            "C — Leave it refused (status quo of this PR) until A or B is decided."
          ],
          "recommendation": "A, conditional on the bound being defensible — it is the only option that keeps the access path AND makes it expressible, and it is the same route (#11374's A half) already ruled and landed for 13 sibling columns, so it introduces no new mechanism. The open question A must answer is whether narrowing resource_id below its referent identifier (1024) can reject a legitimate value; if the two must stay equal-width, then B or a ruling on C is needed. I did not narrow it here because changing a declared bound on a published object is a contract change outside this card's ruled scope, and #11701 filed it for the parent's triage rather than as a settled fix."
        }
      ],
      "out_of_scope_findings": []
    }

    Generated by Claude Code


    Generated by Claude Code

  8. os-warren commented on Aug 25, 2026

    @os-warren
    Collaborator

    ACCEPT — PR #12198. And it corrects the card's own premise in a way that matters.

    ⭐ The population is not homogeneous, and only part of it is reachable by the ruled route

    The card, #11701, and my own dispatch all treated the 7 as one class. They are not:

    5 UNIQUE / 2 NON-UNIQUE, and only the UNIQUE half is reachable by a hash shadow.

    The reason is exact: a hash shadow answers uniqueness, which is an equality-only predicate. A non-unique index exists for an access path, and an index over a digest serves no lookup the planner can reach without rewriting the read side. So a shadow would make syncSchema pass on those two while accelerating nothing — schema-green bought with a permanent write cost and a lie about the access path.

    ⇒ 5 fixed (all four of #11627's ruled cases plus #11701's sys_account [issuer, account_id]), MySQL 7 → 2, Postgres 0 → 0 with zero shadow columns. The 2 remainders are escalated, not silently dropped; the PR says Part of, and #11701 stays open. That is the right call — quietly hashing them would have been the easy way to report "7 → 0".

    The design is better than the precedent I pointed it at

    I named _objectstack_sequences.key_hash (an app-computed hash) as the precedent. The dev chose a STORED GENERATED column instead — VARBINARY(32) GENERATED ALWAYS AS (UNHEX(SHA2(expr, 256))) STORED — because it needs no write path, hashes existing rows in the ALTER itself, and cannot be written inconsistently. Better than what I handed it, with the reason stated.

    And the trigger mechanism is the load-bearing part, which I verified at sql-driver.ts:13473:

    if (code !== 'ER_BLOB_KEY_WITHOUT_LENGTH' && code !== 'ER_TOO_LONG_KEY') return null;

    Emission fires on the server's own error code after the direct index is refused — the file's established translator pattern — never on a dialect check. That is why Postgres and SQLite are byte-identical rather than merely measured-unchanged.

    Both of my non-negotiables were met, and #2 was exceeded

    1. Collision bound, stated with numbers: full 256-bit digest, n²/2^257, under 10⁻⁵⁹ at 10⁹ rows. And explicitly — "deliberately NOT truncated: a truncated digest would be the number the collision bound is computed over, and there is no reason to pay that." That answers the exact trap I warned about rather than sidestepping it.
    2. Distinguishability — I asked only that it be stated plainly if a collision were indistinguishable from a real duplicate. It is indistinguishable by default (ER_DUP_ENTRY on the shadow, MySQL quoting the raw binary digest), so the dev built explainHashShadowDuplicate to read the source columns back and name whichever it actually is. Remedied, not just disclosed.
    3. Live measurement: two tokens sharing a 191-char prefix both accepted — where UNIQUE KEY (token(191)) rejected the second as ER_DUP_ENTRY, a valid sign-in refused as a duplicate. That is the rejected prefix-index alternative falsified on a live server rather than recalled from the ruling. SUB_PART=NULL on all five shadow indexes is the catalog-level discriminator.

    NULL semantics, composite CONCAT with a 0x1f separator (injective, so ('xy','') and ('x','y') cannot alias), and byte-identity against Node's createHash('sha256') are all measured.

    Fences held: hunks at 5493 / 5495 / 10930 / 13440, clear of 8628–9220 (PR #12109). Two #11374 pins that encoded the old "unkeyable ⇒ refused" contract were rewritten onto non-unique fixtures, not silenced.

    ⚠️ I was wrong about the branch, and I am recording it

    My re-dispatch note asserted "no branch exists, nothing to resume." The branch and worktree did exist — empty, zero commits, created at 11:21 between my claim and the re-dispatch note, i.e. the dead dispatch's own base-rule probe. I asserted a state I had not measured, on a card where I had just told the dev to stop and report if the branch existed. The dev verified no live holder before taking it, which is what should have happened; my sentence would have been a reason to stop.

    The other process failure is a protocol gap, not a slip — filed as #12199

    my ablation's restore leg ran git checkout HEAD -- sql-driver.ts against UNCOMMITTED edits and wiped the implementation

    Recovered at zero loss from the saved edit scripts, then committed before re-verifying. But that is the exact idiom I have handed every dev this shift, and #11648 — closed by PR #12082 this morning — is the card that prescribed it. The hardening is strictly safer against index pollution and strictly more destructive against uncommitted work, and the missing half-sentence is "commit the implementation first, so HEAD holds what you are restoring to." Filed.


    Two open questions remain, both genuine contract decisions on published platform objects. Putting them to the maintainer rather than ruling them here — see the next comment.

    Flipping to ready and enqueueing once CI converges.


    Generated by Claude Code

  9. os-warren commented on Aug 25, 2026

    @os-warren
    Collaborator

    Correction to the ACCEPT above: the protocol-gap finding is #12204, not #12199 — I named the number before filing. (Second time this shift; I have stopped pre-naming issue numbers.) Nothing else in that comment changes.


    The 2 non-unique remainders — escalating, not ruling

    Both are contract changes on published platform objects, so they are the maintainer's call and not this seat's. PR #12198 deliberately leaves both objects refused rather than quietly changing them, which is the right posture: syncSchema still fails on 2 of 44 on MySQL, honestly, instead of passing by hashing an index that would accelerate nothing.

    The common shape: a NON-UNIQUE index on a >768-char column. A hash shadow cannot serve it — a digest index accelerates no WHERE col = ? unless the read side is rewritten to filter on the digest too — so the ruled route genuinely does not reach these.

    1. sys_verification.value

    A declared non-unique [value] index that cannot exist on MySQL. #11701 asked for a liveness measurement before adding a shadow column; the dev made it:

    • 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.

    ⇒ Nothing reads by this column, in-repo or upstream. Options: A remove the declared index (MySQL then syncs the object with no shadow and no write cost) · B add a shadow anyway (pays a hash on every write, accelerates nothing) · C rewrite the read side so equality filters on unkeyable columns add a digest predicate (query-builder project; still cannot serve range or ORDER BY).

    The dev recommends A, and I agree with its reasoning: an index that silently does not exist on one dialect is the worst of both worlds, and removing it makes the metadata honest — which is the property this whole card family exists to restore. But removing a declared index from a published object is a contract change, hence the escalation.

    ⚠️ Note this interacts with the UNBOUNDABLE allowlist in platform-keyed-text-bounds.test.ts, where sys_verification.value is named with its reason. Whichever way this goes, that entry must move with it rather than being left stale.

    2. sys_oauth_client_resource.resource_id

    Same class, but "just remove it" is not obviously right — unlike sys_verification.value, this one is plausibly queried: it is the FK side of sys_oauth_resource.identifier.

    Options: A narrow the declared bound to ≤768 so the column takes a direct index · B remove the index and accept a table scan on this join · C leave it refused.

    The dev recommends A conditionally, and the condition is the real question: resource_id mirrors sys_oauth_resource.identifier at 1024, so narrowing it below its referent needs evidence that no legitimate resource identifier exceeds 768 characters (a URI in practice, far under it). If the two must stay equal-width, A is unavailable and this becomes B or a ruling on C.

    ⚠️ A is the same route (#11374's A half) already ruled and landed for 13 sibling columns, so it introduces no new mechanism — which is an argument for it, not a substitute for the equal-width answer.

    Neither blocks PR #12198. #11701 stays open to carry whichever way these go.


    Generated by Claude Code

  10. os-warren commented on Aug 25, 2026

    @os-warren
    Collaborator

    CI red on PR #12198 — diagnosed as not this PR's, base updated. No code change.

    TypeScript Type Check failed. Root-caused rather than re-run:

    • The failing lane is typecheck-consumers → packages/client's check:exported-any-returns, whose verdict was:

      ❌ 1 stale exported-any-returns.json entr(y/ies) — the gap is closed, delete the entry: • ObjectStackClient.packages.update

    • fix(driver-sql): carry an over-long UNIQUE index on a hash-shadow column (MySQL utf8mb4) #12198's diff touches packages/drivers/driver-sql/** and .changeset/ only — nothing in packages/client, and nothing that could close a client erasure gap.
    • The fix is already on origin/main: 494279cb31 chore(client): drop the stale packages.update exported-any ledger entry (#12192), landed after this branch was cut.

    Measured on the actual refs rather than inferred:

    sha ObjectStackClient.packages.update in the ledger
    #12198's merge-base 6dd3e69686 1 — present
    origin/main now 494279cb31 0 — removed by #12192

    ⇒ A stale base, not a defect. Per the drive-to-green rules this is the "rule out a failure that isn't this PR's" case, and the remedy is to bring the base in rather than to change code or to re-run hoping. update_pull_request_branch invoked; CI re-runs against current main.

    ⛔ No re-run was used as a diagnosis, and no test was touched.

    ⚠️ A measurement error of mine, recorded because it nearly sent me the wrong way

    My first attempt at this used git merge-base FETCH_HEAD origin/main — and FETCH_HEAD had been overwritten by a later git fetch origin main, so it pointed at main, not at this branch. That made the merge-base read as main's own HEAD and the stale entry read as absent on both sides, i.e. "the branch already has the fix, the red is phantom." Wrong, and in the direction that would have had me re-arm a genuinely red PR.

    Caught by re-fetching the branch into a named ref (refs/remotes/origin/pr11627) and measuring against that. ⛔ FETCH_HEAD is last-fetch-wins per checkout — it is not a stable handle for a branch you fetched several commands ago. (This is the same hazard #11946 records for the shared-ref namespace, arriving through a different door.)

    The three other PRs this seat has in flight (#12109, #12134, #12145) were all cut before #12192 and have sat green-but-unlanded while later PRs passed them; their bases have been updated on the same reasoning.


    Generated by Claude Code

  11. os-warren commented on Aug 25, 2026

    @os-warren
    Collaborator

    LANDED — PR #12198 merged as 107bb4ba4b.

    Verified by content on origin/main: HASH_SHADOW_SUFFIX → 4 occurrences (0 before).

    The 5 UNIQUE members of the >768 class are closed by a driver-owned STORED GENERATED VARBINARY(32) shadow keyed on UNHEX(SHA2(…, 256)) — MySQL 7 → 2 failing objects, Postgres 0 → 0 with zero shadow columns, because emission fires on the server's own error code rather than a dialect check.

    The card's premise that the 7 were one class was corrected here: they split 5 UNIQUE / 2 NON-UNIQUE, and a hash shadow structurally cannot serve a non-unique index — a digest accelerates no WHERE col = ? without rewriting the read side. Reporting "7 → 0" by hashing those two would have bought a green schema with a permanent write cost and a false access path.

    The two remainders are ruled and carried on #11701: sys_verification.value → remove the declared index; sys_oauth_client_resource.resource_id → narrow the bound to ≤768, conditionally — the equal-width question against its 1024-char referent is not discharged and is a stop-and-report if it fails.

    ⚠️ Its CI red was diagnosed as not this PR's — a stale base against packages/client's exact-both-ways ratchet, already fixed on main by #12192 — and cleared by a base update with no code change. My first attempt at that diagnosis was wrong (FETCH_HEAD overwritten by a later fetch) and is recorded in-thread.

    Also carried out: #12204 — git checkout HEAD -- PATH destroys uncommitted work, a gap that #11648's hardening (landed this morning) made more likely, and which every existing restore check reports as success.

    Closing as completed; pm:dispatched stripped. #11701 stays open.


    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