- 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).
35 KiB
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 inPeerCompositeEnv).
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:
Dispatcher::serve_single_streamawaitsdispatch()inline in its read loop forcall.requestedframes (src/protocol/dispatch.rs:1009-1014) — the same serial-inline shape as the pre-existing accept-siderun_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.- The F-05 forwarding stub (
make_forwarding_handler,src/client/from_call.rs:392-415) issues its nested call viaconnection.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 thecall.respondedframe (connection.rs:274-303— register pending, write frame, await receiver; resolution is the reader's job). - 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.
- The 30s sweeper (
DEFAULT_CALL_TIMEOUT,connection.rs:32;evict_expired, run every 10s perSWEEPER_INTERVAL,dispatch.rs:44) is the only thing that breaks the cycle — the nested call resolves asCallError::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_callimports on the accept side:run_loop_single_streamhas 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 andregister_importedbefore this commit) —f84d214makes 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_streaminline), 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;registertakes the two maps' write locks sequentially and never across anawait.fork()deep-copies both maps (handlers areArcclosures — cheap, and the F-03 constraint that closures must carry is met); validators carry; fork independence is tested both directions;OperationRegistryBuilder::from_registryseeds the builder path. install_bootstrap_discovery(F-06) is correct — handlers closed over the sameArcthey'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 againstrun_loop_single_stream(dispatch.rs:765-905) anddispatch_envelope(connection.rs:717-741): responded/completed/ error resolve pendings; aborted tries both tables (in-flight sink aborts, then the pending cascade viahandle_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 atdispatch.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_connectiondelegates withNoneand 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_jsonshape), forcedInternal/FromCallon landing (unit-tested),replacesemantics tested,ALREADY_EXISTScollision gate tested (against the overlay — see G-03 for the gap).- The alkhttp cross-repo claims check out.
open-questions.mdOQ-05 and ADR-048's re-point are present in that repo at5b62307. - 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/.jsonin the repo returns zero matches atf84d214. - 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'sEVENT_REQUESTEDarm: spawn the Once dispatch and the Sub pump (the Sink arm's existing pattern, generalized;SharedFrameWriterserializes frames; track handles for teardown). Apply the same rework torun_loop_single_streamor 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_handlergains a serving-registry name-set (closure orArc<OperationRegistry>); base-registry name collisions reject withALREADY_EXISTSregardless ofreplace.- ADR-022 amendment: record the collision policy (peer-announced ops
may collide with peer-announced ops —
replacegoverns; 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_paththroughspec_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-registeredfs/readFile(External) +replace: true→ALREADY_EXISTS; the overlay stays clean and the serving registration is untouched.handler_rejects_collision_with_internal_serving_op— anInternalbase op is equally protected (the review's point that forcedVisibility::Internalon the announced spec never helped:OverlayOperationEnvgates onAccessControl, and the composed child isinternal: trueby design).handler_overlay_collision_still_governed_by_replace— announce/ announce collisions still followreplace(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 realcompose_root_envshape (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 aStartedDispatch:Once(the invocation as a boxed future),Stream(theResponseStream, returned synchronously —invoke_streamingis a sync call), orSink(started inline, unchanged).dispatch()is now a thin wrapper (Once= await the boxed future) and keeps its shape fordispatch_requested/handle_stream/the gateway.- The Sink start stays inline by design: its
chunk_txmust be inin_flight_sinksbefore the nextcall.publishedframe 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_REQUESTEDarms 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 aspawnedlist and aborted at loop exit (teardown: sink-map clear → spawn aborts →fail_all→ sweeper abort).in_flight_sinksmoved behindArc<parking_lot::Mutex>because the ABORTED/PUBLISHED/ERROR arms must not hold the guard across thechunk_tx.send().await(non-Sendguard across an await — the reason the pre-fix loop couldn't just betokio::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. afrom_callimported-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 loopserve_single_streamcomposes 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) towarn(keep reading) in the spawned Once path, matching the Sink arm: a dying transport surfaces on the next read asConnectionClosed; 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 callshub/compose→ the hub's serving loop wire-dispatches it → the handler resolvesconsumer/execviactx.envnested composition → the forwarding stub's nestedcall.requestedcrosses 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-0Connectionmust carry the peer identity —compose_root_envattaches the connection overlay keyed byidentity.id(ADR-030 §5), and a deployment that skips identity resolution silently gets no peer overlay (the test sets it via theAuthContextthe adapter passes toinstall_channel_zero); (b) composition reachability is declared on the composing handler's registration (scoped_env: ScopedPeerEnv::new(["consumer/exec"])) — the emptyScopedPeerEnvis deny-by-default inPeerCompositeEnv::invoke_with_policy, so a wire-dispatched handler with noscoped_envcomposes 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 callshub/interleaveover 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 inrun_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(eachwrite_frameis 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 statusverified clean after removal). - G-01 verified by code trace (the four steps above, each line
read at
f84d214) and empirically: probeprobe_hub_handler_composes_peer_announced_op(insrc/channels/client.rs#[cfg(test)], since removed) — hub registry carriesop/register+hub/compose(whose handler invokes the connection overlay's registration for the announced op, the shapePeerCompositeEnv→OverlayOperationEnvresolution produces); consumer announcesconsumer/execthen callshub/compose. 8s-timeout run:Elapsed(no progress). 45s-cap run: resolved at 30.0007s withCallError::TIMEOUT(retryable) — theDEFAULT_CALL_TIMEOUTsweeper 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) andop_register_announce_then_hub_call_routes_back_to_consumer(client.rs:1529) — the latter'saccept_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) againstPeerCompositeEnv::invoke_with_policy's resolution order (env.rs:230-245) andcompose_root_env's attach (dispatch.rs:207-214). - G-04 verified by field audit:
OperationSpec's fields (spec.rs:175-206) vsspec_to_json_pub(discovery.rs:211-234) vsrebuild_spec_for(from_call.rs:209-286). - G-05 verified against
discovery.rs:290-313and ADR-022 amendment's set (022-…contract.md:380-393). - Frame-arm equivalence of
serve_single_streamvs the two half-loops verified read-through (Non-findings). - alkhttp cross-repo claims verified in
/workspace/@alkdev/alkhttpat5b62307(OQ-05open-questions.md:102-112; ADR-048decisions/048-…md:19-25). - CJK sweep: no CJK-range codepoints in any
.rs/.md/.toml/.jsonunder the repo atf84d214.