Files
alkcall/docs/reviews/004-per-connection-dispatch-and-client-serving-review.md
T
glm-5.3-flash f84d214173 feat: per-session fork registry, connect-side serving loop, op/register (review 004 Units 1-3)
Remediates all six findings of review 004 (per-connection dispatch
resolution and client-side op serving). All claims re-verified in
source before remediation; F-02's member list gains ScopedPeerEnv
(also Clone — fork surface simpler than estimated).

- OperationRegistry: interior mutability (parking_lot RwLock on both
  maps); register takes &self; registration/list_operations return
  owned clones; fork() deep-copies registrations + cached publish-schema
  validators (F-02/F-03); OperationRegistryBuilder::from_registry.
- install_bootstrap_discovery: services/list, services/list-peers,
  services/schema registered closed over the fork itself, so
  per-session openables are discoverable and services/schema answers
  from the fork (F-06).
- Dispatcher::serve_single_stream: full-duplex single-stream loop —
  call.requested dispatches inbound; responded/completed/error resolve
  outbound pendings; aborted tries both tables (in-flight sink aborts
  + pending cascade); published routes inbound sinks (F-04).
- ChannelClient::from_connection_with_serving(connection,
  Option<ServingConfig>): opt-in serving; from_connection keeps the
  pure-consumer default.
- registry::op_register: OpRegisterRequest wire DTO (spec in
  services/schema JSON + replace flag), op_register_spec,
  op_register_handler (rebuild -> forwarding stub -> register_imported,
  forced Internal/FromCall), announce_op; CallError::already_exists;
  spec_to_json_pub; from_call's rebuild_spec_for + forwarding-handler
  constructors crate-shared (F-05).
- ADR-047 §4 amendment #2: per-session fork is the dispatch-registry
  mechanism; overlay stays nested-invocation/peer-announced landing
  zone (F-01/F-02).
- ADR-022 amendment 2026-09-03: bootstrap-op set (services/list,
  services/schema, op/register), opt-in connect-side serving, op/register
  wire shape (F-04/F-05).
- alkhttp ADR-048 reconciliation note + OQ-05 re-pointed at the alkcall
  ADRs (Unit 1b).
- Review 004 status -> remediated; remediation log with gates.

Verification:
- cargo test: 581 passed, 0 failed (565 baseline + 16 new)
- 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 clippy --target wasm32-unknown-unknown -- -D warnings: clean
- cargo fmt --check: clean
- cargo doc --no-deps: clean

