A channel-open op could not be registered or invoked (C-02):
ChannelCore::register_openable did not exist, and resolve_channel_manager
(C-03) was a stub returning None — the ADR-047 §4 dynamic-resolution
shape (downcast context.env to &dyn ChannelOperationEnv) was unworkable
as written: context.env is a PeerCompositeEnv, not a single concrete
type that can be downcast to a channels-backed env.
The fix is per-connection registration (ADR-047 §4 amendment,
2026-08-13): a ChannelCore is constructed per channels connection (in
the install_channel_zero hook, which already runs per-connection and
already receives the ChannelManager), and register_openable is called on
that connection's overlay OperationRegistry (Layer 2 per ADR-019). The
wrapper closes over the per-connection ChannelCore and uses
ChannelCore::manager() directly — no context.env downcast. This
preserves every invariant ADR-047 §4 was written to protect (layering,
per-connection resolution) without adding as_any() to OperationEnv
(which would close the session/connection overlay patterns from
ADR-024, AGENTS.md §6).
Changes:
- ChannelCore::register_openable wraps the ALPN's OpenHandler with the
ACL→check_open→open_channel→spawn→respond flow (ADR-047 §3). Branches
on spec.op_type: Query/Mutation→Once, Sub→Stream (emits { channel_id }
and completes; data plane on the channel's BiStream), Pub→Sink (stub:
channel:pub_open_not_implemented — requires the channel-adoption path,
C-08/Unit 5). The OpenHandler receives (input, Connection, AuthContext)
and spawns the ALPN's protocol on the channel's BiStream, returning a
JoinHandle for teardown.
- ChannelManager::set_handler_task installs the spawned OpenHandler's
JoinHandle after open_channel (which allocates the channel first to
get the BiStream halves, then the handler is spawned, then the task is
recorded for abort on channel/close / connection drop).
- channel:too_many_channels / channel:allocation_failed error codes
mapped to CallError with details (channel:forbidden is the ACL's
FORBIDDEN, already handled by the registry before the wrapper).
- resolve_channel_manager stub removed (C-03); ChannelOperationEnv trait
and ChannelsSessionEnv retained as a two-way-door implementation detail
for future per-connection routing (not on the open-op path). The
tautology filler test (C-22 env.rs) removed.
- ADR-047 §4 amendment records the per-connection-registration decision
(two-way door: the ADR's door-type section explicitly marks the wrapper
shape as a two-way-door implementation detail; the one-way decisions
— per-ALPN op names, channel_open marker, removal of channel/open —
are unchanged).
Acceptance gate (C-02/C-03): one end-to-end test wires ChannelClient ↔
ChannelsAdapter over a real tokio::io::duplex carrying the channels
8-byte chunk header wire format. The accept side's install_channel_zero
hook builds a per-connection ChannelCore, registers a no-op open op
(channels/tty/sub) via register_openable, and runs the dispatch loop.
The client calls call_open_op("channels/tty/sub") on channel 0; the
wrapper does check_open→open_channel→spawn→respond. Asserts the
response carries a non-zero channel_id and that the per-identity quota
was reserved (policy count for the caller incremented to 1).
Verification: 438 tests pass (was 437; +1 e2e), clippy clean, fmt clean,
doc warnings 2 (was 4; fixed the 2 register_openable broken-link
warnings — C-09; the remaining default_policy and env module/macro
warnings are Unit 6 long-tail items).
Channel 0 was dead in both directions (C-01): the call protocol's
stream-per-request model (open_bi per call) is incompatible with
channel 0's single yield-once BiStream — every call_open_op failed
with StreamClosed on the connect side, and channel 0's Connection was
a black hole on the accept side.
The fix is single-stream call mode (ADR-036 amendment): all
EventEnvelope frames are multiplexed on channel 0's one BiStream.
Changes:
- CallConnection gains single_stream_writer: Option<Arc<SharedFrameWriter>>
and new_single_stream() constructor. call_with_payload,
subscribe_with_payload, publish_with_payload, and abort branch on
is_single_stream() — in single-stream mode they write frames through
the shared writer (mutex-serialized) instead of opening a fresh
open_bi per call.
- Dispatcher::run_loop_single_stream reads frames off channel 0's read
half, dispatches call.requested, writes responses through the shared
writer, and routes in-flight call.published/call.completed/
call.aborted to the matching Pub sink's chunk_tx by request_id.
- ChannelsAdapter::handle's InstallChannelZero hook now receives
channel 0's Connection (built by the adapter) and runs the
single-stream dispatch loop on it — closing the accept-side black
hole. Mux runner is spawned BEFORE install_channel_zero so
mux.register(0) can complete.
- ChannelClient::from_connection uses CallConnection::new_single_stream
and spawns a read pump (read_single_stream_until_closed) that routes
channel-0 response frames into the PendingRequestMap via
dispatch_envelope — closing the connect-side StreamClosed path.
- ADR-036 amendment records the single-stream-mode decision (two-way
door: implementation detail, wire format unchanged).
Acceptance gate (C-25 #1): three end-to-end tests wire
ChannelClient ↔ ChannelsAdapter over a real tokio::io::duplex pair
carrying the channels 8-byte chunk header wire format:
- channel_0_end_to_end_call_round_trip: a Query op round-trip
- channel_0_end_to_end_unknown_op_returns_not_found: NOT_FOUND
- channel_0_end_to_end_publish_delivers_chunks: a Pub op with 3 chunks
Verification: 437 tests pass (was 434; +3 e2e), clippy clean, fmt
clean, doc warnings unchanged (4, pre-existing C-09).
Consolidates three code-review passes (one main summary + sub-review A
on ADR-046 + sub-review B on channels ADRs 034–043/047) into a single
verified document under docs/reviews/. Every finding was re-verified
directly against the source at f305f8c with exact file:line refs.
Findings (all verified): 10 critical, 19 major, 7 minor.
- Pub end-to-end cannot work (chunks written to a different bi-stream
than the request); publish() takes Vec<Value> not a Stream; 30s
client timeout; abort-doesn't-cancel-Pub; dispatch re-implements
invoke_sink inline; from_call sink forwarding swallows errors.
- Channel 0 dead in both directions; register_openable absent;
resolve_channel_manager stub; backpressure drops chunks; buffer cap
in messages not bytes; ledger decrement on 1/4 teardown paths; demux
desyncs on oversized chunk; no channel-adoption/collision scheme.
- channel/control + resources/subscribe stubs; busy-wait spin; lost
EOF sentinel; TOCTOU on max_channels; ~50 lines of abandoned
deliberation in poll_write; cargo doc 4 warnings; spec docs
inconsistent post-047.
Includes a 6-unit remediation plan sequenced by dependency, with
acceptance gates. Units 1/2/4 need no spec decisions; Units 3 and 5
each need one written ADR decision first (ADR-047 §4 env-resolution;
§5 channel-id adoption). The overarching acceptance gate is the
end-to-end ChannelClient ↔ ChannelsAdapter test whose absence let
both commits land green.
Two corrections to the original reviews noted in the verification log:
- C-17 (lost EOF sentinel): Sub Review B overstated "the pump does not
write an EOF chunk" — the pump does write EOF when it receives the
sentinel; the real bug is narrower (sentinel lost on full buffer →
pump exits on recv→None without writing EOF).
- Warning count: main review said 5 cargo doc warnings; this pass sees
4 (2× register_openable, 1× default_policy, 1× env module/macro).
Verification:
- cargo test → 432 passed (no source changed; docs-only commit)
- cargo clippy --all-targets -- -D warnings → clean
- cargo fmt --check → clean
- cargo doc --no-deps → 4 warnings (documented as C-09; not addressed
here — fixing them requires the register_openable method to exist,
which is Unit 3 of the remediation plan)
The call protocol had Subscription (server→client streaming) but lacked
the directional complement: client→server streaming, where the initiator
produces a stream and the responder's handler consumes it. This gap was
inherited from the @alkdev/pubsub EventEnvelope prior art, which has
subscribe but no wire-level publish.
ADR-046 adds the Pub primitive:
- OperationType::Pub (client→server streaming)
- OperationType::Subscription renamed to Sub (wire: "subscription" → "sub")
- SinkHandler type + HandlerKind::Sink variant
- PublishStream type alias (Stream<Item = Result<Value, CallError>>)
- OperationRegistry::invoke_sink() dispatch path
- call.published wire event (sixth event type, additive)
- OperationSpec.publish_schema (Option<Value>, validates per-chunk input)
- DispatchResult::Sink + SinkDispatch (handler future + chunk channel)
- Dispatcher::pump_sink (feeds call.published chunks from wire to handler)
- CallConnection::publish() / publish_with_payload() client methods
- from_call sink forwarding handler (make_sink_forwarding_handler)
- make_sink_handler() helper
Fan-out/broker (one producer, N consumers, topic matching) is deferred to
the channels session — the call protocol is point-to-point; the broker is
a routing concern that sits above it. The Pub primitive is the
load-bearing piece the broker will compose on.
- 23 new tests (366 total, up from 343)
- clippy clean, fmt clean
Verification:
cargo test — 366 passed
cargo clippy --all-targets -- -D warnings — clean
cargo fmt --check — clean