35 Commits
Author SHA1 Message Date
glm-5.3-flash 3bda29c419 chore(release): 0.8.1 — duplicate adopt/open fix + fuzz harness bookkeeping
- version 0.8.0 -> 0.8.1 (bug-fix release; semver-checks 196 pass vs 0.8.0)
- CHANGELOG 0.8.1: the manager_routing-found duplicate adopt/open fix,
  the fuzz harness, and the verification summary
- publish exclude gains docs/research/ (internal research notes;
  fuzz/ was already excluded — package verified clean of both)
- fuzzing.md §7.9: standing no-hosted-CI policy — corpus replay is the
  release-verification fuzz gate; campaigns manual via the detached
  runner; OSS-Fuzz out; no workflow files in this repo
- AGENTS.md: corpus replay added to the verification checklist
- lockfiles: alkcall 0.8.1 (root + fuzz)

Verification: 684 tests; clippy -D warnings (main, wasm, fuzz/shared);
fmt clean; doc clean; wasm32-unknown-unknown check+clippy clean;
semver-checks 196 pass; publish --dry-run 116 files (no fuzz/research/
reviews/sdd/AGENTS in the tarball); fuzz corpus replay 5/5 green
2026-09-28 06:56:11 +00:00
glm-5.3-flash 77f7006e2e docs(research): fuzzing steps 1-3 complete; 10-min campaigns clean on all five targets
\u00a77.8 campaign results: manager_routing 1.06M execs (exact-counter and
parked-bytes invariants held across every adversarial sequence),
envelope_semantic 2.44M execs (coverage saturated, all event kinds
round-trip), spec_parse 10.8M execs (registry compiled every
attacker-shaped schema or rejected cleanly). All exited 0, zero
artifacts.

Also records the two harness-model defects the fuzzer caught in the
interim (per-receiver EOF-sentinel latch; odd/even Adopt range per
ADR-047 \u00a75). Doc-only + harness fixes; no crate changes.
2026-09-27 23:52:45 +00:00
glm-5.3-flash a1f257757b fix(channels): duplicate adopt/open must not destroy the live channel; fuzz targets 3-5
Found by the manager_routing fuzz target (docs/research/fuzzing.md \u00a77.8):

- ChannelManager::open_channel / adopt_channel used
  HashMap::insert(...).is_some() as a collision check \u2014 insert REPLACES
  the existing entry, so a duplicate adopt/open installed the new state,
  dropped the live channel's demux_sender (spurious EOF to its readers,
  subsequently routed chunks lost) and still returned
  Err(ChannelExists). Fixed with contains_key pre-check; map untouched
  on collision. Regression tests: adopt_channel_duplicate_id_leaves_
  live_channel_intact, open_channel_duplicate_id_leaves_live_channel_
  intact.

New fuzz targets (\u00a77.4 step 3):
- manager_routing: Arbitrary op sequences over ChannelManager; exact
  counter models (parked/dropped must equal the manager's monotonic
  counters), parked-bytes bound per \u00a76.2-1, clear_all ledger-vs-map
  semantics, drainer-byte reconciliation (lossless routing)
- envelope_semantic: constructors -> serde -> write_frame/read_frame
  structural round-trip; event-type constants; call.error parse-back
- spec_parse: OpRegisterRequest::from_json -> rebuild -> registry
  registration (attacker schemas compile at register, CF-003)
- fuzz/shared/src/arbitrary_value.rs: bounded Arbitrary for
  serde_json::Value; 20 spec_parse + 4 manager_routing seeds
- corpus replay for the new targets in fuzz/shared tests

Verification: cargo test 684 passed (682 + 2 regression); clippy
-D warnings clean (main + fuzz/shared); fmt clean (main + fuzz);
cargo fuzz build clean; 20 s smoke on all three new targets clean
(manager_routing 79k, envelope_semantic 141k, spec_parse 517k runs);
crash input replays clean post-fix
2026-09-27 23:19:18 +00:00
glm-5.3-flash c211c163fd docs(research): fuzzing step 1 complete — campaigns ran clean (fuzzing.md \u00a77.7)
chunk_header: saturated (203.9M execs, ~350k exec/s, 33 fork jobs clean);
envelope_frame: 2119 edges/1664 corpus entries, still growing at budget
end, 7.08M execs, 33 fork jobs clean. No crash/OOM/timeout artifacts in
either 10-min detached campaign; exit 0 both. Grown corpus not merged
(per \u00a77.1 corpus policy). Doc-only change.
2026-09-27 21:27:58 +00:00
glm-5.3-flash 80a37e94ab feat(fuzz): cargo-fuzz workspace, chunk_header + envelope_frame targets (fuzzing.md step 1)
- fuzz/ workspace (nightly-pinned via rust-toolchain.toml, excluded from
  the main workspace and the published package): chunk_header and
  envelope_frame targets per fuzzing.md \u00a77.1
- invariant logic in fuzz/shared (stable-toolchain crate): committed
  corpus replay as plain cargo test (quinn CI pattern, \u00a77.2 tier 3)
- envelope target adds FrameError shape-partition asserts, exact
  consumption accounting, structural write_frame round-trip, serde
  key-contract, and a trailing-byte probe (prefix counts body only)
- chunk_header target adds round-trip identity, TooLarge/short error
  shape, is_eof, 8-byte consumption, input-never-mutated
- committed seed corpora: 245 deterministic seeds via
  fuzz/gen_fuzz_seeds.py (truncations, len=0/MAX+1/u32::MAX, channel 0,
  invalid UTF-8, deep nesting); grown corpora + artifacts gitignored
- fuzz/run-detached.sh: \u00a77.6 detached runner (setsid+nohup+log, fork
  mode, rss/malloc limits) \u2014 campaigns never share fate with a session
- fuzz/json.dict; README; research doc \u00a77.7 records step-1 status

Verification: cargo fuzz build clean; stable side green (cargo test 682
passed, clippy -D warnings, fmt --check incl. fuzz/shared); corpus
replay 245 seeds green; detached 10-min campaigns on both targets
completed with zero crashes/OOMs/timeouts
2026-09-27 21:14:35 +00:00
glm-5.3-flash 7ae0315f19 docs(research): fuzzing must not share fate with agent sessions (detached runner)
- OOM in a fuzz target must cost the fuzzer, never the opencode host
- three defense layers: soft rss/malloc limits, fork-mode blast radius,
  setsid+nohup detachment with log-file polling (fuzz/run-detached.sh)
- no ulimit -v with ASAN; corpus replay + CI flag parity notes
- sequencing and summary updated to make the detached runner mandatory
2026-09-27 20:13:35 +00:00
glm-5.3-flash 791eea7298 docs(research): fuzzing evaluation — adopt cargo-fuzz, five targets, two-tier CI
- web survey of 2025/2026 Rust fuzzing tooling (cargo-fuzz/afl/bolero/libafl)
- practice survey: rustls, quinn, quiche, h2, prost, s2n-quic, yamux/libp2p
- parse-surface inventory of this crate (both wire formats, dispatch, manager)
- pre-fuzz code-review findings: unbounded early-arrival key-space
  (manager.rs early_arrivals), unbounded spawned-JoinHandle growth
  (dispatch.rs), 64 MiB pre-payload allocation window, O(n^2) abort cascade
- recommendation: cargo-fuzz + arbitrary, 5 targets (chunk_header,
  envelope_frame, manager_routing, envelope_semantic, spec_parse),
  committed seeds only, 10s/target smoke CI, OSS-Fuzz after stabilization
2026-09-27 20:01:29 +00:00
glm-5.3-flash 7bb76cd2b5 docs: fold [Unreleased] into [0.8.0] for publish
The post-landing-audit fixes (relay plan Drop guard, empty-ALPN
guards, reserved-reply-key message/log corrections) and the review
009 coverage work were recorded under [Unreleased] after the 0.8.0
section was written, but 0.8.0 has not shipped — folded both into
the release entry with proper Added/Changed/Fixed/Testing/Verified
ordering. Verified test count corrected 669 → 682 (the audit and
coverage commits added 13 tests after that count was recorded).
Bottom link refs gained the missing [0.8.0] and [0.7.1] entries.

Verification: cargo test 682 passed; clippy --all-targets -D
warnings clean; fmt --check clean; doc --no-deps clean; semver-checks
(no update required, 0.7.1 baseline); wasm32 check + clippy clean;
test --all-features 699 passed; publish --dry-run packaged and
verified clean.
2026-09-18 13:23:56 +00:00
glm-5.3-flash 9620ee7b2a test(review 009): all seven coverage findings; errata on three as-filed claims
Review 009's coverage debt in full — the paths the review-008 gates
never walk. No wire or API changes.

- C-1: pin the plain-bundle install-failure arm (un-compilable
  input_schema; the as-filed duplicate-name route does not fail
  registration) — the install task ends before the dispatch loop,
  channel 0 never dispatches. ADR-051 §5 gains the loud-install
  coverage note: relay-openable + plain-bundle arms pinned,
  generic-ops + bootstrap-discovery arms documented as
  best-effort-loud (crate-internal specs compile by construction).
- C-2: the HubLegImports filter — filtered closure path + only
  partition unit-tested (errata: only was already pinned at filing);
  the empty-stash e2e gate (generic ops + discovery only, dropped
  ops resolve NOT_FOUND).
- C-3: the batch-form reserved-reply-key rejection pinned (reason
  handler_error, teardown, ledger decrement, no pump spawn).
- C-4: open_channel_with_reply's failure path pinned e2e (the typed
  error carries the channel:open_failed code + details reason/message).
- C-5: both byte-identical claims golden-pinned — the no-fields reply
  against {"channel_id": 2} and the standard-shape wire payload
  against the full 9-key literal.
- C-6: derivation edge shapes pinned — channels//sub, channels//direct,
  channels → None; the 4-segment strict superset annotated as the
  pre-amendment behavior change (errata: actual is Some("x/sub"), the
  multi-segment-ALPN rule, not the as-filed Some("alk/x/sub")).
- C-7: builder overwrite pinned last-win (single + batch) with the
  doc sentence on with_reply_field.

Verification: 682 tests pass, clippy -D warnings clean, fmt clean,
doc clean, wasm32 check clean.

File: docs/reviews/009 (resolved; errata marked per finding)
2026-09-18 08:02:13 +00:00
glm-5.3-flash 54c2a3f941 fix(review-008 audit): adopted-entry drop guard; explicit-ALPN guards; review 009
Post-landing audit of the 0.7.1 -> 0.8.0 remediation diff: two
hardening guards, one rejection-posture fix, two log/message
corrections, and the deferred coverage debt filed as review 009.

- RelayPlan owns the producer-leg ChannelManager and reclaims the
  adopted spoke channel_id via a Drop guard (replaces the pump
  handler's post-pump_bidi explicit reclaim). Closes the leak
  windows the pump's normal path cannot reach: the wrapper's
  establishment bound expiring after the adopt, and the pump
  handler's early-return arms (plan absent, downcast failure,
  try_unwrap failure, accept_bi failure). The send-half drop still
  EOFs the spoke leg via the mux pump's implicit-EOF sentinel, so
  the spoke-side cascade is unchanged. ADR-051 §6 documents the
  closed post-adopt window (the pre-adopt §6 window and the
  inside-adopt_channel cancellation point stay as documented).
- rebuild_spec_for trims and rejects empty/whitespace
  channel_open_alpn strings — an empty explicit string previously
  overrode a sane name-derived ALPN.
- op_name_is_standard_channel_open_shape applies the same
  empty-segment guard as the derivation: channels//sub no longer
  serializes boolean-only and then reconstructs unmarked (silent
  stub for a marked op); the explicit string rides instead.
- reserved_reply_key_call_error interpolates RESERVED_REPLY_KEY;
  the establisher-bug log fires at warn! (programming error).
- Regression tests: the plan drop guard, the empty-ALPN fallback,
  the empty-segment shape check (672 tests, 3 new).
- CHANGELOG [Unreleased] entry for the audit fixes.
- docs/reviews/009 — the audit's deferred test-coverage gaps
  (template failure arms, filtered/only, batch reserved key,
  wire failure path, golden pins, derivation edge shapes, builder
  overwrite semantics), each with the test to add and gates.

Verification: cargo test 672 passed; clippy --all-targets -D
warnings clean; fmt --check clean; doc --no-deps clean; wasm32
check clean.
2026-09-18 06:19:56 +00:00
glm-5.3-flash 50182d7298 feat(review 008 Unit 3c): the gate-2 e2e harness; 0.8.0 release bookkeeping
- src/channels/gate2_tests.rs (ADR-051 gates 6/7, review 008 gate 2):
  the full consumer -> hub (HubLegTemplate) -> spoke relay e2e —
  the open resolves with the hub-allocated channel_id, `bound`
  survives the relay, data flows both directions with a fake 8-byte
  chunk header riding verbatim (the hub never parses the data plane,
  ADR-034/035), spoke-side close cascades to clean reclaim on both
  legs; hub-side disconnect (the consumer's duplex end dropped via a
  killable transport proxy) tears down both legs with the ledger
  decremented; the mid-establishment window (ADR-051 §6) pinned with
  its two reclaim signals — the consumer leg reclaims at its own
  transport EOF, the spoke channel (allocated before the establisher
  replied) is the honest residual, reclaimed when the spoke-leg
  transport ends; the channels/tty/sub standard-shape companion pins
  no derivation regression.
- src/channels/relay.rs: the relay's adopted producer-leg channel
  entry now reclaims when the relayed pump completes (teardown after
  pump_bidi) — the consumer-leg wrapper's teardown cannot see the
  producer leg's manager; the RelayPlan carries the spoke id for the
  reclaim.
- Release bookkeeping: 0.7.1 -> 0.8.0, the CHANGELOG entry covering
  Units 1-3 (Establishment reply projection, open_channel_with_reply,
  flavor-form discovery derivation, ChannelRelay, HubLegTemplate,
  gate-2 harness); review 008 Status -> Resolved with the U-1/U-2
  commit refs and the 955->945 errata note; ADR-051 Status ->
  all units landed + the §6 mid-establishment residual expanded to
  the two-reclaim-signal shape the gate pins; the stale "0.7.2"
  version mentions in ADR-047/049 corrected to 0.8.0 (the units land
  unreleased).

Verification: cargo test 669 passed / 0 failed; clippy --all-targets
-- -D warnings clean; fmt --check clean; cargo doc --no-deps clean;
cargo check + clippy on wasm32-unknown-unknown clean;
cargo publish --dry-run --allow-dirty passed.
2026-09-18 05:15:29 +00:00
glm-5.3-flash 4e52bd496c feat(review 008 Unit 3b): the hub-leg install template (ADR-051 §5)
- src/channels/hub_leg.rs: HubLegImports (the Clone discover/stash,
  bundles split by the channel_open marker, with the per-consumer
  filter) and HubLegTemplate (the hub-leg install hook: per-connection
  fork + generic channel ops + plain bundles as-is + marked specs via
  ChannelRelay::register_relay_openable + bootstrap discovery closed
  over the fork (review 004 F-06) + serving-identity resolution
  (CF-005) + the single-stream dispatch loop).
- The loud assembly posture holds at install: a Pub-typed (or
  unregistrable) marked spec ends the leg's install task — channel 0
  never dispatches, never a silent stub (ADR-051 §6).
- Assembly rule surfaced by the gate e2e: from_call discovers the
  spoke's bootstrap discovery ops like any plain op; the template
  skips BOOTSTRAP_DISCOVERY_OPS (new pub const, discovery.rs) when
  re-registering plain bundles — the template's own install, closed
  over the fork, supersedes the imported copies.
- Gate tests (7): stash split + filter, the full template e2e
  (services/list shows the re-exposed ops, a plain imported op
  round-trips through the hub, the marked op opens through the relay
  with bound surviving, data both directions), serving-identity
  precedence (scope-bearing override resolves, scope-less fails
  closed), the loud Pub-typed posture, and the filtered-stash subset
  run (NOT_FOUND for the filtered-out marked op).
- Docs: ADR-051 status (3a + 3b implemented, 3c remains); review 008
  Unit 3b landed note (the bootstrap-op skip rule).

Verification: cargo test (665 passed), clippy --all-targets
-D warnings, fmt --check, doc --no-deps — all clean.
2026-09-18 03:48:52 +00:00
glm-5.3-flash 90c182039e feat(review 008 Unit 3a): in-tree ChannelRelay (ADR-051, ADR-042 amendment)
- src/channels/relay.rs: ChannelRelay::register_relay_openable —
  registers a from_call-imported marked spec on a consumer-leg
  channel-0 registry via the full open-op wrapper (translate hop =
  the ADR-049 establisher calling the producer leg with the
  forwarded payload; byte-forward hop = pump_bidi, awaited inline).
  The relay map dissolves (implicit per-channel mapping; ids are
  per-connection), the spoke reply's channel_id is stripped, the
  reply's other fields ride with_reply_fields (bound flows
  end-to-end), reason mapping per ADR-051 §3 (spoke code+message
  preserved; timeout → dial_failed).
- Rejection postures (ADR-051 §6): unmarked and Pub-typed marked
  specs are loud assembly errors.
- Gate tests (9): registration rejections, reason mapping,
  forwarded_for payload shape, the real-wire consumer → hub → spoke
  round trip (bound survives, data both directions, the hub never
  parses the data plane), spoke-failure teardown (ledger
  decremented, no spoke channel leak), and a channels/tty/sub
  standard-shape run pinning no regression.

Verification: cargo test (658 passed), clippy --all-targets
-D warnings, fmt --check, doc --no-deps — all clean.
2026-09-17 23:27:17 +00:00
glm-5.3-flash 91765446b7 docs(review 008 Unit 3 planning): ADR-051 — in-tree ChannelRelay + hub-leg assembly; plan split into 3a/3b/3c
Design session outcome for the relay unit. The hub/spoke family (hub
or spoke may relay; the hub re-exposes spoke services by ACL without
binding ports) needs the call-half support too, so the unit's scope
grew beyond the as-pinned sketch and is now three sub-units.

- ADR-051 (new): the relay is the wrapper composition — translate hop
  = the ADR-049 establisher shape, byte-forward hop = pump_bidi per
  ADR-050; the relay holds Arc<CallConnection> + ChannelManager per
  producer leg (not a ChannelClient — take_call_connection's detach
  is wrong for a hub with three CallConnection claimants); the reason
  mapping preserves the spoke's code+message (timeout is the one
  non-1:1 case, mapping to dial_failed); the registration seam is
  two-phase (discover/stash → per-connection fork-register — the
  ADR-047 §4 fork mechanism makes a one-call import impossible); the
  ADR-042 relay map dissolves (implicit per-channel mapping) and
  channel/close needs no translation surface (EOF cascade propagates
  with correct ledger accounting on both legs); Pub-typed marked
  specs are a loud assembly error; establishment bounds compound per
  hop (noted, no fix); the ACL layering note is pinned (the spoke's
  AccessControl sees only the hub identity; forwarded_for is never
  checked).
- ADR-042 amended: the §Scope note is revised (implementation is an
  alkcall export; hub crates compose it) and the two mechanism
  supersessions are recorded — the contract and auth-model rationale
  unchanged.
- ADR-047 amendment 3's forward reference and the README ADR index
  updated (001..051).
- Review 008 remediation plan: Unit 3 split into 3a (ChannelRelay
  component + gates), 3b (hub-leg install template — the call-half
  support), 3c (gate-2 e2e incl. the mid-establishment disconnect
  window); sequencing note updated; adoption note records the
  session's decisions.

Verified: cargo doc --no-deps (markdown-only change).
2026-09-17 06:47:48 +00:00
glm-5.3-flash 620180d615 feat(review 008 Unit 2): flavor-form open-op ids in discovery (U-1, ADR-047 amendment 3)
Flavor-form open op ids (channels/tunnel/direct, channels/tunnel/
forwarded — alktunnels ADR-007/008) now survive discovery + the
op/register announce path: the marker reconstructs, so a hub consuming
through discovery wraps the op with relay machinery instead of the
silent plain-forwarding-stub failure.

Per the review-008 plan's combined (b)+(c) shape:

- derive_alpn_from_op_name generalizes from strip-/sub|/pub to
  strip-last-segment (rsplit_once) — a strict superset: standard
  two-suffix shapes derive identically, multi-segment ALPNs survive
  the same way, and the flavor form derives. The boolean marker
  remains the gate (consulted only for marked ops in
  rebuild_spec_for) — a plain op named channels/tty/query is
  unaffected.
- spec_to_json_pub emits channel_open_alpn (the explicit string)
  beside the boolean when the op name is not the standard
  channels/<seg>/(sub|pub) shape; standard shapes stay byte-identical
  to the pre-amendment payload. The advertised operation_spec_schema
  documents the optional ["string","null"] property.
- rebuild_spec_for prefers the explicit string, else boolean →
  generalized derivation. Both wire consumers (from_call import,
  op/register announce) parse through the same parser, so one change
  covers both. A channel_open_alpn without the boolean never marks an
  op (the boolean is the dispatch hint).

Residual ambiguity pinned in the ADR: an ALPN segment colliding with
a flavor name (channels/x/direct → alk/x vs alk/x/direct) is
undecidable from the name alone; the explicit string is the
disambiguator.

Tests (review gates 1 + 3; gate 2 lands with the Unit 3 relay):
- channels/tunnel/direct reconstructs WITH channel_open = alk/tunnel,
  both via the explicit string and via the boolean alone (the
  deployment-skew case: old producer, new consumer)
- standard shapes (channels/tty/sub, multi-segment
  channels/custom/proto/sub) round-trip byte-stable — no new key
- explicit string overrides a colliding derivation; string without
  boolean is ignored
- op/register announced flavor-form spec round-trips with the marker
- derivation unit tests: flavor form, non-op-type suffix not
  special-cased, prior shapes unchanged

Docs: ADR-047 amendment 3 — the convention sentence (mirroring
alktunnels ADR-002 Amendment 1: flavors are new op ids, additive, the
.../sub op is never reused), the wire shape, the preference order,
the residual-ambiguity note, one-way-door timing.

Verification: cargo test 649 passed (12 new); clippy -D warnings
clean; cargo fmt --check clean; cargo doc --no-deps clean.
2026-09-16 10:34:45 +00:00
glm-5.3-flash 82ddddf986 feat(review 008 Unit 1): establisher reply projection (U-2, ADR-049 amendment 3)
The establisher can now contribute to the open-op success reply — the
establisher → opener direction amendment 2 left empty. First consumer:
alktunnels ADR-008's bind-first listen establisher, whose observed
OS-chosen bound address rides the reply as an additive `bound` field
(the SOCKS5 BIND reply#1 BND.ADDR fidelity ask).

- Establishment gains reply_fields (private, builder-constructed):
  with_reply_field / with_reply_fields / reply_fields. The map shape
  generalizes beyond `bound` without a fourth amendment; the
  #[non_exhaustive] carrier (amendment 2) makes the extension additive
  — no construction-site break.
- run_open_wrapper merges contributed fields into the success output
  after reserving `channel_id`. The key is wrapper-owned: an
  establisher supplying it fails the open loudly
  (channel:open_failed, reason handler_error, message naming the
  reserved key), tearing the just-allocated channel down — never
  shadowing. Absent fields leave the reply byte-identical to the
  pre-amendment shape (asserted exactly).
- ChannelClient::open_channel_with_reply returns
  (channel_id, reply, send, recv) — the full success output — so
  consumers read `bound` first-class; open_channel delegates and
  discards the fields, signature unchanged.

Tests (all gates from the review + plan):
- projection produces { channel_id, bound } on the wire; no-fields
  replies are byte-identical (establisher and no-establisher shapes)
- reserved-key establisher fails with reason handler_error; channel
  torn down, ledger decremented, pump handler never spawned
- registry-level register_openable_with_establisher projects fields
- e2e over a real channels connection: bound reaches
  open_channel_with_reply; open_channel unchanged against the same
  accept side
- existing establishment tests (establisher-success round-trip,
  plan-flow, no-establisher compat) pass unchanged

Docs: ADR-049 amendment 3 (projection, reservation, read path,
compatibility posture, door type).

Verification: cargo test 637 passed; clippy -D warnings clean;
cargo fmt --check clean; cargo doc --no-deps clean.
2026-09-16 10:27:12 +00:00
glm-5.3-flash 1bc937c061 docs(review 008): remediation plan — findings verified, three units pinned
Both findings verified against HEAD 3b36b40 (0.7.1); all citations
check out (one line drift: U-2 reply construction is operations.rs:945,
errata noted rather than silently edited).

Plan decisions recorded above the as-filed option lists:

- U-1 shape: combined (b)+(c) — explicit channel_open_alpn string for
  non-standard shapes; derivation generalized to strip-last-segment,
  gated on the boolean marker. As-filed option (a) is unsound (the
  derivation runs consumer-side; a registration-threaded allowlist has
  no path to rebuild_spec_for).
- U-1 gate 2 escalated to an in-tree relay component (src/channels/
  relay.rs), amending ADR-042's downstream-implementation scope note —
  the re-produce/relay shape is general (alkhttp fallback hub, alknodes)
  and a real consumer now requires it.
- U-2 adds open_channel_with_reply (ergonomic read path for `bound`);
  open_channel unchanged.

Units, sequenced 1 -> 2 -> 3, one minor release:
1. U-2 reply projection (ADR-049 am. 3) — lands first; the relay's
   establisher projects spoke reply fields through it.
2. U-1 derivation + wire field (ADR-047 am.) — standard shapes
   byte-stable; round-trip gates for flavor-form and boolean shapes.
3. Relay component + gate-2 e2e (ADR-042 am.) — establisher = translate
   hop (forwarded_for, adopt_channel, reason-code mapping, bound
   projection), OpenHandler = inline-await byte-forward hop;
   consumer -> hub -> producer e2e asserting bound survives the relay.

As-filed findings retained untouched above the divider (the review-007
pattern).

Verification: docs-only change; no build/lint/test impact.
2026-09-16 09:58:17 +00:00
glm-5.3-flash 3b36b4074f docs: review 008 — graduation upstream asks (flavor-form discovery derivation; establisher reply projection)
Filed from the alktunnels graduation spec work (ADRs 007/008):
U-1 extends ADR-047's op-name → ALPN derivation to flavor-form open
op ids (channels/tunnel/direct, channels/tunnel/forwarded) so the
marker survives discovery and hub relay wraps them as channels, not
plain forwarding stubs; U-2 (ADR-049 amendment 3 sketch) gives the
establisher a path to contribute additive reply fields (the bind-first
listen 'bound' address — BND.ADDR fidelity). Both additive; data
plane untouched. alktunnels implementation is sequenced after these
land.
2026-09-16 08:32:19 +00:00
glm-5.3-flash 098090d564 chore: bump to 0.7.1, changelog for the MSRV floor raise (1.85 -> 1.88)
Verification: 631 default / 648 all-features tests on stable, 1.88
toolchain test + clippy clean, clippy (all-targets, wasm32) clean,
fmt clean, doc clean, semver-checks clean, publish dry-run OK.
2026-09-09 19:36:25 +00:00
glm-5.3-flash e2ca981eb6 chore: raise rust-version floor to 1.88; fix 1.88 clippy lints
The 1.85 claim was already false at the dependency level: the resolved
lockfile pulls icu_* 2.x (MSRV 1.86) and wasip2 1.87 transitively via
jsonschema -> idna, so 1.85 toolchains cannot build 0.7.0 regardless of
what Cargo.toml declares. Raising the floor to 1.88 aligns with the
noq QUIC path (per the ecosystem MSRV audit) and breaks no downstream
that could build the crate before.

- Cargo.toml: rust-version 1.85 -> 1.88 (version-line bump deferred to
  release time; semver-checks passes either way)
- src/channels/mux.rs: collapse else { if .. } (collapsible_else_if)
- src/core/types.rs, src/registry/registration.rs: inline format args
  (uninlined_format_args)

Verification:
- 1.88: cargo test --locked (631 pass), clippy --all-targets -D warnings
- stable 1.94: test --locked, clippy -D warnings, --all-features, fmt,
  doc --no-deps
- wasm32-unknown-unknown: cargo check
- cargo semver-checks check-release: no semver update required
2026-09-09 19:32:50 +00:00
glm-5.3-flash ed741c34e0 feat(cf-006/cf-007): per-call opener identity for open-op hooks; ADR-016 code-list sweep
Batch-fixes every remaining open alkcall-side finding from downstream
consumers so 0.7.0 is the only release they need to absorb.

- CF-006 (the CF-005 corollary): run_open_wrapper derives a per-call
  AuthContext — the opener's dispatch-resolved identity (the same
  identity the ACL gate and cap check saw) overlaid onto the
  install-time context — and passes it to both the establisher and
  the pump handler. Identity-less calls keep the install-time
  identity (no synthetic-anonymous rewrite); transport-truthful
  fields are never rewritten. Signatures unchanged — behavior-only;
  identical on per-connection registries, hub-forwarded opens now
  show the end client. Gates:
  open_wrapper_overlays_per_call_identity_on_install_time_auth +
  open_wrapper_keeps_install_time_identity_when_call_identityless.
- CF-007 (alkhttp review 006 Part C doc drift): ADR-016 amended to
  the eight-code list — ALREADY_EXISTS + CONNECTION_CLOSED in the
  Context, §3 table, and from_openapi collision rule; new §2a
  documents the undelivered-vs-ambiguous write-failure distinction.
- ChannelPlan type doc now states the Send + Sync payload constraint
  (alktunnels POC F-1 re-derived it by compiler error).
- Ledger: CF-006 and CF-007 filed + resolved; CF-005's corollary
  note points at CF-006; the Open section is empty. ADR-049 gains
  the per-call-identity note. Changelog 0.7.0 covers the batch.

Sweep result (all downstream reviews): no other open alkcall items —
alktunnels W3/F-2 are downstream-by-design, alktty R3/P14 are closed
constraints, alknet has none; OQ-24/37/39/40/41 stay
deferred-by-design (no consumer pull yet).

Verification: cargo test (631) + --all-features (648), clippy
(all-targets, all-features, -D warnings), fmt --check, doc
--no-deps, wasm32 check, semver-checks (no update required),
publish --dry-run.
2026-09-07 11:26:12 +00:00
glm-5.3-flash db5530ed34 feat(cf-005): connect-side serving identity — ServingConfig.identity + transport-identity propagation
Verifies and fixes CF-005 (alktunnels reverse-flow POC W1): the
connect-side serving path built channel 0 internally and never set an
identity, so a scope-gated serving op could only be satisfied via the
payload auth_token. Token is now the fallback (hub-forwarding /
browser path); transport/key-based identity is the primary path.

- ServingConfig gains identity: Option<Identity> — the explicit
  override (remediation a). Semver-relevant struct-literal change →
  0.7.0 (minor bump at 0.x, wire surface unchanged).
- from_connection_with_serving propagates the transport
  Connection::identity() to the channel-0 connection via set_identity
  before the serving loop starts (remediation b) — mirrors the accept
  side's install-hook set_identity; process-local, nothing new on the
  wire.
- Dispatch identity precedence on the serving loop: payload
  auth_token → identity_provider, then ServingConfig.identity, then
  transport identity; identity-less dispatch still fails closed
  (FORBIDDEN).
- Public core::auth::NoopIdentityProvider (resolves nothing; the
  ServingConfig::default() provider — three private test copies
  existed).
- Regression gates: four cf005_* e2e tests (transport propagation,
  override precedence, identity-less denial, token fallback +
  precedence).
- Ledger CF-005 → resolved; ADR-022 §connect-side-serving amended;
  README example updated; changelog 0.7.0.

Verification: cargo test (629) + --all-features (646), clippy
(all-targets, all-features, -D warnings), fmt --check, doc
--no-deps, wasm32 check, semver-checks (no update required at
0.7.0), publish --dry-run.
2026-09-07 11:00:47 +00:00
glm-5.3-flash 0d287a97b0 docs(ledger): CF-005 — connect-side serving path has no caller-identity capture (alktunnels reverse-flow POC W1) 2026-09-07 10:06:30 +00:00
glm-5.3-flash d22cecd317 docs: ADR-049/050 in indexes; changelog link refs; ported-ADR range 001..048
- architecture README: index rows for ADR-049 (establishment phase) and
  ADR-050 (pump_bidi); stale ADR-001..045 range labels corrected
- channel-operations.md: design-decision table + references list the
  two new ADRs
- CHANGELOG: missing link references for 0.4.0/0.4.1/0.5.0/0.6.0
- AGENTS.md: ADR range 001..050
2026-09-07 09:03:10 +00:00
glm-5.3-flash d25deb5eaf docs(review 007): mark R-01/R-02/R-03 resolved; record the two sketch deviations
Status → Resolved with the three unit commits; both deviations from
the review's sketches (typed-opaque ChannelPlan over Option<Value>;
(u64, u64) over io::Result) recorded up top with their rationale, as
the ADRs carry them.
2026-09-07 08:48:06 +00:00
glm-5.3-flash 9c6fec17ca feat(review 007 Unit 3): pump_bidi two-pump helper (R-03, ADR-050)
Extracts the two-pump data-plane helper alknet ADR-078 deferred until
the shapes converged (they have: alktunnels POC pump_halves + alktty's
channels session). Purely additive.

- channels::pump::pump_bidi(channel, peer_read, peer_write) -> (u64, u64):
  two joined pumps, shutdown-on-completion per direction; copy counts
  for observability. The channel side is a single AsyncRead +
  AsyncWrite value (the accept_bi BiStream); the peer side takes split
  halves — the establisher's natural dial result (into_split).
- Return (u64, u64), not the review sketch's io::Result<(u64, u64)>:
  both pumps swallow copy errors by contract (mid-stream error =
  abrupt close, no error channel mid-stream per ADR-049 §6), so an
  Err state would be dead code. Deviation recorded in ADR-050.
- alktty's three-pump session does not fit (exit future as a third
  signal) and stays as-is, per the review's scope.
- Tests reproduce the POC's two-pump semantics through the helper:
  bidirectional flow with exact counts, EOF-from-one-side completes
  the other's shutdown (clean EOF at the far end), dead-source =
  EOF-shaped teardown.
- ADR-050 records the decision, deviations, and two-way door type.

Verification: cargo test (625 passed, +2), clippy -D warnings, fmt
--check, doc clean, test --all-features clean, wasm32-unknown-unknown
check clean.
2026-09-07 08:47:46 +00:00
glm-5.3-flash 8f122b0a38 feat(review 007 Unit 2): birth-teardown telemetry — unaccepted-stream debug hint (R-02)
The optional hardening half of R-02 (the doc notes landed with Unit 1,
dc4ad2b). The wrapper's teardown task now observes whether the pump
handler ever accepted the channel's stream:

- ChannelBidiStreamSource gains a shared acceptance flag
  (with_accepted_flag / accepted()); channel_source_with_accepted_flag
  threads it from run_open_wrapper.
- On handler exit without accept, the teardown task logs a debug!
  naming the contract: "the returned JoinHandle must track the
  data-plane lifetime ... await pumps inline" — the telemetry hint for
  the teardown-at-birth shape the alktunnels POC hit empirically.
- Telemetry only: no behavior change; the benign no-data close still
  tears down identically.

Verification: cargo test (623 passed, +2: accepted-flag flip on the
yield-once accept; accepting vs non-accepting handler teardown
behavior), clippy -D warnings, fmt.
2026-09-07 08:45:09 +00:00
glm-5.3-flash dc4ad2bc6d feat(review 007 Unit 1): Establishment carries the channel plan (R-01) + lifetime doc (R-02)
Implements ADR-049 amendment 2 — the reserved Establishment payload is
filled, and the OpenHandler lifetime contract is documented.

- Establishment { plan: Option<ChannelPlan> } with ChannelPlan =
  Arc<dyn Any + Send + Sync>: typed-opaque, because the payload an
  establisher hands the pump handler is a live handle (dialed socket,
  TTY handle), not JSON — the review's Option<Value> sketch could not
  satisfy its own verification gate. #[non_exhaustive] keeps a future
  carrier change from being another break. Construction:
  Establishment::new(plan) / Establishment::default().
- OpenHandler gains the plan parameter:
  Fn(Value, Option<ChannelPlan>, Connection, AuthContext) ->
  JoinHandle<()>. Separate parameter (not merged into input) — a
  typed payload cannot ride the JSON input; no schema collision.
  Wire surface unchanged: the plan is process-local (establisher ->
  wrapper -> handler).
