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:
1 parent
8cb2a6eb6d
commit
d5fd548b8d
7 files changed
+939
No files matched your search
@@ -100,6 +100,7 @@ are wire-stable and unchanged — see ADR-004.
|
||||
| [045](decisions/045-alknetclient-native-dial-seam.md) | AlknetClient Dial Seam | spawn_dispatch / from_connection take-over; dial in consumer |
|
||||
| [046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | Publish Operation Type and HandlerKind::Sink | `OperationType::Pub` (producer→consumer streaming); `SinkHandler` + `HandlerKind::Sink`; `call.published` wire event; `invoke_sink()` dispatch; `Subscription` renamed to `Sub` |
|
||||
| [047](decisions/047-openable-alpns-are-operations.md) | Openable ALPNs Are Operations | `channel/open` dissolves into per-ALPN ops `channels/<alpn>/sub`/`pub`; `channel_open` marker on `OperationSpec`; `ChannelCore` wrapper; extension-trait `ChannelOperationEnv`; connection-owner allocates `channel_id`; opener ledger (Gap 2 fix); ALPNs are call apps |
|
||||
| [048](decisions/048-dispatch-spine-gateway-module.md) | Dispatch Spine (feature-gated `gateway` module) | `alkcall::gateway` behind the `gateway` feature; `GatewayDispatch` invoke spine (deadline knob, re-rooted context) + `schema_disclosure_denial` (FORBIDDEN for ACL deny, spec-404 for Internal); promoted from alkhttp for hub/spoke reuse |
|
||||
|
||||
## Relevant Open Questions
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user