Files
alkcall/docs/reviews/005-serving-loop-concurrency-and-op-register-review.md
T
glm-5.3-flash d5b2661b38 fix(review 005 Unit 3): resource_id_path wire round-trip + bootstrap-list doc alignment (G-04, G-05)
- resource_id_path rides both halves of the spec wire round-trip:
  spec_to_json_pub serializes it (optional string key), rebuild_spec_for
  parses it. Additive optional field - absent stays absent. Previously
  an announced (or from_call-imported) op declaring ownership-scoped
  resource extraction silently rebuilt with resource_id: None, so ACL
  checks ran without the resource ID.
- Gates: spec_round_trips_resource_id_path (serialize -> parse ->
  field intact) + spec_without_resource_id_path_stays_absent (additive
  field breaks no consumer).
- ADR-022 amendment: bootstrap-op set gains services/list-peers with a
  dated G-05 note (the installer has registered it since the amendment
  landed; the doc lagged the code). Set remains closed at four.

Verification: cargo test 589 / --all-features 606, clippy
(all-targets, all-features, wasm32) clean, fmt clean, doc clean.

Refs docs/reviews/005-...md (G-04, G-05; all findings closed).
2026-09-04 09:41:43 +00:00

35 KiB
Raw Blame History

Review 005 — Serving-Loop Concurrency and op/register Composition (post-remediation review of f84d214)

Status

Units 1–3 remediated and verified (G-01..G-05). All findings closed; the review is resolved. See Remediation log.

Scope

Post-remediation review of commit f84d214 (review 004 Units 1–3: per-session fork registry, connect-side serving loop, op/register). The remediation landed all six F-findings' mechanisms; this pass reviews the landed implementation against the decided spec (ADR-022 amendment 2026-09-03, ADR-047 §4 amendment #2, review 004's acceptance gates) rather than re-litigating the findings.

Every finding below was verified directly in source at tree f84d214. The headline finding (G-01) was verified empirically: a probe test was added to the tree, run, observed to reproduce the defect, and removed — the tree at this review's baseline carries no source changes. Findings continue alkcall review numbering with the new prefix G (001–004 used P/C/R/A/B/C/D/F — each review numbers independently).

Cross-repo context: the alkhttp re-pointing claimed by the remediation log was verified in that repo (5b62307 — open-questions.md:102-112, decisions/048…md:19-25). Unit 4 (alkhttp wiring) remains downstream and is not reviewed here.

Baseline verification (this pass)

cargo test                                        → 581 passed, 0 failed
cargo test --all-features                         → 598 passed, 0 failed
cargo clippy --all-targets -- -D warnings         → clean
cargo clippy --all-features --all-targets -- -D warnings → clean
cargo fmt --check                                 → clean
cargo check --target wasm32-unknown-unknown       → clean
cargo clippy --target wasm32-unknown-unknown -- -D warnings → clean
cargo doc --no-deps                               → clean

All gates the remediation log claims reproduce. The suite is green — and again the green is partial: the gates test the flows they name, and the one flow the ADR amendment promises most loudly (nested composition of peer-announced ops) is the one no gate exercises. See G-02 for why the existing gate cannot catch G-01.

Verdict

  • Unit 2's fork surface is solid. Interior mutability, lock discipline, fork independence, validator carry, and the self-referential bootstrap-discovery closure are all correct and tested (see Non-findings).
  • Unit 3's serving loop resolves the F-04 frame gap but inherits the dispatch loop's serial-inline shape — and the F-05 flow it exists to enable is exactly the shape that deadlocks under it. The ADR-022 amendment's "invocable via nested composition" promise is unmechanized for wire-dispatched handlers (the primary caller shape); a 30s sweeper timeout masquerades as resolution.
  • The F-05 acceptance gate does not exercise the forwarding stub — the one component that is genuinely new protocol surface. It verifies announce-lands-in-overlay and the consumer serves, by calling the announced op directly, bypassing the stub entirely.
  • op/register's collision gate is overlay-only, so a peer-announced op can shadow the serving side's own ops in nested composition (connections resolve before base in PeerCompositeEnv).