Gates: fork_registry_open_op_resolves_and_is_discoverable (open op via
fork + services/list shows openable + services/schema validates),
serving_loop_hub_to_consumer_call_resolves (hub->consumer call through
consumer's serving loop, consumer->hub still resolves),
op_register_announce_then_hub_call_routes_back_to_consumer (announce ->
overlay -> hub call -> forwarding stub -> consumer serves).
2026-09-03 17:32:45 +00:00

26 KiB
Raw Blame History

Review 004 — Per-Connection Dispatch Resolution and Client-Side Op Serving (found via alkhttp's WS data-channel drill-down)

Status

Verified, remediated (2026-09-03 — Units 1–3; Unit 4 remains downstream in alkhttp). See "Remediation log (2026-09-03)" at the bottom for the landing summary and the gates.

Scope

Focused review of two design/spec mismatches in the call + channels integration, uncovered while drilling into the deferred half of alkhttp's WebSocket path (alkhttp review 003, findings WS-24/WS-25 — the OQ-05 "browser data channels" deferral). The drill-down established that the alkhttp-side gap is mostly wiring, but two assumptions underneath it resolve to this crate:

  1. Dispatch resolution for per-connection operations. ADR-047's §4 amendment (2026-08-13) decided that openable-ALPN open ops are registered per-connection on the connection overlay registry (Layer 2). The top-level dispatch path never consults that layer, so an op registered per the amendment's mechanism cannot be invoked over the wire. The only shape proven to dispatch is different from the one the ADR describes.
  2. Client-side operation serving. The connection-local overlay (CallConnection::register_imported → overlay_env()) and the session overlay (compose_root_env attaching peer-keyed overlays) are fully built and tested — but only the accept side has a loop that serves inbound call.requested frames. The connect side's read pump resolves responses and silently drops requests. There is also no wire mechanism by which a client could announce an op in the first place. Together these leave the "both sides can be both" symmetry (AGENTS.md §8; ADR-022 §direction semantics; alkhttp ADR-048's browser bidirectionality) unreachable on the single-stream channel-0 shape.

The pass re-verified every claim directly in source at tree c16b069. Cross-repo context: these findings block alkhttp review 003's Unit 2 (data-channel wiring for WS sessions, both browser and native-fallback consumers); the decision work and the fixes live here. Findings continue alkcall review numbering (F-01..; reviews 001–003 used P/C/R/A/B/C/D prefixes — F is the new prefix for this focused review, findings numbered independently).

Baseline verification (this pass)

cargo test                                    → 565 passed, 0 failed
cargo clippy --all-targets -- -D warnings     → clean
cargo fmt --check                             → clean

The suite is green; these findings are design/spec mismatches, not regressions. Notably, one of them (F-04's serving gap) is invisible to the suite because the connect side has never been asked to serve — the existing e2e tests all put the dispatcher on the accept side.

Verdict

  • The channel machinery is done and proven. ChannelCore / register_openable / ChannelOperations, the opener ledger, the odd/even id split, the demux hardening, and the full e2e wiring test (channels/client.rs:767-870) are all in place and tested. Nothing in Part A below re-opens that machinery.
  • The gap is the dispatch-resolution mechanism (F-01/F-02/F-03) and the connect-side serving half (F-04) — both upstream of any transport (TCP+TLS, WS, whatever). Fixing them here unblocks alkhttp's data-channel wiring, and — since the crate is wasm-targetable and not yet published to consumers — the fixes are cheap now and required regardless of alkhttp.
  • The recommended resolution is assembly, not invention: per- session registry forks (F-02/F-03) + a bootstrap op/register op (F-01's mechanism choice) + a connect-side serving loop (F-04). Three of the four pieces have working reference shapes in-tree; the genuinely new protocol surface is one op handler and one read loop.

Severity legend

  • [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 — Dispatch resolution vs the ADR-047 §4 amendment

F-01 [major] — Top-level dispatch never consults the connection overlay; ops registered per the ADR-047 §4 amendment mechanism resolve NOT_FOUND on the wire

ADR drift: ADR-047 "Amendment (§4 per-connection registration, 2026-08-13)": "open ops are registered per-connection … register_openable is called on the connection overlay registry (Layer 2 per ADR-019), closing over the per-connection ChannelCore." Verified: YES, three ways:

  1. Dispatcher::dispatch reads the operation's op_type from self.registry.registration(...) only (src/protocol/dispatch.rs:316-320) and invokes via self.registry.invoke / invoke_streaming / resolve_sink_handler (:330-357) — the dispatcher's base registry only. There is no fallback to connection.overlay_env().
  2. The connection overlay is reachable solely as a layer of context.env: compose_root_env attaches it via env.attach_peer(peer_id, connection.overlay_env()) (dispatch.rs:195-215). context.env is consulted for nested invocations (a handler calling context.env.invoke(...)) — not for resolving the incoming call.requested itself. An open op registered on the overlay resolves NOT_FOUND when the wire request arrives.
  3. The one place this wiring is proven end-to-end uses a different shape than the amendment describes: alkcall's own e2e test (src/channels/client.rs:767-870) has the install_channel_zero hook build a fresh per-connection registry containing the open op and pass it as the dispatcher's base registry (Dispatcher::new(registry, ...) at :837) — not as an overlay. That shape works; the amendment's shape cannot.

Consequence: ADR-047's data-plane promise (per-connection open ops invocable on channel 0) is unreachable through the mechanism the amendment names. Every consumer of register_openable must either adopt the undocumented per-session-base-registry shape or fail.

Fix (Unit 1): decide the mechanism deliberately (see F-02) and amend ADR-047 §4's wording to describe the mechanism that actually dispatches. The amendment's rationale (per-connection state, no context.env downcast) stands under either option.

F-02 [major] — OperationRegistry has no fork/clone surface; the proven per-session-registry shape requires one

Verified: YES. OperationRegistry (src/registry/registration.rs:97-100) is HashMap<String, HandlerRegistration> + a validators map, with new/register/registration/publish_validator/list_operations (:102-180) — no Clone impl and no fork API. Every type inside a HandlerRegistration is clone-able (verified):

  • HandlerKind — #[derive(Clone)] (registration.rs:51)
  • OperationSpec — #[derive(Debug, Clone, PartialEq)] (spec.rs:174-175)
  • OperationProvenance — #[derive(Debug, Clone, Copy, PartialEq, Eq)] (registration.rs:58-59)
  • CompositionAuthority — #[derive(Debug, Clone)] (context.rs:82-83)
  • Capabilities — manual impl Clone (core/types.rs:75-80)
  • jsonschema::Validator — #[derive(Clone, Debug)] (jsonschema 0.46.10 validator.rs:294)

So the fork is a small addition (derive Clone on HandlerRegistration + OperationRegistry, or an explicit fork() -> OperationRegistry that copies both maps). Two shape options:

  • (a) per-session base registry (recommended). The install_channel_zero hook (and any future session-establishment seam) forks the deployment's base registry, registers the per-connection operations on the fork (generic channel ops, the openable ALPNs, and — with F-05 — the bootstrap op), and dispatches channel 0 over the fork. This is the proven shape; the ADR wording moves from "overlay registry" to "per-connection registry installed as the session's dispatch registry." Cost: services/list / services/schema registered on the base registry must be re-registered (or inherited via fork) per session to stay discoverable — the fork makes that free if the bootstrap ops are part of the fork source.
  • (b) overlay-aware top-level dispatch. Add an overlay fallback in dispatch / run_loop_single_stream: when the base registry misses, consult connection.overlay_env(). Keeps one static registry and makes the amendment's wording true as written, but touches the shared dispatch loop (higher blast radius, and the overlay's invoke_with_policy shape — namespace-scoped, parent- context-driven — does not match the top-level call shape, so the fallback needs care: op-type lookup, ACL, visibility).

Recommendation: (a). It is the only shape with an end-to-end proof, it needs no dispatch-loop change, and it matches ADR-019's layering (the overlay stays what it is today: the nested-invocation landing zone for imported ops — which F-05 needs).

Fix (Unit 2): Clone/fork surface + the ADR wording amendment; acceptance = the review-001-style e2e gate (open op resolves over a live channels connection through the fork).

F-03 [minor] — Per-session fork + static base: no Default/builder seam exists to compose "base ops + per-session ops" without re-registration

Verified: YES. Even with a Clone surface (F-02), the hook must compose three sources into the per-session registry: the deployment's base ops (already registered, not retained anywhere as a "source" list), the generic channel ops (ChannelOperations::register_on), and the openables. A fork of the base registry makes the first source free — this finding exists only if (a) is chosen without the fork (i.e., rebuilding per-session registries from scratch). Record it as a constraint on the fork API: it must clone registered handlers (HandlerKind clones carry their closures), not just specs, so services/list keeps its closure over the base registry. publish_validator's validators map must be carried too (schema enforcement would silently vanish otherwise).

Fix: fold into F-02's fork surface; the acceptance test covers it (services/schema on the fork still validates input).


Part B — The connect side cannot serve; no wire path announces ops

F-04 [major] — The connect side's channel-0 read pump resolves responses only; inbound call.requested frames are silently dropped — there is no serving half on channel 0

Verified: YES. ChannelClient::from_connection (src/channels/client.rs:85-134) spawns the read pump as read_single_stream_until_closed(single_stream_reader, &pending_map) (connection.rs:681-686), whose only job is resolving the PendingRequestMap — its frame handler (dispatch_envelope, connection.rs:705-...) branches on call.responded / call.completed / call.aborted / call.error and has no arm for call.requested. The accept side's mirror (Dispatcher::run_loop_single_stream, dispatch.rs:727-850) serves requests and has no pending-resolution arm. Each side silently drops the other half's frames.

Consequences:

  • A consumer that dials a producer via ChannelClient can call ops and open channels, but if the producer later calls an op the consumer serves, the request is dropped (the caller's pending hangs until the sweeper or connection close). This is the exact shape ADR-022 §connection-direction-independence promises ("both sides can be both simultaneously" — AGENTS.md §8), and ADR-036's channel-0 pre-negotiation makes channel 0 the single control plane where it must work.
  • It blocks the bootstrap registration story (F-05): the hub cannot call anything the WS/native client serves, and the client cannot even receive the call that would let it register.

The fix is well-understood and symmetric: a full-duplex single-stream read loop that branches both ways — call.requested → dispatch and write a response frame (the run_loop_single_stream arms), call.responded/completed/aborted/published → resolve pendings and route sink chunks (the read-pump arms). IDs are UUID-generated on each side (generate_request_id()), so cross-correlation is not a hazard; the abort arm needs to try both tables (in-flight sink aborts and the pending map's cascade), which run_loop_single_stream's EVENT_ABORTED arm already does within its own scope. The wasm-targetable browser story compounds the value: the same serving loop is what a wasm alkcall browser compiles.

Fix (Unit 3): a serving-capable single-stream loop usable from the connect side (e.g., a Dispatcher variant or a CallConnection::serve_single_stream that composes both frame directions), wired into ChannelClient behind an opt-in (a serving registry is not always present — a pure consumer may have none). Acceptance gates: hub→consumer call over an existing ChannelClient session resolves; consumer-served op participates in ACL (its AccessControl gates the hub's call); disconnect mid-call still fails pendings on both sides.

F-05 [major] — No wire mechanism announces client-side ops; ADR-022's import flow is hub→consumer only and the browser-native path has none

Verified: YES. The envelope kind set is closed at six — call.requested / call.responded / call.completed / call.aborted / call.error / call.published (src/protocol/wire.rs:12-17). Op registration onto an overlay (register_imported / register_imported_all, connection.rs:162-172) happens only in-process: from_call (client/from_call.rs:82-91) builds bundles the local process registers after it dialed out — the consumer importing a hub's ops. There is no op, frame, or handshake by which a connected peer announces "here are the ops I serve." Every consumer-facing registration surface today requires the registrant to hold the local CallConnection handle in-process.

Consequence: the decided bidirectionality model — either side registers ops the other can call (AGENTS.md §8; ADR-022 §direction semantics; alkhttp ADR-048's connection-local overlay for browser-registered ops) — has no on-the-wire expression for the non-in-process side. It is exactly the assumed-bootstrap-op set the protocol already leans on (services/list is dialed and called by from_call on every import; each side is expected to serve it) extended by one op: e.g. op/register carrying the HandlerRegistration's serializable parts (spec + provenance + access control), whose hub-side per-session handler writes the registration into that connection's overlay via register_imported. The overlay is already the landing zone (compose_root_env attaches it keyed by identity, dispatch.rs:211-213), and discovery of what a peer registered is already built: services/list-peers reads ctx.env.peer_ids() / peer_operations() (registry/discovery.rs:260-307) — the same overlay-populated env.

Scope decision this forces (record it in the ADR, either way):

  • Design it (recommended): op/register (+ a deregister or replace semantics for the reconnect path) as an assumed op in the bootstrap set, served per-session; the wire stays six kinds. The HandlerRegistration's Handler closures cannot cross the wire — the client sends spec + a call-forwarding contract, and the hub-side handler wraps it as a forwarding handler that issues a nested call.requested back over channel 0 (the same shape from_call's imported bundles use — protocol/adapter.rs:541,610 show the in-process twin).
  • Or scope-cut: amend ADR-022/alkhttp ADR-048 to say peer-side op registration is hub-initiated-import only until a contract exists. Cheaper, but it re-opens the same hedge the deferral was criticized for — the promise would remain written-but-unmechanized.

Recommendation: design it. The pieces are all present; the cost is one op handler, an AccessControl-gated registration surface (the registry's existing ACL path covers it — an op with restrictive AccessControl cannot be overwritten by an unprivileged peer), and the F-04 serving loop to make it reachable in both directions.

Fix (Unit 3): with F-04 — bootstrap op + client serving loop + ADR-022 amendment naming the bootstrap op set (assumed ops each side may serve: services/list, services/schema, and op/register).

F-06 [minor] — services/list on a per-session fork needs a closed-over fork reference, or per-session ops are undiscoverable

Verified: YES. services_list_handler closes over a specific Arc<OperationRegistry> (registry/discovery.rs:233-258) — it lists that registry's ops. Under the F-02(a) shape, per-session openables live on the fork, so a services/list handler closed over the base registry cannot see them. Two clean outs, both cheap: (i) fork before registering the bootstrap discovery ops (then the handler closes over the fork), or (ii) the fork API registers base-registry bootstrap ops fresh on the fork. Either way the constraint is: discovery ops must be registered against the per-session registry, not the static one. Also note services/list on the fork still ACL-filters (the handler re-checks per-caller) — no privilege regression.

Fix: fold into Unit 2 (the fork seam registers bootstrap ops on the fork); acceptance: a per-session openable appears in services/list for an authorized caller and not for an unauthorized one.


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

  • The e2e reference shape is real and passes — channels/client.rs:767-870 wires ChannelClient ↔ ChannelsAdapter over duplex with an open op registered on the per-connection registry, invoked end-to-end, quota reserved. The F-02(a) shape is not speculative; it is in-tree.
  • register_openable's wrapper machinery is complete — ACL via the registry's normal invoke path, check_open → open_channel → ledger → spawn → teardown decrement, with Once/Stream/Sink arms (channels/operations.rs:399-453, 483-555). Nothing in F-01 re-opens it; the ops it registers just need a dispatch-reachable home.
  • Channel-id allocation is collision-safe for the bidirectional open story (accept side even from 2, connect side odd from 1, manager.rs:105-125; adopt_channel for the non-allocating side, manager.rs:273-309).
  • The call.* envelope kind set staying closed is correct — AGENTS.md §7 allows adding kinds but F-05's resolution does not need one; bootstrap ops over channel 0 are the lighter door.
  • Wasm-cleanliness is unaffected — the recommended fixes (fork + handler + read-loop branch) touch no transport, no fs/net tokio features; cargo check --target wasm32-unknown-unknown remains the release gate.

Remediation plan

Sequenced by dependency. Units 1–3 are alkcall work; the alkhttp consequences are tracked as alkhttp review 003 Unit 1 (updated to point here).

Unit 1 — Decision recording (F-01, F-02 direction)

ADR work only:

  • ADR-047 §4 amendment #2: the open-op registration mechanism is the per-connection registry installed as the session's dispatch registry (option (a)); the overlay registry remains the landing zone for peer-announced ops (F-05) and nested invocation.
  • ADR-022 amendment: the bootstrap-op set (each side may serve services/list, services/schema, op/register), and the direction-semantics note that serving on the connect side is opt-in (F-04).
  • alkhttp ADR-048's bidirectionality promise is re-pointed at these ADRs instead of standing as an unmechanized commitment.

Gate: ADRs recorded; alkhttp OQ-05 and ADR-048 cross-reference them.

Unit 2 — Fork surface + per-session composition (F-02, F-03, F-06)

  • Clone (or explicit fork) on HandlerRegistration + OperationRegistry, carrying handlers and validators.
  • A composition helper (e.g. OperationRegistry::fork_with(...)) or documented pattern: fork base → register generic channel ops + openables + bootstrap ops against the fork.
  • Gate: e2e over a live channels connection — open op resolves through the fork; services/list shows per-session openables (ACL-filtered); services/schema on the fork still validates.

Unit 3 — Client serving half + bootstrap registration (F-04, F-05)

  • Serving-capable single-stream loop (compose the read-pump and dispatch arms); wired into ChannelClient opt-in.
  • op/register bootstrap op (per-session handler → register_imported into the connection overlay), with AccessControl gating and reconnect/replace semantics decided in the ADR.
  • Gates: hub→consumer call over an existing session resolves; peer-registered op is discoverable via services/list-peers and callable back over channel 0; unregistered/unauthorized registration attempts fail loudly; disconnect drops the overlay and fails both sides' pendings.

Unit 4 — alkhttp wiring (downstream, after Units 1–3)

Tracked in alkhttp review 003 (Units 2–4 there): openables surface, install_channel_zero rework over the fork, retained session handle, e2e tests, spec reconciliation (OQ-05, ADR-067/048 v1-cut notes).


Verification log (this pass)

  • All claims verified at tree c16b069; the F-01 dispatch claim was checked against both dispatch paths (run_loop stream-per-request reads handle_stream → same base-registry-only resolution; run_loop_single_stream at dispatch.rs:727-850).
  • The F-04 claim was verified by reading both loops' frame arms: read_single_stream_until_closed → dispatch_envelope (connection.rs:681-705, no EVENT_REQUESTED arm) vs run_loop_single_stream (dispatch.rs:754-850, no pending-resolution arm).
  • The F-02 clone claims were verified type-by-type, including the third-party jsonschema::Validator (0.46.10) and the vendored Capabilities (manual Clone preserving Secret's clone).
  • The F-05 wire-closure claim was verified against the full kind set (wire.rs:12-17) and grep over src/ for any wire-side registration path (none; register_imported* callers are in-process only).
  • services/list-peers' overlay-backed discovery verified at registry/discovery.rs:260-307 (reads ctx.env.peer_ids() / peer_operations(), populated by compose_root_env at dispatch.rs:211-213).
  • Baseline gates re-run for this pass: cargo test (565), clippy (all-targets), fmt — all clean. No source changes.

Remediation log (2026-09-03)

Units 1–3 landed in one pass. Every claim above was re-verified against the source before remediation began; all six findings confirmed (F-02's member-type list was missing ScopedPeerEnv (context.rs:53) — also Clone, which made the fork surface simpler than the review estimated).

Unit 1 — decision recording (F-01/F-02 direction, F-04/F-05 scope):

  • ADR-047 §4 amendment #2 (2026-09-03): the per-connection registration mechanism is the per-session fork of the base registry installed as the session's dispatch registry (option (a)); the overlay registry stays the nested-invocation / peer-announced-ops landing zone.
  • ADR-022 amendment (2026-09-03): the bootstrap-op set (services/list, services/schema, op/register), the opt-in connect-side serving loop, and the op/register wire shape (spec in services/schema JSON + replace flag; forwarding-handler wrap into the connection overlay; ALREADY_EXISTS collision gate).
  • alkhttp ADR-048's reconciliation note and OQ-05 re-pointed at these ADRs (Unit 1b).

Unit 2 — fork surface + per-session composition (F-02, F-03, F-06):

  • OperationRegistry gained interior mutability (parking_lot::RwLock around the operations and validators maps) so a registry can live behind an Arc and be self-referential. register now takes &self; registration/list_operations return owned clones (handlers are Arc closures — the clone is cheap).
  • OperationRegistry::fork() — deep copy of registrations (handlers, provenance, composition authority, capabilities, scoped_env) and the cached publish-schema validators. OperationRegistryBuilder:: from_registry seeds the builder path from an existing registry.
  • install_bootstrap_discovery(&Arc<OperationRegistry>) — registers services/list / services/list-peers / services/schema closed over the fork itself, so per-session openables are discoverable (F-06) and services/schema answers from the fork.
  • Gate: fork_registry_open_op_resolves_and_is_discoverable (src/channels/client.rs) — open op registered on the fork resolves over a live channels connection; the openable appears in services/list on the fork; services/schema on the fork still answers. Unit tests: fork independence both directions, validator carry, post-dispatch registration through a shared Arc.

Unit 3 — client serving half + bootstrap registration (F-04, F-05):

  • Dispatcher::serve_single_stream — the full-duplex single-stream loop: call.requested → dispatch + response frames (the run_loop_single_stream arms); call.responded/completed/error → pending resolution (the read-pump arms); call.aborted → both tables (in-flight sink aborts and the pending map's cascade); call.published → inbound in-flight sinks. Disconnect fails pendings and drops in-flight sinks (teardown identical to the two half-loops it composes).
  • ChannelClient::from_connection_with_serving(connection, Option<ServingConfig>) — opt-in serving; from_connection preserves the pure-consumer default (resolution-only read pump). Gate: serving_loop_hub_to_consumer_call_resolves — hub→consumer call resolves through the consumer's serving loop, consumer→hub still resolves in the same loop.
  • registry::op_register — OpRegisterRequest wire DTO, op_register_spec, op_register_handler (rebuild → forwarding stub → register_imported into the connection overlay, forced Internal/FromCall), announce_op. CallError::already_exists added for the collision gate. spec_to_json_pub made the services/schema wire shape public; rebuild_spec_for and the forwarding-handler constructors are crate-shared with from_call. Gates: op_register_announce_then_hub_call_routes_back_to_consumer (announce → overlay → hub call → forwarding stub → consumer serves) plus unit tests (round-trip, collision, replace, forced visibility/provenance).

Baseline after remediation: cargo test 581 passed / 0 failed; clippy (all-targets, -D warnings) clean; cargo fmt --check clean; wasm target gate re-run below. No wire-format changes: the envelope kind set stays closed at six; the bootstrap ops ride channel 0's call registry.