Skip to content

chore(deps)!: better-auth 1.7.0-rc.2 + the production-dependency batch (#3517) - #3632

Merged
os-zhuang merged 1 commit into
mainfrom
claude/pr-3517-better-auth-upgrade-x1swcg
Jul 27, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/pr-3517-better-auth-upgrade-x1swcg

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Replaces dependabot #3517 (its base is ~40 commits stale, and its three red checks are all fixed here) and additionally carries the better-auth family from 1.7.0-rc.1 to 1.7.0-rc.2.

Why #3517 was red

Check Cause Status here
Validate Package Dependencies OSV found 10 advisories (react-router, sharp, @sveltejs/kit, brace-expansion, @hono/node-server) Fixed on main by #3614; OSV-Scanner is clean on the regenerated lockfile
Check Changeset Dependabot adds none Changeset added
Scaffold with repo dist docker.io i/o timeout pulling node:22-slim Infra flake, not a code failure

The dependency batch

@oclif/core 4.13.0, @hono/node-server 2.0.12, hono 4.12.32, tar 7.5.22, jose 6.2.4, pinyin-pro 3.28.2, plus the private docs app's fumadocs 16.12.1 / next 16.2.12 / react 19.2.8 / lucide-react 1.27.0. js-yaml was already at 5.2.2 on main.

BREAKING — better-auth 1.7.0-rc.2 account identity

rc.2 renames account.accountId to account.providerAccountId, adds a required account.issuer, and resolves every account by (issuer, providerAccountId). An unmapped or unstamped issuer is not a type error — it is a silent "no such account" on every sign-in.

  • AUTH_ACCOUNT_CONFIG maps providerAccountId onto the existing account_id column (same value, renamed upstream — no data moves) and adds issuer.
  • sys_account gains the issuer column, plus the unique (issuer, account_id) index better-auth declares on its own table. The column is deliberately nullable: a NOT NULL column cannot be added to a table that already holds rows, and schema sync runs before the backfill.
  • internalAdapter.createAccount() now passes issuer + providerAccountId; credential accounts carry better-auth's own local:credential.
  • /unlink-account narrowed to { accountId }, where accountId is the account row id — the sys_account row action and client.auth.accounts.unlink() follow, and accounts.list() now returns issuer + providerAccountId.

Upgrading an existing deployment

Rows written before 1.7 have no issuer and are invisible to sign-in, so the auth plugin runs an idempotent boot-time backfill (backfillAccountIssuer, also exported for hosts that migrate on their own schedule). It stamps what it can derive:

Account Issuer
password local:credential
configured social provider local:oauth:<providerId>
registered SSO provider the IdP's real iss from sys_sso_provider

Accounts from a federated IdP that is no longer registered cannot be derived. They are logged with provider id and row count rather than guessed — a wrong issuer is indistinguishable from a missing one at sign-in, and it also occupies the unique slot the correct row needs. Those users sign in again through that provider once the row is stamped with the IdP's issuer or removed so a fresh login re-links it.

Also required by 1.7

SecondaryStorage gained two mandatory methods, both now implemented over the kernel cache service: getAndDelete (single-use verification values) and increment (fixed-window rate-limit counter — rateLimit.storage: 'secondary-storage' throws at boot without it). Neither cache primitive is atomic; each carries a comment stating the exact race it leaves open and that an adapter with GETDEL/INCR closes both.

@better-auth/scim stays at 1.7.0-rc.1

rc.2 replaces the SCIM plugin wholesale: code-defined connections + static credentials instead of runtime-generated tokens, the scimProvider model and generate-token endpoint gone, six new models (scimUser, scimGroup, scimGroupMember, scimSubject, scimConnectionBinding, scimIdentityTombstone). Adopting it means new platform objects, retiring sys_scim_provider, and a new way for a tenant to register a connection — an ADR-0071 feature migration, not a version bump. rc.1's peer range accepts rc.2 core and it still carries the GHSA-j8v8-g9cx-5qf4 fix. Flagged in pnpm-workspace.yaml so nobody "aligns" it by reflex.

Verification

  • pnpm test — 132/132 turbo tasks green; pnpm build green; eslint clean; tsc --noEmit clean.
  • OSV-Scanner v2.3.8 on the regenerated lockfile: no issues found. check-override-consistency and check-changeset-fixed pass.
  • Real boot (showcase stack, in-memory SQLite): sign-up writes issuer on sys_account, sign-in resolves the account, and the backfill stamps a legacy issuer-less row through the real ObjectQL engine. The existing OIDC authorization-code dogfood flow (authorize → code → token) passes against rc.2.
  • New unit coverage for the backfill (derivation, non-guessing, idempotence, failure reporting) and for both new SecondaryStorage methods (fixed window not extended, restart after expiry, corrupt-envelope recovery).

Generated by Claude Code

#3517)

Takes over dependabot #3517 (rebased onto current main, where js-yaml/tar/next
already moved) and carries the better-auth family from 1.7.0-rc.1 to
1.7.0-rc.2.

