Skip to content

test(plugin-auth): reconcile the schema-parity gate's plugin list against auth-manager.ts - #9693

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-8122-parity-gate-plugin-reconciliation
Aug 18, 2026
Merged

test(plugin-auth): reconcile the schema-parity gate's plugin list against auth-manager.ts#9693
os-project-manager merged 1 commit into
mainfrom
claude/issue-8122-parity-gate-plugin-reconciliation

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#8122

What this is, scoped precisely

A plugin-list reconciliation for the better-auth schema-parity gate. better-auth-schema-parity.test.ts derived better-auth's column surface from six plugin factories written out by hand and reconciled against nothing; its sibling managed-extension-fields.test.ts (ADR-0092 D7) has scanned auth-manager.ts for its await import(...) sites since #7820 and failed on any factory not accounted for. The parity gate never got that tripwire.

⚠️Nothing was miscovered, and this closes no live hole. The second describe block still covers sso/scim by reading each plugin's own declared schema; coverage was and is intact. What this replaces is luck — a hand-written list whose written justification could expire with every gate green. And the new assertion is a sample, not a class detector: it catches a factory arriving in auth-manager.ts unaccounted for. It does not catch a plugin loaded from anywhere else, and it is not a general detector for hand-written lists anywhere in the repo.

Premise re-verified on origin/main (c07d6e8b9) before editing

card's claimmeasured
the parity gate's list is still exactly 6yes — :121-131, organization / twoFactor / admin / phoneNumber / jwt / deviceAuthorization
D7 still has the construct or skip shape plus a reconciled scanyes — map :373, authManagerPluginFactories():490, reconciliation :668
the now-false file-header rationale is still there verbatimyes — :46

auth-manager.ts can assemble 15 names by that scan today (14 plugin factories plus hasPermission, which is not a factory). Per-factory model-surface contribution, measured against the installed packages rather than copied from the card's survey:

  • contribute models or columns: organization, twoFactor, admin, phoneNumber, jwt, deviceAuthorization (the existing six), plus sso, scim, oauthProvider
  • contribute no model and no column on the pinned version: bearer, haveIBeenPwned, magicLink, genericOAuth, customSession — the card's survey still holds

Part 1 — the accounting shape

AUTH_MANAGER_PLUGINS maps every factory to either a construct thunk or a written skip reason; the derived surface is built from that map, so coverage cannot be declared without being delivered. The new assertion fails on an unaccounted factory, on a stale entry, and on a skip with no reason.

The five no-model plugins are constructed, not skipped — a version bump that gives one of them a column is then compared against the platform object the day it lands, which is one of the two silent-opening paths the card names. Measured: they add no model and no field, so the derived tables are unchanged (same 12 models before and after).

sso / scim / oauthProvider / hasPermission are skip, each with its measured reason (see below for sso/scim; oauthProvider ships as its own package with the dedicated oauth-provider-schema-parity.test.ts; hasPermission is a permission predicate, not a factory).

The accounting map is deliberately NOT shared with D7 — the ruling asked whether it extracts cleanly, and measured, it does not. The two maps must differ twice over: in construction (this gate passes the schema: options from auth-schema-config.ts, D7 must not, because those carry our own ADR-0105 D8 additionalFields) and in disposition (sso / scim / oauthProvider are construct in D7 and skip here). Constructing sso here goes red on the mapping assertion, since getAuthTables() reports its model as ssoProvider, not sys_sso_provider. So the reconciliation is duplicated instead of abstracted, exactly as the ruling directed — and each copy carries its own stale half, so a scan blinded by an import-shape change fails in the file it blinded rather than only in the other one.

Part 2 — the now-false rationale, re-measured

The header said, verbatim:

@better-auth/sso / @better-auth/scim accept no schema option, so getAuthTables() cannot see them

Re-measured 2026-08-18 against the installed better-auth@1.7.0-rc.2, @better-auth/sso@1.7.0-rc.2, @better-auth/scim@1.7.0-rc.1#8224's measurement holds, and the sentence is false in a second way that #8224 did not record:

  1. sso accepts a schema option.SSOOptions.schema.ssoProvider.{modelName,fields,additionalFields} (index-D1yk91me.d.mts), honoured at runtime (index.mjs:4155 for modelName, :4160-4199 per-field fieldName, :4201additionalFields). Confirms [finding] @better-auth/sso now accepts a schema.ssoProvider option — the adapter-level ssoProvider model bridge predates that and may be replaceable #8224.
  2. scim still accepts none.SCIMOptions declares no schema / modelName / fields — that half of the old sentence survives for scim alone, so the correction is per-plugin, not wholesale.
  3. "getAuthTables() cannot see them" is false for BOTH. Passing sso() yields the ssoProvider model; scim({}) yields scimProvider plus four scimGroup* models. The library was never hiding them.