The architecture decisions (fork as dispatch registry; overlay as landing zone; bootstrap-op set; opt-in serving) remain sound. The defects are in the serving loop's concurrency model and in what the gates measure.

Severity legend

Same scale as review 004:

  • [critical] — a decided spec invariant is violated in a way that makes a promised capability unreachable end-to-end; or corrupts data.
  • [major] — a core protocol path cannot serve a decided behavior; works only via shapes the spec does not describe.
  • [minor] — drift, doc/spec inconsistency, or a missing convenience with no correctness impact.

Part A — The serving loop's concurrency model

G-01 [major] — Same-connection nested composition deadlocks the serving loop; the forwarding stub's nested call resolves only via the 30s sweeper

Status: REMEDIATED (Unit 1) — see Remediation log; both gates added and verified load-bearing against the pre-fix loop.

ADR drift: ADR-022 amendment (2026-09-03) §op/register: the announced op is "invocable via nested composition (env.invoke)"; the amendment's whole point is that the hub's handlers compose peer-announced ops. Review 004 F-05's recommendation said the same: the hub-side handler wraps announcements "as a forwarding handler that issues a nested call.requested back over channel 0".

Verified: YES, by code trace and by an empirical probe (see Verification log).

Mechanism, in four steps:

  1. Dispatcher::serve_single_stream awaits dispatch() inline in its read loop for call.requested frames (src/protocol/dispatch.rs:1009-1014) — the same serial-inline shape as the pre-existing accept-side run_loop_single_stream (:765-770). Only the Sink arm is spawned (:1031-1053); Once responses and Sub pumps (pump_stream_single_stream, awaited inline at :1027-1029) hold the loop.
  2. The F-05 forwarding stub (make_forwarding_handler, src/client/from_call.rs:392-415) issues its nested call via connection.call_with_payload (:408) — over the same connection whose loop dispatched the parent. In single-stream mode the pending resolves only when some loop reads the call.responded frame (connection.rs:274-303 — register pending, write frame, await receiver; resolution is the reader's job).
  3. The only reader of that connection is the serving loop itself — which is blocked in step 1 awaiting the parent dispatch, whose handler is blocked in step 2 awaiting the nested call. Circular wait. The transport buffers the response frame; nobody reads it.
  4. The 30s sweeper (DEFAULT_CALL_TIMEOUT, connection.rs:32; evict_expired, run every 10s per SWEEPER_INTERVAL, dispatch.rs:44) is the only thing that breaks the cycle — the nested call resolves as CallError::timeout.

Empirical probe (this pass): hub serves op/register + a hub/compose handler that invokes the connection overlay's registration (the forwarding stub — the exact resolution PeerCompositeEnv → OverlayOperationEnv produces for nested composition) from inside a wire-dispatched handler; consumer announces consumer/exec, then calls hub/compose over the wire. Result:

PROBE: hub/compose resolved after 30.000761619s:
  Err(CallError { code: "TIMEOUT", message: "request timed out", retryable: true })

A second run with an 8s timeout confirmed the hang is unconditional (not load-dependent): zero progress until the sweeper. The probe was removed after evidence capture; the tree is unchanged.

Consequences (verified):

  • The ADR-022 amendment's flagship flow — a hub handler composing a peer-announced op — fails for every wire-dispatched parent handler. It "works" only for parents dispatched from in-process contexts (where the serving loop is idle and free to resolve the nested call's response) — a shape the ADR does not describe. Per the severity legend this is a decided behavior served only via an undescribed shape; it borders [critical] for the composition flow specifically.
  • The same circular wait applies to composition of from_call imports on the accept side: run_loop_single_stream has the same inline shape, and the connection overlay's imported-op stubs ride the same connection. That hazard is latent pre-f84d214 (the accept side had the inline loop and register_imported before this commit) — f84d214 makes it load-bearing by (a) adding the wire path that populates overlays with stubs, (b) putting serving loops on the connect side where both directions are live by design, and (c) writing the composition promise into ADR-022.
  • The same starvation is not limited to the stub: while the loop serves any Sub (pump_stream_single_stream inline), the side's own outbound pendings queue unresolved. A serving-enabled consumer that calls out while serving a long-lived Sub to the same peer eats 30s timeouts on its own calls. The F-04 gate tests the two directions only sequentially (hub→consumer resolves, then consumer→hub) — never interleaved.
  • Head-of-line blocking: the peer's subsequent requests queue behind each inline dispatch.

