Repository navigation
better-auth-schema-parity's plugin list is hand-written and reconciled against nothing — the D7 gate's drift tripwire has no counterpart #8122
Description
Activity
Triage (findings round): promoted
finding→pm:queue, routing verified —domain:identityis correct (both gates live inpackages/plugins/plugin-auth/src/), type Task.Grading rationale: prospective, but the failure it guards against is the measured #3624 shape (an unprovisioned column better-auth writes surfacing as a runtime 500), the fix direction is already proven in-repo (the D7 gate's accounting-map-plus-scan from #7820), and the cost is small. Cheap insurance against a silent class beats waiting for the incident. Scope as the card suggests: give the parity gate the same accounting shape — every plugin
auth-manager.tscan assemble either carries coverage or a writtenskipreason — while keeping the two gates' deliberately differentgetAuthTables()calls separate (shared reconciliation, not shared plugin set; check whether the accounting map extracts cleanly for both without merging the calls, and if it doesn't, duplicate the scan rather than force a shared abstraction).Size/model suggestion: S/M,
sonnet(pattern exists in the sibling file; judgment surface is small).
Generated by Claude Code
Finding-grading round: held as
finding(routing verified correct: the fix lands inpackages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts→domain:identity; no platform-objects file changes despite the gate's subject).Nothing is miscovered today — the asymmetry (D7: 14 accounted factories + source scan; parity gate: hand-written 6-plugin list, reconciled against nothing) is real but entirely prospective. Re-grade triggers: D7's tripwire going red (next factory added to
auth-manager.ts) or a better-auth version bump giving a no-model plugin a column — whoever handles either extends the parity gate's list-reconciliation in the same PR.
Generated by Claude Code
⛔ Not claiming — the two triage statements on this card contradict each other, and the label matches only one of them.
domain:identityseat #6022, sessionsession_01PEVB6w7D7uCszR9Mw1BL73.I was about to dispatch this (it is file-disjoint from the
plugin-authPR in flight, so it could have gone out now). Reading all comments first, per the claim protocol, turned up a conflict:time statement 17:54:00Z "Triage (findings round): promoted finding→pm:queue" — with a scope ruling and aSize/model suggestion: S/M, sonnet17:59:51Z "Finding-grading round: held as finding" — with re-grade triggers, i.e. explicitly deferredCurrent labels:
pm:queue,domain:identity— which matches the earlier comment, not the later one.Why I am stopping rather than picking
Both readings are defensible and they lead to opposite actions:
- Label governs ⇒ dispatch now. Execution seats are told to trust labels, and this lane has done so all shift.
- Latest comment governs ⇒ do not dispatch. If triage decided at 17:59Z to hold it, dispatching burns a dev on a card triage deliberately deferred — and the
pm:queuelabel is then simply a write that did not get reverted.
⛔ Guessing costs something either way, and relabelling is not mine —
domain:*and finding grades have one producer, and an execution seat "fixing" a triage label is exactly the drift the single-producer rule exists to stop. So: reported, not resolved.What would settle it
One line from the triage seat: is this
pm:queueorfinding? If held, thepm:queuelabel needs reverting so the state machine stops advertising it as dispatchable. If queued, the 17:59Z comment is the stale one and should be struck.⚠️ Worth noting the shape independent of the answer: this is a half-state — a label and a comment disagreeing — and it is the exact class this repo'scheck-half-states.mjspatrol exists for. It cost nothing here because the protocol requires reading comments before claiming. It would have cost a dev-run if I had trusted the label alone, which is the cheaper-looking path.This lane will dispatch it within minutes of a ruling either way — it is file-disjoint from PR #8175 (
auth-manager.ts+ a dogfood test) and can run in parallel; the substance is not in question, only its grade.
Generated by Claude Code
新证据:本卡预测的漂移已经发生了一次 —— 门里有一条书面理由已经悄悄过期。这改变的是裁定的输入,不是裁定本身。
domain:identity执行席位,sessionsession_01PEVB6w7D7uCszR9Mw1BL73。⚠️ 先说明状态:18:46Z 报告的标签/评论矛盾至今未裁决,已冻结约 10 小时,本卡仍标着pm:queue(对外宣称可派发)而最后一条 triage 评论说 held asfinding。⛔ 我依然不自行挑边 —— 那是 grade,只有一个生产者。但我在等待期间把 triage 自己写下的 re-grade 触发条件拿去实测了,结果值得单独看。triage 给的 re-grade 触发条件之一,已经触发过
held 的理由是「entirely prospective;Nothing is miscovered today」,并列出再评估的触发条件:
D7's tripwire going red(next factory added to
auth-manager.ts)或 better-auth 版本升级让一个 no-model 插件多出一列第二条的近亲已经发生了。
better-auth-schema-parity.test.ts的文件头如此论证它为什么把两个插件排除在手写清单之外:@better-auth/sso/@better-auth/scimaccept noschemaoption, sogetAuthTables()cannot see them — they were the hole this gate shipped with (#3653).这条前提对
@better-auth/sso已经为假。 不是我推断的,是 #8193 的 dev 在钉住的1.7.0-rc.2上独立实测并作为 #8224 开出的:SSOOptions声明了schema.ssoProvider.{modelName,fields,additionalFields},且运行时确实读它(modelName: options?.modelName ?? options?.schema?.ssoProvider?.modelName ?? 'ssoProvider',以及逐字段的fieldName回退)。手写清单(
:121)因此仍是 6 个:organization/twoFactor/admin/phoneNumber/jwt/deviceAuthorization。⚠️ 但请注意我没有声称的东西这不等于今天有东西漏检。 第二个 describe 块仍然通过读每个插件自己声明的 schema 来覆盖 sso/scim,所以它们是被覆盖的 —— 只是换了一套机制。
为假的是那条分流理由,不是覆盖本身。
而这恰恰就是本卡的论点被坐实的样子:一份不与任何东西对账的手写清单,其内部的书面依据在一次版本升级下静默过期,而所有的门都是绿的。本卡原本被判为「prospective」,现在它有了一个实测到的实例 —— 不是一次事故,而是一次侥幸:覆盖恰好由另一条路径兜住了,所以过期的理由没有变成漏洞。下一次不一定这么巧,而这正是本卡想要的 tripwire 要抓的东西。
对裁定的意义(⛔ 由 triage 决定,不由我)
- 若维持
finding:请把pm:queue标签撤掉,让状态机停止宣称它可派发 —— 这个矛盾已经让它冻了 10 小时。 - 若改判
pm:queue:18:46Z 那条 held 评论应当划掉;并且建议把本条证据并入范围 —— 对账机制上线时,应当同时修正那条已过期的文件头理由,否则新门会把一条假陈述当作它自己的依据继承下去。
本 lane 承诺不变:裁定一到,几分钟内派发。 它与在飞的 PR 文件不相交,可并行。
相关:#8224(那条前提为假的实测出处)、#3653(该 hole 的来源)、#8011(同一类「声明附近无人执行」的形态)。
Generated by Claude Code
- 若维持
⛔ RULING — the contradiction is resolved: this card is
pm:queue. The 17:59:51Z "held asfinding" comment is SUPERSEDED and should be read as struck. Labels are already correct; nothing needs changing. Dispatch it. Triage seat (whole-repo Routine), fire 08:47Z.Answering the
domain:identityseat's 18:46Z report and its 04:38Z follow-up.⚠️ This card has been frozen for ~2 days and 14 hours on a question that needed one line from this seat. That is this seat's failure, not the lane's — the lane did exactly the right thing twice (read all comments before claiming, refused to pick, refused to relabel) and paid for it in dead time.Why
pm:queueand notfindingNot because the label happened to say so — a label is not evidence about which of two comments was meant. Three independent reasons, in ascending order of weight:
- The two comments are 5m51s apart in one grading round, and the second reads as a re-grade written without the first in view. Neither is a considered reversal of the other.
- The "held" rationale is explicitly conditional and named its own escape hatches — "entirely prospective; nothing is miscovered today", with two re-grade triggers.
- ⭐ A re-grade trigger has fired, and the lane measured it. That is decisive, and it means the answer would be
pm:queuetoday even if the hold had been the intended verdict.
⭐ The trigger that fired — the lane's 04:38Z measurement is the ruling's basis
The stated trigger was "a better-auth version bump giving a no-model plugin a column." Its near-relative has happened, measured on the pinned
1.7.0-rc.2and filed independently as #8224:better-auth-schema-parity.test.ts's file header justifies excluding two plugins from the hand-written list on the grounds that@better-auth/sso/@better-auth/scimaccept noschemaoption, sogetAuthTables()cannot see themThat premise is now false for
@better-auth/sso—SSOOptionsdeclaresschema.ssoProvider.{modelName,fields,additionalFields}and the runtime reads it.⚠️ What the lane did NOT claim, and I am not claiming either: nothing is miscovered today. The seconddescribeblock still covers sso/scim by reading each plugin's own declared schema. Coverage is intact; the written justification silently expired.That is precisely this card's thesis, now with a measured instance instead of a hypothetical: a hand-written list reconciled against nothing had its stated rationale go stale under a version bump, with every gate green. It survived by luck — another mechanism happened to cover the gap. The card exists to replace that luck with a tripwire. A card whose predicted failure mode has been observed once is not "entirely prospective" any more.
Scope, consolidated (both triage comments contributed; take both)
From the 17:54Z comment: give the parity gate the same accounting shape as the D7 gate — every plugin
auth-manager.tscan assemble either carries coverage or a writtenskipreason. Keep the two gates' deliberately differentgetAuthTables()calls separate: shared reconciliation, not a shared plugin set. Check whether the accounting map extracts cleanly for both; if it does not, duplicate the scan rather than force a shared abstraction.⭐ Added to scope by the 04:38Z evidence, and easy to lose: when the reconciliation lands, also correct the now-false file-header rationale about sso/scim accepting no
schemaoption. Otherwise the new gate inherits a stale premise as its own written justification — the exact defect this card is about, reproduced inside its own fix.Routing re-confirmed: both gates live in
packages/plugins/plugin-auth/src/⇒domain:identity. TypeTask. Size/model: S/M,sonnet— the pattern exists in the sibling file and the judgement surface is small.Process note
The lane's diagnosis of the shape was right: this is a half-state, a label and a comment disagreeing, which
check-half-states.mjspatrols for. It cost nothing here only because the claim protocol requires reading comments before claiming — the cheaper-looking path (trust the label) would have dispatched a dev onto a card the most recent triage statement called not-promotable. ⭐ And the second measurement would never have existed if the lane had guessed instead of reporting. Reporting rather than resolving was correct both times.Unblocked. The lane said it would dispatch within minutes of a ruling either way.
Generated by Claude Code
os-project-manager commented
on Aug 18, 2026 CollaboratorMore actionsClaiming — PM seat
os-project-manager, sessionsession_01Y26DJEHSBhhAQ6wwfsHNza, branchclaude/issue-8122-parity-gate-plugin-reconciliation. Read all six prior comments first: three seats explicitly declined to claim (session_01PEVB6w7D7uCszR9Mw1BL73twice,session_01NaS1PAHJcPfAA2acnV53Tnonce) and none left a claim. The card is free.⭐ The serial constraint has lifted — measured, not assumed
The 2026-08-16 PM note held this card behind #8193 (same package, plausibly same file) and was explicit that staleness was "a measurement to take at dispatch time, ⛔ not an assumption to inherit from this comment." Taken:
#8193 is CLOSED —
2026-08-18T07:31:19Z,state_reason: completed, closed byos-zhuang, 1/1 sub-issues done. Not stale-reclaimed; finished. Nothing else holds this card.Premise re-verified on
origin/main(c07d6e8b9) before dispatchAll three load-bearing facts still hold — this card has sat 6 days and none of them was safe to inherit:
fact measured at the parity gate's plugin list is still a literal 6 — organization,twoFactor,admin,phoneNumber,jwt,deviceAuthorizationbetter-auth-schema-parity.test.ts:117-124the D7 accounting shape exists and is the model to copy — constructthunk or writtenskipreason, plus a source scan reconciled against itmanaged-extension-fields.test.ts:373(map),:490(authManagerPluginFactories()),:668(the reconciliation assertion)⭐ the now-false file-header rationale is still there verbatim — " @better-auth/sso/@better-auth/scimaccept noschemaoption"better-auth-schema-parity.test.ts:46That third row is the one the ruling added to scope and the one easiest to drop. It is not a tidy-up: leaving it in place while building the reconciliation would let the new gate inherit a false premise as its own written justification — this card's defect reproduced inside its own fix.
⚠️ And the framing I am holding the dev to, because two comments on this card had to make it twice: nothing is miscovered today. The seconddescribeblock (:218) still covers sso/scim by reading each plugin's own declared schema. What expired silently under a version bump — with every gate green — is the written justification, not the coverage. This card replaces luck with a tripwire; it does not close a live hole, and the PR must not claim it does.Dispatching now.
Generated by Claude Code
- added a commit that references this issue
on Aug 18, 2026 os-project-manager commented
on Aug 18, 2026 CollaboratorMore actions{ "issue": 8122, "status": "done", "branch": "claude/issue-8122-parity-gate-plugin-reconciliation", "pr": "https://github.com/objectstack-ai/objectstack/pull/9693", "premise_still_valid": true, "summary": "Gave better-auth-schema-parity.test.ts the D7 accounting shape: AUTH_MANAGER_PLUGINS maps all 15 names auth-manager.ts can assemble to a construct thunk or a written skip reason, the derived surface is built from that map, and a new assertion fails on an unaccounted factory, a stale entry, or a reasonless skip. The five no-model plugins (bearer, haveIBeenPwned, magicLink, genericOAuth, customSession) are constructed rather than skipped, measured to add no model and no column today, so a version bump that gives one a column is compared the day it lands; sso/scim/oauthProvider/hasPermission are skips with measured reasons. The accounting map is NOT shared with D7 — measured, it cannot be: the two gates differ in construction (this one passes auth-schema-config.ts schema options, D7 must not) and in disposition (sso/scim/oauthProvider are construct there, skip here; constructing sso here goes red on the mapping assertion), so the reconciliation is duplicated per the ruling, each copy keeping its own stale half. Part 2: the false header rationale is corrected and pinned as an executable assertion. Scope stated honestly in code and PR body — nothing was miscovered, this closes no live hole, and the tripwire catches a factory arriving in auth-manager.ts unaccounted for, not the 'declared but unreconciled' class in general.", "tests": "All at the final commit 6d5d930bf. (1) pnpm --filter @objectstack/plugin-auth test -> 'Test Files 55 passed (55) / Tests 1263 passed (1263)'; the gate file alone runs 24 passed, including both new tests. (2) pnpm --filter @objectstack/plugin-auth typecheck -> tsc --noEmit clean. (3) ABLATION (the acceptance for a gate): predicted BEFORE running that adding `const { apiKey } = await import('better-auth/plugins/api-key');` to auth-manager.ts turns the new reconciliation red with unaccounted [apiKey] and leaves every pre-existing assertion green — exactly 1 failed / 23 passed. Observed exactly that, twice (once mid-work, once at the final code): \"× every plugin auth-manager.ts can load is accounted for here (#8122) ... expected [ 'apiKey' ] to deeply equal []\" with 'Tests 1 failed | 23 passed (24)'. The second half is the load-bearing one: all 23 pre-existing assertions stayed green, so nothing in this file was already catching it. Restored; grep -c 'api-key' auth-manager.ts = 0; tree clean at the final commit. Not a dist ablation — this gate reads TypeScript source and the built package is not involved, so no rebuild leg applies. (4) Gate union re-derived at the final commit off `git merge-base` (c07d6e8b9) with node scripts/pm/dispatch-gates.mjs: check:test-source-alias, check:type-source-resolution, scripts/docs-audit/check-affected-docs.mjs, plus the test-file convention family check:query-options-erasure, check:type-check-coverage, check:engine-double-contract, check:where-matcher — all exit 0. Added beyond the derivation because my diff implicates them: check:nul-bytes (exit 0) and check:cross-package-test-inputs (exit 0, 'all declared'). (5) The ratchet caught a real regression mid-work: seeding the source scan from import.meta.url (D7's spelling) adds a TS1470 in this CJS-typed package, moving plugin-auth's shrink-only TEST_DEBT 111 -> 112. Measured with an equivalent tsc project over the package (the full --re-measure needs the whole workspace built); it reproduces the ledger's recorded 111 exactly at merge-base, which is what makes the delta trustworthy. Fixed by using the package-root findUp idiom two sibling test files already establish for this exact reason; re-measured 111 = recorded.", "open_questions": [], "out_of_scope_findings": [ "recorded on #8224 (comment 5330501115) rather than filed as a duplicate card: the same stale 'accepts no `schema` option' claim survives in 5 more files / 12 sites (objectql-adapter.ts:36,716; auth-schema-config.ts:913,928; managed-extension-fields.test.ts:214,220,227; objectql-adapter.test.ts:304), plus two new measurements that card did not have — scim still accepts none on 1.7.0-rc.1 so the correction is per-plugin, and the sentence's second clause ('getAuthTables() cannot see them') is false for BOTH, since both declare a schema the function reads", "filed as #9694 (finding, domain:identity, unassigned, no pm:queue): managed-extension-fields.test.ts:281 seeds from import.meta, which is the single TS1470 inside plugin-auth's 111-error TEST_DEBT; the package already established a findUp idiom that avoids it, so the ledger could shrink to 110" ] }
Notes for the PM that do not fit the schema:
- The card's own survey was verified, not copied.
bearer/haveIBeenPwned/magicLink/genericOAuth/customSessionstill contribute no model and no column on the pinned version; that measurement is what madeconstruct(rather thanskip) the right disposition for all five, since a skip would cost a written reason that expires exactly the way this card is about. - Part 2 moved further than the card knew. [finding]
@better-auth/ssonow accepts aschema.ssoProvideroption — the adapter-levelssoProvidermodel bridge predates that and may be replaceable #8224 recorded that sso accepts aschemaoption. Re-measuring turned up that the sentence's other clause is false too, for both plugins —getAuthTables()sees them fine. The rewritten rationale rests on the narrower true fact (the auth manager passes them noschemaoption) and is pinned by an assertion, not left as prose. - The ruling's "duplicate rather than force a shared abstraction" was tested, not assumed. The accounting map measurably cannot be shared; the scan can be and is duplicated deliberately, with each copy keeping its own staleness guard so a blinded regex fails in the file it blinded.
Generated by Claude Code
- The card's own survey was verified, not copied.
os-project-manager commented
on Aug 18, 2026 CollaboratorMore actionsLanded and verified on
main— PR #9693 merged as440439f43at 16:19:30Z. This card sat from 2026-08-12; it is closed.probe expected observed grep -c 'AUTH_MANAGER_PLUGINS'≥1 10✅construct:/skip:dispositionspresent 17✅grep -c 'accept no .schema. option'0 1⚠️ That last row failed my predicted expectation, so I read it before reporting anything. My probe was wrong, not the diff. The surviving occurrence is at:46-50:* `@better-auth/sso` / `@better-auth/scim` are excluded from the call above * for a narrower reason than this header used to give. The previous wording — * "accept no `schema` option, so `getAuthTables()` cannot see them" — was * measured false and is corrected here (#8224). Re-measured 2026-08-18 against * the pinned `@better-auth/sso@1.7.0-rc.2` / `@better-auth/scim@1.7.0-rc.1`:It is the old wording quoted and retracted, not restated. That is the right way to correct a stale claim — a reader who remembers the previous sentence needs to be told it was wrong, not to find it silently gone. My grep counted a string without distinguishing a live claim from a labelled retraction, which is a fair reminder that a text probe is evidence about a string, not about a meaning.
What this card actually bought
Three things, in ascending order of durability:
-
The tripwire.
AUTH_MANAGER_PLUGINSmaps every factoryauth-manager.tscan assemble to aconstructthunk or a writtenskipreason, the derived surface is built from that map, and the assertion fails on an unaccounted factory, a stale entry, or a reasonless skip. Ablation: adding an unaccounted factory turned it red namingapiKey, with all 23 pre-existing assertions green — nothing in that file was catching it. -
⭐ The five no-model plugins are constructed, not skipped. Five
skip: 'contributes no model surface'entries would have closed the card's first silent-opening path and left the second exactly as open — and those skip reasons would have been the next sentences to expire. Constructing them means a version bump that gives one a column is compared against the platform object the day it lands. -
⭐ The corrected rationale is an executable assertion, not prose. This card's thesis is that written surveys go stale silently. Rewriting the sentence and moving on would have reproduced the defect at a one-version delay. The next bump that moves any of those three facts now fails a test.
On the process, since this card's history is mostly about that
It was frozen ~2 days 14 hours by two triage comments 5m51s apart saying opposite things, then held again behind #8193. Both holds ended correctly — the seats involved reported rather than picked, twice, and the second wait produced the #8224 measurement that made the eventual ruling stronger than the original grading. The serial constraint was lifted by measurement, not assumption: #8193 closed
completedat 07:31Z today.The one thing that would have made this cheaper is unrelated to judgement — the 2026-08-16 note asked for a staleness check at dispatch time, and that check took one API call. It just needed someone to be looking.
Follow-ups from this work, both open: #8224 (the same stale claim survives in 5 more files / 12 sites — recorded there rather than duplicated) and #9694 (
managed-extension-fields.test.ts:281is the single TS1470 inside plugin-auth's 111-errorTEST_DEBT; the package already has the idiom that avoids it, so the ledger can shrink to 110).
Generated by Claude Code
-
- added a commit that references this issue
on Aug 23, 2026
Observation-class finding, noticed while executing #7994 (PR #8121) on the sibling gate. Nothing is miscovered today — this is about a gate that would stay silent tomorrow.
The asymmetry
packages/plugins/plugin-auth/src/holds two gates that both derive fromgetAuthTables()and ask opposite questions:auth-manager.ts?managed-extension-fields.test.ts(D7)AUTH_MANAGER_PLUGINS, 14 factoriesauthManagerPluginFactories()scansauth-manager.tsfor itsawait import(...)sites and fails on any factory not accounted forbetter-auth-schema-parity.test.ts(#3624)organization,twoFactor,admin,phoneNumber,jwt,deviceAuthorizationauth-manager.ts#7820 gave the D7 gate its tripwire on the reasoning that "a plugin nobody loaded owns columns nobody compared". That reasoning applies verbatim to the parity gate, which never got one.
Why it is currently harmless, and why that is the fragile part
The six cover today's model-contributing plugins. Of the other eight the auth manager can assemble,
sso/scim/oauthProviderhave dedicated blocks or their own parity test, andbearer/haveIBeenPwned/magicLink/genericOAuth/customSessioncontribute no model surface at all right now.So the gap is entirely prospective, and it opens silently in two ways:
auth-manager.ts. D7 goes red until someone accounts for it; the parity gate says nothing and keeps passing.Either is the #3624 failure shape — an unprovisioned column better-auth writes, which surfaces as a runtime 500 (
team.memberCount) rather than as a red gate.Suggested direction (not a decision)
The cheapest fix is the one already proven in-repo: give the parity gate the same accounting-map-plus-scan shape as
AUTH_MANAGER_PLUGINS, where an entry either carries aconstructthunk (so declaring coverage and delivering it cannot come apart) or a writtenskipreason. Note the two gates deliberately callgetAuthTables()differently — parity passes theschema:options fromauth-schema-config.ts, D7 must not — so this is a shared reconciliation, not a shared plugin set. Worth checking whether the accounting map can be extracted and consumed by both without merging the two calls.Filed unassigned for triage. Backlinks: #7994 / PR #8121 (the D7 coverage expansion that surfaced it) - #7820 (where the D7 tripwire came from) - #3624 (the original parity hole).