- run_open_wrapper threads establishment.plan to the handler; None
  when no establisher is registered. Kills the alktunnels-POC
  side-channel handoff (resource-keyed slot + poll loop) whose
  concurrent same-resource race is now unreachable — each open's
  establisher result flows to its own handler.
- Lifetime contract documented (R-02, doc-only half): the returned
  JoinHandle must track the data-plane lifetime — the wrapper awaits
  it and its completion triggers teardown; early return = teardown
  at birth. Noted on the OpenHandler type docs and both registration
  entry points.
- Breaking at 0.6.0 (the point of landing it before alktunnels
  Phase 1): Ok(Establishment {}) sites become
  Ok(Establishment::default()) mechanically.

Verification: cargo test (621 passed, +4: plan-flows-to-handler,
concurrent same-resource opens get distinct plans, no-establisher
None plan, Establishment construction), clippy -D warnings, fmt
--check, doc clean, test --all-features clean.
2026-09-07 08:43:17 +00:00
glm-5.3-flash 6590ab005f docs: review 007 — establishment follow-ups (from the alktunnels UDP POC)
Findings filed to prevent a second fix->publish->update-dependents
cycle; each was reached by building working code against 0.5.0:

- R-01 [major] — Establishment is payloadless but the channel plan
  is exactly what establishers need to hand to the pump handler
  (ADR-049's own 'reserved for a channel plan' note). Costs verified
  in two consumers: alktunnels POC side-channel handoff (same-resource
  opens race the slot), alktty forced to keep backend allocate
  post-open in-band (allocate_failed stays an in-band frame — the
  shape ADR-049 eliminates, alive one layer down). Ask: fill the
  reserved field (plan: Option<Value>, process-local, wire unchanged)
  in a 0.6.0 sweep; the break is mechanical (Establishment::default).
- R-02 [minor] — the OpenHandler JoinHandle lifetime contract is
  undocumented and load-bearing: the wrapper's await of the returned
  handle IS the teardown trigger; a handler that returns before its
  pumps finish tears the channel down at birth (the POC found this
  empirically — every tunnel EOF'd instantly). Doc note + ADR-049
  amendment; optional debug warning.
- R-03 [minor, optional] — the ADR-078 two-pump helper's convergence
  test is satisfied (POC gives both shapes); extracting pump_bidi
  now is additive (no break) and pins the contract upstream. Decide
  in the same 0.6 sweep.

Non-findings: reverse-flow (-R) needs no upstream mechanism
(from_connection_with_serving + serving-side allocation, verified by
trace); establisher receives registry-validated input; typed
establishment errors complete on the wire; early-arrival cap
unchanged; EstablishmentError reason set sufficient.

Goal stated in the review: 0.6 is the last breaking sweep forced by
known work. alkcall tests: 617 passed (docs-only change; baseline
check).
2026-09-07 08:18:11 +00:00
glm-5.3-flash 36e74cda11 feat(review 006 Unit 3): teardown-race log, early-arrival bound docs, count accessor (E-03, E-04, N-2)
- E-03: the open wrapper's handler-exit teardown no longer discards
  UnknownChannel silently — debug log + benign-race pinning comment
  (ledger take is the atomic gate; no double-decrement)
- E-04: consumer-facing doc note on the 64-parked-chunks observable
  bound (channel-client.md + EARLY_ARRIVAL_CAP const doc) for
  tunnel-style push-first producers
- N-2: ChannelManager::early_arrival_count() accessor (the
  observability choice over removing the write-only counter),
  documented monotonic, with a park/adopt-drain monotonicity test
- review 006: Unit 3 marked implemented in Status and remediation plan

Verification: 617 tests pass; clippy -D warnings clean (host +
wasm32 check); fmt clean; doc clean
2026-09-06 19:35:18 +00:00
glm-5.3-flash f8dad9dbc8 feat(review 006 Unit 2): additive OperationSpec.description disclosed via discovery (E-02)
- OperationSpec gains description: Option<String> (builder
  with_description, defaults None; no struct-literal construction
  sites exist, so additive by construction)
- spec_to_json_pub emits description when set; rebuild_spec_for
  parses it back — the field survives from_call discovery and
  op/register announcement (same round-trip pattern as
  resource_id_path / publish_schema)
- services/list and the local-ops half of services/list-peers emit
  description when set; output-schema docs on both listing specs and
  operation_spec_schema advertise the field
- Tests: builder/default, emit/omit, listing emission, schema
  disclosure, schema-doc presence, round-trip + absent-stays-absent
  (8 new; 616 total)
- Docs: review 006 Unit 2 marked IMPLEMENTED; ADR-047 §6 amendment
  records the E-02 discovery decision (listing enrichment lands, the
  channel/resources/subscribe half stays deferred); OQ-40 gains the
  load-bearing note; operation-registry.md struct + listing docs;
  CHANGELOG

Verification: cargo test (616 pass), clippy -D warnings (host +
wasm32), fmt --check, cargo doc --no-deps, wasm32 check — all clean
2026-09-06 19:26:51 +00:00
glm-5.3-flash 2586c3b217 feat(review 006 Unit 1): channel-open establishment phase + typed client error (E-01, N-1)
Implements ADR-049 Unit 1 — the open-op wrapper gains an awaited,
bounded establishment phase, and the client stops erasing the error.

- OpenEstablisher hook + Establishment/EstablishmentError types:
  register_openable_with_establisher awaits the establisher bounded
  (earlier of dispatch deadline and per-registration timeout, else
  ESTABLISHMENT_TIMEOUT = 10s) after allocation, before the reply and
  before the pump handler is spawned (ADR-049 §1/§2). Implementation
  note: the establisher takes (input, auth) only — the channel's
  yield-once BiStream belongs exclusively to the pump handler
  (amendment recorded in ADR-049).
- Establishment failure: teardown_channel + opener-ledger take +
  policy.on_close un-increment (allocation and teardown balance;
  the ledger take is the atomic gate, ADR-047 §7), reply
  channel:open_failed with details {reason, message} — reason ∈
  dial_failed / unknown_resource / resource_shortage / handler_error
  / timeout (ADR-049 §3). SSH contract consumer-visible: a failed
  open never returns a channel_id.
- register_openable unchanged (no establisher = always-OK; existing
  registrations compile and behave identically — compat gate test).
- ChannelClient::open_channel returns ChannelOpenError (breaking at
  0.5.0): CallFailed { error: CallError } carries the wire error
  verbatim (establishment_reason() branches on details.reason);
  MissingChannelId / AdoptFailed cover the local-only shapes
  (ADR-049 §4, review 006 N-1).
- Tests cover all four verification gates from the review: e2e
  establisher failure through a real channels connection (typed
  reason + no-channel + ledger un-increment), bounded timeout,
  no-establisher compat, establisher-success pump round-trip; plus
  reason-vocabulary mapping and bound arithmetic.
- Bump to 0.5.0 (open_channel error-type change is semver-relevant).

Verification: cargo test (608 passed), clippy --all-targets -D
warnings, fmt --check, doc --no-deps, wasm32 check — all clean.
2026-09-06 19:00:04 +00:00
glm-5.3-flash 48ceeba55c docs: ADR-049 channel-open establishment phase; verify review 006
Verify review 006's findings against source at 88e3f5e (E-01..E-04
all confirmed; E-02 cost corrected — OperationSpec has no description
field, four touchpoints) and file three additional findings from the
same sweep (N-1 client error-type gap, N-2 write-only early-arrival
counter, N-3 pump-panic posture).

ADR-049 resolves E-01 + N-1: split-hook OpenEstablisher awaited
bounded by the open-op wrapper (restoring ADR-047 §3's "channel
plan" shape), teardown + typed channel:open_failed reply on
establishment failure, ChannelClient::open_channel typed error.
Review 006 gains the post-verification remediation plan and verdict
appendix.

Verification: cargo test (597 passed), cargo doc --no-deps clean.
2026-09-06 10:57:20 +00:00
glm-5.3-flash 88e3f5e9c3 docs: review 006 — channel-open establishment gap (from alktunnels phase 0)
Design review from the alktunnels Phase 0 research pass, verified
against tree a22b2b8 (0.4.1). Findings numbered E-01..E-04:

- E-01 [major] — the open op cannot fail after allocation: the
  wrapper replies {channel_id} the moment the OpenHandler is spawned;
  establishment failures (params-valid-but-rejected, backend lookup
  failure, target dial failure) present to the consumer as a
  successful open followed by an instant, indistinguishable clean
  EOF (implicit-EOF mux path + unified poll_read EOF arms). SSH
  semantics (RFC 4254 §5.1 open-failure reply with reason codes;
  channel never exists opener-side), SOCKS5 reply codes, and
  udpgw's opaque ERR bit (counterexample) surveyed in
  alktunnels/docs/research/ssh-socks5-survey.md. alktty's in-band
  error-frame mechanism (send_negotiation_error, 0x00-peek) is the
  per-crate workaround this upstream establisher obsoletes for the
  channels path. Proposed shape: an awaited establishment hook
  (OpenEstablisher) or await-and-inspect OpenHandler, tearing down on
  failure and replying channel:open_failed with SSH-four reason codes
  in ADR-016 details. Remediation sketch + verification gates
  included.
- E-02 [minor] — services/list discloses no per-op metadata; OQ-40
  (channel/resources/subscribe) becomes load-bearing for the first
  time via the alktunnels discovery resolution (OQ-TN-08).
- E-03 [minor] — OpenHandler-exit vs channel/close teardown race is
  benign (ledger take is the gate) but the let _ = discard at
  operations.rs:509 is silent; recommend log-or-comment.
- E-04 [minor] — early-arrival park cap (64) is an observable bound
  for push-first producers under slow adopters; no change requested,
  filed so the constraint is visible to the next consumer.

Non-findings recorded: open-op ACL path complete across all three
dispatch entry points; input_schema enforcement covers open params;
EOF arms unified; channel_open marker + resource_id_path wire
round-trip intact; opener-ledger decrement atomic at every call site.

alkcall tests: 597 passed (docs-only change; baseline check).
2026-09-06 09:54:13 +00:00
glm-5.3-flash a22b2b84c9 fix: park early-arrival chunks for un-adopted channels (open/first-data race)
The connect side adopts a channel (installs local routing state) only
after the open-op response arrives, but the accept side's OpenHandler
can start pumping data the moment the channel opens — the two race and
the demux's lenient unknown-channel drop (REQ-CH-04) silently lost the
producer's first chunks (a TTY backend's banner, a sub protocol's
greeting).

route_payload now parks up to 64 payloads per unknown channel_id in a
bounded early-arrival buffer; adopt_channel drains them into the new
receiver in order. Beyond the cap the chunk drops with the existing
debug log + dropped_unknown_chunks counter (which now also counts
overflow). clear_all drops parked buffers with the connection.

Surfaced by alktty's consumer end-to-end test (review #001 L3): the
session never resolved because the producer's first chunks (stdout
sentinel + exit chunk for an immediately-resolving backend) arrived
before the adopt and were dropped. REQ-CH-04 wording updated by this
behavior; ADR-039 §demux loop describes the lenient drop for genuinely
unknown channels, which remains the case past the cap.

Verification: cargo test 597 (2 rewritten for the new semantics +
route_payload_to_unknown_channel_parks_until_adopt gate);
--all-features 614; clippy -D warnings clean; fmt clean; doc 0
warnings; publish --dry-run ok; standalone probe (handler-writes-first
e2e over one connection) shows 0 dropped chunks with the fix vs 1
without.
2026-09-05 07:04:30 +00:00
1502 changed files with 14336 additions and 175 deletions

No files matched your search

+4 -2
View File
@@ -194,6 +194,7 @@ Run these before committing. All must pass.
```bash
cargo test # full suite
cargo test --manifest-path fuzz/shared/Cargo.toml # fuzz corpus replay (stable)
cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo doc --no-deps # if docs changed
@@ -215,13 +216,14 @@ cargo semver-checks check-release # before a release
## Architecture Context
- `docs/architecture/` — the authoritative spec. Read it before
non-trivial changes. ADRs are numbered 001..048; OQs (open questions)
non-trivial changes. ADRs are numbered 001..050; OQs (open questions)
track resolved/deferred decisions.
- This crate unifies `alknet-call` and `alknet-channels` from the
alknet mono-repo (`/workspace/@alkdev/alknet`). The source
architecture docs were ported from
`/workspace/@alkdev/alknet/docs/architecture/` and renumbered as
alkcall ADRs (001..048; ADR-048 was authored in this crate). The ALPN
alkcall ADRs (001..048; ADR-049 and later were authored in this
crate). The ALPN
strings (`alk/call`,
`alk/channels`) are wire-stable going forward (renamed from
`alknet/` to `alk/` in v0.1.1, before the first published consumer).
+442
View File
@@ -4,6 +4,440 @@ All notable changes to this crate are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
this crate adheres to [Semantic Versioning](https://semver.org/).
## [0.8.1] - 2026-09-28
Bug-fix release: a duplicate `adopt_channel`/`open_channel` no longer
destroys the live channel it collided with, found by the crate's new
fuzzing harness (`fuzz/` workspace — five cargo-fuzz targets, committed
seed corpora, three 10-minute campaigns clean). No API or wire-format
change; the error is still `Err(ChannelExists)` — the difference is
entirely in what the collision leaves behind.
### Fixed
- **Duplicate adopt/open no longer replaces the live channel's state
(found by the `manager_routing` fuzz target,
`docs/research/fuzzing.md` §7.8).** `ChannelManager::open_channel`
and `adopt_channel` used `HashMap::insert(...).is_some()` as the
collision check — but `insert` *replaces* an occupied entry and
returns the old value, so a duplicate on an in-use id installed the
new `ChannelState`, dropped the live channel's `demux_sender`
(spurious EOF to its readers; all subsequently routed chunks lost),
and *still* returned `Err(ChannelExists)`. The mux write half kept
framing onto the transport for a channel the demux no longer fed.
Both functions now check `contains_key` and return before any map
mutation — the live channel's routing state is untouched on
collision. Reachable whenever an open-op response is replayed or a
connection-owner race re-announces an id. Regression tests:
`adopt_channel_duplicate_id_leaves_live_channel_intact`,
`open_channel_duplicate_id_leaves_live_channel_intact` (both verify
routing survives a rejected duplicate; the first also verifies the
EOF sentinel remains the only EOF source).
### Added (development)
- **Fuzzing harness (`fuzz/` workspace, `docs/research/fuzzing.md`).**
Five cargo-fuzz targets over the two attacker-reachable wire formats
and the stateful channel-routing surface: `chunk_header` (8-byte
header no-panic/round-trip/accounting), `envelope_frame` (frame
decode error-shape partition + allocation bound), `manager_routing`
(`Arbitrary` op sequences over `ChannelManager` with exact counter
models and the parked-bytes bound), `envelope_semantic` (all six
event kinds: constructors → serde → framing round-trip), and
`spec_parse` (attacker-shaped op-register schemas compile or reject
cleanly at registration). Invariant logic lives in
`fuzz/shared/` — a stable-toolchain crate replayed as plain
`cargo test`, so the committed corpora stay executable without
nightly. Campaigns (10 min per target, detached runner): ~25M
executions total, zero crashes/hangs/OOMs. The `manager_routing`
campaign found the 0.8.1 fix above within minutes — exactly the
stateful interleaving class example-based tests cannot reach.
`fuzz/` is excluded from the workspace and from the published
package.
### Verified
- 684 tests pass; `clippy --all-targets -- -D warnings`, `fmt --check`,
`cargo doc`, wasm32-unknown-unknown check, `cargo semver-checks`
(no API changes vs 0.8.0), and `cargo publish --dry-run` clean;
corpus replay (`cargo test --manifest-path fuzz/shared/Cargo.toml`)
green.
## [0.8.0] - 2026-09-18
Review 008's remediation lands in full — the graduation upstream asks
(U-1 flavor-form open-op ids in discovery; U-2 the establisher → reply
projection) plus the in-tree channel relay they demanded
(`ChannelRelay`, the hub-leg install template, and the review's gate-2
e2e harness). A post-landing audit of that diff hardened the relay's
adopted-entry teardown and the discovery ALPN guards, corrected two
log/error messages, and review 009's coverage debt is paid in full —
all failure-path; no wire or API changes. The channels data plane is
untouched (ADR-034/035's one-way doors stay closed); the new surfaces
are additive on the call-plane JSON and the registry seams.
### Added
- **`Establishment` reply projection (review 008 U-2, ADR-049
amendment 3)** — `Establishment::with_reply_field` /
`with_reply_fields` / `reply_fields()` (an optional map riding the
typed-opaque plan carrier). The open-op wrapper merges the fields
into the success output after reserving `channel_id` — an
establisher supplying the reserved key fails loudly
(`channel:open_failed`, reason `handler_error`), never shadows.
Absent fields leave the reply byte-identical to the pre-amendment
shape.
- **`ChannelClient::open_channel_with_reply` (review 008 U-2)** — the
full open-op success output (the extra reply fields, e.g. a
bind-first listener's `bound`) alongside the streams;
`open_channel` delegates with the fields discarded, its signature
unchanged.
- **Flavor-form open-op ids in discovery (review 008 U-1, ADR-047
amendment 3)** — `spec_to_json_pub` emits an explicit
`channel_open_alpn` string alongside the boolean marker when the op
name is not a standard `channels/<seg>/(sub|pub)` shape (standard
shapes stay byte-stable); `rebuild_spec_for` prefers the explicit
string and generalizes the derivation to strip the LAST path
segment when the marker is present. `channels/tunnel/direct`-shaped
ops now reconstruct WITH the `channel_open` marker through
discovery — hubs relaying through `from_call` never see them as
plain forwarding stubs.
- **`ChannelRelay` (review 008 Unit 3a, ADR-042 as amended by
ADR-051)** — the in-tree relay component:
`ProducerLeg` (`Arc<CallConnection>` + producer-leg
`ChannelManager` — not a `ChannelClient`, whose `take_call_
connection` detach is wrong for a hub with multiple connection
claimants) and `ChannelRelay::register_relay_openable` (registers a
from_call-imported marked spec on a consumer-leg registry via the
full open-op wrapper: ACL, per-identity cap/ledger, establishment
bound, teardown). The translate hop calls the producer leg with the
forwarded payload (`forwarded_for` from the consumer's per-call
identity, ADR-026 §3) and adopts the spoke-allocated id; the
byte-forward hop is `pump_bidi` awaited inline — the hub never
parses chunk framing, ids are per-connection (no rewrite exists).
The spoke reply's `channel_id` is stripped (the consumer reply
carries the hub-allocated id); the reply's other fields ride
through (`bound` flows end-to-end, per-hop truthful). Reason
mapping per ADR-051 §3 — the spoke's code and message are preserved
(`timeout` maps to `dial_failed`, the one non-1:1 row). The
adopted producer-leg channel entry reclaims when the relayed pump
completes. Rejection postures are loud: unmarked and Pub-typed
marked specs fail registration (`RelayError`), never silent stubs.
- **`HubLegImports` + `HubLegTemplate` (review 008 Unit 3b, ADR-051
§5)** — the hub-leg install template as an in-tree export: the
`Clone` discover/stash (from_call bundles split by the marker, with
the per-consumer `filtered`/`only` subset filter) and the
`install_channel_zero` hook composing fork + generic channel ops +
bootstrap discovery closed over the fork (review 004 F-06) + plain
bundles as-is + relay openables + serving-identity resolution
(CF-005 precedence) + the single-stream dispatch loop. A
Pub-typed (or unregistrable) marked spec ends the leg's install
task — channel 0 never dispatches. Discovered bootstrap-discovery
op names are skipped on re-registration (the template's own
install supersedes the imported copies).
### Changed
- **New module `src/channels/relay.rs` and `src/channels/hub_leg.rs`
re-exported from `channels`**; the relay's teardown fix is part of
this landing (no pre-relay behavior to preserve).
### Fixed
Post-landing audit of the remediation diff — hardening guards,
message corrections, and one rejection-posture fix; all failure-path.
- **Adopted-entry teardown on every relay plan path** (ADR-051 §6) —
the relay's `RelayPlan` now owns the producer-leg
`ChannelManager` and reclaims the adopted spoke `channel_id` via a
`Drop` guard, instead of the pump handler's post-`pump_bidi`
explicit reclaim. This closes the leak windows the pump's normal
path cannot reach: the open wrapper's establishment bound expiring
after the adopt (the plan dropped before the pump ever spawns) and
the pump handler's early-return arms (plan absent, downcast
failure, `try_unwrap` failure, `accept_bi` failure). Dropping the
plan's send half still EOFs the spoke side via the mux pump's
implicit-EOF sentinel, so the spoke handler reclaims through the
same cascade as before. Regression test:
`relay_plan_drop_reclaims_the_adopted_producer_leg_entry`.
- **Empty/whitespace `channel_open_alpn` rejected at rebuild** — an
explicit string of `""` (or whitespace-only) previously overrode a
sane name-derived ALPN, poisoning the marker for every consumer
from one misconfigured producer; `rebuild_spec_for` now trims and
rejects empties, falling back to the derivation.
- **Empty-segment standard-shape names** — `channels//sub` was
serialized boolean-only (the standard-shape check saw flavor
`sub`) but reconstructed UNMARKED (the derivation's empty-segment
guard returned `None`) — a silent stub for a marked op. The
standard-shape check now applies the same empty-segment guard, so
the explicit `channel_open_alpn` string rides the wire and the
marker reconstructs.
- **Reserved-reply-key error text and log level** — the
`channel:open_failed` message interpolates `RESERVED_REPLY_KEY`
instead of hardcoding `"channel_id"` (the two could drift), and the
establisher-bug log fires at `warn!` (a programming error), not
`debug!`.
### Testing
Review 009's coverage debt (all seven findings, no wire or API
changes; three as-filed errata recorded in the review):
- **C-1** — the template's plain-bundle install-failure arm is pinned
(un-compilable `input_schema` → the install task ends before the
dispatch loop, channel 0 never dispatches); ADR-051 §5 carries the
loud-install coverage note (relay-openable + plain-bundle arms
pinned; generic-ops + bootstrap-discovery arms documented as
best-effort-loud).
- **C-2** — the `HubLegImports` filter: the `filtered` closure path
and the `only` marked/plain partition unit-tested; the empty-stash
e2e gate (serves only the generic ops + discovery, dropped ops
resolve `NOT_FOUND`).
- **C-3** — the batch-form reserved-reply-key rejection
(`with_reply_fields` smuggling `channel_id`) pinned: reason
`handler_error`, channel torn down, ledger decremented, pump never
spawns.
- **C-4** — `open_channel_with_reply`'s failure path pinned e2e: the
typed error carries the `channel:open_failed` code and the full
`details` shape (`reason` + `message`) through the new API.
- **C-5** — both "byte-identical" claims are golden-pinned: the
no-fields reply against the exact `{"channel_id": 2}` literal, and
the standard-shape wire payload against the full 9-key literal.
- **C-6** — the derivation's edge shapes pinned: `channels//sub` /
`channels//direct` / `channels` → `None`; the 4-segment strict
superset (`channels/x/sub/extra` → `Some("x/sub")`) annotated as the
pre-amendment behavior change; the verbatim `channels/alk/tty/sub`
case.
- **C-7** — the builder's overwrite semantics pinned last-win (single
and batch forms) with the doc sentence on `with_reply_field`.
### Verified
- 682 tests pass (`cargo test`), `clippy --all-targets -- -D warnings`
and `fmt --check` clean. The gate-2 e2e harness
(`src/channels/gate2_tests.rs`) pins the review's gate 2: the full
consumer → hub → producer relay through the template (hub-allocated
id, `bound` surviving, data both directions with a fake 8-byte
chunk header riding verbatim), hub-side disconnect tearing down
both legs, the mid-establishment window's two reclaim signals
(ADR-051 §6), and the `channels/tty/sub` standard-shape companion.
## [0.7.1] - 2026-09-09
The MSRV floor becomes honest: `rust-version` raises from 1.85 to
1.88. The 1.85 claim was already false at the dependency level — the
resolved lockfile pulls icu 2.x (MSRV 1.86) and wasip2 1.87
transitively via `jsonschema` → `idna`, so no 1.85 toolchain could
build 0.7.0. The raise aligns with the ecosystem floor (noq's 1.88,
matching the QUIC path) and breaks no downstream that could build the
crate before. No API or wire-format change; downstreams on caret
requirements pick this up on their next `cargo update`.
### Changed
- **`rust-version` 1.85 → 1.88.** Verified on a real 1.88 toolchain
(full test suite, clippy). Two clippy lints promoted since 1.85
were fixed to keep `-D warnings` clean on the new floor:
`collapsible_else_if` (`channels::mux`) and
`uninlined_format_args` (`core::types`,
`registry::registration`). Behavior-identical.
## [0.7.0] - 2026-09-07
The connect-side caller-identity seam (CF-005) plus the full sweep of
every open alkcall finding from downstream consumers (CF-006, CF-007):
a connect-side serving op can now authenticate the
transport-authenticated peer by key-based identity (mTLS/QUIC), the
open-op establisher/pump handler see the per-call opener identity,
and the ADR-016 code list is current. The wire surface is unchanged.
### Added
- **`ServingConfig.identity: Option<Identity>` (CF-005 remediation
(a))** — an explicit caller identity for the connect-side serving
dispatch (`ChannelClient::from_connection_with_serving`). Wins over
the propagated transport identity; the payload `auth_token` path
still takes precedence over both (ADR-017 §7).
**Semver-relevant at 0.x:** struct literals must add
`identity: None`.
- **`core::auth::NoopIdentityProvider`** — a public `IdentityProvider`
that resolves nothing; the identity-less posture for ACL-free
serving and the `ServingConfig::default()` provider (three private
test copies of it existed across the crate).
### Changed
- **The transport identity propagates to channel 0 (CF-005
remediation (b)).** `from_connection_with_serving` copies the
transport `Connection::identity()` to the channel-0 connection via
`set_identity` before the serving loop starts, mirroring the accept
side (the adapter hands the install hook the transport
`AuthContext`). The connect-side serving dispatch now resolves the
caller identity in precedence order: payload `auth_token` →
`ServingConfig.identity_provider`; `ServingConfig.identity`;
transport identity. With none, the dispatch runs identity-less and
`AccessControl::check` fails closed (`FORBIDDEN`) — unchanged. The
identity resolution is process-local (`set_identity` on the
internal channel-0 `Connection`); nothing new crosses the transport.
- **The open-op establisher and pump handler see the per-call opener
identity (CF-006 — the CF-005 corollary).** `run_open_wrapper`
derives a per-call `AuthContext` — the opener's dispatch-resolved
identity overlaid onto the install-time context — and passes it to
both the establisher (ADR-049 §1) and the pump handler
(`OpenHandler`). Identity-less calls keep the install-time
identity; transport-truthful fields (`alpn`, `remote_addr`,
`tls_client_fingerprint`) are never rewritten. Signatures are
unchanged — behavior-only; on a per-connection registry (ADR-047
§4) this is identical to before, and on hub-forwarded opens the
establisher now sees the end client instead of the hub.
- **ADR-016 amended: the protocol-code list is eight codes (CF-007 —
alkhttp review 006 Part C doc drift).** `ALREADY_EXISTS`
(non-retryable, `op/register` collision) and `CONNECTION_CLOSED`
(retryable, provably-undelivered call) are now in the Context
paragraph, the §3 table, and the `from_openapi` collision rule; §2a
documents the undelivered-vs-ambiguous write-failure distinction.
Doc-only.
- **`ChannelPlan` type doc carries the `Send + Sync` payload
constraint** (alktunnels POC F-1 — a non-`Sync` boxed handle needs
a wrapper; documented so the next consumer doesn't re-derive it by
compiler error).
- Regression gates: the four `cf005_*` tests in
`src/channels/client.rs` (transport propagation, override
precedence, identity-less failure, token fallback + precedence) and
the two open-op identity-overlay tests in
`src/channels/operations.rs`. Ledger: CF-005, CF-006, CF-007 →
resolved (the ledger's Open section is now empty). Specs: ADR-022
§connect-side-serving, ADR-016 (code list), ADR-049 establisher
doc note.
## [0.6.0] - 2026-09-07
The establishment follow-ups sweep (review 007): the `Establishment`
plan payload lands (R-01), the `OpenHandler` lifetime contract is
documented (R-02), and the two-pump helper is extracted (R-03). One
breaking change (below); the wire surface is unchanged.
### Added
- **`pump_bidi` (review 007 R-03 / ADR-050).** The two-pump helper —
`channels::pump_bidi(channel, peer_read, peer_write)` — pumps a
channel stream (`BiStream`-shaped) against a peer's split
read/write halves: one pump per direction, each shutting down the
opposite sink on completion (alknet ADR-078's shutdown-on-
completion), joined, returning `(u64, u64)` copy counts. Errors
are EOF-shaped by design (the POC + ADR-078 semantics — no `Err`
state; the review sketch's `io::Result` return was dead code).
The helper pins the contract in one place instead of three
(alktunnels POC `pump_halves`, alktty's channels session, the next
consumer). Purely additive.
### Changed
- **`Establishment` carries the channel plan (review 007 R-01 —
breaking at 0.6.0).** The reserved field is filled:
`Establishment { plan: Option<ChannelPlan> }` with
`ChannelPlan = Arc<dyn Any + Send + Sync>` — typed-opaque, because
the payload an establisher hands the pump handler is a live handle
(a dialed socket, a TTY handle), not JSON. The wrapper threads
`establishment.plan` to the `OpenHandler`'s new second parameter
(`Fn(Value, Option<ChannelPlan>, Connection, AuthContext) ->
JoinHandle<()>`); `None` when no establisher is registered or it
returned `Establishment::default()`. Process-local: establisher →
wrapper → handler; nothing new crosses the transport. This kills
the side-channel handoff the alktunnels POC shipped (resource-keyed
slot + poll loop) with its concurrent same-resource race — each
open's establisher result flows to its own handler. Migration:
`Ok(Establishment {})` → `Ok(Establishment::default())` (or
`Establishment::new(handle)` to deliver a handle); handler closures
gain a `_plan` (or `plan`) parameter.
- **`OpenHandler` lifetime contract documented (review 007 R-02).**
Doc-only semantics note on the `OpenHandler` type and the
registration entry points: the returned `JoinHandle` must track the
data-plane lifetime — the wrapper awaits it and its completion
triggers channel teardown (drop of the demux sender = EOF to the
handler's read half); a handler that returns before its pumps
finish tears the channel down at birth (await pumps inline, never
spawn-and-forget). Plus a `debug!` telemetry line in
`run_open_wrapper` when a handler exits without having accepted the
channel's `BiStream` (the birth-teardown hint).
## [0.5.0] - 2026-09-06
The channel-open establishment phase (ADR-049 — review 006 E-01 +
N-1). Additive establishment machinery; one breaking change (the
`ChannelClient::open_channel` error type, below).
### Added
- **`OperationSpec.description` (review 006 E-02).** Additive
`Option<String>` op description (builder: `with_description`),
disclosed by `services/list` and `services/list-peers` (local
listings) when set, carried in the `services/schema` wire shape
(`spec_to_json_pub` emit / `rebuild_spec_for` parse — the field
survives `from_call` discovery and `op/register` announcement).
Describes the op, not the produced resource set (ADR-047 §6
amendment: the live resource-enumeration half stays deferred,
OQ-40). Absent on the wire when unset — additive for all consumers.
- **Channel-open establishment phase (ADR-049 — review 006 E-01).**
`ChannelCore::register_openable_with_establisher` registers a
per-ALPN open op with an establisher hook (`OpenEstablisher`): an
awaited establishment phase — validate params semantically, dial the
backend — bounded by the dispatch deadline or the registration's
timeout override, else `ESTABLISHMENT_TIMEOUT` (10s; the earlier of
the two). On establisher failure (error or deadline) the wrapper
tears down the just-allocated channel (demux sender, opener-ledger
take, `policy.on_close` un-increment — the allocation and teardown
balance) and replies `channel:open_failed` with
`details: { reason, message }`, reason ∈ `dial_failed` /
`unknown_resource` / `resource_shortage` / `handler_error` /
`timeout`. The SSH contract holds consumer-visibly: a failed open
never returns a `channel_id`. `register_openable` is unchanged
(no establisher = always-OK, existing registrations compile and
behave identically); the establisher takes `(input, auth)` — the
channel's yield-once `BiStream` belongs exclusively to the pump
handler. Additive wire surface (new error-code string + `details`
shape).
### Changed
- **`ChannelClient::open_channel` returns a typed error (ADR-049 §4 —
review 006 N-1, breaking at 0.5.0).** The open op's `CallError` is
carried verbatim in `ChannelOpenError::CallFailed` instead of being
flattened into a debug-formatted string, so consumers branch on
`channel:open_failed`'s typed reason (`establishment_reason()`).
Other variants: `MissingChannelId` (malformed success reply),
`AdoptFailed` (local adoption failure). Mechanical for consumers —
the `String` was a debug-formatting wrapper.
## [0.4.1] - 2026-09-05
Bug-fix release: chunks arriving for a not-yet-adopted channel are
parked instead of dropped (the open-op response / first-data race).
No API changes.
### Fixed
- **Early-arrival chunks for un-adopted channels are parked, not
dropped.** The connect side adopts a channel (installs local routing
state) only after the open-op response arrives, but the accept side's
`OpenHandler` can start pumping data the moment the channel opens —
the two race. Previously the connect side's demux dropped those
chunks (REQ-CH-04's lenient unknown-channel drop), silently losing
the first chunks of any push-first producer (a TTY backend's banner
or greeting, a sub protocol's initial frame). `route_payload` now
parks up to 64 payloads per unknown `channel_id` in a bounded
early-arrival buffer and `adopt_channel` drains them into the new
receiver in order; chunks beyond the cap drop with the pre-existing
debug log and `dropped_unknown_chunks` counter (the counter now means
"early-arrival overflow or genuinely unknown channel", not just the
latter). `clear_all` drops the parked buffers with the connection.
Surfaced by alktty's consumer end-to-end test (review #001 L3): the
session never resolved because the producer's first chunks (the
stdout sentinel + exit chunk for an immediately-resolving backend)
arrived before the adopt and were dropped.
## [0.4.0] - 2026-09-05
`input_schema` is now enforced at call time (the advertise-vs-enforce
@@ -303,6 +737,14 @@ Vendored core types (`Connection`, `ProtocolHandler`, `BiStream`,
(ADR-046), the channels protocol with openable-ALPNs-as-operations
(ADR-047), and the `ChannelClient` transport-agnostic client.
[0.8.1]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.8.1
[0.8.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.8.0
[0.7.1]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.7.1
[0.7.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.7.0
[0.6.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.6.0
[0.5.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.5.0
[0.4.1]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.4.1
[0.4.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.4.0
[0.3.1]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.3.1
[0.3.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.3.0
[0.2.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.2.0
Generated
+1 -1
View File
@@ -27,7 +27,7 @@ dependencies = [
[[package]]
name = "alkcall"
version = "0.4.0"
version = "0.8.1"
dependencies = [
"async-trait",
"bytes",
+7 -3
View File
@@ -1,15 +1,19 @@
[package]
name = "alkcall"
version = "0.4.0"
version = "0.8.1"
edition = "2021"
rust-version = "1.85"
rust-version = "1.88"
license = "MIT OR Apache-2.0"
description = "Call + channels RPC: structured JSON operations, streaming subscriptions, service discovery, and N-channel multiplexing over one transport stream"
readme = "README.md"
repository = "https://git.alk.dev/alkdev/alkcall"
keywords = ["rpc", "json-rpc", "multiplexing", "wire-format", "alpn"]
categories = ["network-programming", "asynchronous", "encoding"]
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/sdd_process.md"]
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/sdd_process.md", "docs/research/", "fuzz/"]
[workspace]
members = ["."]
exclude = ["fuzz"]
[lib]
name = "alkcall"
+1
View File
@@ -120,6 +120,7 @@ let client = ChannelClient::from_connection_with_serving(
Some(ServingConfig {
registry: Arc::clone(&registry),
identity_provider: provider,
identity: None, // peer identity: transport `Connection::set_identity` propagates
}),
).await?;
// peer-callable ops resolve against `registry` on channel 0;
+6 -3
View File
@@ -13,7 +13,7 @@ This crate unifies `alknet-call` and `alknet-channels` from the alknet
mono-repo, plus the vendored core types formerly in `alknet-core`. The
source architecture docs were ported from
`/workspace/@alkdev/alknet/docs/architecture/` and renumbered as alkcall
ADRs (ADR-001..045). The ALPN strings (`alk/call`, `alk/channels`)
ADRs (ADR-001..051). The ALPN strings (`alk/call`, `alk/channels`)
are wire-stable and unchanged — see ADR-004.
## Documents
@@ -101,6 +101,9 @@ are wire-stable and unchanged — see ADR-004.
| [046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | Publish Operation Type and HandlerKind::Sink | `OperationType::Pub` (producer→consumer streaming); `SinkHandler` + `HandlerKind::Sink`; `call.published` wire event; `invoke_sink()` dispatch; `Subscription` renamed to `Sub` |
| [047](decisions/047-openable-alpns-are-operations.md) | Openable ALPNs Are Operations | `channel/open` dissolves into per-ALPN ops `channels/<alpn>/sub`/`pub`; `channel_open` marker on `OperationSpec`; `ChannelCore` wrapper; extension-trait `ChannelOperationEnv`; connection-owner allocates `channel_id`; opener ledger (Gap 2 fix); ALPNs are call apps |
| [048](decisions/048-dispatch-spine-gateway-module.md) | Dispatch Spine (feature-gated `gateway` module) | `alkcall::gateway` behind the `gateway` feature; `GatewayDispatch` invoke spine (deadline knob, re-rooted context) + `schema_disclosure_denial` (FORBIDDEN for ACL deny, spec-404 for Internal); promoted from alkhttp for hub/spoke reuse |
| [049](decisions/049-channel-open-establishment-phase.md) | Channel-Open Establishment Phase | `OpenEstablisher` + `register_openable_with_establisher` (awaited, bounded); typed `channel:open_failed` with `details.reason`; `Establishment.plan` (`ChannelPlan`) threaded to the `OpenHandler` (amendment 2); the `JoinHandle` data-plane lifetime contract |
| [050](decisions/050-pump-bidi-two-pump-helper.md) | `pump_bidi` Two-Pump Helper | `channels::pump_bidi` — shutdown-on-completion two-pump data plane pinned in one place (alknet ADR-078) |
| [051](decisions/051-channel-relay-and-hub-leg.md) | In-Tree Channel Relay (`ChannelRelay`) and the Hub-Leg Assembly | ADR-042 amended — the relay implementation is an alkcall export; translate hop = ADR-049 establisher (`register_relay_openable`), byte-forward hop = `pump_bidi`; implicit per-channel id mapping; two-phase registration (discover/stash → fork-register); hub-leg install template; ACL layering note |
## Relevant Open Questions
@@ -272,7 +275,7 @@ feature flags) or in the downstream alknet crate.
- `@alkdev/alknet: docs/architecture/` — the source architecture docs
these were ported from (renumbered from alknet ADR-001..094 to alkcall
ADR-001..045)
ADR-001..048; ADR-049 and later were authored in this crate)
- `@alkdev/alktype` — the binary struct engine; compiles BAST documents
(e.g. `chunk-header.bast.json`) into readers/writers/validators
- `@alkdev/pubsub` — the TypeScript EventEnvelope prior art the call
@@ -288,5 +291,5 @@ feature flags) or in the downstream alknet crate.
> TTY's wire format, ADR-082 for alknet-tls, ADR-086 for endpoint types).
> These are ADRs for sibling crates that are not part of alkcall. They
> retain their alknet numbering (052, 082, 086, etc.) — any ADR number
> outside the alkcall range 001..045 is an alknet source ADR, found at
> outside the alkcall range 001..048 is an alknet source ADR, found at
> `/workspace/@alkdev/alknet/docs/architecture/decisions/`.
+42 -2
View File
@@ -68,12 +68,19 @@ impl ChannelClient {
/// the `MpscRecvStream` (read half). The caller can build a
/// `Connection` from these via `channel_source` and
/// `Connection::from_source`.
///
/// The error is typed (ADR-049 §4 — review 006 N-1):
/// `ChannelOpenError::CallFailed` carries the wire `CallError`
/// verbatim (branch on `establishment_reason()` for
/// `channel:open_failed`'s `details.reason`); `MissingChannelId`
/// covers a malformed success reply; `AdoptFailed` covers local
/// adoption failure.
pub async fn open_channel(
&self,
operation_id: &str,
input: Value,
alpn: &str,
) -> Result<(u32, MpscSendStream, MpscRecvStream), String>;
) -> Result<(u32, MpscSendStream, MpscRecvStream), ChannelOpenError>;
/// Take the `CallConnection` — used by the consumer to register
/// imported ops (`from_call`) on the connection's overlay. After
@@ -88,7 +95,11 @@ impl ChannelClient {
ADR-047 dissolved the generic `channel/open` operation into per-ALPN
open ops (`channels/<alpn>/sub`, `channels/<alpn>/pub`). Each ALPN
crate registers its own open op via `ChannelCore::register_openable`
(ADR-047 §3, as amended 2026-08-13 — per-connection registration).
(ADR-047 §3, as amended 2026-08-13 — per-connection registration),
optionally with an establisher via
`register_openable_with_establisher` (ADR-049 — the awaited,
bounded establishment phase; establishment failure resolves
`Err` on the client with `channel:open_failed` + `details.reason`).
The `ChannelClient` calls these ops by name on channel 0 via
`call_open_op`; `open_channel` wraps `call_open_op` + `adopt_channel`.
@@ -97,6 +108,35 @@ and `subscribe_resources` method are deferred (OQ-40, OQ-41). The
`ResourceEntry.access` preview was dropped by ADR-047 §6 — it is
available via `services/schema` on the op spec.
## The early-arrival park bound (push-first producers)
The open-op response carrying `channel_id` races the producer's first
data-plane writes: the producer's handler can start pumping before the
consumer's `adopt_channel` runs. The demux parks those first chunks
per-channel (FIFO) instead of dropping them, and `adopt_channel` drains
the parked chunks into the new receiver — a push-first producer (a TTY
backend's banner, a sub protocol's greeting) must not lose its first
chunks.
The park is bounded: **up to 64 chunks per channel** are parked
(`EARLY_ARRIVAL_CAP`, ADR-040's memory bounds); chunks arriving past
the cap are dropped silently — at the consumer this presents as a
truncated stream with clean framing everywhere else, not as an error.
Consumer-facing consequences (review 006 E-04, noted for tunnel-style
consumers):
- Size any pre-adopt buffering math (e.g. a UDP POC's MTU-vs-buffer
sizing) against **64 parked chunks as the observable bound**, and
adopt promptly — the open reply resolves *before* the first data
arrives by design, so the adopt is the consumer's next step, not a
slow path.
- The producer side observes both halves of the race via
`ChannelManager::early_arrival_count()` (chunks parked) and
`ChannelManager::dropped_unknown_chunks()` (chunks lost to the cap) —
a non-zero dropped counter under a push-first producer means the
adopter was too slow for the producer's burst, and the affected
streams were truncated at the park boundary.
## Transport-agnostic by construction
`ChannelClient` is the client side of the channels protocol. The channels
+32
View File
@@ -77,12 +77,39 @@ round-trip, so the open round-trip is not additive latency.
| `channel:forbidden` | `AccessControl::check` denied the open | false |
| `channel:allocation_failed` | Handler allocate failed (e.g., backend couldn't start) | true (often transient) |
| `channel:too_many_channels` | Per-connection or per-identity channel limit hit (ADR-040, ADR-041) | false |
| `channel:open_failed` | The establishment phase failed (ADR-049) — `details: { reason, message }`, reason ∈ `dial_failed` / `unknown_resource` / `resource_shortage` / `handler_error` / `timeout` | false |
| `channel:no_channels_session` | The op was invoked outside a channels session (no `ChannelManager` — ADR-047 §2) | false |
`channel:unknown_alpn` and `channel:invalid_params` (ADR-037) are gone —
an unregistered ALPN is an ordinary `NOT_FOUND` (op not registered); a
bad `input` is ordinary schema rejection.
### The establishment phase (ADR-049)
An openable ALPN may register an **establisher** alongside its open
handler (`ChannelCore::register_openable_with_establisher`). The
establisher is the awaited preparation step ADR-047 §3 described
("validate params, consult ownership, prepare the backend, return a
channel plan"): the wrapper awaits it **bounded** (the dispatch
deadline when the op carries one, else the registration's override or
the 10s `ESTABLISHMENT_TIMEOUT` — the earlier of the two), **before
replying and before spawning the pump handler**. On establisher
failure (error or deadline) the wrapper tears down the just-allocated
channel (demux sender, opener-ledger entry, `policy.on_close`
un-increment — the same atomic ledger-take gate every teardown path
runs) and replies `channel:open_failed` with
`details: { reason, message }` (the table row above). The SSH
contract holds consumer-visibly: a failed open never returns a
`channel_id`.
Registrations without an establisher behave exactly as before (the
open op cannot fail post-allocation; establishment work inside the
handler is invisible to the open reply). The bound applies only to
the establisher — the spawned pump handler's lifetime is governed by
the existing teardown machinery, unchanged. Head-of-line safety: the
serving loop spawns Once invocations as independent tasks, so a slow
establisher on one open op does not block other calls on channel 0.
### `channels/<alpn>/pub` — publish a binary stream
`OperationType::Pub` (ADR-046). The initiator publishes a stream of
@@ -443,6 +470,8 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [035](decisions/035-channels-pure-channel-multiplexing.md) | Pure Channel Multiplexing | No stream_types; handler owns sub-mux |
| [021](decisions/021-streaming-handler-for-subscriptions.md) | StreamingHandler | The machinery `channel/resources/subscribe` uses |
| [046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | Pub Operation Type | The `Pub`/`Sub` primitives the per-ALPN open ops build on |
| [049](decisions/049-channel-open-establishment-phase.md) | Channel-Open Establishment Phase | The establisher hook; typed `channel:open_failed`; `Establishment.plan` (amendment 2); the `JoinHandle` lifetime contract |
| [050](decisions/050-pump-bidi-two-pump-helper.md) | `pump_bidi` Two-Pump Helper | The data-plane helper openable-ALPN handlers await inline |
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens (and why the cap is per direct-caller, not per `forwarded_for`) |
| [011](decisions/011-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The parallel — a channel slot is a resource, the cap is a quota check; `resource_id_path` works again under per-ALPN ops |
@@ -450,6 +479,9 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
- ADR-047: openable ALPNs are operations (the unifying ADR — per-ALPN
open ops, `channel_open` marker, `ChannelCore` wrapper, opener ledger)
- ADR-049: channel-open establishment phase (the establisher hook,
`channel:open_failed`, the plan payload)
- ADR-050: `pump_bidi` (the two-pump helper)
- ADR-037: channel lifecycle operations (amended by ADR-047)
- ADR-041: per-identity channel cap (amended by ADR-047 §7 — opener
ledger, every teardown path)
@@ -2,23 +2,30 @@
## Status
Accepted (amended by ADR-021 — protocol-level code list extended to six)
Accepted (amended by ADR-021 — protocol-level code list extended to six;
amended 2026-09-07 — extended to eight, see §2a below)
## Context
The `OperationSpec` in alknet-call has `input_schema` and `output_schema` but
no `error_schemas`. The `call.error` payload (call-protocol.md L128–134)
carries a `code` and `message`, where `code` is one of six infrastructure
carries a `code` and `message`, where `code` is one of eight infrastructure
codes: `NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`, `INVALID_OPERATION_TYPE`,
`INTERNAL`, `TIMEOUT`.
`INTERNAL`, `TIMEOUT`, `ALREADY_EXISTS`, `CONNECTION_CLOSED`.
These six codes cover **protocol-level failures** — the call protocol
These eight codes cover **protocol-level failures** — the call protocol
itself can always fail to find an operation, deny access, reject bad input,
reject the wrong dispatch method for the operation type, time out, or hit
an internal error. They are emitted by the dispatch machinery (the registry,
reject the wrong dispatch method for the operation type, time out, hit an
internal error, reject a registration collision, or report a provably
undelivered call. They are emitted by the dispatch machinery (the registry,
the adapter), not by operation handlers. `INVALID_OPERATION_TYPE` was added
by ADR-021 (streaming handler for subscriptions — `invoke()` called on a
`Subscription`, or `invoke_streaming()` on a `Query`/`Mutation`).
`ALREADY_EXISTS` was added by the ADR-022 collision sub-amendment
(2026-09-04 — `op/register` rejects a peer-announce collision; non-retryable,
registration is state). `CONNECTION_CLOSED` was added by the consumer
findings ledger CF-001 (2026-08-30 — write failures on provably undelivered
`call.requested` frames; **retryable** — see §2a).
But operations also have **domain-level failures** that are not covered:
@@ -186,7 +193,7 @@ optional-array convention.
### 3. Protocol-level vs operation-level error codes
The six existing codes are **protocol-level** — emitted by the dispatch
The eight existing codes are **protocol-level** — emitted by the dispatch
machinery, not by handlers:
| Code | Emitted by | Meaning |
@@ -197,6 +204,20 @@ machinery, not by handlers:
| `INVALID_OPERATION_TYPE` | Registry / `OperationEnv` | Wrong dispatch path for the operation's type (`invoke()` on a `Subscription`, `invoke_streaming()` on a `Query`/`Mutation`, or `OperationEnv::invoke()` on a `Subscription` during composition — ADR-021) |
| `INTERNAL` | Registry / Adapter | Handler panic, unhandled error, connection failure |
| `TIMEOUT` | Adapter | Request timed out |
| `ALREADY_EXISTS` | Registry (`op/register` collision gate) | A peer-announce or import collides with an existing registration (`replace: false`, or a serving-side name — ADR-022). Non-retryable without `replace: true`. |
| `CONNECTION_CLOSED` | Client write path | The `call.requested` frame could not be written — the call is provably undelivered (CF-001). `retryable: true` — the only protocol code a caller may auto-retry. Mid-publish and completed-frame write failures stay `INTERNAL` (delivery ambiguous). |
#### 2a. `CONNECTION_CLOSED` — the retryable undelivered-call code (CF-001)
The write-failure mapping distinguishes **provably undelivered** from
**delivery-ambiguous**: a failed write of the *request* frame means the
producer never saw the call — reconnect/retry is safe. A failed write
*after* delivery started (mid-publish, completed frame) is ambiguous —
retry unsafe — and stays `INTERNAL`. The producer-side `fail_all(...)` on
connection close also stays `INTERNAL` (the callee cannot know what the
caller received). The code is additive to the wire vocabulary; consumers
treat unknown codes per their existing policy, with the `retryable` flag
as the machine-readable signal.
Operation-level domain codes are emitted by **handlers** — the operation's
own logic determines what went wrong. They are declared in `error_schemas`
@@ -247,9 +268,9 @@ accordingly.
```
**Normative rule (review #002 W20)**: `from_openapi` must not produce error
codes that collide with the six protocol-level codes (`NOT_FOUND`,
codes that collide with the protocol-level codes (`NOT_FOUND`,
`FORBIDDEN`, `INVALID_INPUT`, `INVALID_OPERATION_TYPE`, `INTERNAL`,
`TIMEOUT`). The adapter prefixes
`TIMEOUT`, `ALREADY_EXISTS`, `CONNECTION_CLOSED`). The adapter prefixes
imported error codes with `HTTP_` and the status number (e.g., `HTTP_404`,
`HTTP_429`) to avoid collision. This is a requirement for the adapter, not
a naming convention — the `from_openapi` example above was previously shown
@@ -410,6 +431,10 @@ enum instead of a generic `Result<Output, string>`.
- ADR-021: Streaming handler for subscriptions (amends this ADR's
protocol-level code list — `INVALID_OPERATION_TYPE` added as the sixth
protocol-level code)
- ADR-022 (collision sub-amendment 2026-09-04): `ALREADY_EXISTS` — the
`op/register` collision rejection code
- Consumer findings ledger CF-001 (2026-08-30): `CONNECTION_CLOSED` —
the retryable provably-undelivered-call code
- docs/sdd_process.md L19, L423 (Safe Exit protocol — the general principle
of making failure typed and declared)
- TypeScript reference: `/workspace/@alkdev/operations/src/types.ts`
@@ -402,12 +402,32 @@ surfaces discovery failure as `AdapterError::DiscoveryFailed`).
`ChannelClient::from_connection_with_serving(connection,
Option<ServingConfig>)` — with `None` (the pure-consumer default) the
read pump resolves outbound pendings only (previous behavior). With
`Some(ServingConfig { registry, identity_provider })` the read pump
becomes the full-duplex serving loop (`Dispatcher::serve_single_stream`):
inbound `call.requested` frames dispatch against the configured
registry and resolve back to the peer; outbound pendings still resolve
in the same loop. Serving is opt-in because a pure consumer has no
registry to serve; the *protocol* is symmetric, the *API* is explicit.
`Some(ServingConfig { registry, identity_provider, identity })` the
read pump becomes the full-duplex serving loop
(`Dispatcher::serve_single_stream`): inbound `call.requested` frames
dispatch against the configured registry and resolve back to the peer;
outbound pendings still resolve in the same loop. Serving is opt-in
because a pure consumer has no registry to serve; the *protocol* is
symmetric, the *API* is explicit.
**Caller identity for the serving dispatch (CF-005, 2026-09-07).**
`from_connection_with_serving` builds channel 0 internally, so before
the remediation there was no capture point for the
transport-authenticated peer: the serving dispatch resolved identity
only from the payload `auth_token`, and a scope-gated serving op could
not authenticate a key-based (mTLS/QUIC) peer by transport identity.
The remediation makes the identity resolution, in precedence order:
(1) the payload `auth_token` → `ServingConfig.identity_provider`
(hub-forwarding and browser-token path, ADR-017 §7); (2) the
`ServingConfig.identity` override (explicit, e.g. an
assembly-layer-resolved principal); (3) the transport connection's
identity, propagated automatically — `from_connection_with_serving`
copies `connection.identity()` onto the channel-0 connection via
`set_identity` before the serving loop starts, mirroring the accept
side, where the adapter hands the install hook the transport
`AuthContext` and the hook sets the channel-0 identity. With no
identity from any path the dispatch runs identity-less and
`AccessControl::check` fails closed (`FORBIDDEN`).
Direction disambiguation in the loop is by table membership, not
framing: an id that is one of *our* outbound pendings resolves there;
@@ -2,7 +2,52 @@
## Status
Accepted
Accepted (amended 2026-09-17, ADR-051 — the relay implementation is an
alkcall export (`ChannelRelay`), not downstream-only code; the §"Scope
note" below is revised, and the §`channel_id` mapping / close-translation
mechanism is superseded by an implicit-per-channel shape — see
"Amendment (ADR-051, in-tree relay export, 2026-09-17)" below;
the translate-not-forward contract and the auth-model rationale are
unchanged)
## Amendment (ADR-051, in-tree relay export, 2026-09-17)
The relay implementation moved into alkcall as a reusable export
(`ChannelRelay`, ADR-051) — consumers that exist (alktunnels'
graduation, alkhttp's fallback hub, alknodes) all need the same shape,
so the §"Scope note" decision ("the relay implementation lives in
`alknet-hub`/downstream") is revised: hub crates COMPOSE the export.
Two implementation findings supersede mechanism text above, both
within the two-way door this ADR already marked:
1. **The `channel_id` mapping dissolves.** §`channel_id` mapping
pinned a `HashMap<channel_id, channel_id>` per (browser, spoke)
pair. The implementation needs no map: each leg's `ChannelManager`
holds its own id → routing state, and the per-channel pump closure
binds both legs. The mapping is implicit-per-channel — a
`channel_id` never crosses legs, so the translate hop produces
per-leg-truthful ids (this also refines alktunnels ADR-008 §Hub
re-produce's "re-map the id at their re-produce hop naturally":
the re-map is two independent allocations, not a rewrite).
2. **`channel/close` needs no translation surface.** The §mapping
bullet's "On `channel/close` (translated the same way), the mapping
is removed" assumed the map; without it, close propagates through
the EOF cascade (consumer-side close → open-op wrapper teardown →
pump abort → send-half drop → EOF chunk → spoke handler exits →
spoke's own wrapper decrements its ledger) with correct accounting
on both legs. The hub never sees a `channel/close` to translate.
`channel/control` stays per-leg (OQ-39 — a hub wanting cross-leg
control translation composes it downstream), unchanged.
The byte-forward layer is ADR-050's `pump_bidi` per channel (the
hub never parses chunk framing — stronger than §"Data-channel layer"'s
rewrite phrasing, which assumed the map-based rewrite shape). The
translate layer is the ADR-049 establisher shape
(`register_relay_openable`), keeping the consumer leg's full wrapper
machinery (registry ACL, per-identity cap/ledger, establishment bound,
teardown-on-failure) — the auth-model rationale above is unchanged and
now has a concrete in-tree carrier.
## Context
@@ -8,7 +8,128 @@ Accepted (amends ADR-037; refines ADR-044, ADR-046; §4 amended
registration, 2026-08-13)"; 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**, not the
connection overlay — see "Amendment (§4 mechanism, 2026-09-03)" below)
connection overlay — see "Amendment (§4 mechanism, 2026-09-03)" below;
§6 amended 2026-09-06 — the static half of discovery gains an additive
per-op `description` on the listing, the dynamic half stays deferred
— see "Amendment (§6 listing enrichment, 2026-09-06)" below;
amendment 3 (2026-09-16, review 008 U-1) — the op-name convention
extends to the flavor form and discovery carries an explicit
`channel_open_alpn` for non-derivable names — see "Amendment 3" below)
## Amendment 3 (flavor-form open-op ids + explicit `channel_open_alpn`, 2026-09-16 — review 008 U-1)
alktunnels' graduation (ADR-002 Amendment 1 at
`/workspace/@alkdev/alktunnels/docs/architecture/decisions/002-alpn-strategy.md`)
pins two NEW `Sub`-typed open ops on the existing `alk/tunnel` ALPN
with flavor-form op ids (`channels/tunnel/direct`,
`channels/tunnel/forwarded`) — the ssh
`direct-tcpip`/`forwarded-tcpip` shape: one ALPN, several channel
flavors, one open op per flavor. §1 pinned the open-op naming to
exactly two shapes per ALPN (`channels/<alpn>/sub`,
`channels/<alpn>/pub`), and Gap F's marker reconstruction derived the
ALPN by stripping exactly those two suffixes — so a flavor-form op id
failed the derivation and a hub consuming through discovery rebuilt
the spec WITHOUT the marker (the silent plain-forwarding-stub failure,
the worst mode for a relay). Runtime was never blocked (registration
sets the ALPN explicitly; `open_channel` takes it as an argument);
discovery + hub relay are the re-produce path that must survive.
**The convention sentence (mirroring alktunnels ADR-002 Amendment 1):**
`channels/<alpn>/<flavor>` is a valid open-op name where `<flavor>`
is a bare path segment (no `/`), `Sub`-typed, served through the same
establishment wrapper and relay path as `…/sub`. New flavors are new
op ids — additive; the `…/sub` op is never reused for a different
meaning. `OperationType` (`Sub`/`Pub`) continues to carry the
direction; the flavor is the channel-type discriminator the producing
crate owns (per-ALPN params semantics, per alktunnels ADR-001
Amendment 1).
**The wire shape (Gap F refinement):** the boolean marker's
round-trip is derivable only for the standard shapes. The rule:
- `spec_to_json_pub` keeps emitting `"channel_open": true` for every
marked op (unchanged). When the op name is NOT the standard
`channels/<segment>/(sub|pub)` shape, it ALSO emits
`"channel_open_alpn": "<alpn>"` — the explicit string rides beside
the boolean. Standard shapes stay byte-identical to the
pre-amendment payload (no new key).
- `rebuild_spec_for` prefers the explicit string when present; else
(the boolean alone — the deployment-skew case of an old producer)
the derivation generalizes from "strip `/sub`|`/pub`" to "strip the
LAST path segment." The boolean marker remains the gate: the
derivation is consulted only for marked ops, so a plain op named
`channels/tty/query` is unaffected, and a `channel_open_alpn`
string without the boolean never marks an op.
- `services/schema`'s advertised `operation_spec_schema` documents
`channel_open_alpn` as an optional `["string", "null"]` property
(schema-type widening, additive — old consumers ignore unknown
properties).
- Both wire consumers of the shape — the `from_call` import and the
`op/register` announced-spec path — parse through the same
`rebuild_spec_for`, so one parser change covers both.
**Residual ambiguity, pinned:** an op name whose ALPN segment itself
ends in a flavor-like name (`channels/x/direct` where the intended
ALPN is `alk/x/direct`-shaped) is undecidable from the name alone —
strip-last reads `alk/x`, and the true ALPN is unknowable from the
name. The explicit `channel_open_alpn` string is the disambiguator:
producing crates with non-`alk/*`-derivable names set it; the
derivation is the fallback for derivable shapes. Old producers
(alkcall ≤ 0.7.1) cannot serve flavor-form marked ops discoverably
until upgraded — the review's one-way-door timing note.
Door type: the flavor-form names and the additive
`channel_open_alpn` key are wire-stable from the first consumer
(alksocks composes against them); the boolean's meaning for
standard-shape ops is unchanged (the pre-amendment byte-stability is
pinned by test). The `op_name_is_standard_channel_open_shape` helper
and the strip-last derivation are two-way-door implementation details
within the one-way wire shape.
Implemented surface (alkcall 0.8.0): the generalized
`derive_alpn_from_op_name` (strip-last), the explicit-field emission
(`spec_to_json_pub` + the advertised schema property), and the
preference order in `rebuild_spec_for`. Verification gates landed as
tests: `channels/tunnel/direct` reconstructs WITH
`channel_open = alk/tunnel` (gate 1, both the explicit-string and the
skew-case paths); standard boolean ops (`channels/tty/sub`,
multi-segment `channels/custom/proto/sub`) round-trip byte-stable
(gate 3); the explicit string overrides a colliding derivation; a
string without the boolean never marks; `op/register` announced
flavor-form specs round-trip with the marker. Gate 2 (the hub-relay
round trip) lands with the in-tree relay component (review 008
remediation plan Unit 3c, amending ADR-042 — the relay export is
ADR-051).
## Amendment (§6 listing enrichment, 2026-09-06)
Review 006 E-02 (from the alktunnels Phase 0 sweep) made §6's dynamic
half load-bearing for the first time and asked for a decision on the
static half. Decision: **both halves of the "static per-op" split gain
what is cheap today; the dynamic half stays deferred** (OQ-40, now
with a "load-bearing for alktunnels discovery UI" note in
`open-questions.md`; alktunnels v1 uses config-known op names):
- `OperationSpec` gains `description: Option<String>` — a human-
readable op description, set via `with_description` at registration.
Additive (defaults `None`; no struct-literal construction sites
exist — all sites use `OperationSpec::new`).
- `services/list` (and the local-ops half of `services/list-peers`)
emit `description` when set — one round-trip answers "which ops
exist and what are they for" without the N+1 `services/schema`
sweep. The output-schema docs on both listing specs and on
`services/schema`'s `operation_spec_schema` advertise the field.
- `spec_to_json_pub` emits it when set; `rebuild_spec_for` parses it
back — the description survives discovery and peer announcement
(`from_call`, `op/register`) like every other additive spec field
(`resource_id_path`, `publish_schema` round-trip the same way).
Scope note from E-02 stands: the listing field describes **the op**,
not **the produced resource set** (a tunnel producer registers one op
and N resources). "Which tunnel resources may I open, live" remains
`channel/resources/subscribe`'s job (§6's dynamic half, ADR-037 §
`channel/resources/subscribe`) — deferred until a consumer needs live
resource discovery.
## Amendment (§4 mechanism, 2026-09-03)
@@ -0,0 +1,481 @@
# ADR-049: Channel-Open Establishment Phase (`OpenEstablisher`)
## Status
Accepted — implemented in alkcall 0.5.0 (Unit 1: E-01 + N-1; see the
"Amendment (Unit 1 implementation, 2026-09-06)" at the bottom).
Amends ADR-047 §3 — the open-op wrapper gains an awaited
establishment phase ahead of the spawned pump handler; resolves review
006 E-01 and N-1. Amendment 3 (2026-09-16, review 008 U-2) adds the
establisher reply projection — see "Amendment 3" below.
## Context
ADR-047 §3 made openable ALPNs operations: the ALPN crate supplies an
open handler, and the channels wrapper does the channel machinery
(allocation, ledger, policy, spawn). The §3 decision text describes the
handler's job as "validate params, consult ownership, prepare the
backend, return a 'channel plan'" — an awaited preparation step the
wrapper consults **before** replying. The implemented `OpenHandler`
type (`Arc<dyn Fn(Value, Connection, AuthContext) -> JoinHandle<()>`)
collapsed that preparation into a fire-and-forget spawn: the wrapper
collects the `JoinHandle`, records it for teardown, and writes
`{ "channel_id": <id> }` to the wire the moment the handler task is
*spawned* (`run_open_wrapper`, `src/channels/operations.rs`). The
establishment phase the ADR described never became a thing the wrapper
could consult.
The consequence (review 006 E-01, verified at tree `88e3f5e`): the open
op **cannot fail after allocation**. Any establishment failure inside
the handler — params valid at the schema level but semantically
rejected, backend lookup failure, a target dial refused for a
`direct-tcpip`-shaped tunnel, a resource no longer available — is
invisible to the open reply. The consumer observes: the call op
succeeds with `{channel_id}`, the channel is adopted, and then the
channel EOFs (the handler exits without writing; the mux pump writes
the implicit-EOF chunk; `MpscRecvStream::poll_read` returns clean EOF
for both the sentinel and sender-drop arms). A dial failure is
byte-for-byte indistinguishable from a target that closed immediately
after connecting — the two most different failure/success stories map
to the same consumer-visible event.
Every established tunnel/forwarding protocol puts establishment failure
in the open reply, not in the data stream:
- **SSH** (RFC 4254 §5.1): `SSH_MSG_CHANNEL_OPEN_FAILURE` is a
first-class reply carrying a reason code
(`ADMINISTRATIVELY_PROHIBITED` / `CONNECT_FAILED` /
`UNKNOWN_CHANNEL_TYPE` / `RESOURCE_SHORTAGE`) plus a description
string; the channel never exists on the opener's side afterward.
- **SOCKS5** (RFC 1928 §6): the reply carries REP codes 0x01–0x08;
error-then-close, never "success then in-stream error."
- **udpgw** (tun2proxy) is the counterexample: an opaque ERR bit with
zero reason information — the vocabulary to avoid.
The per-crate workaround proves the gap is load-bearing: alktty's
channels path answers establishment failures with a length-prefixed
JSON error frame **on the channel stream**
(`send_negotiation_error`, `alktty/src/adapter.rs`), disambiguated from
data by a `0x00` first-byte peek. That is a per-crate reinvention of a
protocol-level capability every ALPN crate will need: a structured,
typed, **establishment-failure reply to the open op**. It also forces
the phantom-opened channel to exist in the manager — allocation, ledger,
and policy all fire for a channel that never carries data.
alktunnels (the next consumer, arbitrary TCP/UDP tunnels over channels)
hits this on day one: its producer dials the tunnel target inside the
open op, and dial failure is the *common* case, not the edge case
(review 006 E-01; alktunnels OQ-TN-09).
This is the cheapest moment to fix upstream: three downstream crates
(alktty, alkhttp, alktunnels-in-progress), alkcall 0.4.x, no published
consumer depends on the phantom-open shape.
## Decision
### 1. The open-op wrapper gains an awaited establishment phase
`ChannelCore::register_openable` accepts an optional
**establisher** alongside the existing `OpenHandler`:
```rust
/// The establishment result. `Establishment` carries what the
/// pump phase needs (today: nothing — reserved for a channel plan).
/// `EstablishmentError` carries the reason code + message the
/// wrapper puts in the open reply's `details`.
pub type OpenEstablisher = Arc<
dyn Fn(Value, Connection, AuthContext)
-> BoxFuture<'static, Result<Establishment, EstablishmentError>>
+ Send
+ Sync,
>;
```
The establisher is **awaited by the wrapper, bounded** — before the
reply is written, before the pump handler is spawned. The pump handler
(the existing `OpenHandler`, unchanged) is spawned only on
establishment success. The dial is the natural establisher step for
tunnels; the pumps remain the spawned handler. This restores ADR-047
§3's original "channel plan" shape: the establisher is the awaited
preparation, the wrapper consults its result, the pumps are the
spawned protocol.
**Why split-hook, not await-and-inspect** (the two candidates review
006 proposed):
- The split preserves the `OpenHandler` type exactly. Await-and-inspect
changes `OpenHandler`'s return type (`JoinHandle<()>` →
`JoinHandle<OpenResult>`), breaking all three consumers' handlers and
the alkhttp `OpenableAlpn` ferry for no compensating gain.
- Establishment and pump are genuinely different lifecycles. The dial
is synchronous with the open reply (SSH's semantics: the failure is
the open's reply); the pumps outlive the reply. Coupling the
establishment signal to the pump task's lifecycle (watching a
`JoinHandle` for a first resolution) conflates them and makes the
"established, continue" signal an out-of-band convention (a sentinel
`Result` value, a oneshot the handler must remember to signal) —
more protocol per crate, the thing this fix exists to remove.
- The split is backward compatible by construction: no establisher
registered = an always-OK establisher. Existing registrations compile
and behave unchanged.
### 2. The deadline bounds the establisher, not the pumps
The establisher await is bounded by the dispatch deadline when the
`OperationContext` carries one (`context.deadline`), else by a crate
constant (`ESTABLISHMENT_TIMEOUT`, 10s default; overridable per
registration via a `Duration` argument on the establisher-taking
`register_openable` variant). On deadline expiry the wrapper treats it
as establishment failure with reason `timeout`.
The bound applies **only** to the establisher. The spawned pump
handler's lifetime is governed by the existing teardown machinery
(`channel/close`, connection drop, handler exit) — unchanged.
Head-of-line safety is already proven: the serving loop spawns Once
invocations as independent tasks
(`Dispatcher::spawn_once_dispatch`), so a slow establisher on one open
op does not block other calls on channel 0.
### 3. Establishment failure: teardown + typed `channel:open_failed`
On establishment failure (error or deadline), the wrapper:
1. Tears down the just-allocated channel
(`teardown_channel` — drops the demux sender, returns the not-yet-
installed handler task handle if any),
2. Takes the opener-ledger entry and calls `policy.on_close(opener)`
(the same un-increment path the allocation-failure arms already
run — the ledger `take` is the atomic gate, ADR-047 §7),
3. Replies with a new typed error:
```
code: "channel:open_failed"
message: human-readable establishment failure description
retryable: false
details: { "reason": <reason-code>, "message": <detail string> }
```
The reason-code vocabulary maps 1:1 onto what an establisher can
actually produce (per the SSH four; the survey's finding):
| reason | meaning |
|---|---|
| `dial_failed` | the backend/target could not be reached or refused |
| `unknown_resource` | the requested resource does not exist |
| `resource_shortage` | the backend is out of capacity (ports, fds, slots) |
| `handler_error` | establisher-internal failure not covered above |
| `timeout` | establishment exceeded the deadline |
Policy denial stays `channel:too_many_channels` (pre-allocation,
unchanged); ACL denial stays `FORBIDDEN` (registry gate, unchanged).
The new code is an additive wire addition (new error-code string +
optional `details` shape); no existing consumer breaks. ALPN crates'
open-op specs gain matching `ErrorDefinition` entries per ADR-016 so
`services/schema` discloses the failure contract.
The SSH "channel never exists opener-side" property is the contract:
the consumer's open resolves `Err` and no `channel_id` was ever
returned. (The allocation still happened accept-side momentarily —
that is invisible to the consumer and is what the teardown in step 1
cleans up.)
### 4. `ChannelClient::open_channel` stops erasing the error (review 006 N-1)
`ChannelClient::open_channel` currently flattens the `CallError` into a
`String` (`format!("open op failed: {e:?}")`), which would make the
typed reason invisible to consumers — the E-01 fix would be unreachable
end-to-end through the primary client path. It changes to return a
typed error carrying the `CallError` (a new
`ChannelOpenError { error: CallError }` or equivalent), so the
consumer branches on `channel:open_failed` + `details.reason`.
This is a breaking change to a method signature introduced in this
crate's 0.4.x — acceptable at 0.5.0 (see Consequences), and it is the
point of the change: the reason must be consumer-usable.
### 5. Compatibility and migration
- `OpenHandler`'s type is unchanged. Existing registrations compile
unchanged.
- `ChannelCore::register_openable` keeps its current signature
(no establisher = always-OK); a new
`register_openable_with_establisher(spec, establisher, open_handler,
registry, auth)` variant adds the hook. alkhttp's `OpenableAlpn`
gains an optional `establisher` field (default `None`) — the ferry
passes it through mechanically.
- alktty migrates its **channels path** semantic failures (unknown
backend, `carriage != "raw"`, `allocate_failed`, ownership denial —
currently post-open error frames) into the establisher, resolving
them as `channel:open_failed`. Its direct-ALPN path **keeps** the
in-band error frame (two transports, two contracts; the direct path
has no open op to fail). The `0x00`-peek disambiguation stays for
the direct path only.
- alkhttp is unaffected (no openable ops in the default surface; the
`OpenableAlpn` change is additive).
### 6. Panicked pump handlers stay EOF-shaped (pinned as designed)
The wrapper's teardown task swallows the pump handler's `JoinError`
(`let _ = raw_task.await`). A panicked pump = instant EOF, which is
the correct consumer-visible outcome for a mid-stream handler crash
(indistinguishable from an abrupt close — there is no error channel
mid-stream by design; establishment errors are the only kind that
belong in the open reply). This ADR pins that as intended; no change.
The establisher, by contrast, runs pre-reply — its panic (a future
that panics when polled) surfaces as the spawned Once task's panic,
which the serving loop already tolerates (the call never resolves;
the deadline / client timeout is the bound). Establisher
implementations return `EstablishmentError` instead of panicking, per
this crate's no-panic convention.
## Consequences
**Positive:**
- Establishment failure reaches the consumer as a typed, branchable
call error — retry policy, client UX, and error reporting become
possible for dial-refused, unknown-resource, and shortage cases
(previously: instant-EOF ambiguity).
- The SSH contract ("the channel never exists opener-side") holds
consumer-visibly: a failed open never returns a `channel_id`.
- No phantom channels: the ledger, policy count, and manager state are
restored atomically on failure — allocation and teardown balance.
- alktty's per-crate in-band error vocabulary is retired on the
channels path; every future ALPN crate (alktunnels first) gets the
establishment reply for free.
- ADR-047 §3's "channel plan" shape is realized: awaited preparation
before reply, spawned pumps after.
**Negative:**
- `channel:open_failed` + the reason vocabulary is a new wire-visible
error surface — additive, but it joins the stable error set
consumers may branch on (per ADR-016, `details` shapes are
discoverable via `services/schema`).
- `ChannelClient::open_channel`'s error type changes (breaking at
0.5.0; mechanical for consumers — the `String` was a
debug-formatting wrapper anyway).
- `OpenableAlpn` (alkhttp) gains a field; its two construction sites
add `None` (mechanical).
- The establisher await adds a bounded latency to open-op replies
where handlers previously replied instantly (the spawn). The 10s
default is the worst case for a hung establisher; real establishers
(dial, lookup) complete in dial-time. Consumers already tolerate
call-op latency; the deadline is the bound.
## Door type
**One-way (wire-visible error surface).** `channel:open_failed` and its
`details.reason` vocabulary join the stable error set: once consumers
branch on reason codes, changing the vocabulary requires a migration
(the same one-way-ness ADR-016 gives typed error details). The
establisher hook shape itself — `OpenEstablisher`, the
`register_openable_with_establisher` variant, the
`Establishment`/`EstablishmentError` types — is a **two-way-door
implementation detail** within the one-way decision (the wrapper shape,
per ADR-047 §3's own door-type note). The `OpenHandler` type is
untouched, which is what keeps the split cheap to revise.
## Implementation units
1. **alkcall 0.5.0** — `OpenEstablisher` +
`register_openable_with_establisher`; wrapper flow (await bounded →
teardown-on-failure → `channel:open_failed` with details);
`ChannelClient::open_channel` typed error (N-1); tests:
- establisher fails after allocation → consumer's `open_channel`
resolves `Err(channel:open_failed)` + reason details; channel
absent from `channel_ids()` afterward;
- establisher never completes → `timeout`-reason failure within the
deadline, channel torn down, ledger decremented;
- no-establisher registration behaves exactly as today (compat
gate);
- establisher success spawns pumps and replies `{channel_id}`
unchanged.
2. **alktty migration** — channels-path semantic failures move into an
establisher; `open_via_channels_surfaces_negotiation_rejected`
resolves via call error; the direct-ALPN error-frame path is
retained.
3. **alkhttp pass** — `OpenableAlpn.establisher: Option<...>` (default
`None`), threaded through the session fork (mechanical).
## References
- Review 006 E-01 (the establishment gap — findings and prior-art
survey), N-1 (the client error-type gap this ADR also resolves),
E-03/E-04 (adjacent teardown/early-arrival notes, filed separately
from this ADR's scope)
- ADR-047 §3 (openable ALPNs are operations — the "channel plan"
wrapper shape this ADR restores; §7 opener ledger — the teardown
un-increment path)
- ADR-016 (typed error schemas — the `details` vehicle)
- ADR-040/041 (backpressure/caps — untouched; the teardown path keeps
the ledger `take` as the atomic gate)
- alktty ADR-009 (the open op's input is the negotiation) + review 001
L1/L3 — the in-band mechanism retired on the channels path
- alktunnels OQ-TN-09 (dial-failure reporting — the first consumer of
the new error) and `docs/research/ssh-socks5-survey.md`
§"Open-failure path" (the reason-code prior art)
- RFC 4254 §5.1, RFC 1928 §6 — SSH/SOCKS5 open-failure semantics
## Amendment (Unit 1 implementation, 2026-09-06)
Unit 1 landed in alkcall 0.5.0. Two implementation-shape notes, both
within this ADR's two-way door (the hook shape is the revisable
implementation detail; the wire surface is unchanged from §1/§3):
1. **The establisher does not receive the channel `Connection`.** §1's
signature sketch passed `Connection` to the establisher, but the
channel's `BiStream` is yield-once (`ChannelBidiStreamSource`) — it
cannot be handed to both the establisher and the pump handler, and
the establisher is pre-data-plane by design (its dial targets the
backend, not the channel). The implemented signature is
`Fn(Value, AuthContext) -> BoxFuture<'static,
Result<Establishment, EstablishmentError>>`; the `Connection`
belongs exclusively to the `OpenHandler` (unchanged).
**Identity semantics (CF-005 corollary, 2026-09-07):** the
`AuthContext` the establisher and the pump handler receive is the
**per-call** context — the opener's dispatch-resolved identity (the
same identity the ACL gate and the per-identity cap check saw)
overlaid onto the install-time context; transport-truthful fields
(`alpn`, `remote_addr`, `tls_client_fingerprint`) stay
install-time. On a per-connection registry (ADR-047 §4) this is
identical to the install-time context; on hub-forwarded opens the
establisher sees the end client, not the hub.
2. **The bound is the earlier of the dispatch deadline and the
per-registration timeout.** §2 names the dispatch deadline "when
the `OperationContext` carries one, else the crate constant";
implemented as `min(deadline_remaining, timeout_override |
ESTABLISHMENT_TIMEOUT)` — the registration override stays
meaningful for `Query`/`Mutation`-typed open ops (whose dispatch
carries a 30s deadline; `Sub` clears it), and a deadline already in
the past yields a zero bound (immediate `timeout` reason).
Implemented surface: `OpenEstablisher`, `Establishment`,
`EstablishmentError` (reasons `dial_failed`/`unknown_resource`/
`resource_shortage`/`handler_error` + the wrapper's `timeout`),
`ESTABLISHMENT_TIMEOUT` (10s), `CHANNEL_OPEN_FAILED`
(`channel:open_failed`), `ChannelCore::register_openable_with_establisher`
(`register_openable` delegates with `establisher: None`),
`ChannelOpenError` (client-side typed error: `CallFailed { error:
CallError }` / `MissingChannelId` / `AdoptFailed`, with
`call_error()` + `establishment_reason()` accessors). All four
verification gates from the review landed as tests (establisher
failure e2e through a real channels connection with ledger
un-increment + no-channel assertions, bounded timeout, no-establisher
compat, establisher-success pump round-trip).
## Amendment 2 (plan payload, 2026-09-07 — review 007 R-01/R-02)
Review 007 (from the alktunnels UDP POC) filed two follow-ups on the
establishment surface; both landed in alkcall 0.6.0.
**1. `Establishment` carries the channel plan (R-01).** §1 reserved
the payload ("today: nothing") and the wrapper consulted only
success/failure — so an establisher whose backend produces a handle
(a dialed socket, a TTY allocation) had to cross it to the pump
handler through a per-crate side channel. The alktunnels POC shipped
a resource-keyed slot + poll loop whose concurrent same-resource race
is unfixable within that shape; alktty documented the same wall
(backend `allocate` cannot cross, so failure classes stayed in-band —
the phantom-channel shape ADR-049 removed, alive one layer down).
The plan is now real: `Establishment { plan: Option<ChannelPlan> }` with
`ChannelPlan = Arc<dyn Any + Send + Sync>` — **typed-opaque, not
`serde_json::Value`**. The review's `Option<Value>` sketch could not
satisfy its own verification gate ("establisher dials, `plan` carries
the handle"): the payloads establishers actually hand off are live
handles with no JSON representation. The establisher and the
`OpenHandler` agree on the concrete type; alkcall never inspects it.
The wrapper threads `establishment.plan` to the handler's new second
parameter (`OpenHandler = Fn(Value, Option<ChannelPlan>, Connection,
AuthContext) -> JoinHandle<()>`); the separate-parameter shape wins
over merging into `input` because a typed payload cannot ride the
JSON input without a downcast-side registry and the reserved-key
collision the review already anticipated. The plan is process-local
(establisher → wrapper → handler on the producing side); the wire
surface is unchanged — nothing crosses the transport that isn't
already the open op's input. `#[non_exhaustive]` on `Establishment`
keeps a future carrier change from being another breaking release.
Construction is `Establishment::new(plan)` /
`Establishment::default()`; the 0.5.0 `Ok(Establishment {})` sites
break mechanically at 0.6.0, which is the point of landing this now
(before alktunnels Phase 1 ships the side-channel shape into a real
crate and the payload lands later anyway as a second break).
**2. The `OpenHandler` lifetime contract is documented (R-02).** The
wrapper awaits the returned `JoinHandle` and its completion triggers
teardown — so the handle must track the data-plane lifetime: a
handler that returns before its pumps finish tears the channel down
at birth (the POC's first pump implementation hit exactly this: every
tunnel connected then instantly EOF'd). The contract was implemented
but never documented; the type docs now state it ("await the pumps
inline, never spawn-and-forget and return early") on `OpenHandler`
and the registration entry points, plus a `debug!` telemetry line in
`run_open_wrapper` when a handler exits without having accepted the
channel's `BiStream` (the birth-teardown hint; the accept is
observable in-process via the yield-once source). §6's pinned
EOF-shaped panic semantics are unchanged.
## Amendment 3 (establisher reply projection, 2026-09-16 — review 008 U-2)
Amendment 2 filled the establisher → handler direction (the plan);
the establisher → opener direction stayed empty — the wrapper
hardcodes the success reply to `channel_id`
(`run_open_wrapper`'s `ResponseEnvelope::ok`). alktunnels ADR-008's
bind-first listen establisher ends establishment at bind time, and the
observed OS-chosen bound address must ride the open-op reply as an
additive `"bound"` field (the SOCKS5 BIND reply#1 `BND.ADDR` fidelity
ask) — the alternative (an out-of-band query op) is a new wire
surface, strictly worse than an optional reply field.
**The decision: `Establishment` gains optional reply fields the
wrapper merges into the success output.**
- The carrier: `Establishment.reply_fields: Option<Map<String,
Value>>` (private, builder-constructed) —
`Establishment::new(plan).with_reply_field("bound", json!({...}))`,
plus `with_reply_fields(map)` (batch) and `reply_fields()` (read).
The map shape generalizes beyond `bound` without a fourth
amendment; `#[non_exhaustive]` (amendment 2) made the carrier
extension additive — no construction-site break.
- The merge: on establisher success the wrapper extends the success
output with the contributed fields AFTER reserving `channel_id`.
**The `channel_id` reservation:** the key is wrapper-owned; an
establisher supplying it is an establisher bug — the wrapper fails
the open loudly (`channel:open_failed`, reason `handler_error`,
message naming the reserved key) and tears the just-allocated
channel down, never silently shadowing its own value. Contributed
fields otherwise merge flat; the output schema remains the op's own
concern (the producing crate documents its optional fields —
alktunnels' listen-op spec documents `bound`).
- The client read path: `ChannelClient::open_channel_with_reply`
returns `(channel_id, reply, send, recv)` — the full success
output — so a consumer reads `bound` without dropping to
`call_open_op` + manual `adopt_channel`;
`open_channel` delegates and discards the extra fields, signature
unchanged.
- Compatibility: absent reply fields leave the reply byte-identical
to the pre-amendment shape (`{ channel_id }`) — verified by test.
Old consumers (`open_channel` extracting `channel_id`, ignoring
unknown fields) are unaffected by a NEW field; new consumers reading
`bound` against an OLD alkcall see the field absent — the additive
posture alktunnels ADR-008 §2 pins.
Door type: the reply fields are an additive call-plane JSON surface
(like `channel:open_failed`'s `details` in §3 — consumers ignore
unknown fields by the envelope's own parse posture); the field set an
op may contribute is the producing crate's schema concern, not a
protocol vocabulary. No data-plane change.
Implemented surface (alkcall 0.8.0): `Establishment::with_reply_field`
/ `with_reply_fields` / `reply_fields`, the wrapper's
reservation check (`RESERVED_REPLY_KEY` = `channel_id`) +
`merge_reply_fields`, `ChannelClient::open_channel_with_reply`.
Verification gates landed as tests: the projection produces
`{ channel_id, bound }` on the wire; no-fields (establisher or not)
produces exactly `{ channel_id }` (byte-identical); the reserved-key
establisher fails with reason `handler_error`, channel torn down,
ledger decremented, handler never spawned; the e2e gate carries
`bound` through a real channels connection to
`open_channel_with_reply` while `open_channel` stays unchanged.
@@ -0,0 +1,105 @@
# ADR-050: `pump_bidi` — the two-pump helper, extracted upstream
## Status
Accepted — implemented in alkcall 0.6.0 (review 007 Unit 3 / R-03).
Pins alknet ADR-078's two-pump contract in one place.
## Context
alknet ADR-078 defined the two-pump data-plane contract for forwarding
channels (one pump per direction; each pump shuts the opposite sink
down on completion — `try_join!` alone deadlocks) and deferred helper
extraction until a second two-pump consumer existed and the shapes
converged. Review 007 (R-03) supplied both halves of that test from
the alktunnels UDP POC:
- **Producer side:** the POC's `pump_halves` — two `tokio::io::copy`
pumps over split channel-vs-substrate halves, each shutting down
the opposite sink on completion, joined.
- **Consumer side:** `TunnelSession::take_halves` + the assembly
layer's copy — the same shape modulo channel side.
alktty's channels session already implements the shape (its
`drive_session_pre_negotiated` awaited inline); alkhttp's ferry does
not pump. The shapes converged; the review asked this sweep to either
extract the helper or record the decision not to — an un-extracted
helper would surface during alktunnels Phase 1 as the same
fix→publish→update treadmill for a purely additive change.
## Decision
Extract the helper as `channels::pump_bidi`:
```rust
pub async fn pump_bidi<C, R, W>(channel: C, peer_read: R, mut peer_write: W) -> (u64, u64)
where
C: AsyncRead + AsyncWrite + Unpin,
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
```
Two pumps, joined: `channel → peer_write` and `peer_read → channel`.
On each pump's completion the opposite sink is shut down
(shutdown-on-completion — the half-close the contract specifies).
Returns the two copy counts for observability.
Three deliberate deviations from the review's sketch, all
semantics-first:
1. **Return `(u64, u64)`, not `io::Result<(u64, u64)>`.** There is no
meaningful `Err` state: both pumps swallow copy errors by contract
— a mid-stream error is indistinguishable from an abrupt close
(no error channel exists mid-stream, per ADR-049 §6's pinned
EOF-shaped semantics), and shutdown-of-the-opposite-sink runs
either way. A returned `Err` would be dead code; the counts
(with `unwrap_or(0)` on error) are the observability.
2. **Peer side takes split halves (`peer_read: R`, `peer_write: W`),
not one `AsyncRead + AsyncWrite` value.** The dominant producer
shape is the establisher's dial result: `TcpStream::into_split`
halves (tunnels), pty pair halves (TTY). Taking halves matches the
POC (`pump_halves`) and avoids forcing consumers to re-join halves
just to satisfy a bound. The channel side stays a single value —
that is what the pump handler's `accept_bi` yields.
3. **Not `async fn` over `AsyncWriteExt` bounds on both sides.** The
channel side needs `tokio::io::split` (one value, two pumps), so
its bound is `AsyncRead + AsyncWrite + Unpin`; the `AsyncWriteExt`
bound the sketch had on every parameter is the extension trait
only where shutdown is called.
alktty's three-pump session does not fit the helper (the exit future
is a third signal) and stays as-is — the helper serves the two-pump
shape, exactly as the review scoped.
## Compatibility
Purely additive: a new pub fn in `channels::pump`, re-exported in the
module docs; no existing surface changes. Consumers may adopt it at
their own pace (alktunnels Phase 1 gets it for free; alktty's
channels session can migrate later if it wants; the POC's shape is
now canonical).
## Door type
**Two-way.** A helper function is trivially removable or re-shapable;
no wire surface, no trait, no state.
## Verification gate
The POC's two-pump semantics reproduced through the helper, as unit
tests in `src/channels/pump.rs`: data flows both directions with
exact copy counts; EOF from one side completes the other's
shutdown-on-completion (the far end observes clean EOF, not an
error); a dead source is EOF-shaped teardown, not an `Err`.
## References
- Review 007 R-03 (the extraction ask + the convergence evidence)
- alknet ADR-078 (the two-pump contract; shutdown-on-completion)
- alktunnels `poc-summary.md` §Issues Surfaced #4 (the producer-side
shape; `pump_halves` in the POC's producer.rs)
- alktty `src/channels.rs` (the channels session shape; three-pump
carve-out)
- ADR-049 amendment 2 (R-02 — the lifetime contract the helper's
callers must respect: await `pump_bidi(..).await` inline inside the
handler task)
@@ -0,0 +1,343 @@
# ADR-051: In-Tree Channel Relay (`ChannelRelay`) and the Hub-Leg Assembly
## Status
Accepted (2026-09-17) — amends ADR-042 (the relay contract's
implementation home moves into alkcall; the translate-not-forward
contract is unchanged). Sequenced after review 008 remediation Units 1
and 2 (both landed, alkcall 0.8.0): the relay's establisher projects
the spoke reply's extra fields via `Establishment` reply fields
(ADR-049 amendment 3), and the spoke's flavor-form open ops arrive
reconstructed WITH the `channel_open` marker through discovery
(ADR-047 amendment 3). All three implementation units are landed:
Unit 3a (the `ChannelRelay` component, §1–§4, §6) in
`src/channels/relay.rs`, Unit 3b (the hub-leg install template, §5) in
`src/channels/hub_leg.rs`, and Unit 3c (the review's gate-2 e2e
harness, gates 6/7) in `src/channels/gate2_tests.rs` — alkcall 0.8.0.
## Context
Review 008's remediation plan (Unit 3) pinned the in-tree relay as the
implementation of the review's U-1 gate 2 — the hub-relay round trip
that proves flavor-form marked ops survive discovery into a genuine
relay. The as-filed ADR-042 §"Scope note" put the relay implementation
in `alknet-hub`/downstream; the consumers that now exist (alktunnels'
graduation, alkhttp's fallback hub, alknodes, and the hub/spoke family
behind them) all need the same shape, so the implementation moves into
alkcall as a reusable export.
The consumer family the relay must serve is the hub/spoke composition:
spokes connect to a hub and expose services; the hub re-exposes those
services to other spokes (and consumers) based on ACL. Both a hub and
a spoke can be a relay depending on the setup. A canonical example:
spoke A exposes `channels/tunnel/direct`; the hub imports it via
`from_call`, gates it with its own ACL, and re-exposes it on its
consumer-leg channel-0 registries; spoke B opens through the hub and
the hub relays the channel to spoke A. The hub performs no port
binding and no protocol work on the data plane — the property that
makes the hub-acl-relay role cheap (alktunnels' no-bind posture, the
same property ADR-042 §What the hub runs pins: "the hub never runs a
handler for `alk/tty`, `alk/ssh`, or `alk/tunnel`").
Two implementation surfaces were evaluated for the data plane; the
decision records why the wrapper-composition shape wins over
byte-forward-with-rewrite.
## Decision
### 1. The relay is the wrapper composition — translate-not-forward, implemented with the existing open-op machinery
ADR-042's two-layer contract is implemented as composition of pieces
this crate already has:
- **Layer 1 (call-protocol translate)** is the ADR-049 establisher:
the hub's consumer-leg open op resolves through the full open-op
wrapper (registry ACL → per-identity cap → allocation), then the
establisher calls the producer leg's open op, adopts the returned
spoke channel id into the producer-leg `ChannelManager`, and returns
`Establishment::new(plan)` whose plan carries the spoke-leg streams.
The hub allocates its own consumer-leg `channel_id`; the spoke
allocated its own producer-leg id; the two ids never appear in each
other's namespaces.
- **Layer 2 (byte-forward)** is the ADR-050 `OpenHandler`:
`pump_bidi(consumer_leg_bistream, spoke_recv, spoke_send)` — the
pump's generic bounds already match the relay's two legs. The relay
never parses chunk framing on either leg; the mux/demux on each leg
absorbs the 8-byte header (ADR-034) and the channel-id namespaces
are per-connection, so no header rewrite exists. Awaited inline per
the R-02 lifetime contract; on completion both legs tear down (the
wrapper's handler-exit teardown covers the consumer leg; the pump's
send/recv drops EOF the spoke leg).
**The relay map dissolves.** ADR-042 §`channel_id` mapping pinned a
`HashMap<channel_id, channel_id>` per (browser, spoke) pair maintained
by the hub. The implementation needs no map: each leg's `ChannelManager`
already holds its own id → routing state, and the per-channel pump
closure binds both legs' streams. The mapping is implicit-per-channel.
This is a two-way-door implementation detail within ADR-042's contract
(the ADR itself marked the mapping strategy two-way).
**`channel/close` and `channel/control` need no translation surface.**
Consumer-side close → wrapper teardown → pump abort → send-half drop →
EOF chunk → spoke handler exits → spoke's own wrapper decrements its
ledger. Close propagates through the EOF cascade with correct ledger
accounting on both legs; the hub never sees a `channel/close` to
translate. Control stays per-leg (`channel/control` is not implemented
upstream, OQ-39 — a hub wanting cross-leg control translation composes
it downstream). This supersedes the ADR-042 §mapping bullet's
close-translation sentence as an implementation matter: the mapping
entry the sentence's removal depended on does not exist.
### 2. What the relay holds on the producer leg: `CallConnection` + `ChannelManager`
The relay's producer-leg surface is `Arc<CallConnection>` (for the
translate hop's open call) plus the producer-leg `ChannelManager` (for
`adopt_channel` and teardown visibility) — not a `ChannelClient`.
Rationale: `ChannelClient`'s calling surface detaches on
`take_call_connection` (the hub needs the connection for `from_call`
import AND for the relay's establisher AND possibly its own ops —
three claimants), while `Arc<CallConnection>` is `Clone` and the
client's manager accessor yields the same `ChannelManager` the relay
needs. A hub that dials the spoke with `ChannelClient::from_connection`
and then tears the client down after import keeps the relay alive on
the `Arc`s. The establisher builds its forwarded payload directly
(`build_forwarded_payload` semantics — the hub as caller, the consumer
as `forwarded_for`, ADR-026 §3) rather than through
`call_open_op`, which carries no payload variant.
The spoke reply's `channel_id` is stripped before projection (the
consumer-leg reply carries the hub-allocated id, not the spoke's);
the spoke reply's other fields ride through `with_reply_fields` —
`bound` flows end-to-end with per-hop truthfulness (a hub re-producing
a listen resource binds its own listener and contributes its own
`bound`; alktunnels ADR-008 §Hub re-produce).
### 3. Reason-code mapping (spoke `CallError` → `EstablishmentError`)
The spoke's open can fail with the full call-error vocabulary; the
establisher maps it into `EstablishmentError` by class:
| Spoke failure | `EstablishmentError` variant | Wire reason |
|---|---|---|
| `channel:open_failed` with a typed reason | the same class — `DialFailed` for `dial_failed`/`timeout`, `UnknownResource` for `unknown_resource`, `ResourceShortage` for `resource_shortage`, `HandlerError` for `handler_error` | the mapped reason |
| `channel:open_failed` reason `timeout` | `DialFailed` (the spoke establishment exceeded the spoke bound — from the consumer's side it is unreachable-target-shaped) | `dial_failed`, spoke message preserved |
| `NOT_FOUND` (spoke predates the op) | `HandlerError` carrying the code | `handler_error` |
| `FORBIDDEN` (hub lacks the spoke grant) | `HandlerError` carrying the code | `handler_error` |
| other `CallError` | `HandlerError` carrying the code | `handler_error` |
The spoke's original code and message are preserved in the mapped
variant's `message` (never discarded — a consumer debugging a
FORBIDDEN-through-relay must see the spoke's grant failure, not a bare
`handler_error`). The `timeout` mapping is the one non-1:1 case: the
consumer-visible reason is `dial_failed` because establishment
timeouts are wrapper-generated (ADR-049 §3) and not an establisher
vocabulary word; the spoke's message carries the truth.
### 4. The registration seam: two phases, fork-registry registration
Openable ops register on a **per-connection fork of the hub's base
registry** installed as the consumer leg's dispatch registry (the
ADR-047 §4 amendment mechanism). Discovery of the producer leg's ops
happens once (hub startup or reconnect); registration happens per
consumer-leg connection inside the `install_channel_zero` hook. The
one-call signature the review sketched cannot exist without adapter
changes — the two phases are the API:
1. **Discover/stash** — `from_call` against the producer leg returns
the imported `HandlerRegistration` bundles. Marked specs (rebuilt
WITH the `channel_open` marker, ADR-047 amendment 3) are separated
from plain bundles by the marker. The stash is a `Clone`able
template: `HandlerRegistration` and `HandlerKind` are `Clone`, and
`OperationRegistry::fork` deep-copies registrations, so one
discovered set serves any number of consumer legs.
2. **Register per connection** — the hub-leg install hook forks the
base registry, registers the generic channel ops
(`ChannelOperations::register_on`), registers the stashed plain
bundles as-is (the existing forwarding handlers), and registers
each stashed marked spec via the relay's
`register_relay_openable` (the fork is the `consumer_registry`
argument), then dispatches channel 0 over the fork.
Per-consumer ACL differentiation is a composition consequence, not a
mechanism: the fork is per consumer leg, so a hub re-exposing
different op subsets to different consumers filters the stash per
fork. The imported spec's own `access_control` remains the gate for
the re-exposed op; the hub composes additional ACL at its assembly
layer.
### 5. The hub-leg install hook is an in-tree export (the call-half support)
Every existing test hand-rolls the hub-side install hook
(`make_install_channel_zero`-shaped); the hub/spoke family all need
the same composition. The relay lands with a documented hub-leg
template — fork, generic ops, bootstrap discovery, stashed bundles
(plain + relay), per-call identity resolution (`ServingConfig`-shaped),
single-stream dispatch loop — as an in-tree composition (unit-scoped:
Unit 3b). This is composition of existing pieces, not a new protocol
surface; its exact API shape is a two-way-door implementation detail.
The spoke side needs nothing new: a spoke serving ops through a hub is
the existing connect-side serving shape (`from_connection_with_serving`)
plus the producer-leg registration it already does.
**ACL layering note (pinned, because it surprises):** the spoke's
`AccessControl` sees only the hub's identity — the end consumer's
identity rides `forwarded_for` as metadata and is never consulted by
any `AccessControl::check` (ADR-024/ADR-026). "The hub re-exposes
based on ACL" therefore means: the hub-side gate is the imported
spec's ACL (+ hub policy) on the consumer leg; the spoke-side grant
goes to the hub identity, which needs scopes on every spoke op it
relays. The end consumer never authenticates to the spoke directly.
**Loud-install coverage note (review 009 C-1):** the template's four
install-failure arms all end the install task before the dispatch
loop (channel 0 never dispatches — never a silent stub). Two arms are
unit-test-pinned: the relay-openable registration failure (the
Pub-typed marked spec gate) and the plain-bundle registration failure
(an un-compilable schema — the registry's fail-closed rule). The
other two arms — generic channel-ops registration and bootstrap
discovery install — are best-effort-loud: both register
crate-internal specs whose schemas compile by construction, so
failure is not reachable through any public path and an injectable
failure seam would test the seam, not the arm. This note is the
contract: if a future change makes either arm reachable, it gets the
same pin.
### 6. Bounds: rejected shapes and residual notes
- **Pub-typed open ops** — rejected loudly (`channel:pub_open_not_implemented`,
the C-08 blocker). The relay registers only `Query`/`Mutation`-typed
ops (plain forwarding) and `Sub`-typed marked ops (relay openables);
a discovered `Pub`-typed marked spec is an assembly error, not a
silent stub.
- **Compounding establishment bounds** — each hop's establisher is
bounded independently (ADR-049 §2); an N-hop chain adds N bounds.
The consumer's dispatch deadline (`min(deadline, 10s)` per hop)
bounds each hop, not the chain. No fix — noted for capacity
planning.
- **Mid-establishment consumer disconnect** — the one teardown window
with a delay, with two reclaim signals pinned by the Unit 3c gate
(`gate2_mid_establishment_disconnect_reclaims_without_leak`): (a)
the consumer leg's just-allocated channel reclaims at the leg's own
transport EOF (`clear_all` + ledger decrement) — the hub has not
adopted, so the producer leg stays clean; (b) the spoke's channel —
the spoke allocates before its establisher replies — is the honest
residual: it cannot be reclaimed by the hub (no adoption exists),
so it lingers until a spoke-side teardown signal (the spoke↔hub leg
ending, or the spoke's own establishment bound). The gate bounds
the window deterministically by severing the spoke leg and asserts
the spoke-side reclaim + ledger decrement. A channel never outlives
its leg lifetimes; never a leak past the connection's lifetime.
- **Adopted-entry teardown on every plan path** — the hub's
producer-leg entry (the `adopt_channel` state) is owned by the
relay's `RelayPlan` via a `Drop` guard, so it reclaims whenever the
plan dies: the normal pump completion, the pump handler's
early-return arms (plan absent, downcast failure, `try_unwrap`
failure, `accept_bi` failure), and the wrapper's establishment
bound expiring after the adopt (the plan dropped before the pump
ever spawns). Dropping the plan's send half also EOFs the spoke
side through the mux pump's implicit-EOF sentinel (REQ-CH-01), so
the spoke handler reclaims through the same cascade the normal
path uses. This closes the adopt-then-timeout window the landing
review surfaced (the §6 window above is the *pre-adopt* window;
this is the *post-adopt* one, now closed by construction). The
one remaining cancellation point *inside* `adopt_channel`'s own
await is the same pre-existing client-side window
(`ChannelClient::open_channel`'s adopt has it too) — bounded by
the leg's transport-EOF `clear_all`, not by the plan.
- **Multi-hop relays** — a chain of relays composes (each hop is a
hub leg pair), with the compounding-bound note above. No special
machinery.
## Consequences
**Positive:**
- The relay is O(1) new machinery: an establisher closure, a
`pump_bidi` handler, and a registration entry point. No parallel
auth path (the consumer leg's full wrapper machinery applies), no
new wire surface (the relay speaks only already-stable ops), no
data-plane parsing (ADR-034's one-way door stays closed).
- `from_call` gains a real in-tree consumer for the marker-branching
seam Gap C named (ADR-047 §Gap C): downstream crates compose
`ChannelRelay` instead of re-deriving the hub shape.
- U-1's gate 2 (the review's silent-failure scenario — flavor-form ops
relaying as plain forwarding stubs) becomes a permanent e2e gate.
**Negative:**
- New pub API (`ChannelRelay`, the registration entry point, the
hub-leg template) — minor bump, one release with the review's other
units, review-007 precedent.
- The two-phase discover/stash-then-register shape is more assembly
code for a trivial hub than a one-call import would be. The fork
mechanism forces it; the stash being `Clone` keeps the per-connection
work cheap.
- The hub holds `Arc<CallConnection>` + `ChannelManager` per spoke leg
for the connection's lifetime — per-leg state, bounded by the spoke
count.
## Door type
**Two-way (implementation shape) within a one-way posture.** The
translate-not-forward contract (ADR-042) and the ACL layering are
unchanged one-way decisions; the in-tree export, the implicit-mapping
implementation, and the two-phase registration shape are revisable
implementation details — downstream crates compose the export, not the
internals. The rejection posture for Pub-typed marked ops is loud (an
assembly error), so a future implementation of the Pub path is
additive.
## Verification gates (implementation units)
Unit 3a (the relay component):
1. `register_relay_openable` on a forked consumer-leg registry
resolves the open op end-to-end; the establisher populates
`forwarded_for` from the consumer's per-call identity (ADR-026 §3)
and strips the spoke reply's `channel_id`.
2. A spoke `channel:open_failed` maps to the consumer-leg
`channel:open_failed` with the mapped reason (the §3 table) and the
spoke's message preserved; the consumer leg tears down (ledger
decremented); no spoke channel leaks.
3. `bound` (Unit 1's reply projection) survives the relay to the
consumer reply.
4. Pub-typed marked specs are rejected at registration.
Unit 3b (the hub-leg template):
5. The template composes fork + generic ops + bootstrap discovery +
plain bundles + relay openables + serving identity and dispatches
channel 0; `services/list` on the consumer leg shows the
re-exposed ops.
Unit 3c (the gate-2 e2e):
6. The review's full harness: consumer → hub (forked channel-0
registry + relay) → producer, opening `channels/tunnel/direct` end
to end: the open resolves with the hub-allocated `channel_id`, the
establisher-contributed `bound` field survives to the consumer
reply, data flows both directions (the hub never parses it), and
hub-side disconnect / spoke-side close tears down both legs
(including the mid-establishment window from §6).
7. A companion `channels/tty/sub` relay run pins no regression on
standard shapes.
## References
- ADR-042 (amended by this ADR — the implementation home; the
contract unchanged; the relay map and close-translation bullets
superseded by the implicit-per-channel shape)
- ADR-047 amendment 3 (flavor-form ops reconstruct WITH the marker —
the discovery input this relay consumes), §Gap C (the
marker-branching seam), §4 amendment (the fork mechanism Unit 3b
composes)
- ADR-049 amendments 2/3 (the plan payload the relay's establisher
carries; the reply projection `bound` rides through)
- ADR-026 §3 (forwarded_for on the translate hop), ADR-050
(`pump_bidi` — the byte-forward hop), ADR-040/041 (the caps the
consumer leg keeps enforcing)
- alktunnels ADR-007/008 (the first consumers: the direct op's relay
and the listen `bound` field's per-hop truthfulness)
- Review 008 remediation plan Units 1–3 (the sequencing this ADR
lands inside)
+1 -1
View File
@@ -83,7 +83,7 @@ is the load-bearing piece the broker composes on.
| OQ-37 | `from_call` relay wrapper for marked ops | open | medium | ADR-047 §1 names it as a consumer (hub) concern; alkcall's `from_call` reconstructs the marker (Gap F resolved) so the consumer can branch on it |
| OQ-38 | ALPN→path-segment mapping | resolved | low | ADR-047 §"Negative" — strip the `alk/` prefix; ALPNs without that prefix use the full ALPN string (rare, two-way-door) |
| OQ-39 | `channel/control` control-handle surface | open | medium | The `channel/control` handler currently returns `channel:control_not_implemented`. The control-handle surface (per-channel control callbacks registered by ALPN crates, routing `message` to the handler's control handle for `channel_id`) is real design work — each ALPN crate needs a way to register a control callback, and the channels layer needs a control-handle registry keyed by `channel_id`. Deferred until an ALPN crate (TTY, tunnel) needs out-of-band control. |
| OQ-40 | `channel/resources/subscribe` live subscription | open | medium | The `channel/resources/subscribe` handler currently returns `channel:resources_not_implemented`. The live subscription aggregated from ALPN-crate resource enumerators (ADR-047 §6) requires each ALPN crate to provide a resource enumerator, and the channels layer to aggregate them into a live `Stream` that emits on any change. The current stub is a one-shot error; the real implementation is deferred until a consumer (hub, dashboard) needs live resource discovery. |
| OQ-40 | `channel/resources/subscribe` live subscription | open | medium | The `channel/resources/subscribe` handler currently returns `channel:resources_not_implemented`. The live subscription aggregated from ALPN-crate resource enumerators (ADR-047 §6) requires each ALPN crate to provide a resource enumerator, and the channels layer to aggregate them into a live `Stream` that emits on any change. The current stub is a one-shot error; the real implementation is deferred until a consumer (hub, dashboard) needs live resource discovery. **Load-bearing for alktunnels' discovery UI** (review 006 E-02): a consumer cannot distinguish produced tunnel resources by op name alone. The static half is covered (0.5.0: `OperationSpec.description` on `services/list` — describes the op, not the live resource set); the dynamic half stays deferred — alktunnels v1 uses config-known op names. |
| OQ-41 | QUIC-native multi-stream substrate | open | medium | Only the in-line substrate mode is implemented (single bidi stream, header-demuxed N channels). The QUIC-native multi-stream substrate (accept remaining bidi streams, read headers off each — ADR-034 §substrate modes) is deferred to the downstream alknet crate. The wire format and demux loop are correct for both substrates; only the outer `accept_bi()` loop is missing. The alknet crate owns the QUIC dial/accept loop and is the natural place for the multi-stream accept loop. This crate stays transport-agnostic (no QUIC dependency, WASM-compatible). |
## Core Types
+9
View File
@@ -39,6 +39,9 @@ pub struct OperationSpec {
pub output_schema: Value, // JSON Schema for output
pub error_schemas: Vec<ErrorDefinition>, // Declared domain errors (ADR-016)
pub access_control: AccessControl,
/// Human-readable op description (review 006 E-02). Disclosed by
/// `services/list` when set; `None` when the op declares none.
pub description: Option<String>,
/// JSON pointer into the input for the resource ID, when
/// `access_control.resource_type` is set and the operation targets a
/// specific runtime-spawned resource (ADR-011). e.g., `"$.containerId"`
@@ -829,6 +832,12 @@ These are read-only — no admin operations are exposed through the call protoco
}
```
Each listing entry also carries `description` (review 006 E-02) when
the op's spec declares one (`OperationSpec.description`, set via
`with_description`) — the field is additive and absent otherwise. It
describes the op, not the produced resource set: live resource
discovery stays with `channel/resources/subscribe` (ADR-047 §6, OQ-40).
`services/schema` accepts `{ "name": "fs/readFile" }` (no leading slash —
registry form, same as `OperationSpec.name`) and returns the full
`OperationSpec` including input/output JSON Schemas and declared
+814
View File
@@ -0,0 +1,814 @@
# Fuzzing alkcall: Research and Recommendation
**Status:** steps 1–3 of §7.4 adopted — `fuzz/` workspace, five targets,
committed seeds, detached runner, two-tier campaigns run; one real
finding (duplicate adopt/open destroying the live channel) fixed with
regression tests. §7.2/§7.4's CI tiers are **not planned** — this repo
will not run hosted CI (the git host is a minimal gitea that serves git
only; see §7.9). CI's two deliverables are covered instead: the stable
side's corpus replay (`cargo test --manifest-path fuzz/shared/Cargo.toml`)
runs as part of every release's verification checklist (AGENTS.md), and
campaigns run manually via the detached runner (§7.6)
**Date:** 2026-09-27
**Research inputs:** web survey of the 2025–2026 Rust fuzzing ecosystem, survey
of fuzzing practice in comparable Rust protocol crates (rustls, quinn, quiche,
h2, prost, s2n-quic, yamux/libp2p, serde_json), and a first-hand inventory of
alkcall's untrusted-input parse surfaces (§5, verified against the code in
this repo).
---
## 1. Should this crate be fuzzed?
**Yes.** Three independent lines of argument converge:
1. **Position in the dependency graph.** alkcall is the substrate crate:
vendored core types, the call protocol, and the channels demux/mux all
live here, and downstream crates consume the wire formats rather than
re-implementing them (ADR-031 crate decomposition, ADR-007/008/009
vendored types). A parser bug here propagates everywhere; a fuzzed
parser here protects the whole stack. Fuzzing "at this level and working
down" is the right order — it is exactly what rustls did (fuzz the
deframer at the bottom, then the state machines).
2. **Wire formats are stable and attacker-reachable by design.** The
`EventEnvelope` framing (`{ type, id, payload: serde_json::Value }`
behind a 4-byte BE length prefix, ADR-014) and the channels 8-byte chunk
header (`[channel_id:u32 BE][len:u32 BE][payload]`, ADR-034) are
one-way doors. Both are parsed from bytes produced by *arbitrary peers*
— including relayed peers in the ADR-042 hub topology, where the hub
forwards bytes it never validated. The `payload` field is deliberately
schema-free JSON, so serde is on the attacker-controlled path with no
type-level defense.
3. **The failure classes that dominate real-world findings in Rust are
present in this codebase's shape, and several already have concrete
candidate findings.** The rust-fuzz trophy case (250+ entries) shows
that for Rust protocol crates the dominant bug classes are not memory
unsafety but: panics on untrusted input, integer/length arithmetic
errors, OOM via length-prefix-driven allocation, and unbounded state
growth. alkcall is safe Rust end-to-end (zero `unsafe` blocks —
verified), which means **no memory-corruption class at all**, but every
other class maps directly onto code in `src/` (see §5 and §6.2).
The strongest single datapoint: **RUSTSEC-2026-0037 / CVE-2026-31812
(quinn-proto, March 2026, CVSS 8.7)** — a remote DoS via `unwrap()` in
transport-parameter parsing. The maintainers' post-incident statement was
explicit: *"we did not have sufficient fuzzing coverage to find this issue.
We have since added a fuzzing target to cover this code path."* quinn-proto
is structurally very close to alkcall (async Rust RPC-ish protocol crate,
safe Rust, peers on the wire). A panic on attacker wire bytes in this crate
would be the same CVE class — and "the code is well tested" did not save
quinn, because conventional tests do not randomly explore malformed inputs.
**The honest caveat (why this was not obviously required before):** alkcall
has no `unsafe`, uses serde_json (whose 128-depth recursion limit and
non-panicking parser absorb most JSON pathology), bounds both length
prefixes before allocating (64 MiB frame cap, 16 MiB chunk cap — both
verified), and has solid round-trip unit tests. So the *expected* yield is
not memory-safety bugs; it is (a) panic-class bugs in hand-rolled dispatch
parsing, (b) the DoS-class findings in §6.2 which no amount of
example-based testing would have caught, and (c) regression protection for
two stable wire formats. If the campaign's first months find nothing, that
is a successful negative result, not a wasted one — it converts "we think
the parsers are robust" into a demonstrated property.
---
## 2. Tool landscape (2025–2026 snapshot)
| Tool | Version (date) | Engine | Toolchain | Status / verdict for alkcall |
|---|---|---|---|---|
| `cargo-fuzz` | 0.13.2 (2026-06) | libFuzzer | nightly required | **Recommended primary.** Actively maintained (rust-fuzz org), 3 releases since mid-2025, the de-facto standard; every surveyed protocol crate uses it. |
| `libfuzzer-sys` | 0.4.13 (2026-06) | libFuzzer runtime | via cargo-fuzz | Re-exports `arbitrary`; `fuzz_target!` accepts any `Arbitrary` type. |
| `arbitrary` | 1.4.2 (2025-08) | bytes → structured values | stable | **Recommended for structured targets.** `#[derive(Arbitrary)]`; 1.4.x brought notable fuzzing-speed wins. |
| `afl` (afl.rs, wraps AFL++) | 0.18.2 (2026-05) | AFL++ | **stable OK** | Recommended secondary/optional engine — long campaigns, multi-core, no nightly needed. CMPLOG on by default. |
| `honggfuzz` | 0.5.62 (2026-08) | honggfuzz | stable OK | Usable but upstream quiet since mid-2024; no reason to prefer over the above. |
| `bolero` | 0.13.4 (2025-07) | front-end: libfuzzer/AFL/honggfuzz/**Kani**/plain `cargo test` | stable for test/random | Attractive "same test, both engines" model, production-proven at s2n-quic. Considered and **deferred** (§7). |
| `libafl` | 0.16.1 (2025-08) | library for building fuzzers | stable | State of the art but a framework, not a tool; overkill for byte-buffer parser targets. Revisit only for stateful connection fuzzing. |
| OSS-Fuzz | — | hosted service | nightly (provided) | Free continuous fuzzing; expects exactly the cargo-fuzz layout (`libfuzzer` + `address` only). Adopt after targets stabilize. |
| `proptest` / quickcheck | mature | random generation + shrinking | stable | Complement, not replacement: no coverage guidance. Good for round-trip properties in the normal suite. |
Corroborating detail on cargo-fuzz status: published on crates.io, install
via `cargo install cargo-fuzz`; subcommands `init`, `add`, `run`, `fmt`,
`tmin`, `cmin`, `coverage`; recent releases added `--fuzz-engine`,
`--disable-branch-folding`, codegen-units tuning, Windows/MSVC support.
libFuzzer itself is upstream-maintenance-mode (its authors moved to
Centipede), which is irrelevant in practice for Rust app crates —
cargo-fuzz remains the recommended path in the Rust Fuzz Book.
---
## 3. What comparable crates actually do
Full details in the research notes; the load-bearing patterns:
- **rustls** (the reference model): `fuzz/` workspace, 8 targets spanning
byte-parsers *and* full state machines; internals exposed to targets via
`#[cfg(fuzzing)] pub mod fuzzing` (targets get access without widening
the public API); invariants include consumption accounting
(`assert!(processed <= buf.len())`) and re-encode round-trips; fuzz
functions additionally exercised from plain unit tests; corpora in a
dedicated repo; CI runs **10 s per target on every push** + OSS-Fuzz
CIFuzz at 150 s on PRs.
- **quinn**: `#[derive(Arbitrary)]` structured inputs; the `packet.rs`
target asserts the decoder consumed exactly the input
(`assert_eq!(len, decoded.0.len() + rest)`); a stateful target replays
`Vec<Op>` op sequences against `StreamsState`. Post-CVE, this is the
closest structural analogue to alkcall.
- **quiche**: fuzzes the *whole* protocol handler with committed test
certs; 1800+ committed seed corpus files generated by a seed script; Mayhem for continuous fuzzing.
- **h2**: the standout e2e pattern — fuzz input interpreted as a *script*
for mock I/O (length-prefixed chunks drive every socket read/write), so
libFuzzer explores partial-write/partial-read interleavings through the
full tokio state machine.
- **prost**: libFuzzer + AFL side by side; decode-then-re-encode round-trip
invariant; committed seed corpus for the AFL side.
- **yamux / libp2p** (no fuzz targets — the cautionary example): rely on
quickcheck only; their hand-rolled length-prefix handling shows the
defensive patterns fuzzing would police — `MAX_FRAME_BODY_LEN` checked
**before** `vec![0; body_len]` (yamux `frame/io.rs`), "validate RPC
limits by parsing the wire format without allocating" (libp2p gossipsub
`validate_rpc_limits`).
- **s2n-quic**: bolero's flagship consumer — fuzz checks live as ordinary
`#[cfg(test)]` functions, run in normal CI as `cargo test` (corpus
committed as tarball per test), long-run under libFuzzer out-of-band,
and the same properties carry Kani proof attributes.
**Invariant taxonomy observed across all repos** (in ascending strength):
1. **No-panic** — baseline; `drop(result)` suffices because libFuzzer +
ASan + debug assertions catch panics.
2. **Consumption accounting** — decoder consumed exactly the expected
bytes; nothing lost or duplicated (quinn packet, rustls deframer).
3. **Round-trip** — `decode(encode(x)) == x` or `encode(decode(bytes))`
prefix-match (yamux, quiche qpack, s2n-quic, prost).
4. **Resource bounds** — attacker-controlled lengths rejected (or skipped
without allocating) before allocation; bounded map growth.
5. **Stateful op sequences** — structured `Vec<Op>` replay against
internal state machines (quinn streams, h2 MockIo).
CI is universal but budgeted: the norm is a short per-push smoke run
(10–300 s) plus a longer scheduled campaign; crash artifacts uploaded via
`actions/upload-artifact` on failure. Cargo corpora are committed by
quiche/rustls/s2n-quic, gitignored by quinn/h2.
---
## 4. Toolchain decision: cargo-fuzz + arbitrary, AFL optional, bolero deferred
**Primary: `cargo-fuzz` + `arbitrary`.** Rationale: it is what every
surveyed crate uses, so patterns and CI templates transfer directly; the
`fuzz_target!` macro accepts both raw `&[u8]` and `Arbitrary`-derived
types, which maps onto alkcall's two input shapes (malformed-byte hunting
vs. structured logic replay); OSS-Fuzz compatibility for free; nightly
requirement is confined to the fuzz job (a pinned nightly toolchain for
the `fuzz/` workspace only — the crate itself stays stable at MSRV 1.88
and the fuzz crate is excluded from publishing via `exclude` in
`Cargo.toml`).
**Secondary (optional, later): `afl` (AFL++).** Stable-toolchain, good for
long multi-core campaigns and as an independent engine to cross-check
findings (prost runs exactly this pairing). Not needed for the initial
rollout.
**Deferred: bolero.** The s2n-quic model (fuzz check colocated as a normal
unit test, replayed in CI with a committed corpus, long-run out-of-band)
has a real advantage for this repo specifically — alkcall has *no* `fuzz/`
workspace today and its tests are inline `#[cfg(test)]` modules, so
colocated checks fit the house style. But it adds a dependency and an
engine-selection layer between the code and cargo-fuzz, and none of the
RPC-protocol crates surveyed (quinn, rustls, prost, h2) use it. The
deciding factor: alkcall's highest-value targets are *async* (the framing
reader and demux loop are tokio-generic), and bolero's in-process test
form adds friction there (`#[tokio::test]` cannot drive `bolero::check!`
iteration without shims). Recommendation: start with a standard `fuzz/`
workspace; if corpus-replay-as-unit-test proves valuable later, adopting
bolero's pattern (or just hand-rolling a corpus-replay test, which quinn
does in plain `cargo test`) is a cheap follow-up. Note quinn's CI already
does plain corpus replay without bolero — that pattern is available
without any new dependency.
**Not adopted: libafl, honggfuzz, OSS-Fuzz (initially).** libafl is a
framework for bespoke fuzzers — unnecessary for parser targets. honggfuzz
is superseded. OSS-Fuzz is a strong *later* step once targets exist and
have been stable for a while (it is free continuous fuzzing and expects
exactly this target layout; both rustls and prost are on it).
**Sanitizer choice:** default `--sanitizer=address` with debug assertions
(cargo-fuzz's default config, `-O1`). MSAN is not warranted — with zero
`unsafe` there is no uninitialized-memory class; ASAN mainly adds
heap-buffer-overflow detection for slice math in hand-rolled framing and
double-free-style issues in vendored types, plus the debug-assertions
panic tripwire, which is the real detector here.
---
## 5. alkcall's untrusted-input parse surfaces (inventory)
Verified against the code (file:line as of 0.8.0). The crate is safe Rust
end-to-end: **zero `unsafe` blocks** (grep-verified). Two wire formats,
plus JSON-based op payloads:
| # | Surface | Location | Sync? | Notes |
|---|---|---|---|---|
| 1 | `EventEnvelope` framing decode | `src/protocol/wire.rs:204-232` | async (`read_frame`, generic over `AsyncRead`; tests already drive it with `std::io::Cursor`) | 4-byte BE length prefix; rejects len 0 and len > `MAX_FRAME_SIZE` (64 MiB) **before** `vec![0u8; length]` at `:221`; partial prefix/body handled (`ConnectionClosed`); serde_json parse of up to 64 MiB body. |
| 2 | Channels chunk header | `src/channels/wire.rs:96-112` (`parse_header`) | **sync, pure, no allocation** | `len >= 8` check, `length > MAX_CHUNK_LEN` (16 MiB) rejected before payload exists. `write_header` at `:123`. |
| 3 | Demux loop | `src/channels/adapter.rs:126-222` (`run_demux_loop_for_client`, pub) | async | `parse_header` → bounded alloc (`:181`) → `route_payload`; `TooLarge` branch skips in 64 KiB reads with a cumulative 256 MiB budget (`:137`, `:153-170`) instead of allocating; EOF → `clear_all`. |
| 4 | `ChannelManager` routing | `src/channels/manager.rs:439-457` (`route_payload`), `:463-485` (`park_early_arrival`) | async (bounded send await) | Unknown ids park up to `EARLY_ARRIVAL_CAP = 64` chunks per id; **the map's key-space is unbounded** (see §6.2). |
| 5 | Spec rebuild from wire | `src/client/from_call.rs:209-310` (`rebuild_spec_for`, `pub(crate)`, sync pure) | sync | Remote-announced op specs: op_type/visibility/access_control/error_schemas/`publish_schema` — attacker-shaped JSON becomes compiled `jsonschema` validators at registration. |
| 6 | `op/register` DTO | `src/registry/op_register.rs:66-84` (`OpRegisterRequest::from_json`, `pub`, sync pure) | sync | Wraps `rebuild_spec_for` + replace flag. |
| 7 | Pending-request state machine | `src/protocol/pending.rs:111-229` (sync, `pub`) | sync | `handle_responded/completed/aborted/error`, eviction. |
| 8 | Abort cascade | `src/protocol/abort.rs:46` (`cascade_abort`, sync pure) | sync | Tree walk; O(n²) descendant search (§6.2). |
| 9 | Dispatch payload parsing | `src/protocol/dispatch.rs:341-412` (`dispatch_start`), `:646-770` (`pump_sink`), `:897-1027` / `:1120-1250` (single-stream loops) | sync-prefix / async | `payload.get(...)` extraction, `forwarded_for` as `serde_json::from_value::<Identity>`, `call.error` → `CallError`, jsonschema validation of published chunks. |
| 10 | Client-side envelope pump | `src/protocol/connection.rs:693-741` (`read_single_stream_until_closed`, `dispatch_envelope`) | async | pub; decode → pending-resolution pump. |
| 11 | Channel lifecycle ops | `src/channels/operations.rs:182-272` (close/control handlers) | async | `as_u64` → `as u32` cast truncation on `channel_id`. |
| 12 | Relay reply parsing | `src/channels/relay.rs:239-297` (`open_on_producer_leg`), `:308-335` (`map_spoke_error`) | async | reply `channel_id` extraction, `details.reason/message`. |
Also relevant: **`jsonschema` is compiled from schemas that arrive from
the wire** (`from_call.rs:299-303` announced `publish_schema` /
`input_schema`; enforced per chunk at `dispatch.rs:706-718`,
`registration.rs:257-274`) — a compilable-but-pathological remote schema
is a CPU-amplification class the fuzz campaign can probe.
Non-surfaces (deliberately): no TTY/SFTP/varint parsers in this crate; the
channels layer carries opaque `Bytes` and the handler owns sub-stream
framing (ADR-035) — those downstream parsers are downstream crates' fuzz
targets.
---
## 6. Findings the campaign should target (candidate issues in current code)
These are pre-fuzzing code-review findings (mine, verified at 0.8.0) that
define what the fuzz targets must encode as invariants. They are also, in
effect, the first candidates for the fuzzer to confirm or refute.
### 6.1 Framing and parse invariants (encode as assertions in targets)
1. **No-panic on any byte sequence** for both framing paths and all sync
pure parsers — the baseline every target asserts by existing.
2. **Consumption accounting** — `read_frame` consumes exactly
`4 + length` bytes for a valid frame; `parse_header` reads exactly 8.
3. **Round-trip** — envelope encode→decode (structural equality; note
serde_json runs **without** `preserve_order` in this crate's dep tree,
so `Value` object key order is not preserved — byte-identity round-trip
asserts are invalid for envelopes; use structural equality); chunk
header encode→decode identity.
4. **Allocation bounds** — decode paths never allocate more than
`MAX_FRAME_SIZE` / `MAX_CHUNK_LEN` for any input.
5. **Termination** — demux skip loop obeys the 256 MiB budget and always
terminates on a trickle-feed peer.
### 6.2 DoS-class candidate findings (pre-fuzz code review)
1. **Unbounded early-arrival key-space** — `ChannelManager.early_arrivals`
(`src/channels/manager.rs:102`, `park_early_arrival` `:463-485`) caps
parked chunks **per key** at 64, but nothing bounds the number of
distinct never-adopted `channel_id` keys. A peer spraying unknown ids
parks up to 64 chunks × up to 16 MiB *per distinct id* until
`clear_all` at EOF — an OOM class on a long-lived connection. A fuzz
target driving `route_payload` with arbitrary ids and payloads, with a
total-bytes-parked assertion, will find this class immediately.
2. **Unbounded per-connection task accumulation** — `spawned: Vec<JoinHandle>`
(`src/protocol/dispatch.rs:894-895`, pushed at `:914`, drained only at
loop end `:1030-1032`, and the channel-0 twin `:1115-1118`,
`:1253-1255`): one task per inbound `call.requested`, no cap on
concurrent requests per connection. Cheap-request spam grows the
vector unboundedly for the connection's life.
3. **64 MiB pre-payload allocation window** — `read_frame` allocates
`vec![0u8; length]` after validating ≤ 64 MiB but *before reading any
payload byte* (`src/protocol/wire.rs:217-221`): repeated 4-byte
headers force repeated 64-MiB zeroing (allocation-rate pressure).
Bounded per frame, but worth a target asserting decode never
allocates beyond the cap and exploring whether a read-into-capped-
buffer refactor (libp2p gossipsub's validate-without-allocating
pattern) is warranted.
4. **O(n²) abort cascade** — `find_descendants` scans the full pending map
per frontier step (`src/protocol/abort.rs:80-104`); many pendings + an
abort = quadratic CPU. An op-sequence target on `PendingRequestMap` +
`AbortCascade` with many registered requests will surface it.
5. **Remote-schema CPU amplification** — compiled-from-wire jsonschema
validators applied per published chunk (§5); pathological-schema
generation is a semantic fuzz target.
Items 1 and 2 are the strongest arguments that fuzzing (specifically
*stateful*, not just parser-level fuzzing) is warranted here: they are
resource-bound bugs in correct-looking safe Rust that example-based tests
structurally cannot find, in code paths every downstream consumer exposes
to peers.
---
## 7. Recommendation
### 7.1 Adopt fuzzing; scope it to the wire surface
Create `fuzz/` via `cargo fuzz init` (cargo-fuzz ≥ 0.13; keep the fuzz
crate in the main workspace, its default since 0.11.4), add `fuzz/` and
`fuzz/artifacts/` to `.gitignore`, add `"fuzz"` handling to the publish
exclude list if needed (cargo-fuzz's generated layout is already
workspace-compatible). Add five targets, in priority order:
| # | Target | Input style | Drives | Invariants |
|---|---|---|---|---|
| 1 | `chunk_header` | raw `&[u8]` | `parse_header` / `write_header` (`channels/wire.rs`) | no-panic; round-trip identity; `TooLarge` iff `len > MAX_CHUNK_LEN`; consumption accounting (8 bytes) |
| 2 | `envelope_frame` | raw `&[u8]` | `FrameFramedReader::new(Cursor::new(bytes)).read_frame()` under a current-thread runtime's `block_on` (tests already use `Cursor`, `wire.rs:534`) | no-panic; never allocates > `MAX_FRAME_SIZE`; clean `FrameError` on truncation/oversize/zero-length; consumption accounting |
| 3 | `manager_routing` | op-sequence: `#[derive(Arbitrary)]` enum over `{ route_payload(id, bytes), adopt, teardown, clear_all, … }` | `ChannelManager` | **total parked-bytes bound** (§6.2-1); no-panic; id-uniqueness invariants |
| 4 | `envelope_semantic` | `#[derive(Arbitrary)]` envelope-shaped input | constructors → `write_frame` → `read_frame` round-trip | structural round-trip; `CallError` payload parse never panics |
| 5 | `spec_parse` | raw bytes → `serde_json::Value` → `rebuild_spec_for` / `OpRegisterRequest::from_json` | sync pure parsers | no-panic; registration always `Result`; round-trip vs `spec_to_json_pub` |
Second wave (after the first five stabilize, roughly in quinn's
`streams.rs` style): a pending-map/abort op-sequence target (§6.2-4), a
demux-loop byte-sequence target through `run_demux_loop_for_client`
(needs a small current-thread runtime and a spawned mux runner), and a
client-pump target on `read_single_stream_until_closed`.
**Corpus policy: commit hand-made seeds, gitignore grown corpora** (the
quinn/h2 pattern). Seeds per target: valid frame/header, truncation at
every prefix length, `length = 0`, `length = MAX+1`, `length = u32::MAX`,
channel 0, invalid UTF-8 in JSON, deep-ish nesting. libFuzzer runs fine
without seeds but is far more efficient on structured inputs. Seed
generation can be scripted from existing unit tests (quiche's
`gen_fuzz_seeds.sh` pattern).
**Where internals need exposure:** follow quinn/rustls — `#[cfg(fuzzing)]
pub mod fuzzing` modules re-exporting `pub(crate)` items
(`rebuild_spec_for`) rather than widening the public API. The crate
already has `pub` sync parsers (`parse_header`, `from_json`,
`PendingRequestMap`), so exposure needs are small.
### 7.2 CI: two-tier, rustls-style
1. **Per-push smoke** (cheap, catches build rot and shallow bugs): pinned
nightly toolchain + pinned cargo-fuzz; `cargo fuzz build`; then per
target `cargo fuzz run <t> -- -max_total_time=10` (rustls's exact
budget) in a small matrix; upload `fuzz/artifacts` on failure.
2. **Scheduled campaign** (weekly at first; cron-driven Actions workflow):
`-max_total_time=1800` (30 min) per target with a cached corpus
(Actions cache, `restore-keys` prefix matching) and `-fork=$(nproc)`;
grow corpora incrementally. This is the depot.dev pattern; adopt it
only after the smoke tier is green for a while.
3. **No-cargo-fuzz fallback for PRs**: since nightly is a dedicated-job
concern, an even cheaper PR gate is `cargo test --manifest-path
fuzz/Cargo.toml` (replays the committed corpus through the targets as
plain unit tests — quinn's CI does this; corpus replay costs nothing
and prevents seed rot).
libFuzzer flags worth pinning in the targets' run configs:
`-rss_limit_mb=2048` (the OOM tripwire — directly relevant to §6.2-1/3),
`-max_len=65536` on framing targets, `-timeout=25` (well under CI job
limits), `-use_value_profile=1` (helps get past length/JSON-prefix
comparisons), and a `-dict` of JSON tokens for the envelope targets.
### 7.3 What not to do
- **Do not** gate regular development on nightly: fuzz tooling lives
entirely in `fuzz/` + the fuzz CI job; `cargo test` / MSRV / wasm
targets are untouched.
- **Do not** assert byte-identity round-trips for `Value` payloads
(serde_json without `preserve_order` sorts object keys — structural
equality only). This would generate false positives from day one.
- **Do not** commit grown corpora to git at first (quinn/h2 model);
revisit if the scheduled campaign produces inputs worth curating.
- **Do not** reach for libafl/OSS-Fuzz/Mayhem until the five initial
targets are stable; OSS-Fuzz onboarding is the natural next step after
that (free continuous fuzzing, expects exactly this layout).
### 7.4 Sequencing
1. `cargo fuzz init` + first two targets (`chunk_header`, `envelope_frame`)
— highest value, lowest setup (both are thin wrappers over existing
sync/`Cursor`-drivable APIs). **Done — see §7.7.**
2. Run a local 10–30 min campaign per target **via the detached runner
(§7.6 — never in the foreground of an agent session)**; fix anything
found; triage §6.2 candidates with targeted corpus entries.
**Done for targets 1–2 — see §7.7.**
3. Add targets 3–5, the smoke CI job, and `.gitignore` entries.
**Targets 3–5 done, campaigns clean — see §7.8. The CI job will not
exist — see §7.9 (no hosted CI policy); the stable-side corpus
replay `cargo test --manifest-path fuzz/shared/Cargo.toml` is in
the release verification checklist instead (AGENTS.md).**
4. ~~Scheduled campaign tier; then OSS-Fuzz application.~~ **Not
planned — see §7.9.** Long campaigns are run manually via the
detached runner (§7.6) when wanted; OSS-Fuzz is out (it requires
hosted infrastructure and a public repo posture this project does
not have).
### 7.5 Relationship to existing tests
The existing suite is strong on *valid-input* round-trips and documented
error paths (framing tests at `wire.rs:262-588`, demux skip tests at
`adapter.rs:279-693`, spec round-trips at `from_call.rs:634-1053`). What
no example-based suite provides, and what fuzzing adds:
- random *malformed* input exploration (the quinn CVE class),
- stateful interleavings (many ids × many ops, the §6.2-1/2 classes),
- resource-bound assertions under unbounded adversarial sequences.
Two zero-cost complements, available without any new toolchain: (a) replay
committed corpora as unit tests in normal CI (quinn's pattern — add it
from day one, it is two lines of CI); (b) a quickcheck-style round-trip
property test for the chunk header (yamux's pattern) if the team wants
in-suite fuzz-adjacent coverage without nightly. Both are optional
nice-to-haves; the core recommendation is the `fuzz/` workspace.
### 7.6 Operational isolation: fuzzing must never share fate with the agent session
This one is an environment constraint, not a nice-to-have. Agent sessions
(opencode) run fuzzer builds/campaigns through a bash tool that spawns the
fuzzer as a **child of the session's own process tree and cgroup**. A fuzz
run is precisely the workload shape that kills its own host:
- **OOM-killer shared fate**: the target's worst case *is* unbounded
allocation (that's what §6.2-1/3 look for). If libFuzzer's own RSS guard
is miscalibrated or races the kernel, the kernel OOM killer fires — and
it picks the largest-RSS process in the *cgroup*, which can be the
opencode server hosting the session, killing the agent mid-run. An
OOM in a fuzz target must cost the fuzzer, never the session.
- **Fork bombs and CPU saturation**: `-fork=N` mode spawns many workers;
a runaway campaign or a buggy target with unbounded task spawning can
starve the session host's CPU/IO.
- **Interactive-shell edge cases**: libFuzzer prints status lines that
confuse non-TTY shells; a crashed fuzzer must never leave the tool's
bash session hanging.
Three layers of defense, all standard, cheapest first:
1. **libFuzzer's own soft limits (always on)**: `-rss_limit_mb=2048` +
`-malloc_limit_mb=2048` make libFuzzer *exit cleanly* (report the
input as OOM-class artifact) when the target exceeds the budget, before
the kernel gets involved. This is the primary defense and it is
already part of the §7.2 flag set. Note the libFuzzer RSS limit is
**soft** (it polls `/proc/self/statm` in a background thread and
exits), not a hard rlimit — a fast single huge allocation can still
beat it; that's what layers 2–3 are for. Do **not** use `ulimit -v`
with ASAN/MSAN (the sanitizer reserves terabytes of virtual address
space; the classic failure mode is immediate `MmapAlloc` death) — the
equivalent knob under ASAN is `malloc_limit_mb`, and libFuzzer's
documented recipe for hard-limiting RSS under sanitizers is `-fork=1`
(see next item).
2. **Fork mode as the blast-radius containment (default for local
campaigns)**: `-fork=1` (or `$(nproc)`) runs each input in a short-
lived child process. A target crash, timeout, OOM, or leak kills only
that one-shot child; the parent harness survives, records the artifact,
and keeps fuzzing. This is libFuzzer's own documented containment
model — and it is mutually exclusive with in-process ASAN crash
reporting (the child still detects, the parent persists the evidence).
Fork mode also solves the *other* shared-fate trap: a target that
deadlocks no longer hangs the campaign (per-input timeout kills the
child, not the session's bash call).
3. **Process detachment from the session tree (mandatory for agent-run
campaigns)**: agent sessions must never run fuzzing as a foreground
child. The runner script below wraps `cargo fuzz run` in
`setsid` + `nohup` + stdin/stdout/stderr redirection to a log file,
which (a) removes the fuzzer from the session's controlling terminal
and signal-relation, (b) makes the run survive the session ending, and
(c) gives the agent a pollable log/artifact tail instead of a blocking
call. Combined with fork mode, an OOM-class finding costs one child
process; combined with the soft RSS limit, the kernel OOM killer should
never be the discovery mechanism at all.
The detached-runner recipe for agent sessions (write as
`fuzz/run-detached.sh` in the implementation step, not inline in a tool
call):
```bash
#!/usr/bin/env bash
# Detached fuzzing runner for agent sessions: the fuzz campaign never
# runs as a foreground child of the session (OOM in a target must not
# take down the agent host), and survives the session ending.
set -euo pipefail
target="${1:?usage: run-detached.sh <target> [extra libfuzzer args...]}"
shift
runtime="${FUZZ_RUNTIME_SECS:-600}"
log="fuzz/artifacts/${target}-$(date -u +%Y%m%d-%H%M%S).log"
mkdir -p fuzz/artifacts
setsid nohup cargo fuzz run "$target" -- \
-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25 \
-max_total_time="$runtime" "$@" \
>"$log" 2>&1 < /dev/null &
echo "pid=$! log=$log"
```
Polling protocol for the agent: `tail -n 50 fuzz/artifacts/<target>-*.log`
and check `fuzz/artifacts/` for `crash-*`/`oom-*`/`timeout-*` files
periodically; `pgrep -f "cargo fuzz run <target>"` to see if it is still
running. Never `wait` on the detached process from a tool call — poll
instead. One session can hold one detached campaign per target; the log
filename carries the UTC timestamp.
**For CI the constraint does not apply** (the runner *is* the job), but
the same flags apply everywhere: `-rss_limit_mb`/`-malloc_limit_mb` and
`-fork=1` should be pinned in the target run configs in both the smoke
tier and the scheduled campaign. For the scheduled campaign tier, also
cap concurrency (`-fork=$(nproc)` is fine on CI runners; locally prefer
an explicit number well below core count) and keep the Actions-job
timeout above the `-max_total_time` budget so a wedged harness is killed
by the runner, not the host.
---
## 7.7 Implementation status (step 1 of §7.4, 2026-09-27)
**Adopted.** The `fuzz/` workspace exists with targets 1 and 2 from
§7.1, committed seed corpora, the detached runner, and stable-side
corpus replay. Everything below is verified in-tree.
### Layout (as implemented)
```
fuzz/
├── Cargo.toml alkcall-fuzz (nightly-only bins; own [workspace])
├── rust-toolchain.toml pins nightly for this subtree only
├── fuzz_targets/
│ ├── chunk_header.rs thin fuzz_target! wrapper
│ └── envelope_frame.rs thin fuzz_target! wrapper
├── shared/ alkcall-fuzz-shared — STABLE-toolchain library
│ └── src/{chunk_header,envelope_frame}.rs invariant logic + corpus replay
├── corpus/{chunk_header,envelope_frame}/ committed seeds (245 total)
├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern)
├── run-detached.sh §7.6 detached runner (CWD-independent)
├── json.dict JSON token dictionary for envelope targets
└── README.md
```
Deviations from the §7.1 sketch, all load-bearing:
- **The invariant logic lives in `fuzz/shared/` (a separate
stable-toolchain crate), not inline in the target binaries.** This
makes §7.2's tier-3 fallback (corpus replay as plain `cargo test`)
real from day one: `cargo test --manifest-path fuzz/shared/Cargo.toml`
replays every committed seed through the identical invariant functions
the fuzzer runs, on stable, in normal CI. The `fuzz_target!` binaries
are three-line wrappers.
- **The root `Cargo.toml` gained a `[workspace]` table
(`members = ["."]`, `exclude = ["fuzz"]`)** and `"fuzz/"` joined the
publish `exclude` list. Without the explicit workspace table, cargo
auto-discovers `fuzz/shared/` into the main workspace and the MSRV
toolchain tries to build the nightly-consumed dev-deps.
- **`fuzz/rust-toolchain.toml` pins `nightly`** for the fuzz subtree so
`cargo fuzz build` works from any CWD (rustup resolves toolchains per
directory; the initial build attempt from the repo root picked the
stable toolchain and failed on `-Zsanitizer`). Nightly remains
confined to `fuzz/` — the crate itself stays stable at MSRV 1.88.
- **No `#[cfg(fuzzing)]` exposure was needed.** Both target APIs were
already `pub`: `parse_header`/`write_header` (`channels/wire.rs`) and
`FrameFramedReader`/`FrameFramedWriter` (`protocol/wire.rs`). The one
private item, `MAX_FRAME_SIZE` (`wire.rs:20`), is a wire-stable
constant (ADR-014), so the shared crate carries its own copy
(16 MiB/64 MiB are one-way-door values; a drift would be caught by the
shape invariants, which assert the rejection boundary).
- **Seeds are committed** (245 files), gitignored artifacts/corpus-grown
per the generated `fuzz/.gitignore`; the generator script
(`gen_fuzz_seeds.py`) is committed next to them.
### Invariants encoded (beyond the §7.1 table)
`chunk_header`:
- no-panic; round-trip identity (`write_header(parse_header(x)) ==
x[..8]`); `TooLarge` iff `length > MAX_CHUNK_LEN` with the parsed
`length` echoed in the error; `HeaderTooShort` iff `< 8` bytes with
exact `need`/`have`; `is_eof() == (length == 0)`; consumption
accounting (8 bytes); **input buffer never mutated**.
`envelope_frame`:
- no-panic; clean error classes with **shape assertions** on each
`FrameError` variant: `ConnectionClosed` only when the prefix is
truncated or the body is short (a complete body must never yield it);
`InvalidFrame` only for `len == 0` or `len > MAX_FRAME_SIZE`;
`Json` only when the full body was present; `Io` impossible on a
Cursor; Ok only when `len > 0`, `len <= MAX`, body complete.
- allocation bound: the reader never allocates for `len > MAX_FRAME_SIZE`
or `len == 0` (rejection precedes `vec![0u8; length]` — verified by the
`InvalidFrame`/Ok shape partition).
- exact consumption accounting (4 + len bytes read for a valid frame).
- structural round-trip: every decodable envelope re-encodes via
`write_frame` and re-decodes to an equal envelope (structural —
serde_json without `preserve_order` does not preserve key order).
- serde contract: the decoded envelope serializes with `type`/`id`/
`payload` keys intact (`type` survives the rename).
- **trailing-byte probe**: for any input ending in a complete JSON
object body, a copy with one extra byte appended *and counted in the
length prefix* must fail with a `Json` error (serde_json's
from_slice rejects trailing non-whitespace) — catching any future
switch to a streaming/tolerant reader that would silently accept
junk after the JSON document.
Note on the probe: the length prefix counts **only the body**, not the
4-byte prefix — the first probe draft claimed `4 + len + 1` and tripped
its own shape assert (`ConnectionClosed`). The fuzzer would have found
that instantly; corpus replay found it first.
### Verification at adoption
- `cargo fuzz build` clean (nightly-1.95.0-nightly, cargo-fuzz 0.13.2,
libfuzzer-sys 0.4.13).
- Stable side untouched and green: `cargo test` 682 passed / 0 failed;
`cargo clippy --all-targets -- -D warnings` clean; `cargo fmt --check`
clean (main crate, fuzz workspace, and fuzz/shared).
- Corpus replay (`cargo test --manifest-path fuzz/shared/Cargo.toml`):
245 committed seeds through both invariant sets, green.
Commit: 80a37e9 (step-1 implementation, before the campaigns below ran).
### Campaign results (10-min detached runs per target, §7.6 runner)
**No crashes, no hangs, no leak/OOM findings.** Both campaigns ran to
their time budget via `run-detached.sh` and exited 0 (`-fork=1
-rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25`;
`-max_len=65536 -dict=json.dict` for the envelope target); artifact
directories empty after both.
- `chunk_header` — saturated its state space in the first seconds and
spent the remaining ~10 min confirming stability: flat
`cov: 51 ft: 54` from job ~5 onward, 203.9M execs at ~350k exec/s,
`oom/timeout/crash: 0/0/0` on all 33 fork jobs. The pure-8-byte-parser
ceiling is fully enumerated.
- `envelope_frame` — still finding coverage when the budget ended:
2119 edges / 7246 features / 1664 in-memory corpus entries
(from the 245 committed seeds), ~7.08M execs at ~12k exec/s,
`oom/timeout/crash: 0/0/0` on all 33 fork jobs. Growth was still
positive (JSON structure exploration), so longer campaigns keep
paying; the 25 s timeout and both memory tripwires never fired.
The grown envelope corpus (1631 new entries) was **not** merged into
the committed seeds — per the §7.1 policy, grown corpora stay
gitignored; worth revisiting if a future scheduled campaign produces
inputs worth curating. §6.2-3 (pre-payload allocation window) remains
bounded as designed. §6.2 items 1/2/4/5 are stateful targets
(§7.4 step 3) and were not exercised by these parser-level campaigns.
---
## 7.8 Step 3: targets 3–5 (2026-09-27)
**Adopted.** Three more targets, all over already-`pub` APIs (the
`#[cfg(fuzzing)]` exposure never materialized — quinn/rustls-style
exposure has not been needed for any of the five targets):
| Target | Drives | Invariants |
|---|---|---|
| `manager_routing` | `ChannelManager` under `#[derive(Arbitrary)]` op sequences (`Route`/`Open`/`Adopt`/`Teardown`/`ClearAll`), 256 ops × 4 KiB payloads, live mux runner, drainer tasks | no-panic; **exact counter models**: harness parked/dropped counters must equal the manager's monotonic `early_arrival_count`/`dropped_unknown_chunks` after every op; parked bytes ≤ distinct-ids × 64 × 16 MiB (the documented per-channel bound, §6.2-1); post-sequence `clear_all` reconciles every routed byte against the drainers' totals (lossless bounded-buffer routing, sentinel semantics included) |
| `envelope_semantic` | `#[derive(Arbitrary)]` `EnvelopeKind` → the six `EventEnvelope` constructors → serde round-trip → `write_frame`/`read_frame` | no-panic; constructors emit exactly the wire-stable event-type constants; any `Value` payload survives a serde round-trip; `call.error` payloads always parse back as `CallError` (ADR-016 closed schema); structural `write_frame`/`read_frame` round-trip |
| `spec_parse` | raw bytes → `serde_json` → `OpRegisterRequest::from_json` (the `pub` wrapper over `rebuild_spec_for`) → `OperationRegistry::register` (attacker-shaped schemas **compile at registration**, CF-003) | no-panic; parse failures are clean `INVALID_INPUT`; `spec_to_json_pub` → `from_json` round-trips the rebuilt spec; registration is always `Result` (uncompilable schema = compile error, never silent); registry bookkeeping survives repeated attacker-shaped inserts |
New supporting pieces: `fuzz/shared/src/arbitrary_value.rs` (a bounded
`Arbitrary` impl for `serde_json::Value` — depth 6, width 6, sized
strings/keys), and 20 `spec_parse` + 4 `manager_routing` committed
seeds (the op-sequence corpus is tiny because structured `Arbitrary`
inputs self-generate; the four committed seeds are semantic fixtures
including the duplicate-adopt sequence that pinned the bug below).
### First real finding: duplicate adopt/open destroyed the live channel
The `manager_routing` campaign found it in its first minutes — the
§6.2 stateful thesis, confirmed: **the bug is invisible to
example-based tests** because it needs an *interleaving* (adopt → drop
write half → duplicate adopt → route) that no unit test thinks to
replay.
**Mechanism** (`src/channels/manager.rs`, `open_channel` and
`adopt_channel`, pre-fix):
```rust
if channels.insert(channel_id, state).is_some() {
return Err(ManagerError::ChannelExists(channel_id));
}
```
`HashMap::insert` **replaces** an occupied entry and returns the old
value — so the collision-detection idiom silently *installed the new
state and dropped the old `ChannelState`*, whose `demux_sender` was
the live channel's read half. A duplicate `adopt_channel`/`open_channel`
on an in-use id returned `Err(ChannelExists)` (correct) **while
destroying the live channel** (incorrect): readers saw a spurious EOF,
subsequently routed chunks were lost, and the mux write half kept
framing onto the transport for a channel the demux no longer fed.
Reproducibility: a failed adopt on a live channel whose mux pump had
finished (the write half dropped — as any caller does) re-registered a
pump, succeeded through `mux.register`, and hit the replacing insert.
The duplicate-adopt path is reachable whenever an open-op response is
replayed or a connection-owner race re-announces an id.
**Fix** (this commit): check `channels.contains_key(&channel_id)` and
return before any insert — the map is never touched on collision in
either `open_channel` or `adopt_channel`. Regression tests:
`adopt_channel_duplicate_id_leaves_live_channel_intact`,
`open_channel_duplicate_id_leaves_live_channel_intact` (both verify
routing survives a rejected duplicate; the first also verifies the
EOF sentinel remains the only EOF source).
**Also fixed in the harness** (fuzzer-vs-harness findings, not crate
bugs): `clear_all` returns the opener-ledger entries (ADR-047 §7),
not channel-map entries — adopted channels carry no ledger entry, so
`drained.len() == open_count()` is *not* an invariant; the monotonic
counters survive `clear_all`; and drainer-byte reconciliation needs
per-id supersession when a fresh stream replaces a drained one.
### Campaign results (10-min detached runs per target, §7.6 runner)
The smoke-tier runs above surfaced two harness-model defects worth
recording (the fuzzer as a harness-checker), fixed before the long
runs: the op-sequence driver's EOF-sentinel latch is **per-receiver,
not per-channel-id** — a fresh adopt installs a fresh reassembled
stream with no latched EOF, while a sentinel inside a *drained
early-arrival queue* latches the new receiver too
(`drain_early_arrivals` delivers it); and `Adopt` ops must respect
ADR-047 §5's odd/even split (an Accept-side manager adopts the peer's
odd ids; its own `Open` allocates even — a cross-range adopt/open
collision is a protocol violation, not a manager bug).
**Final 10-minute detached campaigns, all three targets (post-fixes,
post the `insert`-replace fix): every run exited 0 with an empty
artifacts directory — no crashes, hangs, OOMs, or leaks.**
- `manager_routing` — 1.06M execs (~1.9k exec/s; each exec replays up
to 256 manager ops on a live runtime), 33 fork jobs clean,
`oom/timeout/crash: 0/0/0` throughout. Final coverage 2499 edges /
11332 features / 461 in-memory corpus entries. The exact-counter and
parked-bytes invariants held across every adversarial sequence.
- `envelope_semantic` — 2.44M execs (~4.4k exec/s), 33 fork jobs
clean, coverage saturated at 1669 edges (constructor × payload shape
space is finite), corpus 765. All six event kinds round-tripped
structurally; `CallError` parse-back never failed.
- `spec_parse` — 10.8M execs (~19k exec/s), 33 fork jobs clean,
coverage 978 edges / corpus 1267. The registry compiled every
attacker-shaped schema it was handed (or rejected cleanly); the
`spec_to_json_pub` → `from_json` round-trip never diverged.
§6.2-1 (parked-bytes bound) is now encoded as an exact counter model
and held; §6.2-3 confirmed bounded at the parser level (§7.7). The
stateful coverage §7.4 step 3 called for exists and is clean.
---
## 7.9 No hosted CI — the standing policy (2026-09-28)
§7.2's two CI tiers (per-push smoke, scheduled campaign) and the
OSS-Fuzz step assume a hosted CI platform. This repo has none and will
not get one: the git host is a minimal gitea that serves git and
nothing else, deliberately — gitea/gitlab have had full-compromise CVEs
(app.ini and any plaintext DB tokens exfiltrated in one real incident
elsewhere), so the attack surface stays minimal. All verification and
campaigns are **manual, run from the separate publishing server**:
- **Corpus replay is the fuzz gate, as plain `cargo test`:**
`cargo test --manifest-path fuzz/shared/Cargo.toml` replays every
committed seed through the same invariant functions the fuzzer runs
— on stable, no nightly, no cargo-fuzz. It is part of the release
verification checklist (AGENTS.md). This is CI tier 3's deliverable
(§7.2), minus the automation.
- **Campaigns** run via the detached runner (§7.6) when wanted —
e.g. before a release, or after touching `src/protocol/wire.rs`,
`src/channels/wire.rs`, the demux loop, or `ChannelManager`.
- **OSS-Fuzz is out**: it requires hosted infrastructure and a public
repo posture this project does not have.
- Do not add Actions/Gitea-Actions/workflow files anywhere in this
repo, and do not reintroduce "when CI exists" language into docs —
point at this section instead.
## 8. Answering the "if / how" directly
- **If?** Yes — justified by position in the dependency graph, two stable
attacker-reachable wire formats, and two concrete DoS-class findings a
fuzz campaign would have caught (§6.2). The expected bug classes here
are panics and resource exhaustion, not memory unsafety (no `unsafe`
exists), which is precisely the profile where cheap fuzzing pays off.
- **How?** `cargo-fuzz` + `arbitrary`, a standard `fuzz/` workspace with
five targets (§7.1), committed seed corpora only, `#[cfg(fuzzing)]`
internals exposure where needed, two-tier CI with a 10 s-per-target
smoke on every push (§7.2), nightly confined to the fuzz job, AFL as an
optional secondary engine, bolero deferred, OSS-Fuzz after
stabilization. **Locally (agent sessions), campaigns always run via the
detached runner (§7.6): `setsid` + `nohup` + log file + poll, fork mode
on, soft RSS/malloc limits pinned — an OOM in a fuzz target must cost
the fuzzer, never the agent session.**
## 9. References
- RUSTSEC-2026-0037 / CVE-2026-31812 (quinn-proto remote DoS; maintainers'
fuzzing-coverage statement) — https://rustsec.org/advisories/RUSTSEC-2026-0037.html
- cargo-fuzz releases / Rust Fuzz Book — https://github.com/rust-fuzz/cargo-fuzz ·
https://rust-fuzz.github.io/book/
- arbitrary 1.4.2 and the 2025 speed analysis —
https://nnethercote.github.io/2025/08/16/speed-wins-when-fuzzing-rust-code-with-derive-arbitrary.html
- rustls fuzz targets + CI — https://github.com/rustls/rustls/tree/main/fuzz ·
https://github.com/rustls/rustls-fuzzing-corpora
- quinn fuzz targets (packet/streams/params) — https://github.com/quinn-rs/quinn/tree/main/fuzz
- quiche fuzzing + seed generation — https://github.com/cloudflare/quiche/tree/master/fuzz
- h2 MockIo e2e target — https://github.com/hyperium/h2/tree/master/fuzz
- prost fuzz (libfuzzer + AFL, FUZZING.md) — https://github.com/tokio-rs/prost/tree/master/fuzz
- s2n-quic bolero corpus-per-test pattern — https://github.com/aws/s2n-quic
- yamux allocation guard / libp2p validate-without-allocating —
https://github.com/libp2p/rust-yamux ·
https://github.com/libp2p/rust-libp2p (gossipsub `validate_rpc_limits`)
- rust-fuzz trophy case (finding-class taxonomy) — https://github.com/rust-fuzz/trophy-case
- OSS-Fuzz Rust integration — https://google.github.io/oss-fuzz/getting-started/new-project-guide/rust-lang/
- Scheduled CI fuzzing with cached corpora — https://depot.dev/blog/distributed-rust-fuzzing
Internal: ADR-014 (envelope framing), ADR-034 (chunk header), ADR-040
(backpressure/limits), ADR-042 (hub relay), ADR-031 (crate
decomposition); review 006 E-04 (early-arrival cap sizing note,
`src/channels/manager.rs:106-121`).
@@ -0,0 +1,561 @@
# Review 006 — Channel-Open Establishment Gap (from alktunnels Phase 0)
## Status
**Resolved-by-ADR (E-01, N-1) / Implemented (E-02, E-03, E-04, N-2).**
Findings filed from the alktunnels Phase 0
research pass (2026-09-06). This is a design review, not a code-defect
review: the establishment gap (E-01) is real, POC-observable, and
load-bearing for the next downstream crate; the remaining findings are
smaller mechanism/coverage gaps noticed in the same sweep.
**2026-09-06 verification + remediation pass.** All four findings were
independently re-verified against source at tree `88e3f5e` (0.4.1 +
this review's own commit) — verdicts CONFIRMED for E-01..E-04, with
one correction to E-02's cost estimate (see the verification appendix
at the bottom of this file). Three additional findings filed from the
same sweep: **N-1** (client error-type gap — blocking for E-01's
consumer visibility), **N-2** (dead counter), **N-3** (panic posture,
pinned). **E-01 + N-1 are resolved by ADR-049**
(`docs/architecture/decisions/049-channel-open-establishment-phase.md`
— split-hook `OpenEstablisher`, bounded await, `channel:open_failed`
typed error). **Unit 1 (E-01 + N-1) is implemented** in alkcall 0.5.0
(`OpenEstablisher` + `register_openable_with_establisher`, wrapper
establishment phase with bounded await, `channel:open_failed` typed
error, `ChannelOpenError` typed client error — all four verification
gates landed as tests; two implementation-shape notes recorded in
ADR-049's amendment: the establisher does not receive the channel
`Connection` (yield-once BiStream belongs to the pump handler), and
the bound is the earlier of dispatch deadline and per-registration
timeout). **Unit 2 (E-02) is implemented** in alkcall 0.5.0
(`OperationSpec.description: Option<String>` +
`with_description`, `spec_to_json_pub` emits it when set,
`rebuild_spec_for` parses it (shared by `from_call` and `op/register`),
`services/list` and `services/list-peers` local listings emit it when
set; the ADR-047 §6 amendment below records the discovery decision).
**Unit 3 (E-03, E-04, N-2) is implemented** in alkcall 0.5.0 (E-03: the
wrapper's handler-exit teardown discards `UnknownChannel` through a
debug log + a benign-race pinning comment; E-04: the 64-parked-chunks
observable bound is documented consumer-side in `channel-client.md` §
"The early-arrival park bound (push-first producers)" and on the
`EARLY_ARRIVAL_CAP` const; N-2: `early_arrival_count` is exposed via
`ChannelManager::early_arrival_count()` — the observability choice,
paired with `dropped_unknown_chunks` — with a monotonicity test).
The original remediation sketch below is superseded by the
"Remediation plan (post-verification)" section; the original sketch
is retained for the record.
Findings continue the review numbering with prefix `E` (001–005 used
P/C/R/A/B/C/D/F/G — each review numbers independently).
## Scope
The channels open-op path (`run_open_wrapper` and the `OpenHandler`
contract) reviewed from the perspective of the next consumer crate
(alktunnels — arbitrary TCP/UDP tunnels over channels), cross-checked
against the two existing consumers (alktty, alkhttp) and the SSH
forwarding prior art recorded in
`/workspace/@alkdev/alktunnels/docs/research/ssh-socks5-survey.md`.
Everything below was verified directly in source at tree `a22b2b8`
(0.4.1 + the early-arrival park fix). No code changes were made in
this repo by this review. The 2026-09-06 re-verification pass
(verification appendix) re-traced all findings at tree `88e3f5e` —
no source changes touched the reviewed paths between the two trees
(the only delta is this review's own commit).
```
Verified against: alkcall a22b2b8 (0.4.1); re-verified at 88e3f5e
Reading list: src/channels/operations.rs (run_open_wrapper, OpenHandler,
make_open_handler_once/stream/sink), src/channels/manager.rs
(open_channel, teardown_channel, route_payload, early-arrival park),
src/channels/reassembly.rs (MpscSendStream::poll_shutdown, Drop,
MpscRecvStream::poll_read EOF arms), src/channels/mux.rs (implicit-EOF
pump path), src/protocol/wire.rs (CallError), src/registry/
registration.rs (invoke/invoke_streaming gates), src/registry/
discovery.rs (services/list, spec_to_json_pub), src/client/from_call.rs
(rebuild_spec_for), docs/architecture/decisions/047,
docs/architecture/channel-operations.md
```
## Severity legend
Same scale as reviews 004/005:
- **[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 open-op establishment gap
## E-01 [major] — The open op cannot fail after allocation: the consumer receives a live channel for a tunnel whose establishment failed, with no error channel
**Verified:** YES, by code trace and mechanism analysis.
### The mechanics
`OpenHandler` (ADR-047 §3) is the ALPN crate's hook for "validate
params, consult ownership, prepare the backend" — i.e., the
establishment phase of a channel. But the wrapper does not await it:
1. `run_open_wrapper` (`src/channels/operations.rs:483-528`):
`policy.check_open` → `manager.open_channel(alpn, opener_id, None)`
→ build the channel `Connection` → `open_handler(input, channel_conn,
auth)` **spawns** the handler and collects its `JoinHandle` →
`ResponseEnvelope::ok(request_id, json!({ "channel_id": channel_id }))`
(`operations.rs:503-527, 537`). The reply is written to the wire the
moment the handler task is *spawned*, not when the handler has done
its establishment work.
2. The `OpenHandler` type (`operations.rs:333-334`) returns
`tokio::task::JoinHandle<()>` — there is no result, no error variant,
no establishment phase the wrapper can consult. Any failure inside
the handler (params valid at the schema level but semantically
rejected, backend lookup failure, target dial failure for a
`direct-tcpip`-shaped tunnel, resource no longer available) is
invisible to the open op's reply.
3. What the consumer observes on handler-side failure: the call op
**succeeds** with `{channel_id}`, the channel is adopted
(`ChannelClient::open_channel`, `src/channels/client.rs:227-250`),
and then the channel EOFs — the handler wrote nothing before
exiting, the mux pump writes the implicit-EOF chunk on receiver end
(`src/channels/mux.rs:78-81`, REQ-CH-01 implicit-EOF path), and
`MpscRecvStream::poll_read` returns clean EOF for both the sentinel
and sender-drop arms (`src/channels/reassembly.rs:111-135`). A
dial-failure EOF is byte-for-byte indistinguishable from a target
that closed immediately after connecting — the two most different
failure/success stories map to the same consumer-visible event.
### Why this is the wrong shape (SSH's semantics, prior-art checked)
Every established tunnel/forwarding protocol puts establishment
failure in the open reply, not in the data stream:
- **SSH** (RFC 4254 §5.1): `SSH_MSG_CHANNEL_OPEN_FAILURE` is a
first-class reply to the open, carrying a reason code
(`ADMINISTRATIVELY_PROHIBITED` / `CONNECT_FAILED` /
`UNKNOWN_CHANNEL_TYPE` / `RESOURCE_SHORTAGE`) plus a description
string; the channel never exists on the opener's side afterward
(verified end-to-end in russh:
`src/server/encrypted.rs:1244-1278`, `src/client/encrypted.rs:414-434`
— see `/workspace/@alkdev/alktunnels/docs/research/ssh-socks5-survey.md`
§"Open-failure path").
- **SOCKS5** (RFC 1928 §6): the reply carries REP codes 0x01–0x08 and
the connection closes within 10s on failure — error-then-close, never
"success then in-stream error."
- **udpgw** (`tun2proxy/src/udpgw.rs:21-26`) is the counterexample: an
opaque ERR bit with zero reason information — the survey flags it as
the vocabulary to avoid.
- **alktty was forced to reinvent the missing mechanism in-band.** The
direct-ALPN path answers the negotiation with a length-prefixed JSON
error frame *on the stream* (`send_negotiation_error`,
`alktty/src/adapter.rs:177-187`; the `0x00`-prefix first-byte
disambiguation trick, `alktty/docs/architecture/tty-adapter.md:186-197`),
and the channels path retains the same error-frame shape even though
the open op's `input` is the negotiation (alktty ADR-009) — because
post-allocation failures have nowhere better to go. That is
per-crate reinvention of a protocol-level capability every ALPN
crate will need: a structured, typed, **establishment-failure reply
to the open op**.
### The cost today, concretely
- A consumer cannot distinguish "ACL denied" (call error,
`channel:forbidden` — never allocated) from "dial refused" (open
succeeded, instant EOF) from "target accepted then instantly closed"
(open succeeded, instant EOF). Retry policy, client UX, and error
reporting are impossible on the second and third.
- ADR-016's typed error details (`CallError.details`) — which the
wrapper already uses for `channel:too_many_channels` with
`{count, max}` details (`operations.rs:530-545`) — cannot carry
dial-failure reasons.
- The alktty in-band error-frame path exists *only* because the
wrapper can't fail the open post-allocation; every future ALPN crate
faces the same fork: reinvent an in-band error vocabulary or
silently-EOF.
### The ask (proposed shape, for the ADR — not a prescriptive API)
Give the open-op wrapper an **establishment phase it awaits before
replying**. Minimal, backward-compatible shape:
1. `OpenHandler` gains an establishment result. Two candidate shapes:
- **Split the hook**: `OpenEstablisher` (async, awaited by the
wrapper — validates params semantically, prepares/dials the
backend, returns `Result<Establishment, EstablishmentError>`)
followed by `OpenHandler` (spawned on the established channel, as
today). The dial is the natural establisher step for tunnels; the
pumps remain the spawned handler.
- **Await-and-inspect**: keep the single `OpenHandler` returning
`JoinHandle<OpenResult>`; the wrapper awaits a bounded
establishment phase (a `JoinHandle::timeout` equivalent — select
on the handle vs an establishment deadline) before replying. The
handler signals "established, continue" via an agreed value
(e.g. the handler resolves a first `Result<(), HandlerError>`
promptly, or the wrapper watches a oneshot the handler signals).
2. On establishment failure: the wrapper tears down the just-allocated
channel (`teardown_channel` — the ledger/policy paths already
handle this atomically) and replies with a **new typed CallError**,
e.g. `channel:open_failed`, with `details` carrying a reason code +
message. Reason-code vocabulary per the SSH four (the survey's
finding): policy-denied (already distinct — `channel:forbidden`),
dial-failed, unknown-resource-or-substrate, resource-shortage —
mapping 1:1 onto what an open handler can actually produce. Wire
addition is additive (new error code string + optional details
shape), no existing consumer breaks.
3. Backward compatibility: the existing `OpenHandler` signature is
preserved if the split shape is chosen (old handlers still compile —
the establisher is a new, separately-registered hook, defaulting to
an always-OK establisher for the no-establishment-work case).
### Alternative considered and rejected
An in-band establishment/error frame (alktty-style, on the channel
stream, alktunnels' original OQ-TN-09 direction) works without an
upstream change, but: (a) it forces every ALPN crate to define a frame
vocabulary and a disambiguation scheme (alktty's `0x00` peek is
exactly this cost, paid once per crate); (b) it cannot carry typed
`CallError.details` or participate in ADR-016 error schemas; (c) it
leaves the phantom-opened channel in the manager (allocation/ledger/
policy all fire for a channel that never carried data); (d) SSH's
semantics — the failure is the open's reply, the channel never exists
opener-side — are the cleaner contract, and channels is at exactly the
maturity point (three downstream dependents, alkcall 0.4.x) to fix it
upstream cheaply.
### Severity
[major] — not [critical]: no data corruption, and the capability is
reachable via per-crate workarounds (alktty proves it). But it is the
shape every future protocol crate will fight, the fix is cheaper now
than after the next consumer, and the workaround path (in-band frames)
bakes in a wire format that would then need to stay stable per crate.
---
# Part B — Smaller findings from the same sweep
## E-02 [minor] — `services/list` discloses no per-op metadata; openable resources are indistinguishable by name alone
`services_list_handler` (`src/registry/discovery.rs:247-269`) maps
`list_operations()` to `{name, namespace, op_type}` only. For a tunnel
registry, the consumer's discovery question is "which tunnel resources
may I open" — and the answer arrives as a bare list of op names
(`channels/tunnel/sub`...). Per-resource identity (which target, which
substrate, human description) has no field to live in:
- `services/schema` (`spec_to_json_pub`, `discovery.rs:211-236`) can
disclose it *per op* (input schema, access_control), so the data
path exists — but it is N+1 round-trips, and the input schema
describes the *open params contract*, not the *set of produced
resources* (a tunnel producer registers one op and N resources).
- ADR-047 §6 already anticipated the dynamic half:
`channel/resources/subscribe` aggregates per-ALPN resource
enumerators — but the handler is a not-implemented stub
(`channel:resources_not_implemented`, OQ-40,
`operations.rs:274-299`).
This is not an alkcall defect — OQ-40 is decided-deferred and the stub
fails loudly by design. Filing it because alktunnels' discovery need
(OQ-TN-08 resolution: "the ops listing IS tunnel-resource discovery")
makes OQ-40 load-bearing for the first time: a consumer UI cannot
distinguish produced tunnel resources without either the enumerator
aggregation or a listing enrichment. Recommend deciding (small ADR or
OQ-40 update) whether:
1. `channel/resources/subscribe` lands (per-ALPN enumerators — the
decided shape), or
2. `services/list` gains an additive per-op `description`/`metadata`
field (static, registry-side — cheap, but describes the op, not the
resource set), or
3. Both: subscribe for live resource sets, list enriched for op
descriptions.
alktunnels Phase 1 can proceed with (3)'s shape assumed; the minimal
v1 consumer can also just know the op name out-of-band (config), which
is why this is [minor] today.
## E-03 [minor] — `OpenHandler`-exit teardown races `channel/close`'s awaited teardown, but the ledger `take` makes it benign (verify + document)
`run_open_wrapper`'s spawned teardown task (`operations.rs:505-518`)
and the `channel/close` handler's teardown path
(`make_close_handler`, `operations.rs:170-238` — 5s await on the
handler task, then ledger `take` + `on_close`) both walk
`opener_ledger().take(id)` → `policy.on_close(opener)`. The `take` is
atomic (first caller removes the entry), so no double-decrement — the
cap cannot drift upward. But the loser of the race:
- The wrapper task's `teardown_channel(id)` returns
`Err(UnknownChannel)` (logged? no — `let _ =` discard,
`operations.rs:509`) and skips the ledger/policy half silently.
- The close handler's `Ok(task)` arm then awaits a task that is
already exiting.
Verified benign for the cap invariant (`take` is the gate —
`operations.rs:510`, `operations.rs:210`), but the `let _ =` discard
at `operations.rs:509` means a real `UnknownChannel` after a
`set_handler_task` failure (`operations.rs:520-527` — the "channel
vanished between open and handler-task install" warn) is
indistinguishable from a benign race. Recommend either a debug log on
the discard or a comment pinning the race as designed. No correctness
impact; filing for the record since it was checked during the E-01
trace.
## E-04 [minor] — Early-arrival park cap (64) interacts with push-first producers under slow adopters — cap-drop is silent at the consumer
The early-arrival buffer (`EARLY_ARRIVAL_CAP = 64`,
`manager.rs:111`, `park_early_arrival` `manager.rs:440-462`) is
per-channel and drops past the cap with a debug log + counter —
correct per the adopt-race design. But for a *tunnel* producer whose
handler dials then immediately pumps (the common case), 64 chunks can
arrive before the consumer's `adopt_channel` runs if the open-op
response is delayed (relay hops, scheduling). The drops are counted
(`dropped_unknown_chunks`) but not visible to the channel's
consumer — data loss presents as a truncated stream with clean framing
elsewhere. Not a correctness bug (the cap is the documented behavior;
the fix shipped in `a22b2b8` is the right shape), but worth a note in
the tunnel-crate's POC checklist: the UDP POC's MTU-vs-buffer sizing
should treat 64 parked chunks as the observable bound. No alkcall
change requested; filed so the constraint is visible to the next
consumer.
## Non-findings (verified correct, recorded to bound the re-review)
- **The open-op ACL path is complete**: `invoke_streaming` runs the
same visibility + `AccessControl::check` + `input_schema` gates as
`invoke` (`registration.rs:380-404` vs `:347+`); the Sub-typed open
op's ACL failure is a `call.error` on the open, no channel
allocated. Verified against the `invoke_streaming_acl_denied_yields_
forbidden` test (`registration.rs:1628`).
- **`input_schema` enforcement covers open params** (0.4.0, the
alktty-review-L1 fix): `check_input_schema` runs before the handler
in all three dispatch entry points; schema-invalid open params are
`INVALID_INPUT` call errors — never a phantom channel. The
semantic-beyond-schema gap is E-01's subject and is properly
post-schema.
- **EOF arms are unified and clean**: both the sentinel (`Bytes::new`)
and sender-drop arms of `MpscRecvStream::poll_read` yield clean EOF
(`reassembly.rs:111-135`); the mux pump writes the implicit-EOF
chunk for handler-drop-without-shutdown (`mux.rs:78-81` +
`mux_pump_writes_eof_on_implicit_close` test). The two-pump
shutdown contract (alknet ADR-078) has its upstream half in place.
- **The `channel_open` marker survives the wire round-trip**
(`spec_to_json_pub` emits the boolean; `rebuild_spec_for` re-derives
the ALPN from the op name — `from_call.rs:265-280`, `:298-314`),
including the `custom/proto` multi-segment case. The G-04
`resource_id_path` fix is present (`from_call.rs:260`,
round-trip test `from_call.rs:610-617`).
- **The opener-ledger cap decrement is atomic with removal**
(`opener_ledger().take` gates every `on_close` path); the
double-decrement concern from ADR-047 §7 is closed at every call
site traced.
---
# Remediation sketch (original, superseded — see "Remediation plan (post-verification)")
**Unit 1 — E-01 (the establishment phase).** Decided-shape ADR first
(this is ADR-047 §3 contract territory — the `OpenHandler` type shape
is a cross-crate API surface, and both existing consumers' handlers
must be considered; alktty's `TtyOpenHandler` is the migration
prototype). Implementation sketch: `ChannelCore::register_openable`
gains an optional establisher hook; `run_open_wrapper` awaits it
bounded (establishment deadline — a constant, e.g. 10s, or per-spec),
tears down on failure, replies `channel:open_failed` with
`{reason, message}` details on failure and `{channel_id}` on success.
alktty migrates its channels-path error-frame to the call-error path
where applicable (its direct-ALPN path keeps the in-band frame — two
transports, two contracts). alkhttp unaffected (no openable ops).
**Unit 2 — E-02 (discovery enrichment).** Decide via OQ-40 update +
small ADR amendment: recommend (3) — subscribe for live resource sets
(the decided shape, now load-bearing), plus an additive
`description` field on the listing (cheap, immediately useful).
**Implemented 2026-09-06 (alkcall 0.5.0):** the listing half landed
(`OperationSpec.description`, four touchpoints as the appendix
corrected); the subscribe half stays deferred (OQ-40, now with the
load-bearing note).
**Unit 3 — E-03/E-04.** E-03: log-or-comment; E-04: doc note. Trivial.
# Remediation plan (post-verification)
Firm ordering, decided 2026-09-06. E-01's ADR is **ADR-049**
(`docs/architecture/decisions/049-channel-open-establishment-phase.md`)
— decided shape: the **split hook** (`OpenEstablisher` awaited bounded
by the wrapper, `OpenHandler` spawned unchanged after success), chosen
over await-and-inspect because it preserves the `OpenHandler` type
(backward compatible by construction), keeps establishment and pump
lifecycles separate (SSH semantics: the dial is synchronous with the
open reply), and restores ADR-047 §3's original "channel plan" wrapper
shape. N-1 rides the same unit — without the client error-type fix,
`channel:open_failed`'s typed reason is unreachable through the
primary client path.
**Unit 1 — E-01 + N-1 (alkcall 0.5.0).** `OpenEstablisher` +
`register_openable_with_establisher` (no-establisher = always-OK,
existing registrations compile unchanged); wrapper flow: `check_open`
→ `open_channel` → await establisher bounded (dispatch deadline when
`Some`, else `ESTABLISHMENT_TIMEOUT` = 10s) → on failure:
`teardown_channel` + ledger `take` + `policy.on_close` + reply
`channel:open_failed` with `details: {reason, message}` (reason ∈
`dial_failed` / `unknown_resource` / `resource_shortage` /
`handler_error` / `timeout`); on success: spawn pumps,
`set_handler_task`, reply `{channel_id}`. `ChannelClient::open_channel`
returns a typed error carrying the `CallError`. Concurrency is safe:
the dispatcher spawns Once invocations as independent tasks
(`dispatch.rs` `spawn_once_dispatch`), so an awaited establisher does
not head-of-line-block channel 0. Version 0.5.0 (the `open_channel`
error-type change is semver-relevant). **Status: IMPLEMENTED
(2026-09-06)** — with two implementation-shape notes recorded in
ADR-049's amendment: the establisher takes `(input, auth)` only (the
yield-once channel `BiStream` belongs exclusively to the pump
handler), and the effective bound is `min(dispatch deadline,
registration timeout | ESTABLISHMENT_TIMEOUT)`. All four verification
gates below are tests in the crate.
**Unit 2 — E-02 (ride the 0.5.0 release).** Additive
`description: Option<String>` on `OperationSpec` — note this is four
touchpoints, not one (see the verification appendix's E-02
correction): struct field + `spec_to_json_pub` emit +
`rebuild_spec_for` parse + the `services/list` output-schema doc.
`services/list` emits it when set. OQ-40 stays deferred but gains a
"load-bearing for alktunnels discovery UI" note; the
`channel/resources/subscribe` half stays deferred (alktunnels v1 uses
config-known op names). **Status: IMPLEMENTED (2026-09-06)** — the
discovery decision (3: enriched listing now, subscribe deferred) is
recorded as an amendment to ADR-047 §6.
**Unit 3 — E-03/E-04/N-2 (trivial batch, same PR series as Unit 1).**
E-03: debug log (or pinning comment) on the discarded `UnknownChannel`
at the wrapper's teardown discard. E-04: doc note on `EARLY_ARRIVAL_CAP`
(the 64-parked-chunks observable bound) for tunnel-crate-facing
consumers. N-2: expose an accessor for `early_arrival_count` or remove
the write-only counter. **Status: IMPLEMENTED (2026-09-06)** — E-03:
debug log + benign-race pinning comment on the wrapper's handler-exit
teardown (mirroring the sibling log the Unit 1 establisher-failure
teardown already carries); E-04: consumer-side doc note in
`channel-client.md` plus the const doc; N-2: accessor
(`ChannelManager::early_arrival_count()`, documented as monotonic —
adopt-drain does not decrement) + test.
**Sequencing:** ADR-049 (landed) → alkcall 0.5.0 (Units 1+2+3) →
alktty migration (channels-path semantic failures move into an
establisher; the direct-ALPN in-band error frame is retained — two
transports, two contracts) → alkhttp mechanical pass
(`OpenableAlpn.establisher: Option<...>`, default `None`). alktunnels
Phase 1 blocks only on the ADR decision (landed), not the
implementation: its OQ-TN-09 direction ("self-contained control frame")
shrinks to *post-establishment* control only once `channel:open_failed`
exists.
## Verification gates for the E-01 remediation
- A channels end-to-end test: producer handler whose establisher fails
after channel allocation → consumer's `open_channel` resolves
`Err` with `channel:open_failed` + reason details; no channel in the
manager's `channel_ids()` afterward (the SSH "channel never exists
opener-side" property, consumer-visible as "no `channel_id` was
ever returned").
- A bounded-establishment test: establisher that never completes →
open op fails with a timeout-flavored error within the deadline,
channel torn down, ledger decremented.
- An alktty-migration test: the existing
`open_via_channels_surfaces_negotiation_rejected` scenario resolves
via call error (or retained error-frame — per the ADR's chosen
compat shape) unchanged in behavior.
- (Added post-verification) A compat gate: a no-establisher
registration behaves exactly as today — `{channel_id}` reply,
handler spawned, teardown on handler exit.
## Verification appendix (2026-09-06 re-verification pass)
All findings re-verified by independent source trace at tree `88e3f5e`.
Verdicts, corrections, and the additional findings:
- **E-01 CONFIRMED — and strengthened.** The wrapper's
spawn-then-reply flow is as described
(`src/channels/operations.rs` `run_open_wrapper`: handler spawned,
reply written before the handler performs any work). New supporting
evidence the original pass missed: **ADR-047 §3's decision text**
describes the ALPN open handler as "validate params, consult
ownership, prepare the backend, return a 'channel plan'" — an
awaited preparation the wrapper consults before replying. The
implemented `OpenHandler` (`JoinHandle<()>`) collapsed that phase
into a fire-and-forget spawn. E-01 is therefore **drift from
ADR-047 §3's own wrapper shape**, not merely a missing convenience —
the remediation restores the decided design, which lowers the ADR's
contention cost. Also verified: the serving loop spawns Once
invocations as independent tasks, so an awaited establishment phase
does not head-of-line-block other calls on channel 0 (a fix-feasibility
question the original pass did not address).
- **E-02 CONFIRMED — one correction.** `services_list_handler` emits
`{name, namespace, op_type}` only, as described. But the "cheap,
additive `description` field" framing understates the work:
`OperationSpec` has **no `description` field at all** (the
`description` in `spec.rs` is on `ErrorDefinition`). The change is
four touchpoints: struct field, `spec_to_json_pub` emit,
`rebuild_spec_for` parse, and the `services/list` output-schema doc.
Still small; still [minor].
- **E-03 CONFIRMED.** Both teardown paths gate on the atomic
`opener_ledger().take(id)`; the race loser gets `UnknownChannel` /
`take → None` — no double-decrement. The `let _ =` discard on the
wrapper's `teardown_channel` result is as described. Also traced:
the `set_handler_task` failure path still runs the wrapper task to
completion (self-teardown), so no leak in that arm either. The
log-or-comment remedy stands.
- **E-04 CONFIRMED.** `EARLY_ARRIVAL_CAP = 64`, per-channel, FIFO,
drop-past-cap with debug log + `dropped_unknown_chunks` counter.
Doc-note-only remedy stands.
- **N-1 [minor today, blocking for E-01's consumer visibility] —
`ChannelClient::open_channel` erases the typed error.**
(`src/channels/client.rs`) It flattens the `CallError` into a
`String` via `format!("open op failed: {e:?}")`. Even once the
wrapper replies `channel:open_failed` with reason details, a
consumer cannot branch on the reason — the error type destroys it.
E-01's verification gate ("consumer's `open_channel` resolves `Err`
with `channel:open_failed` + reason details") is unreachable without
changing this error type, so the fix is in scope for the E-01 unit
(ADR-049 §4), not deferred.
- **N-2 [trivial] — `early_arrival_count` is write-only.** Incremented
in `park_early_arrival`, never read anywhere (no accessor; only
`dropped_unknown_chunks` is exposed). Either expose an accessor
(observability for the E-04 bound) or remove the counter.
- **N-3 [observation, pinned by ADR-049 §6] — panicked pump handlers
are EOF-shaped by design.** The wrapper's teardown task swallows the
pump handler's `JoinError` (`let _ = raw_task.await`). A panicked
handler = phantom channel + instant EOF, indistinguishable from a
clean short-lived channel. With the establisher split, the pump-phase
panic stays in this category (correct — there is no mid-stream error
channel by design; establishment errors are the only kind that
belong in the open reply). ADR-049 §6 pins this posture; no change.
## References
- alknet ADR-078 / channels two-pump contract — the teardown half this
review's EOF-arms non-finding confirms upstream.
- alkcall ADR-047 (openable ALPNs are operations), ADR-016 (typed
error schemas — the vehicle for `channel:open_failed` details),
ADR-040/041 (backpressure/caps — untouched by E-01).
- alktty ADR-009 (the open op's input is the negotiation) + review
#001 L1/L3 resolution — the per-crate workaround E-01 obsoletes;
`send_negotiation_error` (`alktty/src/adapter.rs:177-187`) and the
`0x00`-peek (`alktty/docs/architecture/tty-adapter.md:186-197`) are
the in-band mechanism the upstream establisher replaces for the
channels path.
- alktunnels Phase 0 (`docs/research/phase-0-findings.md` OQ-TN-09)
and the SSH/SOCKS5 survey (`docs/research/ssh-socks5-survey.md`
§"Open-failure path", §"Comparison") — the prior art motivating
E-01's reason-code vocabulary.
- The consumer-findings ledger convention (`docs/reviews/consumer-
findings-ledger.md`) — findings here follow the same spirit
(downstream-discovered, filed for upstream action); E-01..E-04 are
alktunnels-discovered but numbered in alkcall's review series since
they are alkcall findings.
- **ADR-049** (`docs/architecture/decisions/049-channel-open-
establishment-phase.md`) — the E-01/N-1 resolution (split-hook
`OpenEstablisher`, bounded establishment, `channel:open_failed`
typed error, client error-type fix).
@@ -0,0 +1,343 @@
# Review 007 — Establishment Follow-Ups (from the alktunnels UDP POC)
## Status
Resolved — all three units landed in alkcall 0.6.0 (2026-09-07):
Unit 1 `dc4ad2b` (R-01 + R-02's doc notes), Unit 2 `8f122b0` (R-02's
telemetry), Unit 3 `9c6fec1` (R-03 + ADR-050). Two deviations from
the sketches below, both recorded in the ADRs: (1) R-01's plan is
**typed-opaque** (`ChannelPlan = Arc<dyn Any + Send + Sync>`), not
`Option<Value>` — the sketch could not satisfy this review's own
verification gate, because the payloads establishers hand off are
live handles (dialed sockets, TTY handles) with no JSON
representation; (2) R-03's helper returns `(u64, u64)`, not
`io::Result<(u64, u64)>` — both pumps swallow copy errors by the
ADR-078 contract (mid-stream error = abrupt close), so an `Err` state
would be dead code. Original findings below, retained as filed
(verified against tree `36e74cd`, 0.5.0).
Open — findings filed from the alktunnels UDP POC pass
(`alktunnels-udp-poc`, 2026-09-06; summary at
`/workspace/@alkdev/alktunnels/docs/research/poc-summary.md`). This
review exists to prevent a second fix→publish→update-dependents cycle:
every finding below is either (a) guaranteed to be needed by
alktunnels Phase 1 implementation, or (b) a documented contract gap
that will bite the next `OpenHandler` author the way it bit the POC.
Nothing here is speculative — each finding was reached by writing
working code against 0.5.0 and finding the shape insufficient.
Findings continue the review numbering with prefix `R` (001–006 used
P/C/R/A/B/C/D/F/G/E — each review numbers independently).
> **Post-remediation note (2026-09-07):** the "Open —" paragraph
> above is the original filing state, retained for the record. All
> units landed; see Status above and ADR-049 amendment 2 + ADR-050.
## Scope
The ADR-049 establishment surface (`OpenEstablisher`, `Establishment`,
`EstablishmentError`, `register_openable_with_establisher`) and the
`OpenHandler` contract, reviewed from the alktunnels UDP POC's
consumer/producer implementations. Everything verified against source
at tree `36e74cd` (0.5.0). Cross-references: alkcall review 006
(E-01..E-04), alkcall ADR-049, alktunnels `poc-summary.md`
(§Issues Surfaced), alktty's channels establisher
(`make_tty_establisher`).
```
Verified against: alkcall 36e74cd (0.5.0)
Reading list: src/channels/operations.rs (OpenEstablisher, Establishment,
run_open_wrapper, teardown_failed_channel), src/channels/client.rs
(ChannelOpenError::establishment_reason), src/channels/manager.rs
(teardown_channel, adopt_channel), docs/architecture/decisions/
047 + 049, and the consumers: alktty src/channels.rs (make_tty_
establisher / make_tty_open_handler), alkhttp src/websocket/
upgrade.rs (OpenableAlpn ferry), alktunnels-udp-poc src/producer.rs
```
## Severity legend
Same scale as reviews 004–006.
---
# Part A — The findings
## R-01 [major] — `Establishment` is payloadless, but the channel plan is exactly what establishers need to hand to the pump handler
**Verified:** YES — by building against the API. The ADR-049 §1 text
itself anticipates this: *"Reserved for a channel plan — today the
wrapper consults only success/failure, so `()` carries no data"*
(`src/channels/operations.rs:341-345`, the empty
`pub struct Establishment {}`).
### The mechanism
The establisher is pre-data-plane: it cannot see the channel's
yield-once `BiStream` (ADR-049 amendment), so anything it
establishes — a dialed socket, an allocated handle — must cross to
the pump handler through a side channel the ALPN crate invents. The
alktunnels POC's workaround is a resource-keyed
`Mutex<HashMap<String, SubstrateHandle>>` + a poll-loop `take`
(`producer.rs::HandleHandoff`) that works only because the wrapper
guarantees establisher-before-handler ordering. The costs:
1. **Concurrent same-resource opens race the slot.** Two opens of the
same resource: the second establisher's `deliver` overwrites the
first handler's not-yet-taken handle. The POC documents this as a
simplification; a real crate cannot.
2. **alktty hit the same wall and documented it as a limitation.**
`alktty/src/channels.rs:253-257`: the establisher cannot carry the
allocated `TtyHandle` across, so *backend allocation* stays
post-open in the pump handler (`allocate_failed` remains an
in-band negotiation error frame — ADR-010) — exactly the
phantom-channel-ish shape ADR-049 exists to eliminate, still
alive one layer down. TTY's establisher can only validate/lookup/
ownership-check; the one thing that can actually fail with a
runtime error (allocate) is unreachable from the typed error path.
3. **Every ALPN crate pays the side-channel tax again.** Handoffs,
slots, poll loops or oneshots — per crate, per resource key, all
with the same concurrency caveat.
### The ask
Give `Establishment` its payload — the channel plan ADR-047 §3
originally described ("return a 'channel plan'... the wrapper consults
its result"). Minimal shape:
```rust
pub struct Establishment {
/// ALPN-defined plan data — the dialed handle, an allocation
/// ticket, whatever the pump phase needs. Opaque to alkcall.
pub plan: Option<Value>,
}
```
or, keeping it typed at the boundary:
```rust
pub struct Establishment { pub plan: Option<Value> }
```
with the wrapper passing `establishment.plan` (or `Null`) to the
`OpenHandler`'s `input` — e.g. as a well-known key the pump handler
reads, or as a third callback parameter. The wire surface is
**unchanged** (the plan is process-local: establisher → wrapper →
handler in the same process on the producing side; nothing crosses
the transport that isn't already the open op's input). Consumers'
`Ok(Establishment {})` construction sites (alktty has two; alkhttp's
ferry has none) break mechanically at 0.6 — `Establishment::new(plan)`
/ `Establishment::default()` make the migration one-liners.
**The break is the point of doing this now:** 0.5.0 published
yesterday with `Establishment {}` documented as reserved. The next
consumer (alktunnels) needs the payload in Phase 1 — implementing
Phase 1 without it means shipping the POC's side-channel handoff into
the real crate, with its same-resource race, and then the payload
lands later anyway as *another* breaking release. Filling the
reserved field now is the cheap moment; the alternative is paying the
breaking change twice.
### Severity
[major] — the capability gap is structural for any establisher whose
backend produces a handle (tunnels: dial; TTY: allocate), not a
polish item. Not [critical] because workarounds exist (the POC proves
one), but every workaround carries the same-resource race or forces
failure classes back into per-crate in-band frames — the exact cost
ADR-049 was written to remove.
---
## R-02 [minor] — The `OpenHandler` `JoinHandle` lifetime contract is undocumented and load-bearing (found empirically by the POC)
The POC's first pump implementation returned a wrapper task that
spawned the pump as a nested fire-and-forget task. The result:
**every tunnel connected then instantly EOF'd with zero bytes** — the
wrapper awaited the (already-complete) handler task, tore the channel
down at birth, and both pumps saw immediate EOF. Everything upstream
(establisher, open reply, channel routing) looked healthy; the bug
was purely in the handler's return-value semantics.
The contract, as implemented: the wrapper awaits the returned
`JoinHandle` and *that completion is the teardown trigger*
(`run_open_wrapper`'s spawned task: `let _ = raw_task.await;`
→ `teardown_channel(id)` → drop the demux sender → EOF to the
handler's read half). Therefore:
- **The returned `JoinHandle` must track the data-plane lifetime.** A
handler that returns before its pumps finish tears the channel down
at birth. The pump must be awaited *inline* inside the handler task
(`let _ = pump_halves(...).await;`), not spawned-and-forgotten.
- Half-open semantics fall out of this correctly (one pump finishing
shuts down the opposite sink per ADR-078; the handler's task
completes when both pumps finish — which is when teardown *should*
happen). The contract is right; only its documentation is missing.
The current type docs (`operations.rs:325-339`) say the handle is
"recorded for teardown (abort on `channel/close` / connection drop)"
— abort semantics — but never say **early return = teardown-at-birth**.
alktty got this right by accident of shape (`drive_session_pre_negotiated`
awaited inline in its single spawned task — `channels.rs:355-395`);
the POC got it wrong the natural way. The next consumer will write
the wrong shape too, because the natural reading of "spawn your
protocol and return the `JoinHandle`" is a task-spawner, not a
lifecycle promise.
**The ask:** doc note on `OpenHandler` and
`ChannelCore::register_openable*` — "the returned `JoinHandle` must
track the data-plane lifetime: the wrapper awaits it and tears the
channel down on completion; return a task that runs the handler to
completion, never a spawner that exits early" — plus the ADR-049
amendment (§1 or §6) recording the semantics. Optional hardening (not
required): a `debug!` in `run_open_wrapper` when the awaited handler
exits without the channel's `BiStream` having been accepted (a
telemetry hint for the birth-teardown pattern; the accept is
observable in-process). Doc-only; no break.
---
## R-03 [minor, optional unit] — The two-pump helper's convergence test is satisfied; extracting it now removes the last reason for a later sweep
alknet ADR-078 deferred helper extraction until a second two-pump
consumer existed and the shapes converged. The POC supplies both
halves of that test:
- **Producer side:** the alktunnels POC's `pump_halves` — two
`tokio::io::copy` pumps over split channel-vs-substrate halves,
each pump shutting down the opposite sink on completion, joined.
- **Consumer side:** `TunnelSession::take_halves` + the assembly
layer's copy — the same shape modulo channel side.
The shapes converged. The helper (`pump_bidi` or similar) is a
candidate for **this same 0.6 sweep** — additive (a new pub fn in
`channels` or `core`), zero breaking change, and it pins the
ADR-078 contract in one place instead of three. The natural signature
follows the POC:
```rust
pub async fn pump_bidi<A, B>(a: A, b: B) -> io::Result<(u64, u64)>
where
A: AsyncRead + AsyncWriteExt + Send + Unpin,
B: AsyncRead + AsyncWriteExt + Send + Unpin,
```
(join'd two-pump with shutdown-on-completion; returns the copy
counts for observability). alktty's three-pump session does not fit
it (the exit future is a third signal) — that is fine; the helper
serves the two-pump shape, TTY stays as-is.
**The ask:** decide in this sweep — either extract in 0.6 (additive;
recommended, since alktunnels Phase 1 will implement the pattern
anyway and an upstream helper makes the third consumer free), or
record the explicit decision to keep it per-crate. Do not leave it
half-decided: an un-extracted helper is not itself a break, but
finding this out during Phase 1 would be the same
fix→publish→update treadmill for a purely additive change.
---
# Part B — Verified non-issues (bounded so the next review doesn't re-check)
- **The reverse-flow (`-R`) story needs no upstream change.** A hub
proxy opening a channel *toward* a worker is the worker serving its
own open op on the connect side — supported by
`ChannelClient::from_connection_with_serving` (ADR-022 §2 both-sides
semantics) + `ChannelOperations::register_on` on the serving
registry + the wrapper allocating on the serving side (ADR-047 §5
"the side that holds the `ChannelManager` allocates" — both sides
do, with odd/even split per ADR-047 §5). Verified by trace; no API
gap. alktunnels' reverse-flow POC (OQ-TN-10 #2) will exercise this
end-to-end, but no upstream mechanism is missing.
- **The establisher receives registry-validated input** —
`invoke_streaming`'s `input_schema` check runs before the wrapper
(`registration.rs:397`); the establisher's `input.clone()`
(`operations.rs:718`) is post-schema. Confirmed; no gap.
- **Typed establishment errors are complete on the wire** —
`establishment_error_to_call_error` carries both `reason` and
`message` in `details` (`operations.rs:646-653`); the client-side
`establishment_reason()` branches on it (`client.rs:75-83`); alktty's
open spec declares the matching `ErrorDefinition` (ADR-016). The
vocabulary needs no extension for tunnels (`dial_failed` /
`unknown_resource` / `resource_shortage` / `handler_error` /
`timeout` cover every tunnel establisher failure class — verified by
the POC's three typed-error tests).
- **The early-arrival park cap (64)** — reviewed in 006 E-04; the POC
treated it as a sizing constraint, not a defect. Unchanged.
- **`EstablishmentError`'s reason set is sufficient** — the POC's
establisher used three of four reasons; `handler_error` covers
params-parse failures (the TTY pattern). No new reason needed.
---
# Remediation plan
## Unit 1 — `Establishment` carries the channel plan (R-01)
1. ADR-049 amendment (or ADR-050-adjacent amendment): `Establishment`
gains `plan: Option<Value>` (ALPN-opaque); the wrapper threads
`plan` into the pump handler (third `OpenHandler` parameter, or
merged into `input` under a reserved key — decide in the ADR; the
separate-parameter shape avoids colliding with the input schema).
2. Bump to 0.6.0 (breaks `Ok(Establishment {})` construction sites —
alktty, two sites; mechanical `Establishment::default()` or
`Establishment { plan: None }`).
3. Migration: alktty can then move backend `allocate` into its
establisher (killing the in-band `allocate_failed` frame on the
channels path — ADR-010's reason for that frame disappears);
alktunnels Phase 1 uses `plan` for the dialed handle; alkhttp's
ferry passes `Option` through unchanged.
## Unit 2 — `OpenHandler` lifetime doc note (R-02)
Doc-only (type docs + ADR-049 amendment). Optional debug warning.
No break.
## Unit 3 (optional) — `pump_bidi` helper extraction (R-03)
Additive pub fn + tests. No break. Do it in the same 0.6 or record
the decision not to.
## What must NOT ride along
Nothing else. The remaining alktunnels Phase 1 needs (UDP codec ADR,
reverse-flow lifecycle, access-policy details) are alktunnels-local
spec work — the POC validated the mechanics and found no further
upstream gaps. The deliberate goal of this review: **0.6 is the last
breaking sweep forced by known work**; anything after it should be a
genuinely new discovery, not a known-dangling item.
## Verification gates
- Unit 1: end-to-end test — establisher dials, `plan` carries the
handle, pump handler receives it; concurrent same-resource opens
each get their own handle (the race the POC documented is
unreachable); alktty migration test (`allocate` in the establisher
→ `allocate_failed` is a call error, no in-band frame).
- Unit 2: doc gate only (`cargo doc` clean; the note present on both
the type and the registration entry points).
- Unit 3: helper test — the POC's two-pump semantics (EOF from one
direction completes the other; shutdown-on-completion) reproduced
through the helper.
## References
- alkcall review 006 (E-01 establishment gap; R-01 is the payload
half of E-01's own remediation — ADR-047 §3's "channel plan"
through the reserved `Establishment` field) and ADR-049 (the
establishment phase; §1's original sketch already described the
plan-carrying shape).
- alkcall ADR-047 §3 ("return a 'channel plan'" — the original shape
this review restores), ADR-022 §2 (both-sides serving — the
reverse-flow non-finding), ADR-047 §5 (odd/even allocation —
reverse-flow allocation on the serving side).
- alknet ADR-078 (the two-pump contract; R-03's helper pins it
upstream).
- alktunnels `docs/research/poc-summary.md` (§Issues Surfaced #1/#2 —
the empirical findings; §#4 — the convergence input for R-03) and
the POC crate (`/workspace/alktunnels-udp-poc`, 17 tests — the
empirical basis for every finding here).
- alktty `src/channels.rs:253-257` — the existing documentation of
the R-01 cost on the TTY side (`allocate` cannot cross; in-band
`allocate_failed` remains), the second consumer confirming the gap
is not tunnel-specific.
@@ -0,0 +1,519 @@
# Review 008 — Graduation Upstream Asks (from the alktunnels graduation)
## Status
Resolved — all three remediation units landed in alkcall 0.8.0
(2026-09-18): Unit 1 `82ddddf` (U-2, ADR-049 amendment 3), Unit 2
`620180d` (U-1, ADR-047 amendment 3), Unit 3a `90c1820` (the in-tree
`ChannelRelay`, ADR-051), Unit 3b `4e52bd4` (the hub-leg install
template, ADR-051 §5), Unit 3c (the gate-2 e2e harness in
`src/channels/gate2_tests.rs` + the relay's producer-leg teardown +
this release's bookkeeping). Two notes on the as-filed text: the U-2
line citation is 945, not 955 (see Errata), and U-1's gate 2 landed as
an in-tree relay component plus a permanent e2e gate rather than a
test-only harness (the escalation the remediation plan pinned above).
Original findings below, retained as filed (verified against tree
`3b36b40`, 0.7.1).
> **Errata (post-landing):** U-2 cites the reply construction at
> `src/channels/operations.rs:955`; the cf-006/007 commit had already
> shifted it to 945 by filing time. The citation in the finding below
> is left as filed; the verified line is 945.
Open — filed 2026-09-16 from the alktunnels graduation spec work
(ADRs 007/008 at
`/workspace/@alkdev/alktunnels/docs/architecture/decisions/`;
research record at
`/workspace/@alkdev/alktunnels/docs/research/tunnels-graduation.md`).
Both asks are prerequisites for the alktunnels graduation
implementation (the direct op + the listen-metadata shape), which is
deliberately sequenced AFTER these land — alksocks (the first
consumer) composes against the graduated surface, so the discovery
and reply shapes must be final before it pins them. Verified against
alkcall tree @ HEAD, 0.7.1 (all code references below cite current
files/lines).
Findings continue the review numbering with prefix `U`.
## Scope
The two wire-adjacent surfaces the graduation's ADRs depend on:
(1) the open-op ↔ discovery relationship (alkcall ADR-047's op-name →
ALPN derivation and its relay reconstruction), which flavor-form open
op ids must survive; (2) the establishment → reply boundary
(alkcall ADR-049's wrapper), which a bind-first listen establisher
must be able to project metadata into. Both are additive; neither
changes the channels data-plane wire format (ADR-034/071 untouched).
## U-1: Flavor-form open-op ids in discovery derivation
**Finding.** *(Resolved — ADR-047 amendment 3, Unit 2 `620180d`; the
explicit `channel_open_alpn` field plus the last-segment derivation,
exactly the pinned (b)+(c) shape. Gate 2 landed as the in-tree relay +
`src/channels/gate2_tests.rs`, Unit 3.)* ADR-047 §1 pins the open-op
naming to exactly two shapes
per ALPN — `channels/<alpn>/sub` and `channels/<alpn>/pub` — and Gap
F's marker derivation (`rebuild_spec_for`,
`src/client/from_call.rs:209` + `derive_alpn_from_op_name`,
`src/client/from_call.rs:305`) parses exactly those suffixes to
reconstruct the `channel_open` marker after discovery
(`spec_to_json` serializes the marker as a boolean,
`src/registry/discovery.rs:244` — the ALPN string itself does not
survive the round trip). alktunnels' graduation pins two NEW
`Sub`-typed open ops on the existing `alk/tunnel` ALPN with
flavor-form op ids:
- `channels/tunnel/direct` (ADR-007 — dynamic-target egress; the
ssh `direct-tcpip` capability, scope `tunnel:direct`)
- `channels/tunnel/forwarded` (ADR-008 — accept-as-open toward the
listen-opener side; the ssh `forwarded-tcpip` capability, scope
`tunnel:forwarded`)
Runtime is NOT blocked today: registration sets the ALPN marker
explicitly (`with_channel_open`), dispatch never parses the op name
(`register_openable_with_establisher` accepts any spec name with a
marker — verified, `src/channels/operations.rs:596`), and the client
takes the ALPN as an argument (`open_channel(op_id, params, alpn)`).
The gap is **discovery + hub relay**: a flavor-form op id fails
`derive_alpn_from_op_name`'s suffix match, so `rebuild_spec_for`
rebuilds the spec WITHOUT the `channel_open` marker, so a hub
consuming through discovery (the `from_call` relay path, ADR-047 Gap
C — the alknodes re-produce shape) treats the open op as a plain
forwarding stub instead of wrapping it with relay machinery.
**Prior art in-tree.** `derive_alpn_from_op_name` already tolerates
multi-segment ALPNs (`segment.contains('/')` → used verbatim, so
`channels/x-y/sub` → `alk/x-y`); the shape is one suffix away from
flavor support.
**Requested change.** Extend the op-name convention (ADR-047
amendment): `channels/<alpn>/<flavor>` is a valid open-op name where
`<flavor>` is a bare path segment (no `/`); the derivation generalizes
from "strip `/sub` or `/pub`" to "strip the LAST segment when it is a
known op-type marker (`sub`, `pub`) or a flavor registered with an
explicit ALPN marker." Two viable implementation shapes, either
acceptable:
- **(a) Suffix-set extension** — `strip_suffix` against
`["/sub", "/pub"]` extended with a flavor allowlist threaded from
registration. Minimal, but couples discovery to a registry.
- **(b) Wire the flavor op's ALPN explicitly through discovery** —
add an optional `channel_open_alpn` string field to the
`services/schema` payload (`spec_to_json` emits the ALPN string
instead of `true` when the op name is not a standard `…/sub`/`…/pub`
shape; `rebuild_spec_for` prefers the explicit string). This also
future-proofs the boolean-only marker (Gap F) whose round-trip is
lossy by construction for any non-derivable name.
alktunnels ADR-002 Amendment 1 pins the convention sentence ("new
flavors are new op ids — additive; the `…/sub` op is never reused for
a different meaning"); alkcall's amendment should mirror it. The
boolean marker's wire meaning for standard-shape ops is unchanged.
**Why one-way-door timing:** the op ids are wire-stable from the
first consumer (alksocks). If discovery's derivation ships after
consumers exist, hubs deployed in between cannot relay the new ops —
the failure is silent (plain forwarding stub instead of channel
relay), which is the worst failure mode for a relay.
**Verification gates:**
1. `rebuild_spec_for("channels/tunnel/direct" summary)` reconstructs
the spec WITH `channel_open = alk/tunnel`.
2. A hub-relay round trip over a real channels connection: consumer →
hub (`from_call` + relay wrapper) → producer, opening
`channels/tunnel/direct` end to end, the hub forwarding the
channel (the ADR-042 relay path riding the reconstructed marker).
3. Old boolean-marker ops (`channels/tty/sub`) round-trip unchanged
(no regression on the existing derivation).
## U-2: Establisher → reply projection (`Establishment` contributing reply fields)
**Finding.** *(Resolved — ADR-049 amendment 3, Unit 1 `82ddddf`: the
`reply_fields` carrier, the `channel_id` reservation failing loudly,
`open_channel_with_reply` on the client.)* The open-op success reply
is constructed by the wrapper
and hardcoded to the channel id:
`ResponseEnvelope::ok(request_id, json!({ "channel_id": channel_id }))`
(`src/channels/operations.rs:955` in `run_open_wrapper`). ADR-049 §1
explicitly reserved the establishment phase as the wire's pre-data
moment and ADR-049 amendment 2 landed the typed-opaque plan
(`Establishment { plan }`) for the establisher → handler direction —
but the establisher has no path to contribute to the REPLY (the
establisher → opener direction). alktunnels ADR-008's bind-first
listen establisher needs exactly that: establishment ends at bind
time, and the observed OS-chosen bound address must ride the open-op
reply as an additive `"bound"` field (the SOCKS5 BIND reply#1
`BND.ADDR` fidelity ask; the alternative — an out-of-band query op —
is a new wire surface, strictly worse than an optional reply field).
**Shape note.** `#[non_exhaustive]` on `Establishment`
(ADR-049 amendment 2) anticipated exactly this carrier change. The
sketch:
- `Establishment` gains optional reply fields — e.g.
`Establishment::new(plan).with_reply_field("bound", json!({...}))`
or `reply_fields: Option<Map<String, Value>>` (bikeshed: builder
vs struct field; the map shape generalizes beyond `bound` without
a second amendment).
- The wrapper merges them into the success output AFTER reserving
`channel_id` (wrapper-owned key: an establisher-provided
`channel_id` key is an establisher bug — reject or
`handler_error`-class it loudly rather than shadowing).
- Absent fields → the reply is byte-identical to today's; no schema
change on the wrapper side (the output schema is the op's own
concern — alktunnels' listen-op spec documents `bound` as an
optional output field).
**Consumer-compat check:** `open_channel` extracts `channel_id` from
the response and ignores unknown fields (the client-side parse,
`src/channels/client.rs:932`'s contract: "respond `{ channel_id }` —
asserts the response carries a channel_id"), so old consumers are
unaffected by a NEW field; new consumers reading `bound` against an
OLD alkcall simply see the field absent — the additive posture
alktunnels ADR-008 §2 pins ("until upstream lands, the field is
simply absent").
**Requested change (ADR-049 amendment 3):** the reply projection as
sketched above, with the `channel_id` reservation documented.
**Verification gates:**
1. An establisher returning a reply field produces
`{ channel_id, bound }` on the wire; one returning none produces
`{ channel_id }` (byte-identical to today's).
2. An establisher attempting to set `channel_id` fails loudly (the
reservation holds).
3. The existing establishment tests (establisher-success pump
round-trip, no-establisher compat) pass unchanged.
## Non-asks (recorded to bound the review)
- **The channels data plane is untouched** — the forwarded op's data
plane is the existing `BiStream` + `pump_bidi`; the `bound` field
is call-plane JSON. ADR-034/071's one-way doors stay closed.
- **No new alkcall op types** — both new tunnel ops are `Sub`-typed
open ops (the existing `HandlerKind::Stream` wrapper path).
- **No identity/ACL changes** — the new scopes (`tunnel:direct`,
`tunnel:forwarded`) ride the existing op-spec `required_scopes`;
the per-call opener identity (CF-005/CF-006) is the only identity
surface the establisher/handler need (the forwarded op's `peer`
param is producer-asserted data, not transport truth — alkcall need
not validate it).
## References
- alktunnels ADR-007 (`docs/architecture/decisions/007-direct-open-op.md`),
ADR-008 (`008-listen-metadata-forwarded-op.md`) — the consumers of
both asks, with the full context and alternatives
- alktunnels ADR-002 Amendment 1 — the flavor-form op-id convention
sentence
- alkcall ADR-047 (open ops, Gap F, the derivation),
ADR-049 (establishment; amendment 2 — the plan payload whose
`#[non_exhaustive]` anticipates this carrier), ADR-042 (hub relay —
the relay path U-1's gate 2 exercises)
- alksocks `poc-bind-findings.md` (the two fidelity asks with
evidence; the upstream-ask ledger this review retires)
- Review 007 (the precedent for this filing: POC-driven upstream asks
before the dependent implementation)
---
# Remediation plan (adopted 2026-09-16 — findings verified, plan pinned)
Both findings verified against HEAD `3b36b40` (0.7.1) — all code
citations check out, with one line drift: U-2's reply construction is
`src/channels/operations.rs:945` (review cites 955; the cf-006/007
commit shifted the wrapper down ten lines). One review-scope deviation
from the as-filed text, adopted in planning:
- **U-1's gate 2 escalates from "test harness" to an in-tree relay
component.** The as-filed review treated ADR-042's relay wrapper as
consumer-side code (the ADR's §Scope note pins the implementation
downstream). The consumer decision is that the re-produce/relay shape
is general (alkhttp's fallback hub, alknodes, the vpn-like endgame all
compose it), and a real consumer now requires it — so ADR-042 is
amended: the relay implementation moves into alkcall as a reusable
component, and gate 2 becomes a genuine in-tree e2e.
Design decisions pinned above the as-filed option lists:
- **2026-09-17 (Unit 3 planning session): Unit 3 split into 3a/3b/3c
and design pinned in ADR-051** (`docs/architecture/decisions/
051-channel-relay-and-hub-leg.md`) — the session's discussion
expanded the unit's scope beyond the as-pinned sketch: the hub/spoke
family (hub or spoke may relay; the hub re-exposes spoke services by
ACL without binding ports) needs the call-half support too (the
hub-leg install template — now Unit 3b). Key decisions, all in
ADR-051: the relay holds `Arc<CallConnection>` + `ChannelManager`
per producer leg (not a `ChannelClient` — `take_call_connection`'s
detach is wrong for a hub with three `CallConnection` claimants);
the reason mapping preserves the spoke's code+message (`timeout` is
the one non-1:1 case — it maps to `dial_failed`); the registration
seam is two-phase (fork mechanism, §4); the relay map and
`channel/close` translation dissolve (implicit mapping + EOF
cascade, recorded as the ADR-042 amendment's mechanism supersessions);
Pub-typed marked specs are a loud assembly error; establishment
bounds compound per hop (noted, no fix).
- **U-1 shape: combined (b) + (c).** The as-filed option (a) is
unsound as stated — the derivation runs consumer-side against a wire
JSON payload, so an allowlist "threaded from registration" has no
path to `rebuild_spec_for` (both its consumers — `from_call`,
`op/register` — are wire-fed). The pinned shape: `spec_to_json_pub`
emits an explicit `channel_open_alpn` string alongside the boolean
when the op name is not a standard `…/sub`/`…/pub` shape (standard
shapes stay byte-identical); `rebuild_spec_for` prefers the explicit
string, else generalizes the derivation to strip the LAST segment
when the boolean marker is present (the marker is the gate, so any
bare-segment flavor works; `op/register` is fixed by the same
parser).
- **U-2 client read path: add the ergonomic accessor now** —
`open_channel_with_reply` returning the extra reply fields alongside
the streams; `open_channel` unchanged. Keeps alkcall's primary
surface intact while giving alktunnels' `TunnelListener::open` a
first-class way to read `bound`.
## Unit 1 — U-2: establisher reply projection (ADR-049 amendment 3)
Land first: the relay component (Unit 3a) projects a spoke's extra reply
fields through `Establishment`, so the projection must exist before the
e2e can assert `bound` flowing through a relay.
1. `Establishment` gains `reply_fields: Option<Map<String, Value>>` +
builder (`Establishment::new(plan).with_reply_field("bound",
json!({...}))`; also `with_reply_fields(map)` / `reply_fields()`
accessor). `#[non_exhaustive]` holds; additive, no break.
2. `run_open_wrapper`: on establisher success, merge
`reply_fields` into the success output AFTER reserving
`channel_id`. An establisher-supplied `channel_id` key is an
establisher bug — fail loudly as
`EstablishmentError::HandlerError` (reason `handler_error`, message
naming the reserved key), tearing the channel down (the wrapper's
existing failure path). No silent shadowing.
3. Client: `ChannelClient::open_channel_with_reply(operation_id, input,
alpn) -> Result<(u32, Value, MpscSendStream, MpscRecvStream),
ChannelOpenError>` — the full success output (the consumer reads
`bound` from it); `open_channel` delegates with the fields
discarded. Additive; no signature change.
4. Doc: ADR-049 amendment 3 (the projection, the `channel_id`
reservation, the accessor). The output schema remains the op's own
concern (alktunnels' listen-op spec documents `bound` as optional).
Verification gates (from the review, tightened):
- Establisher returning a reply field produces `{ channel_id, bound }`
on the wire; one returning none produces `{ channel_id }`
(byte-identical to today's — assert exact JSON).
- Establisher attempting `channel_id` → `channel:open_failed` with
reason `handler_error`; channel torn down; ledger decremented.
- The existing establishment tests (establisher-success pump
round-trip, plan-flow, no-establisher compat) pass unchanged.
- `open_channel_with_reply` returns the extra fields end-to-end over a
real channels connection; `open_channel` behavior unchanged.
## Unit 2 — U-1: flavor-form derivation + explicit ALPN field (ADR-047 amendment)
1. `derive_alpn_from_op_name` (`src/client/from_call.rs:305`)
generalizes from strip-`/sub`|`/pub` to strip the LAST path segment
(`rsplit_once('/')`), which is a strict superset of today's behavior
(standard two-suffix shapes derive identically; multi-segment ALPNs
survive the same way). The marker boolean remains the gate in
`rebuild_spec_for` — the derivation is only consulted for marked
ops, so plain ops named `channels/x/y` are unaffected (the
`channels/tty/query`-returns-None unit test moves from the function
to the marker-gated rebuild level).
2. `spec_to_json_pub` (`src/registry/discovery.rs:222`): when
`channel_open` is set and the op name is NOT a standard
`channels/<seg>/(sub|pub)` shape, emit `"channel_open": true` PLUS
`"channel_open_alpn": "<alpn>"`. Standard shapes keep the boolean
only — byte-stable for every existing consumer. The advertised
`operation_spec_schema` (`discovery.rs:159`) gains the optional
string property (schema-type widening, additive).
3. `rebuild_spec_for` (`from_call.rs:209`): prefer the explicit
`channel_open_alpn` string; else boolean → generalized derivation.
Covers both wire consumers (`from_call`, `op/register`) by
construction.
4. Doc: ADR-047 amendment — the convention sentence mirroring
alktunnels ADR-002 Amendment 1 (`channels/<alpn>/<flavor>` is a
valid open-op name, `<flavor>` a bare segment; flavors are additive,
the `…/sub` op is never reused), the explicit-field wire shape, and
the one-way-door note (the field joins the stable
`services/schema` payload; the boolean's meaning for standard-shape
ops is unchanged). Record the residual ambiguity: an ALPN segment
that itself collides with a flavor name (`channels/x/direct` where
the ALPN is `alk/x/direct`-shaped) is undecidable from the name
alone — the explicit field is the disambiguator.
Verification gates:
- Review gates 1 and 3 as unit/round-trip tests:
`rebuild_spec_for("channels/tunnel/direct" summary)` reconstructs
WITH `channel_open = alk/tunnel`; `channels/tty/sub` (boolean) and
`channels/custom/proto/sub` (multi-segment) round-trip unchanged;
flavor-form spec serializes with the explicit string and rebuilds
from it; `op/register` announced flavor-form spec round-trips with
the marker.
- Review gate 2 (hub-relay e2e) lands with Unit 3c.
## Unit 3 — the in-tree relay component (ADR-042 amendment, ADR-051) + gate 2 e2e
Adopted 2026-09-17, design pinned in ADR-051 (`docs/architecture/
decisions/051-channel-relay-and-hub-leg.md`): the relay is the wrapper
composition (translate hop = the ADR-049 establisher shape; byte-
forward hop = `pump_bidi` per ADR-050), the ADR-042 relay map
dissolves (implicit per-channel mapping), `channel/close` needs no
translation surface (the EOF cascade propagates with correct ledger
accounting on both legs), and the registration seam is two-phase
(discover/stash → per-connection fork-register — the ADR-047 §4
amendment's fork mechanism makes a one-call import impossible without
adapter changes). The call-half support the hub/spoke family needs is
the hub-leg install template (Unit 3b) — composition of existing
pieces, not a new protocol surface. Three sub-units, three sessions'
scope:
### Unit 3a — `ChannelRelay` (the relay component)
New module `src/channels/relay.rs`:
1. `ChannelRelay::register_relay_openable(consumer_registry, spec,
producer_leg)` — for a from_call-imported spec with `channel_open`
set, registers on the hub's consumer-leg channel-0 fork registry
via `register_openable_with_establisher`, where:
- **`producer_leg`** is `Arc<CallConnection>` +
producer-leg `ChannelManager` (ADR-051 §2 — not a
`ChannelClient`: the hub's `CallConnection` is shared by import,
relay establisher, and hub ops, so `take_call_connection`'s
detach is wrong; the establisher builds its forwarded payload
directly — the hub as caller, the consumer as `forwarded_for`
from the per-call identity, ADR-026 §3).
- The **establisher** (the translate hop, ADR-042 layer 1): calls
the producer leg's open op with the forwarded payload, adopts the
returned spoke `channel_id` into the producer-leg manager, and
returns `Establishment::new(plan)` whose plan carries the
spoke-leg streams. The spoke reply's `channel_id` is stripped;
the reply's other fields ride `with_reply_fields` (`bound`
flows end-to-end, per-hop truthful). Failure mapping per
ADR-051 §3 (the reason table; spoke `CallError` code + message
preserved in the mapped variant).
- The **OpenHandler** (the byte-forward hop, ADR-042 layer 2):
`pump_bidi(consumer_leg_bistream, spoke_recv, spoke_send)` —
the relay never parses chunk framing (per-leg mux/demux absorbs
the 8-byte header; no ID rewrite exists — ids are
per-connection). Awaited inline (R-02); on completion both legs
tear down (the wrapper's handler-exit teardown covers the
consumer leg; the pump's drops EOF the spoke leg).
- The consumer leg keeps the full wrapper machinery: ACL,
per-identity cap/ledger, establishment bound (bounds the spoke
dial round-trip), teardown-on-failure — no parallel
authorization path.
2. Rejection postures (ADR-051 §6): a `Pub`-typed marked spec is an
assembly error at registration (`channel:pub_open_not_implemented`
class, C-08 blocker) — never a silent stub; an unmarked spec
passed to the relay registration is likewise an error (branch on
the marker before calling).
Verification gates:
- The establisher populates `forwarded_for` from the consumer's
per-call identity; the spoke reply's `channel_id` is stripped (the
consumer reply carries the hub-allocated id).
- A spoke `channel:open_failed` maps to the consumer-leg
`channel:open_failed` with the mapped reason and the spoke's
message preserved; consumer leg tears down (ledger decremented);
no spoke channel leaks (the adopt-then-fail window is covered).
- `bound` (Unit 1's projection) survives the relay to the consumer
reply.
- Pub-typed and unmarked specs are rejected at registration.
### Unit 3b — the hub-leg install template (the call-half support)
Landed (2026-09-17): `src/channels/hub_leg.rs` — `HubLegImports`
(the `Clone` stash split by the marker) and `HubLegTemplate`
(the §5 composition exported as the `InstallChannelZero` hook;
`serving identity` via the CF-005 seam). One assembly rule the
planning sketch missed, surfaced by the template's gate e2e:
`from_call` discovers the spoke's bootstrap discovery ops like any
plain op, so the template skips
`registry::discovery::BOOTSTRAP_DISCOVERY_OPS` when re-registering
plain bundles — the template's own install (closed over the fork,
review 004 F-06) supersedes the imported copies. Unit 3c (the
gate-2 e2e) remains.
The hub-side composition every test hand-rolls
(`make_install_channel_zero`-shaped), exported in-tree (ADR-051 §5 —
two-way-door API shape):
1. Fork the hub's base registry (ADR-047 §4 amendment mechanism).
2. Register the generic channel ops (`ChannelOperations::register_on`)
and the bootstrap discovery ops (closed over the fork, so
`services/list` sees the fork's ops — review 004 F-06).
3. Register the stashed plain bundles as-is (the existing forwarding
handlers) and the marked specs via Unit 3a's relay registration.
The stash is the `Clone`able `Vec<HandlerRegistration>` `from_call`
returned (registrations and `HandlerKind` are `Clone`; one
discovered set serves any number of consumer legs).
4. Per-call identity resolution for the serving dispatch
(`ServingConfig`-shaped precedence: payload token → explicit
override → transport identity), then the single-stream dispatch
loop.
5. Per-consumer op-subset filtering composes here (the fork is per
consumer leg — ADR-051 §4's composition note). ACL layering is
pinned in ADR-051 §5: the spoke's `AccessControl` sees only the
hub identity; the end consumer's identity is `forwarded_for`
metadata, never checked by any `AccessControl::check`.
Verification gates:
- The template composes fork + generic ops + bootstrap discovery +
plain bundles + relay openables + serving identity and dispatches
channel 0; `services/list` on the consumer leg shows the
re-exposed ops; a plain imported op round-trips through the hub
(the call-half forwarding path exercised for real).
### Unit 3c — gate 2, the review's full e2e harness
The review's verification gate 2, now with the in-tree components:
1. Consumer → hub (Unit 3b's template on the consumer leg;
`ChannelsAdapter` both legs) → producer, opening
`channels/tunnel/direct` end to end: the open resolves on the
consumer with the hub-allocated `channel_id`, the
establisher-contributed `bound` field (Unit 1) survives the relay
to the consumer reply, data flows both directions on the data
channel (the hub never parses it), and hub-side disconnect /
spoke-side close tears down both legs' channels.
2. The mid-establishment disconnect window (ADR-051 §6): consumer
leg dies between spoke open and pump spawn — the spoke channel is
reclaimed when the pumps run (EOF cascade), both ledgers
decremented, no leak.
3. A companion `channels/tty/sub` relay run pins no regression on
standard shapes.
Remaining bookkeeping for the final unit: ADR-042 amendment (landed
with ADR-051 — the §Scope-note revision and the two mechanism
supersessions), changelog + minor version bump (new pub API:
`ChannelRelay`, `register_relay_openable`, the hub-leg template;
`open_channel_with_reply` and `Establishment::with_reply_field*`
landed with Units 1–2).
## Sequencing and non-goals
Order: Unit 1 → Unit 2 → Unit 3a → Unit 3b → Unit 3c (Unit 3a is the
component; 3b the assembly; 3c the e2e gate asserting `bound` through
the relay — Units 1–2 are prerequisites of 3a; 3b and 3c of each
other). One release (minor bump), matching the review-007 precedent.
Not in scope (extends the review's non-asks): the data-plane wire
format (untouched — the relay rides `MpscSendStream`/`MpscRecvStream`
and `pump_bidi`; no header rewrite exists — ids are per-connection,
ADR-051), any `channel/control` translation surface (close propagates
through the EOF cascade; control is per-leg, OQ-39 — a hub wanting
cross-leg control translation composes it downstream), the Pub-typed
open path (rejected loudly; C-08 blocker), and the alktunnels-side
work (the bind-first establisher, the listen-op spec's `bound`
documentation, ADR-007/008 implementation) which sequences AFTER these
land.
Review-008 status updates when units land: mark U-1/U-2 resolved with
commit refs (the review-007 Status pattern), and correct the U-2 line
citation (955 → 945) in an errata note rather than silently editing the
as-filed text.
@@ -0,0 +1,247 @@
# Review 009 — Post-landing audit of the review-008 remediation (0.7.1 → 0.8.0)
## Status
Resolved — all seven findings landed in alkcall 0.8.0 (2026-09-18,
same working tree as the audit's inline fixes): C-1 (plain-bundle arm
pinned + the ADR-051 §5 coverage note), C-2 (`filtered` closure unit +
the empty-stash e2e gate), C-3 (the batch-form reserved key), C-4
(the `open_channel_with_reply` failure path), C-5 (both golden pins),
C-6 (the derivation edge shapes), C-7 (last-win pinned + the doc
sentence). Three errata on the as-filed text (marked per finding).
No wire or API changes were needed. Verified at resolution: 680
tests pass, clippy/fmt clean, wasm check clean.
Open — filed 2026-09-18 from the post-landing audit of the six commits
`82ddddf..50182d7` (review 008's three units). Scope: correctness
review of the full diff, test-coverage mapping, and the classic
implementation-issue sweep. Verified against HEAD at filing (0.8.0 +
the audit's inline hardening commit): 672 tests pass, clippy/fmt/doc
clean, wasm check clean.
The audit's code findings were fixed inline at filing time (see the
CHANGELOG [Unreleased] entry): the adopted-entry leak windows
(audit F-1/F-2 — `RelayPlan` drop guard), the empty explicit
`channel_open_alpn` (audit F-3), the `channels//sub` silent-stub
shape (audit N-4), and the reserved-key message/log-level nits
(audit N-1/N-2). ADR-051 §6 documents the closed post-adopt window.
What remains from the audit is the **test-coverage debt** below —
deliberately deferred to this review so the fix commit stayed small.
Findings continue the review numbering with prefix `C` (coverage).
## Scope
Test-coverage gaps in the 0.8.0 surface: the Unit 3 modules
(`src/channels/relay.rs`, `src/channels/hub_leg.rs`) and the Unit 1/2
diff surfaces (`src/channels/operations.rs`, `src/channels/client.rs`,
`src/client/from_call.rs`, `src/registry/discovery.rs`). No production
code changes are requested by this review unless a gap's write-up
says otherwise; each finding names the test(s) to add and what they
pin. None block a consumer — the e2e gates the review-008 plan pinned
are all landed and passing; these gaps are the paths the gates never
walk.
## C-1: `HubLegTemplate::install_hook` failure arms are loud-only-in-code
**Errata (2026-09-18, at resolution).** The as-filed route (a) —
"a duplicate name between a plain bundle and the generic channel ops"
— does not fail `OperationRegistry::register` (same-name registration
overwrites; the map is an insert). The honest failure mechanism used
is the registry's other fail-closed rule: an un-compilable
`input_schema` (`{"type": "object", "required": "not-an-array"}`,
the CF-003 shape). Resolved with route (c)'s documentation half for
the generic-ops/bootstrap-discovery arms: ADR-051 §5 now carries the
loud-install coverage note (two arms pinned, two arms
best-effort-loud — the crate-internal specs compile by construction,
so an injectable seam would test the seam, not the arm).
**Finding.** `src/channels/hub_leg.rs:232-275` — four install arms end
the leg with only a `tracing::warn!`: generic channel ops registration
failure, plain-bundle registration failure, relay-openable
registration failure, bootstrap-discovery install failure. ADR-051 §6
pins the posture as "loud, never a silent stub" and only the
relay-openable arm is test-pinned
(`hub_leg_tests.rs` `template_pub_typed_marked_spec_is_loud_at_install`).
The other three are near-unreachable (generic ops and bootstrap
discovery cannot realistically fail; plain bundles were already
registered on the producing side) — but the ADR's posture claim rests
on code that no test walks.
**Requested change.** Unit-test the three unpinned arms. The plain
routes: (a) a plain bundle that fails registration — e.g. a duplicate
name between a plain bundle and the generic channel ops — asserts the
install task ends before `run_loop_single_stream`; (b) the same shape
for a relay-openable failure is already pinned; (c) the
generic-ops/bootstrap-discovery arms need an injectable failure seam
if they are to be tested honestly — if that is disproportionate,
instead demote the claim: document in ADR-051 §5 that only the
relay-openable arm is test-pinned and the others are
best-effort-loud, so the ADR and the code say the same thing.
**Verification gates:**
1. The template's install task provably ends (channel 0 never
dispatches) on each exercised failure arm.
2. The ADR-051 §5/§6 text matches what is actually pinned.
## C-2: `HubLegImports::filtered` / `only` have no test
**Errata (2026-09-18, at resolution).** The as-filed "no unit test"
overstated: `stash_filter_keeps_named_ops_only` (landed with Unit 3b)
already exercised `only` at filing time. The real gaps — the
`filtered` closure path and the empty-filter e2e shape — are what
landed (`stash_only_keeps_the_marked_direct_spec_and_drops_the_rest`,
`stash_filtered_closure_partitions_both_halves`,
`template_empty_only_stash_installs_generic_ops_and_discovery_only`).
**Finding.** `src/channels/hub_leg.rs:91-111` — the per-consumer
op-subset filter, the mechanism behind ADR-051 §4's "per-consumer ACL
differentiation is a composition consequence" note, has no unit test.
Trivial code, but it is exported pub API (`HubLegImports` is a crate
export) and the filter story is load-bearing for the hub/spoke family.
**Requested change.** Unit tests: `from_bundles` splits by marker;
`filtered`/`only` partition both halves; a `#[must_use]` misuse
compiles with a warning (already enforced by the attribute — just
exercise the two builders).
**Verification gates:**
1. `only(&["channels/tunnel/direct"])` keeps the marked direct spec,
drops everything else, plain bundle untouched by the marked list.
2. An empty `only` stash installs a leg that serves only the generic
ops + discovery (no re-exposed ops in `services/list`).
## C-3: Batch-form reserved reply key
**Finding.** `src/channels/operations.rs` — the reserved-key teardown
test (`with_reply_field("channel_id", …)`, `operations.rs:~2606`)
covers only the builder form. The wrapper's check inspects the merged
map, so `with_reply_fields(map)` smuggling `channel_id` is covered by
construction — but the batch path is the shape a hub's
`Establishment::with_reply_fields(relay_map)` will actually use, and
it is unasserted.
**Requested change.** One-line extension of the existing test (or a
sibling): the batch form fails with reason `handler_error`, channel
torn down, ledger decremented.
**Verification gates:**
1. `Establishment::default().with_reply_fields(json_map_containing_channel_id)`
→ `channel:open_failed` / `handler_error`; `count_for == 0` after.
## C-4: `open_channel_with_reply` wire failure path
**Finding.** `src/channels/client.rs:1654` — the new client API's e2e
test covers the success path only. The failure path (a
`channel:open_failed` resolving through `open_channel_with_reply`) is
covered only indirectly (wrapper-level test + the generic
`establisher_failure_resolves_typed_open_failed_on_the_client`).
Low risk — the parse path is shared with `open_channel` — but the new
pub API's error path has no direct pin.
**Requested change.** One e2e test against the existing
establisher-always-fails harness: `open_channel_with_reply` resolves
`Err(ChannelOpenError::CallFailed)` with the `channel:open_failed`
code and details intact.
**Verification gates:**
1. The full reply shape (`reason`, `message` in `details`) is
assertable by the caller through the new API.
## C-5: Byte-identical claims are shape-pinned, not golden-pinned
**Finding.** Two "byte-identical" claims are pinned structurally
(key-set equality), not by literal:
- `run_open_wrapper_without_reply_fields_is_byte_identical_to_pre_amendment`
(`operations.rs:2553`) asserts `v == json!({"channel_id":
v["channel_id"]})` — pins the exact key set but builds the expected
from the actual (a wrong-typed `channel_id` value would pass).
- The Unit-2 standard-shape wire payload
(`spec_standard_shape_channel_open_stays_boolean_only`) pins the
key *absence* of `channel_open_alpn` (the real pin, solid) but not
the payload's full key set.
**Requested change.** Golden-pin both: assert serialized bytes or a
literal `json!` against the actual for the no-fields reply, and a
full-object comparison for the standard-shape payload. Cheap, and it
converts "structurally equal" into "these exact bytes" for the two
claims the ADRs advertise as wire-stable.
**Verification gates:**
1. The no-fields open reply equals `json!({"channel_id": <exact
literal>})` — not a self-referential compare.
2. The standard-shape `services/schema` payload compares equal to a
literal object including key order (serde_json default map).
## C-6: `derive_alpn_from_op_name` edge shapes unpinned
**Errata (2026-09-18, at resolution).** The as-filed expectation
`"channels/x/sub/extra"` → `Some("alk/x/sub")` mis-stated the actual
behavior: the last-segment strip yields `rest = "x/sub/extra"`, and
`x/sub` (multi-segment, non-`alk/*`) rides as a full ALPN per the
ALPNs-without-the-prefix rule — `Some("x/sub")`, the same rule the
existing 5-segment test (`vendor/service/run`) pins. The landed test
asserts the actual behavior with the behavior-change-vs-pre-amendment
annotation.
**Finding.** `src/client/from_call.rs:326-337` — the empty-segment
guard (`segment.is_empty()`) and the bare-no-slash name have no unit
test, and the 4-segment name behavior *changed* (pre-amendment:
`None`; now: `Some("x/sub")`) without any test noticing. The audit
closed the `channels//sub` serialization half (N-4); the derivation
function's own edge-shape unit tests remain unlanded.
**Requested change.** Unit tests over the derivation directly:
`"channels//sub"` → `None`; `"channels//direct"` → `None`;
`"channels"` → `None`; `"channels/x/sub/extra"` → `Some("alk/x/sub")`
(the deliberate strict-superset behavior, worth pinning as intended);
`"channels/alk/tty/sub"` → `Some("alk/tty")` (the verbatim case).
**Verification gates:**
1. All five shapes assert as above; the 4-segment case is annotated
as a behavior change vs the pre-amendment derivation.
## C-7: Builder overwrite semantics unpinned
**Finding.** `src/channels/operations.rs:420-434` — two
`with_reply_field("same-key", …)` calls silently last-win; the batch
form extends (also last-win). Establisher-own concern, nit-level, but
it is pub-API behavior a consumer will rely on or be confused by.
**Requested change.** Either pin last-win with a test (and one doc
sentence on `with_reply_field`), or reject duplicates loudly at
build time. Prefer pinning: rejection adds a failure mode with no
consumer ask behind it.
**Verification gates:**
1. `.with_reply_field("k", v1).with_reply_field("k", v2)` →
`reply_fields()["k"] == v2`.
## Non-goals (recorded to bound the review)
- **No wire-format or API changes** — everything above is tests plus
at most doc text. The audit's code findings are already landed;
they are not re-opened here.
- **No new ADRs** — C-1's documentation route (if chosen) edits
ADR-051 §5/§6 text only.
- **alktunnels-side work** (the bind-first establisher, the listen-op
spec) sequences after this review as planned; nothing here blocks
it.
## References
- ADR-051 §6 (the post-adopt teardown bullet the drop guard landed;
C-1 pins its assembly-arm siblings)
- ADR-047 amendment 3 (C-6's derivation shapes), ADR-049 amendment 3
(C-3/C-7's reply projection), ADR-047 (C-5's wire-stability claims)
- CHANGELOG [Unreleased] — the audit's inline fixes this review
complements
- Review 008 — the remediation whose surface this review audits
+129
View File
@@ -17,6 +17,135 @@ Format: date | found-in (alkhttp context) | severity | status.
## Resolved
### CF-005 — connect-side serving path (`from_connection_with_serving`) has no caller-identity capture; scope-gated ops are satisfiable only via payload `auth_token` (2026-09-07) — RESOLVED 2026-09-07
- **Found in:** alktunnels reverse-flow POC
(`alktunnels-reverse-poc`, summary at
`/workspace/@alkdev/alktunnels/docs/research/reverse-poc-summary.md`
§W1; verified against alkcall 0.6.0). The worker is the connect side
AND the serving side (ADR-022 §2 both-sides): it serves its tunnel
open op to the hub over the connection it dialed. The open op is
scope-gated (`AccessControl.required_scopes`), but the identity the
ACL sees comes from `Dispatcher::dispatch_start` →
`resolve_identity(connection_identity, payload)`, and:
- `connection.identity()` is `None` — `from_connection_with_serving`
builds channel 0 internally via `Connection::from_source`
(`client.rs` channel-0 block) and never sets an identity; there is
no capture point for the transport-authenticated peer
(mTLS/QUIC identity) on this path.
- The only caller-identity path is the payload `auth_token` →
`ServingConfig.identity_provider` (`resolve_from_token`).
- Corollary: the `AuthContext` closed over by
`register_openable*`'s wrapper (what the establisher and pump
handler receive) is the connection-establishment-time context, not
the per-call opener's identity — on the accept side the install
hook receives the transport `AuthContext` and can
`channel0_conn.set_identity`, but `from_connection_with_serving`
has no equivalent seam.
- **Impact:** a connect-side serving op with a scope gate cannot
authenticate the legitimate peer by transport identity — the POC
had to attach `auth_token` payloads from the hub side
(`CallConnection::call_with_payload`). Acceptable for hub-forwarding
topologies (from_call's ADR-017 §7 token path), but a gap for
direct connect-side serving where the transport already
authenticated the peer.
- **Verification:** confirmed statically (the chain
`Connection::from_source` → empty `OnceLock` identity →
`dispatch_start` → `resolve_identity(None, payload)` →
`AccessControl::check(None)` fails closed) and empirically (e2e
duplex tests reproducing the POC shape; the tokenless scope-gated
call was denied `FORBIDDEN: authentication required` while the
token path succeeded).
- **Fix (both remediations (a) + (b), 2026-09-07):** the caller
identity for the connect-side serving dispatch resolves in
precedence order — (1) payload `auth_token` →
`ServingConfig.identity_provider` (unchanged; the hub-forwarding /
browser-token fallback, ADR-017 §7); (2) new
`ServingConfig.identity: Option<Identity>` — the explicit override
(remediation (a)); (3) the transport connection's identity,
propagated automatically: `from_connection_with_serving` copies
`connection.identity()` to the channel-0 connection via
`set_identity` before the serving loop starts (remediation (b) —
the native-client mTLS/QUIC key-based path; mirrors the accept
side's install-hook `set_identity`). Also added the public
`core::auth::NoopIdentityProvider` (resolves nothing) — the
identity-less posture and the `ServingConfig::default()`
identity provider; three private test copies of it existed.
- **Behavior change (semver-relevant):** `ServingConfig` gained an
`identity` field — struct literals must add `identity: None`
(0.x; changelog note). The call half is unaffected
(`spawn_dispatch` takes the consumer-owned `Connection`, so the
consumer can `set_identity` before wrapping).
- The corollary (`register_openable`'s closed-over
establishment-time `AuthContext` vs the per-call opener identity)
was resolved in the same batch (2026-09-07, CF-006 below).
- **Status:** resolved — 2026-09-07 (regression gates: `cf005_*`
tests in `src/channels/client.rs`).
### CF-006 — open-op establisher/pump handler receive the install-time `AuthContext`, never the per-call opener's identity (CF-005 corollary) (2026-09-07) — RESOLVED 2026-09-07
- **Found in:** the CF-005 verification pass (same alktunnels
reverse-flow POC W1 material; the ledger's CF-005 entry named it as
a "design follow-up, tracked separately"). `run_open_wrapper`
(`src/channels/operations.rs`) closed over the
`install_channel_zero`-time `AuthContext` and passed `auth.clone()`
verbatim to both the establisher (ADR-049 §1) and the pump handler
(`OpenHandler`'s 4th parameter). The per-call opener's identity —
the same identity the ACL gate and the per-identity cap check saw —
reached neither hook. On a per-connection registry (ADR-047 §4)
install-time identity IS the caller's, so the gap was invisible
there; it shows on hub-forwarded opens (the establisher saw the
hub, not the end client) and on the connect-side serving path
(token- or `ServingConfig.identity`-resolved callers). alktty
review 002 R3 independently recorded the same seam (closed
per-connection by design, with the "registry must stay
per-connection" constraint).
- **Impact:** an establisher doing per-principal authorization or
ownership checks on the backend could not see the actual opener;
the pump handler tagging sessions by principal saw the hub.
- **Fix (2026-09-07):** `run_open_wrapper` derives a **per-call
`AuthContext`** — the opener's dispatch-resolved identity overlaid
onto the install-time context — and passes it to both the
establisher and the pump handler. When the call has no resolved
identity, the install-time identity is kept (anonymous-keep; no
synthetic-`anonymous` rewrite of a real install-time identity).
Transport-truthful fields (`alpn`, `remote_addr`,
`tls_client_fingerprint`) are never rewritten. Signatures are
unchanged (`OpenEstablisher`, `OpenHandler` still take
`AuthContext`) — behavior-only, additive; downstream code compiles
unchanged. Regression gates:
`open_wrapper_overlays_per_call_identity_on_install_time_auth` +
`open_wrapper_keeps_install_time_identity_when_call_identityless`
in `src/channels/operations.rs`.
- **Also in this batch:** `ChannelPlan`'s type doc now spells out the
`Send + Sync` payload constraint (alktunnels POC F-1 re-derived it
by compiler error — a doc line prevents the next consumer repeating
that).
- **Status:** resolved — 2026-09-07.
### CF-007 — ADR-016's protocol-code list is stale: omits `ALREADY_EXISTS` and `CONNECTION_CLOSED` (2026-09-07) — RESOLVED 2026-09-07
- **Found in:** alkhttp review 006, Part C
(`alkhttp/docs/reviews/006-alkcall-0.3.0-consequence-review.md`
L207–233, L319–324 — "upstream doc drift … flagged for the next
alkcall doc pass"). ADR-016 said "six codes" and its table listed
only the original six, while the wire vocabulary had grown:
`ALREADY_EXISTS` (ADR-022 collision sub-amendment, 0.3.0) and
`CONNECTION_CLOSED` (CF-001, 0.4.x, the retryable
provably-undelivered-call code). Downstream consumers already
handle both; the authoritative ADR was the thing that was wrong.
- **Fix (2026-09-07):** ADR-016 amended — Status notes the 2026-09-07
extension to eight; the Context paragraph and the §3 table carry
both codes with their semantics (`ALREADY_EXISTS` non-retryable,
registration state; `CONNECTION_CLOSED` retryable — the only
protocol code a caller may auto-retry), a §2a subsection documents
the undelivered-vs-ambiguous write-failure distinction, the
`from_openapi` collision rule lists all eight, and the
cross-references cite the two amendments. Doc-only; the wire
surface is unchanged.
- **Status:** resolved — 2026-09-07.
---
### CF-004 — `services_schema_handler` discloses Internal/ACL-restricted op specs — no visibility or AccessControl check (2026-08-30) — RESOLVED 2026-08-31
- **Found in:** alkhttp Review 002, finding PRJ-16
+6
View File
@@ -0,0 +1,6 @@
target
corpus/*
!corpus/chunk_header
!corpus/envelope_frame
artifacts
coverage
+1280
View File
File diff suppressed because it is too large. Load diff
+52
View File
@@ -0,0 +1,52 @@
[package]
name = "alkcall-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
alkcall-fuzz-shared = { path = "shared" }
[dependencies.alkcall]
path = ".."
[[bin]]
name = "chunk_header"
path = "fuzz_targets/chunk_header.rs"
test = false
doc = false
bench = false
[[bin]]
name = "envelope_frame"
path = "fuzz_targets/envelope_frame.rs"
test = false
doc = false
bench = false
[[bin]]
name = "manager_routing"
path = "fuzz_targets/manager_routing.rs"
test = false
doc = false
bench = false
[[bin]]
name = "envelope_semantic"
path = "fuzz_targets/envelope_semantic.rs"
test = false
doc = false
bench = false
[[bin]]
name = "spec_parse"
path = "fuzz_targets/spec_parse.rs"
test = false
doc = false
bench = false
[workspace]
+53
View File
@@ -0,0 +1,53 @@
# alkcall fuzzing
libFuzzer targets for alkcall's wire formats (see
`docs/research/fuzzing.md` for the full rationale and campaign plan).
## Layout
- `fuzz_targets/` — one binary per target; thin `fuzz_target!` wrappers.
- `shared/` — the invariant logic, as a plain library so normal
`cargo test` (stable toolchain) can replay the committed corpora
through the same invariants (`fuzz/shared/src/*.rs` `#[cfg(test)]`
modules; quinn's CI pattern). The fuzz binaries are nightly-only;
the shared crate is stable-clean.
- `corpus/<target>/` — committed hand-made seeds (regenerate with
`python3 fuzz/gen_fuzz_seeds.py` from the repo root). Grown corpora
and artifacts are gitignored.
- `run-detached.sh` — mandatory runner for agent sessions: wraps
`cargo fuzz run` in `setsid` + `nohup` + log redirection so an OOM
in a target can never take down the agent host (research doc §7.6).
- `json.dict` — JSON token dictionary for the envelope targets.
## Targets
| Target | Drives | Invariants |
|---|---|---|
| `chunk_header` | `parse_header` / `write_header` (`channels/wire.rs`) | no-panic; round-trip identity; `TooLarge` iff `length > MAX_CHUNK_LEN`; consumption accounting (8 bytes); input never mutated |
| `envelope_frame` | `FrameFramedReader::new(Cursor).read_frame()` on a current-thread runtime | no-panic; never allocates > `MAX_FRAME_SIZE`; clean `FrameError` on truncation/oversize/zero-length; exact consumption; structural `write_frame` round-trip |
## Commands
`cargo fuzz build` and `cargo fuzz run` must be executed with `fuzz/`
(or deeper) as the working directory so rustup selects the pinned
nightly toolchain — the detached runner handles that itself.
```sh
# build (nightly, pinned by rust-toolchain.toml inside fuzz/; run from fuzz/)
cargo fuzz build
# agent sessions: detached campaign (never foreground; CWD-independent)
FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh chunk_header
FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh envelope_frame -max_len=65536 -dict=json.dict
# corpus replay through the invariants (stable toolchain, no nightly)
cargo test --manifest-path fuzz/shared/Cargo.toml
# coverage
cargo fuzz coverage <target>
```
The fuzz workspace is excluded from the main workspace and from the
published package (`exclude` in the root `Cargo.toml`); it pins its own
nightly toolchain via `rust-toolchain.toml` and does not affect the
crate's stable MSRV.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
��������
View File
Whitespace-only changes.
+1
View File
@@ -0,0 +1 @@

+1
View File
@@ -0,0 +1 @@

+1
View File
@@ -0,0 +1 @@

+1
View File
@@ -0,0 +1 @@

+1
View File
@@ -0,0 +1 @@

+1
View File
@@ -0,0 +1 @@

+1
View File
@@ -0,0 +1 @@

Binary file not shown.
Loaded 100 of 1502 files, more files were not shown because too many files have changed in this diff. Show more