- open-questions.md: OQ-39 → ADR-052, OQ-40 → ADR-053 (resolutions replace the deferred-until-needed posture) - channel-operations.md: the put contract (initiator allocates + streams, reserved input key, one response, acks-in-result sentence), the control routing (ADR-052), the enumerator aggregation (ADR-053), the connection-owner paragraph's pub-era correction, the ADR table rows - ADR-051 §6: PubTypedOpen → the put translation; ADR-047's 2026-09-06 amendment records its era honestly (the dynamic half is now ADR-053) - review 010: all four findings resolved (with the S-01 sketch correction so the published record stops carrying the wrong-graph reading) - review 011: closed (all seven pins implemented + gates landed) - review 001 C-08: resolved note (odd/even + adopt, implemented) - AGENTS.md: the 'Stub Guard' section — the not_implemented grep is part of cargo test; the registration invariants and the implement-or-stop ruling are release-blocking Verified: cargo test (696+guard), clippy -D warnings, fmt, doc clean.
1337 lines
72 KiB
Markdown
1337 lines
72 KiB
Markdown
# Review 001 — Pub (ADR-046) and Channels Integration (ADR-047)
|
||
|
||
## Status
|
||
|
||
Verified, open for remediation.
|
||
|
||
## Scope
|
||
|
||
Consolidated code review of the two commits that landed the `Pub`
|
||
operation type / `HandlerKind::Sink` (ADR-046, commit `ea66398`) and
|
||
the channels protocol + openable-ALPNs model (ADR-047, commit
|
||
`f305f8c`). The review consolidates three passes (one main summary +
|
||
two sub-reviews: one focused on ADR-046, one on the channels ADRs
|
||
034–043/047) and was produced against the tree at `f305f8c`.
|
||
|
||
Every finding below was **re-verified directly in the source** during
|
||
this consolidation pass (2026-08-12). Each finding carries: the ADR it
|
||
drifts from (if any), a severity, the exact `file:line` reference(s),
|
||
the verification status, and the unit-of-work bucket it belongs to.
|
||
Findings that did not survive verification, or that the original
|
||
reviews overstated, are called out as such.
|
||
|
||
## Baseline verification (this pass)
|
||
|
||
```
|
||
cargo test → 432 passed, 0 failed
|
||
cargo clippy --all-targets -D warnings → clean
|
||
cargo fmt --check → clean
|
||
cargo doc --no-deps → 4 warnings (see C-09)
|
||
```
|
||
|
||
The suite is green, but green is misleading here: **all coverage is
|
||
unit-level**. Not a single test wires two sides of either new protocol
|
||
together end-to-end (no `CallConnection::publish` ↔
|
||
`Dispatcher::handle_stream` round-trip; no `ChannelClient` ↔
|
||
`ChannelsAdapter` round-trip). The integration seams — which is exactly
|
||
where ADR-047 lives — are where the real problems live, and the absence
|
||
of end-to-end tests is what let both commits land green with multiple
|
||
implementation-killing defects.
|
||
|
||
## Verdict
|
||
|
||
- **ADR-046 registry half (types, `HandlerKind::Sink` validation,
|
||
`invoke_sink`, builders, wire strings, discovery strings): solid**,
|
||
close to spec, well tested at the unit level.
|
||
- **ADR-046 protocol half: has one implementation-killing bug** (P-01:
|
||
publish chunks are written to a different bi-stream than the request
|
||
— end-to-end publish cannot succeed on any transport), plus two
|
||
unimplemented decided behaviors and an abort story that exists only
|
||
in doc comments.
|
||
- **ADR-047: the components are good but the integration layer — the
|
||
part ADR-047 actually decided — is scaffolding.** As committed, the
|
||
channels protocol cannot open, use, or account for a single data
|
||
channel end-to-end, channel 0 is dead in both directions, and a
|
||
`publish()` cannot succeed on any transport.
|
||
|
||
The architecture is right: no channels types leaked into the call
|
||
crate, `OperationEnv` stayed a trait, the wire formats (envelope +
|
||
8-byte chunk header) match spec exactly, the `channel_open` marker
|
||
plumbing (Gap F) is complete and tested, and auth stays on the one
|
||
`AccessControl` path. What's missing is the connective tissue a
|
||
tty/docker crate would actually call.
|
||
|
||
## Severity legend
|
||
|
||
- **[critical]** — the protocol cannot function end-to-end as committed
|
||
on any transport; or a decided spec invariant is violated in a way
|
||
that corrupts data or causes silent permanent state damage.
|
||
- **[major]** — a decided behavior is missing, wrong, or a real
|
||
reliability hazard; works in the happy path but fails a spec-required
|
||
edge case or stores wrong data.
|
||
- **[minor]** — drift, convention violation, dead code, or a
|
||
doc/spec inconsistency with no correctness impact.
|
||
|
||
---
|
||
|
||
# Part A — Pub / `HandlerKind::Sink` (ADR-046) findings
|
||
|
||
## P-01 [critical] — Publish chunks are written to the wrong stream; end-to-end publish cannot work
|
||
|
||
**ADR drift:** ADR-046 §5, §8 (the stream lifecycle and `publish()`
|
||
client method).
|
||
**Verified:** YES. `src/protocol/connection.rs:285-311` (open stream A,
|
||
write `call.requested`), `src/protocol/connection.rs:334-362`
|
||
(`write_publish_chunks` opens a **fresh** `open_bi()` at line 343-346
|
||
and writes all `call.published` + `call.completed` on stream B).
|
||
**Responder side:** `src/protocol/dispatch.rs:433-435, 516`
|
||
(`pump_sink` reads chunks from the *same* stream the request arrived
|
||
on — stream A). Stream B arrives as a fresh `accept_bi` whose frames
|
||
hit the `handle_stream` "ignoring non-requested/non-aborted event"
|
||
branch (`src/protocol/dispatch.rs:443-444`) and are silently
|
||
discarded.
|
||
|
||
**Consequences (verified):**
|
||
- Multi-stream transport (QUIC): chunks are lost; `pump_sink` blocks
|
||
on stream A until the client's dropped write-half surfaces as
|
||
reset/EOF — best case the handler sees an *empty* stream and returns
|
||
a bogus result (e.g. `count: 0`), worst case it hangs until the 30s
|
||
client-side timeout (P-05).
|
||
- Single-stream transport (`Connection::from_bidi`): the second
|
||
`open_bi` returns `StreamClosed` (`src/core/types.rs`), so *every*
|
||
publish fails outright with "failed to open stream".
|
||
|
||
No end-to-end publish test exists (see P-12), which is how this landed
|
||
green. **Fix:** keep stream A's write half and pump chunks on it. This
|
||
is also what a `Stream`-typed `publish` (P-02) would force
|
||
structurally. **Acceptance gate:** one end-to-end publish test that
|
||
moves ≥2 chunks and asserts the responder sees them.
|
||
|
||
## P-02 [major] — `publish()` takes `Vec<Value>`, not a `Stream`
|
||
|
||
**ADR drift:** ADR-046 §8 specifies
|
||
`stream: Pin<Box<dyn Stream<Item = Value> + Send>>`. The implementation
|
||
is `chunks: Vec<Value>`.
|
||
**Verified:** YES. `src/protocol/connection.rs:249-254` (`publish`)
|
||
and `:268-272` (`publish_with_payload`) both take `chunks: Vec<Value>`;
|
||
`write_publish_chunks` (`:334-362`) iterates the `Vec`.
|
||
|
||
This buffers the entire publish in memory before anything is sent and
|
||
makes the ADR's flagship use cases (telemetry ingest, live upload,
|
||
drag-drop file streaming) impossible from this API — the chunk set
|
||
must be fully materialized up front. **Fix P-01 + P-02 together:**
|
||
hold stream A's write half, accept a `Stream`, pump it as
|
||
`call.published` events. This also resolves P-05's tension (a streaming
|
||
publish should register like `subscribe` with `timeout: None`, not the
|
||
30s `Call` deadline).
|
||
|
||
## P-03 [major] — `publish_schema` per-chunk validation is written but never read
|
||
|
||
**Fixed (2026-08-13, Unit 8).** `publish_schema` now round-trips through
|
||
`spec_to_json` / `operation_spec_schema()` / `rebuild_spec_for`, and both
|
||
dispatch paths (`pump_sink` and `run_loop_single_stream`) validate each
|
||
`call.published` chunk's `input` against the schema via
|
||
`alktype::validation::build_validator`; a validation failure injects
|
||
`Err(CallError::invalid_input(...))` and terminates the stream.
|
||
|
||
**ADR drift:** ADR-046 §4, and the field's own doc comment at
|
||
`src/registry/spec.rs:181-186` which says "the dispatch path validates
|
||
each `call.published` event's `payload.input` against this schema
|
||
before yielding it to the `SinkHandler`."
|
||
**Verified:** YES. `grep publish_schema` outside `src/registry/spec.rs`
|
||
matches nothing. The field is declared (`spec.rs:186`), defaults to
|
||
`None` (`:228`), and has a builder (`with_publish_schema`, `:236-239`),
|
||
and is **never read anywhere**. `pump_sink` forwards chunks as-is
|
||
(`src/protocol/dispatch.rs:527-535`). It is also missing from
|
||
`spec_to_json`, from `operation_spec_schema()`, and from `from_call`'s
|
||
`rebuild_spec_for` — so an imported Pub op loses the schema entirely.
|
||
`alktype` is a declared dependency (`Cargo.toml:21`) and AGENTS.md §10
|
||
says to use it for exactly this; it is unused in `src/`. Dead field +
|
||
documented-but-unimplemented behavior.
|
||
|
||
## P-04 [major] — Initiator `call.error` does not terminate the publish stream
|
||
|
||
**Fixed (2026-08-13, Unit 8).** Both `pump_sink` and
|
||
`run_loop_single_stream` now match `call.error`, parse the `CallError`
|
||
from the payload, inject it as `Err(call_error)` into the sink's
|
||
`chunk_tx`, and terminate the feed. A malformed payload falls back to
|
||
`CallError::internal("publish error from initiator (malformed)")`.
|
||
|
||
**ADR drift:** ADR-046 §5 event table and §6 ("`Err(call_error)` for an
|
||
initiator-side error (a `call.error` event from the initiator)"); plus
|
||
the `SinkHandler`/`PublishStream` doc contract
|
||
(`src/registry/registration.rs:26-31, 44-49`).
|
||
**Verified:** YES. `pump_sink` matches only `"call.published"`,
|
||
`"call.completed"`, `"call.aborted"` and drops everything else into a
|
||
debug-log ignore branch (`src/protocol/dispatch.rs:526-550`). A
|
||
`call.error` frame is silently ignored; the only `Err` ever injected is
|
||
the `call.aborted` case, and it's an ad-hoc
|
||
`CallError::internal("publish aborted by initiator")`
|
||
(`dispatch.rs:538-543`), not the initiator's error.
|
||
|
||
## P-05 [major] — Client-side 30s timeout on Pub contradicts "long-lived, same as Sub"
|
||
|
||
**ADR drift:** ADR-046 §7 ("the stream may be long-lived, same as
|
||
`Sub`") clears the deadline for Pub on the responder.
|
||
**Verified:** YES. `publish_with_payload` registers via `register_call`
|
||
with `DEFAULT_CALL_TIMEOUT` = 30s (`src/protocol/connection.rs:32,
|
||
296-303`). The sweeper resolves expired `Call` entries with `TIMEOUT`
|
||
(`src/protocol/pending.rs`). `subscribe` avoids this with
|
||
`register_subscribe(timeout: None)` (`connection.rs:215-218`). Any
|
||
publish whose stream takes >30s wall-clock gets a spurious client-side
|
||
`TIMEOUT` while the responder keeps consuming. **Fix:** register like
|
||
`subscribe` with `timeout: None` (naturally falls out of the P-01/P-02
|
||
streaming rework).
|
||
|
||
## P-06 [major] — Abort does not cancel a Pub
|
||
|
||
**ADR drift:** ADR-046 §5 table (`call.aborted` | either side | "Cancel
|
||
the Pub"); ADR-020 (stream drop on abort; cascade through sink
|
||
handlers).
|
||
**Verified:** YES, both abort paths are broken:
|
||
- **Cross-stream (the path the shipped client exercises):**
|
||
`CallConnection::abort()` sends `call.aborted` on a **new** stream
|
||
(`src/protocol/connection.rs:236-243` → `write_envelope` → `open_bi`
|
||
at `:388-392`). On the responder that frame is handled by a different
|
||
`handle_stream` task, whose `handle_abort` only mutates the
|
||
`PendingRequestMap` (`src/protocol/dispatch.rs:381-389`). Inbound
|
||
requests are never registered in that map, and nothing links the map
|
||
to the spawned `handle_stream`/`pump_sink` task — the running
|
||
`SinkHandler` and feed loop are untouched. The doc comments at
|
||
`dispatch.rs:494-498` (and `:459-462` for `pump_stream`) describe a
|
||
cancellation mechanism that does not exist.
|
||
- **Same-stream (dead-untested):** `pump_sink` injects an `Err` and
|
||
breaks the feed on `call.aborted` (`dispatch.rs:538-543`), then
|
||
**still `tokio::join!`s the handler to completion and writes the
|
||
response back to the aborting initiator** (`:562-568`). The handler
|
||
future is never dropped.
|
||
|
||
Net: an initiator cannot actually cancel an in-flight Pub. (The
|
||
`pump_stream` half of this predates ADR-046, but ADR-046 §5 explicitly
|
||
promises abort-cancels-Pub, so it is in scope here.) **Also:** during a
|
||
sink pump, `call.aborted` frames for **other** request IDs on the same
|
||
stream are dropped by the id-mismatch guard before the type match
|
||
(`dispatch.rs:518-525`) — a conforming peer that serializes calls on
|
||
one stream loses requests. Same for a second `call.requested`
|
||
multiplexed on that stream.
|
||
|
||
## P-07 [major] — Handler-returns-early: response withheld until the initiator finishes
|
||
|
||
**Fixed (2026-08-13, Unit 8).** `pump_sink` now uses
|
||
`tokio::select!` over `handler.fuse()` and `reader.read_frame()`; when
|
||
the handler completes first, the response is written immediately and
|
||
the feed is short-circuited (the loop breaks, `chunk_tx` is dropped,
|
||
the handler's `PublishStream` sees EOF). The feed-wins branch awaits
|
||
the handler after the loop as before.
|
||
|
||
**ADR drift:** ADR-046 §7 (long-lived publish streams).
|
||
**Verified:** YES. `pump_sink` uses `tokio::join!(feed_fut, handler)`
|
||
and only writes the response after **both** complete
|
||
(`src/protocol/dispatch.rs:562-568`). If the `SinkHandler` returns
|
||
early (e.g. rejects the upload after chunk 1), the feed loop notices
|
||
only when its next `chunk_tx.send` fails (`dispatch.rs:533-535`) — only
|
||
when another chunk arrives. An initiator that pauses between chunks, or
|
||
a long/infinite publish, never learns of the early error; the response
|
||
is deferred indefinitely. Not a deadlock, but an unbounded stall on a
|
||
path the ADR intends for long-lived streams.
|
||
|
||
## P-08 [major] — The dispatch Pub branch re-implements `invoke_sink` inline
|
||
|
||
**ADR drift:** ADR-046 §6, §7 (`Pub` → `invoke_sink()`; `dispatch()`
|
||
branches on `op_type` and routes Pub through `invoke_sink`).
|
||
**Verified:** YES. The `Pub` branch of `Dispatcher::dispatch`
|
||
re-implements the not-found / visibility / ACL / handler-kind checks
|
||
inline (`src/protocol/dispatch.rs:309-377`) and invokes the
|
||
`SinkHandler` directly, duplicating `OperationRegistry::invoke_sink`
|
||
(`src/registry/registration.rs:283-345`) line for line. `invoke_sink`
|
||
is only ever called from its own tests. Divergence hazard: any future
|
||
fix in `invoke_sink` (e.g. adding the missing `publish_schema`
|
||
validation, P-03) won't reach the wire path.
|
||
|
||
## P-09 [major] — `from_call` sink forwarding is store-and-forward and swallows errors
|
||
|
||
**ADR drift:** ADR-046 §9 (sink forwarding); the `SinkHandler` contract
|
||
(`src/registry/registration.rs:29-31`: an `Err` terminates the
|
||
stream).
|
||
**Verified:** YES. `make_sink_forwarding_handler` does
|
||
`publish_stream.filter_map(|item| async move { item.ok() }).collect::<Vec<_>>().await`
|
||
then one `publish_with_payload` call
|
||
(`src/client/from_call.rs:464-475`). Three problems:
|
||
1. The whole stream is buffered in memory — unbounded, contradicting
|
||
the doc comment "No truncation — the full stream is forwarded
|
||
end-to-end" (`from_call.rs:452-453`) in spirit (forwarded, but not
|
||
streamed; a hub relaying a large upload holds it all in RAM).
|
||
2. `Err` items are **silently discarded and iteration continues**
|
||
(`item.ok()` → `None` → filtered out), contradicting the contract
|
||
that an `Err` terminates the stream — a partial/errored publish is
|
||
forwarded upstream as if it completed cleanly.
|
||
3. It inherits P-01, so the forwarded publish is broken anyway.
|
||
|
||
## P-10 [minor] — String literals instead of wire constants in `pump_sink`
|
||
|
||
**Verified:** YES. `pump_sink` matches `"call.published"`,
|
||
`"call.completed"`, `"call.aborted"` (`src/protocol/dispatch.rs:527,
|
||
537, 538`) instead of `EVENT_PUBLISHED`/`EVENT_COMPLETED`/`EVENT_ABORTED`
|
||
(`src/protocol/wire.rs:12-17`), which the rest of the file imports and
|
||
uses. Refactor hazard, not a correctness issue.
|
||
|
||
## P-11 [minor] — ADR-046 §3 vs §6 `SinkHandler` stream-item-type inconsistency
|
||
|
||
**Verified:** YES (internal ADR inconsistency, not a code bug). ADR-046
|
||
§3 defines `SinkHandler` receiving `Pin<Box<dyn Stream<Item = Value> +
|
||
Send>>`; §6 defines `invoke_sink`'s `publish_stream` as
|
||
`Item = Result<Value, CallError>`. The code resolves this in favor of
|
||
§6 uniformly: `SinkHandler` itself takes `Item = Result<Value,
|
||
CallError>` (`src/registry/registration.rs:32-40`), aliased as
|
||
`PublishStream` (`:49`), and `invoke_sink` passes it straight through
|
||
(`:344`). This contradicts §3's literal type but is arguably the right
|
||
resolution (the handler must see initiator errors), and the ADR's
|
||
Door-type section explicitly marks the concrete stream item type a
|
||
two-way-door detail. **Action:** amend ADR-046 §3 text to match §6.
|
||
|
||
## P-12 — Test coverage gaps (ADR-046)
|
||
|
||
**Verified:** YES — all 23 new Pub tests are either registry-level
|
||
(`invoke_sink` with a synthetic stream), responder-side with
|
||
hand-crafted frames on **one** stream, or builder/wire-shape tests.
|
||
`src/protocol/connection.rs` has **zero** publish tests. Specific
|
||
decided behaviors with no test:
|
||
1. End-to-end publish through `CallConnection::publish` ↔
|
||
`Dispatcher::handle_stream` (would have caught P-01 immediately).
|
||
2. `deadline: None` for Pub — `dispatch_pub_clears_deadline_to_none`
|
||
asserts nothing about the deadline; the test name is misleading.
|
||
3. `publish_schema` validation (untested + unimplemented, P-03).
|
||
4. Initiator `call.error` terminating the publish stream (P-04).
|
||
5. Abort during a Pub, both directions (P-06 — same-stream path is
|
||
dead-untested; no initiator-side `abort()` of an in-flight publish).
|
||
6. `from_call` Pub forwarding — no test that `build_bundles` maps
|
||
`"pub"` → `HandlerKind::Sink`; no behavioral test of
|
||
`make_sink_forwarding_handler` (Err-dropping, forwarded_for
|
||
population).
|
||
7. Backpressure — nothing exercises a full 64-slot chunk channel
|
||
(`PUBLISH_CHANNEL_BUFFER`, `dispatch.rs:45`) with a slow handler,
|
||
or the handler-returns-early feed-termination path.
|
||
8. `OverlayOperationEnv` Sink arm (`connection.rs:523-528`) — the Sub
|
||
arm has a test; Sink does not.
|
||
|
||
## P-13 [minor] — `SinkDispatch` exposes `chunk_tx` publicly
|
||
|
||
**Verified:** YES. `SinkDispatch` exposes `chunk_tx:
|
||
futures::mpsc::Sender` publicly (`src/protocol/dispatch.rs:78-81`). The
|
||
channel type and buffer size are effectively public API now; consider
|
||
opaque wrapping before crates.io.
|
||
|
||
---
|
||
|
||
# Part B — Channels (ADR-034–043, 047) findings
|
||
|
||
## C-01 [critical] — Channel 0 is dead in both directions
|
||
|
||
**ADR drift:** ADR-036 (channel 0 pre-negotiated as `alknet/call`).
|
||
**Verified:** YES, both sides.
|
||
- **Accept side:** `src/channels/adapter.rs:181-188` — channel 0's
|
||
`Connection` is built and bound to `_channel0_conn`, which is
|
||
**never handed to anything**. The `InstallChannelZero` hook
|
||
(`adapter.rs:41-42`) receives only `(&ChannelManager, &AuthContext)`
|
||
— it has no way to reach the channel-0 `Connection` or its send/recv
|
||
halves (already consumed by `install_channel_zero` at `adapter.rs:181`,
|
||
which can't be called again — it returns `ChannelExists`). Inbound
|
||
`call.requested` chunks on channel 0 route into a receiver nobody
|
||
reads. Channel 0 on the accept side is a black hole.
|
||
- **Connect side:** `src/channels/client.rs:64-65` wraps channel 0 in
|
||
`CallConnection`, but `CallConnection::call_with_payload` opens a
|
||
**new bidi stream per request** via `connection.open_bi()`, and
|
||
`ChannelBidiStreamSource::open_bi` returns `Err(StreamClosed)`
|
||
unconditionally (`src/channels/source.rs:58-60`). So **every**
|
||
`ChannelClient::call_open_op` fails immediately with "failed to open
|
||
stream."
|
||
|
||
The call protocol's stream-per-request model
|
||
(`Dispatcher::run_loop` accepting streams in a loop,
|
||
`dispatch.rs:603-620`; `CallConnection` opening one stream per call)
|
||
is structurally incompatible with channel 0's single yield-once
|
||
`BiStream`. **This needs a single-stream call mode** (multiplex all
|
||
requests on one framed stream for both the client and the dispatch
|
||
side), which is a real design gap, not just a bug fix. **Acceptance
|
||
gate:** one end-to-end channel-0 test: `ChannelClient::call_open_op`
|
||
↔ `ChannelsAdapter` accept-side call dispatch, one round-trip.
|
||
|
||
## C-02 [critical] — `register_openable` / the ADR-047 §3 open flow does not exist
|
||
|
||
**ADR drift:** ADR-047 §3 (`ChannelCore` wrapper +
|
||
`register_openable(spec, open_handler, channel_core)` helper; the
|
||
ACL→cap→allocate→ledger→spawn→respond flow).
|
||
**Verified:** YES. `ChannelCore` has only `new`/`manager`/`check_open`/
|
||
`on_close` (`src/channels/operations.rs:280-311`). Doc comments link to
|
||
`ChannelCore::register_openable` (`operations.rs:10, 62`) — the
|
||
rustdoc warnings in C-09 are those dangling links. There is currently
|
||
**no way to register a channel-open op at all**; the
|
||
`channel:forbidden` / `channel:no_channels_session` /
|
||
`channel:allocation_failed` / `channel:too_many_channels` error-code
|
||
mapping to `CallError` exists only in comments. The whole open flow is
|
||
unreachable.
|
||
|
||
## C-03 [critical] — `resolve_channel_manager` (ADR-047 §4) is a stub
|
||
|
||
**ADR drift:** ADR-047 §4 (per-connection `ChannelManager` resolution
|
||
via the extension-trait downcast).
|
||
**Verified:** YES. `resolve_channel_manager` unconditionally returns
|
||
`None` (`src/channels/env.rs:108-128`). The comment at `:111-125` admits
|
||
a real downcast is impossible without `Any`-machinery and says "the
|
||
implementation uses the `ChannelCore`'s stored manager instead" —
|
||
which contradicts ADR-047 §4's core point (a statically-registered
|
||
handler closing over a static `ChannelCore` "has no way to know which
|
||
channels connection invoked it"). `ChannelsSessionEnv` exists
|
||
(`env.rs:46`) but nothing constructs it outside tests (`env.rs:148` is
|
||
the only construction site). `channel:no_channels_session` appears only
|
||
in comments. **Decision needed:** either add `as_any()` to
|
||
`OperationEnv` (or similar) to make §4 implementable, or amend ADR-047
|
||
to bless the "manager passed at registration via `ChannelCore`" shape.
|
||
The current stub gives you neither per-connection resolution nor the
|
||
amended spec.
|
||
|
||
## C-04 [critical] — Backpressure is inverted: the demux drops chunks
|
||
|
||
**ADR drift:** ADR-040 DP-5 / REQ-CH-05 (lossless bounded-buffer
|
||
backpressure — the demux stalls until the consumer drains).
|
||
**Verified:** YES. `route_payload` uses `try_send` and on `Full` logs a
|
||
warn and **drops the chunk** (`src/channels/manager.rs:253-258`).
|
||
`route_payload` is sync (`manager.rs:246`), so it *cannot* await — the
|
||
shape itself deviates. This is a reliability-layer protocol with no
|
||
retransmit; a dropped mid-stream data chunk silently corrupts the
|
||
channel's byte stream for the handler. **Fix:** make `route_payload`
|
||
async (or use a `blocking_send`/`reserve`-equivalent) and await on
|
||
full.
|
||
|
||
## C-05 [critical] — The buffer bound is in messages, not bytes
|
||
|
||
**ADR drift:** ADR-040 ("1 MiB default"; "256 × 1 MiB = 256 MiB worst
|
||
case per connection" memory bound).
|
||
**Verified:** YES. `DEFAULT_BUFFER_CAP = 1024 * 1024`
|
||
(`src/channels/reassembly.rs:35`) is passed as the **capacity of
|
||
`tokio::mpsc::channel`** (`reassembly.rs:77`, also `manager.rs:180,
|
||
219`, `mux.rs:115`), which counts *messages*, not bytes. The actual
|
||
per-channel bound is 1,048,576 chunks × up to 16 MiB each ≈ 16 TiB,
|
||
not 1 MiB. The doc comment at `reassembly.rs:31-34` even claims "1 MiB"
|
||
while the bound is ~6 orders of magnitude larger. ADR-040's
|
||
per-connection memory bound effectively does not exist. **Fix:** a
|
||
byte-budgeted bound (track accumulated `payload.len()` against the
|
||
cap, not message count).
|
||
|
||
## C-06 [critical] — Opener-ledger decrement happens on only 1 of 4 teardown paths
|
||
|
||
**ADR drift:** ADR-047 §7 (the self-DoS fix: decrement on
|
||
close-received, close-sent, handler-exit, connection-drop).
|
||
**Verified:** YES. Only close-received is wired
|
||
(`src/channels/operations.rs:197-204`). On transport EOF,
|
||
`clear_all()` returns the `(channel_id, opener)` list
|
||
(`src/channels/manager.rs:305-316`) and the demux loop **only logs its
|
||
length** (`src/channels/adapter.rs:139-143`) — `on_close` is never
|
||
called. Close-sent locally: nothing sends `channel/close` locally, no
|
||
decrement hook. Handler exit: a handler task that finishes naturally
|
||
triggers no teardown at all. The exact "peer whose connection dies at
|
||
cap is permanently at cap" self-DoS that ADR-047 §7 was written to fix
|
||
is back. The auth-blind adapter has no way to reach the policy — there
|
||
is no hook — so this is an architectural gap, not just a missed call.
|
||
|
||
## C-07 [critical] — Demux desynchronizes permanently on an oversized chunk
|
||
|
||
**ADR drift:** channels-wire.md §MAX_CHUNK_LEN (resync by skipping
|
||
`length` bytes; the parsed length is available in the error variant).
|
||
**Verified:** YES. On `ChunkError::TooLarge` (and any header-parse
|
||
error) the loop `continue`s without skipping the payload bytes
|
||
(`src/channels/adapter.rs:104-110`). The next 8-byte header read
|
||
consumes the first 8 bytes of the oversized payload → permanent stream
|
||
desync; every subsequent "header" is garbage routed to random
|
||
`channel_id`s (lenient drop hides the corruption). `ChunkError::TooLarge`
|
||
carries the parsed `length` (`src/channels/wire.rs:95-98`) — it is
|
||
discarded. **Fix:** on `TooLarge`, read and discard `length` bytes
|
||
before continuing.
|
||
|
||
## C-08 [critical] — No channel-adoption path → channel-id agreement is impossible
|
||
|
||
**ADR drift:** implied by ADR-037/047 §5 (connection-owner allocates).
|
||
**Verified:** YES. The side that did **not** allocate a `channel_id`
|
||
has no way to install routing state for it: `open_channel` always
|
||
allocates a fresh id (`manager.rs:167`), `install_channel_zero` is
|
||
hardcoded to 0. A consumer that receives `{channel_id: 7}` from the
|
||
responder cannot register 7 in its local manager/mux — inbound chunks
|
||
for 7 are dropped as unknown (`manager.rs:268-274`) and there's nothing
|
||
to write to. Additionally, both sides construct independent managers
|
||
with `next_id` starting at 1 (`manager.rs:98`; adapter side
|
||
`adapter.rs:170`; client side `client.rs:55`), so if both sides ever
|
||
allocate on one connection (Sub opened by A + Pub opened by A, per
|
||
ADR-047 §5's Pub case), **IDs collide** — one wire `channel_id`, two
|
||
different channels. No odd/even split or any collision-avoidance scheme
|
||
exists in either the ADRs (post-047) or the code. **This needs a spec
|
||
decision** (odd/even split, or an adoption op), not just a code fix.
|
||
|
||
## C-09 [major] — `cargo doc` emits 4 warnings (broken doc links)
|
||
|
||
**Verified:** YES — 4 warnings (the original reviews said 5; this pass
|
||
sees 4):
|
||
- `unresolved link to ChannelCore::register_openable` ×2 (from
|
||
`operations.rs:10, 62` — the nonexistent helper, C-02).
|
||
- `unresolved link to default_policy` (from `operations.rs:49`).
|
||
- `env is both a module and a macro` (from the `env` module name
|
||
colliding with the `env!` macro).
|
||
|
||
The commit message's "cargo doc generates" was technically true; clean
|
||
it is not.
|
||
|
||
## C-10 [major] — `channel/control` is a stub returning success for a dropped message
|
||
|
||
**ADR drift:** ADR-037 ("the channels layer routes `message` to the
|
||
handler's control handle for `channel_id`").
|
||
**Verified:** YES. `src/channels/operations.rs:236-248` parses
|
||
`channel_id`, ignores `message` (`_message`), and returns
|
||
`{"ok": true}`. There is no control-handle concept anywhere (no field
|
||
on `ChannelState`). Returning success for a silently-discarded control
|
||
message is worse than returning `unimplemented`.
|
||
|
||
## C-11 [major] — `channel/resources/subscribe` emits a one-shot snapshot of the wrong data
|
||
|
||
**ADR drift:** ADR-037 (as amended by ADR-047 §6): live subscription of
|
||
**openable resources** aggregated from ALPN-crate resource enumerators,
|
||
with subsequent events on any change.
|
||
**Verified:** YES, three problems
|
||
(`src/channels/operations.rs:257-277`):
|
||
1. `futures::stream::once(...)`: emits only the initial snapshot, never
|
||
"subsequent events on any change." ADR-037 explicitly rejected
|
||
poll-shaped behavior; this is a one-shot poll wearing a `Sub` type.
|
||
2. Wrong content: emits the ALPNs of **currently-open channels**, not
|
||
the set of **openable ALPNs** aggregated from ALPN-crate resource
|
||
enumerators ("which containers are running…"). A fresh connection
|
||
reports an empty resource set even when TTY/tunnel ops are
|
||
registered.
|
||
3. (Positive) The `access` preview **is** correctly dropped per
|
||
ADR-047 §6 (`operations.rs:255`; the output schema at `:148-161` has
|
||
no `access`).
|
||
|
||
## C-12 [major] — No guard on `channel/close` with `channel_id: 0`
|
||
|
||
**Verified:** YES. `channel/close` has no guard against `channel_id: 0`
|
||
(`src/channels/operations.rs:190`). Any peer can tear down channel-0
|
||
state via `teardown_channel(0)` (`manager.rs:284-298`), killing the
|
||
call channel's demux routing. **Fix:** reject `channel_id: 0` in the
|
||
`channel/close` handler (channel 0 is pre-negotiated and not
|
||
closeable).
|
||
|
||
## C-13 [major] — `channel/close` abort ≠ drain; REQ-CH-06 ordering absent
|
||
|
||
**ADR drift:** ADR-041 §3 (decrement "after the drain completes");
|
||
ADR-037 (responder drains and signals EOF to the handler);
|
||
channels-wire.md REQ-CH-06 (exit-chunk-before-close ordering — "the
|
||
channels layer owns the ordering guarantee").
|
||
**Verified:** YES. `channel/close`'s handler task is `abort()`ed
|
||
immediately and the decrement happens immediately
|
||
(`src/channels/operations.rs:190-204`); abort ≠ EOF-and-drain.
|
||
REQ-CH-06 (the close handler must observe data-pump completion) has no
|
||
implementation anywhere.
|
||
|
||
## C-14 [major] — Substrate modes: only in-line is implemented
|
||
|
||
**ADR drift:** ADR-034 §substrate modes, ADR-039 (QUIC-native
|
||
multi-stream substrate).
|
||
**Verified:** YES. `handle()` calls `connection.accept_bi()` **once**
|
||
and never loops (`src/channels/adapter.rs:159`). The doc comment at
|
||
`adapter.rs:80-84` claims the multi-stream behavior the code doesn't
|
||
have. The QUIC-native substrate ("accept remaining bidi streams… read
|
||
headers off each") is not implemented.
|
||
|
||
## C-15 [major] — `ChannelClient` API does not match ADR-043
|
||
|
||
**ADR drift:** ADR-043 (`open_channel(...) -> Channel`,
|
||
`Channel { channel_id, source }`, `subscribe_resources`, `call()`
|
||
accessor).
|
||
**Verified:** YES. `src/channels/client.rs` has `from_connection` ✓
|
||
(transport-agnostic, correct per the amendment), but: no
|
||
`open_channel(...) -> Channel`, no `Channel { channel_id, source }`,
|
||
no `subscribe_resources`, no `call()` accessor (instead `call_open_op`
|
||
+ `take_call_connection`). The doc comment at `client.rs:104-106`
|
||
refers to `manager.open_channel_stream(channel_id)` — **no such method
|
||
exists**. A consumer who receives `{channel_id: 7}` from the responder
|
||
has no way to obtain a `BiStream` for it (see C-08, channel adoption).
|
||
|
||
## C-16 [major] — Busy-wait spin on full write buffer
|
||
|
||
**Verified:** YES. `poll_write` on `TrySendError::Full` does
|
||
`cx.waker().wake_by_ref(); Poll::Pending`
|
||
(`src/channels/reassembly.rs:256-257`) — a hot busy-loop that pegs a
|
||
core until the mux drains (the waker is re-woken immediately, not
|
||
registered against capacity). The correct shape is storing a
|
||
`reserve()`/`poll_ready`-style future. The abandoned deliberation left
|
||
in comments (`reassembly.rs:205-255`) documents that the author knew
|
||
this (see C-20).
|
||
|
||
## C-17 [major] — Lost EOF sentinel path → remote handler hang
|
||
|
||
**Verified:** YES, with a precision correction to Sub Review B. The mux
|
||
pump **does** write an EOF chunk to the wire when it receives the
|
||
zero-length sentinel (`src/channels/mux.rs:124-133`). The real bug:
|
||
`MpscSendStream::poll_shutdown` (`reassembly.rs:283-291`) and `Drop`
|
||
(`:295-309`) emit the sentinel via `try_send`, best-effort; if the
|
||
buffer is full the sentinel is silently dropped. When the sentinel is
|
||
lost, the pump's `recv` yields `None` (sender dropped, no sentinel) and
|
||
the pump loop exits (`mux.rs:122-147`) **without** writing an EOF
|
||
chunk. Result: the remote handler never sees channel EOF until full
|
||
transport close. REQ-CH-01 violation on this path. The doc comments at
|
||
`reassembly.rs:283-291, 295-309` ("the peer's read will still EOF when
|
||
the sender drops — REQ-CH-02") are **wrong about which side of the
|
||
wire** this sender feeds (it feeds the local mux pump, not the peer's
|
||
demux). The doc comment at `mux.rs:109-112` ("the pump emits the EOF
|
||
sentinel") is misleading because it depends on the `Drop` impl's
|
||
`try_send` succeeding. **Fix:** the pump must write an EOF chunk when
|
||
its receiver ends without a sentinel (treat `recv → None` as implicit
|
||
EOF).
|
||
|
||
## C-18 [major] — `open_channel` TOCTOU on `max_channels`
|
||
|
||
**Verified:** YES. `src/channels/manager.rs:159-194` — the cap check
|
||
(`:161`) and the insert (`:188-189`) are under **separate** lock
|
||
acquisitions with `mux.register(...)` awaited in between
|
||
(`:170-176`). N concurrent opens at `len == max-1` all pass the check
|
||
and all insert → cap exceeded by N-1. **Fix:** hold the channels lock
|
||
across check + insert (release for the `mux.register` await, re-acquire
|
||
for insert, re-check on re-acquire).
|
||
|
||
## C-19 [minor] — Misc channels correctness issues
|
||
|
||
- **`next_id` wrap allocates channel 0:** `manager.rs:98` starts at 1;
|
||
`fetch_add(1, Relaxed)` wraps `u32::MAX → 0`, colliding with the
|
||
reserved channel 0. The `ChannelExists` defensive check catches the
|
||
map collision (`:189-193`), but only after registering a mux pump
|
||
that is never cleaned up. Effectively unreachable in practice
|
||
(ADR-040), but the wrap story is not handled as stated.
|
||
- **Unbounded loop in resources snapshot:** `operations.rs:267-274` —
|
||
`for channel_id in 0..u32::MAX` with a mutex lock per iteration,
|
||
break keyed on `resources.len() >= manager.open_count()`.
|
||
`open_count()` is re-read each iteration; a channel closing
|
||
mid-iteration (or sparse ids after churn — monotonic ids guarantee
|
||
sparseness) can iterate millions/billions of times.
|
||
- **Mux pump map leak + duplicate registration:** `mux.rs:148` —
|
||
finished pump `JoinHandle`s accumulate in `self.pumps` for the
|
||
connection's lifetime. Duplicate `register(channel_id)` silently
|
||
overwrites the handle while the old pump keeps running (two pumps,
|
||
one channel id).
|
||
- **Mux shutdown aborts pumps mid-`write_chunk`:** `mux.rs:154-158` —
|
||
pumps are `abort()`ed possibly mid-`write_chunk` while holding the
|
||
writer lock (a half-written chunk header corrupts the wire; moot
|
||
since the transport is going away, except the code then writes EOF
|
||
sentinels onto that possibly-mid-chunk stream).
|
||
- **`manager.rs:309`:** `drop(state.demux_sender.clone())` clones a
|
||
sender then drops the clone — a no-op (the real drop happens when
|
||
`drained` falls out of scope; the code works by accident, the line is
|
||
misleading).
|
||
- **`manager.rs:176, 218`:** mux `register` failure (runner closed)
|
||
mapped to `ManagerError::ChannelExists` — wrong error.
|
||
- **`client.rs:61`:** `install_channel_zero` error mapped to
|
||
`StreamError::StreamClosed` — conflation.
|
||
- **`policy.rs:127-140`:** `check_open` **increments** the count
|
||
(check-and-reserve). If allocation subsequently fails, no rollback
|
||
API exists other than `on_close(&Identity)`; a future open-op wrapper
|
||
must remember to decrement on every post-check failure path or quota
|
||
leaks. Not a bug today (nothing calls it), but a trap in the trait's
|
||
contract vs ADR-041's "check_open = check, on_close = decrement after
|
||
deallocation" framing.
|
||
- **REQ-CH-04's error counter (`Demux::stats()`):** no counter, no
|
||
stats surface; only a `debug!` log (`manager.rs:268-274`).
|
||
- **`derive_alpn_from_op_name`** (`src/client/from_call.rs:289-301`):
|
||
ADR-047 says non-`alknet/*` ALPNs "use their full ALPN string as the
|
||
path segment." The implementation takes only the **first** path
|
||
segment (`rest.split('/').next()` at `:291`), so
|
||
`channels/custom/proto/sub` derives `alknet/custom` — wrong. The
|
||
`segment.starts_with("alknet/")` branch at `:295` is dead code (a
|
||
single segment can't contain `/`), and `segment == "alknet"` yields
|
||
the nonsense ALPN `"alknet"` (`:296`).
|
||
- **`Box::leak` per discovery** (`from_call.rs:312-314`): bounded per
|
||
unique ALPN in theory, but it leaks on *every* rediscovery of every
|
||
marked op. Documented in-code as accepted; the `Arc<str>` refactor
|
||
should happen before any long-lived hub uses `from_call` against
|
||
churning peers.
|
||
- **`from_call.rs:269` "SAFETY:" comment** is factually wrong (describes
|
||
a `'static`-only return that isn't what `derive_alpn_from_op_name`
|
||
does — it returns `Option<String>`; the leak happens in `leak_alpn`),
|
||
and "SAFETY" is a loaded term that conventionally marks `unsafe`
|
||
blocks; there's no `unsafe` here.
|
||
- **Ledger placement:** ADR-047 §7 says "the ledger lives in
|
||
channels-call, not channels-core," but `OpenerLedger` is defined in
|
||
`src/channels/mux.rs:169-207` and owned by `ChannelManager`
|
||
(`manager.rs:81`) — the auth-blind core layer. Doc comment at
|
||
`manager.rs:62-69` argues for this; it's a deliberate deviation, but
|
||
it is a deviation, and the "which-crate" boundary matters when the
|
||
crate is split per ADR-044.
|
||
|
||
## C-20 [major → convention] — Abandoned deliberation + pervasive inline comments
|
||
|
||
**Convention drift:** AGENTS.md §1 (no inline `//` comments unless
|
||
asked; doc comments `///`/`//!` are fine).
|
||
**Verified:** YES. Worst instance: `src/channels/reassembly.rs:205-255`
|
||
— ~50 lines of abandoned stream-of-consciousness deliberation ("no, we
|
||
need `Sender::reserve_slot`… Actually, the simplest approach…") shipped
|
||
inside `poll_write`. Plus pervasive inline `//` comments across the
|
||
channels module: `adapter.rs:154-158, 163-165, 177-180, 189-194`,
|
||
`client.rs:57, 67, 74-76`, `manager.rs:170, 177-179, 190-191, 196,
|
||
234-237, 268-269, 291-292, 314`, `env.rs:112-126`,
|
||
`operations.rs:188-189, 195-196, 243-247, 262-266`,
|
||
`from_call.rs:256-262, 269-274`. A handful qualify as "non-obvious
|
||
correctness constraint" (REQ references); most don't. Several comments
|
||
are actively *wrong* (the mux EOF claim `mux.rs:109-112`, the
|
||
abort-cancels claim `dispatch.rs:495-498`, the
|
||
`reassembly.rs:283-291` "peer's read will still EOF" wrong-side claim,
|
||
the `from_call.rs:269` "SAFETY:" comment marking no `unsafe`).
|
||
|
||
## C-21 [minor → convention] — `write_header` panics on short buffers
|
||
|
||
**Convention drift:** AGENTS.md §2 (no panics in library code).
|
||
**Verified:** YES. `src/channels/wire.rs:110-114` — `write_header`
|
||
slices `out[..CHUNK_HEADER_LEN]`, panicking on short buffers; the doc
|
||
comment at `:106-107` declares the panic. All current callers pass
|
||
`[0u8; 8]`, but the API is `&mut [u8]`. Same class: a potential panic
|
||
path documented instead of typed. **Fix:** return a
|
||
`Result<_, ChunkError::HeaderTooShort>` (the error variant already
|
||
exists for the parse side, `wire.rs:87-90`).
|
||
|
||
## C-22 [minor → convention] — Filler tests padding the count
|
||
|
||
**Verified:** YES. `src/channels/client.rs:131-136` — a `PhantomData`
|
||
"smoke test" (`let _ = std::marker::PhantomData::<ChannelClient>;`).
|
||
`src/channels/env.rs:155` — `assert!(env.contains("anything") ||
|
||
!env.contains("anything"))` tautology. These pad the "66 new channels
|
||
tests" / "23 new Pub tests" counts claimed in the commit messages.
|
||
|
||
## C-23 [minor] — `dispatch_requested` Sink/Stream errors use empty request id
|
||
|
||
**Verified:** YES. `dispatch_requested`'s Sink error uses
|
||
`String::new()` as request id (`src/protocol/dispatch.rs:244-247`),
|
||
producing an envelope with an empty id (shared flaw with the Stream
|
||
arm at `:236-243`).
|
||
|
||
## C-24 [minor] — Producer/consumer naming leaked into docs
|
||
|
||
**Convention drift:** AGENTS.md §8 (producer/consumer, not
|
||
server/client).
|
||
**Verified:** YES, minor. Prod code is largely fine; "client→server
|
||
streaming" leaked from ADR-046 into `docs/architecture/call-protocol.md:335`
|
||
and the ADR-046 reference table at `:584`. No public API names offend;
|
||
only test-local `duplex` variable names use client/server
|
||
(`mux.rs:217, 253`, etc.) — acceptable.
|
||
|
||
## C-25 — Test coverage gaps (channels)
|
||
|
||
**Verified:** YES — present coverage is unit-level only; **no
|
||
end-to-end test wires two sides together**. Missing:
|
||
1. Channel 0 end-to-end (ADR-036): adapter ↔ client, one
|
||
`call_open_op` round-trip (would catch C-01 immediately).
|
||
2. Demux resync on `TooLarge` (channels-wire.md §MAX_CHUNK_LEN): send
|
||
oversized chunk + valid chunk; assert the valid one survives (C-07).
|
||
3. Backpressure semantics (ADR-040/REQ-CH-05): slow reader on channel
|
||
A, fast writer, assert no data loss and channel B unaffected (C-04,
|
||
C-05, C-16).
|
||
4. Policy decrement on connection drop / handler exit (ADR-047 §7):
|
||
open at cap, drop transport, reopen (C-06).
|
||
5. Per-connection `max_channels` via `open_channel` — no test fills 256
|
||
and asserts `TooManyChannels` (only `Display` is tested,
|
||
`manager.rs:446-452`); no concurrent-open race test (C-18).
|
||
6. REQ-CH-01 through the mux to the wire on drop-without-shutdown —
|
||
`mux_handle_register_and_write_round_trips_to_transport` covers
|
||
explicit shutdown only; the drop-with-full-buffer path is untested
|
||
(C-17).
|
||
7. REQ-CH-06 — untested (and unimplemented, C-13).
|
||
8. `channel/close` / `channel/control` / `resources/subscribe` handler
|
||
invocation — the handler bodies (`make_close_handler` etc.) have
|
||
zero tests; only spec-shape and registration are tested
|
||
(`operations.rs:329-364`).
|
||
9. `resolve_channel_manager` — untested (it's a stub; a test asserting
|
||
the ADR-047 §4 behavior would have flagged it, C-03).
|
||
10. ID wrap → 0 — untested (C-19).
|
||
11. Marker wire format: well tested ✓ (`discovery.rs:807-839`,
|
||
`from_call.rs:592-653`, `spec.rs:338-365`).
|
||
|
||
## C-26 — Spec docs internally inconsistent post-047
|
||
|
||
**Verified:** YES. Only `channel-operations.md` was updated. The other
|
||
channels spec docs still describe the pre-047 model that ADR-047
|
||
removed, and carry stale alknet ADR numbers (071/075/076/093/094/079/
|
||
080) that point to nonexistent files after the renumbering (alkcall
|
||
decisions only go up to 047):
|
||
- `docs/architecture/channel-client.md:54-59, 94-98` still specifies
|
||
`open_channel(alpn, params, direction)`, `ChannelDirection`, and
|
||
`ResourceEntry.access` — all removed by ADR-047.
|
||
- `docs/architecture/channels-wire.md:241` lifecycle table still says
|
||
"`channel/open` call operation… responder allocates."
|
||
- `docs/architecture/channels-adapter.md:142-194` still describes the
|
||
generic `channel/open` handler with `channel:unknown_alpn`.
|
||
- Stale ADR-number references: `channel-client.md:170-171`,
|
||
`channels-wire.md:273-274`, `channels-adapter.md:294-299`.
|
||
|
||
---
|
||
|
||
# Cross-cutting: what is solid (verified)
|
||
|
||
These were checked and are correct/close to spec — listed so the
|
||
remediation plan can focus on what's actually broken:
|
||
|
||
- **Wire formats:** `EventEnvelope` shape and the five+one event types
|
||
match ADR-014/046 exactly; the channels 8-byte chunk header
|
||
`[channel_id:u32 BE][length:u32 BE]` matches ADR-034/035 exactly
|
||
(`src/channels/wire.rs:85-114`). No `stream_type` anywhere in the
|
||
channels layer (verified by grep). ✓
|
||
- **`HandlerKind::Sink` validation:** Pub↔Sink both directions
|
||
(`src/registry/registration.rs:108-124, 371-411`), `make_sink_handler`
|
||
(`:587-593`), `with_local_sink`/`with_leaf_sink`/
|
||
`with_leaf_sink_provenance` (`:507-552`). ✓
|
||
- **Wire `op_type` strings:** `"query"`/`"mutation"`/`"sub"`/`"pub"`
|
||
emit (`src/registry/discovery.rs:163-169`) and parse with
|
||
`"subscription"` correctly rejected (`src/client/from_call.rs:316-326`).
|
||
✓
|
||
- **`call.published` payload `{ "input": <chunk> }`:** `src/protocol/wire.rs:54-56`,
|
||
correlated by envelope `id`. ✓
|
||
- **Responder-side `deadline: None` for Pub:** `dispatch.rs:310`
|
||
(implemented; untested — P-12).
|
||
- **`invoke()` / `invoke_streaming()` on Pub → `INVALID_OPERATION_TYPE`:**
|
||
`registration.rs:193-197, 262-271`. ✓
|
||
- **Gap F (`channel_open` marker):** `spec_to_json` emits
|
||
`"channel_open": true` only when set (`discovery.rs:213-215`),
|
||
schema documents it as optional (`:145-148`), `rebuild_spec_for`
|
||
parses it and ignores `false`/non-channels names (`from_call.rs:263-277`).
|
||
`ChannelOpenSpec { alpn: &'static str }` matches ADR-047 §2
|
||
(`spec.rs:38-40`). Well tested. ✓
|
||
- **Per-identity cap default 256:** `policy.rs:156`, `default_policy()`,
|
||
`PerIdentityChannelPolicy::new(256)` tested at `policy.rs:252-259`. ✓
|
||
- **Channel 0 pre-installed, skips the ledger:** correct
|
||
(`manager.rs:234-237`), ALPN recorded as `alknet/call`. ✓ (Functionally
|
||
dead per C-01, but the bookkeeping is right.)
|
||
- **Connection-owner allocation mechanism:** `ChannelManager::open_channel`
|
||
allocating via `next_id.fetch_add` (`manager.rs:167`) — "whoever holds
|
||
the manager allocates" ✓ — but nothing invokes it in the Sub flow (no
|
||
open-op wrapper, C-02) and the Pub-case flow doesn't exist.
|
||
- **Layering held:** no channels types leaked into the call crate,
|
||
`OperationEnv` stayed a trait, auth stays on the one
|
||
`AccessControl` path. ✓
|
||
- **Hub relay (ADR-042), broker (Gap B), `from_call` relay wrapper
|
||
(Gap C):** intentionally out of scope per ADR-047 — correctly not
|
||
flagged as bugs. ADR-047 Gap C expects alkcall's `from_call` to
|
||
reconstruct the marker so the consumer can branch — that half **is**
|
||
implemented ✓.
|
||
- **No `unwrap()`/`expect()`/panics** in the new library code outside
|
||
the `write_header` short-buffer path (C-21) and the channels
|
||
deliberation comments. No blocking I/O on async paths. Poisoning N/A
|
||
(`parking_lot`). ✓
|
||
|
||
---
|
||
|
||
# Remediation plan
|
||
|
||
The plan is split into **six units of work**, ordered by dependency and
|
||
severity. Each unit is a independently shippable commit (or small
|
||
sequence) with its own acceptance gate. The units are sized so that
|
||
none of the critical bugs is blocked behind a spec decision except
|
||
where noted (C-03, C-08). The overarching acceptance gate for the
|
||
whole effort is the single end-to-end test both reviews identify as
|
||
missing:
|
||
|
||
> **One end-to-end test:** `ChannelClient` ↔ `ChannelsAdapter`, open a
|
||
> channel via a registered op, move bytes, tear down, verify quota.
|
||
|
||
That test is the gate because its absence is what let both commits land
|
||
green. Each unit below either moves toward it or removes a defect it
|
||
would surface.
|
||
|
||
## Unit 1 — Fix Pub end-to-end (P-01, P-02, P-05, P-08, P-12 #1)
|
||
|
||
**Goal:** a `publish()` that actually works on any transport.
|
||
**Files:** `src/protocol/connection.rs`, `src/protocol/dispatch.rs`.
|
||
**Scope:**
|
||
- P-01: keep stream A's write half; pump `call.published` + `call.completed`
|
||
on it (do not re-open `open_bi` in `write_publish_chunks`).
|
||
- P-02: change `publish`/`publish_with_payload` to take
|
||
`Pin<Box<dyn Stream<Item = Value> + Send>>` (ADR-046 §8 shape). Hold
|
||
stream A's write half and pump the stream.
|
||
- P-05: register the publish like `subscribe` (`timeout: None`), not
|
||
with `DEFAULT_CALL_TIMEOUT`. Falls out of the streaming rework.
|
||
- P-08: route the dispatch Pub branch through
|
||
`OperationRegistry::invoke_sink` instead of re-implementing it inline
|
||
(removes the divergence hazard; the `invoke_sink` path becomes the
|
||
wire path).
|
||
- P-12 #1: add the end-to-end publish test (≥2 chunks, assert the
|
||
responder sees them and returns the right result). This is the
|
||
acceptance gate for the unit.
|
||
|
||
**Note:** P-02 changes a public API (`publish` signature) — but per
|
||
ADR-046 §8 the `Stream` shape *is* the spec, so the current `Vec<Value>`
|
||
was the drift. This is correcting drift, not introducing a wire break
|
||
(the wire format is unchanged). No deployments exist.
|
||
|
||
## Unit 2 — Channel 0 single-stream call mode, both sides (C-01, C-25 #1)
|
||
|
||
**Goal:** channel 0 actually carries call traffic in both directions.
|
||
**Files:** `src/protocol/connection.rs`, `src/protocol/dispatch.rs`,
|
||
`src/channels/source.rs`, `src/channels/client.rs`,
|
||
`src/channels/adapter.rs`.
|
||
**Scope:** This is the real design gap, not a one-line fix. The call
|
||
protocol's stream-per-request model is incompatible with channel 0's
|
||
single yield-once `BiStream`. Options:
|
||
- (a) A single-stream call mode: multiplex all requests/responses on
|
||
one framed stream (length-prefixed `EventEnvelope`s on channel 0's
|
||
one `BiStream`). Both `CallConnection` (client) and `Dispatcher`
|
||
(accept side) gain a "single-stream mode" that reads/writes frames
|
||
on the one `BiStream` instead of opening/accepting one stream per
|
||
request. `ChannelBidiStreamSource::open_bi` stops being called.
|
||
- (b) Make `ChannelBidiStreamSource` yield a fresh `BiStream` per
|
||
`accept_bi`/`open_bi` by framing sub-streams over the one channel —
|
||
more complex, probably wrong.
|
||
Recommend (a). This unblocks C-01's connect side (every `call_open_op`
|
||
no longer fails) and accept side (channel 0's `Connection` is actually
|
||
driven by a call dispatch loop). **Acceptance gate:** C-25 #1 — one
|
||
end-to-end channel-0 round-trip test. **May require a short ADR or
|
||
ADR-036 amendment** recording the single-stream-mode decision (it's a
|
||
two-way-door implementation detail per ADR-046/036, but worth pinning).
|
||
|
||
## Unit 3 — `register_openable` + decide the §4 env-resolution question (C-02, C-03)
|
||
|
||
**Goal:** a channel-open op can actually be registered and invoked.
|
||
**Files:** `src/channels/operations.rs`, `src/channels/env.rs`,
|
||
`src/registry/env.rs` (possibly, if `as_any()` is added).
|
||
**Scope:** This unit requires a **spec decision first**:
|
||
- **Option A (implement §4 as written):** add `as_any()` (or equivalent
|
||
downcast machinery) to `OperationEnv`, implement
|
||
`resolve_channel_manager` for real, and have the open-op wrapper
|
||
resolve the per-connection manager at invocation time.
|
||
- **Option B (amend ADR-047 §4):** bless the "manager passed at
|
||
registration via `ChannelCore`" shape that the stub's comment
|
||
describes. This drops per-connection resolution but is simpler; it
|
||
works only if a `ChannelCore` is registered per-connection (an
|
||
overlay concern), which interacts with ADR-019's layering.
|
||
Recommend Option A (it preserves the ADR's stated design and the
|
||
session/connection overlay patterns from ADR-024). Whichever is
|
||
chosen, **amend or affirm ADR-047 §4 in writing** before coding. Then:
|
||
- Implement `ChannelCore::register_openable(spec, open_handler,
|
||
channel_core)` wrapping the ALPN open handler with the
|
||
ACL→`check_open`→allocate→ledger→spawn→respond flow
|
||
(`src/channels/operations.rs:280-311`).
|
||
- Map `channel:forbidden` / `channel:no_channels_session` /
|
||
`channel:allocation_failed` / `channel:too_many_channels` to
|
||
`CallError`.
|
||
- Fix the broken rustdoc links (C-09's `register_openable` ×2) once the
|
||
method exists.
|
||
- Test: register a no-op open op via `register_openable`, invoke it
|
||
end-to-end through channel 0 (depends on Unit 2), assert
|
||
`channel_id` is returned and quota is reserved.
|
||
|
||
## Unit 4 — Backpressure fixes (C-04, C-05, C-07, C-16, C-17, C-25 #2 #3 #6)
|
||
|
||
**Goal:** the channels layer is lossless and bounded as ADR-040
|
||
promises.
|
||
**Files:** `src/channels/manager.rs`, `src/channels/reassembly.rs`,
|
||
`src/channels/adapter.rs`, `src/channels/mux.rs`.
|
||
**Scope:**
|
||
- C-04: make `route_payload` async (or use `blocking_send`/`reserve`),
|
||
await on full instead of dropping. The demux loop calls it on the
|
||
read path — make the demux loop await it.
|
||
- C-05: replace the message-count buffer cap with a byte-budgeted cap
|
||
(track accumulated `payload.len()` against the cap). Update
|
||
`DEFAULT_BUFFER_CAP`'s doc comment to match reality.
|
||
- C-07: on `ChunkError::TooLarge`, read and discard `length` bytes
|
||
before continuing. The parsed length is in the error variant.
|
||
- C-16: replace the `wake_by_ref` + `Pending` busy-loop with a stored
|
||
`reserve()` future (or `Poll`-friendly ready notification). Remove
|
||
the ~50 lines of abandoned deliberation while here (C-20 overlap).
|
||
- C-17: the mux pump must write an EOF chunk when its receiver ends
|
||
without a sentinel (treat `recv → None` as implicit EOF). Fix the
|
||
wrong-side doc comments in `reassembly.rs:283-291, 295-309` and the
|
||
misleading `mux.rs:109-112` claim.
|
||
- C-25 #2 #3 #6: add the demux-resync test, the backpressure test, and
|
||
the drop-without-shutdown EOF test.
|
||
|
||
## Unit 5 — Ledger decrement on all teardown paths + channel-id adoption/collision decision (C-06, C-08, C-12, C-13, C-18)
|
||
|
||
**Goal:** quota accounting is correct and channel ids don't collide.
|
||
**Files:** `src/channels/adapter.rs`, `src/channels/manager.rs`,
|
||
`src/channels/operations.rs`, `src/channels/mux.rs`.
|
||
**Scope:**
|
||
- C-06: wire `on_close` into the three missing teardown paths:
|
||
close-sent locally, handler-exit, connection-drop. On transport EOF,
|
||
`clear_all()` already returns the `(channel_id, opener)` list — call
|
||
`policy.on_close(&opener)` for each (the adapter needs a hook to the
|
||
policy; this is the architectural gap Sub Review B flagged — add a
|
||
`DecrementOnDrop` hook or have the adapter hold a `ChannelCore`).
|
||
- C-08: **spec decision needed** — odd/even id split, or an adoption op
|
||
so the non-allocating side can install routing for a remotely-assigned
|
||
id. Recommend odd/even (initiator allocates odd, responder even, or
|
||
vice versa) as the smaller change; record the decision in ADR-047 §5
|
||
or a follow-on ADR. Then implement the adoption path: the
|
||
non-allocating side registers the remotely-assigned id in its
|
||
manager/mux. **[RESOLVED 2026-10-06/13: the implemented odd/even
|
||
split (connect odd, accept even) + `ChannelManager::adopt_channel`;
|
||
the marked-pub contract rides it end-to-end — ADR-047 amendment 4;
|
||
the C-08-blocker stub class this decision previously gated on is
|
||
closed with reviews 010/011's single-publish fixes.]**
|
||
- C-12: reject `channel_id: 0` in the `channel/close` handler.
|
||
- C-13: `channel/close` must drain (signal EOF to the handler, await
|
||
pump completion) before `abort`/decrement; implement REQ-CH-06
|
||
ordering (the close handler observes data-pump completion).
|
||
- C-18: hold the channels lock across the `max_channels` check + insert
|
||
in `open_channel` (re-check on re-acquire after the `mux.register`
|
||
await).
|
||
- Test: C-25 #4 #5 — open-at-cap-then-drop-transport-then-reopen, and
|
||
the concurrent-open race test.
|
||
|
||
## Units 6-10 — the post-integration long tail
|
||
|
||
The original Unit 6 was the largest unit by item count and mixed
|
||
trivial doc fixes with real protocol-semantics work (P-03/P-04/P-06/
|
||
P-07). It has been decomposed into five focused units, ordered by risk
|
||
and dependency. Several original Unit 6 items were already absorbed by
|
||
Units 1-5 during their implementation:
|
||
|
||
- **P-09** fell out of Unit 1's P-01/P-02 streaming rework
|
||
(`make_sink_forwarding_handler` now streams + terminates on `Err`).
|
||
- **C-20's** biggest item — the ~50 lines of abandoned deliberation in
|
||
`reassembly.rs` — landed with Unit 4's backpressure rewrite
|
||
(reassembly.rs was rewritten from scratch).
|
||
- **C-22's** `env.rs:155` tautology test was removed in Unit 3.
|
||
- **C-19's** `next_id` wrap-to-0, the misleading
|
||
`drop(state.demux_sender.clone())`, and the `manager.rs` mux-register
|
||
error mapping were fixed in Units 4/5.
|
||
- **C-13** (drain-before-close) and **C-06** (all 4 teardown paths)
|
||
landed in Unit 5.
|
||
|
||
The units below cover what remains. Each is independently shippable.
|
||
|
||
## Unit 6 — Convention + doc cleanup (C-09, C-20-rem, C-22-rem, C-24, P-10, P-11)
|
||
|
||
**Goal:** conventions satisfied, `cargo doc` clean, no actively-wrong
|
||
comments, producer/consumer naming consistent in docs.
|
||
**Files:** `src/protocol/dispatch.rs`, `src/client/from_call.rs`,
|
||
`src/channels/operations.rs`, `src/channels/mod.rs`,
|
||
`docs/architecture/call-protocol.md`,
|
||
`docs/architecture/operation-registry.md`,
|
||
`docs/architecture/decisions/046-publish-operation-type-and-handler-kind-sink.md`.
|
||
**Scope:**
|
||
- **P-10:** `pump_sink` matches the string literals `"call.published"`,
|
||
`"call.completed"`, `"call.aborted"` (`dispatch.rs:475,485,486`)
|
||
instead of the `EVENT_PUBLISHED`/`EVENT_COMPLETED`/`EVENT_ABORTED`
|
||
constants the rest of the file imports (`wire.rs:12-17`). Pure
|
||
refactor hazard; no behavior change.
|
||
- **C-20 remainder:** the wrong "SAFETY:" comment at
|
||
`from_call.rs:271` (marks no `unsafe` block — `derive_alpn_from_op_name`
|
||
returns `Option<String>`, the leak happens in `leak_alpn`). Reword to
|
||
a plain note about the `'static` lifetime requirement. The mux EOF
|
||
claim, abort-cancels claim, and wrong-side peer-EOF claim were fixed
|
||
with Unit 4's reassembly rewrite — verify they're gone and remove
|
||
any remaining stray inline `//` comments in the channels module that
|
||
aren't carrying a non-obvious correctness constraint.
|
||
- **C-09:** fix the 2 remaining `cargo doc` warnings (verified 2026-08-13):
|
||
- `unresolved link to default_policy` (`operations.rs:50` — the
|
||
`[`default_policy`]` intra-doc link resolves to `super::policy::default_policy`;
|
||
use the full path `[`super::policy::default_policy`]` or re-export).
|
||
- `env is both a module and a macro` (`channels/mod.rs:30` — the
|
||
`[`env`]` link in the module doc is ambiguous; qualify it as
|
||
`[`env`][self::env]` or use the full path in the prose).
|
||
- **C-22 remainder:** remove the filler `PhantomData` test at
|
||
`client.rs:291-294` (`let _ = std::marker::PhantomData::<ChannelClient>;`
|
||
— asserts nothing). The `env.rs:155` tautology is already gone.
|
||
- **C-24:** replace "client→server streaming" with producer/consumer
|
||
phrasing in `call-protocol.md` (the Pub reference-table line) and
|
||
`operation-registry.md` (the ADR-046 reference-table line). No public
|
||
API names offend; this is doc-only.
|
||
- **P-11:** amend ADR-046 §3's `SinkHandler` type so the stream item
|
||
type matches §6. §3 (`decisions/046-...md:146`) declares
|
||
`Pin<Box<dyn Stream<Item = Value> + Send>>`; §6 (`:268`) declares
|
||
`Pin<Box<dyn Stream<Item = Result<Value, CallError>> + Send>>`. The
|
||
code uses §6's shape uniformly (`registration.rs:32-40`, aliased as
|
||
`PublishStream`). §3 is the one to amend (§6's `Result`-carrying shape
|
||
is correct — the handler must see initiator errors). The ADR's
|
||
Door-type section explicitly marks the concrete stream item type a
|
||
two-way-door detail, so this is a text amendment, not a design change.
|
||
**Acceptance gate:** `cargo doc --no-deps` emits 0 warnings;
|
||
`cargo clippy --all-targets -- -D warnings` clean; `cargo fmt --check`
|
||
clean; `cargo test` green.
|
||
|
||
## Unit 7 — Small correctness fixes (C-19-misc, C-21, C-23, P-13)
|
||
|
||
**Goal:** no panic paths in library code, no misleading public fields,
|
||
honest error envelopes, latent bugs in `derive_alpn_from_op_name` and
|
||
the mux pump map fixed.
|
||
**Files:** `src/channels/wire.rs`, `src/channels/mux.rs`,
|
||
`src/channels/operations.rs`, `src/client/from_call.rs`,
|
||
`src/protocol/dispatch.rs`, `src/registry/spec.rs`.
|
||
**Scope:**
|
||
- **C-21:** `write_header` (`wire.rs:110-114`) slices `out[..CHUNK_HEADER_LEN]`
|
||
and panics on short buffers. The doc comment at `:106-107` declares
|
||
the panic. Return `Result<_, ChunkError::HeaderTooShort>` instead
|
||
(the variant already exists for the parse side, `wire.rs:67-68`).
|
||
Update `write_chunk` (`:138-150`) and all callers (they pass
|
||
`[0u8; 8]` today, so the `Result` will always be `Ok` — but the API
|
||
is `&mut [u8]` and the contract should be typed, not panic-documented).
|
||
- **C-23:** `dispatch_requested`'s Sink and Stream error arms use
|
||
`String::new()` as the request id (`dispatch.rs:236,242`), producing
|
||
envelopes with an empty id. Pass the real `request_id` (the `Once`
|
||
arm at `:298` already does this correctly via `invoke`).
|
||
- **P-13:** `SinkDispatch::chunk_tx` is `pub` (`dispatch.rs:76-79`),
|
||
exposing the `futures::mpsc::Sender` type and the 64-slot buffer size
|
||
as effective public API. Make the field private (or `pub(crate)`)
|
||
before crates.io; `handle_stream` and `pump_sink` are the only
|
||
readers and they're in the same module.
|
||
- **C-19 (mux pump map leak + duplicate registration):** `mux.rs:159`
|
||
inserts each pump's `JoinHandle` into `self.pumps` and never removes
|
||
finished ones — they accumulate for the connection's lifetime.
|
||
Duplicate `register(channel_id)` silently overwrites the old handle
|
||
while the old pump keeps running (two pumps, one channel id). Fix:
|
||
detect a finished pump before insert (poll the `JoinHandle` or use
|
||
`is_finished()`), and reject duplicate registration (return an error
|
||
the caller maps to `ChannelExists`).
|
||
- **C-19 (`derive_alpn_from_op_name`):** `from_call.rs:291-303` takes
|
||
only the first path segment (`rest.split('/').next()`), so
|
||
`channels/custom/proto/sub` derives `alknet/custom` instead of the
|
||
full ALPN `custom/proto`. The `segment.starts_with("alknet/")` branch
|
||
at `:297` is dead (a single segment can't contain `/`), and
|
||
`segment == "alknet"` yields the nonsense ALPN `"alknet"`. Fix: strip
|
||
the known `/sub`|`/pub` suffix from `rest` instead of taking the
|
||
first segment, so multi-segment ALPNs survive. The `channels/tty/sub`
|
||
common case already works; this fixes the latent non-`alknet/*`
|
||
multi-segment case (no live callers yet, but the `Arc<str>` refactor
|
||
in Unit 8 will make this path reachable).
|
||
- **C-19 (`Box::leak` per discovery):** `from_call.rs:314-316` leaks
|
||
a `String` to `'static` on every `leak_alpn` call. Bounded per unique
|
||
ALPN in theory, but leaks on every *rediscovery* of every marked op.
|
||
Refactor `ChannelOpenSpec::alpn` from `&'static str` to
|
||
`Cow<'static, str>` (or `Arc<str>`) so `leak_alpn` can return an
|
||
owned value. This is a small public-API change to `ChannelOpenSpec`
|
||
(`spec.rs:38-46`); no deployments exist, and the ADR-047 §2 shape
|
||
said `&'static str` was "for now" (the `Box::leak` was the workaround
|
||
for that constraint). The `Cow`/`Arc<str>` is the deferred refactor.
|
||
(If this grows beyond a quick edit, split it into its own unit.)
|
||
- **C-19 (REQ-CH-04 error counter):** the demux's lenient
|
||
unknown-channel drop (`manager.rs:389-394`) only `debug!`-logs; there
|
||
is no counter or stats surface. Add a simple `AtomicU64` dropped-chunks
|
||
counter to `ChannelManager` with a `dropped_unknown_chunks()` accessor
|
||
for observability. (Low priority — file an OQ if the counter surface
|
||
isn't worth the API surface today.)
|
||
- **C-19 (resources-snapshot unbounded loop):** `operations.rs:278-285`
|
||
iterates `0..u32::MAX` with a per-iteration mutex lock, breaking when
|
||
`resources.len() >= manager.open_count()`. With sparse ids (monotonic
|
||
after churn) or a channel closing mid-iteration, this can iterate
|
||
millions of times. Replace with an iterator over the channel map's
|
||
keys (add a `ChannelManager::channel_ids() -> Vec<u32>` accessor) so
|
||
the snapshot is O(open channels), not O(max id). (This is also
|
||
touched by Unit 9's `resources/subscribe` rewrite — coordinate or
|
||
fold the loop fix into Unit 9.)
|
||
**Acceptance gate:** `cargo test` green; `cargo clippy --all-targets
|
||
-- -D warnings` clean; no panics reachable from `write_header`; the
|
||
`derive_alpn_from_op_name` test covers a multi-segment non-`alknet/*`
|
||
ALPN.
|
||
|
||
## Unit 8 — Pub protocol semantics (P-03, P-04, P-07)
|
||
|
||
**Status: complete (2026-08-13).** All three findings fixed; 15 new
|
||
tests (7 in `dispatch.rs`, 4 in `discovery.rs`, 4 in `from_call.rs`…
|
||
the single-stream P-03/P-04 tests live in `channels/client.rs`); full
|
||
suite 480 passed, clippy/fmt/doc clean. `alktype::validation::build_validator`
|
||
is now used on both dispatch paths (`pump_sink` and
|
||
`run_loop_single_stream`); `jsonschema` was added as a direct
|
||
dependency (the validator return type is part of `alktype`'s public
|
||
API). The `SinkDispatch` struct gained a `publish_validator` field
|
||
built once at dispatch time; the single-stream `in_flight_sinks` map
|
||
gained an `InFlightSink` struct carrying the validator alongside
|
||
`chunk_tx`. The P-07 rework replaced `tokio::join!` with a
|
||
`tokio::select!` loop over `handler.fuse()` and `reader.read_frame()`
|
||
so an early handler return writes the response immediately without
|
||
waiting for the feed.
|
||
|
||
**Goal:** the publish path's decided-but-unimplemented behaviors land:
|
||
per-chunk schema validation, initiator `call.error` terminates the
|
||
stream, and an early handler return short-circuits the feed.
|
||
**Files:** `src/registry/spec.rs`, `src/registry/discovery.rs`,
|
||
`src/client/from_call.rs`, `src/protocol/dispatch.rs`.
|
||
**Scope:**
|
||
- **P-03:** `OperationSpec::publish_schema` (`spec.rs:181-186`) is
|
||
declared, has a builder (`with_publish_schema`, `:236-239`), defaults
|
||
to `None`, and is **never read**. `pump_sink` forwards chunks as-is
|
||
(`dispatch.rs:475-484`). It is also missing from `spec_to_json`
|
||
(`discovery.rs:197-217`), from `operation_spec_schema()`
|
||
(`discovery.rs:102-161`), and from `from_call`'s `rebuild_spec_for`
|
||
(`from_call.rs:205-282`) — so an imported Pub op loses the schema.
|
||
Implement: (a) add `publish_schema` to `spec_to_json` (emit as
|
||
`"publish_schema"` when `Some`), `operation_spec_schema()` (add the
|
||
property to the schema), and `rebuild_spec_for` (parse it back); (b)
|
||
in `pump_sink`, validate each `call.published` chunk's `input`
|
||
against `publish_schema` before yielding it — on validation failure,
|
||
inject an `Err(CallError::invalid_input(...))` and terminate the
|
||
stream (matching the `SinkHandler` contract). Use `alktype`'s
|
||
`validation::build_validator` (the dependency is declared and unused;
|
||
`jsonschema` is transitively available). When `publish_schema` is
|
||
`None`, chunks are yielded as-is (current behavior).
|
||
- **P-04:** `pump_sink` matches only `"call.published"`, `"call.completed"`,
|
||
`"call.aborted"` and drops everything else into a debug-log ignore
|
||
branch (`dispatch.rs:492-497`). A `call.error` frame from the
|
||
initiator is silently ignored. Per ADR-046 §6, an initiator-side
|
||
`call.error` should inject the initiator's `CallError` as an `Err`
|
||
item that terminates the stream. Match `"call.error"` in the
|
||
`pump_sink` event match, parse the `CallError` from the payload, send
|
||
`Err(call_error)` into `chunk_tx`, and break the feed.
|
||
- **P-07:** `pump_sink` uses `tokio::join!(feed_fut, handler)`
|
||
(`dispatch.rs:510`) and only writes the response after **both**
|
||
complete. If the `SinkHandler` returns early (e.g. rejects after chunk
|
||
1), the feed loop notices only when its next `chunk_tx.send` fails —
|
||
a paused or long initiator never learns of the early error. Fix:
|
||
use `tokio::select!` (or race the handler against the feed) so that
|
||
when the handler completes, the feed is short-circuited and the
|
||
response is written immediately. Dropping `chunk_tx` on the handler
|
||
side signals the feed to stop. Add a test: a sink handler that
|
||
returns after 1 chunk; an initiator that publishes 3; assert the
|
||
response arrives without waiting for all 3 chunks.
|
||
**Acceptance gate:** a test that registers a Pub op with a
|
||
`publish_schema`, publishes a chunk that violates it, and asserts the
|
||
handler receives an `Err` and the stream terminates; a test that an
|
||
initiator `call.error` terminates the publish stream; a test that an
|
||
early-returning handler's response is not deferred behind a slow feed.
|
||
|
||
## Unit 9 — Abort-cancels-Pub (P-06)
|
||
|
||
**Goal:** an initiator can actually cancel an in-flight Pub; the
|
||
handler future is dropped on abort, not just `join!`-ed to completion.
|
||
**Files:** `src/protocol/dispatch.rs`, `src/protocol/connection.rs`,
|
||
possibly `src/protocol/pending.rs`.
|
||
**Scope:** This is the one Unit 6 item with a design decision, so it
|
||
gets its own unit. The single-stream call mode from Unit 2 already
|
||
routes `call.aborted` for an in-flight sink's `request_id` to the
|
||
matching `chunk_tx` (`dispatch.rs:684-693`), injecting an `Err` — but
|
||
the handler future is spawned separately (`dispatch.rs:670-680`) and
|
||
is **not** dropped on abort; it runs to completion and its response is
|
||
written to the wire after the abort. The stream-per-request
|
||
`pump_sink` path (`dispatch.rs:447-517`) `join!`s the handler to
|
||
completion regardless of abort. Decisions:
|
||
- **Single-stream path:** wire a cancellation token (or `oneshot`) per
|
||
in-flight sink so `call.aborted` both injects the `Err` **and**
|
||
aborts the spawned handler task. The handler's `JoinHandle` is
|
||
already stored implicitly via `tokio::spawn` — keep it in the
|
||
`in_flight_sinks` map alongside `chunk_tx` so abort can `.abort()` it.
|
||
- **Stream-per-request path:** `pump_sink` needs the same: on
|
||
`call.aborted` for this `request_id`, drop `chunk_tx` (feed gets EOF)
|
||
**and** drop the handler future (use `select!` with an abort signal
|
||
instead of `join!`). The cross-stream `abort()` path
|
||
(`connection.rs:398-415`) opens a new stream and only mutates
|
||
`PendingRequestMap` — on the responder, a different `handle_stream`
|
||
task receives it and calls `handle_abort`, which doesn't reach the
|
||
running `pump_sink`. The single-stream mode makes the cross-stream
|
||
abort path less relevant for channel 0; document whether
|
||
stream-per-request abort is worth fixing or should be deprecated in
|
||
favor of single-stream.
|
||
- **Also (from P-06):** during a sink pump, `call.aborted` frames for
|
||
**other** request IDs on the same stream are dropped by the
|
||
id-mismatch guard before the type match (`dispatch.rs:466-473`). On
|
||
the single-stream path this is handled (the outer loop routes by
|
||
`request_id` first); on the stream-per-request path it's a latent
|
||
bug. Verify the single-stream path is correct and decide whether the
|
||
stream-per-request path needs the multiplexing fix or just
|
||
deprecation.
|
||
**Acceptance gate:** a test that aborts an in-flight publish and
|
||
asserts the handler future is cancelled (the handler observes a
|
||
drop/cancellation, not just an `Err` injected into the stream it's no
|
||
longer reading); a test that the aborting initiator receives
|
||
confirmation and the responder's resources are released.
|
||
|
||
## Unit 10 — Stubs, substrate modes, and spec-doc renumbering (C-10, C-11, C-14, C-15, C-26)
|
||
|
||
**Goal:** the remaining decided-but-deferred behaviors are honestly
|
||
stubbed (no fake success) with OQs filed; the spec docs are consistent
|
||
with the post-047 model and the alkcall ADR numbering.
|
||
**Files:** `src/channels/operations.rs`, `src/channels/adapter.rs`,
|
||
`src/channels/client.rs`, `docs/architecture/channel-client.md`,
|
||
`docs/architecture/channels-adapter.md`,
|
||
`docs/architecture/channels-wire.md`, `docs/architecture/open-questions.md`.
|
||
**Scope:** This is the lowest-risk unit and can land last. It is mostly
|
||
docs + OQ filing.
|
||
- **C-10 / C-11:** `channel/control` (`operations.rs:234-262`) returns
|
||
`{"ok": true}` for a silently-discarded `message`; there is no
|
||
control-handle concept. `channel/resources/subscribe`
|
||
(`operations.rs:268-289`) is `futures::stream::once(...)` (one-shot,
|
||
not live), emits currently-open channels' ALPNs (not the set of
|
||
openable ALPNs aggregated from ALPN-crate resource enumerators), and
|
||
has the unbounded `0..u32::MAX` loop (see Unit 7). Either implement
|
||
(control-handle routing; live subscription aggregated from
|
||
enumerators) or file OQs and make the stubs honest: return an
|
||
explicit `unimplemented`-style `CallError` (code
|
||
`channel:control_not_implemented` / `channel:resources_not_implemented`)
|
||
so callers fail loudly, not with fake success. Recommend: file OQs
|
||
(the control-handle and resource-enumerator surfaces are real design
|
||
work, not cleanup) and make the stubs honest.
|
||
- **C-14 / C-15:** only the in-line substrate mode is implemented
|
||
(`adapter.rs:199-259` accepts one bidi stream once). The QUIC-native
|
||
multi-stream substrate ("accept remaining bidi streams, read headers
|
||
off each") is not implemented. `ChannelClient` has `open_channel`
|
||
(Unit 5) but not `subscribe_resources` or the `Channel { channel_id,
|
||
source }` struct from ADR-043. Either implement or file OQs and
|
||
remove the misleading doc comments / nonexistent method references.
|
||
Recommend: file OQs (QUIC-native substrate + the full `ChannelClient`
|
||
API are features, not fixes) and remove the stale doc claims
|
||
(`client.rs` doc comment referencing a nonexistent
|
||
`manager.open_channel_stream`).
|
||
- **C-26:** update `channel-client.md`, `channels-adapter.md`,
|
||
`channels-wire.md` to the post-047 model: remove `open_channel(...,
|
||
direction)`, `ChannelDirection`, `ResourceEntry.access`, the generic
|
||
`channel/open` handler, `channel:unknown_alpn` (replaced by
|
||
per-ALPN ops in ADR-047 §3). Renumber stale alknet ADR refs
|
||
(071/075/076/093/094/079/080 and others >047) to their alkcall
|
||
renumbered equivalents (ADR-001..047), or mark them as
|
||
"alknet source ADR, not yet ported" where no alkcall equivalent
|
||
exists. This is a doc-only pass; verify against
|
||
`docs/architecture/decisions/` (which only goes to 047).
|
||
**Acceptance gate:** stubs return explicit errors (not `{"ok": true}`);
|
||
`cargo doc --no-deps` has no broken links to nonexistent methods; the
|
||
channels spec docs no longer describe the pre-047 `channel/open`
|
||
handler or `ChannelDirection`; OQs filed for C-10/C-11/C-14/C-15.
|
||
|
||
## Suggested sequencing (post-Units 1-5)
|
||
|
||
```
|
||
Unit 6 (convention + doc cleanup) → no deps; mechanical; do first
|
||
Unit 7 (small correctness fixes) → no deps; independent
|
||
Unit 8 (Pub protocol semantics) → no deps beyond Units 1-5; P-03 uses alktype
|
||
Unit 9 (abort-cancels-Pub) → depends on Unit 8's pump_sink changes (shared file)
|
||
Unit 10 (stubs + substrate + doc renumber) → no deps; can land any time
|
||
```
|
||
|
||
Units 6, 7, 8, 10 are independent and can proceed in any order (or in
|
||
parallel across separate branches). Unit 9 touches the same `pump_sink`
|
||
code as Unit 8, so land 8 before 9 to avoid merge conflicts. None of
|
||
Units 6-10 require a spec decision (the ADR-047 §4 and §5 decisions
|
||
were made in Units 3 and 5). Units 1-5 are complete (commits
|
||
`502488c` through `f25d0a6`); the integration is end-to-end functional.
|
||
|
||
## On the baseline objective
|
||
|
||
The layering held, the wire formats are right, the architecture is
|
||
right. What's missing is the connective tissue a tty/docker crate would
|
||
actually call: a working `publish()`, a working channel 0,
|
||
`register_openable`, channel adoption, and a streaming `publish()`.
|
||
**A tty extraction cannot start on this foundation yet** — treat the
|
||
end-to-end `ChannelClient` ↔ `ChannelsAdapter` test (Unit 2's gate,
|
||
then Unit 3's gate) as the acceptance gate for the next round.
|
||
|
||
---
|
||
|
||
## Verification log (this consolidation pass)
|
||
|
||
Findings re-verified directly in source on 2026-08-12 against tree
|
||
`f305f8c`. Every `file:line` reference above was checked. Findings that
|
||
did not survive verification or were overstated in the original
|
||
reviews, corrected here:
|
||
|
||
- **Sub Review B's "the pump does not write an EOF chunk" (C-17):**
|
||
overstated. The pump *does* write EOF when it receives the sentinel
|
||
(`mux.rs:124-133`). The real bug is narrower: when the sentinel is
|
||
lost (full buffer `try_send`), the pump exits on `recv → None`
|
||
without writing EOF. C-17 above states the corrected mechanism.
|
||
- **Warning count (C-09):** the main review said 5; this pass sees 4
|
||
`cargo doc` warnings (2× `register_openable`, 1× `default_policy`,
|
||
1× `env` module/macro). Documented as 4.
|
||
- **P-11:** flagged in Sub Review A as drift; on verification this is
|
||
an internal ADR-046 inconsistency (§3 vs §6) that the code resolved
|
||
correctly and consistently. Reclassified from "drift" to "amend the
|
||
ADR §3 text."
|
||
|
||
All other findings (P-01 through P-10, C-01 through C-08, C-12 through
|
||
C-26) verified as stated. |