Skip to content

feat(auth)!: adopt better-auth's account-issuer rollback — drop sys_account.issuer, retire the backfill, lift the family to 1.7.3 - #17454

Draft
hotlong wants to merge 17 commits into
mainfrom
claude/adopt-account-issuer-rollback-17440
Draft

feat(auth)!: adopt better-auth's account-issuer rollback — drop sys_account.issuer, retire the backfill, lift the family to 1.7.3#17454
hotlong wants to merge 17 commits into
mainfrom
claude/adopt-account-issuer-rollback-17440

Conversation

@hotlong

@hotlong hotlong commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #17440

Maintainer ruling 2026-09-10 on #16629, option 1: adopt better-auth's account-issuer
rollback. sys_account.issuer retires with the backfill that served it, and the
@better-auth/* family lifts to an exact 1.7.3 in one line.

Verified at e577e0eb4.


⭐ The finding that shaped the migration

The card asks for a pre-flight that detects rows sharing provider_id + account_id
and differing only in issuer. Measuring the premise first changed how that pre-flight
had to be built:

sys_account has declared { fields: ['provider_id', 'account_id'], unique: true }
since the object was created.
git log -S puts it in the commit that created the
object; the (issuer, account_id) pair arrived much later, with the 1.7.0-rc.2 bump
(#3632). So the "new" key is not new — it long predates the column being dropped, and
wherever that index is physically present the collision class is refused at write time.

That does not make the pre-flight unnecessary. It makes one thing about it load-bearing:

⚠️ "Declared" is not "present." syncDeclaredIndexes logs a plain UNIQUE whose
CREATE fails on existing duplicates onto the durability channel and lets the boot
continue (#14902 / #15479) — deliberately, so one dirty table cannot take a deployment
down. A database that ever held duplicates therefore carries the declaration and not the
constraint, and can still hold the class today.

On such a database the drop does not blow up. It degrades silently: the rows become
indistinguishable, findAccountByKey resolves whichever the driver hands back first, and
a sign-in can land on the wrong user's account. That is strictly worse than a failed
apply, and it is why the pre-flight reads rows, never the index declaration.

The ceremony — reused, not invented

ADR-0131 D10 fixes the shape and says in the same breath that it "reuses the ADR-0120 D4
migration ceremony where it exists (index and column changes) rather than inventing a
second one."
A column drop plus an index re-key is exactly ADR-0120 D4's class, and this
repository already ships every leg of it:

leg what runs it new here?
plan os migrate plan reports the drop as destructive drift no
⭐ row pre-flight os migrate account-issuer — read-only, exits non-zero yes — this was the gap
backup the operator's act, and the apply step's stated precondition no
apply os migrate apply --allow-destructive, which now refuses this drop while the pre-flight is dirty refusal is new
post-check re-run os migrate account-issuer; it reads zero no
boot refusal runArtifactBootMigrationGate already fails the boot on unapplied destructive drift, naming the command; os serve never auto-migrates no

So the smallest honest addition was the read-only pre-flight D4 asks for on a narrowing
index change, plus a refusal in front of the drop. An os migrate account-issuer --apply
that dropped the column itself would be the second ceremony D10 forbids, and it would drop
a column outside the drift reconciler that owns every other column drop.

⛔ No sys_migration flag, deliberately — os migrate summary-nulls documents the rule
that a deployment flag nothing reads is a fact nothing reads. The consumer of this verdict
is the gate in os migrate apply, which re-runs the probe against the live database at the
moment it matters; a row saying "clean on Tuesday" authorises nothing on Thursday.

Refusal discipline

Two readings are deliberately not reported as clean, because a pre-flight that cannot
see is not a pre-flight that found nothing:

  1. A read that throws refuses. The retired backfill-account-issuer.ts wrapped its
    reads in try { … } catch { return [] } — correct for an idempotent best-effort pass
    that runs again next boot, and exactly wrong for an answer that authorises an
    irreversible drop.
  2. A truncated walk refuses. An unenumerated tail is not zero rows.

⛔ Nothing is merged or deleted for the operator: which row survives is application
knowledge, and two different people can be behind one colliding key.

⚠️ The re-pointed provider — answered, and pinned

A provider_id re-pointed at a different IdP must have its account bindings REBUILT. No
key separates them, and after the column drop nothing can.

sys_sso_provider declares { fields: ['provider_id'], unique: true }, so within an
environment provider_id → issuer is a function and (provider_id, account_id)
determines what (issuer, account_id) determined — for as long as that function holds.
Re-pointing breaks it. If the new IdP mints a sub the old one had already issued to
somebody else, the new key resolves that sign-in onto the other person's account row.

⚠️ Under the old key that shape failed loudly: findAccountByKey missed the old row,
better-auth tried to insert, and the long-standing (provider_id, account_id) unique
refused it — the user saw unable_to_link_account. Under the new key it resolves
silently. The narrowing turns a loud refusal into a quiet cross-user sign-in, which is why
this is answered rather than left to a constraint.

Enforced at the re-point, because that is the last moment the distinction exists. After
the drop no column records which IdP vouched for a row, so no runtime check can tell an old
binding from a new one. refuseIssuerRepointWithLiveBindings sits on the sys_sso_provider
update doors and declines an issuer change while accounts are still bound to that
provider_id (RESOURCE_CONFLICT / 409). The operator deletes the stale bindings; each
user re-links on their next sign-in.

Pinned by five cases, including the one that states the answer directly — two rows under
one provider_id differing only in issuer are one key, two issuers, two people.

The two flagged items

showcase-demo-personas-loginable.dogfood.test.ts keeps its file and its real half.
The issuer assertion is replaced, not dropped: its job was "the account is resolvable
under the key sign-in uses", and the key is now (provider_id, account_id) — so that is
what it asserts, with the admin's own better-auth-minted account as the same positive
control the issuer case carried, plus a new assertion that the retired column is absent.
The header quotes the old assertion verbatim and records why it went away, so the trap that
bit four checklist items is not lost with the field that caused it.

check:vendor-export-contract is not loosened. It still requires an exact declared
range, agreement with the installed version, and real resolution of every named symbol.
Its self-test carried the instruction "if the durable fix landed, retire this case with
it"
— this is that fix. ⛔ Retiring the specimen is not retiring the case: what it
catches is a collector that has silently stopped reaching publishable source, which is how
#16186 passed over nothing for three releases. So it re-anchors on a live edge
(better-auth/adapterscreateAdapterFactory) and still asserts a named symbol, and
a new case asserts the two deleted names are imported nowhere.

Out of scope, untouched

#11627's hash-shadow-key machinery stays — a generic driver capability serving five
UNIQUE members of the >768-char class. The one place it was cited as an illustration
(platform-keyed-text-bounds.test.ts) moves to a measured surviving member of that
class, sys_oauth_access_token.token (1024), rather than a plausible-looking name.


Verification

⚠️ Declared narrowing — verification ran UNLOCKED. scripts/pm/os-verify-lock.sh could
not take the shared verify lock on this host: no usable flock. The shared verify lock is
declared Linux-only (flock is util-linux, and a stock macOS does not ship it), so the
commands below were run directly, without the lock — a declared narrowing, not a silent
one. No serialization guarantee held for these runs.

⚠️ Also declared: TMPDIR was pointed at a non-symlinked path for the CLI and dogfood
suites. On macOS /var is a symlink to /private/var, and ten CLI cases compare a path
the test itself built from tmpdir() against the realpath Node returns. Proven to be the
host and not this diff: the same three files, unchanged on this branch, pass 41/41 under
TMPDIR=/private/tmp/…. CI runs on Linux, where /tmp is not symlinked.

Acceptance

① The pre-flight refuses on a fixture containing the collision class — watched refusing.

✓ #17440 the preflight REFUSES on the collision class > refuses, naming the rows, when one key is held by two rows differing only in issuer
✓ … > flags a same-user collision WITHOUT the cross-user marker
✓ CONTROL — a clean table passes …          ✓ CONTROL — an empty table is clean …
✓ a read that throws refuses instead of reporting zero rows
✓ a walk stopped by its row cap refuses instead of reporting a partial scan as clean
Test Files 1 passed (1) · Tests 15 passed (15)

Every refusal asserts the ADR-0112 envelope (code and status) and the substance of
the message — never a bare toThrow(), which would pass on a fixture that never reached
the probe.

The fixture registers an index-less sys_account on purpose, and the file says why.
The PREMISE case proves the class cannot be inserted where the declared unique is
physically present, by trying against the real object and watching the driver refuse
(with a control: a different account_id inserts fine). So the only population that can
hold the class is a deployment carrying the declaration without the constraint — which is
exactly what the fixture models.

② Fresh install and existing-data upgrade both end with working sign-in over a real auth
route.

Fresh — the real showcase boot:

✓ each persona holds a credential account resolvable under the SAME key better-auth uses for the admin
✓ each persona SIGNS IN over the real auth route, and the session resolves to that persona
Test Files 1 passed (1) · Tests 4 passed (4)

Existing data — two engines over one SQLite file (engine A declares issuer and signs a
user up through the real HTTP route so the hash is better-auth's own; engine B on the same
file registers today's objects: new code, old table):

✓ a 1.7.2-era account still SIGNS IN over the real auth route after the column is undeclared
✓ the undeclared column is NOT silently dropped by schema sync — the drop stays the operator's deliberate act
✓ the pre-flight reads CLEAN on that database, which is what authorises the drop
✓ and sign-in still works once the column is actually GONE — the far side of the ceremony
Test Files 1 passed (1) · Tests 4 passed (4)

Both sign-ins are judged by the principal the session resolves to, never by a status. Both
PRAGMA reads carry a control — not.toContain passes vacuously on an empty array, which
is the one reading this must never produce by accident.

③ The re-pointed-provider answer is stated and pinned — stated above, pinned by the five
cases in account-identity-preflight.test.ts.

check:vendor-export-contract — both directions proven.

Passing at 1.7.3:

check-vendor-export-contract --self-test OK (1 governed vendor family)
VERDICT: PASS — vendor export contract (installed workspace), 1 edge(s) verified
  ✓ better-auth/adapters @ better-auth@1.7.3 (1 symbol(s): createAdapterFactory)

Still failing when pointed at a symbol the pinned version does not export — an ablation on
the committed tree, mutation confirmed on disk before the measurement and the restore proven
by hash:

HEAD blob hash: 1b1d1b5ed15cd67945e7d04331dee637d6e6129b
--- before: original present=1, injected present=0
--- after:  original present=0, injected present=1
--- MUTATION CONFIRMED ON DISK ---
ABLATED_EXIT=1
VERDICT: FAIL — vendor export contract (installed workspace)
  - better-auth/adapters at better-auth@1.7.3 does not export resolveAccountIssuerForProvider
    — imported statically by @objectstack/plugin-auth. A static ESM named import of a
      missing export is a link-time SyntaxError: the package does not load at all.
post-restore blob hash: 1b1d1b5ed15cd67945e7d04331dee637d6e6129b
--- RESTORE PROVEN: hash matches HEAD blob, git diff HEAD empty ---

No rebuild leg is owed: this gate parses publishable source and resolves against
node_modules, so no dist/ sits between the mutation and the verdict.

⑤ The changeset carries its ADR-0087 disposition and the FROM → TO mapping.
The changeset carries the disposition marker naming sys-account-issuer-retired (spelled as the
HTML comment the gate reads; not reproduced here, because this body's sanitizer eats
angle-bracket fragments). The entry is added under
protocol major 18 and registry.ts plus both projections regenerated.

⚠️ Graded minor, not major. The dispatch card asked for a major arm;
check-changeset-no-major refuses a major in this launch window, and the live convention
carries breaking-ness with a BREAKING banner plus the ADR-0087 disposition. Flagged
rather than silently chosen.

Suites and gates

@objectstack/plugin-auth  test        106 files · 2243 tests · all passed
@objectstack/plugin-auth  typecheck   OK (+ check:test-typecheck)
@objectstack/cli          test        234 files · 3040 tests · all passed
@objectstack/dogfood      personas    1 file · 4 tests · all passed
typecheck  cli · client · platform-objects · spec · example-showcase — all Done

Gates run locally, all exit 0: check:nul-bytes · check:vendor-export-contract ·
check:adr-0087-registration · check:override-consistency · check:changeset-gate-self-tests ·
check:error-code-casing · check:doc-authoring · check:i18n · check:i18n-coverage ·
check:i18n-stale-fill · check:i18n-walk-parity · check:cli-command-ids ·
check:cli-examples-parity · check:test-source-alias · check:cross-package-test-inputs ·
check:engine-double-contract · check:dts-closure · check:published-readme-exports ·
check:pm-widening-tells · check:single-claim-paths · check:route-envelope ·
check:error-status-conformance · check:agent-test-spelling · check:pm-governed-prose ·
check:partof-closing-keyword, and in @objectstack/spec: check:migration-registry ·
check:spec-changes · check:upgrade-guide · check:api-surface · check:export-origins ·
check:exported-any · check:liveness · check:authorable-surface.

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derives 110
families for this change set and was re-derived after the diff grew (no new families). The
remainder is the repo-wide farm, which CI runs exactly once — a declared narrowing, not an
omission.

⚠️ check:nul-bytes caught a real defect of mine mid-run: two raw NUL bytes in the
pre-flight's composite map key, an escape materialised into the byte while the file was
being written. The key is now JSON.stringify([providerId, accountId]) — no delimiter
ambiguity and no control byte at all.

A neighbouring behaviour change the family lift brought with it

better-auth 1.7.3 added the #10700 gate one layer above ours: /two-factor/enable throws
TOTP_ALREADY_ENABLED when a two-factor row exists with verified !== false. Measured:
that code appears in 0 files in 1.7.2 and 5 in 1.7.3, against a control code present
in both (5 / 5).

Upstream's gate READS verified — the exact field #10700 was about — so
two-factor-reenrollment-verified-reset.ts is what keeps that gate's input truthful. ⛔ It
is not dead code superseded by the vendor.
The re-enrollment legs now assert the upstream
refusal envelope plus the property behind it (nothing rotated behind the refusal); the
inertness assertion moves to the unconfirmed path, which is the one upstream's gate still
admits; and rotation moves to disable → enable → confirm.


Merged with main — the conflict that was running zero CI

This PR sat conflicting, and a conflicting PR runs nothing: mergeable=false,
mergeable_state=dirty, 0 workflow runs on its head. Its check list was the
previous head's and said nothing about this one. Two census artefacts conflicted,
both generated — content/docs/permissions/tenant-audit-census.mdx and
docs/audits/2026-08-tenant-audit-write-call-sites.counts.md. main's 2a79726ac
(#17436, verified here rather than taken on trust) had independently re-run the
same census, so both sides rewrote the same Measured on line and the same
corpus-scale table.

The order is fixed and it is not the obvious one — regenerating while the
tree is still in MERGE state rolls a generated anchor back to the branch's old
fork point, and a rolled-back artefact is still authentic, so every gate passes
while a landed advance is quietly undone. scripts/pm/os-regen-merge.sh
mechanises the right order and was used:

  1. bash scripts/pm/os-regen-merge.sh — fetched, merged, and stopped exactly
    where it should: neither conflicted path is routed to the merge=os-regen
    driver, and the script refuses to resolve non-generated files on your behalf.
    ⭐ Measured before resolving anything: of the six paths changed on both
    sides of this merge, zero are os-regen paths — so the driver's silent-drop
    hazard did not apply here and the script's step 2 had no work to do. ⛔ It was
    deliberately not re-run after the merge commit: its own header says the
    base must be read BEFORE step 1, and afterwards git merge-base HEAD origin/main is main's own tip, which makes step 2 inert for the wrong reason.
  2. The conflicted block was resolved to main's side and committed as the merge
    — a placeholder, and the commit message says so, because a census block is an
    answer to a tree and the merged tree is neither side's.
  3. node scripts/tenant-audit-census.mjs --write, on the committed merge.
  4. Every prose figure re-derived from that census.

Re-derived, never carried forward

⛔ No figure below was copied from a CI log, from the pre-merge branch, or from
main. A script imported the gate's own PROSE_COUNTS, applied the same
splitPage plus whitespace normalisation the gate applies — the page is
hard-wrapped at 80 columns, so an un-normalised match is a false NO MATCH, which
is how six rows first read as missing — and evaluated expected(census) against
the page for all 23 enforced rows:

enforced rows: 23, failures: 0

Only corpus scale moved: engine-shaped types recognised 58 to 59, plus the
dated marker. sources scanned 562, declared objects 300 and non-engine calls 137 arrived with main's re-run and the merged tree reproduces all three.
The population held exactly still — 222 write call sites, 148 decidable, 9
provable-and-tenancy-enabled, 32 unreadable — which is why no enforced prose
figure needed an edit. That is a measurement, not an assumption.

⚠️ The claims that ride on a figure without quoting it — the class no gate can
see — were re-checked against the same census:

  • 44 of 222 reached through an erased receiver is 19.8%, and a fifth of 222
    is 44.4, so "just under a fifth" still holds. main's side of that sentence
    reads "better than a fifth" at 45 of 222: true for main's tree, false for
    the merged one. The auto-merge kept the branch's corrected wording, and this is
    the reading that confirms it.
  • 104 of 222 decidably elevated is 46.9%, so the quoted (47%) still rounds
    true. The gate captures the count and the population out of that sentence and
    leaves the per cent unread.
  • Across 300 declared objects is the one UNENFORCED prose figure — required to
    be present, never compared. It came in from main's side and the regenerated
    scale row agrees with it.

node scripts/check-tenant-audit-census.mjs and its --self-test both exit 0 on
the merged tree.


The ten version stamps the 1.7.3 lift falsified

check:vendor-version-stamps was red, and it is this PR's own doing — the same
gate exits 0 on an unmodified main checkout. CI had not reported it because the
lint job fail-fasts on the census check, roughly 900 lines earlier.

Not a 1.7.2 to 1.7.3 substitution. The gate's own reason: a stamp
attests that a behaviour was MEASURED against the version it names, so changing
the number without redoing the measurement manufactures a claim nobody made,
which is worse than a stale one. The ten sites were judged one at a time, and
they split 7 / 3.

Route (a) — re-measured against the installed 1.7.3, then restamped AND dated

Seven sites whose claim is a static reading of the vendor's published files.
Cheap to take again and worth taking, because a family lift is precisely the
event that could invalidate one. All seven came back unchanged:

site what was re-read at 1.7.3
packages/cli/src/commands/init.ts:178 @better-auth/scim@1.7.3 still peers @better-auth/utils@0.4.2 exactly, off the installed manifest
packages/cli/src/commands/init.ts:509 nothing in better-auth 1.7.3's published files names better-sqlite3 except its own peer declaration
packages/plugins/plugin-auth/src/auth-schema-config.ts:954 SCIMOptions still declares no schema / modelName / fields — the same six members
packages/plugins/plugin-auth/src/list-user-invitations-verification.ts:11 crud-invites.mjs still asks the helper on the three id-addressed routes and still throws unconditionally in listUserInvitations, so the defect this file repairs is still minted upstream
packages/plugins/plugin-auth/src/auth-email-locale.test.ts:869 /sign-in/magic-link still sends with no user lookup; /magic-link/verify still creates the user unless disableSignUp
packages/plugins/plugin-auth/src/auth-email-locale.test.ts:1001 signInMagicLinkBodySchema is still z.email() with no case transform; findUserByEmail still matches on email.toLowerCase()
packages/plugins/plugin-auth/src/auth-manager.ts:3888 db/adapter-base.mjs still builds memoryDB from Object.keys(tables) — the schema KEY — while @better-auth/memory-adapter still resolves by model name and throws

⚠️ One of them was also made more accurate rather than merely restamped:
init.ts:509 said better-sqlite3 is referenced by "no file in the published
package at all"
, and package.json is a file that references it. It now reads
except that peer declaration itself.

Route (b) — anchored, deliberately NOT restamped

Three sites whose reading came from a drive, not from a file. Restamping
these would assert a drive nobody re-ran.

site why anchoring is the honest route
packages/client/src/index.ts:3656 measured over a real AuthManager plus SqlDriver. Anchored to the date and card that took it (2026-09-09, #16761), scoped to "the then-installed 1.7.2", and the sentence now says out loud that the drive has not been re-run against the lifted family
packages/plugins/plugin-auth/src/scim-connection-service.ts:55 ⭐ the reading is an ablation of a REJECTED designenterWith losing the store. Re-measuring would mean re-breaking the scope to watch it fail again. Anchored to 2026-09-02 / #14624, and the sentence now points at scim-transaction-scope.test.ts, which pins the SHIPPED behaviour at run time against whatever version is installed
packages/plugins/plugin-auth/src/account-issuer-upgrade-path.test.ts:27 the version named is the pre-upgrade runtime this fixture models. 1.7.2 is not installed any more and cannot be — that is the premise of the whole file — so it is scoped and dated. ⛔ Not "then-installed": 1.7.3 was already installed when this file was written, so the honest scope is that the reading came off the derivation this branch retires

Proof that the repair changed prose and not behaviour

Seven of the eight touched files are provably comment-only: each was
transpiled with removeComments at HEAD and at the working copy, and the emitted
JS hashes are equal — with a const to let control on every file proving the
instrument can say no, so "identical" is not a vacuous verdict. ⛔ A raw scanner
is not sound for this question (template literals and regex-versus-division
need parser context); the first attempt using one reported three false
differences before it was replaced with a real parse and emit.

packages/cli/src/commands/init.ts is the exception by design — its stamp
lives in string literals the scaffold writes into a user's project, so it is a
real change to emitted content. Its scaffold tests were therefore run:

Test Files  3 passed (3)     Tests  62 passed (62)
  test/init.test.ts · test/scaffold-workspace-consistency.test.ts
  test/better-sqlite3-peer-declaration.pin.test.ts

Gates, at the commit that carries them

Union re-run after the final commit, a760606a6, all exiting 0:
check-tenant-audit-census and its --self-test · check:vendor-version-stamps
(self-test 64 checks, then 6980 files scanned) · check:nul-bytes ·
check:doc-authoring · check:corpus-claim-drift · check:pm-governed-prose ·
check:scaffold-emission-policy · check:cli-examples-parity ·
check:type-check-coverage · check:type-check-debt.

Repo-wide pnpm lint exits 0 (24s — not narrowed, so no narrowing needs
declaring). typecheck green on @objectstack/cli, @objectstack/client and
@objectstack/plugin-auth, over freshly built dependency closures.

⚠️ Declared narrowing, same as the section above: these runs were UNLOCKED.
scripts/pm/os-verify-lock.sh reports NO USABLE flock on this host — the
shared verify lock is Linux-only — so every heavy command was routed through the
entry point and ran in its declared unlocked mode, each printing
VERDICT command-exit 0 · UNLOCKED (declared). No serialization guarantee held.

⚠️ A correction that cannot be made in place: the commit message for the
stamp repairs heads route (a) with "six sites" and then lists seven. The split is
7 / 3, as the tables above show. Pushed history is not rewritten on this
branch, so the correction lives here.

Filed, not fixed

#17453 — three knownGap texts in docs/qa/platform-checklist/areas/approvals.json cite
the now-retired backfill-account-issuer.ts. Their own convention is the gap text stays
because it carries the reason
, so the right rewrite is a judgment call about historical
record rather than a path substitution. No gate is red on it.

🤖 Generated with Claude Code


Generated by Claude Code

hotlong and others added 12 commits September 10, 2026 22:24
better-auth 1.7.3 removed the issuer-scoped account identity outright
(better-auth/better-auth#10909). #16186 held the family at an exact 1.7.2
as a stopgap; this is the durable half — the family moves as ONE line,
since @better-auth/core@1.7.2 and @better-auth/kysely-adapter@1.7.3 are
mutually incompatible in both directions.

The exact-target rule is unchanged and stays exact for the reason 1.7.3
itself demonstrated: this vendor deletes public exports in patch releases.

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e-point guard

The preflight reads sys_account ROWS (never the index declaration) and
refuses when a (provider_id, account_id) key is held by more than one
row -- the class that is legal under the retired (issuer, account_id)
key and is ONE account under the key better-auth 1.7.3 restored.

Two reads that are NOT reported as clean: one that throws, and a walk
that truncates. The retired backfill swallowed both; correct there,
wrong for an answer that authorises an irreversible drop.

The re-point guard answers the one case issuer still discriminated: a
provider_id re-pointed at another IdP must have its account bindings
rebuilt, because no column records which IdP vouched for a row.

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…point answer

15 cases. Every refusal asserts the ADR-0112 envelope (code + status) and
the substance of the message -- a bare toThrow() would pass on a fixture
that never reached the probe.

The collision fixture registers an INDEX-LESS sys_account on purpose: the
PREMISE case proves the class cannot be inserted where the long-declared
(provider_id, account_id) UNIQUE is physically present, so the only
population that can hold it is a deployment carrying the declaration
without the constraint (#14902 / #15479).

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…table

The read-only pre-flight leg ADR-0120 D4 requires on a NARROWING index
change, plus a refusal in os migrate apply that sits BELOW the report and
ABOVE both writes -- so the column drop cannot proceed on a database
holding the collision class.

No second ceremony: plan/backup/apply/post-check and the boot refusal all
already exist for a column drop (os migrate plan, the operator's backup,
os migrate apply --allow-destructive, runArtifactBootMigrationGate). Only
the row-level pre-flight was missing.

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… gate

1.7.3 added the #10700 gate one layer above ours: /two-factor/enable now
throws TOTP_ALREADY_ENABLED when a two-factor row exists with
verified !== false. Measured: 0 files carry that code in 1.7.2, 5 in
1.7.3, against a control code present in both.

The re-enrollment legs now assert the upstream refusal envelope plus the
property behind it (nothing rotated behind the refusal); the #10700
inertness assertion moves to the unconfirmed path, which is the one
upstream's gate still admits; rotation moves to disable -> enable ->
confirm, carrying the cookie disable installs.

Upstream's gate READS verified -- the field #10700 was about -- so
two-factor-reenrollment-verified-reset.ts keeps that gate's input
truthful and is NOT dead code superseded by the vendor.

Also drops the retired issuer mapping from the two account-config pins.

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four generated translation bundles lose the sys_account.issuer label
(regenerated via pnpm i18n:extract, 4 files x 4 lines). The client's
accounts.list type drops the field the route no longer returns. The
showcase seed drops the issuer derivation, its failure branch and the
whole silent-lockout class behind it.

The dogfood personas test keeps its file and its real-HTTP-sign-in half;
the issuer assertion is REPLACED by the key sign-in now resolves on,
with the header recording verbatim what the old assertion said and why
it went away. It additionally asserts the retired column is ABSENT.

platform-keyed-text-bounds's live illustration moves to a MEASURED
surviving member of the >768 unique class.

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…LIVE edge

The retired specimen was @better-auth/core/db naming
createLocalAccountIssuer -- the #16186 defect -- and the case carried the
instruction 'if the durable fix landed, retire this case with it'. It has
landed.

Retiring the SPECIMEN is not retiring the case: what it catches is a
collector that has silently stopped reaching publishable source, which is
how #16186 passed over nothing for three releases. So it re-anchors on
better-auth/adapters -> createAdapterFactory and still asserts a NAMED
symbol, and a new case asserts the two deleted names are imported nowhere.

⛔ The gate is not loosened: it still requires an exact declared range,
agreement with the installed version, and real resolution of every named
symbol.

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…etirement

Registers sys-account-issuer-retired under protocol major 18 and
regenerates registry.ts and both projections. The changeset carries the
BREAKING banner, the adr-0087 disposition marker and the FROM -> TO table,
and is graded minor under the launch-window convention
(check-changeset-no-major refuses major).

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ator

check:nul-bytes caught two raw NUL bytes in the preflight's composite map
key -- an escape materialised into the real byte while the file was being
written, which is the exact slip that gate's header documents. A raw NUL
renders as NOTHING, so a load-bearing separator reads in grep and in
review as an empty string.

JSON.stringify([providerId, accountId]) has no delimiter ambiguity and no
control byte at all.

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…l database

Two engines over one SQLite file: engine A declares issuer (the
pre-upgrade shape) and a real AuthManager signs a user up through the real
HTTP route so the hash is better-auth's own, then the row is stamped the
way a 1.7.2 runtime stamped it; engine B on the SAME file registers
today's objects -- new code, old table.

Four cases: the legacy account still signs in over the real auth route
(judged by the principal the session resolves to, never by a status);
schema sync does NOT silently drop the undeclared column, so the drop
stays the operator's deliberate act; the pre-flight reads clean, which is
what authorises it; and sign-in still works once the column is gone.

Both PRAGMA reads carry a control -- not.toContain passes vacuously on an
empty array, which is the one reading this must never produce by accident.

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ription

check:doc-authoring: a command description reaches operators, who have no
tracker to resolve #NNNN against. The id moves to an adjacent comment,
where the reader who can resolve it already is.

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 10, 2026
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/cli, @objectstack/client, @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/spec, touching 61 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/cli/src/index.ts, packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/index.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

46 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 3ef96b47120a989d9333aeeeb6181bc371bc9b52.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 changed file(s) yielded no anchor (packages/cli/src/index.ts, packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/index.ts, …) — pages documenting those are invisible to this run
  • 24 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 144 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3ef96b47120a989d9333aeeeb6181bc371bc9b52packageMentionDocs.

Which tree this was computed on

This run read content/docs from 126b1642372fad6193f6c805fbb39858bd355d4b — the merge of head b57c135c0bd26432649bdabf9727bd13d9f6c726 into base 3ef96b47120a989d9333aeeeb6181bc371bc9b52, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 126b1642372fad6193f6c805fbb39858bd355d4b && git checkout 126b1642372fad6193f6c805fbb39858bd355d4b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3ef96b47120a989d9333aeeeb6181bc371bc9b52 b57c135c0bd26432649bdabf9727bd13d9f6c726 && git checkout -B drift-repro 3ef96b47120a989d9333aeeeb6181bc371bc9b52 && git merge --no-ff b57c135c0bd26432649bdabf9727bd13d9f6c726

node scripts/docs-audit/affected-docs.mjs --json 3ef96b47120a989d9333aeeeb6181bc371bc9b52

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 3ef96b47120a989d9333aeeeb6181bc371bc9b52 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…was deleted

Retiring `backfill-account-issuer.ts` removed one of the write call sites the
tenant-audit census counts, so both committed artefacts and every hand-written
figure quoting them went one out of date. `check-tenant-audit-census` caught it
on both arms: `[census-drift]` on the two generated regions and `[prose-count]`
on ten quoted numbers.

Regenerated with the gate's own repair path, `node scripts/tenant-audit-census.mjs
--write`, then re-derived the prose from that census rather than transcribing the
CI log: a small script evaluated the gate's own `PROSE_COUNTS[].expected(census)`
against the regenerated page and printed page-vs-census for all 23 rows, which is
what the eight replacements were taken from. 223 -> 222 write call sites, 149 ->
148 statically decidable, 105 -> 104 decidably elevated, 45 -> 44 reached through
an erased receiver.

One claim rides on a number rather than quoting it, and the gate cannot see it:
44/222 is 19.8%, so the erased-receiver share is no longer "better than a fifth"
— it now reads "just under a fifth". Leaving it would be the same failure in
prose that the gate catches in figures.

Nothing else is touched: the two census artefacts are the whole diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hotlong and others added 4 commits September 11, 2026 09:47
Both sides independently re-ran the tenant-audit census, so the two census
artefacts conflicted on exactly the block a re-run rewrites: the `Measured on`
line and the corpus-scale table. Resolved by taking main's side of that block on
both files, which is a placeholder, not an answer — the census is regenerated in
the next commit and every prose figure re-derived from it.

Everything else merged without conflict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The merge resolved the census conflict with a placeholder — main's side of the
`Measured on` line and the corpus-scale table — because a census block is an
answer to a tree, and the merged tree is neither side's. This is the answer.

Regenerated with the gate's own repair path, `node scripts/tenant-audit-census.mjs
--write`. What moved is corpus scale only: engine-shaped types 58 -> 59, and the
dated marker to the merged tree. `sources scanned` (562), `declared objects`
(300) and `non-engine calls` (137) came in from main's re-run and the merged
tree reproduces them.

⭐ Every prose figure was RE-DERIVED from this census rather than carried
forward. A script imported the gate's own `PROSE_COUNTS`, applied the same
`splitPage` + whitespace normalisation the gate applies, and evaluated
`expected(census)` against the page for all 23 rows: 23 ok, 0 failures. The one
unenforced prose figure, `Across 300 declared objects`, matches the regenerated
scale row — it arrived from main's side of the auto-merge and the merged tree
agrees with it.

The two claims that ride on a figure without quoting it, which no gate can see,
were re-checked against the same census:

  44 of 222 reached through an erased receiver = 19.8%, and a fifth of 222 is
  44.4 — so "just under a fifth" still holds. (main's side said "better than a
  fifth" at 45 of 222; that is main's number, not the merged tree's.)

  104 of 222 decidably elevated = 46.9%, so the quoted "(47%)" still rounds
  true. The gate captures the count and the population from that sentence but
  leaves the per cent unread.

The population held exactly still across the merge — 222 write call sites, 148
decidable, 9 provable-and-tenancy-enabled, 32 unreadable — so no prose figure
needed editing. That is a measurement, not an assumption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`check:vendor-version-stamps` went red on this branch and nowhere else — the
same gate exits 0 on an unmodified `main` checkout, so the ten drifted stamps
are this PR's own doing. CI has not said so yet only because the lint job
fail-fasts on the census check ~900 lines earlier.

⛔ Not a 1.7.2 -> 1.7.3 substitution. The gate's own words: a stamp attests that
a behaviour was MEASURED against the version it names, and changing the number
without redoing the measurement manufactures a claim nobody made. Each site was
judged on its own, and the ten do NOT take the same route.

RE-MEASURED against the installed 1.7.3, then restamped AND dated (route a) —
six sites whose claim is a STATIC reading of the vendor's published files, which
is cheap to take again and worth taking, because the lift is exactly the event
that could have invalidated it. Every one came back unchanged:

  cli/src/commands/init.ts:178 — `@better-auth/scim@1.7.3` still peers
  `@better-auth/utils@0.4.2` exactly (read off the installed manifest).

  cli/src/commands/init.ts:509 — nothing in better-auth 1.7.3's published files
  references better-sqlite3 except its own peer declaration. The sentence is
  also made precise: it used to say "no file … at all", and package.json is a
  file that does reference it.

  plugin-auth/src/auth-schema-config.ts:954 — `SCIMOptions` at 1.7.3 still
  declares no `schema` / `modelName` / `fields`; its six members are unchanged.

  plugin-auth/src/list-user-invitations-verification.ts:11 — 1.7.3's
  `crud-invites.mjs` still asks `shouldRequireVerifiedEmailForInvitationIdAction`
  on the three id-addressed routes and still throws unconditionally in
  `listUserInvitations`. The defect this file repairs is still minted upstream.

  plugin-auth/src/auth-email-locale.test.ts:869 and :1001 — at 1.7.3
  `/sign-in/magic-link` still sends with no user lookup, `/magic-link/verify`
  still creates the user unless `disableSignUp`, `signInMagicLinkBodySchema` is
  still `z.email()` with no case transform, and `findUserByEmail` still matches
  on `email.toLowerCase()`.

  plugin-auth/src/auth-manager.ts:3888 — 1.7.3's `db/adapter-base.mjs` still
  builds `memoryDB` from `Object.keys(tables)` (the schema KEY) while
  `@better-auth/memory-adapter` still throws on a lookup by resolved model name.

ANCHORED, deliberately NOT restamped (route b) — three sites whose reading came
from a DRIVE, not from a file. Restamping them would claim a drive that was
never re-run:

  client/src/index.ts:3656 — measured over a real `AuthManager` + `SqlDriver`;
  anchored to the date and card that took it (2026-09-09, #16761) and scoped to
  "the then-installed 1.7.2", with the non-re-run said out loud.

  plugin-auth/src/scim-connection-service.ts:55 — the reading is an ABLATION of
  a REJECTED design (`enterWith` losing the store). Re-running it would mean
  re-breaking the scope to watch it fail. Anchored to 2026-09-02 / #14624, and
  the sentence now points at `scim-transaction-scope.test.ts`, which pins the
  SHIPPED behaviour at run time against whatever version is installed.

  plugin-auth/src/account-issuer-upgrade-path.test.ts:27 — the version named is
  the PRE-upgrade runtime this fixture models. 1.7.2 is not installed any more
  and cannot be, which is the premise of the whole file, so it is scoped and
  dated rather than re-measured. ⛔ Not "then-installed": 1.7.3 was already
  installed when this file was written; the honest scope is that the reading
  came off the derivation this branch retires.

VERIFIED. `pnpm check:vendor-version-stamps` exits 0 (self-test 64 checks, then
6980 files scanned). Seven of the eight files are provably COMMENT-ONLY: each
was transpiled with `removeComments` at HEAD and at the working copy and the
emitted JS hashes are equal, with a `const`->`let` control proving the
instrument can say no. `init.ts` is the exception BY DESIGN — its stamp lives in
string literals the scaffold writes into a user's project — so its scaffold
tests were run: 3 files / 62 tests passed. Repo-wide `pnpm lint` exits 0;
typecheck green on cli, client and plugin-auth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…leted parity test

`account-issuer-parity.test.ts` retired with `sys_account.issuer`, so its
grandfathered `blind: 1` entry now names a file that is gone and
`check:objectql-double-limit` refuses it by name:

  packages/plugins/plugin-auth/src/account-issuer-parity.test.ts: baselined
  file is clean or gone -- ratchet DOWN: delete its entry.

Deleting the entry is the only legitimate direction here: it shrinks the
grandfathered set by one. Nothing is added and nothing is loosened; the gate
re-reports 251 grandfathered files where it read 252, "none new", and the
baseline key set still verifies against the merge base.

⭐ Found by running the whole `Lint & Repo Gates` step sequence locally rather
than the one gate CI named. The lint job fail-fasts, so each CI round has
revealed exactly one stale ledger and hidden the rest -- this is the third such
notch on this branch (tenant-audit census, then vendor version stamps, now this
ratchet). All 162 `run:` steps of the job were executed in workflow order,
continuing past every failure. This was the only gate failing for a reason this
PR owns.

The five remaining red steps are host artifacts, not diff artifacts: the only
bash on this machine is 3.2.57, and each of the five needs a bash >= 4 builtin
or a shell that can simulate 3.2 by removing one. Proven by running the same
five blocks, extracted from main's own lint.yml, in a detached worktree at an
unmodified origin/main (3ef96b4) -- same exit codes, byte-identical failing
assertions. This PR changes no `.sh` file, no workflow and no hook, and the
bash-3.2 gate's real-tree scan -- the half a diff could move -- is green with
0 findings on both trees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Adopt better-auth's account-issuer rollback: drop sys_account.issuer, retire the backfill, raise the family to 1.7.3

1 participant