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.
151 lines
7.3 KiB
Markdown
151 lines
7.3 KiB
Markdown
# 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. |