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

5.1 KiB

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::InternalNOT_FOUND (404 on HTTP).
  • Inner op denies the caller's AccessControlFORBIDDEN (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.