feat: per-session fork registry, connect-side serving loop, op/register (review 004 Units 1-3)
Remediates all six findings of review 004 (per-connection dispatch resolution and client-side op serving). All claims re-verified in source before remediation; F-02's member list gains ScopedPeerEnv (also Clone — fork surface simpler than estimated). - OperationRegistry: interior mutability (parking_lot RwLock on both maps); register takes &self; registration/list_operations return owned clones; fork() deep-copies registrations + cached publish-schema validators (F-02/F-03); OperationRegistryBuilder::from_registry. - install_bootstrap_discovery: services/list, services/list-peers, services/schema registered closed over the fork itself, so per-session openables are discoverable and services/schema answers from the fork (F-06). - Dispatcher::serve_single_stream: full-duplex single-stream loop — call.requested dispatches inbound; responded/completed/error resolve outbound pendings; aborted tries both tables (in-flight sink aborts + pending cascade); published routes inbound sinks (F-04). - ChannelClient::from_connection_with_serving(connection, Option<ServingConfig>): opt-in serving; from_connection keeps the pure-consumer default. - registry::op_register: OpRegisterRequest wire DTO (spec in services/schema JSON + replace flag), op_register_spec, op_register_handler (rebuild -> forwarding stub -> register_imported, forced Internal/FromCall), announce_op; CallError::already_exists; spec_to_json_pub; from_call's rebuild_spec_for + forwarding-handler constructors crate-shared (F-05). - ADR-047 §4 amendment #2: per-session fork is the dispatch-registry mechanism; overlay stays nested-invocation/peer-announced landing zone (F-01/F-02). - ADR-022 amendment 2026-09-03: bootstrap-op set (services/list, services/schema, op/register), opt-in connect-side serving, op/register wire shape (F-04/F-05). - alkhttp ADR-048 reconciliation note + OQ-05 re-pointed at the alkcall ADRs (Unit 1b). - Review 004 status -> remediated; remediation log with gates. Verification: - cargo test: 581 passed, 0 failed (565 baseline + 16 new) - cargo test --all-features: 598 passed, 0 failed - cargo clippy --all-targets -- -D warnings: clean - cargo clippy --all-features --all-targets -- -D warnings: clean - cargo clippy --target wasm32-unknown-unknown -- -D warnings: clean - cargo fmt --check: clean - cargo doc --no-deps: clean Gates: fork_registry_open_op_resolves_and_is_discoverable (open op via fork + services/list shows openable + services/schema validates), serving_loop_hub_to_consumer_call_resolves (hub->consumer call through consumer's serving loop, consumer->hub still resolves), op_register_announce_then_hub_call_routes_back_to_consumer (announce -> overlay -> hub call -> forwarding stub -> consumer serves).
This commit is contained in:
1 parent
c0dbf82518
commit
f84d214173
18 files changed
+2024
-138
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (amended 2026-06-26, 2026-07-13, and 2026-07-16 — see "Amendments" below; the 2026-07-16 amendment per ADR-045 §5 removes `CallClient::connect`)
|
||||
Accepted (amended 2026-06-26, 2026-07-13, and 2026-07-16 — see "Amendments" below; the 2026-07-16 amendment per ADR-045 §5 removes `CallClient::connect`; amendment 2026-09-03 — the bootstrap-op set and the connect-side serving loop, see "Amendment (2026-09-03)" below)
|
||||
|
||||
## Context
|
||||
|
||||
@@ -359,6 +359,103 @@ same as `from_openapi` receives HTTP credentials.
|
||||
prior art
|
||||
- POC at `/workspace/@alkdev/dispatch` — head/worker dispatch over SSH+axum
|
||||
|
||||
## Amendment (2026-09-03): bootstrap-op set and the connect-side serving loop
|
||||
|
||||
Review 004 (F-04/F-05) verified two gaps between this ADR's
|
||||
bidirectionality promise (§2 "connection direction is independent of
|
||||
call direction") and the single-stream channel-0 implementation:
|
||||
|
||||
1. **The connect side's read pump resolved responses only** — inbound
|
||||
`call.requested` frames were silently dropped. Serving existed only
|
||||
on the accept side (`run_loop_single_stream`).
|
||||
2. **No wire mechanism announced client-side ops** — `from_call`
|
||||
imports hub ops into the consumer, but a connected peer had no way
|
||||
to announce "here are the ops I serve" over the wire.
|
||||
|
||||
The amendment (implemented in alkcall; the e2e gates are
|
||||
`serving_loop_hub_to_consumer_call_resolves` and
|
||||
`op_register_announce_then_hub_call_routes_back_to_consumer` in
|
||||
`src/channels/client.rs`):
|
||||
|
||||
### The bootstrap-op set (one-way)
|
||||
|
||||
Each side of a channels connection **may serve** the bootstrap ops on
|
||||
channel 0. The set is closed:
|
||||
|
||||
- `services/list` — discovery (the op `from_call` dials on every
|
||||
import; each side is expected to serve it)
|
||||
- `services/schema` — per-op schema disclosure
|
||||
- `op/register` — peer op announcement (below)
|
||||
|
||||
These are the ops a peer may assume are reachable (subject to each
|
||||
op's `AccessControl`); a peer that does not serve one answers
|
||||
`NOT_FOUND` and the caller treats it accordingly (`from_call` already
|
||||
surfaces discovery failure as `AdapterError::DiscoveryFailed`).
|
||||
|
||||
### Connect-side serving is opt-in (two-way door at the API level)
|
||||
|
||||
`ChannelClient::from_connection_with_serving(connection,
|
||||
Option<ServingConfig>)` — with `None` (the pure-consumer default) the
|
||||
read pump resolves outbound pendings only (previous behavior). With
|
||||
`Some(ServingConfig { registry, identity_provider })` the read pump
|
||||
becomes the full-duplex serving loop (`Dispatcher::serve_single_stream`):
|
||||
inbound `call.requested` frames dispatch against the configured
|
||||
registry and resolve back to the peer; outbound pendings still resolve
|
||||
in the same loop. Serving is opt-in because a pure consumer has no
|
||||
registry to serve; the *protocol* is symmetric, the *API* is explicit.
|
||||
|
||||
Direction disambiguation in the loop is by table membership, not
|
||||
framing: an id that is one of *our* outbound pendings resolves there;
|
||||
an id that is an inbound request dispatches; `call.aborted` tries both
|
||||
tables (in-flight sink aborts and the pending map's cascade). IDs are
|
||||
UUID-generated per side, so cross-correlation is not a hazard.
|
||||
|
||||
### `op/register` (one-way in wire shape)
|
||||
|
||||
The peer→hub direction of `from_call`'s bundle flow: a peer sends
|
||||
`call.requested` for `op/register` carrying the `OperationSpec` in the
|
||||
`services/schema` wire shape (`spec_to_json`) plus a `replace` flag.
|
||||
The serving-side handler (`registry::op_register::op_register_handler`):
|
||||
|
||||
1. Rebuilds the spec (the same parser `from_call` uses — one spec
|
||||
serialization on the wire).
|
||||
2. Wraps a **call-forwarding handler** that issues a nested
|
||||
`call.requested` back over channel 0 to the announcing peer (the
|
||||
same shape `from_call`'s imported bundles use — the in-process twin
|
||||
at `protocol/adapter.rs`).
|
||||
3. Writes the bundle into **that connection's overlay** via
|
||||
`register_imported` with `FromCall` provenance and
|
||||
`Visibility::Internal` (composition material, ADR-017 — never
|
||||
directly callable from the serving side's own wire).
|
||||
|
||||
The announced op is discoverable via `services/list-peers` (the
|
||||
overlay is peer-keyed, `compose_root_env` attaches it) and invocable
|
||||
via nested composition (`env.invoke`). Announced Sub/Pub ops register
|
||||
as stubs that answer `INVALID_OPERATION_TYPE` — nested composition is
|
||||
request/response-only (`OverlayOperationEnv`'s contract); the
|
||||
streaming/sink forwarding shapes ride on the `from_call` import path.
|
||||
|
||||
Access control: `op/register` itself carries an `AccessControl` (an
|
||||
unprivileged peer cannot reach the handler — the registry's normal
|
||||
invoke path enforces it). Replace semantics: a collision with an
|
||||
existing overlay registration is rejected with `ALREADY_EXISTS`
|
||||
unless `replace: true` (the reconnect path re-announces). The
|
||||
overlay dies with the connection (Layer 2), so reconnect re-announce
|
||||
is naturally scoped.
|
||||
|
||||
The envelope kind set stays closed at six — bootstrap ops over channel
|
||||
0 are the door (AGENTS.md §7 allows adding kinds; none is needed).
|
||||
|
||||
### Cross-references
|
||||
|
||||
- Review 004 (`docs/reviews/004-per-connection-dispatch-and-client-serving-review.md`)
|
||||
F-04/F-05 — the verification and the design decision
|
||||
- ADR-047 §4 amendment #2 (2026-09-03) — the per-session fork the
|
||||
bootstrap ops compose on
|
||||
- alkhttp OQ-05 / ADR-048 — the browser data-channel wiring this
|
||||
unblocks (the alkhttp-side gap was wiring; these two mechanisms are
|
||||
what it wires to)
|
||||
|
||||
## Amendments (2026-06-26)
|
||||
|
||||
This ADR left four decisions as two-way doors (§1 Consequences flagged DC-1's
|
||||
|
||||
@@ -5,7 +5,99 @@
|
||||
Accepted (amends ADR-037; refines ADR-044, ADR-046; §4 amended
|
||||
2026-08-13 — open ops are registered per-connection, not resolved via
|
||||
`context.env` downcast — see "Amendment (§4 per-connection
|
||||
registration, 2026-08-13)" below)
|
||||
registration, 2026-08-13)"; amendment #2 (2026-09-03) — the
|
||||
per-connection registration mechanism is the **per-session fork of the
|
||||
base registry installed as the session's dispatch registry**, not the
|
||||
connection overlay — see "Amendment (§4 mechanism, 2026-09-03)" below)
|
||||
|
||||
## Amendment (§4 mechanism, 2026-09-03)
|
||||
|
||||
The 2026-08-13 amendment named the registration target as "the
|
||||
connection overlay registry (Layer 2 per ADR-019)". Review 004 (F-01)
|
||||
verified that this shape cannot dispatch: the top-level dispatch path
|
||||
(`Dispatcher::dispatch` / `run_loop_single_stream`) resolves and
|
||||
invokes against the dispatcher's **base registry only**; the
|
||||
connection overlay is reachable solely as a layer of `context.env`
|
||||
(for nested invocations — a handler calling `env.invoke(...)), never
|
||||
for resolving the incoming `call.requested` itself. An open op
|
||||
registered on the overlay resolves `NOT_FOUND` on the wire.
|
||||
|
||||
The one shape proven end-to-end (alkcall's own e2e gate) is different:
|
||||
the `install_channel_zero` hook builds a **fresh per-connection
|
||||
registry containing the open op and passes it as the dispatcher's base
|
||||
registry**. This amendment makes that the operative mechanism.
|
||||
|
||||
**The decision: per-connection registration happens on a fork of the
|
||||
deployment's base registry, installed as the session's dispatch
|
||||
registry.** The `install_channel_zero` hook (and any future
|
||||
session-establishment seam):
|
||||
|
||||
1. **Forks** the deployment's base registry
|
||||
(`OperationRegistry::fork` — a deep copy carrying handlers,
|
||||
provenance, composition authority, capabilities, and the cached
|
||||
publish-schema validators; review 004 F-02/F-03).
|
||||
2. **Registers the per-session ops on the fork** — the generic channel
|
||||
ops (`ChannelOperations::register_on`), the openables
|
||||
(`ChannelCore::register_openable`), and the bootstrap discovery ops
|
||||
(`install_bootstrap_discovery`, closed over the fork itself so
|
||||
`services/list` sees the fork's per-session ops — review 004 F-06).
|
||||
3. **Dispatches channel 0 over the fork** (`Dispatcher::new(fork,
|
||||
...)`).
|
||||
|
||||
The fork is possible because `OperationRegistry` is internally
|
||||
mutable (`parking_lot::RwLock` around both maps) — a fork shared as an
|
||||
`Arc<OperationRegistry>` can receive bootstrap ops after the
|
||||
dispatcher was built, and the self-referential discovery closure sees
|
||||
every post-install registration.
|
||||
|
||||
The **connection overlay (Layer 2) remains what ADR-019/ADR-024
|
||||
describe**: the landing zone for peer-announced ops (`op/register`,
|
||||
review 004 F-05 — ADR-022 amendment) and the nested-invocation target
|
||||
for imported ops. It is not the dispatch-resolution path for the
|
||||
session's own ops.
|
||||
|
||||
Rationale for the fork shape over an overlay-aware dispatch fallback
|
||||
(F-02 option (b)): the fork is the only shape with an end-to-end
|
||||
proof, it needs no change to the shared dispatch loop, and it keeps
|
||||
the overlay's `invoke_with_policy` shape (namespace-scoped,
|
||||
parent-context-driven — built for nested composition) out of the
|
||||
top-level call path, where it does not match the frame-handling
|
||||
contract.
|
||||
|
||||
This preserves every invariant the 2026-08-13 amendment protected:
|
||||
|
||||
- **Layering (ADR-044):** unchanged — the open-op wrapper is in
|
||||
`channels-call`; the call crate's `OperationRegistry` gains only
|
||||
`fork` (and interior mutability), no channels types.
|
||||
- **Per-connection resolution:** the open op gets the *right*
|
||||
`ChannelManager` because the fork is built per-connection and its
|
||||
openable closes over that connection's `ChannelCore`.
|
||||
- **"Marked ops invoked outside a channels session" (ADR-047 §2):**
|
||||
unchanged in effect — a `channels/<alpn>/sub` op registered only on
|
||||
a session fork is not reachable on a bare `alk/call` connection (the
|
||||
fork isn't that session's dispatch registry) — the dispatch path
|
||||
returns `NOT_FOUND`.
|
||||
|
||||
### Door type
|
||||
|
||||
**Two-way (implementation detail), as before.** The registration
|
||||
*target* mechanism (fork as base registry) sits within the same
|
||||
wrapper-shape detail the 2026-08-13 amendment already marked two-way.
|
||||
The one-way decisions (per-ALPN op names, the `channel_open` marker,
|
||||
removal of `channel/open`/`direction`) are unchanged.
|
||||
|
||||
### References
|
||||
|
||||
- Review 004 F-01/F-02/F-03/F-06
|
||||
(`docs/reviews/004-per-connection-dispatch-and-client-serving-review.md`)
|
||||
— the verification and the mechanism decision
|
||||
- ADR-022 amendment (2026-09-03) — the bootstrap-op set (`services/list`,
|
||||
`services/schema`, `op/register`) and the connect-side serving loop
|
||||
- ADR-019: operation registry layering (the overlay stays the nested
|
||||
invocation / peer-announced-ops landing zone)
|
||||
- The e2e gate: `fork_registry_open_op_resolves_and_is_discoverable`
|
||||
(`src/channels/client.rs`) — open op resolves through the fork,
|
||||
per-session openable in `services/list`, `services/schema` validates
|
||||
|
||||
## Amendment (§4 per-connection registration, 2026-08-13)
|
||||
|
||||
|
||||
Reference in new issue
Block a user