Skip to content

fix(runtime)!: an active ADR-0069 gate that cannot read the session answers 503 at the dispatcher's seam - #23363

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23245-dispatcher-auth-gate-outage
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23245-dispatcher-auth-gate-outage

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 in catch { 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's computeExecCtx answers the same window 503 under #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 answered true, a failure to read the session throws AuthzStoreUnavailableError('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 above dispatch()'s own try, so the error leaves dispatch(), and the transport's existing error exit (errorResponseBase in dispatcher-plugin.ts) answers it 503 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

gate state dispatcher before dispatcher after REST computeExecCtx declared-endpoint fallback
no gate feature active (no read is made) admitted admitted, unchanged admitted admitted
isAuthGateActive() throws admitted admitted, unchanged admitted admitted
active, the session read rejects admitted 503 503 503
active, the session API cannot be obtained, or the header bag cannot be normalised admitted 503 anonymous (see note) 503
active, the read resolves with no posture (no session, an un-held user) admitted admitted, unchanged admitted admitted
active, the read carries a posture 403 with the gate code 403, unchanged 403 403

Note 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 sibling readGatedSessionUser does 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.ts drives the three cases through dispatch():

  1. Outage: an active gate whose session read rejects. dispatch() rejects with the branded error (code SERVICE_UNAVAILABLE, status 503, object auth_gate), and the route's handler never runs.
  2. Gate off: the same failing backend with no gate feature active is served as before. The gate makes no read of its own, measured as a read count (an active gate on the same request reads exactly once more than an inactive one).
  3. Active and clear: a read that resolves with an un-held user, or with no session, is admitted.

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 503 with an envelope that parses under BaseResponseSchema and ApiErrorSchema and carries code: SERVICE_UNAVAILABLE; gate off keeps its 401 UNAUTHENTICATED; active and clear keeps the route's own 501; held keeps 403 PASSWORD_EXPIRED.

Negative control (one shot, not kept as a test). With the fix committed (ebf5848f6), node scripts/ablation-replace.mjs replaced the outer net with catch { return null } (anchor 1 hit, x1 to x0, blob 5b42c4c85e3b to db96181d7c71) and ran the pin:

 × CASE 1 — OUTAGE: … → `dispatch()` RESOLVED — the active gate failed open on the outage: expected undefined to be defined
 × OUTAGE on the wire: … → expected 401 to be 503 // Object.is equality
      Tests  2 failed | 7 passed (9)
ablation-replace: ok restored: blob == HEAD (5b42c4c85e3b) and `git diff HEAD` is empty

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 401 there, 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.

  • Every test that wires 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.
  • The error code and object ('auth_gate') appear only at those seams.
  • The fail-open wording (fail-open, fails open, never break dispatch) in packages/runtime/src tests appears in prose only, never in an assertion on this seam.
  • The full @objectstack/runtime suite stays green with the fix.

Verification (at 061c8f78e, after merging origin/main 228357ab6)

  • pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2: Test Files 372 passed (372), Tests 5385 passed | 19 skipped (5404). The repo project: Test Files 3 passed (3), Tests 808 passed (808).
  • pnpm --filter @objectstack/runtime typecheck: exit 0, and check:test-typecheck: OK (the new test file adds no test-layer debt).
  • Gates: node scripts/pm/dispatch-gates.mjs --commands (no paths) derived 62 commands at this head. All 62 ran, and --ran answered 62 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-test lacked a fixture commit that is missing from a shallow clone, and check:dual-build-cjs-loads needs 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.
  • Lint, as a narrowed proof at 061c8f78e. ① Population, read from eslint's own config: the two .ts files 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.mjs enables no type-aware linting (no parserOptions.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 full pnpm lint is CI's.
  • Changeset: .changeset/23245-dispatcher-auth-gate-outage.md, @objectstack/runtime minor, 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-registration passes: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.

Acceptance notes


Generated by Claude Code

…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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 3 documentable anchor(s).

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

  • content/docs/api/environment-routing.mdx (via HttpDispatcher (symbol, a top-level class))
  • content/docs/automation/webhooks.mdx (via HttpDispatcher (symbol, a top-level class))
  • content/docs/kernel/cluster.mdx (via HttpDispatcher (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via HttpDispatcher (symbol, a top-level class))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-5.mdx (via HttpDispatcher (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 26 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 18cd410dc4053cf8983993104e325f0bbef19258 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 67b491dbb44b5a564f15b8a800a079b827b61dbd — the merge of head 061c8f78eb6c96f25fd723272bbb8f529b22308d into base 18cd410dc4053cf8983993104e325f0bbef19258, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 061c8f78eb6c96f25fd723272bbb8f529b22308d
Local-runs: none
Independence: INDEPENDENT AGENT

Inputs read: card #23245 (body and all 4 comments: triage 6144928416, claim 6158013575, os-dev-report 6159157870, the seat's ACCEPT 6159189302), PR #23363 (body, 3-file list, the net diff against main, its one bot comment 6159144660), and the check-runs on the head. The dev report and the ACCEPT note were weighed as claims and checked against the diff and a read-only main checkout; nothing was built, run or re-run. Public text stays at function level.

① Derived judgments

Every accept-set and public-surface change the diff implies, each judged:

  1. Active gate, session read rejects: served before, 503 SERVICE_UNAVAILABLE now. RIGHT. This is the card's acceptance and [finding] two more computeExecCtx seams read "failed" and "not wired" as one value, and both feed authorization inputs — tenancy posture and the ADR-0069 auth gate #13906 decision 2 option B as the card cites it; REST's computeExecCtx and the dispatcher plugin's readGatedSessionUser already answer the same window with the same brand (AuthzStoreUnavailableError('auth_gate', err)), read on main.
  2. Active gate, the session API cannot be obtained or the header bag cannot be normalised: served before, 503 now (the dev's open_questions arm). RIGHT, option A. The in-package sibling readGatedSessionUser (dispatcher-plugin.ts, the cli: REST's direct-mount registrars, the runtime dispatcher plugin's declared-endpoint fallback and cloud-connection's raw-app plugins never consult the ADR-0069 authentication-policy gate #23190 seam reviewed at contract tier per the card) wraps exactly these three steps in one try that throws the auth_gate brand; this diff is byte-for-byte that shape. REST's "anonymous" answer for this window is an artefact of where those steps sit in computeExecCtx (the identity half, above its gate block), not a ruling that an active gate admits when the read could not be started. Option B would keep a fail-open arm on an active gate, which is the shape the ruling closes. Judged inside the ruling, not a new decision.
  3. Active gate, provider mounts no session API: admitted, unchanged. RIGHT. It returns null before any read, as the sibling does ("no session to hold"); the early return sits inside the new try but throws nothing.
  4. Active gate, no header bag on the request: admitted, unchanged. RIGHT. No credential, no session; same early return.
  5. Inactive gate, no auth service, allow-listed path, or a probe that throws: admitted with no read, unchanged. RIGHT. The outer net still returns null for every non-branded fault (rethrowAuthzStoreUnavailable re-raises the brand alone, the shape core prescribes); CASE 2 measures "no read" as a session-read count rather than asserting it.
  6. Active gate, read resolves with no posture (no session, un-held user): admitted, unchanged. RIGHT. Two CASE 3 pins, through dispatch() and on the wire.
  7. Active gate, read carries a posture: the gate's 403 with its code, unchanged. RIGHT. Positive control in both blocks, so a green "served" cannot mean the gate never ran.
  8. Rendering. RIGHT, and it is structural: on main the call sits at dispatch() line 2746 and dispatch()'s own try opens at line 2796, so the branded error leaves dispatch() unhandled and the transport's existing errorResponseBase (which reads err.status first) answers 503. The identity step (line 698) and the membership gate (line 1590) already take this exit. No second rendering added. All four symbols the diff uses (AuthzStoreUnavailableError, rethrowAuthzStoreUnavailable, evaluateAuthGate, inProcessSessionReadInput) are already imported on main (lines 4, 12, 15, 17), so the diff's unchanged import list is sound.
  9. Public surface: none moves. RIGHT. enforceAuthGate is private; no export, type, schema, option or config field changes; SERVICE_UNAVAILABLE is an existing ADR-0112 code and 503 its existing mapping (core's own docblock), so the wire vocabulary is unchanged.
  10. Untouched pins. RIGHT as claimed, verified on main: five runtime test files wire isAuthGateActive; none drives a rejecting read through an active gate at this seam expecting admission (the only matching text is the two prose lines in http-dispatcher.root-auth-gate.test.ts). The declared-endpoint pin (endpoint-policy.auth-gate.pin.test.ts) expects 503 already.
  11. Pin and negative control. RIGHT. Nine cases, both through dispatch() and through the real dispatcher plugin on a recording server; the negative control (one shot, recorded in the PR body: the restored catch { return null } reds exactly the two outage cases, 2 failed / 7 passed, direction predicted before the run) is the card's spelling. The outage assertion checks brand, code, status and object, and the wire body parses under BaseResponseSchema and ApiErrorSchema.
  12. Scope. RIGHT. Three files, all inside the claim 6158013575: enforceAuthGate's body and header only in http-dispatcher.ts, one new pin, one changeset. auth-gate.ts (auth-gate parity: the remediation allow-list exempts the whole auth namespace, and the platform-admin mounts under it judge the caller without the ADR-0069 posture, so a held platform admin may reach user administration (read from source, not yet measured) #23335's file), REST's seam and every other dispatcher seam untouched. Nothing judged wrong.

Check-runs on the head at 2026-10-14T02:05Z, latest run per name: 24 success, 8 skipped, 7 in progress (Check Changeset, Test Core 1/6 through 6/6), 0 failure. Named, not waited for: the landing is the owning seat's after green, per the ACCEPT note.

② Semver level

  • Changeset .changeset/23245-dispatcher-auth-gate-outage.md: @objectstack/runtime minor, title fix(runtime)!:, the launch-window BREAKING line. RIGHT. check-changeset-no-major records the convention: during the launch window a breaking change ships as minor and major is refused. Matches triage 6144928416 and the lane precedent the card names.
  • Clause-②: line. RIGHT and consistent: PR body line 2 reads no (narrowing: ...), the changeset reads no (narrowing) ...; both parse to arm narrowing under readClause2Line's exact pattern (the look-ahead rejects only an alphanumeric or a pipe after the arm word), and no with (narrowing) is BREAKING per AGENTS.md. The facts fit: an outage window that was served is now refused, and no accept set widens.
  • ADR-0087 disposition. RIGHT. Exactly one marker, not-required (no-migration-prescription), a category in the gate's closed list. The body carries no FROM to TO rewrite (its closing paragraph is operator retry guidance, not a consumer code prescription), and no authorable key, export, option or config field is removed or renamed, so the category's precondition holds on the text.
  • skip-changeset: not applicable; a released package publishes. RIGHT.
  • Check Changeset was in progress at reading time; its conclusion is CI's, named above.

③ Boundary flags

Implemented-by: claude/issue-23245-dispatcher-auth-gate-outage
Reviewed-by: session_015sSBzUJfLxTo3ZZ68LLfCG

VERDICT: PASS


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants