Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bda29c419 | ||
|
|
77f7006e2e | ||
|
|
a1f257757b | ||
|
|
c211c163fd | ||
|
|
80a37e94ab | ||
|
|
7ae0315f19 | ||
|
|
791eea7298 | ||
|
|
7bb76cd2b5 | ||
|
|
9620ee7b2a | ||
|
|
54c2a3f941 | ||
|
|
50182d7298 | ||
|
|
4e52bd496c | ||
|
|
90c182039e | ||
|
|
91765446b7 | ||
|
|
620180d615 | ||
|
|
82ddddf986 | ||
|
|
1bc937c061 | ||
|
|
3b36b4074f | ||
|
|
098090d564 | ||
|
|
e2ca981eb6 | ||
|
|
ed741c34e0 | ||
|
|
db5530ed34 | ||
|
|
0d287a97b0 | ||
|
|
d22cecd317 | ||
|
|
d25deb5eaf | ||
|
|
9c6fec17ca | ||
|
|
8f122b0a38 | ||
|
|
dc4ad2bc6d | ||
|
|
6590ab005f | ||
|
|
36e74cda11 | ||
|
|
f8dad9dbc8 | ||
|
|
2586c3b217 | ||
|
|
48ceeba55c | ||
|
|
88e3f5e9c3 | ||
|
|
a22b2b84c9 | ||
|
|
574f58442a | ||
|
|
ba94c70eb2 | ||
|
|
fd212307e2 | ||
|
|
1e20bb77d0 | ||
|
|
d5b2661b38 | ||
|
|
23c9b28c6b | ||
|
|
1cbb7c6536 | ||
|
|
435ae9da2f | ||
|
|
f84d214173 | ||
|
|
c0dbf82518 |
No files matched your search
@@ -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,15 @@ 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..047; 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..047). The ALPN strings (`alk/call`,
|
||||
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).
|
||||
- Key ADRs that inform this crate's design:
|
||||
|
||||
+610
@@ -4,6 +4,606 @@ 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
|
||||
leg ADR-016 promised: `INVALID_INPUT` is the registry's
|
||||
schema-mismatch error code). Minor bump — behavioral break for any
|
||||
caller that was passing schema-violating inputs to ops declaring a
|
||||
non-trivial `input_schema` and relying on the validation being
|
||||
documentation-only.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`OperationSpec.input_schema` is enforced by the registry on every
|
||||
dispatch.** Until now the schema was advertise-only: `services/schema`
|
||||
disclosed it, the wire and gateway paths disclosed it, but no dispatch
|
||||
entry point consulted it (the only schema alkcall actually enforced
|
||||
was `publish_schema` per-chunk on Pub ops, P-03). The schema now
|
||||
compiles once at registration (same fail-closed rule as
|
||||
`publish_schema`/CF-003: an un-compilable schema is a registration
|
||||
error, never a silently-skipped contract) and is checked after the
|
||||
ACL gate in all three dispatch entry points — `invoke`,
|
||||
`invoke_streaming`, and `invoke_sink` (via `resolve_sink_handler`,
|
||||
preserving the P-08 single-source-of-truth property). Violations
|
||||
return `CallError::invalid_input("input failed input_schema
|
||||
validation")` with the input echoed in `details`. Raw-JSON-Schema
|
||||
semantics (permissive on unknown keys) — adapters that want
|
||||
closed-by-default enforcement keep doing their own hardening, as
|
||||
alkhttp's `CompiledInputSchema` already does; the two checks compose.
|
||||
Built-in ops are unaffected: alkcall's own specs declare the empty
|
||||
object schema (accepts anything) and `services/schema` already
|
||||
returned `INVALID_INPUT` for a missing `name` before its handler ran.
|
||||
Motivated by alktty's code review #001 (L1): the channels open-op
|
||||
wrapper hands the registry-checked `input` to the `OpenHandler` as
|
||||
the authoritative params, which requires the registry to actually
|
||||
validate it.
|
||||
- `OperationRegistry` gained an `input_validator` cache (compiled at
|
||||
registration, mirrored on `fork` and in `OperationRegistryBuilder`
|
||||
like the publish validators); `input_validator()` is `pub(crate)` —
|
||||
the public surface is unchanged.
|
||||
|
||||
## [0.3.1] - 2026-09-04
|
||||
|
||||
Bug-fix release: `services/list-peers` can now list peer-announced
|
||||
ops. No API breaks — `OperationEnv` gains one defaulted trait method
|
||||
(non-breaking for all implementors; `cargo semver-checks` 196 checks
|
||||
pass against the 0.3.0 baseline).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`services/list-peers` shows each peer's operations**
|
||||
(UP-03, surfaced by alkhttp's review 006). `PeerCompositeEnv`
|
||||
overrode `peer_ids` only, so `peer_operations` fell to the trait
|
||||
default (`Vec::new()`) and every peer listed with an empty
|
||||
operations array — the ADR-022 amendment's "announced op is
|
||||
discoverable via `services/list-peers`" promise never resolved on
|
||||
the wire. ADR-030 prescribed the fix but it had never been ported
|
||||
into alkcall; the existing `list-peers` unit tests passed because
|
||||
they mock `peer_operations` with hand-rolled envs. Implemented per
|
||||
ADR-030: `OperationEnv::list_operation_names` (default `Vec::new()`)
|
||||
+ overrides on `OverlayOperationEnv` (overlay map keys),
|
||||
`PeerCompositeEnv` (`peer_operations` delegates to the peer's
|
||||
overlay; its own aggregate mirrors `contains()`: session +
|
||||
connections + base), `LocalOperationEnv` (registry names), and
|
||||
`ChannelsSessionEnv` (delegates to base). Gate:
|
||||
`announced_op_is_discoverable_via_services_list_peers` exercises the
|
||||
exact `compose_root_env` shape (announce via `op/register`, then
|
||||
assert both the `peer_operations` probe and the `services/list-peers`
|
||||
wire shape attribute the op to the peer) — verified load-bearing
|
||||
(reverting the override fails the gate). ADR-030 status is now
|
||||
Accepted with the UP-03 provenance note.
|
||||
|
||||
## [0.3.0] - 2026-09-04
|
||||
|
||||
The connect side can serve (two-way ops over one channels connection),
|
||||
per-session fork registries, and the `op/register` bootstrap op — the
|
||||
remediation of reviews 004 and 005
|
||||
(`docs/reviews/004-*.md`, `docs/reviews/005-*.md`). Two
|
||||
`OperationRegistry` methods changed their return types from borrowed to
|
||||
owned (source-breaking for annotated call sites; minor bump per 0.x
|
||||
semver rules) — verified with `cargo semver-checks` (196 checks pass
|
||||
against the published 0.2.0 baseline; the return-type changes were
|
||||
caught by manual diff review, as the tool has no lint for that
|
||||
pattern).
|
||||
|
||||
### Added
|
||||
|
||||
- **`OperationRegistry::fork`** — deep-copies a registry (handlers,
|
||||
provenance, composition authority, capabilities, cached
|
||||
publish-schema validators) into an independently-mutable copy. The
|
||||
per-session shape: fork the deployment's base registry, register the
|
||||
session's ops on the fork, dispatch the session over the fork
|
||||
(ADR-047 §4 amendment, 2026-09-03). Internally mutable, so a fork
|
||||
shared as an `Arc` can receive registrations after the dispatcher
|
||||
was built — `install_bootstrap_discovery` relies on this.
|
||||
- **`OperationRegistryBuilder::from_registry`** — seeds a builder from
|
||||
an existing registry's registrations (the fork surface expressed
|
||||
through the builder; registration order is not preserved — HashMap
|
||||
iteration).
|
||||
- **Connect-side serving (two-way ops over one connection).** The
|
||||
channels connect side's channel-0 read pump previously resolved
|
||||
outbound responses only and silently dropped inbound
|
||||
`call.requested` frames. New `ChannelClient::from_connection_with_serving`
|
||||
takes `Option<ServingConfig>` (`registry` + `identity_provider`);
|
||||
with `Some(..)` the read pump becomes the full-duplex serving loop
|
||||
(`Dispatcher::serve_single_stream`), so a connected peer can call the
|
||||
consumer's ops. `from_connection` keeps the resolution-only pump
|
||||
(pure-consumer default). Serving is opt-in: the protocol is
|
||||
symmetric, the API is explicit (ADR-022 amendment, 2026-09-03).
|
||||
- **`Dispatcher::serve_single_stream` and `Dispatcher::dispatch_start`
|
||||
+ `StartedDispatch`** — the shared serving-loop machinery. Both
|
||||
single-stream loops (accept side and connect side) now run the same
|
||||
concurrency model: Once invocations, Sub pumps, and sink response
|
||||
writers are spawned, so same-connection nested composition resolves
|
||||
concurrently instead of deadlocking until the 30 s sweeper (review
|
||||
005 G-01). Handles are tracked and aborted at loop exit; no lock is
|
||||
held across an await.
|
||||
- **`registry::op_register` module — the `op/register` bootstrap op**
|
||||
(review 004 F-05). A connected peer announces the ops it serves over
|
||||
the wire: `OP_REGISTER_NAME` (`op/register`), `OpRegisterRequest`
|
||||
(wire round-trip via `to_json`/`from_json`), `op_register_spec` /
|
||||
`op_register_handler`. Announcements land in the connection overlay
|
||||
and are served through forwarding handlers, so hub→consumer import
|
||||
(`from_call`) and consumer→hub announce are symmetric. **Collision
|
||||
policy** (review 005 G-03): a peer-announced op may replace other
|
||||
peer-announced ops (when `replace` is set) but never the serving
|
||||
side's own registrations — a name present on the serving registry
|
||||
rejects with the new `CallError::already_exists` (`ALREADY_EXISTS`,
|
||||
non-retryable), regardless of `replace`. The collision gate checks
|
||||
the serving registry, the session fork, and the connection overlay
|
||||
itself.
|
||||
- **`install_bootstrap_discovery`** (review 004 F-06) — registers
|
||||
`services/list`, `services/list-peers`, and `services/schema`
|
||||
against a registry with handlers closed over that same `Arc`, so
|
||||
per-session forks are the discovery source for their own openables
|
||||
(handlers see every op the registry serves at call time). ACL
|
||||
filtering stays per-caller. The bootstrap-op set on channel 0 is
|
||||
closed and now includes `services/list-peers` (review 005 G-05 —
|
||||
doc alignment; the code had installed it since the amendment landed)
|
||||
plus `op/register` (ADR-022 amendment).
|
||||
- **`spec_to_json_pub`** — public serialization of an `OperationSpec`
|
||||
into the `services/schema` wire shape (the shape `op/register`
|
||||
announces with and `services/schema` serves). **`resource_id_path`
|
||||
now survives the spec wire round-trip** (review 005 G-04): both the
|
||||
serializer and the `op/register` parser carry the field.
|
||||
- **`CallConnection::overlay_contains` /
|
||||
`CallConnection::overlay_registration`** — read access to the
|
||||
connection overlay, for `op/register` replace semantics and
|
||||
composition reachability checks.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`OperationRegistry::registration` returns
|
||||
`Option<HandlerRegistration>`** (was `Option<&HandlerRegistration>`)
|
||||
and **`list_operations` returns `Vec<OperationSpec>`** (was
|
||||
`Vec<&OperationSpec>`). Source-breaking for call sites that annotate
|
||||
the borrowed types — clone-at-the-boundary instead. Minor bump per
|
||||
0.x semver rules.
|
||||
- **Registration is `&self` throughout** — `OperationRegistry::register`,
|
||||
`ChannelOperations::register_on`, and `ChannelCore::register_openable`
|
||||
take `&OperationRegistry` (was `&mut`). Source-compatible for
|
||||
callers (reborrow); this is what makes post-construction
|
||||
registration on a forked, `Arc`-shared registry possible.
|
||||
- **`from_call` composition reachability** — the forwarding-handler
|
||||
path declares reachable namespaces on the composing handler's
|
||||
`scoped_env` (empty is deny-by-default) and the connection overlay
|
||||
is attached only for identity-carrying connections (ADR-030 §5).
|
||||
|
||||
## [0.2.0] - 2026-08-31
|
||||
|
||||
Consumer-findings remediation (CF-001..004 from
|
||||
@@ -137,6 +737,16 @@ 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
|
||||
[0.1.1]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.1.1
|
||||
[0.1.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.1.0
|
||||
Generated
+1
-1
@@ -27,7 +27,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alkcall"
|
||||
version = "0.2.0"
|
||||
version = "0.8.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
|
||||
+7
-3
@@ -1,15 +1,19 @@
|
||||
[package]
|
||||
name = "alkcall"
|
||||
version = "0.2.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"
|
||||
|
||||
@@ -21,7 +21,7 @@ use alkcall::registry::{
|
||||
spec::{OperationSpec, OperationType, Visibility, AccessControl},
|
||||
};
|
||||
|
||||
let mut registry = OperationRegistry::new();
|
||||
let registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"echo/run",
|
||||
@@ -98,6 +98,35 @@ for reg in registrations {
|
||||
let response = conn.call("remote/status", serde_json::json!({})).await;
|
||||
```
|
||||
|
||||
### Serving your own ops as a connected consumer
|
||||
|
||||
The call protocol is symmetric — both sides of a connection can serve
|
||||
ops. A `ChannelClient` built with `from_connection` is a pure consumer
|
||||
(inbound `call.requested` frames are dropped); pass a `ServingConfig`
|
||||
to also serve your registry to the peer, and use `op/register` to
|
||||
announce which ops you serve:
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
use alkcall::channels::client::{ChannelClient, ServingConfig};
|
||||
use alkcall::registry::discovery::install_bootstrap_discovery;
|
||||
|
||||
let registry = Arc::new(OperationRegistry::new());
|
||||
// ... register your ops on the registry, then:
|
||||
install_bootstrap_discovery(®istry)?;
|
||||
|
||||
let client = ChannelClient::from_connection_with_serving(
|
||||
connection,
|
||||
Some(ServingConfig {
|
||||
registry: Arc::clone(®istry),
|
||||
identity_provider: provider,
|
||||
identity: None, // peer identity: transport `Connection::set_identity` propagates
|
||||
}),
|
||||
).await?;
|
||||
// peer-callable ops resolve against `registry` on channel 0;
|
||||
// `client.call_open_op` still works — both directions share the pump
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
alkcall is a pure protocol crate — no networking, no transport
|
||||
@@ -107,7 +136,7 @@ Downstream crates compose on top of it in a layered dependency chain.
|
||||
| Role | Call protocol | Channels protocol |
|
||||
|------|---------------|-------------------|
|
||||
| **Producer** | Registers ops on an `OperationRegistry`, runs a `Dispatcher` | Runs a `ChannelsAdapter`, registers openable ALPNs via `ChannelCore::register_openable` |
|
||||
| **Consumer** | Uses `CallConnection` to call ops, uses `from_call` to discover/import remote ops | Uses `ChannelClient` to open channels via `call_open_op` + `open_channel` |
|
||||
| **Consumer** | Uses `CallConnection` to call ops, uses `from_call` to discover/import remote ops; may also serve its own ops (`from_connection_with_serving`) | Uses `ChannelClient` to open channels via `call_open_op` + `open_channel` |
|
||||
| **Hub** | Both: runs a `Dispatcher` for ops it produces, holds `CallConnection`s to spokes for ops it consumes | Both: runs a `ChannelsAdapter` for inbound connections, holds `ChannelClient`s to spokes |
|
||||
| **Spoke / Worker** | Both: produces ops (its own services), consumes hub ops | Both: produces channels (TTY, tunnel), may consume hub channels |
|
||||
|
||||
|
||||
@@ -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/`.
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (amended 2026-06-26, 2026-07-13, and 2026-07-16 — see "Amendments" below; the 2026-07-16 amendment per ADR-045 §5 removes `CallClient::connect`)
|
||||
Accepted (amended 2026-06-26, 2026-07-13, and 2026-07-16 — see "Amendments" below; the 2026-07-16 amendment per ADR-045 §5 removes `CallClient::connect`; amendment 2026-09-03 — the bootstrap-op set and the connect-side serving loop, see "Amendment (2026-09-03)" below; amendment 2026-09-04 — the `op/register` collision policy, in that amendment's "Collision policy" paragraph)
|
||||
|
||||
## Context
|
||||
|
||||
@@ -359,6 +359,143 @@ same as `from_openapi` receives HTTP credentials.
|
||||
prior art
|
||||
- POC at `/workspace/@alkdev/dispatch` — head/worker dispatch over SSH+axum
|
||||
|
||||
## Amendment (2026-09-03): bootstrap-op set and the connect-side serving loop
|
||||
|
||||
Review 004 (F-04/F-05) verified two gaps between this ADR's
|
||||
bidirectionality promise (§2 "connection direction is independent of
|
||||
call direction") and the single-stream channel-0 implementation:
|
||||
|
||||
1. **The connect side's read pump resolved responses only** — inbound
|
||||
`call.requested` frames were silently dropped. Serving existed only
|
||||
on the accept side (`run_loop_single_stream`).
|
||||
2. **No wire mechanism announced client-side ops** — `from_call`
|
||||
imports hub ops into the consumer, but a connected peer had no way
|
||||
to announce "here are the ops I serve" over the wire.
|
||||
|
||||
The amendment (implemented in alkcall; the e2e gates are
|
||||
`serving_loop_hub_to_consumer_call_resolves` and
|
||||
`op_register_announce_then_hub_call_routes_back_to_consumer` in
|
||||
`src/channels/client.rs`):
|
||||
|
||||
### The bootstrap-op set (one-way)
|
||||
|
||||
Each side of a channels connection **may serve** the bootstrap ops on
|
||||
channel 0. The set is closed:
|
||||
|
||||
- `services/list` — discovery (the op `from_call` dials on every
|
||||
import; each side is expected to serve it)
|
||||
- `services/schema` — per-op schema disclosure
|
||||
- `services/list-peers` — peer-keyed discovery (the op that makes
|
||||
peer-announced ops discoverable; `from_call`-import discovery
|
||||
relies on it). Added to this list 2026-09-04 (review 005 G-05) —
|
||||
`install_bootstrap_discovery` has installed it since the amendment
|
||||
landed; the set was declared closed and the doc lagged the code.
|
||||
- `op/register` — peer op announcement (below)
|
||||
|
||||
These are the ops a peer may assume are reachable (subject to each
|
||||
op's `AccessControl`); a peer that does not serve one answers
|
||||
`NOT_FOUND` and the caller treats it accordingly (`from_call` already
|
||||
surfaces discovery failure as `AdapterError::DiscoveryFailed`).
|
||||
|
||||
### Connect-side serving is opt-in (two-way door at the API level)
|
||||
|
||||
`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, 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;
|
||||
an id that is an inbound request dispatches; `call.aborted` tries both
|
||||
tables (in-flight sink aborts and the pending map's cascade). IDs are
|
||||
UUID-generated per side, so cross-correlation is not a hazard.
|
||||
|
||||
### `op/register` (one-way in wire shape)
|
||||
|
||||
The peer→hub direction of `from_call`'s bundle flow: a peer sends
|
||||
`call.requested` for `op/register` carrying the `OperationSpec` in the
|
||||
`services/schema` wire shape (`spec_to_json`) plus a `replace` flag.
|
||||
The serving-side handler (`registry::op_register::op_register_handler`):
|
||||
|
||||
1. Rebuilds the spec (the same parser `from_call` uses — one spec
|
||||
serialization on the wire).
|
||||
2. Wraps a **call-forwarding handler** that issues a nested
|
||||
`call.requested` back over channel 0 to the announcing peer (the
|
||||
same shape `from_call`'s imported bundles use — the in-process twin
|
||||
at `protocol/adapter.rs`).
|
||||
3. Writes the bundle into **that connection's overlay** via
|
||||
`register_imported` with `FromCall` provenance and
|
||||
`Visibility::Internal` (composition material, ADR-017 — never
|
||||
directly callable from the serving side's own wire).
|
||||
|
||||
The announced op is discoverable via `services/list-peers` (the
|
||||
overlay is peer-keyed, `compose_root_env` attaches it) and invocable
|
||||
via nested composition (`env.invoke`). Announced Sub/Pub ops register
|
||||
as stubs that answer `INVALID_OPERATION_TYPE` — nested composition is
|
||||
request/response-only (`OverlayOperationEnv`'s contract); the
|
||||
streaming/sink forwarding shapes ride on the `from_call` import path.
|
||||
|
||||
Access control: `op/register` itself carries an `AccessControl` (an
|
||||
unprivileged peer cannot reach the handler — the registry's normal
|
||||
invoke path enforces it). Replace semantics: a collision with an
|
||||
existing overlay registration is rejected with `ALREADY_EXISTS`
|
||||
unless `replace: true` (the reconnect path re-announces). The
|
||||
overlay dies with the connection (Layer 2), so reconnect re-announce
|
||||
is naturally scoped.
|
||||
|
||||
Collision policy (amended 2026-09-04, review 005 G-03): a
|
||||
peer-announced op may collide with other *peer-announced* ops on the
|
||||
same connection (`replace` governs) but **never** with the serving
|
||||
side's own registrations — a name present on the serving registry
|
||||
rejects with `ALREADY_EXISTS` regardless of `replace`. The connection
|
||||
overlay shadows the base registry in `PeerCompositeEnv` (connections
|
||||
resolve before base, ADR-024 §1), so an unscreened same-name announce
|
||||
would silently rewrite what a wire-dispatched handler's
|
||||
`ctx.env.invoke` resolves for any name the deployment registered:
|
||||
composition authority (ADR-018) belongs to the composing handler's
|
||||
deployer, not the connected peer. Visibility is irrelevant to this
|
||||
gate (`Internal` ops are as shadowable as `External` —
|
||||
`OverlayOperationEnv` gates on `AccessControl`, not visibility; the
|
||||
composed child is `internal: true` by design).
|
||||
|
||||
The envelope kind set stays closed at six — bootstrap ops over channel
|
||||
0 are the door (AGENTS.md §7 allows adding kinds; none is needed).
|
||||
|
||||
### Cross-references
|
||||
|
||||
- Review 004 (`docs/reviews/004-per-connection-dispatch-and-client-serving-review.md`)
|
||||
F-04/F-05 — the verification and the design decision
|
||||
- ADR-047 §4 amendment #2 (2026-09-03) — the per-session fork the
|
||||
bootstrap ops compose on
|
||||
- alkhttp OQ-05 / ADR-048 — the browser data-channel wiring this
|
||||
unblocks (the alkhttp-side gap was wiring; these two mechanisms are
|
||||
what it wires to)
|
||||
|
||||
## Amendments (2026-06-26)
|
||||
|
||||
This ADR left four decisions as two-way doors (§1 Consequences flagged DC-1's
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
Accepted (implemented 2026-09-04 — surfaced as UP-03 in alkhttp's
|
||||
review 006 `docs/…/006-alkcall-0.3.0-consequence-review.md`: the
|
||||
`op/register` amendment's "announced op is discoverable via
|
||||
`services/list-peers`" promise did not resolve on the wire because
|
||||
this override had never been ported into alkcall; the gate is
|
||||
`announced_op_is_discoverable_via_services_list_peers` in
|
||||
`src/registry/op_register.rs`. The `services/list-peers` unit tests
|
||||
did not catch it because they mock `peer_operations` with hand-rolled
|
||||
envs.)
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -5,7 +5,220 @@
|
||||
Accepted (amends ADR-037; refines ADR-044, ADR-046; §4 amended
|
||||
2026-08-13 — open ops are registered per-connection, not resolved via
|
||||
`context.env` downcast — see "Amendment (§4 per-connection
|
||||
registration, 2026-08-13)" below)
|
||||
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;
|
||||
§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)
|
||||
|
||||
The 2026-08-13 amendment named the registration target as "the
|
||||
connection overlay registry (Layer 2 per ADR-019)". Review 004 (F-01)
|
||||
verified that this shape cannot dispatch: the top-level dispatch path
|
||||
(`Dispatcher::dispatch` / `run_loop_single_stream`) resolves and
|
||||
invokes against the dispatcher's **base registry only**; the
|
||||
connection overlay is reachable solely as a layer of `context.env`
|
||||
(for nested invocations — a handler calling `env.invoke(...)), never
|
||||
for resolving the incoming `call.requested` itself. An open op
|
||||
registered on the overlay resolves `NOT_FOUND` on the wire.
|
||||
|
||||
The one shape proven end-to-end (alkcall's own e2e gate) is different:
|
||||
the `install_channel_zero` hook builds a **fresh per-connection
|
||||
registry containing the open op and passes it as the dispatcher's base
|
||||
registry**. This amendment makes that the operative mechanism.
|
||||
|
||||
**The decision: per-connection registration happens on a fork of the
|
||||
deployment's base registry, installed as the session's dispatch
|
||||
registry.** The `install_channel_zero` hook (and any future
|
||||
session-establishment seam):
|
||||
|
||||
1. **Forks** the deployment's base registry
|
||||
(`OperationRegistry::fork` — a deep copy carrying handlers,
|
||||
provenance, composition authority, capabilities, and the cached
|
||||
publish-schema validators; review 004 F-02/F-03).
|
||||
2. **Registers the per-session ops on the fork** — the generic channel
|
||||
ops (`ChannelOperations::register_on`), the openables
|
||||
(`ChannelCore::register_openable`), and the bootstrap discovery ops
|
||||
(`install_bootstrap_discovery`, closed over the fork itself so
|
||||
`services/list` sees the fork's per-session ops — review 004 F-06).
|
||||
3. **Dispatches channel 0 over the fork** (`Dispatcher::new(fork,
|
||||
...)`).
|
||||
|
||||
The fork is possible because `OperationRegistry` is internally
|
||||
mutable (`parking_lot::RwLock` around both maps) — a fork shared as an
|
||||
`Arc<OperationRegistry>` can receive bootstrap ops after the
|
||||
dispatcher was built, and the self-referential discovery closure sees
|
||||
every post-install registration.
|
||||
|
||||
The **connection overlay (Layer 2) remains what ADR-019/ADR-024
|
||||
describe**: the landing zone for peer-announced ops (`op/register`,
|
||||
review 004 F-05 — ADR-022 amendment) and the nested-invocation target
|
||||
for imported ops. It is not the dispatch-resolution path for the
|
||||
session's own ops.
|
||||
|
||||
Rationale for the fork shape over an overlay-aware dispatch fallback
|
||||
(F-02 option (b)): the fork is the only shape with an end-to-end
|
||||
proof, it needs no change to the shared dispatch loop, and it keeps
|
||||
the overlay's `invoke_with_policy` shape (namespace-scoped,
|
||||
parent-context-driven — built for nested composition) out of the
|
||||
top-level call path, where it does not match the frame-handling
|
||||
contract.
|
||||
|
||||
This preserves every invariant the 2026-08-13 amendment protected:
|
||||
|
||||
- **Layering (ADR-044):** unchanged — the open-op wrapper is in
|
||||
`channels-call`; the call crate's `OperationRegistry` gains only
|
||||
`fork` (and interior mutability), no channels types.
|
||||
- **Per-connection resolution:** the open op gets the *right*
|
||||
`ChannelManager` because the fork is built per-connection and its
|
||||
openable closes over that connection's `ChannelCore`.
|
||||
- **"Marked ops invoked outside a channels session" (ADR-047 §2):**
|
||||
unchanged in effect — a `channels/<alpn>/sub` op registered only on
|
||||
a session fork is not reachable on a bare `alk/call` connection (the
|
||||
fork isn't that session's dispatch registry) — the dispatch path
|
||||
returns `NOT_FOUND`.
|
||||
|
||||
### Door type
|
||||
|
||||
**Two-way (implementation detail), as before.** The registration
|
||||
*target* mechanism (fork as base registry) sits within the same
|
||||
wrapper-shape detail the 2026-08-13 amendment already marked two-way.
|
||||
The one-way decisions (per-ALPN op names, the `channel_open` marker,
|
||||
removal of `channel/open`/`direction`) are unchanged.
|
||||
|
||||
### References
|
||||
|
||||
- Review 004 F-01/F-02/F-03/F-06
|
||||
(`docs/reviews/004-per-connection-dispatch-and-client-serving-review.md`)
|
||||
— the verification and the mechanism decision
|
||||
- ADR-022 amendment (2026-09-03) — the bootstrap-op set (`services/list`,
|
||||
`services/schema`, `op/register`) and the connect-side serving loop
|
||||
- ADR-019: operation registry layering (the overlay stays the nested
|
||||
invocation / peer-announced-ops landing zone)
|
||||
- The e2e gate: `fork_registry_open_op_resolves_and_is_discoverable`
|
||||
(`src/channels/client.rs`) — open op resolves through the fork,
|
||||
per-session openable in `services/list`, `services/schema` validates
|
||||
|
||||
## Amendment (§4 per-connection registration, 2026-08-13)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,527 @@
|
||||
# Review 004 — Per-Connection Dispatch Resolution and Client-Side Op Serving (found via alkhttp's WS data-channel drill-down)
|
||||
|
||||
## Status
|
||||
|
||||
Verified, remediated (2026-09-03 — Units 1–3; Unit 4 remains
|
||||
downstream in alkhttp). See "Remediation log (2026-09-03)" at the
|
||||
bottom for the landing summary and the gates.
|
||||
|
||||
## Scope
|
||||
|
||||
Focused review of two design/spec mismatches in the call + channels
|
||||
integration, uncovered while drilling into the deferred half of
|
||||
alkhttp's WebSocket path (alkhttp review 003, findings WS-24/WS-25 —
|
||||
the OQ-05 "browser data channels" deferral). The drill-down
|
||||
established that the alkhttp-side gap is mostly wiring, but two
|
||||
assumptions underneath it resolve to *this* crate:
|
||||
|
||||
1. **Dispatch resolution for per-connection operations.** ADR-047's
|
||||
§4 amendment (2026-08-13) decided that openable-ALPN open ops are
|
||||
registered per-connection on the connection overlay registry
|
||||
(Layer 2). The top-level dispatch path never consults that layer,
|
||||
so an op registered per the amendment's mechanism cannot be
|
||||
invoked over the wire. The only shape proven to dispatch is
|
||||
different from the one the ADR describes.
|
||||
2. **Client-side operation serving.** The connection-local overlay
|
||||
(`CallConnection::register_imported` → `overlay_env()`) and the
|
||||
session overlay (`compose_root_env` attaching peer-keyed overlays)
|
||||
are fully built and tested — but only the *accept side* has a loop
|
||||
that serves inbound `call.requested` frames. The connect side's
|
||||
read pump resolves responses and silently drops requests. There
|
||||
is also no wire mechanism by which a client could announce an op
|
||||
in the first place. Together these leave the "both sides can be
|
||||
both" symmetry (AGENTS.md §8; ADR-022 §direction semantics;
|
||||
alkhttp ADR-048's browser bidirectionality) unreachable on the
|
||||
single-stream channel-0 shape.
|
||||
|
||||
The pass re-verified every claim directly in source at tree
|
||||
`c16b069`. Cross-repo context: these findings block alkhttp review
|
||||
003's Unit 2 (data-channel wiring for WS sessions, both browser and
|
||||
native-fallback consumers); the decision work and the fixes live
|
||||
here. Findings continue alkcall review numbering (F-01..; reviews
|
||||
001–003 used P/C/R/A/B/C/D prefixes — F is the new prefix for this
|
||||
focused review, findings numbered independently).
|
||||
|
||||
## Baseline verification (this pass)
|
||||
|
||||
```
|
||||
cargo test → 565 passed, 0 failed
|
||||
cargo clippy --all-targets -- -D warnings → clean
|
||||
cargo fmt --check → clean
|
||||
```
|
||||
|
||||
The suite is green; these findings are design/spec mismatches, not
|
||||
regressions. Notably, one of them (F-04's serving gap) is invisible
|
||||
to the suite *because* the connect side has never been asked to
|
||||
serve — the existing e2e tests all put the dispatcher on the accept
|
||||
side.
|
||||
|
||||
## Verdict
|
||||
|
||||
- **The channel machinery is done and proven.** `ChannelCore` /
|
||||
`register_openable` / `ChannelOperations`, the opener ledger, the
|
||||
odd/even id split, the demux hardening, and the full e2e wiring
|
||||
test (`channels/client.rs:767-870`) are all in place and tested.
|
||||
Nothing in Part A below re-opens that machinery.
|
||||
- **The gap is the dispatch-resolution *mechanism* (F-01/F-02/F-03)
|
||||
and the connect-side serving half (F-04)** — both upstream of any
|
||||
transport (TCP+TLS, WS, whatever). Fixing them here unblocks
|
||||
alkhttp's data-channel wiring, and — since the crate is
|
||||
wasm-targetable and not yet published to consumers — the fixes are
|
||||
cheap now and required regardless of alkhttp.
|
||||
- **The recommended resolution is assembly, not invention:** per-
|
||||
session registry forks (F-02/F-03) + a bootstrap `op/register` op
|
||||
(F-01's mechanism choice) + a connect-side serving loop (F-04).
|
||||
Three of the four pieces have working reference shapes in-tree;
|
||||
the genuinely new protocol surface is one op handler and one read
|
||||
loop.
|
||||
|
||||
## Severity legend
|
||||
|
||||
- **[critical]** — a decided spec invariant is violated in a way that
|
||||
makes a promised capability unreachable end-to-end; or corrupts
|
||||
data.
|
||||
- **[major]** — a core protocol path cannot serve a decided behavior;
|
||||
works only via shapes the spec does not describe.
|
||||
- **[minor]** — drift, doc/spec inconsistency, or a missing
|
||||
convenience with no correctness impact.
|
||||
|
||||
---
|
||||
|
||||
# Part A — Dispatch resolution vs the ADR-047 §4 amendment
|
||||
|
||||
## F-01 [major] — Top-level dispatch never consults the connection overlay; ops registered per the ADR-047 §4 amendment mechanism resolve `NOT_FOUND` on the wire
|
||||
|
||||
**ADR drift:** ADR-047 "Amendment (§4 per-connection registration,
|
||||
2026-08-13)": *"open ops are registered per-connection …
|
||||
`register_openable` is called on the connection overlay registry
|
||||
(Layer 2 per ADR-019), closing over the per-connection `ChannelCore`."*
|
||||
**Verified:** YES, three ways:
|
||||
|
||||
1. `Dispatcher::dispatch` reads the operation's `op_type` from
|
||||
`self.registry.registration(...)` only
|
||||
(`src/protocol/dispatch.rs:316-320`) and invokes via
|
||||
`self.registry.invoke` / `invoke_streaming` / `resolve_sink_handler`
|
||||
(`:330-357`) — the dispatcher's **base registry** only. There is
|
||||
no fallback to `connection.overlay_env()`.
|
||||
2. The connection overlay is reachable solely as a layer of
|
||||
`context.env`: `compose_root_env` attaches it via
|
||||
`env.attach_peer(peer_id, connection.overlay_env())`
|
||||
(`dispatch.rs:195-215`). `context.env` is consulted for *nested*
|
||||
invocations (a handler calling `context.env.invoke(...)`) — not
|
||||
for resolving the incoming `call.requested` itself. An open op
|
||||
registered on the overlay resolves `NOT_FOUND` when the wire
|
||||
request arrives.
|
||||
3. The one place this wiring is proven end-to-end uses a different
|
||||
shape than the amendment describes: alkcall's own e2e test
|
||||
(`src/channels/client.rs:767-870`) has the `install_channel_zero`
|
||||
hook build a **fresh per-connection registry containing the open
|
||||
op and pass it as the dispatcher's base registry**
|
||||
(`Dispatcher::new(registry, ...)` at `:837`) — not as an overlay.
|
||||
That shape works; the amendment's shape cannot.
|
||||
|
||||
Consequence: ADR-047's data-plane promise (per-connection open ops
|
||||
invocable on channel 0) is unreachable through the mechanism the
|
||||
amendment names. Every consumer of `register_openable` must either
|
||||
adopt the undocumented per-session-base-registry shape or fail.
|
||||
|
||||
**Fix (Unit 1):** decide the mechanism deliberately (see F-02) and
|
||||
amend ADR-047 §4's wording to describe the mechanism that actually
|
||||
dispatches. The amendment's *rationale* (per-connection state, no
|
||||
`context.env` downcast) stands under either option.
|
||||
|
||||
## F-02 [major] — `OperationRegistry` has no fork/clone surface; the proven per-session-registry shape requires one
|
||||
|
||||
**Verified:** YES. `OperationRegistry`
|
||||
(`src/registry/registration.rs:97-100`) is
|
||||
`HashMap<String, HandlerRegistration>` + a validators map, with
|
||||
`new`/`register`/`registration`/`publish_validator`/`list_operations`
|
||||
(`:102-180`) — no `Clone` impl and no fork API. Every type inside a
|
||||
`HandlerRegistration` *is* clone-able (verified):
|
||||
|
||||
- `HandlerKind` — `#[derive(Clone)]` (`registration.rs:51`)
|
||||
- `OperationSpec` — `#[derive(Debug, Clone, PartialEq)]`
|
||||
(`spec.rs:174-175`)
|
||||
- `OperationProvenance` — `#[derive(Debug, Clone, Copy, PartialEq,
|
||||
Eq)]` (`registration.rs:58-59`)
|
||||
- `CompositionAuthority` — `#[derive(Debug, Clone)]`
|
||||
(`context.rs:82-83`)
|
||||
- `Capabilities` — manual `impl Clone` (`core/types.rs:75-80`)
|
||||
- `jsonschema::Validator` — `#[derive(Clone, Debug)]`
|
||||
(jsonschema 0.46.10 `validator.rs:294`)
|
||||
|
||||
So the fork is a small addition (derive `Clone` on
|
||||
`HandlerRegistration` + `OperationRegistry`, or an explicit
|
||||
`fork() -> OperationRegistry` that copies both maps). Two shape
|
||||
options:
|
||||
|
||||
- **(a) per-session base registry (recommended).** The
|
||||
`install_channel_zero` hook (and any future session-establishment
|
||||
seam) forks the deployment's base registry, registers the
|
||||
per-connection operations on the fork (generic channel ops, the
|
||||
openable ALPNs, and — with F-05 — the bootstrap op), and dispatches
|
||||
channel 0 over the fork. This is the proven shape; the ADR wording
|
||||
moves from "overlay registry" to "per-connection registry installed
|
||||
as the session's dispatch registry." Cost: `services/list` /
|
||||
`services/schema` registered on the base registry must be
|
||||
re-registered (or inherited via fork) per session to stay
|
||||
discoverable — the fork makes that free if the bootstrap ops are
|
||||
part of the fork source.
|
||||
- **(b) overlay-aware top-level dispatch.** Add an overlay fallback
|
||||
in `dispatch` / `run_loop_single_stream`: when the base registry
|
||||
misses, consult `connection.overlay_env()`. Keeps one static
|
||||
registry and makes the amendment's wording true as written, but
|
||||
touches the shared dispatch loop (higher blast radius, and the
|
||||
overlay's `invoke_with_policy` shape — namespace-scoped, parent-
|
||||
context-driven — does not match the top-level call shape, so the
|
||||
fallback needs care: op-type lookup, ACL, visibility).
|
||||
|
||||
Recommendation: (a). It is the only shape with an end-to-end proof,
|
||||
it needs no dispatch-loop change, and it matches ADR-019's layering
|
||||
(the overlay stays what it is today: the nested-invocation landing
|
||||
zone for imported ops — which F-05 needs).
|
||||
|
||||
**Fix (Unit 2):** `Clone`/fork surface + the ADR wording amendment;
|
||||
acceptance = the review-001-style e2e gate (open op resolves over a
|
||||
live channels connection through the *fork*).
|
||||
|
||||
## F-03 [minor] — Per-session fork + static base: no `Default`/builder seam exists to compose "base ops + per-session ops" without re-registration
|
||||
|
||||
**Verified:** YES. Even with a `Clone` surface (F-02), the hook must
|
||||
compose three sources into the per-session registry: the deployment's
|
||||
base ops (already registered, not retained anywhere as a "source"
|
||||
list), the generic channel ops (`ChannelOperations::register_on`),
|
||||
and the openables. A fork of the base registry makes the first source
|
||||
free — this finding exists only if (a) is chosen *without* the fork
|
||||
(i.e., rebuilding per-session registries from scratch). Record it as
|
||||
a constraint on the fork API: it must clone *registered handlers*
|
||||
(`HandlerKind` clones carry their closures), not just specs, so
|
||||
`services/list` keeps its closure over the base registry.
|
||||
`publish_validator`'s `validators` map must be carried too (schema
|
||||
enforcement would silently vanish otherwise).
|
||||
|
||||
**Fix:** fold into F-02's fork surface; the acceptance test covers it
|
||||
(`services/schema` on the fork still validates input).
|
||||
|
||||
---
|
||||
|
||||
# Part B — The connect side cannot serve; no wire path announces ops
|
||||
|
||||
## F-04 [major] — The connect side's channel-0 read pump resolves responses only; inbound `call.requested` frames are silently dropped — there is no serving half on channel 0
|
||||
|
||||
**Verified:** YES. `ChannelClient::from_connection`
|
||||
(`src/channels/client.rs:85-134`) spawns the read pump as
|
||||
`read_single_stream_until_closed(single_stream_reader, &pending_map)`
|
||||
(`connection.rs:681-686`), whose only job is resolving the
|
||||
`PendingRequestMap` — its frame handler (`dispatch_envelope`,
|
||||
`connection.rs:705-...`) branches on `call.responded` /
|
||||
`call.completed` / `call.aborted` / `call.error` and **has no arm for
|
||||
`call.requested`**. The accept side's mirror
|
||||
(`Dispatcher::run_loop_single_stream`, `dispatch.rs:727-850`) serves
|
||||
requests and has no pending-resolution arm. Each side silently drops
|
||||
the other half's frames.
|
||||
|
||||
Consequences:
|
||||
|
||||
- A consumer that dials a producer via `ChannelClient` can call ops
|
||||
and open channels, but if the producer later calls an op *the
|
||||
consumer serves*, the request is dropped (the caller's pending
|
||||
hangs until the sweeper or connection close). This is the exact
|
||||
shape ADR-022 §connection-direction-independence promises ("both
|
||||
sides can be both simultaneously" — AGENTS.md §8), and ADR-036's
|
||||
channel-0 pre-negotiation makes channel 0 the single control
|
||||
plane where it must work.
|
||||
- It blocks the bootstrap registration story (F-05): the hub cannot
|
||||
call anything the WS/native client serves, and the client cannot
|
||||
even receive the call that would let it register.
|
||||
|
||||
The fix is well-understood and symmetric: a full-duplex
|
||||
single-stream read loop that branches both ways — `call.requested`
|
||||
→ dispatch and write a response frame (the `run_loop_single_stream`
|
||||
arms), `call.responded`/`completed`/`aborted`/`published` → resolve
|
||||
pendings and route sink chunks (the read-pump arms). IDs are
|
||||
UUID-generated on each side (`generate_request_id()`), so
|
||||
cross-correlation is not a hazard; the abort arm needs to try both
|
||||
tables (in-flight sink aborts *and* the pending map's cascade), which
|
||||
`run_loop_single_stream`'s `EVENT_ABORTED` arm already does within
|
||||
its own scope. The wasm-targetable browser story compounds the
|
||||
value: the same serving loop is what a wasm alkcall browser
|
||||
compiles.
|
||||
|
||||
**Fix (Unit 3):** a serving-capable single-stream loop usable from
|
||||
the connect side (e.g., a `Dispatcher` variant or a
|
||||
`CallConnection::serve_single_stream` that composes both frame
|
||||
directions), wired into `ChannelClient` behind an opt-in (a serving
|
||||
registry is not always present — a pure consumer may have none).
|
||||
Acceptance gates: hub→consumer call over an existing `ChannelClient`
|
||||
session resolves; consumer-served op participates in ACL (its
|
||||
`AccessControl` gates the hub's call); disconnect mid-call still
|
||||
fails pendings on both sides.
|
||||
|
||||
## F-05 [major] — No wire mechanism announces client-side ops; ADR-022's import flow is hub→consumer only and the browser-native path has none
|
||||
|
||||
**Verified:** YES. The envelope kind set is closed at six —
|
||||
`call.requested` / `call.responded` / `call.completed` /
|
||||
`call.aborted` / `call.error` / `call.published`
|
||||
(`src/protocol/wire.rs:12-17`). Op registration onto an overlay
|
||||
(`register_imported` / `register_imported_all`,
|
||||
`connection.rs:162-172`) happens only in-process: `from_call`
|
||||
(`client/from_call.rs:82-91`) builds bundles the *local* process
|
||||
registers after *it* dialed out — the consumer importing a hub's
|
||||
ops. There is no op, frame, or handshake by which a connected peer
|
||||
announces "here are the ops I serve." Every consumer-facing
|
||||
registration surface today requires the registrant to hold the
|
||||
local `CallConnection` handle in-process.
|
||||
|
||||
Consequence: the decided bidirectionality model — either side
|
||||
registers ops the other can call (AGENTS.md §8; ADR-022 §direction
|
||||
semantics; alkhttp ADR-048's connection-local overlay for
|
||||
browser-registered ops) — has no on-the-wire expression for the
|
||||
non-in-process side. It is exactly the assumed-bootstrap-op set the
|
||||
protocol already leans on (`services/list` is dialed and called by
|
||||
`from_call` on every import; each side is *expected* to serve it)
|
||||
extended by one op: e.g. `op/register` carrying the
|
||||
`HandlerRegistration`'s serializable parts (spec + provenance +
|
||||
access control), whose hub-side per-session handler writes the
|
||||
registration into that connection's overlay via
|
||||
`register_imported`. The overlay is already the landing zone
|
||||
(`compose_root_env` attaches it keyed by identity,
|
||||
`dispatch.rs:211-213`), and discovery of what a peer registered is
|
||||
already built: `services/list-peers` reads
|
||||
`ctx.env.peer_ids()` / `peer_operations()`
|
||||
(`registry/discovery.rs:260-307`) — the same overlay-populated env.
|
||||
|
||||
Scope decision this forces (record it in the ADR, either way):
|
||||
|
||||
- **Design it** (recommended): `op/register` (+ a deregister or
|
||||
replace semantics for the reconnect path) as an assumed op in the
|
||||
bootstrap set, served per-session; the wire stays six kinds. The
|
||||
`HandlerRegistration`'s `Handler` closures cannot cross the wire —
|
||||
the client sends spec + a call-forwarding contract, and the
|
||||
hub-side handler wraps it as a forwarding handler that issues a
|
||||
nested `call.requested` back over channel 0 (the same shape
|
||||
`from_call`'s imported bundles use — `protocol/adapter.rs:541,610`
|
||||
show the in-process twin).
|
||||
- **Or scope-cut**: amend ADR-022/alkhttp ADR-048 to say peer-side
|
||||
op registration is hub-initiated-import only until a contract
|
||||
exists. Cheaper, but it re-opens the same hedge the deferral was
|
||||
criticized for — the promise would remain written-but-unmechanized.
|
||||
|
||||
Recommendation: design it. The pieces are all present; the cost is
|
||||
one op handler, an `AccessControl`-gated registration surface (the
|
||||
registry's existing ACL path covers it — an op with restrictive
|
||||
`AccessControl` cannot be overwritten by an unprivileged peer), and
|
||||
the F-04 serving loop to make it reachable in both directions.
|
||||
|
||||
**Fix (Unit 3):** with F-04 — bootstrap op + client serving loop +
|
||||
ADR-022 amendment naming the bootstrap op set (assumed ops each side
|
||||
may serve: `services/list`, `services/schema`, and `op/register`).
|
||||
|
||||
## F-06 [minor] — `services/list` on a per-session fork needs a closed-over fork reference, or per-session ops are undiscoverable
|
||||
|
||||
**Verified:** YES. `services_list_handler` closes over a *specific*
|
||||
`Arc<OperationRegistry>` (`registry/discovery.rs:233-258`) — it
|
||||
lists that registry's ops. Under the F-02(a) shape, per-session
|
||||
openables live on the fork, so a `services/list` handler closed over
|
||||
the base registry cannot see them. Two clean outs, both cheap: (i)
|
||||
fork *before* registering the bootstrap discovery ops (then the
|
||||
handler closes over the fork), or (ii) the fork API registers
|
||||
base-registry bootstrap ops fresh on the fork. Either way the
|
||||
constraint is: discovery ops must be registered against the
|
||||
per-session registry, not the static one. Also note `services/list`
|
||||
on the fork still ACL-filters (the handler re-checks per-caller) —
|
||||
no privilege regression.
|
||||
|
||||
**Fix:** fold into Unit 2 (the fork seam registers bootstrap ops on
|
||||
the fork); acceptance: a per-session openable appears in
|
||||
`services/list` for an authorized caller and not for an unauthorized
|
||||
one.
|
||||
|
||||
---
|
||||
|
||||
# Non-findings (verified correct, recorded to bound the re-review)
|
||||
|
||||
- **The e2e reference shape is real and passes** —
|
||||
`channels/client.rs:767-870` wires `ChannelClient` ↔
|
||||
`ChannelsAdapter` over duplex with an open op registered on the
|
||||
per-connection registry, invoked end-to-end, quota reserved. The
|
||||
F-02(a) shape is not speculative; it is in-tree.
|
||||
- **`register_openable`'s wrapper machinery is complete** — ACL via
|
||||
the registry's normal invoke path, `check_open` → `open_channel` →
|
||||
ledger → spawn → teardown decrement, with `Once`/`Stream`/`Sink`
|
||||
arms (`channels/operations.rs:399-453, 483-555`). Nothing in F-01
|
||||
re-opens it; the ops it registers just need a dispatch-reachable
|
||||
home.
|
||||
- **Channel-id allocation is collision-safe** for the bidirectional
|
||||
open story (accept side even from 2, connect side odd from 1,
|
||||
`manager.rs:105-125`; `adopt_channel` for the non-allocating side,
|
||||
`manager.rs:273-309`).
|
||||
- **The `call.*` envelope kind set staying closed is correct** —
|
||||
AGENTS.md §7 allows *adding* kinds but F-05's resolution does not
|
||||
need one; bootstrap ops over channel 0 are the lighter door.
|
||||
- **Wasm-cleanliness is unaffected** — the recommended fixes (fork +
|
||||
handler + read-loop branch) touch no transport, no fs/net tokio
|
||||
features; `cargo check --target wasm32-unknown-unknown` remains
|
||||
the release gate.
|
||||
|
||||
---
|
||||
|
||||
# Remediation plan
|
||||
|
||||
Sequenced by dependency. Units 1–3 are alkcall work; the alkhttp
|
||||
consequences are tracked as alkhttp review 003 Unit 1 (updated to
|
||||
point here).
|
||||
|
||||
## Unit 1 — Decision recording (F-01, F-02 direction)
|
||||
|
||||
ADR work only:
|
||||
|
||||
- ADR-047 §4 amendment #2: the open-op registration mechanism is the
|
||||
**per-connection registry installed as the session's dispatch
|
||||
registry** (option (a)); the overlay registry remains the landing
|
||||
zone for peer-announced ops (F-05) and nested invocation.
|
||||
- ADR-022 amendment: the bootstrap-op set (each side may serve
|
||||
`services/list`, `services/schema`, `op/register`), and the
|
||||
direction-semantics note that serving on the connect side is
|
||||
opt-in (F-04).
|
||||
- alkhttp ADR-048's bidirectionality promise is re-pointed at these
|
||||
ADRs instead of standing as an unmechanized commitment.
|
||||
|
||||
Gate: ADRs recorded; alkhttp OQ-05 and ADR-048 cross-reference them.
|
||||
|
||||
## Unit 2 — Fork surface + per-session composition (F-02, F-03, F-06)
|
||||
|
||||
- `Clone` (or explicit fork) on `HandlerRegistration` +
|
||||
`OperationRegistry`, carrying handlers and validators.
|
||||
- A composition helper (e.g. `OperationRegistry::fork_with(...)`) or
|
||||
documented pattern: fork base → register generic channel ops +
|
||||
openables + bootstrap ops against the fork.
|
||||
- Gate: e2e over a live channels connection — open op resolves
|
||||
through the fork; `services/list` shows per-session openables
|
||||
(ACL-filtered); `services/schema` on the fork still validates.
|
||||
|
||||
## Unit 3 — Client serving half + bootstrap registration (F-04, F-05)
|
||||
|
||||
- Serving-capable single-stream loop (compose the read-pump and
|
||||
dispatch arms); wired into `ChannelClient` opt-in.
|
||||
- `op/register` bootstrap op (per-session handler →
|
||||
`register_imported` into the connection overlay), with
|
||||
`AccessControl` gating and reconnect/replace semantics decided in
|
||||
the ADR.
|
||||
- Gates: hub→consumer call over an existing session resolves;
|
||||
peer-registered op is discoverable via `services/list-peers` and
|
||||
callable back over channel 0; unregistered/unauthorized
|
||||
registration attempts fail loudly; disconnect drops the overlay
|
||||
and fails both sides' pendings.
|
||||
|
||||
## Unit 4 — alkhttp wiring (downstream, after Units 1–3)
|
||||
|
||||
Tracked in alkhttp review 003 (Units 2–4 there): openables surface,
|
||||
`install_channel_zero` rework over the fork, retained session
|
||||
handle, e2e tests, spec reconciliation (OQ-05, ADR-067/048 v1-cut
|
||||
notes).
|
||||
|
||||
---
|
||||
|
||||
## Verification log (this pass)
|
||||
|
||||
- All claims verified at tree `c16b069`; the F-01 dispatch claim was
|
||||
checked against both dispatch paths (`run_loop` stream-per-request
|
||||
reads `handle_stream` → same base-registry-only resolution;
|
||||
`run_loop_single_stream` at `dispatch.rs:727-850`).
|
||||
- The F-04 claim was verified by reading both loops' frame arms:
|
||||
`read_single_stream_until_closed` → `dispatch_envelope`
|
||||
(`connection.rs:681-705`, no `EVENT_REQUESTED` arm) vs
|
||||
`run_loop_single_stream` (`dispatch.rs:754-850`, no
|
||||
pending-resolution arm).
|
||||
- The F-02 clone claims were verified type-by-type, including the
|
||||
third-party `jsonschema::Validator` (0.46.10) and the vendored
|
||||
`Capabilities` (manual `Clone` preserving `Secret`'s clone).
|
||||
- The F-05 wire-closure claim was verified against the full kind set
|
||||
(`wire.rs:12-17`) and grep over `src/` for any wire-side
|
||||
registration path (none; `register_imported*` callers are
|
||||
in-process only).
|
||||
- `services/list-peers`' overlay-backed discovery verified at
|
||||
`registry/discovery.rs:260-307` (reads `ctx.env.peer_ids()` /
|
||||
`peer_operations()`, populated by `compose_root_env` at
|
||||
`dispatch.rs:211-213`).
|
||||
- Baseline gates re-run for this pass: cargo test (565), clippy
|
||||
(all-targets), fmt — all clean. No source changes.
|
||||
|
||||
## Remediation log (2026-09-03)
|
||||
|
||||
Units 1–3 landed in one pass. Every claim above was re-verified
|
||||
against the source before remediation began; all six findings
|
||||
confirmed (F-02's member-type list was missing `ScopedPeerEnv`
|
||||
(`context.rs:53`) — also Clone, which made the fork surface simpler
|
||||
than the review estimated).
|
||||
|
||||
**Unit 1 — decision recording (F-01/F-02 direction, F-04/F-05 scope):**
|
||||
|
||||
- ADR-047 §4 amendment #2 (2026-09-03): the per-connection registration
|
||||
mechanism is the **per-session fork of the base registry installed as
|
||||
the session's dispatch registry** (option (a)); the overlay registry
|
||||
stays the nested-invocation / peer-announced-ops landing zone.
|
||||
- ADR-022 amendment (2026-09-03): the bootstrap-op set (`services/list`,
|
||||
`services/schema`, `op/register`), the opt-in connect-side serving
|
||||
loop, and the `op/register` wire shape (spec in `services/schema`
|
||||
JSON + `replace` flag; forwarding-handler wrap into the connection
|
||||
overlay; `ALREADY_EXISTS` collision gate).
|
||||
- alkhttp ADR-048's reconciliation note and OQ-05 re-pointed at these
|
||||
ADRs (Unit 1b).
|
||||
|
||||
**Unit 2 — fork surface + per-session composition (F-02, F-03, F-06):**
|
||||
|
||||
- `OperationRegistry` gained interior mutability (`parking_lot::RwLock`
|
||||
around the operations and validators maps) so a registry can live
|
||||
behind an `Arc` and be self-referential. `register` now takes
|
||||
`&self`; `registration`/`list_operations` return owned clones
|
||||
(handlers are `Arc` closures — the clone is cheap).
|
||||
- `OperationRegistry::fork()` — deep copy of registrations (handlers,
|
||||
provenance, composition authority, capabilities, `scoped_env`) and
|
||||
the cached publish-schema validators. `OperationRegistryBuilder::
|
||||
from_registry` seeds the builder path from an existing registry.
|
||||
- `install_bootstrap_discovery(&Arc<OperationRegistry>)` — registers
|
||||
`services/list` / `services/list-peers` / `services/schema` closed
|
||||
over the fork itself, so per-session openables are discoverable
|
||||
(F-06) and `services/schema` answers from the fork.
|
||||
- Gate: `fork_registry_open_op_resolves_and_is_discoverable`
|
||||
(`src/channels/client.rs`) — open op registered on the fork resolves
|
||||
over a live channels connection; the openable appears in
|
||||
`services/list` on the fork; `services/schema` on the fork still
|
||||
answers. Unit tests: fork independence both directions, validator
|
||||
carry, post-dispatch registration through a shared `Arc`.
|
||||
|
||||
**Unit 3 — client serving half + bootstrap registration (F-04, F-05):**
|
||||
|
||||
- `Dispatcher::serve_single_stream` — the full-duplex single-stream
|
||||
loop: `call.requested` → dispatch + response frames (the
|
||||
`run_loop_single_stream` arms); `call.responded`/`completed`/`error`
|
||||
→ pending resolution (the read-pump arms); `call.aborted` → both
|
||||
tables (in-flight sink aborts and the pending map's cascade);
|
||||
`call.published` → inbound in-flight sinks. Disconnect fails
|
||||
pendings and drops in-flight sinks (teardown identical to the two
|
||||
half-loops it composes).
|
||||
- `ChannelClient::from_connection_with_serving(connection,
|
||||
Option<ServingConfig>)` — opt-in serving; `from_connection`
|
||||
preserves the pure-consumer default (resolution-only read pump).
|
||||
Gate: `serving_loop_hub_to_consumer_call_resolves` — hub→consumer
|
||||
call resolves through the consumer's serving loop, consumer→hub
|
||||
still resolves in the same loop.
|
||||
- `registry::op_register` — `OpRegisterRequest` wire DTO,
|
||||
`op_register_spec`, `op_register_handler` (rebuild → forwarding stub
|
||||
→ `register_imported` into the connection overlay, forced
|
||||
`Internal`/`FromCall`), `announce_op`. `CallError::already_exists`
|
||||
added for the collision gate. `spec_to_json_pub` made the
|
||||
`services/schema` wire shape public; `rebuild_spec_for` and the
|
||||
forwarding-handler constructors are crate-shared with `from_call`.
|
||||
Gates: `op_register_announce_then_hub_call_routes_back_to_consumer`
|
||||
(announce → overlay → hub call → forwarding stub → consumer serves)
|
||||
plus unit tests (round-trip, collision, replace, forced
|
||||
visibility/provenance).
|
||||
|
||||
**Baseline after remediation:** cargo test 581 passed / 0 failed;
|
||||
clippy (all-targets, `-D warnings`) clean; `cargo fmt --check` clean;
|
||||
wasm target gate re-run below. No wire-format changes: the envelope
|
||||
kind set stays closed at six; the bootstrap ops ride channel 0's call
|
||||
registry.
|
||||
@@ -0,0 +1,704 @@
|
||||
# Review 005 — Serving-Loop Concurrency and `op/register` Composition (post-remediation review of `f84d214`)
|
||||
|
||||
## Status
|
||||
|
||||
Units 1–3 remediated and verified (G-01..G-05). All findings closed;
|
||||
the review is resolved. See Remediation log.
|
||||
|
||||
## Scope
|
||||
|
||||
Post-remediation review of commit `f84d214` (review 004 Units 1–3:
|
||||
per-session fork registry, connect-side serving loop, `op/register`).
|
||||
The remediation landed all six F-findings' mechanisms; this pass
|
||||
reviews the landed implementation against the decided spec (ADR-022
|
||||
amendment 2026-09-03, ADR-047 §4 amendment #2, review 004's
|
||||
acceptance gates) rather than re-litigating the findings.
|
||||
|
||||
Every finding below was verified directly in source at tree
|
||||
`f84d214`. The headline finding (G-01) was verified **empirically**:
|
||||
a probe test was added to the tree, run, observed to reproduce the
|
||||
defect, and removed — the tree at this review's baseline carries no
|
||||
source changes. Findings continue alkcall review numbering with the
|
||||
new prefix `G` (001–004 used P/C/R/A/B/C/D/F — each review numbers
|
||||
independently).
|
||||
|
||||
Cross-repo context: the alkhttp re-pointing claimed by the remediation
|
||||
log was verified in that repo (`5b62307` — `open-questions.md:102-112`,
|
||||
`decisions/048…md:19-25`). Unit 4 (alkhttp wiring) remains downstream
|
||||
and is not reviewed here.
|
||||
|
||||
## Baseline verification (this pass)
|
||||
|
||||
```
|
||||
cargo test → 581 passed, 0 failed
|
||||
cargo test --all-features → 598 passed, 0 failed
|
||||
cargo clippy --all-targets -- -D warnings → clean
|
||||
cargo clippy --all-features --all-targets -- -D warnings → clean
|
||||
cargo fmt --check → clean
|
||||
cargo check --target wasm32-unknown-unknown → clean
|
||||
cargo clippy --target wasm32-unknown-unknown -- -D warnings → clean
|
||||
cargo doc --no-deps → clean
|
||||
```
|
||||
|
||||
All gates the remediation log claims reproduce. The suite is green —
|
||||
and again the green is partial: the gates test the flows they name,
|
||||
and the one flow the ADR amendment promises most loudly (nested
|
||||
composition of peer-announced ops) is the one no gate exercises. See
|
||||
G-02 for why the existing gate cannot catch G-01.
|
||||
|
||||
## Verdict
|
||||
|
||||
- **Unit 2's fork surface is solid.** Interior mutability, lock
|
||||
discipline, fork independence, validator carry, and the
|
||||
self-referential bootstrap-discovery closure are all correct and
|
||||
tested (see Non-findings).
|
||||
- **Unit 3's serving loop resolves the F-04 frame gap but inherits the
|
||||
dispatch loop's serial-inline shape** — and the F-05 flow it exists
|
||||
to enable is exactly the shape that deadlocks under it. The ADR-022
|
||||
amendment's "invocable via nested composition" promise is
|
||||
unmechanized for wire-dispatched handlers (the primary caller
|
||||
shape); a 30s sweeper timeout masquerades as resolution.
|
||||
- **The F-05 acceptance gate does not exercise the forwarding stub** —
|
||||
the one component that is genuinely new protocol surface. It
|
||||
verifies announce-lands-in-overlay and the consumer serves, by
|
||||
calling the announced op *directly*, bypassing the stub entirely.
|
||||
- **`op/register`'s collision gate is overlay-only**, so a
|
||||
peer-announced op can shadow the serving side's own ops in nested
|
||||
composition (connections resolve before base in `PeerCompositeEnv`).
|
||||
|
||||
The architecture decisions (fork as dispatch registry; overlay as
|
||||
landing zone; bootstrap-op set; opt-in serving) remain sound. The
|
||||
defects are in the serving loop's concurrency model and in what the
|
||||
gates measure.
|
||||
|
||||
## Severity legend
|
||||
|
||||
Same scale as review 004:
|
||||
|
||||
- **[critical]** — a decided spec invariant is violated in a way that
|
||||
makes a promised capability unreachable end-to-end; or corrupts
|
||||
data.
|
||||
- **[major]** — a core protocol path cannot serve a decided behavior;
|
||||
works only via shapes the spec does not describe.
|
||||
- **[minor]** — drift, doc/spec inconsistency, or a missing
|
||||
convenience with no correctness impact.
|
||||
|
||||
---
|
||||
|
||||
# Part A — The serving loop's concurrency model
|
||||
|
||||
## G-01 [major] — Same-connection nested composition deadlocks the serving loop; the forwarding stub's nested call resolves only via the 30s sweeper
|
||||
|
||||
**Status: REMEDIATED (Unit 1)** — see Remediation log; both gates
|
||||
added and verified load-bearing against the pre-fix loop.
|
||||
|
||||
**ADR drift:** ADR-022 amendment (2026-09-03) §`op/register`: the
|
||||
announced op is *"invocable via nested composition (`env.invoke`)"*;
|
||||
the amendment's whole point is that the hub's handlers compose
|
||||
peer-announced ops. Review 004 F-05's recommendation said the same:
|
||||
the hub-side handler wraps announcements *"as a forwarding handler
|
||||
that issues a nested `call.requested` back over channel 0"*.
|
||||
|
||||
**Verified:** YES, by code trace and by an empirical probe (see
|
||||
Verification log).
|
||||
|
||||
Mechanism, in four steps:
|
||||
|
||||
1. `Dispatcher::serve_single_stream` awaits `dispatch()` **inline** in
|
||||
its read loop for `call.requested` frames
|
||||
(`src/protocol/dispatch.rs:1009-1014`) — the same serial-inline
|
||||
shape as the pre-existing accept-side `run_loop_single_stream`
|
||||
(`:765-770`). Only the Sink arm is spawned
|
||||
(`:1031-1053`); Once responses and Sub pumps
|
||||
(`pump_stream_single_stream`, awaited inline at `:1027-1029`)
|
||||
hold the loop.
|
||||
2. The F-05 forwarding stub (`make_forwarding_handler`,
|
||||
`src/client/from_call.rs:392-415`) issues its nested call via
|
||||
`connection.call_with_payload` (`:408`) — over **the same
|
||||
connection** whose loop dispatched the parent. In single-stream
|
||||
mode the pending resolves only when *some* loop reads the
|
||||
`call.responded` frame (`connection.rs:274-303` — register
|
||||
pending, write frame, await receiver; resolution is the reader's
|
||||
job).
|
||||
3. The only reader of that connection is the serving loop itself —
|
||||
which is blocked in step 1 awaiting the parent dispatch, whose
|
||||
handler is blocked in step 2 awaiting the nested call. Circular
|
||||
wait. The transport buffers the response frame; nobody reads it.
|
||||
4. The 30s sweeper (`DEFAULT_CALL_TIMEOUT`, `connection.rs:32`;
|
||||
`evict_expired`, run every 10s per `SWEEPER_INTERVAL`,
|
||||
`dispatch.rs:44`) is the only thing that breaks the cycle —
|
||||
the nested call resolves as `CallError::timeout`.
|
||||
|
||||
**Empirical probe (this pass):** hub serves `op/register` + a
|
||||
`hub/compose` handler that invokes the connection overlay's
|
||||
registration (the forwarding stub — the exact resolution
|
||||
`PeerCompositeEnv` → `OverlayOperationEnv` produces for nested
|
||||
composition) from inside a wire-dispatched handler; consumer announces
|
||||
`consumer/exec`, then calls `hub/compose` over the wire. Result:
|
||||
|
||||
```
|
||||
PROBE: hub/compose resolved after 30.000761619s:
|
||||
Err(CallError { code: "TIMEOUT", message: "request timed out", retryable: true })
|
||||
```
|
||||
|
||||
A second run with an 8s timeout confirmed the hang is unconditional
|
||||
(not load-dependent): zero progress until the sweeper. The probe was
|
||||
removed after evidence capture; the tree is unchanged.
|
||||
|
||||
**Consequences (verified):**
|
||||
|
||||
- The ADR-022 amendment's flagship flow — a hub handler composing a
|
||||
peer-announced op — fails for every wire-dispatched parent handler.
|
||||
It "works" only for parents dispatched from *in-process* contexts
|
||||
(where the serving loop is idle and free to resolve the nested
|
||||
call's response) — a shape the ADR does not describe. Per the
|
||||
severity legend this is a decided behavior served only via an
|
||||
undescribed shape; it borders [critical] for the composition flow
|
||||
specifically.
|
||||
- The same circular wait applies to composition of `from_call`
|
||||
*imports* on the accept side: `run_loop_single_stream` has the same
|
||||
inline shape, and the connection overlay's imported-op stubs ride
|
||||
the same connection. That hazard is **latent pre-`f84d214`** (the
|
||||
accept side had the inline loop and `register_imported` before this
|
||||
commit) — `f84d214` makes it load-bearing by (a) adding the wire
|
||||
path that populates overlays with stubs, (b) putting serving loops
|
||||
on the connect side where both directions are live by design, and
|
||||
(c) writing the composition promise into ADR-022.
|
||||
- The same starvation is not limited to the stub: while the loop
|
||||
serves *any* Sub (`pump_stream_single_stream` inline), the side's
|
||||
own outbound pendings queue unresolved. A serving-enabled consumer
|
||||
that calls out while serving a long-lived Sub to the same peer eats
|
||||
30s timeouts on its own calls. The F-04 gate tests the two
|
||||
directions only **sequentially** (hub→consumer resolves, *then*
|
||||
consumer→hub) — never interleaved.
|
||||
- Head-of-line blocking: the peer's subsequent requests queue behind
|
||||
each inline dispatch.
|
||||
|
||||
**Fix (Unit 1):** spawn the Once dispatch and the Sub pump the way the
|
||||
Sink arm already is (the `SharedFrameWriter` already serializes
|
||||
concurrent frame writes, so per-request frame ordering is preserved);
|
||||
track spawned task handles for teardown on loop exit (the Sink arm's
|
||||
`in_flight_sinks` pattern, generalized). Keep read-loop resolution
|
||||
unblocked at all times. Apply the same rework to
|
||||
`run_loop_single_stream` (or factor the shared loop) — the accept side
|
||||
has the identical latent hazard via imported-op composition.
|
||||
Acceptance: the probe above (recreated as a permanent gate) resolves
|
||||
in < 1s; a concurrent-interleaving gate (outbound call resolving while
|
||||
a Sub is being served) passes without sweeper intervention.
|
||||
|
||||
## G-02 [major] — The F-05 acceptance gate bypasses the forwarding stub it claims to prove
|
||||
|
||||
**Status: REMEDIATED (Unit 1)** — the stub-exercising gate and the
|
||||
interleaved-directions gate are added; see Remediation log.
|
||||
|
||||
**Verified:** YES. `op_register_announce_then_hub_call_routes_back_to_consumer`
|
||||
(`src/channels/client.rs:1412-1543`) asserts the announce resolves and
|
||||
the overlay holds the op — then calls
|
||||
`accept_conn.call("consumer/exec", …)` (`:1529`). That is a **direct
|
||||
wire call**: the hub's dispatcher resolves `consumer/exec`… which is
|
||||
not on the hub at all — the frame crosses to the consumer, whose
|
||||
serving loop dispatches it from its *local* registry. The forwarding
|
||||
stub (`op_register_handler`'s `register_imported` bundle) is never
|
||||
invoked. The gate's comment — *"the forwarding stub routes the nested
|
||||
call back over channel 0"* — describes a path the test does not take.
|
||||
|
||||
**Consequence:** the one behavior that is genuinely new protocol
|
||||
surface (stub routing) is untested, and the one defect it hides (G-01)
|
||||
is precisely in that path. This is the same failure class review 001
|
||||
flagged ("all coverage is unit-level / the integration seams are where
|
||||
the real problems live") — the remediation added e2e gates, but the
|
||||
F-05 gate measures an adjacent, easier path. The F-04 gate is
|
||||
likewise sequential-only (see G-01's third consequence).
|
||||
|
||||
**Fix (fold into Unit 1):** add a gate that exercises the stub —
|
||||
announce → a wire-dispatched hub handler composes the announced op via
|
||||
`ctx.env` (or directly via the overlay registration) → resolves from
|
||||
the consumer. This is the removed probe, productized; it is the
|
||||
acceptance test G-01's fix must pass. Optionally extend the F-04 gate
|
||||
with an interleaved-directions phase.
|
||||
|
||||
---
|
||||
|
||||
# Part B — The `op/register` composition surface
|
||||
|
||||
## G-03 [major] — Announced ops can shadow the serving side's own ops in nested composition; the collision gate checks the overlay only
|
||||
|
||||
**Status: REMEDIATED (Unit 2)** — see Remediation log; the ADR-022
|
||||
amendment records the collision policy.
|
||||
|
||||
**ADR drift:** ADR-018 (composition authority) and ADR-019 (the
|
||||
overlay is the *landing zone* for imported ops — a layer beneath the
|
||||
deployment's own registry, not a rival to it); ADR-022 amendment
|
||||
(silent on name collisions with the serving side's base registry).
|
||||
|
||||
**Verified:** YES. `op_register_handler`'s collision gate is
|
||||
`connection.overlay_contains(&request.spec.name)`
|
||||
(`src/registry/op_register.rs:142`) — it consults **only the
|
||||
connection overlay**. The serving registry (the fork the session
|
||||
dispatches over) is not consulted. Meanwhile `PeerCompositeEnv`
|
||||
resolves **connections before base** (`src/registry/env.rs:230-245` —
|
||||
session, then `connection_order`, then base), and `compose_root_env`
|
||||
attaches the dispatching connection's overlay
|
||||
(`dispatch.rs:207-214`).
|
||||
|
||||
Consequence: a peer announces an op named `fs/readFile` (or any name
|
||||
the serving side has registered — `services/list` itself is fair
|
||||
game). The registration lands in the overlay (forced `Internal`,
|
||||
`FromCall` — both per spec). From then on, every handler
|
||||
**wire-dispatched from that peer's connection** that nested-composes
|
||||
that name via `ctx.env` resolves to the peer's forwarding stub instead
|
||||
of the serving side's own op. The forced `Visibility::Internal` does
|
||||
not help here — it blocks wire invocation, not `env.invoke`
|
||||
(`OverlayOperationEnv` gates on `AccessControl` only,
|
||||
`connection.rs:749-836`; the composed child context is `internal:
|
||||
true` by design).
|
||||
|
||||
Concretely: a hub handler processing peer A's request composes
|
||||
`fs/readFile` expecting *its own* registry op (with the deployment's
|
||||
`scoped_env`, capabilities, and ACL); it silently gets peer A's stub —
|
||||
peer A chooses where the call goes and what it returns. Composition
|
||||
authority (ADR-018) is decided by the composing handler's *deployer*;
|
||||
a same-name peer registration silently rewrites it. The effect is
|
||||
scoped to handlers whose root context is that connection's
|
||||
(`compose_root_env` attaches that connection's overlay), which bounds
|
||||
the blast radius — but that is exactly the flagship F-05 flow.
|
||||
|
||||
**Fix (Unit 2):** decide the collision policy and record it in the
|
||||
ADR-022 amendment. Recommended: `op_register_handler` also rejects
|
||||
names registered on the serving registry (pass the fork — or a
|
||||
name-set closure — into the handler alongside the connection, the
|
||||
same way `install_bootstrap_discovery` closes over its registry);
|
||||
`ALREADY_EXISTS` for base collisions regardless of `replace` (the
|
||||
reconnect path needs to re-announce *peer* ops, not to overwrite the
|
||||
deployment's). The alternative — changing `PeerCompositeEnv` to
|
||||
resolve base before connections — would break `from_call`'s
|
||||
namespace-prefixed imports (prefixed names don't collide) is not
|
||||
needed for that, but would change long-decided layering semantics; the
|
||||
registration-side gate is the cheaper, narrower door.
|
||||
|
||||
## G-04 [minor] — `resource_id_path` does not survive the spec wire round-trip
|
||||
|
||||
**Status: REMEDIATED (Unit 3)** — see Remediation log.
|
||||
|
||||
**Verified:** YES. `spec_to_json_pub` serializes
|
||||
name/namespace/op_type/visibility/schemas/error_schemas/access_control
|
||||
(+ `channel_open`/`publish_schema` markers;
|
||||
`src/registry/discovery.rs:211-234`) — `resource_id_path` is not
|
||||
serialized. `rebuild_spec_for` cannot parse it
|
||||
(`src/client/from_call.rs:209-286`), and the field exists on
|
||||
`OperationSpec` (`src/registry/spec.rs:190`). An announced (or
|
||||
`from_call`-imported) op that declares ownership-scoped resource
|
||||
extraction silently loses it; the rebuilt spec's ACL checks run with
|
||||
`resource_id: None`.
|
||||
|
||||
This is a pre-existing gap in the `from_call` import shape, not
|
||||
introduced by `f84d214` — but `op/register` makes it load-bearing for
|
||||
a second wire surface (peer-announced specs) that explicitly promises
|
||||
"the serializable parts" of a registration. Note `namespace` *is*
|
||||
serialized but `rebuild_spec_for` ignores it in favor of the
|
||||
`namespace_prefix` parameter — consistent for `op/register`
|
||||
(`replace`/re-announce uses `None`), worth a comment either way.
|
||||
|
||||
**Fix (Unit 3):** add `resource_id_path` to both halves of the
|
||||
round-trip plus a round-trip test. Additive optional field in the
|
||||
`services/schema` JSON shape — no existing consumer breaks.
|
||||
|
||||
## G-05 [minor] — `install_bootstrap_discovery` registers `services/list-peers`; the ADR-022 amendment's bootstrap set doesn't name it
|
||||
|
||||
**Status: REMEDIATED (Unit 3)** — see Remediation log.
|
||||
|
||||
**Verified:** YES. `install_bootstrap_discovery` registers
|
||||
`services/list`, `services/list-peers`, and `services/schema`
|
||||
(`src/registry/discovery.rs:290-313`; `list-peers` at `:300`). The
|
||||
ADR-022 amendment's bootstrap set lists exactly three ops —
|
||||
`services/list`, `services/schema`, `op/register`
|
||||
(`022-…contract.md:380-393`). Review 004's remediation log says
|
||||
"list-peers" too; the ADR text was never updated.
|
||||
|
||||
Consequence: none at runtime (installing a fourth op is strictly
|
||||
additive, and `services/list-peers` is what makes F-05's
|
||||
"peer-announced ops are discoverable" true). It is a
|
||||
doc/spec-consistency drift on a set the ADR declares *"closed."*
|
||||
|
||||
**Fix (Unit 3):** add `services/list-peers` to the ADR-022
|
||||
amendment's bootstrap-op list (recommended — `from_call`-import
|
||||
discovery already relies on it) or stop installing it; one line
|
||||
either way.
|
||||
|
||||
---
|
||||
|
||||
# Non-findings (verified correct, recorded to bound the re-review)
|
||||
|
||||
- **The fork surface (F-02/F-03) is solid.** Lock discipline is
|
||||
correct: `registration()` clones under a read guard and drops it
|
||||
before handler invocation (`registration.rs:190-192`), so
|
||||
self-referential discovery handlers cannot deadlock their own
|
||||
registry; `register` takes the two maps' write locks sequentially
|
||||
and never across an `await`. `fork()` deep-copies both maps
|
||||
(handlers are `Arc` closures — cheap, and the F-03 constraint that
|
||||
closures must carry is met); validators carry; fork independence is
|
||||
tested both directions; `OperationRegistryBuilder::from_registry`
|
||||
seeds the builder path.
|
||||
- **`install_bootstrap_discovery` (F-06) is correct** — handlers
|
||||
closed over the same `Arc` they're registered on, so post-install
|
||||
per-session registrations are visible at call time; ACL filtering
|
||||
stays per-caller; the fork gate
|
||||
(`fork_registry_open_op_resolves_and_is_discoverable`) exercises
|
||||
exactly the review-004 acceptance shape.
|
||||
- **`serve_single_stream`'s pending-resolution arms match the two
|
||||
half-loops they compose.** Frame by frame against
|
||||
`run_loop_single_stream` (`dispatch.rs:765-905`) and
|
||||
`dispatch_envelope` (`connection.rs:717-741`): responded/completed/
|
||||
error resolve pendings; aborted tries both tables (in-flight sink
|
||||
aborts, then the pending cascade via `handle_abort`); published
|
||||
routes inbound sinks with the same validation/keep semantics; teardown
|
||||
(fail_all + sink drop + sweeper abort) matches. The direction
|
||||
disambiguation by table membership (documented at
|
||||
`dispatch.rs:940-966`) is correct as written — G-01 is *not* a
|
||||
frame-routing bug; the arms are right, the loop's scheduling is the
|
||||
defect.
|
||||
- **The pure-consumer default is unchanged.** `from_connection`
|
||||
delegates with `None` and keeps the resolution-only read pump; no
|
||||
behavior change for existing callers.
|
||||
- **`op/register`'s DTO and unit semantics are clean** — one spec
|
||||
serialization on the wire (`spec_to_json` shape), forced
|
||||
`Internal`/`FromCall` on landing (unit-tested), `replace` semantics
|
||||
tested, `ALREADY_EXISTS` collision gate tested (against the
|
||||
overlay — see G-03 for the gap).
|
||||
- **The alkhttp cross-repo claims check out.** `open-questions.md`
|
||||
OQ-05 and ADR-048's re-point are present in that repo at `5b62307`.
|
||||
- **No non-English text reached the tree.** The remediation session's
|
||||
language slip (reported anecdotally) did not land in any code,
|
||||
doc, or metadata: a CJK-range regex sweep over all `.rs`/`.md`/
|
||||
`.toml`/`.json` in the repo returns zero matches at `f84d214`.
|
||||
- **Gates reproduce.** All commands in Baseline verification pass at
|
||||
the review tree, matching the remediation log's claims
|
||||
(581 default / 598 all-features).
|
||||
|
||||
---
|
||||
|
||||
# Remediation plan
|
||||
|
||||
Sequenced by dependency. All units are alkcall work; Unit 4 (alkhttp
|
||||
wiring) stays downstream and should **not** start before Unit 1 —
|
||||
alkhttp's serving consumers would compose over the same connection
|
||||
and hit G-01 immediately. (All three units landed — see Remediation
|
||||
log.)
|
||||
|
||||
## Unit 1 — Concurrent serving loop + a stub-exercising gate (G-01, G-02)
|
||||
|
||||
- Rework `serve_single_stream`'s `EVENT_REQUESTED` arm: spawn the Once
|
||||
dispatch and the Sub pump (the Sink arm's existing pattern,
|
||||
generalized; `SharedFrameWriter` serializes frames; track handles
|
||||
for teardown). Apply the same rework to `run_loop_single_stream` or
|
||||
extract the shared loop.
|
||||
- Add the G-02 gate: announce → wire-dispatched hub handler composes
|
||||
the announced op via nested composition → resolves from the
|
||||
consumer. This is the removed probe, productized; it is the
|
||||
acceptance test for G-01.
|
||||
- Optional: an interleaved-directions phase on the F-04 gate
|
||||
(outbound call resolving while a Sub is being served).
|
||||
- Gate: the probe recipe (Verification log) resolves in < 1s; no
|
||||
sweeper evictions in the gates.
|
||||
|
||||
## Unit 2 — `op/register` collision policy (G-03)
|
||||
|
||||
- `op_register_handler` gains a serving-registry name-set (closure or
|
||||
`Arc<OperationRegistry>`); base-registry name collisions reject
|
||||
with `ALREADY_EXISTS` regardless of `replace`.
|
||||
- ADR-022 amendment: record the collision policy (peer-announced ops
|
||||
may collide with peer-announced ops — `replace` governs; never with
|
||||
the serving side's own registrations).
|
||||
- Gates: announcing a base-registered name (External *and* Internal)
|
||||
fails loudly; announcing a distinct name still lands; nested
|
||||
composition of a base op is unaffected by an unrelated announce.
|
||||
|
||||
## Unit 3 — Round-trip completeness + doc alignment (G-04, G-05)
|
||||
|
||||
- `resource_id_path` through `spec_to_json_pub` + `rebuild_spec_for`
|
||||
+ round-trip test.
|
||||
- ADR-022 bootstrap list gains `services/list-peers` (or drop it from
|
||||
the installer; recommend adding).
|
||||
|
||||
---
|
||||
|
||||
# Remediation log
|
||||
|
||||
## Unit 3 — Round-trip completeness + doc alignment (G-04, G-05) — LANDED
|
||||
|
||||
**G-04 fix.** `resource_id_path` now rides both halves of the spec
|
||||
wire round-trip: `spec_to_json_pub` serializes it as an optional
|
||||
`resource_id_path` string (`src/registry/discovery.rs`), and
|
||||
`rebuild_spec_for` parses it into the rebuilt spec's fourth
|
||||
constructor argument (`src/client/from_call.rs`). Additive optional
|
||||
field — absent stays absent, no existing consumer breaks (verified by
|
||||
the companion gate). `rebuild_spec_for`'s doc note from the review
|
||||
("namespace *is* serialized but ignored in favor of
|
||||
`namespace_prefix`") was addressed by leaving the behavior as-is: for
|
||||
`op/register` the parameter is `None` (consistent) and for `from_call`
|
||||
the prefix is authoritative — the round-trip tests pin the `name`
|
||||
field handling.
|
||||
|
||||
**Gates:** `spec_round_trips_resource_id_path` (serialize → parse →
|
||||
field intact) and
|
||||
`spec_without_resource_id_path_stays_absent_through_round_trip`
|
||||
(absent key serializes nothing; rebuilt `None`) in
|
||||
`src/client/from_call.rs`.
|
||||
|
||||
**G-05 fix.** The ADR-022 amendment's bootstrap-op set gained
|
||||
`services/list-peers` with a dated note (review 005 G-05) explaining
|
||||
that the installer has registered it since the amendment landed and
|
||||
the doc lagged the code. The set remains closed at four.
|
||||
|
||||
**Verification (post-fix):**
|
||||
|
||||
```
|
||||
cargo test → 589 passed, 0 failed
|
||||
cargo test --all-features → 606 passed, 0 failed
|
||||
cargo clippy --all-targets -- -D warnings → clean
|
||||
cargo clippy --all-features --all-targets -- -D warnings → clean
|
||||
cargo fmt --check → clean
|
||||
cargo clippy --target wasm32-unknown-unknown -- -D warnings → clean
|
||||
cargo doc --no-deps → clean
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Unit 2 — `op/register` collision policy (G-03) — LANDED
|
||||
|
||||
**Fix shape.** `op_register_handler` now takes the serving registry
|
||||
alongside the connection (`op_register_handler(connection,
|
||||
serving_registry)` — the review's "name-set closure" recommendation,
|
||||
materialized as the `Arc<OperationRegistry>` itself, mirroring how
|
||||
`install_bootstrap_discovery` closes over its registry). The handler
|
||||
checks `serving_registry.registration(name)` **before** the overlay
|
||||
gate and rejects base collisions with `ALREADY_EXISTS` regardless of
|
||||
`replace`; overlay collisions keep the existing `replace` semantics.
|
||||
Announced ops may collide with announced ops, never with the serving
|
||||
side's own registrations.
|
||||
|
||||
**ADR-022 amendment:** the 2026-09-03 amendment's `op/register`
|
||||
section gained a "Collision policy (amended 2026-09-04, review 005
|
||||
G-03)" paragraph recording the decided policy and the rationale (the
|
||||
`PeerCompositeEnv` connections-before-base resolution would let an
|
||||
unscreened announce rewrite composition resolution; visibility is
|
||||
irrelevant to the gate). The ADR's status line notes the sub-amendment.
|
||||
|
||||
**Call sites updated:** both e2e gates pass the fork the session
|
||||
dispatches over (`Arc::clone(&accept_registry)` after `fork()`), which
|
||||
is the production shape — the collision set is exactly the registry
|
||||
the serving loop dispatches against.
|
||||
|
||||
**Gates:**
|
||||
|
||||
- `handler_rejects_base_registry_collision_even_with_replace` —
|
||||
base-registered `fs/readFile` (External) + `replace: true` →
|
||||
`ALREADY_EXISTS`; the overlay stays clean and the serving
|
||||
registration is untouched.
|
||||
- `handler_rejects_collision_with_internal_serving_op` — an
|
||||
`Internal` base op is equally protected (the review's point that
|
||||
forced `Visibility::Internal` on the *announced* spec never helped:
|
||||
`OverlayOperationEnv` gates on `AccessControl`, and the composed
|
||||
child is `internal: true` by design).
|
||||
- `handler_overlay_collision_still_governed_by_replace` — announce/
|
||||
announce collisions still follow `replace` (the base gate is
|
||||
scoped to the serving registry, not widened into the overlay).
|
||||
- `nested_composition_of_base_op_unaffected_by_unrelated_announce` —
|
||||
after a successful distinct-name announce, composing the base op
|
||||
through the real `compose_root_env` shape (`PeerCompositeEnv` +
|
||||
`attach_peer(conn.overlay_env())`) resolves the serving side's own
|
||||
op, not a peer stub. (Unit-level, using the actual env types rather
|
||||
than a new full-wire fixture — the wire shape is already covered by
|
||||
the Unit-1 gates.)
|
||||
|
||||
**Deliberate non-change:** `PeerCompositeEnv` resolution order is
|
||||
untouched (connections before base) — the review's recommendation;
|
||||
reordering would change long-decided layering semantics
|
||||
(ADR-019/ADR-024) for no gain the registration-side gate doesn't
|
||||
deliver.
|
||||
|
||||
**Verification (post-fix):**
|
||||
|
||||
```
|
||||
cargo test → 587 passed, 0 failed
|
||||
cargo test --all-features → 604 passed, 0 failed
|
||||
cargo clippy --all-targets -- -D warnings → clean
|
||||
cargo clippy --all-features --all-targets -- -D warnings → clean
|
||||
cargo fmt --check → clean
|
||||
cargo clippy --target wasm32-unknown-unknown -- -D warnings → clean
|
||||
cargo doc --no-deps → clean
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Unit 1 — Concurrent serving loop + stub-exercising gates (G-01, G-02) — LANDED
|
||||
|
||||
**Fix shape.** `dispatch()` was split into a synchronous start half and
|
||||
an awaited invocation:
|
||||
|
||||
- `Dispatcher::dispatch_start`
|
||||
(`src/protocol/dispatch.rs`) runs the sync prefix (identity
|
||||
resolution, root context, op-type branch) and returns a
|
||||
`StartedDispatch`: `Once` (the invocation as a boxed future), `Stream`
|
||||
(the `ResponseStream`, returned synchronously — `invoke_streaming`
|
||||
is a sync call), or `Sink` (started **inline**, unchanged). `dispatch()`
|
||||
is now a thin wrapper (`Once` = await the boxed future) and keeps its
|
||||
shape for `dispatch_requested`/`handle_stream`/the gateway.
|
||||
- The Sink start stays inline **by design**: its `chunk_tx` must be in
|
||||
`in_flight_sinks` before the next `call.published` frame can be
|
||||
routed; the loop insert cannot race the feed. Pub handlers that
|
||||
block forever inside their sink future are a separate (pre-existing,
|
||||
unreported) shape — the read loop no longer waits on any handler
|
||||
except for the few instructions of the sink start itself.
|
||||
- Both single-stream loops' `EVENT_REQUESTED` arms spawn the Once
|
||||
invocation (`spawn_once_dispatch`), the Sub pump
|
||||
(`spawn_stream_pump`), and the sink's response writer
|
||||
(`spawn_sink_response_writer`); handles are tracked in a
|
||||
`spawned` list and aborted at loop exit (teardown: sink-map clear →
|
||||
spawn aborts → `fail_all` → sweeper abort). `in_flight_sinks` moved
|
||||
behind `Arc<parking_lot::Mutex>` because the ABORTED/PUBLISHED/ERROR
|
||||
arms must not hold the guard across the `chunk_tx.send().await`
|
||||
(non-`Send` guard across an await — the reason the pre-fix loop
|
||||
couldn't just be `tokio::spawn`ed piecemeal). Guard-dropping
|
||||
(`let entry = ...remove()` before the await) keeps lock discipline:
|
||||
no lock is held across an await anywhere in the loops.
|
||||
- **`run_loop_single_stream` (the accept side) got the
|
||||
pending-resolution arms** (`EVENT_RESPONDED`/`COMPLETED`/`ERROR` →
|
||||
the outbound pending map). Previously it served only — an accept-side
|
||||
nested-composing handler (e.g. a `from_call` imported-op stub riding
|
||||
the same connection) had no loop resolving its response frames at
|
||||
all. The G-01 accept-side latent hazard is mechanized shut, not just
|
||||
unblocked: both single-stream loops are now the same shape
|
||||
(dispatch-spawn + pending-resolution), the full-duplex loop
|
||||
`serve_single_stream` composes them and is unchanged in its arm
|
||||
semantics (frame-arm equivalence preserved per the non-findings
|
||||
audit).
|
||||
- Write-failure semantics changed from `break` (close the loop) to
|
||||
`warn` (keep reading) in the spawned Once path, matching the Sink
|
||||
arm: a dying transport surfaces on the next read as
|
||||
`ConnectionClosed`; a transient frame-write failure no longer tears
|
||||
down every in-flight request on the connection.
|
||||
|
||||
**Gates (G-02, productized from the removed probe):**
|
||||
|
||||
- `hub_handler_composes_peer_announced_op_via_nested_composition`
|
||||
(`src/channels/client.rs`): announce → consumer calls `hub/compose`
|
||||
→ the hub's serving loop wire-dispatches it → the handler resolves
|
||||
`consumer/exec` via `ctx.env` nested composition → the forwarding
|
||||
stub's nested `call.requested` crosses back to the consumer. This is
|
||||
the stub path the F-05 gate bypassed. Two wiring facts surfaced (both
|
||||
recorded as spec-consistent, both were silent before): (a) the hub's
|
||||
channel-0 `Connection` must carry the peer identity —
|
||||
`compose_root_env` attaches the connection overlay keyed by
|
||||
`identity.id` (ADR-030 §5), and a deployment that skips identity
|
||||
resolution silently gets no peer overlay (the test sets it via the
|
||||
`AuthContext` the adapter passes to `install_channel_zero`);
|
||||
(b) composition reachability is declared on the composing handler's
|
||||
registration (`scoped_env: ScopedPeerEnv::new(["consumer/exec"])`) —
|
||||
the empty `ScopedPeerEnv` is deny-by-default in
|
||||
`PeerCompositeEnv::invoke_with_policy`, so a wire-dispatched handler
|
||||
with no `scoped_env` composes nothing (the reachability gate, not the
|
||||
overlay, was what the probe's first draft tripped over).
|
||||
- `outbound_call_resolves_while_inbound_subscription_is_being_served`
|
||||
(the optional interleaved-directions phase on the F-04 shape): the
|
||||
consumer subscribes to the hub (Sub served by the consumer's loop),
|
||||
then calls `hub/interleave` over the wire — its handler issues an
|
||||
outbound hub→consumer call on the same connection while the Sub is
|
||||
live — and asserts the call resolves and the Sub is still live
|
||||
after. Both gates bounded at 5s (vs. the 30s sweeper), so a
|
||||
regression fails fast, not via sweeper eviction.
|
||||
|
||||
**Load-bearing verification (both gates, empirical):** each gate was
|
||||
run against the pre-fix loop (`git stash push
|
||||
src/protocol/dispatch.rs`) and reproduced the G-01 hang:
|
||||
|
||||
```
|
||||
hub_handler_composes…: panicked "nested composition through the
|
||||
forwarding stub timed out (G-01 shape): Elapsed(())" — 5.01s, no progress
|
||||
outbound_call_resolves…: panicked "interleaved outbound call starved
|
||||
while Sub served (G-01 shape): Elapsed(())" — 5.06s, no progress
|
||||
```
|
||||
|
||||
With the fix, both resolve (0.01s / 0.11s wall including connection
|
||||
setup). No sweeper evictions (bounded timeouts ≪ 30s).
|
||||
|
||||
**Consequences audited against the fix:**
|
||||
|
||||
- Same-connection nested composition of peer-announced ops works for
|
||||
wire-dispatched parents (the flagship ADR-022 amendment flow) — the
|
||||
gate proves it end-to-end.
|
||||
- The accept-side imported-op composition hazard (G-01's second
|
||||
consequence, latent pre-`f84d214`) is mechanized shut by the
|
||||
pending-resolution arms in `run_loop_single_stream` — not merely
|
||||
unblocked. There is no dedicated gate for this shape yet (the
|
||||
accept-side import + nested-compose e2e would be a further gate;
|
||||
noted as residual work, not a regression).
|
||||
- Head-of-line blocking is gone: spawned arms proceed concurrently;
|
||||
the loop never awaits a handler.
|
||||
- Frame ordering: per-request ordering is preserved by the
|
||||
`SharedFrameWriter` (each `write_frame` is atomic under the mutex);
|
||||
responses for two requests may now interleave *at frame
|
||||
granularity*, which single-stream mode always permitted (both
|
||||
directions' frames are multiplexed by design; correlation is by id).
|
||||
|
||||
**Verification (post-fix):**
|
||||
|
||||
```
|
||||
cargo test → 583 passed, 0 failed
|
||||
cargo test --all-features → 600 passed, 0 failed
|
||||
cargo clippy --all-targets -- -D warnings → clean
|
||||
cargo clippy --all-features --all-targets -- -D warnings → clean
|
||||
cargo fmt --check → clean (fmt applied)
|
||||
cargo clippy --target wasm32-unknown-unknown -- -D warnings → clean
|
||||
cargo doc --no-deps → clean
|
||||
```
|
||||
|
||||
**Residual (not blocking Unit 1):** the sink-start-inline shape means a
|
||||
`Pub` op whose registration itself blocks (ACL/schema compile are sync
|
||||
and fast; `resolve_sink_handler` runs the handler's ACL path) still
|
||||
holds the loop briefly — bounded by registry work, not handler work. A
|
||||
malicious `AccessControl::check` implementation could stall the loop;
|
||||
that is a deployment-provided trait object, the same trust boundary as
|
||||
`IdentityProvider`, and unchanged from the pre-existing shape.
|
||||
|
||||
---
|
||||
|
||||
## Verification log (this pass)
|
||||
|
||||
- All gates in Baseline verification reproduced at tree `f84d214`
|
||||
(clean working tree; the probe was added, run, and removed —
|
||||
`git status` verified clean after removal).
|
||||
- G-01 verified by code trace (the four steps above, each line
|
||||
read at `f84d214`) **and** empirically: probe
|
||||
`probe_hub_handler_composes_peer_announced_op` (in
|
||||
`src/channels/client.rs#[cfg(test)]`, since removed) — hub registry
|
||||
carries `op/register` + `hub/compose` (whose handler invokes the
|
||||
connection overlay's registration for the announced op, the shape
|
||||
`PeerCompositeEnv` → `OverlayOperationEnv` resolution produces);
|
||||
consumer announces `consumer/exec` then calls `hub/compose`.
|
||||
8s-timeout run: `Elapsed` (no progress). 45s-cap run: resolved at
|
||||
**30.0007s** with `CallError::TIMEOUT` (retryable) — the
|
||||
`DEFAULT_CALL_TIMEOUT` sweeper eviction, not live resolution.
|
||||
- The existing gates' blind spot verified by reading both e2e gates'
|
||||
call directions: `serving_loop_hub_to_consumer_call_resolves`
|
||||
(`client.rs:1373-1399`) and
|
||||
`op_register_announce_then_hub_call_routes_back_to_consumer`
|
||||
(`client.rs:1529`) — the latter's `accept_conn.call("consumer/exec")`
|
||||
crosses the wire to the consumer's local registry; the forwarding
|
||||
stub is unreachable from it.
|
||||
- G-03 verified by reading the collision gate (`op_register.rs:142`)
|
||||
against `PeerCompositeEnv::invoke_with_policy`'s resolution order
|
||||
(`env.rs:230-245`) and `compose_root_env`'s attach
|
||||
(`dispatch.rs:207-214`).
|
||||
- G-04 verified by field audit: `OperationSpec`'s fields
|
||||
(`spec.rs:175-206`) vs `spec_to_json_pub` (`discovery.rs:211-234`)
|
||||
vs `rebuild_spec_for` (`from_call.rs:209-286`).
|
||||
- G-05 verified against `discovery.rs:290-313` and ADR-022
|
||||
amendment's set (`022-…contract.md:380-393`).
|
||||
- Frame-arm equivalence of `serve_single_stream` vs the two half-loops
|
||||
verified read-through (Non-findings).
|
||||
- alkhttp cross-repo claims verified in `/workspace/@alkdev/alkhttp`
|
||||
at `5b62307` (OQ-05 `open-questions.md:102-112`; ADR-048
|
||||
`decisions/048-…md:19-25`).
|
||||
- CJK sweep: no CJK-range codepoints in any `.rs`/`.md`/`.toml`/
|
||||
`.json` under the repo at `f84d214`.
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
target
|
||||
corpus/*
|
||||
!corpus/chunk_header
|
||||
!corpus/envelope_frame
|
||||
artifacts
|
||||
coverage
|
||||
Generated
+1280
File diff suppressed because it is too large.
Load diff
@@ -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]
|
||||
@@ -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.
@@ -0,0 +1 @@
|
||||
��������
|
||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
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.
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.
Loaded 100 of 1515 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user