The alkcall decision work (WS-24 dispatch-resolution mechanism, WS-25 client-side op registration) now lives upstream as alkcall review 004. Unit 1 records the leading candidates and their alkhttp consequences: - WS-24 -> option (a), per-session base registry (the only wire-proven dispatch shape; fork surface = alkcall F-02) - WS-25 -> channel-0 bootstrap op (op/register as an assumed op in the bootstrap set; requires the alkcall client serving half, F-04) - discovery composition: services/list on the fork + peer-registered ops via the already-built services/list-peers Consumer-set reframe from WS-23 unchanged; no findings added or removed.
25 KiB
Review 003 — WebSocket data-channel wiring gap (the OQ-05 drill-down)
Status
Verified, open for remediation.
Scope
Focused review of the deferred half of the WS path: the data-channel wiring recorded as OQ-05 (review-001 WS-03, deferred 2026-08-29), plus the bidirectionality half of ADR-048 it leans on. The trigger for this pass was a session-level drill-down into websocket.md §"Data channels for browsers" and the OQ-05 deferral: what exactly is missing, whether the deferral rationale still holds, and whether the "nothing new to design" assumption recorded in OQ-05 is actually true.
The pass covered:
- the WS accept-side wiring end to end (
src/websocket/upgrade.rs, the alkcallChannelsAdapteraccept path it drives), - the alkcall channel machinery the deferred wiring is expected to
reuse (
ChannelCore::register_openable,ChannelOperations,ChannelManageropen/adopt/ledger, the demux loop), including alkcall's own end-to-end test of the exact wiring shape, - the dispatch path that would resolve an open op on channel 0
(
Dispatcher::dispatch/run_loop_single_stream,compose_root_env, the connection overlay), - the deployment surface (
HttpAdapterbuilders,SessionState,RouterState) an openable-ALPN declaration would ride, - the spec record (ADR-047 §4 amendment, ADR-067 v1-cut note, ADR-048 reconciliation note, OQ-05, alkcall ADR-047 §6 discovery split), and
- the consumer framing (OQ-05's "reopened when a browser consumer lands" trigger) against the clarified product position: WS is the browser path and the native-client fallback behind hostile NAT/firewall — the consumer set is wider than browsers, and the wasm-capable protocol stack means a browser client needs only binary-frame parsing and byte reassembly before handing bytes to alkcall-wasm.
Cross-crate findings (alkcall-side) are flagged as such. Findings continue review-002's numbering (WS-20..).
Baseline verification (this pass)
cargo test → 453 passed, 0 failed
cargo test --all-features → 575 passed, 0 failed
cargo clippy --all-targets -- -D warnings → clean
cargo clippy --all-features --all-targets -- -D warnings → clean
cargo fmt --check → clean
Verified against tree 58665f2. No source changes were made in this
pass; the findings are analysis-verified against source (file:line
below) rather than introduced by new code.
Verdict
- OQ-05's core assumption is now known to be wrong, and that is the
headline. OQ-05 records the deferral as "the design is decided
(ADR-067: the browser opens data channels exactly as any channels
consumer — nothing new to design); what is deferred is the
wiring." That held for the alkcall-side machinery — which is
genuinely complete and well tested — but two wiring-blockers turned
out to be design gaps, not mechanical work:
- WS-24 (cross-crate): the ADR-047 §4 amendment's registration
mechanism ("
register_openableis called on the connection's overlay registry, Layer 2") cannot resolve an open op on the wire — the top-level dispatch consults only the dispatcher's base registry and never the connection overlay. The only shape proven to work (alkcall's own e2e test) registers open ops on a per-connection registry installed as the dispatcher's base registry — a shape the ADR does not describe and that has no fork/clone surface onOperationRegistryto support. - WS-25: there is no wire mechanism for a WS client to
register an op at all — the call protocol's envelope kinds are
call.requested/responded/completed/aborted/published/erroronly. ADR-048's browser-side bidirectionality ("the hub can call browser-registered ops") is not designable until alkcall defines client-side op registration (or amends the promise). Every overlay test to date pokesregister_importedin-process.
- WS-24 (cross-crate): the ADR-047 §4 amendment's registration
mechanism ("
- The mechanical half of the gap is real but small (WS-20/21/22):
the accept-side hook discards the
ChannelManageralkcall hands it, the generic channel lifecycle ops are never registered, and no deployment surface exists to declare openable ALPNs. The alkcall machinery these would consume (ChannelCore,ChannelOperations, opener ledger, demux, odd/even id split, cap policy) is built, tested, and ready — alkcall even ships an end-to-end test of the exact wiring over duplex. - The deferral trigger no longer matches the consumer set. OQ-05
says "reopened when a browser consumer lands," but WS is also the
native fallback behind ugly NAT/firewall; a Rust WS consumer
(exactly the
test_supportclient shape) is a legitimate first consumer and needs no JS library — OQ-04 does not block this wiring (WS-23). - No regressions and no new robustness holes were found in the channel-0 path itself; the WS-13/17/18-era pump work is sound and the idle-knob/data-channel interplay (silent-but-alive SSH-style channels) is already documented with a deployment posture.
Severity legend
- [critical] — the protocol cannot function end-to-end as committed; 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 is a realistic security weakness.
- [minor] — drift, convention violation, dead code, or a doc/spec inconsistency with no correctness impact.
Part A — The deferred wiring (mechanical)
WS-20 [major] — install_channel_zero discards the ChannelManager; no ChannelCore, no open ops, ever
Residual of: review-001 WS-03 / OQ-05 (the v1 cut itself).
Verified: YES. The hook alkhttp supplies to
ChannelsAdapter::new ignores the manager:
Arc::new(move |_manager, channel0_conn, auth| ...) —
src/websocket/upgrade.rs:255. alkcall's accept path passes the
per-connection ChannelManager precisely so the hook can build a
ChannelCore and register openable ALPNs on it
(alkcall/src/channels/adapter.rs:268 —
(self.install_channel_zero)(manager.clone(), channel0_conn, auth.clone());
the InstallChannelZero doc at adapter.rs:50-54 says the manager is
reachable via the hook "e.g., to register channel lifecycle ops'
ChannelCore"). The reference wiring exists in alkcall's e2e test
(channels/client.rs:780-837): the hook builds
ChannelCore::new(manager, policy), registers a per-ALPN open op via
register_openable, and dispatches channel 0 with it. In alkhttp the
manager is dropped on the floor, so a browser/native WS client can
never open a data channel — the capability ADR-067 says the channels
design exists to provide, and the only part of the WS path the HTTP
gateway cannot already do.
Fix (Unit 2): rework the hook to take the manager, build the
per-connection ChannelCore, register the generic ops (WS-21) and the
deployment's openable ALPNs (WS-22), then run the dispatcher over the
per-session registry — shape per the WS-24 decision. The policy is
already threaded (ChannelsPolicy extension, review-001 SRV-10's fix);
the openables ride the same path.
WS-21 [major] — Generic channel lifecycle ops are never registered: no channel/close, channel/control, channel/resources/subscribe on WS sessions
Verified: YES. alkcall's ChannelOperations::register_on
(alkcall/src/channels/operations.rs:42-98) registers the three
generic ops — close (with drain-before-close + opener-ledger
decrement), control (loud not_implemented per alkcall OQ-39), and
resources/subscribe (loud not_implemented per alkcall OQ-40).
Grep-verified: nothing in alkhttp calls it, and the per-session hook
(WS-20) never registers them. Consequence: even if open ops were
wired today, a WS client could not close a channel
(channel/close → NOT_FOUND) or observe resources — the open
ledger would only drain via connection teardown. This is also the
discovery seam for the dynamic half of alkcall ADR-047 §6's
discovery split.
Fix: fold into the WS-20 hook rework — register_on on the
per-session registry next to the openables. Note the
not_implemented ops are correctly loud today; registering them is
still right (fail loudly at the op, not NOT_FOUND at discovery).
WS-22 [major] — No deployment surface to declare openable ALPNs for the WS path
Verified: YES. The HttpAdapter builder surface is
with_decoy / with_extra_routes / with_ws_max_sessions /
with_ws_idle_timeout (src/server/adapter.rs:158-233) — nothing
expresses "these ALPNs are openable on WS sessions." SessionState
carries registry/sessions/slots/idle only (upgrade.rs:89-97), and
RouterState likewise. The deployment decision ADR-067 requires
("the deployment decides which ALPNs are openable — the browser
reaches exactly what a Rust consumer on an in-line connection would
reach") has no expression point. Note the distinction from the
existing ChannelsPolicy extension: that is the cap policy
(ChannelLifecyclePolicy::check_open); the openable set (per-ALPN
OperationSpec with the channel_open marker + OpenHandler) is a
separate, currently nonexistent input.
Fix (Unit 2): an OpenableAlpn { spec, open_handler } list +
HttpAdapter::with_ws_openable_alpns(...), threaded
RouterState → SessionState → run_channels_session → hook, with
a request-extension fallback mirroring ChannelsPolicy so
bare-registry/custom upgrade routes can pass their own set. The
ALPN-specific OpenHandler implementations stay in the ALPN crates
(alktty et al.); alkhttp only ferries the registrations.
WS-23 [minor] — No browser-opened-channel e2e test; OQ-05's reopen trigger underestimates the consumer set
Verified: YES. The test suite covers channel-0 dispatch
(tests/ws_*), overlay mechanics (in-process pokes,
tests/ws_overlay_ops.rs), and pump robustness — but no test opens a
data channel through a live WS session (the OQ-05 acceptance item
"browser-opened-channel end-to-end test" exists precisely because
none does). Everything needed already ships behind
test-support: WsClient, frame_channel0_chunk,
ChunkAssembler, FrameAssembler (src/websocket/upgrade.rs:444-709)
— a Rust WS client speaking raw channels framing, which is also
exactly the shape of the native-client-behind-a-firewall consumer.
alkcall has the analogous e2e test over duplex
(channels/client.rs:767-870) — the alkhttp one is the missing gate.
Also record here (product framing, no code change): OQ-05's
status — deferred(scope: v1 cut — no browser consumer yet; reopened when one lands) — frames the consumer as a browser needing the JS/TS
library (OQ-04). The clarified position: WS is the bidirectional path
for browsers and the fallback for native clients behind hostile
NAT/firewall/proxy; a native WS consumer is feasible today with zero
new library work (Rust client, same framing as any in-line
transport), and a browser consumer is mostly byte parsing +
reassembly ahead of wasm-targeted alkcall (WebTransport remains out
of scope per ADR-069). The wiring is therefore not blocked on OQ-04,
and the reopen trigger should be reframed accordingly when OQ-05 is
picked up.
Fix: the Unit-3 e2e test (open → {channel_id} → chunks both
ways → handler sees bytes → channel/close → cap denial → teardown
decrements), plus the OQ-05 reframe when the task lands.
Part B — Design/spec mismatches (planning bugs; need decisions before wiring)
WS-24 [major, cross-crate] — Top-level dispatch never consults the connection overlay; the ADR-047 §4 amendment mechanism cannot resolve open ops on the wire
Verified: YES. The amendment
(alkcall/docs/architecture/decisions/047-openable-alpns-are-operations.md,
"Amendment (§4 per-connection registration, 2026-08-13)") decides:
"open ops are registered per-connection … register_openable is
called on the connection overlay registry (Layer 2 per ADR-019),
closing over a per-connection ChannelCore." But the dispatcher's
top-level resolution never looks at that layer:
Dispatcher::dispatch reads the op's type from
self.registry.registration(...) (dispatch.rs:316-320) and invokes
via self.registry.invoke/invoke_streaming/invoke_sink
(dispatch.rs:330-341) — the base registry only. The connection
overlay (CallConnection::overlay_env(),
connection.rs:174; OverlayOperationEnv) is reachable solely as a
layer of context.env via compose_root_env
(dispatch.rs:195-215) — i.e., for nested invocation by handlers,
not for resolving the incoming call.requested itself. An open op
registered per the amendment's mechanism would resolve NOT_FOUND
on the wire.
The only shape proven to work is alkcall's own e2e test
(channels/client.rs:767-870): the hook builds a fresh
per-connection registry containing the open op and passes it as the
dispatcher's base registry (Dispatcher::new(registry, ...) at
:837). That shape works but implies a per-session fork of the
deployment's base registry (base ops + generic channel ops +
openables) — and OperationRegistry/HandlerRegistration have no
Clone/fork surface (registration.rs:68-75, 97-100; the inner
payloads OperationSpec/HandlerKind do derive Clone), so the
fork needs an alkcall-side addition or a re-registration walk. It
also implies per-session re-registration of services/list /
services/schema closed over the fork, or per-session open ops stay
undiscoverable (alkcall ADR-047 §6's "what may I open" half).
This is the "planning bug": OQ-05 says nothing new to design; mechanically the alkcall machinery is done, but the registration mechanism the ADR names does not dispatch. Either resolution is fine — the decision just needs to be made deliberately:
- (a) per-session base registry (the proven shape): alkcall
amends ADR-047 §4's mechanism wording and adds a fork/clone
surface on
OperationRegistry(most registration payloads alreadyClone); alkhttp builds the per-session fork in the hook. No dispatch-path change; discovery re-registered per session. - (b) overlay-aware top-level dispatch: alkcall adds an overlay
fallback in
dispatch/run_loop_single_stream(consultconnection.overlay_env()when the base registry misses). Larger blast radius (touches the shared dispatch loop); keeps one static registry; makes the ADR-047 amendment's wording true as written.
Cross-crate: the decision and (for (a)) the fork surface live in alkcall; alkhttp consumes whichever lands.
WS-25 [major] — No wire mechanism for a WS client to register an op; ADR-048's browser-side bidirectionality is undesignable as specified
Verified: YES. The call protocol's envelope kinds are
call.requested / call.responded / call.completed /
call.aborted / call.published / call.error
(alkcall/src/protocol/wire.rs:12-17) — there is no
registration/announce kind, and grep-verified no wire op by which a
client could register a handler spec. Client-side registration
onto an overlay (register_imported / register_imported_all,
connection.rs:162-172) is reached only in-process — the
from_call/from_wss import path (a Rust consumer importing a
remote peer's ops) and tests. Every "browser-registered op" test
pokes the overlay directly in-process
(tests/ws_overlay_ops.rs:210-251; the alknet-http overlay.rs
prior art is the same shape). So the decided behavior — the hub can
call browser-registered ops over the same session
(websocket.md §"Bidirectionality"; ADR-048's reconciliation note
records it as decided design, deferred) — has no wire contract for
the browser half: nothing tells the hub the op exists, and the
browser cannot run a Rust-side import().
This is an alkcall-level protocol design gap (a new envelope kind / handshake, an explicit "not supported for WS-native clients" scope cut, or a channel-0 bootstrap op). It does not block the data-channel half (open ops are hub-registered; the browser only calls them), which is why Unit 1 can decide WS-24 first and WS-25 in parallel — but ADR-048's promise should not be treated as implementable by wiring alone.
WS-26 [major] — The upgrade path retains no live connection handle; the hub cannot reach a session's overlay even once registration exists (and cannot push anything, ever)
Verified: YES. run_channels_session hands channel 0's
Connection into the hook, which moves it into the spawned
dispatcher task (upgrade.rs:255-287) — nothing retains a handle the
assembly layer can reach. WsSessions tracks pump handles only (the
WS-08 abort lever). Consequences: ADR-048's "the hub holds that
connection as a live handle" and "the hub reaches browser ops through
the connection handle, not through PeerRef::Specific"
(websocket.md §"Browsers are not alk peers") has no object to hold —
the deployment cannot enumerate live WS sessions, cannot
hub→browser-call an overlay op even after WS-25 defines
registration, and cannot push-initiate anything toward a session.
The alknet prior art retained the handle
(alknet-http/src/websocket/upgrade.rs:104 —
Arc::new(CallConnection::new_overlay_only(identity)) held by the
session state).
Fix: rides the WS-20 rework — the hook (or the session) retains an
Arc<CallConnection> in a deployment-visible registry (shape decided
in Unit 2 alongside the openables surface; eviction hooks into
WsSessions so handles die with the session).
WS-27 [minor] — adapter_install_channel_zero is compiled unconditionally on server under #[allow(dead_code)]; its only caller is test-gated
Verified: YES. src/websocket/upgrade.rs:290-295 exports the
pub(crate) wrapper; mod.rs:36-37 re-exports it under
#[cfg(feature = "server")]; the sole caller is from_wss's
#[cfg(all(test, feature = "server"))] test server
(src/adapters/from_wss.rs:352,394). The #[allow(dead_code)]
documents the current shape but the cleaner cut is gating the wrapper
with the same cfg as its caller — moot after WS-20's rework, which
will give both the upgrade path and the test server a real hook;
fold the cleanup into that task.
Non-findings (verified correct, recorded to bound the re-review)
- Channel-id collision: accept side allocates even ids starting
at 2, connect side odd starting at 1
(
alkcall/src/channels/manager.rs:105-125);adopt_channelis the non-allocating side's install and returnsChannelExistson collision (manager.rs:273-309). The WS browser (connect side) and hub (accept side) allocation split is safe. - Demux hardening already landed (review-001 WS-12's cross-crate
fix):
TooLargeskip with a 64 KiB stack buffer, cumulative 256 MiB skipped-bytes budget, EOF-mid-skip ends the loop (alkcall/src/channels/adapter.rs:131-222) — the DoS shape is bounded before browser traffic ever hits it. - Open-op wrapper completeness:
register_openablecovers Query/Mutation (Once), Sub (Stream, one envelope then out-of-band data), Pub (loudnot_implementedstub pending the channel-adoption path, alkcall C-08) — cap check → allocation → ledger → spawn → teardown decrement all wired (alkcall/src/channels/operations.rs:399-453, 483-555). - Idle-knob vs silent data channels is already documented with a
deployment posture (websocket.md §"Idle-read timeout": progress-
based deadline,
with_ws_idle_timeout(None)posture, no keepalive by decision). Wiring data channels makes the 60 s default bite more often (an idle SSH-style channel is silent-but-alive); the remediation plan should surface that knob in the task's docs note, not change the semantics. NoCapdefault on the built-in surface is review-001 SRV-10's recorded resolution (ChannelsPolicyextension exists for deployments;upgrade.rs:308-317, 391-393) — unchanged by this review.
Remediation plan
Sequenced by dependency; Unit 1 is a decision task and gates the
shape of Unit 2. Upstream findings are tracked in parallel as alkcall
review 004
(/workspace/@alkdev/alkcall/docs/reviews/004-*.md), where the
decision work actually lives; the alkcall remediation units there
carry the acceptance gates for WS-24/WS-25. The plan below is the
alkhttp-side consequence of whichever upstream resolution lands.
Unit 1 — alkcall decision task (WS-24, WS-25) → tracked upstream
Decide the open-op dispatch-resolution mechanism and the client-side op-registration question; record as alkcall ADR amendments (ADR-047 §4 mechanism; ADR-048's bidirectionality scope) with alkhttp-side consequences noted. Full analysis and remediation units live in alkcall review 004; the alkhttp summary:
- WS-24: the leading candidate is option (a), per-session base
registry — the only shape proven to dispatch on the wire
(alkcall's e2e test). It requires the alkcall fork/clone surface
(
OperationRegistryfork; all inner payload types verifiedClone-able — see review 004 F-03). - WS-25: the leading candidate is the channel-0 bootstrap op
(
op/registeras an assumed-op the hub serves per-session, whose handler writes into the connection-local overlay viaregister_imported) — no new envelope kind, the wire stays the sixcall.*kinds. It requires the alkcall client serving half (F-04): the connect-side read pump currently resolves responses only and silently drops inboundcall.requested. It composes with the bootstrap-discovery split:services/liststays the base-registry listing; peer-registered op discovery rides the already-builtservices/list-peers(which readsctx.env.peer_ids()/peer_operations()— populated from the connection overlay bycompose_root_env).
Gate: the alkcall ADRs (or amendments) recorded per review 004, and review 004's Units 1–3 landed; OQ-05/ADR-048 in this repo point at the resolution.
Unit 2 — alkhttp wiring (WS-20, WS-21, WS-22, WS-26)
OpenableAlpnlist +HttpAdapter::with_ws_openable_alpns(...), threaded throughRouterState/SessionStatewith a request-extension fallback (mirroringChannelsPolicy).- Rework
install_channel_zero: take the manager + policy + openables; build the per-connectionChannelCore; registerChannelOperationsgeneric ops + the openables (per Unit 1's shape); retain theArc<CallConnection>in a deployment-visible,WsSessions-evicted handle registry. adapter_install_channel_zerocleanup (WS-27) rides this rework.- Gate: a live WS session lists
channels/<alpn>/subinservices/list(per Unit 1's discovery shape) and an open op on channel 0 returns achannel_id.
Unit 3 — e2e tests (WS-23)
Using test_support::WsClient (the Rust-native-consumer shape):
- open →
{channel_id}→ chunks flow both directions on that channel → theOpenHandler'sConnectionsees the bytes. channel/close→ handler EOF + opener-ledger decrement (policycount drops).- per-identity cap denial (
channel:too_many_channels) and per-connection cap (channel:too_many_channelsfrom the manager). - disconnect mid-open → teardown cascade, no leak.
TooLargechunk on a data channel survives (demux resync) — already proven alkcall-side; assert it through the WS path.
Unit 4 — spec reconciliation (docs)
- OQ-05 → resolved (or narrowed to the remaining piece), with the consumer-set reframe recorded (WS-23): browser + native fallback; OQ-04 not blocking.
- ADR-067 v1-cut note and ADR-048 reconciliation note updated to the landed state; websocket.md §"Data channels for browsers" status block removed; the alknet-ADR-044 §5 pointer checked for staleness.
- websocket.md gains the idle-knob deployment note for silent data channels (already true, just surfaced where a deployer reads).
Verification log (this pass)
- All findings carry
file:linereferences verified against tree58665f2. - The WS-24 dispatch claim was triple-checked:
dispatch()'s op-type lookup (dispatch.rs:316-320) and invoke paths (:330-341) consultself.registryonly;OverlayOperationEnv(connection.rs:731-770) is reachable only viacompose_root_env→context.envlayers (dispatch.rs:195-215); alkcall's e2e test passes the per-connection registry as the dispatcher's base registry (channels/client.rs:789-837), not as an overlay. - The WS-25 claim was grep-verified across alkcall
src/: the onlyregister_imported*callers are in-process import paths (protocol/adapter.rs:541,610,client/from_call.rs:71doc); the wire kind set is closed over the sixcall.*constants. - The WS-20 reference shape was read in full
(
channels/client.rs:767-870), including the fresh-registry detail that motivates the fork-surface ask. adapter_install_channel_zero's caller census:from_wss.rs:394only (cfg(test) module).- Baseline gates re-run for this pass (see above): cargo test (default + all-features), clippy (both), fmt. No coverage run — no source changed.