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.
This commit is contained in:
glm-5.3-flash committed 2026-08-31 08:45:57 +00:00
1 parent 8cb2a6eb6d
commit d5fd548b8d
7 files changed
+939

No files matched your search

@@ -0,0 +1,151 @@
# 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.