rc.2 restructures account identity, which is a breaking change for this repo:
`account.accountId` is now `account.providerAccountId`, `account.issuer` is
required, and every lookup keys on (issuer, providerAccountId).

  - AUTH_ACCOUNT_CONFIG maps `providerAccountId` onto the existing `account_id`
    column and adds `issuer`; `sys_account` gains the column plus the unique
    (issuer, account_id) index better-auth declares on its own table.
  - internalAdapter.createAccount() now supplies `issuer` +
    `providerAccountId`; credential accounts carry better-auth's own
    `local:credential`.
  - /unlink-account narrowed to `{ accountId }` = the account ROW id, so the
    sys_account action and the client SDK follow.
  - New boot-time backfill stamps `issuer` on pre-1.7 rows — `local:credential`
    for passwords, `local:oauth:<provider>` for configured social providers,
    the registered IdP's `iss` from sys_sso_provider for federated ones. What
    it cannot derive it reports rather than guesses, because a wrong issuer is
    indistinguishable from a missing one at sign-in.

SecondaryStorage also gained two required methods; both are now implemented
over the kernel cache service: `getAndDelete` (single-use verification values)
and `increment` (fixed-window rate-limit counter — secondary-storage rate
limiting throws at boot without it).

@better-auth/scim stays at 1.7.0-rc.1: rc.2 replaces the plugin wholesale
(code-defined connections; six new models; no generate-token endpoint), which
is an ADR-0071 feature migration rather than a version bump. Its peer range
accepts rc.2 core and it still carries the GHSA-j8v8-g9cx-5qf4 fix.

Verified: full suite (132 turbo tasks) green, eslint clean, OSV-Scanner clean
on the regenerated lockfile, and a real showcase boot proves better-auth writes
`issuer` on sign-up, that sign-in resolves the account, and that the backfill
stamps a legacy row through the real ObjectQL engine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WmLE2YnDZKh1jXy5KFFE3e
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 27, 2026 11:14am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file tests tooling size/l labels Jul 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 8 package(s): packages/adapters, @objectstack/cli, @objectstack/client, create-objectstack, @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/plugin-hono-server, @objectstack/plugin-pinyin-search.

29 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx (via packages/cli, packages/client, create-objectstack)
  • content/docs/ai/skills.mdx (via create-objectstack)
  • content/docs/api/client-sdk.mdx (via @objectstack/cli, @objectstack/client)
  • content/docs/api/data-flow.mdx (via @objectstack/cli, @objectstack/client)
  • content/docs/api/environment-routing.mdx (via @objectstack/cli, @objectstack/client)
  • content/docs/api/error-catalog.mdx (via @objectstack/cli, @objectstack/client)
  • content/docs/automation/hook-bodies.mdx (via packages/cli)
  • content/docs/data-modeling/queries.mdx (via @objectstack/plugin-pinyin-search)
  • content/docs/deployment/backup-restore.mdx (via @objectstack/cli)
  • content/docs/deployment/cli.mdx (via @objectstack/cli, @objectstack/plugin-auth)
  • content/docs/deployment/environment-variables.mdx (via @objectstack/plugin-pinyin-search)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/cli, create-objectstack)
  • content/docs/getting-started/index.mdx (via create-objectstack)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/cli, @objectstack/client, create-objectstack, @objectstack/plugin-hono-server)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/cli, packages/client)
  • content/docs/kernel/runtime-services/index.mdx (via packages/cli, packages/client)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx (via @objectstack/cli, @objectstack/client, @objectstack/plugin-auth, @objectstack/plugin-hono-server)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth, @objectstack/plugin-hono-server)
  • content/docs/plugins/packages.mdx (via packages/adapters, @objectstack/cli, @objectstack/client, create-objectstack, @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/plugin-hono-server)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/cli, @objectstack/client)
  • content/docs/releases/implementation-status.mdx (via @objectstack/cli, @objectstack/client, @objectstack/plugin-auth, @objectstack/plugin-hono-server)
  • content/docs/releases/v15.mdx (via create-objectstack, @objectstack/plugin-pinyin-search)
  • content/docs/releases/v16.mdx (via @objectstack/cli, @objectstack/client, @objectstack/plugin-hono-server)
  • content/docs/releases/v9.mdx (via create-objectstack, @objectstack/plugin-auth)
  • content/docs/ui/setup-app.mdx (via @objectstack/platform-objects)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang marked this pull request as ready for review July 27, 2026 11:20
@os-zhuang
os-zhuang merged commit 9f060e5 into main Jul 27, 2026
20 checks passed
@os-zhuang
os-zhuang deleted the claude/pr-3517-better-auth-upgrade-x1swcg branch July 27, 2026 11:20
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…ccount.issuer, retire the backfill, lift the family to 1.7.3 (objectstack-ai#17454)

Fixes objectstack-ai#17440

Maintainer ruling 2026-09-10 on objectstack-ai#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
(objectstack-ai#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 (objectstack-ai#14902 / objectstack-ai#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
objectstack-ai#16186 passed over nothing for three releases. So it re-anchors on a
live edge
(`better-auth/adapters` → `createAdapterFactory`) and still asserts a
**named** symbol, and
a new case asserts the two deleted names are imported nowhere.

## Out of scope, untouched

⛔ objectstack-ai#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.**

```
✓ objectstack-ai#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: 1b1d1b5
--- 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: 1b1d1b5
--- 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 objectstack-ai#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 objectstack-ai#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`
(objectstack-ai#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, objectstack-ai#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 design** — `enterWith` losing
the store. Re-measuring would mean re-breaking the scope to watch it
fail again. Anchored to 2026-09-02 / objectstack-ai#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

objectstack-ai#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](https://claude.com/claude-code)

---

_Generated by [Claude
Code](https://claude.ai/code/session_6679d191-11f4-465b-b322-0e0409d76793)_

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants