Files
alkhttp/docs/architecture/decisions/071-dispatch-schema-guard.md
T
glm-5.3-flash 8e8e1f2b14 refactor(gateway): migrate to alkcall 0.2 promoted gateway module
Bump the alkcall dependency to 0.2 (with the gateway feature) and
converge on the promoted shared pieces:

- The local dispatch spine (gateway/dispatch.rs, 721 lines) is deleted;
  GatewayDispatch, schema_disclosure_denial, and DEFAULT_DEADLINE are
  re-exported from alkcall::gateway (alkcall ADR-048). The 30 s default
  deadline preserves the previous behavior exactly.
- gateway/schema_cache.rs (PublishSchemaCache) is deleted: alkcall CF-003
  compiles publish_schema at registration time and exposes
  OperationRegistry::publish_validator; the /publish chunk stream
  resolves against it. Un-compilable schemas are now rejected at
  registration, so the two end-to-end fail-closed tests were reworked
  into a registration-rejection test (a stronger guarantee).
- schema_disclosure_denial consumers (to_mcp, routes) use alkcall's
  promoted implementation; the alkhttp-local copy is gone (ADR-071
  updated: the guard stays as defense-in-depth, the implementation no
  longer forks).
- CF-001: from_wss drop monitor and the WS overlay tests use
  CallError::connection_closed; the review-001-ws-eof-signal race tests
  now assert retryable CONNECTION_CLOSED on both resolution paths (the
  tolerated non-retryable INTERNAL write-failure outcome is gone).
- Added CHANGELOG.md (Keep a Changelog), Unreleased section records the
  bump and convergence.

Verification: cargo test default 453 ok, wss 470 ok, mcp 526 ok,
all-features 575 ok; clippy -D warnings clean (default + all-features,
all-targets); fmt clean; cargo doc warning-free.

Net: -1093 lines.
2026-08-31 10:36:32 +00:00

105 lines
5.1 KiB
Markdown

# ADR-071: The dispatch-spine `services/schema` op-path guard
## Status
Accepted
## Context
Review-002 PRJ-16 ([major, security]) found that the GET `/schema`
disclosure fixes (GW-02, SRV-02) had a sibling hole through the **op
path**: `services/schema` is an External operation with default ACL, so
the gateway's outer-name pre-checks admit it — and alkcall's
`services_schema_handler` performs a bare
`registry.registration(name)` → spec projection with **no visibility
and no AccessControl check** of its own. Any HTTP caller could fetch
the full spec (input/output/error schemas, `required_scopes`) of an
Internal or ACL-restricted operation with:
```
POST /call {"operation":"services/schema","input":{"name":"secret/op"}}
```
— unauthenticated, and identically through the MCP `call` and `batch`
tools. The same GET `/schema` route that returns 404 for that op was
thus circumventable in one request. (Disclosure only; the target op's
handler never executes.)
The complete fix is alkcall-side: the handler itself must check —
filed as **CF-004** in `alkcall/docs/reviews/consumer-findings-ledger.md`.
But the alkcall lead time was unknown, and the hole is in this crate's
wire surface today.
## Decision
**`GatewayDispatch` blocks `services/schema` invocations whose inner
`name` input would be denied by the GET `/schema` route for the same
identity.** Before dispatch, when the resolved operation is the
`services/schema` meta-op, the spine applies the same two checks the
route applies to `?name=`:
- Inner op is `Visibility::Internal``NOT_FOUND` (404 on HTTP).
- Inner op denies the caller's `AccessControl``FORBIDDEN`
(401 unauthenticated / 403 authenticated under the gateway error
mapping — identical to the GET `/schema` denials).
The guard lives in the dispatch spine (`invoke` + `invoke_streaming`),
not in the route handlers, because every HTTP transport and the MCP
`call`/`batch` tools flow through it — one interception point covers
`/call`, `/batch`, `/subscribe` (defense-in-depth: the registry rejects
the Query-typed meta-op on the streaming path anyway), and the MCP
tools. Sink dispatch (`/publish`) cannot reach `services/schema`: the
registry rejects non-`Pub` operations before the handler runs and the
sink ignores the input, so nothing is projected there.
The visibility + ACL check itself is one shared function
(`schema_disclosure_denial`) used by the HTTP GET `/schema` route, the
dispatch-spine guard, and the MCP `schema` tool, so the transports
cannot drift.
## Consequences
- **Transport-level invariant (PRJ-16's wording):** no alkhttp
transport can fetch, through any path, a spec the GET `/schema`
route would deny for the same identity.
- **When CF-004 landed, the guard stayed — and was promoted.** The
alkcall-side handler check is the complete fix for every transport
(wire, overlays, peer composition; alkcall commit `8cb2a6e`). The
alkhttp spine, the GET `/schema` route, the MCP `schema` tool, and
the spine's `services/schema` op-path guard all now converge on the
**shared promoted implementation**: `alkcall::gateway::
schema_disclosure_denial` (alkcall ADR-048). The local copy was
deleted on the alkcall-0.2 bump — one implementation, so the
transports still cannot drift, and no alkhttp-local fork remains.
Do not re-introduce a local copy.
- **The dispatch spine itself is promoted too.** `GatewayDispatch`
(the `invoke`/`invoke_streaming`/`invoke_sink` spine with the
root-`OperationContext` invariants and the inner-`name` guard) is
`alkcall::gateway::GatewayDispatch` (feature `gateway`, enabled
unconditionally by the alkcall dependency — the gateway endpoints
are this crate's sole invoke path, ADR-047). The alkhttp-local copy
was deleted; only the HTTP-specific layers remain here (the
CallError → HTTP mapping in `gateway::error`, NDJSON/SSE framing,
body caps, batch envelopes, and this ADR's original
FORBIDDEN-vs-spec-404 split on the GET route).
- **`/publish` chunk validation is registry-backed.** The local
compile-once `PublishSchemaCache` was deleted: alkcall CF-003
compiles `publish_schema` at registration time and exposes the
cached validator as `OperationRegistry::publish_validator`. The
`/publish` chunk stream resolves against that cache; the
fail-closed guarantee moved upstream with it (an un-compilable
schema is a registration error, so an unvalidated-ingest path
cannot be constructed).
- **CF-001 resolution:** alkcall's `CallError::connection_closed`
(retryable `CONNECTION_CLOSED`) covers the write-failure race; the
from_wss drop-monitor race tests assert retryable-only (the
previously-tolerated non-retryable `INTERNAL: failed to write
request frame` outcome is gone from the wire vocabulary on the
undelivered-request path).
- Wire-observable behavior is unchanged from the pre-promotion guard:
previously-leaking requests still get the same 404/403 the GET
route returns; legitimate `services/schema` calls (allowed inner
names) are unaffected.
- The guard matches the outer registration's `spec.name` against the
`services/schema` constant rather than the raw request string, so
leading-slash variants (`/services/schema`) hit the same check.