Repository navigation
#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
Activity
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 byvalue?) that should precede any shadow-column work — cheaper than building one.
Generated by Claude Code
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 indexRuled: 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_verificationbyvalue.
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 = enforcedproperty 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.valueis named in theUNBOUNDABLEallowlist inpackages/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, conditionallyRuled: 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_idmirrorssys_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.
⚠️ Unlikesys_verification.value, this column is plausibly queried — it is the FK side ofsys_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:queuewith a ruled route for each half.
Generated by Claude Code
- added a commit that references this issue
on Aug 25, 2026 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:
- GitHub REST returned 403 for issue listing from this seat, both authenticated and unauthenticated — the agent proxy denies the CONNECT to
api.github.comas a policy denial ([finding] os-dev subagent seats have no direct GitHub REST channel — GITHUB_TOKEN is a placeholder, curl gets the session-gate 403, gh is absent — while dispatch protocol text assumes REST list endpoints are reachable #12123). The duplicate search before filing sys_oauth_resource.identifier declares an uncited maxLength: 1024, and now over-declares relative to every column that references it #12313 therefore used the MCP channel plus a localgrep, with [finding] Both documented list channels can be down AT ONCE — search_issues zeroed session-wide while the prescribed REST fallback answers 403, contradicting open finding #11577 #11835's under-return risk declared rather than assumed away. - The HTML-comment report marker does not survive the sanitizer ([finding] The prescribed HTML-comment report marker does not survive the comment sanitizer — measured twice in one shift, and the recovery leaves orphaned report comments a marker scan cannot see #12133), so this comment leads with the literal text marker instead.
Generated by Claude Code
Generated by Claude Code
- GitHub REST returned 403 for issue listing from this seat, both authenticated and unauthenticated — the agent proxy denies the CONNECT to
ACCEPT — PR #12314. Both ruled halves implemented, travelled separately as required.
The condition on
resource_idis discharged, not waived — which was the hard fenceI 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 referentoauthResource.identifierasvarchar(255)and this referring column asvarchar(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.issuerrefuses 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_verificationon MySQL previously carried only PRIMARY, becauseinitObjectsaborted at the[value]index. Soidx_sys_verification_identifierandidx_sys_verification_expires_atnow 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
initObjectspass 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_activationlanded 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_id768 accepted / 769 refusedER_DATA_TOO_LONG;NON_UNIQUE=1withSUB_PART=NULL— the discriminator separating a whole key part from the rejected prefix index. And a control that matters:sys_verification.valuestill accepts 5000 chars, proving the index removal narrowed nothing.The allowlist obligation was honoured, and then hardened
The
UNBOUNDABLEentry 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 shiftI 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 44occurrence anywhere inpackages/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:
- "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.
- "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.
- "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.shprintedVERDICT command-exit 0for the ablation batch while the vitest inside exited 1 — the #12288 behaviour; the reading used is the script's ownABLATION-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.identifierdeclares an uncitedmaxLength: 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 movesys_oauth_resourceoff #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
- upstream better-auth is the sole writer (
LANDED — PR #12314 merged as
f6344e7fee.Verified by content on
origin/main: the{ fields: ['value'], unique: false }index onsys_verificationis gone (0 occurrences; it was present at:90before).⚠️ 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 onidentifier/id/expiresAt; no in-repo query filters byvalue).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 atvarchar(255)and this column atvarchar(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 whatsys_account.issuerrefuses 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_verificationon MySQL previously carried only PRIMARY, becauseinitObjectsaborted at the unkeyable index and took the rest of the pass with it — soidx_sys_verification_identifierandidx_sys_verification_expires_atnow exist for the first time, including the one better-auth actually queries on. A cleanup that turned out to be a performance fix.The
UNBOUNDABLEallowlist 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 uncitedmaxLength: 1024, now over-declaring relative to every column referencing it) — gradedneeds-user-decision, since option A there would take that object off #12198's hash shadow.Closing as completed;
pm:dispatchedstripped in the same stroke.
Generated by Claude Code
- added a commit that references this issue
on Aug 26, 2026 - added a commit that references this issue
on Oct 7, 2026
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)
sys_oauth_client_resource.resource_id— declaresmaxLength: 1024(landed, FK tosys_oauth_resource.identifierwhich is one of the four ruled cases), keyed by the declared non-unique index[resource_id]. 1024 > 768 ⇒ column stays TEXT and the index failsER_BLOB_KEY_WITHOUT_LENGTHon MySQL. Same class, same fix family as the parent'ssys_oauth_resource.identifier; a shadow-key or the parent's chosen mechanism must cover the referencing column too or the object keeps failing sync.sys_verification[value]index —valueis 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 insys-verification.object.tsdocuments 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 byvalue— upstream keys verification lookups onidentifier, so the index may be removable; that liveness measurement has not been made and should be, before adding a shadow column.sys_account[issuer, account_id]UNIQUE — new member created by the A half itself:issuernow declaresmaxLength: 2048, transitively from the landedsys_sso_provider.issuercontract (2048) whose values (issclaim / 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 ruledmaxLength: 1024columns (parent's four), plus the three members above, plussys_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:blockedbehind #11374).Generated by Claude Code