Files
alkcall/docs/architecture/decisions/048-dispatch-spine-gateway-module.md
glm-5.3-flash d5fd548b8d feat: promote dispatch spine to gateway module (ADR-048, feature-gated)
Promote alkhttp's transport-neutral dispatch spine into alkcall as
alkcall::gateway behind the opt-in gateway cargo feature (default off;
adds no dependencies):

- GatewayDispatch: deadline-bounded invoke spine over OperationRegistry
  (invoke / invoke_streaming / invoke_sink) with the root-context
  discipline (internal: false, forwarded_for: None) hubs and spokes
  relaying calls (ADR-042 translate path) need identically to alkhttp's
  HTTP gateway. The 30 s deadline becomes a constructor knob
  (with_deadline).
- schema_disclosure_denial: the shared is-internal + ACL check for
  services/schema inner-name disclosure; ACL denial returns FORBIDDEN
  (identity-aware refinement), Internal visibility returns spec-404.
  One implementation so transports cannot drift (CF-004).
- MAX_BATCH_OPERATIONS / CallRequest / HTTP error mapping stay in
  alkhttp (projection + transport concerns); alkhttp migrates to this
  module in a follow-up session and drops its local copy.

Docs: ADR-048 (decision + divergence rationale), ADR index entry,
CHANGELOG.

Verification: 574 tests pass with --features gateway (16 new), 558 pass
default, clippy -D warnings clean both feature sets, --all-features
clean, fmt clean, wasm32 target clean, rustdoc warning-free.
2026-08-31 08:45:57 +00:00

7.3 KiB

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