Fix (Unit 1): spawn the Once dispatch and the Sub pump the way the Sink arm already is (the SharedFrameWriter already serializes concurrent frame writes, so per-request frame ordering is preserved); track spawned task handles for teardown on loop exit (the Sink arm's in_flight_sinks pattern, generalized). Keep read-loop resolution unblocked at all times. Apply the same rework to run_loop_single_stream (or factor the shared loop) — the accept side has the identical latent hazard via imported-op composition. Acceptance: the probe above (recreated as a permanent gate) resolves in < 1s; a concurrent-interleaving gate (outbound call resolving while a Sub is being served) passes without sweeper intervention.

G-02 [major] — The F-05 acceptance gate bypasses the forwarding stub it claims to prove

Status: REMEDIATED (Unit 1) — the stub-exercising gate and the interleaved-directions gate are added; see Remediation log.

Verified: YES. op_register_announce_then_hub_call_routes_back_to_consumer (src/channels/client.rs:1412-1543) asserts the announce resolves and the overlay holds the op — then calls accept_conn.call("consumer/exec", …) (:1529). That is a direct wire call: the hub's dispatcher resolves consumer/exec… which is not on the hub at all — the frame crosses to the consumer, whose serving loop dispatches it from its local registry. The forwarding stub (op_register_handler's register_imported bundle) is never invoked. The gate's comment — "the forwarding stub routes the nested call back over channel 0" — describes a path the test does not take.

Consequence: the one behavior that is genuinely new protocol surface (stub routing) is untested, and the one defect it hides (G-01) is precisely in that path. This is the same failure class review 001 flagged ("all coverage is unit-level / the integration seams are where the real problems live") — the remediation added e2e gates, but the F-05 gate measures an adjacent, easier path. The F-04 gate is likewise sequential-only (see G-01's third consequence).

Fix (fold into Unit 1): add a gate that exercises the stub — announce → a wire-dispatched hub handler composes the announced op via ctx.env (or directly via the overlay registration) → resolves from the consumer. This is the removed probe, productized; it is the acceptance test G-01's fix must pass. Optionally extend the F-04 gate with an interleaved-directions phase.


Part B — The op/register composition surface

G-03 [major] — Announced ops can shadow the serving side's own ops in nested composition; the collision gate checks the overlay only

Status: REMEDIATED (Unit 2) — see Remediation log; the ADR-022 amendment records the collision policy.

ADR drift: ADR-018 (composition authority) and ADR-019 (the overlay is the landing zone for imported ops — a layer beneath the deployment's own registry, not a rival to it); ADR-022 amendment (silent on name collisions with the serving side's base registry).

Verified: YES. op_register_handler's collision gate is connection.overlay_contains(&request.spec.name) (src/registry/op_register.rs:142) — it consults only the connection overlay. The serving registry (the fork the session dispatches over) is not consulted. Meanwhile PeerCompositeEnv resolves connections before base (src/registry/env.rs:230-245 — session, then connection_order, then base), and compose_root_env attaches the dispatching connection's overlay (dispatch.rs:207-214).

Consequence: a peer announces an op named fs/readFile (or any name the serving side has registered — services/list itself is fair game). The registration lands in the overlay (forced Internal, FromCall — both per spec). From then on, every handler wire-dispatched from that peer's connection that nested-composes that name via ctx.env resolves to the peer's forwarding stub instead of the serving side's own op. The forced Visibility::Internal does not help here — it blocks wire invocation, not env.invoke (OverlayOperationEnv gates on AccessControl only, connection.rs:749-836; the composed child context is internal: true by design).

Concretely: a hub handler processing peer A's request composes fs/readFile expecting its own registry op (with the deployment's scoped_env, capabilities, and ACL); it silently gets peer A's stub — peer A chooses where the call goes and what it returns. Composition authority (ADR-018) is decided by the composing handler's deployer; a same-name peer registration silently rewrites it. The effect is scoped to handlers whose root context is that connection's (compose_root_env attaches that connection's overlay), which bounds the blast radius — but that is exactly the flagship F-05 flow.

