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

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

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

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

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

  5. 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」的处置写进验收(改指时强制重建账号绑定,而不是指望键去挡)。不写清就删列,等于把一个已知的窄风险变成无人看守。

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

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

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions