Skip to content

plugin-auth: decide the durable answer to better-auth's account-issuer rollback — 1.7.3 removed the issuer identity outright, and #16186 shipped an exact pin as a stopgap #16629

Closed

Description

@hotlong

The durable half of #16186, which shipped the stopgap: @objectstack/plugin-auth now pins the better-auth family to an exact 1.7.2, which restores a working fresh install but freezes the family on a line upstream has already moved off.

What upstream actually did

@better-auth/core@1.7.3 did not rename createLocalAccountIssuer — it removed the issuer-scoped account identity outright (better-auth/better-auth#10909, merged 2026-08-24). Measured by diffing the two published src trees:

  • db/schema/account.ts — the issuer field is gone from accountSchema; AccountKey reverts from Pick(BaseAccount, "issuer" | "accountId") to Pick(BaseAccount, "providerId" | "accountId"); createLocalAccountIssuer and createOAuthAccountIssuer are deleted.
  • db/get-tables.ts — the account.issuer column and the unique (issuer, accountId) index are both gone.
  • oauth2/oauth-provider.ts — the accountIssuer option is gone, and with it the per-provider declarations (google, apple, line, facebook, cognito, paybin, and the Entra per-login resolver). accountIssuer occurs zero times in the whole 1.7.3 package.

So there is no drop-in replacement to adopt. findAccountByKey keys on (providerId, accountId) again.

What adopting it costs here

Adoption is not a range bump. It removes a required column from a platform object and needs a migration for every existing deployment:

  • packages/platform-objects/src/identity/sys-account.object.ts — issuer is a declared field; the four generated translation bundles carry its label.
  • packages/plugins/plugin-auth/src/backfill-account-issuer.ts (plus its test, 44 references) — the boot-time pass that stamps sys_account.issuer exists only to serve the 1.7 identity model. It retires whole.
  • packages/plugins/plugin-auth/src/account-issuer-parity.test.ts — pins our issuer derivation against @better-auth/core/social-providers, a surface 1.7.3 no longer has.
  • auth-schema-config.ts, managed-extension-fields.ts and auth-manager.ts map the column into better-auth's schema.
  • Existing databases hold rows stamped local:credential and provider issuers under a unique (issuer, account_id) index. Dropping the column changes which rows collide.

The decision this needs

  1. Adopt the rollback — drop sys_account.issuer, retire the backfill, migrate existing rows, and raise the family floor to 1.7.3. Matches upstream; costs a schema migration on the identity table.
  2. Keep the 1.7 identity model as ours — declare issuer as an application-owned additional field and resolve accounts on it ourselves rather than through findAccountByKey. Keeps the data model; owns a divergence from the vendor forever.
  3. Stay pinned — the state bug(plugin-auth): published 17.1.0/17.2.0/17.3.0 float @better-auth/core to 1.7.3, which dropped createLocalAccountIssuer — a fresh objectstack dev --seed-admin never creates the system tables and never seeds #16186 leaves behind. Correct today; the pin cannot take a security patch without a reviewed lift, and the gap widens with every upstream release.

Whichever is chosen, the family still moves as one line: @better-auth/core@1.7.2 and @better-auth/kysely-adapter@1.7.3 are mutually incompatible in both directions (1.7.2 has createLocalAccountIssuer and lacks checksSchema; 1.7.3 is the reverse).

What already guards the choice

pnpm check:vendor-export-contract (added by #16186) fails on any lift whose new version drops a symbol our shipped source imports, and its --resolve leg enumerates every registry version the declared range admits. So this decision cannot be taken by accident, only deliberately.

Filed by the developer seat that shipped the #16186 stopgap. Not claimed.

Activity

  1. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Sep 7, 2026
  2. hotlong commented on Sep 7, 2026

    @hotlong
    ContributorAuthor

    Four-facet block added by the director seat (summon #17, session_01XesLUWmuhjuRwmU618AZ1M, 2026-09-07T14:4xZ) so the card can be presented in a decision batch; the card arrived in the decision box with no analysis. ⛔ Not a ruling. Facts below are read from the card body and from packages/plugins/plugin-auth on origin/main; the pinned line is the #16186 stopgap.

    四棱分析(总监席出具,供裁决)

    • ① 项目长远合理性(≥50%,领起推荐)—— 指向 1(采纳上游回滚)。 sys_account.issuer、启动期回填、issuer 派生对齐测试,这三样存在的唯一理由是「服务 better-auth 1.7 的账户身份模型」;上游在 1.7.3 把这个模型整个撤走了(better-auth#10909),不是改名。选 2 等于把一个供应商已放弃的身份模型变成我们自己永久拥有的分叉——认证库上的自有分叉是本仓最不该长期持有的东西;选 3 把「暂时钉死」默认退化成「永远钉死」。1 是三条里唯一缩小特例的:列、回填、对齐测试整体退役,findAccountByKey 回到 (providerId, accountId)。
    • ② 实际业务拉动 —— 真实但不是客户报障。 拉动来自安全姿态:plugin-auth 是认证库,精确钉在 1.7.2 意味着上游任何安全补丁都要先做一次「受审的解钉」才能进来,且差距逐版扩大;check:vendor-export-contract 已保证解钉不会误发生,但也保证了它不会自动发生。⛔ 没有任何客户报过这件事;拉动是「不做的代价随时间增长」,不是「今天有人被挡」。
    • ③ 防 AI 犯错 —— 指向 1,且反对 2。 3 是静默腐烂的标准形状:一个没人会再看的精确钉;1 的每一步都响亮——迁移是显式的,门禁对任何掉符号的解钉拒绝;2 最差:自有一套账户解析逻辑是一个安全敏感的新面,AI 以后每次动 auth 都要同时维持它与供应商的差异,而这个差异没有上游测试为它背书。
    • ④ 创业阶段不扩散 —— 1 净减代码,2 净增永久义务,3 现在零、随时间增。 ⚠️ 1 的代价是一次身份表迁移:删列、删 (issuer, account_id) 唯一索引、现有行按 (providerId, accountId) 重新判碰撞。创业阶段部署少、行少,这笔成本今天最低、以后只涨——这是本卡不该等的理由,不是可以渐进的理由(2026-08-27 裁定:短期不考虑渐进)。

    推荐:1。回退:3,但必须带到期条件(写明「到上游哪个版本 / 哪个日期前必须解钉」,否则 3 就是 2 的沉默版)。⛔ 2 不可荐——认证库上的自有分叉。

    置信缺口(裁 1 的派发前提,⛔ 不是裁决的前提):现有数据库里 (issuer, account_id) 唯一而 (providerId, accountId) 会碰撞的行数——按供应商逐一数(Entra 的 per-login issuer 解析是最可能碰撞的那一家)。零 ⇒ 迁移是纯删列;非零 ⇒ 迁移必须拒绝并列出碰撞行,⛔ 不得静默合并或静默丢弃任何一行。

    裁后执行(裁 1):needs-user-decision → pm:queue,domain:services 席派发;Clause-②: yes(sys_account 平台对象删一个已声明字段、@objectstack/platform-objects 已发布 .d.ts 收窄)⇒ 契约档或载体补偿;changeset major 分肢按 ADR-0087 带迁移台账行(删列 + 索引换键 + 碰撞处置);同 PR 抬 @better-auth/* 家族到 1.7.3 一条线、退役 backfill-account-issuer.ts 及其测试与 account-issuer-parity.test.ts、四份翻译包去掉该标签。派发令第一步 = 上面那个碰撞数,贴在卡上再动 schema。

    维护者速读

    我们的登录库(better-auth)上个月把「账户属于哪个签发方」这套模型整个撤掉了,不是改名。我们为了适配它 1.7 版本,曾经给账户表加了一列、写了启动时的回填、还写了对齐测试。现在上游没有这套东西了,#16186 只能先把版本精确钉死在旧版,保证新装能用。

    要你拍板的:1(推荐)跟着上游走——删掉那一列和回填,做一次账户表迁移,版本解钉;2 把那套身份模型当成我们自己的、永远维护;3 一直钉着旧版——安全补丁进不来。

    风险与代价:1 要做一次身份表迁移,现在用户少、成本最低;派发时先数一遍现有数据有没有会撞在一起的账户行,有则迁移必须拒绝并列出,不会静默合并。2 是在登录库上养一个自己的分叉。3 是把问题往后拖、越拖越贵。

    你要做的:回一个数字 —— 1 / 2 / 3。


    Generated by Claude Code

  3. hotlong commented on Sep 7, 2026

    @hotlong
    ContributorAuthor

    Ruling recorded — 1: adopt better-auth's account-issuer rollback — sys_account.issuer and the backfill retire, the family lifts to 1.7.3, existing rows migrate with collisions refused, never merged (director seat, summon #17, decision batch #3, 2026-09-07)

    Provenance (who / verbatim / where): maintainer, live PM chat with the director seat (session_01XesLUWmuhjuRwmU618AZ1M), 2026-09-07T14:5xZ, batch #3 presented as 1(1) · 2(1) · 3A · 4C · 5A with this card as item 2 recommending 1 (the 5572249043 four-facet block, four facets aligned); reply, verbatim: 「同意」.

    Ruled. The platform tracks the vendor's identity model; it does not own a fork of one. Everything that existed only to serve better-auth's 1.7 issuer model retires: the issuer field on packages/platform-objects/src/identity/sys-account.object.ts and its label in the four translation bundles, packages/plugins/plugin-auth/src/backfill-account-issuer.ts with its test, account-issuer-parity.test.ts, and the column mappings in auth-schema-config.ts / managed-extension-fields.ts / auth-manager.ts. findAccountByKey keys on (providerId, accountId) again. The @better-auth/* family moves as one line to 1.7.3, and check:vendor-export-contract is green on the lifted line. Option 2 (keep the 1.7 model as ours) is refused — a permanent fork on the authentication library. Option 3 (stay pinned) is refused as the durable answer: it was the #16186 stopgap and it has done its job.

    Measurement before any schema moves (the dev's first step, posted on this card): per provider, the count of existing rows that are unique under (issuer, account_id) and collide under (providerId, accountId) — Entra's per-login issuer resolver is the likeliest source. Zero ⇒ the migration is a column drop and an index re-key. Non-zero ⇒ the migration refuses and lists the colliding rows; ⛔ it never merges or drops a row silently.

    Execution, domain:services lane: one PR — the platform-object change, the migration for existing deployments, the retirements, the family lift. Clause-②: yes (a platform object drops a declared field; a published .d.ts narrows) ⇒ dispatch at CONTRACT_REVIEW_TIER, or with needs:contract-review as compensation. Changeset major arm for @objectstack/platform-objects and @objectstack/plugin-auth per ADR-0087, with the migration ledger row (column drop, index re-key, collision handling) and the BREAKING banner. One step, no staged window (2026-08-27 ruling). Priority and type were not graded at filing; the services seat grades at claim (the director's reading: p2 — an exact pin on the authentication library blocks security patches).

    Labels: needs-user-decision → pm:queue in one write, read back. Blocked-by: none (#16186 landed). Governing text: ADR-0087 (a breaking change carries its migration), check:vendor-export-contract (#16186). Ledger: objectstack#12708, summon #17.


    Generated by Claude Code

  4. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Sep 7, 2026
  5. os-bill commented on Sep 9, 2026

    @os-bill
    Collaborator

    Maintainer ruling: Option 1 — adopt the rollback

    Authority — maintainer instruction, verbatim, in this PM session's chat at 2026-09-09T03:5xZ (⛔ 照抄不译):

    但是我们不可能长期保持旧版本不升级,总要找到方案

    残留的 sys_account.issuer 列 退役,开卡

    ⇒ Option 3 (stay pinned) is rejected as a durable answer, and Option 1 is selected: drop sys_account.issuer, retire the backfill, migrate existing rows, raise the better-auth family floor to 1.7.3. Option 2 (own the divergence forever) is not taken.

    ⛔ No new card was opened. The maintainer asked for one; this card already IS it, and its Option 1 already scopes the column retirement. Splitting the platform-objects half out would produce a fragment that cannot land alone — the column, the backfill and the version floor have to move together (this card's own closing note: the family moves as one line, @better-auth/core@1.7.2 and @better-auth/kysely-adapter@1.7.3 being mutually incompatible in both directions). Recorded here instead, per 查重先行.

    Independent corroboration of this card's premise

    Re-measured from the published tarballs today, before the ruling was recorded — this card's reading holds:

    reading 1.7.2 1.7.3
    dist/db/schema/account.mjs size 1642 bytes 938 bytes
    issuer in that schema issuer: z.string() absent
    Issuer-shaped exports in dist/ createLocalAccountIssuer, createOAuthAccountIssuer, accountIssuer, encodeAccountIssuerProviderId, Issuer, setIssuer Issuer, hasIssuer, setIssuer only
    db/index.d.mts Issuer declarations 2 exported 0

    ⭐ Positive control on the zeros (a zero is only a reading with one): the same matcher found accountId, providerId, accessToken, refreshToken, scope and userId in both versions' account schema — six fields present on both sides, issuer present on one. The file exists in both; it did not vanish, it shrank.

    ⚠️ A successor-hunt was run and came back negative, with the near-miss named so a later reader does not re-open it: 1.7.3 does carry hasIssuer / setIssuer, but they live in dist/oauth2/client-assertion.mjs and dist/db/schema-diff.mjs — the OAuth2 client-assertion iss, a different concept from account identity. ⛔ They are not a migration target.

    Sequencing — this does NOT block the release in flight

    ⛔ sys_account.issuer is load-bearing while the pin is at 1.7.2: better-auth 1.7.2 resolves sign-in through findAccountByKey({ issuer, accountId }), so retiring the column before the floor moves would make every existing account unresolvable. The retirement is downstream of the version lift, in the same change, never ahead of it.

    ⇒ The pending 17.4.0 release (exact 1.7.2, the #16186 stopgap) ships unchanged and unblocked by this ruling. This card is the durable follow-up, not a release blocker.

    维护者速读

    ⭐ 你的裁决已记录:选 1 —— 跟着上游回滚走。 不新开卡,因为这张卡本身就是它,而且 Option 1 里已经写了要 drop 掉 sys_account.issuer。

    ⛔ 拆成两张会得到一张落不了地的卡:列、backfill、版本地板必须同一次一起动(1.7.2 和 kysely-adapter 1.7.3 双向不兼容,这一家必须整条线一起挪)。

    ✅ 我今天独立复测过这张卡的前提,成立:1.7.3 的 account schema 从 1642 字节缩到 938 字节,issuer 整个没了;而 accountId/providerId 等六个字段两边都在(阳性对照)。也追过"是不是改名了"—— 1.7.3 里那个 hasIssuer 是 OAuth2 client assertion 的 iss,不是同一个概念,别当迁移目标。

    ⚠️ 顺序上有一条硬约束:在 pin 还是 1.7.2 的时候,sys_account.issuer 是登录要用的(1.7.2 靠 (issuer, accountId) 找账号)。所以退役必须跟版本抬升同一次落地,⛔ 不能先删列。

    ⇒ 正在发的 17.4.0(精确 1.7.2)不受影响,照发。 这张卡是后续,不是发布阻塞。


    Generated by Claude Code

  6. os-justin commented on Sep 10, 2026

    @os-justin
    Collaborator

    Tier notice — the contract-review-tier requirement on this issue is lifted (skills seat, session session_01MoTv7pn338AZ71owsp19gQ, 2026-09-10T03:13Z; record and rule-text change in flight: #17285).

    Maintainer ruling, verbatim: 「现有的卡片如果写了要求fable的,也要让相关的项目经理知道,opus就够了。」 Under the same ruling set (quoted in full on #17285), the contract-review tier is reserved for the skills seat (protocol files + the published skills/**), the spec seat's clause-② review, and the maintainer-summoned director; triage and every other seat run the default tier.

    For this card: its Clause-②: yes declaration no longer calls for a contract-review-tier review. The lane seat's own default-tier review, plus the gates (widening tells, pin tests, dispatch-gates --tier), is the review of record, and the build stays at the default tier. Unchanged: the Clause-② declaration itself, the manual floor for widenings under 代裁, and the routing rule that a diff touching packages/spec goes to the spec seat, where the contract-review-tier review still applies. This comment changes no label, assignee or claim.


    Generated by Claude Code

  7. hotlong commented on Sep 10, 2026

    @hotlong
    ContributorAuthor

    四维分析(PM 席位,2026-09-10)

    补上这张决策卡缺的四棱块。四条里有两条是立卡后新量到的,都指向同一个方向。

    一 · 业务影响:issuer 现在是净负债,不是净资产

    它被引进来是为了安全(区分「谁担保了这个账号 id」),但实际付出的代价是静默锁死。showcase-demo-personas-loginable.dogfood.test.ts 的头注把它写死了:

    a credential row whose issuer is not the local credential issuer is invisible to findAccountByKey — sign-in then fails INVALID_EMAIL_OR_PASSWORD behind a "User not found" warn that points at the sys_user row, which is fine, instead of at the account, which is not. Four checklist items had that recorded as a knownGap, each rediscovering it.

    ⭐ 四个 checklist 项各自独立踩进同一个坑,各自记成 knownGap。 一个字段能让四拨人分别重新发现同一个陷阱,说明它的失效方式是不可见的:报错指向 sys_user(看着合理),真正坏掉的是 sys_account(没人看那里)。

    1.7.3 里 issuer 不参与键,这一整类 bug 随之消失。这是采纳回滚最实在的收益,比「跟上游」有分量得多。

    二 · 安全价值:基本是空的,残留只有一种情况

    sys_sso_provider 的索引是 { fields: ['provider_id'], unique: true } —— 环境内唯一。所以 provider_id → issuer 是一个函数,(provider_id, account_id) 已经能确定 (issuer, account_id) 所确定的同一件事。

    ⇒ issuer 在键里多出来的区分度,在我们自己的数据模型下几乎为零。

    ⚠️ 残留的唯一真实情况:同一个 provider_id 的注册被改指到另一个 IdP。此时老行与新行 issuer 不同,1.7.3 的键会把它们判成同一个账号。这条必须单独接住,见「四」。

    三 · 技术成本:比卡里写的小

    卡把 hash-shadow-key(#11627 / PR #12198)算作沉没投资。实测不是为这个索引专建的:sql-driver-11627-hash-shadow-key.test.ts 测的是驱动层通用能力(「任意 >768 字符列上的 UNIQUE」「恰好在 MAX_KEYABLE_VARCHAR_CHARS+1 处切换」「复合元组、NULL 保持互不冲突」),服务的是 >768 类的 5 个 UNIQUE 成员。

    ⇒ 删掉这一个索引不浪费那块地基,其余四个成员照用。

    另外我们不直接调 findAccountByKey(全树只有注释提到它),耦合点比看起来少。

    四 · 风险与时机:真风险在迁移,不在决定

    ⛔ 迁移动的是身份表,做错了的形态是「所有人登不进来」,这是这件事唯一需要小心的地方。而最具体的那个危险,卡里没点명:

    唯一性从 (issuer, account_id) 变成 (provider_id, account_id),会改变哪些行算冲突。 如果现有数据里存在两行 provider_id + account_id 相同、仅 issuer 不同 —— 在旧键下完全合法 —— 那么迁移一加新唯一约束就会撞上。这正是「二」里那个残留情况在数据里的样子。

    ⇒ 所以迁移的第一步必须是探测而不是改写:先查有没有这种行,有多少,再决定怎么处置。⛔ 不许先加约束看它炸。

    不做的代价在涨:钉死在 1.7.2 拿不了安全补丁,而这一家人只能整体移动(@better-auth/core@1.7.2 与 @better-auth/kysely-adapter@1.7.3 双向不兼容)。

    结论

    建议选一(采纳上游回滚),理由按权重排:① 它删掉一整类已经咬过四次的静默锁死;② issuer 的区分度在我们的模型下本就接近零;③ 成本比卡里估的小。

    ⛔ 但有一个不可省的前置条件:执行卡必须先做重复行探测,并把「provider 改指到另一个 IdP」的处置写进验收(改指时强制重建账号绑定,而不是指望键去挡)。不写清就删列,等于把一个已知的窄风险变成无人看守。

    选三只是延后并让差距变大;选二是为一个几乎为零的区分度,永久维护一个作者本人已经放弃的模型。

  8. hotlong commented on Sep 10, 2026

    @hotlong
    ContributorAuthor

    Ruled: option 1 — adopt the rollback. Maintainer, 2026-09-10, 「同意」 to the four-dimension analysis above.

    Execution card: #17440, dispatched. It carries the two findings that decided it (the four-times-rediscovered silent lockout, and provider_id being unique per environment), and two constraints the original card did not have:

    • ⭐ a preflight that detects the rows which are legal under (issuer, account_id) and collide under (provider_id, account_id), and refuses — ⛔ rather than adding the constraint and letting apply blow up as the detection mechanism
    • the re-pointed-provider case must be answered and pinned in that PR, not left implicit — it is the one situation where issuer still discriminates, and dropping the column while ignoring it converts a known narrow risk into an unguarded one

    This card stays open until #17440 lands, then closes with it.

  9. added
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 11, 2026
  10. hotlong commented on Sep 11, 2026

    @hotlong
    ContributorAuthor

    Label corrected: pm:queue → pm:blocked. This card was ruled and its execution is #17440, in flight — pm:queue says "dispatchable now" to every seat that reads the board, which would have invited a second dev onto a decision already being executed.

    Blocked-by: #17440

  11. hotlong commented on Sep 12, 2026

    @hotlong
    ContributorAuthor

    Landed — closing with #17440

    PR #17454 merged as 9bd4344e4, verified an ancestor of origin/main. Option 1 is in the tree: sys_account.issuer and its (issuer, account_id) unique index are gone, backfill-account-issuer.ts and account-issuer-parity.test.ts are retired, and the @better-auth/* family moved as one line to an exact 1.7.3.

    Both conditions this decision was made conditional on were met, not waived:

    • ⭐ The pre-flight reads ROWS, never the declaration. That turned out to matter more than the analysis anticipated: (provider_id, account_id) had been declared UNIQUE since the object was created, long predating issuer — but declared is not present. On a database where that index never materialised, dropping the column does not fail loudly, it degrades silently: indistinguishable rows, an arbitrary findAccountByKey resolution, sign-in landing on the wrong user. The four-dimension analysis predicted a migration that fails on duplicates; the real hazard was quieter and worse, and the shipped probe is built for the real one.
    • ⭐ The re-pointed provider is answered and pinned — refuseIssuerRepointWithLiveBindings, on the single-update door and on updateMany, because a re-point smuggled through the bulk door is the same re-point. Guarding one and leaving the other is the classic hole here.

    The ceremony was reused rather than invented: ADR-0131 D10 → ADR-0120 D4, through the os migrate family, with the refusal placed below the report and above both writes.

    ⛔ What this card does not claim: that issuer was worthless. It was the right thing to adopt when better-auth 1.7 keyed on it; what made it a net liability here was that sys_sso_provider.provider_id is unique per environment, so its discriminating power was already near zero in this data model — plus a failure mode (silent lockout behind an error pointing at the wrong row) that four separate checklist items each rediscovered.

    Follow-up carried forward, not lost: #17453 (three approvals-checklist knownGap texts still cite the retired backfill), graded finding, ⛔ deliberately not ridden along on the p1.

  12. removed
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    on Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions