# ADR-048: Dispatch Spine (feature-gated `gateway` module) ## Status Accepted ## Context The first real downstream consumer of this crate — alkhttp — built its HTTP gateway (`POST /call`, `/subscribe`, `/publish`, the MCP `call` tool, the to_openapi projections) on a small internal component it calls the **dispatch spine**: a thin struct over `Arc` that owns the *non-HTTP* half of gateway dispatch. The HTTP layer resolves bearer tokens to an `Identity`, frames NDJSON/SSE, maps `CallError` to HTTP statuses, and wraps axum handlers; the spine does everything that happens after that: - constructs the root `OperationContext` identically for every transport (`internal: false` — ACL runs against the caller's identity, not a handler's composition authority; `forwarded_for: None` — wire-ingress only; identity supplied per-call), - resolves the registration's `composition_authority` / `capabilities` / `scoped_env` into that context, - bounds Once-ops and sink dispatch with a deadline while leaving streaming subscriptions unbounded (ADR-021: subscriptions are long-lived), - and applies the `services/schema` disclosure guard when the dispatched operation's *input* names another operation. This is transport-neutral work. It contains no HTTP concepts: no statuses, no headers, no body framing. And it is not HTTP-shaped by accident — the same shape is exactly what a **hub** needs when it terminates channel 0 on both legs and relays calls to spokes (ADR-042's "translate, not forward" rule): the hub must re-root the context at itself (its own identity, `internal: false`, no `forwarded_for`), re-resolve its capabilities for the outbound leg, and bound the relay so a hung spoke does not wedge the browser-facing connection. alkhttp needed it; the hub relay needs the same thing; any protocol crate that exposes a call surface to a less-trusted in-transport caller (a WS-native relay, a CLI bridge, a test harness) will need it again. Duplicating it per consumer is the failure mode this crate already paid for once: the spine's `services/schema` guard existed because the registry handler and the HTTP route were written against different disclosure rules (CF-004, filed from alkhttp's consumer review). One shared implementation is the fix; a second copy in a second crate would re-open the drift. alkhttp is not yet published. This is the cheapest moment to move the component into alkcall (behind a feature, so the base crate stays lean and the surface is opt-in) and have alkhttp consume it rather than carry its own copy. ## Decision Promote the dispatch spine into alkcall as a new `gateway` module, **feature-gated**: - `alkcall/gateway` behind the `gateway` cargo feature (default off — the base crate stays lean; the module adds no dependencies, the gate exists to keep the audit surface explicit and opt-in). - `GatewayDispatch` — the spine struct: `invoke()`, `invoke_streaming()`, `invoke_sink()`, registry access, and root context construction. The 30 s deadline alkhttp hardcodes becomes a constructor knob (`with_deadline`) so consumers keep their own policy; the default remains 30 s for drop-in equivalence. - `schema_disclosure_denial()` — the shared check that a spec the caller could not invoke is not disclosed: `NOT_FOUND` for Internal-visibility ops, **`FORBIDDEN` for ACL-denied ops**. - `CallRequest` stays in alkhttp (payload framing is transport business); `MAX_BATCH_OPERATIONS` stays in alkhttp (batch is a projection concern). ### ACL denial returns FORBIDDEN, not spec-404 The wire-path `services/schema` handler (CF-004, discovery.rs) returns spec-404 for both Internal visibility and ACL denial — the right answer for an unauthenticated wire caller, where even acknowledging the op's existence is a leak vector. The spine's guard is invoked by a transport that has *already* resolved the caller's identity and often already admitted the op elsewhere (the alkhttp GET `/schema` route answers `403` for ACL-denied ops, deliberately). The spine therefore keeps alkhttp's split: - Internal visibility → `NOT_FOUND` (never acknowledged, at any authority). - ACL denial → `FORBIDDEN` (informative for a caller whose identity the transport resolved; alkcall's own registry `invoke()` produces the same code for the same caller state, so the guard's answer is never *more* restrictive or *less* restrictive than the invoke that would follow it). The two layers are consistent by construction: the wire handler's spec-404 is the conservative outer bound, the spine's FORBIDDEN is the identity-aware refinement. When an unauthenticated caller hits both, `FORBIDDEN` and `NOT_FOUND` differ only in which exists — and `AccessControl::check` with no identity returns `"authentication required"`, which HTTP-side mappers translate to 401. alkhttp consumes this module in a later session and drops its local copy; until then the two implementations coexist (byte-identical in behavior, one in each crate). ### What the spine deliberately does NOT include - **No HTTP mapping.** `CallError` → status/body is the consumer's (alkhttp `gateway::error`). The neutral wire error is the module's lowest-level vocabulary. - **No body framing, limits, or timeouts beyond the deadline knob.** NDJSON/SSE framing, body caps, keep-alive intervals are transport concerns (GW-15/GW-16 in alkhttp's ledger). - **No batch semantics.** `MAX_BATCH_OPERATIONS` and the batch envelope are projections of the registry onto HTTP/MCP payloads. - **No `CallRequest` type.** The spine takes `(op, input)` — parsing `{ operation, input }` is transport framing. ## Consequences - The deadline policy moves from "alkhttp's 30 s HTTP convention" to "spine policy, configured per-consumer" (default 30 s). Sink dispatch is bounded by the same deadline as Once-ops — the wire path's Pub dispatch is unbounded (`deadline: None`); consumers who want the wire behavior pass `Duration::ZERO`-style no-deadline configuration via `with_deadline(None)`. Documented divergence, a choice the *consumer* now owns. - alkhttp migrates to `alkcall::gateway` in a follow-up session; its local spine is deleted then, not deprecated in place (unpublished crate — no compat window needed). - The feature adds no dependencies; `cargo test --no-default-features` and `--all-features` both pass (the wasm-clean baseline is untouched). - Future transports (WS-native relays, protocol crates) reuse the spine instead of re-deriving the context/guard discipline. ## Alternatives considered - **Fold into `registry` as a method set on `OperationRegistry`.** Rejected: the spine is a *policy* wrapper (deadline, root-context shape, disclosure guard) a consumer opts into, not the registry's dispatch core. `GatewayDispatch::invoke` ≠ `OperationRegistry::invoke` — conflating them invites wire paths to pick up transport-shaped policy accidentally. - **Wait for alkhttp to publish first.** Rejected: promotes a duplicate into the wild that immediately has to be deprecated; the cheapest moment to move the type is before either crate's first release. - **Promote the whole gateway module.** Rejected: routes (SSE/NDJSON framing, body limits) and error mapping (`IntoResponse`, status mapping) are HTTP by definition; moving them would drag axum/hyper as optional dependencies into a protocol crate for no reuse — alkhttp is their only consumer.