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:
glm-5.3-flash committed 2026-09-03 17:32:45 +00:00
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)