The real reason they stay out of that call: the auth manager passes them no schema option, so their models are bridged at the adapter layer (AUTH_MODEL_TO_PROTOCOL plus a mechanical camelCase to snake_case in objectql-adapter.ts). sso writes oidc_config; getAuthTables(), given no fields mapping, reports oidcConfig under a model name that is not a platform object name. Deriving them there would compare a column nothing writes against a table nothing provisions.

That corrected rationale is now pinned as an executable assertion, not just rewritten prose — the point of the card is that written surveys go stale, so the new test asserts each of the three facts above. A future version bump that moves any of them fails a test instead of quietly outdating a comment.

Reverse verification (the ablation is the acceptance for a gate)

Predicted before running: adding an unaccounted factory to auth-manager.ts (const { apiKey } = await import('better-auth/plugins/api-key');) turns the new reconciliation red with unaccounted: [apiKey], and leaves every pre-existing parity assertion green — exactly 1 failed / 23 passed.

Observed, at the final code:

× every plugin auth-manager.ts can load is accounted for here (#8122)
→ ...does not account for: apiKey: expected [ 'apiKey' ] to deeply equal []
Tests 1 failed | 23 passed (24)

Prediction met on both halves. The second half is the load-bearing one: all 23 assertions that existed before this change stayed green under the ablation, so nothing already in this file was catching it. Restored afterwards, marker count grep -c 'api-key' = 0, tree clean at the final commit.

Verification, at 6d5d930bf (the final commit)

  • pnpm --filter @objectstack/plugin-auth test — 55 files, 1263 passed
  • pnpm --filter @objectstack/plugin-auth typecheck — clean
  • gate union re-derived off git merge-base at the final commit and run: check:test-source-alias, check:type-source-resolution, check-affected-docs.mjs, plus the test-file conventions check:query-options-erasure, check:type-check-coverage, check:engine-double-contract, check:where-matcher, and check:nul-bytes / check:cross-package-test-inputs — all exit 0
  • check:type-check-debt (the ratchet) caught a real regression mid-work and it is fixed. plugin-auth excludes **/*.test.ts from tsc and carries a shrink-only TEST_DEBT entry of 111. Seeding the source scan from import.meta.url (D7's spelling) adds a TS1470 in this CJS-typed package: measured 111 at merge-base, 112 with that seed. The file now uses the package-root findUp idiom that rate-limit-storage-isolation.test.ts established here for this exact reason, and the count is back to 111 = recorded. Measured with an equivalent tsc project over the package (the full --re-measure needs the whole workspace built); it reproduces the ledger's recorded number exactly at merge-base, which is what makes the delta trustworthy.

Changeset

Deliberately none, with the skip-changeset label. The diff is a single *.test.ts file: not in tsup entries, not in the package's files, no runtime behaviour, no type surface, nothing published. There is nothing for a release note to describe.

Backlinks

#8224 is the independent measurement of the stale premise and remains open — this PR corrects the rationale only in better-auth-schema-parity.test.ts; the same claim survives in five other files, enumerated in a comment there. #7820 is where the D7 tripwire came from. #3624 / #3653 are the parity holes this gate exists for.

Generated by Claude Code


Generated by Claude Code

…inst auth-manager.ts
The parity gate derived better-auth's column surface from six plugin
factories written out by hand and reconciled against nothing, while its
sibling `managed-extension-fields.test.ts` (ADR-0092 D7) has scanned
`auth-manager.ts` for its `await import(...)` sites since #7820 and failed
on any factory not accounted for.
Give the parity gate the same accounting shape: `AUTH_MANAGER_PLUGINS`
maps every factory the auth manager can assemble to either a `construct`
thunk or a written `skip` reason, the derived surface is built from that
map, and an unaccounted or stale factory fails. Scope, stated plainly: it
catches a plugin arriving in `auth-manager.ts` unaccounted for. Nothing is
miscovered today and this closes no live hole.
The accounting map is deliberately not shared with D7 -- measured, the two
maps must differ in construction (this gate passes the `schema:` options
from `auth-schema-config.ts`, D7 must not) and in disposition (sso / scim /
oauthProvider are `construct` there, `skip` here). The reconciliation is
duplicated instead, each copy carrying its own staleness half.
Also correct the file header's now-false rationale for excluding
`@better-auth/sso` / `@better-auth/scim`. Re-measured against the pinned
packages: both declare a schema `getAuthTables()` reads, and sso accepts a
`schema` option (`SSOOptions.schema.ssoProvider`). The real reason is that
the auth manager passes them no such option, so their columns are decided
by the adapter bridge -- pinned now as an executable assertion rather than
prose, so it cannot expire silently again.
Fixes#8122
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

@os-project-managerClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — accepted with no open questions. Flipping to ready and arming auto-merge.

Three things here are better than what the card asked for, and one of them is the reason I am not going to let this be remembered as a small test-file PR.

⭐ Constructing the five no-model plugins instead of skipping them

The card's two silent-opening paths were "a plugin added to auth-manager.ts" and "a version bump giving one of the five no-model plugins a column." An accounting map with five skip: 'contributes no model surface' entries would have closed the first and left the second exactly as open as it was — and the skip reasons would have been the next sentences to expire, which is the defect this card is about.

Constructing them instead means a version bump that gives one of them a column is compared against the platform object the day it lands. That closes the second path with the mechanism rather than with a promise. Measured, not assumed: same 12 models before and after.

⭐ Measuring that the map does NOT extract cleanly, rather than concluding it

The ruling said check whether the accounting map extracts for both, and if it does not, duplicate the scan rather than force a shared abstraction. You measured two independent reasons it cannot — construction (this gate passes auth-schema-config.ts's schema: options, D7 must not, because those carry our ADR-0105 D8 additionalFields) and disposition (sso/scim/oauthProvider are construct in D7 and skip here) — and demonstrated the failure rather than arguing it: constructing sso here goes red on the mapping assertion, since getAuthTables() reports ssoProvider, not sys_sso_provider.

And the detail that makes the duplication correct rather than merely permitted: each copy carries its own stale half, so a scan blinded by an import-shape change fails in the file it blinded rather than only in its sibling. That is the difference between two gates and one gate written twice.

⭐ The rationale is pinned as an executable assertion, not rewritten prose

This is the part that decides whether the card actually closed. Its thesis is that written surveys go stale silently; correcting the sentence and moving on would have reproduced the defect at a one-version delay. Asserting the three facts means the next version bump that moves any of them fails a test instead of quietly outdating a comment.

And you found the old sentence was false in a way #8224 did not record: "getAuthTables() cannot see them" is false for both plugins — passing sso() yields ssoProvider, scim({}) yields scimProvider plus four scimGroup* models. The library was never hiding them; the real reason they stay out of that call is that the auth manager passes them no schema option and their models are bridged at the adapter layer. Per-plugin, because scim still accepts no schema on 1.7.0-rc.1 — so the wholesale correction would itself have been wrong.

On the ratchet catching you mid-work

check:type-check-debt moving 111 → 112 when the source scan was seeded from import.meta.url (D7's spelling, which is fine in that package and TS1470 in this CJS-typed one) is the ratchet doing exactly its job. You fixed it with the findUp idiom two sibling files already established here for this reason, and re-measured back to 111 = recorded — rather than raising the number. Raising it is a reviewed edit, never a remedy, and this is what the correct response looks like.

Scope framing — held, and I want it on the record

The PR body says it in the body and in the code: nothing was miscovered, this closes no live hole, and the new assertion is a sample, not a class detector — it catches a factory arriving in auth-manager.ts unaccounted for, not a plugin loaded from anywhere else, and it is not a general detector for hand-written lists. I have had to correct that framing twice this month on other cards. A gate read as closing a class it only samples is worse than no gate, because it stops people looking. This one names its own reach.

Findings handled correctly

⚠️ One thing I am watching rather than assuming: the changeset reasoning (single *.test.ts, not in tsup entries, not in files, nothing published) is sound, but it depends on the skip-changeset label actually being applied. If Check Changeset reds, that is the cause and I will add it.

Flipped and armed.


Generated by Claude Code

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

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

better-auth-schema-parity's plugin list is hand-written and reconciled against nothing — the D7 gate's drift tripwire has no counterpart

2 participants

@os-project-manager@claude