Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a493e9748 | ||
|
|
4278c13b2d | ||
|
|
3ca7505abd | ||
|
|
ff07f92bdb | ||
|
|
f93ac3792d | ||
|
|
5f019c02f1 | ||
|
|
7b8a3e7558 | ||
|
|
3317ea0963 | ||
|
|
944c300c17 | ||
|
|
df4d4d3a9e | ||
|
|
4eae18f539 | ||
|
|
549893cbbf | ||
|
|
1793265b04 | ||
|
|
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 |
No files matched your search
@@ -99,7 +99,8 @@ organized by theme. Each question has:
|
|||||||
|
|
||||||
- **OQ-ID** (OQ-01, OQ-02, ...) — stable reference
|
- **OQ-ID** (OQ-01, OQ-02, ...) — stable reference
|
||||||
- **Origin** — which spec doc(s) the question appeared in
|
- **Origin** — which spec doc(s) the question appeared in
|
||||||
- **Status** — open, resolved, partially resolved
|
- **Status** — open, resolved, partially resolved, deferred(scope),
|
||||||
|
deferred(unclear), dissolved
|
||||||
- **Priority** — high, medium, low
|
- **Priority** — high, medium, low
|
||||||
- **Resolution** — when resolved, what was decided and which ADR addresses it
|
- **Resolution** — when resolved, what was decided and which ADR addresses it
|
||||||
- **Cross-references** — related OQs and ADRs
|
- **Cross-references** — related OQs and ADRs
|
||||||
@@ -190,6 +191,8 @@ When you encounter a decision that genuinely can't be made:
|
|||||||
|
|
||||||
1. Mark the OQ as `deferred(scope)` with a concrete blocking condition
|
1. Mark the OQ as `deferred(scope)` with a concrete blocking condition
|
||||||
2. Create a blocker task in `tasks/architecture/` naming the dependency
|
2. Create a blocker task in `tasks/architecture/` naming the dependency
|
||||||
|
(where the project tracks tasks that way — otherwise the OQ's
|
||||||
|
`Blocked on:` field is the tracker of record)
|
||||||
3. Continue to decisions that *can* be made — do not stall on one question
|
3. Continue to decisions that *can* be made — do not stall on one question
|
||||||
|
|
||||||
### 6. Request Architecture Review
|
### 6. Request Architecture Review
|
||||||
@@ -274,7 +277,7 @@ Door type classifies **reversal cost** (one-way vs two-way), not
|
|||||||
urgency. A two-way door is a decision you make now and can revert
|
urgency. A two-way door is a decision you make now and can revert
|
||||||
later — not a decision to defer. Using "it's a two-way door" as a
|
later — not a decision to defer. Using "it's a two-way door" as a
|
||||||
reason to leave a decision unmade conflates reversal cost with
|
reason to leave a decision unmade conflates reversal cost with
|
||||||
decision-making. See ADR-009 §"What this framework is NOT" for the
|
decision-making. See ADR-032 §"What this framework is NOT" for the
|
||||||
full rationale.
|
full rationale.
|
||||||
|
|
||||||
Architecture decisions are the architect's, regardless of door type.
|
Architecture decisions are the architect's, regardless of door type.
|
||||||
@@ -329,7 +332,8 @@ A decision should be `deferred(scope)` when:
|
|||||||
- The use case isn't concrete (e.g., "we don't know what the agent crate
|
- The use case isn't concrete (e.g., "we don't know what the agent crate
|
||||||
will need from the call protocol")
|
will need from the call protocol")
|
||||||
- The options depend on something that doesn't exist yet (e.g.,
|
- The options depend on something that doesn't exist yet (e.g.,
|
||||||
"depends on the alknet-http crate spec")
|
"depends on the composition-layer crate spec — owner and shape not
|
||||||
|
committed, see OQ-43")
|
||||||
- The trade-off requires data that can only come from implementation
|
- The trade-off requires data that can only come from implementation
|
||||||
(e.g., "need performance benchmarks to choose between X and Y")
|
(e.g., "need performance benchmarks to choose between X and Y")
|
||||||
- The decision is genuinely not needed for the current scope (e.g., "the
|
- The decision is genuinely not needed for the current scope (e.g., "the
|
||||||
@@ -373,7 +377,7 @@ A decision should be `deferred(unclear)` when:
|
|||||||
(implies it's decided).
|
(implies it's decided).
|
||||||
2. **State the blocking condition** (`deferred(scope)`) or
|
2. **State the blocking condition** (`deferred(scope)`) or
|
||||||
**investigation target** (`deferred(unclear)`) — what specific thing
|
**investigation target** (`deferred(unclear)`) — what specific thing
|
||||||
would unblock this? Be concrete: "blocked on: alknet-agent crate spec
|
would unblock this? Be concrete: "blocked on: the agent crate's spec
|
||||||
exists" or "investigation: work through 2+ example outbound-dial use
|
exists" or "investigation: work through 2+ example outbound-dial use
|
||||||
cases (hub→worker, worker→hub) to see how verifier-selection +
|
cases (hub→worker, worker→hub) to see how verifier-selection +
|
||||||
provider + connector compose."
|
provider + connector compose."
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ also include:
|
|||||||
Example prompt template:
|
Example prompt template:
|
||||||
|
|
||||||
```
|
```
|
||||||
You are an implementation specialist for the @alkdev/alknet project.
|
You are an implementation specialist for the @alkdev/alkcall project.
|
||||||
|
|
||||||
Your task: {{task}}
|
Your task: {{task}}
|
||||||
|
|
||||||
@@ -204,11 +204,11 @@ Your task: {{task}}
|
|||||||
7. Push: git push origin $(git branch --show-current)
|
7. Push: git push origin $(git branch --show-current)
|
||||||
8. Notify: worktree({action: "notify", args: {message: "Task completed: {{task}}. <brief summary>", level: "info"}})
|
8. Notify: worktree({action: "notify", args: {message: "Task completed: {{task}}. <brief summary>", level: "info"}})
|
||||||
|
|
||||||
Key project constraints (@alkdev/alknet):
|
Key project constraints (@alkdev/alkcall):
|
||||||
- Rust: use cargo build, cargo clippy, cargo fmt, cargo test
|
- Rust: use cargo build, cargo clippy, cargo fmt, cargo test
|
||||||
- No comments in code
|
- No comments in code
|
||||||
- anyhow::Result for application errors, thiserror for library error types
|
- anyhow::Result for application errors, thiserror for library error types
|
||||||
- Feature flags for transports (tls, iroh, acme)
|
- Base crate compiles lean (no quinn/iroh unless the feature is on — see AGENTS.md convention 11)
|
||||||
- Async via tokio runtime
|
- Async via tokio runtime
|
||||||
- No panics in library code
|
- No panics in library code
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -46,14 +46,15 @@ change `git config`, skip hooks, or use `git commit -i`.
|
|||||||
|
|
||||||
## Project Conventions (Rust / RPC protocol crate)
|
## Project Conventions (Rust / RPC protocol crate)
|
||||||
|
|
||||||
This is the call + channels RPC crate — the unification of
|
This is the call + channels RPC crate — the extraction and unification of
|
||||||
`alknet-call` (structured JSON RPC: operations, streaming subscriptions,
|
the former `alknet-call` (structured JSON RPC: operations, streaming
|
||||||
service discovery) and `alknet-channels` (multiplexing proxy: N logical
|
subscriptions, service discovery) and `alknet-channels` (multiplexing
|
||||||
channels over one transport stream, channel 0 pre-negotiated as
|
proxy: N logical channels over one transport stream, channel 0
|
||||||
`alk/call`). The conventions below apply to all work in `src/` and
|
pre-negotiated as `alk/call`) crates. The conventions below apply to all
|
||||||
`tests/`. They mirror `.opencode/agents/implementation-specialist.md`
|
work in `src/` and `tests/`. They mirror
|
||||||
§Project Conventions and are repeated here so they apply to every
|
`.opencode/agents/implementation-specialist.md` §Project Conventions and
|
||||||
session, not just spawned implementation agents.
|
are repeated here so they apply to every session, not just spawned
|
||||||
|
implementation agents.
|
||||||
|
|
||||||
1. **No comments in code** unless the user explicitly asks. This is a
|
1. **No comments in code** unless the user explicitly asks. This is a
|
||||||
project-wide convention. Doc comments (`///`, `//!`) are fine and
|
project-wide convention. Doc comments (`///`, `//!`) are fine and
|
||||||
@@ -130,11 +131,13 @@ session, not just spawned implementation agents.
|
|||||||
`AuthContext`, `IdentityProvider`, `Identity`, `AuthToken`,
|
`AuthContext`, `IdentityProvider`, `Identity`, `AuthToken`,
|
||||||
`Capabilities`, `OwnershipProvider`, `HandlerError`, `StreamError`)
|
`Capabilities`, `OwnershipProvider`, `HandlerError`, `StreamError`)
|
||||||
live in this crate. They are the home for these types going forward —
|
live in this crate. They are the home for these types going forward —
|
||||||
do not add a separate `alkcore` dependency. When the alknet mono-repo
|
do not add a separate `alkcore` dependency; the source-repo core crate
|
||||||
is reworked, it will consume alkcall's versions. Keep these types
|
is retired (the alknet mono-repo is being replaced by thin glue over
|
||||||
lean (no TLS, no transport coupling, no endpoint/accept-loop); the
|
alkcall and its sibling host crates — see OQ-43 in
|
||||||
dial and the TLS config are concerns of the consumer, not of this
|
`docs/architecture/open-questions.md` for the composition layer).
|
||||||
crate. See ADR-007, ADR-008, ADR-009.
|
Keep these types lean (no TLS, no transport coupling, no
|
||||||
|
endpoint/accept-loop); the dial and the TLS config are concerns of
|
||||||
|
the consumer, not of this crate. See ADR-007, ADR-008, ADR-009.
|
||||||
|
|
||||||
10. **BAST documents for wire formats** — every binary wire format in
|
10. **BAST documents for wire formats** — every binary wire format in
|
||||||
this crate carries a BAST (Binary Abstract Syntax Tree) document as
|
this crate carries a BAST (Binary Abstract Syntax Tree) document as
|
||||||
@@ -194,6 +197,7 @@ Run these before committing. All must pass.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test # full suite
|
cargo test # full suite
|
||||||
|
cargo test --manifest-path fuzz/shared/Cargo.toml # fuzz corpus replay (stable)
|
||||||
cargo clippy --all-targets -- -D warnings
|
cargo clippy --all-targets -- -D warnings
|
||||||
cargo fmt --check
|
cargo fmt --check
|
||||||
cargo doc --no-deps # if docs changed
|
cargo doc --no-deps # if docs changed
|
||||||
@@ -212,19 +216,53 @@ cargo clippy --target wasm32-unknown-unknown -- -D warnings
|
|||||||
cargo semver-checks check-release # before a release
|
cargo semver-checks check-release # before a release
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Stub Guard (release-blocking)
|
||||||
|
|
||||||
|
Four registered-but-always-failing stubs shipped across nine published
|
||||||
|
versions (reviews 010/011). This class is now release-blocking in both
|
||||||
|
directions:
|
||||||
|
|
||||||
|
1. **Test guard**: `cargo test --test stub_guard` (part of `cargo
|
||||||
|
test`) fails if the string `not_implemented` appears anywhere in
|
||||||
|
`src/`. A "loud failure" stub is still a stub.
|
||||||
|
2. **Registration-time invariants** (ADR-047 amendment 4 §4.8): a
|
||||||
|
marked (`channel_open`) spec refuses the generic registration path
|
||||||
|
(`OperationRegistry::register`, `CallConnection::register_imported`,
|
||||||
|
`op/register`) and refuses `publish_schema`; the sanctioned
|
||||||
|
registrations are the channel wrapper family
|
||||||
|
(`register_openable*`, `register_pub_openable`,
|
||||||
|
`register_relay_openable`).
|
||||||
|
3. **The ruling**: implement-or-stop-advertising before any publish.
|
||||||
|
The only permitted deferral statement is "not this session" — never
|
||||||
|
"next version", "not v1", or any bucket that parks a decision on an
|
||||||
|
unknown future release. Every handler that registers on a
|
||||||
|
discoverable surface must be capable of succeeding.
|
||||||
|
|
||||||
## Architecture Context
|
## Architecture Context
|
||||||
|
|
||||||
- `docs/architecture/` — the authoritative spec. Read it before
|
- `docs/architecture/` — the authoritative spec. Read it before
|
||||||
non-trivial changes. ADRs are numbered 001..048; OQs (open questions)
|
non-trivial changes. ADRs are numbered 001..054; OQs (open questions)
|
||||||
track resolved/deferred decisions.
|
track resolved/deferred decisions (OQ-01..43; OQ-43 tracks the
|
||||||
- This crate unifies `alknet-call` and `alknet-channels` from the
|
composition layer's ownership and shape after the alknet replacement,
|
||||||
alknet mono-repo (`/workspace/@alkdev/alknet`). The source
|
deferred(scope)).
|
||||||
architecture docs were ported from
|
- This crate is the extraction of the former `alknet-call` and
|
||||||
`/workspace/@alkdev/alknet/docs/architecture/` and renumbered as
|
`alknet-channels` crates plus the vendored core types (provenance:
|
||||||
alkcall ADRs (001..048; ADR-048 was authored in this crate). The ALPN
|
the alknet mono-repo at `/workspace/@alkdev/alknet`). The source
|
||||||
strings (`alk/call`,
|
architecture docs were ported and renumbered as alkcall ADRs
|
||||||
`alk/channels`) are wire-stable going forward (renamed from
|
(001..048; ADR-049 and later were authored in this crate). The
|
||||||
`alknet/` to `alk/` in v0.1.1, before the first published consumer).
|
mono-repo is **provenance, not a live dependency** — it is being
|
||||||
|
replaced by thin glue over alkcall and its sibling host crates
|
||||||
|
(`alkhttp`, `alktls`, `alktty`, `alktunnels`, `alktrader`), which are
|
||||||
|
the current homes of the surfaces old docs call `alknet-http`,
|
||||||
|
`alknet-tls`, `alknet-tty`, `alknet-tunnels`, `alknet-trader`. Docs
|
||||||
|
and ADRs cite the source repo as "alknet ADR-NNN (provenance)" /
|
||||||
|
"alknet OQ-NN" — a separately-numbered upstream space, never an
|
||||||
|
in-crate authority; anything that constrains this crate's surface is
|
||||||
|
re-owned by an in-crate ADR (e.g. ADR-054). See the provenance note
|
||||||
|
at the end of `docs/architecture/README.md`. 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:
|
- Key ADRs that inform this crate's design:
|
||||||
|
|
||||||
**Call protocol:**
|
**Call protocol:**
|
||||||
@@ -272,6 +310,14 @@ cargo semver-checks check-release # before a release
|
|||||||
`from_connection` primary; dial lives in the consumer)
|
`from_connection` primary; dial lives in the consumer)
|
||||||
- ADR-041 — per-identity channel cap (256 per `PeerId` via
|
- ADR-041 — per-identity channel cap (256 per `PeerId` via
|
||||||
`ChannelLifecyclePolicy`)
|
`ChannelLifecyclePolicy`)
|
||||||
|
- ADR-052 — `channel/control` control-handle surface
|
||||||
|
(establisher-contributed `ChannelControl`;
|
||||||
|
`channel:no_control_handle` capability answer)
|
||||||
|
- ADR-053 — `channel/resources/subscribe` live resource enumerators
|
||||||
|
(snapshot-then-changes; scan-once aggregation)
|
||||||
|
- ADR-047 amendment 4 — the marked-pub open contract and the
|
||||||
|
registration invariants (§4.8): marked specs refuse the generic
|
||||||
|
registration paths; stubs are unregistrable
|
||||||
|
|
||||||
**Shared (vendored core types):**
|
**Shared (vendored core types):**
|
||||||
- ADR-001 — ALPN-based protocol dispatch
|
- ADR-001 — ALPN-based protocol dispatch
|
||||||
@@ -288,6 +334,10 @@ cargo semver-checks check-release # before a release
|
|||||||
point `ChannelBidiStreamSource` implements)
|
point `ChannelBidiStreamSource` implements)
|
||||||
- ADR-009 — `BiStream` as the handler leaf (`accept_bi` returns
|
- ADR-009 — `BiStream` as the handler leaf (`accept_bi` returns
|
||||||
`BiStream`)
|
`BiStream`)
|
||||||
|
- ADR-054 — outgoing-only X.509 and the three remote roles
|
||||||
|
(verifier selection by `PeerEntry` presence; `RemoteIdentity`
|
||||||
|
`None` semantics — re-owned in-crate from the source repo's
|
||||||
|
alknet ADR-034; the dial/composition layer implements it)
|
||||||
|
|
||||||
- If a TODO references a "Phase B" or a design direction that an ADR
|
- If a TODO references a "Phase B" or a design direction that an ADR
|
||||||
has since decided against, the TODO is stale — remove it and align
|
has since decided against, the TODO is stale — remove it and align
|
||||||
|
|||||||
+513
@@ -4,6 +4,510 @@ 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
|
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||||
this crate adheres to [Semantic Versioning](https://semver.org/).
|
this crate adheres to [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-10-14
|
||||||
|
|
||||||
|
The stub sweep: four registered-but-always-failing stubs shipped across
|
||||||
|
nine published versions (reviews 010/011) are now real implementations
|
||||||
|
— the marked-pub open contract (S-01), `channel/control` (S-02),
|
||||||
|
`channel/resources/subscribe` (S-03), and `op/register`
|
||||||
|
forward-through (S-04). Every handler that registers on a discoverable
|
||||||
|
surface can now succeed; the four stubs are replaced and the class is
|
||||||
|
release-blocking in both directions (a loud-failure stub is still a
|
||||||
|
stub — the stub guard plus registration-time invariants hold the gate).
|
||||||
|
|
||||||
|
**Breaking (semver-relevant, hence the minor bump).** New variant
|
||||||
|
`ManagerError::InvalidAdopt`; `RelayError::PubTypedOpen` removed (the
|
||||||
|
relay put-translation now allocates and opens on the producer leg);
|
||||||
|
`register_imported`/`register_imported_all` return `Result<(), String>`
|
||||||
|
(beyond the boolean); `Establishment` no longer derives `Debug` (the
|
||||||
|
establisher-contributed `ChannelControl` inner is not `Debug`).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **The marked-pub open contract — S-01 (ADR-047 amendment 4, review
|
||||||
|
011 §3).** The put path end-to-end:
|
||||||
|
- `ChannelCore::register_pub_openable` + `PubOpenHandler` — the
|
||||||
|
put's handler returns its result for the merged single response;
|
||||||
|
the wrapper awaits it inline, teardown is EOF-driven, no
|
||||||
|
`handler_task` is ever spawned. `register_openable*` refuses
|
||||||
|
`Pub`-typed specs; the wrapper entry points refuse unmarked /
|
||||||
|
non-`Pub` shapes.
|
||||||
|
- The reserved input key `channel_id` (the initiator-allocated id)
|
||||||
|
is stripped pre-validation by `resolve_sink_handler` (P-08, the
|
||||||
|
single source of truth), handed to the wrapper via
|
||||||
|
`OperationContext.metadata`; a missing key rejects
|
||||||
|
`INVALID_INPUT`; adopt parity is enforced
|
||||||
|
(`ManagerError::InvalidAdopt`) and adopt failures answer
|
||||||
|
`channel:open_failed` / reason `adopt_failed`.
|
||||||
|
- Zero `call.published` on marked ops: the first publish chunk on a
|
||||||
|
marked op is rejected loudly (`channel:publish_chunks_rejected`)
|
||||||
|
in both dispatch loops; the handler is never fed.
|
||||||
|
- The relay's put-translation (ADR-042): the hub allocates odd on
|
||||||
|
the producer leg, opens the spoke op in the background
|
||||||
|
(put-acks-in-result), pumps one direction only, and mirrors the
|
||||||
|
response with the producer's reserved key stripped.
|
||||||
|
- `ChannelClient::open_pub_channel` + `PubOpenReply` — the
|
||||||
|
initiator-allocated put open capability (odd ids on the connect
|
||||||
|
side, ADR-047 §5). A failed open tears the local adopt entry down,
|
||||||
|
including the pre-flight INVALID_INPUT path (the allocated id is
|
||||||
|
released, not parked against the ADR-040 bound).
|
||||||
|
- **`channel/control` implemented — S-02 (ADR-052).** The
|
||||||
|
establisher-contributed control handle (`Establishment::with_control`)
|
||||||
|
is registered manager-side at establishment, removed on teardown, and
|
||||||
|
reachable through the `channel/control` op; an establisher without a
|
||||||
|
control handle answers `channel:no_control_handle`.
|
||||||
|
- **`channel/resources/subscribe` implemented — S-03 (ADR-053).** The
|
||||||
|
per-peer-connection `ResourceEnumerators` registry with
|
||||||
|
snapshot-then-changes enumerators, scan-once aggregation over the
|
||||||
|
channel operations, and the empty seed; the always-failing one-shot
|
||||||
|
error stream is gone.
|
||||||
|
- **`op/register` forward-through — S-04 (ADR-022 amendment).**
|
||||||
|
Announced unmarked `Sub`/`Pub` operations register the real
|
||||||
|
streaming/sink forwarding shapes; marked announcements are refused
|
||||||
|
with `MARKED_SPEC_UNSUPPORTED`; marked specs refuse both the generic
|
||||||
|
registration path (`OperationRegistry::register`, `op/register`) and
|
||||||
|
the connection-overlay path (`CallConnection::register_imported`
|
||||||
|
— now returning `Result`; `register_imported_all` is atomic: a
|
||||||
|
refusal leaves the overlay untouched).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `HandlerRegistration` gained the private `channel_wrapper` field
|
||||||
|
(the channel wrapper family's plumbing); externally constructed
|
||||||
|
registrations stay impossible (`HandlerRegistration::new` unchanged).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`open_pub_channel` no longer parks a channel slot on the
|
||||||
|
pre-flight rejection.** The early-return on a non-object input now
|
||||||
|
tears down the freshly allocated id (regression:
|
||||||
|
`open_pub_channel_invalid_input_releases_the_allocated_id`).
|
||||||
|
- `Capabilities::zeroize`: an unused map-key iteration tripped
|
||||||
|
`clippy::for_kv_map` under `-D warnings`. No behavior change.
|
||||||
|
|
||||||
|
### Guard
|
||||||
|
|
||||||
|
- `tests/stub_guard.rs` — `cargo test` fails if `not_implemented`
|
||||||
|
appears anywhere under `src/`: the four former pinned-stub tests are
|
||||||
|
deleted and the marked-spec registration refusals are pinned by
|
||||||
|
tests instead.
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
|
||||||
|
- 698 tests pass (including the stub guard); `clippy --all-targets
|
||||||
|
--all-features -- -D warnings`, `fmt --check`, `cargo doc`,
|
||||||
|
`wasm32-unknown-unknown` check, fuzz corpus replay
|
||||||
|
(`cargo test --manifest-path fuzz/shared/Cargo.toml`, 5/5), and
|
||||||
|
`cargo semver-checks` (5 breaking lints vs 0.8.1 — the minor-bump
|
||||||
|
classification above).
|
||||||
|
|
||||||
|
## [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
|
## [0.4.1] - 2026-09-05
|
||||||
|
|
||||||
Bug-fix release: chunks arriving for a not-yet-adopted channel are
|
Bug-fix release: chunks arriving for a not-yet-adopted channel are
|
||||||
@@ -330,6 +834,15 @@ Vendored core types (`Connection`, `ProtocolHandler`, `BiStream`,
|
|||||||
(ADR-046), the channels protocol with openable-ALPNs-as-operations
|
(ADR-046), the channels protocol with openable-ALPNs-as-operations
|
||||||
(ADR-047), and the `ChannelClient` transport-agnostic client.
|
(ADR-047), and the `ChannelClient` transport-agnostic client.
|
||||||
|
|
||||||
|
[0.9.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.9.0
|
||||||
|
[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.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.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.2.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.2.0
|
||||||
|
|||||||
Generated
+1
-1
@@ -27,7 +27,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alkcall"
|
name = "alkcall"
|
||||||
version = "0.4.1"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|||||||
+7
-3
@@ -1,15 +1,19 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "alkcall"
|
name = "alkcall"
|
||||||
version = "0.4.1"
|
version = "0.9.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.85"
|
rust-version = "1.88"
|
||||||
license = "MIT OR Apache-2.0"
|
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"
|
description = "Call + channels RPC: structured JSON operations, streaming subscriptions, service discovery, and N-channel multiplexing over one transport stream"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
repository = "https://git.alk.dev/alkdev/alkcall"
|
repository = "https://git.alk.dev/alkdev/alkcall"
|
||||||
keywords = ["rpc", "json-rpc", "multiplexing", "wire-format", "alpn"]
|
keywords = ["rpc", "json-rpc", "multiplexing", "wire-format", "alpn"]
|
||||||
categories = ["network-programming", "asynchronous", "encoding"]
|
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]
|
[lib]
|
||||||
name = "alkcall"
|
name = "alkcall"
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ let client = ChannelClient::from_connection_with_serving(
|
|||||||
Some(ServingConfig {
|
Some(ServingConfig {
|
||||||
registry: Arc::clone(®istry),
|
registry: Arc::clone(®istry),
|
||||||
identity_provider: provider,
|
identity_provider: provider,
|
||||||
|
identity: None, // peer identity: transport `Connection::set_identity` propagates
|
||||||
}),
|
}),
|
||||||
).await?;
|
).await?;
|
||||||
// peer-callable ops resolve against `registry` on channel 0;
|
// peer-callable ops resolve against `registry` on channel 0;
|
||||||
|
|||||||
+80
-39
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-08-12
|
last_updated: 2026-10-13 (composition re-key, ADR-054, alkcall reframing of ported docs)
|
||||||
---
|
---
|
||||||
|
|
||||||
# alkcall
|
# alkcall
|
||||||
@@ -9,12 +9,13 @@ The call + channels RPC crate. Structured JSON RPC (operations, streaming
|
|||||||
subscriptions, service discovery) and N-channel multiplexing over one
|
subscriptions, service discovery) and N-channel multiplexing over one
|
||||||
transport stream (channel 0 pre-negotiated as `alk/call`).
|
transport stream (channel 0 pre-negotiated as `alk/call`).
|
||||||
|
|
||||||
This crate unifies `alknet-call` and `alknet-channels` from the alknet
|
This crate is the extraction and unification of the former `alknet-call`
|
||||||
mono-repo, plus the vendored core types formerly in `alknet-core`. The
|
and `alknet-channels` crates plus the vendored core types formerly in
|
||||||
source architecture docs were ported from
|
`alknet-core` (see ADR-031). It is a pure protocol crate: no networking,
|
||||||
`/workspace/@alkdev/alknet/docs/architecture/` and renumbered as alkcall
|
no transport dependencies — dial, TLS config, and composition live in
|
||||||
ADRs (ADR-001..045). The ALPN strings (`alk/call`, `alk/channels`)
|
downstream crates. The ALPN strings (`alk/call`, `alk/channels`) are
|
||||||
are wire-stable and unchanged — see ADR-004.
|
wire-stable — see ADR-004. (Historical origin and cross-repo citation
|
||||||
|
conventions: see the provenance note at the end of this document.)
|
||||||
|
|
||||||
## Documents
|
## Documents
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ are wire-stable and unchanged — see ADR-004.
|
|||||||
| [024](decisions/024-peer-graph-routing-model.md) | Peer-Graph Routing Model | PeerCompositeEnv, PeerRef, AccessControl peer auth |
|
| [024](decisions/024-peer-graph-routing-model.md) | Peer-Graph Routing Model | PeerCompositeEnv, PeerRef, AccessControl peer auth |
|
||||||
| [025](decisions/025-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | PeerId = Identity.id (stable) |
|
| [025](decisions/025-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | PeerId = Identity.id (stable) |
|
||||||
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | Metadata only, never used by ACL |
|
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | Metadata only, never used by ACL |
|
||||||
| [027](decisions/027-from-jsonschema-as-http-adapter.md) | from_jsonschema as HTTP Adapter | FromJsonSchema provenance stays; impl in alknet-http |
|
| [027](decisions/027-from-jsonschema-as-http-adapter.md) | from_jsonschema as HTTP Adapter | FromJsonSchema provenance stays; impl moved to alkhttp (ex-`alknet-http`) |
|
||||||
| [028](decisions/028-from-call-manual-free-function.md) | from_call Is a Manual Free Function | Assembly layer calls it after dial |
|
| [028](decisions/028-from-call-manual-free-function.md) | from_call Is a Manual Free Function | Assembly layer calls it after dial |
|
||||||
| [029](decisions/029-aggregated-peer-env-wiring.md) | Aggregated Peer-Environment Wiring | Dispatcher hub wiring |
|
| [029](decisions/029-aggregated-peer-env-wiring.md) | Aggregated Peer-Environment Wiring | Dispatcher hub wiring |
|
||||||
| [030](decisions/030-peer-composite-env-peer-operations.md) | PeerCompositeEnv::peer_operations | OperationEnv::peer_operations override |
|
| [030](decisions/030-peer-composite-env-peer-operations.md) | PeerCompositeEnv::peer_operations | OperationEnv::peer_operations override |
|
||||||
@@ -82,7 +83,7 @@ are wire-stable and unchanged — see ADR-004.
|
|||||||
| [032](decisions/032-one-way-door-decision-framework.md) | One-Way Door Decision Framework | Reversal cost classification |
|
| [032](decisions/032-one-way-door-decision-framework.md) | One-Way Door Decision Framework | Reversal cost classification |
|
||||||
| [033](decisions/033-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Rust canonical, TS reference |
|
| [033](decisions/033-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Rust canonical, TS reference |
|
||||||
|
|
||||||
### Channels — ADR-034..045
|
### Channels, pub/sub, and later amendments — ADR-034..054
|
||||||
|
|
||||||
| ADR | Title | Relevance |
|
| ADR | Title | Relevance |
|
||||||
|-----|-------|-----------|
|
|-----|-------|-----------|
|
||||||
@@ -101,22 +102,39 @@ 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` |
|
| [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 |
|
| [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 |
|
| [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 |
|
||||||
|
| [052](decisions/052-channel-control-surface.md) | `channel/control` Control-Handle Surface | Establisher-contributed `ChannelControl` (`Establishment::with_control`), wrapper-installed/teardown-removed in the manager; `channel:no_control_handle` capability answer; resolves OQ-39 |
|
||||||
|
| [053](decisions/053-resource-enumerators.md) | Live Resource Enumerators | `ResourceEnumerators` (peer-shared registry); snapshot-then-changes enumerator contract; scan-once aggregation; empty seed; resolves OQ-40 |
|
||||||
|
| [054](decisions/054-outgoing-only-x509-three-remote-roles.md) | Outgoing-Only X.509 and the Three Remote Roles (Re-Owned) | Verifier-selection by `PeerEntry` presence; three remote roles; `RemoteIdentity` `None` semantics — re-owned in-crate (provenance: alknet ADR-034); resolves OQ-21's cross-repo authority gap |
|
||||||
|
|
||||||
## Relevant Open Questions
|
## Relevant Open Questions
|
||||||
|
|
||||||
See [open-questions.md](open-questions.md) for the full tracker. Key
|
See [open-questions.md](open-questions.md) for the full tracker. Key
|
||||||
questions affecting this crate:
|
questions affecting this crate:
|
||||||
|
|
||||||
- **OQ-01**: Call protocol pub/sub primitive (partially resolved) —
|
- **OQ-22** (resolved) / **OQ-42** (deferred(scope)): The call-protocol
|
||||||
ADR-046 adds the `Pub` primitive (producer→consumer streaming). The
|
pub/sub primitive is delivered (ADR-046); the fan-out/message broker
|
||||||
fan-out/broker is deferred to channels (Gap B in ADR-047 is named
|
is placed out of scope for alkcall — the hub composes it on the
|
||||||
out-of-scope for alkcall; the hub composes the broker on top).
|
`Pub`/`Sub` primitives — and its design remains deferred downstream,
|
||||||
- **OQ-02**: Full channel-level flow-control windowing (deferred(scope))
|
blocked on a concrete producing-crate consumer (Gap B in ADR-047).
|
||||||
|
- **OQ-23** (deferred(scope)): Full channel-level flow-control windowing
|
||||||
— bounded-buffer is decided (ADR-040); full windowing blocked on a real
|
— bounded-buffer is decided (ADR-040); full windowing blocked on a real
|
||||||
HOL-blocking deployment observation.
|
HOL-blocking deployment observation.
|
||||||
- **OQ-03**: Channels add/strip API shape (open) — whether the 8-byte
|
- **OQ-24** (resolved): Channels add/strip API shape — integral to the
|
||||||
header add/strip is built into the read/write path or a standalone
|
read/write path (handlers never see headers); low-level header
|
||||||
utility.
|
primitives stay in `channels::wire` for tooling.
|
||||||
|
- **OQ-37** (resolved): No `from_call` relay wrapper for marked ops —
|
||||||
|
marked announcements and marked-spec imports are refused (ADR-022
|
||||||
|
amendment 2026-10-13, ADR-047 amendment 4 §4.8); the sanctioned
|
||||||
|
compositions are `ChannelRelay::register_relay_openable` (ADR-051) and
|
||||||
|
`ChannelClient`.
|
||||||
|
- **OQ-43** (deferred(scope)): Composition-layer ownership and shape
|
||||||
|
after the alknet replacement — the named receiver for the
|
||||||
|
downstream-side deferrals (OQ-33 endpoint layer, OQ-41 QUIC-native
|
||||||
|
substrate, OQ-42 hub broker, OQ-19 persistence adapters). Not
|
||||||
|
blocking any alkcall API surface.
|
||||||
|
|
||||||
## Key Design Principles
|
## Key Design Principles
|
||||||
|
|
||||||
@@ -140,6 +158,14 @@ questions affecting this crate:
|
|||||||
channel 0. See ADR-036, ADR-037.
|
channel 0. See ADR-036, ADR-037.
|
||||||
10. **Wire formats are stable**: EventEnvelope shape and the 8-byte chunk
|
10. **Wire formats are stable**: EventEnvelope shape and the 8-byte chunk
|
||||||
header are one-way doors. See ADR-014, ADR-034.
|
header are one-way doors. See ADR-014, ADR-034.
|
||||||
|
11. **Implement-or-stop-advertising**: every registration on a
|
||||||
|
discoverable surface must be capable of succeeding — no
|
||||||
|
advertised-always-failing bodies, no deferral buckets ("not this
|
||||||
|
session" is the only permitted deferral statement). Marked
|
||||||
|
(`channel_open`) specs refuse the generic registration paths
|
||||||
|
(ADR-047 amendment 4 §4.8); the `not_implemented` guard test is
|
||||||
|
release-blocking (`tests/stub_guard.rs`). See reviews 010/011 for
|
||||||
|
the incident that pinned this.
|
||||||
|
|
||||||
## Roles and Composition
|
## Roles and Composition
|
||||||
|
|
||||||
@@ -173,8 +199,9 @@ Within a single connection, direction is independent of role:
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────┐
|
||||||
│ alknet / alknode │
|
│ Composition layer │
|
||||||
│ (networking + composition) │
|
│ (today: the alknet glue — being replaced; │
|
||||||
|
│ future owner and shape → OQ-43) │
|
||||||
│ │
|
│ │
|
||||||
│ • QUIC / TCP+TLS dial and accept │
|
│ • QUIC / TCP+TLS dial and accept │
|
||||||
│ • Hub: ChannelsAdapter + Dispatcher + │
|
│ • Hub: ChannelsAdapter + Dispatcher + │
|
||||||
@@ -182,7 +209,7 @@ Within a single connection, direction is independent of role:
|
|||||||
│ • Spoke: ChannelClient + CallConnection + │
|
│ • Spoke: ChannelClient + CallConnection + │
|
||||||
│ from_call │
|
│ from_call │
|
||||||
│ • Wires protocol crates' producers into │
|
│ • Wires protocol crates' producers into │
|
||||||
│ registries, consumers into clients │
|
│ registries, consumers into clients │
|
||||||
└──────────────────┬───────────────────────────┘
|
└──────────────────┬───────────────────────────┘
|
||||||
│ depends on
|
│ depends on
|
||||||
┌──────────────┼──────────────┐
|
┌──────────────┼──────────────┐
|
||||||
@@ -226,10 +253,12 @@ alkcall. They provide two halves:
|
|||||||
(e.g. `trader_client.status().await` instead of
|
(e.g. `trader_client.status().await` instead of
|
||||||
`call_connection.call("trader/status", ...).await`).
|
`call_connection.call("trader/status", ...).await`).
|
||||||
|
|
||||||
**alk/alknode** depends on alkcall + whichever protocol crates are
|
**The composition layer** depends on alkcall + whichever protocol
|
||||||
needed. It's the composition layer — the only place that knows about
|
crates are needed. It's the only place that knows about network
|
||||||
network topology, peer routing, and which protocol crates are wired in.
|
topology, peer routing, and which protocol crates are wired in.
|
||||||
Protocol crates never import a QUIC or TLS dependency.
|
Protocol crates never import a QUIC or TLS dependency. Today this
|
||||||
|
layer is the alknet glue; its post-replacement owner and shape is
|
||||||
|
tracked as OQ-43.
|
||||||
|
|
||||||
### Pattern for protocol crates
|
### Pattern for protocol crates
|
||||||
|
|
||||||
@@ -266,27 +295,39 @@ A protocol crate that uses channels (e.g. alktty) follows this pattern:
|
|||||||
The protocol crate doesn't know which path it's on. It takes a
|
The protocol crate doesn't know which path it's on. It takes a
|
||||||
`BiStream` (or `AsyncRead + AsyncWrite`) and drives the session. The
|
`BiStream` (or `AsyncRead + AsyncWrite`) and drives the session. The
|
||||||
two adapters are thin and live either in the protocol crate (behind
|
two adapters are thin and live either in the protocol crate (behind
|
||||||
feature flags) or in the downstream alknet crate.
|
feature flags) or in the composition layer.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- `@alkdev/alknet: docs/architecture/` — the source architecture docs
|
|
||||||
these were ported from (renumbered from alknet ADR-001..094 to alkcall
|
|
||||||
ADR-001..045)
|
|
||||||
- `@alkdev/alktype` — the binary struct engine; compiles BAST documents
|
- `@alkdev/alktype` — the binary struct engine; compiles BAST documents
|
||||||
(e.g. `chunk-header.bast.json`) into readers/writers/validators
|
(e.g. `chunk-header.bast.json`) into readers/writers/validators
|
||||||
|
- `@alkdev/alkhttp`, `@alkdev/alktls`, `@alkdev/alktty`,
|
||||||
|
`@alkdev/alktunnels`, `@alkdev/alktrader` — sibling crates; the
|
||||||
|
live homes of the surfaces older docs call `alknet-http`,
|
||||||
|
`alknet-tls`, `alknet-tty`, `alknet-tunnels`, `alknet-trader`
|
||||||
- `@alkdev/pubsub` — the TypeScript EventEnvelope prior art the call
|
- `@alkdev/pubsub` — the TypeScript EventEnvelope prior art the call
|
||||||
wire format was derived from
|
wire format was derived from
|
||||||
|
|
||||||
> **Note**: The source ADRs and spec docs were ported from the parent
|
> **Provenance and cross-repo citations.** The source ADRs and spec
|
||||||
> `@alkdev/alknet` workspace where this crate originated. They are
|
> docs were ported from the parent `@alkdev/alknet` workspace where
|
||||||
> preserved here as the authoritative spec for alkcall; the alknet
|
> this crate originated, and renumbered to alkcall ADR-001..048
|
||||||
> mono-repo will consume alkcall's versions when it is reworked.
|
> (ADR-049 and later were authored in this crate). They are the
|
||||||
|
> authoritative spec here; the alknet mono-repo is being replaced by
|
||||||
|
> thin glue over alkcall and its siblings, so alknet-specific citations
|
||||||
|
> are expected to decay into footnotes over time, and anything that
|
||||||
|
> genuinely constrains alkcall's surface gets re-owned by an in-crate
|
||||||
|
> ADR (e.g. ADR-054 re-owns alknet ADR-034's verifier-selection rule).
|
||||||
>
|
>
|
||||||
> **Cross-references to non-ported ADRs**: Some spec docs and ADRs
|
> **Citation conventions**: alkcall's own ADRs are `decisions/001..054`
|
||||||
> reference alknet ADRs by their original numbers (e.g., ADR-052 for
|
> in this directory. Some ported docs also cite source-repo ADRs that
|
||||||
> TTY's wire format, ADR-082 for alknet-tls, ADR-086 for endpoint types).
|
> were **not** ported (e.g. alknet ADR-052 for TTY's wire format). The
|
||||||
> These are ADRs for sibling crates that are not part of alkcall. They
|
> alknet number space collides with alkcall's (alknet ADR-052 has
|
||||||
> retain their alknet numbering (052, 082, 086, etc.) — any ADR number
|
> nothing to do with alkcall's `channel/control` ADR-052), so unambiguous
|
||||||
> outside the alkcall range 001..045 is an alknet source ADR, found at
|
> references carry the `alknet` prefix (e.g. "alknet ADR-052") and link
|
||||||
> `/workspace/@alkdev/alknet/docs/architecture/decisions/`.
|
> to the alknet repo: `/workspace/@alkdev/alknet/docs/architecture/`
|
||||||
|
> (a few legacy cites without the prefix still mean the alknet ADR —
|
||||||
|
> check the link target). Dead relative paths like
|
||||||
|
> `docs/research/alknet-channels/...` live in the alknet repo, not this
|
||||||
|
> one. Similarly, `alknet OQ-NN` citations refer to the alknet repo's
|
||||||
|
> question tracker — this crate's OQ tracker runs its own numbering
|
||||||
|
> (OQ-01..43, see [open-questions.md](open-questions.md)).
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-17
|
last_updated: 2026-10-13 (alkcall reframing — historical alknet framing moved to the provenance note)
|
||||||
review: call/review-call passed 2026-06-23 — registry, protocol, ADR (005/012/014/015/016/017/022/023/024), security, and pattern-consistency checks all conformant; 159 unit/integration tests green; `cargo build`, `cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo test` clean. Call-completion gap (ADR-022 client/adapter surface) addressed 2026-06-26; ADR-024 migration landed. Transport generalization sweep (ADR-014 supersedes ADR-013; ADR-007 `from_stream`) synced 2026-07-09. Crate-extraction sweep (phases 0–5) landed 2026-07-17: `ConnectionCredentials`/`RemoteIdentity` in `alknet-core` (ADR-012); TLS helpers in `alknet-tls` (ADR-045 §5); dial in `alknet-client` (ADR-045); `alknet-call` is a pure protocol crate with no TLS/transport deps.
|
review: call/review-call passed 2026-06-23 — registry, protocol, ADR (005/012/014/015/016/017/022/023/024), security, and pattern-consistency checks all conformant; 159 unit/integration tests green; `cargo build`, `cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo test` clean. Call-completion gap (ADR-022 client/adapter surface) addressed 2026-06-26; ADR-024 migration landed. Transport generalization sweep (ADR-014 supersedes ADR-013; ADR-007 `from_stream`) synced 2026-07-09. Crate-extraction sweep (phases 0–5) landed 2026-07-17: `ConnectionCredentials`/`RemoteIdentity` vendored into alkcall's core types (ADR-012); dial extracted as the composition layer's helper (ADR-045); alkcall is a pure protocol crate with no TLS/transport deps.
|
||||||
---
|
---
|
||||||
|
|
||||||
# alknet-call
|
# alkcall — Call Protocol
|
||||||
|
|
||||||
Structured RPC: operations, request/response, streaming subscriptions, and service discovery. Implements `ProtocolHandler` on ALPN `alk/call`. Runs over QUIC (quinn/iroh) and, via `Connection::from_stream` (ADR-007), over any `AsyncRead + AsyncWrite` transport. A pure protocol crate — no TLS or transport deps (the dial is in `alknet-client`, the TLS config is in `alknet-tls`).
|
Structured RPC: operations, request/response, streaming subscriptions, and service discovery. Implements `ProtocolHandler` on ALPN `alk/call`. Runs over QUIC (quinn/iroh) and, via `Connection::from_stream` (ADR-007), over any `AsyncRead + AsyncWrite` transport. A pure protocol crate — no TLS or transport deps. The dial and TLS config belong to the composition layer (the dial helper is `AlknetClient`; the TLS config types live in the sibling host crate `alktls` — see ADR-045 and OQ-43).
|
||||||
|
|
||||||
## Documents
|
## Documents
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
|
|||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
| [call-protocol.md](call-protocol.md) | draft | CallAdapter, hand-rolled EventEnvelope framing (no irpc — ADR-014), stream model, PendingRequestMap, bidirectional calls |
|
| [call-protocol.md](call-protocol.md) | draft | CallAdapter, hand-rolled EventEnvelope framing (no irpc — ADR-014), stream model, PendingRequestMap, bidirectional calls |
|
||||||
| [operation-registry.md](operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, hand-rolled framing (no irpc — ADR-014) |
|
| [operation-registry.md](operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, hand-rolled framing (no irpc — ADR-014) |
|
||||||
| [client-and-adapters.md](client-and-adapters.md) | draft | CallClient (transport-agnostic `spawn_dispatch` primary; dial lives in `AlknetClient` per ADR-045), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (`from_jsonschema` in alknet-http per ADR-027) |
|
| [client-and-adapters.md](client-and-adapters.md) | draft | CallClient (transport-agnostic `spawn_dispatch` primary; dial lives in the composition layer's dial helper per ADR-045), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (`from_jsonschema` in alkhttp per ADR-027) |
|
||||||
|
|
||||||
## Applicable ADRs
|
## Applicable ADRs
|
||||||
|
|
||||||
@@ -22,54 +22,53 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
|
|||||||
|-----|-------|-----------|
|
|-----|-------|-----------|
|
||||||
| [001](decisions/001-alpn-protocol-dispatch.md) | ALPN-Based Protocol Dispatch | CallAdapter registers on ALPN `alk/call` |
|
| [001](decisions/001-alpn-protocol-dispatch.md) | ALPN-Based Protocol Dispatch | CallAdapter registers on ALPN `alk/call` |
|
||||||
| [002](decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | CallAdapter implements ProtocolHandler |
|
| [002](decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | CallAdapter implements ProtocolHandler |
|
||||||
| [003](decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-call depends on alknet-core (no irpc — ADR-014) |
|
| [031](decisions/031-crate-decomposition.md) | Crate Decomposition | alkcall is self-contained: call + channels subsystems + vendored core types (no irpc — ADR-014) |
|
||||||
| [013](decisions/013-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Adapter traits defined in Rust; TS is reference/browser adaptation |
|
| [033](decisions/033-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Adapter traits defined in Rust; TS is reference/browser adaptation |
|
||||||
| [004](decisions/004-auth-as-shared-core.md) | Auth as Shared Core | AuthContext passed to call handlers |
|
| [003](decisions/003-auth-as-shared-core.md) | Auth as Shared Core | AuthContext passed to call handlers |
|
||||||
| [005](decisions/005-irpc-as-call-protocol-foundation.md) | ~~irpc as Call Protocol Foundation~~ | ~~Accepted~~ → **Superseded** by [ADR-014](decisions/064-irpc-never-integrated-hand-rolled-framing.md) (irpc was never integrated; framing is hand-rolled) |
|
| [013](decisions/013-irpc-as-call-protocol-foundation.md) | ~~irpc as Call Protocol Foundation~~ | ~~Accepted~~ → **Superseded** by [ADR-014](decisions/014-irpc-never-integrated-hand-rolled-framing.md) (irpc was never integrated; framing is hand-rolled) |
|
||||||
| [064](decisions/064-irpc-never-integrated-hand-rolled-framing.md) | Hand-Rolled EventEnvelope Framing | Wire format, registry, dispatch are hand-rolled in alknet-call; supersedes ADR-013 |
|
| [014](decisions/014-irpc-never-integrated-hand-rolled-framing.md) | Hand-Rolled EventEnvelope Framing | Wire format, registry, dispatch are hand-rolled in alkcall; supersedes ADR-013 |
|
||||||
| [065](decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | Generic single-stream connections; unblocks TCP+TLS/SSH/WT/wasm dispatch |
|
| [007](decisions/007-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | Generic single-stream connections; unblocks TCP+TLS/SSH/WT/wasm dispatch |
|
||||||
| [006](decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention | `alk/call` ALPN, one ALPN per connection |
|
| [004](decisions/004-alpn-convention-and-connection-model.md) | ALPN String Convention | `alk/call` ALPN, one ALPN per connection |
|
||||||
| [007](decisions/007-bistream-type-definition.md) | BiStream Type Definition | CallAdapter receives Connection, not BiStream |
|
| [005](decisions/005-bistream-type-definition.md) | BiStream Type Definition | CallAdapter receives Connection, not BiStream |
|
||||||
| [008](decisions/008-secret-service-integration.md) | Vault Integration Point | Vault accessed at assembly layer, not on the wire |
|
| [015](decisions/015-call-protocol-stream-model.md) | Call Protocol Stream Model | Bidirectional streams, EventEnvelope, ID-based correlation |
|
||||||
| [010](decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Static handler registration |
|
| [010](decisions/010-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Call protocol carries no secret material; capabilities injected at assembly layer |
|
||||||
| [012](decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | Bidirectional streams, EventEnvelope, ID-based correlation |
|
| [017](decisions/017-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
|
||||||
| [014](decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Call protocol carries no secret material; capabilities injected at assembly layer |
|
| [020](decisions/020-abort-cascade-for-nested-calls.md) | Abort Cascade for Nested Calls | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
|
||||||
| [015](decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
|
| [022](decisions/022-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction |
|
||||||
| [016](decisions/016-abort-cascade-for-nested-calls.md) | Abort Cascade for Nested Calls | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
|
| [027](decisions/027-from-jsonschema-as-http-adapter.md) | `from_jsonschema` as HTTP-Backed Single-Endpoint Adapter | Moved `from_jsonschema` out of the call protocol (broken schema-only placeholder) to the HTTP host crate as a real reqwest-backed single-endpoint adapter — today `alkhttp` (ex-`alknet-http`); `FromJsonSchema` provenance stays in alkcall as a leaf |
|
||||||
| [017](decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction |
|
| [018](decisions/018-handler-registration-provenance-and-composition-authority.md) | Handler Registration, Provenance, and Composition Authority | Registration bundle carries provenance, composition authority, scoped env, capabilities |
|
||||||
| [066](decisions/066-from-jsonschema-as-http-adapter.md) | `from_jsonschema` as HTTP-Backed Single-Endpoint Adapter in alknet-http | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf |
|
| [016](decisions/016-operation-error-schemas.md) | Operation Error Schemas | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity |
|
||||||
| [022](decisions/022-handler-registration-provenance-and-composition-authority.md) | Handler Registration, Provenance, and Composition Authority | Registration bundle carries provenance, composition authority, scoped env, capabilities |
|
| [019](decisions/019-operation-registry-layering.md) | Operation Registry Layering | Curated (static) + session/connection overlays (dynamic); `OperationEnv` as trait-object integration point; `OperationContext.env` split into `scoped_env` (data) and `env` (dispatch trait) |
|
||||||
| [023](decisions/023-operation-error-schemas.md) | Operation Error Schemas | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity |
|
| [023](decisions/023-callclient-peer-scoped-registry-filtering.md) | ~~Peer-Scoped Registry Filtering~~ | ~~Accepted~~ → **Superseded** by ADR-024 (flat-namespace single-peer model couldn't express head→N-workers; parallel auth system duplicated `AccessControl`) |
|
||||||
| [024](decisions/024-operation-registry-layering.md) | Operation Registry Layering | Curated (static) + session/connection overlays (dynamic); `OperationEnv` as trait-object integration point; `OperationContext.env` split into `scoped_env` (data) and `env` (dispatch trait) |
|
| [024](decisions/024-peer-graph-routing-model.md) | Peer-Graph Routing Model | Peer-keyed overlays + `PeerRef` routing; `AccessControl`-based peer authorization; retires `remote_safe`/`trusted_peer` |
|
||||||
| [028](decisions/028-callclient-peer-scoped-registry-filtering.md) | ~~Peer-Scoped Registry Filtering~~ | ~~Accepted~~ → **Superseded** by ADR-024 (flat-namespace single-peer model couldn't express head→N-workers; parallel auth system duplicated `AccessControl`) |
|
| [025](decisions/025-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | `PeerId` source = `Identity.id` = `PeerEntry.peer_id` (stable); supersedes ADR-024's UUID source |
|
||||||
| [029](decisions/029-peer-graph-routing-model.md) | Peer-Graph Routing Model | Peer-keyed overlays + `PeerRef` routing; `AccessControl`-based peer authorization; retires `remote_safe`/`trusted_peer` |
|
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | `forwarded_for` on `OperationContext` and `call.requested`; metadata only, never used by `AccessControl::check` |
|
||||||
| [030](decisions/030-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | `PeerId` source = `Identity.id` = `PeerEntry.peer_id` (stable); supersedes ADR-024's UUID source |
|
| [045](decisions/045-alknetclient-native-dial-seam.md) | Dial Seam — Dial Out of the Protocol Crate | The dial lives in the composition layer's dial helper (`AlknetClient`, today in the alknet glue — future owner OQ-43); `CallClient` is `spawn_dispatch` only; alkcall is a pure protocol crate with no TLS/transport deps |
|
||||||
| [032](decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | `forwarded_for` on `OperationContext` and `call.requested`; metadata only, never used by `AccessControl::check` |
|
| [012](decisions/012-connectioncredentials-decouple-dial-from-call.md) | `ConnectionCredentials` — Decouple Dial from Call Protocol | `ConnectionCredentials`/`RemoteIdentity` live with the vendored core types in alkcall (originally `alknet-core`); `auth_token` is a per-request payload field |
|
||||||
| [033](decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Storage Boundary and Repo/Adapter Pattern | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
|
|
||||||
| [089](decisions/089-alknetclient-native-dial-seam.md) | AlknetClient — Native Client Dial Seam | The dial is in `alknet-client`; `CallClient` is `spawn_dispatch` only; `alknet-call` is a pure protocol crate with no TLS/transport deps |
|
Provenance (source-repo ADRs, not ported): [alknet ADR-008 — Vault Integration Point](/workspace/@alkdev/alknet/docs/architecture/decisions/008-secret-service-integration.md) (superseded in-crate by ADR-010) and [alknet ADR-010 — ALPN Router and Endpoint](/workspace/@alkdev/alknet/docs/architecture/decisions/010-alpn-router-and-endpoint.md) (superseded in-crate by ADR-001's ALPN dispatch and the decision that alkcall has no endpoint layer) are historical origins only. [alknet ADR-033 — Storage Boundary and Repo/Adapter Pattern](/workspace/@alkdev/alknet/docs/architecture/decisions/033-storage-boundary-and-repo-adapter-pattern.md) is the repo/adapter pattern behind the `OwnershipProvider` persistence story (OQ-19); its persistence adapters belong to the composition layer (OQ-43).
|
||||||
| [091](decisions/091-connectioncredentials-decouple-dial-from-call.md) | `ConnectionCredentials` — Decouple Dial from Call Protocol | `ConnectionCredentials`/`RemoteIdentity` in `alknet-core` (not `alknet-call`); `auth_token` is a per-request payload field |
|
|
||||||
|
|
||||||
## Relevant Open Questions
|
## Relevant Open Questions
|
||||||
|
|
||||||
| OQ | Title | Status | Relevance |
|
| OQ | Title | Status | Relevance |
|
||||||
|----|-------|--------|-----------|
|
|----|-------|--------|-----------|
|
||||||
| OQ-07 | Call protocol scope within a connection | resolved (ADR-015) | Stream model, multiplexing, scope |
|
| OQ-01 | Call protocol scope within a connection | resolved (ADR-015) | Stream model, multiplexing, scope |
|
||||||
| OQ-13 | Operation path format and routing scope | resolved | `/{service}/{op}` is the correct design; remote dispatch is a separate layer |
|
| OQ-02 | Operation path format and routing scope | resolved | `/{service}/{op}` is the correct design; remote dispatch is a separate layer |
|
||||||
| OQ-14 | Batch operation semantics | resolved | Correlated `call.requested` events is the correct protocol design |
|
| OQ-03 | Batch operation semantics | resolved | Correlated `call.requested` events is the correct protocol design |
|
||||||
| OQ-16 | Safe vault operations for call protocol exposure | resolved (ADR-010) | None exposed for now |
|
| OQ-04 | Session-scoped operation registries | resolved | Agent-written operations overlaid on curated registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Generalized by ADR-019 to cover connection-scoped overlays. |
|
||||||
| OQ-19 | Session-scoped operation registries | resolved | Agent-written operations overlaid on curated registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Generalized by ADR-019 to cover connection-scoped overlays. |
|
| OQ-09 | Safe vault operations for call protocol exposure | resolved (ADR-010) | None exposed for now |
|
||||||
| OQ-25 | ~~Remote-safe marking shape~~ | **dissolved** (ADR-024) | `remote_safe`/`trusted_peer` retired; peer authorization is `AccessControl::check(peer_identity)` |
|
| OQ-10 | ~~Remote-safe marking shape~~ | **dissolved** (ADR-024) | `remote_safe`/`trusted_peer` retired; peer authorization is `AccessControl::check(peer_identity)` |
|
||||||
| OQ-26 | OperationAdapter error type (AdapterError variants) | **resolved** | `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`; `#[non_exhaustive]` |
|
| OQ-11 | OperationAdapter error type (AdapterError variants) | **resolved** | `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`; `#[non_exhaustive]` |
|
||||||
| OQ-27 | from_call re-import trigger | **resolved** | `from_call` is a manual free function; the assembly layer calls it after the dial (in `AlknetClient`). `refresh()` is a genuine feature addition. See ADR-028. |
|
| OQ-12 | from_call re-import trigger | **resolved** | `from_call` is a manual free function; the assembly layer calls it after the dial (in the composition layer's dial helper, `AlknetClient` — today the alknet glue; OQ-43). `refresh()` is a genuine feature addition. See ADR-028. |
|
||||||
| OQ-28 | from_call namespace collision | **resolved** | Same-peer collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays) |
|
| OQ-13 | from_call namespace collision | **resolved** | Same-peer collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays) |
|
||||||
| OQ-29 | CallClient TLS client-auth | **resolved** | Wire quinn client-auth; key-type-aware server cert verification; fingerprint normalization |
|
| OQ-14 | CallClient TLS client-auth | **resolved** | Wire quinn client-auth; key-type-aware server cert verification; fingerprint normalization |
|
||||||
| OQ-30 | `PeerRef::Any` routing policy | **resolved** | Insertion-order first-match; richer routing is a feature extension |
|
| OQ-15 | `PeerRef::Any` routing policy | **resolved** | Insertion-order first-match; richer routing is a feature extension |
|
||||||
| OQ-31 | `services/list-peers` re-export semantics | **resolved** | Opt-in `services/list-peers`; `services/list` is "own ops only" |
|
| OQ-16 | `services/list-peers` re-export semantics | **resolved** | Opt-in `services/list-peers`; `services/list` is "own ops only" |
|
||||||
| OQ-32 | Multi-hop federation | open (feature extension) | One-hop model is the commitment; multi-hop is a feature extension, not a deferral |
|
| OQ-17 | Multi-hop federation | deferred(scope) (feature extension) | One-hop model is the commitment; multi-hop is a feature extension, not a deferral |
|
||||||
| OQ-33 | PeerId — crypto identity vs stable logical id | **resolved** (ADR-025) | `PeerId = Identity.id = PeerEntry.peer_id` (stable across key rotation) |
|
| OQ-18 | PeerId — crypto identity vs stable logical id | **resolved** (ADR-025) | `PeerId = Identity.id = PeerEntry.peer_id` (stable across key rotation) |
|
||||||
| OQ-34 | Persistent peer registry | **resolved** (ADR-025+033) | Core trait + in-memory default; persistence adapters are separate crates |
|
| OQ-19 | Persistent peer registry | **resolved** (ADR-025; persistence adapters are composition-layer work — OQ-43) | Core trait + in-memory default; persistence adapters are separate crates |
|
||||||
| OQ-35 | ~~API key asymmetry~~ | **dissolved** | `PeerEntry` supports multiple credential paths; `ApiKeyEntry` is for tokens that ARE the identity |
|
| OQ-20 | ~~API key asymmetry~~ | **dissolved** | `PeerEntry` supports multiple credential paths; `ApiKeyEntry` is for tokens that ARE the identity |
|
||||||
| OQ-37 | X.509 outgoing-only case | **resolved** (ADR-034) | Three remote roles (public X.509 endpoint, transport relay, hub); `PeerEntry` asymmetry correct; verifier by `PeerEntry` presence |
|
| OQ-21 | X.509 outgoing-only case | **resolved** (ADR-054; provenance: alknet ADR-034, not ported) | Three remote roles (public X.509 endpoint, transport relay, hub); `PeerEntry` asymmetry correct; verifier by `PeerEntry` presence — re-owned in-crate by ADR-054 |
|
||||||
|
|
||||||
## Key Design Principles
|
## Key Design Principles
|
||||||
|
|
||||||
@@ -78,12 +77,12 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
|
|||||||
3. **Stream-agnostic correlation**: PendingRequestMap correlates by request ID, not by stream. The protocol works with any stream arrangement.
|
3. **Stream-agnostic correlation**: PendingRequestMap correlates by request ID, not by stream. The protocol works with any stream arrangement.
|
||||||
4. **Operation registry is layered**: The curated layer (`Local` provenance) is static — registered at startup by the CLI binary, immutable for the process lifetime. Session (`Session`) and imported (`FromCall` etc.) ops are dynamic overlays at their respective scopes (per-session, per-connection). The registry supports JSON Schema discovery. See ADR-019.
|
4. **Operation registry is layered**: The curated layer (`Local` provenance) is static — registered at startup by the CLI binary, immutable for the process lifetime. Session (`Session`) and imported (`FromCall` etc.) ops are dynamic overlays at their respective scopes (per-session, per-connection). The registry supports JSON Schema discovery. See ADR-019.
|
||||||
5. **Hand-rolled dispatch (no irpc)**: Operations dispatch through the hand-rolled `OperationRegistry` (ADR-014). The call protocol is the external interface; internal handler dispatch uses `Handler`/`StreamingHandler` trait objects (ADR-021), not an irpc service.
|
5. **Hand-rolled dispatch (no irpc)**: Operations dispatch through the hand-rolled `OperationRegistry` (ADR-014). The call protocol is the external interface; internal handler dispatch uses `Handler`/`StreamingHandler` trait objects (ADR-021), not an irpc service.
|
||||||
6. **Local dispatch only**: The operation registry dispatches to local handlers. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer, not a modification to alknet-call's path format.
|
6. **Local dispatch only**: The operation registry dispatches to local handlers. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer, not a modification to alkcall's path format.
|
||||||
7. **No secret material on the wire**: The call protocol carries no private keys, API keys, mnemonics, or decrypted credentials. Handlers receive outbound credentials through `OperationContext.capabilities`, injected at the assembly layer. See ADR-010.
|
7. **No secret material on the wire**: The call protocol carries no private keys, API keys, mnemonics, or decrypted credentials. Handlers receive outbound credentials through `OperationContext.capabilities`, injected at the assembly layer. See ADR-010.
|
||||||
8. **Abort cascades to descendants**: `call.aborted` for a parent request cascades to all non-terminal descendants. Default `abort-dependents`; `continue-running` opt-in. See ADR-020.
|
8. **Abort cascades to descendants**: `call.aborted` for a parent request cascades to all non-terminal descendants. Default `abort-dependents`; `continue-running` opt-in. See ADR-020.
|
||||||
9. **Internal calls switch authority context, not skip ACL**: The `internal` flag marks composition-originated calls. ACL runs against the handler's composition authority, not the caller's and not as a blanket skip. Operations have External/Internal visibility. Scoped composition env bounds reachability. See ADR-017, ADR-018.
|
9. **Internal calls switch authority context, not skip ACL**: The `internal` flag marks composition-originated calls. ACL runs against the handler's composition authority, not the caller's and not as a blanket skip. Operations have External/Internal visibility. Scoped composition env bounds reachability. See ADR-017, ADR-018.
|
||||||
10. **Provenance determines composition capability**: Only `Local` and `Session` ops can compose. Leaves (`FromOpenAPI`, `FromMCP`, `FromCall`, `FromJsonSchema`) are forwarding stubs — they don't get composition authority or a scoped env. The assembly layer is the sole grantor of composition authority. See ADR-018. (`FromJsonSchema` is now a real HTTP-forwarding leaf per ADR-027, not a schema-only placeholder.)
|
10. **Provenance determines composition capability**: Only `Local` and `Session` ops can compose. Leaves (`FromOpenAPI`, `FromMCP`, `FromCall`, `FromJsonSchema`) are forwarding stubs — they don't get composition authority or a scoped env. The assembly layer is the sole grantor of composition authority. See ADR-018. (`FromJsonSchema` is now a real HTTP-forwarding leaf per ADR-027, not a schema-only placeholder.)
|
||||||
11. **Connection direction is independent of call direction**: Who opens the connection is a connection-layer concern, not a protocol-layer concern. Both sides can call each other once connected. The `CallAdapter` accepts connections; the `CallClient` takes them over (`spawn_dispatch` primary; dial in `AlknetClient` per ADR-045); both produce the same `CallConnection` and dispatch through the same loop. See ADR-022, [client-and-adapters.md](client-and-adapters.md).
|
11. **Connection direction is independent of call direction**: Who opens the connection is a connection-layer concern, not a protocol-layer concern. Both sides can call each other once connected. The `CallAdapter` accepts connections; the `CallClient` takes them over (`spawn_dispatch` primary; dial in `AlknetClient` per ADR-045); both produce the same `CallConnection` and dispatch through the same loop. See ADR-022, [client-and-adapters.md](client-and-adapters.md).
|
||||||
12. **Peer authorization via `AccessControl`**: A remote peer's call is authorized by `AccessControl::check(peer_identity)` against the op's `AccessControl` — the same mechanism that gates every other call. No `remote_safe` flag, no `trusted_peer` bypass. An op with `AccessControl::default()` is callable by any peer; an op with `required_scopes` is callable only by peers whose `Identity.scopes` satisfy them; an op with `Visibility::Internal` is never callable from the wire. See ADR-024.
|
12. **Peer authorization via `AccessControl`**: A remote peer's call is authorized by `AccessControl::check(peer_identity)` against the op's `AccessControl` — the same mechanism that gates every other call. No `remote_safe` flag, no `trusted_peer` bypass. An op with `AccessControl::default()` is callable by any peer; an op with `required_scopes` is callable only by peers whose `Identity.scopes` satisfy them; an op with `Visibility::Internal` is never callable from the wire. See ADR-024.
|
||||||
13. **Adapter trait lives with the types; implementations live with their transport**: `OperationAdapter` is in `alknet-call`; `from_call` is in `alknet-call` (QUIC); `from_jsonschema`/`from_openapi`/`from_mcp`/`to_openapi`/`to_mcp` are in `alknet-http` (reqwest / axum). `alknet-call` stays lean — no HTTP client, no HTTP server. (`from_jsonschema` was originally in `alknet-call` as a schema-only placeholder; ADR-027 moved it to `alknet-http` as a real HTTP-backed adapter.) See [client-and-adapters.md](client-and-adapters.md).
|
13. **Adapter trait lives with the types; implementations live with their transport**: `OperationAdapter` is in alkcall; `from_call` is in alkcall (QUIC); `from_jsonschema`/`from_openapi`/`from_mcp`/`to_openapi`/`to_mcp` are in the HTTP host crate, `alkhttp` (reqwest / axum). alkcall stays lean — no HTTP client, no HTTP server. (`from_jsonschema` was originally in the call protocol as a schema-only placeholder; ADR-027 moved it to `alkhttp` — then named `alknet-http` — as a real HTTP-backed adapter.) See [client-and-adapters.md](client-and-adapters.md).
|
||||||
14. **No handler reads outbound credentials from any source other than `OperationContext.capabilities`** (no-env-vars invariant): the credential injection path is vault → assembly layer → `Capabilities` → `HandlerRegistration.capabilities` → `OperationContext.capabilities` → handler. Downstream consumers' `std::env::var` reads are unreachable because the assembly layer never calls `Default::default()`. See ADR-010, [client-and-adapters.md](client-and-adapters.md).
|
14. **No handler reads outbound credentials from any source other than `OperationContext.capabilities`** (no-env-vars invariant): the credential injection path is vault → assembly layer → `Capabilities` → `HandlerRegistration.capabilities` → `OperationContext.capabilities` → handler. Downstream consumers' `std::env::var` reads are unreachable because the assembly layer never calls `Default::default()`. See ADR-010, [client-and-adapters.md](client-and-adapters.md).
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-09
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# Call Protocol
|
# Call Protocol
|
||||||
|
|
||||||
The wire protocol, stream model, framing, and adapter that alknet-call implements on ALPN `alk/call`.
|
The wire protocol, stream model, framing, and adapter that alkcall implements on ALPN `alk/call`.
|
||||||
|
|
||||||
## What
|
## What
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ The `CallAdapter` implements `ProtocolHandler` for ALPN `alk/call`. It receives
|
|||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
The call protocol is the primary programmatic interface to an alknet node. While SSH provides interactive shell access and HTTP provides REST APIs, the call protocol provides structured, discoverable RPC — the same interface that NAPI clients, MCP tools, and other automation consumers use.
|
The call protocol is the primary programmatic interface to an alk node. While SSH provides interactive shell access and HTTP provides REST APIs, the call protocol provides structured, discoverable RPC — the same interface that NAPI clients, MCP tools, and other automation consumers use.
|
||||||
|
|
||||||
The protocol must be:
|
The protocol must be:
|
||||||
- **Cross-language**: JSON wire format consumable from TypeScript, Python, any language
|
- **Cross-language**: JSON wire format consumable from TypeScript, Python, any language
|
||||||
@@ -40,7 +40,7 @@ pub struct CallAdapter {
|
|||||||
registry: Arc<OperationRegistry>,
|
registry: Arc<OperationRegistry>,
|
||||||
identity_provider: Arc<dyn IdentityProvider>,
|
identity_provider: Arc<dyn IdentityProvider>,
|
||||||
/// Layer 1 — optional session-overlay source (agent crate supplies this;
|
/// Layer 1 — optional session-overlay source (agent crate supplies this;
|
||||||
/// None for non-agent deployments). See ADR-019, OQ-19.
|
/// None for non-agent deployments). See ADR-019, OQ-04.
|
||||||
session_source: Option<Arc<dyn SessionOverlaySource + Send + Sync>>,
|
session_source: Option<Arc<dyn SessionOverlaySource + Send + Sync>>,
|
||||||
/// Default timeout for wire calls (30s). Composed calls inherit the
|
/// Default timeout for wire calls (30s). Composed calls inherit the
|
||||||
/// parent's remaining deadline via `OperationContext.deadline`.
|
/// parent's remaining deadline via `OperationContext.deadline`.
|
||||||
@@ -57,8 +57,9 @@ impl CallAdapter {
|
|||||||
default_timeout: Duration::from_secs(30) }
|
default_timeout: Duration::from_secs(30) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Agent deployment: supply a session-overlay source. The agent crate
|
/// Agent deployment: supply a session-overlay source. A downstream
|
||||||
/// implements `SessionOverlaySource`; alknet-call defines the trait.
|
/// consumer (e.g. the agent crate) implements `SessionOverlaySource`;
|
||||||
|
/// alkcall defines the trait.
|
||||||
pub fn with_session_source(mut self, source: Arc<dyn SessionOverlaySource + Send + Sync>) -> Self {
|
pub fn with_session_source(mut self, source: Arc<dyn SessionOverlaySource + Send + Sync>) -> Self {
|
||||||
self.session_source = Some(source);
|
self.session_source = Some(source);
|
||||||
self
|
self
|
||||||
@@ -71,10 +72,11 @@ impl CallAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Session overlay integration point (ADR-019). Defined in alknet-call
|
/// Session overlay integration point (ADR-019). Defined in alkcall
|
||||||
/// because `CallAdapter` must name the type — alknet-call cannot depend on
|
/// because `CallAdapter` must name the type — alkcall cannot depend on
|
||||||
/// alknet-agent (agent depends on call, not reverse). The agent crate
|
/// its downstream consumers (an agent depends on call, not reverse). The
|
||||||
/// implements this trait; alknet-call defines it. This is the same pattern
|
/// consumer (e.g. the agent crate) implements this trait; alkcall
|
||||||
|
/// defines it. This is the same pattern
|
||||||
/// as `IdentityProvider` (ADR-003: core defines the trait, handlers impl it).
|
/// as `IdentityProvider` (ADR-003: core defines the trait, handlers impl it).
|
||||||
///
|
///
|
||||||
/// The session overlay is an `OperationEnv` impl that wraps the curated base
|
/// The session overlay is an `OperationEnv` impl that wraps the curated base
|
||||||
@@ -129,18 +131,16 @@ pub struct CallConnection {
|
|||||||
impl CallConnection {
|
impl CallConnection {
|
||||||
/// Register an imported operation into this connection's overlay
|
/// Register an imported operation into this connection's overlay
|
||||||
/// (Layer 2, ADR-019). Called by `from_call` after discovery.
|
/// (Layer 2, ADR-019). Called by `from_call` after discovery.
|
||||||
pub fn register_imported(&self, registration: HandlerRegistration) {
|
/// Refuses marked (`channel_open`) specs — they are not
|
||||||
let name = registration.spec.name.clone();
|
/// overlay-importable (ADR-047 amendment 4 §4.8).
|
||||||
self.imported_operations.write().insert(name, registration);
|
pub fn register_imported(&self, registration: HandlerRegistration)
|
||||||
}
|
-> Result<(), String>;
|
||||||
|
|
||||||
/// Register multiple imported operations (bulk variant for `from_call`).
|
/// Register multiple imported operations (bulk variant for `from_call`).
|
||||||
pub fn register_imported_all(&self, registrations: Vec<HandlerRegistration>) {
|
/// Same marked-spec refusal; on refusal the entries inserted before the
|
||||||
let mut overlay = self.imported_operations.write();
|
/// marked one stay in the overlay and the error is returned.
|
||||||
for reg in registrations {
|
pub fn register_imported_all(&self, registrations: Vec<HandlerRegistration>)
|
||||||
overlay.insert(reg.spec.name.clone(), reg);
|
-> Result<(), String>;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build an `OperationEnv` impl for this connection's overlay. Used by
|
/// Build an `OperationEnv` impl for this connection's overlay. Used by
|
||||||
/// the `CallAdapter` when composing the root `OperationContext.env`.
|
/// the `CallAdapter` when composing the root `OperationContext.env`.
|
||||||
@@ -167,6 +167,24 @@ Layer-0-only; runtime overlay registration uses `CallConnection` methods.
|
|||||||
When the connection drops, the overlay (and all imported ops) is dropped —
|
When the connection drops, the overlay (and all imported ops) is dropped —
|
||||||
no explicit deregistration needed.
|
no explicit deregistration needed.
|
||||||
|
|
||||||
|
**Marked-spec registration refusal** (ADR-047 amendment 4 §4.8; ADR-022
|
||||||
|
amendment 2026-10-13): every generic registration path —
|
||||||
|
`OperationRegistry::register`, `CallConnection::register_imported`,
|
||||||
|
`register_imported_all`, and the `op/register` announcement path — refuses
|
||||||
|
a marked (`channel_open`) spec loudly at registration time. Marked ops are
|
||||||
|
channel-machinery ops (the data plane is the allocated channel's
|
||||||
|
`BiStream`); a marked spec can never land as an advertised-but-unusable
|
||||||
|
overlay body. The sanctioned marked-op imports are the channel-wrapper
|
||||||
|
family: `register_openable*`, `register_pub_openable`,
|
||||||
|
`register_relay_openable`. `op/register` rejects a marked announcement
|
||||||
|
with the typed error `MARKED_SPEC_UNSUPPORTED` (ADR-016 error schema),
|
||||||
|
naming `ChannelRelay::register_relay_openable` (relaying hub) and
|
||||||
|
`ChannelClient` (direct consumption) as the sanctioned compositions.
|
||||||
|
Unmarked `Sub`/`Pub` announcements register the real forwarding handler
|
||||||
|
shapes (ADR-022 amendment 2026-10-13). This closes the stub class: an
|
||||||
|
advertised op is always a capable one — implement-or-stop-advertising is
|
||||||
|
release-blocking (`tests/stub_guard.rs`).
|
||||||
|
|
||||||
The adapter:
|
The adapter:
|
||||||
1. Accepts bidirectional streams on the connection
|
1. Accepts bidirectional streams on the connection
|
||||||
2. Reads length-prefixed JSON `EventEnvelope` frames from each stream
|
2. Reads length-prefixed JSON `EventEnvelope` frames from each stream
|
||||||
@@ -185,7 +203,7 @@ differs (accept vs dial). Peer authorization flows through the existing
|
|||||||
(ADR-024 §3). The composition env is peer-keyed (`PeerCompositeEnv`,
|
(ADR-024 §3). The composition env is peer-keyed (`PeerCompositeEnv`,
|
||||||
ADR-024 §1) to handle head→N-workers routing. See
|
ADR-024 §1) to handle head→N-workers routing. See
|
||||||
[client-and-adapters.md](client-and-adapters.md) for the `Dispatcher` mechanism
|
[client-and-adapters.md](client-and-adapters.md) for the `Dispatcher` mechanism
|
||||||
and [ADR-024](decisions/029-peer-graph-routing-model.md) for the
|
and [ADR-024](decisions/024-peer-graph-routing-model.md) for the
|
||||||
peer-graph routing model.
|
peer-graph routing model.
|
||||||
|
|
||||||
### Stream Model
|
### Stream Model
|
||||||
@@ -218,7 +236,7 @@ The `Value` type is `serde_json::Value`. The envelope is JSON because it must be
|
|||||||
Binary payloads (postcard, protobuf) are base64-encoded as a JSON string within the `payload` field. The convention is: if an operation's output schema specifies a binary field, the handler encodes it as a base64 string and the client decodes it. The `EventEnvelope` structure is not aware of this convention — it carries a `serde_json::Value` and does not interpret the payload. This is a handler-level concern, not a protocol-level concern.
|
Binary payloads (postcard, protobuf) are base64-encoded as a JSON string within the `payload` field. The convention is: if an operation's output schema specifies a binary field, the handler encodes it as a base64 string and the client decodes it. The `EventEnvelope` structure is not aware of this convention — it carries a `serde_json::Value` and does not interpret the payload. This is a handler-level concern, not a protocol-level concern.
|
||||||
|
|
||||||
This is hand-rolled length-prefixed JSON framing (ADR-014), coincidentally
|
This is hand-rolled length-prefixed JSON framing (ADR-014), coincidentally
|
||||||
the same shape irpc uses. The Rust implementation in alknet-call is
|
the same shape irpc uses. The Rust implementation in alkcall is
|
||||||
canonical — the `@alkdev/pubsub` TypeScript adapters serve as a reference
|
canonical — the `@alkdev/pubsub` TypeScript adapters serve as a reference
|
||||||
and browser adaptation, not a parallel implementation (see ADR-033).
|
and browser adaptation, not a parallel implementation (see ADR-033).
|
||||||
|
|
||||||
@@ -265,7 +283,7 @@ The `payload` of a `call.requested` event has this shape:
|
|||||||
- `operationId` — the operation to invoke, **with a leading slash** on the wire (e.g., `/fs/readFile`, `/agent/chat`, `/services/list`). This is the display form of the operation name. The registry stores names without the leading slash (`fs/readFile` — see [operation-registry.md](operation-registry.md#operationspec)); the wire format adds it. The `CallAdapter` strips the leading slash before registry lookup.
|
- `operationId` — the operation to invoke, **with a leading slash** on the wire (e.g., `/fs/readFile`, `/agent/chat`, `/services/list`). This is the display form of the operation name. The registry stores names without the leading slash (`fs/readFile` — see [operation-registry.md](operation-registry.md#operationspec)); the wire format adds it. The `CallAdapter` strips the leading slash before registry lookup.
|
||||||
- `input` — the operation input, matching the operation's `input_schema` (JSON Schema). Always a `serde_json::Value`.
|
- `input` — the operation input, matching the operation's `input_schema` (JSON Schema). Always a `serde_json::Value`.
|
||||||
- `auth_token` — optional. If present, the `CallAdapter` resolves it via `IdentityProvider::resolve_from_token()` and the resulting `Identity` takes precedence over the connection-level identity for this request. See [Identity Resolution](#authcontext-and-identity-resolution) below.
|
- `auth_token` — optional. If present, the `CallAdapter` resolves it via `IdentityProvider::resolve_from_token()` and the resulting `Identity` takes precedence over the connection-level identity for this request. See [Identity Resolution](#authcontext-and-identity-resolution) below.
|
||||||
- `forwarded_for` — optional (ADR-026). Present when a `from_call` forwarding handler propagates the originator's identity to a spoke. Carries a serialized `Identity` (id, scopes, resources) — the end user the hub authenticated. **Metadata only** — `AccessControl::check` never reads it; the spoke authorizes the hub (its direct caller), not the end user. The hub may set `forwarded_for: None` if it doesn't want to disclose the originator. See [ADR-026](decisions/032-forwarded-for-identity.md).
|
- `forwarded_for` — optional (ADR-026). Present when a `from_call` forwarding handler propagates the originator's identity to a spoke. Carries a serialized `Identity` (id, scopes, resources) — the end user the hub authenticated. **Metadata only** — `AccessControl::check` never reads it; the spoke authorizes the hub (its direct caller), not the end user. The hub may set `forwarded_for: None` if it doesn't want to disclose the originator. See [ADR-026](decisions/026-forwarded-for-identity.md).
|
||||||
|
|
||||||
The `call.requested` payload does **not** carry an abort policy field. The abort policy (`abort-dependents` vs `continue-running`, ADR-020) is set on `OperationContext` and propagated through `OperationEnv::invoke()` — the composing handler decides the child's policy, not the wire caller. See [Abort Cascade and Nested Calls](#abort-cascade-and-nested-calls) below.
|
The `call.requested` payload does **not** carry an abort policy field. The abort policy (`abort-dependents` vs `continue-running`, ADR-020) is set on `OperationContext` and propagated through `OperationEnv::invoke()` — the composing handler decides the child's policy, not the wire caller. See [Abort Cascade and Nested Calls](#abort-cascade-and-nested-calls) below.
|
||||||
|
|
||||||
@@ -336,7 +354,7 @@ The call protocol defines five top-level operations, expressed through event typ
|
|||||||
| **batch** | multiple `call.requested` (different IDs) → multiple `call.responded` | Multiple operations in one round |
|
| **batch** | multiple `call.requested` (different IDs) → multiple `call.responded` | Multiple operations in one round |
|
||||||
| **schema** | `call.requested` name `services/list` or `services/schema` → `call.responded` | Discover available operations |
|
| **schema** | `call.requested` name `services/list` or `services/schema` → `call.responded` | Discover available operations |
|
||||||
|
|
||||||
Batch is not a separate event type — it's multiple `call.requested` events with different request IDs. The client sends them (on one or many streams) and correlates the responses by ID. See OQ-14.
|
Batch is not a separate event type — it's multiple `call.requested` events with different request IDs. The client sends them (on one or many streams) and correlates the responses by ID. See OQ-03.
|
||||||
|
|
||||||
### Bidirectional Calls
|
### Bidirectional Calls
|
||||||
|
|
||||||
@@ -557,8 +575,8 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
|
|||||||
|
|
||||||
- The call protocol does not depend on any database. `PendingRequestMap` is in-memory. Durable session storage is a consumer concern.
|
- The call protocol does not depend on any database. `PendingRequestMap` is in-memory. Durable session storage is a consumer concern.
|
||||||
- Operation specs use JSON Schema. The envelope is always JSON. Binary payloads may be base64-encoded in the `payload` field.
|
- Operation specs use JSON Schema. The envelope is always JSON. Binary payloads may be base64-encoded in the `payload` field.
|
||||||
- Batch is not a protocol primitive — multiple `call.requested` events with correlated IDs provide equivalent semantics. See OQ-14.
|
- Batch is not a protocol primitive — multiple `call.requested` events with correlated IDs provide equivalent semantics. See OQ-03.
|
||||||
- The call protocol is transport-agnostic at the envelope level. The `EventEnvelope` framing can run over QUIC streams, WebSocket frames, or Worker `postMessage`. The `CallAdapter` is the `ProtocolHandler` implementation that receives a `Connection` (any transport — ADR-007) and dispatches `EventEnvelope` frames. **The `EventEnvelope` shape (`{ type, id, payload }`) was derived from the `@alkdev/pubsub` `EventEnvelope` (`/workspace/@alkdev/pubsub/src/types.ts`), which already has a working WebSocket client/server implementation (`event-target-websocket-client.ts` / `event-target-websocket-server.ts`) and a generalized "event target" abstraction. The call protocol refined the envelope with typed event names (`call.requested`, `call.responded`, etc.) and structured payloads; the delta is small and well-defined, making a browser (and Node) WebSocket client straightforward to derive from the pubsub prior art. See ADR-044, [ADR-048](decisions/048-websocket-native-session-not-gateway.md), and [websocket.md](../http/websocket.md).
|
- The call protocol is transport-agnostic at the envelope level. The `EventEnvelope` framing can run over QUIC streams, WebSocket frames, or Worker `postMessage`. The `CallAdapter` is the `ProtocolHandler` implementation that receives a `Connection` (any transport — ADR-007) and dispatches `EventEnvelope` frames. **The `EventEnvelope` shape (`{ type, id, payload }`) was derived from the `@alkdev/pubsub` `EventEnvelope` (`/workspace/@alkdev/pubsub/src/types.ts`), which already has a working WebSocket client/server implementation (`event-target-websocket-client.ts` / `event-target-websocket-server.ts`) and a generalized "event target" abstraction. The call protocol refined the envelope with typed event names (`call.requested`, `call.responded`, etc.) and structured payloads; the delta is small and well-defined, making a browser (and Node) WebSocket client straightforward to derive from the pubsub prior art. Provenance: alknet ADR-044 and the alknet websocket spec (not ported) recorded the WebSocket-native session decision; the WebSocket carrier for the alk protocols is implemented in the sibling host crate `alkhttp` (its `websocket` feature), not in alkcall — alkcall stays transport- and host-blind.
|
||||||
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer. See ADR-014 and OQ-13.
|
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer. See ADR-014 and OQ-13.
|
||||||
- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials, raw tokens) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. The wire format carries `serde_json::Value` and cannot enforce this at the type level — the constraint is architectural, enforced by the operation registry and by convention. Operations that need to share public key material use a dedicated operation that returns only the public component. See ADR-010.
|
- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials, raw tokens) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. The wire format carries `serde_json::Value` and cannot enforce this at the type level — the constraint is architectural, enforced by the operation registry and by convention. Operations that need to share public key material use a dedicated operation that returns only the public component. See ADR-010.
|
||||||
- **Abort cascades to descendants.** `call.aborted` for a parent request cascades to all non-terminal descendants in the call tree. Default policy is `abort-dependents`; `continue-running` is an opt-in. See ADR-020.
|
- **Abort cascades to descendants.** `call.aborted` for a parent request cascades to all non-terminal descendants in the call tree. Default policy is `abort-dependents`; `continue-running` is an opt-in. See ADR-020.
|
||||||
@@ -567,19 +585,19 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
|
|||||||
|
|
||||||
| Decision | ADR | Summary |
|
| Decision | ADR | Summary |
|
||||||
|----------|-----|---------|
|
|----------|-----|---------|
|
||||||
| Hand-rolled EventEnvelope framing (irpc never integrated) | [ADR-014](decisions/064-irpc-never-integrated-hand-rolled-framing.md) | Hand-rolled length-prefixed JSON framing, operation registry, dispatch; supersedes ADR-013 (irpc was never imported) |
|
| Hand-rolled EventEnvelope framing (irpc never integrated) | [ADR-014](decisions/014-irpc-never-integrated-hand-rolled-framing.md) | Hand-rolled length-prefixed JSON framing, operation registry, dispatch; supersedes ADR-013 (irpc was never imported) |
|
||||||
| Call protocol stream model | [ADR-015](decisions/012-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
|
| Call protocol stream model | [ADR-015](decisions/015-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
|
||||||
| ALPN per connection | [ADR-004](decisions/006-alpn-convention-and-connection-model.md) | `alk/call` is a distinct ALPN, one connection per ALPN |
|
| ALPN per connection | [ADR-004](decisions/004-alpn-convention-and-connection-model.md) | `alk/call` is a distinct ALPN, one connection per ALPN |
|
||||||
| ProtocolHandler receives Connection | [ADR-005](decisions/007-bistream-type-definition.md) | CallAdapter gets Connection, can accept/open multiple streams |
|
| ProtocolHandler receives Connection | [ADR-005](decisions/005-bistream-type-definition.md) | CallAdapter gets Connection, can accept/open multiple streams |
|
||||||
| Vault integration point | [ADR-008](decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time |
|
| Vault integration point | [ADR-010](decisions/010-secret-material-flow-and-capability-injection.md) | Vault is a capability source, accessed at assembly time (provenance: alknet ADR-008, superseded in-crate by ADR-010) |
|
||||||
| Secret material flow | [ADR-010](decisions/014-secret-material-flow-and-capability-injection.md) | Call protocol carries no secret material; capabilities injected at assembly layer |
|
| Secret material flow | [ADR-010](decisions/010-secret-material-flow-and-capability-injection.md) | Call protocol carries no secret material; capabilities injected at assembly layer |
|
||||||
| Privilege model and authority context | [ADR-017](decisions/015-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
|
| Privilege model and authority context | [ADR-017](decisions/017-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
|
||||||
| Abort cascade for nested calls | [ADR-020](decisions/016-abort-cascade-for-nested-calls.md) | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
|
| Abort cascade for nested calls | [ADR-020](decisions/020-abort-cascade-for-nested-calls.md) | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
|
||||||
| Call protocol client and adapter contract | [ADR-022](decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction. Client/adapter surface specced in [client-and-adapters.md](client-and-adapters.md) |
|
| Call protocol client and adapter contract | [ADR-022](decisions/022-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction. Client/adapter surface specced in [client-and-adapters.md](client-and-adapters.md) |
|
||||||
| Handler registration, provenance, and composition authority | [ADR-018](decisions/022-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
|
| Handler registration, provenance, and composition authority | [ADR-018](decisions/018-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
|
||||||
| Peer-graph routing model (supersedes ADR-023) | [ADR-024](decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; `AccessControl`-based peer authorization; retires `remote_safe`/`trusted_peer` |
|
| Peer-graph routing model (supersedes ADR-023) | [ADR-024](decisions/024-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; `AccessControl`-based peer authorization; retires `remote_safe`/`trusted_peer` |
|
||||||
| Forwarded-for identity | [ADR-026](decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
|
| Forwarded-for identity | [ADR-026](decisions/026-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
|
||||||
| Operation error schemas | [ADR-016](decisions/023-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details` |
|
| Operation error schemas | [ADR-016](decisions/016-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details` |
|
||||||
| Streaming handler for subscriptions | [ADR-021](decisions/021-streaming-handler-for-subscriptions.md) | `StreamingHandler` type, `invoke_streaming()` dispatch path, `INVALID_OPERATION_TYPE` protocol code; the server-side streaming branch in `handle_stream` |
|
| Streaming handler for subscriptions | [ADR-021](decisions/021-streaming-handler-for-subscriptions.md) | `StreamingHandler` type, `invoke_streaming()` dispatch path, `INVALID_OPERATION_TYPE` protocol code; the server-side streaming branch in `handle_stream` |
|
||||||
| Publish operation type and HandlerKind::Sink | [ADR-046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | `OperationType::Pub` (producer→consumer streaming); `SinkHandler` + `HandlerKind::Sink`; `call.published` wire event; `invoke_sink()` dispatch; `Subscription` renamed to `Sub` |
|
| Publish operation type and HandlerKind::Sink | [ADR-046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | `OperationType::Pub` (producer→consumer streaming); `SinkHandler` + `HandlerKind::Sink`; `call.published` wire event; `invoke_sink()` dispatch; `Subscription` renamed to `Sub` |
|
||||||
|
|
||||||
@@ -587,44 +605,49 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
|
|||||||
|
|
||||||
See [open-questions.md](open-questions.md) for full details.
|
See [open-questions.md](open-questions.md) for full details.
|
||||||
|
|
||||||
- **OQ-13** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
|
- **OQ-02** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
|
||||||
- **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
|
- **OQ-03** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
|
||||||
- **OQ-16** (resolved by ADR-010): No vault operations are exposed over the call protocol for now.
|
- **OQ-09** (resolved by ADR-010): No vault operations are exposed over the call protocol for now.
|
||||||
- **OQ-19** (resolved): Session-scoped operation registries — agent-written operations overlaid on global registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait.
|
- **OQ-04** (resolved): Session-scoped operation registries — agent-written operations overlaid on global registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait.
|
||||||
- **OQ-25** (dissolved by ADR-024): `remote_safe` marking shape — moot;
|
- **OQ-10** (dissolved by ADR-024): `remote_safe` marking shape — moot;
|
||||||
`remote_safe`/`trusted_peer` retired; peer authorization is
|
`remote_safe`/`trusted_peer` retired; peer authorization is
|
||||||
`AccessControl::check(peer_identity)`.
|
`AccessControl::check(peer_identity)`.
|
||||||
- **OQ-26** (resolved): `OperationAdapter` error type — `AdapterError`
|
- **OQ-11** (resolved): `OperationAdapter` error type — `AdapterError`
|
||||||
variants (`DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`,
|
variants (`DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`,
|
||||||
`SamePeerCollision`); `#[non_exhaustive]`. See
|
`SamePeerCollision`); `#[non_exhaustive]`. See
|
||||||
[client-and-adapters.md](client-and-adapters.md).
|
[client-and-adapters.md](client-and-adapters.md).
|
||||||
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
|
- **OQ-12** (resolved): `from_call` re-import trigger — `from_call` is a
|
||||||
manual free function; the assembly layer calls it after the dial (in
|
manual free function; after the dial (in the composition layer's dial helper
|
||||||
`AlknetClient`). See
|
`AlknetClient` — today the alknet glue; OQ-43). See
|
||||||
[ADR-028](decisions/069-from-call-manual-free-function.md).
|
[ADR-028](decisions/028-from-call-manual-free-function.md).
|
||||||
- **OQ-28** (resolved): `from_call` namespace collision — same-peer collision
|
- **OQ-13** (resolved): `from_call` namespace collision — same-peer collision
|
||||||
= error; cross-peer dissolved by ADR-024 (separate sub-overlays). See
|
= error; cross-peer dissolved by ADR-024 (separate sub-overlays). See
|
||||||
[client-and-adapters.md](client-and-adapters.md).
|
[client-and-adapters.md](client-and-adapters.md).
|
||||||
- **OQ-29** (resolved): `CallClient` TLS client-auth — wire quinn client-auth
|
- **OQ-14** (resolved): `CallClient` TLS client-auth — wire quinn client-auth
|
||||||
(present Ed25519 key as raw public key client cert); key-type-aware server
|
(present Ed25519 key as raw public key client cert); key-type-aware server
|
||||||
cert verification; fingerprint normalization. See
|
cert verification; fingerprint normalization. See
|
||||||
[client-and-adapters.md](client-and-adapters.md).
|
[client-and-adapters.md](client-and-adapters.md).
|
||||||
- **OQ-30** (resolved): `PeerRef::Any` routing policy — insertion-order
|
- **OQ-15** (resolved): `PeerRef::Any` routing policy — insertion-order
|
||||||
first-match. See [client-and-adapters.md](client-and-adapters.md).
|
first-match. See [client-and-adapters.md](client-and-adapters.md).
|
||||||
- **OQ-31** (resolved): `services/list-peers` re-export semantics — opt-in;
|
- **OQ-16** (resolved): `services/list-peers` re-export semantics — opt-in;
|
||||||
`services/list` is "own ops only." See [client-and-adapters.md](client-and-adapters.md).
|
`services/list` is "own ops only." See [client-and-adapters.md](client-and-adapters.md).
|
||||||
- **OQ-32** (open, feature extension): Multi-hop federation — the one-hop
|
- **OQ-17** (deferred(scope), feature extension): Multi-hop federation — the one-hop
|
||||||
model is the architectural commitment; multi-hop is a feature extension
|
model is the architectural commitment; multi-hop is a feature extension
|
||||||
that doesn't break downstream. See [client-and-adapters.md](client-and-adapters.md).
|
that doesn't break downstream. See [client-and-adapters.md](client-and-adapters.md).
|
||||||
- **OQ-33** (resolved by ADR-025): `PeerId` source — `Identity.id` from
|
- **OQ-18** (resolved by ADR-025): `PeerId` source — `Identity.id` from
|
||||||
`IdentityProvider` resolution (= `PeerEntry.peer_id`, stable across key
|
`IdentityProvider` resolution (= `PeerEntry.peer_id`, stable across key
|
||||||
rotation), not a connection-assigned UUID.
|
rotation), not a connection-assigned UUID.
|
||||||
- **OQ-34** (resolved by ADR-025 + ADR-033): Persistent peer registry —
|
- **OQ-19** (resolved by ADR-025): Persistent peer registry —
|
||||||
the storage boundary is `core trait + in-memory default`; persistence
|
the storage boundary is `core trait + in-memory default`; persistence
|
||||||
adapters are separate crates.
|
adapters are separate crates (composition-layer work — OQ-43; the
|
||||||
- **OQ-37** (resolved by ADR-034): X.509 outgoing-only case — three remote
|
concrete adapter shapes were named in the source repo's research
|
||||||
|
(alknet, provenance), not re-specced here).
|
||||||
|
- **OQ-21** (resolved by ADR-054): X.509 outgoing-only case — three remote
|
||||||
roles named (public X.509 endpoint, transport relay, hub); pure-client
|
roles named (public X.509 endpoint, transport relay, hub); pure-client
|
||||||
X.509 connections are not in the peer graph on the client side. See
|
X.509 connections are not in the peer graph on the client side.
|
||||||
|
Re-owned in-crate by ADR-054 (verifier selection by `PeerEntry`
|
||||||
|
presence; provenance: alknet ADR-034, not ported); ADR-025's
|
||||||
|
`PeerEntry` asymmetry is the surviving registry surface. See
|
||||||
[client-and-adapters.md](client-and-adapters.md).
|
[client-and-adapters.md](client-and-adapters.md).
|
||||||
|
|
||||||
## References
|
## References
|
||||||
@@ -636,6 +659,6 @@ See [open-questions.md](open-questions.md) for full details.
|
|||||||
- ADR-024: Peer-graph routing model (peer-keyed overlays + `PeerRef` routing)
|
- ADR-024: Peer-graph routing model (peer-keyed overlays + `PeerRef` routing)
|
||||||
- ADR-025: PeerEntry and Identity.id decoupling (`PeerId` source)
|
- ADR-025: PeerEntry and Identity.id decoupling (`PeerId` source)
|
||||||
- ADR-026: Forwarded-for identity (`forwarded_for` on `call.requested` and `OperationContext`)
|
- ADR-026: Forwarded-for identity (`forwarded_for` on `call.requested` and `OperationContext`)
|
||||||
- ADR-034: Outgoing-only X.509 and the three peer roles
|
- ADR-054: Outgoing-only X.509 and the three remote roles (re-owned in-crate; verifier selection by `PeerEntry` presence)
|
||||||
- ADR-021: Streaming handler for subscriptions (server-side streaming dispatch path)
|
- ADR-021: Streaming handler for subscriptions (server-side streaming dispatch path)
|
||||||
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`
|
- Reference implementation: this crate (`src/`); historical origin: the alknet / alknet-main call-core sources (provenance)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-18
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# channel-client.md — ChannelClient
|
# channel-client.md — ChannelClient
|
||||||
@@ -68,12 +68,19 @@ impl ChannelClient {
|
|||||||
/// the `MpscRecvStream` (read half). The caller can build a
|
/// the `MpscRecvStream` (read half). The caller can build a
|
||||||
/// `Connection` from these via `channel_source` and
|
/// `Connection` from these via `channel_source` and
|
||||||
/// `Connection::from_source`.
|
/// `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(
|
pub async fn open_channel(
|
||||||
&self,
|
&self,
|
||||||
operation_id: &str,
|
operation_id: &str,
|
||||||
input: Value,
|
input: Value,
|
||||||
alpn: &str,
|
alpn: &str,
|
||||||
) -> Result<(u32, MpscSendStream, MpscRecvStream), String>;
|
) -> Result<(u32, MpscSendStream, MpscRecvStream), ChannelOpenError>;
|
||||||
|
|
||||||
/// Take the `CallConnection` — used by the consumer to register
|
/// Take the `CallConnection` — used by the consumer to register
|
||||||
/// imported ops (`from_call`) on the connection's overlay. After
|
/// 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
|
ADR-047 dissolved the generic `channel/open` operation into per-ALPN
|
||||||
open ops (`channels/<alpn>/sub`, `channels/<alpn>/pub`). Each ALPN
|
open ops (`channels/<alpn>/sub`, `channels/<alpn>/pub`). Each ALPN
|
||||||
crate registers its own open op via `ChannelCore::register_openable`
|
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
|
The `ChannelClient` calls these ops by name on channel 0 via
|
||||||
`call_open_op`; `open_channel` wraps `call_open_op` + `adopt_channel`.
|
`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
|
`ResourceEntry.access` preview was dropped by ADR-047 §6 — it is
|
||||||
available via `services/schema` on the op spec.
|
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
|
## Transport-agnostic by construction
|
||||||
|
|
||||||
`ChannelClient` is the client side of the channels protocol. The channels
|
`ChannelClient` is the client side of the channels protocol. The channels
|
||||||
@@ -112,17 +152,19 @@ takes over channels establishment. The transport is the caller's concern:
|
|||||||
`Connection::from_bidi(tls_stream, ...)` for TCP+TLS, a quinn `Connection`,
|
`Connection::from_bidi(tls_stream, ...)` for TCP+TLS, a quinn `Connection`,
|
||||||
a WebTransport `BiStream`, an SSH `direct-tcpip` channel wrapped via
|
a WebTransport `BiStream`, an SSH `direct-tcpip` channel wrapped via
|
||||||
`from_bidi`, a WebSocket carrying `alk/channels` (the browser path per
|
`from_bidi`, a WebSocket carrying `alk/channels` (the browser path per
|
||||||
ADR-044) — all produce a `Connection` that `from_connection` accepts
|
alknet ADR-044 — websocket-native session, provenance) — all produce a `Connection` that `from_connection` accepts
|
||||||
unchanged. This mirrors the server side's `ChannelsAdapter::handle(Connection)`, which is substrate-agnostic by the same mechanism.
|
unchanged. This mirrors the server side's `ChannelsAdapter::handle(Connection)`, which is substrate-agnostic by the same mechanism.
|
||||||
|
|
||||||
The dial (QUIC, TCP+TLS, iroh) lives in `AlknetClient` (`alknet-client`,
|
The dial (QUIC, TCP+TLS, iroh) lives in the composition layer's dial
|
||||||
|
helper, `AlknetClient` (today in the alknet glue; future owner OQ-43 —
|
||||||
ADR-045), not on `ChannelClient`. Callers compose
|
ADR-045), not on `ChannelClient`. Callers compose
|
||||||
`AlknetClient::dial_quic(...).await?` + `ChannelClient::from_connection(conn).await?`
|
`AlknetClient::dial_quic(...).await?` + `ChannelClient::from_connection(conn).await?`
|
||||||
— two lines, the dial then the take-over. Keeping the dial off
|
— two lines, the dial then the take-over. Keeping the dial off
|
||||||
`ChannelClient` avoids `alknet-channels-call` depending on `alknet-client`;
|
`ChannelClient` avoids alkcall depending on a dial crate;
|
||||||
the protocol crates are parallel to the dial, not downstream of it.
|
the protocol crates are parallel to the dial, not downstream of it.
|
||||||
|
|
||||||
The credential/verifier-selection rule (ADR-034) lives in the dial
|
The credential/verifier-selection rule (ADR-054, re-owned from the
|
||||||
|
source repo's alknet ADR-034) lives in the dial
|
||||||
(`AlknetClient`), not in `from_connection` — `from_connection` receives
|
(`AlknetClient`), not in `from_connection` — `from_connection` receives
|
||||||
an already-established, already-authenticated `Connection`, exactly as
|
an already-established, already-authenticated `Connection`, exactly as
|
||||||
`ChannelsAdapter::handle` does on the server side.
|
`ChannelsAdapter::handle` does on the server side.
|
||||||
@@ -141,12 +183,13 @@ connect side's channel 0 (if the connect side registers any).
|
|||||||
name follows the `CallClient` convention (the side that dialed), not a
|
name follows the `CallClient` convention (the side that dialed), not a
|
||||||
request/response role.
|
request/response role.
|
||||||
|
|
||||||
## Relationship to `AlknetClient`
|
## Relationship to the composition layer's dial helper
|
||||||
|
|
||||||
`ChannelClient`'s *API* is transport-agnostic — `from_connection` takes a
|
`ChannelClient`'s *API* is transport-agnostic — `from_connection` takes a
|
||||||
pre-established `Connection`. The shared *dial+TLS* seam
|
pre-established `Connection`. The shared *dial+TLS* seam is the
|
||||||
(`AlknetClient`, OQ-55) is [`alknet-client`](../client/README.md), which
|
composition layer's dial helper, `AlknetClient` (ADR-045; today in the
|
||||||
provides `AlknetClient` with three dial methods (`dial_quic` /
|
alknet glue, future owner OQ-43), which
|
||||||
|
provides three dial methods (`dial_quic` /
|
||||||
`dial_tcp_tls` / `dial_iroh`), each producing a `Connection` that
|
`dial_tcp_tls` / `dial_iroh`), each producing a `Connection` that
|
||||||
`from_connection` consumes. The dial is transport-specific (QUIC,
|
`from_connection` consumes. The dial is transport-specific (QUIC,
|
||||||
TCP+TLS, iroh); the take-over (`from_connection`) is
|
TCP+TLS, iroh); the take-over (`from_connection`) is
|
||||||
@@ -154,10 +197,9 @@ transport-agnostic. The two concerns are separated.
|
|||||||
|
|
||||||
`AlknetClient::dial_quic` is the dial that feeds `from_connection`. A
|
`AlknetClient::dial_quic` is the dial that feeds `from_connection`. A
|
||||||
caller that needs transport selection (QUIC with TCP+TLS fallback) uses
|
caller that needs transport selection (QUIC with TCP+TLS fallback) uses
|
||||||
`AlknetClient` directly; the fallback policy is a caller concern. See
|
the dial helper directly; the fallback policy is a caller concern. See
|
||||||
[ADR-045](decisions/089-alknetclient-native-dial-seam.md) for the
|
[ADR-045](decisions/045-alknetclient-native-dial-seam.md) for the
|
||||||
full decision and [OQ-55](../../questions/055-alknetclient-establishment-extraction.md)
|
full decision.
|
||||||
(resolved).
|
|
||||||
|
|
||||||
## Design Decisions
|
## Design Decisions
|
||||||
|
|
||||||
@@ -165,15 +207,15 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
|
|
||||||
| ADR | Decision | Summary |
|
| ADR | Decision | Summary |
|
||||||
|-----|----------|---------|
|
|-----|----------|---------|
|
||||||
| [043](decisions/043-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045, resolves OQ-55) |
|
| [043](decisions/043-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in the composition layer's dial helper (ADR-045) |
|
||||||
| [035](decisions/035-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | No `stream_types` on `open_channel`/`Channel`; handler owns sub-stream multiplexing |
|
| [035](decisions/035-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | No `stream_types` on `open_channel`/`Channel`; handler owns sub-stream multiplexing |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
- **OQ-55** (resolved by ADR-045): `AlknetClient` core **dial+TLS seam**
|
- **Dial seam ownership** (ADR-045): the dial helper (`AlknetClient`)
|
||||||
— `alknet-client` with three dial methods. `ChannelClient`'s API is
|
lives in the composition layer today; its post-alknet owner and shape
|
||||||
transport-agnostic (`from_connection`); the dial is the shared seam.
|
is OQ-43. `ChannelClient`'s API is transport-agnostic
|
||||||
See [ADR-045](decisions/045-alknetclient-native-dial-seam.md).
|
(`from_connection`); the dial is the shared seam.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -185,6 +227,6 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
- ADR-039: ChannelManager (the shared state `ChannelClient` holds)
|
- ADR-039: ChannelManager (the shared state `ChannelClient` holds)
|
||||||
- ADR-047: openable ALPNs are operations (per-ALPN open ops dissolve
|
- ADR-047: openable ALPNs are operations (per-ALPN open ops dissolve
|
||||||
the generic `channel/open`)
|
the generic `channel/open`)
|
||||||
- OQ-55: AlknetClient / client establishment extraction
|
- ADR-045: the dial seam (dial out of the protocol crate)
|
||||||
- `docs/architecture/crates/call/client-and-adapters.md` — `CallClient` (the
|
- [client-and-adapters.md](client-and-adapters.md) — `CallClient` (the
|
||||||
shape `ChannelClient` mirrors)
|
shape `ChannelClient` mirrors)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: current
|
||||||
last_updated: 2026-08-12
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# channel-operations.md — Channel Lifecycle on the Call Protocol
|
# channel-operations.md — Channel Lifecycle on the Call Protocol
|
||||||
@@ -60,15 +60,18 @@ Response (`call.responded`):
|
|||||||
|-------|------|---------|
|
|-------|------|---------|
|
||||||
| `channel_id` | u32 | Allocated by the connection owner (ADR-047 §5). In the `Sub` case, the responder (producer). Both sides route chunks with this ID to the new channel. |
|
| `channel_id` | u32 | Allocated by the connection owner (ADR-047 §5). In the `Sub` case, the responder (producer). Both sides route chunks with this ID to the new channel. |
|
||||||
|
|
||||||
**`channel_id` allocation (ADR-047 §5): connection-owner allocates.**
|
**`channel_id` allocation (ADR-047 §5, as amended for the put path by
|
||||||
|
ADR-047 amendment 4): connection-owner allocates.**
|
||||||
The side that holds the `ChannelManager` for that connection allocates
|
The side that holds the `ChannelManager` for that connection allocates
|
||||||
the `channel_id` via a monotonic `AtomicU32` (`next_id.fetch_add(1,
|
the `channel_id` via a monotonic `AtomicU32` (odd/even split — connect
|
||||||
Relaxed)`) and returns it in the response. In the `Sub` case the
|
side starts at 1, accept at 2) and returns it in the response. In the
|
||||||
responder owns the connection and allocates; in the `Pub` case the
|
`Sub` case the responder owns the connection and allocates — one
|
||||||
initiator owns and allocates. One round-trip before data flows — the
|
round-trip before data flows (the same round-trip every call makes).
|
||||||
same round-trip the call protocol makes for every operation. All
|
In the `Pub` case (the put contract above) the initiator owns,
|
||||||
current channel types (TTY, tunnel, SSH) already require a negotiation
|
allocates, and streams bytes **before** the open resolves — the
|
||||||
round-trip, so the open round-trip is not additive latency.
|
early-arrival parking covers the adopt race. All current channel
|
||||||
|
types (TTY, tunnel, SSH) already require a negotiation round-trip, so
|
||||||
|
the open round-trip is not additive latency.
|
||||||
|
|
||||||
**Error codes** (`CallError.code` strings):
|
**Error codes** (`CallError.code` strings):
|
||||||
|
|
||||||
@@ -77,25 +80,113 @@ round-trip, so the open round-trip is not additive latency.
|
|||||||
| `channel:forbidden` | `AccessControl::check` denied the open | false |
|
| `channel:forbidden` | `AccessControl::check` denied the open | false |
|
||||||
| `channel:allocation_failed` | Handler allocate failed (e.g., backend couldn't start) | true (often transient) |
|
| `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: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: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 —
|
`channel:unknown_alpn` and `channel:invalid_params` (ADR-037) are gone —
|
||||||
an unregistered ALPN is an ordinary `NOT_FOUND` (op not registered); a
|
an unregistered ALPN is an ordinary `NOT_FOUND` (op not registered); a
|
||||||
bad `input` is ordinary schema rejection.
|
bad `input` is ordinary schema rejection.
|
||||||
|
|
||||||
### `channels/<alpn>/pub` — publish a binary stream
|
### The establishment phase (ADR-049)
|
||||||
|
|
||||||
`OperationType::Pub` (ADR-046). The initiator publishes a stream of
|
An openable ALPN may register an **establisher** alongside its open
|
||||||
`call.published` events to the responder; the responder's `SinkHandler`
|
handler (`ChannelCore::register_openable_with_establisher`). The
|
||||||
consumes them and returns a single `call.responded`. The
|
establisher is the awaited preparation step ADR-047 §3 described
|
||||||
`channel_open` marker on the op spec tells the channels layer the
|
("validate params, consult ownership, prepare the backend, return a
|
||||||
stream is binary — the `call.published` chunks carry binary payloads
|
channel plan"): the wrapper awaits it **bounded** (the dispatch
|
||||||
on a data channel (not JSON on channel 0).
|
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 (the put contract, ADR-047 amendment 4)
|
||||||
|
|
||||||
|
A marked `Pub`-typed open op is the **put** shape: the data plane is
|
||||||
|
the adopted channel's `BiStream` — never the call plane. The contract,
|
||||||
|
pinned end-to-end (ADR-047 amendment 4; review 011's corrections are
|
||||||
|
the authoritative shape — a serving-loop `PublishStream` sketch was
|
||||||
|
considered and rejected, it is ADR-046's *unmarked* pub semantics):
|
||||||
|
|
||||||
|
1. **The initiator allocates and writes immediately.** The putter
|
||||||
|
allocates the channel id on its own side (connect side → odd, per
|
||||||
|
the ADR-047 §5 odd/even split), inserts the **reserved input key
|
||||||
|
`"channel_id"`** into the open op's input, and streams bytes on the
|
||||||
|
channel before the open resolves — the early-arrival parking
|
||||||
|
(ADR-051's tested pre-adopt buffer) covers the responder-adopts
|
||||||
|
race. Zero `call.published` on marked ops: chunks on the call plane
|
||||||
|
are rejected loudly with `channel:publish_chunks_rejected` (the
|
||||||
|
first chunk writes the rejection in the initiator's direction; the
|
||||||
|
handler is never fed). An unmarked `Pub` op is an ordinary ADR-046
|
||||||
|
sink (JSON chunks, `publish_schema`); it does not ride the channel
|
||||||
|
machinery.
|
||||||
|
2. **The responder adopts.** `ChannelCore::register_pub_openable` is
|
||||||
|
the only registration seam: the wrapper strips the reserved key
|
||||||
|
pre-validation (dispatch layer, before `input_schema`) and adopts
|
||||||
|
the given id (`adopt_channel` — the remote parity range enforced;
|
||||||
|
failure replies `channel:open_failed` with reason `adopt_failed`).
|
||||||
|
3. **The wrapper is the responder of record:** `check_open` → adopt →
|
||||||
|
bounded establisher (offer validation + ack fields +
|
||||||
|
`Establishment::with_control`, ADR-049 amendment 3's mechanism) →
|
||||||
|
the `PubOpenHandler` runs on the adopted channel's `Connection`,
|
||||||
|
**awaited inline** → the standard completion teardown → **one
|
||||||
|
response**, merge order pinned:
|
||||||
|
|
||||||
|
```
|
||||||
|
{ "channel_id": <adopted> } → ack fields → handler result
|
||||||
|
```
|
||||||
|
|
||||||
|
(the handler result wins on key collision — its computed result
|
||||||
|
postdates the ack; a handler `Err` IS the response, and the channel
|
||||||
|
tears down).
|
||||||
|
4. **The ack-timing sentence (protocol semantics):** a sink op gets
|
||||||
|
exactly one `call.responded`, produced after the handler completes
|
||||||
|
— a putter stream-waits nothing; the establishment ack is
|
||||||
|
informative-in-result, never a gating ack. The fetch/put asymmetry
|
||||||
|
is direction-inherent: fetch (`Sub`) acks at establishment (the
|
||||||
|
`{channel_id}` response precedes any byte); put (`Pub`) acks in the
|
||||||
|
result. A putter that waits for the ack deadlocks — this sentence
|
||||||
|
is the contract, not per-consumer folklore.
|
||||||
|
5. **The putter-facing client shape:**
|
||||||
|
`ChannelClient::open_pub_channel(operation_id, input, alpn)`
|
||||||
|
allocates the odd id, sends the open op in the background, and
|
||||||
|
returns `(id, send, recv, reply)` immediately; `reply.resolve()`
|
||||||
|
is the awaited merged response.
|
||||||
|
|
||||||
|
**Error codes** (the pub path):
|
||||||
|
|
||||||
|
| code | meaning | retryable |
|
||||||
|
|------|---------|-----------|
|
||||||
|
| `channel:open_failed` | establishment failure (ADR-049 §3 vocabulary) or adoption failure — new reason `adopt_failed` (collisions, cap, out-of-range reserved ids; ADR-047 amendment 4 §4.4) | false |
|
||||||
|
| `channel:publish_chunks_rejected` | `call.published` chunks on a marked op (the data plane is the channel, ADR-047 amendment 4 §4.1) | false |
|
||||||
|
| `INVALID_INPUT` | absent/non-u32 reserved `channel_id` key on a marked pub op (ADR-047 amendment 4 §4.3) | false |
|
||||||
|
|
||||||
The hub-as-proxy pattern (ADR-042, ADR-047 §1) composes on top: a
|
The hub-as-proxy pattern (ADR-042, ADR-047 §1) composes on top: a
|
||||||
worker publishes to the hub, the hub owns the resource, and consumers
|
worker publishes to the hub, the hub owns the resource, consumers
|
||||||
subscribe from the hub. The broker (topic registry, `Pub`↔`Sub`
|
subscribe from the hub. Marked pub openables relay through the put
|
||||||
matching) is a hub concern, not an alkcall concern (ADR-047 §1, Gap B).
|
translation (ADR-047 amendment 4 §4.7): the hub allocates its own
|
||||||
|
connect-side id, opens the spoke op in the background (put
|
||||||
|
acks-in-result — an establisher awaiting the spoke's response would
|
||||||
|
deadlock the leg), pumps consumer bytes → spoke leg, and mirrors the
|
||||||
|
spoke's single response with its reserved key stripped.
|
||||||
|
|
||||||
|
Registration invariants (ADR-047 amendment 4 §4.8 — the stub-class
|
||||||
|
guard): a marked spec refuses the generic registration path and
|
||||||
|
refuses `publish_schema`; `register_openable*` refuse `Pub`-typed
|
||||||
|
specs; `register_pub_openable` refuses unmarked/non-`Pub` specs.
|
||||||
|
|
||||||
## The generic ops (keyed by `channel_id`)
|
## The generic ops (keyed by `channel_id`)
|
||||||
|
|
||||||
@@ -126,13 +217,13 @@ decrement.
|
|||||||
chunks MUST be written and flushed before the `channel/close` operation
|
chunks MUST be written and flushed before the `channel/close` operation
|
||||||
is sent on channel 0. The side closing must observe the data-channel
|
is sent on channel 0. The side closing must observe the data-channel
|
||||||
pump complete before issuing the call operation. For TTY this is the
|
pump complete before issuing the call operation. For TTY this is the
|
||||||
exit-chunk-is-last invariant (ADR-055) carried forward — the exit
|
exit-chunk-is-last invariant (alknet ADR-055, provenance) carried forward — the exit
|
||||||
control message rides on TTY's `STREAM_CTRL_OUT` (stream_type 4, inside
|
control message rides on TTY's `STREAM_CTRL_OUT` (stream_type 4, inside
|
||||||
TTY's 5-byte payload format); for tunnels it is the last data byte
|
TTY's 5-byte payload format); for tunnels it is the last data byte
|
||||||
before close. This invariant crosses two channels (the data channel and
|
before close. This invariant crosses two channels (the data channel and
|
||||||
channel 0), so the channels layer owns the ordering guarantee.
|
channel 0), so the channels layer owns the ordering guarantee.
|
||||||
|
|
||||||
### `channel/control` — out-of-band control on channel 0
|
### `channel/control` — out-of-band control on channel 0 (implemented, ADR-052)
|
||||||
|
|
||||||
For control that doesn't need ordering relative to data (resize, signal,
|
For control that doesn't need ordering relative to data (resize, signal,
|
||||||
keepalive):
|
keepalive):
|
||||||
@@ -147,11 +238,23 @@ keepalive):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The channels layer routes `message` to the handler's control handle for
|
The channels layer routes `message` to the channel's control callback
|
||||||
`channel_id`. The `message` JSON is ALPN-specific; the channels layer
|
(ADR-052): the ALPN crate contributes it through its open-op
|
||||||
does not interpret it.
|
establisher (`Establishment::with_control`); the wrapper installs it
|
||||||
|
under the allocated/adopted id and every teardown path removes it. The
|
||||||
|
`message` JSON is ALPN-specific; the channels layer does not interpret
|
||||||
|
it. The reply is `{ "control": <callback output> }`, or:
|
||||||
|
|
||||||
### `channel/resources/subscribe` — live resource discovery
|
| code | meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `channel:unknown_channel` | no such channel (pre-existing class) |
|
||||||
|
| `channel:no_control_handle` | the channel's ALPN registered no control surface — a capability answer, not a failure (ADR-052 §3); data-ordered control rides the data channel (DP-4 below) |
|
||||||
|
| (callback's own code) | the callback's `CallError` verbatim |
|
||||||
|
|
||||||
|
Control that MUST be ordered relative to data rides the data channel's
|
||||||
|
`BiStream` (the control-message division below is unchanged).
|
||||||
|
|
||||||
|
### `channel/resources/subscribe` — live resource discovery (implemented, ADR-053)
|
||||||
|
|
||||||
**This is a `Sub` operation (ADR-021), not a polled Query.** The call
|
**This is a `Sub` operation (ADR-021), not a polled Query.** The call
|
||||||
protocol has `StreamingHandler` / `invoke_streaming` (implemented and
|
protocol has `StreamingHandler` / `invoke_streaming` (implemented and
|
||||||
@@ -166,8 +269,21 @@ start/stop.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The responder registers a `StreamingHandler` that emits a
|
Each openable ALPN crate provides a **resource enumerator**
|
||||||
`ResponseEnvelope` whenever the resource set changes. Each event:
|
(ADR-053 §1 — snapshot-then-changes: the first item is the current
|
||||||
|
payload, subsequent items are full replacements; the producing crate
|
||||||
|
owns its change detection) at assembly/install time:
|
||||||
|
`ResourceEnumerators::register(alpn, enumerator)`, one shared registry
|
||||||
|
per accepting peer. The handler aggregates the enumerators registered
|
||||||
|
at subscription start (the **scan-once** pin — a later registration is
|
||||||
|
invisible to an in-flight subscription; re-subscribe):
|
||||||
|
|
||||||
|
- **non-empty registry:** each enumerator item becomes one event,
|
||||||
|
`{ "output": { "resources": [ { "alpn": …, …payload } ] } }` — the
|
||||||
|
first events across the ALPNs are the initial snapshot;
|
||||||
|
- **empty registry:** exactly one `{ "resources": [] }` event, then
|
||||||
|
the stream stays open (subscribe, read the first event, cancel —
|
||||||
|
the poll use case).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -211,7 +327,7 @@ would pressure consumers toward the stale-poll path.
|
|||||||
| Call operations on channel 0 (`channel/control`, `channel/close`) | Control that doesn't need ordering relative to data, or lifecycle events | resize, signal, keepalive, close |
|
| Call operations on channel 0 (`channel/control`, `channel/close`) | Control that doesn't need ordering relative to data, or lifecycle events | resize, signal, keepalive, close |
|
||||||
| Data-ordered bytes on the data channel's `BiStream` (handler-internal framing) | Control that MUST be ordered relative to data | EOF before exit, flush before close |
|
| Data-ordered bytes on the data channel's `BiStream` (handler-internal framing) | Control that MUST be ordered relative to data | EOF before exit, flush before close |
|
||||||
|
|
||||||
The TTY crate's exit-chunk-is-last invariant (ADR-055) is the canonical
|
The TTY crate's exit-chunk-is-last invariant (alknet ADR-055, provenance) is the canonical
|
||||||
example of data-ordered control — it rides on TTY's `STREAM_CTRL_OUT`
|
example of data-ordered control — it rides on TTY's `STREAM_CTRL_OUT`
|
||||||
(stream_type 4, inside TTY's 5-byte payload format) because it must
|
(stream_type 4, inside TTY's 5-byte payload format) because it must
|
||||||
arrive after the last data on TTY's stdout stream_type, guaranteed by
|
arrive after the last data on TTY's stdout stream_type, guaranteed by
|
||||||
@@ -224,7 +340,7 @@ follows is on channel 0 and is ordered after the data pump completes
|
|||||||
the channels layer has no `stream_type` concept — it carries the
|
the channels layer has no `stream_type` concept — it carries the
|
||||||
handler's framing transparently in the payload. TTY's `STREAM_CTRL_IN`
|
handler's framing transparently in the payload. TTY's `STREAM_CTRL_IN`
|
||||||
(stream_type 3) and `STREAM_CTRL_OUT` (stream_type 4) are stream_types
|
(stream_type 3) and `STREAM_CTRL_OUT` (stream_type 4) are stream_types
|
||||||
in TTY's 5-byte format (ADR-052, amended by Phase 7), not
|
in TTY's 5-byte format (alknet ADR-052, amended by Phase 7), not
|
||||||
channels-layer concepts. The channels layer routes by `channel_id` only;
|
channels-layer concepts. The channels layer routes by `channel_id` only;
|
||||||
the handler owns its sub-stream multiplexing on the `BiStream` it
|
the handler owns its sub-stream multiplexing on the `BiStream` it
|
||||||
receives.
|
receives.
|
||||||
@@ -443,6 +559,10 @@ 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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
||||||
|
| [052](decisions/052-channel-control-surface.md) | `channel/control` Control-Handle Surface | The establisher-contributed `ChannelControl`; wrapper-install/teardown-removal; `channel:no_control_handle` |
|
||||||
|
| [053](decisions/053-resource-enumerators.md) | Live Resource Enumerators | The snapshot-then-changes enumerator contract; scan-once aggregation; the empty seed |
|
||||||
|
| [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`) |
|
| [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 |
|
| [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 +570,9 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
|
|
||||||
- ADR-047: openable ALPNs are operations (the unifying ADR — per-ALPN
|
- ADR-047: openable ALPNs are operations (the unifying ADR — per-ALPN
|
||||||
open ops, `channel_open` marker, `ChannelCore` wrapper, opener ledger)
|
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-037: channel lifecycle operations (amended by ADR-047)
|
||||||
- ADR-041: per-identity channel cap (amended by ADR-047 §7 — opener
|
- ADR-041: per-identity channel cap (amended by ADR-047 §7 — opener
|
||||||
ledger, every teardown path)
|
ledger, every teardown path)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-18
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# alknet-channels
|
# alkcall — Channels Protocol
|
||||||
|
|
||||||
A multiplexing proxy: a `ProtocolHandler` on `alk/channels` that
|
A multiplexing proxy: a `ProtocolHandler` on `alk/channels` that
|
||||||
decomposes a single bidirectional transport stream into N logical channels,
|
decomposes a single bidirectional transport stream into N logical channels,
|
||||||
@@ -20,46 +20,46 @@ handler owns its sub-stream multiplexing on the `BiStream` it receives.
|
|||||||
|
|
||||||
| Document | Status | Description |
|
| Document | Status | Description |
|
||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
| [overview.md](overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, ALPN, transport agnosticism, WASM, relationship to existing crates |
|
| [overview.md](channels-overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, ALPN, transport agnosticism, WASM, relationship to existing crates |
|
||||||
| [channels-wire.md](channels-wire.md) | draft | The 8-byte chunk format (`[channel_id:u32 be][length:u32 be][payload]`), the add/strip composition, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
|
| [channels-wire.md](channels-wire.md) | draft | The 8-byte chunk format (`[channel_id:u32 be][length:u32 be][payload]`), the add/strip composition, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
|
||||||
| [channels-connection.md](channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-008/074, as amended by ADR-035), `accept_bi` yields one `BiStream` per channel, recursive composition |
|
| [channels-connection.md](channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-008 + `from_source`, as amended by ADR-035), `accept_bi` yields one `BiStream` per channel, recursive composition |
|
||||||
| [channels-adapter.md](channels-adapter.md) | draft | `ChannelsAdapter` (`ProtocolHandler` on `alk/channels`), `ChannelManager`, demux/mux contracts (REQ-CH-01..04), the two-pump pattern (ADR-078) |
|
| [channels-adapter.md](channels-adapter.md) | draft | `ChannelsAdapter` (`ProtocolHandler` on `alk/channels`), `ChannelManager`, demux/mux contracts (REQ-CH-01..04), the two-pump pattern (ADR-050) |
|
||||||
| [channel-operations.md](channel-operations.md) | draft | `channel/open`, `channel/close`, `channel/control`, `channel/resources/subscribe` — call-protocol operations on channel 0, ACL flow, `direction` semantics, the hub relay contract (ADR-042) |
|
| [channel-operations.md](channel-operations.md) | draft | `channel/open`, `channel/close`, `channel/control`, `channel/resources/subscribe` — call-protocol operations on channel 0, ACL flow, `direction` semantics, the hub relay contract (ADR-042) |
|
||||||
| [channel-client.md](channel-client.md) | draft | `ChannelClient` — the client side of a channels connection; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045); bidirectionality preserved |
|
| [channel-client.md](channel-client.md) | draft | `ChannelClient` — the client side of a channels connection; transport-agnostic `from_connection` primary; dial lives in the composition layer's dial helper (ADR-045); bidirectionality preserved |
|
||||||
|
|
||||||
## Applicable ADRs
|
## Applicable ADRs
|
||||||
|
|
||||||
| ADR | Title | Relevance |
|
| ADR | Title | Relevance |
|
||||||
|-----|-------|-----------|
|
|-----|-------|-----------|
|
||||||
| [071](decisions/071-channels-wire-format.md) | channels Wire Format — 8-Byte Chunk Header | The chunk format; channels layer has no `stream_type` concept (amended by ADR-035); substrate-agnostic; one-way door |
|
| [034](decisions/034-channels-wire-format.md) | channels Wire Format — 8-Byte Chunk Header | The chunk format; channels layer has no `stream_type` concept (amended by ADR-035); substrate-agnostic; one-way door |
|
||||||
| [093](decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
|
| [035](decisions/035-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
|
||||||
| [072](decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alk/call` | Channel 0 = call protocol; no special control plane |
|
| [036](decisions/036-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alk/call` | Channel 0 = call protocol; no special control plane |
|
||||||
| [073](decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | `channel/open`/`close`/`control`/`resources/subscribe`; `direction` semantics; subscribe not poll |
|
| [037](decisions/037-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | `channel/open`/`close`/`control`/`resources/subscribe`; `direction` semantics; subscribe not poll |
|
||||||
| [074](decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `accept_bi` yields `BiStream` (amended by ADR-035 — `into_sub_streams` removed) |
|
| [038](decisions/038-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `accept_bi` yields `BiStream` (amended by ADR-035 — `into_sub_streams` removed) |
|
||||||
| [075](decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 contracts |
|
| [039](decisions/039-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 contracts |
|
||||||
| [076](decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Bounded-buffer (1 MiB default), 256-channel per-connection memory bound, monotonic IDs with wrap (DoS defense reframed by ADR-041) |
|
| [040](decisions/040-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Bounded-buffer (1 MiB default), 256-channel per-connection memory bound, monotonic IDs with wrap (DoS defense reframed by ADR-041) |
|
||||||
| [094](decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound; symmetric (both sides enforce); spoke caps hub (direct caller), not browser (forwarded_for is metadata) |
|
| [041](decisions/041-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound; symmetric (both sides enforce); spoke caps hub (direct caller), not browser (forwarded_for is metadata) |
|
||||||
| [077](decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | TTY's two modes (direct vs channels); TTY always uses its 5-byte format, carried transparently in the channels payload |
|
| [050](decisions/050-pump-bidi-two-pump-helper.md) | `pump_bidi` — the Two-Pump Helper | The two-pump deadlock contract; handler-level, not channels-layer |
|
||||||
| [078](decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | The two-pump deadlock contract; handler-level, not channels-layer |
|
| [042](decisions/042-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | The hub translates channel 0, byte-forwards data channels with ID rewrite |
|
||||||
| [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | The hub translates channel 0, byte-forwards data channels with ID rewrite |
|
| [043](decisions/043-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, transport-agnostic `from_connection` primary; dial lives in the composition layer's dial helper (ADR-045) |
|
||||||
| [080](decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045, resolves OQ-55) |
|
| [044](decisions/044-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient) modules inside alkcall; hub and worker are consumers, not sub-crates |
|
||||||
| [081](decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers, not sub-crates |
|
| [008](decisions/008-bidistreamsource-trait.md) | BidiStreamSource Trait | The `Connection` extension point `ChannelBidiStreamSource` implements |
|
||||||
| [070](decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The `Connection` extension point `ChannelBidiStreamSource` implements |
|
| [009](decisions/009-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream`; the transport-leaf decision ADR-035 builds on |
|
||||||
| [092](decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream`; the transport-leaf decision ADR-035 builds on |
|
| [007](decisions/007-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The transport-agnostic `Connection` the channels layer rides on |
|
||||||
| [065](decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The transport-agnostic `Connection` the channels layer rides on |
|
| [021](decisions/021-streaming-handler-for-subscriptions.md) | StreamingHandler for Subscriptions | The machinery `channel/resources/subscribe` uses |
|
||||||
| [052](decisions/052-alknet-tty-wire-format-and-two-carriage.md) | alknet-tty Wire Format | The 5-byte format carried transparently in the channels payload (control bidirectional via `STREAM_CTRL_IN`/`OUT` — Phase 7 amendment) |
|
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed channel opens |
|
||||||
| [049](decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler for Subscriptions | The machinery `channel/resources/subscribe` uses |
|
| [031](decisions/031-crate-decomposition.md) | Crate Decomposition | The channels subsystem lives inside alkcall with the call protocol and vendored core types; no handler-depends-on-handler |
|
||||||
| [032](decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed channel opens |
|
|
||||||
| [003](decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-channels depends on alknet-core only; no handler-depends-on-handler |
|
Provenance (source-repo ADRs, not ported): [alknet ADR-077 — TTY Inside Channels, Sub-Streams Not Wire Format](/workspace/@alkdev/alknet/docs/architecture/decisions/077-tty-inside-channels.md): TTY's two modes (direct vs channels); TTY always uses its 5-byte format, carried transparently in the channels payload. [alknet ADR-052 — TTY Wire Format](/workspace/@alkdev/alknet/docs/architecture/decisions/052-alknet-tty-wire-format-and-two-carriage.md): the 5-byte format carried transparently in the channels payload (control bidirectional via `STREAM_CTRL_IN`/`OUT` — Phase 7 amendment). TTY is now a sibling protocol crate (`alktty`).
|
||||||
|
|
||||||
## Relevant Open Questions
|
## Relevant Open Questions
|
||||||
|
|
||||||
| OQ | Title | Status | Relevance |
|
| OQ | Title | Status | Relevance |
|
||||||
|----|-------|--------|-----------|
|
|----|-------|--------|-----------|
|
||||||
| OQ-55 | AlknetClient / Client Establishment Extraction | resolved (ADR-045) | `ChannelClient`'s API is decided (ADR-043): transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (`alknet-client`, ADR-045) |
|
| OQ-23 ("[tracker](open-questions.md)"; source: alknet OQ-56) | Full channel-level flow-control windowing | deferred(scope) | Bounded-buffer is decided (ADR-040); full windowing is an extension blocked on "a real deployment observes HOL blocking on a saturated channel where bounded buffer is insufficient" |
|
||||||
| OQ-56 | Full channel-level flow-control windowing | deferred(scope) | Bounded-buffer is decided (ADR-040); full windowing is an extension blocked on "a real deployment observes HOL blocking on a saturated channel where bounded buffer is insufficient" |
|
| Dial seam (source: alknet OQ-55) | Client dial helper extraction | resolved (ADR-045); ownership → OQ-43 | `ChannelClient`'s API is decided (ADR-043): transport-agnostic `from_connection` primary; the dial lives in the composition layer's dial helper (`AlknetClient` today; post-alknet owner and shape — OQ-43) |
|
||||||
| OQ-57 | Two-pump helper extraction to alknet-core | deferred(scope) | The shutdown-on-completion *contract* is decided (ADR-078); the *helper* extraction is blocked on a second two-pump handler existing (shape convergence) |
|
| Two-pump helper (source: alknet OQ-57) | Two-pump helper extraction to a shared core crate | dissolved | The shutdown-on-completion *contract* is decided and the helper is in-tree (`channels::pump_bidi`, ADR-050); the extraction target (a mono-repo core crate being replaced) no longer exists — alkcall owns its helpers |
|
||||||
| OQ-68 | Add/strip API shape (built-in vs utility) | open | Whether the 8-byte header add/strip is built into the channels read/write path or exposed as a standalone utility. The *contract* is decided (ADR-035); the *function surface* is not |
|
| OQ-24 ("[tracker](open-questions.md)"; source: alknet OQ-68) | Add/strip API shape (built-in vs utility) | resolved | Whether the 8-byte header add/strip is built into the channels read/write path or exposed as a standalone utility. The *contract* is decided (ADR-035); the shipped surface integrates the strip (see OQ-24 in [open-questions.md](open-questions.md)) |
|
||||||
|
|
||||||
## Key Design Principles
|
## Key Design Principles
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ handler owns its sub-stream multiplexing on the `BiStream` it receives.
|
|||||||
in how they're *opened* (negotiation via `channel/open` on channel 0)
|
in how they're *opened* (negotiation via `channel/open` on channel 0)
|
||||||
and what *multiplexing layer* carries them (the 8-byte chunk format).
|
and what *multiplexing layer* carries them (the 8-byte chunk format).
|
||||||
Once normalized, every channel is an ALPN routed through the same
|
Once normalized, every channel is an ALPN routed through the same
|
||||||
`HandlerRegistry`. See [overview.md](overview.md) and ADR-034 (as
|
`HandlerRegistry`. See [overview.md](channels-overview.md) and ADR-034 (as
|
||||||
amended by ADR-035).
|
amended by ADR-035).
|
||||||
|
|
||||||
2. **Channel 0 is `alk/call` pre-negotiated, not a special control
|
2. **Channel 0 is `alk/call` pre-negotiated, not a special control
|
||||||
@@ -136,6 +136,10 @@ handler owns its sub-stream multiplexing on the `BiStream` it receives.
|
|||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
|
Source-repo research and history (alknet, provenance — these paths live
|
||||||
|
in the alknet repo, `/workspace/@alkdev/alknet/docs/research/...`, not
|
||||||
|
this one):
|
||||||
|
|
||||||
- `docs/research/alknet-channels/phase-0-findings.md` — Phase 0 research
|
- `docs/research/alknet-channels/phase-0-findings.md` — Phase 0 research
|
||||||
(vision, hub motivation, wire format, negotiation, internals, DPs, OQs)
|
(vision, hub motivation, wire format, negotiation, internals, DPs, OQs)
|
||||||
- `docs/research/alknet-channels/poc-summary.md` — the de-risk POC (28
|
- `docs/research/alknet-channels/poc-summary.md` — the de-risk POC (28
|
||||||
@@ -146,8 +150,10 @@ handler owns its sub-stream multiplexing on the `BiStream` it receives.
|
|||||||
surfaced the pure-channel-multiplexing resolution (ADR-035)
|
surfaced the pure-channel-multiplexing resolution (ADR-035)
|
||||||
- `/workspace/alknet-channels-poc/` — the POC codebase
|
- `/workspace/alknet-channels-poc/` — the POC codebase
|
||||||
- `docs/research/alknet-tty/phase-0-findings.md` — the TTY crate's chunk
|
- `docs/research/alknet-tty/phase-0-findings.md` — the TTY crate's chunk
|
||||||
format (the seed of the channels generalization)
|
format (the seed of the channels generalization; TTY is now the
|
||||||
|
sibling crate `alktty`)
|
||||||
- `docs/research/alknet-ssh/phase-0-findings.md` — SSH's channel
|
- `docs/research/alknet-ssh/phase-0-findings.md` — SSH's channel
|
||||||
multiplexer (the prior art for N-channel multiplexing)
|
multiplexer (the prior art for N-channel multiplexing)
|
||||||
- `docs/architecture/crates/hub/README.md` — the hub crate (the primary
|
- Source-repo hub crate (alknet, provenance — the composition layer's
|
||||||
consumer; the relay implementation's home)
|
hub wiring; the relay implementation itself is an alkcall export,
|
||||||
|
`ChannelRelay`, ADR-051)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-18
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# channels-adapter.md — ChannelsAdapter and ChannelManager
|
# channels-adapter.md — ChannelsAdapter and ChannelManager
|
||||||
@@ -69,7 +69,7 @@ transport's stream count differs.
|
|||||||
## `ChannelManager`
|
## `ChannelManager`
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// In alknet-channels-core:
|
// In alkcall (channels subsystem):
|
||||||
pub struct ChannelManager {
|
pub struct ChannelManager {
|
||||||
channels: Mutex<HashMap<u32, ChannelState>>,
|
channels: Mutex<HashMap<u32, ChannelState>>,
|
||||||
handlers: Arc<HandlerRegistry>,
|
handlers: Arc<HandlerRegistry>,
|
||||||
@@ -238,7 +238,7 @@ The runner's `select!` loop exits when all `MuxHandle` clones drop (the
|
|||||||
`new_pumps` sender closes) — the natural shutdown signal. This matches the
|
`new_pumps` sender closes) — the natural shutdown signal. This matches the
|
||||||
dynamic `channel/open` model.
|
dynamic `channel/open` model.
|
||||||
|
|
||||||
## The two-pump pattern (ADR-078 — documented here for handler authors)
|
## The two-pump pattern (ADR-050 — documented here for handler authors)
|
||||||
|
|
||||||
Handlers with a two-pump shape (two `tokio::io::copy` pumps, one per
|
Handlers with a two-pump shape (two `tokio::io::copy` pumps, one per
|
||||||
direction — tunnel, SSH `direct-tcpip`) MUST shut down the opposite sink
|
direction — tunnel, SSH `direct-tcpip`) MUST shut down the opposite sink
|
||||||
@@ -262,7 +262,8 @@ tokio::try_join!(c2t, t2c)?;
|
|||||||
|
|
||||||
The three-pump pattern (TTY's `pump_session`, coordinating via the
|
The three-pump pattern (TTY's `pump_session`, coordinating via the
|
||||||
`exit_code` future) does not have this deadlock — the `exit_code` future is
|
`exit_code` future) does not have this deadlock — the `exit_code` future is
|
||||||
the third signal. The two-pump pattern is documented in ADR-078; the
|
the third signal. The two-pump pattern is pinned in-tree by ADR-050
|
||||||
|
(`channels::pump_bidi`); the
|
||||||
shutdown-on-completion contract is a handler-level concern, not a
|
shutdown-on-completion contract is a handler-level concern, not a
|
||||||
channels-layer one.
|
channels-layer one.
|
||||||
|
|
||||||
@@ -308,7 +309,8 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
|
|
||||||
- ADR-039: ChannelsAdapter and ChannelManager (the decision)
|
- ADR-039: ChannelsAdapter and ChannelManager (the decision)
|
||||||
- ADR-035: channels pure channel multiplexing (the umbrella decision that
|
- ADR-035: channels pure channel multiplexing (the umbrella decision that
|
||||||
amends ADR-034/074/077)
|
amends ADR-034 and the source-repo connection/from_source + TTY-inside-channels decisions —
|
||||||
|
alknet ADR-074/077, provenance; the in-crate homes are ADR-007/008)
|
||||||
- ADR-036: channel 0 pre-negotiated (the `preinstall_channel_0` step)
|
- ADR-036: channel 0 pre-negotiated (the `preinstall_channel_0` step)
|
||||||
- ADR-037: channel lifecycle operations (the ops registered on `call_ops`)
|
- ADR-037: channel lifecycle operations (the ops registered on `call_ops`)
|
||||||
- ADR-038: ChannelBidiStreamSource (what the manager constructs per
|
- ADR-038: ChannelBidiStreamSource (what the manager constructs per
|
||||||
@@ -318,7 +320,9 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
- ADR-041: per-identity channel cap (the `ChannelLifecyclePolicy`
|
- ADR-041: per-identity channel cap (the `ChannelLifecyclePolicy`
|
||||||
consulted by the `channel/open` handler; the relay consequence for
|
consulted by the `channel/open` handler; the relay consequence for
|
||||||
hub-relayed channels)
|
hub-relayed channels)
|
||||||
- `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #4-#7
|
- Source-repo POC research (alknet provenance):
|
||||||
(REQ-CH-01..04, the two-pump deadlock)
|
`/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md`
|
||||||
- `docs/research/stream-unification/findings.md` — the research that
|
§Issues Surfaced #4-#7
|
||||||
|
(REQ-CH-01..04, the two-pump deadlock);
|
||||||
|
`/workspace/@alkdev/alknet/docs/research/stream-unification/findings.md` — the research that
|
||||||
surfaced the pure-multiplexing resolution
|
surfaced the pure-multiplexing resolution
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-18
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# channels-connection.md — ChannelBidiStreamSource and `BiStream` Access
|
# channels-connection.md — ChannelBidiStreamSource and `BiStream` Access
|
||||||
@@ -21,7 +21,7 @@ tunnel uses the `BiStream` as raw bytes, call length-prefixes JSON, SSH
|
|||||||
runs its own channel protocol.
|
runs its own channel protocol.
|
||||||
|
|
||||||
The `BiStream` is wrapped in a `ChannelBidiStreamSource` that implements
|
The `BiStream` is wrapped in a `ChannelBidiStreamSource` that implements
|
||||||
`alknet-core`'s `BidiStreamSource` trait (ADR-008), and a `Connection` is
|
alkcall's vendored-core `BidiStreamSource` trait (ADR-008), and a `Connection` is
|
||||||
constructed from it via `Connection::from_source(source, alpn)`. The
|
constructed from it via `Connection::from_source(source, alpn)`. The
|
||||||
handler receives a `Connection`, calls `accept_bi()` once (yield-once per
|
handler receives a `Connection`, calls `accept_bi()` once (yield-once per
|
||||||
channel), gets a `BiStream`, and drives its session — identical to how it
|
channel), gets a `BiStream`, and drives its session — identical to how it
|
||||||
@@ -30,7 +30,7 @@ works on a top-level QUIC connection.
|
|||||||
## `ChannelBidiStreamSource`
|
## `ChannelBidiStreamSource`
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// In alknet-channels:
|
// In alkcall (channels subsystem):
|
||||||
|
|
||||||
pub struct ChannelBidiStreamSource {
|
pub struct ChannelBidiStreamSource {
|
||||||
// The reassembly buffer for this channel's payload bytes (one per
|
// The reassembly buffer for this channel's payload bytes (one per
|
||||||
@@ -86,7 +86,7 @@ async fn handle(&self, connection: Connection, _auth: &AuthContext)
|
|||||||
let (mut tcp_read, mut tcp_write) = tcp.into_split();
|
let (mut tcp_read, mut tcp_write) = tcp.into_split();
|
||||||
let (mut recv, mut send) = tokio::io::split(&mut bidi);
|
let (mut recv, mut send) = tokio::io::split(&mut bidi);
|
||||||
|
|
||||||
// Two-pump with shutdown-on-completion (ADR-078)
|
// Two-pump with shutdown-on-completion (ADR-050)
|
||||||
let c2t = async {
|
let c2t = async {
|
||||||
tokio::io::copy(&mut recv, &mut tcp_write).await?;
|
tokio::io::copy(&mut recv, &mut tcp_write).await?;
|
||||||
tcp_write.shutdown().await.ok();
|
tcp_write.shutdown().await.ok();
|
||||||
@@ -158,11 +158,11 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
|
|
||||||
| ADR | Decision | Summary |
|
| ADR | Decision | Summary |
|
||||||
|-----|----------|---------|
|
|-----|----------|---------|
|
||||||
| [074](decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` is the only accessor |
|
| [038](decisions/038-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` is the only accessor |
|
||||||
| [093](decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `BiStream`-only |
|
| [035](decisions/035-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `BiStream`-only |
|
||||||
| [070](decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The extension point `ChannelBidiStreamSource` implements |
|
| [008](decisions/008-bidistreamsource-trait.md) | BidiStreamSource Trait | The extension point `ChannelBidiStreamSource` implements |
|
||||||
| [092](decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream` (the transport-leaf decision this doc builds on) |
|
| [009](decisions/009-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream` (the transport-leaf decision this doc builds on) |
|
||||||
| [065](decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The yield-once path generalized for channels |
|
| [007](decisions/007-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The yield-once path generalized for channels |
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -171,9 +171,11 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
- ADR-008: BidiStreamSource trait
|
- ADR-008: BidiStreamSource trait
|
||||||
- ADR-009: `BiStream` as the handler leaf
|
- ADR-009: `BiStream` as the handler leaf
|
||||||
- ADR-007: `Connection::from_stream` (the yield-once path generalized)
|
- ADR-007: `Connection::from_stream` (the yield-once path generalized)
|
||||||
- ADR-077: TTY inside channels (TTY always uses its 5-byte format,
|
- Provenance (source-repo, not ported): [alknet ADR-077](/workspace/@alkdev/alknet/docs/architecture/decisions/077-tty-inside-channels.md) — TTY inside channels (TTY always uses its 5-byte format,
|
||||||
carried transparently in the channels payload)
|
carried transparently in the channels payload)
|
||||||
- `docs/research/alknet-channels/poc-summary.md` §POC Target 2 (the
|
- Source-repo POC research (alknet provenance):
|
||||||
yield-once `Connection::from_stream` validation)
|
`/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md`
|
||||||
- `docs/research/stream-unification/findings.md` — the research that
|
§POC Target 2 (the
|
||||||
|
yield-once `Connection::from_stream` validation);
|
||||||
|
`/workspace/@alkdev/alknet/docs/research/stream-unification/findings.md` — the research that
|
||||||
surfaced the single-accessor resolution
|
surfaced the single-accessor resolution
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-18
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# alknet-channels — Overview
|
# alkcall channels — Overview
|
||||||
|
|
||||||
## What
|
## What
|
||||||
|
|
||||||
`alknet-channels` is a multiplexing proxy crate. It implements
|
The channels subsystem of alkcall is a multiplexing proxy. It implements
|
||||||
`ProtocolHandler` for the `alk/channels` ALPN: it receives one
|
`ProtocolHandler` for the `alk/channels` ALPN: it receives one
|
||||||
bidirectional transport stream, reads 8-byte chunk headers, and routes each
|
bidirectional transport stream, reads 8-byte chunk headers, and routes each
|
||||||
chunk's payload to the right logical channel. Each channel is reassembled
|
chunk's payload to the right logical channel. Each channel is reassembled
|
||||||
@@ -28,7 +28,7 @@ multiplexing on the `BiStream` it receives.
|
|||||||
|
|
||||||
### The problem: three multiplexing models that don't compose
|
### The problem: three multiplexing models that don't compose
|
||||||
|
|
||||||
Before channels, alknet had three multiplexing models:
|
Before channels, the alk stack had three multiplexing models:
|
||||||
|
|
||||||
| Model | Where | Mechanism |
|
| Model | Where | Mechanism |
|
||||||
|-------|-------|-----------|
|
|-------|-------|-----------|
|
||||||
@@ -89,7 +89,8 @@ on the `BiStream` the channels layer gives them (ADR-035).
|
|||||||
inner layer's framing byte, carried transparently in the payload.
|
inner layer's framing byte, carried transparently in the payload.
|
||||||
- **The control channel is handler-internal.** TTY sub-demuxes control
|
- **The control channel is handler-internal.** TTY sub-demuxes control
|
||||||
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN` /
|
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN` /
|
||||||
`STREAM_CTRL_OUT` — ADR-052 amended by Phase 7). The channels layer
|
`STREAM_CTRL_OUT` — the TTY wire format, [alknet ADR-052](/workspace/@alkdev/alknet/docs/architecture/decisions/052-alknet-tty-wire-format-and-two-carriage.md)
|
||||||
|
amended by Phase 7; TTY is now the sibling crate `alktty`). The channels layer
|
||||||
doesn't carry control.
|
doesn't carry control.
|
||||||
- **Recursive composition is literal.** A channel with ALPN
|
- **Recursive composition is literal.** A channel with ALPN
|
||||||
`alk/channels` runs another channels demux on its `BiStream`. The
|
`alk/channels` runs another channels demux on its `BiStream`. The
|
||||||
@@ -110,8 +111,9 @@ The crate has two internal components (ADR-039):
|
|||||||
`channel/open` operation handler closes over.
|
`channel/open` operation handler closes over.
|
||||||
|
|
||||||
Each channel is presented to its handler as a `Connection` constructed via
|
Each channel is presented to its handler as a `Connection` constructed via
|
||||||
`Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-008/074, as
|
`Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-008 for the
|
||||||
amended by ADR-035). The handler calls `accept_bi()` once (yield-once per
|
extension point, ADR-007 for the `Connection` constructors, as amended by
|
||||||
|
ADR-035). The handler calls `accept_bi()` once (yield-once per
|
||||||
channel) and gets a `BiStream` — identical to how it works on a top-level
|
channel) and gets a `BiStream` — identical to how it works on a top-level
|
||||||
QUIC connection.
|
QUIC connection.
|
||||||
|
|
||||||
@@ -139,7 +141,8 @@ Downstream crates depend on alkcall as a single dependency:
|
|||||||
alktty / alktunnels / alktrader (protocol crates)
|
alktty / alktunnels / alktrader (protocol crates)
|
||||||
└── alkcall (call + channels protocols, no networking)
|
└── alkcall (call + channels protocols, no networking)
|
||||||
|
|
||||||
alknet / alknode (networking + composition)
|
composition layer (dial/accept + hub — today the alknet glue,
|
||||||
|
being replaced; future owner and shape → OQ-43)
|
||||||
├── alkcall
|
├── alkcall
|
||||||
├── alktty (optional — whichever protocol crates are wired in)
|
├── alktty (optional — whichever protocol crates are wired in)
|
||||||
├── alktunnels (optional)
|
├── alktunnels (optional)
|
||||||
@@ -147,7 +150,7 @@ alknet / alknode (networking + composition)
|
|||||||
```
|
```
|
||||||
|
|
||||||
The hub and worker are **consumers** of alkcall, not sub-crates. The
|
The hub and worker are **consumers** of alkcall, not sub-crates. The
|
||||||
downstream alknet crate IS the hub — it depends on alkcall and uses
|
composition layer IS the hub — it depends on alkcall and uses
|
||||||
channels as its substrate, with the relay logic (ADR-042) living
|
channels as its substrate, with the relay logic (ADR-042) living
|
||||||
alongside its peer lifecycle and service discovery responsibilities.
|
alongside its peer lifecycle and service discovery responsibilities.
|
||||||
A worker is any crate that uses `ChannelClient` to dial. There are no
|
A worker is any crate that uses `ChannelClient` to dial. There are no
|
||||||
@@ -168,7 +171,7 @@ stream:
|
|||||||
|-----------|-----|
|
|-----------|-----|
|
||||||
| QUIC bidi stream | `alk/channels` ALPN on a QUIC connection; one bidi stream carries all channels |
|
| QUIC bidi stream | `alk/channels` ALPN on a QUIC connection; one bidi stream carries all channels |
|
||||||
| TCP+TLS | `alk/channels` ALPN on a TLS connection; the TCP stream carries all channels |
|
| TCP+TLS | `alk/channels` ALPN on a TLS connection; the TCP stream carries all channels |
|
||||||
| WebTransport | `alk/channels` session (deferred per ADR-044; the browser path uses WebSocket carrying `alk/channels`) |
|
| WebTransport | `alk/channels` session (deferred per alknet ADR-044 — websocket-native session, provenance — the browser path uses WebSocket carrying `alk/channels`) |
|
||||||
| SSH channel | channels connection riding inside an SSH `direct-tcpip` channel (channels-over-SSH) |
|
| SSH channel | channels connection riding inside an SSH `direct-tcpip` channel (channels-over-SSH) |
|
||||||
| Another channels connection | recursive composition (channel type `alk/channels` inside `alk/channels`) |
|
| Another channels connection | recursive composition (channel type `alk/channels` inside `alk/channels`) |
|
||||||
|
|
||||||
@@ -186,14 +189,14 @@ async shell (demux/mux) wraps this core with `read_exact`/`write_all` and
|
|||||||
`mpsc` routing.
|
`mpsc` routing.
|
||||||
|
|
||||||
The `ChannelManager` is ALPN-blind, auth-blind, and transport-blind (ADR-
|
The `ChannelManager` is ALPN-blind, auth-blind, and transport-blind (ADR-
|
||||||
075) — pure byte routing with no platform or protocol dependencies. A WASM
|
039) — pure byte routing with no platform or protocol dependencies. A WASM
|
||||||
build can read chunks from a WebTransport `BiStream`, reassemble them, and
|
build can read chunks from a WebTransport `BiStream`, reassemble them, and
|
||||||
present `AsyncRead + AsyncWrite` handles to WASM-compatible handlers. The
|
present `AsyncRead + AsyncWrite` handles to WASM-compatible handlers. The
|
||||||
handlers themselves may or may not be WASM-compatible (russh's client is;
|
handlers themselves may or may not be WASM-compatible (russh's client is;
|
||||||
`portable_pty` is not), but the channels layer is WASM-compatible by
|
`portable_pty` is not), but the channels layer is WASM-compatible by
|
||||||
construction.
|
construction.
|
||||||
|
|
||||||
The async shell and `alknet-core` dep graph are not fully WASM-clean yet
|
The async shell dep graph is not fully WASM-clean yet
|
||||||
(transitive `getrandom`/`rand` deps) — this is an implementation concern,
|
(transitive `getrandom`/`rand` deps) — this is an implementation concern,
|
||||||
not an architecture concern. The sync core's WASM compatibility is validated.
|
not an architecture concern. The sync core's WASM compatibility is validated.
|
||||||
|
|
||||||
@@ -225,9 +228,9 @@ A protocol crate can support two paths for its wire format:
|
|||||||
The protocol crate doesn't know which path it's on — it takes a
|
The protocol crate doesn't know which path it's on — it takes a
|
||||||
`BiStream` (or `AsyncRead + AsyncWrite`) and drives the session. The
|
`BiStream` (or `AsyncRead + AsyncWrite`) and drives the session. The
|
||||||
two adapters are thin and live either in the protocol crate (behind
|
two adapters are thin and live either in the protocol crate (behind
|
||||||
feature flags) or in the downstream alknet crate.
|
feature flags) or in the composition layer.
|
||||||
|
|
||||||
### alknet-call
|
### Channel 0 is the call protocol
|
||||||
|
|
||||||
The call protocol runs on channel 0 exactly as on a top-level
|
The call protocol runs on channel 0 exactly as on a top-level
|
||||||
`alk/call` connection. The `Dispatcher` receives a `Connection`
|
`alk/call` connection. The `Dispatcher` receives a `Connection`
|
||||||
@@ -236,7 +239,7 @@ doesn't know it's inside channels. The call protocol's `EventEnvelope`
|
|||||||
framing (ADR-014) is the channels payload; the channels layer carries
|
framing (ADR-014) is the channels payload; the channels layer carries
|
||||||
it transparently.
|
it transparently.
|
||||||
|
|
||||||
### alknet-hub / alknode
|
### Hub (composition layer)
|
||||||
|
|
||||||
The hub is the primary consumer. With channels, the hub holds one
|
The hub is the primary consumer. With channels, the hub holds one
|
||||||
channels connection per leg (browser↔hub, hub↔spoke) and relays
|
channels connection per leg (browser↔hub, hub↔spoke) and relays
|
||||||
@@ -261,7 +264,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
| [040](decisions/040-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer (1 MiB), 256-channel cap, monotonic IDs |
|
| [040](decisions/040-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer (1 MiB), 256-channel cap, monotonic IDs |
|
||||||
| [041](decisions/041-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy`; per-connection `max_channels` reframed as a memory bound |
|
| [041](decisions/041-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy`; per-connection `max_channels` reframed as a memory bound |
|
||||||
| [042](decisions/042-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels with ID rewrite |
|
| [042](decisions/042-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels with ID rewrite |
|
||||||
| [043](decisions/043-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045, resolves OQ-55) |
|
| [043](decisions/043-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in the composition layer's dial helper (ADR-045) |
|
||||||
| [044](decisions/044-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers |
|
| [044](decisions/044-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers |
|
||||||
| [047](decisions/047-openable-alpns-are-operations.md) | Openable ALPNs Are Operations | `channel/open` dissolves into per-ALPN ops; `ChannelCore` wrapper; connection-owner allocates `channel_id` |
|
| [047](decisions/047-openable-alpns-are-operations.md) | Openable ALPNs Are Operations | `channel/open` dissolves into per-ALPN ops; `ChannelCore` wrapper; connection-owner allocates `channel_id` |
|
||||||
|
|
||||||
@@ -270,18 +273,22 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
Open questions are tracked in [open-questions.md](open-questions.md).
|
Open questions are tracked in [open-questions.md](open-questions.md).
|
||||||
Key questions affecting this crate:
|
Key questions affecting this crate:
|
||||||
|
|
||||||
- **OQ-55** (resolved by ADR-045): `AlknetClient` core **dial+TLS seam**
|
- **Dial seam** (ADR-045; source: alknet OQ-55): the dial+TLS seam is
|
||||||
— extracted as `alknet-client` with three dial methods.
|
the composition layer's dial helper (`AlknetClient` today — future
|
||||||
`ChannelClient`'s API is transport-agnostic (`from_connection`); the
|
owner and shape OQ-43). `ChannelClient`'s API is transport-agnostic
|
||||||
dial is the shared seam, now extracted. See
|
(`from_connection`); the dial is the shared seam. See
|
||||||
[ADR-045](decisions/045-alknetclient-native-dial-seam.md).
|
[ADR-045](decisions/045-alknetclient-native-dial-seam.md).
|
||||||
- **OQ-56** (deferred(scope)): Full channel-level flow-control windowing —
|
- **OQ-23** ([tracker](open-questions.md); source: alknet OQ-56)
|
||||||
|
(deferred(scope)): Full channel-level flow-control windowing —
|
||||||
bounded-buffer is decided (ADR-040); full windowing is an extension
|
bounded-buffer is decided (ADR-040); full windowing is an extension
|
||||||
blocked on a real HOL-blocking deployment observation.
|
blocked on a real HOL-blocking deployment observation.
|
||||||
- **OQ-57** (deferred(scope)): Two-pump helper extraction to alknet-core —
|
- **Two-pump helper** (source: alknet OQ-57) (dissolved): the
|
||||||
the *contract* is decided (ADR-078); the *helper* is blocked on a second
|
contract is decided (the in-tree helper landed as
|
||||||
two-pump handler existing.
|
`channels::pump_bidi`, ADR-050); the extraction target (a mono-repo
|
||||||
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
|
core crate) no longer exists — alkcall owns its helpers.
|
||||||
|
- **OQ-24** ([tracker](open-questions.md); source: alknet OQ-68)
|
||||||
|
(resolved): Add/strip API shape — whether the 8-byte header
|
||||||
add/strip is built into the channels read/write path or exposed as a
|
add/strip is built into the channels read/write path or exposed as a
|
||||||
standalone utility. The *contract* (channels strips, handler parses
|
standalone utility. The *contract* (channels strips, handler parses
|
||||||
payload) is decided (ADR-035); the *function surface* is not.
|
payload) is decided (ADR-035); the shipped surface integrates the
|
||||||
|
strip (see OQ-24 in [open-questions.md](open-questions.md)).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-18
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# channels-wire.md — The 8-Byte Chunk Format
|
# channels-wire.md — The 8-Byte Chunk Format
|
||||||
@@ -36,7 +36,7 @@ hand-rolled (the hot path); the BAST document is the contract.
|
|||||||
|
|
||||||
The payload is opaque to the channels layer. The handler parses its own
|
The payload is opaque to the channels layer. The handler parses its own
|
||||||
framing from the payload — TTY's `[stream_type:u8][length:u32][payload]`
|
framing from the payload — TTY's `[stream_type:u8][length:u32][payload]`
|
||||||
(5-byte format, ADR-052), call's length-prefixed JSON (`EventEnvelope`
|
(5-byte format, alknet ADR-052), call's length-prefixed JSON (`EventEnvelope`
|
||||||
framing, ADR-014), tunnel's raw bytes, SSH's channel protocol. The
|
framing, ADR-014), tunnel's raw bytes, SSH's channel protocol. The
|
||||||
channels layer carries the bytes transparently.
|
channels layer carries the bytes transparently.
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ its framing entirely. See ADR-035 for the full cost/benefit analysis.
|
|||||||
|
|
||||||
## `MAX_CHUNK_LEN`
|
## `MAX_CHUNK_LEN`
|
||||||
|
|
||||||
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (ADR-052 §5). A chunk with
|
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (alknet ADR-052 §5). A chunk with
|
||||||
`length > MAX_CHUNK_LEN` returns `ChunkTooLarge` and does not corrupt the
|
`length > MAX_CHUNK_LEN` returns `ChunkTooLarge` and does not corrupt the
|
||||||
stream — the demux drops the chunk and continues. The header is always
|
stream — the demux drops the chunk and continues. The header is always
|
||||||
exactly 8 bytes, so the demux can always resync by reading the next
|
exactly 8 bytes, so the demux can always resync by reading the next
|
||||||
@@ -103,14 +103,15 @@ There is no channels-layer framing-disambiguation trick beyond the fixed
|
|||||||
8-byte header. The channels layer does not interpret the payload — it
|
8-byte header. The channels layer does not interpret the payload — it
|
||||||
doesn't know if the payload is TTY chunks, call frames, or tunnel bytes.
|
doesn't know if the payload is TTY chunks, call frames, or tunnel bytes.
|
||||||
Any framing disambiguation within the payload is the handler's concern
|
Any framing disambiguation within the payload is the handler's concern
|
||||||
(see `tty-wire.md` §"Framing disambiguation" for TTY's first-byte trick,
|
(see the TTY wire format's "framing disambiguation" — alknet ADR-052,
|
||||||
|
provenance; TTY is now the sibling crate `alktty` — for TTY's first-byte trick,
|
||||||
which is internal to TTY's 5-byte format).
|
which is internal to TTY's 5-byte format).
|
||||||
|
|
||||||
## Zero-length sentinel = EOF
|
## Zero-length sentinel = EOF
|
||||||
|
|
||||||
A zero-length chunk (`length = 0`) is delivered as an empty payload,
|
A zero-length chunk (`length = 0`) is delivered as an empty payload,
|
||||||
which the reassembled stream interprets as EOF. This is the clean-shutdown
|
which the reassembled stream interprets as EOF. This is the clean-shutdown
|
||||||
signal for a `channel_id` — the same convention as TTY (ADR-052
|
signal for a `channel_id` — the same convention as TTY (alknet ADR-052
|
||||||
§Sentinels), now at the channels layer (one sentinel per channel, not
|
§Sentinels), now at the channels layer (one sentinel per channel, not
|
||||||
per `(channel_id, stream_type)`).
|
per `(channel_id, stream_type)`).
|
||||||
|
|
||||||
@@ -141,13 +142,16 @@ experience. See ADR-034 §substrate modes (as amended by ADR-035), ADR-039.
|
|||||||
**Implementation status:** the in-line substrate mode is implemented
|
**Implementation status:** the in-line substrate mode is implemented
|
||||||
(single bidi stream, header-demuxed N channels). The QUIC-native
|
(single bidi stream, header-demuxed N channels). The QUIC-native
|
||||||
multi-stream substrate (accept remaining bidi streams, read headers off
|
multi-stream substrate (accept remaining bidi streams, read headers off
|
||||||
each) is deferred to the downstream alknet crate (OQ-41). The wire
|
each) is deferred to the composition layer's dial/accept half (OQ-41;
|
||||||
|
its owner — OQ-43). The wire
|
||||||
format and demux loop are correct for both substrates; only the outer
|
format and demux loop are correct for both substrates; only the outer
|
||||||
`accept_bi()` loop is missing.
|
`accept_bi()` loop is missing.
|
||||||
|
|
||||||
## Wire-level invariants (REQ-CH-01, 02, 04, 05)
|
## Wire-level invariants (REQ-CH-01, 02, 04, 05)
|
||||||
|
|
||||||
The de-risk POC (`docs/research/alknet-channels/poc-summary.md` §Issues
|
The de-risk POC (source-repo research, alknet provenance:
|
||||||
|
`/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md`
|
||||||
|
§Issues
|
||||||
Surfaced) surfaced invariants that hang channels silently if underspecified.
|
Surfaced) surfaced invariants that hang channels silently if underspecified.
|
||||||
These are **contracts**, not implementation details — both sides must agree.
|
These are **contracts**, not implementation details — both sides must agree.
|
||||||
|
|
||||||
@@ -245,9 +249,13 @@ same shape, each level.
|
|||||||
|
|
||||||
The exact API shape of the add/strip pair (built into the read/write path
|
The exact API shape of the add/strip pair (built into the read/write path
|
||||||
vs. a standalone utility) is an implementation detail for the channels
|
vs. a standalone utility) is an implementation detail for the channels
|
||||||
crate, tracked as OQ-68. The *contract* — the channels layer strips its
|
crate; the shipped surface resolves it (OQ-24, formerly alknet OQ-68):
|
||||||
8-byte header on read and the handler parses its own framing from the
|
the strip is integrated into the read/write path — the demux loop
|
||||||
payload — is decided; the *function surface* is not.
|
consumes `read_header`/`write_chunk` so a handler's `BiStream` yields
|
||||||
|
payload chunks and never sees the 8-byte header — while the low-level
|
||||||
|
primitives (`parse_header`/`write_header`/`read_header`/`write_chunk`/
|
||||||
|
`write_eof` in `channels::wire`) stay exposed as the standalone utility
|
||||||
|
for tooling.
|
||||||
|
|
||||||
## Channel lifecycle (summary)
|
## Channel lifecycle (summary)
|
||||||
|
|
||||||
@@ -258,14 +266,14 @@ payload — is decided; the *function surface* is not.
|
|||||||
| Control (out-of-band) | `channel/control` call operation on channel 0 (deferred — OQ-39) | ADR-037 |
|
| Control (out-of-band) | `channel/control` call operation on channel 0 (deferred — OQ-39) | ADR-037 |
|
||||||
| Close | `channel/close` call operation on channel 0; data chunks flushed before close | ADR-037, REQ-CH-06 |
|
| Close | `channel/close` call operation on channel 0; data chunks flushed before close | ADR-037, REQ-CH-06 |
|
||||||
|
|
||||||
### REQ-CH-06: exit-chunk-before-close ordering (generalizes ADR-055)
|
### REQ-CH-06: exit-chunk-before-close ordering (generalizes alknet ADR-055 — the TTY exit-chunk invariant, provenance)
|
||||||
|
|
||||||
The channel's data chunks MUST be written and flushed before the
|
The channel's data chunks MUST be written and flushed before the
|
||||||
`channel/close` operation is sent on channel 0. This is a wire-level
|
`channel/close` operation is sent on channel 0. This is a wire-level
|
||||||
invariant: the side closing must observe the data-channel pump complete
|
invariant: the side closing must observe the data-channel pump complete
|
||||||
before issuing the call operation.
|
before issuing the call operation.
|
||||||
|
|
||||||
For TTY this is the exit-chunk-is-last invariant (ADR-055) carried
|
For TTY this is the exit-chunk-is-last invariant (alknet ADR-055, provenance) carried
|
||||||
forward: the exit control message (on TTY's `STREAM_CTRL_OUT` stream_type
|
forward: the exit control message (on TTY's `STREAM_CTRL_OUT` stream_type
|
||||||
4, inside TTY's 5-byte payload) is the last data before `channel/close`.
|
4, inside TTY's 5-byte payload) is the last data before `channel/close`.
|
||||||
For tunnels it is the last data byte before close. The channels layer's
|
For tunnels it is the last data byte before close. The channels layer's
|
||||||
@@ -293,23 +301,25 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
Open questions are tracked in [open-questions.md](open-questions.md).
|
Open questions are tracked in [open-questions.md](open-questions.md).
|
||||||
Key questions affecting this doc:
|
Key questions affecting this doc:
|
||||||
|
|
||||||
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
|
- **OQ-24** ([tracker](open-questions.md); source: alknet OQ-68) (resolved): Add/strip API shape —
|
||||||
add/strip is built into the channels read/write path or exposed as a
|
built into the read/write path (the demux loop strips headers; handlers
|
||||||
standalone utility. The *contract* (channels strips, handler parses
|
never see them), with the low-level header primitives exposed in
|
||||||
payload) is decided; the *function surface* is not.
|
`channels::wire` for tooling.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- ADR-034: channels wire format (the decision, amended by ADR-035 — 8-byte
|
- ADR-034: channels wire format (the decision, amended by ADR-035 — 8-byte
|
||||||
header, no `stream_type`)
|
header, no `stream_type`)
|
||||||
- ADR-035: channels pure channel multiplexing (the umbrella decision that
|
- ADR-035: channels pure channel multiplexing (the umbrella decision that
|
||||||
amends ADR-034/074/077)
|
amends ADR-034 and the source-repo connection/from_source decision —
|
||||||
|
alknet ADR-074, provenance; the in-crate homes are ADR-007/008)
|
||||||
- ADR-036: channel 0 pre-negotiated
|
- ADR-036: channel 0 pre-negotiated
|
||||||
- ADR-037: channel lifecycle operations
|
- ADR-037: channel lifecycle operations
|
||||||
- ADR-040: backpressure, channel limits, ID reuse
|
- ADR-040: backpressure, channel limits, ID reuse
|
||||||
- ADR-047: openable ALPNs are operations (per-ALPN open ops dissolve
|
- ADR-047: openable ALPNs are operations (per-ALPN open ops dissolve
|
||||||
the generic `channel/open`)
|
the generic `channel/open`)
|
||||||
- `docs/research/alknet-channels/poc-summary.md` §POC Target 1, §Issues
|
- Source-repo POC research (alknet provenance):
|
||||||
Surfaced #4-#6 (REQ-CH-01, 02, 04)
|
`/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md`
|
||||||
- `docs/research/stream-unification/findings.md` — the research that
|
§POC Target 1, §Issues Surfaced #4-#6 (REQ-CH-01, 02, 04);
|
||||||
surfaced the 8-byte format decision
|
`/workspace/@alkdev/alknet/docs/research/stream-unification/findings.md`
|
||||||
|
— the research that surfaced the 8-byte format decision
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-17
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# alknet-call — Client and Adapters
|
# alkcall — Client and Adapters
|
||||||
|
|
||||||
The outbound half of the call protocol: opening connections, importing remote
|
The outbound half of the call protocol: opening connections, importing remote
|
||||||
operations, and the adapter contract that ties import-style adapters together.
|
operations, and the adapter contract that ties import-style adapters together.
|
||||||
@@ -11,14 +11,15 @@ This document covers what ADR-022 specced but the server-side implementation
|
|||||||
(`call-protocol.md`, `operation-registry.md`) did not include — the `CallClient`
|
(`call-protocol.md`, `operation-registry.md`) did not include — the `CallClient`
|
||||||
that *opens* a connection, the `from_call` adapter, and the
|
that *opens* a connection, the `from_call` adapter, and the
|
||||||
`OperationAdapter` trait. (`from_jsonschema` was originally specced here
|
`OperationAdapter` trait. (`from_jsonschema` was originally specced here
|
||||||
too, but ADR-027 moved it to `alknet-http` — see §"from_jsonschema" below.)
|
too, but ADR-027 moved it to the HTTP host crate — today `alkhttp`
|
||||||
|
(then named `alknet-http`) — see §"from_jsonschema" below.)
|
||||||
The server-side `CallAdapter` and `CallConnection`
|
The server-side `CallAdapter` and `CallConnection`
|
||||||
dispatch loop are covered in `call-protocol.md`; this document covers the
|
dispatch loop are covered in `call-protocol.md`; this document covers the
|
||||||
client-side connection-establishment half and the adapter surface.
|
client-side connection-establishment half and the adapter surface.
|
||||||
|
|
||||||
## What
|
## What
|
||||||
|
|
||||||
This document specifies three components, all in `alknet-call`:
|
This document specifies three components, all in alkcall:
|
||||||
|
|
||||||
1. **`CallClient`** — takes over an established transport `Connection`
|
1. **`CallClient`** — takes over an established transport `Connection`
|
||||||
on ALPN `alk/call`, spawns the shared dispatch loop, and produces
|
on ALPN `alk/call`, spawns the shared dispatch loop, and produces
|
||||||
@@ -35,20 +36,21 @@ This document specifies three components, all in `alknet-call`:
|
|||||||
`from_openapi`, `from_mcp`, and `from_jsonschema` all implement.
|
`from_openapi`, `from_mcp`, and `from_jsonschema` all implement.
|
||||||
|
|
||||||
> **`from_jsonschema` moved.** ADR-027 moved `from_jsonschema` from
|
> **`from_jsonschema` moved.** ADR-027 moved `from_jsonschema` from
|
||||||
> `alknet-call` to `alknet-http` and gave it a real reqwest-backed
|
> the call protocol to the HTTP host crate and gave it a real reqwest-backed
|
||||||
> forwarding handler (it was a broken schema-only placeholder before).
|
> forwarding handler (it was a broken schema-only placeholder before).
|
||||||
> It is now an HTTP-backed single-endpoint adapter for non-standard /
|
> It is now an HTTP-backed single-endpoint adapter for non-standard /
|
||||||
> non-OpenAPI / basic REST endpoints, functionally similar to
|
> non-OpenAPI / basic REST endpoints, functionally similar to
|
||||||
> `from_openapi` but one endpoint at a time. See
|
> `from_openapi` but one endpoint at a time. It lives in `alkhttp`; its
|
||||||
> [`crates/http/http-adapters.md`](../http/http-adapters.md) §"from_jsonschema".
|
> full spec is `alkhttp`'s `http-adapters.md` (original source-repo copy
|
||||||
> The `FromJsonSchema` provenance variant stays in `alknet-call`
|
> at `/workspace/@alkdev/alknet/docs/architecture/crates/http/http-adapters.md`,
|
||||||
|
> provenance). The `FromJsonSchema` provenance variant stays in alkcall
|
||||||
> (`OperationProvenance`); only the adapter implementation moved.
|
> (`OperationProvenance`); only the adapter implementation moved.
|
||||||
|
|
||||||
It also records two cross-cutting architectural mechanisms that the adapter
|
It also records two cross-cutting architectural mechanisms that the adapter
|
||||||
surface rests on:
|
surface rests on:
|
||||||
|
|
||||||
- The **adapter location map** — which adapters live in `alknet-call` vs
|
- The **adapter location map** — which adapters live in alkcall vs
|
||||||
`alknet-http`, and why.
|
`alkhttp`, and why.
|
||||||
- The **no-env-vars invariant** — the architectural mechanism by which
|
- The **no-env-vars invariant** — the architectural mechanism by which
|
||||||
downstream consumers' `std::env::var` credential reads are made unreachable.
|
downstream consumers' `std::env::var` credential reads are made unreachable.
|
||||||
|
|
||||||
@@ -65,23 +67,26 @@ downstream consumer: the runner pattern (a process that connects outward to a
|
|||||||
hub and exposes local ops), the container-service rewrite, the bilateral
|
hub and exposes local ops), the container-service rewrite, the bilateral
|
||||||
exchange, the NAPI projection, and the agent's cross-node tool dispatch all
|
exchange, the NAPI projection, and the agent's cross-node tool dispatch all
|
||||||
require a `CallClient`. `from_call` is the #2 gap; the `OperationAdapter`
|
require a `CallClient`. `from_call` is the #2 gap; the `OperationAdapter`
|
||||||
trait is the enabling gap for `alknet-http`'s `from_openapi`/`from_mcp`.
|
trait is the enabling gap for `alkhttp`'s `from_openapi`/`from_mcp`.
|
||||||
|
|
||||||
ADR-022 specced this surface. This document is the spec that operationally
|
ADR-022 specced this surface. This document is the spec that operationally
|
||||||
fills the gap ADR-022 left to implementation: the `CallClient` API, the
|
fills the gap ADR-022 left to implementation: the `CallClient` API, the
|
||||||
`from_call` flow, the trait signature, the adapter location, the credential
|
`from_call` flow, the trait signature, the adapter location, the credential
|
||||||
invariant, and the bilateral pattern. The gap
|
invariant, and the bilateral pattern. The gap
|
||||||
analysis (`docs/research/alknet-call-completion/gap-analysis.md`) identified
|
analysis (source-repo research, now at
|
||||||
|
`/workspace/@alkdev/alknet/docs/research/alknet-call-completion/gap-analysis.md`)
|
||||||
|
identified
|
||||||
four decisions (DC-1..4) needed before implementation. DC-1 was initially
|
four decisions (DC-1..4) needed before implementation. DC-1 was initially
|
||||||
resolved by ADR-023 (`remote_safe`/`trusted_peer`), but a subsequent research
|
resolved by ADR-023 (`remote_safe`/`trusted_peer`), but a subsequent research
|
||||||
pass (`docs/research/alknet-call-peer-routing/findings.md`) found that
|
pass (source-repo research, now at
|
||||||
|
`/workspace/@alkdev/alknet/docs/research/alknet-call-peer-routing/findings.md`) found that
|
||||||
ADR-023's model was structurally broken for the head→N-workers pattern (the
|
ADR-023's model was structurally broken for the head→N-workers pattern (the
|
||||||
primary use case) and that its parallel `remote_safe`/`trusted_peer`
|
primary use case) and that its parallel `remote_safe`/`trusted_peer`
|
||||||
authorization system duplicated the existing `AccessControl`/`Identity`
|
authorization system duplicated the existing `AccessControl`/`Identity`
|
||||||
machinery. **ADR-024 supersedes ADR-023**: peer-keyed overlays + `PeerRef`
|
machinery. **ADR-024 supersedes ADR-023**: peer-keyed overlays + `PeerRef`
|
||||||
routing, and peer authorization through the existing `AccessControl::check(peer_identity)`.
|
routing, and peer authorization through the existing `AccessControl::check(peer_identity)`.
|
||||||
DC-2/3/4 are two-way-door defaults recorded here (DC-2→OQ-27, DC-3→OQ-28
|
DC-2/3/4 are two-way-door defaults recorded here (DC-2→OQ-12, DC-3→OQ-13
|
||||||
cross-peer dissolved / same-peer stays, DC-4→OQ-26).
|
cross-peer dissolved / same-peer stays, DC-4→OQ-11).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -101,7 +106,7 @@ ordered, reliable bidirectional stream — QUIC, TCP+TLS, WebTransport,
|
|||||||
SSH `direct-tcpip`, a WebSocket (ADR-007 `Connection::from_stream` /
|
SSH `direct-tcpip`, a WebSocket (ADR-007 `Connection::from_stream` /
|
||||||
`from_bidi`). The primary constructor (`spawn_dispatch`) takes a
|
`from_bidi`). The primary constructor (`spawn_dispatch`) takes a
|
||||||
pre-established `Connection` from any transport; the dial lives in
|
pre-established `Connection` from any transport; the dial lives in
|
||||||
`AlknetClient` (`alknet-client`, ADR-045). This mirrors
|
the composition layer's dial helper, `AlknetClient` (ADR-045). This mirrors
|
||||||
`ChannelClient::from_connection` (ADR-043) and is the client-side
|
`ChannelClient::from_connection` (ADR-043) and is the client-side
|
||||||
analogue of the server-side generalization ADR-007 made.
|
analogue of the server-side generalization ADR-007 made.
|
||||||
|
|
||||||
@@ -136,7 +141,7 @@ the op's required scopes/resources are satisfied, the call dispatches; if not,
|
|||||||
`FORBIDDEN` before the handler runs (capabilities never populated — the
|
`FORBIDDEN` before the handler runs (capabilities never populated — the
|
||||||
security property). An op that should never be callable from the wire uses
|
security property). An op that should never be callable from the wire uses
|
||||||
`Visibility::Internal` (existing mechanism, `NOT_FOUND` before ACL). See
|
`Visibility::Internal` (existing mechanism, `NOT_FOUND` before ACL). See
|
||||||
[ADR-024](decisions/029-peer-graph-routing-model.md) §3 for the full
|
[ADR-024](decisions/024-peer-graph-routing-model.md) §3 for the full
|
||||||
mapping of the three `remote_safe` cases to `AccessControl`/`Visibility`.
|
mapping of the three `remote_safe` cases to `AccessControl`/`Visibility`.
|
||||||
|
|
||||||
The connection is symmetric after establishment (ADR-022 §2): both sides can
|
The connection is symmetric after establishment (ADR-022 §2): both sides can
|
||||||
@@ -176,14 +181,15 @@ authorization machinery that gates every other call. No `RemoteFilter`, no
|
|||||||
primary constructor — it takes a pre-established `Connection`,
|
primary constructor — it takes a pre-established `Connection`,
|
||||||
constructs a `CallConnection`, builds a `Dispatcher`, spawns the
|
constructs a `CallConnection`, builds a `Dispatcher`, spawns the
|
||||||
dispatch task, and returns the live `CallConnection`. The dial lives in
|
dispatch task, and returns the live `CallConnection`. The dial lives in
|
||||||
`AlknetClient` (`alknet-client`, ADR-045): keeping a QUIC convenience
|
the composition layer's dial helper, `AlknetClient` (ADR-045): keeping a QUIC convenience
|
||||||
constructor on `CallClient` would make `alknet-call` depend on
|
constructor on `CallClient` would make alkcall depend on
|
||||||
`alknet-client`, contradicting the dep graph (the protocol crates are
|
the dial crate, contradicting the dep graph (the protocol crates are
|
||||||
parallel to the dial, not downstream of it). Callers compose
|
parallel to the dial, not downstream of it). Callers compose
|
||||||
`AlknetClient::dial_quic` + `spawn_dispatch` — two lines, the dial then
|
`AlknetClient::dial_quic` + `spawn_dispatch` — two lines, the dial then
|
||||||
the take-over. Tests use `spawn_dispatch` directly to wire mock/loopback
|
the take-over. Tests use `spawn_dispatch` directly to wire mock/loopback
|
||||||
connections. The one-way-door surface is `spawn_dispatch`; the dial
|
connections. The one-way-door surface is `spawn_dispatch`; the dial
|
||||||
lives in `alknet-client`.
|
lives in the composition layer (`AlknetClient` today — future owner
|
||||||
|
OQ-43).
|
||||||
|
|
||||||
This mirrors `ChannelClient::from_connection` (ADR-043) and is the
|
This mirrors `ChannelClient::from_connection` (ADR-043) and is the
|
||||||
client-side analogue of the server-side generalization ADR-007 made.
|
client-side analogue of the server-side generalization ADR-007 made.
|
||||||
@@ -212,7 +218,7 @@ pub type PeerId = String; // = Identity.id from IdentityProvider resolution
|
|||||||
|
|
||||||
`OperationEnv` gains a peer-routing method with a `PeerRef` selector
|
`OperationEnv` gains a peer-routing method with a `PeerRef` selector
|
||||||
(`Specific(PeerId)` / `Any`), default-impl for back-compat. See
|
(`Specific(PeerId)` / `Any`), default-impl for back-compat. See
|
||||||
[ADR-024](decisions/029-peer-graph-routing-model.md) §2 for the full
|
[ADR-024](decisions/024-peer-graph-routing-model.md) §2 for the full
|
||||||
`invoke_peer` signature and `ScopedPeerEnv` peer-qualified reachability. The
|
`invoke_peer` signature and `ScopedPeerEnv` peer-qualified reachability. The
|
||||||
per-`CallConnection` overlay stays flat (one connection = one peer); the
|
per-`CallConnection` overlay stays flat (one connection = one peer); the
|
||||||
peer-keying is at the aggregation layer (the head node's composition env).
|
peer-keying is at the aggregation layer (the head node's composition env).
|
||||||
@@ -225,14 +231,14 @@ single `AccessControl`-filtered handler (no `peer_scoped` variant, no
|
|||||||
`remote_safe` filter — both retired by ADR-024). `services/list-peers`
|
`remote_safe` filter — both retired by ADR-024). `services/list-peers`
|
||||||
is the opt-in for peer-attributed re-export listing (each peer's
|
is the opt-in for peer-attributed re-export listing (each peer's
|
||||||
sub-overlay listed with attribution, filtered by the calling peer's
|
sub-overlay listed with attribution, filtered by the calling peer's
|
||||||
authorization). See [ADR-024](decisions/029-peer-graph-routing-model.md) §6.
|
authorization). See [ADR-024](decisions/024-peer-graph-routing-model.md) §6.
|
||||||
|
|
||||||
### Credential sources for connections
|
### Credential sources for connections
|
||||||
|
|
||||||
The credential dimensions are split across two layers (ADR-012, amended
|
The credential dimensions are split across two layers (ADR-012, amended
|
||||||
2026-07-17):
|
2026-07-17):
|
||||||
|
|
||||||
- **`ConnectionCredentials`** (in `alknet-core`, per ADR-012) — the
|
- **`ConnectionCredentials`** (vendored core types in alkcall, per ADR-012) — the
|
||||||
**transport-level** credential bundle, consumed by the dial
|
**transport-level** credential bundle, consumed by the dial
|
||||||
(`AlknetClient`). Carries the two transport-identity dimensions:
|
(`AlknetClient`). Carries the two transport-identity dimensions:
|
||||||
`local_identity` (the local node's `TlsIdentity`) and `remote_identity`
|
`local_identity` (the local node's `TlsIdentity`) and `remote_identity`
|
||||||
@@ -250,7 +256,7 @@ Credentials come from `Capabilities` (ADR-010), never from environment
|
|||||||
variables. The transport-identity dimensions (ADR-022 §7):
|
variables. The transport-identity dimensions (ADR-022 §7):
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// Transport-level (alknet-core, consumed by the dial — ADR-012)
|
// Transport-level (vendored core in alkcall, consumed by the dial — ADR-012)
|
||||||
pub struct ConnectionCredentials {
|
pub struct ConnectionCredentials {
|
||||||
pub local_identity: Option<TlsIdentity>, // RFC 7250 raw key or X.509
|
pub local_identity: Option<TlsIdentity>, // RFC 7250 raw key or X.509
|
||||||
pub remote_identity: Option<RemoteIdentity>, // expected fingerprint (None = CA path / fail-closed)
|
pub remote_identity: Option<RemoteIdentity>, // expected fingerprint (None = CA path / fail-closed)
|
||||||
@@ -262,14 +268,15 @@ pub struct ConnectionCredentials {
|
|||||||
// Dispatcher::resolve_identity reads payload.get("auth_token").
|
// Dispatcher::resolve_identity reads payload.get("auth_token").
|
||||||
```
|
```
|
||||||
|
|
||||||
`RemoteIdentity` (ADR-022 §7, extended by ADR-034 §2) carries a
|
`RemoteIdentity` (ADR-022 §7, extended by alknet ADR-034 §2 — the
|
||||||
|
verifier-rule ADR, re-owned as ADR-054) carries a
|
||||||
fingerprint string the assembly layer derives from `Capabilities` when
|
fingerprint string the assembly layer derives from `Capabilities` when
|
||||||
the local node has a `PeerEntry` for the remote (the known-peer case →
|
the local node has a `PeerEntry` for the remote (the known-peer case →
|
||||||
fingerprint pin). `remote_identity: None` is the **public X.509
|
fingerprint pin). `remote_identity: None` is the **public X.509
|
||||||
endpoint** case: the local node has no `PeerEntry` for the remote, so
|
endpoint** case: the local node has no `PeerEntry` for the remote, so
|
||||||
there is no fingerprint to pin. Combined with an X.509 transport, `None`
|
there is no fingerprint to pin. Combined with an X.509 transport, `None`
|
||||||
selects CA verification (`WebPkiServerVerifier`) per the
|
selects CA verification (`WebPkiServerVerifier`) per the
|
||||||
verifier-selection rule in ADR-034 §3. Combined with an Ed25519
|
verifier-selection rule in ADR-054. Combined with an Ed25519
|
||||||
raw-key transport, `None` fails closed (raw-key remotes are always
|
raw-key transport, `None` fails closed (raw-key remotes are always
|
||||||
known peers — no CA to fall back to). The `Option` is load-bearing, not
|
known peers — no CA to fall back to). The `Option` is load-bearing, not
|
||||||
cosmetic: `Some(fingerprint)` means "pin this" (known peer), `None`
|
cosmetic: `Some(fingerprint)` means "pin this" (known peer), `None`
|
||||||
@@ -282,26 +289,28 @@ pub struct RemoteIdentity { pub fingerprint: String }
|
|||||||
```
|
```
|
||||||
|
|
||||||
There is no call-protocol credential bundle. The transport dimensions
|
There is no call-protocol credential bundle. The transport dimensions
|
||||||
(`local_identity`, `remote_identity`) are in `ConnectionCredentials` in
|
(`local_identity`, `remote_identity`) are in `ConnectionCredentials` with
|
||||||
`alknet-core` per ADR-012.
|
the vendored core types in alkcall, per ADR-012.
|
||||||
|
|
||||||
- **TLS identity** — the local node's Ed25519 raw key (RFC 7250) or X.509 cert,
|
- **TLS identity** — the local node's Ed25519 raw key (RFC 7250) or X.509 cert,
|
||||||
derived from the vault at startup (ADR-020, ADR-026, ADR-027).
|
derived from the vault at startup (vault ADRs live in the source repo —
|
||||||
|
alknet ADR-020/026/027, provenance).
|
||||||
- **Auth token** — an opaque call-protocol-level token, decrypted from the
|
- **Auth token** — an opaque call-protocol-level token, decrypted from the
|
||||||
vault or derived from a shared secret.
|
vault or derived from a shared secret.
|
||||||
- **Remote identity verification** — the expected fingerprint/cert of the
|
- **Remote identity verification** — the expected fingerprint/cert of the
|
||||||
remote node, stored as a capability. `Some` → fingerprint pin (known
|
remote node, stored as a capability. `Some` → fingerprint pin (known
|
||||||
peer with a `PeerEntry`); `None` → CA verification for X.509 remotes,
|
peer with a `PeerEntry`); `None` → CA verification for X.509 remotes,
|
||||||
fail-closed for Ed25519 raw-key remotes (ADR-034 §2/§3). The `None`
|
fail-closed for Ed25519 raw-key remotes (ADR-054). The `None`
|
||||||
case is the public-X.509-endpoint path, not a missing field.
|
case is the public-X.509-endpoint path, not a missing field.
|
||||||
|
|
||||||
These are populated by the assembly layer at `CallClient` construction time
|
These are populated by the assembly layer at `CallClient` construction time
|
||||||
from vault-derived `Capabilities`. The credential path is the no-env-vars
|
from vault-derived `Capabilities`. The credential path is the no-env-vars
|
||||||
invariant (below). The concrete shapes of `TlsIdentity`, `AuthToken`, and
|
invariant (below). The concrete shapes of `TlsIdentity`, `AuthToken`, and
|
||||||
`RemoteIdentity` are implementation-detail two-way doors; the one-way
|
`RemoteIdentity` are implementation-detail two-way doors; the one-way
|
||||||
constraints are that they come from `Capabilities`, not env vars (ADR-010).
|
constraints are that they come from `Capabilities`, not env vars (ADR-010),
|
||||||
|
and that `ConnectionCredentials` lives with the vendored core types in
|
||||||
**TLS client-auth presentation** (OQ-29 #1, wired): the client presents
|
alkcall (ADR-012).
|
||||||
|
**TLS client-auth presentation** (OQ-14 #1, wired): the client presents
|
||||||
its Ed25519 key as an RFC 7250 raw public key client cert — the client-side
|
its Ed25519 key as an RFC 7250 raw public key client cert — the client-side
|
||||||
equivalent of the server's `RawKeyCertResolver`. This is **wired now**, not
|
equivalent of the server's `RawKeyCertResolver`. This is **wired now**, not
|
||||||
additive: it is what activates the `PeerEntry` fingerprint → `peer_id`
|
additive: it is what activates the `PeerEntry` fingerprint → `peer_id`
|
||||||
@@ -310,13 +319,13 @@ peer graph doesn't populate for quinn connections — `PeerId` resolution
|
|||||||
fails because the server has no client cert to extract a fingerprint from.
|
fails because the server has no client cert to extract a fingerprint from.
|
||||||
The iroh path already works (iroh uses RFC 7250 raw keys and exchanges
|
The iroh path already works (iroh uses RFC 7250 raw keys and exchanges
|
||||||
Ed25519 public keys during the TLS handshake automatically); the gap was
|
Ed25519 public keys during the TLS handshake automatically); the gap was
|
||||||
quinn-only, and OQ-29 #1 resolves it by replacing `with_no_client_auth()`
|
quinn-only, and OQ-14 #1 resolves it by replacing `with_no_client_auth()`
|
||||||
with presenting the key. The one-way constraint (credentials from
|
with presenting the key. The one-way constraint (credentials from
|
||||||
`Capabilities`, not env vars, ADR-010) is unaffected — the `auth_token`
|
`Capabilities`, not env vars, ADR-010) is unaffected — the `auth_token`
|
||||||
dimension flows through the call-protocol `auth_token` payload field, not
|
dimension flows through the call-protocol `auth_token` payload field, not
|
||||||
TLS, so the no-env-vars invariant holds independently of the TLS layer.
|
TLS, so the no-env-vars invariant holds independently of the TLS layer.
|
||||||
|
|
||||||
**Remote-identity verification** (OQ-29 #2, additive): verifying the
|
**Remote-identity verification** (OQ-14 #2, additive): verifying the
|
||||||
server's fingerprint against an expected value (`credentials.remote_identity`)
|
server's fingerprint against an expected value (`credentials.remote_identity`)
|
||||||
is **additive** — the server-side fingerprint extraction is what matters for
|
is **additive** — the server-side fingerprint extraction is what matters for
|
||||||
`PeerId`, not the client-side verification. The verifier for raw keys can
|
`PeerId`, not the client-side verification. The verifier for raw keys can
|
||||||
@@ -324,7 +333,8 @@ start as "accept any, extract fingerprint" and add fingerprint-pinning later.
|
|||||||
This is a two-way-door remainder; the one-way constraint (credentials from
|
This is a two-way-door remainder; the one-way constraint (credentials from
|
||||||
`Capabilities`, not env vars) is unaffected.
|
`Capabilities`, not env vars) is unaffected.
|
||||||
|
|
||||||
**Server cert verifier selection** (OQ-29 #2 + ADR-034 §3): the client-side
|
**Server cert verifier selection** (OQ-14 #2 + ADR-054 §2, re-owned
|
||||||
|
from the source repo's ADR-034): the client-side
|
||||||
`ServerCertVerifier` is selected by whether the local node has a `PeerEntry`
|
`ServerCertVerifier` is selected by whether the local node has a `PeerEntry`
|
||||||
for the remote, not by key type alone. A pure-client
|
for the remote, not by key type alone. A pure-client
|
||||||
connection to a **public X.509 endpoint** (no `PeerEntry` on the local
|
connection to a **public X.509 endpoint** (no `PeerEntry` on the local
|
||||||
@@ -339,7 +349,7 @@ with mixed Ed25519 + X.509 fingerprints) uses fingerprint pinning on
|
|||||||
both cert paths and does enter the peer graph. An unknown Ed25519
|
both cert paths and does enter the peer graph. An unknown Ed25519
|
||||||
raw-key remote fails closed (no CA to fall back to — raw-key remotes
|
raw-key remote fails closed (no CA to fall back to — raw-key remotes
|
||||||
are always known peers). See
|
are always known peers). See
|
||||||
[ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md)
|
[ADR-054](decisions/054-outgoing-only-x509-three-remote-roles.md)
|
||||||
for the verifier selection rule and the three-role naming.
|
for the verifier selection rule and the three-role naming.
|
||||||
|
|
||||||
### from_call
|
### from_call
|
||||||
@@ -353,7 +363,7 @@ forwarding handlers. The discovery mechanism (`services/list` +
|
|||||||
```rust
|
```rust
|
||||||
pub struct FromCallConfig {
|
pub struct FromCallConfig {
|
||||||
/// Namespace prefix applied to imported operation names. Optional —
|
/// Namespace prefix applied to imported operation names. Optional —
|
||||||
/// default no prefix. Collision on import is an error (DC-3, OQ-28),
|
/// default no prefix. Collision on import is an error (DC-3, OQ-13),
|
||||||
/// not last-wins.
|
/// not last-wins.
|
||||||
pub namespace_prefix: Option<String>,
|
pub namespace_prefix: Option<String>,
|
||||||
/// Optional filter — import only operations whose names match. None
|
/// Optional filter — import only operations whose names match. None
|
||||||
@@ -384,31 +394,50 @@ The flow (ADR-022 §3):
|
|||||||
sends `call.requested` via `CallConnection::call_with_payload()`, awaits
|
sends `call.requested` via `CallConnection::call_with_payload()`, awaits
|
||||||
the single `call.responded` (or `call.error`), returns the
|
the single `call.responded` (or `call.error`), returns the
|
||||||
`ResponseEnvelope`.
|
`ResponseEnvelope`.
|
||||||
- `Subscription` → a `StreamingHandler` (registered as
|
- `Subscription` → a `StreamingHandler` (registered as
|
||||||
`HandlerKind::Stream`): calls `CallConnection::subscribe()`, which
|
`HandlerKind::Stream`): calls `CallConnection::subscribe()`, which
|
||||||
returns `impl Stream<Item = ResponseEnvelope>` (the client-side
|
returns `impl Stream<Item = ResponseEnvelope>` (the client-side
|
||||||
streaming path, already implemented), maps it to a
|
streaming path, already implemented), maps it to a
|
||||||
`BoxStream<ResponseEnvelope>`. The remote stream flows end-to-end:
|
`BoxStream<ResponseEnvelope>`. The remote stream flows end-to-end:
|
||||||
each `call.responded` the remote sends becomes a stream item; the
|
each `call.responded` the remote sends becomes a stream item; the
|
||||||
remote's `call.completed` ends the stream (→ wire `call.completed`);
|
remote's `call.completed` ends the stream (→ wire `call.completed`);
|
||||||
`call.aborted` drops the stream (cascade per ADR-020). No truncation,
|
`call.aborted` drops the stream (cascade per ADR-020). No truncation,
|
||||||
no first-value fallback — a `from_call`-imported subscription forwards
|
no first-value fallback — a `from_call`-imported subscription forwards
|
||||||
the full remote stream.
|
the full remote stream.
|
||||||
- `provenance: FromCall`, `composition_authority: None`, `scoped_env: None`
|
- `Pub` → the sink forwarding shape (registered as `HandlerKind::Sink`,
|
||||||
(leaf — ADR-018).
|
ADR-046 + ADR-022 amendment 2026-10-13): a real forwarding body, not
|
||||||
|
an advertised-always-failing stub. A local `publish()` on the
|
||||||
|
imported op streams the `call.published` chunks across the
|
||||||
|
connection; the remote peer's `SinkHandler` consumes them.
|
||||||
|
- `provenance: FromCall`, `composition_authority: None`, `scoped_env: None`
|
||||||
|
(leaf — ADR-018).
|
||||||
4. The caller registers the bundles via
|
4. The caller registers the bundles via
|
||||||
`CallConnection::register_imported_all()`.
|
`CallConnection::register_imported_all()`.
|
||||||
|
|
||||||
**Re-import on reconnection** (DC-2, OQ-27): `from_call` is a free function;
|
**Marked ops are not importable** (ADR-047 amendment 4 §4.8):
|
||||||
the assembly layer calls it after the dial (in `AlknetClient`). The overlay
|
`from_call`'s discovery reconstructs the `channel_open` marker (ADR-047
|
||||||
|
Gap F) so the importing side can *report* the remote channel-machinery
|
||||||
|
ops, but `register_imported` / `register_imported_all` refuse marked
|
||||||
|
specs — the marker's data plane is the allocated channel's `BiStream`,
|
||||||
|
which has no overlay path (`OperationEnv::invoke` is request/response
|
||||||
|
only, ADR-046 §6). A hub that needs a marked op composes it with the
|
||||||
|
channel machinery instead: `ChannelRelay::register_relay_openable`
|
||||||
|
(ADR-051) on the relaying side, or direct consumption via `ChannelClient`
|
||||||
|
(ADR-043). Likewise `op/register` refuses a marked announcement with
|
||||||
|
`MARKED_SPEC_UNSUPPORTED` (ADR-022 amendment 2026-10-13). This resolves
|
||||||
|
OQ-37: there is no `from_call` relay wrapper for marked ops — the relay
|
||||||
|
composition is the sanctioned path.
|
||||||
|
|
||||||
|
**Re-import on reconnection** (DC-2, OQ-12): `from_call` is a free function;
|
||||||
|
the assembly layer calls it after the dial (in the composition layer's dial helper, `AlknetClient` — today the alknet glue; OQ-43). The overlay
|
||||||
is per-connection (Layer 2, ADR-019), so a stale overlay dies with the
|
is per-connection (Layer 2, ADR-019), so a stale overlay dies with the
|
||||||
connection; re-import on reconnect is naturally scoped to the new
|
connection; re-import on reconnect is naturally scoped to the new
|
||||||
connection. A `CallConnection::refresh()` method for mid-connection
|
connection. A `CallConnection::refresh()` method for mid-connection
|
||||||
re-discovery is a genuine feature addition — non-breaking, additive — if a
|
re-discovery is a genuine feature addition — non-breaking, additive — if a
|
||||||
deployment needs manual re-discovery without drop-and-reconnect. See
|
deployment needs manual re-discovery without drop-and-reconnect. See
|
||||||
[ADR-028](decisions/069-from-call-manual-free-function.md).
|
[ADR-028](decisions/028-from-call-manual-free-function.md).
|
||||||
|
|
||||||
**Namespace collision** (DC-3, OQ-28): under the peer-graph model (ADR-024),
|
**Namespace collision** (DC-3, OQ-13): under the peer-graph model (ADR-024),
|
||||||
cross-peer collision dissolves — same name on different peers is fine (they
|
cross-peer collision dissolves — same name on different peers is fine (they
|
||||||
live in separate peer sub-overlays, no prefix needed). Same-peer collision
|
live in separate peer sub-overlays, no prefix needed). Same-peer collision
|
||||||
stays an error (a peer shouldn't expose two ops with the same name).
|
stays an error (a peer shouldn't expose two ops with the same name).
|
||||||
@@ -432,40 +461,43 @@ forwarding. The spoke receives it as metadata on its `OperationContext` —
|
|||||||
available for logging, auditing, per-user rate limiting, but never used by
|
available for logging, auditing, per-user rate limiting, but never used by
|
||||||
`AccessControl::check` (the spoke authorizes the hub, its direct caller,
|
`AccessControl::check` (the spoke authorizes the hub, its direct caller,
|
||||||
not the end user). The hub may set `forwarded_for: None` if it doesn't
|
not the end user). The hub may set `forwarded_for: None` if it doesn't
|
||||||
want to disclose the originator. See [ADR-026](decisions/032-forwarded-for-identity.md).
|
want to disclose the originator. See [ADR-026](decisions/026-forwarded-for-identity.md).
|
||||||
|
|
||||||
### from_jsonschema
|
### from_jsonschema
|
||||||
|
|
||||||
`from_jsonschema` was originally specified here (ADR-022 §5) as a
|
`from_jsonschema` was originally specified here (ADR-022 §5) as a
|
||||||
schema-only adapter in `alknet-call` — a placeholder handler returning
|
schema-only adapter in the call protocol — a placeholder handler returning
|
||||||
`NOT_FOUND`. That was broken: an op in the registry needs a real handler,
|
`NOT_FOUND`. That was broken: an op in the registry needs a real handler,
|
||||||
and the "schema-only, no handler" concept conflated schema validation
|
and the "schema-only, no handler" concept conflated schema validation
|
||||||
(a planning activity that doesn't need a registry entry) with operation
|
(a planning activity that doesn't need a registry entry) with operation
|
||||||
registration (which always needs a handler).
|
registration (which always needs a handler).
|
||||||
|
|
||||||
[ADR-027](decisions/066-from-jsonschema-as-http-adapter.md) moved
|
[ADR-027](decisions/027-from-jsonschema-as-http-adapter.md) moved
|
||||||
`from_jsonschema` to `alknet-http` as an HTTP-backed single-endpoint
|
`from_jsonschema` to the HTTP host crate — today `alkhttp`
|
||||||
|
(ex-`alknet-http`) — as an HTTP-backed single-endpoint
|
||||||
adapter: the caller supplies an `OperationSpec` + `HttpServiceConfig` +
|
adapter: the caller supplies an `OperationSpec` + `HttpServiceConfig` +
|
||||||
path template + method, and the adapter builds one
|
path template + method, and the adapter builds one
|
||||||
`HandlerRegistration` with a real reqwest forwarding handler and
|
`HandlerRegistration` with a real reqwest forwarding handler and
|
||||||
`FromJsonSchema` provenance. It is functionally similar to `from_openapi`
|
`FromJsonSchema` provenance. It is functionally similar to `from_openapi`
|
||||||
but one endpoint at a time, for non-standard / non-OpenAPI / basic REST
|
but one endpoint at a time, for non-standard / non-OpenAPI / basic REST
|
||||||
endpoints that don't have a full OpenAPI document. See
|
endpoints that don't have a full OpenAPI document. See `alkhttp`'s
|
||||||
[`crates/http/http-adapters.md`](../http/http-adapters.md) §"from_jsonschema".
|
`http-adapters.md` §"from_jsonschema" (its original source-repo copy at
|
||||||
|
`/workspace/@alkdev/alknet/docs/architecture/crates/http/http-adapters.md`,
|
||||||
|
provenance).
|
||||||
|
|
||||||
The schema-validation-without-a-handler use case (the original stated
|
The schema-validation-without-a-handler use case (the original stated
|
||||||
purpose) is served by consuming `OperationSpec` directly — the spec
|
purpose) is served by consuming `OperationSpec` directly — the spec
|
||||||
already carries the input/output JSON Schemas. No adapter, no registry
|
already carries the input/output JSON Schemas. No adapter, no registry
|
||||||
entry, no handler is needed for that.
|
entry, no handler is needed for that.
|
||||||
|
|
||||||
The `FromJsonSchema` provenance variant stays in `alknet-call`
|
The `FromJsonSchema` provenance variant stays in alkcall
|
||||||
(`OperationProvenance` in `registry/registration.rs`); only the adapter
|
(`OperationProvenance` in `registry/registration.rs`); only the adapter
|
||||||
implementation moved.
|
implementation moved.
|
||||||
|
|
||||||
### OperationAdapter trait
|
### OperationAdapter trait
|
||||||
|
|
||||||
The shared shape across import-style adapters. The trait lives in
|
The shared shape across import-style adapters. The trait lives in
|
||||||
`alknet-call` (where the types live); the implementations live where their
|
alkcall (where the types live); the implementations live where their
|
||||||
transport dependencies live (see "Adapter Location Map" below).
|
transport dependencies live (see "Adapter Location Map" below).
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
@@ -482,7 +514,7 @@ which may be QUIC, TCP+TLS, or any other transport). Sync adapters
|
|||||||
trait — their `import()` bodies contain no `.await` points. This is locked by
|
trait — their `import()` bodies contain no `.await` points. This is locked by
|
||||||
ADR-022 §5.
|
ADR-022 §5.
|
||||||
|
|
||||||
The **error type** (DC-4, OQ-26) is `Result<Vec<HandlerRegistration>,
|
The **error type** (DC-4, OQ-11) is `Result<Vec<HandlerRegistration>,
|
||||||
AdapterError>` where `AdapterError` is a crate-level enum covering the
|
AdapterError>` where `AdapterError` is a crate-level enum covering the
|
||||||
failure modes real implementations hit: discovery transport failure
|
failure modes real implementations hit: discovery transport failure
|
||||||
(`from_call` remote unreachable), schema parse failure (`from_openapi`,
|
(`from_call` remote unreachable), schema parse failure (`from_openapi`,
|
||||||
@@ -495,13 +527,13 @@ door, recorded here.
|
|||||||
|
|
||||||
Implementations:
|
Implementations:
|
||||||
- `FromCall` — call-protocol-backed, transport-agnostic (in
|
- `FromCall` — call-protocol-backed, transport-agnostic (in
|
||||||
`alknet-call`). `from_call` discovers ops over a `CallConnection`,
|
alkcall). `from_call` discovers ops over a `CallConnection`,
|
||||||
which may be QUIC, TCP+TLS, or any transport `Connection::from_stream`
|
which may be QUIC, TCP+TLS, or any transport `Connection::from_stream`
|
||||||
supports (ADR-007).
|
supports (ADR-007).
|
||||||
- `FromOpenAPI` — HTTP-backed (in `alknet-http`).
|
- `FromOpenAPI` — HTTP-backed (in `alkhttp`).
|
||||||
- `FromJsonSchema` — HTTP-backed, single-endpoint (in `alknet-http` per
|
- `FromJsonSchema` — HTTP-backed, single-endpoint (in `alkhttp` per
|
||||||
ADR-027; was a broken schema-only placeholder in `alknet-call`).
|
ADR-027; was a broken schema-only placeholder in the call protocol).
|
||||||
- `FromMCP` — MCP streamable-HTTP-backed (in `alknet-http`, feature-gated).
|
- `FromMCP` — MCP streamable-HTTP-backed (in `alkhttp`, feature-gated).
|
||||||
|
|
||||||
The `to_*` adapters (`to_openapi`, `to_mcp`) are outbound projections, not
|
The `to_*` adapters (`to_openapi`, `to_mcp`) are outbound projections, not
|
||||||
`OperationAdapter` implementations — they consume the registry, they don't
|
`OperationAdapter` implementations — they consume the registry, they don't
|
||||||
@@ -510,18 +542,19 @@ produce entries for it (ADR-022 §5).
|
|||||||
### Adapter Location Map
|
### Adapter Location Map
|
||||||
|
|
||||||
The decomposition principle: **the adapter trait lives where the types live
|
The decomposition principle: **the adapter trait lives where the types live
|
||||||
(`alknet-call`); the adapter implementations live where their transport
|
(alkcall); the adapter implementations live where their transport
|
||||||
dependencies live.**
|
dependencies live.**
|
||||||
|
|
||||||
```
|
```
|
||||||
alknet-call (lean — no HTTP client, no HTTP server)
|
alkcall (lean — no HTTP client, no HTTP server)
|
||||||
├── OperationAdapter trait (the contract — async, per ADR-022 §5)
|
├── OperationAdapter trait (the contract — async, per ADR-022 §5)
|
||||||
├── from_call (transport-agnostic — discovers remote ops via
|
├── from_call (transport-agnostic — discovers remote ops via
|
||||||
│ call protocol over any Connection)
|
│ call protocol over any Connection)
|
||||||
└── CallClient (outbound connection take-over —
|
└── CallClient (outbound connection take-over —
|
||||||
spawn_dispatch, transport-agnostic; dial in AlknetClient)
|
spawn_dispatch, transport-agnostic; dial in
|
||||||
|
the composition layer's helper, AlknetClient)
|
||||||
|
|
||||||
alknet-http (owns HTTP server + HTTP client — separate crate, separate Phase 0)
|
alkhttp (owns HTTP server + HTTP client — sibling host crate)
|
||||||
├── ProtocolHandler for h2/http1.1/h3 (axum server — inbound HTTP)
|
├── ProtocolHandler for h2/http1.1/h3 (axum server — inbound HTTP)
|
||||||
├── from_openapi (parse OpenAPI doc + reqwest forwarding handler)
|
├── from_openapi (parse OpenAPI doc + reqwest forwarding handler)
|
||||||
├── from_jsonschema (single-endpoint reqwest forwarding handler — ADR-027)
|
├── from_jsonschema (single-endpoint reqwest forwarding handler — ADR-027)
|
||||||
@@ -531,28 +564,26 @@ alknet-http (owns HTTP server + HTTP client — separate crate, separate Phase 0
|
|||||||
|
|
||||||
Not built: MCP stdio transport
|
Not built: MCP stdio transport
|
||||||
— stdio = spawn arbitrary executable = built-in RCE ("download untrusted MCP servers")
|
— stdio = spawn arbitrary executable = built-in RCE ("download untrusted MCP servers")
|
||||||
— streamable HTTP is the only supported MCP transport in alknet
|
— streamable HTTP is the only supported MCP transport in the alk stack
|
||||||
— recorded as an explicit security position, not a feature gap
|
— recorded as an explicit security position, not a feature gap
|
||||||
```
|
```
|
||||||
|
|
||||||
`alknet-call` never sees the HTTP client. The `from_openapi`/`from_mcp`
|
alkcall never sees the HTTP client. The `from_openapi`/`from_mcp`
|
||||||
forwarding handlers are opaque `Arc<dyn Handler>` from the registry's
|
forwarding handlers are opaque `Arc<dyn Handler>` from the registry's
|
||||||
perspective — constructed by `alknet_http::from_openapi()` at registration
|
perspective — constructed by `alkhttp::from_openapi()` at registration
|
||||||
time, stored in `HandlerRegistration`, dispatched by the `CallAdapter` which
|
time, stored in `HandlerRegistration`, dispatched by the `CallAdapter` which
|
||||||
doesn't know reqwest is involved. `alknet-call` stays lean (no reqwest, no
|
doesn't know reqwest is involved. alkcall stays lean (no reqwest, no
|
||||||
axum); `alknet-http` owns both HTTP directions.
|
axum); `alkhttp` owns both HTTP directions.
|
||||||
|
|
||||||
**ADR-031 dependency note**: `alknet-http` implementing `from_openapi`/
|
**ADR-031 dependency note**: `alkhttp` implementing `from_openapi`/
|
||||||
`from_mcp` means `alknet-http` depends on `alknet-call` (for `OperationSpec`,
|
`from_mcp` means `alkhttp` depends on alkcall (for `OperationSpec`,
|
||||||
`Handler`, `HandlerRegistration`, `OperationAdapter`). ADR-031's rule is "no
|
`Handler`, `HandlerRegistration`, `OperationAdapter`). ADR-031's rule is "no
|
||||||
handler crate depends on another handler crate" — but `alknet-call` is both
|
handler crate depends on another handler crate" — but alkcall is the
|
||||||
a handler *and* the protocol foundation that `alknet-agent` and `alknet-napi`
|
protocol foundation that every downstream consumer
|
||||||
already consume. `alknet-http` depending on `alknet-call` is "HTTP uses the
|
(including the HTTP host and any agent crate) builds on.
|
||||||
call protocol types," not "HTTP depends on SSH." This is within the spirit of
|
`alkhttp` depending on alkcall is "HTTP uses the
|
||||||
ADR-031 (`alknet-call` is protocol-foundation, not a peer handler). The
|
call protocol types," not "HTTP depends on a sibling handler." This is within the spirit of
|
||||||
`alknet-http` spec should note this explicitly; a one-line amendment to
|
ADR-031 (alkcall is protocol-foundation, not a peer handler).
|
||||||
ADR-031 clarifying that `alknet-call` is a protocol-foundation crate is
|
|
||||||
deferred to the `alknet-http` Phase 0.
|
|
||||||
|
|
||||||
### No-Env-Vars Invariant
|
### No-Env-Vars Invariant
|
||||||
|
|
||||||
@@ -576,7 +607,7 @@ vault (seed)
|
|||||||
→ reqwest request goes out with vault-derived credential
|
→ reqwest request goes out with vault-derived credential
|
||||||
```
|
```
|
||||||
|
|
||||||
The `from_openapi`/`from_mcp` forwarding handlers (in `alknet-http`) are the
|
The `from_openapi`/`from_mcp` forwarding handlers (in `alkhttp`) are the
|
||||||
credential injection point. They read from `context.capabilities`, not from
|
credential injection point. They read from `context.capabilities`, not from
|
||||||
`std::env::var`. The downstream consumers' `Default` impls reading env vars
|
`std::env::var`. The downstream consumers' `Default` impls reading env vars
|
||||||
are simply never called — the assembly layer constructs providers with
|
are simply never called — the assembly layer constructs providers with
|
||||||
@@ -584,14 +615,14 @@ vault-derived credentials through the builder API, or the provider's HTTP
|
|||||||
calls are routed through `from_openapi` operations that carry the credential
|
calls are routed through `from_openapi` operations that carry the credential
|
||||||
in `Capabilities`.
|
in `Capabilities`.
|
||||||
|
|
||||||
**This is a spec-level invariant in `alknet-call`, not a runtime convention.**
|
**This is a spec-level invariant in alkcall, not a runtime convention.**
|
||||||
The dispatch path (`build_root_context` and `OperationEnv::invoke()` per
|
The dispatch path (`build_root_context` and `OperationEnv::invoke()` per
|
||||||
ADR-018 §6) populates `OperationContext.capabilities` from the registration
|
ADR-018 §6) populates `OperationContext.capabilities` from the registration
|
||||||
bundle. The invariant is: *no handler reads outbound credentials from any
|
bundle. The invariant is: *no handler reads outbound credentials from any
|
||||||
source other than `OperationContext.capabilities`.* This is already the
|
source other than `OperationContext.capabilities`.* This is already the
|
||||||
architectural intent of ADR-010; this document records it as an explicit
|
architectural intent of ADR-010; this document records it as an explicit
|
||||||
invariant that the `from_openapi`/`from_mcp` handler implementations (in
|
invariant that the `from_openapi`/`from_mcp` handler implementations (in
|
||||||
`alknet-http`) are verified against.
|
`alkhttp`) are verified against.
|
||||||
|
|
||||||
### Exchange-of-Operations Pattern (Runner / Container Service)
|
### Exchange-of-Operations Pattern (Runner / Container Service)
|
||||||
|
|
||||||
@@ -630,20 +661,21 @@ Bilateral: the container service ALSO runs from_call against the hub,
|
|||||||
the other side's `External` ops. The #2 gap.
|
the other side's `External` ops. The #2 gap.
|
||||||
3. `OperationAdapter` trait — `from_call` implements it. The #3 gap (enabling,
|
3. `OperationAdapter` trait — `from_call` implements it. The #3 gap (enabling,
|
||||||
not blocking — `from_call` can be built as a free function before the trait
|
not blocking — `from_call` can be built as a free function before the trait
|
||||||
exists, but the trait is needed for `alknet-http`'s adapters).
|
exists, but the trait is needed for `alkhttp`'s adapters).
|
||||||
|
|
||||||
**Why the container service doesn't need alknet-ssh**: under the call
|
**Why the container service doesn't need SSH**: under the call
|
||||||
protocol, the container service is a `CallClient` that dials the hub's
|
protocol, the container service is a `CallClient` that dials the hub's
|
||||||
`alk/call` ALPN (over QUIC, TCP+TLS, or any transport) — no SSH in
|
`alk/call` ALPN (over QUIC, TCP+TLS, or any transport) — no SSH in
|
||||||
the loop. SSH port
|
the loop. SSH port
|
||||||
forwarding becomes the *transitional* mechanism for targets that can't run a
|
forwarding becomes the *transitional* mechanism for targets that can't run a
|
||||||
call-protocol client (the `alknet-ssh` phase-0 findings document this
|
call-protocol client (the source repo's SSH phase-0 findings — alknet,
|
||||||
|
provenance — document this
|
||||||
transition). Once the container service runs a `CallClient`, SSH is out of
|
transition). Once the container service runs a `CallClient`, SSH is out of
|
||||||
the path entirely.
|
the path entirely.
|
||||||
|
|
||||||
This is the "dev runner" pattern: a call-protocol client that connects back
|
This is the "dev runner" pattern: a call-protocol client that connects back
|
||||||
to a hub and exposes core dev tools (bash, fs, etc.) as operations. The agent
|
to a hub and exposes core dev tools (bash, fs, etc.) as operations. The agent
|
||||||
service (`alknet-agent`, downstream) is the consumer that orchestrates these
|
crate (downstream) is the consumer that orchestrates these
|
||||||
via `env.invoke()`.
|
via `env.invoke()`.
|
||||||
|
|
||||||
## Implementation Priority Order
|
## Implementation Priority Order
|
||||||
@@ -654,13 +686,13 @@ Based on the gap analysis and the downstream unblock chain:
|
|||||||
runner, no container service, no bilateral exchange. Reuses the existing
|
runner, no container service, no bilateral exchange. Reuses the existing
|
||||||
`CallConnection` for the dispatch loop; adds only the
|
`CallConnection` for the dispatch loop; adds only the
|
||||||
connection-establishment + credential-handling half. The single
|
connection-establishment + credential-handling half. The single
|
||||||
highest-value piece of work in the entire `alknet-call` completion.
|
highest-value piece of work in the entire call-completion effort.
|
||||||
|
|
||||||
2. **`from_call`** (critical, depends on `CallClient`) — consumes the
|
2. **`from_call`** (critical, depends on `CallClient`) — consumes the
|
||||||
already-implemented `services/list` + `services/schema` discovery API.
|
already-implemented `services/list` + `services/schema` discovery API.
|
||||||
|
|
||||||
3. **`OperationAdapter` trait** (enabling) — the async trait. Small,
|
3. **`OperationAdapter` trait** (enabling) — the async trait. Small,
|
||||||
standalone, unblocks `alknet-http` Phase 1 (including `from_jsonschema`
|
standalone, unblocks `alkhttp`'s adapter work (including `from_jsonschema`
|
||||||
per ADR-027).
|
per ADR-027).
|
||||||
|
|
||||||
4. **DC-1 resolution** (peer-graph routing model, ADR-024) — the
|
4. **DC-1 resolution** (peer-graph routing model, ADR-024) — the
|
||||||
@@ -673,23 +705,27 @@ Based on the gap analysis and the downstream unblock chain:
|
|||||||
|
|
||||||
## What This Completion Unblocks
|
## What This Completion Unblocks
|
||||||
|
|
||||||
| Downstream crate | What it needs from alknet-call | Status without completion |
|
Historical planning table (source-repo era; crate names as they were).
|
||||||
|
The completion has since landed in this crate — `alknet-http` is now
|
||||||
|
`alkhttp`.
|
||||||
|
|
||||||
|
| Downstream crate | What it needs from alkcall | Status without completion |
|
||||||
|-------------------|-------------------------------|--------------------------|
|
|-------------------|-------------------------------|--------------------------|
|
||||||
| alknet-http | `OperationAdapter` trait (to implement `from_openapi`/`from_mcp`) | Blocked — can't define HTTP-backed adapters without the trait |
|
| alknet-http (→ alkhttp) | `OperationAdapter` trait (to implement `from_openapi`/`from_mcp`) | Blocked — can't define HTTP-backed adapters without the trait |
|
||||||
| alknet-ssh | Stable alknet-call types (no adapter dependency) | Not blocked — ssh depends on alknet-core, not alknet-call's adapters. Proceeds in parallel. |
|
| alknet-ssh (historical) | Stable alkcall types (no adapter dependency) | Not blocked — ssh depends on the vendored core types, not alkcall's adapters. Proceeds in parallel. |
|
||||||
| alknet-agent | `CallClient` (tool dispatch), `from_call` (remote tool import), `OperationAdapter` (provider adapters) | Blocked on `CallClient` + `from_call` |
|
| alknet-agent (historical) | `CallClient` (tool dispatch), `from_call` (remote tool import), `OperationAdapter` (provider adapters) | Blocked on `CallClient` + `from_call` |
|
||||||
| Container service (dispatch rewrite) | `CallClient` + `from_call` | Blocked — this is the primary consumer |
|
| Container service (dispatch rewrite) | `CallClient` + `from_call` | Blocked — this is the primary consumer |
|
||||||
| Runner pattern (dev runner, opencode runner) | `CallClient` + `from_call` | Blocked — the runner IS a `CallClient` |
|
| Runner pattern (dev runner, opencode runner) | `CallClient` + `from_call` | Blocked — the runner IS a `CallClient` |
|
||||||
| alknet-napi | `CallClient` (Node.js calls remote ops) | Blocked — NAPI projects `CallClient` to JS |
|
| alknet-napi (historical) | `CallClient` (Node.js calls remote ops) | Blocked — NAPI projects `CallClient` to JS |
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|
||||||
- **No HTTP in alknet-call.** `from_openapi`/`from_mcp`/`from_jsonschema`/
|
- **No HTTP in alkcall.** `from_openapi`/`from_mcp`/`from_jsonschema`/
|
||||||
`to_openapi`/`to_mcp` live in `alknet-http`. The `OperationAdapter`
|
`to_openapi`/`to_mcp` live in `alkhttp`. The `OperationAdapter`
|
||||||
trait and the call-protocol-backed adapter (`from_call`, transport-
|
trait and the call-protocol-backed adapter (`from_call`, transport-
|
||||||
agnostic) live in `alknet-call`. `from_jsonschema` was originally
|
agnostic) live in alkcall. `from_jsonschema` was originally
|
||||||
(mis)placed in `alknet-call` as a schema-only placeholder; ADR-027
|
(mis)placed in the call protocol as a schema-only placeholder; ADR-027
|
||||||
moved it to `alknet-http` as a real HTTP-backed adapter. See Adapter
|
moved it to the HTTP host crate as a real HTTP-backed adapter. See Adapter
|
||||||
Location Map.
|
Location Map.
|
||||||
- **No secret material on the wire.** `ConnectionCredentials` carries vault-derived
|
- **No secret material on the wire.** `ConnectionCredentials` carries vault-derived
|
||||||
material for the *outbound* connection (TLS identity); `auth_token` is a
|
material for the *outbound* connection (TLS identity); `auth_token` is a
|
||||||
@@ -710,17 +746,17 @@ Based on the gap analysis and the downstream unblock chain:
|
|||||||
not a singular connection overlay. `invoke_peer()` routes to the right peer
|
not a singular connection overlay. `invoke_peer()` routes to the right peer
|
||||||
via `PeerRef::Specific` / `PeerRef::Any` (ADR-024 §1-2).
|
via `PeerRef::Specific` / `PeerRef::Any` (ADR-024 §1-2).
|
||||||
- **`from_call` is a manual free function.** The assembly layer calls it
|
- **`from_call` is a manual free function.** The assembly layer calls it
|
||||||
after the dial (in `AlknetClient`). The overlay is per-connection so
|
after the dial (in the composition layer's dial helper, `AlknetClient` — today the alknet glue; OQ-43). The overlay is per-connection so
|
||||||
re-import on reconnect is naturally scoped (DC-2, OQ-27). See
|
re-import on reconnect is naturally scoped (DC-2, OQ-12). See
|
||||||
[ADR-028](decisions/069-from-call-manual-free-function.md).
|
[ADR-028](decisions/028-from-call-manual-free-function.md).
|
||||||
- **`from_call` namespace collision is same-peer only.** Cross-peer collision
|
- **`from_call` namespace collision is same-peer only.** Cross-peer collision
|
||||||
dissolves (same name on different peers is fine — separate sub-overlays,
|
dissolves (same name on different peers is fine — separate sub-overlays,
|
||||||
ADR-024 §5). Same-peer collision stays an error. `namespace_prefix` is
|
ADR-024 §5). Same-peer collision stays an error. `namespace_prefix` is
|
||||||
optional local-naming sugar, not the disambiguation mechanism (DC-3, OQ-28).
|
optional local-naming sugar, not the disambiguation mechanism (DC-3, OQ-13).
|
||||||
- **`OperationAdapter::import()` returns `Result`.** Failures surface as
|
- **`OperationAdapter::import()` returns `Result`.** Failures surface as
|
||||||
`AdapterError` (DC-4, OQ-26).
|
`AdapterError` (DC-4, OQ-11).
|
||||||
- **MCP stdio transport is not built.** Streamable HTTP is the only supported
|
- **MCP stdio transport is not built.** Streamable HTTP is the only supported
|
||||||
MCP transport in alknet. stdio = spawn arbitrary executable = built-in RCE.
|
MCP transport in the alk stack. stdio = spawn arbitrary executable = built-in RCE.
|
||||||
Recorded as an explicit security position, not a feature gap.
|
Recorded as an explicit security position, not a feature gap.
|
||||||
- **Pure-client X.509 connections are not in the peer graph on the client
|
- **Pure-client X.509 connections are not in the peer graph on the client
|
||||||
side.** A `CallClient` connection to a public X.509 endpoint with no
|
side.** A `CallClient` connection to a public X.509 endpoint with no
|
||||||
@@ -730,10 +766,10 @@ Based on the gap analysis and the downstream unblock chain:
|
|||||||
invoked through the `CallConnection` handle. The client-side
|
invoked through the `CallConnection` handle. The client-side
|
||||||
`ServerCertVerifier` uses CA verification (`WebPkiServerVerifier`) for
|
`ServerCertVerifier` uses CA verification (`WebPkiServerVerifier`) for
|
||||||
such remotes; known peers (hub with `PeerEntry`) use fingerprint
|
such remotes; known peers (hub with `PeerEntry`) use fingerprint
|
||||||
pinning. See [ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md).
|
pinning. See [ADR-054](decisions/054-outgoing-only-x509-three-remote-roles.md).
|
||||||
- **`ConnectionCredentials.remote_identity: None` is load-bearing.** `None`
|
- **`ConnectionCredentials.remote_identity: None` is load-bearing.** `None`
|
||||||
means "no `PeerEntry` for this remote → use CA verification (X.509)
|
means "no `PeerEntry` for this remote → use CA verification (X.509)
|
||||||
or fail closed (Ed25519 raw key)" per the ADR-034 §3 verifier rule.
|
or fail closed (Ed25519 raw key)" per the verifier rule (ADR-054).
|
||||||
The implementation must not default `remote_identity` to a placeholder
|
The implementation must not default `remote_identity` to a placeholder
|
||||||
to satisfy the field, and must not treat `None` as "skip verification"
|
to satisfy the field, and must not treat `None` as "skip verification"
|
||||||
— `None` + X.509 is CA verification, `None` + raw key is a hard
|
— `None` + X.509 is CA verification, `None` + raw key is a hard
|
||||||
@@ -743,79 +779,73 @@ Based on the gap analysis and the downstream unblock chain:
|
|||||||
|
|
||||||
| Decision | ADR | Summary |
|
| Decision | ADR | Summary |
|
||||||
|----------|-----|---------|
|
|----------|-----|---------|
|
||||||
| Call protocol client and adapter contract | [ADR-022](decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction; trait is async; adapters produce `HandlerRegistration` bundles |
|
| Call protocol client and adapter contract | [ADR-022](decisions/022-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction; trait is async; adapters produce `HandlerRegistration` bundles |
|
||||||
| `from_jsonschema` as HTTP-backed single-endpoint adapter in alknet-http | [ADR-027](decisions/066-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf |
|
| `from_jsonschema` as HTTP-backed single-endpoint adapter | [ADR-027](decisions/027-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from the call protocol (broken schema-only placeholder) to the HTTP host crate as a real reqwest-backed single-endpoint adapter — today `alkhttp` (ex-`alknet-http`); `FromJsonSchema` provenance stays in alkcall as a leaf |
|
||||||
| Peer-graph routing model (DC-1, supersedes ADR-023) | [ADR-024](decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via existing `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` |
|
| Peer-graph routing model (DC-1, supersedes ADR-023) | [ADR-024](decisions/024-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via existing `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` |
|
||||||
| PeerEntry and Identity.id decoupling | [ADR-025](decisions/030-peerentry-and-identity-id-decoupling.md) | `PeerId` source changes from UUID to `Identity.id` (= `PeerEntry.peer_id`, stable across key rotation); `Identity.id` decoupled from crypto material on the fingerprint path |
|
| PeerEntry and Identity.id decoupling | [ADR-025](decisions/025-peerentry-and-identity-id-decoupling.md) | `PeerId` source changes from UUID to `Identity.id` (= `PeerEntry.peer_id`, stable across key rotation); `Identity.id` decoupled from crypto material on the fingerprint path |
|
||||||
| Forwarded-for identity | [ADR-026](decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; the `from_call` handler populates it; metadata only, never used by `AccessControl::check` |
|
| Forwarded-for identity | [ADR-026](decisions/026-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; the `from_call` handler populates it; metadata only, never used by `AccessControl::check` |
|
||||||
| Storage boundary and repo/adapter pattern | [ADR-033](decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
|
| Storage boundary and repo/adapter pattern | [alknet ADR-033](/workspace/@alkdev/alknet/docs/architecture/decisions/033-storage-boundary-and-repo-adapter-pattern.md) (provenance) | Core defines repo traits + in-memory defaults; persistence adapters are separate crates (composition-layer work — OQ-43) |
|
||||||
| Secret material flow and capability injection | [ADR-010](decisions/014-secret-material-flow-and-capability-injection.md) | The no-env-vars invariant's foundation; capabilities injected at assembly layer |
|
| Secret material flow and capability injection | [ADR-010](decisions/010-secret-material-flow-and-capability-injection.md) | The no-env-vars invariant's foundation; capabilities injected at assembly layer |
|
||||||
| Handler registration, provenance, and composition authority | [ADR-018](decisions/022-handler-registration-provenance-and-composition-authority.md) | The registration bundle adapters produce; `composition_authority: None` for leaves |
|
| Handler registration, provenance, and composition authority | [ADR-018](decisions/018-handler-registration-provenance-and-composition-authority.md) | The registration bundle adapters produce; `composition_authority: None` for leaves |
|
||||||
| Operation registry layering | [ADR-019](decisions/024-operation-registry-layering.md) | Layer 2 per-connection overlay where `from_call` imports land |
|
| Operation registry layering | [ADR-019](decisions/019-operation-registry-layering.md) | Layer 2 per-connection overlay where `from_call` imports land |
|
||||||
| Privilege model and authority context | [ADR-017](decisions/015-privilege-model-and-authority-context.md) | Adapter-registered ops are `Internal` by default; default-deny posture |
|
| Privilege model and authority context | [ADR-017](decisions/017-privilege-model-and-authority-context.md) | Adapter-registered ops are `Internal` by default; default-deny posture |
|
||||||
| Abort cascade for nested calls | [ADR-020](decisions/016-abort-cascade-for-nested-calls.md) | Cross-node abort through `from_call` forwarding handler's `parent_request_id` |
|
| Abort cascade for nested calls | [ADR-020](decisions/020-abort-cascade-for-nested-calls.md) | Cross-node abort through `from_call` forwarding handler's `parent_request_id` |
|
||||||
| Operation error schemas | [ADR-016](decisions/023-operation-error-schemas.md) | `error_schemas` mirrored by `from_call` from remote op's spec |
|
| Operation error schemas | [ADR-016](decisions/016-operation-error-schemas.md) | `error_schemas` mirrored by `from_call` from remote op's spec |
|
||||||
| Streaming handler for subscriptions | [ADR-021](decisions/049-streaming-handler-for-subscriptions.md) | `from_call` `Subscription` ops register a `StreamingHandler` (`HandlerKind::Stream`) that calls `CallConnection::subscribe()` and forwards the remote stream; `Query`/`Mutation` stay `HandlerKind::Once` |
|
| Streaming handler for subscriptions | [ADR-021](decisions/021-streaming-handler-for-subscriptions.md) | `from_call` `Subscription` ops register a `StreamingHandler` (`HandlerKind::Stream`) that calls `CallConnection::subscribe()` and forwards the remote stream; `Query`/`Mutation` stay `HandlerKind::Once` |
|
||||||
| TLS identity redesign | [ADR-027](decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | RFC 7250 raw key / X.509 cert dimensions of the local `TlsIdentity` (now carried by `ConnectionCredentials.local_identity`) |
|
| TLS identity redesign | [alknet ADR-027](/workspace/@alkdev/alknet/docs/architecture/decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | RFC 7250 raw key / X.509 cert dimensions of the local `TlsIdentity` (now carried by `ConnectionCredentials.local_identity`) |
|
||||||
| Outgoing-only X.509 and three peer roles | [ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Public X.509 endpoint is not a `PeerEntry` on the client side (no `PeerId`, not in peer graph); client-side verifier by `PeerEntry` presence (CA vs fingerprint pin); hub = mixed-fingerprint `PeerEntry` |
|
| Outgoing-only X.509 and three remote roles | [ADR-054](decisions/054-outgoing-only-x509-three-remote-roles.md) (re-owned; provenance: [alknet ADR-034](/workspace/@alkdev/alknet/docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md)) | Public X.509 endpoint is not a `PeerEntry` on the client side (no `PeerId`, not in peer graph); client-side verifier by `PeerEntry` presence (CA vs fingerprint pin); hub = mixed-fingerprint `PeerEntry` |
|
||||||
| HD derivation for encryption keys | [ADR-020](decisions/020-hd-derivation-for-encryption-keys.md) | Vault-derived TLS identity material |
|
| HD derivation for encryption keys | [alknet ADR-020](/workspace/@alkdev/alknet/docs/architecture/decisions/020-hd-derivation-for-encryption-keys.md) (provenance — vault ADRs live in the source repo) | Vault-derived TLS identity material |
|
||||||
| Vault key model | [ADR-026](decisions/026-vault-key-model-hd-derivation.md) | Vault-derived TLS identity material |
|
| Vault key model | [alknet ADR-026](/workspace/@alkdev/alknet/docs/architecture/decisions/026-vault-key-model-hd-derivation.md) (provenance) | Vault-derived TLS identity material |
|
||||||
| Vault local-only dispatch | [ADR-025](decisions/025-vault-local-only-dispatch.md) | Vault access at assembly layer only; the credential injection path's first hop |
|
| Vault local-only dispatch | [alknet ADR-025](/workspace/@alkdev/alknet/docs/architecture/decisions/025-vault-local-only-dispatch.md) (provenance) | Vault access at assembly layer only; the credential injection path's first hop |
|
||||||
| Crate decomposition | [ADR-031](decisions/003-crate-decomposition.md) | `alknet-http` depends on `alknet-call` (protocol-foundation exception, noted in Adapter Location Map) |
|
| Crate decomposition | [ADR-031](decisions/031-crate-decomposition.md) | `alkhttp` depends on alkcall (protocol-foundation exception, noted in Adapter Location Map) |
|
||||||
| One-way door decision framework | [ADR-032](decisions/009-one-way-door-decision-framework.md) | Door-type classification for DC-1..4 |
|
| One-way door decision framework | [ADR-032](decisions/032-one-way-door-decision-framework.md) | Door-type classification for DC-1..4 |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
See [open-questions.md](open-questions.md) for full details.
|
See [open-questions.md](open-questions.md) for full details.
|
||||||
|
|
||||||
- **OQ-25** (dissolved by ADR-024): `remote_safe` marking shape — moot.
|
- **OQ-10** (dissolved by ADR-024): `remote_safe` marking shape — moot.
|
||||||
`remote_safe`/`trusted_peer` are retired; peer authorization is
|
`remote_safe`/`trusted_peer` are retired; peer authorization is
|
||||||
`AccessControl::check(peer_identity)`. No marking to shape.
|
`AccessControl::check(peer_identity)`. No marking to shape.
|
||||||
- **OQ-26** (resolved): `AdapterError` variants — `DiscoveryFailed`,
|
- **OQ-11** (resolved): `AdapterError` variants — `DiscoveryFailed`,
|
||||||
`SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`
|
`SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`
|
||||||
(replaces flat `Conflict`). `#[non_exhaustive]`.
|
(replaces flat `Conflict`). `#[non_exhaustive]`.
|
||||||
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
|
- **OQ-12** (resolved): `from_call` re-import trigger — `from_call` is a
|
||||||
manual free function; the assembly layer calls it after the dial (in
|
manual free function; the assembly layer calls it after the dial (in
|
||||||
`AlknetClient`). A `CallConnection::refresh()` method is a genuine
|
`AlknetClient`). A `CallConnection::refresh()` method is a genuine
|
||||||
feature addition — non-breaking, additive. See
|
feature addition — non-breaking, additive. See
|
||||||
[ADR-028](decisions/069-from-call-manual-free-function.md).
|
[ADR-028](decisions/028-from-call-manual-free-function.md).
|
||||||
- **OQ-28** (resolved): `from_call` namespace collision — same-peer
|
- **OQ-13** (resolved): `from_call` namespace collision — same-peer
|
||||||
collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays).
|
collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays).
|
||||||
`namespace_prefix` is optional local-naming sugar.
|
`namespace_prefix` is optional local-naming sugar.
|
||||||
- **OQ-29** (resolved): `CallClient` TLS client-auth — wire quinn
|
- **OQ-14** (resolved): `CallClient` TLS client-auth — wire quinn
|
||||||
client-auth (present Ed25519 key as raw public key client cert);
|
client-auth (present Ed25519 key as raw public key client cert);
|
||||||
key-type-aware server cert verification (raw key = fingerprint match,
|
key-type-aware server cert verification (raw key = fingerprint match,
|
||||||
X.509 = CA verification); fingerprint normalization (`ed25519:` across
|
X.509 = CA verification); fingerprint normalization (`ed25519:` across
|
||||||
quinn/iroh). The iroh path already works; the gap was quinn-only.
|
quinn/iroh). The iroh path already works; the gap was quinn-only.
|
||||||
See OQ-29 in open-questions.md.
|
See OQ-14 in open-questions.md.
|
||||||
- **OQ-30** (resolved): `PeerRef::Any` routing policy — insertion-order
|
- **OQ-15** (resolved): `PeerRef::Any` routing policy — insertion-order
|
||||||
first-match. A richer `RoutingPolicy` is a feature extension.
|
first-match. A richer `RoutingPolicy` is a feature extension.
|
||||||
- **OQ-31** (resolved): `services/list-peers` — opt-in; `services/list`
|
- **OQ-16** (resolved): `services/list-peers` — opt-in; `services/list`
|
||||||
is "own ops only."
|
is "own ops only."
|
||||||
- **OQ-32** (open, feature extension): Multi-hop federation — the one-hop
|
- **OQ-17** (deferred(scope), feature extension): Multi-hop federation — the one-hop
|
||||||
model is the architectural commitment; multi-hop is a feature extension
|
model is the architectural commitment; multi-hop is a feature extension
|
||||||
that doesn't break downstream. The peer-keyed model extends to multi-hop
|
that doesn't break downstream. The peer-keyed model extends to multi-hop
|
||||||
without redesign; petgraph is the candidate if path-finding becomes real
|
without redesign; petgraph is the candidate if path-finding becomes real
|
||||||
(ADR-024 §3.7).
|
(ADR-024 §3.7).
|
||||||
- **OQ-33** (resolved by ADR-025): `PeerId` is a logical id. Source is
|
- **OQ-18** (resolved by ADR-025): `PeerId` is a logical id. Source is
|
||||||
`Identity.id` from `IdentityProvider` resolution (= `PeerEntry.peer_id`,
|
`Identity.id` from `IdentityProvider` resolution (= `PeerEntry.peer_id`,
|
||||||
stable across key rotation). See OQ-33 in open-questions.md.
|
stable across key rotation). See OQ-18 in open-questions.md.
|
||||||
- **OQ-34** (resolved by ADR-025 + ADR-033): Persistent peer registry —
|
- **OQ-19** (resolved by ADR-025): Persistent peer registry —
|
||||||
the storage boundary is `core trait + in-memory default` (config-backed
|
the storage boundary is `core trait + in-memory default` (config-backed
|
||||||
`ConfigIdentityProvider` now; persistence adapters additive in separate
|
`ConfigIdentityProvider` now; persistence adapters additive in separate
|
||||||
crates). See OQ-34 in open-questions.md.
|
crates). See OQ-19 in open-questions.md.
|
||||||
- **OQ-35** (dissolved): the "API key asymmetry" framing was wrong;
|
- **OQ-20** (dissolved): the "API key asymmetry" framing was wrong;
|
||||||
`PeerEntry` supports multiple credential paths (fingerprints +
|
`PeerEntry` supports multiple credential paths (fingerprints +
|
||||||
auth_token_hash), `ApiKeyEntry` is for tokens that ARE the identity.
|
auth_token_hash), `ApiKeyEntry` is for tokens that ARE the identity.
|
||||||
See OQ-35 in open-questions.md.
|
See OQ-20 in open-questions.md.
|
||||||
- **OQ-36** (resolved by ADR-035): Concrete persistence adapter shapes —
|
- **OQ-21** (resolved by ADR-054): X.509 outgoing-only
|
||||||
read-sync / write-async split (`IdentityStore` async write trait
|
case — three
|
||||||
extends the sync `IdentityProvider` read trait); SQLite adapter caches
|
|
||||||
in memory and uses honker NOTIFY/LISTEN for no-restart cache
|
|
||||||
invalidation; `alknet-store-sqlite` crate implements both
|
|
||||||
`IdentityStore` and `CredentialStore`. See ADR-035 and OQ-36 in
|
|
||||||
open-questions.md.
|
|
||||||
- **OQ-37** (resolved by ADR-034): X.509 outgoing-only case — three
|
|
||||||
remote roles named (public X.509 endpoint, transport relay, hub).
|
remote roles named (public X.509 endpoint, transport relay, hub).
|
||||||
`PeerEntry` asymmetry is correct: a pure-client connection to a public
|
`PeerEntry` asymmetry is correct: a pure-client connection to a public
|
||||||
X.509 endpoint is **not** in the call-protocol peer graph on the
|
X.509 endpoint is **not** in the call-protocol peer graph on the
|
||||||
@@ -824,8 +854,10 @@ See [open-questions.md](open-questions.md) for full details.
|
|||||||
land in the connection's Layer 2 overlay and are invoked through the
|
land in the connection's Layer 2 overlay and are invoked through the
|
||||||
connection handle. The client-side `ServerCertVerifier` is selected
|
connection handle. The client-side `ServerCertVerifier` is selected
|
||||||
by `PeerEntry` presence: known peer → fingerprint pin; unknown X.509
|
by `PeerEntry` presence: known peer → fingerprint pin; unknown X.509
|
||||||
remote → CA verification (`WebPkiServerVerifier`). See ADR-034 and
|
remote → CA verification (`WebPkiServerVerifier`). The rule is
|
||||||
OQ-37 in open-questions.md.
|
re-owned in-crate by ADR-054 (provenance: alknet ADR-034, "Outgoing-Only
|
||||||
|
X.509 and Three Peer Roles", not ported); ADR-025's
|
||||||
|
`PeerEntry` asymmetry is the registry surface. See OQ-21 in open-questions.md.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -837,11 +869,16 @@ See [open-questions.md](open-questions.md) for full details.
|
|||||||
model (the server-side complement to this document)
|
model (the server-side complement to this document)
|
||||||
- `operation-registry.md` — `HandlerRegistration`, provenance, capability
|
- `operation-registry.md` — `HandlerRegistration`, provenance, capability
|
||||||
injection, service discovery (the discovery API `from_call` consumes)
|
injection, service discovery (the discovery API `from_call` consumes)
|
||||||
- `docs/research/alknet-call-completion/gap-analysis.md` — DC-1..4, the
|
- Source-repo research (alknet, provenance — these paths live in
|
||||||
implementation-state audit, the downstream unblock chain
|
`/workspace/@alkdev/alknet/docs/research/`, not this repo):
|
||||||
- `docs/research/alknet-call-peer-routing/findings.md` — the peer-graph
|
- `alknet-call-completion/gap-analysis.md` — DC-1..4, the
|
||||||
routing research that identified ADR-023's structural gap and validated
|
implementation-state audit, the downstream unblock chain
|
||||||
the ADR-024 design via POC
|
- `alknet-call-peer-routing/findings.md` — the peer-graph
|
||||||
|
routing research that identified ADR-023's structural gap and validated
|
||||||
|
the ADR-024 design via POC
|
||||||
|
- `alknet-ssh/phase-0-findings.md` — SSH Phase 0; confirms ssh depends on
|
||||||
|
the vendored core types, not alkcall's adapters, so it proceeds in
|
||||||
|
parallel with the completion
|
||||||
- `/workspace/@alkdev/operations/` — TypeScript prior art (`from_openapi.ts`,
|
- `/workspace/@alkdev/operations/` — TypeScript prior art (`from_openapi.ts`,
|
||||||
`from_mcp.ts`, `from_schema.ts`, `scanner.ts`)
|
`from_mcp.ts`, `from_schema.ts`, `scanner.ts`)
|
||||||
- `/workspace/@alkdev/dispatch/` — concrete downstream consumer (container
|
- `/workspace/@alkdev/dispatch/` — concrete downstream consumer (container
|
||||||
@@ -849,7 +886,4 @@ See [open-questions.md](open-questions.md) for full details.
|
|||||||
- `/workspace/aisdk/` — downstream consumer (Rust port of Vercel AI SDK); the
|
- `/workspace/aisdk/` — downstream consumer (Rust port of Vercel AI SDK); the
|
||||||
no-env-vars invariant makes its `std::env::var` reads unreachable
|
no-env-vars invariant makes its `std::env::var` reads unreachable
|
||||||
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp); streamable HTTP transport for
|
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp); streamable HTTP transport for
|
||||||
`alknet-http`'s `from_mcp`/`to_mcp` (separate crate, separate Phase 0)
|
`alkhttp`'s `from_mcp`/`to_mcp`
|
||||||
- `docs/research/alknet-ssh/phase-0-findings.md` — alknet-ssh Phase 0;
|
|
||||||
confirms ssh depends on alknet-core not alknet-call's adapters, so it
|
|
||||||
proceeds in parallel with this completion
|
|
||||||
@@ -232,5 +232,5 @@ WebTransport stream).
|
|||||||
- OQ-09: WASM target boundaries (resolution amended — the server-side
|
- OQ-09: WASM target boundaries (resolution amended — the server-side
|
||||||
dispatch door is no longer closed by `Connection` being QUIC-bound)
|
dispatch door is no longer closed by `Connection` being QUIC-bound)
|
||||||
- Transport generalization findings:
|
- Transport generalization findings:
|
||||||
[`docs/research/transport-generalization/findings.md`](../../research/transport-generalization/findings.md)
|
[`docs/research/transport-generalization/findings.md`](/workspace/@alkdev/alknet/docs/research/transport-generalization/findings.md)
|
||||||
- Implementation commit: `865fef6` (2026-07-09)
|
- Implementation commit: `865fef6` (2026-07-09)
|
||||||
@@ -295,8 +295,8 @@ the args are optional for their transport.
|
|||||||
`from_bidi` constructors and the yield-once contract are preserved via
|
`from_bidi` constructors and the yield-once contract are preserved via
|
||||||
`StreamBidiStreamSource`)
|
`StreamBidiStreamSource`)
|
||||||
- Channels POC summary:
|
- Channels POC summary:
|
||||||
[`docs/research/alknet-channels/poc-summary.md`](../../research/alknet-channels/poc-summary.md)
|
[`docs/research/alknet-channels/poc-summary.md`](/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md)
|
||||||
§"Issues Surfaced" #1 (OQ-CH-13 confirmed +EV), #2 (REQ-CORE-02)
|
§"Issues Surfaced" #1 (OQ-CH-13 confirmed +EV), #2 (REQ-CORE-02)
|
||||||
- Channels Phase 0 findings:
|
- Channels Phase 0 findings:
|
||||||
[`docs/research/alknet-channels/phase-0-findings.md`](../../research/alknet-channels/phase-0-findings.md)
|
[`docs/research/alknet-channels/phase-0-findings.md`](/workspace/@alkdev/alknet/docs/research/alknet-channels/phase-0-findings.md)
|
||||||
§POC-Validated Requirements — REQ-CORE-01, REQ-CORE-02
|
§POC-Validated Requirements — REQ-CORE-01, REQ-CORE-02
|
||||||
@@ -49,7 +49,7 @@ handler picks a joining strategy per-handler:
|
|||||||
| 1 | `BiStream` trait (`AsyncRead + AsyncWrite + Send + Unpin`) | `crates/alknet-core/src/types.rs:226` | **Vestigial in code.** Declared per ADR-005, named in ADR-008 as "a client-side / test convenience trait," but grep across the workspace finds **zero** consumers — no `impl BiStream`, no `dyn BiStream`, no `Box<dyn BiStream>`. The trait is the ecosystem convention (`tokio::net::TcpStream`, `TlsStream<TcpStream>`, `russh::Channel::into_stream()` all satisfy it natively) but it was never wired in. |
|
| 1 | `BiStream` trait (`AsyncRead + AsyncWrite + Send + Unpin`) | `crates/alknet-core/src/types.rs:226` | **Vestigial in code.** Declared per ADR-005, named in ADR-008 as "a client-side / test convenience trait," but grep across the workspace finds **zero** consumers — no `impl BiStream`, no `dyn BiStream`, no `Box<dyn BiStream>`. The trait is the ecosystem convention (`tokio::net::TcpStream`, `TlsStream<TcpStream>`, `russh::Channel::into_stream()` all satisfy it natively) but it was never wired in. |
|
||||||
| 2 | `Connection` (yields `(SendStream, RecvStream)` via `accept_bi`) | `crates/alknet-core/src/types.rs:507` | The handler-facing abstraction. Leaf is split. |
|
| 2 | `Connection` (yields `(SendStream, RecvStream)` via `accept_bi`) | `crates/alknet-core/src/types.rs:507` | The handler-facing abstraction. Leaf is split. |
|
||||||
| 3 | `SendStream` (AsyncWrite-only) + `RecvStream` (AsyncRead-only) | `crates/alknet-core/src/types.rs:228-294` | The actual leaves handlers receive. Each carries a quinn/iroh/generic enum (`SendStreamKind` / `RecvStreamKind`) and dispatches per-call. |
|
| 3 | `SendStream` (AsyncWrite-only) + `RecvStream` (AsyncRead-only) | `crates/alknet-core/src/types.rs:228-294` | The actual leaves handlers receive. Each carries a quinn/iroh/generic enum (`SendStreamKind` / `RecvStreamKind`) and dispatches per-call. |
|
||||||
| 4 | `WsStream` trait (recv/send `axum::ws::Message`) | `crates/alknet-http/src/websocket/upgrade.rs:44` | Bypasses `Connection` entirely. The WS session runs its own dispatch loop directly over `axum::extract::ws::WebSocket`; `CallConnection::new_overlay_only` is used instead of `Connection::from_bidi`. ADR-044/048 already say "a WS message stream is another `BiStream`-satisfying transport" — the code does not. |
|
| 4 | `WsStream` trait (recv/send `axum::ws::Message`) | `crates/alknet-http/src/websocket/upgrade.rs:44` | Bypasses `Connection` entirely. The WS session runs its own dispatch loop directly over `axum::extract::ws::WebSocket`; `CallConnection::new_overlay_only` is used instead of `Connection::from_bidi`. alknet ADR-044/048 (alknet space, provenance) already say "a WS message stream is another `BiStream`-satisfying transport" — the code does not. |
|
||||||
| 5 | `MpscSendStream` / `MpscRecvStream` (channels POC) | `/workspace/alknet-channels-poc/src/mpsc_stream.rs` | Split mpsc-backed halves fed to `Connection::from_stream`. The channels POC's `TunnelHandler` consumes them directly as two `tokio::io::copy` pumps — the split shape is right for the tunnel, wrong for HTTP. |
|
| 5 | `MpscSendStream` / `MpscRecvStream` (channels POC) | `/workspace/alknet-channels-poc/src/mpsc_stream.rs` | Split mpsc-backed halves fed to `Connection::from_stream`. The channels POC's `TunnelHandler` consumes them directly as two `tokio::io::copy` pumps — the split shape is right for the tunnel, wrong for HTTP. |
|
||||||
|
|
||||||
The two Phase 6 issues are symptoms of one root: **the leaf type is
|
The two Phase 6 issues are symptoms of one root: **the leaf type is
|
||||||
@@ -108,21 +108,21 @@ accepts any of these directly, no per-handler wrapper.
|
|||||||
|
|
||||||
### The two-pump shape is unaffected
|
### The two-pump shape is unaffected
|
||||||
|
|
||||||
The tunnel handler (ADR-078) and the SSH `direct-tcpip` handler
|
The tunnel handler (alknet ADR-078, provenance — in-crate: ADR-050) and the SSH `direct-tcpip` handler
|
||||||
(future) use the split shape — two `tokio::io::copy` pumps, one per
|
(future) use the split shape — two `tokio::io::copy` pumps, one per
|
||||||
direction. With `BiStream` as the leaf, these handlers call
|
direction. With `BiStream` as the leaf, these handlers call
|
||||||
`tokio::io::split(bidi)` to get `(ReadHalf, WriteHalf)` — the same
|
`tokio::io::split(bidi)` to get `(ReadHalf, WriteHalf)` — the same
|
||||||
stdlib idiom `tokio::io::split` already provides for `TcpStream` and
|
stdlib idiom `tokio::io::split` already provides for `TcpStream` and
|
||||||
`TlsStream<TcpStream>`. The split is a stdlib call at the handler
|
`TlsStream<TcpStream>`. The split is a stdlib call at the handler
|
||||||
boundary, not a per-handler trait wrapper. ADR-078's
|
boundary, not a per-handler trait wrapper. alknet ADR-078's (alknet space, provenance)
|
||||||
shutdown-on-completion contract applies to the `ReadHalf`/`WriteHalf`
|
shutdown-on-completion contract applies to the `ReadHalf`/`WriteHalf`
|
||||||
unchanged.
|
unchanged.
|
||||||
|
|
||||||
### The TTY named-sub-streams case (ADR-038, ADR-077)
|
### The TTY named-sub-streams case (ADR-038; alknet ADR-077, provenance — TTY inside channels)
|
||||||
|
|
||||||
ADR-038 specifies `into_sub_streams()` returning
|
ADR-038 specifies `into_sub_streams()` returning
|
||||||
`Vec<(u8, SubStreamHandle)>` where `SubStreamHandle` is
|
`Vec<(u8, SubStreamHandle)>` where `SubStreamHandle` is
|
||||||
`Send(SendStream) | Recv(RecvStream)`. ADR-077's TTY-inside-channels
|
`Send(SendStream) | Recv(RecvStream)`. alknet ADR-077's (alknet space, provenance) TTY-inside-channels
|
||||||
mode destructures into five named handles (`stdin`, `stdout`,
|
mode destructures into five named handles (`stdin`, `stdout`,
|
||||||
`stderr`, `ctrl_in`, `ctrl_out`). **Every stream_type is
|
`stderr`, `ctrl_in`, `ctrl_out`). **Every stream_type is
|
||||||
unidirectional** (ADR-034) — the typed-sub-stream leaves are
|
unidirectional** (ADR-034) — the typed-sub-stream leaves are
|
||||||
@@ -306,13 +306,13 @@ The `WsStream` trait (`upgrade.rs:44-49`), the bespoke `drive_ws_session`
|
|||||||
loop, the `handle_inbound_envelope` / `dispatch_envelope_to_pending`
|
loop, the `handle_inbound_envelope` / `dispatch_envelope_to_pending`
|
||||||
helpers, and the `run_ws_session` glue are removed. The session's
|
helpers, and the `run_ws_session` glue are removed. The session's
|
||||||
wire-level invariants (binary-only, protocol-level close on text,
|
wire-level invariants (binary-only, protocol-level close on text,
|
||||||
`fail_all` pending on disconnect, ADR-048's `EventEnvelope` framing)
|
`fail_all` pending on disconnect, alknet ADR-048's `EventEnvelope` framing (alknet space, provenance))
|
||||||
move into `WsBidiStream`'s `AsyncRead` / `AsyncWrite` / `poll_shutdown`
|
move into `WsBidiStream`'s `AsyncRead` / `AsyncWrite` / `poll_shutdown`
|
||||||
impls and the standard call-protocol dispatch path.
|
impls and the standard call-protocol dispatch path.
|
||||||
|
|
||||||
`CallConnection::new_overlay_only` stays — it's the
|
`CallConnection::new_overlay_only` stays — it's the
|
||||||
connection-local-overlay construction for non-peer clients
|
connection-local-overlay construction for non-peer clients
|
||||||
(ADR-034 §4, ADR-044 §5), orthogonal to the transport seam. What
|
(alknet ADR-034 §4, alknet ADR-044 §5 — alknet space, provenance), orthogonal to the transport seam. What
|
||||||
changes is that the WS session feeds it through a `Connection` rather
|
changes is that the WS session feeds it through a `Connection` rather
|
||||||
than a parallel `WsStream` trait.
|
than a parallel `WsStream` trait.
|
||||||
|
|
||||||
@@ -330,7 +330,7 @@ async fn accept_bi(&self) -> Result<BiStream, StreamError> {
|
|||||||
`into_sub_streams()` is unchanged in shape — it still returns
|
`into_sub_streams()` is unchanged in shape — it still returns
|
||||||
`Vec<(u8, SubStreamHandle)>` with `SubStreamHandle::Send(SendStream) |
|
`Vec<(u8, SubStreamHandle)>` with `SubStreamHandle::Send(SendStream) |
|
||||||
Recv(RecvStream)`, because TTY's named sub-streams are unidirectional
|
Recv(RecvStream)`, because TTY's named sub-streams are unidirectional
|
||||||
(ADR-034, ADR-077). The two paths (`accept_bi` for handlers that want
|
(ADR-034; alknet ADR-077, provenance). The two paths (`accept_bi` for handlers that want
|
||||||
the joined pair, `into_sub_streams` for handlers that want the typed
|
the joined pair, `into_sub_streams` for handlers that want the typed
|
||||||
unidirectional sub-streams) are preserved per ADR-038.
|
unidirectional sub-streams) are preserved per ADR-038.
|
||||||
|
|
||||||
@@ -348,7 +348,7 @@ The `webtransport.md` spec describes the "VPN-like without being a VPN"
|
|||||||
path: a browser opens a WebTransport session to `/alk/ssh`, the h3
|
path: a browser opens a WebTransport session to `/alk/ssh`, the h3
|
||||||
handler hands each bidi stream to `SshAdapter::handle` as a
|
handler hands each bidi stream to `SshAdapter::handle` as a
|
||||||
`Connection`, the browser's WASM SSH parser speaks SSH over the
|
`Connection`, the browser's WASM SSH parser speaks SSH over the
|
||||||
stream. WebTransport is deferred per ADR-044.
|
stream. WebTransport is deferred per alknet ADR-044 (alknet space, provenance).
|
||||||
|
|
||||||
With `BiStream` as the leaf, the same path exists over WebSocket in
|
With `BiStream` as the leaf, the same path exists over WebSocket in
|
||||||
v1: a browser opens a WS connection, `WsBidiStream` presents it as a
|
v1: a browser opens a WS connection, `WsBidiStream` presents it as a
|
||||||
@@ -356,7 +356,7 @@ v1: a browser opens a WS connection, `WsBidiStream` presents it as a
|
|||||||
runs the channels demux over it, each channel's `accept_bi` yields a
|
runs the channels demux over it, each channel's `accept_bi` yields a
|
||||||
`BiStream` that `SshAdapter::handle` receives. The WASM SSH parser
|
`BiStream` that `SshAdapter::handle` receives. The WASM SSH parser
|
||||||
runs over a `BiStream`-over-WS-message adapter on the browser side
|
runs over a `BiStream`-over-WS-message adapter on the browser side
|
||||||
(the same `WsBidiStream` shape, browser-implemented). ADR-044/048's
|
(the same `WsBidiStream` shape, browser-implemented). alknet ADR-044/048's (alknet space, provenance)
|
||||||
"WS message stream is another `BiStream`-satisfying transport" becomes
|
"WS message stream is another `BiStream`-satisfying transport" becomes
|
||||||
literal — the code does what the spec said.
|
literal — the code does what the spec said.
|
||||||
|
|
||||||
@@ -379,7 +379,7 @@ ALPN-stream-proxy (ADR-040) is the channels-over-WebTransport shape,
|
|||||||
not an `alknet-http` shape.
|
not an `alknet-http` shape.
|
||||||
|
|
||||||
This is out of scope for this ADR (WebTransport is deferred per
|
This is out of scope for this ADR (WebTransport is deferred per
|
||||||
ADR-044). It is recorded here because the unification is what makes
|
alknet ADR-044, alknet space — provenance). It is recorded here because the unification is what makes
|
||||||
the future extraction clean: once `accept_bi` returns `BiStream`, the
|
the future extraction clean: once `accept_bi` returns `BiStream`, the
|
||||||
`h3` handler's "accept a WebTransport session, yield each stream as a
|
`h3` handler's "accept a WebTransport session, yield each stream as a
|
||||||
`BiStream` to the ALPN handler" shape is the same code as
|
`BiStream` to the ALPN handler" shape is the same code as
|
||||||
@@ -405,7 +405,7 @@ redesign.
|
|||||||
unidirectional sub-streams are the case that justifies keeping
|
unidirectional sub-streams are the case that justifies keeping
|
||||||
`SendStream` / `RecvStream` (as thin newtypes, not quinn-welded
|
`SendStream` / `RecvStream` (as thin newtypes, not quinn-welded
|
||||||
enums).
|
enums).
|
||||||
- **The two-pump pattern** (ADR-078) — preserved. Tunnel/SSH handlers
|
- **The two-pump pattern** (alknet ADR-078, provenance — in-crate: ADR-050) — preserved. Tunnel/SSH handlers
|
||||||
call `tokio::io::split(bidi)` for their two pump halves; the
|
call `tokio::io::split(bidi)` for their two pump halves; the
|
||||||
shutdown-on-completion contract applies to the `ReadHalf` /
|
shutdown-on-completion contract applies to the `ReadHalf` /
|
||||||
`WriteHalf` unchanged.
|
`WriteHalf` unchanged.
|
||||||
@@ -436,7 +436,7 @@ redesign.
|
|||||||
lines of WS-specific dispatch glue are removed from
|
lines of WS-specific dispatch glue are removed from
|
||||||
`alknet-http/websocket/upgrade.rs`. The WS session runs through
|
`alknet-http/websocket/upgrade.rs`. The WS session runs through
|
||||||
`Connection::from_bidi` + the call-protocol handler like any other
|
`Connection::from_bidi` + the call-protocol handler like any other
|
||||||
transport. ADR-044/048's "WS message stream is `BiStream`-satisfying"
|
transport. alknet ADR-044/048's (alknet space, provenance) "WS message stream is `BiStream`-satisfying"
|
||||||
becomes literal.
|
becomes literal.
|
||||||
- One abstraction (`BiStream`) replaces five. The leaf type matches the
|
- One abstraction (`BiStream`) replaces five. The leaf type matches the
|
||||||
ecosystem convention (`russh::Channel::into_stream()`, `TcpStream`,
|
ecosystem convention (`russh::Channel::into_stream()`, `TcpStream`,
|
||||||
@@ -550,13 +550,13 @@ compilable:
|
|||||||
`Connection::from_bidi` + the call-protocol handler. This is the
|
`Connection::from_bidi` + the call-protocol handler. This is the
|
||||||
largest single change and can land after (1)-(3) — the WS path is
|
largest single change and can land after (1)-(3) — the WS path is
|
||||||
independent of the handler call-site updates.
|
independent of the handler call-site updates.
|
||||||
5. **Update ADR-007, ADR-008, ADR-038, ADR-077** to reflect the
|
5. **Update ADR-007, ADR-008, ADR-038, alknet ADR-077 (provenance)** to reflect the
|
||||||
`BiStream` return shape. ADR-007's `from_stream` constructor is
|
`BiStream` return shape. ADR-007's `from_stream` constructor is
|
||||||
removed; `from_bidi` is the only public stream constructor (the
|
removed; `from_bidi` is the only public stream constructor (the
|
||||||
rule: the split never crosses a crate boundary as part of a
|
rule: the split never crosses a crate boundary as part of a
|
||||||
constructor). ADR-008's `accept_bi` return type is amended. ADR-038's
|
constructor). ADR-008's `accept_bi` return type is amended. ADR-038's
|
||||||
`ChannelBidiStreamSource::accept_bi` return type is amended;
|
`ChannelBidiStreamSource::accept_bi` return type is amended;
|
||||||
`into_sub_streams()` is unchanged. ADR-077's two-mode TTY design is
|
`into_sub_streams()` is unchanged. alknet ADR-077's (alknet space, provenance) two-mode TTY design is
|
||||||
unchanged (the modes differ in *how* the adapter gets sub-streams,
|
unchanged (the modes differ in *how* the adapter gets sub-streams,
|
||||||
not in the leaf type).
|
not in the leaf type).
|
||||||
6. **Update `findings.md` Phase 6.** The "deferred" status is
|
6. **Update `findings.md` Phase 6.** The "deferred" status is
|
||||||
@@ -602,12 +602,12 @@ fix (unify the leaf so the wrapper moves into core).
|
|||||||
`from_source` extension point are preserved)
|
`from_source` extension point are preserved)
|
||||||
- ADR-038: `ChannelBidiStreamSource` (amended — `accept_bi` returns
|
- ADR-038: `ChannelBidiStreamSource` (amended — `accept_bi` returns
|
||||||
`BiStream`; `into_sub_streams()` is unchanged)
|
`BiStream`; `into_sub_streams()` is unchanged)
|
||||||
- ADR-077: TTY inside channels (unchanged — the two-mode design is
|
- alknet ADR-077 (provenance): TTY inside channels (unchanged — the two-mode design is
|
||||||
preserved; the modes differ in how the adapter gets sub-streams,
|
preserved; the modes differ in how the adapter gets sub-streams,
|
||||||
not in the leaf type)
|
not in the leaf type)
|
||||||
- ADR-078: two-pump shutdown-on-completion (unchanged — the contract
|
- alknet ADR-078 (provenance — in-crate: ADR-050): two-pump shutdown-on-completion (unchanged — the contract
|
||||||
applies to `tokio::io::split(bidi)` halves)
|
applies to `tokio::io::split(bidi)` halves)
|
||||||
- ADR-044, ADR-048: WebSocket is the v1 browser bidirectional path
|
- alknet ADR-044/048 (alknet space, provenance): WebSocket is the v1 browser bidirectional path
|
||||||
(this ADR makes the "WS message stream is `BiStream`-satisfying"
|
(this ADR makes the "WS message stream is `BiStream`-satisfying"
|
||||||
claim literal)
|
claim literal)
|
||||||
- `docs/research/alknet-crate-extraction/findings.md` Phase 6 — the
|
- `docs/research/alknet-crate-extraction/findings.md` Phase 6 — the
|
||||||
|
|||||||
+7
-7
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted (resolves OQ-42; amends the `AccessControl::check` signature and
|
Accepted (resolves alknet OQ-42 — source-repo space, provenance; the question is retracked in-crate as OQ-30 — amends the `AccessControl::check` signature and
|
||||||
adds `OperationSpec.resource_id_path`. Does not amend ADR-017 or ADR-018 —
|
adds `OperationSpec.resource_id_path`. Does not amend ADR-017 or ADR-018 —
|
||||||
specific #4 confirms the composition authority stays static. Blocks lifted:
|
specific #4 confirms the composition authority stays static. Blocks lifted:
|
||||||
the alknet-docker, alknet-tty, opencode-runner wrapper, and
|
the alknet-docker, alknet-tty, opencode-runner wrapper, and
|
||||||
@@ -36,7 +36,7 @@ None of those crate specs can declare their `AccessControl` shapes until
|
|||||||
the core model answers: **how does `AccessControl::check` learn whether
|
the core model answers: **how does `AccessControl::check` learn whether
|
||||||
identity X owns runtime-spawned resource R?**
|
identity X owns runtime-spawned resource R?**
|
||||||
|
|
||||||
OQ-42 tracked this. The OQ resolved five sub-questions:
|
alknet OQ-42 (source-repo space, provenance; in-crate: OQ-30) tracked this. The OQ resolved five sub-questions:
|
||||||
|
|
||||||
1. Storage shape — reuse the repo/adapter pattern (ADR-033).
|
1. Storage shape — reuse the repo/adapter pattern (ADR-033).
|
||||||
2. Integration point — `AccessControl::check` consults an ownership
|
2. Integration point — `AccessControl::check` consults an ownership
|
||||||
@@ -88,7 +88,7 @@ model has no path from that knowledge to `check`.
|
|||||||
|
|
||||||
### The options considered
|
### The options considered
|
||||||
|
|
||||||
Three integration points were considered (see OQ-42 for the full
|
Three integration points were considered (see alknet OQ-42, in-crate OQ-30, for the full
|
||||||
reasoning):
|
reasoning):
|
||||||
|
|
||||||
- **Option 1 — augment `Identity.resources` with a per-request snapshot.**
|
- **Option 1 — augment `Identity.resources` with a per-request snapshot.**
|
||||||
@@ -592,7 +592,7 @@ provider confirms it owns the container (dynamic).
|
|||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- OQ-42: Dynamic Resource Ownership for Runtime-Spawned Resources
|
- alknet OQ-42 (provenance; in-crate: OQ-30): Dynamic Resource Ownership for Runtime-Spawned Resources
|
||||||
(resolved by this ADR — the five sub-questions this ADR writes into
|
(resolved by this ADR — the five sub-questions this ADR writes into
|
||||||
decision text)
|
decision text)
|
||||||
- ADR-003: Auth as Shared Core (`IdentityProvider` — the first instance
|
- ADR-003: Auth as Shared Core (`IdentityProvider` — the first instance
|
||||||
@@ -625,9 +625,9 @@ provider confirms it owns the container (dynamic).
|
|||||||
- ADR-022: Call Protocol Client and Adapter Contract (`from_call` — the
|
- ADR-022: Call Protocol Client and Adapter Contract (`from_call` — the
|
||||||
adapter that imports a peer's operations and re-registers them locally;
|
adapter that imports a peer's operations and re-registers them locally;
|
||||||
the proxy pattern's re-export mechanism)
|
the proxy pattern's re-export mechanism)
|
||||||
- [auth.md](../crates/core/auth.md) (`Identity.resources`,
|
- [auth.md](/workspace/@alkdev/alknet/docs/architecture/crates/core/auth.md) (`Identity.resources`,
|
||||||
`AccessControl::check` interaction — both under edit by this decision)
|
`AccessControl::check` interaction — both under edit by this decision)
|
||||||
- [operation-registry.md](../crates/call/operation-registry.md)
|
- [operation-registry.md](../operation-registry.md)
|
||||||
(`AccessControl`, `OperationSpec` — `resource_id_path` addition)
|
(`AccessControl`, `OperationSpec` — `resource_id_path` addition)
|
||||||
- [alknet-docker POC summary](../../research/alknet-docker/poc-summary.md)
|
- [alknet-docker POC summary](/workspace/@alkdev/alknet/docs/research/alknet-docker/poc-summary.md)
|
||||||
§"Open Unknowns" #3 (the research finding that surfaced this question)
|
§"Open Unknowns" #3 (the research finding that surfaced this question)
|
||||||
@@ -79,7 +79,7 @@ were removed in commit `668d777` (2026-07-09). `irpc` may be re-added as
|
|||||||
`iroh-blobs 0.103`), but that would be a *transitive* dependency of
|
`iroh-blobs 0.103`), but that would be a *transitive* dependency of
|
||||||
`alknet-blobs`, not a direct dependency of `alknet-call` — alknet-call does
|
`alknet-blobs`, not a direct dependency of `alknet-call` — alknet-call does
|
||||||
not import irpc and has no plans to. See
|
not import irpc and has no plans to. See
|
||||||
[`docs/research/transport-generalization/findings.md`](../../research/transport-generalization/findings.md)
|
[`docs/research/transport-generalization/findings.md`](/workspace/@alkdev/alknet/docs/research/transport-generalization/findings.md)
|
||||||
§3.1 for the removal trace.
|
§3.1 for the removal trace.
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
@@ -159,6 +159,6 @@ not import irpc and has no plans to. See
|
|||||||
dispatch path)
|
dispatch path)
|
||||||
- Call protocol wire format: `crates/alknet-call/src/protocol/wire.rs`
|
- Call protocol wire format: `crates/alknet-call/src/protocol/wire.rs`
|
||||||
- Transport generalization findings:
|
- Transport generalization findings:
|
||||||
[`docs/research/transport-generalization/findings.md`](../../research/transport-generalization/findings.md)
|
[`docs/research/transport-generalization/findings.md`](/workspace/@alkdev/alknet/docs/research/transport-generalization/findings.md)
|
||||||
§3.1 (dead `irpc` dep removal)
|
§3.1 (dead `irpc` dep removal)
|
||||||
- Removal commit: `668d777` (2026-07-09)
|
- Removal commit: `668d777` (2026-07-09)
|
||||||
@@ -2,23 +2,30 @@
|
|||||||
|
|
||||||
## Status
|
## 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
|
## Context
|
||||||
|
|
||||||
The `OperationSpec` in alknet-call has `input_schema` and `output_schema` but
|
The `OperationSpec` in alknet-call has `input_schema` and `output_schema` but
|
||||||
no `error_schemas`. The `call.error` payload (call-protocol.md L128–134)
|
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`,
|
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,
|
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
|
reject the wrong dispatch method for the operation type, time out, hit an
|
||||||
an internal error. They are emitted by the dispatch machinery (the registry,
|
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
|
the adapter), not by operation handlers. `INVALID_OPERATION_TYPE` was added
|
||||||
by ADR-021 (streaming handler for subscriptions — `invoke()` called on a
|
by ADR-021 (streaming handler for subscriptions — `invoke()` called on a
|
||||||
`Subscription`, or `invoke_streaming()` on a `Query`/`Mutation`).
|
`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:
|
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
|
### 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:
|
machinery, not by handlers:
|
||||||
|
|
||||||
| Code | Emitted by | Meaning |
|
| 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) |
|
| `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 |
|
| `INTERNAL` | Registry / Adapter | Handler panic, unhandled error, connection failure |
|
||||||
| `TIMEOUT` | Adapter | Request timed out |
|
| `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
|
Operation-level domain codes are emitted by **handlers** — the operation's
|
||||||
own logic determines what went wrong. They are declared in `error_schemas`
|
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
|
**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`,
|
`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`,
|
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
|
`HTTP_429`) to avoid collision. This is a requirement for the adapter, not
|
||||||
a naming convention — the `from_openapi` example above was previously shown
|
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
|
- ADR-021: Streaming handler for subscriptions (amends this ADR's
|
||||||
protocol-level code list — `INVALID_OPERATION_TYPE` added as the sixth
|
protocol-level code list — `INVALID_OPERATION_TYPE` added as the sixth
|
||||||
protocol-level code)
|
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
|
- docs/sdd_process.md L19, L423 (Safe Exit protocol — the general principle
|
||||||
of making failure typed and declared)
|
of making failure typed and declared)
|
||||||
- TypeScript reference: `/workspace/@alkdev/operations/src/types.ts`
|
- TypeScript reference: `/workspace/@alkdev/operations/src/types.ts`
|
||||||
|
|||||||
@@ -302,8 +302,8 @@ Principle of least privilege.
|
|||||||
- OQ-17: Abort cascade (the call tree is the agency chain — `parent_request_id`
|
- OQ-17: Abort cascade (the call tree is the agency chain — `parent_request_id`
|
||||||
traces principal → agent)
|
traces principal → agent)
|
||||||
- OQ-19: Session-scoped registries (session operations are always `Internal`)
|
- OQ-19: Session-scoped registries (session operations are always `Internal`)
|
||||||
- [operation-registry.md](../crates/call/operation-registry.md)
|
- [operation-registry.md](../operation-registry.md)
|
||||||
- [call-protocol.md](../crates/call/call-protocol.md)
|
- [call-protocol.md](../call-protocol.md)
|
||||||
- TypeScript `@alkdev/operations` `buildEnv()` with `allowedNamespaces` — prior
|
- TypeScript `@alkdev/operations` `buildEnv()` with `allowedNamespaces` — prior
|
||||||
art for scoped composition env
|
art for scoped composition env
|
||||||
- POC at `/workspace/toolEnv` — demonstrated the sandbox-to-registry bridge with
|
- POC at `/workspace/toolEnv` — demonstrated the sandbox-to-registry bridge with
|
||||||
|
|||||||
@@ -253,5 +253,5 @@ not through the wire and not through `OperationSpec`.
|
|||||||
tree and participate in cascade)
|
tree and participate in cascade)
|
||||||
- `@alkdev/flowgraph` TypeScript package — prior art for call-graph tracking
|
- `@alkdev/flowgraph` TypeScript package — prior art for call-graph tracking
|
||||||
with `descendants()`, `FailurePolicy`, reactive status propagation
|
with `descendants()`, `FailurePolicy`, reactive status propagation
|
||||||
- [call-protocol.md](../crates/call/call-protocol.md)
|
- [call-protocol.md](../call-protocol.md)
|
||||||
- [operation-registry.md](../crates/call/operation-registry.md)
|
- [operation-registry.md](../operation-registry.md)
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Status
|
## 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`; 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)
|
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; amendment 2026-10-13 — announced Sub/Pub forward-through and marked-spec refusal (review 010 S-04), see "Amendment (2026-10-13)" below)
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
@@ -349,9 +349,9 @@ same as `from_openapi` receives HTTP credentials.
|
|||||||
- OQ-25..28: Two-way-door remainders from the call-completion gap analysis
|
- OQ-25..28: Two-way-door remainders from the call-completion gap analysis
|
||||||
(DC-1 shape, DC-4 error type, DC-2 re-import trigger, DC-3 namespace
|
(DC-1 shape, DC-4 error type, DC-2 re-import trigger, DC-3 namespace
|
||||||
collision — see [open-questions.md](../open-questions.md))
|
collision — see [open-questions.md](../open-questions.md))
|
||||||
- [call-protocol.md](../crates/call/call-protocol.md)
|
- [call-protocol.md](../call-protocol.md)
|
||||||
- [operation-registry.md](../crates/call/operation-registry.md)
|
- [operation-registry.md](../operation-registry.md)
|
||||||
- [client-and-adapters.md](../crates/call/client-and-adapters.md) — the spec
|
- [client-and-adapters.md](../client-and-adapters.md) — the spec
|
||||||
that operationally fills the gap this ADR left to implementation
|
that operationally fills the gap this ADR left to implementation
|
||||||
- `docs/research/alknet-call-completion/gap-analysis.md` — DC-1..4, the
|
- `docs/research/alknet-call-completion/gap-analysis.md` — DC-1..4, the
|
||||||
decisions that needed resolution before implementation
|
decisions that needed resolution before implementation
|
||||||
@@ -402,12 +402,32 @@ surfaces discovery failure as `AdapterError::DiscoveryFailed`).
|
|||||||
`ChannelClient::from_connection_with_serving(connection,
|
`ChannelClient::from_connection_with_serving(connection,
|
||||||
Option<ServingConfig>)` — with `None` (the pure-consumer default) the
|
Option<ServingConfig>)` — with `None` (the pure-consumer default) the
|
||||||
read pump resolves outbound pendings only (previous behavior). With
|
read pump resolves outbound pendings only (previous behavior). With
|
||||||
`Some(ServingConfig { registry, identity_provider })` the read pump
|
`Some(ServingConfig { registry, identity_provider, identity })` the
|
||||||
becomes the full-duplex serving loop (`Dispatcher::serve_single_stream`):
|
read pump becomes the full-duplex serving loop
|
||||||
inbound `call.requested` frames dispatch against the configured
|
(`Dispatcher::serve_single_stream`): inbound `call.requested` frames
|
||||||
registry and resolve back to the peer; outbound pendings still resolve
|
dispatch against the configured registry and resolve back to the peer;
|
||||||
in the same loop. Serving is opt-in because a pure consumer has no
|
outbound pendings still resolve in the same loop. Serving is opt-in
|
||||||
registry to serve; the *protocol* is symmetric, the *API* is explicit.
|
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
|
Direction disambiguation in the loop is by table membership, not
|
||||||
framing: an id that is one of *our* outbound pendings resolves there;
|
framing: an id that is one of *our* outbound pendings resolves there;
|
||||||
@@ -504,7 +524,7 @@ overlays), not by a parallel boolean gate.
|
|||||||
§5 showed `async fn import(&self) -> Vec<HandlerRegistration>` with no error
|
§5 showed `async fn import(&self) -> Vec<HandlerRegistration>` with no error
|
||||||
type. The trait returns `Result<Vec<HandlerRegistration>, AdapterError>`
|
type. The trait returns `Result<Vec<HandlerRegistration>, AdapterError>`
|
||||||
where `AdapterError` is a crate-level enum. The *presence* of the error type
|
where `AdapterError` is a crate-level enum. The *presence* of the error type
|
||||||
is recorded in [client-and-adapters.md](../crates/call/client-and-adapters.md);
|
is recorded in [client-and-adapters.md](../client-and-adapters.md);
|
||||||
the exact variants are the two-way-door remainder, tracked as OQ-26.
|
the exact variants are the two-way-door remainder, tracked as OQ-26.
|
||||||
|
|
||||||
### DC-2 — from_call re-import on reconnection: manual free function
|
### DC-2 — from_call re-import on reconnection: manual free function
|
||||||
@@ -517,7 +537,7 @@ overlay dies with the connection. A `CallConnection::refresh()` method for
|
|||||||
mid-connection re-discovery is a genuine feature addition — non-breaking,
|
mid-connection re-discovery is a genuine feature addition — non-breaking,
|
||||||
additive — if a deployment needs manual re-discovery without
|
additive — if a deployment needs manual re-discovery without
|
||||||
drop-and-reconnect. Two-way door; recorded in
|
drop-and-reconnect. Two-way door; recorded in
|
||||||
[client-and-adapters.md](../crates/call/client-and-adapters.md); tracked as
|
[client-and-adapters.md](../client-and-adapters.md); tracked as
|
||||||
OQ-27. See [ADR-028](028-from-call-manual-free-function.md) for the full
|
OQ-27. See [ADR-028](028-from-call-manual-free-function.md) for the full
|
||||||
rationale.
|
rationale.
|
||||||
|
|
||||||
@@ -527,7 +547,7 @@ rationale.
|
|||||||
collision = error**. A node importing from two remotes that both expose the
|
collision = error**. A node importing from two remotes that both expose the
|
||||||
same unprefixed op name should fail loudly. The operator adds prefixes when
|
same unprefixed op name should fail loudly. The operator adds prefixes when
|
||||||
importing from multiple sources. Two-way door; recorded in
|
importing from multiple sources. Two-way door; recorded in
|
||||||
[client-and-adapters.md](../crates/call/client-and-adapters.md); tracked as
|
[client-and-adapters.md](../client-and-adapters.md); tracked as
|
||||||
OQ-28.
|
OQ-28.
|
||||||
|
|
||||||
### Operational spec
|
### Operational spec
|
||||||
@@ -536,7 +556,7 @@ The gap this ADR left to implementation — the `CallClient` API, the
|
|||||||
`from_call` flow, the trait signature, the adapter location map, the
|
`from_call` flow, the trait signature, the adapter location map, the
|
||||||
no-env-vars invariant, and the exchange-of-operations pattern — is
|
no-env-vars invariant, and the exchange-of-operations pattern — is
|
||||||
specified in
|
specified in
|
||||||
[client-and-adapters.md](../crates/call/client-and-adapters.md). That document
|
[client-and-adapters.md](../client-and-adapters.md). That document
|
||||||
is the operational complement to this ADR; this ADR remains the architectural
|
is the operational complement to this ADR; this ADR remains the architectural
|
||||||
authority.
|
authority.
|
||||||
|
|
||||||
@@ -556,7 +576,7 @@ served by consuming `OperationSpec` directly. The §5 "adapters live in
|
|||||||
alknet-call" one-way-door statement is corrected above to "the adapter
|
alknet-call" one-way-door statement is corrected above to "the adapter
|
||||||
trait lives in `alknet-call`; implementations live with their transport."
|
trait lives in `alknet-call`; implementations live with their transport."
|
||||||
See [ADR-027](027-from-jsonschema-as-http-adapter.md) and
|
See [ADR-027](027-from-jsonschema-as-http-adapter.md) and
|
||||||
[client-and-adapters.md](../crates/call/client-and-adapters.md) §"from_jsonschema".
|
[client-and-adapters.md](../client-and-adapters.md) §"from_jsonschema".
|
||||||
|
|
||||||
## Amendments (2026-07-13)
|
## Amendments (2026-07-13)
|
||||||
|
|
||||||
@@ -565,7 +585,7 @@ See [ADR-027](027-from-jsonschema-as-http-adapter.md) and
|
|||||||
The §1 Decision framed `CallClient::connect(addr: SocketAddr,
|
The §1 Decision framed `CallClient::connect(addr: SocketAddr,
|
||||||
credentials)` as the primary constructor and described it as "opens a
|
credentials)` as the primary constructor and described it as "opens a
|
||||||
QUIC connection." The operational spec
|
QUIC connection." The operational spec
|
||||||
([client-and-adapters.md](../crates/call/client-and-adapters.md))
|
([client-and-adapters.md](../client-and-adapters.md))
|
||||||
framed `spawn_dispatch(connection)` as the "lower-level API" that
|
framed `spawn_dispatch(connection)` as the "lower-level API" that
|
||||||
`connect()` uses after the QUIC dial. That framing welded the
|
`connect()` uses after the QUIC dial. That framing welded the
|
||||||
client-side one-way-door API to QUIC — the same welding ADR-007
|
client-side one-way-door API to QUIC — the same welding ADR-007
|
||||||
@@ -597,9 +617,64 @@ existing code (which already has the right structure —
|
|||||||
The door-type classification is updated: `spawn_dispatch` is one-way
|
The door-type classification is updated: `spawn_dispatch` is one-way
|
||||||
(the handler-facing surface); ~~`connect` is two-way (additive
|
(the handler-facing surface); ~~`connect` is two-way (additive
|
||||||
convenience)~~ `connect` is **removed** (ADR-045 §5). The
|
convenience)~~ `connect` is **removed** (ADR-045 §5). The
|
||||||
`AlknetClient` extraction (OQ-55) is **resolved** by ADR-045 — the
|
`AlknetClient` extraction (alknet OQ-55) is **resolved** by ADR-045 — the
|
||||||
shared dial seam is `alknet-client`; `spawn_dispatch` is the
|
shared dial seam is `alknet-client`; `spawn_dispatch` is the
|
||||||
protocol-crate take-over that consumes the dial's `Connection`.
|
protocol-crate take-over that consumes the dial's `Connection`.
|
||||||
|
|
||||||
See [client-and-adapters.md](../crates/call/client-and-adapters.md)
|
See [client-and-adapters.md](../client-and-adapters.md)
|
||||||
§"CallClient" for the reframed operational spec.
|
§"CallClient" for the reframed operational spec.
|
||||||
|
|
||||||
|
## Amendment (2026-10-13 — review 010 S-04: announced Sub/Pub forward-through, marked-spec refusal)
|
||||||
|
|
||||||
|
`op/register`'s announced Sub/Pub handling shipped as invocation-failing
|
||||||
|
stubs (`INVALID_OPERATION_TYPE` on every invocation of an advertised,
|
||||||
|
discoverable op — review 010 S-04, nine published versions). The
|
||||||
|
amendment replaces the stubs with the real forwarding machinery and
|
||||||
|
closes the marked-spec hole the stubs sat in:
|
||||||
|
|
||||||
|
1. **Unmarked `Sub` announcements** register as real
|
||||||
|
`HandlerKind::Stream` handlers
|
||||||
|
(`make_streaming_forwarding_handler`) — a nested composition that
|
||||||
|
cannot invoke them anyway (`env.invoke` is request/response-only,
|
||||||
|
ADR-046 §6), but the overlay now carries a genuine working forward
|
||||||
|
of the remote stream, not an advertised-always-failing body.
|
||||||
|
2. **Unmarked `Pub` announcements** register as real
|
||||||
|
`HandlerKind::Sink` handlers (`make_sink_forwarding_handler`) —
|
||||||
|
same posture; the shapes already exist in-tree (`from_call`'s own
|
||||||
|
import path).
|
||||||
|
3. **Marked (`channel_open`) announcements — any type — are refused at
|
||||||
|
`op/register`** with a typed error (`MARKED_SPEC_UNSUPPORTED`):
|
||||||
|
marked ops are channel-machinery ops by their own marker contract
|
||||||
|
(ADR-047 §2 — the data plane is the allocated channel's
|
||||||
|
`BiStream`). Forwarding a marked op is incoherent by the marker's
|
||||||
|
own semantics — a `Sub`-side forward would deliver the
|
||||||
|
`{channel_id}` ack of a channel that exists only on the remote
|
||||||
|
side; a `Pub`-side forward would stream call-plane chunks into a
|
||||||
|
surface alkcall's amendment 4 rejects. The announcing peer's
|
||||||
|
sanctioned compositions are the relay
|
||||||
|
(`ChannelRelay::register_relay_openable`) for a hub, and direct
|
||||||
|
consumption (`ChannelClient`) otherwise. The same refusal applies
|
||||||
|
to overlay imports generally: `CallConnection::register_imported`
|
||||||
|
and the generic registration path refuse marked specs that did not
|
||||||
|
come through the channel-machinery seam (ADR-047 amendment 4
|
||||||
|
§4.8), so a marked spec can no longer land anywhere as an
|
||||||
|
advertised-always-failing or advertised-but-unusable body.
|
||||||
|
|
||||||
|
The stub-era rationale ("nested composition is request/response-only;
|
||||||
|
the streaming/sink forwarding shapes ride on the `from_call` import
|
||||||
|
path") explained why the *stub body* existed, not why the *announcement
|
||||||
|
was accepted*; it was reviewed and rejected in review 010 (S-04) — the
|
||||||
|
rationale may justify not implementing nested streaming composition,
|
||||||
|
but not accepting and advertising an announcement that cannot serve it.
|
||||||
|
|
||||||
|
Door type: one-way. The refusal code and the marked-spec overlay
|
||||||
|
invariant are wire-visible (announcing peers observe the rejection) and
|
||||||
|
join the stable error set per ADR-016. The handler-kind mapping for
|
||||||
|
unmarked announcements reuses the existing forwarding shapes — no new
|
||||||
|
surface.
|
||||||
|
|
||||||
|
References: review 010 S-04 (the stub),
|
||||||
|
`src/registry/op_register.rs` (the handler), ADR-047 amendment 4 §4.8
|
||||||
|
(the marked-spec registration invariant this enforces on the wire
|
||||||
|
side), ADR-046 §6 (the composition boundary), ADR-051 (the relay
|
||||||
|
composition marked announcements are refused in favor of).
|
||||||
@@ -270,7 +270,7 @@ There are three credential types in the alknet auth model:
|
|||||||
2. **X.509 cert** — for domain-facing endpoints (`api.alk.dev`, relays,
|
2. **X.509 cert** — for domain-facing endpoints (`api.alk.dev`, relays,
|
||||||
ACME/Let's Encrypt). Fingerprint format: `SHA256:<hex of DER>`. Requires
|
ACME/Let's Encrypt). Fingerprint format: `SHA256:<hex of DER>`. Requires
|
||||||
CA verification on the client side. The outgoing-only case (a client
|
CA verification on the client side. The outgoing-only case (a client
|
||||||
connects to a public X.509 endpoint) is tracked as OQ-37.
|
connects to a public X.509 endpoint) is tracked as OQ-21.
|
||||||
|
|
||||||
3. **Bearer token** (auth_token) — for HTTP clients that can't do TLS
|
3. **Bearer token** (auth_token) — for HTTP clients that can't do TLS
|
||||||
client-auth (browsers, curl), or as a secondary credential path. Carried
|
client-auth (browsers, curl), or as a secondary credential path. Carried
|
||||||
@@ -427,8 +427,9 @@ rotation (`PeerEntry`) or not (`ApiKeyEntry`).
|
|||||||
paths, and `ApiKeyEntry` is for tokens that ARE the identity)
|
paths, and `ApiKeyEntry` is for tokens that ARE the identity)
|
||||||
- OQ-29: CallClient TLS Client-Auth (resolved by this ADR's §6 fingerprint
|
- OQ-29: CallClient TLS Client-Auth (resolved by this ADR's §6 fingerprint
|
||||||
normalization + the client-auth wiring decision recorded in OQ-29)
|
normalization + the client-auth wiring decision recorded in OQ-29)
|
||||||
- OQ-37: X.509 outgoing-only case (the three auth types and how X.509
|
- OQ-21: X.509 outgoing-only case (the three auth types and how X.509
|
||||||
server identity fits — see OQ-37 in open-questions.md)
|
server identity fits — see OQ-21 in open-questions.md; the deciding
|
||||||
|
ADR, alknet ADR-034, was not ported into alkcall)
|
||||||
- `docs/research/alknet-storage-strategy/findings.md` §4 (the `PeerEntry`
|
- `docs/research/alknet-storage-strategy/findings.md` §4 (the `PeerEntry`
|
||||||
model and resolution path)
|
model and resolution path)
|
||||||
- `docs/architecture/crates/core/auth.md` (the spec amended by this ADR)
|
- `docs/architecture/crates/core/auth.md` (the spec amended by this ADR)
|
||||||
|
|||||||
@@ -178,3 +178,15 @@ endpoint.
|
|||||||
established by `from_openapi`.
|
established by `from_openapi`.
|
||||||
- Reuses `HttpServiceConfig` and `SharedHttpClient` from
|
- Reuses `HttpServiceConfig` and `SharedHttpClient` from
|
||||||
`from_openapi` (in `alknet-http`).
|
`from_openapi` (in `alknet-http`).
|
||||||
|
|
||||||
|
## Amendment 2026-10-13: name normalization (`alknet-http` → `alkhttp`)
|
||||||
|
|
||||||
|
This ADR's decision text was authored when the HTTP host crate was
|
||||||
|
named `alknet-http` inside the alknet mono-repo. The HTTP host is now
|
||||||
|
the standalone sibling crate **`alkhttp`**, which wraps alkcall
|
||||||
|
(extraction context: ADR-031; the adapter-location rule is unchanged —
|
||||||
|
the trait lives in alkcall, HTTP-backed adapters live in `alkhttp`).
|
||||||
|
The title's "in alknet-http" and the body's crate-name references
|
||||||
|
should be read as "in `alkhttp`"; the References below link to the
|
||||||
|
source-repo originals (provenance). The `FromJsonSchema` provenance
|
||||||
|
variant remains in alkcall.
|
||||||
@@ -123,7 +123,7 @@ implementations are independent. The Amendment 1 protocol-foundation
|
|||||||
exception remains for alknet-http/agent/napi (which use alknet-call's
|
exception remains for alknet-http/agent/napi (which use alknet-call's
|
||||||
`OperationSpec`/`Handler`/`OperationAdapter` types — actual type reuse,
|
`OperationSpec`/`Handler`/`OperationAdapter` types — actual type reuse,
|
||||||
not framing glue); it no longer covers alknet-tty. See
|
not framing glue); it no longer covers alknet-tty. See
|
||||||
[ADR-057](057-alknet-tty-no-alknet-call-dep.md) for the full decision
|
[ADR-057](/workspace/@alkdev/alknet/docs/architecture/decisions/057-alknet-tty-no-alknet-call-dep.md) for the full decision
|
||||||
and the three options considered (duplicate / promote to core / use
|
and the three options considered (duplicate / promote to core / use
|
||||||
alknet-call).
|
alknet-call).
|
||||||
|
|
||||||
@@ -141,3 +141,30 @@ dep were removed in commit `668d777`. See
|
|||||||
[ADR-014](014-irpc-never-integrated-hand-rolled-framing.md) for the full
|
[ADR-014](014-irpc-never-integrated-hand-rolled-framing.md) for the full
|
||||||
record (ADR-013, which accepted "irpc as the call protocol foundation," is
|
record (ADR-013, which accepted "irpc as the call protocol foundation," is
|
||||||
superseded).
|
superseded).
|
||||||
|
### Amendment 4 (2026-10-13): post-extraction state — alkcall is the unified protocol crate; sibling names normalized
|
||||||
|
|
||||||
|
The decomposition this ADR decided has been executed; this section
|
||||||
|
records where the named crates now live, so the decision text's
|
||||||
|
mono-repo framing reads as history:
|
||||||
|
|
||||||
|
- **alkcall** (this crate) is the unified extraction of `alknet-call`
|
||||||
|
+ `alknet-channels` + the vendored core types (the `alknet-core`
|
||||||
|
surfaces this crate's protocol depends on: `Connection`,
|
||||||
|
`BiStream`, `ProtocolHandler`, `AuthContext`, `IdentityProvider`,
|
||||||
|
`AuthToken`, `Capabilities`, `OwnershipProvider`, `HandlerError`,
|
||||||
|
`StreamError`). Two subsystems: `registry`/`protocol` (call) and the
|
||||||
|
channels subsystem (ADR-044's `channels-core`/`channels-call` split
|
||||||
|
landed as modules here).
|
||||||
|
- **Sibling host crates** (now standalone, all depending on alkcall):
|
||||||
|
`alknet-http` → `alkhttp`; `alknet-tls` → `alktls`;
|
||||||
|
`alknet-tty` → `alktty`; `alknet-tunnels` → `alktunnels`;
|
||||||
|
`alknet-trader` → `alktrader`.
|
||||||
|
- **The mono-repo (alknet) is being replaced** by thin glue over
|
||||||
|
alkcall and its siblings; its remaining roles (dial/accept helper
|
||||||
|
`AlknetClient`, endpoint layer, hub composition) move to the
|
||||||
|
composition layer — post-replacement owner and shape tracked as
|
||||||
|
OQ-43 in this crate's question tracker.
|
||||||
|
- The protocol-foundation rule (Amendment 1) carries over unchanged
|
||||||
|
with current names: `alkhttp` depends on alkcall for operation
|
||||||
|
registry / adapter / client types; peer-handler crates (e.g.
|
||||||
|
`alktty`, `alktunnels`, `alktrader`) depend on alkcall only.
|
||||||
@@ -22,7 +22,7 @@ JSON, tunnel's raw bytes, SSH's channel protocol).
|
|||||||
|
|
||||||
The stream_type decomposition (unidirectional halves, mod 3 formula, 85
|
The stream_type decomposition (unidirectional halves, mod 3 formula, 85
|
||||||
groups) is **removed from the channels layer**. The stream_type concept
|
groups) is **removed from the channels layer**. The stream_type concept
|
||||||
survives in TTY's 5-byte format (ADR-052, amended by Phase 7), which the
|
survives in the TTY 5-byte format (alknet ADR-052, provenance — amended by Phase 7), which the
|
||||||
channels layer carries transparently in its payload. The total header
|
channels layer carries transparently in its payload. The total header
|
||||||
for a TTY chunk inside channels is 13 bytes (8 channels + 5 TTY), not
|
for a TTY chunk inside channels is 13 bytes (8 channels + 5 TTY), not
|
||||||
9; the two length fields are close but not identical
|
9; the two length fields are close but not identical
|
||||||
@@ -47,7 +47,7 @@ Two prior formats inform this design:
|
|||||||
1. **SSH's channel multiplexer (RFC 4254)** — `ChannelId(u32)` with
|
1. **SSH's channel multiplexer (RFC 4254)** — `ChannelId(u32)` with
|
||||||
string-named types negotiated per channel, all traffic interleaved on one
|
string-named types negotiated per channel, all traffic interleaved on one
|
||||||
encrypted transport stream.
|
encrypted transport stream.
|
||||||
2. **alknet-tty's chunk format (ADR-052)** — `[stream_type: u8][length: u32 be]
|
2. **alknet-tty's chunk format (alknet ADR-052, provenance)** — `[stream_type: u8][length: u32 be]
|
||||||
[payload]`, a fixed set of four sub-streams (stdin/stdout/stderr/control)
|
[payload]`, a fixed set of four sub-streams (stdin/stdout/stderr/control)
|
||||||
within one bidi stream. Validated by two POCs and in production code.
|
within one bidi stream. Validated by two POCs and in production code.
|
||||||
|
|
||||||
@@ -100,18 +100,18 @@ client→server) and 4 (read, server→client), not one "bidirectional" 3.
|
|||||||
```
|
```
|
||||||
|
|
||||||
9 bytes of header. The `channel_id` is the addition over TTY's 5-byte
|
9 bytes of header. The `channel_id` is the addition over TTY's 5-byte
|
||||||
format; `stream_type` and `length` are identical to TTY's fields (ADR-052),
|
format; `stream_type` and `length` are identical to TTY's fields (alknet ADR-052, provenance),
|
||||||
preserving the framing-disambiguation soundness property.
|
preserving the framing-disambiguation soundness property.
|
||||||
|
|
||||||
| field | width | meaning |
|
| field | width | meaning |
|
||||||
|-------|-------|---------|
|
|-------|-------|---------|
|
||||||
| `channel_id` | u32 BE | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alk/call` (ADR-036). Channels 1..N are opened dynamically via `channel/open`. |
|
| `channel_id` | u32 BE | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alk/call` (ADR-036). Channels 1..N are opened dynamically via `channel/open`. |
|
||||||
| `stream_type` | u8 | The unidirectional sub-stream within the channel. See "Stream types" below. |
|
| `stream_type` | u8 | The unidirectional sub-stream within the channel. See "Stream types" below. |
|
||||||
| `length` | u32 BE | The payload length in bytes. 0 = EOF sentinel (same convention as TTY — ADR-052 §Sentinels). |
|
| `length` | u32 BE | The payload length in bytes. 0 = EOF sentinel (same convention as TTY — alknet ADR-052 §Sentinels (provenance)). |
|
||||||
|
|
||||||
### `MAX_CHUNK_LEN`
|
### `MAX_CHUNK_LEN`
|
||||||
|
|
||||||
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (ADR-052 §5). A chunk with
|
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (alknet ADR-052 §5, provenance). A chunk with
|
||||||
`length > MAX_CHUNK_LEN` returns `ChunkTooLarge` and does not corrupt the
|
`length > MAX_CHUNK_LEN` returns `ChunkTooLarge` and does not corrupt the
|
||||||
stream — the demux drops the chunk and continues. The header is always
|
stream — the demux drops the chunk and continues. The header is always
|
||||||
exactly 9 bytes, so the demux can always resync by reading the next 9-byte
|
exactly 9 bytes, so the demux can always resync by reading the next 9-byte
|
||||||
@@ -214,7 +214,7 @@ the cost of uniformity. For the intended use cases (TTY, SSH, tunnels, call
|
|||||||
operations), this is noise. The high-throughput escape hatch is
|
operations), this is noise. The high-throughput escape hatch is
|
||||||
multi-connection, not stripping the header.
|
multi-connection, not stripping the header.
|
||||||
|
|
||||||
### Framing disambiguation (carried from ADR-052 §5)
|
### Framing disambiguation (carried from alknet ADR-052 §5, provenance)
|
||||||
|
|
||||||
Channel 0 is just another channel — its chunks have `channel_id=0` in the
|
Channel 0 is just another channel — its chunks have `channel_id=0` in the
|
||||||
header. Disambiguation between channel 0 (call protocol) and data channels
|
header. Disambiguation between channel 0 (call protocol) and data channels
|
||||||
@@ -225,7 +225,7 @@ first byte of a chunk payload from the server is unambiguous.
|
|||||||
### Zero-length sentinel = EOF
|
### Zero-length sentinel = EOF
|
||||||
|
|
||||||
A zero-length chunk is delivered as an empty `Bytes`, which the reassembled
|
A zero-length chunk is delivered as an empty `Bytes`, which the reassembled
|
||||||
stream interprets as EOF (same convention as TTY — ADR-052 §Sentinels). This
|
stream interprets as EOF (same convention as TTY — alknet ADR-052 §Sentinels (provenance)). This
|
||||||
is the clean-shutdown signal for a `(channel_id, stream_type)` pair.
|
is the clean-shutdown signal for a `(channel_id, stream_type)` pair.
|
||||||
|
|
||||||
### Sync core / async shell split
|
### Sync core / async shell split
|
||||||
@@ -251,7 +251,7 @@ tokio-dependent shell.
|
|||||||
- The hub relay correlates by `channel_id` in the header, uniformly across
|
- The hub relay correlates by `channel_id` in the header, uniformly across
|
||||||
substrates — no stream-ID-to-`channel_id` mapping per leg.
|
substrates — no stream-ID-to-`channel_id` mapping per leg.
|
||||||
- WASM-compatible by construction — the pure core has no platform deps.
|
- WASM-compatible by construction — the pure core has no platform deps.
|
||||||
- The framing-disambiguation property from ADR-052 carries forward unchanged.
|
- The framing-disambiguation property from alknet ADR-052 (provenance) carries forward unchanged.
|
||||||
|
|
||||||
**Negative:**
|
**Negative:**
|
||||||
- 9 bytes per chunk on all substrates, including QUIC where the transport
|
- 9 bytes per chunk on all substrates, including QUIC where the transport
|
||||||
@@ -288,7 +288,7 @@ within the one-way format.
|
|||||||
wire format is 8 bytes, not 9; `stream_type` removed from the channels
|
wire format is 8 bytes, not 9; `stream_type` removed from the channels
|
||||||
header; the stream_type decomposition is removed from the channels
|
header; the stream_type decomposition is removed from the channels
|
||||||
layer; the handler owns its sub-stream multiplexing on the `BiStream`)
|
layer; the handler owns its sub-stream multiplexing on the `BiStream`)
|
||||||
- ADR-052: alknet-tty wire format (the 5-byte format this generalizes;
|
- Source-repo provenance (not ported): alknet ADR-052 — alknet-tty wire format (the 5-byte format this generalizes;
|
||||||
amended by ADR-077 — scoped to direct TTY; **re-amended by ADR-035 —
|
amended by ADR-077 — scoped to direct TTY; **re-amended by ADR-035 —
|
||||||
TTY always uses its 5-byte format, carried transparently in the
|
TTY always uses its 5-byte format, carried transparently in the
|
||||||
channels payload**)
|
channels payload**)
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ on the `BiStream` the channels layer gives them.
|
|||||||
framing byte, carried transparently.
|
framing byte, carried transparently.
|
||||||
- **The control channel is handler-internal.** TTY sub-demuxes control
|
- **The control channel is handler-internal.** TTY sub-demuxes control
|
||||||
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN = 3`,
|
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN = 3`,
|
||||||
`STREAM_CTRL_OUT = 4` — ADR-052 amended by Phase 7). The channels layer
|
`STREAM_CTRL_OUT = 4` — alknet ADR-052 (provenance), amended by Phase 7). The channels layer
|
||||||
doesn't carry control. The "control isn't actually bidirectional" flaw
|
doesn't carry control. The "control isn't actually bidirectional" flaw
|
||||||
is fixed at the TTY layer, not the channels layer.
|
is fixed at the TTY layer, not the channels layer.
|
||||||
- **Recursive composition is literal.** A channel with ALPN
|
- **Recursive composition is literal.** A channel with ALPN
|
||||||
@@ -165,7 +165,7 @@ Three things changed since ADR-034/074/077 were accepted:
|
|||||||
handler boundary — but there may be a generalized reason to expose it
|
handler boundary — but there may be a generalized reason to expose it
|
||||||
(recursive composition, test helpers, the hub relay's `channel_id`
|
(recursive composition, test helpers, the hub relay's `channel_id`
|
||||||
rewrite). The exact API shape is an implementation detail for the
|
rewrite). The exact API shape is an implementation detail for the
|
||||||
channels crate, tracked as OQ-68. The *contract* — the channels layer
|
channels crate, tracked as alknet OQ-68 (source-repo space, provenance; in-crate: OQ-24). The *contract* — the channels layer
|
||||||
strips its 8-byte header on read and the handler parses its own framing
|
strips its 8-byte header on read and the handler parses its own framing
|
||||||
from the payload — is decided here; the *function surface* is not.
|
from the payload — is decided here; the *function surface* is not.
|
||||||
- **TTY's `wire.rs` adaptation:** TTY's `ChunkReader` currently reads from
|
- **TTY's `wire.rs` adaptation:** TTY's `ChunkReader` currently reads from
|
||||||
@@ -174,10 +174,10 @@ Three things changed since ADR-034/074/077 were accepted:
|
|||||||
stream_type constants, length validation, control message parsing — is
|
stream_type constants, length validation, control message parsing — is
|
||||||
unchanged). This is an implementation concern for the channels + TTY
|
unchanged). This is an implementation concern for the channels + TTY
|
||||||
integration, not an architecture decision.
|
integration, not an architecture decision.
|
||||||
- **Full channel-level flow-control windowing (OQ-56):** unchanged. The
|
- **Full channel-level flow-control windowing (alknet OQ-56, provenance; in-crate: OQ-23):** unchanged. The
|
||||||
bounded-buffer backpressure (ADR-040) is the v1 mechanism; full
|
bounded-buffer backpressure (ADR-040) is the v1 mechanism; full
|
||||||
windowing is an additive extension that doesn't change the wire
|
windowing is an additive extension that doesn't change the wire
|
||||||
format. OQ-56 stays deferred(scope).
|
format. alknet OQ-56 stays deferred(scope) in the source repo; this crate tracks it as OQ-23.
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ handler's framing, carried transparently.
|
|||||||
| field | offset | width | meaning |
|
| field | offset | width | meaning |
|
||||||
|-------|--------|-------|---------|
|
|-------|--------|-------|---------|
|
||||||
| `channel_id` | 0 | 4 (BE) | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alk/call` (ADR-036). Channels 1..N are opened dynamically via `channel/open` (ADR-037). |
|
| `channel_id` | 0 | 4 (BE) | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alk/call` (ADR-036). Channels 1..N are opened dynamically via `channel/open` (ADR-037). |
|
||||||
| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN` (16 MiB, matching TTY's cap — ADR-052 §5). |
|
| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN` (16 MiB, matching TTY's cap — alknet ADR-052 §5 (provenance)). |
|
||||||
|
|
||||||
The `stream_type` byte is **removed** from the channels header. The
|
The `stream_type` byte is **removed** from the channels header. The
|
||||||
channels layer has no `stream_type` concept — not in its header, not in
|
channels layer has no `stream_type` concept — not in its header, not in
|
||||||
@@ -206,7 +206,7 @@ tunnel's raw bytes, SSH's channel protocol).
|
|||||||
This amends ADR-034: the wire format is 8 bytes, not 9; the
|
This amends ADR-034: the wire format is 8 bytes, not 9; the
|
||||||
`stream_type` decomposition (mod 3, unidirectional halves, 85 groups) is
|
`stream_type` decomposition (mod 3, unidirectional halves, 85 groups) is
|
||||||
removed from the channels layer. The stream_type concept survives in
|
removed from the channels layer. The stream_type concept survives in
|
||||||
TTY's 5-byte format (ADR-052, amended by Phase 7), which the channels
|
TTY's 5-byte format (alknet ADR-052, provenance — amended by Phase 7), which the channels
|
||||||
layer carries transparently.
|
layer carries transparently.
|
||||||
|
|
||||||
### 2. `into_sub_streams()` is removed; `accept_bi` is the only accessor
|
### 2. `into_sub_streams()` is removed; `accept_bi` is the only accessor
|
||||||
@@ -430,7 +430,7 @@ inside-channels), re-introducing a separate inside-channels mode would be
|
|||||||
a rewrite of TTY's session driver. The trade is one unified session
|
a rewrite of TTY's session driver. The trade is one unified session
|
||||||
driver now vs. two-mode maintenance forever.
|
driver now vs. two-mode maintenance forever.
|
||||||
|
|
||||||
The add/strip API shape (OQ-68) is a **two-way door** — whether the
|
The add/strip API shape (alknet OQ-68, provenance; in-crate: OQ-24) is a **two-way door** — whether the
|
||||||
header add/strip is built into the read/write path or exposed as a
|
header add/strip is built into the read/write path or exposed as a
|
||||||
standalone utility is an implementation detail that can change without
|
standalone utility is an implementation detail that can change without
|
||||||
breaking the wire format or the handler contract.
|
breaking the wire format or the handler contract.
|
||||||
@@ -472,7 +472,7 @@ breaking the wire format or the handler contract.
|
|||||||
`open_channel` and `Channel`)
|
`open_channel` and `Channel`)
|
||||||
- ADR-044: sub-crate decomposition (unchanged — 8-byte wire format in
|
- ADR-044: sub-crate decomposition (unchanged — 8-byte wire format in
|
||||||
`channels-core`; call-protocol coupling in `channels-call`)
|
`channels-core`; call-protocol coupling in `channels-call`)
|
||||||
- ADR-052: alknet-tty wire format (the 5-byte format carried
|
- Source-repo provenance (not ported): alknet ADR-052 — alknet-tty wire format (the 5-byte format carried
|
||||||
transparently in the channels payload; the control channel split
|
transparently in the channels payload; the control channel split
|
||||||
from Phase 7 is TTY-internal)
|
from Phase 7 is TTY-internal)
|
||||||
- `docs/research/stream-unification/findings.md` — the research that
|
- `docs/research/stream-unification/findings.md` — the research that
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Every handler — TTY, tunnel, SSH, call — receives a `Connection`, calls
|
|||||||
`accept_bi()` once, gets a `BiStream`, and sub-multiplexes it however it
|
`accept_bi()` once, gets a `BiStream`, and sub-multiplexes it however it
|
||||||
wants. The "typed handler path" (this ADR's motivating case for TTY) is
|
wants. The "typed handler path" (this ADR's motivating case for TTY) is
|
||||||
replaced by TTY sub-demuxing its `BiStream` via its own 5-byte format
|
replaced by TTY sub-demuxing its `BiStream` via its own 5-byte format
|
||||||
(ADR-052) — the same code TTY runs in direct mode. The two-accessor
|
(alknet ADR-052, provenance) — the same code TTY runs in direct mode. The two-accessor
|
||||||
design (`accept_bi` for generic handlers, `into_sub_streams` for typed
|
design (`accept_bi` for generic handlers, `into_sub_streams` for typed
|
||||||
handlers) collapses to one accessor.
|
handlers) collapses to one accessor.
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ per `ChannelManager` (`buffer_cap` field). This prevents memory exhaustion
|
|||||||
without the complexity of SSH's sliding-window protocol.
|
without the complexity of SSH's sliding-window protocol.
|
||||||
|
|
||||||
Full channel-level windowing (SSH-style sliding-window per channel) is a
|
Full channel-level windowing (SSH-style sliding-window per channel) is a
|
||||||
deferred extension, tracked as [OQ-56](../questions/056-full-channel-level-flow-control-windowing.md)
|
deferred extension, tracked as [alknet OQ-56](/workspace/@alkdev/alknet/docs/architecture/questions/056-full-channel-level-flow-control-windowing.md)
|
||||||
(deferred(scope)). It is blocked on a real deployment observing head-of-
|
(deferred(scope)). It is blocked on a real deployment observing head-of-
|
||||||
line blocking where the bounded-buffer mitigation is insufficient. The
|
line blocking where the bounded-buffer mitigation is insufficient. The
|
||||||
bounded-buffer decision is made; the extension is not.
|
bounded-buffer decision is made; the extension is not.
|
||||||
@@ -201,7 +201,7 @@ DoS defense summary.
|
|||||||
- Bounded-buffer backpressure does not eliminate head-of-line blocking — it
|
- Bounded-buffer backpressure does not eliminate head-of-line blocking — it
|
||||||
bounds the memory cost. A slow consumer still stalls its own channel's
|
bounds the memory cost. A slow consumer still stalls its own channel's
|
||||||
demux reads. For the intended use cases (TTY, SSH, tunnels) this is
|
demux reads. For the intended use cases (TTY, SSH, tunnels) this is
|
||||||
acceptable; full windowing is tracked as OQ-56 (deferred(scope)).
|
acceptable; full windowing is tracked as alknet OQ-56 (source-repo space, provenance; in-crate: OQ-23) (deferred(scope)).
|
||||||
|
|
||||||
## Door type
|
## Door type
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,52 @@
|
|||||||
|
|
||||||
## Status
|
## 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
|
## Context
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ surface:
|
|||||||
primary constructor and the one-way-door API. Takes a pre-established
|
primary constructor and the one-way-door API. Takes a pre-established
|
||||||
`Connection` (produced by any transport — TCP+TLS via `from_bidi`,
|
`Connection` (produced by any transport — TCP+TLS via `from_bidi`,
|
||||||
WebTransport `BiStream`, SSH `direct-tcpip`, a quinn connection, a
|
WebTransport `BiStream`, SSH `direct-tcpip`, a quinn connection, a
|
||||||
WebSocket carrying `alk/channels` per ADR-044), installs channel 0,
|
WebSocket carrying `alk/channels` per alknet ADR-044 — the websocket-native-session ADR, provenance —), installs channel 0,
|
||||||
spawns the demux/mux, returns the client. Mirrors the server-side
|
spawns the demux/mux, returns the client. Mirrors the server-side
|
||||||
`ChannelsAdapter::handle(Connection)` (substrate-agnostic) and the
|
`ChannelsAdapter::handle(Connection)` (substrate-agnostic) and the
|
||||||
existing `CallClient::spawn_dispatch(Connection)` pattern.
|
existing `CallClient::spawn_dispatch(Connection)` pattern.
|
||||||
@@ -73,7 +73,7 @@ surface:
|
|||||||
|
|
||||||
The dial+TLS seam (the transport-specific work each dial helper does —
|
The dial+TLS seam (the transport-specific work each dial helper does —
|
||||||
verifier selection per ADR-034, handshake, produce a `Connection`) is the
|
verifier selection per ADR-034, handshake, produce a `Connection`) is the
|
||||||
correct scope of OQ-55's deferral. `AlknetClient` is the eventual shared
|
correct scope of alknet OQ-55's deferral. `AlknetClient` is the eventual shared
|
||||||
*dial*; `from_connection` is the shared *channels-take-over*. Separating
|
*dial*; `from_connection` is the shared *channels-take-over*. Separating
|
||||||
them now, before the one-way-door API is cast, is the point — not a
|
them now, before the one-way-door API is cast, is the point — not a
|
||||||
deferral. The "QUIC-only initially" framing is removed; it was the
|
deferral. The "QUIC-only initially" framing is removed; it was the
|
||||||
@@ -81,7 +81,7 @@ anti-pattern this amendment corrects.
|
|||||||
|
|
||||||
The door-type classification is unchanged: `from_connection` is one-way
|
The door-type classification is unchanged: `from_connection` is one-way
|
||||||
(the handler-facing surface), `connect_quic` is two-way (additive
|
(the handler-facing surface), `connect_quic` is two-way (additive
|
||||||
convenience). The `AlknetClient` extraction remains deferred (OQ-55) — but
|
convenience). The `AlknetClient` extraction remains deferred (alknet OQ-55) — but
|
||||||
what is deferred is the shared *dial*, not a QUIC-welded client API.
|
what is deferred is the shared *dial*, not a QUIC-welded client API.
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
@@ -106,14 +106,14 @@ The phase-0 research (`docs/research/alknet-channels/phase-0-findings.md`
|
|||||||
seam — the transport-specific work (open socket, TLS handshake, ADR-034
|
seam — the transport-specific work (open socket, TLS handshake, ADR-034
|
||||||
verifier selection, produce a `Connection`) that each transport's dial
|
verifier selection, produce a `Connection`) that each transport's dial
|
||||||
helper rebuilds. Genuinely deferred — blocked on a second *transport's*
|
helper rebuilds. Genuinely deferred — blocked on a second *transport's*
|
||||||
dial existing (OQ-55 tracks this correctly). A single QUIC dial
|
dial existing (alknet OQ-55 tracks this correctly). A single QUIC dial
|
||||||
(`connect_quic`) does not give enough information to extract the
|
(`connect_quic`) does not give enough information to extract the
|
||||||
transport-polymorphic dial seam; two different transport dials do.
|
transport-polymorphic dial seam; two different transport dials do.
|
||||||
|
|
||||||
This ADR decides #1 — `ChannelClient`, with `from_connection` as the
|
This ADR decides #1 — `ChannelClient`, with `from_connection` as the
|
||||||
transport-agnostic primary constructor and `connect_quic` as a transport-
|
transport-agnostic primary constructor and `connect_quic` as a transport-
|
||||||
specific dial helper. #2 (the shared `AlknetClient` dial+TLS seam) stays
|
specific dial helper. #2 (the shared `AlknetClient` dial+TLS seam) stays
|
||||||
deferred per OQ-55.
|
deferred per alknet OQ-55.
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ impl ChannelClient {
|
|||||||
/// Transport-agnostic primary constructor. Takes a pre-established
|
/// Transport-agnostic primary constructor. Takes a pre-established
|
||||||
/// `Connection` (any transport — TCP+TLS via `from_bidi`,
|
/// `Connection` (any transport — TCP+TLS via `from_bidi`,
|
||||||
/// WebTransport BiStream, SSH direct-tcpip, a quinn connection, a
|
/// WebTransport BiStream, SSH direct-tcpip, a quinn connection, a
|
||||||
/// WebSocket per ADR-044), installs channel 0 (alk/call), spawns
|
/// WebSocket per alknet ADR-044 — websocket-native session, provenance —), installs channel 0 (alk/call), spawns
|
||||||
/// the demux/mux, and returns the client. Mirrors the server-side
|
/// the demux/mux, and returns the client. Mirrors the server-side
|
||||||
/// `ChannelsAdapter::handle(Connection)`. This is the one-way-door
|
/// `ChannelsAdapter::handle(Connection)`. This is the one-way-door
|
||||||
/// API surface — it must not be coupled to a transport (ADR-034,
|
/// API surface — it must not be coupled to a transport (ADR-034,
|
||||||
@@ -223,10 +223,10 @@ sense — it's one endpoint of a bidirectional channels connection. The name
|
|||||||
`ChannelClient` follows the `CallClient` convention (the side that dialed),
|
`ChannelClient` follows the `CallClient` convention (the side that dialed),
|
||||||
not a request/response role.
|
not a request/response role.
|
||||||
|
|
||||||
### Relationship to `AlknetClient` (OQ-55 — deferred)
|
### Relationship to `AlknetClient` (alknet OQ-55 — deferred)
|
||||||
|
|
||||||
`ChannelClient`'s *API* is transport-agnostic — `from_connection` takes
|
`ChannelClient`'s *API* is transport-agnostic — `from_connection` takes
|
||||||
a pre-established `Connection`. What is deferred (OQ-55) is the shared
|
a pre-established `Connection`. What is deferred (alknet OQ-55) is the shared
|
||||||
*dial+TLS* seam (`AlknetClient`): the transport-specific work each dial
|
*dial+TLS* seam (`AlknetClient`): the transport-specific work each dial
|
||||||
helper does — open a socket, run the TLS handshake, apply ADR-034's
|
helper does — open a socket, run the TLS handshake, apply ADR-034's
|
||||||
verifier-selection rule, produce a `Connection`. That dial is genuinely
|
verifier-selection rule, produce a `Connection`. That dial is genuinely
|
||||||
@@ -252,14 +252,14 @@ duplicated capability and not a QUIC-welded client API.
|
|||||||
matching the call protocol's `CallAdapter`/`CallClient` shape.
|
matching the call protocol's `CallAdapter`/`CallClient` shape.
|
||||||
- Bidirectionality is preserved — the client can both initiate and receive
|
- Bidirectionality is preserved — the client can both initiate and receive
|
||||||
`channel/open`.
|
`channel/open`.
|
||||||
- The `AlknetClient` deferral (OQ-55) is not blocked by `ChannelClient` —
|
- The `AlknetClient` deferral (alknet OQ-55) is not blocked by `ChannelClient` —
|
||||||
they are independent concerns. `ChannelClient` builds standalone; the
|
they are independent concerns. `ChannelClient` builds standalone; the
|
||||||
core extraction happens later when the blocker clears.
|
core extraction happens later when the blocker clears.
|
||||||
|
|
||||||
**Negative:**
|
**Negative:**
|
||||||
- Each transport-specific dial helper duplicates ~20 lines of
|
- Each transport-specific dial helper duplicates ~20 lines of
|
||||||
verifier-selection boilerplate (from `connect_quic`). This is the known
|
verifier-selection boilerplate (from `connect_quic`). This is the known
|
||||||
cost of not extracting `AlknetClient` yet (OQ-55). Acceptable until the
|
cost of not extracting `AlknetClient` yet (alknet OQ-55). Acceptable until the
|
||||||
second transport's dial exists, at which point `AlknetClient` extracts
|
second transport's dial exists, at which point `AlknetClient` extracts
|
||||||
the shared dial+TLS seam. The `from_connection` API — the one-way-door
|
the shared dial+TLS seam. The `from_connection` API — the one-way-door
|
||||||
surface — is unaffected; only the dial helpers carry the duplication.
|
surface — is unaffected; only the dial helpers carry the duplication.
|
||||||
@@ -276,10 +276,10 @@ one-way-door primary constructor (transport-agnostic).
|
|||||||
`from_connection`. Adding, removing, or changing a dial helper is cheap
|
`from_connection`. Adding, removing, or changing a dial helper is cheap
|
||||||
and does not touch the one-way-door surface.
|
and does not touch the one-way-door surface.
|
||||||
|
|
||||||
The `AlknetClient` extraction is a **deferred decision** (OQ-55,
|
The `AlknetClient` extraction is a **deferred decision** (alknet OQ-55,
|
||||||
deferred(scope)), not a door-type attribute. Its door type is two-way (the
|
deferred(scope)), not a door-type attribute. Its door type is two-way (the
|
||||||
extraction is a refactor, not a wire-format change), but it is not decided
|
extraction is a refactor, not a wire-format change), but it is not decided
|
||||||
in this ADR — see OQ-55 for the blocking condition. What is deferred is the
|
in this ADR — see alknet OQ-55 for the blocking condition. What is deferred is the
|
||||||
shared *dial+TLS* seam; `from_connection`'s transport-agnostic contract is
|
shared *dial+TLS* seam; `from_connection`'s transport-agnostic contract is
|
||||||
decided now.
|
decided now.
|
||||||
|
|
||||||
@@ -292,7 +292,7 @@ decided now.
|
|||||||
removed from `open_channel` and `Channel`; handler owns sub-stream
|
removed from `open_channel` and `Channel`; handler owns sub-stream
|
||||||
multiplexing)
|
multiplexing)
|
||||||
- ADR-039: ChannelManager (the shared state `ChannelClient` holds)
|
- ADR-039: ChannelManager (the shared state `ChannelClient` holds)
|
||||||
- OQ-55: AlknetClient / client establishment extraction (the deferred core
|
- alknet OQ-55: AlknetClient / client establishment extraction (the deferred core
|
||||||
concern this ADR does NOT block on)
|
concern this ADR does NOT block on)
|
||||||
- `docs/research/alknet-channels/phase-0-findings.md` §OQ-CH-14 (the
|
- `docs/research/alknet-channels/phase-0-findings.md` §OQ-CH-14 (the
|
||||||
research-scope question this ADR carries forward)
|
research-scope question this ADR carries forward)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ alknet-channels-core — the pure multiplexer (wire format, demux/mux,
|
|||||||
|
|
||||||
There are no `channels-hub` or `channels-worker` sub-crates. The hub and
|
There are no `channels-hub` or `channels-worker` sub-crates. The hub and
|
||||||
worker are **consumers** of channels, not sub-crates of it. The existing
|
worker are **consumers** of channels, not sub-crates of it. The existing
|
||||||
`alknet-hub` crate (`docs/architecture/crates/hub/README.md`) IS the hub —
|
`alknet-hub` crate (source-repo path: docs/architecture/crates/hub/README.md — alknet, provenance) IS the hub —
|
||||||
it depends on `channels-call` and uses the channels protocol as its
|
it depends on `channels-call` and uses the channels protocol as its
|
||||||
substrate. A worker is just a worker — it depends on `channels-call` and
|
substrate. A worker is just a worker — it depends on `channels-call` and
|
||||||
uses `ChannelClient` (ADR-043) to dial. The hub relay logic (ADR-042) lives
|
uses `ChannelClient` (ADR-043) to dial. The hub relay logic (ADR-042) lives
|
||||||
@@ -168,11 +168,11 @@ The dependency direction is: `alknet-hub` → `channels-call` →
|
|||||||
|
|
||||||
### Relationship to `alknet-hub`
|
### Relationship to `alknet-hub`
|
||||||
|
|
||||||
The existing `alknet-hub` crate (`docs/architecture/crates/hub/README.md`)
|
The existing `alknet-hub` crate (source-repo path: docs/architecture/crates/hub/README.md — alknet, provenance)
|
||||||
is the hub pattern: peer lifecycle, aggregated env, service discovery. With
|
is the hub pattern: peer lifecycle, aggregated env, service discovery. With
|
||||||
channels as the substrate, `alknet-hub` gains a dependency on
|
channels as the substrate, `alknet-hub` gains a dependency on
|
||||||
`channels-call` and incorporates the relay logic (ADR-042). The hub spec
|
`channels-call` and incorporates the relay logic (ADR-042). The hub spec
|
||||||
(`crates/hub/README.md`) will be updated to reflect that the hub uses
|
(that source-repo doc) will be updated to reflect that the hub uses
|
||||||
channels as its transport substrate — one channels connection per leg
|
channels as its transport substrate — one channels connection per leg
|
||||||
(browser↔hub, hub↔spoke), with the relay translating `channel/open` and
|
(browser↔hub, hub↔spoke), with the relay translating `channel/open` and
|
||||||
byte-forwarding data channels. The hub's existing responsibilities (peer
|
byte-forwarding data channels. The hub's existing responsibilities (peer
|
||||||
@@ -247,5 +247,15 @@ the dependency direction (hub/worker → channels, not channels → hub/worker).
|
|||||||
- ADR-043: ChannelClient (in `channels-call`; there is no
|
- ADR-043: ChannelClient (in `channels-call`; there is no
|
||||||
`channels-worker` sub-crate — a worker is any crate that uses
|
`channels-worker` sub-crate — a worker is any crate that uses
|
||||||
`ChannelClient` to dial)
|
`ChannelClient` to dial)
|
||||||
- `docs/architecture/crates/hub/README.md` — the existing hub crate (the
|
- alknet hub crate (source-repo path docs/architecture/crates/hub/README.md, provenance) — the existing hub crate (the
|
||||||
relay's consumer)
|
relay's consumer)
|
||||||
|
## Amendment 2026-10-13: the split landed inside alkcall, not as separate crates
|
||||||
|
|
||||||
|
This ADR's decision text was authored when the channels protocol lived
|
||||||
|
in the mono-repo crate `alknet-channels` and framed the split as
|
||||||
|
sub-crates of it. The extraction (ADR-031, amendment 4) landed the
|
||||||
|
`channels-core` (pure multiplexer) / `channels-call` (call coupling:
|
||||||
|
channel 0 pre-negotiation, lifecycle ops, `ChannelCore`, `ChannelClient`)
|
||||||
|
decomposition as **modules inside alkcall** — downstream depends on
|
||||||
|
alkcall as a single crate. Hub and worker remain consumers (no
|
||||||
|
worker/hub sub-crate), as decided here.
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
# ADR-045: AlknetClient — the Native Client Dial Seam
|
# ADR-045: AlknetClient — the Native Client Dial Seam
|
||||||
|
|
||||||
|
> **Citation-space note (2026-10-13).** This ADR was authored in the
|
||||||
|
> alknet workspace before the extraction. Its `ADR-08x` references
|
||||||
|
> (endpoint types, `TlsClientConfig`, accept-loop runner, crypto
|
||||||
|
> provider) and its `OQ-55`/`OQ-58` references are **source-repo
|
||||||
|
> (alknet) space, not this crate's `decisions/`** — provenance; those
|
||||||
|
> ADRs were never ported. The verifier-selection rule formerly cited as
|
||||||
|
> "alknet ADR-034" is re-owned in-crate by ADR-054. This crate's own
|
||||||
|
> tracker/ADR numbers appear only where explicitly marked.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted (resolves OQ-55; §3 and §5 amended 2026-07-16 by ADR-012 —
|
Accepted (resolves alknet OQ-55 — source-repo space, provenance;
|
||||||
|
§3 and §5 amended 2026-07-16 by ADR-012 —
|
||||||
the dial credential bundle is `ConnectionCredentials` (transport-level),
|
the dial credential bundle is `ConnectionCredentials` (transport-level),
|
||||||
not `CallCredentials` (call-protocol-level); all three dial signatures
|
not `CallCredentials` (call-protocol-level); all three dial signatures
|
||||||
unify on `&ConnectionCredentials`; `dial_iroh`'s `node_id` parameter is
|
unify on `&ConnectionCredentials`; `dial_iroh`'s `node_id` parameter is
|
||||||
@@ -21,7 +31,7 @@ reader); `auth_token` is a per-request payload field; the `from_call`
|
|||||||
OQ-55 deferred `AlknetClient::dial()` — the transport-polymorphic client
|
OQ-55 deferred `AlknetClient::dial()` — the transport-polymorphic client
|
||||||
dial seam — blocked on "a second transport's real dial existing." The
|
dial seam — blocked on "a second transport's real dial existing." The
|
||||||
reasoning (recorded in the OQ-55 file and in
|
reasoning (recorded in the OQ-55 file and in
|
||||||
[channel-client.md](../crates/channels/channel-client.md) §"Relationship
|
[channel-client.md](../channel-client.md) §"Relationship
|
||||||
to `AlknetClient`") was sound at the time: extracting a QUIC-shaped
|
to `AlknetClient`") was sound at the time: extracting a QUIC-shaped
|
||||||
connector and naming it `AlknetClient` would bake QUIC in as *the*
|
connector and naming it `AlknetClient` would bake QUIC in as *the*
|
||||||
establishment shape — the same welding ADR-007 unwound on the server
|
establishment shape — the same welding ADR-007 unwound on the server
|
||||||
@@ -81,7 +91,7 @@ contributing to the confusion that made `AlknetClient` hard to spec:
|
|||||||
(client). *Server-side accept vs. client-side dial.* The endpoint
|
(client). *Server-side accept vs. client-side dial.* The endpoint
|
||||||
accepts connections and resolves identity from the incoming
|
accepts connections and resolves identity from the incoming
|
||||||
connection; the client dials and presents identity (client cert)
|
connection; the client dials and presents identity (client cert)
|
||||||
while verifying the remote (ADR-034).
|
while verifying the remote (alknet ADR-034, provenance).
|
||||||
3. **ALPN-level category** — endpoint ALPN / entry-point ALPN
|
3. **ALPN-level category** — endpoint ALPN / entry-point ALPN
|
||||||
(ADR-086 §2). *Identity-gated vs. bootstrap, at the TLS layer.*
|
(ADR-086 §2). *Identity-gated vs. bootstrap, at the TLS layer.*
|
||||||
|
|
||||||
@@ -143,7 +153,7 @@ The symmetry:
|
|||||||
|---------|---------------------------|-------------------------|
|
|---------|---------------------------|-------------------------|
|
||||||
| Transports | `with_quinn` / `with_iroh` / `with_tcp_tls` — pre-built by the assembly layer | `with_quinn` / `with_iroh` / `with_tcp_tls` — pre-built by the assembly layer |
|
| Transports | `with_quinn` / `with_iroh` / `with_tcp_tls` — pre-built by the assembly layer | `with_quinn` / `with_iroh` / `with_tcp_tls` — pre-built by the assembly layer |
|
||||||
| Per-connection work | Accept → extract ALPN + fingerprint → `Connection` → `dispatch` | Dial → TLS handshake → `Connection` (ALPN + fingerprint carried) |
|
| Per-connection work | Accept → extract ALPN + fingerprint → `Connection` → `dispatch` | Dial → TLS handshake → `Connection` (ALPN + fingerprint carried) |
|
||||||
| Identity | Resolved *from* the incoming connection (fingerprint from client cert, or token on channel 0) | *Presented* (local `TlsIdentity` as client cert) + remote *verified* (ADR-034 — fingerprint pin or CA) |
|
| Identity | Resolved *from* the incoming connection (fingerprint from client cert, or token on channel 0) | *Presented* (local `TlsIdentity` as client cert) + remote *verified* (alknet ADR-034, provenance — fingerprint pin or CA) |
|
||||||
| What it does NOT do | Run protocols — handlers do | Run protocols — `CallClient` / `ChannelClient` do |
|
| What it does NOT do | Run protocols — handlers do | Run protocols — `CallClient` / `ChannelClient` do |
|
||||||
| Config | `TlsServerConfig` (per endpoint type, built by assembly) | `TlsClientConfig` (per-dial, built from `CallCredentials`) |
|
| Config | `TlsServerConfig` (per endpoint type, built by assembly) | `TlsClientConfig` (per-dial, built from `CallCredentials`) |
|
||||||
|
|
||||||
@@ -168,7 +178,7 @@ pub struct AlknetClient {
|
|||||||
|
|
||||||
impl AlknetClient {
|
impl AlknetClient {
|
||||||
/// QUIC dial. Builds a `TlsClientConfig` from `credentials`
|
/// QUIC dial. Builds a `TlsClientConfig` from `credentials`
|
||||||
/// (ADR-034 verifier selection + ADR-084 provider), dials `addr`
|
/// (alknet ADR-034 verifier selection, provenance + alknet ADR-084 provider), dials `addr`
|
||||||
/// on `alpn`, returns a `Connection` via
|
/// on `alpn`, returns a `Connection` via
|
||||||
/// `Connection::from_quinn_with_alpn`. Feature-gated on `quinn`.
|
/// `Connection::from_quinn_with_alpn`. Feature-gated on `quinn`.
|
||||||
#[cfg(feature = "quinn")]
|
#[cfg(feature = "quinn")]
|
||||||
@@ -215,7 +225,7 @@ identity + auth token bundle, from `Capabilities`). The two rustls dials
|
|||||||
(QUIC, TCP+TLS) build a `TlsClientConfig` from the credentials; the
|
(QUIC, TCP+TLS) build a `TlsClientConfig` from the credentials; the
|
||||||
iroh dial uses the raw `Ed25519SecretKey` directly. This mirrors the
|
iroh dial uses the raw `Ed25519SecretKey` directly. This mirrors the
|
||||||
server side's "iroh shares the key, not the config" (ADR-082, ADR-087
|
server side's "iroh shares the key, not the config" (ADR-082, ADR-087
|
||||||
§3) — the consistency is in the rule (ADR-034 verifier selection), not
|
§3) — the consistency is in the rule (the verifier-selection rule — alknet ADR-034, provenance; re-owned in-crate as ADR-054), not
|
||||||
in the type.
|
in the type.
|
||||||
|
|
||||||
> **Amendment 2026-07-16 (ADR-012):** The dial credential bundle is
|
> **Amendment 2026-07-16 (ADR-012):** The dial credential bundle is
|
||||||
@@ -238,7 +248,7 @@ in the type.
|
|||||||
> is derived from `creds.remote_identity.fingerprint`
|
> is derived from `creds.remote_identity.fingerprint`
|
||||||
> (`ed25519:<hex>` → `NodeId::from_bytes`), the same extraction pattern
|
> (`ed25519:<hex>` → `NodeId::from_bytes`), the same extraction pattern
|
||||||
> the rustls dials use for the verifier. The consistency is now in both
|
> the rustls dials use for the verifier. The consistency is now in both
|
||||||
> the rule (ADR-034) and the type. `CallCredentials` stays in
|
> the rule (alknet ADR-034, provenance) and the type. `CallCredentials` stays in
|
||||||
> `alknet-call` as the call-protocol credential bundle; the
|
> `alknet-call` as the call-protocol credential bundle; the
|
||||||
> `auth_token` is a per-request field on `call.requested` payloads
|
> `auth_token` is a per-request field on `call.requested` payloads
|
||||||
> (set by the caller or the `from_call` forwarding handler, resolved by
|
> (set by the caller or the `from_call` forwarding handler, resolved by
|
||||||
@@ -492,7 +502,7 @@ several possible native clients sharing the same wire protocols.
|
|||||||
graph.
|
graph.
|
||||||
- **The iroh dial is the exception.** It does not use
|
- **The iroh dial is the exception.** It does not use
|
||||||
`TlsClientConfig` — iroh has its own TLS. The dial helper applies
|
`TlsClientConfig` — iroh has its own TLS. The dial helper applies
|
||||||
the same ADR-034 rule via iroh's API (NodeId match). The
|
the same verifier rule (alknet ADR-034, provenance) via iroh's API (NodeId match). The
|
||||||
consistency is in the rule, not in the type. This is the same
|
consistency is in the rule, not in the type. This is the same
|
||||||
exception as the server side (ADR-082, ADR-087 §3) — unavoidable,
|
exception as the server side (ADR-082, ADR-087 §3) — unavoidable,
|
||||||
and isolated to one dial method.
|
and isolated to one dial method.
|
||||||
@@ -528,24 +538,24 @@ dedicated ADR lands.
|
|||||||
|
|
||||||
- OQ-55 (resolved by this ADR) — `AlknetClient` / client establishment
|
- OQ-55 (resolved by this ADR) — `AlknetClient` / client establishment
|
||||||
extraction
|
extraction
|
||||||
- [ADR-083](083-endpoint-as-accept-loop-runner.md) — `AlknetEndpoint`
|
- [ADR-083](/workspace/@alkdev/alknet/docs/architecture/decisions/083-endpoint-as-accept-loop-runner.md) — `AlknetEndpoint`
|
||||||
as multi-transport accept-loop runner; the server-side shape this
|
as multi-transport accept-loop runner; the server-side shape this
|
||||||
ADR mirrors on the client side
|
ADR mirrors on the client side
|
||||||
- [ADR-086](086-endpoint-types-and-entry-points.md) — endpoint types
|
- [ADR-086](/workspace/@alkdev/alknet/docs/architecture/decisions/086-endpoint-types-and-entry-points.md) — endpoint types
|
||||||
(native has QUIC + TCP+TLS + iroh); entry-point vs. endpoint ALPN
|
(native has QUIC + TCP+TLS + iroh); entry-point vs. endpoint ALPN
|
||||||
distinction (§2)
|
distinction (§2)
|
||||||
- [ADR-087](087-tlsclientconfig-not-blocked-on-dial.md) —
|
- [ADR-087](/workspace/@alkdev/alknet/docs/architecture/decisions/087-tlsclientconfig-not-blocked-on-dial.md) —
|
||||||
`TlsClientConfig` not blocked on the dial seam; breaks the circular
|
`TlsClientConfig` not blocked on the dial seam; breaks the circular
|
||||||
hedge; the TLS config is a prerequisite for the dial
|
hedge; the TLS config is a prerequisite for the dial
|
||||||
- [ADR-082](082-alknet-tls-extraction.md) — `TlsServerConfig` /
|
- [ADR-082](/workspace/@alkdev/alknet/docs/architecture/decisions/082-alknet-tls-extraction.md) — `TlsServerConfig` /
|
||||||
`TlsClientConfig` in `alknet-tls`; "iroh shares the key, not the
|
`TlsClientConfig` in `alknet-tls`; "iroh shares the key, not the
|
||||||
config"
|
config"
|
||||||
- [ADR-007](007-connection-from-stream-generic-single-stream.md) —
|
- [ADR-007](007-connection-from-stream-generic-single-stream.md) —
|
||||||
`Connection::from_stream` / `from_bidi`; the server-side
|
`Connection::from_stream` / `from_bidi`; the server-side
|
||||||
generalization whose client-side analogue this ADR completes
|
generalization whose client-side analogue this ADR completes
|
||||||
- [ADR-034](034-outgoing-only-x509-and-three-peer-roles.md) —
|
- [ADR-034](/workspace/@alkdev/alknet/docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md) —
|
||||||
client-side verifier selection (fingerprint pin vs CA vs fail-closed)
|
client-side verifier selection (fingerprint pin vs CA vs fail-closed)
|
||||||
- [ADR-084](084-aws-lc-rs-crypto-provider.md) — aws-lc-rs crypto
|
- [ADR-084](/workspace/@alkdev/alknet/docs/architecture/decisions/084-aws-lc-rs-crypto-provider.md) — aws-lc-rs crypto
|
||||||
provider on all paths
|
provider on all paths
|
||||||
- [ADR-043](043-channelclient.md) — `ChannelClient::from_connection`
|
- [ADR-043](043-channelclient.md) — `ChannelClient::from_connection`
|
||||||
(the take-over `AlknetClient` feeds)
|
(the take-over `AlknetClient` feeds)
|
||||||
@@ -555,3 +565,22 @@ dedicated ADR lands.
|
|||||||
is the native analogue)
|
is the native analogue)
|
||||||
- `docs/architecture/crates/channels/channel-client.md` §"Relationship
|
- `docs/architecture/crates/channels/channel-client.md` §"Relationship
|
||||||
to `AlknetClient`" — the deferral this ADR resolves
|
to `AlknetClient`" — the deferral this ADR resolves
|
||||||
|
|
||||||
|
## Amendment 2026-10-13: name normalization (`alknet-client` → the composition layer's dial helper)
|
||||||
|
|
||||||
|
This ADR's decision text was authored when the dial helper lived in the
|
||||||
|
mono-repo crate `alknet-client`. The decision itself is name-independent
|
||||||
|
and unchanged: **the dial lives in the consumer/composition layer; the
|
||||||
|
protocol crates take over pre-established `Connection`s (`spawn_dispatch`
|
||||||
|
/ `from_connection`).** Current naming:
|
||||||
|
|
||||||
|
- `AlknetClient` remains the dial helper's type name, but its host is
|
||||||
|
the **composition layer** (today the alknet glue, which is being
|
||||||
|
replaced). The post-replacement owner and shape of the composition
|
||||||
|
layer — including this dial seam — is tracked as OQ-43.
|
||||||
|
- `ConnectionCredentials` / `RemoteIdentity` (ADR-012) live with the
|
||||||
|
vendored core types in alkcall.
|
||||||
|
- The client-side verifier-selection rule this ADR's dial applies
|
||||||
|
(fingerprint pin vs CA vs fail-closed) is re-owned in-crate by
|
||||||
|
ADR-054; the alknet ADR-034 and ADR-084 references below are
|
||||||
|
provenance.
|
||||||
@@ -49,6 +49,13 @@ primitive is the load-bearing call-protocol piece; the broker composes
|
|||||||
on top of it later, in channels, the same way the hub relay composes on
|
on top of it later, in channels, the same way the hub relay composes on
|
||||||
top of existing call ops.
|
top of existing call ops.
|
||||||
|
|
||||||
|
> **Status note (post-deferral)**: the channels work this deferral
|
||||||
|
> pointed at has since landed, and the placement is now decided — the
|
||||||
|
> broker is out of scope for alkcall (ADR-047 §"What this ADR decides",
|
||||||
|
> Gap B) and tracked downstream as OQ-42 (`deferred(scope)`, blocked on
|
||||||
|
> a concrete producing-crate consumer). Read "deferred to the channels
|
||||||
|
> session" below as this placement decision.
|
||||||
|
|
||||||
### Gap A from the research findings: `Pub` handler shape
|
### Gap A from the research findings: `Pub` handler shape
|
||||||
|
|
||||||
The research findings' Gap A identified that the current
|
The research findings' Gap A identified that the current
|
||||||
@@ -441,6 +448,13 @@ one-way addition (the field is part of the spec's public API); its
|
|||||||
|
|
||||||
## Fan-out (deferred — OQ-22 §fan-out scope)
|
## Fan-out (deferred — OQ-22 §fan-out scope)
|
||||||
|
|
||||||
|
> **Status note (post-deferral)**: see OQ-22/OQ-42 in
|
||||||
|
> [open-questions.md](../open-questions.md) — the placement is decided
|
||||||
|
> (out of scope for alkcall, ADR-047 Gap B); the broker design and
|
||||||
|
> build are downstream (OQ-42, deferred(scope)). "The channels session
|
||||||
|
> will add the broker" below predates that placement; read it as "the
|
||||||
|
> downstream hub composes the broker."
|
||||||
|
|
||||||
The fan-out / broker mechanism (one producer, N consumers, topic
|
The fan-out / broker mechanism (one producer, N consumers, topic
|
||||||
matching by `(op_name, params_hash)`) is **not** part of this ADR. It
|
matching by `(op_name, params_hash)`) is **not** part of this ADR. It
|
||||||
is deferred to the channels session for these reasons:
|
is deferred to the channels session for these reasons:
|
||||||
|
|||||||
@@ -8,7 +8,388 @@ Accepted (amends ADR-037; refines ADR-044, ADR-046; §4 amended
|
|||||||
registration, 2026-08-13)"; amendment #2 (2026-09-03) — the
|
registration, 2026-08-13)"; amendment #2 (2026-09-03) — the
|
||||||
per-connection registration mechanism is the **per-session fork of the
|
per-connection registration mechanism is the **per-session fork of the
|
||||||
base registry installed as the session's dispatch registry**, not the
|
base registry installed as the session's dispatch registry**, not the
|
||||||
connection overlay — see "Amendment (§4 mechanism, 2026-09-03)" below)
|
connection overlay — see "Amendment (§4 mechanism, 2026-09-03)" below;
|
||||||
|
§6 amended 2026-09-06 — the static half of discovery gains an additive
|
||||||
|
per-op `description` on the listing, the dynamic half stays deferred
|
||||||
|
— see "Amendment (§6 listing enrichment, 2026-09-06)" below;
|
||||||
|
amendment 3 (2026-09-16, review 008 U-1) — the op-name convention
|
||||||
|
extends to the flavor form and discovery carries an explicit
|
||||||
|
`channel_open_alpn` for non-derivable names — see "Amendment 3" below;
|
||||||
|
amendment 4 (2026-10-13, reviews 010 S-01 / 011) — the marked-pub open
|
||||||
|
contract: the initiator-allocated channel, the adopt path, the wrapper
|
||||||
|
as responder of record, zero `call.published` on marked ops, the relay
|
||||||
|
translation, and the client-side put-open capability — see "Amendment 4"
|
||||||
|
below)
|
||||||
|
|
||||||
|
## Amendment 4 (the marked-pub open contract, 2026-10-13 — review 010 S-01, review 011)
|
||||||
|
|
||||||
|
`channels/<alpn>/pub` open ops were registered, advertised, and stubbed
|
||||||
|
(`channel:pub_open_not_implemented` on every invocation — review 010
|
||||||
|
S-01, shipped nine published versions). This amendment pins the
|
||||||
|
implemented contract in place of the stub; review 011 corrected the
|
||||||
|
remediation sketch before this amendment was drafted (the serving-loop
|
||||||
|
`PublishStream` shape is the **unmarked** pub semantics and does not
|
||||||
|
apply to marked ops).
|
||||||
|
|
||||||
|
The shape, in one sentence: **a marked pub open is an ordinary channel
|
||||||
|
open whose `channel_id` the initiator allocated on its own side** — the
|
||||||
|
initiator streams bytes on that channel before the open resolves (the
|
||||||
|
early-arrival parking covers the race), and the responder adopts and
|
||||||
|
serves the data plane as the responder of record.
|
||||||
|
|
||||||
|
### 4.1 Marker semantics (pin 1)
|
||||||
|
|
||||||
|
The marked pub op's data plane is the adopted channel's `BiStream` —
|
||||||
|
one direction, initiator → responder, with the ADR-040 bounded buffer
|
||||||
|
as the backpressure mechanism. **Zero `call.published` data on the call
|
||||||
|
plane for marked pub ops**: a nonempty publish-chunk stream on a marked
|
||||||
|
op is rejected loudly with a new wire error (code
|
||||||
|
`channel:publish_chunks_rejected`, retryable false, message stating
|
||||||
|
that the data plane is the adopted channel) written in the initiator's
|
||||||
|
direction when the first chunk arrives; the chunks are never consumed,
|
||||||
|
and the marked-pub flow continues to its own single response. Two
|
||||||
|
competing data paths on a marked op must never exist. Unmarked `Pub`
|
||||||
|
ops are unaffected — they remain the ADR-046 `call.published` stream
|
||||||
|
through `publish_schema` validation.
|
||||||
|
|
||||||
|
### 4.2 `publish_schema` refused at registration for marked ops (pin 2)
|
||||||
|
|
||||||
|
Publish chunks never exist on a marked op (§4.1), so `publish_schema`
|
||||||
|
is dead machinery on a marked spec — the registered-but-incapable-of-
|
||||||
|
mattering class review 010 outlaws. `OperationRegistry::register`
|
||||||
|
refuses a marked spec that carries `publish_schema` (registration-time
|
||||||
|
loudness, per the house pattern).
|
||||||
|
|
||||||
|
### 4.3 Reserved input key + dispatch-layer strip (pin 3)
|
||||||
|
|
||||||
|
The adopted channel id crosses the open invocation as the reserved
|
||||||
|
input key **`"channel_id"`** — the same key the wrapper owns on the
|
||||||
|
reply side (`RESERVED_REPLY_KEY`, ADR-049 amendment 3); the wrapper
|
||||||
|
owns the `channel_id` key on both sides of a marked open op. Placement
|
||||||
|
rationale (review 011 §1.2): schema validation runs in `invoke_*`
|
||||||
|
before any handler, so the reserved key must be stripped
|
||||||
|
**pre-validation** in the sink resolve path (`resolve_sink_handler` —
|
||||||
|
the single source of truth shared by the wire dispatch and
|
||||||
|
registry-level `invoke_sink`, per review 001 P-08) before
|
||||||
|
`input_schema` validation; the stripped id is handed to the wrapper via
|
||||||
|
`OperationContext.metadata` under the key `channel_open_channel_id`.
|
||||||
|
The producing crate's `input_schema` never contains the key, and
|
||||||
|
dispatch rejects a marked pub op whose input **lacks** it — the
|
||||||
|
initiator-allocated id is mandatory, not optional plumbing
|
||||||
|
(`INVALID_INPUT`, message naming the key). An op input carrying
|
||||||
|
`channel_id` on a NON-pub or unmarked op is not stripped (their
|
||||||
|
schemas own their full input surface; the strip and the requirement are
|
||||||
|
both scoped to marked pub ops).
|
||||||
|
|
||||||
|
### 4.4 The wrapper as responder of record (pin 4)
|
||||||
|
|
||||||
|
The marked-pub open registration is
|
||||||
|
`ChannelCore::register_pub_openable(spec, establisher, pub_open_handler,
|
||||||
|
registry, auth, timeout)`. The entry points refuse wrong shapes at
|
||||||
|
registration: the `Query`/`Mutation`/`Sub` wrapper entry points
|
||||||
|
(`register_openable*`) refuse `Pub`-typed specs, and
|
||||||
|
`register_pub_openable` refuses non-`Pub` or unmarked specs. The
|
||||||
|
handler type is a separate one from `OpenHandler`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub type PubOpenHandler = Arc<
|
||||||
|
dyn Fn(Value, Option<ChannelPlan>, Connection, AuthContext)
|
||||||
|
-> BoxFuture<'static, Result<Value, CallError>>
|
||||||
|
+ Send + Sync,
|
||||||
|
>;
|
||||||
|
```
|
||||||
|
|
||||||
|
(`JoinHandle<()>` — the `OpenHandler` return — cannot carry the
|
||||||
|
handler result the merged response needs; see §4.6 for why this is a
|
||||||
|
separate type rather than an `OpenHandler` change.)
|
||||||
|
|
||||||
|
The wrapper flow (the responder of record):
|
||||||
|
|
||||||
|
1. `policy.check_open(identity)` — unchanged (cap gate, pre-adopt).
|
||||||
|
2. **Adopt** the initiator-allocated id:
|
||||||
|
`manager.adopt_channel(reserved_id, alpn, None)` — the existing
|
||||||
|
demux/mux install + early-arrival drain; no new machinery. Adoption
|
||||||
|
failure (collision or cap) tears nothing down (nothing was created)
|
||||||
|
and replies `channel:open_failed` with a new reason **`adopt_failed`**
|
||||||
|
in the ADR-049 §3 vocabulary.
|
||||||
|
3. The establisher (bounded, ADR-049 §1/§2 machinery, unchanged) may
|
||||||
|
validate the offer and contribute ack fields
|
||||||
|
(`Establishment.reply_fields`); its plan threads to the handler.
|
||||||
|
4. Spawn the pub handler on the channel's `Connection` — the wrapper
|
||||||
|
**awaits the handler inline** (review 011 §1.3), which is what makes
|
||||||
|
R-02's lifetime contract structural here: the handler cannot return
|
||||||
|
before its pumps finish, so the teardown-at-birth trap does not
|
||||||
|
exist on this path, and the channel's `ChannelState.handler_task`
|
||||||
|
stays `None` (teardown is EOF-driven: a `channel/close` during the
|
||||||
|
put drops the demux sender, the handler's reads see EOF, the handler
|
||||||
|
finishes early, and the single response is still written — the
|
||||||
|
handler decides the semantics of a truncated stream).
|
||||||
|
5. **One response, merge order pinned:** `{ "channel_id": <adopted> }`
|
||||||
|
→ extended with the establisher's ack fields (ADR-049 amendment 3,
|
||||||
|
unchanged, including the reserved-key establisher-bug refusal) →
|
||||||
|
extended with the handler's result fields (handler result wins on
|
||||||
|
key collision — its computed result postdates the ack). A handler
|
||||||
|
returning `Err` responds that error directly and the channel tears
|
||||||
|
down; racing initiator bytes hit EOF and are discarded.
|
||||||
|
6. The standard completion teardown runs after the await (demux
|
||||||
|
sender drop, ledger take, `policy.on_close` — the same atomic gate
|
||||||
|
every teardown path runs, ADR-047 §7).
|
||||||
|
|
||||||
|
A marked pub op registered outside the wrapper family (a plain
|
||||||
|
`SinkHandler` on a marked pub spec) can never adopt — the bytes would
|
||||||
|
park and drop. That class is refused at registration (§4.8).
|
||||||
|
|
||||||
|
### 4.5 The ack-timing sentence (pin 5 — protocol semantics)
|
||||||
|
|
||||||
|
**A sink op gets exactly one `call.responded`, produced after the
|
||||||
|
handler completes — so a putter stream-waits nothing: the
|
||||||
|
establishment ack (the adopt + establishment reply fields) rides
|
||||||
|
informatively in that one result, never as a gating ack before the
|
||||||
|
byte stream.** The fetch/put asymmetry is direction-inherent: fetch
|
||||||
|
(`Sub`) acks at establishment — the `{channel_id}` response precedes
|
||||||
|
any byte (and the `{channel_id}` is the ack); put (`Pub`) acks in the
|
||||||
|
result. This is protocol semantics, not per-consumer folklore: a
|
||||||
|
consumer protocol layered on marked pub ops (alkblobs' put) cannot
|
||||||
|
wait for the ack without deadlocking (the response cannot exist until
|
||||||
|
the stream completes), and the amendment states it so no consumer
|
||||||
|
re-derives it.
|
||||||
|
|
||||||
|
### 4.6 Why `PubOpenHandler` is a separate type (pin record)
|
||||||
|
|
||||||
|
The alternative — changing `OpenHandler`'s return type — breaks every
|
||||||
|
published consumer's open handlers (alktty, alkhttp, alktunnels,
|
||||||
|
alkty, alksocks all register `Sub`-type openables) for zero benefit:
|
||||||
|
the `()`-returning shape is correct for the spawn-and-reconcile `Sub`
|
||||||
|
path (the channel outlives the open call; the wrapper never awaits
|
||||||
|
it), while the put path needs completion-in-response. The split keeps
|
||||||
|
S-01 capability-additive (review 011 §5: no shipped consumer registers
|
||||||
|
a `Pub`-typed openable); the put path's type is new API surface,
|
||||||
|
one-way like its siblings (ADR-046's handler-type door).
|
||||||
|
|
||||||
|
### 4.7 Relay translation replaces `PubTypedOpen` (pin 6)
|
||||||
|
|
||||||
|
`RelayError::PubTypedOpen` is removed. The hub's pub-openable
|
||||||
|
translation (the put-forwarding shape):
|
||||||
|
|
||||||
|
- The consumer-leg registration composes
|
||||||
|
`ChannelCore::register_pub_openable` with a relay establisher: the
|
||||||
|
hub **allocates** its connect-side id on the producer leg
|
||||||
|
(`open_channel` — odd, the implemented split), sends the open op
|
||||||
|
carrying the reserved key to the spoke **in the background** (never
|
||||||
|
awaited inside the establisher — the spoke's put response cannot
|
||||||
|
exist before its byte stream completes; awaiting it in the
|
||||||
|
establisher deadlocks the leg), and the `RelayPlan` carries the
|
||||||
|
spoke-leg send half, the early-arrival-drained read half, the
|
||||||
|
producer-leg entry reclaim guard (as before, ADR-051), and the
|
||||||
|
response receiver.
|
||||||
|
- The relay pub handler (the consumer-leg pub handler): pumps the
|
||||||
|
consumer-leg channel bytes → spoke send half (one direction, pub);
|
||||||
|
on consumer EOF, EOFs the spoke side; awaits the spoke's single
|
||||||
|
response; strips the producer's reserved reply key from the spoke
|
||||||
|
output (the hub's reply must carry the hub-allocated consumer-leg
|
||||||
|
id — a spoke `channel_id` merged through would shadow it, the same
|
||||||
|
reserved-key class the wrapper refuses) and returns the remainder as
|
||||||
|
the handler result — which the consumer-leg wrapper merges into the
|
||||||
|
single response with the pinned order.
|
||||||
|
- The spoke's open-time establishment failure surfaces in the final
|
||||||
|
response (put-acks-in-result), mapped through the existing
|
||||||
|
`map_spoke_error` table; the plan's reclaim guard tears down the
|
||||||
|
producer-leg entry on every handler path, as ADR-051 §6 already
|
||||||
|
pins for the sub direction.
|
||||||
|
- The hub-leg template's pub-typed assembly error becomes unreachable
|
||||||
|
(registered through the relay like the sub ones).
|
||||||
|
|
||||||
|
### 4.8 The registration invariants (the stub-class guard)
|
||||||
|
|
||||||
|
`OperationRegistry::register` enforces, at registration time:
|
||||||
|
|
||||||
|
- A marked spec with `publish_schema` → refused (§4.2).
|
||||||
|
- A marked spec registered through the generic path → refused: marked
|
||||||
|
specs are registered only through the channel-machinery seam
|
||||||
|
(`register_openable*`, `register_pub_openable`, the relay). The
|
||||||
|
wrapper-family registrations carry an internal registration flag the
|
||||||
|
generic paths cannot set; `CallConnection::register_imported` (the
|
||||||
|
overlay landing) and direct `registry.register` both hit the refusal
|
||||||
|
— for overlay imports because a marked op is unreachable by
|
||||||
|
composition (`OperationEnv::invoke` is request/response-only; the
|
||||||
|
marker's data plane has no overlay path), for direct registrations
|
||||||
|
because the channel machinery would silently not exist.
|
||||||
|
|
||||||
|
### 4.9 The client-side initiator-allocates capability (pin 7)
|
||||||
|
|
||||||
|
`ChannelClient::open_pub_channel(operation_id, input, alpn)`: allocates
|
||||||
|
the connect-side odd id locally (`open_channel` on its own manager),
|
||||||
|
inserts the reserved input key, sends the open op **in a background
|
||||||
|
task**, and returns `(id, send, recv, reply)` immediately — the caller
|
||||||
|
writes bytes on `send` without waiting for anything (put acks in
|
||||||
|
result, §4.5). `reply` is the awaited single merged response
|
||||||
|
(`Ok(Value)` — the full output; `Err(ChannelOpenError)` — the wire
|
||||||
|
error passthrough, with the local adopt entry torn down on failure).
|
||||||
|
The existing `open_channel`/`open_channel_with_reply` await the reply
|
||||||
|
before returning the halves — unusable for a put; this is the
|
||||||
|
putter-facing shape the wrapper is untestable end-to-end without.
|
||||||
|
|
||||||
|
### Door type
|
||||||
|
|
||||||
|
**One-way (wire-visible).** The reserved input key and its
|
||||||
|
strip-before-validation, the missing-key rejection, the
|
||||||
|
chunk-rejection code, the merge order, the single-response semantics,
|
||||||
|
and the ack-timing sentence are wire-visible from the first consumer
|
||||||
|
(alkblobs' put — which pends behind this amendment, so this is the
|
||||||
|
cheapest moment to pin; no shipped consumer rides any of it, review
|
||||||
|
011 §5). `PubOpenHandler`, `register_pub_openable`,
|
||||||
|
`open_pub_channel`, and the `adopt_failed` reason are new API surface,
|
||||||
|
one-way per the handler-API precedent. The early-arrival parking, the
|
||||||
|
wrapper internals, and the relay plan internals are two-way-door
|
||||||
|
details inside the one-way contract.
|
||||||
|
|
||||||
|
### Verification gates (implementation units, review 011 §3)
|
||||||
|
|
||||||
|
- known-length put e2e: offer + adopt + bytes → one response,
|
||||||
|
`{channel_id, ack fields, handler result}` merge order verified;
|
||||||
|
- unknown-length put e2e: the handler's digest rides the merged
|
||||||
|
response;
|
||||||
|
- publish-chunk rejection: a marked pub op carrying nonempty publish
|
||||||
|
chunks → loud rejection, no handler consumption;
|
||||||
|
- establishment failure: `channel:open_failed`, channel torn down,
|
||||||
|
ledger un-incremented, racing bytes EOF-discarded;
|
||||||
|
- `publish_schema` registration refusal on a marked op;
|
||||||
|
- relay translation: a hub translating a pub openable end-to-end.
|
||||||
|
- plus: missing reserved key rejection; `open_pub_channel` happy +
|
||||||
|
wire-failure paths (the client capability's gates).
|
||||||
|
|
||||||
|
### References
|
||||||
|
|
||||||
|
- Review 010 S-01 (the four stubs; the implement-or-stop-advertising
|
||||||
|
ruling), review 011 (the corrected shape this amendment pins)
|
||||||
|
- alkblobs ADR-014 (`014-marked-pub-put-contract-and-alkcall-gate`) —
|
||||||
|
the downstream ruling this amendment unblocks
|
||||||
|
- ADR-046 (the unmarked pub semantics that marked ops do NOT ride),
|
||||||
|
ADR-049 (establishment machinery + `RESERVED_REPLY_KEY` + merge),
|
||||||
|
ADR-040 (bounded buffer — the data-plane backpressure), ADR-050
|
||||||
|
(pumps), ADR-051 (the relay this amendment extends to pub), ADR-043
|
||||||
|
(`ChannelClient` — the `open_pub_channel` home)
|
||||||
|
- `src/channels/manager.rs` (`next_channel_id` odd/even;
|
||||||
|
`drain_early_arrivals`), `src/channels/operations.rs` (the wrapper
|
||||||
|
family), `src/channels/relay.rs`, `src/registry/registration.rs`
|
||||||
|
(`resolve_sink_handler` — the strip site)
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
*[Amendment 4 era, 2026-10-13: the dynamic half is now implemented —
|
||||||
|
ADR-053; this 2026-09-06 text records the state at its writing.]*
|
||||||
|
|
||||||
|
- `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)
|
## Amendment (§4 mechanism, 2026-09-03)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,481 @@
|
|||||||
|
# ADR-049: Channel-Open Establishment Phase (`OpenEstablisher`)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted — implemented in alkcall 0.5.0 (Unit 1: E-01 + N-1; see the
|
||||||
|
"Amendment (Unit 1 implementation, 2026-09-06)" at the bottom).
|
||||||
|
Amends ADR-047 §3 — the open-op wrapper gains an awaited
|
||||||
|
establishment phase ahead of the spawned pump handler; resolves review
|
||||||
|
006 E-01 and N-1. Amendment 3 (2026-09-16, review 008 U-2) adds the
|
||||||
|
establisher reply projection — see "Amendment 3" below.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-047 §3 made openable ALPNs operations: the ALPN crate supplies an
|
||||||
|
open handler, and the channels wrapper does the channel machinery
|
||||||
|
(allocation, ledger, policy, spawn). The §3 decision text describes the
|
||||||
|
handler's job as "validate params, consult ownership, prepare the
|
||||||
|
backend, return a 'channel plan'" — an awaited preparation step the
|
||||||
|
wrapper consults **before** replying. The implemented `OpenHandler`
|
||||||
|
type (`Arc<dyn Fn(Value, Connection, AuthContext) -> JoinHandle<()>`)
|
||||||
|
collapsed that preparation into a fire-and-forget spawn: the wrapper
|
||||||
|
collects the `JoinHandle`, records it for teardown, and writes
|
||||||
|
`{ "channel_id": <id> }` to the wire the moment the handler task is
|
||||||
|
*spawned* (`run_open_wrapper`, `src/channels/operations.rs`). The
|
||||||
|
establishment phase the ADR described never became a thing the wrapper
|
||||||
|
could consult.
|
||||||
|
|
||||||
|
The consequence (review 006 E-01, verified at tree `88e3f5e`): the open
|
||||||
|
op **cannot fail after allocation**. Any establishment failure inside
|
||||||
|
the handler — params valid at the schema level but semantically
|
||||||
|
rejected, backend lookup failure, a target dial refused for a
|
||||||
|
`direct-tcpip`-shaped tunnel, a resource no longer available — is
|
||||||
|
invisible to the open reply. The consumer observes: the call op
|
||||||
|
succeeds with `{channel_id}`, the channel is adopted, and then the
|
||||||
|
channel EOFs (the handler exits without writing; the mux pump writes
|
||||||
|
the implicit-EOF chunk; `MpscRecvStream::poll_read` returns clean EOF
|
||||||
|
for both the sentinel and sender-drop arms). A dial failure is
|
||||||
|
byte-for-byte indistinguishable from a target that closed immediately
|
||||||
|
after connecting — the two most different failure/success stories map
|
||||||
|
to the same consumer-visible event.
|
||||||
|
|
||||||
|
Every established tunnel/forwarding protocol puts establishment failure
|
||||||
|
in the open reply, not in the data stream:
|
||||||
|
|
||||||
|
- **SSH** (RFC 4254 §5.1): `SSH_MSG_CHANNEL_OPEN_FAILURE` is a
|
||||||
|
first-class reply carrying a reason code
|
||||||
|
(`ADMINISTRATIVELY_PROHIBITED` / `CONNECT_FAILED` /
|
||||||
|
`UNKNOWN_CHANNEL_TYPE` / `RESOURCE_SHORTAGE`) plus a description
|
||||||
|
string; the channel never exists on the opener's side afterward.
|
||||||
|
- **SOCKS5** (RFC 1928 §6): the reply carries REP codes 0x01–0x08;
|
||||||
|
error-then-close, never "success then in-stream error."
|
||||||
|
- **udpgw** (tun2proxy) is the counterexample: an opaque ERR bit with
|
||||||
|
zero reason information — the vocabulary to avoid.
|
||||||
|
|
||||||
|
The per-crate workaround proves the gap is load-bearing: alktty's
|
||||||
|
channels path answers establishment failures with a length-prefixed
|
||||||
|
JSON error frame **on the channel stream**
|
||||||
|
(`send_negotiation_error`, `alktty/src/adapter.rs`), disambiguated from
|
||||||
|
data by a `0x00` first-byte peek. That is a per-crate reinvention of a
|
||||||
|
protocol-level capability every ALPN crate will need: a structured,
|
||||||
|
typed, **establishment-failure reply to the open op**. It also forces
|
||||||
|
the phantom-opened channel to exist in the manager — allocation, ledger,
|
||||||
|
and policy all fire for a channel that never carries data.
|
||||||
|
|
||||||
|
alktunnels (the next consumer, arbitrary TCP/UDP tunnels over channels)
|
||||||
|
hits this on day one: its producer dials the tunnel target inside the
|
||||||
|
open op, and dial failure is the *common* case, not the edge case
|
||||||
|
(review 006 E-01; alktunnels OQ-TN-09).
|
||||||
|
|
||||||
|
This is the cheapest moment to fix upstream: three downstream crates
|
||||||
|
(alktty, alkhttp, alktunnels-in-progress), alkcall 0.4.x, no published
|
||||||
|
consumer depends on the phantom-open shape.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. The open-op wrapper gains an awaited establishment phase
|
||||||
|
|
||||||
|
`ChannelCore::register_openable` accepts an optional
|
||||||
|
**establisher** alongside the existing `OpenHandler`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// The establishment result. `Establishment` carries what the
|
||||||
|
/// pump phase needs (today: nothing — reserved for a channel plan).
|
||||||
|
/// `EstablishmentError` carries the reason code + message the
|
||||||
|
/// wrapper puts in the open reply's `details`.
|
||||||
|
pub type OpenEstablisher = Arc<
|
||||||
|
dyn Fn(Value, Connection, AuthContext)
|
||||||
|
-> BoxFuture<'static, Result<Establishment, EstablishmentError>>
|
||||||
|
+ Send
|
||||||
|
+ Sync,
|
||||||
|
>;
|
||||||
|
```
|
||||||
|
|
||||||
|
The establisher is **awaited by the wrapper, bounded** — before the
|
||||||
|
reply is written, before the pump handler is spawned. The pump handler
|
||||||
|
(the existing `OpenHandler`, unchanged) is spawned only on
|
||||||
|
establishment success. The dial is the natural establisher step for
|
||||||
|
tunnels; the pumps remain the spawned handler. This restores ADR-047
|
||||||
|
§3's original "channel plan" shape: the establisher is the awaited
|
||||||
|
preparation, the wrapper consults its result, the pumps are the
|
||||||
|
spawned protocol.
|
||||||
|
|
||||||
|
**Why split-hook, not await-and-inspect** (the two candidates review
|
||||||
|
006 proposed):
|
||||||
|
|
||||||
|
- The split preserves the `OpenHandler` type exactly. Await-and-inspect
|
||||||
|
changes `OpenHandler`'s return type (`JoinHandle<()>` →
|
||||||
|
`JoinHandle<OpenResult>`), breaking all three consumers' handlers and
|
||||||
|
the alkhttp `OpenableAlpn` ferry for no compensating gain.
|
||||||
|
- Establishment and pump are genuinely different lifecycles. The dial
|
||||||
|
is synchronous with the open reply (SSH's semantics: the failure is
|
||||||
|
the open's reply); the pumps outlive the reply. Coupling the
|
||||||
|
establishment signal to the pump task's lifecycle (watching a
|
||||||
|
`JoinHandle` for a first resolution) conflates them and makes the
|
||||||
|
"established, continue" signal an out-of-band convention (a sentinel
|
||||||
|
`Result` value, a oneshot the handler must remember to signal) —
|
||||||
|
more protocol per crate, the thing this fix exists to remove.
|
||||||
|
- The split is backward compatible by construction: no establisher
|
||||||
|
registered = an always-OK establisher. Existing registrations compile
|
||||||
|
and behave unchanged.
|
||||||
|
|
||||||
|
### 2. The deadline bounds the establisher, not the pumps
|
||||||
|
|
||||||
|
The establisher await is bounded by the dispatch deadline when the
|
||||||
|
`OperationContext` carries one (`context.deadline`), else by a crate
|
||||||
|
constant (`ESTABLISHMENT_TIMEOUT`, 10s default; overridable per
|
||||||
|
registration via a `Duration` argument on the establisher-taking
|
||||||
|
`register_openable` variant). On deadline expiry the wrapper treats it
|
||||||
|
as establishment failure with reason `timeout`.
|
||||||
|
|
||||||
|
The bound applies **only** to the establisher. The spawned pump
|
||||||
|
handler's lifetime is governed by the existing teardown machinery
|
||||||
|
(`channel/close`, connection drop, handler exit) — unchanged.
|
||||||
|
|
||||||
|
Head-of-line safety is already proven: the serving loop spawns Once
|
||||||
|
invocations as independent tasks
|
||||||
|
(`Dispatcher::spawn_once_dispatch`), so a slow establisher on one open
|
||||||
|
op does not block other calls on channel 0.
|
||||||
|
|
||||||
|
### 3. Establishment failure: teardown + typed `channel:open_failed`
|
||||||
|
|
||||||
|
On establishment failure (error or deadline), the wrapper:
|
||||||
|
|
||||||
|
1. Tears down the just-allocated channel
|
||||||
|
(`teardown_channel` — drops the demux sender, returns the not-yet-
|
||||||
|
installed handler task handle if any),
|
||||||
|
2. Takes the opener-ledger entry and calls `policy.on_close(opener)`
|
||||||
|
(the same un-increment path the allocation-failure arms already
|
||||||
|
run — the ledger `take` is the atomic gate, ADR-047 §7),
|
||||||
|
3. Replies with a new typed error:
|
||||||
|
|
||||||
|
```
|
||||||
|
code: "channel:open_failed"
|
||||||
|
message: human-readable establishment failure description
|
||||||
|
retryable: false
|
||||||
|
details: { "reason": <reason-code>, "message": <detail string> }
|
||||||
|
```
|
||||||
|
|
||||||
|
The reason-code vocabulary maps 1:1 onto what an establisher can
|
||||||
|
actually produce (per the SSH four; the survey's finding):
|
||||||
|
|
||||||
|
| reason | meaning |
|
||||||
|
|---|---|
|
||||||
|
| `dial_failed` | the backend/target could not be reached or refused |
|
||||||
|
| `unknown_resource` | the requested resource does not exist |
|
||||||
|
| `resource_shortage` | the backend is out of capacity (ports, fds, slots) |
|
||||||
|
| `handler_error` | establisher-internal failure not covered above |
|
||||||
|
| `timeout` | establishment exceeded the deadline |
|
||||||
|
|
||||||
|
Policy denial stays `channel:too_many_channels` (pre-allocation,
|
||||||
|
unchanged); ACL denial stays `FORBIDDEN` (registry gate, unchanged).
|
||||||
|
The new code is an additive wire addition (new error-code string +
|
||||||
|
optional `details` shape); no existing consumer breaks. ALPN crates'
|
||||||
|
open-op specs gain matching `ErrorDefinition` entries per ADR-016 so
|
||||||
|
`services/schema` discloses the failure contract.
|
||||||
|
|
||||||
|
The SSH "channel never exists opener-side" property is the contract:
|
||||||
|
the consumer's open resolves `Err` and no `channel_id` was ever
|
||||||
|
returned. (The allocation still happened accept-side momentarily —
|
||||||
|
that is invisible to the consumer and is what the teardown in step 1
|
||||||
|
cleans up.)
|
||||||
|
|
||||||
|
### 4. `ChannelClient::open_channel` stops erasing the error (review 006 N-1)
|
||||||
|
|
||||||
|
`ChannelClient::open_channel` currently flattens the `CallError` into a
|
||||||
|
`String` (`format!("open op failed: {e:?}")`), which would make the
|
||||||
|
typed reason invisible to consumers — the E-01 fix would be unreachable
|
||||||
|
end-to-end through the primary client path. It changes to return a
|
||||||
|
typed error carrying the `CallError` (a new
|
||||||
|
`ChannelOpenError { error: CallError }` or equivalent), so the
|
||||||
|
consumer branches on `channel:open_failed` + `details.reason`.
|
||||||
|
|
||||||
|
This is a breaking change to a method signature introduced in this
|
||||||
|
crate's 0.4.x — acceptable at 0.5.0 (see Consequences), and it is the
|
||||||
|
point of the change: the reason must be consumer-usable.
|
||||||
|
|
||||||
|
### 5. Compatibility and migration
|
||||||
|
|
||||||
|
- `OpenHandler`'s type is unchanged. Existing registrations compile
|
||||||
|
unchanged.
|
||||||
|
- `ChannelCore::register_openable` keeps its current signature
|
||||||
|
(no establisher = always-OK); a new
|
||||||
|
`register_openable_with_establisher(spec, establisher, open_handler,
|
||||||
|
registry, auth)` variant adds the hook. alkhttp's `OpenableAlpn`
|
||||||
|
gains an optional `establisher` field (default `None`) — the ferry
|
||||||
|
passes it through mechanically.
|
||||||
|
- alktty migrates its **channels path** semantic failures (unknown
|
||||||
|
backend, `carriage != "raw"`, `allocate_failed`, ownership denial —
|
||||||
|
currently post-open error frames) into the establisher, resolving
|
||||||
|
them as `channel:open_failed`. Its direct-ALPN path **keeps** the
|
||||||
|
in-band error frame (two transports, two contracts; the direct path
|
||||||
|
has no open op to fail). The `0x00`-peek disambiguation stays for
|
||||||
|
the direct path only.
|
||||||
|
- alkhttp is unaffected (no openable ops in the default surface; the
|
||||||
|
`OpenableAlpn` change is additive).
|
||||||
|
|
||||||
|
### 6. Panicked pump handlers stay EOF-shaped (pinned as designed)
|
||||||
|
|
||||||
|
The wrapper's teardown task swallows the pump handler's `JoinError`
|
||||||
|
(`let _ = raw_task.await`). A panicked pump = instant EOF, which is
|
||||||
|
the correct consumer-visible outcome for a mid-stream handler crash
|
||||||
|
(indistinguishable from an abrupt close — there is no error channel
|
||||||
|
mid-stream by design; establishment errors are the only kind that
|
||||||
|
belong in the open reply). This ADR pins that as intended; no change.
|
||||||
|
The establisher, by contrast, runs pre-reply — its panic (a future
|
||||||
|
that panics when polled) surfaces as the spawned Once task's panic,
|
||||||
|
which the serving loop already tolerates (the call never resolves;
|
||||||
|
the deadline / client timeout is the bound). Establisher
|
||||||
|
implementations return `EstablishmentError` instead of panicking, per
|
||||||
|
this crate's no-panic convention.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
|
||||||
|
- Establishment failure reaches the consumer as a typed, branchable
|
||||||
|
call error — retry policy, client UX, and error reporting become
|
||||||
|
possible for dial-refused, unknown-resource, and shortage cases
|
||||||
|
(previously: instant-EOF ambiguity).
|
||||||
|
- The SSH contract ("the channel never exists opener-side") holds
|
||||||
|
consumer-visibly: a failed open never returns a `channel_id`.
|
||||||
|
- No phantom channels: the ledger, policy count, and manager state are
|
||||||
|
restored atomically on failure — allocation and teardown balance.
|
||||||
|
- alktty's per-crate in-band error vocabulary is retired on the
|
||||||
|
channels path; every future ALPN crate (alktunnels first) gets the
|
||||||
|
establishment reply for free.
|
||||||
|
- ADR-047 §3's "channel plan" shape is realized: awaited preparation
|
||||||
|
before reply, spawned pumps after.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
|
||||||
|
- `channel:open_failed` + the reason vocabulary is a new wire-visible
|
||||||
|
error surface — additive, but it joins the stable error set
|
||||||
|
consumers may branch on (per ADR-016, `details` shapes are
|
||||||
|
discoverable via `services/schema`).
|
||||||
|
- `ChannelClient::open_channel`'s error type changes (breaking at
|
||||||
|
0.5.0; mechanical for consumers — the `String` was a
|
||||||
|
debug-formatting wrapper anyway).
|
||||||
|
- `OpenableAlpn` (alkhttp) gains a field; its two construction sites
|
||||||
|
add `None` (mechanical).
|
||||||
|
- The establisher await adds a bounded latency to open-op replies
|
||||||
|
where handlers previously replied instantly (the spawn). The 10s
|
||||||
|
default is the worst case for a hung establisher; real establishers
|
||||||
|
(dial, lookup) complete in dial-time. Consumers already tolerate
|
||||||
|
call-op latency; the deadline is the bound.
|
||||||
|
|
||||||
|
## Door type
|
||||||
|
|
||||||
|
**One-way (wire-visible error surface).** `channel:open_failed` and its
|
||||||
|
`details.reason` vocabulary join the stable error set: once consumers
|
||||||
|
branch on reason codes, changing the vocabulary requires a migration
|
||||||
|
(the same one-way-ness ADR-016 gives typed error details). The
|
||||||
|
establisher hook shape itself — `OpenEstablisher`, the
|
||||||
|
`register_openable_with_establisher` variant, the
|
||||||
|
`Establishment`/`EstablishmentError` types — is a **two-way-door
|
||||||
|
implementation detail** within the one-way decision (the wrapper shape,
|
||||||
|
per ADR-047 §3's own door-type note). The `OpenHandler` type is
|
||||||
|
untouched, which is what keeps the split cheap to revise.
|
||||||
|
|
||||||
|
## Implementation units
|
||||||
|
|
||||||
|
1. **alkcall 0.5.0** — `OpenEstablisher` +
|
||||||
|
`register_openable_with_establisher`; wrapper flow (await bounded →
|
||||||
|
teardown-on-failure → `channel:open_failed` with details);
|
||||||
|
`ChannelClient::open_channel` typed error (N-1); tests:
|
||||||
|
- establisher fails after allocation → consumer's `open_channel`
|
||||||
|
resolves `Err(channel:open_failed)` + reason details; channel
|
||||||
|
absent from `channel_ids()` afterward;
|
||||||
|
- establisher never completes → `timeout`-reason failure within the
|
||||||
|
deadline, channel torn down, ledger decremented;
|
||||||
|
- no-establisher registration behaves exactly as today (compat
|
||||||
|
gate);
|
||||||
|
- establisher success spawns pumps and replies `{channel_id}`
|
||||||
|
unchanged.
|
||||||
|
2. **alktty migration** — channels-path semantic failures move into an
|
||||||
|
establisher; `open_via_channels_surfaces_negotiation_rejected`
|
||||||
|
resolves via call error; the direct-ALPN error-frame path is
|
||||||
|
retained.
|
||||||
|
3. **alkhttp pass** — `OpenableAlpn.establisher: Option<...>` (default
|
||||||
|
`None`), threaded through the session fork (mechanical).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Review 006 E-01 (the establishment gap — findings and prior-art
|
||||||
|
survey), N-1 (the client error-type gap this ADR also resolves),
|
||||||
|
E-03/E-04 (adjacent teardown/early-arrival notes, filed separately
|
||||||
|
from this ADR's scope)
|
||||||
|
- ADR-047 §3 (openable ALPNs are operations — the "channel plan"
|
||||||
|
wrapper shape this ADR restores; §7 opener ledger — the teardown
|
||||||
|
un-increment path)
|
||||||
|
- ADR-016 (typed error schemas — the `details` vehicle)
|
||||||
|
- ADR-040/041 (backpressure/caps — untouched; the teardown path keeps
|
||||||
|
the ledger `take` as the atomic gate)
|
||||||
|
- alktty ADR-009 (the open op's input is the negotiation) + review 001
|
||||||
|
L1/L3 — the in-band mechanism retired on the channels path
|
||||||
|
- alktunnels OQ-TN-09 (dial-failure reporting — the first consumer of
|
||||||
|
the new error) and `docs/research/ssh-socks5-survey.md`
|
||||||
|
§"Open-failure path" (the reason-code prior art)
|
||||||
|
- RFC 4254 §5.1, RFC 1928 §6 — SSH/SOCKS5 open-failure semantics
|
||||||
|
|
||||||
|
## Amendment (Unit 1 implementation, 2026-09-06)
|
||||||
|
|
||||||
|
Unit 1 landed in alkcall 0.5.0. Two implementation-shape notes, both
|
||||||
|
within this ADR's two-way door (the hook shape is the revisable
|
||||||
|
implementation detail; the wire surface is unchanged from §1/§3):
|
||||||
|
|
||||||
|
1. **The establisher does not receive the channel `Connection`.** §1's
|
||||||
|
signature sketch passed `Connection` to the establisher, but the
|
||||||
|
channel's `BiStream` is yield-once (`ChannelBidiStreamSource`) — it
|
||||||
|
cannot be handed to both the establisher and the pump handler, and
|
||||||
|
the establisher is pre-data-plane by design (its dial targets the
|
||||||
|
backend, not the channel). The implemented signature is
|
||||||
|
`Fn(Value, AuthContext) -> BoxFuture<'static,
|
||||||
|
Result<Establishment, EstablishmentError>>`; the `Connection`
|
||||||
|
belongs exclusively to the `OpenHandler` (unchanged).
|
||||||
|
**Identity semantics (CF-005 corollary, 2026-09-07):** the
|
||||||
|
`AuthContext` the establisher and the pump handler receive is the
|
||||||
|
**per-call** context — the opener's dispatch-resolved identity (the
|
||||||
|
same identity the ACL gate and the per-identity cap check saw)
|
||||||
|
overlaid onto the install-time context; transport-truthful fields
|
||||||
|
(`alpn`, `remote_addr`, `tls_client_fingerprint`) stay
|
||||||
|
install-time. On a per-connection registry (ADR-047 §4) this is
|
||||||
|
identical to the install-time context; on hub-forwarded opens the
|
||||||
|
establisher sees the end client, not the hub.
|
||||||
|
2. **The bound is the earlier of the dispatch deadline and the
|
||||||
|
per-registration timeout.** §2 names the dispatch deadline "when
|
||||||
|
the `OperationContext` carries one, else the crate constant";
|
||||||
|
implemented as `min(deadline_remaining, timeout_override |
|
||||||
|
ESTABLISHMENT_TIMEOUT)` — the registration override stays
|
||||||
|
meaningful for `Query`/`Mutation`-typed open ops (whose dispatch
|
||||||
|
carries a 30s deadline; `Sub` clears it), and a deadline already in
|
||||||
|
the past yields a zero bound (immediate `timeout` reason).
|
||||||
|
|
||||||
|
Implemented surface: `OpenEstablisher`, `Establishment`,
|
||||||
|
`EstablishmentError` (reasons `dial_failed`/`unknown_resource`/
|
||||||
|
`resource_shortage`/`handler_error` + the wrapper's `timeout`),
|
||||||
|
`ESTABLISHMENT_TIMEOUT` (10s), `CHANNEL_OPEN_FAILED`
|
||||||
|
(`channel:open_failed`), `ChannelCore::register_openable_with_establisher`
|
||||||
|
(`register_openable` delegates with `establisher: None`),
|
||||||
|
`ChannelOpenError` (client-side typed error: `CallFailed { error:
|
||||||
|
CallError }` / `MissingChannelId` / `AdoptFailed`, with
|
||||||
|
`call_error()` + `establishment_reason()` accessors). All four
|
||||||
|
verification gates from the review landed as tests (establisher
|
||||||
|
failure e2e through a real channels connection with ledger
|
||||||
|
un-increment + no-channel assertions, bounded timeout, no-establisher
|
||||||
|
compat, establisher-success pump round-trip).
|
||||||
|
|
||||||
|
## Amendment 2 (plan payload, 2026-09-07 — review 007 R-01/R-02)
|
||||||
|
|
||||||
|
Review 007 (from the alktunnels UDP POC) filed two follow-ups on the
|
||||||
|
establishment surface; both landed in alkcall 0.6.0.
|
||||||
|
|
||||||
|
**1. `Establishment` carries the channel plan (R-01).** §1 reserved
|
||||||
|
the payload ("today: nothing") and the wrapper consulted only
|
||||||
|
success/failure — so an establisher whose backend produces a handle
|
||||||
|
(a dialed socket, a TTY allocation) had to cross it to the pump
|
||||||
|
handler through a per-crate side channel. The alktunnels POC shipped
|
||||||
|
a resource-keyed slot + poll loop whose concurrent same-resource race
|
||||||
|
is unfixable within that shape; alktty documented the same wall
|
||||||
|
(backend `allocate` cannot cross, so failure classes stayed in-band —
|
||||||
|
the phantom-channel shape ADR-049 removed, alive one layer down).
|
||||||
|
|
||||||
|
The plan is now real: `Establishment { plan: Option<ChannelPlan> }` with
|
||||||
|
`ChannelPlan = Arc<dyn Any + Send + Sync>` — **typed-opaque, not
|
||||||
|
`serde_json::Value`**. The review's `Option<Value>` sketch could not
|
||||||
|
satisfy its own verification gate ("establisher dials, `plan` carries
|
||||||
|
the handle"): the payloads establishers actually hand off are live
|
||||||
|
handles with no JSON representation. The establisher and the
|
||||||
|
`OpenHandler` agree on the concrete type; alkcall never inspects it.
|
||||||
|
The wrapper threads `establishment.plan` to the handler's new second
|
||||||
|
parameter (`OpenHandler = Fn(Value, Option<ChannelPlan>, Connection,
|
||||||
|
AuthContext) -> JoinHandle<()>`); the separate-parameter shape wins
|
||||||
|
over merging into `input` because a typed payload cannot ride the
|
||||||
|
JSON input without a downcast-side registry and the reserved-key
|
||||||
|
collision the review already anticipated. The plan is process-local
|
||||||
|
(establisher → wrapper → handler on the producing side); the wire
|
||||||
|
surface is unchanged — nothing crosses the transport that isn't
|
||||||
|
already the open op's input. `#[non_exhaustive]` on `Establishment`
|
||||||
|
keeps a future carrier change from being another breaking release.
|
||||||
|
Construction is `Establishment::new(plan)` /
|
||||||
|
`Establishment::default()`; the 0.5.0 `Ok(Establishment {})` sites
|
||||||
|
break mechanically at 0.6.0, which is the point of landing this now
|
||||||
|
(before alktunnels Phase 1 ships the side-channel shape into a real
|
||||||
|
crate and the payload lands later anyway as a second break).
|
||||||
|
|
||||||
|
**2. The `OpenHandler` lifetime contract is documented (R-02).** The
|
||||||
|
wrapper awaits the returned `JoinHandle` and its completion triggers
|
||||||
|
teardown — so the handle must track the data-plane lifetime: a
|
||||||
|
handler that returns before its pumps finish tears the channel down
|
||||||
|
at birth (the POC's first pump implementation hit exactly this: every
|
||||||
|
tunnel connected then instantly EOF'd). The contract was implemented
|
||||||
|
but never documented; the type docs now state it ("await the pumps
|
||||||
|
inline, never spawn-and-forget and return early") on `OpenHandler`
|
||||||
|
and the registration entry points, plus a `debug!` telemetry line in
|
||||||
|
`run_open_wrapper` when a handler exits without having accepted the
|
||||||
|
channel's `BiStream` (the birth-teardown hint; the accept is
|
||||||
|
observable in-process via the yield-once source). §6's pinned
|
||||||
|
EOF-shaped panic semantics are unchanged.
|
||||||
|
|
||||||
|
## Amendment 3 (establisher reply projection, 2026-09-16 — review 008 U-2)
|
||||||
|
|
||||||
|
Amendment 2 filled the establisher → handler direction (the plan);
|
||||||
|
the establisher → opener direction stayed empty — the wrapper
|
||||||
|
hardcodes the success reply to `channel_id`
|
||||||
|
(`run_open_wrapper`'s `ResponseEnvelope::ok`). alktunnels ADR-008's
|
||||||
|
bind-first listen establisher ends establishment at bind time, and the
|
||||||
|
observed OS-chosen bound address must ride the open-op reply as an
|
||||||
|
additive `"bound"` field (the SOCKS5 BIND reply#1 `BND.ADDR` fidelity
|
||||||
|
ask) — the alternative (an out-of-band query op) is a new wire
|
||||||
|
surface, strictly worse than an optional reply field.
|
||||||
|
|
||||||
|
**The decision: `Establishment` gains optional reply fields the
|
||||||
|
wrapper merges into the success output.**
|
||||||
|
|
||||||
|
- The carrier: `Establishment.reply_fields: Option<Map<String,
|
||||||
|
Value>>` (private, builder-constructed) —
|
||||||
|
`Establishment::new(plan).with_reply_field("bound", json!({...}))`,
|
||||||
|
plus `with_reply_fields(map)` (batch) and `reply_fields()` (read).
|
||||||
|
The map shape generalizes beyond `bound` without a fourth
|
||||||
|
amendment; `#[non_exhaustive]` (amendment 2) made the carrier
|
||||||
|
extension additive — no construction-site break.
|
||||||
|
- The merge: on establisher success the wrapper extends the success
|
||||||
|
output with the contributed fields AFTER reserving `channel_id`.
|
||||||
|
**The `channel_id` reservation:** the key is wrapper-owned; an
|
||||||
|
establisher supplying it is an establisher bug — the wrapper fails
|
||||||
|
the open loudly (`channel:open_failed`, reason `handler_error`,
|
||||||
|
message naming the reserved key) and tears the just-allocated
|
||||||
|
channel down, never silently shadowing its own value. Contributed
|
||||||
|
fields otherwise merge flat; the output schema remains the op's own
|
||||||
|
concern (the producing crate documents its optional fields —
|
||||||
|
alktunnels' listen-op spec documents `bound`).
|
||||||
|
- The client read path: `ChannelClient::open_channel_with_reply`
|
||||||
|
returns `(channel_id, reply, send, recv)` — the full success
|
||||||
|
output — so a consumer reads `bound` without dropping to
|
||||||
|
`call_open_op` + manual `adopt_channel`;
|
||||||
|
`open_channel` delegates and discards the extra fields, signature
|
||||||
|
unchanged.
|
||||||
|
- Compatibility: absent reply fields leave the reply byte-identical
|
||||||
|
to the pre-amendment shape (`{ channel_id }`) — verified by test.
|
||||||
|
Old consumers (`open_channel` extracting `channel_id`, ignoring
|
||||||
|
unknown fields) are unaffected by a NEW field; new consumers reading
|
||||||
|
`bound` against an OLD alkcall see the field absent — the additive
|
||||||
|
posture alktunnels ADR-008 §2 pins.
|
||||||
|
|
||||||
|
Door type: the reply fields are an additive call-plane JSON surface
|
||||||
|
(like `channel:open_failed`'s `details` in §3 — consumers ignore
|
||||||
|
unknown fields by the envelope's own parse posture); the field set an
|
||||||
|
op may contribute is the producing crate's schema concern, not a
|
||||||
|
protocol vocabulary. No data-plane change.
|
||||||
|
|
||||||
|
Implemented surface (alkcall 0.8.0): `Establishment::with_reply_field`
|
||||||
|
/ `with_reply_fields` / `reply_fields`, the wrapper's
|
||||||
|
reservation check (`RESERVED_REPLY_KEY` = `channel_id`) +
|
||||||
|
`merge_reply_fields`, `ChannelClient::open_channel_with_reply`.
|
||||||
|
Verification gates landed as tests: the projection produces
|
||||||
|
`{ channel_id, bound }` on the wire; no-fields (establisher or not)
|
||||||
|
produces exactly `{ channel_id }` (byte-identical); the reserved-key
|
||||||
|
establisher fails with reason `handler_error`, channel torn down,
|
||||||
|
ledger decremented, handler never spawned; the e2e gate carries
|
||||||
|
`bound` through a real channels connection to
|
||||||
|
`open_channel_with_reply` while `open_channel` stays unchanged.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# ADR-050: `pump_bidi` — the two-pump helper, extracted upstream
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted — implemented in alkcall 0.6.0 (review 007 Unit 3 / R-03).
|
||||||
|
Pins alknet ADR-078's two-pump contract in one place.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
alknet ADR-078 defined the two-pump data-plane contract for forwarding
|
||||||
|
channels (one pump per direction; each pump shuts the opposite sink
|
||||||
|
down on completion — `try_join!` alone deadlocks) and deferred helper
|
||||||
|
extraction until a second two-pump consumer existed and the shapes
|
||||||
|
converged. Review 007 (R-03) supplied both halves of that test from
|
||||||
|
the alktunnels UDP POC:
|
||||||
|
|
||||||
|
- **Producer side:** the POC's `pump_halves` — two `tokio::io::copy`
|
||||||
|
pumps over split channel-vs-substrate halves, each shutting down
|
||||||
|
the opposite sink on completion, joined.
|
||||||
|
- **Consumer side:** `TunnelSession::take_halves` + the assembly
|
||||||
|
layer's copy — the same shape modulo channel side.
|
||||||
|
|
||||||
|
alktty's channels session already implements the shape (its
|
||||||
|
`drive_session_pre_negotiated` awaited inline); alkhttp's ferry does
|
||||||
|
not pump. The shapes converged; the review asked this sweep to either
|
||||||
|
extract the helper or record the decision not to — an un-extracted
|
||||||
|
helper would surface during alktunnels Phase 1 as the same
|
||||||
|
fix→publish→update treadmill for a purely additive change.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Extract the helper as `channels::pump_bidi`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub async fn pump_bidi<C, R, W>(channel: C, peer_read: R, mut peer_write: W) -> (u64, u64)
|
||||||
|
where
|
||||||
|
C: AsyncRead + AsyncWrite + Unpin,
|
||||||
|
R: AsyncRead + Unpin,
|
||||||
|
W: AsyncWrite + Unpin,
|
||||||
|
```
|
||||||
|
|
||||||
|
Two pumps, joined: `channel → peer_write` and `peer_read → channel`.
|
||||||
|
On each pump's completion the opposite sink is shut down
|
||||||
|
(shutdown-on-completion — the half-close the contract specifies).
|
||||||
|
Returns the two copy counts for observability.
|
||||||
|
|
||||||
|
Three deliberate deviations from the review's sketch, all
|
||||||
|
semantics-first:
|
||||||
|
|
||||||
|
1. **Return `(u64, u64)`, not `io::Result<(u64, u64)>`.** There is no
|
||||||
|
meaningful `Err` state: both pumps swallow copy errors by contract
|
||||||
|
— a mid-stream error is indistinguishable from an abrupt close
|
||||||
|
(no error channel exists mid-stream, per ADR-049 §6's pinned
|
||||||
|
EOF-shaped semantics), and shutdown-of-the-opposite-sink runs
|
||||||
|
either way. A returned `Err` would be dead code; the counts
|
||||||
|
(with `unwrap_or(0)` on error) are the observability.
|
||||||
|
2. **Peer side takes split halves (`peer_read: R`, `peer_write: W`),
|
||||||
|
not one `AsyncRead + AsyncWrite` value.** The dominant producer
|
||||||
|
shape is the establisher's dial result: `TcpStream::into_split`
|
||||||
|
halves (tunnels), pty pair halves (TTY). Taking halves matches the
|
||||||
|
POC (`pump_halves`) and avoids forcing consumers to re-join halves
|
||||||
|
just to satisfy a bound. The channel side stays a single value —
|
||||||
|
that is what the pump handler's `accept_bi` yields.
|
||||||
|
3. **Not `async fn` over `AsyncWriteExt` bounds on both sides.** The
|
||||||
|
channel side needs `tokio::io::split` (one value, two pumps), so
|
||||||
|
its bound is `AsyncRead + AsyncWrite + Unpin`; the `AsyncWriteExt`
|
||||||
|
bound the sketch had on every parameter is the extension trait
|
||||||
|
only where shutdown is called.
|
||||||
|
|
||||||
|
alktty's three-pump session does not fit the helper (the exit future
|
||||||
|
is a third signal) and stays as-is — the helper serves the two-pump
|
||||||
|
shape, exactly as the review scoped.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
Purely additive: a new pub fn in `channels::pump`, re-exported in the
|
||||||
|
module docs; no existing surface changes. Consumers may adopt it at
|
||||||
|
their own pace (alktunnels Phase 1 gets it for free; alktty's
|
||||||
|
channels session can migrate later if it wants; the POC's shape is
|
||||||
|
now canonical).
|
||||||
|
|
||||||
|
## Door type
|
||||||
|
|
||||||
|
**Two-way.** A helper function is trivially removable or re-shapable;
|
||||||
|
no wire surface, no trait, no state.
|
||||||
|
|
||||||
|
## Verification gate
|
||||||
|
|
||||||
|
The POC's two-pump semantics reproduced through the helper, as unit
|
||||||
|
tests in `src/channels/pump.rs`: data flows both directions with
|
||||||
|
exact copy counts; EOF from one side completes the other's
|
||||||
|
shutdown-on-completion (the far end observes clean EOF, not an
|
||||||
|
error); a dead source is EOF-shaped teardown, not an `Err`.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Review 007 R-03 (the extraction ask + the convergence evidence)
|
||||||
|
- alknet ADR-078 (the two-pump contract; shutdown-on-completion)
|
||||||
|
- alktunnels `poc-summary.md` §Issues Surfaced #4 (the producer-side
|
||||||
|
shape; `pump_halves` in the POC's producer.rs)
|
||||||
|
- alktty `src/channels.rs` (the channels session shape; three-pump
|
||||||
|
carve-out)
|
||||||
|
- ADR-049 amendment 2 (R-02 — the lifetime contract the helper's
|
||||||
|
callers must respect: await `pump_bidi(..).await` inline inside the
|
||||||
|
handler task)
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
# 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 (ADR-052): the control surface is
|
||||||
|
establisher-contributed (`Establishment::with_control`) on each leg's
|
||||||
|
own allocation — the hub never relays a `channel/control` between legs;
|
||||||
|
a hub wanting cross-leg control composes it with the per-leg control
|
||||||
|
handles, not with a translation surface. 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~~ **translated** (ADR-047
|
||||||
|
amendment 4 §4.7 — the put-forwarding translation: the hub allocates
|
||||||
|
its connect-side id, opens the spoke op in the background,
|
||||||
|
pumps the consumer bytes → spoke, and mirrors the spoke's single
|
||||||
|
response with its reserved key stripped; `PubTypedOpen` is removed).
|
||||||
|
The rejection remains for the genuinely-unsupported shapes: unmarked
|
||||||
|
specs (`UnmarkedSpec`) only.
|
||||||
|
- **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)
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# ADR-052: The `channel/control` Control-Handle Surface
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted — amends ADR-037 (`channel/control`'s contract) and ADR-047
|
||||||
|
(amended §1: the generic ops stay). Resolves review 010 S-02 (the
|
||||||
|
`channel/control` stub shipped nine published versions; the stub was
|
||||||
|
pinned by tests and tracked as OQ-39) by implementing the surface the
|
||||||
|
ADR-037 text always promised. The stub's tracked design work (OQ-39)
|
||||||
|
is promoted here verbatim-ish: ALPN-crate-registered per-channel
|
||||||
|
control callbacks, a channels-layer callback registry keyed by
|
||||||
|
`channel_id`.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-037 specified `channel/control` as the out-of-band control op on
|
||||||
|
channel 0 (resize, signal, keepalive — anything that does not need
|
||||||
|
ordering relative to data), with the channels layer routing the
|
||||||
|
ALPN-specific `message` to a handler's control handle. The
|
||||||
|
implementation shipped a registered, discoverable stub that checked
|
||||||
|
`channel` existence and then returned `channel:control_not_implemented`
|
||||||
|
on every invocation — which perversely returned *not found* for unknown
|
||||||
|
channels and *not implemented* for real ones (review 010 S-02). The
|
||||||
|
implementation was tracked as OQ-39 with the shape recorded; nothing
|
||||||
|
blocked it except that no ALPN crate had asked yet. alktty's move of
|
||||||
|
its control surface to the channels path (ADR-049 §5's migration list)
|
||||||
|
and the general stub ruling (review 010) make that answer invalid.
|
||||||
|
|
||||||
|
The design constraint from ADR-039: `ChannelManager` is auth-blind,
|
||||||
|
ALPN-blind, transport-blind. The control-handle surface must not
|
||||||
|
reintroduce ALPN interpretation into the manager.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. The control callback type and registry
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub type ChannelControl = Arc<
|
||||||
|
dyn Fn(Value) -> BoxFuture<'static, Result<Value, CallError>> + Send + Sync,
|
||||||
|
>;
|
||||||
|
```
|
||||||
|
|
||||||
|
The callback receives the `message` value (ALPN-specific JSON; the
|
||||||
|
channels layer does not interpret it) and returns the ALPN-specific
|
||||||
|
reply (or a `CallError` it wants surfaced verbatim — e.g.
|
||||||
|
`target_gone`). The live handle: `HashMap<u32, ChannelControl>` in the
|
||||||
|
`ChannelManager`'s internals (the same map discipline as `channels`;
|
||||||
|
`parking_lot` mutex).
|
||||||
|
|
||||||
|
The manager stays blind to what a callback does — it stores opaque
|
||||||
|
closures, exactly as `ChannelState` already stores opaque `handler_task`
|
||||||
|
handles.
|
||||||
|
|
||||||
|
### 2. Registration: the establisher contributes the control handle
|
||||||
|
|
||||||
|
The ALPN crate registers a control callback by returning it from its
|
||||||
|
open-op establisher: `Establishment::with_control(callback)` (builder,
|
||||||
|
alongside `with_reply_field` — ADR-049 amendment 3). The **wrapper**
|
||||||
|
installs it under the allocated/adopted id
|
||||||
|
(`manager.set_control_handle(id, callback)`) and every teardown path
|
||||||
|
removes it (`teardown_channel` drops the entry; `clear_all` drains
|
||||||
|
them) — same closure-lifecycle discipline as the opener ledger.
|
||||||
|
|
||||||
|
Why the establisher, not the handler: the establisher already returns
|
||||||
|
typed payloads the wrapper routes (`Establishment`), it runs where the
|
||||||
|
ALPN crate has prepared its backend (the resize target is the backend
|
||||||
|
the establisher dialled/planned), and adding a registration path
|
||||||
|
through the `OpenHandler` would require handing the handler the
|
||||||
|
manager and the id — a signature break across every published consumer
|
||||||
|
for zero benefit. The wrapper-owned installation also makes the
|
||||||
|
callback's lifetime exactly the channel's lifetime by construction.
|
||||||
|
|
||||||
|
### 3. The op: routing and error surface
|
||||||
|
|
||||||
|
`make_control_handler` (the ADR-037 op, registered by
|
||||||
|
`ChannelOps::register_on`) becomes: validate `channel_id` (unchanged)
|
||||||
|
→ `channel` exists? (unchanged; `channel:unknown_channel`) → callback
|
||||||
|
registered for the id? →
|
||||||
|
|
||||||
|
| case | reply |
|
||||||
|
|------|-------|
|
||||||
|
| unknown channel | `channel:unknown_channel` (existing class) |
|
||||||
|
| no callback registered | `channel:no_control_handle` — typed, retryable false, message: the channel's ALPN registered no control surface (`details: { alpn }`) |
|
||||||
|
| callback ok | `{ "control": <callback reply> }` |
|
||||||
|
| callback err | the callback's `CallError` verbatim |
|
||||||
|
|
||||||
|
`channel:no_control_handle` is a real capability answer, not a stub:
|
||||||
|
a consumer that calls `channel/control` on a channel whose ALPN chose
|
||||||
|
not to expose control learns exactly that. The `message` body is
|
||||||
|
ALPN-specific JSON (unchanged; the channels layer does not read it).
|
||||||
|
|
||||||
|
**The callback receives the message only** — no identity, no
|
||||||
|
`OperationContext`. The authorization home is the op's own
|
||||||
|
`access_control` (checked by the registry before the handler, per
|
||||||
|
ADR-010/017 — the same op-level ACL every other surface runs); a
|
||||||
|
per-owner resize is expressed as ADR-011 `resource_id_path` on the
|
||||||
|
`channel/control` spec for ALPNs that want it, not by widening the
|
||||||
|
callback signature. Wire-visibility: `channel:no_control_handle`
|
||||||
|
joins the stable error set (ADR-016 — consumers branch on it
|
||||||
|
deliberately); the callback's reply shape is the producing crate's
|
||||||
|
schema concern.
|
||||||
|
|
||||||
|
### 4. What this does not decide
|
||||||
|
|
||||||
|
Ordering-sensitive control rides the data channel (unchanged —
|
||||||
|
channel-operations.md's control-message division, DP-4 pinned). The
|
||||||
|
manager keeps its blindness; the enumerators registry of ADR-053 is
|
||||||
|
the analogous ALPN-level surface for discovery, not control.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:** the advertised op serves; S-02 dies; ALPN crates get
|
||||||
|
out-of-band control for free (resize/signal arrive as one routed
|
||||||
|
call-op instead of per-crate in-band framing); teardown-bound callback
|
||||||
|
lifetime is structural.
|
||||||
|
|
||||||
|
**Negative:** the manager's internals gain one map (opaque closures —
|
||||||
|
no blindness regression); a control callback racing teardown can
|
||||||
|
observe a torn-down backend through its own error (`CallError`) — the
|
||||||
|
same shape the handler EOF path already produces, not a new class.
|
||||||
|
|
||||||
|
## Door type
|
||||||
|
|
||||||
|
**One-way (wire-visible).** `channel:no_control_handle` and the reply
|
||||||
|
envelope shape join the stable surface once consumers branch on them.
|
||||||
|
The callback type, the establisher carrier, and the map are two-way-door
|
||||||
|
implementation details inside it (the `Establishment` carrier is
|
||||||
|
`#[non_exhaustive]` — ADR-049 amendment 2's posture).
|
||||||
|
|
||||||
|
## Verification gates
|
||||||
|
|
||||||
|
- `channel/control` routes to the establisher-contributed callback and
|
||||||
|
the reply is the callback's output (e2e over a real channels
|
||||||
|
connection);
|
||||||
|
- `channel:no_control_handle` on a channel whose ALPN registered none;
|
||||||
|
- `channel:unknown_channel` preserved (the pre-existing error);
|
||||||
|
- teardown removes the handle (post-teardown control answers
|
||||||
|
`channel:unknown_channel`, never a stale callback);
|
||||||
|
- callback `Err` passes through verbatim.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Review 010 S-02 (the stub; the implement ruling), review 011 §4
|
||||||
|
(downstream-safe; implement preferred)
|
||||||
|
- OQ-39 (the promoted design note this ADR resolves)
|
||||||
|
- ADR-037 (the op's contract), ADR-047 (openable ALPNs; the wrapper
|
||||||
|
this registration rides), ADR-049 (the establisher carrier this
|
||||||
|
registers through), ADR-039 (manager blindness), ADR-011
|
||||||
|
(`resource_id_path` — the per-owner control-gate lever)
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# ADR-053: `channel/resources/subscribe` — Live Resource Enumerators
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted — implements ADR-037 §6's dynamic half and ADR-047 §6's
|
||||||
|
discovery split ("what is currently there"). Resolves review 010 S-03
|
||||||
|
(the `channel/resources/subscribe` stub: a fake one-element error
|
||||||
|
stream shipped nine published versions, pinned by tests, tracked as
|
||||||
|
OQ-40) by implementing the aggregated live subscription. OQ-40's
|
||||||
|
promotion: each ALPN crate provides a resource enumerator; the
|
||||||
|
channels layer aggregates them into a live `Stream`.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The static half is covered (ADR-047's 2026-09-06 amendment:
|
||||||
|
`OperationSpec.description` on `services/list` describes the op — not
|
||||||
|
the produced resource set). The dynamic half — "which tunnel resources
|
||||||
|
may I open, live" — was deferred (OQ-40) and then shipped as a stub; a
|
||||||
|
consumer discovering the op got a capability that does not exist
|
||||||
|
(review 010 S-03). The load-bearing consumer (alktunnels' discovery UI,
|
||||||
|
review 006 E-02) uses config-known op names today, but the stub ruling
|
||||||
|
makes deferral unavailable as a posture: registered means implementable.
|
||||||
|
|
||||||
|
ADR-037 §6 + ADR-047 §6 pinned the shape: each ALPN crate that
|
||||||
|
registers open ops provides a resource enumerator (which containers
|
||||||
|
are running; which TTY sessions are active); `channel/resources/subscribe`
|
||||||
|
aggregates across registered openable ALPNs; the data source lives in
|
||||||
|
the ALPN crate. What was never pinned: the registration seam, the
|
||||||
|
enumerator contract, and the aggregation semantics. This ADR pins them.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. The enumerator contract
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub type ResourceStream = Pin<Box<dyn Stream<Item = Value> + Send>>;
|
||||||
|
|
||||||
|
pub trait ResourceEnumerator: Send + Sync + 'static {
|
||||||
|
/// Snapshot-then-changes: the first item is the ALPN's complete
|
||||||
|
/// current resource payload; each subsequent item replaces it
|
||||||
|
/// (a full snapshot, not a delta). The channels layer does not
|
||||||
|
/// interpret the payload.
|
||||||
|
fn subscribe(&self) -> ResourceStream;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each item is the **ALPN's full current resource payload** — an object
|
||||||
|
merged with `"alpn"` into the wire entry (`{ "resources": [{ "alpn":
|
||||||
|
"alk/tty", ...payload }] }`; a non-object payload wraps as
|
||||||
|
`{ "alpn": …, "data": … }`). The snapshot-then-changes contract lives
|
||||||
|
in the producing crate (a `watch` channel or similar is its own
|
||||||
|
machinery) — the channels layer never polls an enumerator mid-life.
|
||||||
|
|
||||||
|
### 2. Registration seam
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct ResourceEnumerators; // channels-call; RwLock<HashMap<String /*alpn*/, Arc<dyn ResourceEnumerator>>>
|
||||||
|
|
||||||
|
impl ResourceEnumerators {
|
||||||
|
pub fn new() -> Self;
|
||||||
|
pub fn register(&self, alpn: &str, enumerator: Arc<dyn ResourceEnumerator>);
|
||||||
|
pub fn alpns(&self) -> Vec<String>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Registered by the composing crate at assembly/install time (the same
|
||||||
|
seam that composes the policy and the openables — the composer has the
|
||||||
|
backend handles). The registry is shared per accepting peer (like
|
||||||
|
`PerIdentityChannelPolicy`, ADR-041): one `Arc<ResourceEnumerators>`
|
||||||
|
across every channels connection that peer accepts.
|
||||||
|
|
||||||
|
**Scan-once:** the merged stream's enumerator set is scanned when the
|
||||||
|
subscription starts; ALPNs registered later are invisible to in-flight
|
||||||
|
subscriptions (re-subscribe to see them). Registrations happen at
|
||||||
|
install time, before any subscription — a late-registration notify
|
||||||
|
machinery is machinery nobody needs, and this pin prevents a future
|
||||||
|
half-design of it.
|
||||||
|
|
||||||
|
### 3. The op: aggregation semantics
|
||||||
|
|
||||||
|
`ChannelOperations::with_resource_enumerators(manager, policy,
|
||||||
|
enumerators)` threads the registry to the aggregate handler; the plain
|
||||||
|
`ChannelOperations::new(manager, policy)` constructor keeps an empty
|
||||||
|
registry. `make_resources_subscribe_handler` (the `Sub` op — ADR-021
|
||||||
|
machinery, unchanged) becomes a real subscription:
|
||||||
|
|
||||||
|
- **Non-empty registry:** merge every registered enumerator's stream
|
||||||
|
(`futures::stream::select_all`), each item mapped to a one-entry
|
||||||
|
`{ "resources": [{ "alpn": <alpn>, ...payload }] }` envelope. The
|
||||||
|
first events across the ALPNs are the initial snapshot (each
|
||||||
|
enumerator's first item); subsequent events are the live updates.
|
||||||
|
- **Empty registry:** one `{ "resources": [] }` envelope, then the
|
||||||
|
stream stays open (the poll use case — subscribe, read the first
|
||||||
|
event, cancel — gets its snapshot; per the scan-once pin, nothing
|
||||||
|
further arrives until re-subscribe).
|
||||||
|
- **Zero enumerators ever registered but the op discoverable** is
|
||||||
|
impossible to distinguish from "no resources right now" — by design:
|
||||||
|
the static half answers "which ALPNs accept open ops"
|
||||||
|
(`services/list`), the dynamic half answers "what is currently
|
||||||
|
there"; a peer registering openables without enumerators reports an
|
||||||
|
empty live resource set, which is the truthful answer for "the
|
||||||
|
channels layer does not interpret your resources" (ADR-035 posture).
|
||||||
|
|
||||||
|
The `access` preview stays dropped (ADR-047 §6 — the spec is the
|
||||||
|
authority). The documented wire shape (channel-operations.md) is
|
||||||
|
unchanged — this is the implementation of the documented op, not a new
|
||||||
|
wire surface.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:** the advertised op serves (S-03 dies); ALPN crates carry
|
||||||
|
their resource truth themselves (the channels layer stays ALPN-blind —
|
||||||
|
it maps and merges opaque values); the hub/dashboard consumer gets the
|
||||||
|
live subscription ADR-037 promised.
|
||||||
|
|
||||||
|
**Negative:** `ChannelOperations` gains a second constructor (additive
|
||||||
|
— `new` keeps the empty default); an ALPN crate that never registers
|
||||||
|
an enumerator serves an always-empty resource set — truthful, and
|
||||||
|
`services/list` still discloses the ops (the static half is not
|
||||||
|
encumbered by the dynamic one).
|
||||||
|
|
||||||
|
## Door type
|
||||||
|
|
||||||
|
**Two-way (implementation shape) inside the documented wire surface.**
|
||||||
|
The wire shape (`{ "resources": [...] }`, per-ALPN entries, no
|
||||||
|
`access` preview) is ADR-037/047's pre-existing one-way surface, now
|
||||||
|
actually served. The enumerator trait, the scan-once rule, the empty
|
||||||
|
seed, and the registration seam are revisable implementation details —
|
||||||
|
no consumer observes them beyond the stream of envelopes.
|
||||||
|
|
||||||
|
## Verification gates
|
||||||
|
|
||||||
|
- initial snapshot: N registered enumerators → N first events carrying
|
||||||
|
each ALPN's payload merged with `"alpn"`;
|
||||||
|
- live update: an enumerator's second item arrives as the next envelope
|
||||||
|
without re-subscription;
|
||||||
|
- empty registry: exactly one `{ "resources": [] }` envelope, stream
|
||||||
|
stays open, subscriber cancel works (abort cascade, ADR-020);
|
||||||
|
- late registration invisibility: a post-subscription registration does
|
||||||
|
not appear in an in-flight subscription (the scan-once pin);
|
||||||
|
- non-object payload wrapping.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Review 010 S-03 (the stub; the implement ruling), review 011 §4
|
||||||
|
(downstream-safe; implement preferred)
|
||||||
|
- OQ-40 (the promoted design note this ADR resolves; the "load-bearing
|
||||||
|
for alktunnels' discovery UI" note)
|
||||||
|
- ADR-037 §6 (the dynamic half's contract), ADR-047 §6 (the
|
||||||
|
discovery split; the 2026-09-06 amendment's static half)
|
||||||
|
- ADR-021 (the `StreamingHandler` machinery this op rides), ADR-039
|
||||||
|
(manager blindness — the enumerators' channels-call home), ADR-041
|
||||||
|
(the shared-per-peer construction pattern the registry follows)
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# ADR-054: Outgoing-Only X.509 and the Three Remote Roles (Re-Owned In-Crate)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted (re-owns the verifier-selection rule for alkcall's surface;
|
||||||
|
provenance: alknet ADR-034, not ported)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
alkcall's connection surface depends on a verifier-selection rule and a
|
||||||
|
role taxonomy that were decided while this crate was a subdirectory of
|
||||||
|
the alknet mono-repo. The deciding ADR (alknet ADR-034, "Outgoing-Only
|
||||||
|
X.509 and Three Peer Roles") was never ported, so the rule's authority
|
||||||
|
lives in a cross-repo link — a dependency on a document outside this
|
||||||
|
crate for a constraint that binds this crate's public surface:
|
||||||
|
|
||||||
|
- `ConnectionCredentials.remote_identity: None` semantics (ADR-022 §7,
|
||||||
|
(ADR-022 §7; alknet ADR-034 §2/§3, provenance): `None` is a load-bearing state — the
|
||||||
|
public-X.509-endpoint path, not a missing field.
|
||||||
|
- Client-side `ServerCertVerifier` selection is by `PeerEntry`
|
||||||
|
presence, not by key type alone (OQ-14 #2).
|
||||||
|
- `PeerEntry` asymmetry: a pure-client connection to a public X.509
|
||||||
|
endpoint is not in the call-protocol peer graph on the client side
|
||||||
|
(no `PeerEntry`, no `PeerId`, no `PeerRef::Specific` routing).
|
||||||
|
|
||||||
|
The alknet mono-repo is being rewritten as a thin glue layer over this
|
||||||
|
crate and its siblings; cross-repo ADR links will not survive that
|
||||||
|
rewrite as authority. The rule must be owned here.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
alkcall re-owns the rule in-crate. Three things are adopted:
|
||||||
|
|
||||||
|
### 1. Three remote roles
|
||||||
|
|
||||||
|
The single label "X.509 endpoint" conflated three structurally
|
||||||
|
different remote roles:
|
||||||
|
|
||||||
|
- **Public X.509 endpoint** — a remote server reachable by domain
|
||||||
|
name, authenticated by a CA-issued X.509 cert. The local node is a
|
||||||
|
*client* of it (a third-party API, a public hub dialed over the
|
||||||
|
open internet, a peer that exposes domain + X.509 instead of an
|
||||||
|
Ed25519 raw key). The client authenticates to the server by bearer
|
||||||
|
token (`auth_token` payload field when riding the call protocol);
|
||||||
|
the server authenticates to the client by CA verification
|
||||||
|
(WebPKI), not fingerprint pinning.
|
||||||
|
- **Transport relay** — a connectivity-assistance node
|
||||||
|
(iroh's DERP-equivalent) that forwards encrypted datagrams between
|
||||||
|
peers who cannot directly connect. It is infrastructure, not an
|
||||||
|
application peer: no registered operations, no peer graph
|
||||||
|
participation, no `PeerEntry`. Its identity is the transport's
|
||||||
|
concern, not this crate's.
|
||||||
|
- **Hub / hosting node** — an application peer acting as a hub in a
|
||||||
|
hub-and-spoke topology. An ordinary `PeerEntry` holding mixed
|
||||||
|
fingerprints (Ed25519 raw key and/or X.509) — fingerprint pinning
|
||||||
|
applies on both cert paths.
|
||||||
|
|
||||||
|
### 2. Verifier selection is by `PeerEntry` presence, not key type
|
||||||
|
|
||||||
|
The client-side `ServerCertVerifier` is selected by whether the local
|
||||||
|
node holds a `PeerEntry` for the remote:
|
||||||
|
|
||||||
|
| Local `PeerEntry` for remote? | Remote cert | Verifier | Peer graph? |
|
||||||
|
|-------------------------------|-------------|----------|-------------|
|
||||||
|
| yes | Ed25519 or X.509 | fingerprint pin | in (`PeerId` resolved from client-cert fingerprint — ADR-025 §5) |
|
||||||
|
| no | X.509 | `WebPkiServerVerifier` (CA) | **not in** (no `PeerId`) |
|
||||||
|
| no | Ed25519 raw key | fail closed (no CA to fall back to) | not in |
|
||||||
|
|
||||||
|
### 3. `remote_identity: None` is load-bearing
|
||||||
|
|
||||||
|
`Some(fingerprint)` means "pin this" (known peer). `None` means "no
|
||||||
|
`PeerEntry` for this remote" — CA verification for X.509 remotes,
|
||||||
|
hard failure for Ed25519 raw-key remotes. An implementation must not
|
||||||
|
default `remote_identity` to a placeholder to satisfy the field, and
|
||||||
|
must not treat `None` as "skip verification."
|
||||||
|
|
||||||
|
### 4. Scope of the re-own
|
||||||
|
|
||||||
|
This ADR is the deciding authority for alkcall's surface
|
||||||
|
(`ConnectionCredentials`, `RemoteIdentity`, the `ServerCertVerifier`
|
||||||
|
selection the dial-composer implements, `PeerEntry` asymmetry in the
|
||||||
|
peer graph). It does not re-own alknet ADR-034's other content (ACME
|
||||||
|
issuance, cert rotation, the alktls config surface) — those remain
|
||||||
|
properties of the transport/composition layer (see OQ-43, composition
|
||||||
|
layer). alknet ADR-034 is demoted to provenance: historical origin of
|
||||||
|
this rule, cited as such.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Positive:
|
||||||
|
|
||||||
|
- The constraint binding alkcall's public surface is decided in this
|
||||||
|
crate's decision log; cross-repo link rot cannot strip the authority.
|
||||||
|
- OQ-21's "deciding ADR (alknet ADR-034, not ported)" caveat is
|
||||||
|
resolved — the surviving surfaces (`client-and-adapters.md`
|
||||||
|
§credentials, ADR-025's `PeerEntry` asymmetry) now cite an in-crate
|
||||||
|
ADR.
|
||||||
|
- The alknet rewrite can proceed without a documentation dependency
|
||||||
|
from this crate on the old repo's ADR numbering.
|
||||||
|
|
||||||
|
Negative:
|
||||||
|
|
||||||
|
- One more rule with a historical origin story in a different numbering
|
||||||
|
space; the provenance note (README §References) is the single place
|
||||||
|
that maps old to new.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [client-and-adapters.md](../client-and-adapters.md) — the working
|
||||||
|
spec carrying these semantics (§Credential sources for connections,
|
||||||
|
§Constraints "Pure-client X.509 connections", `remote_identity`
|
||||||
|
load-bearing rule)
|
||||||
|
- [ADR-022](022-call-protocol-client-and-adapter-contract.md) §7 —
|
||||||
|
transport-identity dimensions on the call protocol
|
||||||
|
- [ADR-025](025-peerentry-and-identity-id-decoupling.md) —
|
||||||
|
`PeerEntry` asymmetry, `PeerId` source, client-cert fingerprint →
|
||||||
|
`peer_id` resolution
|
||||||
|
- [ADR-012](012-connectioncredentials-decouple-dial-from-call.md) —
|
||||||
|
`ConnectionCredentials` shape (the dial consumes it; verifier
|
||||||
|
selection is applied there)
|
||||||
|
- OQ-21 (resolved by this ADR), OQ-43 (composition-layer ownership —
|
||||||
|
the transport/composition half mentioned in §4's scope carve-out)
|
||||||
|
- Provenance: alknet ADR-034, "Outgoing-Only X.509 and Three Peer
|
||||||
|
Roles" (`/workspace/@alkdev/alknet/docs/architecture/decisions/`),
|
||||||
|
retained as history, not authority
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: current
|
||||||
last_updated: 2026-08-12
|
last_updated: 2026-10-13 (composition-layer re-key)
|
||||||
---
|
---
|
||||||
|
|
||||||
# Open Questions
|
# Open Questions
|
||||||
@@ -35,13 +35,14 @@ status, priority, and (when resolved) a resolution citing the ADR.
|
|||||||
| OQ-18 | PeerId — crypto identity vs stable logical id | resolved | high | ADR-025 — PeerId = Identity.id (stable) |
|
| OQ-18 | PeerId — crypto identity vs stable logical id | resolved | high | ADR-025 — PeerId = Identity.id (stable) |
|
||||||
| OQ-19 | Persistent peer registry | resolved | medium | ADR-025 — core trait + in-memory default; persistence adapters separate |
|
| OQ-19 | Persistent peer registry | resolved | medium | ADR-025 — core trait + in-memory default; persistence adapters separate |
|
||||||
| OQ-20 | ~~API key asymmetry~~ | dissolved | medium | PeerEntry supports multiple credential paths |
|
| OQ-20 | ~~API key asymmetry~~ | dissolved | medium | PeerEntry supports multiple credential paths |
|
||||||
| OQ-21 | X.509 outgoing-only case | resolved | medium | Three remote roles; PeerEntry asymmetry correct |
|
| OQ-21 | X.509 outgoing-only case | resolved | medium | Three remote roles; PeerEntry asymmetry correct. Re-owned in-crate by ADR-054 — the verifier-selection rule that binds alkcall's surface (`RemoteIdentity: None` semantics, `ServerCertVerifier` by `PeerEntry` presence) is decided here, not by the un-ported source ADR (which is demoted to provenance). |
|
||||||
|
|
||||||
## Call Protocol — Pub/Sub
|
## Call Protocol — Pub/Sub
|
||||||
|
|
||||||
| OQ | Title | Status | Priority | Resolution |
|
| OQ | Title | Status | Priority | Resolution |
|
||||||
|----|-------|--------|----------|------------|
|
|----|-------|--------|----------|------------|
|
||||||
| OQ-22 | Call protocol pub/sub primitive — pub to go with sub | partially resolved | high | ADR-046 resolves the primitive: `OperationType::Pub` + `HandlerKind::Sink` + `call.published` wire event + `invoke_sink()` dispatch path. The fan-out/broker mechanism (one producer, N consumers, topic matching) is deferred to the channels session — the call protocol is point-to-point; the broker is a routing concern that sits above it. ADR-047 §1 names the broker (Gap B) as out-of-scope for alkcall; the hub composes it on top of the `Pub`/`Sub` primitives. See §"Pub/Sub Gap" below. |
|
| OQ-22 | Call protocol pub/sub primitive — pub to go with sub | resolved | high | The question had two halves, both now decided. (1) The primitive: ADR-046 delivers `OperationType::Pub` + `HandlerKind::Sink` + `call.published` wire event + `invoke_sink()` dispatch + `CallConnection::publish()`. (2) Placement of the fan-out/broker: **out of scope for alkcall** — the call protocol is point-to-point; a topic registry that outlives individual calls is a routing concern that sits above it. The hub composes the broker on top of the `Pub`/`Sub` primitives (ADR-047 §"What this ADR decides" Gap B). The broker design itself is tracked as OQ-42. See §"Pub/Sub Gap" below. |
|
||||||
|
| OQ-42 | Hub message broker (topic registry, `Pub`↔`Sub` matching, N-consumer fan-out) | deferred(scope) | medium | Deferred downstream: the broker is a hub composition layering on alkcall's `Pub`/`Sub` primitives, not an alkcall deliverable (OQ-22 placement resolution, ADR-047 Gap B). **Blocked on**: a concrete producing crate committing to fan-out — a spec that requires one produced topic with N independent spoke subscribers (plausible first candidate: alktrader market-data distribution; ADR-046 §fan-out records the consumer-blind rationale). Not circular — the primitives it composes on are delivered and load-bearing (ADR-046), and no alkcall API surface advertises or depends on the broker. The hub itself lives in the composition layer (owner — OQ-43). |
|
||||||
|
|
||||||
### Pub/Sub Gap
|
### Pub/Sub Gap
|
||||||
|
|
||||||
@@ -59,32 +60,44 @@ type. `call.published` is the wire event carrying stream chunks.
|
|||||||
the client method. The `Subscription` variant is renamed to `Sub`
|
the client method. The `Subscription` variant is renamed to `Sub`
|
||||||
(wire string `"sub"`).
|
(wire string `"sub"`).
|
||||||
|
|
||||||
**Fan-out deferred.** The broker (topic registry, `Pub`↔`Sub` matching,
|
**Placement decided: the broker is not an alkcall deliverable.** The
|
||||||
N-consumer fan-out) is deferred to the channels session. The call
|
fan-out/broker mechanism (topic registry, `Pub`↔`Sub` matching,
|
||||||
protocol is point-to-point (one initiator, one responder, a stream
|
N-consumer fan-out) sits above the call protocol: the call protocol is
|
||||||
between them); a topic registry that outlives individual calls is a
|
point-to-point (one initiator, one responder, a stream between them);
|
||||||
different lifecycle and a different concern. The broker's first
|
a topic registry that outlives individual calls is a different
|
||||||
consumer is the channels `channel/resources/subscribe` operation
|
lifecycle and a different concern. The broker is a hub composition on
|
||||||
(ADR-037) and the hub-as-broker pattern (ADR-042). The `Pub` primitive
|
top of the `Pub`/`Sub` primitives — ADR-047 §"What this ADR decides"
|
||||||
is the load-bearing piece the broker composes on.
|
(Gap B) places it out of scope for alkcall. The `Pub` primitive is the
|
||||||
|
load-bearing piece the broker composes on (delivered, ADR-046); ADR-053
|
||||||
|
served the broker's originally-named first consumer
|
||||||
|
(`channel/resources/subscribe`) with enumerators instead.
|
||||||
|
|
||||||
|
**The broker design is downstream work (OQ-42, deferred(scope)).** It
|
||||||
|
unblocks when a concrete producing crate commits to fan-out. The
|
||||||
|
original deferral's rationale (ADR-046 §fan-out — designing the broker
|
||||||
|
without its first real consumer produces a half-design) still holds:
|
||||||
|
the honest state is a tracked, concretely-blocked deferral, not a
|
||||||
|
pending promise. The "channels session" phrasing in ADR-046 §fan-out
|
||||||
|
should be read as this placement decision (the channels work has since
|
||||||
|
landed; what remains unbuilt is the downstream hub broker).
|
||||||
|
|
||||||
## Channels
|
## Channels
|
||||||
|
|
||||||
| OQ | Title | Status | Priority | Resolution |
|
| OQ | Title | Status | Priority | Resolution |
|
||||||
|----|-------|--------|----------|------------|
|
|----|-------|--------|----------|------------|
|
||||||
| OQ-23 | Full channel-level flow-control windowing | deferred(scope) | low | Bounded-buffer decided (ADR-040); full windowing blocked on HOL-blocking deployment observation |
|
| OQ-23 | Full channel-level flow-control windowing | deferred(scope) | low | Bounded-buffer decided (ADR-040); full windowing blocked on HOL-blocking deployment observation |
|
||||||
| OQ-24 | Channels add/strip API shape | open | low | Whether the 8-byte header add/strip is built into the read/write path or a standalone utility. The contract (ADR-035) is decided; the function surface is not |
|
| OQ-24 | Channels add/strip API shape | resolved | low | Resolved by the shipped surface: the strip is **integrated into the read/write path** — the demux loop consumes `read_header`/`write_chunk` so a handler's `BiStream` yields payload chunks and never sees the 8-byte header (ADR-035; matches ADR-037's payload-parse contract). The low-level primitives (`parse_header`/`write_header`/`read_header`/`write_chunk`/`write_eof` in `channels::wire`) remain exposed as the standalone utility for tooling/codegen, but no handler-facing add/strip wrapper exists or is needed. |
|
||||||
| OQ-31 | `channel/open` ACL granularity | resolved | high | ADR-047 — `channel/open` dissolves into per-ALPN ops; each op has its own `access_control` |
|
| OQ-31 | `channel/open` ACL granularity | resolved | high | ADR-047 — `channel/open` dissolves into per-ALPN ops; each op has its own `access_control` |
|
||||||
| OQ-32 | Quota lifecycle (opener vs closer, transport drop) | resolved | high | ADR-047 §7 — the per-connection opener ledger; decrement on every teardown path, keyed by opener |
|
| OQ-32 | Quota lifecycle (opener vs closer, transport drop) | resolved | high | ADR-047 §7 — the per-connection opener ledger; decrement on every teardown path, keyed by opener |
|
||||||
| OQ-33 | Per-identity connection cap (endpoint layer) | deferred(scope) | low | Named as a separate layer (ADR-047 §"ALPN category reframe" references the findings); belongs at `alknet-endpoint`, not channels. Named to stop the re-tangle |
|
| OQ-33 | Per-identity connection cap (endpoint layer) | deferred(scope) | low | Named as a separate layer (ADR-047 §"ALPN category reframe" references the findings); belongs at the endpoint/connect-accept layer of the composition stack, not channels. Named to stop the re-tangle. The endpoint layer's home is the composition layer (owner — OQ-43; today it lives in the alknet glue). |
|
||||||
| OQ-34 | `channel_open` marker wire format | resolved | medium | ADR-047 §2 — boolean `"channel_open": true` in `services/schema`; ALPN derivable from op name |
|
| OQ-34 | `channel_open` marker wire format | resolved | medium | ADR-047 §2 — boolean `"channel_open": true` in `services/schema`; ALPN derivable from op name |
|
||||||
| OQ-35 | `OperationEnv::channel_manager()` coupling | resolved | high | ADR-047 §4 — extension trait `ChannelOperationEnv` in `channels-call`; call crate stays free of channels types |
|
| OQ-35 | `OperationEnv::channel_manager()` coupling | resolved | high | ADR-047 §4 — extension trait `ChannelOperationEnv` in `channels-call`; call crate stays free of channels types |
|
||||||
| OQ-36 | `channel_id` allocation in Pub case | resolved | medium | ADR-047 §5 — "connection owner allocates" (the side that holds the `ChannelManager`); amends "responder allocates" |
|
| OQ-36 | `channel_id` allocation in Pub case | resolved | medium | ADR-047 §5 — "connection owner allocates" (the side that holds the `ChannelManager`); amends "responder allocates" |
|
||||||
| 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-37 | `from_call` relay wrapper for marked ops | resolved | medium | ADR-022 amendment 2026-10-13 — there is no `from_call` relay wrapper, and there won't be one: marked (`channel_open`) announcements are refused with `MARKED_SPEC_UNSUPPORTED`, and `register_imported`/`register_imported_all` refuse marked specs (ADR-047 amendment 4 §4.8) — a marked op's data plane (the allocated channel's `BiStream`) has no overlay path. The sanctioned compositions are named in the refusal: `ChannelRelay::register_relay_openable` (ADR-051) for a relaying hub, `ChannelClient` (ADR-043) for direct consumption. `from_call`'s discovery still reconstructs the marker (ADR-047 Gap F) for reporting, but importing a marked spec is refused at the overlay landing. |
|
||||||
| 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-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-39 | `channel/control` control-handle surface | resolved | medium | ADR-052 — the control-handle surface is implemented: per-channel control callbacks contributed by the ALPN establisher (`Establishment::with_control`), wrapper-installed and teardown-removed in the manager; the op routes the ALPN-specific `message` and answers `channel:no_control_handle` when the channel's ALPN registered none. The stub (`channel:control_not_implemented`) was removed with the single-publish fix (reviews 010 S-02 / 011). |
|
||||||
| 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 | resolved | medium | ADR-053 — the live subscription is implemented: one `ResourceEnumerator` per openable ALPN (provided by the producing crate, snapshot-then-changes), aggregated by the handler over the scan-once snapshot; an empty registry emits one `{ "resources": [] }` seed and stays open. The one-shot error stub was removed with the single-publish fix (reviews 010 S-03 / 011). The alktunnels discovery-UI note (review 006 E-02) is served by the enumerator surface. |
|
||||||
| 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). |
|
| OQ-41 | QUIC-native multi-stream substrate | deferred(scope) | medium | **Deferred downstream** — the substrate lives in the dial/accept layer, which alkcall does not own. Only the in-line substrate mode is implemented (single bidi stream, header-demuxed N channels); the QUIC-native multi-stream mode (accept remaining bidi streams, read headers off each — ADR-034 §substrate modes) belongs in the composition layer's QUIC accept loop. **Blocked on**: the composition layer's multi-stream substrate integration (owner — OQ-43; today the alknet glue, being replaced). **Impacts**: none in-crate — the wire format and demux loop are correct for both substrates; alkcall stays transport-agnostic (no QUIC dependency, WASM-compatible). |
|
||||||
|
|
||||||
## Core Types
|
## Core Types
|
||||||
|
|
||||||
@@ -96,3 +109,19 @@ is the load-bearing piece the broker composes on.
|
|||||||
| OQ-28 | Dynamic handler registration | resolved | low | ADR-019 — curated static, overlays dynamic |
|
| OQ-28 | Dynamic handler registration | resolved | low | ADR-019 — curated static, overlays dynamic |
|
||||||
| OQ-29 | Handler-level auth resolution observability | resolved | medium | set_identity() on Connection for observability |
|
| OQ-29 | Handler-level auth resolution observability | resolved | medium | set_identity() on Connection for observability |
|
||||||
| OQ-30 | Dynamic resource ownership | resolved | high | ADR-011 — OwnershipProvider, resource_id_path |
|
| OQ-30 | Dynamic resource ownership | resolved | high | ADR-011 — OwnershipProvider, resource_id_path |
|
||||||
|
|
||||||
|
## Composition Layer
|
||||||
|
|
||||||
|
The layer above alkcall that owns dial/accept, hub composition, and
|
||||||
|
endpoint concerns. As of 2026-10 that layer is the alknet glue (the
|
||||||
|
alknet mono-repo's crates: its client dial helper `AlknetClient`, its
|
||||||
|
endpoint crate, its hub wiring); the mono-repo itself is being replaced
|
||||||
|
by thin glue over alkcall and its sibling protocol/host crates
|
||||||
|
(alkhttp, alktty, alktunnels, alktrader, alktls, …). Several deferred
|
||||||
|
questions name "downstream" as their receiver — this OQ is the named
|
||||||
|
receiver for all of them, so the deferrals survive the replacement
|
||||||
|
rather than being repo-pinned to a codebase slated for deletion.
|
||||||
|
|
||||||
|
| OQ | Title | Status | Priority | Resolution |
|
||||||
|
|----|-------|--------|----------|------------|
|
||||||
|
| OQ-43 | Composition-layer ownership and shape after the alknet replacement | deferred(scope) | medium | **Deferred scope**: who owns dial/accept/hub composition, the endpoint layer (OQ-33), the QUIC-native substrate (OQ-41), and persistence adapters (OQ-19) after the alknet mono-repo is replaced — and whether that composition is one thin glue workspace (an `alknode`-type crate consuming alkcall + the sibling crates) or several small glue binaries. **Blocked on**: the glue-workspace decision being committed (the stated direction is "a couple kloc of glue", but the shape — one workspace vs several binaries — and its ownership are not yet committed). **Impacts**: re-keys the deferrals OQ-33, OQ-41, OQ-42, OQ-19; determines the dial seam's future home (ADR-045's `AlknetClient`, today in the alknet glue); determines where hub-relay wiring templates are consumed (ADR-042, ADR-051). **Not blocking** any alkcall v0.9 API surface — alkcall is transport-blind and composition-blind by construction (ADR-031, ADR-035). **Cross-references**: OQ-33, OQ-41, OQ-42, OQ-19; ADR-045, ADR-031, ADR-042, ADR-051. |
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-09
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# Operation Registry
|
# Operation Registry
|
||||||
@@ -21,7 +21,7 @@ The operation registry provides:
|
|||||||
- **Type safety**: JSON Schema for input and output enables validation and client code generation
|
- **Type safety**: JSON Schema for input and output enables validation and client code generation
|
||||||
- **Composability**: Handlers can invoke other operations through `OperationEnv` (local dispatch — remote dispatch is a separate architectural concern, see Constraints)
|
- **Composability**: Handlers can invoke other operations through `OperationEnv` (local dispatch — remote dispatch is a separate architectural concern, see Constraints)
|
||||||
|
|
||||||
The registry design is informed by the `@alkdev/operations` TypeScript package, which demonstrated the same capabilities in JavaScript runtimes. The Rust implementation in alknet-call is canonical — it preserves the behavioral contract (namespace + operation name → invoke with input, return output) while defining the adapter contract (from_*, to_*) in Rust (see ADR-033).
|
The registry design is informed by the `@alkdev/operations` TypeScript package, which demonstrated the same capabilities in JavaScript runtimes. The Rust implementation in alkcall is canonical — it preserves the behavioral contract (namespace + operation name → invoke with input, return output) while defining the adapter contract (from_*, to_*) in Rust (see ADR-033).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -39,6 +39,9 @@ pub struct OperationSpec {
|
|||||||
pub output_schema: Value, // JSON Schema for output
|
pub output_schema: Value, // JSON Schema for output
|
||||||
pub error_schemas: Vec<ErrorDefinition>, // Declared domain errors (ADR-016)
|
pub error_schemas: Vec<ErrorDefinition>, // Declared domain errors (ADR-016)
|
||||||
pub access_control: AccessControl,
|
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
|
/// JSON pointer into the input for the resource ID, when
|
||||||
/// `access_control.resource_type` is set and the operation targets a
|
/// `access_control.resource_type` is set and the operation targets a
|
||||||
/// specific runtime-spawned resource (ADR-011). e.g., `"$.containerId"`
|
/// specific runtime-spawned resource (ADR-011). e.g., `"$.containerId"`
|
||||||
@@ -87,7 +90,7 @@ Operation names use slash-based paths without a leading slash, aligned with URL
|
|||||||
|
|
||||||
The `namespace` field is derived from the name: for `fs/readFile` it's `fs`, for `agent/chat` it's `agent`. It's a convenience accessor for ACL matching and service grouping.
|
The `namespace` field is derived from the name: for `fs/readFile` it's `fs`, for `agent/chat` it's `agent`. It's a convenience accessor for ACL matching and service grouping.
|
||||||
|
|
||||||
Visibility (ADR-017) controls whether an operation is callable from the wire. `External` operations are wire-facing — they appear in `services/list` and accept `call.requested` from clients. `Internal` operations are composition-only — they return `NOT_FOUND` (not `FORBIDDEN`) when called from the wire, and do not appear in `services/list`. The assembly layer declares visibility at registration. All import adapters (`from_openapi`, `from_mcp`, `from_jsonschema`, `from_call`) register operations as `Internal` by default (they're composition material, not directly callable); the handler that composes them is `External`. (`from_jsonschema` is now a real HTTP-backed adapter in `alknet-http` per ADR-027, not the schema-only placeholder it was.)
|
Visibility (ADR-017) controls whether an operation is callable from the wire. `External` operations are wire-facing — they appear in `services/list` and accept `call.requested` from clients. `Internal` operations are composition-only — they return `NOT_FOUND` (not `FORBIDDEN`) when called from the wire, and do not appear in `services/list`. The assembly layer declares visibility at registration. All import adapters (`from_openapi`, `from_mcp`, `from_jsonschema`, `from_call`) register operations as `Internal` by default (they're composition material, not directly callable); the handler that composes them is `External`. (`from_jsonschema` is now a real HTTP-backed adapter in `alkhttp` per ADR-027, not the schema-only placeholder it was.)
|
||||||
|
|
||||||
### AccessControl
|
### AccessControl
|
||||||
|
|
||||||
@@ -132,10 +135,13 @@ impl AccessControl {
|
|||||||
|
|
||||||
The `OwnershipProvider` trait (read side, sync — called on the dispatch hot
|
The `OwnershipProvider` trait (read side, sync — called on the dispatch hot
|
||||||
path) and the `OwnershipStore` trait (write side, async — called by
|
path) and the `OwnershipStore` trait (write side, async — called by
|
||||||
handlers that manage resource lifecycles) are defined in `alknet-core` per
|
handlers that manage resource lifecycles) live with the vendored core
|
||||||
ADR-011's storage decision (fourth instance of the repo/adapter pattern,
|
types in alkcall, per
|
||||||
ADR-033). See [auth.md](../core/auth.md) §"Ownership Provider and Store"
|
ADR-011's storage decision (fourth instance of the source repo's
|
||||||
for the trait shapes and the in-memory default adapter.
|
repo/adapter pattern — alknet ADR-033, provenance, not ported). The
|
||||||
|
trait shapes and the in-memory default adapter are in this crate's core
|
||||||
|
module; the parent repo's `auth.md` (alknet) is historical origin, not
|
||||||
|
part of this spec.
|
||||||
|
|
||||||
**The ownership provider is carried on `OperationContext`** (or threaded
|
**The ownership provider is carried on `OperationContext`** (or threaded
|
||||||
by the dispatcher), populated by the dispatch path from the registry's
|
by the dispatcher), populated by the dispatch path from the registry's
|
||||||
@@ -437,7 +443,7 @@ pub enum OperationProvenance {
|
|||||||
| `Session` | Yes (within sandbox) | Yes — scopes set at sandbox creation | Internal always |
|
| `Session` | Yes (within sandbox) | Yes — scopes set at sandbox creation | Internal always |
|
||||||
|
|
||||||
> **`FromJsonSchema` provenance.** `from_jsonschema` is an HTTP-backed
|
> **`FromJsonSchema` provenance.** `from_jsonschema` is an HTTP-backed
|
||||||
> single-endpoint adapter in `alknet-http` (ADR-027): a real reqwest
|
> single-endpoint adapter in `alkhttp` (ADR-027): a real reqwest
|
||||||
> forwarding handler, not a schema-only placeholder. `FromJsonSchema`
|
> forwarding handler, not a schema-only placeholder. `FromJsonSchema`
|
||||||
> is a leaf, same trust model as `FromOpenAPI` (HTTP endpoint trusted;
|
> is a leaf, same trust model as `FromOpenAPI` (HTTP endpoint trusted;
|
||||||
> handler is a forwarding stub). Schema validation without a handler is
|
> handler is a forwarding stub). Schema validation without a handler is
|
||||||
@@ -520,7 +526,7 @@ The `OperationEnv` trait is the universal composition mechanism. A handler calls
|
|||||||
/// through its `OperationContext.env` (which implements this trait).
|
/// through its `OperationContext.env` (which implements this trait).
|
||||||
///
|
///
|
||||||
/// This must remain a trait, not a concrete type — session-scoped
|
/// This must remain a trait, not a concrete type — session-scoped
|
||||||
/// registries (OQ-19) depend on wrapping the global env via trait
|
/// registries (OQ-04) depend on wrapping the global env via trait
|
||||||
/// layering. Making `OperationEnv` concrete or hardcoding the global
|
/// layering. Making `OperationEnv` concrete or hardcoding the global
|
||||||
/// registry into the dispatch path would close the session-overlay
|
/// registry into the dispatch path would close the session-overlay
|
||||||
/// pattern.
|
/// pattern.
|
||||||
@@ -601,8 +607,8 @@ The `parent` parameter propagates the calling context: the nested call gets `par
|
|||||||
|
|
||||||
The `invoke_peer` / `peer_contains` methods (ADR-024 §2) take a `PeerRef`
|
The `invoke_peer` / `peer_contains` methods (ADR-024 §2) take a `PeerRef`
|
||||||
selector and a `PeerId`. These types are defined alongside the
|
selector and a `PeerId`. These types are defined alongside the
|
||||||
`PeerCompositeEnv` struct — see [client-and-adapters.md](client-and-adapters.md#peer-keyed-composition-env-adr-029)
|
`PeerCompositeEnv` struct — see [client-and-adapters.md](client-and-adapters.md#peer-keyed-composition-env-adr-024)
|
||||||
and [ADR-024](decisions/029-peer-graph-routing-model.md) §2:
|
and [ADR-024](decisions/024-peer-graph-routing-model.md) §2:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub enum PeerRef {
|
pub enum PeerRef {
|
||||||
@@ -724,7 +730,7 @@ impl OperationEnv for PeerCompositeEnv {
|
|||||||
}
|
}
|
||||||
// Peer-keyed overlay: iterate peers in insertion order, dispatch to
|
// Peer-keyed overlay: iterate peers in insertion order, dispatch to
|
||||||
// the first peer whose sub-overlay contains the op. This is the
|
// the first peer whose sub-overlay contains the op. This is the
|
||||||
// head→N-workers fan-out primitive (ADR-024 §2, OQ-30: insertion-
|
// head→N-workers fan-out primitive (ADR-024 §2, OQ-15: insertion-
|
||||||
// order first-match).
|
// order first-match).
|
||||||
for peer_id in &self.connection_order {
|
for peer_id in &self.connection_order {
|
||||||
if let Some(conn_env) = self.connections.get(peer_id) {
|
if let Some(conn_env) = self.connections.get(peer_id) {
|
||||||
@@ -802,7 +808,7 @@ Two things happen in `invoke()`:
|
|||||||
|
|
||||||
Future work may add remote call protocol dispatch as an additional backend. The handler-facing API stays the same.
|
Future work may add remote call protocol dispatch as an additional backend. The handler-facing API stays the same.
|
||||||
|
|
||||||
**`OperationEnv` must remain a trait.** This is a constraint, not a suggestion. The trait-based design enables registry layering (ADR-019): the CallAdapter composes the root env per call from the curated base + active peer-keyed connection overlays + session overlay, and overlays wrap the base via trait layering. Session-scoped registries (OQ-19) and connection-scoped remote imports (ADR-022 `from_call`) are both overlays on the same base, using the same mechanism. The peer-keyed extension (`PeerCompositeEnv`, `invoke_peer`, ADR-024) composes on top of the same trait — it overrides the new peer-routing methods, not the base dispatch. Making `OperationEnv` concrete or hardcoding the global registry into the dispatch path would close both the session-overlay and connection-overlay patterns, and would prevent the peer-keyed routing model from composing. This is the same integration-point pattern as `IdentityProvider` (ADR-003). See OQ-19, ADR-019, and ADR-024.
|
**`OperationEnv` must remain a trait.** This is a constraint, not a suggestion. The trait-based design enables registry layering (ADR-019): the CallAdapter composes the root env per call from the curated base + active peer-keyed connection overlays + session overlay, and overlays wrap the base via trait layering. Session-scoped registries (OQ-04) and connection-scoped remote imports (ADR-022 `from_call`) are both overlays on the same base, using the same mechanism. The peer-keyed extension (`PeerCompositeEnv`, `invoke_peer`, ADR-024) composes on top of the same trait — it overrides the new peer-routing methods, not the base dispatch. Making `OperationEnv` concrete or hardcoding the global registry into the dispatch path would close both the session-overlay and connection-overlay patterns, and would prevent the peer-keyed routing model from composing. This is the same integration-point pattern as `IdentityProvider` (ADR-003). See OQ-04, ADR-019, and ADR-024.
|
||||||
|
|
||||||
### Service Discovery
|
### Service Discovery
|
||||||
|
|
||||||
@@ -829,6 +835,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 —
|
`services/schema` accepts `{ "name": "fs/readFile" }` (no leading slash —
|
||||||
registry form, same as `OperationSpec.name`) and returns the full
|
registry form, same as `OperationSpec.name`) and returns the full
|
||||||
`OperationSpec` including input/output JSON Schemas and declared
|
`OperationSpec` including input/output JSON Schemas and declared
|
||||||
@@ -840,7 +852,7 @@ error handling.
|
|||||||
|
|
||||||
### Operation Registry (hand-rolled, no irpc)
|
### Operation Registry (hand-rolled, no irpc)
|
||||||
|
|
||||||
The operation registry is hand-rolled in alknet-call. ADR-013 accepted
|
The operation registry is hand-rolled in alkcall. ADR-013 accepted
|
||||||
"irpc as the call protocol foundation," but no `.rs` file in the workspace
|
"irpc as the call protocol foundation," but no `.rs` file in the workspace
|
||||||
ever imported irpc — the wire format (`wire.rs`), the operation registry,
|
ever imported irpc — the wire format (`wire.rs`), the operation registry,
|
||||||
and the dispatch are all hand-rolled. ADR-014 supersedes ADR-013 and
|
and the dispatch are all hand-rolled. ADR-014 supersedes ADR-013 and
|
||||||
@@ -849,7 +861,7 @@ protocol (external, JSON)" with "irpc services (internal, postcard)" is
|
|||||||
moot — there is no irpc layer.
|
moot — there is no irpc layer.
|
||||||
|
|
||||||
If a handler internally uses a postcard/binary RPC for in-process calls,
|
If a handler internally uses a postcard/binary RPC for in-process calls,
|
||||||
that's a handler-internal choice, not an alknet-call integration. The
|
that's a handler-internal choice, not an alkcall integration. The
|
||||||
operation registry's external interface is always JSON (the `EventEnvelope`
|
operation registry's external interface is always JSON (the `EventEnvelope`
|
||||||
wire format); the internal handler dispatch is a `Handler` /
|
wire format); the internal handler dispatch is a `Handler` /
|
||||||
`StreamingHandler` trait object (ADR-021), not an irpc `Service`.
|
`StreamingHandler` trait object (ADR-021), not an irpc `Service`.
|
||||||
@@ -957,71 +969,74 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
|
|||||||
|
|
||||||
| Decision | ADR | Summary |
|
| Decision | ADR | Summary |
|
||||||
|----------|-----|---------|
|
|----------|-----|---------|
|
||||||
| Hand-rolled EventEnvelope framing (irpc never integrated) | [ADR-014](decisions/064-irpc-never-integrated-hand-rolled-framing.md) | Hand-rolled framing, registry, dispatch; supersedes ADR-013 |
|
| Hand-rolled EventEnvelope framing (irpc never integrated) | [ADR-014](decisions/014-irpc-never-integrated-hand-rolled-framing.md) | Hand-rolled framing, registry, dispatch; supersedes ADR-013 |
|
||||||
| Call protocol stream model | [ADR-015](decisions/012-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
|
| Call protocol stream model | [ADR-015](decisions/015-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
|
||||||
| Static handler registration | [ADR-010](decisions/010-alpn-router-and-endpoint.md) | `HandlerRegistry` (ALPN-level) immutable after construction; `OperationRegistry` layered by ADR-019 (curated immutable, session/imported dynamic) |
|
| Static handler registration | [ADR-001](decisions/001-alpn-protocol-dispatch.md) | `HandlerRegistry` (ALPN-level) immutable after construction; `OperationRegistry` layered by ADR-019 (curated immutable, session/imported dynamic). Provenance: alknet ADR-010's endpoint/router layer is retired — alkcall has no endpoint layer (see OQ-43 for the endpoint layer's composition home) |
|
||||||
| Vault integration via assembly layer | [ADR-008](decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time |
|
| Vault integration via assembly layer | [ADR-010](decisions/010-secret-material-flow-and-capability-injection.md) | Vault is a capability source, accessed at assembly time (provenance: alknet ADR-008, superseded in-crate) |
|
||||||
| Secret material flow and capability injection | [ADR-010](decisions/014-secret-material-flow-and-capability-injection.md) | Capabilities carry outbound credentials; call protocol carries no secret material |
|
| Secret material flow and capability injection | [ADR-010](decisions/010-secret-material-flow-and-capability-injection.md) | Capabilities carry outbound credentials; call protocol carries no secret material |
|
||||||
| Privilege model and authority context | [ADR-017](decisions/015-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; composition authority + scoped env |
|
| Privilege model and authority context | [ADR-017](decisions/017-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; composition authority + scoped env |
|
||||||
| Handler registration, provenance, and composition authority | [ADR-018](decisions/022-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
|
| Handler registration, provenance, and composition authority | [ADR-018](decisions/018-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
|
||||||
| Operation registry layering | [ADR-019](decisions/024-operation-registry-layering.md) | Curated (static, immutable) + session and connection overlays (dynamic); `OperationEnv` as trait-object integration point; `OperationContext.env` split into `scoped_env` (data) and `env` (dispatch trait) |
|
| Operation registry layering | [ADR-019](decisions/019-operation-registry-layering.md) | Curated (static, immutable) + session and connection overlays (dynamic); `OperationEnv` as trait-object integration point; `OperationContext.env` split into `scoped_env` (data) and `env` (dispatch trait) |
|
||||||
| Operation error schemas | [ADR-016](decisions/023-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity for `from_openapi`/`to_openapi` |
|
| Operation error schemas | [ADR-016](decisions/016-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity for `from_openapi`/`to_openapi` |
|
||||||
| Call protocol client and adapter contract | [ADR-022](decisions/017-call-protocol-client-and-adapter-contract.md) | `from_call`/`OperationAdapter` produce `HandlerRegistration` bundles; adapter-registered ops are `Internal` leaves. Surface specced in [client-and-adapters.md](client-and-adapters.md) |
|
| Call protocol client and adapter contract | [ADR-022](decisions/022-call-protocol-client-and-adapter-contract.md) | `from_call`/`OperationAdapter` produce `HandlerRegistration` bundles; adapter-registered ops are `Internal` leaves. Surface specced in [client-and-adapters.md](client-and-adapters.md) |
|
||||||
| `from_jsonschema` as HTTP-backed single-endpoint adapter | [ADR-027](decisions/066-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf (now handler-bearing, not "no handler") |
|
| `from_jsonschema` as HTTP-backed single-endpoint adapter | [ADR-027](decisions/027-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from the call protocol (broken schema-only placeholder) to the HTTP host crate as a real reqwest-backed single-endpoint adapter — today `alkhttp` (ex-`alknet-http`); `FromJsonSchema` provenance stays in alkcall as a leaf (now handler-bearing, not "no handler") |
|
||||||
| Peer-graph routing model (supersedes ADR-023) | [ADR-024](decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` (the field this doc's `HandlerRegistration` previously gained) |
|
| Peer-graph routing model (supersedes ADR-023) | [ADR-024](decisions/024-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` (the field this doc's `HandlerRegistration` previously gained) |
|
||||||
| Forwarded-for identity | [ADR-026](decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `OperationContext` and `call.requested`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
|
| Forwarded-for identity | [ADR-026](decisions/026-forwarded-for-identity.md) | `forwarded_for` field on `OperationContext` and `call.requested`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
|
||||||
| Streaming handler for subscriptions | [ADR-021](decisions/049-streaming-handler-for-subscriptions.md) | `StreamingHandler` type alongside `Handler`; `HandlerKind` enum on `HandlerRegistration` validated against `op_type`; `invoke_streaming()` on `OperationRegistry`; `invoke()` and `OperationEnv::invoke()` error with `INVALID_OPERATION_TYPE` on `Subscription` ops; composition stays request/response-only, stream composition is handler-level |
|
| Streaming handler for subscriptions | [ADR-021](decisions/021-streaming-handler-for-subscriptions.md) | `StreamingHandler` type alongside `Handler`; `HandlerKind` enum on `HandlerRegistration` validated against `op_type`; `invoke_streaming()` on `OperationRegistry`; `invoke()` and `OperationEnv::invoke()` error with `INVALID_OPERATION_TYPE` on `Subscription` ops; composition stays request/response-only, stream composition is handler-level |
|
||||||
| Dynamic resource ownership for runtime-spawned resources | [ADR-011](decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | `AccessControl::check` consults an `OwnershipProvider` (sync read trait, ADR-033 repo/adapter pattern); `OperationSpec` gains `resource_id_path` (JSON pointer into the input); proxy-only access pattern (spawner owns, proxy to share, teardown revokes); `list` = scope-gate + result-filter; teardown = automatic, handler-driven; composition = two orthogonal checks, ADR-017/022 unchanged |
|
| Dynamic resource ownership for runtime-spawned resources | [ADR-011](decisions/011-dynamic-resource-ownership-for-runtime-spawned-resources.md) | `AccessControl::check` consults an `OwnershipProvider` (sync read trait; the source repo's repo/adapter pattern — alknet ADR-033, provenance); `OperationSpec` gains `resource_id_path` (JSON pointer into the input); proxy-only access pattern (spawner owns, proxy to share, teardown revokes); `list` = scope-gate + result-filter; teardown = automatic, handler-driven; composition = two orthogonal checks, ADR-017/022 unchanged |
|
||||||
| Publish operation type and HandlerKind::Sink | [ADR-046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | `OperationType::Pub` (producer→consumer streaming); `SinkHandler` + `HandlerKind::Sink` + `PublishStream`; `invoke_sink()` dispatch path; `call.published` wire event; `OperationSpec.publish_schema`; `Subscription` renamed to `Sub`; fan-out/broker deferred to channels |
|
| Publish operation type and HandlerKind::Sink | [ADR-046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | `OperationType::Pub` (producer→consumer streaming); `SinkHandler` + `HandlerKind::Sink` + `PublishStream`; `invoke_sink()` dispatch path; `call.published` wire event; `OperationSpec.publish_schema`; `Subscription` renamed to `Sub`; fan-out/broker out of scope for alkcall (OQ-22 resolved, OQ-42 downstream) |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
See [open-questions.md](open-questions.md) for full details.
|
See [open-questions.md](open-questions.md) for full details.
|
||||||
|
|
||||||
- **OQ-13** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
|
- **OQ-02** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
|
||||||
- **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
|
- **OQ-03** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
|
||||||
- **OQ-16** (resolved by ADR-010): No vault operations are exposed over the call protocol for now.
|
- **OQ-09** (resolved by ADR-010): No vault operations are exposed over the call protocol for now.
|
||||||
- **OQ-19** (resolved): Session-scoped operation registries — agent-written operations overlaid on the curated registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Session ops are `Session` provenance (ADR-018) — always `Internal`, compose under restricted authority scoped down at sandbox creation. Generalized by ADR-019 to cover connection-scoped overlays as well.
|
- **OQ-04** (resolved): Session-scoped operation registries — agent-written operations overlaid on the curated registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Session ops are `Session` provenance (ADR-018) — always `Internal`, compose under restricted authority scoped down at sandbox creation. Generalized by ADR-019 to cover connection-scoped overlays as well.
|
||||||
- **OQ-25** (dissolved by ADR-024): `remote_safe` marking shape — moot.
|
- **OQ-10** (dissolved by ADR-024): `remote_safe` marking shape — moot.
|
||||||
`remote_safe`/`trusted_peer` are retired; peer authorization is
|
`remote_safe`/`trusted_peer` are retired; peer authorization is
|
||||||
`AccessControl::check(peer_identity)`, the existing mechanism. See
|
`AccessControl::check(peer_identity)`, the existing mechanism. See
|
||||||
[client-and-adapters.md](client-and-adapters.md) and ADR-024 §3.
|
[client-and-adapters.md](client-and-adapters.md) and ADR-024 §3.
|
||||||
- **OQ-26** (resolved): `OperationAdapter` error type — `AdapterError`
|
- **OQ-11** (resolved): `OperationAdapter` error type — `AdapterError`
|
||||||
variants: `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`,
|
variants: `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`,
|
||||||
`SamePeerCollision` (replaces flat `Conflict`). `#[non_exhaustive]`. See
|
`SamePeerCollision` (replaces flat `Conflict`). `#[non_exhaustive]`. See
|
||||||
[client-and-adapters.md](client-and-adapters.md).
|
[client-and-adapters.md](client-and-adapters.md).
|
||||||
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
|
- **OQ-12** (resolved): `from_call` re-import trigger — `from_call` is a
|
||||||
manual free function; the assembly layer calls it after the dial (in
|
manual free function; the assembly layer calls it after the dial (in the composition layer's dial helper
|
||||||
`AlknetClient`). A `CallConnection::refresh()` method is a genuine
|
`AlknetClient` — today the alknet glue; OQ-43). A `CallConnection::refresh()` method is a genuine
|
||||||
feature addition — non-breaking, additive. See
|
feature addition — non-breaking, additive. See
|
||||||
[ADR-028](decisions/069-from-call-manual-free-function.md).
|
[ADR-028](decisions/028-from-call-manual-free-function.md).
|
||||||
- **OQ-28** (resolved): `from_call` namespace collision — same-peer
|
- **OQ-13** (resolved): `from_call` namespace collision — same-peer
|
||||||
collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays).
|
collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays).
|
||||||
`namespace_prefix` is optional local-naming sugar. See
|
`namespace_prefix` is optional local-naming sugar. See
|
||||||
[client-and-adapters.md](client-and-adapters.md).
|
[client-and-adapters.md](client-and-adapters.md).
|
||||||
- **OQ-29..37** are tracked in [client-and-adapters.md](client-and-adapters.md)
|
- The client-surface questions (the `CallClient` / adapter surface and
|
||||||
(they concern the `CallClient` / adapter surface and peer-graph routing,
|
peer-graph routing) are tracked in
|
||||||
not the registry layering this document covers). In brief: OQ-29
|
[client-and-adapters.md](client-and-adapters.md), not the registry
|
||||||
(CallClient TLS client-auth) resolved; OQ-30 (`PeerRef::Any` routing
|
layering this document covers; in brief: OQ-14
|
||||||
policy) resolved; OQ-31 (`services/list-peers` re-export semantics)
|
(`CallClient` TLS client-auth) resolved; OQ-15 (`PeerRef::Any` routing
|
||||||
resolved; OQ-32 (multi-hop federation) open (feature extension);
|
policy) resolved; OQ-16 (`services/list-peers` re-export semantics)
|
||||||
OQ-33 (PeerId source) resolved by ADR-025; OQ-34 (persistent peer
|
resolved; OQ-17 (multi-hop federation) deferred(scope) (feature
|
||||||
registry) resolved by ADR-025+033; OQ-35 (API key asymmetry) dissolved;
|
extension); OQ-18 (`PeerId` source) resolved by ADR-025; OQ-19
|
||||||
OQ-36 (concrete persistence adapter shapes) resolved by ADR-035;
|
(persistent peer registry) resolved by ADR-025; OQ-20 (API key
|
||||||
OQ-37 (X.509 outgoing-only) resolved by ADR-034.
|
asymmetry) dissolved; OQ-21 (X.509 outgoing-only) resolved by
|
||||||
- **OQ-42** (resolved by ADR-011): Dynamic resource ownership for
|
ADR-054 — three remote roles, verifier by `PeerEntry` presence (the
|
||||||
|
original deciding ADR, alknet ADR-034, was not ported; ADR-054
|
||||||
|
re-owns the rule, provenance retained); see OQ-21 in
|
||||||
|
[open-questions.md](open-questions.md).
|
||||||
|
- **OQ-30** (resolved by ADR-011): Dynamic resource ownership for
|
||||||
runtime-spawned resources — `AccessControl::check` consults an
|
runtime-spawned resources — `AccessControl::check` consults an
|
||||||
`OwnershipProvider`; `OperationSpec` gains `resource_id_path`; proxy-only
|
`OwnershipProvider`; `OperationSpec` gains `resource_id_path`; proxy-only
|
||||||
access pattern; four edge specifics pinned (`list`, teardown, fleet,
|
access pattern; four edge specifics pinned (`list`, teardown, fleet,
|
||||||
composition). See [auth.md](../core/auth.md) §"Ownership Provider and
|
composition). See ADR-011 for the trait shapes.
|
||||||
Store" for the trait shapes.
|
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- [call-protocol.md](call-protocol.md) — CallAdapter, EventEnvelope, stream model, PendingRequestMap
|
- [call-protocol.md](call-protocol.md) — CallAdapter, EventEnvelope, stream model, PendingRequestMap
|
||||||
- ADR-014: Hand-rolled EventEnvelope framing (irpc never integrated; supersedes ADR-013)
|
- ADR-014: Hand-rolled EventEnvelope framing (irpc never integrated; supersedes ADR-013)
|
||||||
- ADR-008: Vault integration point
|
- ADR-010: Secret material flow and capability injection (the vault integration point)
|
||||||
- ADR-010: ALPN router and endpoint (static registration — applies to the `HandlerRegistry`, not the `OperationRegistry`; see ADR-019 for the distinction)
|
- ADR-001: ALPN-based protocol dispatch (`HandlerRegistry` static registration)
|
||||||
- ADR-015: Call protocol stream model
|
- ADR-015: Call protocol stream model
|
||||||
- ADR-019: Operation registry layering (curated + session/connection overlays; `OperationEnv` as trait-object integration point)
|
- ADR-019: Operation registry layering (curated + session/connection overlays; `OperationEnv` as trait-object integration point)
|
||||||
- ADR-024: Peer-graph routing model (peer-keyed overlays + `PeerRef` routing; `PeerCompositeEnv` supersedes the singular-connection `CompositeOperationEnv`)
|
- ADR-024: Peer-graph routing model (peer-keyed overlays + `PeerRef` routing; `PeerCompositeEnv` supersedes the singular-connection `CompositeOperationEnv`)
|
||||||
@@ -1029,4 +1044,4 @@ See [open-questions.md](open-questions.md) for full details.
|
|||||||
- ADR-026: Forwarded-for identity (`forwarded_for` on `OperationContext` and `call.requested`; metadata only)
|
- ADR-026: Forwarded-for identity (`forwarded_for` on `OperationContext` and `call.requested`; metadata only)
|
||||||
- ADR-021: Streaming handler for subscriptions (`StreamingHandler`, `HandlerKind`, `invoke_streaming()`, `INVALID_OPERATION_TYPE`)
|
- ADR-021: Streaming handler for subscriptions (`StreamingHandler`, `HandlerKind`, `invoke_streaming()`, `INVALID_OPERATION_TYPE`)
|
||||||
- ADR-011: Dynamic resource ownership for runtime-spawned resources (`OwnershipProvider` consulted by `AccessControl::check`; `OperationSpec.resource_id_path`; proxy-only access pattern; composition = two orthogonal checks, ADR-017/022 unchanged)
|
- ADR-011: Dynamic resource ownership for runtime-spawned resources (`OwnershipProvider` consulted by `AccessControl::check`; `OperationSpec.resource_id_path`; proxy-only access pattern; composition = two orthogonal checks, ADR-017/022 unchanged)
|
||||||
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`
|
- Reference implementation: this crate (`src/`); historical origin: the alknet / alknet-main call-core sources (provenance)
|
||||||
@@ -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,400 @@
|
|||||||
|
# The Topic Broker: Placement, Shape, and the "Op as Topic" Tangle
|
||||||
|
|
||||||
|
**Status:** Phase 0 (exploration — captures the problem and the candidate
|
||||||
|
shapes; no decision taken, no ADR written. Implementation follows only
|
||||||
|
after this converges and Phase 1 produces the ADR + spec).
|
||||||
|
**Date:** 2026-10-13
|
||||||
|
**Research inputs:** operator recollection (2026-10-13, recorded and
|
||||||
|
disentangled in §3), the post-stub-sweep context (reviews 010/011, the
|
||||||
|
v0.9 release-plan discussion), alkcall ADRs 021/024/040/042/046/047/049/052/053
|
||||||
|
and OQ-22/39/40/42/43, the alkstore consumer inventory + ADR-015
|
||||||
|
(alkstore `/workspace/@alkdev/alkstore`), the TypeScript reference
|
||||||
|
implementation (`/workspace/@alkdev/pubsub`, `@alkdev/pubsub` — the
|
||||||
|
crates.io-unpublished ancestor of alkcall's `EventEnvelope`), and the
|
||||||
|
hub-relay composition (ADR-042, ADR-051).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why this research exists
|
||||||
|
|
||||||
|
Two independent observations converged on the same question in the
|
||||||
|
v0.9 release-planning discussion:
|
||||||
|
|
||||||
|
1. **The sweep found its own neighbor.** The stub sweep removed the
|
||||||
|
last `not_implemented` surfaces and re-keyed the live docs — but the
|
||||||
|
OQ ledger still carries one deferral whose *premise expired and
|
||||||
|
whose escape route is itself unowned*. OQ-42 (the broker) is
|
||||||
|
`deferred(scope)`, parked on "a concrete producing crate committing
|
||||||
|
to fan-out," and its home is the composition layer (OQ-43) — whose
|
||||||
|
ownership and shape are *also* uncommitted. A deferral blocked on a
|
||||||
|
deferral, while two crates downstream (alkgit, alkfs) wait on what
|
||||||
|
it would produce, is the circular-hedging shape in paperwork form:
|
||||||
|
no false advertising on any API surface, no failing handler — just
|
||||||
|
a dependency chain where every link waits on a link that never
|
||||||
|
commits.
|
||||||
|
|
||||||
|
2. **The deferral was priced under a different dependency graph.**
|
||||||
|
ADR-046 §fan-out and ADR-047 Gap B ruled the broker out of alkcall
|
||||||
|
when the alternative was cheap: at that point, no downstream crate
|
||||||
|
built on the pub/sub vocabulary. Today, four published crates
|
||||||
|
consume alkcall, alkblobs' reactive ops surface is the sharpest
|
||||||
|
named need (the alkblobs consumer-remediation input, review 011),
|
||||||
|
and alkgit + alkfs (both paused on alkblobs) grow subscription
|
||||||
|
surfaces on top of it. If the broker is built later, the version
|
||||||
|
bump that delivers it ripples across every crate that grew a
|
||||||
|
subscription surface in the meantime — the operator's stated cost
|
||||||
|
estimate is 6–8 crates, vs riding the v0.9 bump that already touches
|
||||||
|
the 4 published dependents. Deferral here is not free; it
|
||||||
|
compounds.
|
||||||
|
|
||||||
|
So this document's job is narrow: disentangle the fused concept that
|
||||||
|
blocked honest specification (§3), inventory what already exists to
|
||||||
|
compose on (§5), assess whether the placement ruling should be amended
|
||||||
|
(§6), and enumerate the open questions that Phase 1 must resolve before
|
||||||
|
any code is written (§8).
|
||||||
|
|
||||||
|
## 2. Prior state of the question (what is decided today)
|
||||||
|
|
||||||
|
- **The primitives are delivered and load-bearing** (ADR-046): the
|
||||||
|
`Pub` operation type, `HandlerKind::Sink` + `SinkHandler`,
|
||||||
|
`invoke_sink()` dispatch, the `call.published` wire event (the
|
||||||
|
envelope's sixth event type, wire-stable), and
|
||||||
|
`CallConnection::publish()`.
|
||||||
|
- **The fan-out/broker was ruled out of scope for alkcall** (ADR-047
|
||||||
|
Gap B; ADR-046 §"Fan-out"): topic registry, `Pub`↔`Sub` matching by
|
||||||
|
`(op_name, params_hash)`, N-consumer fan-out — placed at the hub
|
||||||
|
composition layer, "the same way the hub relay composes on top of
|
||||||
|
call ops." OQ-22 resolved the primitive half; the broker is OQ-42.
|
||||||
|
- **The named first consumer drifted.** OQ-42's blocking condition
|
||||||
|
names "a concrete producing crate committing to fan-out (plausible
|
||||||
|
first candidate: alktrader market-data distribution)." alktrader has
|
||||||
|
since become a quant/backtest library: its market-data path is an
|
||||||
|
analytics input, not a live N-spoke distribution surface. No crate
|
||||||
|
currently matches the blocking condition — which is how OQ-42 ended
|
||||||
|
up uncommittable.
|
||||||
|
- **The "channels" consumer the original rationale named is served
|
||||||
|
differently.** ADR-046's deferral reason 2 was "the broker's first
|
||||||
|
consumer is channels" (`channel/resources/subscribe`, hub-as-broker
|
||||||
|
ADR-042). The discovery-UI half of `resources/subscribe` shipped as
|
||||||
|
the snapshot-then-changes enumerator surface (ADR-053) — live
|
||||||
|
resource enumeration, not a general topic broker. Hub relay
|
||||||
|
(ADR-042/051) composes call ops without a broker.
|
||||||
|
- **alkstore owns the durable substrate below all of this.** Its
|
||||||
|
consumer inventory (streams row) explicitly names alkstore streams as
|
||||||
|
the durable back of alkcall's pub/sub vocabulary — per-stream FIFO by
|
||||||
|
offset, per-consumer cursors, replay-on-attach — and pins the topic
|
||||||
|
dimension to *separate streams*, never per-key lanes within one
|
||||||
|
stream (alkstore ADR-015 §1).
|
||||||
|
|
||||||
|
## 3. The tangle: two ideas fused in "op as topic"
|
||||||
|
|
||||||
|
The operator's recollection (recorded verbatim, then split):
|
||||||
|
|
||||||
|
> "The 'op as a topic' was a bit hand wavy in hindsight. The general
|
||||||
|
> idea was to basically use a hash as a topic and for deduping
|
||||||
|
> purposes. The 'op as a topic' was mostly so one could call a
|
||||||
|
> subscription operation and receive a stream of events. The reason
|
||||||
|
> they were modeled like regular ops is that we can validate the input
|
||||||
|
> and provide a schema that is required for that particular
|
||||||
|
> subscription (same on the pub side). So it should allow for ACL
|
||||||
|
> patterns inside the pubsub implementation."
|
||||||
|
|
||||||
|
Two separable design needs got fused into one mechanism:
|
||||||
|
|
||||||
|
- **Need A — a topic identity for matching and dedup.** Something
|
||||||
|
hashable, stable, and comparable: the key used to route a published
|
||||||
|
event to matching subscribers, and (possibly) the dedup key that
|
||||||
|
prevents duplicate delivery. ADR-046 §fan-out's `(op_name,
|
||||||
|
params_hash)` is one instance of this.
|
||||||
|
- **Need B — a subscription surface with real operation properties.**
|
||||||
|
Subscriptions should be validatable (declare the input schema on the
|
||||||
|
subscribe side; declare the publish schema on the publish side), and
|
||||||
|
authorizable (`AccessControl::check` on both halves). The "model it
|
||||||
|
as a regular op" instinct exists so the *registry machinery* —
|
||||||
|
schema compile, ACL check, visibility, provenance — is inherited for
|
||||||
|
free rather than hand-rolled inside the broker.
|
||||||
|
|
||||||
|
Splitting them exposes a design space the fused formulation hid:
|
||||||
|
|
||||||
|
| Aspect | Need A (identity) | Need B (op surface) |
|
||||||
|
|---|---|---|
|
||||||
|
| Decides | which subscribers an event reaches; dedup | who may subscribe/publish; what shapes are accepted |
|
||||||
|
| Natural home | topic table / matching engine | registry registration (specs + ACL) |
|
||||||
|
| Failure mode if fused | topics inherit op-registry coupling they don't need; hash identity leaks op internals | ad-hoc topic surface re-implements schema + ACL machinery the registry already has |
|
||||||
|
|
||||||
|
The candidate resolutions for both are the Phase 1 subjects
|
||||||
|
(§8, QP-1/QP-3).
|
||||||
|
|
||||||
|
## 4. Consumer inventory (who waits on this)
|
||||||
|
|
||||||
|
Cross-referenced from the alkstore consumer inventory, review 011's
|
||||||
|
downstream input, and the release-plan discussion:
|
||||||
|
|
||||||
|
| Consumer | Need | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| alkblobs | its reactive ops surface (Sink ops at their sharpest — REQ-2/ADR-008 lineage); ops-put works against the delivered `register_pub_openable` path post-fix (reviews 010/011), but cross-put fan-out ("many consumers of one blob stream") wants the broker | published-pending (unpublished crate, pre-first-release) |
|
||||||
|
| alkgit | repo-change subscriptions ("users and other apps subscribe to changes on a repo" — the operator's recorded need in alkstore's consumer inventory, streams row); paused on storage → alkblobs | paused |
|
||||||
|
| alkfs | the VFS workspace crate; its ancestor POC runs on honker notify; client-side cache invalidation (the "repo + cache invalidation" hot-path pattern the operator cites) wants topic-shaped invalidation signals | not yet decomposed |
|
||||||
|
| hub/relay deployments | one producer publishing, N spokes receiving (ADR-042's relay handles bytes-forwarding for channels; the *event-shaped* fan-out is the broker's) | composition layer, unowned (OQ-43) |
|
||||||
|
| alktrader | OQ-42's *original* named candidate | drifted — analytics, not distribution; do not re-park on it |
|
||||||
|
| family-wide | the reactivity requirement row (alkstore consumer inventory, streams): "watching for specific event types from several different places at once," durable, offline-tolerant | the named shape all of the above instantiate |
|
||||||
|
|
||||||
|
Note the family's shape consensus, recorded independently on both
|
||||||
|
sides: the topic dimension routes to **separate channels/streams — one
|
||||||
|
per topic** (alkstore ADR-015 §1; ADR-034 §substrate for wire
|
||||||
|
channels), never per-key lanes inside one stream. Whatever identity
|
||||||
|
model Phase 1 picks, it should preserve one-topic-one-delivery-pipe.
|
||||||
|
|
||||||
|
## 5. What exists to compose on (in-crate inventory)
|
||||||
|
|
||||||
|
Everything the broker needs as load-bearing inputs is already in this
|
||||||
|
crate and verified:
|
||||||
|
|
||||||
|
- **Delivery primitives** — `invoke_sink()` (`call.published`
|
||||||
|
sink-mode), `invoke_streaming()` (Sub, `call.responded`), the
|
||||||
|
wire-stable `EventEnvelope` framing with additive-event rule
|
||||||
|
(ADR-014: new event types may be added; existing shapes frozen).
|
||||||
|
- **Registry machinery for Need B** — `OperationSpec` (schema compile +
|
||||||
|
`publish_schema` validation), `AccessControl` + `required_scopes`,
|
||||||
|
`Visibility` (ADR-017), provenance (ADR-018), and the
|
||||||
|
registry-layering pattern (ADR-019) — session/connection overlays
|
||||||
|
for dynamic subscription attach/detach.
|
||||||
|
- **The in-crate composition precedents that weaken the placement
|
||||||
|
ruling** — `ChannelOperations` (channel lifecycle ops composed on
|
||||||
|
the call registry, ADR-047), establishers with `Establishment` plan
|
||||||
|
delivery (ADR-049/052), and `channel/resources/subscribe` — a live
|
||||||
|
subscription op with per-resource routing, snapshot-then-changes,
|
||||||
|
composed in-crate (ADR-053). None of these needed transport, ALPN,
|
||||||
|
or dial knowledge.
|
||||||
|
- **Peer vocabulary for cross-node fan-out** — ADR-024's peer-graph
|
||||||
|
routing model (`PeerRef` routing, peer-keyed overlays) is the
|
||||||
|
in-crate vocabulary for "deliver to N peers"; OQ-17 keeps
|
||||||
|
multi-hop out of scope.
|
||||||
|
- **Backpressure/lifecycle precedents** — bounded buffers, per-identity
|
||||||
|
caps, monotonic IDs (ADR-040/041), the abort cascade with
|
||||||
|
`continue-running` opt-in for slow-subscriber management (ADR-020).
|
||||||
|
- **The durable substrate downstream** — alkstore streams (per-consumer
|
||||||
|
offset, replay, trim), engines mem/sqlite/postgres under one
|
||||||
|
contract.
|
||||||
|
- **The TS reference** — `@alkdev/pubsub` (typed event-target: topic =
|
||||||
|
event type name; subscribe/publish/subscribeOnce vocabulary; the
|
||||||
|
`EventEnvelope` ancestor). Shape only; the Rust realization composes
|
||||||
|
on the registry instead of `CustomEvent`.
|
||||||
|
|
||||||
|
## 6. The placement ruling: amend ADR-047 Gap B?
|
||||||
|
|
||||||
|
The case for keeping the broker out of alkcall was: (1) point-to-point
|
||||||
|
call protocol, (2) the broker's lifecycle is routing-shaped, (3)
|
||||||
|
designing blind produces half-designs. Each has softened:
|
||||||
|
|
||||||
|
1. **Point-to-point is true of transport, not of composition.** The
|
||||||
|
registry-operations pattern (ADR-047) already puts composition ops
|
||||||
|
in-crate; ADR-053 ships a live subscriber op in-crate. A topic
|
||||||
|
registry is a *registry overlay + dispatch bookkeeping*, the same
|
||||||
|
class.
|
||||||
|
2. **The lifecycle concern dissolves at the module boundary.** A broker
|
||||||
|
module composed on a registry has exactly the lifecycle its
|
||||||
|
registry has; the "topic registry outlives individual calls"
|
||||||
|
objection describes a *deployment* property, satisfied by any
|
||||||
|
long-lived node, hub or not.
|
||||||
|
3. **The half-design risk is the real surviving constraint — and it
|
||||||
|
now has both halves.** The consumer side is no longer hypothetical
|
||||||
|
(alkblobs' ops surface, alkgit's subscription vocabulary recorded in
|
||||||
|
the alkstore inventory), and the substrate side exists (alkstore
|
||||||
|
streams, the TS reference). A Phase 1 spec against these is not a
|
||||||
|
blind design.
|
||||||
|
|
||||||
|
**Recommendation (non-final until §8 converges):** amend the placement
|
||||||
|
— the broker becomes an in-crate, opt-in composition module (the
|
||||||
|
`ChannelOperations` pattern): it registers topic ops into whatever
|
||||||
|
`OperationRegistry` the node owner points it at, carries no transport
|
||||||
|
or dial knowledge, and stays unregistered by default so no existing
|
||||||
|
surface silently gains it. What remains out of alkcall: engine wiring,
|
||||||
|
glue, dial — OQ-43's territory. The hub that fans out *across*
|
||||||
|
connections composes the module with its `CallConnection`s; within one
|
||||||
|
connection, the module composes locally. The durable event log behind
|
||||||
|
a topic's replay is a `TopicStore` trait seam (§7), not an in-crate
|
||||||
|
engine choice.
|
||||||
|
|
||||||
|
This also resolves OQ-42's uncommittable blocking condition: the
|
||||||
|
deferral's "wait for a consumer to commit" gate is replaced by shipped
|
||||||
|
consumers already on record.
|
||||||
|
|
||||||
|
## 7. The durability seam (the "not rocket surgery" constraint)
|
||||||
|
|
||||||
|
The operator's requirement: the trait seam must let a downstream
|
||||||
|
assembly add memory/sqlite/postgres adapters with the same ease as the
|
||||||
|
repo + cache-invalidation hot-path pattern — adapters are written once
|
||||||
|
per engine, not per deployment.
|
||||||
|
|
||||||
|
Shape to converge on in Phase 1:
|
||||||
|
|
||||||
|
- **In-memory is the default realization** — a `TopicBroker` with no
|
||||||
|
injected store behaves as the TS reference does: live fan-out,
|
||||||
|
stateless, zero durability.
|
||||||
|
- **The seam is one small trait** (working name `TopicStore`),
|
||||||
|
roughly covering: append a published event (returning the topic's
|
||||||
|
monotonically increasing position), read events at-or-after a
|
||||||
|
position for a topic, and per-subscriber cursor save/load. This maps
|
||||||
|
one-to-one onto the alkstore stream contract (`publish_with_key_tx`,
|
||||||
|
offset-ordered reads, per-consumer cursor saves — alkstore ADR-015
|
||||||
|
§2/§4), so the postgres/sqlite adapters are alkstore-streams-backed
|
||||||
|
and the memory adapter is the broker itself.
|
||||||
|
- **Reverse dependency discipline** — alkcall gains no alkstore
|
||||||
|
dependency. The seam stays dependency-free (plain trait, core-value
|
||||||
|
bytes crossing it, mirroring the vendored-core "lean, no engine"
|
||||||
|
posture); alkstore-backed adapters live downstream, where engines
|
||||||
|
already live (the OQ-43 glue consumes both crates).
|
||||||
|
- **Honest scope statement for durable mode:** durable delivery with
|
||||||
|
per-consumer replay is a *deployment* capability. The v1 broker
|
||||||
|
contract should specify the seam, not the durability guarantees
|
||||||
|
beyond it (ordering, dedup, retention pricing ride the store behind
|
||||||
|
the seam — for alkstore-backed adapters, those are ADR-015's pinned
|
||||||
|
rows verbatim).
|
||||||
|
|
||||||
|
## 8. Open questions (proposed — to be promoted into Phase 1's ledger)
|
||||||
|
|
||||||
|
These are the questions this research identifies as needing decisions
|
||||||
|
before any ADR or code. They're proposed in broker-local numbering
|
||||||
|
(QP-*) until Phase 1 promotes them as OQ-44+.
|
||||||
|
|
||||||
|
### QP-1: Topic identity — named topics, op-shaped topics, or a split?
|
||||||
|
|
||||||
|
Need A's model. Candidates:
|
||||||
|
|
||||||
|
- **(a) Named topics (strings).** topic = stream/event-type name; the
|
||||||
|
TS reference's shape; one-topic-one-pipe; alkstore-stream-aligned.
|
||||||
|
Matching is exact-name; wildcarding is optional and additive.
|
||||||
|
- **(b) Op-shaped (`(op_name, params_hash)`), ADR-046 §fan-out's
|
||||||
|
literal form.** Fan-out targets are Sub-typed ops; identity inherits
|
||||||
|
op internals. Pros: dedup key falls out; wire-consistency with
|
||||||
|
existing ops. Cons: the identity couples routing to op schema shape
|
||||||
|
(params_hash stability depends on the schema's canonicalization —
|
||||||
|
which is a real design burden, see §3's A/B split); one topic per op
|
||||||
|
per registrant, so cross-op co-subscription needs the hash of the
|
||||||
|
*matching rule* anyway.
|
||||||
|
- **(c) The split:** identity is named topics (a); "ops as topics"
|
||||||
|
survives only as Need B — a subscriber declares a topic *and* the
|
||||||
|
input schema it validates against, so per-subscription input
|
||||||
|
validation is a subscriber-side schema, validated at subscribe time
|
||||||
|
and at delivery time.
|
||||||
|
|
||||||
|
Working recommendation: **(c)** — identity as named topics, schema/ACL
|
||||||
|
inheritance via the registry on the two broker ops, dedup keying
|
||||||
|
re-derived from topic semantics rather than op params.
|
||||||
|
|
||||||
|
### QP-2: Dedup and delivery semantics
|
||||||
|
|
||||||
|
"A hash as a topic ... for deduping purposes" — what does dedup mean
|
||||||
|
here, precisely? Candidate framings to resolve: idempotent-publish
|
||||||
|
keys (producer-supplied event ids, dedup at the store seam), vs
|
||||||
|
subscriber-side dedup (at-least-once delivery + a dedup window), vs
|
||||||
|
no dedup contract in v1. The answer is pinned partly by the store
|
||||||
|
seam: durable adapters can offer producer-supplied-id dedup for free;
|
||||||
|
in-memory mode cannot (and shouldn't claim to). The contract must
|
||||||
|
state delivery semantics honestly per mode (live: at-most-once per
|
||||||
|
attached subscriber; durable-with-replay: at-least-once per cursor)
|
||||||
|
rather than promising a uniform thing two substrates can't both
|
||||||
|
provide.
|
||||||
|
|
||||||
|
### QP-3: Wire shape — new ops, new event type, or composed handler kinds?
|
||||||
|
|
||||||
|
- Broker ops registered into a registry (`topic/subscribe`,
|
||||||
|
`topic/publish`, optionally `topic/topics` listing) — additive, no
|
||||||
|
envelope change required: subscribe rides `call.responded` (Sub),
|
||||||
|
publish rides `call.published` (Sink).
|
||||||
|
- If wire-visible events beyond op names are wanted (e.g. per-topic
|
||||||
|
attach/detach events), they are *new envelope event types* — legal
|
||||||
|
under ADR-014's additive rule, but each one is a cross-language
|
||||||
|
deliverable to keep in sync; default to zero new event types.
|
||||||
|
|
||||||
|
### QP-4: ACL and schema on both halves
|
||||||
|
|
||||||
|
The operator's requirement, mapped to the registry: the broker ops
|
||||||
|
carry their own `AccessControl` (who may subscribe/publish at all) —
|
||||||
|
and each *topic's* authorization model (per-topic publish ACL, per-
|
||||||
|
topic subscribe ACL, maybe scope patterns) is the piece the fused
|
||||||
|
concept gestured at with "ACL patterns inside the pubsub." Decision
|
||||||
|
needed: per-topic ACL tables owned by the broker module vs topic ops
|
||||||
|
as individually registered specs (each topic's publish/subscribe ops
|
||||||
|
registered separately, inheriting the full registry ACL/visibility/
|
||||||
|
provenance machinery). The latter is the pure Need-B realization but
|
||||||
|
costs a registration per topic; the former is one registration pair
|
||||||
|
with a topic-table lookup. This materially changes the decomposition.
|
||||||
|
|
||||||
|
### QP-5: Subscription lifecycle
|
||||||
|
|
||||||
|
Attach (subscribe with optional position for durable mode), detach,
|
||||||
|
disconnect behavior (live mode: gone on transport drop; durable mode:
|
||||||
|
cursor persists, resubscribe at position — the alkstore streams row's
|
||||||
|
contract), subscriber cap policy (ADR-041 pattern), and interaction
|
||||||
|
with the abort cascade (ADR-020) when a subscriber's sink stream dies.
|
||||||
|
|
||||||
|
### QP-6: Cross-node fan-out and the hub's margin of work
|
||||||
|
|
||||||
|
Within one node: registry dispatch. Across nodes: the hub composes (it
|
||||||
|
holds `CallConnection`s; ADR-024 vocabulary). Decision: does v1 ship
|
||||||
|
any in-crate cross-connection fan-out helper (a `FanOut` that mirrors
|
||||||
|
`call.published` streams into peer connections), or does the hub wire
|
||||||
|
that itself from the primitives? ADR-042's relay exists for channels
|
||||||
|
byte-forwarding; its analog for event fan-out is un-specced — it is
|
||||||
|
either a small in-crate helper or hub-side composition, and the
|
||||||
|
placement ruling (§6) should state which explicitly so OQ-43 doesn't
|
||||||
|
inherit an ambiguity.
|
||||||
|
|
||||||
|
### QP-7: Backpressure across fan-out
|
||||||
|
|
||||||
|
One slow subscriber must not back-pressure the topic (the OQ-23/ADR-040
|
||||||
|
lesson carried into event delivery): per-subscriber bounded queues with
|
||||||
|
an explicit slow-subscriber policy (drop + event, drop + disconnect,
|
||||||
|
block-bounded). Policy default and configurability are to be pinned in
|
||||||
|
Phase 1; the ADR-040 bounded-buffer machinery is the reference.
|
||||||
|
|
||||||
|
### QP-8: Ordering guarantee
|
||||||
|
|
||||||
|
Per-topic FIFO (matches alkstore ADR-015 §1's pinned row and ADR-046's
|
||||||
|
per-stream delivery posture). The open part: does the broker promise
|
||||||
|
ordering *across* publishers to one topic (single sequencer required)
|
||||||
|
or only per-publisher (interleaving legal, no sequencer needed)? The
|
||||||
|
durable store behind the seam forces single-writer-per-topic-sequencer
|
||||||
|
for (a); in-memory could do either — but the contract has to match the
|
||||||
|
store's row, so this resolves with QP-2 rather than independently.
|
||||||
|
|
||||||
|
## 9. Recommendation and convergence criteria
|
||||||
|
|
||||||
|
**Direction (to carry into Phase 1, not final):** an in-crate,
|
||||||
|
opt-in broker composition module on the call registry — named topics
|
||||||
|
for identity (QP-1c), registry machinery providing schema + ACL on the
|
||||||
|
two broker ops (QP-3, QP-4 resolved as one topic-table vs per-topic
|
||||||
|
registration decision), a dependency-free `TopicStore` seam with the
|
||||||
|
broker itself as the in-memory adapter (QP-D from §7), per-topic FIFO
|
||||||
|
with an honest per-mode delivery contract (QP-2, QP-8), ADR-040-style
|
||||||
|
per-subscriber backpressure policy (QP-7), and an explicit statement
|
||||||
|
on the cross-node margin (QP-6). ADR-047 Gap B is amended by the Phase
|
||||||
|
1 ADR (which supersedes the placement ruling for this scope); OQ-42 is
|
||||||
|
resolved by that ADR; OQ-43 remains untouched (dial/engine/glue is
|
||||||
|
still the composition layer's).
|
||||||
|
|
||||||
|
**Convergence criteria — Phase 0 is done when:**
|
||||||
|
|
||||||
|
1. Every QP-* above carries a candidate resolution with its rejection
|
||||||
|
cases written down (the ADR-032 one-way-door discipline applies to
|
||||||
|
the wire-visible ones).
|
||||||
|
2. At least one concrete consumer surface has signed off on the topic
|
||||||
|
model — alkblobs' put-path ops for the publish half, the recorded
|
||||||
|
alkgit subscription vocabulary for the subscribe half (the review
|
||||||
|
011 downstream-input pattern: the consuming crate drives the
|
||||||
|
contract).
|
||||||
|
3. The QP-4 decision (topic-table vs per-topic registration) is made —
|
||||||
|
it is the largest decomposition determinant.
|
||||||
|
4. The placement amendment's blast radius is enumerated: which ADRs/
|
||||||
|
docs re-key (046 §fan-out, 047 Gap B, open-questions OQ-42, README
|
||||||
|
ADR tables; alkstore consumer-inventory rows are cross-repo notes,
|
||||||
|
updatable but not blocking).
|
||||||
|
|
||||||
|
**Out of scope for this research:** implementation, task decomposition,
|
||||||
|
version assignment, wire-format drafting. Nothing here creates a
|
||||||
|
`not_implemented` surface — the broker module does not exist and no
|
||||||
|
API names, docs, or changelog entries will advertise it until Phase 1's
|
||||||
|
ADR lands, per the implement-or-stop-advertising ruling (the only
|
||||||
|
permitted deferral statement is "not this session").
|
||||||
@@ -962,7 +962,11 @@ promises.
|
|||||||
vice versa) as the smaller change; record the decision in ADR-047 §5
|
vice versa) as the smaller change; record the decision in ADR-047 §5
|
||||||
or a follow-on ADR. Then implement the adoption path: the
|
or a follow-on ADR. Then implement the adoption path: the
|
||||||
non-allocating side registers the remotely-assigned id in its
|
non-allocating side registers the remotely-assigned id in its
|
||||||
manager/mux.
|
manager/mux. **[RESOLVED 2026-10-06/13: the implemented odd/even
|
||||||
|
split (connect odd, accept even) + `ChannelManager::adopt_channel`;
|
||||||
|
the marked-pub contract rides it end-to-end — ADR-047 amendment 4;
|
||||||
|
the C-08-blocker stub class this decision previously gated on is
|
||||||
|
closed with reviews 010/011's single-publish fixes.]**
|
||||||
- C-12: reject `channel_id: 0` in the `channel/close` handler.
|
- C-12: reject `channel_id: 0` in the `channel/close` handler.
|
||||||
- C-13: `channel/close` must drain (signal EOF to the handler, await
|
- C-13: `channel/close` must drain (signal EOF to the handler, await
|
||||||
pump completion) before `abort`/decrement; implement REQ-CH-06
|
pump completion) before `abort`/decrement; implement REQ-CH-06
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
# Review 010 — Stub sweep of the published surface (0.8.1)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Open — filed 2026-10-13. Triggered by a downstream finding: alkblobs'
|
||||||
|
ops-put task hits `channel:pub_open_not_implemented` when its Sink op
|
||||||
|
carrying the `channel_open` marker is invoked, exactly as the code
|
||||||
|
comments predict. The downstream project is paused on this.
|
||||||
|
|
||||||
|
**All four findings resolved 2026-10-13** — implemented in alkcall
|
||||||
|
main ahead of the single 0.9.0 publish (see the resolution note under
|
||||||
|
S-01's remediation options; the pinned stub tests are deleted and the
|
||||||
|
`not_implemented` source-tree guard is part of the test suite,
|
||||||
|
`tests/stub_guard.rs`, wired into AGENTS.md's "Stub Guard" section).
|
||||||
|
|
||||||
|
Policy context: shipping stubs to crates.io is now a hard failure per
|
||||||
|
the repo owner's ruling. Any handler that registers successfully and
|
||||||
|
returns an error of the form `*:not_implemented` on every invocation
|
||||||
|
is a bug, not a documented limitation. The state tracked in OQ-39,
|
||||||
|
OQ-40, and the C-08 blocker comment is re-classed from "deferred
|
||||||
|
open question" to "unpublishable stub" by this review. The rationale
|
||||||
|
previously used to justify shipping them (loud failure, OQ tracking)
|
||||||
|
was reviewed and rejected: a loud stub is still a stub, and the only
|
||||||
|
accepted endpoint is implement-or-stop-advertising before the next
|
||||||
|
published version.
|
||||||
|
|
||||||
|
Full sweep of `src/` performed: no `TODO`/`FIXME`/`todo!`/
|
||||||
|
`unimplemented!` markers exist anywhere; the four findings below are
|
||||||
|
the complete set of registered-but-always-failing handlers. No other
|
||||||
|
module (gateway, client, protocol, core) contains an
|
||||||
|
invocation-time unimplemented path.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Every handler registered on a public surface — `OperationRegistry`
|
||||||
|
(local or per-connection overlay), `services/list` discovery output,
|
||||||
|
relay openables — that cannot serve its declared operation type.
|
||||||
|
Evidence includes pinned tests that *assert* the stub behavior:
|
||||||
|
|
||||||
|
- `make_open_handler_sink_returns_not_implemented`
|
||||||
|
(src/channels/operations.rs:1695)
|
||||||
|
- `resources_subscribe_handler_returns_not_implemented` and
|
||||||
|
`resources_subscribe_handler_not_implemented_even_when_empty` (src/channels/operations.rs:1303, 1322)
|
||||||
|
- `control_handler_success_path_returns_not_implemented` (src/channels/operations.rs:1439)
|
||||||
|
- relay_tests.rs:113 asserts `pub_open_not_implemented`
|
||||||
|
|
||||||
|
Findings continue the review numbering with prefix `S` (stub).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-01 [critical] Pub-typed open wrapper: registered, advertised, returns `INTERNAL` on every invocation
|
||||||
|
|
||||||
|
**Location:** `make_open_handler_sink`, src/channels/operations.rs:1158-1199.
|
||||||
|
Wired into `ChannelCore::register_openable_with_establisher` at
|
||||||
|
src/channels/operations.rs:682 (`OperationType::Pub =>
|
||||||
|
HandlerKind::Sink(make_open_handler_sink(...))`).
|
||||||
|
|
||||||
|
**Behavior:** registration for a `Pub`-typed open op **succeeds**. The
|
||||||
|
spec enters `OperationRegistry` with the `channel_open` marker and a
|
||||||
|
`publish_schema`, so it is discoverable via `services/list` /
|
||||||
|
`services/schema` and selectable by a consumer's open flow. The first
|
||||||
|
invocation fails with `channel:pub_open_not_implemented: Pub-typed
|
||||||
|
open ops are not yet implemented (requires channel-adoption path,
|
||||||
|
C-08/Unit 5)`.
|
||||||
|
|
||||||
|
**Exposure window:** introduced 2026-08-13 (commit 8066d08), four
|
||||||
|
days before the first published version — this stub has shipped in
|
||||||
|
every release from v0.1.0 through 0.8.1, nine published versions.
|
||||||
|
|
||||||
|
**Why this one is inexcusable rather than merely incomplete:** the
|
||||||
|
entire Sink stack ADR-046/047 call for is implemented and tested in
|
||||||
|
the same crate:
|
||||||
|
|
||||||
|
- wire event `call.published` (src/protocol/wire.rs:17)
|
||||||
|
- serving-loop sink dispatch, chunk feeding, `publish_schema`
|
||||||
|
per-chunk validation (src/protocol/dispatch.rs:607-643, 1002, 1225)
|
||||||
|
- registry `resolve_sink_handler` / `invoke_sink` (src/registry/registration.rs:428-509)
|
||||||
|
- client-side sink forwarding, initiator→responder `call.published`
|
||||||
|
streaming (src/client/from_call.rs:478-496)
|
||||||
|
- gateway sink dispatch (src/gateway/dispatch.rs:160-550)
|
||||||
|
- discovery advertises per-chunk `publish_schema` on the open op's
|
||||||
|
spec (src/registry/discovery.rs:178)
|
||||||
|
|
||||||
|
The only missing piece is the open-op wrapper itself. The stub's
|
||||||
|
textual justification (data plane must ride the adopted channel's
|
||||||
|
`BiStream`, gated on the C-08 adoption path) is in tension with
|
||||||
|
ADR-047 §1's own semantics, which define the open-op Pub case as
|
||||||
|
data flowing over channel 0's call plane via `call.published` —
|
||||||
|
precisely what the implemented stack does. The comment even admits
|
||||||
|
the design question is unresolved ("whether that data flows through
|
||||||
|
the allocated channel's `BiStream` ... or is consumed directly by the
|
||||||
|
`OpenHandler`"), i.e. the C-09-style blocker was asserted, not
|
||||||
|
established. This is hedges-cured-as-code.
|
||||||
|
|
||||||
|
**Downstream impact:** alkblobs ops-put (`/workspace/@alkdev/alkblobs/tasks/ops-put.md`)
|
||||||
|
depends on this exact shape (`blobs/put` as a Sink spec with the
|
||||||
|
`channel_open` marker); the task is paused on the decision. ADR-051
|
||||||
|
§6 documents the same gap from the relay side, where registration at
|
||||||
|
least fails loudly.
|
||||||
|
|
||||||
|
**Remediation options (pick one before the next release):**
|
||||||
|
|
||||||
|
> **CORRECTED 2026-10-13 (reviews 011 + ADR-047 amendment 4):** the
|
||||||
|
> implemented fix is option 1, but option 1's sketch below was the
|
||||||
|
> wrong shape for marked ops and is superseded — the correct shape is
|
||||||
|
> review 011's (also pinned as ADR-047 amendment 4): the initiator
|
||||||
|
> allocates and streams bytes on the channel before the open
|
||||||
|
> resolves; the responder adopts (`register_pub_openable`); the
|
||||||
|
> wrapper is the responder of record, awaiting the handler and
|
||||||
|
> merging ack fields + handler result into ONE response; zero
|
||||||
|
> `call.published` on marked ops (chunks rejected loudly);
|
||||||
|
> `RelayError::PubTypedOpen` is replaced by the relay put
|
||||||
|
> translation. Handing the `OpenHandler` the serving loop's
|
||||||
|
> `PublishStream` (the sketch below) is ADR-046's *unmarked* pub
|
||||||
|
> semantics and would have re-validated MB-scale binary payloads as
|
||||||
|
> JSON call chunks. The downstream agent caught this in review 011
|
||||||
|
> before the amendment was implemented.
|
||||||
|
|
||||||
|
> **RESOLVED 2026-10-13** — implemented in alkcall main (the single
|
||||||
|
> 0.9.0 publish, review 011 §6): S-01 via the marked-pub contract
|
||||||
|
> above, S-02/S-03 implemented (ADR-052/ADR-053), S-04
|
||||||
|
> forward-through (ADR-022, 2026-10-13 amendment), the
|
||||||
|
> `not_implemented` source-tree guard wired into AGENTS.md's
|
||||||
|
> "Stub Guard" section, and the pinned stub tests deleted.
|
||||||
|
|
||||||
|
1. **Implement the wrapper** — allocate the channel id, run the
|
||||||
|
establishment phase (reply fields carry the offer ack, per ADR-047
|
||||||
|
§1 and ops-put's acceptance criteria), hand the `OpenHandler` the
|
||||||
|
`PublishStream` already produced by the serving loop along with the
|
||||||
|
channel's `Connection`. All plumbing exists; this is wrapper glue,
|
||||||
|
plus deciding the OpenHandler-reads-channel-vs-reads-PublishStream
|
||||||
|
question in an amended ADR-047. *[Superseded sketch — see the
|
||||||
|
correction above; review 011's shape is the pinned one.]*
|
||||||
|
2. **Reject at registration** — return an error from
|
||||||
|
`register_openable_with_establisher` for `OperationType::Pub`,
|
||||||
|
mirroring `RelayError::PubTypedOpen` (src/channels/relay.rs:147-156),
|
||||||
|
which already follows the correct fail-fast pattern. The op never
|
||||||
|
exists, so nothing is advertised that cannot serve. ops-put then
|
||||||
|
re-plans its establishment ack onto a plain Sink op without the
|
||||||
|
marker, or waits for option 1.
|
||||||
|
|
||||||
|
Option 2 is the honest minimum for the next release; option 1
|
||||||
|
unblocks ops-put directly.
|
||||||
|
|
||||||
|
## S-02 [critical] `channel/control`: ADR-037 op registered on every channels connection, always fails
|
||||||
|
|
||||||
|
**Location:** `make_control_handler`, src/channels/operations.rs:242-272.
|
||||||
|
Registered publicly by `ChannelOps::register_on` (src/channels/operations.rs:67),
|
||||||
|
spec exported (`channel_control_spec`, line 124). OQ-39; and
|
||||||
|
docs/architecture/channel-operations.md §"channel/control —
|
||||||
|
out-of-band control on channel 0" documents the request shape as if
|
||||||
|
the op works.
|
||||||
|
|
||||||
|
**Behavior:** validates `channel_id`, checks `manager.has_channel`,
|
||||||
|
then returns `channel:control_not_implemented` unconditionally. The
|
||||||
|
channel-existence check is the only code that does anything — which
|
||||||
|
perversely means the stub returns *not found* for unknown channels
|
||||||
|
but *not implemented* for real ones.
|
||||||
|
|
||||||
|
**Exposure window:** shipped since the ADR-037 surface landed
|
||||||
|
(0.1.0-era); nine published versions.
|
||||||
|
|
||||||
|
**Remediation:** implement the control-handle surface (the design is
|
||||||
|
specified in OQ-39: ALPN-crate-registered per-channel control
|
||||||
|
callbacks, channels-layer registry keyed by `channel_id`), or remove
|
||||||
|
`channel_control_spec` from `ChannelOps::register_on` until a
|
||||||
|
consumer needs it. Publishing an op that exists on the wire's
|
||||||
|
discovery surface but cannot run is the failure mode being outlawed.
|
||||||
|
|
||||||
|
## S-03 [critical] `channel/resources/subscribe`: ADR-037 §6 streaming op registered, always fails with a one-shot error
|
||||||
|
|
||||||
|
**Location:** `make_resources_subscribe_handler`,
|
||||||
|
src/channels/operations.rs:283-297. Registered by
|
||||||
|
`ChannelOps::register_on`; spec exported (line 146). OQ-40.
|
||||||
|
|
||||||
|
**Behavior:** the `StreamingHandler` emits exactly one
|
||||||
|
`channel:resources_not_implemented` error envelope — a fake stream
|
||||||
|
that terminates after one error, rather than a snapshot or a live
|
||||||
|
subscription. The doc comment says "callers fail loudly rather than
|
||||||
|
receiving a fake one-shot snapshot", but the loud failure *is* the
|
||||||
|
fake one-shot artifact.
|
||||||
|
|
||||||
|
**Exposure window:** same as S-02.
|
||||||
|
|
||||||
|
**Downstream note:** OQ-40 self-declares this "load-bearing for
|
||||||
|
alktunnels' discovery UI"; a hub or dashboard consumer that discovers
|
||||||
|
this op gets a capability that does not exist.
|
||||||
|
|
||||||
|
**Remediation:** implement the aggregated-enumerator subscription
|
||||||
|
(ADR-047 §6), or drop the op from `ChannelOps::register_on` (the
|
||||||
|
static half — `OperationSpec.description` on `services/list` —
|
||||||
|
remains available, as OQ-40 itself records).
|
||||||
|
|
||||||
|
## S-04 [major] Peer-announced Sub/Pub ops install invocation-failing stubs on connection overlays
|
||||||
|
|
||||||
|
**Location:** `forwarding_stub_for_announced_op`,
|
||||||
|
src/registry/op_register.rs:215-229, called from `op_register_handler`
|
||||||
|
(line 184).
|
||||||
|
|
||||||
|
**Behavior:** when a peer announces a `Sub`- or `Pub`-typed spec via
|
||||||
|
`op/register`, the handler is accepted and registered on the
|
||||||
|
connection overlay (`HandlerKind::Once`) with a body that returns
|
||||||
|
`INVALID_OPERATION_TYPE` on every invocation. The spec stays
|
||||||
|
discoverable on the peer-keyed overlay (`services/list-peers`,
|
||||||
|
provenance `FromCall`), and a composing handler that resolves through
|
||||||
|
`ctx.env.invoke` hits the error instead of the real op.
|
||||||
|
|
||||||
|
Unlike S-01 through S-03 this path has a documented contract
|
||||||
|
rationale (nested composition is request/response-only; streaming/
|
||||||
|
sink forwarding should ride `from_call` in the other direction), and
|
||||||
|
review 005 discusses the shape. Reviewed and re-classed: the
|
||||||
|
rationale may justify *not implementing nested sink forwarding*, but
|
||||||
|
it does not justify *accepting and advertising* the announcement. The
|
||||||
|
observable behavior is identical to a stub.
|
||||||
|
|
||||||
|
**Remediation:** reject the announcement of `Sub`/`Pub` op types at
|
||||||
|
`op/register` time with a typed error (registration-time loudness, no
|
||||||
|
overlay entry), or wire them through the existing forwarding shapes
|
||||||
|
(`make_streaming_forwarding_handler` / `make_sink_forwarding_handler`
|
||||||
|
already exist in src/client/from_call.rs). Minimum-bar is the
|
||||||
|
rejection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-findings (checked and cleared)
|
||||||
|
|
||||||
|
The sweep had to distinguish real stubs from deferred-by-design
|
||||||
|
boundaries and test scaffolding. For the record:
|
||||||
|
|
||||||
|
- `sink_empty_connection` (src/protocol/test_support.rs) — test-only
|
||||||
|
helper; every use is under `#[cfg(test)]`. Not a stub.
|
||||||
|
- `src/channels/adapter.rs` "stream header interpretation deferred to
|
||||||
|
the downstream ALPN crate (OQ-41)" — this is ADR-035's design
|
||||||
|
boundary (the channels layer does not interpret stream contents),
|
||||||
|
not missing functionality on a registered surface.
|
||||||
|
- Early-arrival chunk parking (src/channels/manager.rs:485) —
|
||||||
|
implemented behavior, the log text merely says "not yet adopted".
|
||||||
|
- `channels//sub` silent-stub shape (review 009 audit N-4) — already
|
||||||
|
fixed in 0.8.0; listed here as the precedent for the correct
|
||||||
|
remedy (loud failure at the boundary), though see the policy note
|
||||||
|
below since even loud invocation-time failure is no longer
|
||||||
|
sufficient for registered surfaces.
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
Four stubs shipped across nine published versions, three of them
|
||||||
|
reachable by any consumer doing routine discovery. The pinned tests
|
||||||
|
demonstrate the team knew, documented, tracked open questions for
|
||||||
|
each, and published anyway. Per the ruling, none may be present in
|
||||||
|
the next published version. The relay's `PubTypedOpen` /
|
||||||
|
`UnmarkedSpec` errors are the house pattern to generalize: **reject
|
||||||
|
at registration, never at invocation.**
|
||||||
|
|
||||||
|
## Required actions before next release
|
||||||
|
|
||||||
|
1. Resolve S-01/S-02/S-03 each as implement-or-stop-advertising.
|
||||||
|
Whichever path is chosen, no `*:not_implemented` error string may
|
||||||
|
remain reachable in `src/`.
|
||||||
|
2. Resolve S-04 at minimum by rejecting Sub/Pub announcements at
|
||||||
|
`op/register`; forwarding-through is the better fix and is
|
||||||
|
mechanically trivial given the existing from_call shapes.
|
||||||
|
3. Add a guard so this class cannot recur: a test that greps the
|
||||||
|
source tree for `not_implemented` (and a registration-time
|
||||||
|
invariant that a registered `HandlerKind` must be capable of
|
||||||
|
succeeding), wired into the release checklist in AGENTS.md.
|
||||||
|
4. Update OQ-39/OQ-40/C-08 entries and channel-operations.md to match
|
||||||
|
the post-fix reality; the "current stub" language in both is now
|
||||||
|
invalid documentation of shipped behavior.
|
||||||
|
5. Amend ADR-047 with the Pub open-op data-plane decision (if
|
||||||
|
implementing) or mark `channels/<alpn>/pub` as not-provided until
|
||||||
|
implemented (if rejecting); either way close the gap between the
|
||||||
|
ADR table (which lists `channels/<alpn>/pub` as a supported shape)
|
||||||
|
and the code.
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
# Review 011 — The marked-pub open contract: downstream input on review 010's S-01, and what the single-publish fix must pin
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Closed — filed 2026-10-13. From alkblobs (the downstream crate paused on
|
||||||
|
review 010 S-01; its ops-put task gates on this surface). Purpose:
|
||||||
|
correct review 010's S-01 remediation sketch before the amendment is
|
||||||
|
drafted, enumerate what the amendment must pin so one publish
|
||||||
|
(0.9.0) fixes all four stubs right, and record the downstream
|
||||||
|
requirements and non-requirements from the consumer seat.
|
||||||
|
|
||||||
|
**Closed 2026-10-13:** all seven §2 pins are implemented and pinned in
|
||||||
|
ADR-047 amendment 4 / ADR-052 / ADR-053 / ADR-022's 2026-10-13
|
||||||
|
amendment, with the §3 verification gates landed as tests
|
||||||
|
(`channels::operations::pub_open_tests`,
|
||||||
|
`channels::hub_leg::gate2_tests::gate_pub_relay_put_end_to_end`, the
|
||||||
|
registration-invariant tests in `registry::registration` and
|
||||||
|
`registry::op_register`), the guard is `tests/stub_guard.rs`, and the
|
||||||
|
docs rewrites below are done. The §6 sequencing concluded.
|
||||||
|
|
||||||
|
Input basis: alkblobs ADR-014
|
||||||
|
(`/workspace/@alkdev/alkblobs/docs/architecture/decisions/
|
||||||
|
014-marked-pub-put-contract-and-alkcall-gate.md`, committed 7ac0099)
|
||||||
|
— the reviewed downstream ruling this review summarizes the
|
||||||
|
requirements of.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. S-01: the remediation is "implement", but the review's option-1 sketch is the wrong shape — amend the review text
|
||||||
|
|
||||||
|
Review 010's S-01 poses two remedies. Option 2 (reject at
|
||||||
|
registration) is honest but strands the downstream: alkblobs' put is
|
||||||
|
the real consumer of the marked-pub shape, and its settlement was
|
||||||
|
"implement, in the adoption shape". The one publish should take
|
||||||
|
option 1. But **option 1's sketch is wrong for marked ops** and
|
||||||
|
would, if implemented as sketched, ship a second incoherence this
|
||||||
|
ADR cycle exists to end:
|
||||||
|
|
||||||
|
> "hand the `OpenHandler` the `PublishStream` already produced by the
|
||||||
|
> serving loop along with the channel's `Connection`"
|
||||||
|
|
||||||
|
That reads ADR-046's **unmarked** Pub semantics (`call.published`
|
||||||
|
JSON chunks through `publish_schema` validation) into the **marked**
|
||||||
|
case. ADR-047 §2's marker sentence says the opposite: *"the
|
||||||
|
`channel_open` marker that tells the channels layer 'this op's
|
||||||
|
stream is binary, not JSON' — allocate a channel for it."* A marked
|
||||||
|
Pub op whose bulk bytes rode `call.published` would be the worst of
|
||||||
|
both stacks: bulk binary re-validated per-chunk as JSON against
|
||||||
|
`publish_schema`, MB-scale payloads through the JSON event grammar,
|
||||||
|
and no bounded-buffer backpressure (ADR-040's bounded buffer is
|
||||||
|
precisely the mechanism that exists for this). Section 010's
|
||||||
|
"tension" paragraph (stub comment vs ADR-047 §1's `call.published`
|
||||||
|
prose) resolves the same way: §1/AAD-046's `call.published` prose
|
||||||
|
describes *unmarked* Pub ops; the *marked* case's stream is the
|
||||||
|
allocated channel's BiStream.
|
||||||
|
|
||||||
|
The settled shape (agreed with the downstream's architecture
|
||||||
|
session; recorded as alkblobs ADR-014 §1/§2/§3):
|
||||||
|
|
||||||
|
1. **The initiator allocates and writes immediately.**
|
||||||
|
Connection-owner allocates the `channel_id` (ADR-047 §5; connect
|
||||||
|
side → odd per the implemented odd/even split,
|
||||||
|
`src/channels/manager.rs` `next_channel_id`), opens the write
|
||||||
|
half on it, and streams bytes before the open op resolves. The
|
||||||
|
existing early-arrival parking (`drain_early_arrivals`,
|
||||||
|
`src/channels/manager.rs`) is the established mechanism covering
|
||||||
|
the responder-adopts race — implemented, tested, no new
|
||||||
|
machinery.
|
||||||
|
2. **The responder adopts.** The channel ID crosses the open
|
||||||
|
invocation as a wrapper-reserved field. The reserved-field
|
||||||
|
placement question — reserved input key vs envelope field — is
|
||||||
|
decided by schema-validation ordering: validation runs in
|
||||||
|
`invoke_*` before any handler, so a reserved *input* key must be
|
||||||
|
stripped in the dispatch sink branch (the layer that reads the
|
||||||
|
`channel_open` marker) before `input_schema` validation; an
|
||||||
|
envelope field would extend the call event grammar for one op
|
||||||
|
family. Recommendation: **reserved input key + dispatch-layer
|
||||||
|
strip**, symmetric with amendment 3's reply-side
|
||||||
|
`RESERVED_REPLY_KEY` reservation — the input side of the same
|
||||||
|
pattern. Either way, the exact key name and the reject-not-strip
|
||||||
|
posture for a *marked* op are wire-visible from the first
|
||||||
|
consumer (alkblobs' put; not yet shipped — it pends behind this
|
||||||
|
amendment, so this is the cheapest moment to pin).
|
||||||
|
3. **The wrapper is the responder of record.** Flow:
|
||||||
|
`policy.check_open` → adopt → bounded establisher (offer
|
||||||
|
validation, establishment reply fields — ADR-049 amendment 3's
|
||||||
|
mechanism, unchanged) → spawn the `OpenHandler` on the channel's
|
||||||
|
`Connection` → **await the handler's `JoinHandle`** → respond
|
||||||
|
once: the `{channel_id}` output extended with the establisher's
|
||||||
|
reply fields and the handler-completion result merged (R-02's
|
||||||
|
lifetime contract is load-bearing here — the handle must track
|
||||||
|
the data plane). A failed establishment tears the channel down
|
||||||
|
and replies `channel:open_failed` (the only response); initiator
|
||||||
|
bytes racing the torn-down channel hit EOF and are discarded.
|
||||||
|
4. **Zero `call.published` data on the call plane for marked pub
|
||||||
|
ops** — a nonempty publish-chunk stream on a marked op is
|
||||||
|
rejected loudly, never consumed. Two competing data paths is the
|
||||||
|
incoherence that must never exist.
|
||||||
|
5. **Relay lifting:** `PubTypedOpen` rejection
|
||||||
|
(`src/channels/relay.rs`) is replaced with real relay
|
||||||
|
translation for pub openables (the hub's put-forwarding shape).
|
||||||
|
|
||||||
|
The corollary this implies downstream (also pinned in alkblobs
|
||||||
|
ADR-014 §2): **the put's establishment ack is informative-in-result,
|
||||||
|
not a gating ack.** A sink op gets exactly one `call.responded`
|
||||||
|
(ADR-046 §5), produced after the handler completes — so a putter
|
||||||
|
cannot wait for the ack before streaming (a wait deadlocks: the
|
||||||
|
response cannot exist until the stream completes). The fetch/put
|
||||||
|
asymmetry is direction-inherent: fetch (`Sub`) acks at
|
||||||
|
establishment before any byte; put (`Pub`) acks in the result. The
|
||||||
|
amendment should state this sentence, not leave it for each
|
||||||
|
consumer to derive — it is exactly the kind of half-stated
|
||||||
|
implication that review 010 flagged as drift fuel.
|
||||||
|
|
||||||
|
## 2. What amendment 4 must pin (the publish checklist)
|
||||||
|
|
||||||
|
All of these are wire-visible or API-visible from the first
|
||||||
|
consumer; none is safely deferrable to a later amendment while
|
||||||
|
put-shaped consumers exist:
|
||||||
|
|
||||||
|
1. **Marker semantics** — the marked Pub op's stream is the adopted
|
||||||
|
channel's `BiStream` (ADR-050 pump patterns, one direction);
|
||||||
|
zero `call.published` (§1.4 above; rejection is the wire
|
||||||
|
behavior).
|
||||||
|
2. **`publish_schema` on a marked op is refused at registration** —
|
||||||
|
publish chunks never exist, so the field is dead machinery on a
|
||||||
|
marked spec; the same class review 010 outlaws (registered but
|
||||||
|
incapable of mattering). Registration-time loudness, per the
|
||||||
|
house pattern.
|
||||||
|
3. **The reserved-field wire form** for the adopted channel id
|
||||||
|
(input-key + dispatch-strip recommended, §1.2) — key name and
|
||||||
|
strip-before-validation ordering pinned in the ADR; one-way from
|
||||||
|
the first consumer.
|
||||||
|
4. **The wrapper-as-responder-of-record flow** (§1.3) — response
|
||||||
|
merge order (`channel_id` reserved → establisher reply fields →
|
||||||
|
handler result), single-response semantics, teardown-on-fail.
|
||||||
|
5. **The ack-timing sentence** (§1 corollary) — put acks in-result;
|
||||||
|
consumers stream without waiting.
|
||||||
|
6. **Relay translation for pub openables** (§1.5) — no
|
||||||
|
`PubTypedOpen` remains.
|
||||||
|
7. **The client-side initiator-allocates put-open capability.**
|
||||||
|
Nothing in `ChannelClient` supports stream-then-open today:
|
||||||
|
`open_channel`/`open_channel_with_reply` await the open
|
||||||
|
*reply* before returning the halves — unusable when bytes flow
|
||||||
|
before the reply. The amendment supplies the putter-facing
|
||||||
|
shape (allocate odd id locally → write half streaming
|
||||||
|
immediately → send the open op carrying the reserved id → await
|
||||||
|
the single merged response). Without this the server-side
|
||||||
|
wrapper is untestable end-to-end in-crate.
|
||||||
|
|
||||||
|
## 3. Verification gates for the S-01 implementation
|
||||||
|
|
||||||
|
To be landed as tests in the same publish (deleting the pinned stub
|
||||||
|
tests per review 010's action 3 —
|
||||||
|
`make_open_handler_sink_returns_not_implemented`,
|
||||||
|
`relay_tests.rs:113`, and the C-08/C-09 blocker-comment assertions
|
||||||
|
they assert):
|
||||||
|
|
||||||
|
- known-length put e2e: offer + adopt + bytes → one response,
|
||||||
|
`{channel_id, ack fields, handler result}` merge order verified;
|
||||||
|
- unknown-length put e2e: the handler's digest rides the merged
|
||||||
|
response (the downstream putter's wire contract, ADR-012 §2's
|
||||||
|
`{token, digest}`);
|
||||||
|
- publish-chunk rejection: a marked pub op carrying nonempty
|
||||||
|
publish chunks → loud rejection, no handler consumption;
|
||||||
|
- establishment failure: `channel:open_failed`, channel torn down,
|
||||||
|
ledger un-incremented, racing bytes EOF-discarded;
|
||||||
|
- `publish_schema` registration refusal on a marked op;
|
||||||
|
- relay translation: a hub translating a pub openable end-to-end
|
||||||
|
(the ADR-051-§6 gap closed).
|
||||||
|
|
||||||
|
## 4. S-02 / S-03 / S-04 from the downstream seat
|
||||||
|
|
||||||
|
No alkblobs requirement rides any of the three — the ops module
|
||||||
|
rides neither `channel/control` nor `channel/resources/subscribe`
|
||||||
|
nor `op/register`-announced streaming. Each remedy is downstream-safe:
|
||||||
|
|
||||||
|
- **S-02/S-03**: implement-or-stop-advertising per the ruling;
|
||||||
|
implementing both is preferred (the publish is one 0.x bump
|
||||||
|
either way, and re-advertising later would be a second bump).
|
||||||
|
What the downstream needs: nothing. Do not couple the S-01 shape
|
||||||
|
to these designs (the OQ-39 control-handle and OQ-40 enumerator
|
||||||
|
designs are independent machinery).
|
||||||
|
- **S-04**: forwarding-through
|
||||||
|
(`make_streaming_forwarding_handler` /
|
||||||
|
`make_sink_forwarding_handler` already exist) over rejection —
|
||||||
|
the rejection leaves a discoverable-but-unusable service
|
||||||
|
announcement, which is the same consumer-visible failure mode
|
||||||
|
the ruling targets, one layer over.
|
||||||
|
|
||||||
|
## 5. The four published crates' impact (the same-publish update)
|
||||||
|
|
||||||
|
No published consumer registers a Pub-typed openable (alktunnels'
|
||||||
|
four openables are Sub-type; alkhttp has no openables in its
|
||||||
|
default surface; alkty rides Sub + control-JSON; alksocks rides
|
||||||
|
Sub) — **the S-01 implementation is capability-additive in
|
||||||
|
practice**. The semver-relevant risk concentrates in what the
|
||||||
|
publish *removes*:
|
||||||
|
|
||||||
|
- S-02/S-03 implemented → additive, zero removals, the four crates
|
||||||
|
re-verify and bump their floors only.
|
||||||
|
- S-02/S-03 stop-advertised → two discoverable ops disappear;
|
||||||
|
acceptable per the ruling, but a 0.x removal worth a changelog
|
||||||
|
line for every consumer.
|
||||||
|
- S-01/§2's wire pins (reserved key, reject-chunks) carry no
|
||||||
|
shipped consumer; the first consumer (alkblobs' put) pends
|
||||||
|
behind the amendment — ADR-014 §3's gate — and floors up to the
|
||||||
|
amendment release at task start.
|
||||||
|
|
||||||
|
## 6. Sequencing for the one publish
|
||||||
|
|
||||||
|
1. The four amendment documents (ADR-047 amendment 4; the
|
||||||
|
OQ-39/OQ-40 promotions; the S-04 pin) — drafted for review
|
||||||
|
before code, per the working agreement.
|
||||||
|
2. Implementation + the §3 gates + the review-010 action-3 guard
|
||||||
|
(`not_implemented` source-tree grep wired into AGENTS.md's
|
||||||
|
release checklist; the four pinned stub tests deleted).
|
||||||
|
3. Docs: OQ-39/40 resolved, C-08 closed, channel-operations.md's
|
||||||
|
hedge-era sentences rewritten, review 010's S-01 sketch
|
||||||
|
corrected to this review's shape (so the published record stops
|
||||||
|
carrying the wrong-graph reading).
|
||||||
|
4. One publish, all downstream floors bump in it.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- review 010 (the stub sweep this review amends the S-01 path of)
|
||||||
|
- alkblobs ADR-014 (`014-marked-pub-put-contract-and-alkcall-gate.md`)
|
||||||
|
— the downstream ruling; its §2 pins the ack-timing asymmetry
|
||||||
|
- alkblobs tasks/ops-put.md — the paused task; its Notes record the
|
||||||
|
gate and the two timing clarifications
|
||||||
|
- ADR-040 (bounded buffer — the backpressure the data plane rides),
|
||||||
|
ADR-046 (the pub lifecycle's single response), ADR-047 §2/§5
|
||||||
|
(the marker; connection-owner allocates), ADR-049 + amendments
|
||||||
|
2/3 (establishment, plan, reply fields; the RESERVED_REPLY_KEY
|
||||||
|
pattern the input-side reservation mirrors), ADR-050 (pumps)
|
||||||
|
- ADR-051 §6 (the relay-side documentation of the same gap)
|
||||||
|
- `src/channels/manager.rs` (`next_channel_id` odd/even;
|
||||||
|
`drain_early_arrivals`), `src/channels/operations.rs` (the stub,
|
||||||
|
the wrapper), `src/channels/relay.rs` (`PubTypedOpen`)
|
||||||
@@ -17,6 +17,135 @@ Format: date | found-in (alkhttp context) | severity | status.
|
|||||||
|
|
||||||
## Resolved
|
## 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
|
### 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
|
- **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.
Loaded 100 of 1545 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user