fix(plugin-auth): open the SCIM request scope at handleRequest so SCIM provisioning runs inside one engine transaction (#14522) - #14624

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14522-scim-transaction-scope
Sep 2, 2026
Merged

fix(plugin-auth): open the SCIM request scope at handleRequest so SCIM provisioning runs inside one engine transaction (#14522)#14624
os-sales merged 2 commits into
mainfrom
claude/issue-14522-scim-transaction-scope

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#14522

What changed

@better-auth/scim wraps every User/Group mutation in runIdentityMutationTransaction@better-auth/core's runWithTransaction(adapter, fn)adapter.transaction(...). On this adapter, transaction opens a real engine.transaction() only while inScimRequestScope() reads true. That scope was stamped with AsyncLocalStorage.enterWith inside the verifyBearerToken callback handed to the SCIM plugin — and at write time the store was gone, so the vendor's transaction callback ran with no engine transaction at all (the #3653 scoping note in objectql-adapter.ts declared the opposite).

The fix opens the scope where it is observed: AuthManager.handleRequest now wraps the whole request in scimRequestScope.run({ scim: true }, ...) when the better-auth endpoint path is under /scim/v2 — the same door the actor-attribution scope (runWithAuthActorScope) and the subject-erasure transaction (runSubjectErasureAtomically) already use, keyed on the same betterAuthEndpointPath(request) reading. run has a callback boundary; every als.run the vendor performs underneath nests inside it. The verifier no longer stamps anything. scimRequestScope / inScimRequestScope stay in use, unchanged in shape.

Files: auth-manager.ts (the door scope + SCIM_PROTOCOL_PATH_PREFIX, the verifier's stamp removed, the reconcileScimUserLifecycle doc corrected), objectql-adapter.ts (the #3653 note rewritten — where the scope is stamped now and why the verifier stamp never propagated), scim-connection-service.ts (the ALS doc rewritten), user-ban-write.ts (one stale "once that transaction is real" clause corrected — prose only, the write is untouched), scim-deactivation-reconcile-user.test.ts (face (c) line 487 flipped falsetrue deliberately, comment rewritten; line 501 untouched), new scim-transaction-scope.test.ts (the runtime pin), one patch changeset for @objectstack/plugin-auth.

Triage scope guards (14522#issuecomment-5507946166, quoted verbatim)

  1. Do not make non-SCIM better-auth flows transactional.objectql-adapter.ts:826-828 records that the conditional is load-bearing against "two measured breakages", and the historical sequential behaviour for non-SCIM flows is deliberate. Whatever replaces inScimRequestScope() must be at least as narrow — a /scim/v2-scoped signal, not "transactions on by default".
  2. Land the runtime pin the card asks for.credential-at-rest-posture.test.ts's note that the vendor refuses to mount on a sequential-fallback transaction is a mount-time assertion and it passed while this was broken. Add the observation the card names: a SCIM mutation observed to call engine.transaction at least once. A fix without that pin can regress silently the same way.
  3. [finding] SCIM active:false no longer disables the account — the vendor ban coupling was removed upstream in @better-auth/scim 1.7.0 and nothing in this repo replaced it #14360's suite already pins the residualscim-deactivation-reconcile-user.test.ts, face (c). Flip that assertion deliberately and say so in the changeset; do not let it look like an incidental test edit.
  4. The card's two unmeasured items stay unmeasured unless cheap: whether the scope propagated on 1.7.0-rc.1 (may well have — the #3653 note may have been true when written, and saying so is fairer than implying it was always wrong), and Postgres/MySQL. The mechanism is adapter-side, so the sqlite reading should carry; note it rather than re-running the matrix.

How each is met: (1) the door scope is keyed on the endpoint path prefix /scim/v2 — the predicate the vendor's own after-hook matcher uses — and nothing else changed in the adapter's conditional; pin (c) below measures sign-up + sign-in at ZERO engine.transaction calls, and the FULL plugin-auth suite is green. (2) scim-transaction-scope.test.ts (a) observes POST /Users and PATCH /Users/{id} each calling engine.transaction ≥ 1 and driver.beginTransaction ≥ 1, with every identity write sampled INSIDE the scope. (3) line 487 flipped falsetrue with a rewritten comment; the changeset names the flip. (4) both items recorded as NOT MEASURED below.

Step 0 — base

git merge-base --is-ancestor 21c7dbe76b7d44df32fbd9f6497db6372b9f2d0f origin/main exits 1, and that is the squash, not a missing landing: PR #14540 landed as 7303cbf4d ("… (#14360) (#14540)") on origin/main, and the file to flip is on origin/main with a blob byte-identical to the PR head's (git rev-parse of scim-deactivation-reconcile-user.test.ts at 21c7dbe76 and at origin/main both 8a4d445f4ed811c9d257e211910dffe9f96ebae0; git diff --stat between them exits 0 printing nothing). Worktree cut from origin/mainf60ab90ae (BASE), not from the PR branch; origin/main (2a2653619) merged in afterwards as ec7278259 (clean, no regen-pending, plugin-auth untouched on main in that range).

Premise checks (on origin/main before any edit)

  • P1 holds.objectql-adapter.ts:829if (!inScimRequestScope()) return cb(wrappedAdapter as never); (:8 imports it); auth-manager.ts:3279scimRequestScope.enterWith({ scim: true }); inside the verifyBearerToken callback (:3269 destructures it from the dynamic import); scim-connection-service.ts:63 the ALS, :66-67 the reader. The adapter still reads the ALS; the stamp still lives in the verifier.
  • P2 holds — reproduced before editing, kept as the red half.scim-transaction-scope.test.ts on the unfixed tree (f60ab90ae + the test file only): engine.transaction0 calls on POST /scim/v2/Users, writes sampled insert:sys_scim_connection_binding:scim insert:sys_user:NO-SCOPE insert:sys_scim_subject:NO-SCOPE insert:sys_scim_user:NO-SCOPE update:sys_scim_subject:NO-SCOPE update:sys_scim_connection_binding:NO-SCOPE; 0 calls on PATCH /scim/v2/Users/{id}active:false, writes update:sys_scim_subject:NO-SCOPE update:sys_user:NO-SCOPE update:sys_scim_user:NO-SCOPE update:sys_user:NO-SCOPE update:sys_scim_connection_binding:NO-SCOPE; the (b) atomicity case: sys_user survived the failed provisioning. Both requests answered 201 / 200. Note the one scim-scoped write: the vendor's connection-binding insert, made by the auth middleware right after await verifyBearerToken() — a DESCENDANT of the stamp — is the only write that saw it, which is exactly the enterWith shape.
  • P3 holds. face (c) line 487 read expect(await scimActive(h, owner.scimId)).toBe(false); under the comment block naming this card; line 501 is the positive control's false.
  • P4 holds. Installed @better-auth/scim1.7.2 / better-auth1.7.2 / @better-auth/core1.7.2 (pnpm store node_modules/.pnpm/@better-auth+scim@1.7.2_…). @better-auth/scim/dist/index.mjs:4002runIdentityMutationTransaction:4011runWithTransaction(adapter, async () => callback(await getCurrentAdapter(adapter))) (imported from @better-auth/core/context, :5); @better-auth/core/dist/context/transaction.mjsrunWithTransactionadapter.transaction(async (trx) => als.run({ adapter: trx, pendingHooks, isTransactionActive: true }, fn)). The three SCIM mutation sites: :6527 (POST /Users), :6749, :6839 (PATCH / PUT). assertNativeSCIMTransactions at :5499 asks only typeof adapter.options?.adapterConfig.transaction === "function".
  • P5 holds.packages/spec/** and content/docs/releases/** untouched (diff file list above).

Hypotheses (by measurement)

  • H1 holds — mechanism measured, boundary named. A throwaway probe (deleted before commit) drove the installed better-auth 1.7.2 directly: betterAuth({...}) with a global hooks.before doing alsA.enterWith(...), a plugin endpoint whose use middleware does alsB.enterWith(...) (the verifier's shape), the request issued inside alsC.run(...), and a fourth ALS opened at the door and MUTATED from hooks.before; the handler read all four through a nested als.run (the shape runWithTransaction uses). Reading: beforeHookEnterWith: null, useMiddlewareEnterWith: null, doorRun: { via: "door run" }, beforeHookMutatedDoorStore: { flag: true }, endpointContextPath: "/probe/scope". So the boundary is the await of the middleware chain in the endpoint frame — better-call's endpoint runner awaits the use middlewares (the verifier's caller) and dispatchAuthEndpoint awaits runBeforeHooks(...) (better-auth/dist/api/dispatch.mjs:205-228, which then re-enters the handler under a second runWithEndpointContext) — from continuations captured BEFORE the stamp. An enterWith marks the async resource it runs in and that resource's descendants only.
  • H2 holds — remedy picked by measurement. Candidate (a) as literally proposed (enterWith in hooks.before) is REFUTED by the probe (null). Candidate (b) — reading the vendor's endpoint context at write time — is VIABLE (tryGetCurrentAuthEndpointContext()?.path reads /probe/scope through the nested als.run; @better-auth/core/context exports it, and session-tombstone.ts:231 already imports from that module), but it retires scimRequestScope / inScimRequestScope, which the dispatch declares an export removal (Clause-② yes + stop), and couples the adapter to the endpoint-context API. Chosen: the run-scoped stamp at the door (doorRun visible; beforeHookMutatedDoorStore shows the same run-at-the-door mechanism the actor-attribution scope relies on). It is the house pattern for both a request-scoped ALS and endpoint-path-keyed atomicity, keeps both symbols in use, and is exactly as narrow: SCIM protocol requests only.
  • H3 holds. Green half on the fixed tree: scim-transaction-scope.test.ts 5/5 and scim-deactivation-reconcile-user.test.ts 11/11 (Test Files 2 passed (2) · Tests 16 passed (16)), including the flipped face (c) — the refused last-administrator deactivation now leaves the SCIM resource reading active: true while the positive control's false on line 501 still passes.
  • H4 holds. Pin (c): sign-up + sign-in through handleRequest write sys_user / sys_account / sys_session with engine.transaction0 and driver.beginTransaction0, no write in the SCIM scope. FULL @objectstack/plugin-auth suite: pnpm --filter @objectstack/plugin-auth test (the package's vitest run, VITEST_MAX_WORKERS from scripts/vitest-worker-cap.mjs) on ec7278259Test Files 91 passed (91) · Tests 1835 passed (1835), VERDICT command-exit 0 (held the lock 155 s). The six SCIM-adjacent siblings the dispatch names (credential-at-rest-posture, scim-case-insensitive-identifier, better-auth-schema-parity, last-admin-guard, managed-extension-fields, auth-manager) are inside that run. The two measured breakages the #3653 note records (sign-up 500s on the memory engine; the 180 s hook-timeout deadlock on single-connection sqlite) did not return.
  • H5 holds.git diff -U0 origin/main...HEAD | grep -E '^[+-].*\bexport\b' → nothing (grep exit 1). Clause-② no. Changeset patch for @objectstack/plugin-auth.

Tests (head ec7278259)

  • RED half (unfixed tree): pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/scim-transaction-scope.test.ts …Tests 3 failed | 3 passed (6) — (a) POST, (a) PATCH, (b) red with the readings quoted under P2; (c), (d) green (they measure the unchanged non-SCIM posture); VERDICT command-exit 1.
  • GREEN half: … src/scim-transaction-scope.test.ts src/scim-deactivation-reconcile-user.test.tsTest Files 2 passed (2) · Tests 16 passed (16), VERDICT command-exit 0.
  • pnpm --filter @objectstack/plugin-auth typecheck (src + tsconfig.examples.json + check:test-typecheck) → check:test-typecheck: OK — … 10 file(s) / 94 error(s) / 23 pinned signature(s) held in test-typecheck-debt.json (ledger unchanged), VERDICT command-exit 0. (A first run failed only on examples/basic-usage.ts resolving the package's own unbuilt dist/ — PREREQUISITE NOT MET, cleared by pnpm --filter @objectstack/plugin-auth build.)
  • FULL suite: pnpm --filter @objectstack/plugin-auth test (the package's vitest run, VITEST_MAX_WORKERS from scripts/vitest-worker-cap.mjs) on ec7278259Test Files 91 passed (91) · Tests 1835 passed (1835), VERDICT command-exit 0 (held the lock 155 s). The six SCIM-adjacent siblings the dispatch names (credential-at-rest-posture, scim-case-insensitive-identifier, better-auth-schema-parity, last-admin-guard, managed-extension-fields, auth-manager) are inside that run. The two measured breakages the #3653 note records (sign-up 500s on the memory engine; the 180 s hook-timeout deadlock on single-connection sqlite) did not return.
  • pnpm check:system-context-censusOK — 109 elevation read sites in 20 packages across 45 files, all anchored; no re-anchor of content/docs/permissions/system-context.mdx needed (working tree clean after the gate).
  • pnpm check:nul-bytesOK (scanned 7980 text file(s) … no raw ASCII control bytes); grep -naP control-byte self-scan over the seven touched files: 0 hits.

Ablation (committed tree)

Mutation = the OLD mechanism restored on the committed tree ec7278259, in auth-manager.ts only: the door scope disabled (const runRequest = (false as boolean) && isScimProtocolPath(endpointPath)), and scimRequestScope.enterWith({ scim: true }) put back as the first line of the verifyBearerToken callback (with its destructure). Applied by perl -0pi, proven on disk by anchored counts, never by the editor's exit code: ABLATION-14522-door=1 (expect 1) · ABLATION-14522-import=1 (expect 1) · ABLATION-14522-enterWith=1 (expect 1) · original-door-line=0 (expect 0) · enterWith-total=2 (expect 1) — the 2 is the marker line plus the comment line "⛔ No scimRequestScope.enterWith(...) here", i.e. one real call; pre-mutation blob 4e1bede81930d4e97cf9aca641a4036d79bd63a3 = HEAD blob, post-mutation blob 7165d4eefaac562a1633f185d640374047d689f1. No build leg: the subject (AuthManager) is imported by both test files relatively from src (./auth-manager.js), not through a package exports map, so no dist/ is on the resolution path and ablation-dist-preflight does not apply.

Reading (predicted direction: pins (a)/(b) and #14360 face (c) red, (c)/(d) green): Test Files 2 failed (2) · Tests 4 failed | 12 passed (16) — red: (a) POST /Users …, (a) PATCH /Users/{id} active:false …, (b) a failure on the sys_scim_user write rolls … back, and (c) the IdP gets a 403 SCIM error naming the invariant, and the account stays active (the flipped line 487 reads false again under the old mechanism); green: (c) sign-up/sign-in zero transactions, (d) SCIM read, and the other ten #14360 cases. ABLATION vitest exit: 1; VERDICT command-exit 0 (the wrapper's own exit is the script's, which restores and exits 0).

Restore = git checkout HEAD -- ABS_PATH in trap … EXIT INT TERM, proven: RESTORE: hash-object=4e1bede81930d4e97cf9aca641a4036d79bd63a3 HEAD-blob=4e1bede81930d4e97cf9aca641a4036d79bd63a3 MATCH · git diff HEAD --stat: [] · porcelain: [].

Gates (derived on the final head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, no paths passed)

Derived at ec7278259 (the derivation's own stderr line: "gate list derived from the tree of 'objectstack-ai/objectstack' at commit ec72782"; it notes the tree is 10 commits behind origin/main2514d49f3 — post-merge landings, none under plugin-auth, and the merge-tree against that origin/main is clean). The list is byte-identical to the pre-merge derivation on a88c952fd (36 commands). Run unlocked in the foreground in batches under the ~10-minute cap — the lock script's own header classes check:* gates as unlocked sibling work, and the lock was saturated by a sibling's gate batches (two 540 s queue timeouts on the full-suite call before it acquired) — with each command's exit captured to a results file BEFORE any pipe. Batch 2 was cap-killed (exit 143) after scripts/pm/check-half-states.mjs alone took 517 s; the two commands it left unrun (check:engine-double-contract, check:logger-receiver-detach) were re-run in a fourth call.

  • 33 of 36 exit 0: check-adr-0087-registration, check-changeset-no-major, check-ci-filter-parity, check-comment-mask-adoption, check-cross-package-test-inputs (node and pnpm spellings), check-empty-changeset, check-keyed-text-bounds, check-plugin-teardown-shape, check-shard-attestation, check-system-context-census (OK — 109 elevation read sites in 20 packages across 45 files, all anchored), check-tenant-audit-census, check-undeclared-dep-imports, docs-audit/check-affected-docs, docs-audit/check-drift-comment, pm/check-half-states (report-only patrol; its 155 half-state(s) found is a board census, not a verdict on this PR), pm/release-rehearsal-clone --self-test, check:changeset-gate-self-tests, check:dispatcher-error-vocabulary, check:doc-authoring, check:engine-double-contract, check:logger-receiver-detach, check:objectql-double-limit, check:objectui-changeset, check:page-declaration-shape, check:pm-half-states, check:published-files, check:query-options-erasure, check:slot-lookup, check:test-source-alias, check:type-check-coverage (OK — 69/79 workspace packages type-checked (plus the root), 10 in the DEBT ledger), check:type-source-resolution, check:where-matcher.
  • 3 NOT MEASURED, each declared by the gate itself with exit 3 (its prerequisite code, distinct from a finding's 1): check-test-completeness.mjs ("PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named … record this gate as NOT MEASURED"); check:dual-build-cjs-loads ("PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … Run pnpm build first. ⛔ This is NOT a pass: nothing was measured"); check:type-check-debt ("--re-measure cannot run: 29 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk … Build the closure first, exactly as lint.yml does"). A whole-workspace build is a repo-level run this container cannot fit under the cap; CI owns all three.
  • 0 red.

pnpm lint (eslint . --no-inline-config, repo-wide) is CI's run; the local reading is a PROVEN narrowing, three pieces: ① the population is read from eslint.config.mjs itself — its own comment at line 328 states "(no parserOptions.project, no typed @typescript-eslint rules)", and every parserOptions block in the file carries only ecmaVersion / sourceType, so linting is not type-aware; ② pnpm exec eslint --no-inline-config --format json over the six touched .ts files → files linted: 6 | errors: 0 | warnings: 0, exit 0; ③ invariance: with no type-aware rules a file's verdict depends only on its own bytes, so this diff cannot move the verdict of any untouched file — the narrowing excludes nothing.

NOT MEASURED (scope guard 4)

  • Whether the scope propagated on @better-auth/scim 1.7.0-rc.1 — not re-measured. The #3653 note may well have been true when written (a different dispatch shape); the rewritten prose says the stamp "never reached this seam" on 1.7.2 and does not claim it never did.
  • Postgres / MySQL — measured on better-sqlite3 :memory: only. The mechanism is adapter-side (where the ALS store is read), not driver-side, so the reading should carry; driver.beginTransaction ≥ 1 is pinned on sqlite only.

Log levels

No new log site at any level. The vendor's [Better Auth]: back-channel logout planning failed … no such table: sys_oauth_access_token ERROR seen in the suite output is pre-existing harness noise (the SCIM harnesses register no OAuth objects), filed separately.

Out of scope

Filed as #14615 (finding, unassigned, after a targeted MCP search_issues dedup — four non-matching closed hits — with a positive control that returned #14522): the plugin-auth SCIM harnesses (the #14360 suite and, by copying its shape, the new pin) register no OAuth objects, so every driven sign-in prints a Better Auth ERROR back-channel logout planning failed … no such table: sys_oauth_access_token. Green-run noise, not a failure; not touched here.

Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…M provisioning runs inside one engine transaction
The adapter's `transaction` config opens a real `engine.transaction()` only
while `inScimRequestScope()` reads true. The scope was stamped with
`AsyncLocalStorage.enterWith` inside the `verifyBearerToken` callback handed
to `@better-auth/scim`, and on 1.7.2 the store never reached the writes: an
`enterWith` marks only the async resource it runs in and its descendants,
and the vendor resumes the endpoint handler from a continuation captured
before the verifier ran. Measured: zero `engine.transaction` and zero
`driver.beginTransaction` calls across POST /Users + PATCH /Users/{id},
`inScimRequestScope()` false inside every sys_user / sys_scim_user write.
`AuthManager.handleRequest` now opens the scope with `run(...)` around every
request whose better-auth endpoint path is under `/scim/v2` — the same seam
the actor-attribution scope and the subject-erasure transaction use, and
exactly as narrow as before: non-SCIM flows keep their sequential posture.
A refused last-administrator deactivation now rolls the vendor's own
`scimUser.active = false` write back; the #14360 suite's face (c) pin on that
residual is flipped from `false` to `true` deliberately. A new runtime pin
(`scim-transaction-scope.test.ts`) observes each SCIM mutation calling
`engine.transaction`, a failed provisioning leaving no partial identity, and
sign-up/sign-in opening zero transactions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 8 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/user-ban-write.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx(via AuthManager (symbol, a top-level class), handleRequest (symbol, a method of class AuthManager))
  • content/docs/kernel/services-checklist.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via AuthManager (symbol, a top-level class), createObjectQLAdapterFactory (symbol, a top-level function), handleRequest (symbol, a method of class AuthManager))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/user-ban-write.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 11 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 9d7f7259fa96bd79138d3e55c2801886f56221bbpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3700abebe9135748c5eabe9d57041eefdf9c72a3 — the merge of head ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce into base 9d7f7259fa96bd79138d3e55c2801886f56221bb, 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 3700abebe9135748c5eabe9d57041eefdf9c72a3 && git checkout 3700abebe9135748c5eabe9d57041eefdf9c72a3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9d7f7259fa96bd79138d3e55c2801886f56221bb ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce && git checkout -B drift-repro 9d7f7259fa96bd79138d3e55c2801886f56221bb && git merge --no-ff ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce
node scripts/docs-audit/affected-docs.mjs --json 9d7f7259fa96bd79138d3e55c2801886f56221bb

⚠️ 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 9d7f7259fa96bd79138d3e55c2801886f56221bb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head ec7278259


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(plugin-auth): open the SCIM request scope at handleRequest so SCIM provisioning runs inside one engine transaction (#14522) - #14624

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14522-scim-transaction-scope
Sep 2, 2026
Merged

fix(plugin-auth): open the SCIM request scope at handleRequest so SCIM provisioning runs inside one engine transaction (#14522)#14624
os-sales merged 2 commits into
mainfrom
claude/issue-14522-scim-transaction-scope

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#14522

What changed

@better-auth/scim wraps every User/Group mutation in runIdentityMutationTransaction@better-auth/core's runWithTransaction(adapter, fn)adapter.transaction(...). On this adapter, transaction opens a real engine.transaction() only while inScimRequestScope() reads true. That scope was stamped with AsyncLocalStorage.enterWith inside the verifyBearerToken callback handed to the SCIM plugin — and at write time the store was gone, so the vendor's transaction callback ran with no engine transaction at all (the #3653 scoping note in objectql-adapter.ts declared the opposite).

The fix opens the scope where it is observed: AuthManager.handleRequest now wraps the whole request in scimRequestScope.run({ scim: true }, ...) when the better-auth endpoint path is under /scim/v2 — the same door the actor-attribution scope (runWithAuthActorScope) and the subject-erasure transaction (runSubjectErasureAtomically) already use, keyed on the same betterAuthEndpointPath(request) reading. run has a callback boundary; every als.run the vendor performs underneath nests inside it. The verifier no longer stamps anything. scimRequestScope / inScimRequestScope stay in use, unchanged in shape.

Files: auth-manager.ts (the door scope + SCIM_PROTOCOL_PATH_PREFIX, the verifier's stamp removed, the reconcileScimUserLifecycle doc corrected), objectql-adapter.ts (the #3653 note rewritten — where the scope is stamped now and why the verifier stamp never propagated), scim-connection-service.ts (the ALS doc rewritten), user-ban-write.ts (one stale "once that transaction is real" clause corrected — prose only, the write is untouched), scim-deactivation-reconcile-user.test.ts (face (c) line 487 flipped falsetrue deliberately, comment rewritten; line 501 untouched), new scim-transaction-scope.test.ts (the runtime pin), one patch changeset for @objectstack/plugin-auth.

Triage scope guards (14522#issuecomment-5507946166, quoted verbatim)

  1. Do not make non-SCIM better-auth flows transactional.objectql-adapter.ts:826-828 records that the conditional is load-bearing against "two measured breakages", and the historical sequential behaviour for non-SCIM flows is deliberate. Whatever replaces inScimRequestScope() must be at least as narrow — a /scim/v2-scoped signal, not "transactions on by default".
  2. Land the runtime pin the card asks for.credential-at-rest-posture.test.ts's note that the vendor refuses to mount on a sequential-fallback transaction is a mount-time assertion and it passed while this was broken. Add the observation the card names: a SCIM mutation observed to call engine.transaction at least once. A fix without that pin can regress silently the same way.
  3. [finding] SCIM active:false no longer disables the account — the vendor ban coupling was removed upstream in @better-auth/scim 1.7.0 and nothing in this repo replaced it #14360's suite already pins the residualscim-deactivation-reconcile-user.test.ts, face (c). Flip that assertion deliberately and say so in the changeset; do not let it look like an incidental test edit.
  4. The card's two unmeasured items stay unmeasured unless cheap: whether the scope propagated on 1.7.0-rc.1 (may well have — the #3653 note may have been true when written, and saying so is fairer than implying it was always wrong), and Postgres/MySQL. The mechanism is adapter-side, so the sqlite reading should carry; note it rather than re-running the matrix.

How each is met: (1) the door scope is keyed on the endpoint path prefix /scim/v2 — the predicate the vendor's own after-hook matcher uses — and nothing else changed in the adapter's conditional; pin (c) below measures sign-up + sign-in at ZERO engine.transaction calls, and the FULL plugin-auth suite is green. (2) scim-transaction-scope.test.ts (a) observes POST /Users and PATCH /Users/{id} each calling engine.transaction ≥ 1 and driver.beginTransaction ≥ 1, with every identity write sampled INSIDE the scope. (3) line 487 flipped falsetrue with a rewritten comment; the changeset names the flip. (4) both items recorded as NOT MEASURED below.

Step 0 — base

git merge-base --is-ancestor 21c7dbe76b7d44df32fbd9f6497db6372b9f2d0f origin/main exits 1, and that is the squash, not a missing landing: PR #14540 landed as 7303cbf4d ("… (#14360) (#14540)") on origin/main, and the file to flip is on origin/main with a blob byte-identical to the PR head's (git rev-parse of scim-deactivation-reconcile-user.test.ts at 21c7dbe76 and at origin/main both 8a4d445f4ed811c9d257e211910dffe9f96ebae0; git diff --stat between them exits 0 printing nothing). Worktree cut from origin/mainf60ab90ae (BASE), not from the PR branch; origin/main (2a2653619) merged in afterwards as ec7278259 (clean, no regen-pending, plugin-auth untouched on main in that range).

Premise checks (on origin/main before any edit)

  • P1 holds.objectql-adapter.ts:829if (!inScimRequestScope()) return cb(wrappedAdapter as never); (:8 imports it); auth-manager.ts:3279scimRequestScope.enterWith({ scim: true }); inside the verifyBearerToken callback (:3269 destructures it from the dynamic import); scim-connection-service.ts:63 the ALS, :66-67 the reader. The adapter still reads the ALS; the stamp still lives in the verifier.
  • P2 holds — reproduced before editing, kept as the red half.scim-transaction-scope.test.ts on the unfixed tree (f60ab90ae + the test file only): engine.transaction0 calls on POST /scim/v2/Users, writes sampled insert:sys_scim_connection_binding:scim insert:sys_user:NO-SCOPE insert:sys_scim_subject:NO-SCOPE insert:sys_scim_user:NO-SCOPE update:sys_scim_subject:NO-SCOPE update:sys_scim_connection_binding:NO-SCOPE; 0 calls on PATCH /scim/v2/Users/{id}active:false, writes update:sys_scim_subject:NO-SCOPE update:sys_user:NO-SCOPE update:sys_scim_user:NO-SCOPE update:sys_user:NO-SCOPE update:sys_scim_connection_binding:NO-SCOPE; the (b) atomicity case: sys_user survived the failed provisioning. Both requests answered 201 / 200. Note the one scim-scoped write: the vendor's connection-binding insert, made by the auth middleware right after await verifyBearerToken() — a DESCENDANT of the stamp — is the only write that saw it, which is exactly the enterWith shape.
  • P3 holds. face (c) line 487 read expect(await scimActive(h, owner.scimId)).toBe(false); under the comment block naming this card; line 501 is the positive control's false.
  • P4 holds. Installed @better-auth/scim1.7.2 / better-auth1.7.2 / @better-auth/core1.7.2 (pnpm store node_modules/.pnpm/@better-auth+scim@1.7.2_…). @better-auth/scim/dist/index.mjs:4002runIdentityMutationTransaction:4011runWithTransaction(adapter, async () => callback(await getCurrentAdapter(adapter))) (imported from @better-auth/core/context, :5); @better-auth/core/dist/context/transaction.mjsrunWithTransactionadapter.transaction(async (trx) => als.run({ adapter: trx, pendingHooks, isTransactionActive: true }, fn)). The three SCIM mutation sites: :6527 (POST /Users), :6749, :6839 (PATCH / PUT). assertNativeSCIMTransactions at :5499 asks only typeof adapter.options?.adapterConfig.transaction === "function".
  • P5 holds.packages/spec/** and content/docs/releases/** untouched (diff file list above).

Hypotheses (by measurement)

  • H1 holds — mechanism measured, boundary named. A throwaway probe (deleted before commit) drove the installed better-auth 1.7.2 directly: betterAuth({...}) with a global hooks.before doing alsA.enterWith(...), a plugin endpoint whose use middleware does alsB.enterWith(...) (the verifier's shape), the request issued inside alsC.run(...), and a fourth ALS opened at the door and MUTATED from hooks.before; the handler read all four through a nested als.run (the shape runWithTransaction uses). Reading: beforeHookEnterWith: null, useMiddlewareEnterWith: null, doorRun: { via: "door run" }, beforeHookMutatedDoorStore: { flag: true }, endpointContextPath: "/probe/scope". So the boundary is the await of the middleware chain in the endpoint frame — better-call's endpoint runner awaits the use middlewares (the verifier's caller) and dispatchAuthEndpoint awaits runBeforeHooks(...) (better-auth/dist/api/dispatch.mjs:205-228, which then re-enters the handler under a second runWithEndpointContext) — from continuations captured BEFORE the stamp. An enterWith marks the async resource it runs in and that resource's descendants only.
  • H2 holds — remedy picked by measurement. Candidate (a) as literally proposed (enterWith in hooks.before) is REFUTED by the probe (null). Candidate (b) — reading the vendor's endpoint context at write time — is VIABLE (tryGetCurrentAuthEndpointContext()?.path reads /probe/scope through the nested als.run; @better-auth/core/context exports it, and session-tombstone.ts:231 already imports from that module), but it retires scimRequestScope / inScimRequestScope, which the dispatch declares an export removal (Clause-② yes + stop), and couples the adapter to the endpoint-context API. Chosen: the run-scoped stamp at the door (doorRun visible; beforeHookMutatedDoorStore shows the same run-at-the-door mechanism the actor-attribution scope relies on). It is the house pattern for both a request-scoped ALS and endpoint-path-keyed atomicity, keeps both symbols in use, and is exactly as narrow: SCIM protocol requests only.
  • H3 holds. Green half on the fixed tree: scim-transaction-scope.test.ts 5/5 and scim-deactivation-reconcile-user.test.ts 11/11 (Test Files 2 passed (2) · Tests 16 passed (16)), including the flipped face (c) — the refused last-administrator deactivation now leaves the SCIM resource reading active: true while the positive control's false on line 501 still passes.
  • H4 holds. Pin (c): sign-up + sign-in through handleRequest write sys_user / sys_account / sys_session with engine.transaction0 and driver.beginTransaction0, no write in the SCIM scope. FULL @objectstack/plugin-auth suite: pnpm --filter @objectstack/plugin-auth test (the package's vitest run, VITEST_MAX_WORKERS from scripts/vitest-worker-cap.mjs) on ec7278259Test Files 91 passed (91) · Tests 1835 passed (1835), VERDICT command-exit 0 (held the lock 155 s). The six SCIM-adjacent siblings the dispatch names (credential-at-rest-posture, scim-case-insensitive-identifier, better-auth-schema-parity, last-admin-guard, managed-extension-fields, auth-manager) are inside that run. The two measured breakages the #3653 note records (sign-up 500s on the memory engine; the 180 s hook-timeout deadlock on single-connection sqlite) did not return.
  • H5 holds.git diff -U0 origin/main...HEAD | grep -E '^[+-].*\bexport\b' → nothing (grep exit 1). Clause-② no. Changeset patch for @objectstack/plugin-auth.

Tests (head ec7278259)

  • RED half (unfixed tree): pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/scim-transaction-scope.test.ts …Tests 3 failed | 3 passed (6) — (a) POST, (a) PATCH, (b) red with the readings quoted under P2; (c), (d) green (they measure the unchanged non-SCIM posture); VERDICT command-exit 1.
  • GREEN half: … src/scim-transaction-scope.test.ts src/scim-deactivation-reconcile-user.test.tsTest Files 2 passed (2) · Tests 16 passed (16), VERDICT command-exit 0.
  • pnpm --filter @objectstack/plugin-auth typecheck (src + tsconfig.examples.json + check:test-typecheck) → check:test-typecheck: OK — … 10 file(s) / 94 error(s) / 23 pinned signature(s) held in test-typecheck-debt.json (ledger unchanged), VERDICT command-exit 0. (A first run failed only on examples/basic-usage.ts resolving the package's own unbuilt dist/ — PREREQUISITE NOT MET, cleared by pnpm --filter @objectstack/plugin-auth build.)
  • FULL suite: pnpm --filter @objectstack/plugin-auth test (the package's vitest run, VITEST_MAX_WORKERS from scripts/vitest-worker-cap.mjs) on ec7278259Test Files 91 passed (91) · Tests 1835 passed (1835), VERDICT command-exit 0 (held the lock 155 s). The six SCIM-adjacent siblings the dispatch names (credential-at-rest-posture, scim-case-insensitive-identifier, better-auth-schema-parity, last-admin-guard, managed-extension-fields, auth-manager) are inside that run. The two measured breakages the #3653 note records (sign-up 500s on the memory engine; the 180 s hook-timeout deadlock on single-connection sqlite) did not return.
  • pnpm check:system-context-censusOK — 109 elevation read sites in 20 packages across 45 files, all anchored; no re-anchor of content/docs/permissions/system-context.mdx needed (working tree clean after the gate).
  • pnpm check:nul-bytesOK (scanned 7980 text file(s) … no raw ASCII control bytes); grep -naP control-byte self-scan over the seven touched files: 0 hits.

Ablation (committed tree)

Mutation = the OLD mechanism restored on the committed tree ec7278259, in auth-manager.ts only: the door scope disabled (const runRequest = (false as boolean) && isScimProtocolPath(endpointPath)), and scimRequestScope.enterWith({ scim: true }) put back as the first line of the verifyBearerToken callback (with its destructure). Applied by perl -0pi, proven on disk by anchored counts, never by the editor's exit code: ABLATION-14522-door=1 (expect 1) · ABLATION-14522-import=1 (expect 1) · ABLATION-14522-enterWith=1 (expect 1) · original-door-line=0 (expect 0) · enterWith-total=2 (expect 1) — the 2 is the marker line plus the comment line "⛔ No scimRequestScope.enterWith(...) here", i.e. one real call; pre-mutation blob 4e1bede81930d4e97cf9aca641a4036d79bd63a3 = HEAD blob, post-mutation blob 7165d4eefaac562a1633f185d640374047d689f1. No build leg: the subject (AuthManager) is imported by both test files relatively from src (./auth-manager.js), not through a package exports map, so no dist/ is on the resolution path and ablation-dist-preflight does not apply.

Reading (predicted direction: pins (a)/(b) and #14360 face (c) red, (c)/(d) green): Test Files 2 failed (2) · Tests 4 failed | 12 passed (16) — red: (a) POST /Users …, (a) PATCH /Users/{id} active:false …, (b) a failure on the sys_scim_user write rolls … back, and (c) the IdP gets a 403 SCIM error naming the invariant, and the account stays active (the flipped line 487 reads false again under the old mechanism); green: (c) sign-up/sign-in zero transactions, (d) SCIM read, and the other ten #14360 cases. ABLATION vitest exit: 1; VERDICT command-exit 0 (the wrapper's own exit is the script's, which restores and exits 0).

Restore = git checkout HEAD -- ABS_PATH in trap … EXIT INT TERM, proven: RESTORE: hash-object=4e1bede81930d4e97cf9aca641a4036d79bd63a3 HEAD-blob=4e1bede81930d4e97cf9aca641a4036d79bd63a3 MATCH · git diff HEAD --stat: [] · porcelain: [].

Gates (derived on the final head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, no paths passed)

Derived at ec7278259 (the derivation's own stderr line: "gate list derived from the tree of 'objectstack-ai/objectstack' at commit ec72782"; it notes the tree is 10 commits behind origin/main2514d49f3 — post-merge landings, none under plugin-auth, and the merge-tree against that origin/main is clean). The list is byte-identical to the pre-merge derivation on a88c952fd (36 commands). Run unlocked in the foreground in batches under the ~10-minute cap — the lock script's own header classes check:* gates as unlocked sibling work, and the lock was saturated by a sibling's gate batches (two 540 s queue timeouts on the full-suite call before it acquired) — with each command's exit captured to a results file BEFORE any pipe. Batch 2 was cap-killed (exit 143) after scripts/pm/check-half-states.mjs alone took 517 s; the two commands it left unrun (check:engine-double-contract, check:logger-receiver-detach) were re-run in a fourth call.

  • 33 of 36 exit 0: check-adr-0087-registration, check-changeset-no-major, check-ci-filter-parity, check-comment-mask-adoption, check-cross-package-test-inputs (node and pnpm spellings), check-empty-changeset, check-keyed-text-bounds, check-plugin-teardown-shape, check-shard-attestation, check-system-context-census (OK — 109 elevation read sites in 20 packages across 45 files, all anchored), check-tenant-audit-census, check-undeclared-dep-imports, docs-audit/check-affected-docs, docs-audit/check-drift-comment, pm/check-half-states (report-only patrol; its 155 half-state(s) found is a board census, not a verdict on this PR), pm/release-rehearsal-clone --self-test, check:changeset-gate-self-tests, check:dispatcher-error-vocabulary, check:doc-authoring, check:engine-double-contract, check:logger-receiver-detach, check:objectql-double-limit, check:objectui-changeset, check:page-declaration-shape, check:pm-half-states, check:published-files, check:query-options-erasure, check:slot-lookup, check:test-source-alias, check:type-check-coverage (OK — 69/79 workspace packages type-checked (plus the root), 10 in the DEBT ledger), check:type-source-resolution, check:where-matcher.
  • 3 NOT MEASURED, each declared by the gate itself with exit 3 (its prerequisite code, distinct from a finding's 1): check-test-completeness.mjs ("PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named … record this gate as NOT MEASURED"); check:dual-build-cjs-loads ("PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … Run pnpm build first. ⛔ This is NOT a pass: nothing was measured"); check:type-check-debt ("--re-measure cannot run: 29 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk … Build the closure first, exactly as lint.yml does"). A whole-workspace build is a repo-level run this container cannot fit under the cap; CI owns all three.
  • 0 red.

pnpm lint (eslint . --no-inline-config, repo-wide) is CI's run; the local reading is a PROVEN narrowing, three pieces: ① the population is read from eslint.config.mjs itself — its own comment at line 328 states "(no parserOptions.project, no typed @typescript-eslint rules)", and every parserOptions block in the file carries only ecmaVersion / sourceType, so linting is not type-aware; ② pnpm exec eslint --no-inline-config --format json over the six touched .ts files → files linted: 6 | errors: 0 | warnings: 0, exit 0; ③ invariance: with no type-aware rules a file's verdict depends only on its own bytes, so this diff cannot move the verdict of any untouched file — the narrowing excludes nothing.

NOT MEASURED (scope guard 4)

  • Whether the scope propagated on @better-auth/scim 1.7.0-rc.1 — not re-measured. The #3653 note may well have been true when written (a different dispatch shape); the rewritten prose says the stamp "never reached this seam" on 1.7.2 and does not claim it never did.
  • Postgres / MySQL — measured on better-sqlite3 :memory: only. The mechanism is adapter-side (where the ALS store is read), not driver-side, so the reading should carry; driver.beginTransaction ≥ 1 is pinned on sqlite only.

Log levels

No new log site at any level. The vendor's [Better Auth]: back-channel logout planning failed … no such table: sys_oauth_access_token ERROR seen in the suite output is pre-existing harness noise (the SCIM harnesses register no OAuth objects), filed separately.

Out of scope

Filed as #14615 (finding, unassigned, after a targeted MCP search_issues dedup — four non-matching closed hits — with a positive control that returned #14522): the plugin-auth SCIM harnesses (the #14360 suite and, by copying its shape, the new pin) register no OAuth objects, so every driven sign-in prints a Better Auth ERROR back-channel logout planning failed … no such table: sys_oauth_access_token. Green-run noise, not a failure; not touched here.

Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…M provisioning runs inside one engine transaction
The adapter's `transaction` config opens a real `engine.transaction()` only
while `inScimRequestScope()` reads true. The scope was stamped with
`AsyncLocalStorage.enterWith` inside the `verifyBearerToken` callback handed
to `@better-auth/scim`, and on 1.7.2 the store never reached the writes: an
`enterWith` marks only the async resource it runs in and its descendants,
and the vendor resumes the endpoint handler from a continuation captured
before the verifier ran. Measured: zero `engine.transaction` and zero
`driver.beginTransaction` calls across POST /Users + PATCH /Users/{id},
`inScimRequestScope()` false inside every sys_user / sys_scim_user write.
`AuthManager.handleRequest` now opens the scope with `run(...)` around every
request whose better-auth endpoint path is under `/scim/v2` — the same seam
the actor-attribution scope and the subject-erasure transaction use, and
exactly as narrow as before: non-SCIM flows keep their sequential posture.
A refused last-administrator deactivation now rolls the vendor's own
`scimUser.active = false` write back; the #14360 suite's face (c) pin on that
residual is flipped from `false` to `true` deliberately. A new runtime pin
(`scim-transaction-scope.test.ts`) observes each SCIM mutation calling
`engine.transaction`, a failed provisioning leaving no partial identity, and
sign-up/sign-in opening zero transactions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 8 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/user-ban-write.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx(via AuthManager (symbol, a top-level class), handleRequest (symbol, a method of class AuthManager))
  • content/docs/kernel/services-checklist.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via AuthManager (symbol, a top-level class), createObjectQLAdapterFactory (symbol, a top-level function), handleRequest (symbol, a method of class AuthManager))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/user-ban-write.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 11 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 9d7f7259fa96bd79138d3e55c2801886f56221bbpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3700abebe9135748c5eabe9d57041eefdf9c72a3 — the merge of head ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce into base 9d7f7259fa96bd79138d3e55c2801886f56221bb, 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 3700abebe9135748c5eabe9d57041eefdf9c72a3 && git checkout 3700abebe9135748c5eabe9d57041eefdf9c72a3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9d7f7259fa96bd79138d3e55c2801886f56221bb ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce && git checkout -B drift-repro 9d7f7259fa96bd79138d3e55c2801886f56221bb && git merge --no-ff ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce
node scripts/docs-audit/affected-docs.mjs --json 9d7f7259fa96bd79138d3e55c2801886f56221bb

⚠️ 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 9d7f7259fa96bd79138d3e55c2801886f56221bb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head ec7278259


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-auth): open the SCIM request scope at handleRequest so SCIM provisioning runs inside one engine transaction (#14522) - #14624

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14522-scim-transaction-scope
Sep 2, 2026
Merged

fix(plugin-auth): open the SCIM request scope at handleRequest so SCIM provisioning runs inside one engine transaction (#14522)#14624
os-sales merged 2 commits into
mainfrom
claude/issue-14522-scim-transaction-scope

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#14522

What changed

@better-auth/scim wraps every User/Group mutation in runIdentityMutationTransaction@better-auth/core's runWithTransaction(adapter, fn)adapter.transaction(...). On this adapter, transaction opens a real engine.transaction() only while inScimRequestScope() reads true. That scope was stamped with AsyncLocalStorage.enterWith inside the verifyBearerToken callback handed to the SCIM plugin — and at write time the store was gone, so the vendor's transaction callback ran with no engine transaction at all (the #3653 scoping note in objectql-adapter.ts declared the opposite).

The fix opens the scope where it is observed: AuthManager.handleRequest now wraps the whole request in scimRequestScope.run({ scim: true }, ...) when the better-auth endpoint path is under /scim/v2 — the same door the actor-attribution scope (runWithAuthActorScope) and the subject-erasure transaction (runSubjectErasureAtomically) already use, keyed on the same betterAuthEndpointPath(request) reading. run has a callback boundary; every als.run the vendor performs underneath nests inside it. The verifier no longer stamps anything. scimRequestScope / inScimRequestScope stay in use, unchanged in shape.

Files: auth-manager.ts (the door scope + SCIM_PROTOCOL_PATH_PREFIX, the verifier's stamp removed, the reconcileScimUserLifecycle doc corrected), objectql-adapter.ts (the #3653 note rewritten — where the scope is stamped now and why the verifier stamp never propagated), scim-connection-service.ts (the ALS doc rewritten), user-ban-write.ts (one stale "once that transaction is real" clause corrected — prose only, the write is untouched), scim-deactivation-reconcile-user.test.ts (face (c) line 487 flipped falsetrue deliberately, comment rewritten; line 501 untouched), new scim-transaction-scope.test.ts (the runtime pin), one patch changeset for @objectstack/plugin-auth.

Triage scope guards (14522#issuecomment-5507946166, quoted verbatim)

  1. Do not make non-SCIM better-auth flows transactional.objectql-adapter.ts:826-828 records that the conditional is load-bearing against "two measured breakages", and the historical sequential behaviour for non-SCIM flows is deliberate. Whatever replaces inScimRequestScope() must be at least as narrow — a /scim/v2-scoped signal, not "transactions on by default".
  2. Land the runtime pin the card asks for.credential-at-rest-posture.test.ts's note that the vendor refuses to mount on a sequential-fallback transaction is a mount-time assertion and it passed while this was broken. Add the observation the card names: a SCIM mutation observed to call engine.transaction at least once. A fix without that pin can regress silently the same way.
  3. [finding] SCIM active:false no longer disables the account — the vendor ban coupling was removed upstream in @better-auth/scim 1.7.0 and nothing in this repo replaced it #14360's suite already pins the residualscim-deactivation-reconcile-user.test.ts, face (c). Flip that assertion deliberately and say so in the changeset; do not let it look like an incidental test edit.
  4. The card's two unmeasured items stay unmeasured unless cheap: whether the scope propagated on 1.7.0-rc.1 (may well have — the #3653 note may have been true when written, and saying so is fairer than implying it was always wrong), and Postgres/MySQL. The mechanism is adapter-side, so the sqlite reading should carry; note it rather than re-running the matrix.

How each is met: (1) the door scope is keyed on the endpoint path prefix /scim/v2 — the predicate the vendor's own after-hook matcher uses — and nothing else changed in the adapter's conditional; pin (c) below measures sign-up + sign-in at ZERO engine.transaction calls, and the FULL plugin-auth suite is green. (2) scim-transaction-scope.test.ts (a) observes POST /Users and PATCH /Users/{id} each calling engine.transaction ≥ 1 and driver.beginTransaction ≥ 1, with every identity write sampled INSIDE the scope. (3) line 487 flipped falsetrue with a rewritten comment; the changeset names the flip. (4) both items recorded as NOT MEASURED below.

Step 0 — base

git merge-base --is-ancestor 21c7dbe76b7d44df32fbd9f6497db6372b9f2d0f origin/main exits 1, and that is the squash, not a missing landing: PR #14540 landed as 7303cbf4d ("… (#14360) (#14540)") on origin/main, and the file to flip is on origin/main with a blob byte-identical to the PR head's (git rev-parse of scim-deactivation-reconcile-user.test.ts at 21c7dbe76 and at origin/main both 8a4d445f4ed811c9d257e211910dffe9f96ebae0; git diff --stat between them exits 0 printing nothing). Worktree cut from origin/mainf60ab90ae (BASE), not from the PR branch; origin/main (2a2653619) merged in afterwards as ec7278259 (clean, no regen-pending, plugin-auth untouched on main in that range).

Premise checks (on origin/main before any edit)

  • P1 holds.objectql-adapter.ts:829if (!inScimRequestScope()) return cb(wrappedAdapter as never); (:8 imports it); auth-manager.ts:3279scimRequestScope.enterWith({ scim: true }); inside the verifyBearerToken callback (:3269 destructures it from the dynamic import); scim-connection-service.ts:63 the ALS, :66-67 the reader. The adapter still reads the ALS; the stamp still lives in the verifier.
  • P2 holds — reproduced before editing, kept as the red half.scim-transaction-scope.test.ts on the unfixed tree (f60ab90ae + the test file only): engine.transaction0 calls on POST /scim/v2/Users, writes sampled insert:sys_scim_connection_binding:scim insert:sys_user:NO-SCOPE insert:sys_scim_subject:NO-SCOPE insert:sys_scim_user:NO-SCOPE update:sys_scim_subject:NO-SCOPE update:sys_scim_connection_binding:NO-SCOPE; 0 calls on PATCH /scim/v2/Users/{id}active:false, writes update:sys_scim_subject:NO-SCOPE update:sys_user:NO-SCOPE update:sys_scim_user:NO-SCOPE update:sys_user:NO-SCOPE update:sys_scim_connection_binding:NO-SCOPE; the (b) atomicity case: sys_user survived the failed provisioning. Both requests answered 201 / 200. Note the one scim-scoped write: the vendor's connection-binding insert, made by the auth middleware right after await verifyBearerToken() — a DESCENDANT of the stamp — is the only write that saw it, which is exactly the enterWith shape.
  • P3 holds. face (c) line 487 read expect(await scimActive(h, owner.scimId)).toBe(false); under the comment block naming this card; line 501 is the positive control's false.
  • P4 holds. Installed @better-auth/scim1.7.2 / better-auth1.7.2 / @better-auth/core1.7.2 (pnpm store node_modules/.pnpm/@better-auth+scim@1.7.2_…). @better-auth/scim/dist/index.mjs:4002runIdentityMutationTransaction:4011runWithTransaction(adapter, async () => callback(await getCurrentAdapter(adapter))) (imported from @better-auth/core/context, :5); @better-auth/core/dist/context/transaction.mjsrunWithTransactionadapter.transaction(async (trx) => als.run({ adapter: trx, pendingHooks, isTransactionActive: true }, fn)). The three SCIM mutation sites: :6527 (POST /Users), :6749, :6839 (PATCH / PUT). assertNativeSCIMTransactions at :5499 asks only typeof adapter.options?.adapterConfig.transaction === "function".
  • P5 holds.packages/spec/** and content/docs/releases/** untouched (diff file list above).

Hypotheses (by measurement)

  • H1 holds — mechanism measured, boundary named. A throwaway probe (deleted before commit) drove the installed better-auth 1.7.2 directly: betterAuth({...}) with a global hooks.before doing alsA.enterWith(...), a plugin endpoint whose use middleware does alsB.enterWith(...) (the verifier's shape), the request issued inside alsC.run(...), and a fourth ALS opened at the door and MUTATED from hooks.before; the handler read all four through a nested als.run (the shape runWithTransaction uses). Reading: beforeHookEnterWith: null, useMiddlewareEnterWith: null, doorRun: { via: "door run" }, beforeHookMutatedDoorStore: { flag: true }, endpointContextPath: "/probe/scope". So the boundary is the await of the middleware chain in the endpoint frame — better-call's endpoint runner awaits the use middlewares (the verifier's caller) and dispatchAuthEndpoint awaits runBeforeHooks(...) (better-auth/dist/api/dispatch.mjs:205-228, which then re-enters the handler under a second runWithEndpointContext) — from continuations captured BEFORE the stamp. An enterWith marks the async resource it runs in and that resource's descendants only.
  • H2 holds — remedy picked by measurement. Candidate (a) as literally proposed (enterWith in hooks.before) is REFUTED by the probe (null). Candidate (b) — reading the vendor's endpoint context at write time — is VIABLE (tryGetCurrentAuthEndpointContext()?.path reads /probe/scope through the nested als.run; @better-auth/core/context exports it, and session-tombstone.ts:231 already imports from that module), but it retires scimRequestScope / inScimRequestScope, which the dispatch declares an export removal (Clause-② yes + stop), and couples the adapter to the endpoint-context API. Chosen: the run-scoped stamp at the door (doorRun visible; beforeHookMutatedDoorStore shows the same run-at-the-door mechanism the actor-attribution scope relies on). It is the house pattern for both a request-scoped ALS and endpoint-path-keyed atomicity, keeps both symbols in use, and is exactly as narrow: SCIM protocol requests only.
  • H3 holds. Green half on the fixed tree: scim-transaction-scope.test.ts 5/5 and scim-deactivation-reconcile-user.test.ts 11/11 (Test Files 2 passed (2) · Tests 16 passed (16)), including the flipped face (c) — the refused last-administrator deactivation now leaves the SCIM resource reading active: true while the positive control's false on line 501 still passes.
  • H4 holds. Pin (c): sign-up + sign-in through handleRequest write sys_user / sys_account / sys_session with engine.transaction0 and driver.beginTransaction0, no write in the SCIM scope. FULL @objectstack/plugin-auth suite: pnpm --filter @objectstack/plugin-auth test (the package's vitest run, VITEST_MAX_WORKERS from scripts/vitest-worker-cap.mjs) on ec7278259Test Files 91 passed (91) · Tests 1835 passed (1835), VERDICT command-exit 0 (held the lock 155 s). The six SCIM-adjacent siblings the dispatch names (credential-at-rest-posture, scim-case-insensitive-identifier, better-auth-schema-parity, last-admin-guard, managed-extension-fields, auth-manager) are inside that run. The two measured breakages the #3653 note records (sign-up 500s on the memory engine; the 180 s hook-timeout deadlock on single-connection sqlite) did not return.
  • H5 holds.git diff -U0 origin/main...HEAD | grep -E '^[+-].*\bexport\b' → nothing (grep exit 1). Clause-② no. Changeset patch for @objectstack/plugin-auth.

Tests (head ec7278259)

  • RED half (unfixed tree): pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/scim-transaction-scope.test.ts …Tests 3 failed | 3 passed (6) — (a) POST, (a) PATCH, (b) red with the readings quoted under P2; (c), (d) green (they measure the unchanged non-SCIM posture); VERDICT command-exit 1.
  • GREEN half: … src/scim-transaction-scope.test.ts src/scim-deactivation-reconcile-user.test.tsTest Files 2 passed (2) · Tests 16 passed (16), VERDICT command-exit 0.
  • pnpm --filter @objectstack/plugin-auth typecheck (src + tsconfig.examples.json + check:test-typecheck) → check:test-typecheck: OK — … 10 file(s) / 94 error(s) / 23 pinned signature(s) held in test-typecheck-debt.json (ledger unchanged), VERDICT command-exit 0. (A first run failed only on examples/basic-usage.ts resolving the package's own unbuilt dist/ — PREREQUISITE NOT MET, cleared by pnpm --filter @objectstack/plugin-auth build.)
  • FULL suite: pnpm --filter @objectstack/plugin-auth test (the package's vitest run, VITEST_MAX_WORKERS from scripts/vitest-worker-cap.mjs) on ec7278259Test Files 91 passed (91) · Tests 1835 passed (1835), VERDICT command-exit 0 (held the lock 155 s). The six SCIM-adjacent siblings the dispatch names (credential-at-rest-posture, scim-case-insensitive-identifier, better-auth-schema-parity, last-admin-guard, managed-extension-fields, auth-manager) are inside that run. The two measured breakages the #3653 note records (sign-up 500s on the memory engine; the 180 s hook-timeout deadlock on single-connection sqlite) did not return.
  • pnpm check:system-context-censusOK — 109 elevation read sites in 20 packages across 45 files, all anchored; no re-anchor of content/docs/permissions/system-context.mdx needed (working tree clean after the gate).
  • pnpm check:nul-bytesOK (scanned 7980 text file(s) … no raw ASCII control bytes); grep -naP control-byte self-scan over the seven touched files: 0 hits.

Ablation (committed tree)

Mutation = the OLD mechanism restored on the committed tree ec7278259, in auth-manager.ts only: the door scope disabled (const runRequest = (false as boolean) && isScimProtocolPath(endpointPath)), and scimRequestScope.enterWith({ scim: true }) put back as the first line of the verifyBearerToken callback (with its destructure). Applied by perl -0pi, proven on disk by anchored counts, never by the editor's exit code: ABLATION-14522-door=1 (expect 1) · ABLATION-14522-import=1 (expect 1) · ABLATION-14522-enterWith=1 (expect 1) · original-door-line=0 (expect 0) · enterWith-total=2 (expect 1) — the 2 is the marker line plus the comment line "⛔ No scimRequestScope.enterWith(...) here", i.e. one real call; pre-mutation blob 4e1bede81930d4e97cf9aca641a4036d79bd63a3 = HEAD blob, post-mutation blob 7165d4eefaac562a1633f185d640374047d689f1. No build leg: the subject (AuthManager) is imported by both test files relatively from src (./auth-manager.js), not through a package exports map, so no dist/ is on the resolution path and ablation-dist-preflight does not apply.

Reading (predicted direction: pins (a)/(b) and #14360 face (c) red, (c)/(d) green): Test Files 2 failed (2) · Tests 4 failed | 12 passed (16) — red: (a) POST /Users …, (a) PATCH /Users/{id} active:false …, (b) a failure on the sys_scim_user write rolls … back, and (c) the IdP gets a 403 SCIM error naming the invariant, and the account stays active (the flipped line 487 reads false again under the old mechanism); green: (c) sign-up/sign-in zero transactions, (d) SCIM read, and the other ten #14360 cases. ABLATION vitest exit: 1; VERDICT command-exit 0 (the wrapper's own exit is the script's, which restores and exits 0).

Restore = git checkout HEAD -- ABS_PATH in trap … EXIT INT TERM, proven: RESTORE: hash-object=4e1bede81930d4e97cf9aca641a4036d79bd63a3 HEAD-blob=4e1bede81930d4e97cf9aca641a4036d79bd63a3 MATCH · git diff HEAD --stat: [] · porcelain: [].

Gates (derived on the final head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, no paths passed)

Derived at ec7278259 (the derivation's own stderr line: "gate list derived from the tree of 'objectstack-ai/objectstack' at commit ec72782"; it notes the tree is 10 commits behind origin/main2514d49f3 — post-merge landings, none under plugin-auth, and the merge-tree against that origin/main is clean). The list is byte-identical to the pre-merge derivation on a88c952fd (36 commands). Run unlocked in the foreground in batches under the ~10-minute cap — the lock script's own header classes check:* gates as unlocked sibling work, and the lock was saturated by a sibling's gate batches (two 540 s queue timeouts on the full-suite call before it acquired) — with each command's exit captured to a results file BEFORE any pipe. Batch 2 was cap-killed (exit 143) after scripts/pm/check-half-states.mjs alone took 517 s; the two commands it left unrun (check:engine-double-contract, check:logger-receiver-detach) were re-run in a fourth call.

  • 33 of 36 exit 0: check-adr-0087-registration, check-changeset-no-major, check-ci-filter-parity, check-comment-mask-adoption, check-cross-package-test-inputs (node and pnpm spellings), check-empty-changeset, check-keyed-text-bounds, check-plugin-teardown-shape, check-shard-attestation, check-system-context-census (OK — 109 elevation read sites in 20 packages across 45 files, all anchored), check-tenant-audit-census, check-undeclared-dep-imports, docs-audit/check-affected-docs, docs-audit/check-drift-comment, pm/check-half-states (report-only patrol; its 155 half-state(s) found is a board census, not a verdict on this PR), pm/release-rehearsal-clone --self-test, check:changeset-gate-self-tests, check:dispatcher-error-vocabulary, check:doc-authoring, check:engine-double-contract, check:logger-receiver-detach, check:objectql-double-limit, check:objectui-changeset, check:page-declaration-shape, check:pm-half-states, check:published-files, check:query-options-erasure, check:slot-lookup, check:test-source-alias, check:type-check-coverage (OK — 69/79 workspace packages type-checked (plus the root), 10 in the DEBT ledger), check:type-source-resolution, check:where-matcher.
  • 3 NOT MEASURED, each declared by the gate itself with exit 3 (its prerequisite code, distinct from a finding's 1): check-test-completeness.mjs ("PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named … record this gate as NOT MEASURED"); check:dual-build-cjs-loads ("PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … Run pnpm build first. ⛔ This is NOT a pass: nothing was measured"); check:type-check-debt ("--re-measure cannot run: 29 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk … Build the closure first, exactly as lint.yml does"). A whole-workspace build is a repo-level run this container cannot fit under the cap; CI owns all three.
  • 0 red.

pnpm lint (eslint . --no-inline-config, repo-wide) is CI's run; the local reading is a PROVEN narrowing, three pieces: ① the population is read from eslint.config.mjs itself — its own comment at line 328 states "(no parserOptions.project, no typed @typescript-eslint rules)", and every parserOptions block in the file carries only ecmaVersion / sourceType, so linting is not type-aware; ② pnpm exec eslint --no-inline-config --format json over the six touched .ts files → files linted: 6 | errors: 0 | warnings: 0, exit 0; ③ invariance: with no type-aware rules a file's verdict depends only on its own bytes, so this diff cannot move the verdict of any untouched file — the narrowing excludes nothing.

NOT MEASURED (scope guard 4)

  • Whether the scope propagated on @better-auth/scim 1.7.0-rc.1 — not re-measured. The #3653 note may well have been true when written (a different dispatch shape); the rewritten prose says the stamp "never reached this seam" on 1.7.2 and does not claim it never did.
  • Postgres / MySQL — measured on better-sqlite3 :memory: only. The mechanism is adapter-side (where the ALS store is read), not driver-side, so the reading should carry; driver.beginTransaction ≥ 1 is pinned on sqlite only.

Log levels

No new log site at any level. The vendor's [Better Auth]: back-channel logout planning failed … no such table: sys_oauth_access_token ERROR seen in the suite output is pre-existing harness noise (the SCIM harnesses register no OAuth objects), filed separately.

Out of scope

Filed as #14615 (finding, unassigned, after a targeted MCP search_issues dedup — four non-matching closed hits — with a positive control that returned #14522): the plugin-auth SCIM harnesses (the #14360 suite and, by copying its shape, the new pin) register no OAuth objects, so every driven sign-in prints a Better Auth ERROR back-channel logout planning failed … no such table: sys_oauth_access_token. Green-run noise, not a failure; not touched here.

Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…M provisioning runs inside one engine transaction
The adapter's `transaction` config opens a real `engine.transaction()` only
while `inScimRequestScope()` reads true. The scope was stamped with
`AsyncLocalStorage.enterWith` inside the `verifyBearerToken` callback handed
to `@better-auth/scim`, and on 1.7.2 the store never reached the writes: an
`enterWith` marks only the async resource it runs in and its descendants,
and the vendor resumes the endpoint handler from a continuation captured
before the verifier ran. Measured: zero `engine.transaction` and zero
`driver.beginTransaction` calls across POST /Users + PATCH /Users/{id},
`inScimRequestScope()` false inside every sys_user / sys_scim_user write.
`AuthManager.handleRequest` now opens the scope with `run(...)` around every
request whose better-auth endpoint path is under `/scim/v2` — the same seam
the actor-attribution scope and the subject-erasure transaction use, and
exactly as narrow as before: non-SCIM flows keep their sequential posture.
A refused last-administrator deactivation now rolls the vendor's own
`scimUser.active = false` write back; the #14360 suite's face (c) pin on that
residual is flipped from `false` to `true` deliberately. A new runtime pin
(`scim-transaction-scope.test.ts`) observes each SCIM mutation calling
`engine.transaction`, a failed provisioning leaving no partial identity, and
sign-up/sign-in opening zero transactions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 8 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/user-ban-write.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx(via AuthManager (symbol, a top-level class), handleRequest (symbol, a method of class AuthManager))
  • content/docs/kernel/services-checklist.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via AuthManager (symbol, a top-level class), createObjectQLAdapterFactory (symbol, a top-level function), handleRequest (symbol, a method of class AuthManager))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/user-ban-write.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 11 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 9d7f7259fa96bd79138d3e55c2801886f56221bbpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3700abebe9135748c5eabe9d57041eefdf9c72a3 — the merge of head ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce into base 9d7f7259fa96bd79138d3e55c2801886f56221bb, 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 3700abebe9135748c5eabe9d57041eefdf9c72a3 && git checkout 3700abebe9135748c5eabe9d57041eefdf9c72a3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9d7f7259fa96bd79138d3e55c2801886f56221bb ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce && git checkout -B drift-repro 9d7f7259fa96bd79138d3e55c2801886f56221bb && git merge --no-ff ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce
node scripts/docs-audit/affected-docs.mjs --json 9d7f7259fa96bd79138d3e55c2801886f56221bb

⚠️ 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 9d7f7259fa96bd79138d3e55c2801886f56221bb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head ec7278259


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-auth): open the SCIM request scope at handleRequest so SCIM provisioning runs inside one engine transaction (#14522) - #14624

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14522-scim-transaction-scope
Sep 2, 2026
Merged

fix(plugin-auth): open the SCIM request scope at handleRequest so SCIM provisioning runs inside one engine transaction (#14522)#14624
os-sales merged 2 commits into
mainfrom
claude/issue-14522-scim-transaction-scope

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#14522

What changed

@better-auth/scim wraps every User/Group mutation in runIdentityMutationTransaction@better-auth/core's runWithTransaction(adapter, fn)adapter.transaction(...). On this adapter, transaction opens a real engine.transaction() only while inScimRequestScope() reads true. That scope was stamped with AsyncLocalStorage.enterWith inside the verifyBearerToken callback handed to the SCIM plugin — and at write time the store was gone, so the vendor's transaction callback ran with no engine transaction at all (the #3653 scoping note in objectql-adapter.ts declared the opposite).

The fix opens the scope where it is observed: AuthManager.handleRequest now wraps the whole request in scimRequestScope.run({ scim: true }, ...) when the better-auth endpoint path is under /scim/v2 — the same door the actor-attribution scope (runWithAuthActorScope) and the subject-erasure transaction (runSubjectErasureAtomically) already use, keyed on the same betterAuthEndpointPath(request) reading. run has a callback boundary; every als.run the vendor performs underneath nests inside it. The verifier no longer stamps anything. scimRequestScope / inScimRequestScope stay in use, unchanged in shape.

Files: auth-manager.ts (the door scope + SCIM_PROTOCOL_PATH_PREFIX, the verifier's stamp removed, the reconcileScimUserLifecycle doc corrected), objectql-adapter.ts (the #3653 note rewritten — where the scope is stamped now and why the verifier stamp never propagated), scim-connection-service.ts (the ALS doc rewritten), user-ban-write.ts (one stale "once that transaction is real" clause corrected — prose only, the write is untouched), scim-deactivation-reconcile-user.test.ts (face (c) line 487 flipped falsetrue deliberately, comment rewritten; line 501 untouched), new scim-transaction-scope.test.ts (the runtime pin), one patch changeset for @objectstack/plugin-auth.

Triage scope guards (14522#issuecomment-5507946166, quoted verbatim)

  1. Do not make non-SCIM better-auth flows transactional.objectql-adapter.ts:826-828 records that the conditional is load-bearing against "two measured breakages", and the historical sequential behaviour for non-SCIM flows is deliberate. Whatever replaces inScimRequestScope() must be at least as narrow — a /scim/v2-scoped signal, not "transactions on by default".
  2. Land the runtime pin the card asks for.credential-at-rest-posture.test.ts's note that the vendor refuses to mount on a sequential-fallback transaction is a mount-time assertion and it passed while this was broken. Add the observation the card names: a SCIM mutation observed to call engine.transaction at least once. A fix without that pin can regress silently the same way.
  3. [finding] SCIM active:false no longer disables the account — the vendor ban coupling was removed upstream in @better-auth/scim 1.7.0 and nothing in this repo replaced it #14360's suite already pins the residualscim-deactivation-reconcile-user.test.ts, face (c). Flip that assertion deliberately and say so in the changeset; do not let it look like an incidental test edit.
  4. The card's two unmeasured items stay unmeasured unless cheap: whether the scope propagated on 1.7.0-rc.1 (may well have — the #3653 note may have been true when written, and saying so is fairer than implying it was always wrong), and Postgres/MySQL. The mechanism is adapter-side, so the sqlite reading should carry; note it rather than re-running the matrix.

How each is met: (1) the door scope is keyed on the endpoint path prefix /scim/v2 — the predicate the vendor's own after-hook matcher uses — and nothing else changed in the adapter's conditional; pin (c) below measures sign-up + sign-in at ZERO engine.transaction calls, and the FULL plugin-auth suite is green. (2) scim-transaction-scope.test.ts (a) observes POST /Users and PATCH /Users/{id} each calling engine.transaction ≥ 1 and driver.beginTransaction ≥ 1, with every identity write sampled INSIDE the scope. (3) line 487 flipped falsetrue with a rewritten comment; the changeset names the flip. (4) both items recorded as NOT MEASURED below.

Step 0 — base

git merge-base --is-ancestor 21c7dbe76b7d44df32fbd9f6497db6372b9f2d0f origin/main exits 1, and that is the squash, not a missing landing: PR #14540 landed as 7303cbf4d ("… (#14360) (#14540)") on origin/main, and the file to flip is on origin/main with a blob byte-identical to the PR head's (git rev-parse of scim-deactivation-reconcile-user.test.ts at 21c7dbe76 and at origin/main both 8a4d445f4ed811c9d257e211910dffe9f96ebae0; git diff --stat between them exits 0 printing nothing). Worktree cut from origin/mainf60ab90ae (BASE), not from the PR branch; origin/main (2a2653619) merged in afterwards as ec7278259 (clean, no regen-pending, plugin-auth untouched on main in that range).

Premise checks (on origin/main before any edit)

  • P1 holds.objectql-adapter.ts:829if (!inScimRequestScope()) return cb(wrappedAdapter as never); (:8 imports it); auth-manager.ts:3279scimRequestScope.enterWith({ scim: true }); inside the verifyBearerToken callback (:3269 destructures it from the dynamic import); scim-connection-service.ts:63 the ALS, :66-67 the reader. The adapter still reads the ALS; the stamp still lives in the verifier.
  • P2 holds — reproduced before editing, kept as the red half.scim-transaction-scope.test.ts on the unfixed tree (f60ab90ae + the test file only): engine.transaction0 calls on POST /scim/v2/Users, writes sampled insert:sys_scim_connection_binding:scim insert:sys_user:NO-SCOPE insert:sys_scim_subject:NO-SCOPE insert:sys_scim_user:NO-SCOPE update:sys_scim_subject:NO-SCOPE update:sys_scim_connection_binding:NO-SCOPE; 0 calls on PATCH /scim/v2/Users/{id}active:false, writes update:sys_scim_subject:NO-SCOPE update:sys_user:NO-SCOPE update:sys_scim_user:NO-SCOPE update:sys_user:NO-SCOPE update:sys_scim_connection_binding:NO-SCOPE; the (b) atomicity case: sys_user survived the failed provisioning. Both requests answered 201 / 200. Note the one scim-scoped write: the vendor's connection-binding insert, made by the auth middleware right after await verifyBearerToken() — a DESCENDANT of the stamp — is the only write that saw it, which is exactly the enterWith shape.
  • P3 holds. face (c) line 487 read expect(await scimActive(h, owner.scimId)).toBe(false); under the comment block naming this card; line 501 is the positive control's false.
  • P4 holds. Installed @better-auth/scim1.7.2 / better-auth1.7.2 / @better-auth/core1.7.2 (pnpm store node_modules/.pnpm/@better-auth+scim@1.7.2_…). @better-auth/scim/dist/index.mjs:4002runIdentityMutationTransaction:4011runWithTransaction(adapter, async () => callback(await getCurrentAdapter(adapter))) (imported from @better-auth/core/context, :5); @better-auth/core/dist/context/transaction.mjsrunWithTransactionadapter.transaction(async (trx) => als.run({ adapter: trx, pendingHooks, isTransactionActive: true }, fn)). The three SCIM mutation sites: :6527 (POST /Users), :6749, :6839 (PATCH / PUT). assertNativeSCIMTransactions at :5499 asks only typeof adapter.options?.adapterConfig.transaction === "function".
  • P5 holds.packages/spec/** and content/docs/releases/** untouched (diff file list above).

Hypotheses (by measurement)

  • H1 holds — mechanism measured, boundary named. A throwaway probe (deleted before commit) drove the installed better-auth 1.7.2 directly: betterAuth({...}) with a global hooks.before doing alsA.enterWith(...), a plugin endpoint whose use middleware does alsB.enterWith(...) (the verifier's shape), the request issued inside alsC.run(...), and a fourth ALS opened at the door and MUTATED from hooks.before; the handler read all four through a nested als.run (the shape runWithTransaction uses). Reading: beforeHookEnterWith: null, useMiddlewareEnterWith: null, doorRun: { via: "door run" }, beforeHookMutatedDoorStore: { flag: true }, endpointContextPath: "/probe/scope". So the boundary is the await of the middleware chain in the endpoint frame — better-call's endpoint runner awaits the use middlewares (the verifier's caller) and dispatchAuthEndpoint awaits runBeforeHooks(...) (better-auth/dist/api/dispatch.mjs:205-228, which then re-enters the handler under a second runWithEndpointContext) — from continuations captured BEFORE the stamp. An enterWith marks the async resource it runs in and that resource's descendants only.
  • H2 holds — remedy picked by measurement. Candidate (a) as literally proposed (enterWith in hooks.before) is REFUTED by the probe (null). Candidate (b) — reading the vendor's endpoint context at write time — is VIABLE (tryGetCurrentAuthEndpointContext()?.path reads /probe/scope through the nested als.run; @better-auth/core/context exports it, and session-tombstone.ts:231 already imports from that module), but it retires scimRequestScope / inScimRequestScope, which the dispatch declares an export removal (Clause-② yes + stop), and couples the adapter to the endpoint-context API. Chosen: the run-scoped stamp at the door (doorRun visible; beforeHookMutatedDoorStore shows the same run-at-the-door mechanism the actor-attribution scope relies on). It is the house pattern for both a request-scoped ALS and endpoint-path-keyed atomicity, keeps both symbols in use, and is exactly as narrow: SCIM protocol requests only.
  • H3 holds. Green half on the fixed tree: scim-transaction-scope.test.ts 5/5 and scim-deactivation-reconcile-user.test.ts 11/11 (Test Files 2 passed (2) · Tests 16 passed (16)), including the flipped face (c) — the refused last-administrator deactivation now leaves the SCIM resource reading active: true while the positive control's false on line 501 still passes.
  • H4 holds. Pin (c): sign-up + sign-in through handleRequest write sys_user / sys_account / sys_session with engine.transaction0 and driver.beginTransaction0, no write in the SCIM scope. FULL @objectstack/plugin-auth suite: pnpm --filter @objectstack/plugin-auth test (the package's vitest run, VITEST_MAX_WORKERS from scripts/vitest-worker-cap.mjs) on ec7278259Test Files 91 passed (91) · Tests 1835 passed (1835), VERDICT command-exit 0 (held the lock 155 s). The six SCIM-adjacent siblings the dispatch names (credential-at-rest-posture, scim-case-insensitive-identifier, better-auth-schema-parity, last-admin-guard, managed-extension-fields, auth-manager) are inside that run. The two measured breakages the #3653 note records (sign-up 500s on the memory engine; the 180 s hook-timeout deadlock on single-connection sqlite) did not return.
  • H5 holds.git diff -U0 origin/main...HEAD | grep -E '^[+-].*\bexport\b' → nothing (grep exit 1). Clause-② no. Changeset patch for @objectstack/plugin-auth.

Tests (head ec7278259)

  • RED half (unfixed tree): pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/scim-transaction-scope.test.ts …Tests 3 failed | 3 passed (6) — (a) POST, (a) PATCH, (b) red with the readings quoted under P2; (c), (d) green (they measure the unchanged non-SCIM posture); VERDICT command-exit 1.
  • GREEN half: … src/scim-transaction-scope.test.ts src/scim-deactivation-reconcile-user.test.tsTest Files 2 passed (2) · Tests 16 passed (16), VERDICT command-exit 0.
  • pnpm --filter @objectstack/plugin-auth typecheck (src + tsconfig.examples.json + check:test-typecheck) → check:test-typecheck: OK — … 10 file(s) / 94 error(s) / 23 pinned signature(s) held in test-typecheck-debt.json (ledger unchanged), VERDICT command-exit 0. (A first run failed only on examples/basic-usage.ts resolving the package's own unbuilt dist/ — PREREQUISITE NOT MET, cleared by pnpm --filter @objectstack/plugin-auth build.)
  • FULL suite: pnpm --filter @objectstack/plugin-auth test (the package's vitest run, VITEST_MAX_WORKERS from scripts/vitest-worker-cap.mjs) on ec7278259Test Files 91 passed (91) · Tests 1835 passed (1835), VERDICT command-exit 0 (held the lock 155 s). The six SCIM-adjacent siblings the dispatch names (credential-at-rest-posture, scim-case-insensitive-identifier, better-auth-schema-parity, last-admin-guard, managed-extension-fields, auth-manager) are inside that run. The two measured breakages the #3653 note records (sign-up 500s on the memory engine; the 180 s hook-timeout deadlock on single-connection sqlite) did not return.
  • pnpm check:system-context-censusOK — 109 elevation read sites in 20 packages across 45 files, all anchored; no re-anchor of content/docs/permissions/system-context.mdx needed (working tree clean after the gate).
  • pnpm check:nul-bytesOK (scanned 7980 text file(s) … no raw ASCII control bytes); grep -naP control-byte self-scan over the seven touched files: 0 hits.

Ablation (committed tree)

Mutation = the OLD mechanism restored on the committed tree ec7278259, in auth-manager.ts only: the door scope disabled (const runRequest = (false as boolean) && isScimProtocolPath(endpointPath)), and scimRequestScope.enterWith({ scim: true }) put back as the first line of the verifyBearerToken callback (with its destructure). Applied by perl -0pi, proven on disk by anchored counts, never by the editor's exit code: ABLATION-14522-door=1 (expect 1) · ABLATION-14522-import=1 (expect 1) · ABLATION-14522-enterWith=1 (expect 1) · original-door-line=0 (expect 0) · enterWith-total=2 (expect 1) — the 2 is the marker line plus the comment line "⛔ No scimRequestScope.enterWith(...) here", i.e. one real call; pre-mutation blob 4e1bede81930d4e97cf9aca641a4036d79bd63a3 = HEAD blob, post-mutation blob 7165d4eefaac562a1633f185d640374047d689f1. No build leg: the subject (AuthManager) is imported by both test files relatively from src (./auth-manager.js), not through a package exports map, so no dist/ is on the resolution path and ablation-dist-preflight does not apply.

Reading (predicted direction: pins (a)/(b) and #14360 face (c) red, (c)/(d) green): Test Files 2 failed (2) · Tests 4 failed | 12 passed (16) — red: (a) POST /Users …, (a) PATCH /Users/{id} active:false …, (b) a failure on the sys_scim_user write rolls … back, and (c) the IdP gets a 403 SCIM error naming the invariant, and the account stays active (the flipped line 487 reads false again under the old mechanism); green: (c) sign-up/sign-in zero transactions, (d) SCIM read, and the other ten #14360 cases. ABLATION vitest exit: 1; VERDICT command-exit 0 (the wrapper's own exit is the script's, which restores and exits 0).

Restore = git checkout HEAD -- ABS_PATH in trap … EXIT INT TERM, proven: RESTORE: hash-object=4e1bede81930d4e97cf9aca641a4036d79bd63a3 HEAD-blob=4e1bede81930d4e97cf9aca641a4036d79bd63a3 MATCH · git diff HEAD --stat: [] · porcelain: [].

Gates (derived on the final head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, no paths passed)

Derived at ec7278259 (the derivation's own stderr line: "gate list derived from the tree of 'objectstack-ai/objectstack' at commit ec72782"; it notes the tree is 10 commits behind origin/main2514d49f3 — post-merge landings, none under plugin-auth, and the merge-tree against that origin/main is clean). The list is byte-identical to the pre-merge derivation on a88c952fd (36 commands). Run unlocked in the foreground in batches under the ~10-minute cap — the lock script's own header classes check:* gates as unlocked sibling work, and the lock was saturated by a sibling's gate batches (two 540 s queue timeouts on the full-suite call before it acquired) — with each command's exit captured to a results file BEFORE any pipe. Batch 2 was cap-killed (exit 143) after scripts/pm/check-half-states.mjs alone took 517 s; the two commands it left unrun (check:engine-double-contract, check:logger-receiver-detach) were re-run in a fourth call.

  • 33 of 36 exit 0: check-adr-0087-registration, check-changeset-no-major, check-ci-filter-parity, check-comment-mask-adoption, check-cross-package-test-inputs (node and pnpm spellings), check-empty-changeset, check-keyed-text-bounds, check-plugin-teardown-shape, check-shard-attestation, check-system-context-census (OK — 109 elevation read sites in 20 packages across 45 files, all anchored), check-tenant-audit-census, check-undeclared-dep-imports, docs-audit/check-affected-docs, docs-audit/check-drift-comment, pm/check-half-states (report-only patrol; its 155 half-state(s) found is a board census, not a verdict on this PR), pm/release-rehearsal-clone --self-test, check:changeset-gate-self-tests, check:dispatcher-error-vocabulary, check:doc-authoring, check:engine-double-contract, check:logger-receiver-detach, check:objectql-double-limit, check:objectui-changeset, check:page-declaration-shape, check:pm-half-states, check:published-files, check:query-options-erasure, check:slot-lookup, check:test-source-alias, check:type-check-coverage (OK — 69/79 workspace packages type-checked (plus the root), 10 in the DEBT ledger), check:type-source-resolution, check:where-matcher.
  • 3 NOT MEASURED, each declared by the gate itself with exit 3 (its prerequisite code, distinct from a finding's 1): check-test-completeness.mjs ("PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named … record this gate as NOT MEASURED"); check:dual-build-cjs-loads ("PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … Run pnpm build first. ⛔ This is NOT a pass: nothing was measured"); check:type-check-debt ("--re-measure cannot run: 29 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk … Build the closure first, exactly as lint.yml does"). A whole-workspace build is a repo-level run this container cannot fit under the cap; CI owns all three.
  • 0 red.

pnpm lint (eslint . --no-inline-config, repo-wide) is CI's run; the local reading is a PROVEN narrowing, three pieces: ① the population is read from eslint.config.mjs itself — its own comment at line 328 states "(no parserOptions.project, no typed @typescript-eslint rules)", and every parserOptions block in the file carries only ecmaVersion / sourceType, so linting is not type-aware; ② pnpm exec eslint --no-inline-config --format json over the six touched .ts files → files linted: 6 | errors: 0 | warnings: 0, exit 0; ③ invariance: with no type-aware rules a file's verdict depends only on its own bytes, so this diff cannot move the verdict of any untouched file — the narrowing excludes nothing.

NOT MEASURED (scope guard 4)

  • Whether the scope propagated on @better-auth/scim 1.7.0-rc.1 — not re-measured. The #3653 note may well have been true when written (a different dispatch shape); the rewritten prose says the stamp "never reached this seam" on 1.7.2 and does not claim it never did.
  • Postgres / MySQL — measured on better-sqlite3 :memory: only. The mechanism is adapter-side (where the ALS store is read), not driver-side, so the reading should carry; driver.beginTransaction ≥ 1 is pinned on sqlite only.

Log levels

No new log site at any level. The vendor's [Better Auth]: back-channel logout planning failed … no such table: sys_oauth_access_token ERROR seen in the suite output is pre-existing harness noise (the SCIM harnesses register no OAuth objects), filed separately.

Out of scope

Filed as #14615 (finding, unassigned, after a targeted MCP search_issues dedup — four non-matching closed hits — with a positive control that returned #14522): the plugin-auth SCIM harnesses (the #14360 suite and, by copying its shape, the new pin) register no OAuth objects, so every driven sign-in prints a Better Auth ERROR back-channel logout planning failed … no such table: sys_oauth_access_token. Green-run noise, not a failure; not touched here.

Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…M provisioning runs inside one engine transaction
The adapter's `transaction` config opens a real `engine.transaction()` only
while `inScimRequestScope()` reads true. The scope was stamped with
`AsyncLocalStorage.enterWith` inside the `verifyBearerToken` callback handed
to `@better-auth/scim`, and on 1.7.2 the store never reached the writes: an
`enterWith` marks only the async resource it runs in and its descendants,
and the vendor resumes the endpoint handler from a continuation captured
before the verifier ran. Measured: zero `engine.transaction` and zero
`driver.beginTransaction` calls across POST /Users + PATCH /Users/{id},
`inScimRequestScope()` false inside every sys_user / sys_scim_user write.
`AuthManager.handleRequest` now opens the scope with `run(...)` around every
request whose better-auth endpoint path is under `/scim/v2` — the same seam
the actor-attribution scope and the subject-erasure transaction use, and
exactly as narrow as before: non-SCIM flows keep their sequential posture.
A refused last-administrator deactivation now rolls the vendor's own
`scimUser.active = false` write back; the #14360 suite's face (c) pin on that
residual is flipped from `false` to `true` deliberately. A new runtime pin
(`scim-transaction-scope.test.ts`) observes each SCIM mutation calling
`engine.transaction`, a failed provisioning leaving no partial identity, and
sign-up/sign-in opening zero transactions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 8 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/user-ban-write.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx(via AuthManager (symbol, a top-level class), handleRequest (symbol, a method of class AuthManager))
  • content/docs/kernel/services-checklist.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via AuthManager (symbol, a top-level class), createObjectQLAdapterFactory (symbol, a top-level function), handleRequest (symbol, a method of class AuthManager))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/user-ban-write.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 11 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 9d7f7259fa96bd79138d3e55c2801886f56221bbpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3700abebe9135748c5eabe9d57041eefdf9c72a3 — the merge of head ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce into base 9d7f7259fa96bd79138d3e55c2801886f56221bb, 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 3700abebe9135748c5eabe9d57041eefdf9c72a3 && git checkout 3700abebe9135748c5eabe9d57041eefdf9c72a3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9d7f7259fa96bd79138d3e55c2801886f56221bb ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce && git checkout -B drift-repro 9d7f7259fa96bd79138d3e55c2801886f56221bb && git merge --no-ff ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce
node scripts/docs-audit/affected-docs.mjs --json 9d7f7259fa96bd79138d3e55c2801886f56221bb

⚠️ 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 9d7f7259fa96bd79138d3e55c2801886f56221bb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head ec7278259


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(plugin-auth): open the SCIM request scope at handleRequest so SCIM provisioning runs inside one engine transaction (#14522) - #14624

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14522-scim-transaction-scope
Sep 2, 2026
Merged

fix(plugin-auth): open the SCIM request scope at handleRequest so SCIM provisioning runs inside one engine transaction (#14522)#14624
os-sales merged 2 commits into
mainfrom
claude/issue-14522-scim-transaction-scope

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#14522

What changed

@better-auth/scim wraps every User/Group mutation in runIdentityMutationTransaction@better-auth/core's runWithTransaction(adapter, fn)adapter.transaction(...). On this adapter, transaction opens a real engine.transaction() only while inScimRequestScope() reads true. That scope was stamped with AsyncLocalStorage.enterWith inside the verifyBearerToken callback handed to the SCIM plugin — and at write time the store was gone, so the vendor's transaction callback ran with no engine transaction at all (the #3653 scoping note in objectql-adapter.ts declared the opposite).

The fix opens the scope where it is observed: AuthManager.handleRequest now wraps the whole request in scimRequestScope.run({ scim: true }, ...) when the better-auth endpoint path is under /scim/v2 — the same door the actor-attribution scope (runWithAuthActorScope) and the subject-erasure transaction (runSubjectErasureAtomically) already use, keyed on the same betterAuthEndpointPath(request) reading. run has a callback boundary; every als.run the vendor performs underneath nests inside it. The verifier no longer stamps anything. scimRequestScope / inScimRequestScope stay in use, unchanged in shape.

Files: auth-manager.ts (the door scope + SCIM_PROTOCOL_PATH_PREFIX, the verifier's stamp removed, the reconcileScimUserLifecycle doc corrected), objectql-adapter.ts (the #3653 note rewritten — where the scope is stamped now and why the verifier stamp never propagated), scim-connection-service.ts (the ALS doc rewritten), user-ban-write.ts (one stale "once that transaction is real" clause corrected — prose only, the write is untouched), scim-deactivation-reconcile-user.test.ts (face (c) line 487 flipped falsetrue deliberately, comment rewritten; line 501 untouched), new scim-transaction-scope.test.ts (the runtime pin), one patch changeset for @objectstack/plugin-auth.

Triage scope guards (14522#issuecomment-5507946166, quoted verbatim)

  1. Do not make non-SCIM better-auth flows transactional.objectql-adapter.ts:826-828 records that the conditional is load-bearing against "two measured breakages", and the historical sequential behaviour for non-SCIM flows is deliberate. Whatever replaces inScimRequestScope() must be at least as narrow — a /scim/v2-scoped signal, not "transactions on by default".
  2. Land the runtime pin the card asks for.credential-at-rest-posture.test.ts's note that the vendor refuses to mount on a sequential-fallback transaction is a mount-time assertion and it passed while this was broken. Add the observation the card names: a SCIM mutation observed to call engine.transaction at least once. A fix without that pin can regress silently the same way.
  3. [finding] SCIM active:false no longer disables the account — the vendor ban coupling was removed upstream in @better-auth/scim 1.7.0 and nothing in this repo replaced it #14360's suite already pins the residualscim-deactivation-reconcile-user.test.ts, face (c). Flip that assertion deliberately and say so in the changeset; do not let it look like an incidental test edit.
  4. The card's two unmeasured items stay unmeasured unless cheap: whether the scope propagated on 1.7.0-rc.1 (may well have — the #3653 note may have been true when written, and saying so is fairer than implying it was always wrong), and Postgres/MySQL. The mechanism is adapter-side, so the sqlite reading should carry; note it rather than re-running the matrix.

How each is met: (1) the door scope is keyed on the endpoint path prefix /scim/v2 — the predicate the vendor's own after-hook matcher uses — and nothing else changed in the adapter's conditional; pin (c) below measures sign-up + sign-in at ZERO engine.transaction calls, and the FULL plugin-auth suite is green. (2) scim-transaction-scope.test.ts (a) observes POST /Users and PATCH /Users/{id} each calling engine.transaction ≥ 1 and driver.beginTransaction ≥ 1, with every identity write sampled INSIDE the scope. (3) line 487 flipped falsetrue with a rewritten comment; the changeset names the flip. (4) both items recorded as NOT MEASURED below.

Step 0 — base

git merge-base --is-ancestor 21c7dbe76b7d44df32fbd9f6497db6372b9f2d0f origin/main exits 1, and that is the squash, not a missing landing: PR #14540 landed as 7303cbf4d ("… (#14360) (#14540)") on origin/main, and the file to flip is on origin/main with a blob byte-identical to the PR head's (git rev-parse of scim-deactivation-reconcile-user.test.ts at 21c7dbe76 and at origin/main both 8a4d445f4ed811c9d257e211910dffe9f96ebae0; git diff --stat between them exits 0 printing nothing). Worktree cut from origin/mainf60ab90ae (BASE), not from the PR branch; origin/main (2a2653619) merged in afterwards as ec7278259 (clean, no regen-pending, plugin-auth untouched on main in that range).

Premise checks (on origin/main before any edit)

  • P1 holds.objectql-adapter.ts:829if (!inScimRequestScope()) return cb(wrappedAdapter as never); (:8 imports it); auth-manager.ts:3279scimRequestScope.enterWith({ scim: true }); inside the verifyBearerToken callback (:3269 destructures it from the dynamic import); scim-connection-service.ts:63 the ALS, :66-67 the reader. The adapter still reads the ALS; the stamp still lives in the verifier.
  • P2 holds — reproduced before editing, kept as the red half.scim-transaction-scope.test.ts on the unfixed tree (f60ab90ae + the test file only): engine.transaction0 calls on POST /scim/v2/Users, writes sampled insert:sys_scim_connection_binding:scim insert:sys_user:NO-SCOPE insert:sys_scim_subject:NO-SCOPE insert:sys_scim_user:NO-SCOPE update:sys_scim_subject:NO-SCOPE update:sys_scim_connection_binding:NO-SCOPE; 0 calls on PATCH /scim/v2/Users/{id}active:false, writes update:sys_scim_subject:NO-SCOPE update:sys_user:NO-SCOPE update:sys_scim_user:NO-SCOPE update:sys_user:NO-SCOPE update:sys_scim_connection_binding:NO-SCOPE; the (b) atomicity case: sys_user survived the failed provisioning. Both requests answered 201 / 200. Note the one scim-scoped write: the vendor's connection-binding insert, made by the auth middleware right after await verifyBearerToken() — a DESCENDANT of the stamp — is the only write that saw it, which is exactly the enterWith shape.
  • P3 holds. face (c) line 487 read expect(await scimActive(h, owner.scimId)).toBe(false); under the comment block naming this card; line 501 is the positive control's false.
  • P4 holds. Installed @better-auth/scim1.7.2 / better-auth1.7.2 / @better-auth/core1.7.2 (pnpm store node_modules/.pnpm/@better-auth+scim@1.7.2_…). @better-auth/scim/dist/index.mjs:4002runIdentityMutationTransaction:4011runWithTransaction(adapter, async () => callback(await getCurrentAdapter(adapter))) (imported from @better-auth/core/context, :5); @better-auth/core/dist/context/transaction.mjsrunWithTransactionadapter.transaction(async (trx) => als.run({ adapter: trx, pendingHooks, isTransactionActive: true }, fn)). The three SCIM mutation sites: :6527 (POST /Users), :6749, :6839 (PATCH / PUT). assertNativeSCIMTransactions at :5499 asks only typeof adapter.options?.adapterConfig.transaction === "function".
  • P5 holds.packages/spec/** and content/docs/releases/** untouched (diff file list above).

Hypotheses (by measurement)

  • H1 holds — mechanism measured, boundary named. A throwaway probe (deleted before commit) drove the installed better-auth 1.7.2 directly: betterAuth({...}) with a global hooks.before doing alsA.enterWith(...), a plugin endpoint whose use middleware does alsB.enterWith(...) (the verifier's shape), the request issued inside alsC.run(...), and a fourth ALS opened at the door and MUTATED from hooks.before; the handler read all four through a nested als.run (the shape runWithTransaction uses). Reading: beforeHookEnterWith: null, useMiddlewareEnterWith: null, doorRun: { via: "door run" }, beforeHookMutatedDoorStore: { flag: true }, endpointContextPath: "/probe/scope". So the boundary is the await of the middleware chain in the endpoint frame — better-call's endpoint runner awaits the use middlewares (the verifier's caller) and dispatchAuthEndpoint awaits runBeforeHooks(...) (better-auth/dist/api/dispatch.mjs:205-228, which then re-enters the handler under a second runWithEndpointContext) — from continuations captured BEFORE the stamp. An enterWith marks the async resource it runs in and that resource's descendants only.
  • H2 holds — remedy picked by measurement. Candidate (a) as literally proposed (enterWith in hooks.before) is REFUTED by the probe (null). Candidate (b) — reading the vendor's endpoint context at write time — is VIABLE (tryGetCurrentAuthEndpointContext()?.path reads /probe/scope through the nested als.run; @better-auth/core/context exports it, and session-tombstone.ts:231 already imports from that module), but it retires scimRequestScope / inScimRequestScope, which the dispatch declares an export removal (Clause-② yes + stop), and couples the adapter to the endpoint-context API. Chosen: the run-scoped stamp at the door (doorRun visible; beforeHookMutatedDoorStore shows the same run-at-the-door mechanism the actor-attribution scope relies on). It is the house pattern for both a request-scoped ALS and endpoint-path-keyed atomicity, keeps both symbols in use, and is exactly as narrow: SCIM protocol requests only.
  • H3 holds. Green half on the fixed tree: scim-transaction-scope.test.ts 5/5 and scim-deactivation-reconcile-user.test.ts 11/11 (Test Files 2 passed (2) · Tests 16 passed (16)), including the flipped face (c) — the refused last-administrator deactivation now leaves the SCIM resource reading active: true while the positive control's false on line 501 still passes.
  • H4 holds. Pin (c): sign-up + sign-in through handleRequest write sys_user / sys_account / sys_session with engine.transaction0 and driver.beginTransaction0, no write in the SCIM scope. FULL @objectstack/plugin-auth suite: pnpm --filter @objectstack/plugin-auth test (the package's vitest run, VITEST_MAX_WORKERS from scripts/vitest-worker-cap.mjs) on ec7278259Test Files 91 passed (91) · Tests 1835 passed (1835), VERDICT command-exit 0 (held the lock 155 s). The six SCIM-adjacent siblings the dispatch names (credential-at-rest-posture, scim-case-insensitive-identifier, better-auth-schema-parity, last-admin-guard, managed-extension-fields, auth-manager) are inside that run. The two measured breakages the #3653 note records (sign-up 500s on the memory engine; the 180 s hook-timeout deadlock on single-connection sqlite) did not return.
  • H5 holds.git diff -U0 origin/main...HEAD | grep -E '^[+-].*\bexport\b' → nothing (grep exit 1). Clause-② no. Changeset patch for @objectstack/plugin-auth.

Tests (head ec7278259)

  • RED half (unfixed tree): pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/scim-transaction-scope.test.ts …Tests 3 failed | 3 passed (6) — (a) POST, (a) PATCH, (b) red with the readings quoted under P2; (c), (d) green (they measure the unchanged non-SCIM posture); VERDICT command-exit 1.
  • GREEN half: … src/scim-transaction-scope.test.ts src/scim-deactivation-reconcile-user.test.tsTest Files 2 passed (2) · Tests 16 passed (16), VERDICT command-exit 0.
  • pnpm --filter @objectstack/plugin-auth typecheck (src + tsconfig.examples.json + check:test-typecheck) → check:test-typecheck: OK — … 10 file(s) / 94 error(s) / 23 pinned signature(s) held in test-typecheck-debt.json (ledger unchanged), VERDICT command-exit 0. (A first run failed only on examples/basic-usage.ts resolving the package's own unbuilt dist/ — PREREQUISITE NOT MET, cleared by pnpm --filter @objectstack/plugin-auth build.)
  • FULL suite: pnpm --filter @objectstack/plugin-auth test (the package's vitest run, VITEST_MAX_WORKERS from scripts/vitest-worker-cap.mjs) on ec7278259Test Files 91 passed (91) · Tests 1835 passed (1835), VERDICT command-exit 0 (held the lock 155 s). The six SCIM-adjacent siblings the dispatch names (credential-at-rest-posture, scim-case-insensitive-identifier, better-auth-schema-parity, last-admin-guard, managed-extension-fields, auth-manager) are inside that run. The two measured breakages the #3653 note records (sign-up 500s on the memory engine; the 180 s hook-timeout deadlock on single-connection sqlite) did not return.
  • pnpm check:system-context-censusOK — 109 elevation read sites in 20 packages across 45 files, all anchored; no re-anchor of content/docs/permissions/system-context.mdx needed (working tree clean after the gate).
  • pnpm check:nul-bytesOK (scanned 7980 text file(s) … no raw ASCII control bytes); grep -naP control-byte self-scan over the seven touched files: 0 hits.

Ablation (committed tree)

Mutation = the OLD mechanism restored on the committed tree ec7278259, in auth-manager.ts only: the door scope disabled (const runRequest = (false as boolean) && isScimProtocolPath(endpointPath)), and scimRequestScope.enterWith({ scim: true }) put back as the first line of the verifyBearerToken callback (with its destructure). Applied by perl -0pi, proven on disk by anchored counts, never by the editor's exit code: ABLATION-14522-door=1 (expect 1) · ABLATION-14522-import=1 (expect 1) · ABLATION-14522-enterWith=1 (expect 1) · original-door-line=0 (expect 0) · enterWith-total=2 (expect 1) — the 2 is the marker line plus the comment line "⛔ No scimRequestScope.enterWith(...) here", i.e. one real call; pre-mutation blob 4e1bede81930d4e97cf9aca641a4036d79bd63a3 = HEAD blob, post-mutation blob 7165d4eefaac562a1633f185d640374047d689f1. No build leg: the subject (AuthManager) is imported by both test files relatively from src (./auth-manager.js), not through a package exports map, so no dist/ is on the resolution path and ablation-dist-preflight does not apply.

Reading (predicted direction: pins (a)/(b) and #14360 face (c) red, (c)/(d) green): Test Files 2 failed (2) · Tests 4 failed | 12 passed (16) — red: (a) POST /Users …, (a) PATCH /Users/{id} active:false …, (b) a failure on the sys_scim_user write rolls … back, and (c) the IdP gets a 403 SCIM error naming the invariant, and the account stays active (the flipped line 487 reads false again under the old mechanism); green: (c) sign-up/sign-in zero transactions, (d) SCIM read, and the other ten #14360 cases. ABLATION vitest exit: 1; VERDICT command-exit 0 (the wrapper's own exit is the script's, which restores and exits 0).

Restore = git checkout HEAD -- ABS_PATH in trap … EXIT INT TERM, proven: RESTORE: hash-object=4e1bede81930d4e97cf9aca641a4036d79bd63a3 HEAD-blob=4e1bede81930d4e97cf9aca641a4036d79bd63a3 MATCH · git diff HEAD --stat: [] · porcelain: [].

Gates (derived on the final head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, no paths passed)

Derived at ec7278259 (the derivation's own stderr line: "gate list derived from the tree of 'objectstack-ai/objectstack' at commit ec72782"; it notes the tree is 10 commits behind origin/main2514d49f3 — post-merge landings, none under plugin-auth, and the merge-tree against that origin/main is clean). The list is byte-identical to the pre-merge derivation on a88c952fd (36 commands). Run unlocked in the foreground in batches under the ~10-minute cap — the lock script's own header classes check:* gates as unlocked sibling work, and the lock was saturated by a sibling's gate batches (two 540 s queue timeouts on the full-suite call before it acquired) — with each command's exit captured to a results file BEFORE any pipe. Batch 2 was cap-killed (exit 143) after scripts/pm/check-half-states.mjs alone took 517 s; the two commands it left unrun (check:engine-double-contract, check:logger-receiver-detach) were re-run in a fourth call.

  • 33 of 36 exit 0: check-adr-0087-registration, check-changeset-no-major, check-ci-filter-parity, check-comment-mask-adoption, check-cross-package-test-inputs (node and pnpm spellings), check-empty-changeset, check-keyed-text-bounds, check-plugin-teardown-shape, check-shard-attestation, check-system-context-census (OK — 109 elevation read sites in 20 packages across 45 files, all anchored), check-tenant-audit-census, check-undeclared-dep-imports, docs-audit/check-affected-docs, docs-audit/check-drift-comment, pm/check-half-states (report-only patrol; its 155 half-state(s) found is a board census, not a verdict on this PR), pm/release-rehearsal-clone --self-test, check:changeset-gate-self-tests, check:dispatcher-error-vocabulary, check:doc-authoring, check:engine-double-contract, check:logger-receiver-detach, check:objectql-double-limit, check:objectui-changeset, check:page-declaration-shape, check:pm-half-states, check:published-files, check:query-options-erasure, check:slot-lookup, check:test-source-alias, check:type-check-coverage (OK — 69/79 workspace packages type-checked (plus the root), 10 in the DEBT ledger), check:type-source-resolution, check:where-matcher.
  • 3 NOT MEASURED, each declared by the gate itself with exit 3 (its prerequisite code, distinct from a finding's 1): check-test-completeness.mjs ("PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named … record this gate as NOT MEASURED"); check:dual-build-cjs-loads ("PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … Run pnpm build first. ⛔ This is NOT a pass: nothing was measured"); check:type-check-debt ("--re-measure cannot run: 29 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk … Build the closure first, exactly as lint.yml does"). A whole-workspace build is a repo-level run this container cannot fit under the cap; CI owns all three.
  • 0 red.

pnpm lint (eslint . --no-inline-config, repo-wide) is CI's run; the local reading is a PROVEN narrowing, three pieces: ① the population is read from eslint.config.mjs itself — its own comment at line 328 states "(no parserOptions.project, no typed @typescript-eslint rules)", and every parserOptions block in the file carries only ecmaVersion / sourceType, so linting is not type-aware; ② pnpm exec eslint --no-inline-config --format json over the six touched .ts files → files linted: 6 | errors: 0 | warnings: 0, exit 0; ③ invariance: with no type-aware rules a file's verdict depends only on its own bytes, so this diff cannot move the verdict of any untouched file — the narrowing excludes nothing.

NOT MEASURED (scope guard 4)

  • Whether the scope propagated on @better-auth/scim 1.7.0-rc.1 — not re-measured. The #3653 note may well have been true when written (a different dispatch shape); the rewritten prose says the stamp "never reached this seam" on 1.7.2 and does not claim it never did.
  • Postgres / MySQL — measured on better-sqlite3 :memory: only. The mechanism is adapter-side (where the ALS store is read), not driver-side, so the reading should carry; driver.beginTransaction ≥ 1 is pinned on sqlite only.

Log levels

No new log site at any level. The vendor's [Better Auth]: back-channel logout planning failed … no such table: sys_oauth_access_token ERROR seen in the suite output is pre-existing harness noise (the SCIM harnesses register no OAuth objects), filed separately.

Out of scope

Filed as #14615 (finding, unassigned, after a targeted MCP search_issues dedup — four non-matching closed hits — with a positive control that returned #14522): the plugin-auth SCIM harnesses (the #14360 suite and, by copying its shape, the new pin) register no OAuth objects, so every driven sign-in prints a Better Auth ERROR back-channel logout planning failed … no such table: sys_oauth_access_token. Green-run noise, not a failure; not touched here.

Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…M provisioning runs inside one engine transaction
The adapter's `transaction` config opens a real `engine.transaction()` only
while `inScimRequestScope()` reads true. The scope was stamped with
`AsyncLocalStorage.enterWith` inside the `verifyBearerToken` callback handed
to `@better-auth/scim`, and on 1.7.2 the store never reached the writes: an
`enterWith` marks only the async resource it runs in and its descendants,
and the vendor resumes the endpoint handler from a continuation captured
before the verifier ran. Measured: zero `engine.transaction` and zero
`driver.beginTransaction` calls across POST /Users + PATCH /Users/{id},
`inScimRequestScope()` false inside every sys_user / sys_scim_user write.
`AuthManager.handleRequest` now opens the scope with `run(...)` around every
request whose better-auth endpoint path is under `/scim/v2` — the same seam
the actor-attribution scope and the subject-erasure transaction use, and
exactly as narrow as before: non-SCIM flows keep their sequential posture.
A refused last-administrator deactivation now rolls the vendor's own
`scimUser.active = false` write back; the #14360 suite's face (c) pin on that
residual is flipped from `false` to `true` deliberately. A new runtime pin
(`scim-transaction-scope.test.ts`) observes each SCIM mutation calling
`engine.transaction`, a failed provisioning leaving no partial identity, and
sign-up/sign-in opening zero transactions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 8 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/user-ban-write.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx(via AuthManager (symbol, a top-level class), handleRequest (symbol, a method of class AuthManager))
  • content/docs/kernel/services-checklist.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via AuthManager (symbol, a top-level class), createObjectQLAdapterFactory (symbol, a top-level function), handleRequest (symbol, a method of class AuthManager))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/user-ban-write.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 11 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 9d7f7259fa96bd79138d3e55c2801886f56221bbpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3700abebe9135748c5eabe9d57041eefdf9c72a3 — the merge of head ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce into base 9d7f7259fa96bd79138d3e55c2801886f56221bb, 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 3700abebe9135748c5eabe9d57041eefdf9c72a3 && git checkout 3700abebe9135748c5eabe9d57041eefdf9c72a3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9d7f7259fa96bd79138d3e55c2801886f56221bb ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce && git checkout -B drift-repro 9d7f7259fa96bd79138d3e55c2801886f56221bb && git merge --no-ff ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce
node scripts/docs-audit/affected-docs.mjs --json 9d7f7259fa96bd79138d3e55c2801886f56221bb

⚠️ 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 9d7f7259fa96bd79138d3e55c2801886f56221bb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head ec7278259


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-auth): open the SCIM request scope at handleRequest so SCIM provisioning runs inside one engine transaction (#14522) - #14624

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14522-scim-transaction-scope
Sep 2, 2026
Merged

fix(plugin-auth): open the SCIM request scope at handleRequest so SCIM provisioning runs inside one engine transaction (#14522)#14624
os-sales merged 2 commits into
mainfrom
claude/issue-14522-scim-transaction-scope

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#14522

What changed

@better-auth/scim wraps every User/Group mutation in runIdentityMutationTransaction@better-auth/core's runWithTransaction(adapter, fn)adapter.transaction(...). On this adapter, transaction opens a real engine.transaction() only while inScimRequestScope() reads true. That scope was stamped with AsyncLocalStorage.enterWith inside the verifyBearerToken callback handed to the SCIM plugin — and at write time the store was gone, so the vendor's transaction callback ran with no engine transaction at all (the #3653 scoping note in objectql-adapter.ts declared the opposite).

The fix opens the scope where it is observed: AuthManager.handleRequest now wraps the whole request in scimRequestScope.run({ scim: true }, ...) when the better-auth endpoint path is under /scim/v2 — the same door the actor-attribution scope (runWithAuthActorScope) and the subject-erasure transaction (runSubjectErasureAtomically) already use, keyed on the same betterAuthEndpointPath(request) reading. run has a callback boundary; every als.run the vendor performs underneath nests inside it. The verifier no longer stamps anything. scimRequestScope / inScimRequestScope stay in use, unchanged in shape.

Files: auth-manager.ts (the door scope + SCIM_PROTOCOL_PATH_PREFIX, the verifier's stamp removed, the reconcileScimUserLifecycle doc corrected), objectql-adapter.ts (the #3653 note rewritten — where the scope is stamped now and why the verifier stamp never propagated), scim-connection-service.ts (the ALS doc rewritten), user-ban-write.ts (one stale "once that transaction is real" clause corrected — prose only, the write is untouched), scim-deactivation-reconcile-user.test.ts (face (c) line 487 flipped falsetrue deliberately, comment rewritten; line 501 untouched), new scim-transaction-scope.test.ts (the runtime pin), one patch changeset for @objectstack/plugin-auth.

Triage scope guards (14522#issuecomment-5507946166, quoted verbatim)

  1. Do not make non-SCIM better-auth flows transactional.objectql-adapter.ts:826-828 records that the conditional is load-bearing against "two measured breakages", and the historical sequential behaviour for non-SCIM flows is deliberate. Whatever replaces inScimRequestScope() must be at least as narrow — a /scim/v2-scoped signal, not "transactions on by default".
  2. Land the runtime pin the card asks for.credential-at-rest-posture.test.ts's note that the vendor refuses to mount on a sequential-fallback transaction is a mount-time assertion and it passed while this was broken. Add the observation the card names: a SCIM mutation observed to call engine.transaction at least once. A fix without that pin can regress silently the same way.
  3. [finding] SCIM active:false no longer disables the account — the vendor ban coupling was removed upstream in @better-auth/scim 1.7.0 and nothing in this repo replaced it #14360's suite already pins the residualscim-deactivation-reconcile-user.test.ts, face (c). Flip that assertion deliberately and say so in the changeset; do not let it look like an incidental test edit.
  4. The card's two unmeasured items stay unmeasured unless cheap: whether the scope propagated on 1.7.0-rc.1 (may well have — the #3653 note may have been true when written, and saying so is fairer than implying it was always wrong), and Postgres/MySQL. The mechanism is adapter-side, so the sqlite reading should carry; note it rather than re-running the matrix.

How each is met: (1) the door scope is keyed on the endpoint path prefix /scim/v2 — the predicate the vendor's own after-hook matcher uses — and nothing else changed in the adapter's conditional; pin (c) below measures sign-up + sign-in at ZERO engine.transaction calls, and the FULL plugin-auth suite is green. (2) scim-transaction-scope.test.ts (a) observes POST /Users and PATCH /Users/{id} each calling engine.transaction ≥ 1 and driver.beginTransaction ≥ 1, with every identity write sampled INSIDE the scope. (3) line 487 flipped falsetrue with a rewritten comment; the changeset names the flip. (4) both items recorded as NOT MEASURED below.

Step 0 — base

git merge-base --is-ancestor 21c7dbe76b7d44df32fbd9f6497db6372b9f2d0f origin/main exits 1, and that is the squash, not a missing landing: PR #14540 landed as 7303cbf4d ("… (#14360) (#14540)") on origin/main, and the file to flip is on origin/main with a blob byte-identical to the PR head's (git rev-parse of scim-deactivation-reconcile-user.test.ts at 21c7dbe76 and at origin/main both 8a4d445f4ed811c9d257e211910dffe9f96ebae0; git diff --stat between them exits 0 printing nothing). Worktree cut from origin/mainf60ab90ae (BASE), not from the PR branch; origin/main (2a2653619) merged in afterwards as ec7278259 (clean, no regen-pending, plugin-auth untouched on main in that range).

Premise checks (on origin/main before any edit)

  • P1 holds.objectql-adapter.ts:829if (!inScimRequestScope()) return cb(wrappedAdapter as never); (:8 imports it); auth-manager.ts:3279scimRequestScope.enterWith({ scim: true }); inside the verifyBearerToken callback (:3269 destructures it from the dynamic import); scim-connection-service.ts:63 the ALS, :66-67 the reader. The adapter still reads the ALS; the stamp still lives in the verifier.
  • P2 holds — reproduced before editing, kept as the red half.scim-transaction-scope.test.ts on the unfixed tree (f60ab90ae + the test file only): engine.transaction0 calls on POST /scim/v2/Users, writes sampled insert:sys_scim_connection_binding:scim insert:sys_user:NO-SCOPE insert:sys_scim_subject:NO-SCOPE insert:sys_scim_user:NO-SCOPE update:sys_scim_subject:NO-SCOPE update:sys_scim_connection_binding:NO-SCOPE; 0 calls on PATCH /scim/v2/Users/{id}active:false, writes update:sys_scim_subject:NO-SCOPE update:sys_user:NO-SCOPE update:sys_scim_user:NO-SCOPE update:sys_user:NO-SCOPE update:sys_scim_connection_binding:NO-SCOPE; the (b) atomicity case: sys_user survived the failed provisioning. Both requests answered 201 / 200. Note the one scim-scoped write: the vendor's connection-binding insert, made by the auth middleware right after await verifyBearerToken() — a DESCENDANT of the stamp — is the only write that saw it, which is exactly the enterWith shape.
  • P3 holds. face (c) line 487 read expect(await scimActive(h, owner.scimId)).toBe(false); under the comment block naming this card; line 501 is the positive control's false.
  • P4 holds. Installed @better-auth/scim1.7.2 / better-auth1.7.2 / @better-auth/core1.7.2 (pnpm store node_modules/.pnpm/@better-auth+scim@1.7.2_…). @better-auth/scim/dist/index.mjs:4002runIdentityMutationTransaction:4011runWithTransaction(adapter, async () => callback(await getCurrentAdapter(adapter))) (imported from @better-auth/core/context, :5); @better-auth/core/dist/context/transaction.mjsrunWithTransactionadapter.transaction(async (trx) => als.run({ adapter: trx, pendingHooks, isTransactionActive: true }, fn)). The three SCIM mutation sites: :6527 (POST /Users), :6749, :6839 (PATCH / PUT). assertNativeSCIMTransactions at :5499 asks only typeof adapter.options?.adapterConfig.transaction === "function".
  • P5 holds.packages/spec/** and content/docs/releases/** untouched (diff file list above).

Hypotheses (by measurement)

  • H1 holds — mechanism measured, boundary named. A throwaway probe (deleted before commit) drove the installed better-auth 1.7.2 directly: betterAuth({...}) with a global hooks.before doing alsA.enterWith(...), a plugin endpoint whose use middleware does alsB.enterWith(...) (the verifier's shape), the request issued inside alsC.run(...), and a fourth ALS opened at the door and MUTATED from hooks.before; the handler read all four through a nested als.run (the shape runWithTransaction uses). Reading: beforeHookEnterWith: null, useMiddlewareEnterWith: null, doorRun: { via: "door run" }, beforeHookMutatedDoorStore: { flag: true }, endpointContextPath: "/probe/scope". So the boundary is the await of the middleware chain in the endpoint frame — better-call's endpoint runner awaits the use middlewares (the verifier's caller) and dispatchAuthEndpoint awaits runBeforeHooks(...) (better-auth/dist/api/dispatch.mjs:205-228, which then re-enters the handler under a second runWithEndpointContext) — from continuations captured BEFORE the stamp. An enterWith marks the async resource it runs in and that resource's descendants only.
  • H2 holds — remedy picked by measurement. Candidate (a) as literally proposed (enterWith in hooks.before) is REFUTED by the probe (null). Candidate (b) — reading the vendor's endpoint context at write time — is VIABLE (tryGetCurrentAuthEndpointContext()?.path reads /probe/scope through the nested als.run; @better-auth/core/context exports it, and session-tombstone.ts:231 already imports from that module), but it retires scimRequestScope / inScimRequestScope, which the dispatch declares an export removal (Clause-② yes + stop), and couples the adapter to the endpoint-context API. Chosen: the run-scoped stamp at the door (doorRun visible; beforeHookMutatedDoorStore shows the same run-at-the-door mechanism the actor-attribution scope relies on). It is the house pattern for both a request-scoped ALS and endpoint-path-keyed atomicity, keeps both symbols in use, and is exactly as narrow: SCIM protocol requests only.
  • H3 holds. Green half on the fixed tree: scim-transaction-scope.test.ts 5/5 and scim-deactivation-reconcile-user.test.ts 11/11 (Test Files 2 passed (2) · Tests 16 passed (16)), including the flipped face (c) — the refused last-administrator deactivation now leaves the SCIM resource reading active: true while the positive control's false on line 501 still passes.
  • H4 holds. Pin (c): sign-up + sign-in through handleRequest write sys_user / sys_account / sys_session with engine.transaction0 and driver.beginTransaction0, no write in the SCIM scope. FULL @objectstack/plugin-auth suite: pnpm --filter @objectstack/plugin-auth test (the package's vitest run, VITEST_MAX_WORKERS from scripts/vitest-worker-cap.mjs) on ec7278259Test Files 91 passed (91) · Tests 1835 passed (1835), VERDICT command-exit 0 (held the lock 155 s). The six SCIM-adjacent siblings the dispatch names (credential-at-rest-posture, scim-case-insensitive-identifier, better-auth-schema-parity, last-admin-guard, managed-extension-fields, auth-manager) are inside that run. The two measured breakages the #3653 note records (sign-up 500s on the memory engine; the 180 s hook-timeout deadlock on single-connection sqlite) did not return.
  • H5 holds.git diff -U0 origin/main...HEAD | grep -E '^[+-].*\bexport\b' → nothing (grep exit 1). Clause-② no. Changeset patch for @objectstack/plugin-auth.

Tests (head ec7278259)

  • RED half (unfixed tree): pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/scim-transaction-scope.test.ts …Tests 3 failed | 3 passed (6) — (a) POST, (a) PATCH, (b) red with the readings quoted under P2; (c), (d) green (they measure the unchanged non-SCIM posture); VERDICT command-exit 1.
  • GREEN half: … src/scim-transaction-scope.test.ts src/scim-deactivation-reconcile-user.test.tsTest Files 2 passed (2) · Tests 16 passed (16), VERDICT command-exit 0.
  • pnpm --filter @objectstack/plugin-auth typecheck (src + tsconfig.examples.json + check:test-typecheck) → check:test-typecheck: OK — … 10 file(s) / 94 error(s) / 23 pinned signature(s) held in test-typecheck-debt.json (ledger unchanged), VERDICT command-exit 0. (A first run failed only on examples/basic-usage.ts resolving the package's own unbuilt dist/ — PREREQUISITE NOT MET, cleared by pnpm --filter @objectstack/plugin-auth build.)
  • FULL suite: pnpm --filter @objectstack/plugin-auth test (the package's vitest run, VITEST_MAX_WORKERS from scripts/vitest-worker-cap.mjs) on ec7278259Test Files 91 passed (91) · Tests 1835 passed (1835), VERDICT command-exit 0 (held the lock 155 s). The six SCIM-adjacent siblings the dispatch names (credential-at-rest-posture, scim-case-insensitive-identifier, better-auth-schema-parity, last-admin-guard, managed-extension-fields, auth-manager) are inside that run. The two measured breakages the #3653 note records (sign-up 500s on the memory engine; the 180 s hook-timeout deadlock on single-connection sqlite) did not return.
  • pnpm check:system-context-censusOK — 109 elevation read sites in 20 packages across 45 files, all anchored; no re-anchor of content/docs/permissions/system-context.mdx needed (working tree clean after the gate).
  • pnpm check:nul-bytesOK (scanned 7980 text file(s) … no raw ASCII control bytes); grep -naP control-byte self-scan over the seven touched files: 0 hits.

Ablation (committed tree)

Mutation = the OLD mechanism restored on the committed tree ec7278259, in auth-manager.ts only: the door scope disabled (const runRequest = (false as boolean) && isScimProtocolPath(endpointPath)), and scimRequestScope.enterWith({ scim: true }) put back as the first line of the verifyBearerToken callback (with its destructure). Applied by perl -0pi, proven on disk by anchored counts, never by the editor's exit code: ABLATION-14522-door=1 (expect 1) · ABLATION-14522-import=1 (expect 1) · ABLATION-14522-enterWith=1 (expect 1) · original-door-line=0 (expect 0) · enterWith-total=2 (expect 1) — the 2 is the marker line plus the comment line "⛔ No scimRequestScope.enterWith(...) here", i.e. one real call; pre-mutation blob 4e1bede81930d4e97cf9aca641a4036d79bd63a3 = HEAD blob, post-mutation blob 7165d4eefaac562a1633f185d640374047d689f1. No build leg: the subject (AuthManager) is imported by both test files relatively from src (./auth-manager.js), not through a package exports map, so no dist/ is on the resolution path and ablation-dist-preflight does not apply.

Reading (predicted direction: pins (a)/(b) and #14360 face (c) red, (c)/(d) green): Test Files 2 failed (2) · Tests 4 failed | 12 passed (16) — red: (a) POST /Users …, (a) PATCH /Users/{id} active:false …, (b) a failure on the sys_scim_user write rolls … back, and (c) the IdP gets a 403 SCIM error naming the invariant, and the account stays active (the flipped line 487 reads false again under the old mechanism); green: (c) sign-up/sign-in zero transactions, (d) SCIM read, and the other ten #14360 cases. ABLATION vitest exit: 1; VERDICT command-exit 0 (the wrapper's own exit is the script's, which restores and exits 0).

Restore = git checkout HEAD -- ABS_PATH in trap … EXIT INT TERM, proven: RESTORE: hash-object=4e1bede81930d4e97cf9aca641a4036d79bd63a3 HEAD-blob=4e1bede81930d4e97cf9aca641a4036d79bd63a3 MATCH · git diff HEAD --stat: [] · porcelain: [].

Gates (derived on the final head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, no paths passed)

Derived at ec7278259 (the derivation's own stderr line: "gate list derived from the tree of 'objectstack-ai/objectstack' at commit ec72782"; it notes the tree is 10 commits behind origin/main2514d49f3 — post-merge landings, none under plugin-auth, and the merge-tree against that origin/main is clean). The list is byte-identical to the pre-merge derivation on a88c952fd (36 commands). Run unlocked in the foreground in batches under the ~10-minute cap — the lock script's own header classes check:* gates as unlocked sibling work, and the lock was saturated by a sibling's gate batches (two 540 s queue timeouts on the full-suite call before it acquired) — with each command's exit captured to a results file BEFORE any pipe. Batch 2 was cap-killed (exit 143) after scripts/pm/check-half-states.mjs alone took 517 s; the two commands it left unrun (check:engine-double-contract, check:logger-receiver-detach) were re-run in a fourth call.

  • 33 of 36 exit 0: check-adr-0087-registration, check-changeset-no-major, check-ci-filter-parity, check-comment-mask-adoption, check-cross-package-test-inputs (node and pnpm spellings), check-empty-changeset, check-keyed-text-bounds, check-plugin-teardown-shape, check-shard-attestation, check-system-context-census (OK — 109 elevation read sites in 20 packages across 45 files, all anchored), check-tenant-audit-census, check-undeclared-dep-imports, docs-audit/check-affected-docs, docs-audit/check-drift-comment, pm/check-half-states (report-only patrol; its 155 half-state(s) found is a board census, not a verdict on this PR), pm/release-rehearsal-clone --self-test, check:changeset-gate-self-tests, check:dispatcher-error-vocabulary, check:doc-authoring, check:engine-double-contract, check:logger-receiver-detach, check:objectql-double-limit, check:objectui-changeset, check:page-declaration-shape, check:pm-half-states, check:published-files, check:query-options-erasure, check:slot-lookup, check:test-source-alias, check:type-check-coverage (OK — 69/79 workspace packages type-checked (plus the root), 10 in the DEBT ledger), check:type-source-resolution, check:where-matcher.
  • 3 NOT MEASURED, each declared by the gate itself with exit 3 (its prerequisite code, distinct from a finding's 1): check-test-completeness.mjs ("PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named … record this gate as NOT MEASURED"); check:dual-build-cjs-loads ("PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … Run pnpm build first. ⛔ This is NOT a pass: nothing was measured"); check:type-check-debt ("--re-measure cannot run: 29 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk … Build the closure first, exactly as lint.yml does"). A whole-workspace build is a repo-level run this container cannot fit under the cap; CI owns all three.
  • 0 red.

pnpm lint (eslint . --no-inline-config, repo-wide) is CI's run; the local reading is a PROVEN narrowing, three pieces: ① the population is read from eslint.config.mjs itself — its own comment at line 328 states "(no parserOptions.project, no typed @typescript-eslint rules)", and every parserOptions block in the file carries only ecmaVersion / sourceType, so linting is not type-aware; ② pnpm exec eslint --no-inline-config --format json over the six touched .ts files → files linted: 6 | errors: 0 | warnings: 0, exit 0; ③ invariance: with no type-aware rules a file's verdict depends only on its own bytes, so this diff cannot move the verdict of any untouched file — the narrowing excludes nothing.

NOT MEASURED (scope guard 4)

  • Whether the scope propagated on @better-auth/scim 1.7.0-rc.1 — not re-measured. The #3653 note may well have been true when written (a different dispatch shape); the rewritten prose says the stamp "never reached this seam" on 1.7.2 and does not claim it never did.
  • Postgres / MySQL — measured on better-sqlite3 :memory: only. The mechanism is adapter-side (where the ALS store is read), not driver-side, so the reading should carry; driver.beginTransaction ≥ 1 is pinned on sqlite only.

Log levels

No new log site at any level. The vendor's [Better Auth]: back-channel logout planning failed … no such table: sys_oauth_access_token ERROR seen in the suite output is pre-existing harness noise (the SCIM harnesses register no OAuth objects), filed separately.

Out of scope

Filed as #14615 (finding, unassigned, after a targeted MCP search_issues dedup — four non-matching closed hits — with a positive control that returned #14522): the plugin-auth SCIM harnesses (the #14360 suite and, by copying its shape, the new pin) register no OAuth objects, so every driven sign-in prints a Better Auth ERROR back-channel logout planning failed … no such table: sys_oauth_access_token. Green-run noise, not a failure; not touched here.

Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…M provisioning runs inside one engine transaction
The adapter's `transaction` config opens a real `engine.transaction()` only
while `inScimRequestScope()` reads true. The scope was stamped with
`AsyncLocalStorage.enterWith` inside the `verifyBearerToken` callback handed
to `@better-auth/scim`, and on 1.7.2 the store never reached the writes: an
`enterWith` marks only the async resource it runs in and its descendants,
and the vendor resumes the endpoint handler from a continuation captured
before the verifier ran. Measured: zero `engine.transaction` and zero
`driver.beginTransaction` calls across POST /Users + PATCH /Users/{id},
`inScimRequestScope()` false inside every sys_user / sys_scim_user write.
`AuthManager.handleRequest` now opens the scope with `run(...)` around every
request whose better-auth endpoint path is under `/scim/v2` — the same seam
the actor-attribution scope and the subject-erasure transaction use, and
exactly as narrow as before: non-SCIM flows keep their sequential posture.
A refused last-administrator deactivation now rolls the vendor's own
`scimUser.active = false` write back; the #14360 suite's face (c) pin on that
residual is flipped from `false` to `true` deliberately. A new runtime pin
(`scim-transaction-scope.test.ts`) observes each SCIM mutation calling
`engine.transaction`, a failed provisioning leaving no partial identity, and
sign-up/sign-in opening zero transactions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 8 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/user-ban-write.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx(via AuthManager (symbol, a top-level class), handleRequest (symbol, a method of class AuthManager))
  • content/docs/kernel/services-checklist.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via AuthManager (symbol, a top-level class), createObjectQLAdapterFactory (symbol, a top-level function), handleRequest (symbol, a method of class AuthManager))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/user-ban-write.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 11 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 9d7f7259fa96bd79138d3e55c2801886f56221bbpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3700abebe9135748c5eabe9d57041eefdf9c72a3 — the merge of head ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce into base 9d7f7259fa96bd79138d3e55c2801886f56221bb, 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 3700abebe9135748c5eabe9d57041eefdf9c72a3 && git checkout 3700abebe9135748c5eabe9d57041eefdf9c72a3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9d7f7259fa96bd79138d3e55c2801886f56221bb ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce && git checkout -B drift-repro 9d7f7259fa96bd79138d3e55c2801886f56221bb && git merge --no-ff ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce
node scripts/docs-audit/affected-docs.mjs --json 9d7f7259fa96bd79138d3e55c2801886f56221bb

⚠️ 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 9d7f7259fa96bd79138d3e55c2801886f56221bb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head ec7278259


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-auth): open the SCIM request scope at handleRequest so SCIM provisioning runs inside one engine transaction (#14522) - #14624

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14522-scim-transaction-scope
Sep 2, 2026
Merged

fix(plugin-auth): open the SCIM request scope at handleRequest so SCIM provisioning runs inside one engine transaction (#14522)#14624
os-sales merged 2 commits into
mainfrom
claude/issue-14522-scim-transaction-scope

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#14522

What changed

@better-auth/scim wraps every User/Group mutation in runIdentityMutationTransaction@better-auth/core's runWithTransaction(adapter, fn)adapter.transaction(...). On this adapter, transaction opens a real engine.transaction() only while inScimRequestScope() reads true. That scope was stamped with AsyncLocalStorage.enterWith inside the verifyBearerToken callback handed to the SCIM plugin — and at write time the store was gone, so the vendor's transaction callback ran with no engine transaction at all (the #3653 scoping note in objectql-adapter.ts declared the opposite).

The fix opens the scope where it is observed: AuthManager.handleRequest now wraps the whole request in scimRequestScope.run({ scim: true }, ...) when the better-auth endpoint path is under /scim/v2 — the same door the actor-attribution scope (runWithAuthActorScope) and the subject-erasure transaction (runSubjectErasureAtomically) already use, keyed on the same betterAuthEndpointPath(request) reading. run has a callback boundary; every als.run the vendor performs underneath nests inside it. The verifier no longer stamps anything. scimRequestScope / inScimRequestScope stay in use, unchanged in shape.

Files: auth-manager.ts (the door scope + SCIM_PROTOCOL_PATH_PREFIX, the verifier's stamp removed, the reconcileScimUserLifecycle doc corrected), objectql-adapter.ts (the #3653 note rewritten — where the scope is stamped now and why the verifier stamp never propagated), scim-connection-service.ts (the ALS doc rewritten), user-ban-write.ts (one stale "once that transaction is real" clause corrected — prose only, the write is untouched), scim-deactivation-reconcile-user.test.ts (face (c) line 487 flipped falsetrue deliberately, comment rewritten; line 501 untouched), new scim-transaction-scope.test.ts (the runtime pin), one patch changeset for @objectstack/plugin-auth.

Triage scope guards (14522#issuecomment-5507946166, quoted verbatim)

  1. Do not make non-SCIM better-auth flows transactional.objectql-adapter.ts:826-828 records that the conditional is load-bearing against "two measured breakages", and the historical sequential behaviour for non-SCIM flows is deliberate. Whatever replaces inScimRequestScope() must be at least as narrow — a /scim/v2-scoped signal, not "transactions on by default".
  2. Land the runtime pin the card asks for.credential-at-rest-posture.test.ts's note that the vendor refuses to mount on a sequential-fallback transaction is a mount-time assertion and it passed while this was broken. Add the observation the card names: a SCIM mutation observed to call engine.transaction at least once. A fix without that pin can regress silently the same way.
  3. [finding] SCIM active:false no longer disables the account — the vendor ban coupling was removed upstream in @better-auth/scim 1.7.0 and nothing in this repo replaced it #14360's suite already pins the residualscim-deactivation-reconcile-user.test.ts, face (c). Flip that assertion deliberately and say so in the changeset; do not let it look like an incidental test edit.
  4. The card's two unmeasured items stay unmeasured unless cheap: whether the scope propagated on 1.7.0-rc.1 (may well have — the #3653 note may have been true when written, and saying so is fairer than implying it was always wrong), and Postgres/MySQL. The mechanism is adapter-side, so the sqlite reading should carry; note it rather than re-running the matrix.

How each is met: (1) the door scope is keyed on the endpoint path prefix /scim/v2 — the predicate the vendor's own after-hook matcher uses — and nothing else changed in the adapter's conditional; pin (c) below measures sign-up + sign-in at ZERO engine.transaction calls, and the FULL plugin-auth suite is green. (2) scim-transaction-scope.test.ts (a) observes POST /Users and PATCH /Users/{id} each calling engine.transaction ≥ 1 and driver.beginTransaction ≥ 1, with every identity write sampled INSIDE the scope. (3) line 487 flipped falsetrue with a rewritten comment; the changeset names the flip. (4) both items recorded as NOT MEASURED below.

Step 0 — base

git merge-base --is-ancestor 21c7dbe76b7d44df32fbd9f6497db6372b9f2d0f origin/main exits 1, and that is the squash, not a missing landing: PR #14540 landed as 7303cbf4d ("… (#14360) (#14540)") on origin/main, and the file to flip is on origin/main with a blob byte-identical to the PR head's (git rev-parse of scim-deactivation-reconcile-user.test.ts at 21c7dbe76 and at origin/main both 8a4d445f4ed811c9d257e211910dffe9f96ebae0; git diff --stat between them exits 0 printing nothing). Worktree cut from origin/mainf60ab90ae (BASE), not from the PR branch; origin/main (2a2653619) merged in afterwards as ec7278259 (clean, no regen-pending, plugin-auth untouched on main in that range).

Premise checks (on origin/main before any edit)

  • P1 holds.objectql-adapter.ts:829if (!inScimRequestScope()) return cb(wrappedAdapter as never); (:8 imports it); auth-manager.ts:3279scimRequestScope.enterWith({ scim: true }); inside the verifyBearerToken callback (:3269 destructures it from the dynamic import); scim-connection-service.ts:63 the ALS, :66-67 the reader. The adapter still reads the ALS; the stamp still lives in the verifier.
  • P2 holds — reproduced before editing, kept as the red half.scim-transaction-scope.test.ts on the unfixed tree (f60ab90ae + the test file only): engine.transaction0 calls on POST /scim/v2/Users, writes sampled insert:sys_scim_connection_binding:scim insert:sys_user:NO-SCOPE insert:sys_scim_subject:NO-SCOPE insert:sys_scim_user:NO-SCOPE update:sys_scim_subject:NO-SCOPE update:sys_scim_connection_binding:NO-SCOPE; 0 calls on PATCH /scim/v2/Users/{id}active:false, writes update:sys_scim_subject:NO-SCOPE update:sys_user:NO-SCOPE update:sys_scim_user:NO-SCOPE update:sys_user:NO-SCOPE update:sys_scim_connection_binding:NO-SCOPE; the (b) atomicity case: sys_user survived the failed provisioning. Both requests answered 201 / 200. Note the one scim-scoped write: the vendor's connection-binding insert, made by the auth middleware right after await verifyBearerToken() — a DESCENDANT of the stamp — is the only write that saw it, which is exactly the enterWith shape.
  • P3 holds. face (c) line 487 read expect(await scimActive(h, owner.scimId)).toBe(false); under the comment block naming this card; line 501 is the positive control's false.
  • P4 holds. Installed @better-auth/scim1.7.2 / better-auth1.7.2 / @better-auth/core1.7.2 (pnpm store node_modules/.pnpm/@better-auth+scim@1.7.2_…). @better-auth/scim/dist/index.mjs:4002runIdentityMutationTransaction:4011runWithTransaction(adapter, async () => callback(await getCurrentAdapter(adapter))) (imported from @better-auth/core/context, :5); @better-auth/core/dist/context/transaction.mjsrunWithTransactionadapter.transaction(async (trx) => als.run({ adapter: trx, pendingHooks, isTransactionActive: true }, fn)). The three SCIM mutation sites: :6527 (POST /Users), :6749, :6839 (PATCH / PUT). assertNativeSCIMTransactions at :5499 asks only typeof adapter.options?.adapterConfig.transaction === "function".
  • P5 holds.packages/spec/** and content/docs/releases/** untouched (diff file list above).

Hypotheses (by measurement)

  • H1 holds — mechanism measured, boundary named. A throwaway probe (deleted before commit) drove the installed better-auth 1.7.2 directly: betterAuth({...}) with a global hooks.before doing alsA.enterWith(...), a plugin endpoint whose use middleware does alsB.enterWith(...) (the verifier's shape), the request issued inside alsC.run(...), and a fourth ALS opened at the door and MUTATED from hooks.before; the handler read all four through a nested als.run (the shape runWithTransaction uses). Reading: beforeHookEnterWith: null, useMiddlewareEnterWith: null, doorRun: { via: "door run" }, beforeHookMutatedDoorStore: { flag: true }, endpointContextPath: "/probe/scope". So the boundary is the await of the middleware chain in the endpoint frame — better-call's endpoint runner awaits the use middlewares (the verifier's caller) and dispatchAuthEndpoint awaits runBeforeHooks(...) (better-auth/dist/api/dispatch.mjs:205-228, which then re-enters the handler under a second runWithEndpointContext) — from continuations captured BEFORE the stamp. An enterWith marks the async resource it runs in and that resource's descendants only.
  • H2 holds — remedy picked by measurement. Candidate (a) as literally proposed (enterWith in hooks.before) is REFUTED by the probe (null). Candidate (b) — reading the vendor's endpoint context at write time — is VIABLE (tryGetCurrentAuthEndpointContext()?.path reads /probe/scope through the nested als.run; @better-auth/core/context exports it, and session-tombstone.ts:231 already imports from that module), but it retires scimRequestScope / inScimRequestScope, which the dispatch declares an export removal (Clause-② yes + stop), and couples the adapter to the endpoint-context API. Chosen: the run-scoped stamp at the door (doorRun visible; beforeHookMutatedDoorStore shows the same run-at-the-door mechanism the actor-attribution scope relies on). It is the house pattern for both a request-scoped ALS and endpoint-path-keyed atomicity, keeps both symbols in use, and is exactly as narrow: SCIM protocol requests only.
  • H3 holds. Green half on the fixed tree: scim-transaction-scope.test.ts 5/5 and scim-deactivation-reconcile-user.test.ts 11/11 (Test Files 2 passed (2) · Tests 16 passed (16)), including the flipped face (c) — the refused last-administrator deactivation now leaves the SCIM resource reading active: true while the positive control's false on line 501 still passes.
  • H4 holds. Pin (c): sign-up + sign-in through handleRequest write sys_user / sys_account / sys_session with engine.transaction0 and driver.beginTransaction0, no write in the SCIM scope. FULL @objectstack/plugin-auth suite: pnpm --filter @objectstack/plugin-auth test (the package's vitest run, VITEST_MAX_WORKERS from scripts/vitest-worker-cap.mjs) on ec7278259Test Files 91 passed (91) · Tests 1835 passed (1835), VERDICT command-exit 0 (held the lock 155 s). The six SCIM-adjacent siblings the dispatch names (credential-at-rest-posture, scim-case-insensitive-identifier, better-auth-schema-parity, last-admin-guard, managed-extension-fields, auth-manager) are inside that run. The two measured breakages the #3653 note records (sign-up 500s on the memory engine; the 180 s hook-timeout deadlock on single-connection sqlite) did not return.
  • H5 holds.git diff -U0 origin/main...HEAD | grep -E '^[+-].*\bexport\b' → nothing (grep exit 1). Clause-② no. Changeset patch for @objectstack/plugin-auth.

Tests (head ec7278259)

  • RED half (unfixed tree): pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/scim-transaction-scope.test.ts …Tests 3 failed | 3 passed (6) — (a) POST, (a) PATCH, (b) red with the readings quoted under P2; (c), (d) green (they measure the unchanged non-SCIM posture); VERDICT command-exit 1.
  • GREEN half: … src/scim-transaction-scope.test.ts src/scim-deactivation-reconcile-user.test.tsTest Files 2 passed (2) · Tests 16 passed (16), VERDICT command-exit 0.
  • pnpm --filter @objectstack/plugin-auth typecheck (src + tsconfig.examples.json + check:test-typecheck) → check:test-typecheck: OK — … 10 file(s) / 94 error(s) / 23 pinned signature(s) held in test-typecheck-debt.json (ledger unchanged), VERDICT command-exit 0. (A first run failed only on examples/basic-usage.ts resolving the package's own unbuilt dist/ — PREREQUISITE NOT MET, cleared by pnpm --filter @objectstack/plugin-auth build.)
  • FULL suite: pnpm --filter @objectstack/plugin-auth test (the package's vitest run, VITEST_MAX_WORKERS from scripts/vitest-worker-cap.mjs) on ec7278259Test Files 91 passed (91) · Tests 1835 passed (1835), VERDICT command-exit 0 (held the lock 155 s). The six SCIM-adjacent siblings the dispatch names (credential-at-rest-posture, scim-case-insensitive-identifier, better-auth-schema-parity, last-admin-guard, managed-extension-fields, auth-manager) are inside that run. The two measured breakages the #3653 note records (sign-up 500s on the memory engine; the 180 s hook-timeout deadlock on single-connection sqlite) did not return.
  • pnpm check:system-context-censusOK — 109 elevation read sites in 20 packages across 45 files, all anchored; no re-anchor of content/docs/permissions/system-context.mdx needed (working tree clean after the gate).
  • pnpm check:nul-bytesOK (scanned 7980 text file(s) … no raw ASCII control bytes); grep -naP control-byte self-scan over the seven touched files: 0 hits.

Ablation (committed tree)

Mutation = the OLD mechanism restored on the committed tree ec7278259, in auth-manager.ts only: the door scope disabled (const runRequest = (false as boolean) && isScimProtocolPath(endpointPath)), and scimRequestScope.enterWith({ scim: true }) put back as the first line of the verifyBearerToken callback (with its destructure). Applied by perl -0pi, proven on disk by anchored counts, never by the editor's exit code: ABLATION-14522-door=1 (expect 1) · ABLATION-14522-import=1 (expect 1) · ABLATION-14522-enterWith=1 (expect 1) · original-door-line=0 (expect 0) · enterWith-total=2 (expect 1) — the 2 is the marker line plus the comment line "⛔ No scimRequestScope.enterWith(...) here", i.e. one real call; pre-mutation blob 4e1bede81930d4e97cf9aca641a4036d79bd63a3 = HEAD blob, post-mutation blob 7165d4eefaac562a1633f185d640374047d689f1. No build leg: the subject (AuthManager) is imported by both test files relatively from src (./auth-manager.js), not through a package exports map, so no dist/ is on the resolution path and ablation-dist-preflight does not apply.

Reading (predicted direction: pins (a)/(b) and #14360 face (c) red, (c)/(d) green): Test Files 2 failed (2) · Tests 4 failed | 12 passed (16) — red: (a) POST /Users …, (a) PATCH /Users/{id} active:false …, (b) a failure on the sys_scim_user write rolls … back, and (c) the IdP gets a 403 SCIM error naming the invariant, and the account stays active (the flipped line 487 reads false again under the old mechanism); green: (c) sign-up/sign-in zero transactions, (d) SCIM read, and the other ten #14360 cases. ABLATION vitest exit: 1; VERDICT command-exit 0 (the wrapper's own exit is the script's, which restores and exits 0).

Restore = git checkout HEAD -- ABS_PATH in trap … EXIT INT TERM, proven: RESTORE: hash-object=4e1bede81930d4e97cf9aca641a4036d79bd63a3 HEAD-blob=4e1bede81930d4e97cf9aca641a4036d79bd63a3 MATCH · git diff HEAD --stat: [] · porcelain: [].

Gates (derived on the final head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, no paths passed)

Derived at ec7278259 (the derivation's own stderr line: "gate list derived from the tree of 'objectstack-ai/objectstack' at commit ec72782"; it notes the tree is 10 commits behind origin/main2514d49f3 — post-merge landings, none under plugin-auth, and the merge-tree against that origin/main is clean). The list is byte-identical to the pre-merge derivation on a88c952fd (36 commands). Run unlocked in the foreground in batches under the ~10-minute cap — the lock script's own header classes check:* gates as unlocked sibling work, and the lock was saturated by a sibling's gate batches (two 540 s queue timeouts on the full-suite call before it acquired) — with each command's exit captured to a results file BEFORE any pipe. Batch 2 was cap-killed (exit 143) after scripts/pm/check-half-states.mjs alone took 517 s; the two commands it left unrun (check:engine-double-contract, check:logger-receiver-detach) were re-run in a fourth call.

  • 33 of 36 exit 0: check-adr-0087-registration, check-changeset-no-major, check-ci-filter-parity, check-comment-mask-adoption, check-cross-package-test-inputs (node and pnpm spellings), check-empty-changeset, check-keyed-text-bounds, check-plugin-teardown-shape, check-shard-attestation, check-system-context-census (OK — 109 elevation read sites in 20 packages across 45 files, all anchored), check-tenant-audit-census, check-undeclared-dep-imports, docs-audit/check-affected-docs, docs-audit/check-drift-comment, pm/check-half-states (report-only patrol; its 155 half-state(s) found is a board census, not a verdict on this PR), pm/release-rehearsal-clone --self-test, check:changeset-gate-self-tests, check:dispatcher-error-vocabulary, check:doc-authoring, check:engine-double-contract, check:logger-receiver-detach, check:objectql-double-limit, check:objectui-changeset, check:page-declaration-shape, check:pm-half-states, check:published-files, check:query-options-erasure, check:slot-lookup, check:test-source-alias, check:type-check-coverage (OK — 69/79 workspace packages type-checked (plus the root), 10 in the DEBT ledger), check:type-source-resolution, check:where-matcher.
  • 3 NOT MEASURED, each declared by the gate itself with exit 3 (its prerequisite code, distinct from a finding's 1): check-test-completeness.mjs ("PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named … record this gate as NOT MEASURED"); check:dual-build-cjs-loads ("PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … Run pnpm build first. ⛔ This is NOT a pass: nothing was measured"); check:type-check-debt ("--re-measure cannot run: 29 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk … Build the closure first, exactly as lint.yml does"). A whole-workspace build is a repo-level run this container cannot fit under the cap; CI owns all three.
  • 0 red.

pnpm lint (eslint . --no-inline-config, repo-wide) is CI's run; the local reading is a PROVEN narrowing, three pieces: ① the population is read from eslint.config.mjs itself — its own comment at line 328 states "(no parserOptions.project, no typed @typescript-eslint rules)", and every parserOptions block in the file carries only ecmaVersion / sourceType, so linting is not type-aware; ② pnpm exec eslint --no-inline-config --format json over the six touched .ts files → files linted: 6 | errors: 0 | warnings: 0, exit 0; ③ invariance: with no type-aware rules a file's verdict depends only on its own bytes, so this diff cannot move the verdict of any untouched file — the narrowing excludes nothing.

NOT MEASURED (scope guard 4)

  • Whether the scope propagated on @better-auth/scim 1.7.0-rc.1 — not re-measured. The #3653 note may well have been true when written (a different dispatch shape); the rewritten prose says the stamp "never reached this seam" on 1.7.2 and does not claim it never did.
  • Postgres / MySQL — measured on better-sqlite3 :memory: only. The mechanism is adapter-side (where the ALS store is read), not driver-side, so the reading should carry; driver.beginTransaction ≥ 1 is pinned on sqlite only.

Log levels

No new log site at any level. The vendor's [Better Auth]: back-channel logout planning failed … no such table: sys_oauth_access_token ERROR seen in the suite output is pre-existing harness noise (the SCIM harnesses register no OAuth objects), filed separately.

Out of scope

Filed as #14615 (finding, unassigned, after a targeted MCP search_issues dedup — four non-matching closed hits — with a positive control that returned #14522): the plugin-auth SCIM harnesses (the #14360 suite and, by copying its shape, the new pin) register no OAuth objects, so every driven sign-in prints a Better Auth ERROR back-channel logout planning failed … no such table: sys_oauth_access_token. Green-run noise, not a failure; not touched here.

Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…M provisioning runs inside one engine transaction
The adapter's `transaction` config opens a real `engine.transaction()` only
while `inScimRequestScope()` reads true. The scope was stamped with
`AsyncLocalStorage.enterWith` inside the `verifyBearerToken` callback handed
to `@better-auth/scim`, and on 1.7.2 the store never reached the writes: an
`enterWith` marks only the async resource it runs in and its descendants,
and the vendor resumes the endpoint handler from a continuation captured
before the verifier ran. Measured: zero `engine.transaction` and zero
`driver.beginTransaction` calls across POST /Users + PATCH /Users/{id},
`inScimRequestScope()` false inside every sys_user / sys_scim_user write.
`AuthManager.handleRequest` now opens the scope with `run(...)` around every
request whose better-auth endpoint path is under `/scim/v2` — the same seam
the actor-attribution scope and the subject-erasure transaction use, and
exactly as narrow as before: non-SCIM flows keep their sequential posture.
A refused last-administrator deactivation now rolls the vendor's own
`scimUser.active = false` write back; the #14360 suite's face (c) pin on that
residual is flipped from `false` to `true` deliberately. A new runtime pin
(`scim-transaction-scope.test.ts`) observes each SCIM mutation calling
`engine.transaction`, a failed provisioning leaving no partial identity, and
sign-up/sign-in opening zero transactions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 8 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/user-ban-write.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx(via AuthManager (symbol, a top-level class), handleRequest (symbol, a method of class AuthManager))
  • content/docs/kernel/services-checklist.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via AuthManager (symbol, a top-level class), createObjectQLAdapterFactory (symbol, a top-level function), handleRequest (symbol, a method of class AuthManager))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/user-ban-write.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 11 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 9d7f7259fa96bd79138d3e55c2801886f56221bbpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3700abebe9135748c5eabe9d57041eefdf9c72a3 — the merge of head ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce into base 9d7f7259fa96bd79138d3e55c2801886f56221bb, 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 3700abebe9135748c5eabe9d57041eefdf9c72a3 && git checkout 3700abebe9135748c5eabe9d57041eefdf9c72a3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9d7f7259fa96bd79138d3e55c2801886f56221bb ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce && git checkout -B drift-repro 9d7f7259fa96bd79138d3e55c2801886f56221bb && git merge --no-ff ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce
node scripts/docs-audit/affected-docs.mjs --json 9d7f7259fa96bd79138d3e55c2801886f56221bb

⚠️ 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 9d7f7259fa96bd79138d3e55c2801886f56221bb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head ec7278259


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(plugin-auth): open the SCIM request scope at handleRequest so SCIM provisioning runs inside one engine transaction (#14522) - #14624

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14522-scim-transaction-scope
Sep 2, 2026
Merged

fix(plugin-auth): open the SCIM request scope at handleRequest so SCIM provisioning runs inside one engine transaction (#14522)#14624
os-sales merged 2 commits into
mainfrom
claude/issue-14522-scim-transaction-scope

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#14522

What changed

@better-auth/scim wraps every User/Group mutation in runIdentityMutationTransaction@better-auth/core's runWithTransaction(adapter, fn)adapter.transaction(...). On this adapter, transaction opens a real engine.transaction() only while inScimRequestScope() reads true. That scope was stamped with AsyncLocalStorage.enterWith inside the verifyBearerToken callback handed to the SCIM plugin — and at write time the store was gone, so the vendor's transaction callback ran with no engine transaction at all (the #3653 scoping note in objectql-adapter.ts declared the opposite).

The fix opens the scope where it is observed: AuthManager.handleRequest now wraps the whole request in scimRequestScope.run({ scim: true }, ...) when the better-auth endpoint path is under /scim/v2 — the same door the actor-attribution scope (runWithAuthActorScope) and the subject-erasure transaction (runSubjectErasureAtomically) already use, keyed on the same betterAuthEndpointPath(request) reading. run has a callback boundary; every als.run the vendor performs underneath nests inside it. The verifier no longer stamps anything. scimRequestScope / inScimRequestScope stay in use, unchanged in shape.

Files: auth-manager.ts (the door scope + SCIM_PROTOCOL_PATH_PREFIX, the verifier's stamp removed, the reconcileScimUserLifecycle doc corrected), objectql-adapter.ts (the #3653 note rewritten — where the scope is stamped now and why the verifier stamp never propagated), scim-connection-service.ts (the ALS doc rewritten), user-ban-write.ts (one stale "once that transaction is real" clause corrected — prose only, the write is untouched), scim-deactivation-reconcile-user.test.ts (face (c) line 487 flipped falsetrue deliberately, comment rewritten; line 501 untouched), new scim-transaction-scope.test.ts (the runtime pin), one patch changeset for @objectstack/plugin-auth.

Triage scope guards (14522#issuecomment-5507946166, quoted verbatim)

  1. Do not make non-SCIM better-auth flows transactional.objectql-adapter.ts:826-828 records that the conditional is load-bearing against "two measured breakages", and the historical sequential behaviour for non-SCIM flows is deliberate. Whatever replaces inScimRequestScope() must be at least as narrow — a /scim/v2-scoped signal, not "transactions on by default".
  2. Land the runtime pin the card asks for.credential-at-rest-posture.test.ts's note that the vendor refuses to mount on a sequential-fallback transaction is a mount-time assertion and it passed while this was broken. Add the observation the card names: a SCIM mutation observed to call engine.transaction at least once. A fix without that pin can regress silently the same way.
  3. [finding] SCIM active:false no longer disables the account — the vendor ban coupling was removed upstream in @better-auth/scim 1.7.0 and nothing in this repo replaced it #14360's suite already pins the residualscim-deactivation-reconcile-user.test.ts, face (c). Flip that assertion deliberately and say so in the changeset; do not let it look like an incidental test edit.
  4. The card's two unmeasured items stay unmeasured unless cheap: whether the scope propagated on 1.7.0-rc.1 (may well have — the #3653 note may have been true when written, and saying so is fairer than implying it was always wrong), and Postgres/MySQL. The mechanism is adapter-side, so the sqlite reading should carry; note it rather than re-running the matrix.

How each is met: (1) the door scope is keyed on the endpoint path prefix /scim/v2 — the predicate the vendor's own after-hook matcher uses — and nothing else changed in the adapter's conditional; pin (c) below measures sign-up + sign-in at ZERO engine.transaction calls, and the FULL plugin-auth suite is green. (2) scim-transaction-scope.test.ts (a) observes POST /Users and PATCH /Users/{id} each calling engine.transaction ≥ 1 and driver.beginTransaction ≥ 1, with every identity write sampled INSIDE the scope. (3) line 487 flipped falsetrue with a rewritten comment; the changeset names the flip. (4) both items recorded as NOT MEASURED below.

Step 0 — base

git merge-base --is-ancestor 21c7dbe76b7d44df32fbd9f6497db6372b9f2d0f origin/main exits 1, and that is the squash, not a missing landing: PR #14540 landed as 7303cbf4d ("… (#14360) (#14540)") on origin/main, and the file to flip is on origin/main with a blob byte-identical to the PR head's (git rev-parse of scim-deactivation-reconcile-user.test.ts at 21c7dbe76 and at origin/main both 8a4d445f4ed811c9d257e211910dffe9f96ebae0; git diff --stat between them exits 0 printing nothing). Worktree cut from origin/mainf60ab90ae (BASE), not from the PR branch; origin/main (2a2653619) merged in afterwards as ec7278259 (clean, no regen-pending, plugin-auth untouched on main in that range).

Premise checks (on origin/main before any edit)

  • P1 holds.objectql-adapter.ts:829if (!inScimRequestScope()) return cb(wrappedAdapter as never); (:8 imports it); auth-manager.ts:3279scimRequestScope.enterWith({ scim: true }); inside the verifyBearerToken callback (:3269 destructures it from the dynamic import); scim-connection-service.ts:63 the ALS, :66-67 the reader. The adapter still reads the ALS; the stamp still lives in the verifier.
  • P2 holds — reproduced before editing, kept as the red half.scim-transaction-scope.test.ts on the unfixed tree (f60ab90ae + the test file only): engine.transaction0 calls on POST /scim/v2/Users, writes sampled insert:sys_scim_connection_binding:scim insert:sys_user:NO-SCOPE insert:sys_scim_subject:NO-SCOPE insert:sys_scim_user:NO-SCOPE update:sys_scim_subject:NO-SCOPE update:sys_scim_connection_binding:NO-SCOPE; 0 calls on PATCH /scim/v2/Users/{id}active:false, writes update:sys_scim_subject:NO-SCOPE update:sys_user:NO-SCOPE update:sys_scim_user:NO-SCOPE update:sys_user:NO-SCOPE update:sys_scim_connection_binding:NO-SCOPE; the (b) atomicity case: sys_user survived the failed provisioning. Both requests answered 201 / 200. Note the one scim-scoped write: the vendor's connection-binding insert, made by the auth middleware right after await verifyBearerToken() — a DESCENDANT of the stamp — is the only write that saw it, which is exactly the enterWith shape.
  • P3 holds. face (c) line 487 read expect(await scimActive(h, owner.scimId)).toBe(false); under the comment block naming this card; line 501 is the positive control's false.
  • P4 holds. Installed @better-auth/scim1.7.2 / better-auth1.7.2 / @better-auth/core1.7.2 (pnpm store node_modules/.pnpm/@better-auth+scim@1.7.2_…). @better-auth/scim/dist/index.mjs:4002runIdentityMutationTransaction:4011runWithTransaction(adapter, async () => callback(await getCurrentAdapter(adapter))) (imported from @better-auth/core/context, :5); @better-auth/core/dist/context/transaction.mjsrunWithTransactionadapter.transaction(async (trx) => als.run({ adapter: trx, pendingHooks, isTransactionActive: true }, fn)). The three SCIM mutation sites: :6527 (POST /Users), :6749, :6839 (PATCH / PUT). assertNativeSCIMTransactions at :5499 asks only typeof adapter.options?.adapterConfig.transaction === "function".
  • P5 holds.packages/spec/** and content/docs/releases/** untouched (diff file list above).

Hypotheses (by measurement)

  • H1 holds — mechanism measured, boundary named. A throwaway probe (deleted before commit) drove the installed better-auth 1.7.2 directly: betterAuth({...}) with a global hooks.before doing alsA.enterWith(...), a plugin endpoint whose use middleware does alsB.enterWith(...) (the verifier's shape), the request issued inside alsC.run(...), and a fourth ALS opened at the door and MUTATED from hooks.before; the handler read all four through a nested als.run (the shape runWithTransaction uses). Reading: beforeHookEnterWith: null, useMiddlewareEnterWith: null, doorRun: { via: "door run" }, beforeHookMutatedDoorStore: { flag: true }, endpointContextPath: "/probe/scope". So the boundary is the await of the middleware chain in the endpoint frame — better-call's endpoint runner awaits the use middlewares (the verifier's caller) and dispatchAuthEndpoint awaits runBeforeHooks(...) (better-auth/dist/api/dispatch.mjs:205-228, which then re-enters the handler under a second runWithEndpointContext) — from continuations captured BEFORE the stamp. An enterWith marks the async resource it runs in and that resource's descendants only.
  • H2 holds — remedy picked by measurement. Candidate (a) as literally proposed (enterWith in hooks.before) is REFUTED by the probe (null). Candidate (b) — reading the vendor's endpoint context at write time — is VIABLE (tryGetCurrentAuthEndpointContext()?.path reads /probe/scope through the nested als.run; @better-auth/core/context exports it, and session-tombstone.ts:231 already imports from that module), but it retires scimRequestScope / inScimRequestScope, which the dispatch declares an export removal (Clause-② yes + stop), and couples the adapter to the endpoint-context API. Chosen: the run-scoped stamp at the door (doorRun visible; beforeHookMutatedDoorStore shows the same run-at-the-door mechanism the actor-attribution scope relies on). It is the house pattern for both a request-scoped ALS and endpoint-path-keyed atomicity, keeps both symbols in use, and is exactly as narrow: SCIM protocol requests only.
  • H3 holds. Green half on the fixed tree: scim-transaction-scope.test.ts 5/5 and scim-deactivation-reconcile-user.test.ts 11/11 (Test Files 2 passed (2) · Tests 16 passed (16)), including the flipped face (c) — the refused last-administrator deactivation now leaves the SCIM resource reading active: true while the positive control's false on line 501 still passes.
  • H4 holds. Pin (c): sign-up + sign-in through handleRequest write sys_user / sys_account / sys_session with engine.transaction0 and driver.beginTransaction0, no write in the SCIM scope. FULL @objectstack/plugin-auth suite: pnpm --filter @objectstack/plugin-auth test (the package's vitest run, VITEST_MAX_WORKERS from scripts/vitest-worker-cap.mjs) on ec7278259Test Files 91 passed (91) · Tests 1835 passed (1835), VERDICT command-exit 0 (held the lock 155 s). The six SCIM-adjacent siblings the dispatch names (credential-at-rest-posture, scim-case-insensitive-identifier, better-auth-schema-parity, last-admin-guard, managed-extension-fields, auth-manager) are inside that run. The two measured breakages the #3653 note records (sign-up 500s on the memory engine; the 180 s hook-timeout deadlock on single-connection sqlite) did not return.
  • H5 holds.git diff -U0 origin/main...HEAD | grep -E '^[+-].*\bexport\b' → nothing (grep exit 1). Clause-② no. Changeset patch for @objectstack/plugin-auth.

Tests (head ec7278259)

  • RED half (unfixed tree): pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/scim-transaction-scope.test.ts …Tests 3 failed | 3 passed (6) — (a) POST, (a) PATCH, (b) red with the readings quoted under P2; (c), (d) green (they measure the unchanged non-SCIM posture); VERDICT command-exit 1.
  • GREEN half: … src/scim-transaction-scope.test.ts src/scim-deactivation-reconcile-user.test.tsTest Files 2 passed (2) · Tests 16 passed (16), VERDICT command-exit 0.
  • pnpm --filter @objectstack/plugin-auth typecheck (src + tsconfig.examples.json + check:test-typecheck) → check:test-typecheck: OK — … 10 file(s) / 94 error(s) / 23 pinned signature(s) held in test-typecheck-debt.json (ledger unchanged), VERDICT command-exit 0. (A first run failed only on examples/basic-usage.ts resolving the package's own unbuilt dist/ — PREREQUISITE NOT MET, cleared by pnpm --filter @objectstack/plugin-auth build.)
  • FULL suite: pnpm --filter @objectstack/plugin-auth test (the package's vitest run, VITEST_MAX_WORKERS from scripts/vitest-worker-cap.mjs) on ec7278259Test Files 91 passed (91) · Tests 1835 passed (1835), VERDICT command-exit 0 (held the lock 155 s). The six SCIM-adjacent siblings the dispatch names (credential-at-rest-posture, scim-case-insensitive-identifier, better-auth-schema-parity, last-admin-guard, managed-extension-fields, auth-manager) are inside that run. The two measured breakages the #3653 note records (sign-up 500s on the memory engine; the 180 s hook-timeout deadlock on single-connection sqlite) did not return.
  • pnpm check:system-context-censusOK — 109 elevation read sites in 20 packages across 45 files, all anchored; no re-anchor of content/docs/permissions/system-context.mdx needed (working tree clean after the gate).
  • pnpm check:nul-bytesOK (scanned 7980 text file(s) … no raw ASCII control bytes); grep -naP control-byte self-scan over the seven touched files: 0 hits.

Ablation (committed tree)

Mutation = the OLD mechanism restored on the committed tree ec7278259, in auth-manager.ts only: the door scope disabled (const runRequest = (false as boolean) && isScimProtocolPath(endpointPath)), and scimRequestScope.enterWith({ scim: true }) put back as the first line of the verifyBearerToken callback (with its destructure). Applied by perl -0pi, proven on disk by anchored counts, never by the editor's exit code: ABLATION-14522-door=1 (expect 1) · ABLATION-14522-import=1 (expect 1) · ABLATION-14522-enterWith=1 (expect 1) · original-door-line=0 (expect 0) · enterWith-total=2 (expect 1) — the 2 is the marker line plus the comment line "⛔ No scimRequestScope.enterWith(...) here", i.e. one real call; pre-mutation blob 4e1bede81930d4e97cf9aca641a4036d79bd63a3 = HEAD blob, post-mutation blob 7165d4eefaac562a1633f185d640374047d689f1. No build leg: the subject (AuthManager) is imported by both test files relatively from src (./auth-manager.js), not through a package exports map, so no dist/ is on the resolution path and ablation-dist-preflight does not apply.

Reading (predicted direction: pins (a)/(b) and #14360 face (c) red, (c)/(d) green): Test Files 2 failed (2) · Tests 4 failed | 12 passed (16) — red: (a) POST /Users …, (a) PATCH /Users/{id} active:false …, (b) a failure on the sys_scim_user write rolls … back, and (c) the IdP gets a 403 SCIM error naming the invariant, and the account stays active (the flipped line 487 reads false again under the old mechanism); green: (c) sign-up/sign-in zero transactions, (d) SCIM read, and the other ten #14360 cases. ABLATION vitest exit: 1; VERDICT command-exit 0 (the wrapper's own exit is the script's, which restores and exits 0).

Restore = git checkout HEAD -- ABS_PATH in trap … EXIT INT TERM, proven: RESTORE: hash-object=4e1bede81930d4e97cf9aca641a4036d79bd63a3 HEAD-blob=4e1bede81930d4e97cf9aca641a4036d79bd63a3 MATCH · git diff HEAD --stat: [] · porcelain: [].

Gates (derived on the final head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, no paths passed)

Derived at ec7278259 (the derivation's own stderr line: "gate list derived from the tree of 'objectstack-ai/objectstack' at commit ec72782"; it notes the tree is 10 commits behind origin/main2514d49f3 — post-merge landings, none under plugin-auth, and the merge-tree against that origin/main is clean). The list is byte-identical to the pre-merge derivation on a88c952fd (36 commands). Run unlocked in the foreground in batches under the ~10-minute cap — the lock script's own header classes check:* gates as unlocked sibling work, and the lock was saturated by a sibling's gate batches (two 540 s queue timeouts on the full-suite call before it acquired) — with each command's exit captured to a results file BEFORE any pipe. Batch 2 was cap-killed (exit 143) after scripts/pm/check-half-states.mjs alone took 517 s; the two commands it left unrun (check:engine-double-contract, check:logger-receiver-detach) were re-run in a fourth call.

  • 33 of 36 exit 0: check-adr-0087-registration, check-changeset-no-major, check-ci-filter-parity, check-comment-mask-adoption, check-cross-package-test-inputs (node and pnpm spellings), check-empty-changeset, check-keyed-text-bounds, check-plugin-teardown-shape, check-shard-attestation, check-system-context-census (OK — 109 elevation read sites in 20 packages across 45 files, all anchored), check-tenant-audit-census, check-undeclared-dep-imports, docs-audit/check-affected-docs, docs-audit/check-drift-comment, pm/check-half-states (report-only patrol; its 155 half-state(s) found is a board census, not a verdict on this PR), pm/release-rehearsal-clone --self-test, check:changeset-gate-self-tests, check:dispatcher-error-vocabulary, check:doc-authoring, check:engine-double-contract, check:logger-receiver-detach, check:objectql-double-limit, check:objectui-changeset, check:page-declaration-shape, check:pm-half-states, check:published-files, check:query-options-erasure, check:slot-lookup, check:test-source-alias, check:type-check-coverage (OK — 69/79 workspace packages type-checked (plus the root), 10 in the DEBT ledger), check:type-source-resolution, check:where-matcher.
  • 3 NOT MEASURED, each declared by the gate itself with exit 3 (its prerequisite code, distinct from a finding's 1): check-test-completeness.mjs ("PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named … record this gate as NOT MEASURED"); check:dual-build-cjs-loads ("PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … Run pnpm build first. ⛔ This is NOT a pass: nothing was measured"); check:type-check-debt ("--re-measure cannot run: 29 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk … Build the closure first, exactly as lint.yml does"). A whole-workspace build is a repo-level run this container cannot fit under the cap; CI owns all three.
  • 0 red.

pnpm lint (eslint . --no-inline-config, repo-wide) is CI's run; the local reading is a PROVEN narrowing, three pieces: ① the population is read from eslint.config.mjs itself — its own comment at line 328 states "(no parserOptions.project, no typed @typescript-eslint rules)", and every parserOptions block in the file carries only ecmaVersion / sourceType, so linting is not type-aware; ② pnpm exec eslint --no-inline-config --format json over the six touched .ts files → files linted: 6 | errors: 0 | warnings: 0, exit 0; ③ invariance: with no type-aware rules a file's verdict depends only on its own bytes, so this diff cannot move the verdict of any untouched file — the narrowing excludes nothing.

NOT MEASURED (scope guard 4)

  • Whether the scope propagated on @better-auth/scim 1.7.0-rc.1 — not re-measured. The #3653 note may well have been true when written (a different dispatch shape); the rewritten prose says the stamp "never reached this seam" on 1.7.2 and does not claim it never did.
  • Postgres / MySQL — measured on better-sqlite3 :memory: only. The mechanism is adapter-side (where the ALS store is read), not driver-side, so the reading should carry; driver.beginTransaction ≥ 1 is pinned on sqlite only.

Log levels

No new log site at any level. The vendor's [Better Auth]: back-channel logout planning failed … no such table: sys_oauth_access_token ERROR seen in the suite output is pre-existing harness noise (the SCIM harnesses register no OAuth objects), filed separately.

Out of scope

Filed as #14615 (finding, unassigned, after a targeted MCP search_issues dedup — four non-matching closed hits — with a positive control that returned #14522): the plugin-auth SCIM harnesses (the #14360 suite and, by copying its shape, the new pin) register no OAuth objects, so every driven sign-in prints a Better Auth ERROR back-channel logout planning failed … no such table: sys_oauth_access_token. Green-run noise, not a failure; not touched here.

Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…M provisioning runs inside one engine transaction
The adapter's `transaction` config opens a real `engine.transaction()` only
while `inScimRequestScope()` reads true. The scope was stamped with
`AsyncLocalStorage.enterWith` inside the `verifyBearerToken` callback handed
to `@better-auth/scim`, and on 1.7.2 the store never reached the writes: an
`enterWith` marks only the async resource it runs in and its descendants,
and the vendor resumes the endpoint handler from a continuation captured
before the verifier ran. Measured: zero `engine.transaction` and zero
`driver.beginTransaction` calls across POST /Users + PATCH /Users/{id},
`inScimRequestScope()` false inside every sys_user / sys_scim_user write.
`AuthManager.handleRequest` now opens the scope with `run(...)` around every
request whose better-auth endpoint path is under `/scim/v2` — the same seam
the actor-attribution scope and the subject-erasure transaction use, and
exactly as narrow as before: non-SCIM flows keep their sequential posture.
A refused last-administrator deactivation now rolls the vendor's own
`scimUser.active = false` write back; the #14360 suite's face (c) pin on that
residual is flipped from `false` to `true` deliberately. A new runtime pin
(`scim-transaction-scope.test.ts`) observes each SCIM mutation calling
`engine.transaction`, a failed provisioning leaving no partial identity, and
sign-up/sign-in opening zero transactions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 8 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/user-ban-write.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx(via AuthManager (symbol, a top-level class), handleRequest (symbol, a method of class AuthManager))
  • content/docs/kernel/services-checklist.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via AuthManager (symbol, a top-level class), createObjectQLAdapterFactory (symbol, a top-level function), handleRequest (symbol, a method of class AuthManager))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/user-ban-write.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 11 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 9d7f7259fa96bd79138d3e55c2801886f56221bbpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3700abebe9135748c5eabe9d57041eefdf9c72a3 — the merge of head ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce into base 9d7f7259fa96bd79138d3e55c2801886f56221bb, 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 3700abebe9135748c5eabe9d57041eefdf9c72a3 && git checkout 3700abebe9135748c5eabe9d57041eefdf9c72a3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9d7f7259fa96bd79138d3e55c2801886f56221bb ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce && git checkout -B drift-repro 9d7f7259fa96bd79138d3e55c2801886f56221bb && git merge --no-ff ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce
node scripts/docs-audit/affected-docs.mjs --json 9d7f7259fa96bd79138d3e55c2801886f56221bb

⚠️ 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 9d7f7259fa96bd79138d3e55c2801886f56221bb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head ec7278259


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-sales@claude