# 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.