Repository navigation
SCIM provisioning writes run outside any engine transaction on @better-auth/scim 1.7.2 — the #3653 scimRequestScope stamped in verifyBearerToken is not observed at write time (0 engine.transaction calls across POST + PATCH /Users) #14522
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p1High: required for production / M2High: required for production / M2
on Sep 2, 2026 Triage — graded. Re-read the mechanism at
origin/mained44512(the card measured00ff228fe); unchanged, and every structural claim holds.packages/plugins/plugin-auth/src/scim-connection-service.ts:63—export const scimRequestScope = new AsyncLocalStorage<{ scim: true }>();, withinScimRequestScope()at:66-67readinggetStore()?.scim === true.packages/plugins/plugin-auth/src/auth-manager.ts:3273—scimRequestScope.enterWith({ scim: true }), inside theverifyBearerTokencallback handed toscim().packages/plugins/plugin-auth/src/objectql-adapter.ts:829—if (!inScimRequestScope()) return cb(wrappedAdapter as never);— and that is the whole story: when the store is not visible, the vendor's transaction callback is run with no engine transaction, and returns successfully.
One structural note the card stops just short of
The card says the exact async-context boundary is not diagnosed and names better-auth's wrappers as suspects. That restraint is right, and I am not diagnosing it either — but the shape is worth writing down for whoever picks this up, because it narrows the search:
enterWith()is the half of theAsyncLocalStorageAPI with no callback boundary. It mutates the current async context and its descendants only. Anything that resumes from a context snapshot captured before the verifier ran, or that re-enters through its ownals.run(...), does not see the store.@better-auth/core'srunWithTransactiondoes exactly that —adapter.transaction(trx => als.run({ adapter: trx, isTransactionActive: true }, fn)), per the card's own trace. So "the marker was set in the verifier and is gone at write time" is the expected outcome ofenterWithacross an endpoint dispatch, not an anomaly needing an exotic explanation.Both of the card's remedy directions — stamping in a
hooks.beforematcher on/scim/v2that runs in the endpoint's own context, or readinggetCurrentAuthEndpointContext().pathdirectly in thetransactionconfig — work because they stop depending onenterWithpropagation at all. That is the right shape and it does not need a ruling.Why this is p1 +
security, and dispatchable rather than a decisionConsequence 2 is the one that sets the priority, and it is not a durability nicety. The break-glass guard refuses to deactivate the last administrator, the IdP correctly receives SCIM 403 — and the vendor's
scimUser.active = falsewrite stays committed. The SCIM resource then reports the administrator as deactivated while the account is enabled and signing in.That is an identity-boundary divergence in the worst direction: the operator's source of truth says an admin account is off, and it is on. An IdP-driven offboarding reports success against an account that still authenticates. Nobody gets an error to investigate.
Consequence 1 compounds it —
POST /Userswritingsys_user,sys_scim_subject,sys_scim_userand bindings as separate autocommits means a mid-sequence failure leaves a partial identity, which is precisely what the#3653block says the scoping exists to prevent. And the vendor's mount-timeassertNativeSCIMTransactionsis satisfied bytransactionmerely being a function, so the guard that would have caught this is structurally blind to it.Dispatchable, not
needs-user-decision. This restores documented intended behaviour —objectql-adapter.ts:795and the#3653note already state that SCIM multi-writes run inside a realengine.transaction(). The floor covers changing a security boundary; repairing a defect that violates the one already written down is ordinary work. The two candidate remedies differ in mechanism, not in observable contract.Scope guards for the implementer
- ⛔ Do not make non-SCIM better-auth flows transactional.
objectql-adapter.ts:826-828records that the conditional is load-bearing against "two measured breakages", and the historical sequential behaviour for non-SCIM flows is deliberate. Whatever replacesinScimRequestScope()must be at least as narrow — a/scim/v2-scoped signal, not "transactions on by default". - 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-fallbacktransactionis a mount-time assertion and it passed while this was broken. Add the observation the card names: a SCIM mutation observed to callengine.transactionat least once. A fix without that pin can regress silently the same way. - [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 residual —
scim-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. - The card's two unmeasured items stay unmeasured unless cheap: whether the scope propagated on
1.7.0-rc.1(may well have — the#3653note 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.
Routing
domain:services—plugin-authis in that lane per the lane table (identity merged in 2026-08-19), and every landing site is insidepackages/plugins/plugin-auth/src.priority:p1+security+bug: an administrator reported deactivated to the IdP while still able to sign in is an authorization-state divergence on the account class that matters most, it is measured rather than theoretical, and it is reachable through the ordinary offboarding path.pm:queue. Belongs under the #11632 stable-SCIM epic; I am not attaching it as a sub-issue from triage — the epic's owner should decide whether it hangs there or lands ahead of it, and it should not wait on that either way.
Generated by Claude Code
- added a commit that references this issue
on Sep 2, 2026 Two readings from the #14360 patch round (PR #14540, head
21c7dbe76), carried here at the contract review's request:-
Post-refusal behaviour. After a refused last-administrator deactivation, the vendor's surviving
scimUser.active = false(the residual this card describes) makes EVERY later SCIM mutation of that user — an attribute-only PATCH included — re-attempt the ban and answer 403 until the identity provider sendsactive: truefor that user or a second administrator exists. Loud and fail-closed, but an operator will meet it. -
The pin that flips with this card.
packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.tsface (c), line 487 on21c7dbe76—expect(await scimActive(h, owner.scimId)).toBe(false);— asserts the defective residual ON PURPOSE (the comment block above it names this card). The fix for this card must flip that assertion totrue, not delete it; line 501 in the same file is the positive control's genuinefalseand must stay.
Generated by Claude Code
Generated by Claude Code
-
Claim:
domain:servicesPM seat, sessionsession_01AUF1NoViznQK32gqpK8wS8(GitHubos-sales), 2026-09-02 ~12:55Z.- Session:
session_01AUF1NoViznQK32gqpK8wS8 - Branch:
claude/issue-14522-scim-transaction-scope - Worktree:
/home/user/objectstack-issue-14522(dev-created, worktree-first) - Domain:
domain:services(packages/plugins/plugin-auth) - File surface:
packages/plugins/plugin-auth/src/objectql-adapter.ts(the adapter factory'stransactionconfig and the#3653scoping note above it,:790-835onorigin/main7286dd58e);packages/plugins/plugin-auth/src/auth-manager.ts(where the SCIM request scope is stamped — today theverifyBearerTokencallback at:3263-3277; the remedy may move the stamp to the globalhooks.beforemiddleware at:1550or replace the ALS read altogether);packages/plugins/plugin-auth/src/scim-connection-service.ts(scimRequestScope/inScimRequestScope,:63-67); tests underpackages/plugins/plugin-auth/src/*.test.ts— a new runtime pin that a SCIM mutation callsengine.transactionat least once (natural home: a sibling ofcredential-at-rest-posture.test.ts), plus the DELIBERATE flip ofscim-deactivation-reconcile-user.test.tsface (c) line 487 (false→true; line 501, the positive control's genuinefalse, stays) — that file lands with PR fix(plugin-auth): SCIM active:false disables the account again — route the vendor's reconcileUser hook to the platform ban write (#14360) #14540; one changeset (@objectstack/plugin-auth). ⛔ Not touched: the transactionality of NON-SCIM better-auth flows (theobjectql-adapter.ts:826-828conditional is load-bearing — whatever replacesinScimRequestScope()must be at least as narrow),packages/spec/**,content/docs/releases/**, the admin-forcing sites, and PR fix(plugin-auth): SCIM active:false disables the account again — route the vendor's reconcileUser hook to the platform ban write (#14360) #14540's own surface (user-ban-write.ts,admin-ban-endpoints.ts, thereconcileScimUserLifecyclemethod) beyond what the flip requires. - Serial constraints: every open PR branch diffed against its merge-base at ~12:49Z — the only open PR holding a
plugin-authfile is PR fix(plugin-auth): SCIM active:false disables the account again — route the vendor's reconcileUser hook to the platform ban write (#14360) #14540 ([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:auth-manager.ts,admin-ban-endpoints.ts,user-ban-write.ts, the reconcile suite), which is in the merge queue (merge-group run since 12:23:58Z). The dev is launched only after PR fix(plugin-auth): SCIM active:false disables the account again — route the vendor's reconcileUser hook to the platform ban write (#14360) #14540 reads MERGED: the assertion this card must flip lives in a file that PR carries, and theauth-manager.tsregion overlaps. Epic [Epic] Migrate SCIM from @better-auth/scim 1.7.0-rc.1 to stable 1.7.x — seven-model provision, sys_scim_provider disposition, token reissue coordination, net-new Setup surface #11632 (os-litant) declares no file-territory list and none of its legs has an open PR onplugin-auth; triage 14522#issuecomment-5507946166 states the card should not wait on the epic either way. - Container & model: in-process
os-devsubagent, model fable.node scripts/pm/dispatch-gates.mjs --tierover the five paths above: no path-derived mandate (floor sonnet · default opus · ceiling fable); the seat's judgment is fable — p1security, an async-context propagation diagnosis across a vendor boundary, and a transaction seam whose conditional guards two measured breakages. Clause-② judged from content: expectedno(the fix restores the transactional behaviour the#3653note already declares; no export added, no accept/reject change on a public endpoint — the last-administrator refusal already answers SCIM 403; what changes is that the vendor's own write inside the refused request no longer survives it). The dev re-declares from the actual diff. - Ruling of record: no maintainer ruling is needed — triage grading 14522#issuecomment-5507946166 ("Dispatchable, not
needs-user-decision. This restores documented intended behaviour … repairing a defect that violates the one already written down is ordinary work") with its four scope guards (no non-SCIM transactions; land the runtime pin; flip the [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 residual pin deliberately and say so in the changeset; leave the two unmeasured items unmeasured unless cheap), and the [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 patch-round readings in 14522#issuecomment-5508809336 (post-refusal 403 loop; line 487 flips totrue, line 501 stays).
Race re-read follows this comment; the dev is launched at PR #14540's MERGED reading.
Generated by Claude Code
- Session:
Dev interruption record: the dev subagent dispatched at 12:58Z was terminated by the platform's session usage limit (HTTP 429, "session limit resets 14:20 UTC") at ~13:08Z while still reading the repo contracts and this card — no edit had been made; the branch
claude/issue-14522-scim-transaction-scopeis pushed atf60ab90ae(no commits) and the worktree/home/user/objectstack-issue-14522exists. Not a maintainer stop and not a dead claim: the same agent was resumed at ~14:22Z with an idempotent order (mergeorigin/main2a2653619, Step 0 gate already satisfied by PR #14540's merge, then the original order from P1). Claim, branch and file surface unchanged.
Generated by Claude Code
os-dev-report
{ "issue": 14522, "status": "done", "branch": "claude/issue-14522-scim-transaction-scope", "pr": "https://github.com/objectstack-ai/objectstack/pull/14624", "head": "ec7278259", "premise_still_valid": true, "step_0": "merge-base --is-ancestor 21c7dbe76 origin/main exits 1 because #14540 squash-landed as 7303cbf4d; the flip file's blob on origin/main is byte-identical to the PR head's (8a4d445f4). Worktree cut from origin/main f60ab90ae, never from the PR branch; origin/main 2a2653619 merged in as ec7278259 (clean, no regen-pending).", "premises": { "P1": "holds — adapter :829 still reads inScimRequestScope(); auth-manager :3279 still enterWith inside verifyBearerToken", "P2": "holds — reproduced before editing: engine.transaction 0 calls on POST and PATCH, every sys_user/sys_scim_subject/sys_scim_user write sampled NO-SCOPE, sys_user survived a failed provisioning (the only in-scope write was the middleware's own connection-binding insert, a DESCENDANT of the stamp)", "P3": "holds — line 487 read toBe(false) under the comment naming this card; line 501 is the control", "P4": "holds — @better-auth/scim 1.7.2 dist/index.mjs:4002 runIdentityMutationTransaction → :4011 runWithTransaction (from @better-auth/core/context) → @better-auth/core/dist/context/transaction.mjs adapter.transaction(trx => als.run({adapter: trx, pendingHooks, isTransactionActive: true}, fn)); assertNativeSCIMTransactions :5499 asks only typeof transaction === function", "P5": "holds — packages/spec and content/docs/releases untouched" }, "hypotheses": { "H1": "holds — measured on the installed vendor with a throwaway probe: enterWith inside hooks.before reads null in the handler, enterWith inside an endpoint use-middleware (the verifier's shape) reads null, a run() opened around auth.handler reads its store, a hooks.before MUTATION of a door-opened store is visible, and the vendor's endpoint-context path is visible through a nested als.run. Boundary named: the await of the middleware chain in the endpoint frame (better-call's use-middleware runner; dispatchAuthEndpoint's await runBeforeHooks at better-auth/dist/api/dispatch.mjs:205-228, which then re-enters the handler under a second runWithEndpointContext) — continuations captured BEFORE the stamp.", "H2": "holds — remedy chosen by measurement: (a) as literally proposed (enterWith in hooks.before) REFUTED; (b) reading tryGetCurrentAuthEndpointContext().path at write time VIABLE but retires the ALS pair (Clause-2 yes per the dispatch) and couples the adapter to the endpoint-context API; chosen: scimRequestScope.run({scim:true}, ...) at AuthManager.handleRequest keyed on betterAuthEndpointPath(request) under /scim/v2 — the door the actor-attribution scope and runSubjectErasureAtomically already use; both symbols stay in use; as narrow as before.", "H3": "holds — POST and PATCH each call engine.transaction and driver.beginTransaction, every identity write in scope; a failure on the sys_scim_user insert leaves sys_user/sys_scim_subject/sys_scim_user all absent; #14360 face (c) flipped to true passes, line 501 false still passes; the rest of the #14360 suite green.", "H4": "holds — sign-up + sign-in: engine.transaction 0, beginTransaction 0, no in-scope write; FULL plugin-auth suite 91 files / 1835 tests green; the two #3653 breakage classes did not return.", "H5": "holds — git diff -U0 origin/main...HEAD | grep export → nothing (grep exit 1); Clause-2 no; changeset patch for @objectstack/plugin-auth." }, "summary": "SCIM provisioning multi-writes now run inside one engine transaction. The scope the adapter's transaction config reads was stamped with enterWith inside the SCIM verifyBearerToken callback and never reached the writes; AuthManager.handleRequest now opens scimRequestScope with run(...) around every request whose better-auth endpoint path is under /scim/v2 (SCIM_PROTOCOL_PATH_PREFIX), the verifier stamps nothing, and the four prose sites that described the old mechanism are rewritten. A refused last-administrator deactivation now rolls the vendor's own scimUser.active=false write back, so the #14360 face (c) pin is flipped false → true deliberately (line 501 untouched); a new runtime pin scim-transaction-scope.test.ts observes the transaction, the atomicity, and the unchanged non-SCIM posture. Assignee was set by the PM (never written here). Lock contention note: two 540 s queue timeouts behind a sibling's locked gate batches before the full suite acquired; the ablation waited 343 s.", "tests": "RED half on the unfixed tree: vitest scim-transaction-scope.test.ts → 'Tests 3 failed | 3 passed (6)' with 'expected \"transaction\" to be called at least once' and 'sys_user survived the failed provisioning', VERDICT command-exit 1. GREEN half: scim-transaction-scope.test.ts + scim-deactivation-reconcile-user.test.ts → 'Test Files 2 passed (2) / Tests 16 passed (16)', VERDICT command-exit 0. FULL suite on ec7278259: pnpm --filter @objectstack/plugin-auth test → 'Test Files 91 passed (91) / Tests 1835 passed (1835)', VERDICT command-exit 0. Typecheck chain (tsc, examples tsconfig, check:test-typecheck) → 'check:test-typecheck: OK — 10 file(s) / 94 error(s) / 23 pinned signature(s)' (ledger unchanged), VERDICT command-exit 0 (a first run was PREREQUISITE NOT MET on the package's own unbuilt dist, cleared by building it). Ablation on the committed tree (door scope disabled + enterWith restored in the verifier; no build leg because both suites import AuthManager relatively from src): mutation proven on disk by anchored grep counts (ABLATION-14522-door=1, -import=1, -enterWith=1, original door line 0; blob 7165d4eef vs HEAD 4e1bede81) → 'Tests 4 failed | 12 passed (16)': (a) POST, (a) PATCH, (b) and #14360 face (c) red, (c)/(d) green — the predicted direction; restore via git checkout HEAD -- ABS_PATH in a trap, proven 'hash-object=4e1bede81… HEAD-blob=4e1bede81… MATCH · git diff HEAD --stat: [] · porcelain: []'. Lint: proven narrowing (eslint.config.mjs line 328 declares no type-aware rules; --format json over the 6 touched files → files linted 6, errors 0, warnings 0).", "gates": "Derived on the final head ec7278259 (dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, no paths; identical to the pre-merge list, 36 commands). 33 exit 0 including check:system-context-census ('OK — 109 elevation read sites … all anchored', no re-anchor), check:doc-authoring, check:engine-double-contract, check:type-check-coverage, check:nul-bytes (run separately, 'no raw ASCII control bytes'). 3 NOT MEASURED, each self-declared with exit 3: check-test-completeness (needs a saved turbo test log), check:dual-build-cjs-loads and check:type-check-debt (both need the whole workspace built; CI owns them). 0 red. Run unlocked in the foreground in batches (the lock header classes check:* gates as unlocked sibling work; the lock was saturated); batch 2 was cap-killed (exit 143) after check-half-states took 517 s and its two unrun commands were re-run. Merge-tree against origin/main 2514d49f3 clean; census page not listed.", "clause_2": "no — no export added, removed or changed; no accept/reject change on a public endpoint beyond the rollback the card describes", "not_measured": "1.7.0-rc.1 propagation (the #3653 note may have been true then; the rewritten prose says the stamp never reached the seam on 1.7.2 and claims nothing earlier); Postgres/MySQL (sqlite only; mechanism is adapter-side, driver.beginTransaction pinned on sqlite)", "log_levels": "no new log site at any level", "mcp_calls": "5 — 2 search_issues (dedup + positive control returning #14522), 1 issue_write (#14615), 1 create_pull_request (#14624), 1 add_issue_comment (this report)", "open_questions": [], "out_of_scope_findings": [ "filed as #14615: plugin-auth SCIM harnesses (#14360 suite and the new pin, which copies its shape) register no OAuth objects, so every driven sign-in logs a Better Auth ERROR 'back-channel logout planning failed … no such table: sys_oauth_access_token' — green-run noise, finding label, unassigned" ] }Generated by Claude Code
Generated by Claude Code
PM ACCEPT — PR #14624 at
ec7278259(Clause-②no, verified on the tree)Collected the
os-dev-reportand verified every load-bearing claim againstorigin/mainand the branch, not against the report.Scope guards (triage 14522#issuecomment-5507946166), each checked on the tree.
- Non-SCIM flows stay sequential. The scope is opened at the request door and keyed on the better-auth endpoint path:
SCIM_PROTOCOL_PATH_PREFIX = '/scim/v2'with an exact-or-prefixed match, andscimRequestScope.run({ scim: true }, runHandler)only when that predicate holds; every other request keeps the untouchedrunHandler. The adapter's own conditional (objectql-adapter.ts) has no non-comment change at all — its diff is prose only. That is at least as narrow as the reading it replaces, which is what the guard demands. The dev's own negative pin (sign-up + sign-in at zeroengine.transaction/driver.beginTransactioncalls) and the full 91-file / 1835-test package suite back it. - The runtime pin landed. New
scim-transaction-scope.test.tsobservesPOST /UsersandPATCH /Users/{id}each callingengine.transactionat least once anddriver.beginTransactionat least once, with every identity write sampled inside the scope, plus the atomicity case (a failedsys_scim_userwrite leaves all three identity objects absent). - The [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 residual pin is flipped deliberately.
scim-deactivation-reconcile-user.test.tshas exactly one non-comment change: line 487toBe(false)→toBe(true). Line 501, the positive control's genuinefalse, is byte-identical. The changeset names the flip in its own sentence, so it cannot read as an incidental test edit. - The two unmeasured items stay unmeasured, recorded as such in the PR body: whether the scope propagated on
1.7.0-rc.1, and Postgres / MySQL. The rewritten prose says the stamp never reached this seam on 1.7.2 and does not claim it never did — which is the fairness the guard asked for.
Seat verification (measured).
- File surface: 7 files, all inside the claim. Three carry prose only —
objectql-adapter.tsandscim-connection-service.tshave zero non-comment lines in the diff, anduser-ban-write.tsis one clause ("once that transaction is real") that this very change falsifies. Correcting a sentence the change makes untrue is inside the fence, not beyond it. - Clause-② no:
git diff -U0 origin/main...HEAD | grep -E '^[+-].*\bexport\b'returns nothing.scimRequestScope/inScimRequestScopestay exported and in use (the alternative remedy would have retired them, which is why it was not taken); no export added, removed or renamed;packages/spec/**andcontent/docs/releases/**untouched. - Changeset
patchfor@objectstack/plugin-auth, stating the measured before-state, the narrowness of the new scope, the operator-visible consequence, and the deliberate pin flip. git merge-tree --write-tree --name-only origin/main HEADlists no file.- Log levels: no new log site at any level.
- Docs: the drift bot lists three pages naming
AuthManager/handleRequest/createObjectQLAdapterFactory. The seat read them — none states anything about SCIM transactionality (zero "transaction" mentions across all three; the same grep over the same files returns hits for "scim", so that zero is a real zero), and no page undercontent/docsstates whether SCIM writes are transactional. Nothing to correct.
Mechanism, for the record. The boundary is named by measurement rather than guessed:
enterWithinside the verifier (an endpointusemiddleware) is invisible to the handler because the endpoint frame awaits the middleware chain from a continuation captured before the stamp, and the vendor re-enters the handler under its own context wrapper.run(...)at the door has a callback boundary, so everyals.runthe vendor performs nests inside it.Out-of-scope finding: #14615 (the SCIM harnesses register no OAuth objects, so each driven sign-in logs a vendor ERROR about a missing
sys_oauth_access_tokentable — green-run noise). Left with triage.Landing: on all checks green at
ec7278259the seat flips the PR to ready, arms auto-merge and posts landing provenance; on MERGED the card closes andpm:dispatchedcomes off.
Generated by Claude Code
- Non-SCIM flows stay sequential. The scope is opened at the request door and keyed on the better-auth endpoint path:
Filed by the #14360 dev seat (session
session_01AUF1NoViznQK32gqpK8wS8, worktreeobjectstack-issue-14360, scratchpad keyissue-14360) — out of scope for that card; recording only, unassigned. Measured onorigin/mainat00ff228fewith@better-auth/scim@1.7.2/better-auth@1.7.2installed.What was measured
packages/plugins/plugin-auth/src/objectql-adapter.ts(the#3653block abovetransaction:in the adapter factory config) declares that SCIM provisioning multi-writes run inside a REALengine.transaction()— "the real transaction opens exactly where upstream's assertion demands it — inside an authenticated SCIM protocol request, marked by the auth manager'sverifyBearerTokenviascimRequestScope". The marker isscimRequestScope.enterWith({ scim: true })inauth-manager.ts(inside theverifyBearerTokencallback handed toscim()), and the adapter'stransactionconfig reads it withinScimRequestScope()— when false it runs the callback on the same adapter with NO engine transaction.On 1.7.2 that scope is not observed at write time. Probe (a vitest file in
packages/plugins/plugin-auth/src, since deleted; realObjectQLover@objectstack/driver-sql+ better-sqlite3:memory:, realAuthManagerwithplugins: { scim: true, organization: true }, a credential minted bymintScimConnectionCredential, requests throughmanager.handleRequest):vi.spyOn(engine, 'transaction')— 0 calls duringPOST /scim/v2/Users, 0 duringPATCH /scim/v2/Users/{id}(active: false);vi.spyOn(driver, 'beginTransaction')— 0 / 0 (the driver has the method; the degrade branch is not what fired);inScimRequestScope()sampled inside everyengine.updateonsys_user/sys_scim_userduring the PATCH —[false, false, false].Both requests answered 201 / 200. The vendor does call the seam:
@better-auth/scim1.7.2 wraps every User/Group mutation inrunIdentityMutationTransaction→@better-auth/core'srunWithTransaction(adapter, fn)→adapter.transaction(trx => als.run({ adapter: trx, isTransactionActive: true }, fn))(@better-auth/core/dist/context/transaction.mjs). It is this repo'stransactionconfig that hands the callback back without opening one, because the AsyncLocalStorage store stamped inside the verifier is not the store the handler continuation runs under. The exact async-context boundary (better-auth's ownrunWithEndpointContext/runWithRequestStatewrappers around the middleware are the suspects) is NOT diagnosed here.Consequences
main.POST /Userswritessys_user,sys_scim_subject,sys_scim_user(and bindings) as separate autocommits; a failure between them leaves a partial identity. This is exactly the shape the SCIM: 停在 @better-auth/scim rc.1,等正式版再整体迁移 —— rc.2 换掉了整套模型 #3653 comment says the scoping exists to prevent, and the mount-timeassertNativeSCIMTransactionscheck the vendor performs is satisfied by the config being a function, not by it opening anything.identity.reconcileUserrouted to the platform ban write, deactivating the LAST administrator is refused by the break-glass guard (ADR-0024 D5.2) and the IdP gets the SCIM 403 — but the vendor's ownscimUser.active = falsewrite, made BEFORE the callback inside what it believes is a transaction, stays committed. The SCIM resource then reportsactive: falsewhile the account is still enabled and signing in. The [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 suite pins that residual explicitly (scim-deactivation-reconcile-user.test.ts, face (c)) so the fix for THIS card flips that line deliberately.What was NOT measured
1.7.0-rc.1(the version the SCIM: 停在 @better-auth/scim rc.1,等正式版再整体迁移 —— rc.2 换掉了整套模型 #3653 note was written against) — not re-measured; the note may have been true then.Remedy direction (not decided here)
Open the engine transaction from a place the handler continuation actually inherits — e.g. stamp the scope in a better-auth
hooks.beforematcher on/scim/v2(which runs in the endpoint's own context) rather than inside the verifier callback, or read the endpoint context (getCurrentAuthEndpointContext().path) in the adapter'stransactionconfig instead of a separate ALS. Either waycredential-at-rest-posture.test.ts's note that the vendor "refuses to mount on an adapter whosetransactionis the factory's sequential fallback" should gain a runtime pin: a SCIM mutation observed to callengine.transactionat least once.Refs: #3653 (the scoping's origin) · #14360 (where the half-landed refusal is pinned) · #11632 (the stable-SCIM migration epic this belongs under).
Generated by Claude Code
Generated by Claude Code