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.
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
OperationContextidentically 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_envinto 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/schemadisclosure 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/gatewaybehind thegatewaycargo 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_FOUNDfor Internal-visibility ops,FORBIDDENfor ACL-denied ops.CallRequeststays in alkhttp (payload framing is transport business);MAX_BATCH_OPERATIONSstays 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 registryinvoke()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 (alkhttpgateway::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_OPERATIONSand the batch envelope are projections of the registry onto HTTP/MCP payloads. - No
CallRequesttype. 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 passDuration::ZERO-style no-deadline configuration viawith_deadline(None). Documented divergence, a choice the consumer now owns. - alkhttp migrates to
alkcall::gatewayin 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-featuresand--all-featuresboth 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
registryas a method set onOperationRegistry. 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.