Fix (Unit 2): decide the collision policy and record it in the ADR-022 amendment. Recommended: op_register_handler also rejects names registered on the serving registry (pass the fork — or a name-set closure — into the handler alongside the connection, the same way install_bootstrap_discovery closes over its registry); ALREADY_EXISTS for base collisions regardless of replace (the reconnect path needs to re-announce peer ops, not to overwrite the deployment's). The alternative — changing PeerCompositeEnv to resolve base before connections — would break from_call's namespace-prefixed imports (prefixed names don't collide) is not needed for that, but would change long-decided layering semantics; the registration-side gate is the cheaper, narrower door.

G-04 [minor] — resource_id_path does not survive the spec wire round-trip

Status: REMEDIATED (Unit 3) — see Remediation log.

Verified: YES. spec_to_json_pub serializes name/namespace/op_type/visibility/schemas/error_schemas/access_control (+ channel_open/publish_schema markers; src/registry/discovery.rs:211-234) — resource_id_path is not serialized. rebuild_spec_for cannot parse it (src/client/from_call.rs:209-286), and the field exists on OperationSpec (src/registry/spec.rs:190). An announced (or from_call-imported) op that declares ownership-scoped resource extraction silently loses it; the rebuilt spec's ACL checks run with resource_id: None.

This is a pre-existing gap in the from_call import shape, not introduced by f84d214 — but op/register makes it load-bearing for a second wire surface (peer-announced specs) that explicitly promises "the serializable parts" of a registration. Note namespace is serialized but rebuild_spec_for ignores it in favor of the namespace_prefix parameter — consistent for op/register (replace/re-announce uses None), worth a comment either way.

Fix (Unit 3): add resource_id_path to both halves of the round-trip plus a round-trip test. Additive optional field in the services/schema JSON shape — no existing consumer breaks.

G-05 [minor] — install_bootstrap_discovery registers services/list-peers; the ADR-022 amendment's bootstrap set doesn't name it

Status: REMEDIATED (Unit 3) — see Remediation log.

Verified: YES. install_bootstrap_discovery registers services/list, services/list-peers, and services/schema (src/registry/discovery.rs:290-313; list-peers at :300). The ADR-022 amendment's bootstrap set lists exactly three ops — services/list, services/schema, op/register (022-…contract.md:380-393). Review 004's remediation log says "list-peers" too; the ADR text was never updated.

Consequence: none at runtime (installing a fourth op is strictly additive, and services/list-peers is what makes F-05's "peer-announced ops are discoverable" true). It is a doc/spec-consistency drift on a set the ADR declares "closed."

Fix (Unit 3): add services/list-peers to the ADR-022 amendment's bootstrap-op list (recommended — from_call-import discovery already relies on it) or stop installing it; one line either way.


Non-findings (verified correct, recorded to bound the re-review)

  • The fork surface (F-02/F-03) is solid. Lock discipline is correct: registration() clones under a read guard and drops it before handler invocation (registration.rs:190-192), so self-referential discovery handlers cannot deadlock their own registry; register takes the two maps' write locks sequentially and never across an await. fork() deep-copies both maps (handlers are Arc closures — cheap, and the F-03 constraint that closures must carry is met); validators carry; fork independence is tested both directions; OperationRegistryBuilder::from_registry seeds the builder path.
  • install_bootstrap_discovery (F-06) is correct — handlers closed over the same Arc they're registered on, so post-install per-session registrations are visible at call time; ACL filtering stays per-caller; the fork gate (fork_registry_open_op_resolves_and_is_discoverable) exercises exactly the review-004 acceptance shape.
  • serve_single_stream's pending-resolution arms match the two half-loops they compose. Frame by frame against run_loop_single_stream (dispatch.rs:765-905) and dispatch_envelope (connection.rs:717-741): responded/completed/ error resolve pendings; aborted tries both tables (in-flight sink aborts, then the pending cascade via handle_abort); published routes inbound sinks with the same validation/keep semantics; teardown (fail_all + sink drop + sweeper abort) matches. The direction disambiguation by table membership (documented at dispatch.rs:940-966) is correct as written — G-01 is not a frame-routing bug; the arms are right, the loop's scheduling is the defect.
  • The pure-consumer default is unchanged. from_connection delegates with None and keeps the resolution-only read pump; no behavior change for existing callers.
  • op/register's DTO and unit semantics are clean — one spec serialization on the wire (spec_to_json shape), forced Internal/FromCall on landing (unit-tested), replace semantics tested, ALREADY_EXISTS collision gate tested (against the overlay — see G-03 for the gap).
  • The alkhttp cross-repo claims check out. open-questions.md OQ-05 and ADR-048's re-point are present in that repo at 5b62307.
  • No non-English text reached the tree. The remediation session's language slip (reported anecdotally) did not land in any code, doc, or metadata: a CJK-range regex sweep over all .rs/.md/ .toml/.json in the repo returns zero matches at f84d214.
  • Gates reproduce. All commands in Baseline verification pass at the review tree, matching the remediation log's claims (581 default / 598 all-features).

Remediation plan

Sequenced by dependency. All units are alkcall work; Unit 4 (alkhttp wiring) stays downstream and should not start before Unit 1 — alkhttp's serving consumers would compose over the same connection and hit G-01 immediately. (All three units landed — see Remediation log.)

Unit 1 — Concurrent serving loop + a stub-exercising gate (G-01, G-02)

  • Rework serve_single_stream's EVENT_REQUESTED arm: spawn the Once dispatch and the Sub pump (the Sink arm's existing pattern, generalized; SharedFrameWriter serializes frames; track handles for teardown). Apply the same rework to run_loop_single_stream or extract the shared loop.
  • Add the G-02 gate: announce → wire-dispatched hub handler composes the announced op via nested composition → resolves from the consumer. This is the removed probe, productized; it is the acceptance test for G-01.
  • Optional: an interleaved-directions phase on the F-04 gate (outbound call resolving while a Sub is being served).
  • Gate: the probe recipe (Verification log) resolves in < 1s; no sweeper evictions in the gates.

Unit 2 — op/register collision policy (G-03)

  • op_register_handler gains a serving-registry name-set (closure or Arc<OperationRegistry>); base-registry name collisions reject with ALREADY_EXISTS regardless of replace.
  • ADR-022 amendment: record the collision policy (peer-announced ops may collide with peer-announced ops — replace governs; never with the serving side's own registrations).
  • Gates: announcing a base-registered name (External and Internal) fails loudly; announcing a distinct name still lands; nested composition of a base op is unaffected by an unrelated announce.

Unit 3 — Round-trip completeness + doc alignment (G-04, G-05)

  • resource_id_path through spec_to_json_pub + rebuild_spec_for
    • round-trip test.
  • ADR-022 bootstrap list gains services/list-peers (or drop it from the installer; recommend adding).

Remediation log

Unit 3 — Round-trip completeness + doc alignment (G-04, G-05) — LANDED

G-04 fix. resource_id_path now rides both halves of the spec wire round-trip: spec_to_json_pub serializes it as an optional resource_id_path string (src/registry/discovery.rs), and rebuild_spec_for parses it into the rebuilt spec's fourth constructor argument (src/client/from_call.rs). Additive optional field — absent stays absent, no existing consumer breaks (verified by the companion gate). rebuild_spec_for's doc note from the review ("namespace is serialized but ignored in favor of namespace_prefix") was addressed by leaving the behavior as-is: for op/register the parameter is None (consistent) and for from_call the prefix is authoritative — the round-trip tests pin the name field handling.

Gates: spec_round_trips_resource_id_path (serialize → parse → field intact) and spec_without_resource_id_path_stays_absent_through_round_trip (absent key serializes nothing; rebuilt None) in src/client/from_call.rs.

G-05 fix. The ADR-022 amendment's bootstrap-op set gained services/list-peers with a dated note (review 005 G-05) explaining that the installer has registered it since the amendment landed and the doc lagged the code. The set remains closed at four.

Verification (post-fix):

cargo test                                        → 589 passed, 0 failed
cargo test --all-features                         → 606 passed, 0 failed
cargo clippy --all-targets -- -D warnings         → clean
cargo clippy --all-features --all-targets -- -D warnings → clean
cargo fmt --check                                 → clean
cargo clippy --target wasm32-unknown-unknown -- -D warnings → clean
cargo doc --no-deps                               → clean

Unit 2 — op/register collision policy (G-03) — LANDED

Fix shape. op_register_handler now takes the serving registry alongside the connection (op_register_handler(connection, serving_registry) — the review's "name-set closure" recommendation, materialized as the Arc<OperationRegistry> itself, mirroring how install_bootstrap_discovery closes over its registry). The handler checks serving_registry.registration(name) before the overlay gate and rejects base collisions with ALREADY_EXISTS regardless of replace; overlay collisions keep the existing replace semantics. Announced ops may collide with announced ops, never with the serving side's own registrations.

ADR-022 amendment: the 2026-09-03 amendment's op/register section gained a "Collision policy (amended 2026-09-04, review 005 G-03)" paragraph recording the decided policy and the rationale (the PeerCompositeEnv connections-before-base resolution would let an unscreened announce rewrite composition resolution; visibility is irrelevant to the gate). The ADR's status line notes the sub-amendment.

Call sites updated: both e2e gates pass the fork the session dispatches over (Arc::clone(&accept_registry) after fork()), which is the production shape — the collision set is exactly the registry the serving loop dispatches against.

Gates:

  • handler_rejects_base_registry_collision_even_with_replace — base-registered fs/readFile (External) + replace: true → ALREADY_EXISTS; the overlay stays clean and the serving registration is untouched.
  • handler_rejects_collision_with_internal_serving_op — an Internal base op is equally protected (the review's point that forced Visibility::Internal on the announced spec never helped: OverlayOperationEnv gates on AccessControl, and the composed child is internal: true by design).
  • handler_overlay_collision_still_governed_by_replace — announce/ announce collisions still follow replace (the base gate is scoped to the serving registry, not widened into the overlay).
  • nested_composition_of_base_op_unaffected_by_unrelated_announce — after a successful distinct-name announce, composing the base op through the real compose_root_env shape (PeerCompositeEnv + attach_peer(conn.overlay_env())) resolves the serving side's own op, not a peer stub. (Unit-level, using the actual env types rather than a new full-wire fixture — the wire shape is already covered by the Unit-1 gates.)

Deliberate non-change: PeerCompositeEnv resolution order is untouched (connections before base) — the review's recommendation; reordering would change long-decided layering semantics (ADR-019/ADR-024) for no gain the registration-side gate doesn't deliver.

Verification (post-fix):

cargo test                                        → 587 passed, 0 failed
cargo test --all-features                         → 604 passed, 0 failed
cargo clippy --all-targets -- -D warnings         → clean
cargo clippy --all-features --all-targets -- -D warnings → clean
cargo fmt --check                                 → clean
cargo clippy --target wasm32-unknown-unknown -- -D warnings → clean
cargo doc --no-deps                               → clean

Unit 1 — Concurrent serving loop + stub-exercising gates (G-01, G-02) — LANDED

Fix shape. dispatch() was split into a synchronous start half and an awaited invocation:

  • Dispatcher::dispatch_start (src/protocol/dispatch.rs) runs the sync prefix (identity resolution, root context, op-type branch) and returns a StartedDispatch: Once (the invocation as a boxed future), Stream (the ResponseStream, returned synchronously — invoke_streaming is a sync call), or Sink (started inline, unchanged). dispatch() is now a thin wrapper (Once = await the boxed future) and keeps its shape for dispatch_requested/handle_stream/the gateway.
  • The Sink start stays inline by design: its chunk_tx must be in in_flight_sinks before the next call.published frame can be routed; the loop insert cannot race the feed. Pub handlers that block forever inside their sink future are a separate (pre-existing, unreported) shape — the read loop no longer waits on any handler except for the few instructions of the sink start itself.
  • Both single-stream loops' EVENT_REQUESTED arms spawn the Once invocation (spawn_once_dispatch), the Sub pump (spawn_stream_pump), and the sink's response writer (spawn_sink_response_writer); handles are tracked in a spawned list and aborted at loop exit (teardown: sink-map clear → spawn aborts → fail_all → sweeper abort). in_flight_sinks moved behind Arc<parking_lot::Mutex> because the ABORTED/PUBLISHED/ERROR arms must not hold the guard across the chunk_tx.send().await (non-Send guard across an await — the reason the pre-fix loop couldn't just be tokio::spawned piecemeal). Guard-dropping (let entry = ...remove() before the await) keeps lock discipline: no lock is held across an await anywhere in the loops.
  • run_loop_single_stream (the accept side) got the pending-resolution arms (EVENT_RESPONDED/COMPLETED/ERROR → the outbound pending map). Previously it served only — an accept-side nested-composing handler (e.g. a from_call imported-op stub riding the same connection) had no loop resolving its response frames at all. The G-01 accept-side latent hazard is mechanized shut, not just unblocked: both single-stream loops are now the same shape (dispatch-spawn + pending-resolution), the full-duplex loop serve_single_stream composes them and is unchanged in its arm semantics (frame-arm equivalence preserved per the non-findings audit).
  • Write-failure semantics changed from break (close the loop) to warn (keep reading) in the spawned Once path, matching the Sink arm: a dying transport surfaces on the next read as ConnectionClosed; a transient frame-write failure no longer tears down every in-flight request on the connection.

Gates (G-02, productized from the removed probe):

  • hub_handler_composes_peer_announced_op_via_nested_composition (src/channels/client.rs): announce → consumer calls hub/compose → the hub's serving loop wire-dispatches it → the handler resolves consumer/exec via ctx.env nested composition → the forwarding stub's nested call.requested crosses back to the consumer. This is the stub path the F-05 gate bypassed. Two wiring facts surfaced (both recorded as spec-consistent, both were silent before): (a) the hub's channel-0 Connection must carry the peer identity — compose_root_env attaches the connection overlay keyed by identity.id (ADR-030 §5), and a deployment that skips identity resolution silently gets no peer overlay (the test sets it via the AuthContext the adapter passes to install_channel_zero); (b) composition reachability is declared on the composing handler's registration (scoped_env: ScopedPeerEnv::new(["consumer/exec"])) — the empty ScopedPeerEnv is deny-by-default in PeerCompositeEnv::invoke_with_policy, so a wire-dispatched handler with no scoped_env composes nothing (the reachability gate, not the overlay, was what the probe's first draft tripped over).
  • outbound_call_resolves_while_inbound_subscription_is_being_served (the optional interleaved-directions phase on the F-04 shape): the consumer subscribes to the hub (Sub served by the consumer's loop), then calls hub/interleave over the wire — its handler issues an outbound hub→consumer call on the same connection while the Sub is live — and asserts the call resolves and the Sub is still live after. Both gates bounded at 5s (vs. the 30s sweeper), so a regression fails fast, not via sweeper eviction.

Load-bearing verification (both gates, empirical): each gate was run against the pre-fix loop (git stash push src/protocol/dispatch.rs) and reproduced the G-01 hang:

hub_handler_composes…:  panicked "nested composition through the
  forwarding stub timed out (G-01 shape): Elapsed(())"  — 5.01s, no progress
outbound_call_resolves…: panicked "interleaved outbound call starved
  while Sub served (G-01 shape): Elapsed(())"  — 5.06s, no progress

With the fix, both resolve (0.01s / 0.11s wall including connection setup). No sweeper evictions (bounded timeouts ≪ 30s).

Consequences audited against the fix:

  • Same-connection nested composition of peer-announced ops works for wire-dispatched parents (the flagship ADR-022 amendment flow) — the gate proves it end-to-end.
  • The accept-side imported-op composition hazard (G-01's second consequence, latent pre-f84d214) is mechanized shut by the pending-resolution arms in run_loop_single_stream — not merely unblocked. There is no dedicated gate for this shape yet (the accept-side import + nested-compose e2e would be a further gate; noted as residual work, not a regression).
  • Head-of-line blocking is gone: spawned arms proceed concurrently; the loop never awaits a handler.
  • Frame ordering: per-request ordering is preserved by the SharedFrameWriter (each write_frame is atomic under the mutex); responses for two requests may now interleave at frame granularity, which single-stream mode always permitted (both directions' frames are multiplexed by design; correlation is by id).

Verification (post-fix):

cargo test                                        → 583 passed, 0 failed
cargo test --all-features                         → 600 passed, 0 failed
cargo clippy --all-targets -- -D warnings         → clean
cargo clippy --all-features --all-targets -- -D warnings → clean
cargo fmt --check                                 → clean (fmt applied)
cargo clippy --target wasm32-unknown-unknown -- -D warnings → clean
cargo doc --no-deps                               → clean

Residual (not blocking Unit 1): the sink-start-inline shape means a Pub op whose registration itself blocks (ACL/schema compile are sync and fast; resolve_sink_handler runs the handler's ACL path) still holds the loop briefly — bounded by registry work, not handler work. A malicious AccessControl::check implementation could stall the loop; that is a deployment-provided trait object, the same trust boundary as IdentityProvider, and unchanged from the pre-existing shape.


Verification log (this pass)

  • All gates in Baseline verification reproduced at tree f84d214 (clean working tree; the probe was added, run, and removed — git status verified clean after removal).
  • G-01 verified by code trace (the four steps above, each line read at f84d214) and empirically: probe probe_hub_handler_composes_peer_announced_op (in src/channels/client.rs#[cfg(test)], since removed) — hub registry carries op/register + hub/compose (whose handler invokes the connection overlay's registration for the announced op, the shape PeerCompositeEnv → OverlayOperationEnv resolution produces); consumer announces consumer/exec then calls hub/compose. 8s-timeout run: Elapsed (no progress). 45s-cap run: resolved at 30.0007s with CallError::TIMEOUT (retryable) — the DEFAULT_CALL_TIMEOUT sweeper eviction, not live resolution.
  • The existing gates' blind spot verified by reading both e2e gates' call directions: serving_loop_hub_to_consumer_call_resolves (client.rs:1373-1399) and op_register_announce_then_hub_call_routes_back_to_consumer (client.rs:1529) — the latter's accept_conn.call("consumer/exec") crosses the wire to the consumer's local registry; the forwarding stub is unreachable from it.
  • G-03 verified by reading the collision gate (op_register.rs:142) against PeerCompositeEnv::invoke_with_policy's resolution order (env.rs:230-245) and compose_root_env's attach (dispatch.rs:207-214).
  • G-04 verified by field audit: OperationSpec's fields (spec.rs:175-206) vs spec_to_json_pub (discovery.rs:211-234) vs rebuild_spec_for (from_call.rs:209-286).
  • G-05 verified against discovery.rs:290-313 and ADR-022 amendment's set (022-…contract.md:380-393).
  • Frame-arm equivalence of serve_single_stream vs the two half-loops verified read-through (Non-findings).
  • alkhttp cross-repo claims verified in /workspace/@alkdev/alkhttp at 5b62307 (OQ-05 open-questions.md:102-112; ADR-048 decisions/048-…md:19-25).
  • CJK sweep: no CJK-range codepoints in any .rs/.md/.toml/ .json under the repo at f84d214.