Repository navigation
fix(runtime)!: an active ADR-0069 gate that cannot read the session answers 503 at the dispatcher's seam - #23363
Conversation
…nswers 503 at the dispatcher's seam
HttpDispatcher.enforceAuthGate collapsed a failed session read into
"no session" and admitted. An active gate's read failure now raises
AuthzStoreUnavailableError('auth_gate', ...), which leaves dispatch() and
is answered 503 SERVICE_UNAVAILABLE by the transport envelope, as REST's
seam answers the same window (#13906 decision 2 B). An inactive gate
still makes no read, and a read carrying no posture still admits.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sSBzUJfLxTo3ZZ68LLfCG
…ispatch() and the wire Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sSBzUJfLxTo3ZZ68LLfCG
… launch-window breaking narrowing Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sSBzUJfLxTo3ZZ68LLfCG
…spatcher-auth-gate-outage
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 67b491dbb44b5a564f15b8a800a079b827b61dbd && git checkout 67b491dbb44b5a564f15b8a800a079b827b61dbd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 18cd410dc4053cf8983993104e325f0bbef19258 061c8f78eb6c96f25fd723272bbb8f529b22308d && git checkout -B drift-repro 18cd410dc4053cf8983993104e325f0bbef19258 && git merge --no-ff 061c8f78eb6c96f25fd723272bbb8f529b22308d
node scripts/docs-audit/affected-docs.mjs --json 18cd410dc4053cf8983993104e325f0bbef19258
|
Contract reviewServed-tier: Inputs read: card #23245 (body and all 4 comments: triage ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each judged:
Check-runs on the head at 2026-10-14T02:05Z, latest run per name: 24 success, 8 skipped, 7 in progress ( ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #23245
Clause-②: no (narrowing: an active gate's outage now refuses what it served)
What this changes
HttpDispatcher.enforceAuthGate(packages/runtime/src/http-dispatcher.ts) read the session with.catch(() => undefined)and wrapped the whole decision incatch { return null }. So while the auth service could not answer, an active ADR-0069 gate took the failed read for "no session" and admitted, on every route the runtime dispatcher serves. REST'scomputeExecCtxanswers the same window503under #13906's decision 2 (option B, "an active auth gate whose session re-read fails answers 503"), and so does the dispatcher plugin's declared-endpoint fallback since #23190.Now, once
isAuthGateActive()has answeredtrue, a failure to read the session throwsAuthzStoreUnavailableError('auth_gate', err). The outer net re-raises that brand alone (rethrowAuthzStoreUnavailable, the shape core prescribes) and keeps failing open on every other fault. The call sits abovedispatch()'s owntry, so the error leavesdispatch(), and the transport's existing error exit (errorResponseBaseindispatcher-plugin.ts) answers it503 SERVICE_UNAVAILABLE. That is the exit the identity step's and the membership gate's outages already take. No second rendering is added.The arms, case for case
computeExecCtxisAuthGateActive()throwsNote on the fourth row. On REST those steps run in the identity half of
computeExecCtx, before its gate block, and a fault there falls to the method's outer net, so the caller becomes anonymous. The dispatcher's gate runs after identity has been decided, so "anonymous" is not an answer this seam can give: the choice is to admit or to refuse. This PR refuses, as the in-package siblingreadGatedSessionUserdoes for exactly these steps. An active gate whose session read could not even be made has failed to read, in the ruling's terms. The arm is named here so the review can rule on it.Pin
packages/runtime/src/http-dispatcher.auth-gate-outage.pin.test.tsdrives the three cases throughdispatch():dispatch()rejects with the branded error (codeSERVICE_UNAVAILABLE,status503,objectauth_gate), and the route's handler never runs.A positive control rides beside them: an expired password on a route the allow-list does not name gets the gate's 403, so a green "served" cannot mean that the gate never ran. A second block mounts the real dispatcher plugin on a recording server and reads the wire, one row per gate state: the outage is answered
503with an envelope that parses underBaseResponseSchemaandApiErrorSchemaand carriescode: SERVICE_UNAVAILABLE; gate off keeps its401 UNAUTHENTICATED; active and clear keeps the route's own501; held keeps403 PASSWORD_EXPIRED.Negative control (one shot, not kept as a test). With the fix committed (
ebf5848f6),node scripts/ablation-replace.mjsreplaced the outer net withcatch { return null }(anchor 1 hit, x1 to x0, blob5b42c4c85e3btodb96181d7c71) and ran the pin:Both outage cases went red and every control stayed green, which was the direction predicted before the run. The wire reading also confirms the "before" column: the outage used to answer
401there, byte for byte the gate-off answer.Full-repo pin sweep
No pin in the repository asserts the old fail-open at this seam, so nothing needed flipping.
isAuthGateActive(13 files) was checked. The only ones that drive a failing read on an active gate pin other seams (REST, the declared-endpoint fallback, the storage routes), and each already expects 503.'auth_gate') appear only at those seams.fail-open,fails open,never break dispatch) inpackages/runtime/srctests appears in prose only, never in an assertion on this seam.@objectstack/runtimesuite stays green with the fix.Verification (at
061c8f78e, after mergingorigin/main228357ab6)pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2:Test Files 372 passed (372),Tests 5385 passed | 19 skipped (5404). Therepoproject:Test Files 3 passed (3),Tests 808 passed (808).pnpm --filter @objectstack/runtime typecheck: exit 0, andcheck:test-typecheck: OK(the new test file adds no test-layer debt).node scripts/pm/dispatch-gates.mjs --commands(no paths) derived 62 commands at this head. All 62 ran, and--rananswered62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED. Every one exited 0 at this head. On the pre-merge run, two had refused with PREREQUISITE NOT MET:check-plugin-teardown-shape --self-testlacked a fixture commit that is missing from a shallow clone, andcheck:dual-build-cjs-loadsneeds an all-package build. Both were re-run green after fetching that commit and building every package. Both refusals are recorded as refusals, not as passes.061c8f78e. ① Population, read from eslint's own config: the two.tsfiles are linted, and the changeset answers "File ignored because no matching configuration was supplied". ②--format json: 2 files linted, 0 errors, 0 warnings. ③ Invariance:eslint.config.mjsenables no type-aware linting (noparserOptions.project), and its only disk reads are two baseline JSON files this diff does not touch, so no untouched file's verdict can move. The fullpnpm lintis CI's..changeset/23245-dispatcher-auth-gate-outage.md,@objectstack/runtimeminor,fix(runtime)!:, one ADR-0087 marker (not-required (no-migration-prescription)), and the launch-window BREAKING line, per PR fix(rest)!: PUT and the five by-name /meta sub-doors declare closed query sets, so ?member= is refused, never dropped (#23072, RestServer half) #23133's precedent.check-adr-0087-registrationpasses:1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.Acceptance notes
packages/runtime/src/http-dispatcher.root-auth-gate.test.ts(header near line 44, and the comment near line 110) saysenforceAuthGatefails open on "any thrown error". After this PR that is no longer true for an active gate's failed session read. The prose is not an assertion, and the file is outside this PR's claimed surface. Carrier: the next PR that touches that file.c12ba7bd0changes only comments inpackages/core/src/security/auth-gate.ts. The pin's posture is an expired password on a route the allow-list does not name, which that change does not touch.dispatcher-error-vocabulary.ts, which is a gate's declaration file and is not imported at runtime, so the two changes are not coupled. The pin stays green on top of it.CONTRACT_REVIEW_TIERreview of record: owed before the queue (the seat runs it).Generated by Claude Code