Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a493e9748 | ||
|
|
4278c13b2d | ||
|
|
3ca7505abd | ||
|
|
ff07f92bdb | ||
|
|
f93ac3792d | ||
|
|
5f019c02f1 | ||
|
|
7b8a3e7558 | ||
|
|
3317ea0963 | ||
|
|
944c300c17 | ||
|
|
df4d4d3a9e | ||
|
|
4eae18f539 | ||
|
|
549893cbbf | ||
|
|
1793265b04 |
No files matched your search
@@ -99,7 +99,8 @@ organized by theme. Each question has:
|
||||
|
||||
- **OQ-ID** (OQ-01, OQ-02, ...) — stable reference
|
||||
- **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
|
||||
- **Resolution** — when resolved, what was decided and which ADR addresses it
|
||||
- **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
|
||||
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
|
||||
|
||||
### 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
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
will need from the call protocol")
|
||||
- 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
|
||||
(e.g., "need performance benchmarks to choose between X and Y")
|
||||
- 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).
|
||||
2. **State the blocking condition** (`deferred(scope)`) or
|
||||
**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
|
||||
cases (hub→worker, worker→hub) to see how verifier-selection +
|
||||
provider + connector compose."
|
||||
|
||||
@@ -191,7 +191,7 @@ also include:
|
||||
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}}
|
||||
|
||||
@@ -204,11 +204,11 @@ Your task: {{task}}
|
||||
7. Push: git push origin $(git branch --show-current)
|
||||
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
|
||||
- No comments in code
|
||||
- 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
|
||||
- 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)
|
||||
|
||||
This is the call + channels RPC crate — the unification of
|
||||
`alknet-call` (structured JSON RPC: operations, streaming subscriptions,
|
||||
service discovery) and `alknet-channels` (multiplexing proxy: N logical
|
||||
channels over one transport stream, channel 0 pre-negotiated as
|
||||
`alk/call`). The conventions below apply to all work in `src/` and
|
||||
`tests/`. They mirror `.opencode/agents/implementation-specialist.md`
|
||||
§Project Conventions and are repeated here so they apply to every
|
||||
session, not just spawned implementation agents.
|
||||
This is the call + channels RPC crate — the extraction and unification of
|
||||
the former `alknet-call` (structured JSON RPC: operations, streaming
|
||||
subscriptions, service discovery) and `alknet-channels` (multiplexing
|
||||
proxy: N logical channels over one transport stream, channel 0
|
||||
pre-negotiated as `alk/call`) crates. The conventions below apply to all
|
||||
work in `src/` and `tests/`. They mirror
|
||||
`.opencode/agents/implementation-specialist.md` §Project Conventions and
|
||||
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
|
||||
project-wide convention. Doc comments (`///`, `//!`) are fine and
|
||||
@@ -130,11 +131,13 @@ session, not just spawned implementation agents.
|
||||
`AuthContext`, `IdentityProvider`, `Identity`, `AuthToken`,
|
||||
`Capabilities`, `OwnershipProvider`, `HandlerError`, `StreamError`)
|
||||
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
|
||||
is reworked, it will consume alkcall's versions. 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.
|
||||
do not add a separate `alkcore` dependency; the source-repo core crate
|
||||
is retired (the alknet mono-repo is being replaced by thin glue over
|
||||
alkcall and its sibling host crates — see OQ-43 in
|
||||
`docs/architecture/open-questions.md` for the composition layer).
|
||||
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
|
||||
this crate carries a BAST (Binary Abstract Syntax Tree) document as
|
||||
@@ -213,20 +216,53 @@ cargo clippy --target wasm32-unknown-unknown -- -D warnings
|
||||
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
|
||||
|
||||
- `docs/architecture/` — the authoritative spec. Read it before
|
||||
non-trivial changes. ADRs are numbered 001..050; OQs (open questions)
|
||||
track resolved/deferred decisions.
|
||||
- This crate unifies `alknet-call` and `alknet-channels` from the
|
||||
alknet mono-repo (`/workspace/@alkdev/alknet`). The source
|
||||
architecture docs were ported from
|
||||
`/workspace/@alkdev/alknet/docs/architecture/` and renumbered as
|
||||
alkcall ADRs (001..048; ADR-049 and later were authored in this
|
||||
crate). The ALPN
|
||||
strings (`alk/call`,
|
||||
`alk/channels`) are wire-stable going forward (renamed from
|
||||
`alknet/` to `alk/` in v0.1.1, before the first published consumer).
|
||||
non-trivial changes. ADRs are numbered 001..054; OQs (open questions)
|
||||
track resolved/deferred decisions (OQ-01..43; OQ-43 tracks the
|
||||
composition layer's ownership and shape after the alknet replacement,
|
||||
deferred(scope)).
|
||||
- This crate is the extraction of the former `alknet-call` and
|
||||
`alknet-channels` crates plus the vendored core types (provenance:
|
||||
the alknet mono-repo at `/workspace/@alkdev/alknet`). The source
|
||||
architecture docs were ported and renumbered as alkcall ADRs
|
||||
(001..048; ADR-049 and later were authored in this crate). The
|
||||
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:
|
||||
|
||||
**Call protocol:**
|
||||
@@ -274,6 +310,14 @@ cargo semver-checks check-release # before a release
|
||||
`from_connection` primary; dial lives in the consumer)
|
||||
- ADR-041 — per-identity channel cap (256 per `PeerId` via
|
||||
`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):**
|
||||
- ADR-001 — ALPN-based protocol dispatch
|
||||
@@ -290,6 +334,10 @@ cargo semver-checks check-release # before a release
|
||||
point `ChannelBidiStreamSource` implements)
|
||||
- ADR-009 — `BiStream` as the handler leaf (`accept_bi` returns
|
||||
`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
|
||||
has since decided against, the TODO is stale — remove it and align
|
||||
|
||||
@@ -4,6 +4,103 @@ All notable changes to this crate are documented here. The format is
|
||||
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
this crate adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.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
|
||||
@@ -737,6 +834,7 @@ Vendored core types (`Connection`, `ProtocolHandler`, `BiStream`,
|
||||
(ADR-046), the channels protocol with openable-ALPNs-as-operations
|
||||
(ADR-047), and the `ChannelClient` transport-agnostic client.
|
||||
|
||||
[0.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
|
||||
|
||||
Generated
+1
-1
@@ -27,7 +27,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alkcall"
|
||||
version = "0.8.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "alkcall"
|
||||
version = "0.8.1"
|
||||
version = "0.9.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.88"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+76
-38
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-08-12
|
||||
last_updated: 2026-10-13 (composition re-key, ADR-054, alkcall reframing of ported docs)
|
||||
---
|
||||
|
||||
# alkcall
|
||||
@@ -9,12 +9,13 @@ The call + channels RPC crate. Structured JSON RPC (operations, streaming
|
||||
subscriptions, service discovery) and N-channel multiplexing over one
|
||||
transport stream (channel 0 pre-negotiated as `alk/call`).
|
||||
|
||||
This crate unifies `alknet-call` and `alknet-channels` from the alknet
|
||||
mono-repo, plus the vendored core types formerly in `alknet-core`. The
|
||||
source architecture docs were ported from
|
||||
`/workspace/@alkdev/alknet/docs/architecture/` and renumbered as alkcall
|
||||
ADRs (ADR-001..051). The ALPN strings (`alk/call`, `alk/channels`)
|
||||
are wire-stable and unchanged — see ADR-004.
|
||||
This crate is the extraction and unification of the former `alknet-call`
|
||||
and `alknet-channels` crates plus the vendored core types formerly in
|
||||
`alknet-core` (see ADR-031). It is a pure protocol crate: no networking,
|
||||
no transport dependencies — dial, TLS config, and composition live in
|
||||
downstream crates. The ALPN strings (`alk/call`, `alk/channels`) are
|
||||
wire-stable — see ADR-004. (Historical origin and cross-repo citation
|
||||
conventions: see the provenance note at the end of this document.)
|
||||
|
||||
## 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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
@@ -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 |
|
||||
| [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 |
|
||||
|-----|-------|-----------|
|
||||
@@ -104,22 +105,36 @@ are wire-stable and unchanged — see ADR-004.
|
||||
| [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
|
||||
|
||||
See [open-questions.md](open-questions.md) for the full tracker. Key
|
||||
questions affecting this crate:
|
||||
|
||||
- **OQ-01**: Call protocol pub/sub primitive (partially resolved) —
|
||||
ADR-046 adds the `Pub` primitive (producer→consumer streaming). The
|
||||
fan-out/broker is deferred to channels (Gap B in ADR-047 is named
|
||||
out-of-scope for alkcall; the hub composes the broker on top).
|
||||
- **OQ-02**: Full channel-level flow-control windowing (deferred(scope))
|
||||
- **OQ-22** (resolved) / **OQ-42** (deferred(scope)): The call-protocol
|
||||
pub/sub primitive is delivered (ADR-046); the fan-out/message broker
|
||||
is placed out of scope for alkcall — the hub composes it on the
|
||||
`Pub`/`Sub` primitives — and its design remains deferred downstream,
|
||||
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
|
||||
HOL-blocking deployment observation.
|
||||
- **OQ-03**: Channels add/strip API shape (open) — whether the 8-byte
|
||||
header add/strip is built into the read/write path or a standalone
|
||||
utility.
|
||||
- **OQ-24** (resolved): Channels add/strip API shape — integral to the
|
||||
read/write path (handlers never see headers); low-level header
|
||||
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
|
||||
|
||||
@@ -143,6 +158,14 @@ questions affecting this crate:
|
||||
channel 0. See ADR-036, ADR-037.
|
||||
10. **Wire formats are stable**: EventEnvelope shape and the 8-byte chunk
|
||||
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
|
||||
|
||||
@@ -176,8 +199,9 @@ Within a single connection, direction is independent of role:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ alknet / alknode │
|
||||
│ (networking + composition) │
|
||||
│ Composition layer │
|
||||
│ (today: the alknet glue — being replaced; │
|
||||
│ future owner and shape → OQ-43) │
|
||||
│ │
|
||||
│ • QUIC / TCP+TLS dial and accept │
|
||||
│ • Hub: ChannelsAdapter + Dispatcher + │
|
||||
@@ -229,10 +253,12 @@ alkcall. They provide two halves:
|
||||
(e.g. `trader_client.status().await` instead of
|
||||
`call_connection.call("trader/status", ...).await`).
|
||||
|
||||
**alk/alknode** depends on alkcall + whichever protocol crates are
|
||||
needed. It's the composition layer — the only place that knows about
|
||||
network topology, peer routing, and which protocol crates are wired in.
|
||||
Protocol crates never import a QUIC or TLS dependency.
|
||||
**The composition layer** depends on alkcall + whichever protocol
|
||||
crates are needed. It's the only place that knows about network
|
||||
topology, peer routing, and which protocol crates are wired in.
|
||||
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
|
||||
|
||||
@@ -269,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
|
||||
`BiStream` (or `AsyncRead + AsyncWrite`) and drives the session. The
|
||||
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
|
||||
|
||||
- `@alkdev/alknet: docs/architecture/` — the source architecture docs
|
||||
these were ported from (renumbered from alknet ADR-001..094 to alkcall
|
||||
ADR-001..048; ADR-049 and later were authored in this crate)
|
||||
- `@alkdev/alktype` — the binary struct engine; compiles BAST documents
|
||||
(e.g. `chunk-header.bast.json`) into readers/writers/validators
|
||||
- `@alkdev/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
|
||||
wire format was derived from
|
||||
|
||||
> **Note**: The source ADRs and spec docs were ported from the parent
|
||||
> `@alkdev/alknet` workspace where this crate originated. They are
|
||||
> preserved here as the authoritative spec for alkcall; the alknet
|
||||
> mono-repo will consume alkcall's versions when it is reworked.
|
||||
> **Provenance and cross-repo citations.** The source ADRs and spec
|
||||
> docs were ported from the parent `@alkdev/alknet` workspace where
|
||||
> this crate originated, and renumbered to alkcall ADR-001..048
|
||||
> (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
|
||||
> reference alknet ADRs by their original numbers (e.g., ADR-052 for
|
||||
> TTY's wire format, ADR-082 for alknet-tls, ADR-086 for endpoint types).
|
||||
> These are ADRs for sibling crates that are not part of alkcall. They
|
||||
> retain their alknet numbering (052, 082, 086, etc.) — any ADR number
|
||||
> outside the alkcall range 001..048 is an alknet source ADR, found at
|
||||
> `/workspace/@alkdev/alknet/docs/architecture/decisions/`.
|
||||
> **Citation conventions**: alkcall's own ADRs are `decisions/001..054`
|
||||
> in this directory. Some ported docs also cite source-repo ADRs that
|
||||
> were **not** ported (e.g. alknet ADR-052 for TTY's wire format). The
|
||||
> alknet number space collides with alkcall's (alknet ADR-052 has
|
||||
> nothing to do with alkcall's `channel/control` ADR-052), so unambiguous
|
||||
> references carry the `alknet` prefix (e.g. "alknet ADR-052") and link
|
||||
> 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
|
||||
last_updated: 2026-07-17
|
||||
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.
|
||||
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` 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
|
||||
|
||||
@@ -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 |
|
||||
| [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
|
||||
|
||||
@@ -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` |
|
||||
| [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) |
|
||||
| [013](decisions/013-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 |
|
||||
| [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) |
|
||||
| [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 |
|
||||
| [065](decisions/065-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 |
|
||||
| [007](decisions/007-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 |
|
||||
| [010](decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Static handler registration |
|
||||
| [012](decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | Bidirectional streams, EventEnvelope, ID-based correlation |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [023](decisions/023-operation-error-schemas.md) | Operation Error Schemas | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity |
|
||||
| [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) |
|
||||
| [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`) |
|
||||
| [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` |
|
||||
| [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 |
|
||||
| [032](decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | `forwarded_for` on `OperationContext` and `call.requested`; metadata only, never used by `AccessControl::check` |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [031](decisions/031-crate-decomposition.md) | Crate Decomposition | alkcall is self-contained: call + channels subsystems + vendored core types (no irpc — ADR-014) |
|
||||
| [033](decisions/033-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Adapter traits defined in Rust; TS is reference/browser adaptation |
|
||||
| [003](decisions/003-auth-as-shared-core.md) | Auth as Shared Core | AuthContext passed to call handlers |
|
||||
| [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) |
|
||||
| [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 |
|
||||
| [007](decisions/007-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | Generic single-stream connections; unblocks TCP+TLS/SSH/WT/wasm dispatch |
|
||||
| [004](decisions/004-alpn-convention-and-connection-model.md) | ALPN String Convention | `alk/call` ALPN, one ALPN per connection |
|
||||
| [005](decisions/005-bistream-type-definition.md) | BiStream Type Definition | CallAdapter receives Connection, not BiStream |
|
||||
| [015](decisions/015-call-protocol-stream-model.md) | Call Protocol Stream Model | Bidirectional streams, EventEnvelope, ID-based correlation |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [016](decisions/016-operation-error-schemas.md) | Operation Error Schemas | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity |
|
||||
| [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-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-peer-graph-routing-model.md) | Peer-Graph Routing Model | Peer-keyed overlays + `PeerRef` routing; `AccessControl`-based peer authorization; retires `remote_safe`/`trusted_peer` |
|
||||
| [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 |
|
||||
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | `forwarded_for` on `OperationContext` and `call.requested`; metadata only, never used by `AccessControl::check` |
|
||||
| [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 |
|
||||
| [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 |
|
||||
|
||||
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).
|
||||
|
||||
## Relevant Open Questions
|
||||
|
||||
| OQ | Title | Status | Relevance |
|
||||
|----|-------|--------|-----------|
|
||||
| OQ-07 | 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-14 | 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-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-25 | ~~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-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-28 | 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-30 | `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-32 | Multi-hop federation | open (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-34 | Persistent peer registry | **resolved** (ADR-025+033) | 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-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-01 | Call protocol scope within a connection | resolved (ADR-015) | Stream model, multiplexing, scope |
|
||||
| OQ-02 | Operation path format and routing scope | resolved | `/{service}/{op}` is the correct design; remote dispatch is a separate layer |
|
||||
| OQ-03 | Batch operation semantics | resolved | Correlated `call.requested` events is the correct protocol design |
|
||||
| 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-09 | Safe vault operations for call protocol exposure | resolved (ADR-010) | None exposed for now |
|
||||
| OQ-10 | ~~Remote-safe marking shape~~ | **dissolved** (ADR-024) | `remote_safe`/`trusted_peer` retired; peer authorization is `AccessControl::check(peer_identity)` |
|
||||
| OQ-11 | OperationAdapter error type (AdapterError variants) | **resolved** | `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`; `#[non_exhaustive]` |
|
||||
| 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-13 | from_call namespace collision | **resolved** | Same-peer collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays) |
|
||||
| OQ-14 | CallClient TLS client-auth | **resolved** | Wire quinn client-auth; key-type-aware server cert verification; fingerprint normalization |
|
||||
| OQ-15 | `PeerRef::Any` routing policy | **resolved** | Insertion-order first-match; richer routing is a feature extension |
|
||||
| OQ-16 | `services/list-peers` re-export semantics | **resolved** | Opt-in `services/list-peers`; `services/list` is "own ops only" |
|
||||
| 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-18 | PeerId — crypto identity vs stable logical id | **resolved** (ADR-025) | `PeerId = Identity.id = PeerEntry.peer_id` (stable across key rotation) |
|
||||
| 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-20 | ~~API key asymmetry~~ | **dissolved** | `PeerEntry` supports multiple credential paths; `ApiKeyEntry` is for tokens that ARE the identity |
|
||||
| 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
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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).
|
||||
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).
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-09
|
||||
last_updated: 2026-10-13 (alkcall reframing)
|
||||
---
|
||||
|
||||
# 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
|
||||
|
||||
@@ -15,7 +15,7 @@ The `CallAdapter` implements `ProtocolHandler` for ALPN `alk/call`. It receives
|
||||
|
||||
## 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:
|
||||
- **Cross-language**: JSON wire format consumable from TypeScript, Python, any language
|
||||
@@ -40,7 +40,7 @@ pub struct CallAdapter {
|
||||
registry: Arc<OperationRegistry>,
|
||||
identity_provider: Arc<dyn IdentityProvider>,
|
||||
/// 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>>,
|
||||
/// Default timeout for wire calls (30s). Composed calls inherit the
|
||||
/// parent's remaining deadline via `OperationContext.deadline`.
|
||||
@@ -57,8 +57,9 @@ impl CallAdapter {
|
||||
default_timeout: Duration::from_secs(30) }
|
||||
}
|
||||
|
||||
/// Agent deployment: supply a session-overlay source. The agent crate
|
||||
/// implements `SessionOverlaySource`; alknet-call defines the trait.
|
||||
/// Agent deployment: supply a session-overlay source. A downstream
|
||||
/// 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 {
|
||||
self.session_source = Some(source);
|
||||
self
|
||||
@@ -71,10 +72,11 @@ impl CallAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Session overlay integration point (ADR-019). Defined in alknet-call
|
||||
/// because `CallAdapter` must name the type — alknet-call cannot depend on
|
||||
/// alknet-agent (agent depends on call, not reverse). The agent crate
|
||||
/// implements this trait; alknet-call defines it. This is the same pattern
|
||||
/// Session overlay integration point (ADR-019). Defined in alkcall
|
||||
/// because `CallAdapter` must name the type — alkcall cannot depend on
|
||||
/// its downstream consumers (an agent depends on call, not reverse). The
|
||||
/// 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).
|
||||
///
|
||||
/// The session overlay is an `OperationEnv` impl that wraps the curated base
|
||||
@@ -129,18 +131,16 @@ pub struct CallConnection {
|
||||
impl CallConnection {
|
||||
/// Register an imported operation into this connection's overlay
|
||||
/// (Layer 2, ADR-019). Called by `from_call` after discovery.
|
||||
pub fn register_imported(&self, registration: HandlerRegistration) {
|
||||
let name = registration.spec.name.clone();
|
||||
self.imported_operations.write().insert(name, registration);
|
||||
}
|
||||
/// Refuses marked (`channel_open`) specs — they are not
|
||||
/// overlay-importable (ADR-047 amendment 4 §4.8).
|
||||
pub fn register_imported(&self, registration: HandlerRegistration)
|
||||
-> Result<(), String>;
|
||||
|
||||
/// Register multiple imported operations (bulk variant for `from_call`).
|
||||
pub fn register_imported_all(&self, registrations: Vec<HandlerRegistration>) {
|
||||
let mut overlay = self.imported_operations.write();
|
||||
for reg in registrations {
|
||||
overlay.insert(reg.spec.name.clone(), reg);
|
||||
}
|
||||
}
|
||||
/// Same marked-spec refusal; on refusal the entries inserted before the
|
||||
/// marked one stay in the overlay and the error is returned.
|
||||
pub fn register_imported_all(&self, registrations: Vec<HandlerRegistration>)
|
||||
-> Result<(), String>;
|
||||
|
||||
/// Build an `OperationEnv` impl for this connection's overlay. Used by
|
||||
/// 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 —
|
||||
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:
|
||||
1. Accepts bidirectional streams on the connection
|
||||
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 §1) to handle head→N-workers routing. See
|
||||
[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.
|
||||
|
||||
### 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.
|
||||
|
||||
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
|
||||
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.
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
@@ -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 |
|
||||
| **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
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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).
|
||||
- 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. 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.
|
||||
- **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.
|
||||
@@ -567,19 +585,19 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
|
||||
|
||||
| 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) |
|
||||
| Call protocol stream model | [ADR-015](decisions/012-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 |
|
||||
| ProtocolHandler receives Connection | [ADR-005](decisions/007-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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| 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` |
|
||||
| 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 |
|
||||
| Operation error schemas | [ADR-016](decisions/023-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details` |
|
||||
| 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/015-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
|
||||
| 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/005-bistream-type-definition.md) | CallAdapter gets Connection, can accept/open multiple streams |
|
||||
| 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/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/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/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/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/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/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/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/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` |
|
||||
| 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.
|
||||
|
||||
- **OQ-13** (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-16** (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-25** (dissolved by ADR-024): `remote_safe` marking shape — moot;
|
||||
- **OQ-02** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
|
||||
- **OQ-03** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
|
||||
- **OQ-09** (resolved by ADR-010): No vault operations are exposed over the call protocol for now.
|
||||
- **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-10** (dissolved by ADR-024): `remote_safe` marking shape — moot;
|
||||
`remote_safe`/`trusted_peer` retired; peer authorization is
|
||||
`AccessControl::check(peer_identity)`.
|
||||
- **OQ-26** (resolved): `OperationAdapter` error type — `AdapterError`
|
||||
- **OQ-11** (resolved): `OperationAdapter` error type — `AdapterError`
|
||||
variants (`DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`,
|
||||
`SamePeerCollision`); `#[non_exhaustive]`. See
|
||||
[client-and-adapters.md](client-and-adapters.md).
|
||||
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
|
||||
manual free function; the assembly layer calls it after the dial (in
|
||||
`AlknetClient`). See
|
||||
[ADR-028](decisions/069-from-call-manual-free-function.md).
|
||||
- **OQ-28** (resolved): `from_call` namespace collision — same-peer collision
|
||||
- **OQ-12** (resolved): `from_call` re-import trigger — `from_call` is a
|
||||
manual free function; after the dial (in the composition layer's dial helper
|
||||
`AlknetClient` — today the alknet glue; OQ-43). See
|
||||
[ADR-028](decisions/028-from-call-manual-free-function.md).
|
||||
- **OQ-13** (resolved): `from_call` namespace collision — same-peer collision
|
||||
= error; cross-peer dissolved by ADR-024 (separate sub-overlays). See
|
||||
[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
|
||||
cert verification; fingerprint normalization. See
|
||||
[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).
|
||||
- **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).
|
||||
- **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
|
||||
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
|
||||
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
|
||||
adapters are separate crates.
|
||||
- **OQ-37** (resolved by ADR-034): X.509 outgoing-only case — three remote
|
||||
adapters are separate crates (composition-layer work — OQ-43; the
|
||||
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
|
||||
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).
|
||||
|
||||
## 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-025: PeerEntry and Identity.id decoupling (`PeerId` source)
|
||||
- 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)
|
||||
- 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
|
||||
last_updated: 2026-07-18
|
||||
last_updated: 2026-10-13 (alkcall reframing)
|
||||
---
|
||||
|
||||
# channel-client.md — ChannelClient
|
||||
@@ -152,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`,
|
||||
a WebTransport `BiStream`, an SSH `direct-tcpip` channel wrapped via
|
||||
`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.
|
||||
|
||||
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
|
||||
`AlknetClient::dial_quic(...).await?` + `ChannelClient::from_connection(conn).await?`
|
||||
— 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 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
|
||||
an already-established, already-authenticated `Connection`, exactly as
|
||||
`ChannelsAdapter::handle` does on the server side.
|
||||
@@ -181,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
|
||||
request/response role.
|
||||
|
||||
## Relationship to `AlknetClient`
|
||||
## Relationship to the composition layer's dial helper
|
||||
|
||||
`ChannelClient`'s *API* is transport-agnostic — `from_connection` takes a
|
||||
pre-established `Connection`. The shared *dial+TLS* seam
|
||||
(`AlknetClient`, OQ-55) is [`alknet-client`](../client/README.md), which
|
||||
provides `AlknetClient` with three dial methods (`dial_quic` /
|
||||
pre-established `Connection`. The shared *dial+TLS* seam is the
|
||||
composition layer's dial helper, `AlknetClient` (ADR-045; today in the
|
||||
alknet glue, future owner OQ-43), which
|
||||
provides three dial methods (`dial_quic` /
|
||||
`dial_tcp_tls` / `dial_iroh`), each producing a `Connection` that
|
||||
`from_connection` consumes. The dial is transport-specific (QUIC,
|
||||
TCP+TLS, iroh); the take-over (`from_connection`) is
|
||||
@@ -194,10 +197,9 @@ transport-agnostic. The two concerns are separated.
|
||||
|
||||
`AlknetClient::dial_quic` is the dial that feeds `from_connection`. A
|
||||
caller that needs transport selection (QUIC with TCP+TLS fallback) uses
|
||||
`AlknetClient` directly; the fallback policy is a caller concern. See
|
||||
[ADR-045](decisions/089-alknetclient-native-dial-seam.md) for the
|
||||
full decision and [OQ-55](../../questions/055-alknetclient-establishment-extraction.md)
|
||||
(resolved).
|
||||
the dial helper directly; the fallback policy is a caller concern. See
|
||||
[ADR-045](decisions/045-alknetclient-native-dial-seam.md) for the
|
||||
full decision.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
@@ -205,15 +207,15 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
||||
|
||||
| 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 |
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **OQ-55** (resolved by ADR-045): `AlknetClient` core **dial+TLS seam**
|
||||
— `alknet-client` with three dial methods. `ChannelClient`'s API is
|
||||
transport-agnostic (`from_connection`); the dial is the shared seam.
|
||||
See [ADR-045](decisions/045-alknetclient-native-dial-seam.md).
|
||||
- **Dial seam ownership** (ADR-045): the dial helper (`AlknetClient`)
|
||||
lives in the composition layer today; its post-alknet owner and shape
|
||||
is OQ-43. `ChannelClient`'s API is transport-agnostic
|
||||
(`from_connection`); the dial is the shared seam.
|
||||
|
||||
## References
|
||||
|
||||
@@ -225,6 +227,6 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
||||
- ADR-039: ChannelManager (the shared state `ChannelClient` holds)
|
||||
- ADR-047: openable ALPNs are operations (per-ALPN open ops dissolve
|
||||
the generic `channel/open`)
|
||||
- OQ-55: AlknetClient / client establishment extraction
|
||||
- `docs/architecture/crates/call/client-and-adapters.md` — `CallClient` (the
|
||||
- ADR-045: the dial seam (dial out of the protocol crate)
|
||||
- [client-and-adapters.md](client-and-adapters.md) — `CallClient` (the
|
||||
shape `ChannelClient` mirrors)
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-08-12
|
||||
status: current
|
||||
last_updated: 2026-10-13 (alkcall reframing)
|
||||
---
|
||||
|
||||
# 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` 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 `channel_id` via a monotonic `AtomicU32` (`next_id.fetch_add(1,
|
||||
Relaxed)`) and returns it in the response. In the `Sub` case the
|
||||
responder owns the connection and allocates; in the `Pub` case the
|
||||
initiator owns and allocates. One round-trip before data flows — the
|
||||
same round-trip the call protocol makes for every operation. All
|
||||
current channel types (TTY, tunnel, SSH) already require a negotiation
|
||||
round-trip, so the open round-trip is not additive latency.
|
||||
the `channel_id` via a monotonic `AtomicU32` (odd/even split — connect
|
||||
side starts at 1, accept at 2) and returns it in the response. In the
|
||||
`Sub` case the responder owns the connection and allocates — one
|
||||
round-trip before data flows (the same round-trip every call makes).
|
||||
In the `Pub` case (the put contract above) the initiator owns,
|
||||
allocates, and streams bytes **before** the open resolves — the
|
||||
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):
|
||||
|
||||
@@ -110,19 +113,80 @@ 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
|
||||
### `channels/<alpn>/pub` — publish a binary stream (the put contract, ADR-047 amendment 4)
|
||||
|
||||
`OperationType::Pub` (ADR-046). The initiator publishes a stream of
|
||||
`call.published` events to the responder; the responder's `SinkHandler`
|
||||
consumes them and returns a single `call.responded`. The
|
||||
`channel_open` marker on the op spec tells the channels layer the
|
||||
stream is binary — the `call.published` chunks carry binary payloads
|
||||
on a data channel (not JSON on channel 0).
|
||||
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
|
||||
worker publishes to the hub, the hub owns the resource, and consumers
|
||||
subscribe from the hub. The broker (topic registry, `Pub`↔`Sub`
|
||||
matching) is a hub concern, not an alkcall concern (ADR-047 §1, Gap B).
|
||||
worker publishes to the hub, the hub owns the resource, consumers
|
||||
subscribe from the hub. Marked pub openables relay through the put
|
||||
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`)
|
||||
|
||||
@@ -153,13 +217,13 @@ decrement.
|
||||
chunks MUST be written and flushed before the `channel/close` operation
|
||||
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
|
||||
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
|
||||
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
|
||||
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,
|
||||
keepalive):
|
||||
@@ -174,11 +238,23 @@ keepalive):
|
||||
}
|
||||
```
|
||||
|
||||
The channels layer routes `message` to the handler's control handle for
|
||||
`channel_id`. The `message` JSON is ALPN-specific; the channels layer
|
||||
does not interpret it.
|
||||
The channels layer routes `message` to the channel's control callback
|
||||
(ADR-052): the ALPN crate contributes it through its open-op
|
||||
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
|
||||
protocol has `StreamingHandler` / `invoke_streaming` (implemented and
|
||||
@@ -193,8 +269,21 @@ start/stop.
|
||||
}
|
||||
```
|
||||
|
||||
The responder registers a `StreamingHandler` that emits a
|
||||
`ResponseEnvelope` whenever the resource set changes. Each event:
|
||||
Each openable ALPN crate provides a **resource enumerator**
|
||||
(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
|
||||
{
|
||||
@@ -238,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 |
|
||||
| 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`
|
||||
(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
|
||||
@@ -251,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
|
||||
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
|
||||
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;
|
||||
the handler owns its sub-stream multiplexing on the `BiStream` it
|
||||
receives.
|
||||
@@ -471,6 +560,8 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
||||
| [021](decisions/021-streaming-handler-for-subscriptions.md) | StreamingHandler | The machinery `channel/resources/subscribe` uses |
|
||||
| [046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | Pub Operation Type | The `Pub`/`Sub` primitives the per-ALPN open ops build on |
|
||||
| [049](decisions/049-channel-open-establishment-phase.md) | Channel-Open Establishment Phase | The establisher hook; typed `channel:open_failed`; `Establishment.plan` (amendment 2); the `JoinHandle` lifetime contract |
|
||||
| [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`) |
|
||||
| [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 |
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
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
|
||||
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 |
|
||||
|----------|--------|-------------|
|
||||
| [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-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-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-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-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-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
|
||||
|
||||
| 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 |
|
||||
| [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 |
|
||||
| [072](decisions/072-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 |
|
||||
| [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) |
|
||||
| [075](decisions/075-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) |
|
||||
| [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) |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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) |
|
||||
| [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 |
|
||||
| [070](decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The `Connection` 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 ADR-035 builds on |
|
||||
| [065](decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The transport-agnostic `Connection` the channels layer rides on |
|
||||
| [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) |
|
||||
| [049](decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler for Subscriptions | The machinery `channel/resources/subscribe` uses |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [036](decisions/036-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alk/call` | Channel 0 = call protocol; no special control plane |
|
||||
| [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 |
|
||||
| [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) |
|
||||
| [039](decisions/039-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 contracts |
|
||||
| [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) |
|
||||
| [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) |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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) |
|
||||
| [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 |
|
||||
| [008](decisions/008-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 |
|
||||
| [007](decisions/007-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 |
|
||||
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed channel opens |
|
||||
| [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 |
|
||||
|
||||
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
|
||||
|
||||
| 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-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-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) |
|
||||
| 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-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" |
|
||||
| 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) |
|
||||
| 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-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
|
||||
|
||||
@@ -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)
|
||||
and what *multiplexing layer* carries them (the 8-byte chunk format).
|
||||
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).
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
(vision, hub motivation, wire format, negotiation, internals, DPs, OQs)
|
||||
- `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)
|
||||
- `/workspace/alknet-channels-poc/` — the POC codebase
|
||||
- `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
|
||||
multiplexer (the prior art for N-channel multiplexing)
|
||||
- `docs/architecture/crates/hub/README.md` — the hub crate (the primary
|
||||
consumer; the relay implementation's home)
|
||||
- Source-repo hub crate (alknet, provenance — the composition layer's
|
||||
hub wiring; the relay implementation itself is an alkcall export,
|
||||
`ChannelRelay`, ADR-051)
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-18
|
||||
last_updated: 2026-10-13 (alkcall reframing)
|
||||
---
|
||||
|
||||
# channels-adapter.md — ChannelsAdapter and ChannelManager
|
||||
@@ -69,7 +69,7 @@ transport's stream count differs.
|
||||
## `ChannelManager`
|
||||
|
||||
```rust
|
||||
// In alknet-channels-core:
|
||||
// In alkcall (channels subsystem):
|
||||
pub struct ChannelManager {
|
||||
channels: Mutex<HashMap<u32, ChannelState>>,
|
||||
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
|
||||
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
|
||||
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
|
||||
`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
|
||||
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-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-037: channel lifecycle operations (the ops registered on `call_ops`)
|
||||
- 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`
|
||||
consulted by the `channel/open` handler; the relay consequence for
|
||||
hub-relayed channels)
|
||||
- `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #4-#7
|
||||
(REQ-CH-01..04, the two-pump deadlock)
|
||||
- `docs/research/stream-unification/findings.md` — the research that
|
||||
- Source-repo POC research (alknet provenance):
|
||||
`/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md`
|
||||
§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
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-18
|
||||
last_updated: 2026-10-13 (alkcall reframing)
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
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
|
||||
handler receives a `Connection`, calls `accept_bi()` once (yield-once per
|
||||
channel), gets a `BiStream`, and drives its session — identical to how it
|
||||
@@ -30,7 +30,7 @@ works on a top-level QUIC connection.
|
||||
## `ChannelBidiStreamSource`
|
||||
|
||||
```rust
|
||||
// In alknet-channels:
|
||||
// In alkcall (channels subsystem):
|
||||
|
||||
pub struct ChannelBidiStreamSource {
|
||||
// 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 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 {
|
||||
tokio::io::copy(&mut recv, &mut tcp_write).await?;
|
||||
tcp_write.shutdown().await.ok();
|
||||
@@ -158,11 +158,11 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
||||
|
||||
| ADR | Decision | Summary |
|
||||
|-----|----------|---------|
|
||||
| [074](decisions/074-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 |
|
||||
| [070](decisions/070-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) |
|
||||
| [065](decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The yield-once path generalized for channels |
|
||||
| [038](decisions/038-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` is the only accessor |
|
||||
| [035](decisions/035-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `BiStream`-only |
|
||||
| [008](decisions/008-bidistreamsource-trait.md) | BidiStreamSource Trait | The 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 this doc builds on) |
|
||||
| [007](decisions/007-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The yield-once path generalized for channels |
|
||||
|
||||
## References
|
||||
|
||||
@@ -171,9 +171,11 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
||||
- ADR-008: BidiStreamSource trait
|
||||
- ADR-009: `BiStream` as the handler leaf
|
||||
- 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)
|
||||
- `docs/research/alknet-channels/poc-summary.md` §POC Target 2 (the
|
||||
yield-once `Connection::from_stream` validation)
|
||||
- `docs/research/stream-unification/findings.md` — the research that
|
||||
- Source-repo POC research (alknet provenance):
|
||||
`/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md`
|
||||
§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
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-18
|
||||
last_updated: 2026-10-13 (alkcall reframing)
|
||||
---
|
||||
|
||||
# alknet-channels — Overview
|
||||
# alkcall channels — Overview
|
||||
|
||||
## 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
|
||||
bidirectional transport stream, reads 8-byte chunk headers, and routes each
|
||||
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
|
||||
|
||||
Before channels, alknet had three multiplexing models:
|
||||
Before channels, the alk stack had three multiplexing models:
|
||||
|
||||
| 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.
|
||||
- **The control channel is handler-internal.** TTY sub-demuxes control
|
||||
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.
|
||||
- **Recursive composition is literal.** A channel with ALPN
|
||||
`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.
|
||||
|
||||
Each channel is presented to its handler as a `Connection` constructed via
|
||||
`Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-008/074, as
|
||||
amended by ADR-035). The handler calls `accept_bi()` once (yield-once per
|
||||
`Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-008 for the
|
||||
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
|
||||
QUIC connection.
|
||||
|
||||
@@ -139,7 +141,8 @@ Downstream crates depend on alkcall as a single dependency:
|
||||
alktty / alktunnels / alktrader (protocol crates)
|
||||
└── 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
|
||||
├── alktty (optional — whichever protocol crates are wired in)
|
||||
├── alktunnels (optional)
|
||||
@@ -147,7 +150,7 @@ alknet / alknode (networking + composition)
|
||||
```
|
||||
|
||||
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
|
||||
alongside its peer lifecycle and service discovery responsibilities.
|
||||
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 |
|
||||
| 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) |
|
||||
| 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.
|
||||
|
||||
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
|
||||
present `AsyncRead + AsyncWrite` handles to WASM-compatible handlers. The
|
||||
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
|
||||
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,
|
||||
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
|
||||
`BiStream` (or `AsyncRead + AsyncWrite`) and drives the session. The
|
||||
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
|
||||
`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
|
||||
it transparently.
|
||||
|
||||
### alknet-hub / alknode
|
||||
### Hub (composition layer)
|
||||
|
||||
The hub is the primary consumer. With channels, the hub holds one
|
||||
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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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).
|
||||
Key questions affecting this crate:
|
||||
|
||||
- **OQ-55** (resolved by ADR-045): `AlknetClient` core **dial+TLS seam**
|
||||
— extracted as `alknet-client` with three dial methods.
|
||||
`ChannelClient`'s API is transport-agnostic (`from_connection`); the
|
||||
dial is the shared seam, now extracted. See
|
||||
- **Dial seam** (ADR-045; source: alknet OQ-55): the dial+TLS seam is
|
||||
the composition layer's dial helper (`AlknetClient` today — future
|
||||
owner and shape OQ-43). `ChannelClient`'s API is transport-agnostic
|
||||
(`from_connection`); the dial is the shared seam. See
|
||||
[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
|
||||
blocked on a real HOL-blocking deployment observation.
|
||||
- **OQ-57** (deferred(scope)): Two-pump helper extraction to alknet-core —
|
||||
the *contract* is decided (ADR-078); the *helper* is blocked on a second
|
||||
two-pump handler existing.
|
||||
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
|
||||
- **Two-pump helper** (source: alknet OQ-57) (dissolved): the
|
||||
contract is decided (the in-tree helper landed as
|
||||
`channels::pump_bidi`, ADR-050); the extraction target (a mono-repo
|
||||
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
|
||||
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
|
||||
last_updated: 2026-07-18
|
||||
last_updated: 2026-10-13 (alkcall reframing)
|
||||
---
|
||||
|
||||
# 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
|
||||
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
|
||||
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`
|
||||
|
||||
`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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
(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).
|
||||
|
||||
## Zero-length sentinel = EOF
|
||||
|
||||
A zero-length chunk (`length = 0`) is delivered as an empty payload,
|
||||
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
|
||||
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
|
||||
(single bidi stream, header-demuxed N channels). The QUIC-native
|
||||
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
|
||||
`accept_bi()` loop is missing.
|
||||
|
||||
## 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.
|
||||
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
|
||||
vs. a standalone utility) is an implementation detail for the channels
|
||||
crate, tracked as OQ-68. The *contract* — the channels layer strips its
|
||||
8-byte header on read and the handler parses its own framing from the
|
||||
payload — is decided; the *function surface* is not.
|
||||
crate; the shipped surface resolves it (OQ-24, formerly alknet OQ-68):
|
||||
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 — 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)
|
||||
|
||||
@@ -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 |
|
||||
| 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
|
||||
`channel/close` operation is sent on channel 0. This is a wire-level
|
||||
invariant: the side closing must observe the data-channel pump complete
|
||||
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
|
||||
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
|
||||
@@ -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).
|
||||
Key questions affecting this doc:
|
||||
|
||||
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
|
||||
add/strip is built into the channels read/write path or exposed as a
|
||||
standalone utility. The *contract* (channels strips, handler parses
|
||||
payload) is decided; the *function surface* is not.
|
||||
- **OQ-24** ([tracker](open-questions.md); source: alknet OQ-68) (resolved): Add/strip API shape —
|
||||
built into the read/write path (the demux loop strips headers; handlers
|
||||
never see them), with the low-level header primitives exposed in
|
||||
`channels::wire` for tooling.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-034: channels wire format (the decision, amended by ADR-035 — 8-byte
|
||||
header, no `stream_type`)
|
||||
- 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-037: channel lifecycle operations
|
||||
- ADR-040: backpressure, channel limits, ID reuse
|
||||
- ADR-047: openable ALPNs are operations (per-ALPN open ops dissolve
|
||||
the generic `channel/open`)
|
||||
- `docs/research/alknet-channels/poc-summary.md` §POC Target 1, §Issues
|
||||
Surfaced #4-#6 (REQ-CH-01, 02, 04)
|
||||
- `docs/research/stream-unification/findings.md` — the research that
|
||||
surfaced the 8-byte format decision
|
||||
- Source-repo POC research (alknet provenance):
|
||||
`/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md`
|
||||
§POC Target 1, §Issues Surfaced #4-#6 (REQ-CH-01, 02, 04);
|
||||
`/workspace/@alkdev/alknet/docs/research/stream-unification/findings.md`
|
||||
— the research that surfaced the 8-byte format decision
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
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
|
||||
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`
|
||||
that *opens* a connection, the `from_call` adapter, and the
|
||||
`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`
|
||||
dispatch loop are covered in `call-protocol.md`; this document covers the
|
||||
client-side connection-establishment half and the adapter surface.
|
||||
|
||||
## 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`
|
||||
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_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).
|
||||
> It is now an HTTP-backed single-endpoint adapter for non-standard /
|
||||
> non-OpenAPI / basic REST endpoints, functionally similar to
|
||||
> `from_openapi` but one endpoint at a time. See
|
||||
> [`crates/http/http-adapters.md`](../http/http-adapters.md) §"from_jsonschema".
|
||||
> The `FromJsonSchema` provenance variant stays in `alknet-call`
|
||||
> `from_openapi` but one endpoint at a time. It lives in `alkhttp`; its
|
||||
> full spec is `alkhttp`'s `http-adapters.md` (original source-repo copy
|
||||
> 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.
|
||||
|
||||
It also records two cross-cutting architectural mechanisms that the adapter
|
||||
surface rests on:
|
||||
|
||||
- The **adapter location map** — which adapters live in `alknet-call` vs
|
||||
`alknet-http`, and why.
|
||||
- The **adapter location map** — which adapters live in alkcall vs
|
||||
`alkhttp`, and why.
|
||||
- The **no-env-vars invariant** — the architectural mechanism by which
|
||||
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
|
||||
exchange, the NAPI projection, and the agent's cross-node tool dispatch all
|
||||
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
|
||||
fills the gap ADR-022 left to implementation: the `CallClient` API, the
|
||||
`from_call` flow, the trait signature, the adapter location, the credential
|
||||
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
|
||||
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
|
||||
primary use case) and that its parallel `remote_safe`/`trusted_peer`
|
||||
authorization system duplicated the existing `AccessControl`/`Identity`
|
||||
machinery. **ADR-024 supersedes ADR-023**: peer-keyed overlays + `PeerRef`
|
||||
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
|
||||
cross-peer dissolved / same-peer stays, DC-4→OQ-26).
|
||||
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-11).
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -101,7 +106,7 @@ ordered, reliable bidirectional stream — QUIC, TCP+TLS, WebTransport,
|
||||
SSH `direct-tcpip`, a WebSocket (ADR-007 `Connection::from_stream` /
|
||||
`from_bidi`). The primary constructor (`spawn_dispatch`) takes a
|
||||
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
|
||||
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
|
||||
security property). An op that should never be callable from the wire uses
|
||||
`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`.
|
||||
|
||||
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`,
|
||||
constructs a `CallConnection`, builds a `Dispatcher`, spawns the
|
||||
dispatch task, and returns the live `CallConnection`. The dial lives in
|
||||
`AlknetClient` (`alknet-client`, ADR-045): keeping a QUIC convenience
|
||||
constructor on `CallClient` would make `alknet-call` depend on
|
||||
`alknet-client`, contradicting the dep graph (the protocol crates are
|
||||
the composition layer's dial helper, `AlknetClient` (ADR-045): keeping a QUIC convenience
|
||||
constructor on `CallClient` would make alkcall depend on
|
||||
the dial crate, contradicting the dep graph (the protocol crates are
|
||||
parallel to the dial, not downstream of it). Callers compose
|
||||
`AlknetClient::dial_quic` + `spawn_dispatch` — two lines, the dial then
|
||||
the take-over. Tests use `spawn_dispatch` directly to wire mock/loopback
|
||||
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
|
||||
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
|
||||
(`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
|
||||
per-`CallConnection` overlay stays flat (one connection = one peer); the
|
||||
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`
|
||||
is the opt-in for peer-attributed re-export listing (each 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
|
||||
|
||||
The credential dimensions are split across two layers (ADR-012, amended
|
||||
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
|
||||
(`AlknetClient`). Carries the two transport-identity dimensions:
|
||||
`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):
|
||||
|
||||
```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 local_identity: Option<TlsIdentity>, // RFC 7250 raw key or X.509
|
||||
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").
|
||||
```
|
||||
|
||||
`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
|
||||
the local node has a `PeerEntry` for the remote (the known-peer case →
|
||||
fingerprint pin). `remote_identity: None` is the **public X.509
|
||||
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`
|
||||
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
|
||||
known peers — no CA to fall back to). The `Option` is load-bearing, not
|
||||
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
|
||||
(`local_identity`, `remote_identity`) are in `ConnectionCredentials` in
|
||||
`alknet-core` per ADR-012.
|
||||
(`local_identity`, `remote_identity`) are in `ConnectionCredentials` with
|
||||
the vendored core types in alkcall, per ADR-012.
|
||||
|
||||
- **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
|
||||
vault or derived from a shared secret.
|
||||
- **Remote identity verification** — the expected fingerprint/cert of the
|
||||
remote node, stored as a capability. `Some` → fingerprint pin (known
|
||||
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.
|
||||
|
||||
These are populated by the assembly layer at `CallClient` construction time
|
||||
from vault-derived `Capabilities`. The credential path is the no-env-vars
|
||||
invariant (below). The concrete shapes of `TlsIdentity`, `AuthToken`, and
|
||||
`RemoteIdentity` are implementation-detail two-way doors; the one-way
|
||||
constraints are that they come from `Capabilities`, not env vars (ADR-010).
|
||||
|
||||
**TLS client-auth presentation** (OQ-29 #1, wired): the client presents
|
||||
constraints are that they come from `Capabilities`, not env vars (ADR-010),
|
||||
and that `ConnectionCredentials` lives with the vendored core types in
|
||||
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
|
||||
equivalent of the server's `RawKeyCertResolver`. This is **wired now**, not
|
||||
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.
|
||||
The iroh path already works (iroh uses RFC 7250 raw keys and exchanges
|
||||
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
|
||||
`Capabilities`, not env vars, ADR-010) is unaffected — the `auth_token`
|
||||
dimension flows through the call-protocol `auth_token` payload field, not
|
||||
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`)
|
||||
is **additive** — the server-side fingerprint extraction is what matters for
|
||||
`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
|
||||
`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`
|
||||
for the remote, not by key type alone. A pure-client
|
||||
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
|
||||
raw-key remote fails closed (no CA to fall back to — raw-key remotes
|
||||
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.
|
||||
|
||||
### from_call
|
||||
@@ -353,7 +363,7 @@ forwarding handlers. The discovery mechanism (`services/list` +
|
||||
```rust
|
||||
pub struct FromCallConfig {
|
||||
/// 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.
|
||||
pub namespace_prefix: Option<String>,
|
||||
/// Optional filter — import only operations whose names match. None
|
||||
@@ -394,21 +404,40 @@ The flow (ADR-022 §3):
|
||||
`call.aborted` drops the stream (cascade per ADR-020). No truncation,
|
||||
no first-value fallback — a `from_call`-imported subscription forwards
|
||||
the full remote stream.
|
||||
- `Pub` → the sink forwarding shape (registered as `HandlerKind::Sink`,
|
||||
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
|
||||
`CallConnection::register_imported_all()`.
|
||||
|
||||
**Re-import on reconnection** (DC-2, OQ-27): `from_call` is a free function;
|
||||
the assembly layer calls it after the dial (in `AlknetClient`). The overlay
|
||||
**Marked ops are not importable** (ADR-047 amendment 4 §4.8):
|
||||
`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
|
||||
connection; re-import on reconnect is naturally scoped to the new
|
||||
connection. A `CallConnection::refresh()` method for mid-connection
|
||||
re-discovery is a genuine feature addition — non-breaking, additive — if a
|
||||
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
|
||||
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).
|
||||
@@ -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
|
||||
`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
|
||||
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` 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,
|
||||
and the "schema-only, no handler" concept conflated schema validation
|
||||
(a planning activity that doesn't need a registry entry) with operation
|
||||
registration (which always needs a handler).
|
||||
|
||||
[ADR-027](decisions/066-from-jsonschema-as-http-adapter.md) moved
|
||||
`from_jsonschema` to `alknet-http` as an HTTP-backed single-endpoint
|
||||
[ADR-027](decisions/027-from-jsonschema-as-http-adapter.md) moved
|
||||
`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` +
|
||||
path template + method, and the adapter builds one
|
||||
`HandlerRegistration` with a real reqwest forwarding handler and
|
||||
`FromJsonSchema` provenance. It is functionally similar to `from_openapi`
|
||||
but one endpoint at a time, for non-standard / non-OpenAPI / basic REST
|
||||
endpoints that don't have a full OpenAPI document. See
|
||||
[`crates/http/http-adapters.md`](../http/http-adapters.md) §"from_jsonschema".
|
||||
endpoints that don't have a full OpenAPI document. See `alkhttp`'s
|
||||
`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
|
||||
purpose) is served by consuming `OperationSpec` directly — the spec
|
||||
already carries the input/output JSON Schemas. No adapter, no registry
|
||||
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
|
||||
implementation moved.
|
||||
|
||||
### OperationAdapter trait
|
||||
|
||||
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).
|
||||
|
||||
```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
|
||||
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
|
||||
failure modes real implementations hit: discovery transport failure
|
||||
(`from_call` remote unreachable), schema parse failure (`from_openapi`,
|
||||
@@ -495,13 +527,13 @@ door, recorded here.
|
||||
|
||||
Implementations:
|
||||
- `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`
|
||||
supports (ADR-007).
|
||||
- `FromOpenAPI` — HTTP-backed (in `alknet-http`).
|
||||
- `FromJsonSchema` — HTTP-backed, single-endpoint (in `alknet-http` per
|
||||
ADR-027; was a broken schema-only placeholder in `alknet-call`).
|
||||
- `FromMCP` — MCP streamable-HTTP-backed (in `alknet-http`, feature-gated).
|
||||
- `FromOpenAPI` — HTTP-backed (in `alkhttp`).
|
||||
- `FromJsonSchema` — HTTP-backed, single-endpoint (in `alkhttp` per
|
||||
ADR-027; was a broken schema-only placeholder in the call protocol).
|
||||
- `FromMCP` — MCP streamable-HTTP-backed (in `alkhttp`, feature-gated).
|
||||
|
||||
The `to_*` adapters (`to_openapi`, `to_mcp`) are outbound projections, not
|
||||
`OperationAdapter` implementations — they consume the registry, they don't
|
||||
@@ -510,18 +542,19 @@ produce entries for it (ADR-022 §5).
|
||||
### Adapter Location Map
|
||||
|
||||
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.**
|
||||
|
||||
```
|
||||
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)
|
||||
├── from_call (transport-agnostic — discovers remote ops via
|
||||
│ call protocol over any Connection)
|
||||
└── 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)
|
||||
├── from_openapi (parse OpenAPI doc + reqwest forwarding handler)
|
||||
├── 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
|
||||
— 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
|
||||
```
|
||||
|
||||
`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
|
||||
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
|
||||
doesn't know reqwest is involved. `alknet-call` stays lean (no reqwest, no
|
||||
axum); `alknet-http` owns both HTTP directions.
|
||||
doesn't know reqwest is involved. alkcall stays lean (no reqwest, no
|
||||
axum); `alkhttp` owns both HTTP directions.
|
||||
|
||||
**ADR-031 dependency note**: `alknet-http` implementing `from_openapi`/
|
||||
`from_mcp` means `alknet-http` depends on `alknet-call` (for `OperationSpec`,
|
||||
**ADR-031 dependency note**: `alkhttp` implementing `from_openapi`/
|
||||
`from_mcp` means `alkhttp` depends on alkcall (for `OperationSpec`,
|
||||
`Handler`, `HandlerRegistration`, `OperationAdapter`). ADR-031's rule is "no
|
||||
handler crate depends on another handler crate" — but `alknet-call` is both
|
||||
a handler *and* the protocol foundation that `alknet-agent` and `alknet-napi`
|
||||
already consume. `alknet-http` depending on `alknet-call` is "HTTP uses the
|
||||
call protocol types," not "HTTP depends on SSH." This is within the spirit of
|
||||
ADR-031 (`alknet-call` is protocol-foundation, not a peer handler). The
|
||||
`alknet-http` spec should note this explicitly; a one-line amendment to
|
||||
ADR-031 clarifying that `alknet-call` is a protocol-foundation crate is
|
||||
deferred to the `alknet-http` Phase 0.
|
||||
handler crate depends on another handler crate" — but alkcall is the
|
||||
protocol foundation that every downstream consumer
|
||||
(including the HTTP host and any agent crate) builds on.
|
||||
`alkhttp` depending on alkcall is "HTTP uses the
|
||||
call protocol types," not "HTTP depends on a sibling handler." This is within the spirit of
|
||||
ADR-031 (alkcall is protocol-foundation, not a peer handler).
|
||||
|
||||
### No-Env-Vars Invariant
|
||||
|
||||
@@ -576,7 +607,7 @@ vault (seed)
|
||||
→ 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
|
||||
`std::env::var`. The downstream consumers' `Default` impls reading env vars
|
||||
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
|
||||
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
|
||||
ADR-018 §6) populates `OperationContext.capabilities` from the registration
|
||||
bundle. The invariant is: *no handler reads outbound credentials from any
|
||||
source other than `OperationContext.capabilities`.* This is already the
|
||||
architectural intent of ADR-010; this document records it as an explicit
|
||||
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)
|
||||
|
||||
@@ -630,20 +661,21 @@ Bilateral: the container service ALSO runs from_call against the hub,
|
||||
the other side's `External` ops. The #2 gap.
|
||||
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
|
||||
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
|
||||
`alk/call` ALPN (over QUIC, TCP+TLS, or any transport) — no SSH in
|
||||
the loop. SSH port
|
||||
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
|
||||
the path entirely.
|
||||
|
||||
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
|
||||
service (`alknet-agent`, downstream) is the consumer that orchestrates these
|
||||
crate (downstream) is the consumer that orchestrates these
|
||||
via `env.invoke()`.
|
||||
|
||||
## 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
|
||||
`CallConnection` for the dispatch loop; adds only the
|
||||
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
|
||||
already-implemented `services/list` + `services/schema` discovery API.
|
||||
|
||||
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).
|
||||
|
||||
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
|
||||
|
||||
| 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-ssh | Stable alknet-call types (no adapter dependency) | Not blocked — ssh depends on alknet-core, not alknet-call's adapters. Proceeds in parallel. |
|
||||
| alknet-agent | `CallClient` (tool dispatch), `from_call` (remote tool import), `OperationAdapter` (provider adapters) | Blocked on `CallClient` + `from_call` |
|
||||
| alknet-http (→ alkhttp) | `OperationAdapter` trait (to implement `from_openapi`/`from_mcp`) | Blocked — can't define HTTP-backed adapters without the trait |
|
||||
| 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 (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 |
|
||||
| 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
|
||||
|
||||
- **No HTTP in alknet-call.** `from_openapi`/`from_mcp`/`from_jsonschema`/
|
||||
`to_openapi`/`to_mcp` live in `alknet-http`. The `OperationAdapter`
|
||||
- **No HTTP in alkcall.** `from_openapi`/`from_mcp`/`from_jsonschema`/
|
||||
`to_openapi`/`to_mcp` live in `alkhttp`. The `OperationAdapter`
|
||||
trait and the call-protocol-backed adapter (`from_call`, transport-
|
||||
agnostic) live in `alknet-call`. `from_jsonschema` was originally
|
||||
(mis)placed in `alknet-call` as a schema-only placeholder; ADR-027
|
||||
moved it to `alknet-http` as a real HTTP-backed adapter. See Adapter
|
||||
agnostic) live in alkcall. `from_jsonschema` was originally
|
||||
(mis)placed in the call protocol as a schema-only placeholder; ADR-027
|
||||
moved it to the HTTP host crate as a real HTTP-backed adapter. See Adapter
|
||||
Location Map.
|
||||
- **No secret material on the wire.** `ConnectionCredentials` carries vault-derived
|
||||
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
|
||||
via `PeerRef::Specific` / `PeerRef::Any` (ADR-024 §1-2).
|
||||
- **`from_call` is a manual free function.** The assembly layer calls it
|
||||
after the dial (in `AlknetClient`). The overlay is per-connection so
|
||||
re-import on reconnect is naturally scoped (DC-2, OQ-27). See
|
||||
[ADR-028](decisions/069-from-call-manual-free-function.md).
|
||||
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-12). See
|
||||
[ADR-028](decisions/028-from-call-manual-free-function.md).
|
||||
- **`from_call` namespace collision is same-peer only.** Cross-peer collision
|
||||
dissolves (same name on different peers is fine — separate sub-overlays,
|
||||
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
|
||||
`AdapterError` (DC-4, OQ-26).
|
||||
`AdapterError` (DC-4, OQ-11).
|
||||
- **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.
|
||||
- **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
|
||||
@@ -730,10 +766,10 @@ Based on the gap analysis and the downstream unblock chain:
|
||||
invoked through the `CallConnection` handle. The client-side
|
||||
`ServerCertVerifier` uses CA verification (`WebPkiServerVerifier`) for
|
||||
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`
|
||||
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
|
||||
to satisfy the field, and must not treat `None` as "skip verification"
|
||||
— `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 |
|
||||
|----------|-----|---------|
|
||||
| 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 |
|
||||
| `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 |
|
||||
| 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` |
|
||||
| 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 |
|
||||
| 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` |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Operation registry layering | [ADR-019](decisions/024-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 |
|
||||
| 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` |
|
||||
| Operation error schemas | [ADR-016](decisions/023-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` |
|
||||
| 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`) |
|
||||
| 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` |
|
||||
| HD derivation for encryption keys | [ADR-020](decisions/020-hd-derivation-for-encryption-keys.md) | Vault-derived TLS identity material |
|
||||
| Vault key model | [ADR-026](decisions/026-vault-key-model-hd-derivation.md) | 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 |
|
||||
| Crate decomposition | [ADR-031](decisions/003-crate-decomposition.md) | `alknet-http` depends on `alknet-call` (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 |
|
||||
| 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 | [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/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/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/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 | [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/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/018-handler-registration-provenance-and-composition-authority.md) | The registration bundle adapters produce; `composition_authority: None` for leaves |
|
||||
| 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/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/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/016-operation-error-schemas.md) | `error_schemas` mirrored by `from_call` from remote op's spec |
|
||||
| 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 | [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 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 | [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 | [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 | [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/031-crate-decomposition.md) | `alkhttp` depends on alkcall (protocol-foundation exception, noted in Adapter Location Map) |
|
||||
| One-way door decision framework | [ADR-032](decisions/032-one-way-door-decision-framework.md) | Door-type classification for DC-1..4 |
|
||||
|
||||
## Open Questions
|
||||
|
||||
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
|
||||
`AccessControl::check(peer_identity)`. No marking to shape.
|
||||
- **OQ-26** (resolved): `AdapterError` variants — `DiscoveryFailed`,
|
||||
- **OQ-11** (resolved): `AdapterError` variants — `DiscoveryFailed`,
|
||||
`SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`
|
||||
(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
|
||||
`AlknetClient`). A `CallConnection::refresh()` method is a genuine
|
||||
feature addition — non-breaking, additive. See
|
||||
[ADR-028](decisions/069-from-call-manual-free-function.md).
|
||||
- **OQ-28** (resolved): `from_call` namespace collision — same-peer
|
||||
[ADR-028](decisions/028-from-call-manual-free-function.md).
|
||||
- **OQ-13** (resolved): `from_call` namespace collision — same-peer
|
||||
collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays).
|
||||
`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);
|
||||
key-type-aware server cert verification (raw key = fingerprint match,
|
||||
X.509 = CA verification); fingerprint normalization (`ed25519:` across
|
||||
quinn/iroh). The iroh path already works; the gap was quinn-only.
|
||||
See OQ-29 in open-questions.md.
|
||||
- **OQ-30** (resolved): `PeerRef::Any` routing policy — insertion-order
|
||||
See OQ-14 in open-questions.md.
|
||||
- **OQ-15** (resolved): `PeerRef::Any` routing policy — insertion-order
|
||||
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."
|
||||
- **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
|
||||
that doesn't break downstream. The peer-keyed model extends to multi-hop
|
||||
without redesign; petgraph is the candidate if path-finding becomes real
|
||||
(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`,
|
||||
stable across key rotation). See OQ-33 in open-questions.md.
|
||||
- **OQ-34** (resolved by ADR-025 + ADR-033): Persistent peer registry —
|
||||
stable across key rotation). See OQ-18 in open-questions.md.
|
||||
- **OQ-19** (resolved by ADR-025): Persistent peer registry —
|
||||
the storage boundary is `core trait + in-memory default` (config-backed
|
||||
`ConfigIdentityProvider` now; persistence adapters additive in separate
|
||||
crates). See OQ-34 in open-questions.md.
|
||||
- **OQ-35** (dissolved): the "API key asymmetry" framing was wrong;
|
||||
crates). See OQ-19 in open-questions.md.
|
||||
- **OQ-20** (dissolved): the "API key asymmetry" framing was wrong;
|
||||
`PeerEntry` supports multiple credential paths (fingerprints +
|
||||
auth_token_hash), `ApiKeyEntry` is for tokens that ARE the identity.
|
||||
See OQ-35 in open-questions.md.
|
||||
- **OQ-36** (resolved by ADR-035): Concrete persistence adapter shapes —
|
||||
read-sync / write-async split (`IdentityStore` async write trait
|
||||
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
|
||||
See OQ-20 in open-questions.md.
|
||||
- **OQ-21** (resolved by ADR-054): X.509 outgoing-only
|
||||
case — three
|
||||
remote roles named (public X.509 endpoint, transport relay, hub).
|
||||
`PeerEntry` asymmetry is correct: a pure-client connection to a public
|
||||
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
|
||||
connection handle. The client-side `ServerCertVerifier` is selected
|
||||
by `PeerEntry` presence: known peer → fingerprint pin; unknown X.509
|
||||
remote → CA verification (`WebPkiServerVerifier`). See ADR-034 and
|
||||
OQ-37 in open-questions.md.
|
||||
remote → CA verification (`WebPkiServerVerifier`). The rule is
|
||||
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
|
||||
|
||||
@@ -837,11 +869,16 @@ See [open-questions.md](open-questions.md) for full details.
|
||||
model (the server-side complement to this document)
|
||||
- `operation-registry.md` — `HandlerRegistration`, provenance, capability
|
||||
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
|
||||
`/workspace/@alkdev/alknet/docs/research/`, not this repo):
|
||||
- `alknet-call-completion/gap-analysis.md` — DC-1..4, the
|
||||
implementation-state audit, the downstream unblock chain
|
||||
- `docs/research/alknet-call-peer-routing/findings.md` — the peer-graph
|
||||
- `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`,
|
||||
`from_mcp.ts`, `from_schema.ts`, `scanner.ts`)
|
||||
- `/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
|
||||
no-env-vars invariant makes its `std::env::var` reads unreachable
|
||||
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp); streamable HTTP transport for
|
||||
`alknet-http`'s `from_mcp`/`to_mcp` (separate crate, separate Phase 0)
|
||||
- `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
|
||||
`alkhttp`'s `from_mcp`/`to_mcp`
|
||||
@@ -232,5 +232,5 @@ WebTransport stream).
|
||||
- OQ-09: WASM target boundaries (resolution amended — the server-side
|
||||
dispatch door is no longer closed by `Connection` being QUIC-bound)
|
||||
- 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)
|
||||
@@ -295,8 +295,8 @@ the args are optional for their transport.
|
||||
`from_bidi` constructors and the yield-once contract are preserved via
|
||||
`StreamBidiStreamSource`)
|
||||
- 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)
|
||||
- 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
|
||||
@@ -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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
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 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
|
||||
direction. With `BiStream` as the leaf, these handlers call
|
||||
`tokio::io::split(bidi)` to get `(ReadHalf, WriteHalf)` — the same
|
||||
stdlib idiom `tokio::io::split` already provides for `TcpStream` and
|
||||
`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`
|
||||
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
|
||||
`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`,
|
||||
`stderr`, `ctrl_in`, `ctrl_out`). **Every stream_type is
|
||||
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`
|
||||
helpers, and the `run_ws_session` glue are removed. The session's
|
||||
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`
|
||||
impls and the standard call-protocol dispatch path.
|
||||
|
||||
`CallConnection::new_overlay_only` stays — it's the
|
||||
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
|
||||
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
|
||||
`Vec<(u8, SubStreamHandle)>` with `SubStreamHandle::Send(SendStream) |
|
||||
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
|
||||
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
|
||||
handler hands each bidi stream to `SshAdapter::handle` as a
|
||||
`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
|
||||
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
|
||||
`BiStream` that `SshAdapter::handle` receives. The WASM SSH parser
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
`h3` handler's "accept a WebTransport session, yield each stream as a
|
||||
`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
|
||||
`SendStream` / `RecvStream` (as thin newtypes, not quinn-welded
|
||||
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
|
||||
shutdown-on-completion contract applies to the `ReadHalf` /
|
||||
`WriteHalf` unchanged.
|
||||
@@ -436,7 +436,7 @@ redesign.
|
||||
lines of WS-specific dispatch glue are removed from
|
||||
`alknet-http/websocket/upgrade.rs`. The WS session runs through
|
||||
`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.
|
||||
- One abstraction (`BiStream`) replaces five. The leaf type matches the
|
||||
ecosystem convention (`russh::Channel::into_stream()`, `TcpStream`,
|
||||
@@ -550,13 +550,13 @@ compilable:
|
||||
`Connection::from_bidi` + the call-protocol handler. This is the
|
||||
largest single change and can land after (1)-(3) — the WS path is
|
||||
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
|
||||
removed; `from_bidi` is the only public stream constructor (the
|
||||
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
|
||||
`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,
|
||||
not in the leaf type).
|
||||
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)
|
||||
- ADR-038: `ChannelBidiStreamSource` (amended — `accept_bi` returns
|
||||
`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,
|
||||
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)
|
||||
- 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"
|
||||
claim literal)
|
||||
- `docs/research/alknet-crate-extraction/findings.md` Phase 6 — the
|
||||
|
||||
+7
-7
@@ -2,7 +2,7 @@
|
||||
|
||||
## 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 —
|
||||
specific #4 confirms the composition authority stays static. Blocks lifted:
|
||||
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
|
||||
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).
|
||||
2. Integration point — `AccessControl::check` consults an ownership
|
||||
@@ -88,7 +88,7 @@ model has no path from that knowledge to `check`.
|
||||
|
||||
### 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):
|
||||
|
||||
- **Option 1 — augment `Identity.resources` with a per-request snapshot.**
|
||||
@@ -592,7 +592,7 @@ provider confirms it owns the container (dynamic).
|
||||
|
||||
## 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
|
||||
decision text)
|
||||
- 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
|
||||
adapter that imports a peer's operations and re-registers them locally;
|
||||
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)
|
||||
- [operation-registry.md](../crates/call/operation-registry.md)
|
||||
- [operation-registry.md](../operation-registry.md)
|
||||
(`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)
|
||||
@@ -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
|
||||
`alknet-blobs`, not a direct dependency of `alknet-call` — alknet-call does
|
||||
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.
|
||||
|
||||
## Decision
|
||||
@@ -159,6 +159,6 @@ not import irpc and has no plans to. See
|
||||
dispatch path)
|
||||
- Call protocol wire format: `crates/alknet-call/src/protocol/wire.rs`
|
||||
- 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)
|
||||
- Removal commit: `668d777` (2026-07-09)
|
||||
@@ -302,8 +302,8 @@ Principle of least privilege.
|
||||
- OQ-17: Abort cascade (the call tree is the agency chain — `parent_request_id`
|
||||
traces principal → agent)
|
||||
- OQ-19: Session-scoped registries (session operations are always `Internal`)
|
||||
- [operation-registry.md](../crates/call/operation-registry.md)
|
||||
- [call-protocol.md](../crates/call/call-protocol.md)
|
||||
- [operation-registry.md](../operation-registry.md)
|
||||
- [call-protocol.md](../call-protocol.md)
|
||||
- TypeScript `@alkdev/operations` `buildEnv()` with `allowedNamespaces` — prior
|
||||
art for scoped composition env
|
||||
- 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)
|
||||
- `@alkdev/flowgraph` TypeScript package — prior art for call-graph tracking
|
||||
with `descendants()`, `FailurePolicy`, reactive status propagation
|
||||
- [call-protocol.md](../crates/call/call-protocol.md)
|
||||
- [operation-registry.md](../crates/call/operation-registry.md)
|
||||
- [call-protocol.md](../call-protocol.md)
|
||||
- [operation-registry.md](../operation-registry.md)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (amended 2026-06-26, 2026-07-13, and 2026-07-16 — see "Amendments" below; the 2026-07-16 amendment per ADR-045 §5 removes `CallClient::connect`; 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
|
||||
|
||||
@@ -349,9 +349,9 @@ same as `from_openapi` receives HTTP credentials.
|
||||
- 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
|
||||
collision — see [open-questions.md](../open-questions.md))
|
||||
- [call-protocol.md](../crates/call/call-protocol.md)
|
||||
- [operation-registry.md](../crates/call/operation-registry.md)
|
||||
- [client-and-adapters.md](../crates/call/client-and-adapters.md) — the spec
|
||||
- [call-protocol.md](../call-protocol.md)
|
||||
- [operation-registry.md](../operation-registry.md)
|
||||
- [client-and-adapters.md](../client-and-adapters.md) — the spec
|
||||
that operationally fills the gap this ADR left to implementation
|
||||
- `docs/research/alknet-call-completion/gap-analysis.md` — DC-1..4, the
|
||||
decisions that needed resolution before implementation
|
||||
@@ -524,7 +524,7 @@ overlays), not by a parallel boolean gate.
|
||||
§5 showed `async fn import(&self) -> Vec<HandlerRegistration>` with no error
|
||||
type. The trait returns `Result<Vec<HandlerRegistration>, AdapterError>`
|
||||
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.
|
||||
|
||||
### DC-2 — from_call re-import on reconnection: manual free function
|
||||
@@ -537,7 +537,7 @@ overlay dies with the connection. A `CallConnection::refresh()` method for
|
||||
mid-connection re-discovery is a genuine feature addition — non-breaking,
|
||||
additive — if a deployment needs manual re-discovery without
|
||||
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
|
||||
rationale.
|
||||
|
||||
@@ -547,7 +547,7 @@ rationale.
|
||||
collision = error**. A node importing from two remotes that both expose the
|
||||
same unprefixed op name should fail loudly. The operator adds prefixes when
|
||||
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.
|
||||
|
||||
### Operational spec
|
||||
@@ -556,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
|
||||
no-env-vars invariant, and the exchange-of-operations pattern — is
|
||||
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
|
||||
authority.
|
||||
|
||||
@@ -576,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
|
||||
trait lives in `alknet-call`; implementations live with their transport."
|
||||
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)
|
||||
|
||||
@@ -585,7 +585,7 @@ See [ADR-027](027-from-jsonschema-as-http-adapter.md) and
|
||||
The §1 Decision framed `CallClient::connect(addr: SocketAddr,
|
||||
credentials)` as the primary constructor and described it as "opens a
|
||||
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
|
||||
`connect()` uses after the QUIC dial. That framing welded the
|
||||
client-side one-way-door API to QUIC — the same welding ADR-007
|
||||
@@ -617,9 +617,64 @@ existing code (which already has the right structure —
|
||||
The door-type classification is updated: `spawn_dispatch` is one-way
|
||||
(the handler-facing surface); ~~`connect` is two-way (additive
|
||||
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
|
||||
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.
|
||||
|
||||
## 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,
|
||||
ACME/Let's Encrypt). Fingerprint format: `SHA256:<hex of DER>`. Requires
|
||||
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
|
||||
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)
|
||||
- OQ-29: CallClient TLS Client-Auth (resolved by this ADR's §6 fingerprint
|
||||
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
|
||||
server identity fits — see OQ-37 in open-questions.md)
|
||||
- OQ-21: X.509 outgoing-only case (the three auth types and how X.509
|
||||
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`
|
||||
model and resolution path)
|
||||
- `docs/architecture/crates/core/auth.md` (the spec amended by this ADR)
|
||||
|
||||
@@ -178,3 +178,15 @@ endpoint.
|
||||
established by `from_openapi`.
|
||||
- Reuses `HttpServiceConfig` and `SharedHttpClient` from
|
||||
`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
|
||||
`OperationSpec`/`Handler`/`OperationAdapter` types — actual type reuse,
|
||||
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
|
||||
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
|
||||
record (ADR-013, which accepted "irpc as the call protocol foundation," is
|
||||
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
|
||||
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
|
||||
for a TTY chunk inside channels is 13 bytes (8 channels + 5 TTY), not
|
||||
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
|
||||
string-named types negotiated per channel, all traffic interleaved on one
|
||||
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)
|
||||
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
|
||||
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.
|
||||
|
||||
| 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`. |
|
||||
| `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`
|
||||
|
||||
`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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
### Sync core / async shell split
|
||||
@@ -251,7 +251,7 @@ tokio-dependent shell.
|
||||
- The hub relay correlates by `channel_id` in the header, uniformly across
|
||||
substrates — no stream-ID-to-`channel_id` mapping per leg.
|
||||
- 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:**
|
||||
- 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
|
||||
header; the stream_type decomposition is removed from the channels
|
||||
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 —
|
||||
TTY always uses its 5-byte format, carried transparently in the
|
||||
channels payload**)
|
||||
|
||||
@@ -76,7 +76,7 @@ on the `BiStream` the channels layer gives them.
|
||||
framing byte, carried transparently.
|
||||
- **The control channel is handler-internal.** TTY sub-demuxes control
|
||||
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
|
||||
is fixed at the TTY layer, not the channels layer.
|
||||
- **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
|
||||
(recursive composition, test helpers, the hub relay's `channel_id`
|
||||
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
|
||||
from the payload — is decided here; the *function surface* is not.
|
||||
- **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
|
||||
unchanged). This is an implementation concern for the channels + TTY
|
||||
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
|
||||
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
|
||||
|
||||
@@ -194,7 +194,7 @@ handler's framing, carried transparently.
|
||||
| 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). |
|
||||
| `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
|
||||
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
|
||||
`stream_type` decomposition (mod 3, unidirectional halves, 85 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 channels
|
||||
TTY's 5-byte format (alknet ADR-052, provenance — amended by Phase 7), which the channels
|
||||
layer carries transparently.
|
||||
|
||||
### 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
|
||||
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
|
||||
standalone utility is an implementation detail that can change without
|
||||
breaking the wire format or the handler contract.
|
||||
@@ -472,7 +472,7 @@ breaking the wire format or the handler contract.
|
||||
`open_channel` and `Channel`)
|
||||
- ADR-044: sub-crate decomposition (unchanged — 8-byte wire format in
|
||||
`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
|
||||
from Phase 7 is TTY-internal)
|
||||
- `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
|
||||
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
|
||||
(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
|
||||
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.
|
||||
|
||||
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-
|
||||
line blocking where the bounded-buffer mitigation is insufficient. The
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ surface:
|
||||
primary constructor and the one-way-door API. Takes a pre-established
|
||||
`Connection` (produced by any transport — TCP+TLS via `from_bidi`,
|
||||
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
|
||||
`ChannelsAdapter::handle(Connection)` (substrate-agnostic) and the
|
||||
existing `CallClient::spawn_dispatch(Connection)` pattern.
|
||||
@@ -73,7 +73,7 @@ surface:
|
||||
|
||||
The dial+TLS seam (the transport-specific work each dial helper does —
|
||||
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
|
||||
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
|
||||
@@ -81,7 +81,7 @@ anti-pattern this amendment corrects.
|
||||
|
||||
The door-type classification is unchanged: `from_connection` is one-way
|
||||
(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.
|
||||
|
||||
## 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
|
||||
verifier selection, produce a `Connection`) that each transport's dial
|
||||
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
|
||||
transport-polymorphic dial seam; two different transport dials do.
|
||||
|
||||
This ADR decides #1 — `ChannelClient`, with `from_connection` as the
|
||||
transport-agnostic primary constructor and `connect_quic` as a transport-
|
||||
specific dial helper. #2 (the shared `AlknetClient` dial+TLS seam) stays
|
||||
deferred per OQ-55.
|
||||
deferred per alknet OQ-55.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -130,7 +130,7 @@ impl ChannelClient {
|
||||
/// Transport-agnostic primary constructor. Takes a pre-established
|
||||
/// `Connection` (any transport — TCP+TLS via `from_bidi`,
|
||||
/// 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
|
||||
/// `ChannelsAdapter::handle(Connection)`. This is the one-way-door
|
||||
/// 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),
|
||||
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
|
||||
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
|
||||
helper does — open a socket, run the TLS handshake, apply ADR-034's
|
||||
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.
|
||||
- Bidirectionality is preserved — the client can both initiate and receive
|
||||
`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
|
||||
core extraction happens later when the blocker clears.
|
||||
|
||||
**Negative:**
|
||||
- Each transport-specific dial helper duplicates ~20 lines of
|
||||
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
|
||||
the shared dial+TLS seam. The `from_connection` API — the one-way-door
|
||||
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
|
||||
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
|
||||
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
|
||||
decided now.
|
||||
|
||||
@@ -292,7 +292,7 @@ decided now.
|
||||
removed from `open_channel` and `Channel`; handler owns sub-stream
|
||||
multiplexing)
|
||||
- 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)
|
||||
- `docs/research/alknet-channels/phase-0-findings.md` §OQ-CH-14 (the
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -168,11 +168,11 @@ The dependency direction is: `alknet-hub` → `channels-call` →
|
||||
|
||||
### 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
|
||||
channels as the substrate, `alknet-hub` gains a dependency on
|
||||
`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
|
||||
(browser↔hub, hub↔spoke), with the relay translating `channel/open` and
|
||||
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
|
||||
`channels-worker` sub-crate — a worker is any crate that uses
|
||||
`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)
|
||||
## 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
|
||||
|
||||
> **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
|
||||
|
||||
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),
|
||||
not `CallCredentials` (call-protocol-level); all three dial signatures
|
||||
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
|
||||
dial seam — blocked on "a second transport's real dial existing." The
|
||||
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
|
||||
connector and naming it `AlknetClient` would bake QUIC in as *the*
|
||||
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
|
||||
accepts connections and resolves identity from the incoming
|
||||
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
|
||||
(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 |
|
||||
| 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 |
|
||||
| Config | `TlsServerConfig` (per endpoint type, built by assembly) | `TlsClientConfig` (per-dial, built from `CallCredentials`) |
|
||||
|
||||
@@ -168,7 +178,7 @@ pub struct AlknetClient {
|
||||
|
||||
impl AlknetClient {
|
||||
/// 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
|
||||
/// `Connection::from_quinn_with_alpn`. Feature-gated on `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
|
||||
iroh dial uses the raw `Ed25519SecretKey` directly. This mirrors the
|
||||
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.
|
||||
|
||||
> **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`
|
||||
> (`ed25519:<hex>` → `NodeId::from_bytes`), the same extraction pattern
|
||||
> 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
|
||||
> `auth_token` is a per-request field on `call.requested` payloads
|
||||
> (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.
|
||||
- **The iroh dial is the exception.** It does not use
|
||||
`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
|
||||
exception as the server side (ADR-082, ADR-087 §3) — unavoidable,
|
||||
and isolated to one dial method.
|
||||
@@ -528,24 +538,24 @@ dedicated ADR lands.
|
||||
|
||||
- OQ-55 (resolved by this ADR) — `AlknetClient` / client establishment
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
config"
|
||||
- [ADR-007](007-connection-from-stream-generic-single-stream.md) —
|
||||
`Connection::from_stream` / `from_bidi`; the server-side
|
||||
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)
|
||||
- [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
|
||||
- [ADR-043](043-channelclient.md) — `ChannelClient::from_connection`
|
||||
(the take-over `AlknetClient` feeds)
|
||||
@@ -555,3 +565,22 @@ dedicated ADR lands.
|
||||
is the native analogue)
|
||||
- `docs/architecture/crates/channels/channel-client.md` §"Relationship
|
||||
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
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
> **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
|
||||
matching by `(op_name, params_hash)`) is **not** part of this ADR. It
|
||||
is deferred to the channels session for these reasons:
|
||||
|
||||
@@ -14,7 +14,265 @@ 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)
|
||||
`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)
|
||||
|
||||
@@ -108,7 +366,9 @@ 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):
|
||||
`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.
|
||||
|
||||
@@ -82,9 +82,12 @@ Consumer-side close → wrapper teardown → pump abort → send-half drop →
|
||||
EOF chunk → spoke handler exits → spoke's own wrapper decrements its
|
||||
ledger. Close propagates through the EOF cascade with correct ledger
|
||||
accounting on both legs; the hub never sees a `channel/close` to
|
||||
translate. Control stays per-leg (`channel/control` is not implemented
|
||||
upstream, OQ-39 — a hub wanting cross-leg control translation composes
|
||||
it downstream). This supersedes the ADR-042 §mapping bullet's
|
||||
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.
|
||||
|
||||
@@ -205,11 +208,13 @@ same pin.
|
||||
|
||||
### 6. Bounds: rejected shapes and residual notes
|
||||
|
||||
- **Pub-typed open ops** — rejected loudly (`channel:pub_open_not_implemented`,
|
||||
the C-08 blocker). The relay registers only `Query`/`Mutation`-typed
|
||||
ops (plain forwarding) and `Sub`-typed marked ops (relay openables);
|
||||
a discovered `Pub`-typed marked spec is an assembly error, not a
|
||||
silent stub.
|
||||
- **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)
|
||||
@@ -263,7 +268,6 @@ same pin.
|
||||
`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
|
||||
|
||||
@@ -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
|
||||
last_updated: 2026-08-12
|
||||
status: current
|
||||
last_updated: 2026-10-13 (composition-layer re-key)
|
||||
---
|
||||
|
||||
# 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-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-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
|
||||
|
||||
| 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
|
||||
|
||||
@@ -59,32 +60,44 @@ type. `call.published` is the wire event carrying stream chunks.
|
||||
the client method. The `Subscription` variant is renamed to `Sub`
|
||||
(wire string `"sub"`).
|
||||
|
||||
**Fan-out deferred.** The broker (topic registry, `Pub`↔`Sub` matching,
|
||||
N-consumer fan-out) is deferred to the channels session. The call
|
||||
protocol is point-to-point (one initiator, one responder, a stream
|
||||
between them); a topic registry that outlives individual calls is a
|
||||
different lifecycle and a different concern. The broker's first
|
||||
consumer is the channels `channel/resources/subscribe` operation
|
||||
(ADR-037) and the hub-as-broker pattern (ADR-042). The `Pub` primitive
|
||||
is the load-bearing piece the broker composes on.
|
||||
**Placement decided: the broker is not an alkcall deliverable.** The
|
||||
fan-out/broker mechanism (topic registry, `Pub`↔`Sub` matching,
|
||||
N-consumer fan-out) sits above the call protocol: the call protocol is
|
||||
point-to-point (one initiator, one responder, a stream between them);
|
||||
a topic registry that outlives individual calls is a different
|
||||
lifecycle and a different concern. The broker is a hub composition on
|
||||
top of the `Pub`/`Sub` primitives — ADR-047 §"What this ADR decides"
|
||||
(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
|
||||
|
||||
| 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-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-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-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-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-39 | `channel/control` control-handle surface | open | medium | The `channel/control` handler currently returns `channel:control_not_implemented`. The control-handle surface (per-channel control callbacks registered by ALPN crates, routing `message` to the handler's control handle for `channel_id`) is real design work — each ALPN crate needs a way to register a control callback, and the channels layer needs a control-handle registry keyed by `channel_id`. Deferred until an ALPN crate (TTY, tunnel) needs out-of-band control. |
|
||||
| OQ-40 | `channel/resources/subscribe` live subscription | open | medium | The `channel/resources/subscribe` handler currently returns `channel:resources_not_implemented`. The live subscription aggregated from ALPN-crate resource enumerators (ADR-047 §6) requires each ALPN crate to provide a resource enumerator, and the channels layer to aggregate them into a live `Stream` that emits on any change. The current stub is a one-shot error; the real implementation is deferred until a consumer (hub, dashboard) needs live resource discovery. **Load-bearing for alktunnels' discovery UI** (review 006 E-02): a consumer cannot distinguish produced tunnel resources by op name alone. The static half is covered (0.5.0: `OperationSpec.description` on `services/list` — describes the op, not the live resource set); the dynamic half stays deferred — alktunnels v1 uses config-known op names. |
|
||||
| OQ-41 | QUIC-native multi-stream substrate | open | medium | Only the in-line substrate mode is implemented (single bidi stream, header-demuxed N channels). The QUIC-native multi-stream substrate (accept remaining bidi streams, read headers off each — ADR-034 §substrate modes) is deferred to the downstream alknet crate. The wire format and demux loop are correct for both substrates; only the outer `accept_bi()` loop is missing. The alknet crate owns the QUIC dial/accept loop and is the natural place for the multi-stream accept loop. This crate stays transport-agnostic (no QUIC dependency, WASM-compatible). |
|
||||
| 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 | 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 | 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
|
||||
|
||||
@@ -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-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 |
|
||||
|
||||
## 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
|
||||
last_updated: 2026-07-09
|
||||
last_updated: 2026-10-13 (alkcall reframing)
|
||||
---
|
||||
|
||||
# Operation Registry
|
||||
@@ -21,7 +21,7 @@ The operation registry provides:
|
||||
- **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)
|
||||
|
||||
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
|
||||
|
||||
@@ -90,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.
|
||||
|
||||
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
|
||||
|
||||
@@ -135,10 +135,13 @@ impl AccessControl {
|
||||
|
||||
The `OwnershipProvider` trait (read side, sync — called on the dispatch hot
|
||||
path) and the `OwnershipStore` trait (write side, async — called by
|
||||
handlers that manage resource lifecycles) are defined in `alknet-core` per
|
||||
ADR-011's storage decision (fourth instance of the repo/adapter pattern,
|
||||
ADR-033). See [auth.md](../core/auth.md) §"Ownership Provider and Store"
|
||||
for the trait shapes and the in-memory default adapter.
|
||||
handlers that manage resource lifecycles) live with the vendored core
|
||||
types in alkcall, per
|
||||
ADR-011's storage decision (fourth instance of the source repo's
|
||||
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
|
||||
by the dispatcher), populated by the dispatch path from the registry's
|
||||
@@ -440,7 +443,7 @@ pub enum OperationProvenance {
|
||||
| `Session` | Yes (within sandbox) | Yes — scopes set at sandbox creation | Internal always |
|
||||
|
||||
> **`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`
|
||||
> is a leaf, same trust model as `FromOpenAPI` (HTTP endpoint trusted;
|
||||
> handler is a forwarding stub). Schema validation without a handler is
|
||||
@@ -523,7 +526,7 @@ The `OperationEnv` trait is the universal composition mechanism. A handler calls
|
||||
/// through its `OperationContext.env` (which implements this trait).
|
||||
///
|
||||
/// 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
|
||||
/// registry into the dispatch path would close the session-overlay
|
||||
/// pattern.
|
||||
@@ -604,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`
|
||||
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)
|
||||
and [ADR-024](decisions/029-peer-graph-routing-model.md) §2:
|
||||
`PeerCompositeEnv` struct — see [client-and-adapters.md](client-and-adapters.md#peer-keyed-composition-env-adr-024)
|
||||
and [ADR-024](decisions/024-peer-graph-routing-model.md) §2:
|
||||
|
||||
```rust
|
||||
pub enum PeerRef {
|
||||
@@ -727,7 +730,7 @@ impl OperationEnv for PeerCompositeEnv {
|
||||
}
|
||||
// Peer-keyed overlay: iterate peers in insertion order, dispatch to
|
||||
// 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).
|
||||
for peer_id in &self.connection_order {
|
||||
if let Some(conn_env) = self.connections.get(peer_id) {
|
||||
@@ -805,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.
|
||||
|
||||
**`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
|
||||
|
||||
@@ -849,7 +852,7 @@ error handling.
|
||||
|
||||
### 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
|
||||
ever imported irpc — the wire format (`wire.rs`), the operation registry,
|
||||
and the dispatch are all hand-rolled. ADR-014 supersedes ADR-013 and
|
||||
@@ -858,7 +861,7 @@ protocol (external, JSON)" with "irpc services (internal, postcard)" is
|
||||
moot — there is no irpc layer.
|
||||
|
||||
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`
|
||||
wire format); the internal handler dispatch is a `Handler` /
|
||||
`StreamingHandler` trait object (ADR-021), not an irpc `Service`.
|
||||
@@ -966,71 +969,74 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
|
||||
|
||||
| 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 |
|
||||
| Call protocol stream model | [ADR-015](decisions/012-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) |
|
||||
| Vault integration via assembly layer | [ADR-008](decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 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` |
|
||||
| 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) |
|
||||
| `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") |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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/015-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
|
||||
| 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-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/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/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/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/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/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/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/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/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/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/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/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 out of scope for alkcall (OQ-22 resolved, OQ-42 downstream) |
|
||||
|
||||
## Open Questions
|
||||
|
||||
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-14** (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-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-25** (dissolved by ADR-024): `remote_safe` marking shape — moot.
|
||||
- **OQ-02** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
|
||||
- **OQ-03** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
|
||||
- **OQ-09** (resolved by ADR-010): No vault operations are exposed over the call protocol for now.
|
||||
- **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-10** (dissolved by ADR-024): `remote_safe` marking shape — moot.
|
||||
`remote_safe`/`trusted_peer` are retired; peer authorization is
|
||||
`AccessControl::check(peer_identity)`, the existing mechanism. See
|
||||
[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`,
|
||||
`SamePeerCollision` (replaces flat `Conflict`). `#[non_exhaustive]`. See
|
||||
[client-and-adapters.md](client-and-adapters.md).
|
||||
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
|
||||
manual free function; the assembly layer calls it after the dial (in
|
||||
`AlknetClient`). A `CallConnection::refresh()` method is a genuine
|
||||
- **OQ-12** (resolved): `from_call` re-import trigger — `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). A `CallConnection::refresh()` method is a genuine
|
||||
feature addition — non-breaking, additive. See
|
||||
[ADR-028](decisions/069-from-call-manual-free-function.md).
|
||||
- **OQ-28** (resolved): `from_call` namespace collision — same-peer
|
||||
[ADR-028](decisions/028-from-call-manual-free-function.md).
|
||||
- **OQ-13** (resolved): `from_call` namespace collision — same-peer
|
||||
collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays).
|
||||
`namespace_prefix` is optional local-naming sugar. See
|
||||
[client-and-adapters.md](client-and-adapters.md).
|
||||
- **OQ-29..37** are tracked in [client-and-adapters.md](client-and-adapters.md)
|
||||
(they concern the `CallClient` / adapter surface and peer-graph routing,
|
||||
not the registry layering this document covers). In brief: OQ-29
|
||||
(CallClient TLS client-auth) resolved; OQ-30 (`PeerRef::Any` routing
|
||||
policy) resolved; OQ-31 (`services/list-peers` re-export semantics)
|
||||
resolved; OQ-32 (multi-hop federation) open (feature extension);
|
||||
OQ-33 (PeerId source) resolved by ADR-025; OQ-34 (persistent peer
|
||||
registry) resolved by ADR-025+033; OQ-35 (API key asymmetry) dissolved;
|
||||
OQ-36 (concrete persistence adapter shapes) resolved by ADR-035;
|
||||
OQ-37 (X.509 outgoing-only) resolved by ADR-034.
|
||||
- **OQ-42** (resolved by ADR-011): Dynamic resource ownership for
|
||||
- The client-surface questions (the `CallClient` / adapter surface and
|
||||
peer-graph routing) are tracked in
|
||||
[client-and-adapters.md](client-and-adapters.md), not the registry
|
||||
layering this document covers; in brief: OQ-14
|
||||
(`CallClient` TLS client-auth) resolved; OQ-15 (`PeerRef::Any` routing
|
||||
policy) resolved; OQ-16 (`services/list-peers` re-export semantics)
|
||||
resolved; OQ-17 (multi-hop federation) deferred(scope) (feature
|
||||
extension); OQ-18 (`PeerId` source) resolved by ADR-025; OQ-19
|
||||
(persistent peer registry) resolved by ADR-025; OQ-20 (API key
|
||||
asymmetry) dissolved; OQ-21 (X.509 outgoing-only) resolved by
|
||||
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
|
||||
`OwnershipProvider`; `OperationSpec` gains `resource_id_path`; proxy-only
|
||||
access pattern; four edge specifics pinned (`list`, teardown, fleet,
|
||||
composition). See [auth.md](../core/auth.md) §"Ownership Provider and
|
||||
Store" for the trait shapes.
|
||||
composition). See ADR-011 for the trait shapes.
|
||||
|
||||
## References
|
||||
|
||||
- [call-protocol.md](call-protocol.md) — CallAdapter, EventEnvelope, stream model, PendingRequestMap
|
||||
- ADR-014: Hand-rolled EventEnvelope framing (irpc never integrated; supersedes ADR-013)
|
||||
- ADR-008: 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-010: Secret material flow and capability injection (the vault integration point)
|
||||
- ADR-001: ALPN-based protocol dispatch (`HandlerRegistry` static registration)
|
||||
- ADR-015: Call protocol stream model
|
||||
- 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`)
|
||||
@@ -1038,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-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)
|
||||
- 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,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
|
||||
or a follow-on ADR. Then implement the adoption path: the
|
||||
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-13: `channel/close` must drain (signal EOF to the handler, await
|
||||
pump completion) before `abort`/decrement; implement REQ-CH-06
|
||||
|
||||
@@ -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`)
|
||||
@@ -8,7 +8,8 @@
|
||||
//! (single bidi stream, header-demuxed N channels — TCP+TLS,
|
||||
//! WebTransport session, SSH `direct-tcpip`). The QUIC-native
|
||||
//! multi-stream substrate (accept remaining bidi streams, read headers
|
||||
//! off each) is deferred to the downstream alknet crate (OQ-41).
|
||||
//! off each) belongs in the composition layer's QUIC accept loop
|
||||
//! (OQ-41; composition-layer owner OQ-43 — today the alknet glue).
|
||||
//! The wire format and demux loop are correct for both substrates;
|
||||
//! only the outer `accept_bi()` loop is missing.
|
||||
//!
|
||||
@@ -103,8 +104,8 @@ impl ChannelsAdapter {
|
||||
/// in-line substrate mode (single bidi stream, header-demuxed N
|
||||
/// channels — TCP+TLS, WebTransport session, SSH `direct-tcpip`).
|
||||
/// The QUIC-native multi-stream substrate (accept remaining bidi
|
||||
/// streams, read headers off each) is deferred to the downstream
|
||||
/// alknet crate (OQ-41). The wire format and demux loop are correct
|
||||
/// streams, read headers off each) belongs in the composition
|
||||
/// layer (OQ-41; owner OQ-43 — today the alknet glue). The wire format and demux loop are correct
|
||||
/// for both substrates; only the outer `accept_bi()` loop is missing.
|
||||
/// See ADR-034 §substrate modes.
|
||||
async fn run_demux_loop(
|
||||
|
||||
+131
-2
@@ -70,8 +70,8 @@ impl ChannelOpenError {
|
||||
|
||||
/// The establishment-failure reason code — `details.reason` of a
|
||||
/// `channel:open_failed` error (`dial_failed`, `unknown_resource`,
|
||||
/// `resource_shortage`, `handler_error`, `timeout`). `None` for
|
||||
/// any other failure shape.
|
||||
/// `resource_shortage`, `handler_error`, `timeout`, `adopt_failed`).
|
||||
/// `None` for any other failure shape.
|
||||
pub fn establishment_reason(&self) -> Option<&str> {
|
||||
match self {
|
||||
ChannelOpenError::CallFailed { error }
|
||||
@@ -88,6 +88,30 @@ impl ChannelOpenError {
|
||||
}
|
||||
}
|
||||
|
||||
/// The awaited single merged response of a put open
|
||||
/// ([`ChannelClient::open_pub_channel`], ADR-047 amendment 4 §4.9 —
|
||||
/// the put ack rides in the result, ADR-047 amendment 4 §4.5). The
|
||||
/// putter writes bytes on the returned `send` half (or spawns pumps)
|
||||
/// and awaits this for the put's wire contract (e.g. alkblobs' put
|
||||
/// replying `{ token, digest }`). Await this after (or alongside) the
|
||||
/// byte stream, never before it — the response cannot exist before the
|
||||
/// byte stream completes.
|
||||
pub struct PubOpenReply {
|
||||
rx: tokio::sync::oneshot::Receiver<Result<Value, ChannelOpenError>>,
|
||||
}
|
||||
|
||||
impl PubOpenReply {
|
||||
/// The putter-side await: the merged response output (`Ok`);
|
||||
/// `Err` carries the wire `CallError` verbatim
|
||||
/// (`channel:open_failed` + `details.reason` per ADR-049 §3).
|
||||
pub async fn resolve(self) -> Result<Value, ChannelOpenError> {
|
||||
match self.rx.await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(ChannelOpenError::MissingChannelId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The client-side handle for a channels connection. Constructed via
|
||||
/// [`ChannelClient::from_connection`] from an established
|
||||
/// `Connection`. Holds the `ChannelManager` (for the demux/mux state)
|
||||
@@ -382,6 +406,111 @@ impl ChannelClient {
|
||||
.map(|(send, recv)| (channel_id, out, send, recv))
|
||||
}
|
||||
|
||||
/// The putter-facing open (ADR-047 amendment 4 §4.9 — the
|
||||
/// initiator-allocates capability `open_channel`/`open_channel_with_reply`
|
||||
/// cannot provide, because those await the open reply before
|
||||
/// returning the halves and a put's bytes flow before the reply
|
||||
/// exists, review 011 §2.7): allocates the connect-side odd id
|
||||
/// locally, inserts the reserved input key, sends the open op in a
|
||||
/// background task, and returns **immediately** — the caller
|
||||
/// writes bytes on `send` without waiting for anything (the
|
||||
/// put ack rides in the merged response; ADR-047 amendment 4 §4.5).
|
||||
///
|
||||
/// Returns `(channel_id, send, recv, reply)`; `reply` is the
|
||||
/// awaited single merged response — `Ok(Value)` (the full output,
|
||||
/// e.g. `{ token, digest }`) or the wire error. On a failed open
|
||||
/// the local adopt entry is torn down and `send`/`recv` are
|
||||
/// already EOF'd (teardown); the caller still holds them but every
|
||||
/// write is EOF-shaped. The `recv` half is the (bidirectional)
|
||||
/// channel's read side — a put that also receives reads it.
|
||||
pub async fn open_pub_channel(
|
||||
&self,
|
||||
operation_id: &str,
|
||||
input: Value,
|
||||
alpn: &str,
|
||||
) -> Result<(u32, MpscSendStream, MpscRecvStream, PubOpenReply), ChannelOpenError> {
|
||||
use crate::registry::spec::RESERVED_OPEN_INPUT_KEY;
|
||||
|
||||
let (channel_id, send, recv) = self
|
||||
.manager
|
||||
.open_channel(
|
||||
alpn,
|
||||
// The opener ledger is inert on the connect side (it
|
||||
// does not enforce the per-identity cap, ADR-041) —
|
||||
// the record is observability only.
|
||||
"channel-client",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
crate::channels::manager::ManagerError::TooManyChannels { count, max } => {
|
||||
ChannelOpenError::CallFailed {
|
||||
error: crate::protocol::wire::CallError::new(
|
||||
"channel:too_many_channels",
|
||||
format!("per-connection channel cap exceeded: {count}/{max}"),
|
||||
false,
|
||||
)
|
||||
.with_details(serde_json::json!({ "count": count, "max": max })),
|
||||
}
|
||||
}
|
||||
other => ChannelOpenError::CallFailed {
|
||||
error: crate::protocol::wire::CallError::new(
|
||||
"channel:allocation_failed",
|
||||
format!("channel allocation failed: {other}"),
|
||||
false,
|
||||
),
|
||||
},
|
||||
})?;
|
||||
|
||||
let mut op_input = input;
|
||||
match op_input.as_object_mut() {
|
||||
Some(obj) => {
|
||||
obj.insert(
|
||||
RESERVED_OPEN_INPUT_KEY.to_string(),
|
||||
serde_json::json!(channel_id),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
let _ = self.manager.teardown_channel(channel_id);
|
||||
return Err(ChannelOpenError::CallFailed {
|
||||
error: crate::protocol::wire::CallError::invalid_input(
|
||||
"marked pub open op input must be an object (ADR-047 amendment 4 §4.3)",
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let call_conn = {
|
||||
let guard = self.call_connection.lock().await;
|
||||
guard.as_ref().map(Arc::clone)
|
||||
};
|
||||
let manager = self.manager.clone();
|
||||
let op_id = operation_id.to_string();
|
||||
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
|
||||
tokio::spawn(async move {
|
||||
let response = match &call_conn {
|
||||
Some(conn) => conn.call(&op_id, op_input).await,
|
||||
None => ResponseEnvelope::error(
|
||||
"channel-client",
|
||||
crate::protocol::wire::CallError::internal("channel client closed"),
|
||||
),
|
||||
};
|
||||
// A failed open tears the allocator-side entry down: the
|
||||
// responder refused, the channel is dead, and the local
|
||||
// half must not linger in the connection's channel map.
|
||||
if response.result.is_err() {
|
||||
let _ = manager.teardown_channel(channel_id);
|
||||
}
|
||||
let reply = match response.result {
|
||||
Ok(out) => Ok(out),
|
||||
Err(error) => Err(ChannelOpenError::CallFailed { error }),
|
||||
};
|
||||
let _ = reply_tx.send(reply);
|
||||
});
|
||||
|
||||
Ok((channel_id, send, recv, PubOpenReply { rx: reply_rx }))
|
||||
}
|
||||
|
||||
/// Take the `CallConnection` — used by the consumer to register
|
||||
/// imported ops (`from_call`) on the connection's overlay. After
|
||||
/// this, `call_open_op` returns an error (the connection is owned
|
||||
|
||||
@@ -62,6 +62,7 @@ const TTY_OP: &str = "channels/tty/sub";
|
||||
const TTY_ALPN: &str = "alk/tty";
|
||||
const ALPN: &str = "alk/tunnel";
|
||||
const PLAIN_OP: &str = "fs/read";
|
||||
const PUT_OP: &str = "channels/put/bulk";
|
||||
|
||||
fn identity(id: &str, scopes: &[&str]) -> Identity {
|
||||
Identity {
|
||||
@@ -256,6 +257,49 @@ fn make_spoke_install(
|
||||
)
|
||||
.expect("spoke registers the tty open op");
|
||||
|
||||
// The marked pub openable (ADR-047 amendment 4 §4.7's
|
||||
// relay-translation gate): a put handler that consumes the
|
||||
// adopted channel's data plane to EOF and replies with the
|
||||
// digest over the streamed bytes.
|
||||
let put_spec = OperationSpec::new(
|
||||
PUT_OP,
|
||||
OperationType::Pub,
|
||||
Visibility::External,
|
||||
json!({ "type": "object" }),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel_id": { "type": "integer" },
|
||||
"digest": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
)
|
||||
.with_channel_open(ChannelOpenSpec::new("alk/blobs"));
|
||||
let put_handler: crate::channels::operations::PubOpenHandler =
|
||||
Arc::new(move |_input, _plan, channel_conn, _auth| {
|
||||
Box::pin(async move {
|
||||
let mut bidi = channel_conn.accept_bi().await.map_err(|_| {
|
||||
crate::protocol::wire::CallError::internal(
|
||||
"spoke put: the data plane never opened",
|
||||
)
|
||||
})?;
|
||||
let mut buf = Vec::new();
|
||||
tokio::io::AsyncReadExt::read_to_end(&mut bidi, &mut buf)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::protocol::wire::CallError::internal(format!(
|
||||
"spoke put read failed: {e}"
|
||||
))
|
||||
})?;
|
||||
Ok(json!({ "digest": format!("spoke-{}", buf.len()) }))
|
||||
})
|
||||
});
|
||||
core.register_pub_openable(put_spec, None, put_handler, ®istry, auth.clone(), None)
|
||||
.expect("spoke registers the marked pub openable");
|
||||
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
plain_spec(PLAIN_OP),
|
||||
@@ -740,3 +784,51 @@ async fn gate2_tty_standard_shape_relays_with_no_regression() {
|
||||
"the tty relay's ledger decremented"
|
||||
);
|
||||
}
|
||||
|
||||
// --- gate: the pub-openables relay translation (ADR-047 amendment 4 §4.7) ---
|
||||
|
||||
/// A hub translating a pub openable end-to-end (review 011 §3): the
|
||||
/// browser's put via `open_pub_channel` on the consumer leg; bytes
|
||||
/// flow browser → hub → spoke (the hub never parses them); the
|
||||
/// spoke's single response loses its reserved key and its result
|
||||
/// fields ride the consumer reply; the consumer replies exactly once
|
||||
/// with the hub-allocated consumer-leg id.
|
||||
#[tokio::test]
|
||||
async fn gate_pub_relay_put_end_to_end() {
|
||||
let h = start().await;
|
||||
|
||||
let (id, mut send, _recv, reply) = h
|
||||
.consumer
|
||||
.open_pub_channel(PUT_OP, json!({ "path": "g" }), "alk/blobs")
|
||||
.await
|
||||
.expect("the put opens through the relayed pub openable");
|
||||
send.write_all(b"payload!").await.expect("stream the bytes");
|
||||
drop(send);
|
||||
|
||||
let merged = reply
|
||||
.resolve()
|
||||
.await
|
||||
.expect("the put's single merged response");
|
||||
assert_eq!(
|
||||
merged["digest"],
|
||||
json!("spoke-8"),
|
||||
"the spoke's result rode the relay (the producer's reserved key stripped)"
|
||||
);
|
||||
assert_eq!(
|
||||
merged["channel_id"],
|
||||
json!(id),
|
||||
"the reply carries the hub-allocated consumer-leg id"
|
||||
);
|
||||
assert_eq!(id % 2, 1, "the browser (connect side) allocated odd");
|
||||
|
||||
wait_for(
|
||||
|| data_channels(&h.hub_consumer_manager) == 0,
|
||||
"the consumer-leg put channel reclaimed at the wrapper's completion teardown",
|
||||
)
|
||||
.await;
|
||||
wait_for(
|
||||
|| data_channels(&h.hub_producer_manager) == 0,
|
||||
"the producer-leg put channel reclaimed at the plan's drop guard",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -668,7 +668,11 @@ async fn template_plain_bundle_registration_failure_is_loud_at_install() {
|
||||
/// call to any op — including the bootstrap discovery ops — does not
|
||||
/// resolve). Never a silent stub.
|
||||
#[tokio::test]
|
||||
async fn template_pub_typed_marked_spec_is_loud_at_install() {
|
||||
async fn template_pub_typed_marked_spec_registers_through_the_relay() {
|
||||
// ADR-047 amendment 4 §4.7: the put-forwarding translation replaced
|
||||
// the `PubTypedOpen` assembly error — a pub-typed marked spec in
|
||||
// the stash composes through `register_pub_openable` and the op
|
||||
// re-exposes on the consumer leg.
|
||||
let pub_bundle = HandlerRegistration::new(
|
||||
relay_spec(
|
||||
"channels/x/pub",
|
||||
@@ -685,15 +689,19 @@ async fn template_pub_typed_marked_spec_is_loud_at_install() {
|
||||
);
|
||||
let imports = HubLegImports::from_bundles(vec![pub_bundle]);
|
||||
let consumer = start_hub(None, Some(imports), None, false).await;
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
consumer.call_open_op("services/list", json!({})),
|
||||
)
|
||||
.await;
|
||||
let response = consumer.call_open_op("services/list", json!({})).await;
|
||||
let out = response
|
||||
.result
|
||||
.expect("the leg dispatches; the op re-exposed");
|
||||
let names: Vec<&str> = out["operations"]
|
||||
.as_array()
|
||||
.expect("operations")
|
||||
.iter()
|
||||
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
|
||||
.collect();
|
||||
assert!(
|
||||
response.is_err(),
|
||||
"the leg's channel 0 never dispatches after the loud assembly error, got {:?}",
|
||||
response
|
||||
names.contains(&"channels/x/pub"),
|
||||
"the pub openable is re-exposed through the relay, got: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::protocol::wire::CallError;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::mux::{MuxHandle, OpenerLedger};
|
||||
use super::reassembly::{MpscRecvStream, MpscSendStream, DEFAULT_BUFFER_CAP};
|
||||
|
||||
@@ -38,6 +41,15 @@ pub enum ChannelSide {
|
||||
/// `ChannelLifecyclePolicy` (ADR-041, amended by ADR-047 §7).
|
||||
pub const DEFAULT_MAX_CHANNELS: usize = 256;
|
||||
|
||||
/// The per-channel out-of-band control callback (ADR-052): received
|
||||
/// from the open-op establisher (`Establishment::with_control`),
|
||||
/// installed by the open-op wrapper under the channel's id, routed to
|
||||
/// by `channel/control`, removed by every teardown path. The manager
|
||||
/// stays blind to what the closure does (ADR-039).
|
||||
pub type ChannelControl = Arc<
|
||||
dyn Fn(Value) -> futures::future::BoxFuture<'static, Result<Value, CallError>> + Send + Sync,
|
||||
>;
|
||||
|
||||
/// The per-channel state — the reassembly sender (the demux feeds chunk
|
||||
/// payloads into it; the handler reads them out via `MpscRecvStream`),
|
||||
/// the handler task handle, and the ALPN (for observability).
|
||||
@@ -62,6 +74,13 @@ pub enum ManagerError {
|
||||
UnknownChannel(u32),
|
||||
#[error("channel already exists: {0}")]
|
||||
ChannelExists(u32),
|
||||
#[error(
|
||||
"channel {channel_id} is outside the remote side's allocation range (expected {expected})"
|
||||
)]
|
||||
InvalidAdopt {
|
||||
channel_id: u32,
|
||||
expected: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
/// The shared state for a channels connection. `Clone` (cheap — `Arc`
|
||||
@@ -101,6 +120,10 @@ struct Inner {
|
||||
/// drains them into the new receiver. `clear_all` drops the map.
|
||||
early_arrivals: Mutex<HashMap<u32, VecDeque<Bytes>>>,
|
||||
early_arrival_count: AtomicU64,
|
||||
/// Per-channel out-of-band control callbacks (ADR-052). Installed
|
||||
/// by the open-op wrapper from the establisher's
|
||||
/// `Establishment::with_control`; removed by every teardown path.
|
||||
control_handles: Mutex<HashMap<u32, ChannelControl>>,
|
||||
}
|
||||
|
||||
/// The per-channel cap on parked early-arrival chunks (`route_payload`
|
||||
@@ -151,6 +174,7 @@ impl ChannelManager {
|
||||
dropped_unknown_chunks: AtomicU64::new(0),
|
||||
early_arrivals: Mutex::new(HashMap::new()),
|
||||
early_arrival_count: AtomicU64::new(0),
|
||||
control_handles: Mutex::new(HashMap::new()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -317,6 +341,32 @@ impl ChannelManager {
|
||||
alpn: impl Into<String>,
|
||||
handler_task: Option<JoinHandle<()>>,
|
||||
) -> Result<(MpscSendStream, MpscRecvStream), ManagerError> {
|
||||
// The adopt range is the remote side's allocation range (the
|
||||
// odd/even split, ADR-047 §5): the connect side allocates odd,
|
||||
// so the accept side adopts odd; the accept side allocates
|
||||
// even, so the connect side adopts even. Adopting inside the
|
||||
// local range would collide with this side's own monotonic
|
||||
// allocator shortly after. The duplicate check comes first —
|
||||
// adopting an id this side already holds returns
|
||||
// `ChannelExists` and must never touch the live channel (the
|
||||
// 0.8.1 regression pin).
|
||||
let expected = match self.inner.side {
|
||||
ChannelSide::Connect => "even (the accept side's allocated ids; ADR-047 §5)",
|
||||
ChannelSide::Accept => "odd (the connect side's allocated ids; ADR-047 §5)",
|
||||
};
|
||||
if self.inner.channels.lock().contains_key(&channel_id) {
|
||||
return Err(ManagerError::ChannelExists(channel_id));
|
||||
}
|
||||
let parity_ok = match self.inner.side {
|
||||
ChannelSide::Connect => channel_id.is_multiple_of(2),
|
||||
ChannelSide::Accept => !channel_id.is_multiple_of(2),
|
||||
};
|
||||
if !parity_ok {
|
||||
return Err(ManagerError::InvalidAdopt {
|
||||
channel_id,
|
||||
expected,
|
||||
});
|
||||
}
|
||||
let alpn = alpn.into();
|
||||
|
||||
let send = self
|
||||
@@ -381,6 +431,30 @@ impl ChannelManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the out-of-band control callback for a channel
|
||||
/// (ADR-052). Called by the open-op wrapper from the establisher's
|
||||
/// `Establishment::with_control`. Replaces an existing callback for
|
||||
/// the id (the establisher runs once per channel — replacement is
|
||||
/// not an expected path; the last call wins).
|
||||
pub fn set_control_handle(&self, channel_id: u32, control: ChannelControl) {
|
||||
self.inner
|
||||
.control_handles
|
||||
.lock()
|
||||
.insert(channel_id, control);
|
||||
}
|
||||
|
||||
/// The control callback for a channel, for routing (the
|
||||
/// `channel/control` handler). `None` when the channel's ALPN
|
||||
/// registered no control surface (the op answers
|
||||
/// `channel:no_control_handle`).
|
||||
pub fn control_handle(&self, channel_id: u32) -> Option<ChannelControl> {
|
||||
self.inner.control_handles.lock().get(&channel_id).cloned()
|
||||
}
|
||||
|
||||
fn remove_control_handle(&self, channel_id: u32) {
|
||||
self.inner.control_handles.lock().remove(&channel_id);
|
||||
}
|
||||
|
||||
/// Install channel 0 (pre-negotiated as `alk/call`, ADR-036).
|
||||
/// Channel 0 is special only in that it's pre-allocated (by
|
||||
/// `channels-call`); the `ChannelsAdapter` hands the resulting
|
||||
@@ -530,6 +604,7 @@ impl ChannelManager {
|
||||
// Dropping `state.demux_sender` signals EOF to the
|
||||
// handler's read half (REQ-CH-02).
|
||||
drop(state.demux_sender);
|
||||
self.remove_control_handle(channel_id);
|
||||
Ok(state.handler_task)
|
||||
}
|
||||
None => Err(ManagerError::UnknownChannel(channel_id)),
|
||||
@@ -550,6 +625,7 @@ impl ChannelManager {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
self.inner.control_handles.lock().clear();
|
||||
// The early-arrival buffers die with the connection too.
|
||||
self.inner.early_arrivals.lock().clear();
|
||||
// The opener ledger has the opener PeerIds.
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
//! as amended by ADR-051): translate hop + `pump_bidi` byte-forward
|
||||
//! hop for from_call-imported marked specs re-exposed on a hub's
|
||||
//! consumer-leg channel-0 registry.
|
||||
//! - [`resource`]: `ResourceEnumerators` — the enumerator registry
|
||||
//! behind `channel/resources/subscribe`'s live subscription
|
||||
//! (ADR-053).
|
||||
//! - [`hub_leg`]: `HubLegTemplate` — the hub-leg install template
|
||||
//! (ADR-051 §5): fork + generic channel ops + bootstrap discovery +
|
||||
//! plain bundles + relay openables + serving identity, composed into
|
||||
@@ -53,5 +56,6 @@ pub mod policy;
|
||||
pub mod pump;
|
||||
pub mod reassembly;
|
||||
pub mod relay;
|
||||
pub mod resource;
|
||||
pub mod source;
|
||||
pub mod wire;
|
||||
+778
-225
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,477 @@
|
||||
//! The marked-pub open contract gates (review 011 §3; ADR-047
|
||||
//! amendment 4). The put path end-to-end over a real channels
|
||||
//! connection: response merge order, the digest-in-result wire
|
||||
//! contract, publish-chunk rejection, establishment failure
|
||||
//! (teardown + ledger + EOF-discarded racing bytes), the reserved-key
|
||||
//! rejections, and adopt parity.
|
||||
|
||||
use crate::channels::adapter::ChannelsAdapter;
|
||||
use crate::channels::client::ChannelClient;
|
||||
use crate::channels::manager::ChannelManager;
|
||||
use crate::channels::operations::{ChannelCore, Establishment, OpenEstablisher, PubOpenHandler};
|
||||
use crate::channels::policy::NoCap;
|
||||
use crate::core::auth::{AuthContext, Identity, IdentityProvider};
|
||||
use crate::core::types::Connection;
|
||||
use crate::protocol::connection::{split_single_stream, CallConnection};
|
||||
use crate::protocol::dispatch::Dispatcher;
|
||||
use crate::protocol::wire::CallError;
|
||||
use crate::registry::discovery::install_bootstrap_discovery;
|
||||
use crate::registry::registration::OperationRegistry;
|
||||
use crate::registry::spec::{
|
||||
AccessControl, ChannelOpenSpec, OperationSpec, OperationType, Visibility,
|
||||
RESERVED_OPEN_INPUT_KEY,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream};
|
||||
|
||||
const TEST_ADDR: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321);
|
||||
const PUT_OP: &str = "channels/blobs/put";
|
||||
|
||||
struct NoopIdProvider;
|
||||
impl IdentityProvider for NoopIdProvider {
|
||||
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_from_token(&self, _: &crate::core::auth::AuthToken) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn pub_put_spec() -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
PUT_OP,
|
||||
OperationType::Pub,
|
||||
Visibility::External,
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": { "path": { "type": "string" } },
|
||||
"required": ["path"]
|
||||
}),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel_id": { "type": "integer" },
|
||||
"token": { "type": "string" },
|
||||
"digest": { "type": "string" },
|
||||
"priority": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
)
|
||||
.with_channel_open(ChannelOpenSpec::new("alk/blobs"))
|
||||
}
|
||||
|
||||
/// The put handler: consume the adopted channel's data plane to EOF,
|
||||
/// return `{ digest, priority }` — the unknown-length put's wire
|
||||
/// contract (the result computed from the streamed bytes). The
|
||||
/// `priority` field exists to pin the merge order (the handler's
|
||||
/// result wins the collision; ADR-047 amendment 4 §4.4).
|
||||
fn digest_handler() -> PubOpenHandler {
|
||||
Arc::new(move |_input, _plan, channel_conn, _auth| {
|
||||
Box::pin(async move {
|
||||
let mut bidi = channel_conn
|
||||
.accept_bi()
|
||||
.await
|
||||
.map_err(|_| CallError::internal("put handler: the data plane never opened"))?;
|
||||
let mut buf = Vec::new();
|
||||
bidi.read_to_end(&mut buf)
|
||||
.await
|
||||
.map_err(|e| CallError::internal(format!("put handler read failed: {e}")))?;
|
||||
Ok(json!({
|
||||
"digest": format!("len-{}", buf.len()),
|
||||
"priority": "handler",
|
||||
}))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The establisher: contributes an informative-in-result ack field
|
||||
/// (`token`) and its own `priority` (the ack — which the handler's
|
||||
/// result beats on collision).
|
||||
fn token_establisher() -> OpenEstablisher {
|
||||
Arc::new(|_input: Value, _auth| {
|
||||
Box::pin(async {
|
||||
Ok(Establishment::default().with_reply_field("token", json!("tok-1")))
|
||||
.map(|e: Establishment| e.with_reply_field("priority", json!("ack")))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the put-serving side over the duplex's server end: register
|
||||
/// the marked pub openable (with the given establisher + handler) and
|
||||
/// run the channel-0 dispatch loop. Returns the captured server
|
||||
/// manager (the ledger/teardown observability point).
|
||||
async fn serve_put_side(
|
||||
server_end: DuplexStream,
|
||||
establisher: OpenEstablisher,
|
||||
handler: PubOpenHandler,
|
||||
) -> Arc<tokio::sync::Mutex<Option<ChannelManager>>> {
|
||||
let captured = Arc::new(tokio::sync::Mutex::<Option<ChannelManager>>::new(None));
|
||||
let captured_for_hook = Arc::clone(&captured);
|
||||
let install_hook: crate::channels::adapter::InstallChannelZero =
|
||||
Arc::new(move |manager, channel0_conn, auth| {
|
||||
let establisher = establisher.clone();
|
||||
let handler = Arc::clone(&handler);
|
||||
let manager = manager.clone();
|
||||
let captured = Arc::clone(&captured_for_hook);
|
||||
tokio::spawn(async move {
|
||||
let _ = captured.lock().await.replace(manager.clone());
|
||||
let Ok(channel0_bidi) = channel0_conn.accept_bi().await else {
|
||||
return;
|
||||
};
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
let core = ChannelCore::new(manager, Arc::new(NoCap));
|
||||
let registry = Arc::new(OperationRegistry::new());
|
||||
install_bootstrap_discovery(®istry).expect("bootstrap discovery");
|
||||
core.register_pub_openable(
|
||||
pub_put_spec(),
|
||||
Some(establisher),
|
||||
handler,
|
||||
®istry,
|
||||
auth.clone(),
|
||||
None,
|
||||
)
|
||||
.expect("register the marked pub openable");
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
|
||||
let call_connection = Arc::new(CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
Arc::clone(&writer),
|
||||
));
|
||||
Dispatcher::new(registry, provider)
|
||||
.run_loop_single_stream(call_connection, reader, writer)
|
||||
.await;
|
||||
})
|
||||
});
|
||||
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
|
||||
let server_conn = Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let auth = AuthContext::anonymous(b"alk/channels");
|
||||
tokio::spawn(async move {
|
||||
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
|
||||
});
|
||||
captured
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_e2e_merge_order_and_digest() {
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let _captured = serve_put_side(server_end, token_establisher(), digest_handler()).await;
|
||||
|
||||
let client = ChannelClient::from_connection(client_conn)
|
||||
.await
|
||||
.expect("channel client init");
|
||||
|
||||
{
|
||||
// The known-length shape: offer + bytes → one response whose
|
||||
// merge order is `{channel_id}` → ack fields → handler result.
|
||||
let (_id, mut send, _recv, reply) = client
|
||||
.open_pub_channel(PUT_OP, json!({ "path": "b/one" }), "alk/blobs")
|
||||
.await
|
||||
.expect("open_pub_channel");
|
||||
send.write_all(b"hello").await.expect("stream the bytes");
|
||||
drop(send);
|
||||
|
||||
let merged = reply
|
||||
.resolve()
|
||||
.await
|
||||
.expect("the put resolves with the merged response");
|
||||
let _ = _id;
|
||||
assert_eq!(
|
||||
merged["token"],
|
||||
json!("tok-1"),
|
||||
"the ack field rides the result"
|
||||
);
|
||||
assert_eq!(
|
||||
merged["digest"],
|
||||
json!("len-5"),
|
||||
"the handler result rides the result"
|
||||
);
|
||||
assert_eq!(
|
||||
merged["priority"],
|
||||
json!("handler"),
|
||||
"merge order: handler result wins the collision with the ack (§4.4)"
|
||||
);
|
||||
assert!(
|
||||
merged["channel_id"].as_u64().is_some(),
|
||||
"channel_id reserved"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
// The unknown-length shape: larger than the reassembly buffer
|
||||
// (crosses chunk boundaries); the handler's digest is computed
|
||||
// over the streamed bytes.
|
||||
let (_id2, send2, _recv2, reply2) = client
|
||||
.open_pub_channel(PUT_OP, json!({ "path": "b/two" }), "alk/blobs")
|
||||
.await
|
||||
.expect("open_pub_channel");
|
||||
let mut send2 = send2;
|
||||
let chunk = vec![0xAB_u8; 4096];
|
||||
for _ in 0..8 {
|
||||
send2.write_all(&chunk).await.expect("stream a chunk");
|
||||
}
|
||||
drop(send2);
|
||||
|
||||
let merged2 = reply2.resolve().await.expect("unknown-length put");
|
||||
assert_eq!(merged2["digest"], json!("len-32768"));
|
||||
assert_eq!(merged2["token"], json!("tok-1"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The happy-path put's ids: the putter allocates odd (its side is
|
||||
/// Connect); the responder adopts; the reply carries the putter's id.
|
||||
#[tokio::test]
|
||||
async fn put_e2e_client_parity_is_odd() {
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let _captured = serve_put_side(server_end, token_establisher(), digest_handler()).await;
|
||||
|
||||
let client = ChannelClient::from_connection(client_conn)
|
||||
.await
|
||||
.expect("client");
|
||||
let (id, mut send, _recv, reply) = client
|
||||
.open_pub_channel(PUT_OP, json!({ "path": "b/odd" }), "alk/blobs")
|
||||
.await
|
||||
.expect("open_pub_channel");
|
||||
send.write_all(b"zz").await.expect("stream");
|
||||
drop(send);
|
||||
let merged = reply.resolve().await.expect("put");
|
||||
assert_eq!(
|
||||
merged["channel_id"],
|
||||
json!(id),
|
||||
"the reply carries the putter's id"
|
||||
);
|
||||
assert_eq!(id % 2, 1, "the connect side allocates odd (ADR-047 §5)");
|
||||
}
|
||||
|
||||
/// Publish-chunk rejection (§4.1): a marked op fed via the call-plane
|
||||
/// publish path rejects loudly in the initiator's direction and the
|
||||
/// handler never consumes a chunk.
|
||||
#[tokio::test]
|
||||
async fn put_rejects_publish_chunks_loudly() {
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let _captured = serve_put_side(server_end, token_establisher(), digest_handler()).await;
|
||||
|
||||
let client = ChannelClient::from_connection(client_conn)
|
||||
.await
|
||||
.expect("client");
|
||||
let conn = Arc::clone(&client.take_call_connection().await.expect("call conn"));
|
||||
|
||||
let chunks = Box::pin(futures::stream::iter(vec![
|
||||
json!({ "chunk": 1 }),
|
||||
json!({ "chunk": 2 }),
|
||||
]));
|
||||
// The reserved key is present (this client misuses the wire
|
||||
// contract — the open resolves, the chunks are what get rejected).
|
||||
let response = conn
|
||||
.publish_with_payload(
|
||||
json!({ "operationId": PUT_OP, "input": { "path": "b/violating", RESERVED_OPEN_INPUT_KEY: 3 } }),
|
||||
chunks,
|
||||
)
|
||||
.await;
|
||||
let e = response.result.expect_err("chunks rejected loudly");
|
||||
assert_eq!(e.code, crate::registry::spec::MARKED_PUB_CHUNKS_REJECTED);
|
||||
// The put side's own single response may also arrive (the initiator
|
||||
// already resolved with the rejection); the rejection is what the
|
||||
// pending call carries — assert it was NOT a success-shaped result
|
||||
// (done above by expect_err).
|
||||
let _ = conn;
|
||||
}
|
||||
|
||||
/// Establishment failure (ADR-049 §3 through the pub path): the reply
|
||||
/// is `channel:open_failed` with the mapped reason; the channel is
|
||||
/// torn down on both sides; racing bytes EOF-discard (the write
|
||||
/// completes locally before the failure reply — the adopt entry is
|
||||
/// gone afterward, ledger un-incremented).
|
||||
#[tokio::test]
|
||||
async fn put_establishment_failure_replies_open_failed_and_tears_down() {
|
||||
use crate::channels::operations::EstablishmentError;
|
||||
|
||||
let failing: OpenEstablisher = Arc::new(|_input, _auth| {
|
||||
Box::pin(async {
|
||||
Err(EstablishmentError::UnknownResource {
|
||||
message: "container not found".to_string(),
|
||||
})
|
||||
})
|
||||
});
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let captured = serve_put_side(server_end, failing, digest_handler()).await;
|
||||
|
||||
let client = ChannelClient::from_connection(client_conn)
|
||||
.await
|
||||
.expect("client");
|
||||
let (id, mut send, _recv, reply) = client
|
||||
.open_pub_channel(PUT_OP, json!({ "path": "b/gone" }), "alk/blobs")
|
||||
.await
|
||||
.expect("open_pub_channel");
|
||||
// Racing bytes: written before the failure reply arrives — the
|
||||
// responder never adopted, so the bytes park and drop (EOF-shaped
|
||||
// for the putter).
|
||||
let _ = send.write_all(b"racing").await;
|
||||
drop(send);
|
||||
|
||||
let error = reply
|
||||
.resolve()
|
||||
.await
|
||||
.expect_err("the put resolves with the establishment failure");
|
||||
assert_eq!(
|
||||
error.establishment_reason(),
|
||||
Some("unknown_resource"),
|
||||
"the spoke establishment reason maps through (ADR-049 §3)"
|
||||
);
|
||||
assert_eq!(
|
||||
error.call_error().map(|e| e.code.as_str()),
|
||||
Some("channel:open_failed")
|
||||
);
|
||||
|
||||
// The responder's side: no channel lingers (open_failed tears
|
||||
// down; nothing was adopted anyway — but the client-side
|
||||
// open-failure teardown removed the allocator-side entry).
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
let manager = captured.lock().await.clone().expect("server manager");
|
||||
assert!(
|
||||
!manager.has_channel(id),
|
||||
"a failed establishment leaves no channel (ADR-047 amendment 4 §4.4)"
|
||||
);
|
||||
assert_eq!(
|
||||
manager.opener_ledger().take(id),
|
||||
None,
|
||||
"ledger un-incremented"
|
||||
);
|
||||
}
|
||||
|
||||
/// A marked pub op whose input lacks the reserved `channel_id` key is
|
||||
/// rejected (`INVALID_INPUT`) — the initiator-allocated id is
|
||||
/// mandatory (ADR-047 amendment 4 §4.3).
|
||||
#[tokio::test]
|
||||
async fn put_missing_reserved_key_is_rejected() {
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let _captured = serve_put_side(server_end, token_establisher(), digest_handler()).await;
|
||||
|
||||
let client = ChannelClient::from_connection(client_conn)
|
||||
.await
|
||||
.expect("client");
|
||||
let response = client
|
||||
.call_open_op(PUT_OP, json!({ "path": "b/no-id" }))
|
||||
.await;
|
||||
let e = response.result.expect_err("the reserved key is mandatory");
|
||||
assert_eq!(e.code, "INVALID_INPUT");
|
||||
assert!(
|
||||
e.message.contains(RESERVED_OPEN_INPUT_KEY),
|
||||
"the error names the key: {}",
|
||||
e.message
|
||||
);
|
||||
}
|
||||
|
||||
/// Adopt parity (§4.4 + ADR-047 §5): a reserved id inside the
|
||||
/// responder's OWN allocation range (even — an id the accept side
|
||||
/// would allocate itself, violating the split) fails the open with
|
||||
/// `adopt_failed`; racing bytes are discarded.
|
||||
#[tokio::test]
|
||||
async fn put_out_of_range_reserved_id_replies_adopt_failed() {
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let _captured = serve_put_side(server_end, token_establisher(), digest_handler()).await;
|
||||
|
||||
let client = ChannelClient::from_connection(client_conn)
|
||||
.await
|
||||
.expect("client");
|
||||
let response = client
|
||||
.call_open_op(
|
||||
PUT_OP,
|
||||
json!({ "path": "b/bad", RESERVED_OPEN_INPUT_KEY: 2 }),
|
||||
)
|
||||
.await;
|
||||
let e = response
|
||||
.result
|
||||
.expect_err("the responder refuses its own range");
|
||||
assert_eq!(e.code, "channel:open_failed");
|
||||
assert_eq!(
|
||||
e.details
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("reason"))
|
||||
.and_then(|r| r.as_str()),
|
||||
Some("adopt_failed"),
|
||||
"adoption failure → adopt_failed (ADR-047 amendment 4 §4.4): {e:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The responder-side put handler that returns an error: the single
|
||||
/// response IS that error (the wrapper passthrough; §4.4).
|
||||
#[tokio::test]
|
||||
async fn put_handler_error_becomes_the_response() {
|
||||
let failing_handler: PubOpenHandler = Arc::new(|_input, _plan, channel_conn, _auth| {
|
||||
Box::pin(async move {
|
||||
let mut bidi = channel_conn
|
||||
.accept_bi()
|
||||
.await
|
||||
.map_err(|_| CallError::internal("data plane"))?;
|
||||
let mut buf = [0u8; 3];
|
||||
let _ = bidi.read_exact(&mut buf).await;
|
||||
Err(CallError::new(
|
||||
"blobs:read_only",
|
||||
"the container is read-only",
|
||||
false,
|
||||
))
|
||||
})
|
||||
});
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let _captured = serve_put_side(server_end, token_establisher(), failing_handler).await;
|
||||
|
||||
let client = ChannelClient::from_connection(client_conn)
|
||||
.await
|
||||
.expect("client");
|
||||
let (_id, mut send, _recv, reply) = client
|
||||
.open_pub_channel(PUT_OP, json!({ "path": "b/ro" }), "alk/blobs")
|
||||
.await
|
||||
.expect("open_pub_channel");
|
||||
send.write_all(b"abc").await.expect("stream");
|
||||
drop(send);
|
||||
|
||||
let error = reply
|
||||
.resolve()
|
||||
.await
|
||||
.expect_err("the handler error IS the response");
|
||||
assert_eq!(
|
||||
error.call_error().map(|e| e.code.clone()),
|
||||
Some("blobs:read_only".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
/// A non-object input to `open_pub_channel` is rejected INVALID_INPUT
|
||||
/// before any wire traffic — and the locally allocated channel id is
|
||||
/// released (teardown) so the slot is not parked until connection
|
||||
/// close (the 256-per-connection bound, ADR-040).
|
||||
#[tokio::test]
|
||||
async fn open_pub_channel_invalid_input_releases_the_allocated_id() {
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let _captured = serve_put_side(server_end, token_establisher(), digest_handler()).await;
|
||||
|
||||
let client = ChannelClient::from_connection(client_conn)
|
||||
.await
|
||||
.expect("client");
|
||||
let before = client.manager().open_count();
|
||||
assert!(before >= 1, "channel 0 is installed at client init");
|
||||
let error = match client.open_pub_channel(PUT_OP, json!(7), "alk/blobs").await {
|
||||
Ok(_) => panic!("a non-object input is rejected"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(
|
||||
error.call_error().map(|e| e.code.as_str()),
|
||||
Some("INVALID_INPUT")
|
||||
);
|
||||
assert_eq!(
|
||||
client.manager().open_count(),
|
||||
before,
|
||||
"the allocated id is not parked on the early-return path"
|
||||
);
|
||||
}
|
||||
+215
-19
@@ -35,11 +35,13 @@ use crate::registry::spec::{OperationSpec, OperationType};
|
||||
|
||||
use super::manager::ChannelManager;
|
||||
use super::operations::{
|
||||
ChannelCore, Establishment, EstablishmentError, OpenEstablisher, OpenHandler,
|
||||
CHANNEL_OPEN_FAILED, RESERVED_REPLY_KEY,
|
||||
ChannelCore, ChannelPlan, Establishment, EstablishmentError, OpenEstablisher, OpenHandler,
|
||||
PubOpenHandler, CHANNEL_OPEN_FAILED, RESERVED_REPLY_KEY,
|
||||
};
|
||||
use super::pump::pump_bidi;
|
||||
|
||||
use super::reassembly::{MpscRecvStream, MpscSendStream};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
/// The producer-leg surface the relay holds (ADR-051 §2): the
|
||||
/// `CallConnection` for the translate hop's open call plus the
|
||||
@@ -81,8 +83,17 @@ struct RelayPlan {
|
||||
producer_manager: ChannelManager,
|
||||
spoke_send: Option<MpscSendStream>,
|
||||
spoke_recv: Option<MpscRecvStream>,
|
||||
/// The put path (ADR-047 amendment 4 §4.7): the producer leg's
|
||||
/// open-op response, awaited by the relay pub handler after the
|
||||
/// consumer EOF (put-acks-in-result — the spoke's response cannot
|
||||
/// exist before its byte stream completes, so the establisher
|
||||
/// never awaits it).
|
||||
pub_reply: Option<tokio::sync::oneshot::Receiver<CallConnectionResponse>>,
|
||||
}
|
||||
|
||||
/// The producer leg's open-op response, carried on the put path's plan.
|
||||
type CallConnectionResponse = crate::protocol::wire::ResponseEnvelope;
|
||||
|
||||
impl Drop for RelayPlan {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.producer_manager.teardown_channel(self.spoke_id);
|
||||
@@ -120,9 +131,11 @@ impl ChannelRelay {
|
||||
/// produced open op.
|
||||
/// - `spec` must carry the `channel_open` marker (reconstructed
|
||||
/// WITH the marker by discovery, ADR-047 amendment 3). A
|
||||
/// `Pub`-typed marked spec is a loud assembly error
|
||||
/// ([`RelayError::PubTypedOpen`] — the C-08 blocker, ADR-051
|
||||
/// §6); an unmarked spec is likewise an error — branch on the
|
||||
/// `Pub`-typed marked spec composes through the put-forwarding
|
||||
/// translation (`register_relay_pub_openable`; ADR-047 amendment 4
|
||||
/// §4.7); an
|
||||
/// unmarked spec is a
|
||||
/// [`RelayError::UnmarkedSpec`] error — branch on the
|
||||
/// marker before calling.
|
||||
/// - The translate hop calls the producer leg with the spec's own
|
||||
/// op name; a hub that re-names the consumer-visible op composes
|
||||
@@ -145,14 +158,12 @@ impl ChannelRelay {
|
||||
});
|
||||
}
|
||||
if spec.op_type == OperationType::Pub {
|
||||
return Err(RelayError::PubTypedOpen {
|
||||
message: format!(
|
||||
"spec `{}` is a Pub-typed channel-open op — the Pub open path is \
|
||||
not implemented (C-08 blocker, `channel:pub_open_not_implemented` \
|
||||
class; ADR-051 §6)",
|
||||
spec.name
|
||||
),
|
||||
});
|
||||
return self.register_relay_pub_openable(
|
||||
consumer_core,
|
||||
consumer_registry,
|
||||
spec,
|
||||
consumer_auth,
|
||||
);
|
||||
}
|
||||
|
||||
let producer_call = Arc::clone(&self.producer_leg.call);
|
||||
@@ -227,6 +238,76 @@ impl ChannelRelay {
|
||||
)
|
||||
.map_err(|e| RelayError::Registration { message: e })
|
||||
}
|
||||
|
||||
/// Register a from_call-imported **marked `Pub`** spec as a relay
|
||||
/// openable — the put-forwarding translation (ADR-047 amendment 4
|
||||
/// §4.7). The consumer leg runs the full marked-pub wrapper (adopt
|
||||
/// → establish → pub handler awaited inline); this side's compose:
|
||||
///
|
||||
/// - the establisher allocates the hub's connect-side id on the
|
||||
/// producer leg (`open_channel` — an odd id, disjoint from the
|
||||
/// spoke's even allocations), sends the open op **in the
|
||||
/// background** (the spoke's put response cannot exist before
|
||||
/// its byte stream completes; an establisher that awaited it
|
||||
/// deadlocks the leg), and plans the spoke-leg streams + the
|
||||
/// response's oneshot; the plan's Drop guard reclaims the
|
||||
/// producer-leg entry on every handler path, as ADR-051 §6 pins.
|
||||
/// - the handler pumps the consumer-leg bytes → the spoke leg
|
||||
/// (one direction, pub), EOFs the spoke side on consumer EOF,
|
||||
/// then awaits the spoke's single response, strips the
|
||||
/// producer's reserved reply key (the consumer reply carries 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 — the consumer-leg
|
||||
/// wrapper merges it with the pinned order (ack fields are none
|
||||
/// here; the put acks in the spoke's result, §4.5).
|
||||
///
|
||||
/// A spoke open-time establishment failure surfaces verbatim in
|
||||
/// the final response (put-acks-in-result; the spoke's mapped
|
||||
/// `channel:open_failed` rides the response, not the establisher).
|
||||
fn register_relay_pub_openable(
|
||||
&self,
|
||||
consumer_core: &ChannelCore,
|
||||
consumer_registry: &OperationRegistry,
|
||||
spec: OperationSpec,
|
||||
consumer_auth: AuthContext,
|
||||
) -> Result<(), RelayError> {
|
||||
let producer_call = Arc::clone(&self.producer_leg.call);
|
||||
let producer_manager = self.producer_leg.manager.clone();
|
||||
let op_name = spec.name.clone();
|
||||
|
||||
let establisher: OpenEstablisher = Arc::new(move |input, per_call_auth| {
|
||||
let producer_call = Arc::clone(&producer_call);
|
||||
let producer_manager = producer_manager.clone();
|
||||
let op_name = op_name.clone();
|
||||
Box::pin(async move {
|
||||
open_pub_on_producer_leg(
|
||||
&producer_call,
|
||||
&producer_manager,
|
||||
&op_name,
|
||||
input,
|
||||
&per_call_auth,
|
||||
)
|
||||
.await
|
||||
})
|
||||
});
|
||||
|
||||
let pub_open_handler: PubOpenHandler =
|
||||
Arc::new(move |_input, plan, channel_conn, _auth| {
|
||||
Box::pin(async move { pub_relay_forward(channel_conn, plan).await })
|
||||
});
|
||||
|
||||
consumer_core
|
||||
.register_pub_openable(
|
||||
spec,
|
||||
Some(establisher),
|
||||
pub_open_handler,
|
||||
consumer_registry,
|
||||
consumer_auth,
|
||||
None,
|
||||
)
|
||||
.map_err(|e| RelayError::Registration { message: e })
|
||||
}
|
||||
}
|
||||
|
||||
/// The translate hop (ADR-051 §1 layer 1, §2): call the producer leg's
|
||||
@@ -288,6 +369,7 @@ async fn open_on_producer_leg(
|
||||
producer_manager: producer_manager.clone(),
|
||||
spoke_send: Some(spoke_send),
|
||||
spoke_recv: Some(spoke_recv),
|
||||
pub_reply: None,
|
||||
}) as super::operations::ChannelPlan);
|
||||
Ok(if reply_fields.is_empty() {
|
||||
establishment
|
||||
@@ -334,6 +416,123 @@ fn map_spoke_error(spoke_error: &CallError) -> EstablishmentError {
|
||||
}
|
||||
}
|
||||
|
||||
/// The put path's translate hop (ADR-047 amendment 4 §4.7): allocate
|
||||
/// the hub's connect-side channel id on the producer leg, send the
|
||||
/// spoke's open op **in the background** with the reserved input key
|
||||
/// set, and plan the spoke-leg streams + the response oneshot. The
|
||||
/// establisher returns promptly; the spoke's response arrives when the
|
||||
/// spoke's put completes (or fails) — put-acks-in-result.
|
||||
async fn open_pub_on_producer_leg(
|
||||
producer_call: &Arc<CallConnection>,
|
||||
producer_manager: &ChannelManager,
|
||||
op_name: &str,
|
||||
input: Value,
|
||||
per_call_auth: &AuthContext,
|
||||
) -> Result<Establishment, EstablishmentError> {
|
||||
let (spoke_id, spoke_send, spoke_recv) = producer_manager
|
||||
.open_channel(
|
||||
"alk/relay",
|
||||
// The opener ledger is inert on the hub's connect side
|
||||
// (the connect side does not enforce the per-identity cap,
|
||||
// ADR-041) — the record is observability only.
|
||||
"hub-relay",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| EstablishmentError::ResourceShortage {
|
||||
message: format!("producer leg: channel allocation failed: {e}"),
|
||||
})?;
|
||||
|
||||
let mut hop_input = input.clone();
|
||||
if let Some(obj) = hop_input.as_object_mut() {
|
||||
obj.insert(
|
||||
crate::registry::spec::RESERVED_OPEN_INPUT_KEY.to_string(),
|
||||
serde_json::json!(spoke_id),
|
||||
);
|
||||
}
|
||||
let payload = build_relay_payload(op_name, hop_input, per_call_auth);
|
||||
|
||||
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
|
||||
let producer_call_task = Arc::clone(producer_call);
|
||||
tokio::spawn(async move {
|
||||
let _ = reply_tx.send(producer_call_task.call_with_payload(payload).await);
|
||||
});
|
||||
|
||||
Ok(Establishment::new(Arc::new(RelayPlan {
|
||||
spoke_id,
|
||||
producer_manager: producer_manager.clone(),
|
||||
spoke_send: Some(spoke_send),
|
||||
spoke_recv: Some(spoke_recv),
|
||||
pub_reply: Some(reply_rx),
|
||||
}) as ChannelPlan))
|
||||
}
|
||||
|
||||
/// The relay pub handler (ADR-047 amendment 4 §4.7): pump the
|
||||
/// consumer-leg bytes → the spoke leg (one direction, pub — the
|
||||
/// shutdown-on-completion contract, ADR-050's), then await the spoke's
|
||||
/// single response and strip its reserved reply key. The plan's Drop
|
||||
/// guard reclaims the producer-leg entry on every path.
|
||||
async fn pub_relay_forward(
|
||||
channel_conn: crate::core::types::Connection,
|
||||
plan: Option<ChannelPlan>,
|
||||
) -> Result<Value, CallError> {
|
||||
let Some(plan) = plan else {
|
||||
return Err(CallError::internal(
|
||||
"relay: pub establisher produced no plan",
|
||||
));
|
||||
};
|
||||
let Ok(relay_plan_arc) = plan.downcast::<RelayPlan>() else {
|
||||
return Err(CallError::internal(
|
||||
"relay: establisher plan of unexpected type",
|
||||
));
|
||||
};
|
||||
let Ok(mut relay_plan) = Arc::try_unwrap(relay_plan_arc) else {
|
||||
return Err(CallError::internal(
|
||||
"relay: establisher plan held elsewhere; skipping pump",
|
||||
));
|
||||
};
|
||||
let Some(mut spoke_send) = relay_plan.spoke_send.take() else {
|
||||
return Err(CallError::internal(
|
||||
"relay: pub plan missing the spoke send half",
|
||||
));
|
||||
};
|
||||
relay_plan.spoke_recv.take();
|
||||
|
||||
let Ok(bidi) = channel_conn.accept_bi().await else {
|
||||
return Err(CallError::internal(
|
||||
"relay: consumer leg data plane never opened",
|
||||
));
|
||||
};
|
||||
let (mut read_half, _write_half) = tokio::io::split(bidi);
|
||||
// The one-direction pump: consumer bytes → spoke; shutdown on
|
||||
// consumer EOF (the pump contract, ADR-050).
|
||||
let _copied = tokio::io::copy(&mut read_half, &mut spoke_send)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let _ = spoke_send.shutdown().await;
|
||||
drop(spoke_send);
|
||||
|
||||
match relay_plan.pub_reply.take() {
|
||||
Some(rx) => match rx.await {
|
||||
Ok(response) => match response.result {
|
||||
Ok(mut out) => {
|
||||
if let Some(obj) = out.as_object_mut() {
|
||||
obj.remove(RESERVED_REPLY_KEY);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
Err(_) => Err(CallError::internal(
|
||||
"relay: producer-leg open resolved without a response",
|
||||
)),
|
||||
},
|
||||
None => Err(CallError::internal(
|
||||
"relay: pub plan missing the response receiver",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the `call.requested` payload for the translate hop: the hub
|
||||
/// as caller, the consumer as `forwarded_for` (ADR-026 §3, ADR-032 §3
|
||||
/// — the same shape `build_forwarded_payload` produces for the plain
|
||||
@@ -357,18 +556,15 @@ fn build_relay_payload(op_name: &str, input: Value, per_call_auth: &AuthContext)
|
||||
|
||||
/// The assembly-time errors `ChannelRelay::register_relay_openable`
|
||||
/// produces (ADR-051 §6 — the rejection postures are loud, never
|
||||
/// silent stubs).
|
||||
/// silent stubs; the `PubTypedOpen` variant was removed by ADR-047
|
||||
/// amendment 4 §4.7 — pub-typed marked specs translate now, never
|
||||
/// reject-and-strand).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum RelayError {
|
||||
/// The spec carries no `channel_open` marker — the relay only
|
||||
/// registers marked specs.
|
||||
#[error("unmarked spec: {message}")]
|
||||
UnmarkedSpec { message: String },
|
||||
/// A `Pub`-typed marked spec — the Pub open path is not
|
||||
/// implemented (C-08 blocker; `channel:pub_open_not_implemented`
|
||||
/// class).
|
||||
#[error("pub-typed open op: {message}")]
|
||||
PubTypedOpen { message: String },
|
||||
/// The consumer-leg registration itself failed (schema compile,
|
||||
/// handler-kind mismatch).
|
||||
#[error("registration failed: {message}")]
|
||||
|
||||
+25
-11
@@ -89,15 +89,21 @@ async fn unmarked_spec_is_rejected_at_registration() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pub_typed_marked_spec_is_rejected_at_registration() {
|
||||
async fn pub_typed_marked_spec_registers_through_the_relay() {
|
||||
// ADR-047 amendment 4 §4.7: the put-forwarding translation replaced
|
||||
// the `PubTypedOpen` rejection — a pub-typed marked spec composes
|
||||
// through `register_pub_openable` (the full marked-pub wrapper
|
||||
// machinery) and lands on the consumer-leg registry, discoverable.
|
||||
let producer_manager = manager_for_test();
|
||||
let relay = ChannelRelay::new(ProducerLeg {
|
||||
call: overlay_connection(),
|
||||
manager: manager_for_test(),
|
||||
manager: producer_manager,
|
||||
});
|
||||
let err = relay
|
||||
let registry = OperationRegistry::new();
|
||||
relay
|
||||
.register_relay_openable(
|
||||
&core_for_test(),
|
||||
&OperationRegistry::new(),
|
||||
®istry,
|
||||
relay_spec(
|
||||
"channels/x/pub",
|
||||
OperationType::Pub,
|
||||
@@ -105,12 +111,10 @@ async fn pub_typed_marked_spec_is_rejected_at_registration() {
|
||||
),
|
||||
AuthContext::anonymous(b"alk/call"),
|
||||
)
|
||||
.expect_err("Pub-typed marked spec must be rejected");
|
||||
assert!(
|
||||
matches!(err, RelayError::PubTypedOpen { .. }),
|
||||
"got {err:?}"
|
||||
);
|
||||
assert!(err.to_string().contains("pub_open_not_implemented"));
|
||||
.expect("the pub openable registers through the relay");
|
||||
assert!(registry.registration("channels/x/pub").is_some());
|
||||
let registration = registry.registration("channels/x/pub").expect("registered");
|
||||
assert!(registration.spec.channel_open.is_some());
|
||||
}
|
||||
|
||||
// --- ADR-051 §3: the reason mapping --------------------------------------
|
||||
@@ -228,6 +232,7 @@ async fn relay_plan_drop_reclaims_the_adopted_producer_leg_entry() {
|
||||
producer_manager: producer_manager.clone(),
|
||||
spoke_send: Some(spoke_send),
|
||||
spoke_recv: Some(spoke_recv),
|
||||
pub_reply: None,
|
||||
};
|
||||
drop(plan);
|
||||
|
||||
@@ -256,7 +261,16 @@ fn manager_for_test() -> ChannelManager {
|
||||
tokio::spawn(async move {
|
||||
let _ = runner.run().await;
|
||||
});
|
||||
ChannelManager::with_defaults(handle, None)
|
||||
// The producer leg's manager belongs to the hub, which is the
|
||||
// connect side of the hub↔spoke channels connection (ADR-051 §4 —
|
||||
// the spoke's ids are even, the hub's odd).
|
||||
ChannelManager::new(
|
||||
handle,
|
||||
crate::channels::manager::DEFAULT_MAX_CHANNELS,
|
||||
crate::channels::reassembly::DEFAULT_BUFFER_CAP,
|
||||
None,
|
||||
crate::channels::manager::ChannelSide::Connect,
|
||||
)
|
||||
}
|
||||
|
||||
fn core_for_test() -> ChannelCore {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! `ResourceEnumerators` — the registry behind
|
||||
//! `channel/resources/subscribe`'s live subscription (ADR-053).
|
||||
//!
|
||||
//! Each openable-ALPN crate registers one enumerator (its resource
|
||||
//! truth: which containers are running, which sessions are active);
|
||||
//! the subscribe handler aggregates them. The channels layer never
|
||||
//! interprets the payloads — they ride opaquely, merged with the
|
||||
//! producing ALPN's name (ADR-053 §1).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::Stream;
|
||||
use parking_lot::RwLock;
|
||||
use serde_json::Value;
|
||||
|
||||
/// A registered enumerator's item: `(alpn, payload)` — the payload is
|
||||
/// the ALPN's full current resource state (snapshot-then-changes,
|
||||
/// ADR-053 §1). The channels layer does not interpret it.
|
||||
pub type ResourceItem = (String, Value);
|
||||
|
||||
/// A registered enumerator's merged stream.
|
||||
pub type ResourceStream = Pin<Box<dyn Stream<Item = ResourceItem> + Send>>;
|
||||
|
||||
/// The resource enumerator contract (ADR-053 §1). The producing crate
|
||||
/// owns its change detection; the first item on the returned stream is
|
||||
/// the current snapshot, and each subsequent item is a full snapshot
|
||||
/// of the changed set.
|
||||
pub trait ResourceEnumerator: Send + Sync + 'static {
|
||||
fn subscribe(&self) -> ResourceStream;
|
||||
}
|
||||
|
||||
/// The registry of ALPN resource enumerators (ADR-053 §2). Shared per
|
||||
/// accepting peer (like `PerIdentityChannelPolicy` — ADR-041): one
|
||||
/// `Arc<ResourceEnumerators>` across every channels connection that
|
||||
/// peer accepts. Registered at assembly/install time; the subscribe
|
||||
/// handler scans it once at subscription start (the scan-once pin —
|
||||
/// late registrations are invisible to in-flight subscriptions;
|
||||
/// re-subscribe to see them).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ResourceEnumerators {
|
||||
inner: Arc<RwLock<HashMap<String, Arc<dyn ResourceEnumerator>>>>,
|
||||
}
|
||||
|
||||
impl ResourceEnumerators {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Registered at assembly/install time by the composing crate.
|
||||
pub fn register(&self, alpn: &str, enumerator: Arc<dyn ResourceEnumerator>) {
|
||||
self.inner.write().insert(alpn.to_string(), enumerator);
|
||||
}
|
||||
|
||||
/// The registered ALPNs (observability).
|
||||
pub fn alpns(&self) -> Vec<String> {
|
||||
self.inner.read().keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Snapshot the registered set (the subscribe handler's scan-once
|
||||
/// read; ADR-053 §2).
|
||||
pub(crate) fn snapshot(&self) -> Vec<(String, Arc<dyn ResourceEnumerator>)> {
|
||||
self.inner
|
||||
.read()
|
||||
.iter()
|
||||
.map(|(alpn, enumerator)| (alpn.clone(), Arc::clone(enumerator)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -1244,7 +1244,9 @@ mod tests {
|
||||
/// `from_call` against a stub `CallConnection` (no real transport) returns
|
||||
/// a `DiscoveryFailed` because `services/list` can't dispatch on a mock
|
||||
/// connection. This verifies the error path rather than the happy path
|
||||
/// (the happy path is covered by the integration test in a later task).
|
||||
/// (the happy path is covered by the loopback integration tests
|
||||
/// in `src/channels/relay_tests.rs`, `src/channels/hub_leg_tests.rs`,
|
||||
/// and `src/channels/gate2_tests.rs`).
|
||||
#[tokio::test]
|
||||
async fn from_call_against_mock_connection_returns_discovery_failed() {
|
||||
let conn = CallConnection::new(stub_connection());
|
||||
|
||||
+5
-2
@@ -13,14 +13,17 @@ pub use from_call::{from_call, FromCallConfig};
|
||||
// crate-internal surface for the `op/register` bootstrap path (review
|
||||
// 004 F-05): the forwarding-handler constructor and the spec wire
|
||||
// parser are shared with `registry::op_register`.
|
||||
pub(crate) use from_call::{make_forwarding_handler, rebuild_spec_for};
|
||||
pub(crate) use from_call::{
|
||||
make_forwarding_handler, make_sink_forwarding_handler, make_streaming_forwarding_handler,
|
||||
rebuild_spec_for,
|
||||
};
|
||||
|
||||
use crate::registry::registration::HandlerRegistration;
|
||||
|
||||
/// Errors produced by [`OperationAdapter::import`].
|
||||
///
|
||||
/// The variant set is the v1 default (two-way-door remainder, OQ-26);
|
||||
/// `#[non_exhaustive]` lets downstream adapters (e.g. `alknet-http`'s
|
||||
/// `#[non_exhaustive]` lets downstream adapters (e.g. `alkhttp`'s
|
||||
/// `from_openapi`/`from_mcp`) extend without breaking match arms. All
|
||||
/// payloads are string messages — kept simple and `Send + Sync` by
|
||||
/// construction.
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ pub struct Capabilities {
|
||||
|
||||
impl Zeroize for Capabilities {
|
||||
fn zeroize(&mut self) {
|
||||
for (_, v) in self.entries.iter_mut() {
|
||||
for v in self.entries.values_mut() {
|
||||
v.zeroize();
|
||||
}
|
||||
self.entries.clear();
|
||||
|
||||
@@ -538,7 +538,7 @@ mod tests {
|
||||
None,
|
||||
Capabilities::new(),
|
||||
);
|
||||
conn.register_imported(imported);
|
||||
let _error = conn.register_imported(imported);
|
||||
let peer_identity = Identity {
|
||||
id: "worker-a".to_string(),
|
||||
scopes: vec![],
|
||||
@@ -607,7 +607,7 @@ mod tests {
|
||||
None,
|
||||
Capabilities::new(),
|
||||
);
|
||||
conn.register_imported(imported);
|
||||
let _error = conn.register_imported(imported);
|
||||
|
||||
let context =
|
||||
adapter.build_root_context("req-6".to_string(), "fs/readFile", None, None, &conn);
|
||||
|
||||
+52
-14
@@ -159,9 +159,27 @@ impl CallConnection {
|
||||
&self.pending
|
||||
}
|
||||
|
||||
pub fn register_imported(&self, registration: HandlerRegistration) {
|
||||
/// Land a peer-announced/discovered bundle on the connection
|
||||
/// overlay. Marked (`channel_open`) specs refuse here: a marked op
|
||||
/// is unreachable by composition (`OperationEnv::invoke` is
|
||||
/// request/response-only; ADR-046 §6) and its data plane has no
|
||||
/// overlay path — registration-time loudness, never an advertised
|
||||
/// dead body (ADR-047 amendment 4 §4.8; ADR-022's 2026-10-13
|
||||
/// amendment). Relay composition
|
||||
/// (`ChannelRelay::register_relay_openable`) is the sanctioned
|
||||
/// marked-op import.
|
||||
pub fn register_imported(&self, registration: HandlerRegistration) -> Result<(), String> {
|
||||
if registration.spec.channel_open.is_some() {
|
||||
return Err(format!(
|
||||
"register_imported: spec `{}` carries the `channel_open` marker — marked \
|
||||
specs are not overlay-importable; compose `ChannelRelay` or consume via \
|
||||
`ChannelClient` (ADR-047 amendment 4 §4.8)",
|
||||
registration.spec.name
|
||||
));
|
||||
}
|
||||
let name = registration.spec.name.clone();
|
||||
self.imported_operations.write().insert(name, registration);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `true` when the connection overlay holds a registration for
|
||||
@@ -176,12 +194,31 @@ impl CallConnection {
|
||||
self.imported_operations.read().get(name).cloned()
|
||||
}
|
||||
|
||||
pub fn register_imported_all(&self, registrations: Vec<HandlerRegistration>) {
|
||||
/// Install a bundle of remote announcements on the connection
|
||||
/// overlay. Atomic: if any registration carries the `channel_open`
|
||||
/// marker, the overlay is left untouched (nothing partially
|
||||
/// installed).
|
||||
pub fn register_imported_all(
|
||||
&self,
|
||||
registrations: Vec<HandlerRegistration>,
|
||||
) -> Result<(), String> {
|
||||
let mut overlay = self.imported_operations.write();
|
||||
for reg in registrations {
|
||||
overlay.insert(reg.spec.name.clone(), reg);
|
||||
for reg in ®istrations {
|
||||
if reg.spec.channel_open.is_some() {
|
||||
return Err(format!(
|
||||
"register_imported_all: spec `{}` carries the `channel_open` marker — \
|
||||
marked specs are not overlay-importable; compose `ChannelRelay` or \
|
||||
consume via `ChannelClient` (ADR-047 amendment 4 §4.8)",
|
||||
reg.spec.name
|
||||
));
|
||||
}
|
||||
}
|
||||
for reg in registrations {
|
||||
let name = reg.spec.name.clone();
|
||||
overlay.insert(name, reg);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn overlay_env(&self) -> Arc<dyn OperationEnv + Send + Sync> {
|
||||
Arc::new(OverlayOperationEnv {
|
||||
@@ -975,7 +1012,7 @@ mod tests {
|
||||
|
||||
assert!(!env.contains("worker/exec"));
|
||||
|
||||
conn.register_imported(imported_registration("worker/exec"));
|
||||
let _error = conn.register_imported(imported_registration("worker/exec"));
|
||||
|
||||
assert!(env.contains("worker/exec"));
|
||||
assert!(!env.contains("worker/missing"));
|
||||
@@ -986,11 +1023,12 @@ mod tests {
|
||||
let conn = CallConnection::new(stub_connection());
|
||||
let env = conn.overlay_env();
|
||||
|
||||
conn.register_imported_all(vec![
|
||||
let registrations_result = conn.register_imported_all(vec![
|
||||
imported_registration("worker/exec"),
|
||||
imported_registration("worker/status"),
|
||||
imported_registration("fs/readFile"),
|
||||
]);
|
||||
let _error = registrations_result;
|
||||
|
||||
assert!(env.contains("worker/exec"));
|
||||
assert!(env.contains("worker/status"));
|
||||
@@ -1001,7 +1039,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn overlay_env_dispatches_to_imported_op() {
|
||||
let conn = CallConnection::new(stub_connection());
|
||||
conn.register_imported(imported_registration("worker/exec"));
|
||||
let _error = conn.register_imported(imported_registration("worker/exec"));
|
||||
let env = conn.overlay_env();
|
||||
|
||||
let scoped = ScopedPeerEnv::new(["worker/exec"]);
|
||||
@@ -1018,7 +1056,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn overlay_env_contains_returns_false_for_non_imported_op() {
|
||||
let conn = CallConnection::new(stub_connection());
|
||||
conn.register_imported(imported_registration("worker/exec"));
|
||||
let _error = conn.register_imported(imported_registration("worker/exec"));
|
||||
let env = conn.overlay_env();
|
||||
|
||||
assert!(!env.contains("worker/missing"));
|
||||
@@ -1039,7 +1077,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn overlay_env_reachability_check_returns_not_found_for_disallowed_op() {
|
||||
let conn = CallConnection::new(stub_connection());
|
||||
conn.register_imported(imported_registration("worker/exec"));
|
||||
let _error = conn.register_imported(imported_registration("worker/exec"));
|
||||
let env = conn.overlay_env();
|
||||
|
||||
let scoped = ScopedPeerEnv::empty();
|
||||
@@ -1069,7 +1107,7 @@ mod tests {
|
||||
}),
|
||||
)
|
||||
});
|
||||
conn.register_imported(HandlerRegistration::new(
|
||||
let _error = conn.register_imported(HandlerRegistration::new(
|
||||
external_spec("worker/exec"),
|
||||
HandlerKind::Once(inspect_handler),
|
||||
OperationProvenance::FromCall,
|
||||
@@ -1115,7 +1153,7 @@ mod tests {
|
||||
Arc::new(RwLock::new(HashMap::new()));
|
||||
{
|
||||
let conn = CallConnection::new(stub_connection());
|
||||
conn.register_imported(imported_registration("worker/exec"));
|
||||
let _error = conn.register_imported(imported_registration("worker/exec"));
|
||||
assert!(conn.overlay_env().contains("worker/exec"));
|
||||
std::mem::swap(
|
||||
&mut *captured.write(),
|
||||
@@ -1380,14 +1418,14 @@ mod tests {
|
||||
let conn = CallConnection::new_overlay_only(sample_identity("ws-peer"));
|
||||
let env = conn.overlay_env();
|
||||
assert!(!env.contains("worker/exec"));
|
||||
conn.register_imported(imported_registration("worker/exec"));
|
||||
let _error = conn.register_imported(imported_registration("worker/exec"));
|
||||
assert!(env.contains("worker/exec"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overlay_only_overlay_env_dispatches_imported_op() {
|
||||
let conn = CallConnection::new_overlay_only(sample_identity("ws-peer"));
|
||||
conn.register_imported(imported_registration("worker/exec"));
|
||||
let _error = conn.register_imported(imported_registration("worker/exec"));
|
||||
let env = conn.overlay_env();
|
||||
|
||||
let scoped = ScopedPeerEnv::new(["worker/exec"]);
|
||||
@@ -1433,7 +1471,7 @@ mod tests {
|
||||
let streaming_handler = make_streaming_handler(|input, ctx| {
|
||||
futures::stream::iter(vec![ResponseEnvelope::ok(ctx.request_id, input)])
|
||||
});
|
||||
conn.register_imported(HandlerRegistration::new(
|
||||
let _error = conn.register_imported(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"events/stream",
|
||||
OperationType::Sub,
|
||||
|
||||
+110
-2
@@ -86,6 +86,11 @@ pub struct SinkDispatch {
|
||||
pub(crate) handler: Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>,
|
||||
pub(crate) chunk_tx: mpsc::Sender<Result<Value, CallError>>,
|
||||
pub(crate) publish_validator: Option<jsonschema::Validator>,
|
||||
/// Marked (`channel_open`) `Pub` op (ADR-047 amendment 4 §4.1):
|
||||
/// publish chunks never feed the handler — the first
|
||||
/// `call.published` chunk is rejected loudly in the initiator's
|
||||
/// direction and the rest are ignored.
|
||||
pub(crate) reject_publish: bool,
|
||||
}
|
||||
|
||||
/// An in-flight Pub (Sink) on the single-stream dispatch loop
|
||||
@@ -100,6 +105,11 @@ struct InFlightSink {
|
||||
chunk_tx: mpsc::Sender<Result<Value, CallError>>,
|
||||
publish_validator: Option<jsonschema::Validator>,
|
||||
handler_handle: JoinHandle<()>,
|
||||
/// Marked (`channel_open`) `Pub` op (ADR-047 amendment 4 §4.1):
|
||||
/// the first `call.published` chunk is rejected loudly in the
|
||||
/// initiator's direction and never fed to the handler.
|
||||
reject_publish: bool,
|
||||
reject_written: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DispatchResult {
|
||||
@@ -356,7 +366,7 @@ impl Dispatcher {
|
||||
.get("forwarded_for")
|
||||
.and_then(|v| serde_json::from_value::<Identity>(v.clone()).ok());
|
||||
|
||||
let input = payload.get("input").cloned().unwrap_or(Value::Null);
|
||||
let mut input = payload.get("input").cloned().unwrap_or(Value::Null);
|
||||
|
||||
let op_type = self
|
||||
.registry
|
||||
@@ -391,9 +401,13 @@ impl Dispatcher {
|
||||
context.deadline = None;
|
||||
match self
|
||||
.registry
|
||||
.resolve_sink_handler(&operation_name, &input, &context)
|
||||
.resolve_sink_handler(&operation_name, &mut input, &mut context)
|
||||
{
|
||||
Ok(sink_handler) => {
|
||||
let reject_publish = self
|
||||
.registry
|
||||
.registration(&operation_name)
|
||||
.is_some_and(|r| r.spec.channel_open.is_some());
|
||||
let publish_validator = self.registry.publish_validator(&operation_name);
|
||||
let (chunk_tx, chunk_rx) =
|
||||
mpsc::channel::<Result<Value, CallError>>(PUBLISH_CHANNEL_BUFFER);
|
||||
@@ -403,6 +417,7 @@ impl Dispatcher {
|
||||
handler: Box::pin(handler),
|
||||
chunk_tx,
|
||||
publish_validator,
|
||||
reject_publish,
|
||||
})
|
||||
}
|
||||
Err(envelope) => StartedDispatch::Once(Box::pin(std::future::ready(envelope))),
|
||||
@@ -657,6 +672,7 @@ impl Dispatcher {
|
||||
handler,
|
||||
mut chunk_tx,
|
||||
publish_validator,
|
||||
reject_publish,
|
||||
} = sink;
|
||||
|
||||
let (abort_tx, mut abort_rx) = oneshot::channel::<()>();
|
||||
@@ -668,6 +684,7 @@ impl Dispatcher {
|
||||
let mut handler = handler.fuse();
|
||||
let mut response: Option<ResponseEnvelope> = None;
|
||||
let mut aborted = false;
|
||||
let mut reject_written = false;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -698,6 +715,31 @@ impl Dispatcher {
|
||||
}
|
||||
match envelope.r#type.as_str() {
|
||||
EVENT_PUBLISHED => {
|
||||
// Marked ops: the data plane is the adopted
|
||||
// channel's `BiStream` — publish chunks are
|
||||
// rejected loudly, never consumed (ADR-047
|
||||
// amendment 4 §4.1). The handler continues on
|
||||
// the channel plane; the initiator's pending
|
||||
// call resolves with this rejection.
|
||||
if reject_publish {
|
||||
if !reject_written {
|
||||
reject_written = true;
|
||||
let rejection: EventEnvelope = ResponseEnvelope::error(
|
||||
request_id.clone(),
|
||||
CallError::new(
|
||||
crate::registry::spec::MARKED_PUB_CHUNKS_REJECTED,
|
||||
"the data plane of a marked pub op is the adopted \
|
||||
channel's BiStream — call.published chunks are \
|
||||
rejected (ADR-047 amendment 4 §4.1)",
|
||||
false,
|
||||
),
|
||||
).into();
|
||||
if let Err(err) = writer.write_frame(&rejection).await {
|
||||
warn!(error = %err, request_id = %request_id, "failed to write marked-op publish rejection");
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let chunk = envelope
|
||||
.payload
|
||||
.get("input")
|
||||
@@ -923,6 +965,7 @@ impl Dispatcher {
|
||||
handler,
|
||||
chunk_tx,
|
||||
publish_validator,
|
||||
reject_publish,
|
||||
} = sink;
|
||||
let handle = Self::spawn_sink_response_writer(
|
||||
&writer,
|
||||
@@ -935,6 +978,8 @@ impl Dispatcher {
|
||||
chunk_tx,
|
||||
publish_validator,
|
||||
handler_handle: handle,
|
||||
reject_publish,
|
||||
reject_written: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -977,6 +1022,36 @@ impl Dispatcher {
|
||||
.unwrap_or(Value::Null);
|
||||
let entry = in_flight_sinks.lock().remove(&request_id);
|
||||
if let Some(mut entry) = entry {
|
||||
if entry.reject_publish {
|
||||
// Marked ops: the data plane is the adopted
|
||||
// channel's `BiStream` — publish chunks are
|
||||
// rejected loudly, never consumed (ADR-047
|
||||
// amendment 4 §4.1). The loud error is written
|
||||
// once per request; the entry stays registered
|
||||
// so the handler's own final response still gets
|
||||
// written. The handler continues on the channel
|
||||
// plane; the initiator's pending call resolves
|
||||
// with this rejection immediately.
|
||||
if !entry.reject_written {
|
||||
entry.reject_written = true;
|
||||
let rejection: EventEnvelope = ResponseEnvelope::error(
|
||||
request_id.clone(),
|
||||
CallError::new(
|
||||
crate::registry::spec::MARKED_PUB_CHUNKS_REJECTED,
|
||||
"the data plane of a marked pub op is the \
|
||||
adopted channel's BiStream — call.published \
|
||||
chunks are rejected (ADR-047 amendment 4 §4.1)",
|
||||
false,
|
||||
),
|
||||
)
|
||||
.into();
|
||||
if let Err(err) = writer.write_frame(&rejection).await {
|
||||
warn!(error = %err, request_id = %request_id, "failed to write marked-op publish rejection");
|
||||
}
|
||||
}
|
||||
in_flight_sinks.lock().insert(request_id, entry);
|
||||
continue;
|
||||
}
|
||||
let validated = match &entry.publish_validator {
|
||||
Some(validator) => {
|
||||
if validator.is_valid(&chunk) {
|
||||
@@ -1146,6 +1221,7 @@ impl Dispatcher {
|
||||
handler,
|
||||
chunk_tx,
|
||||
publish_validator,
|
||||
reject_publish,
|
||||
} = sink;
|
||||
let handle = Self::spawn_sink_response_writer(
|
||||
&writer,
|
||||
@@ -1158,6 +1234,8 @@ impl Dispatcher {
|
||||
chunk_tx,
|
||||
publish_validator,
|
||||
handler_handle: handle,
|
||||
reject_publish,
|
||||
reject_written: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1200,6 +1278,36 @@ impl Dispatcher {
|
||||
.unwrap_or(Value::Null);
|
||||
let entry = in_flight_sinks.lock().remove(&request_id);
|
||||
if let Some(mut entry) = entry {
|
||||
if entry.reject_publish {
|
||||
// Marked ops: the data plane is the adopted
|
||||
// channel's `BiStream` — publish chunks are
|
||||
// rejected loudly, never consumed (ADR-047
|
||||
// amendment 4 §4.1). The loud error is written
|
||||
// once per request; the entry stays registered
|
||||
// so the handler's own final response still gets
|
||||
// written. The handler continues on the channel
|
||||
// plane; the initiator's pending call resolves
|
||||
// with this rejection immediately.
|
||||
if !entry.reject_written {
|
||||
entry.reject_written = true;
|
||||
let rejection: EventEnvelope = ResponseEnvelope::error(
|
||||
request_id.clone(),
|
||||
CallError::new(
|
||||
crate::registry::spec::MARKED_PUB_CHUNKS_REJECTED,
|
||||
"the data plane of a marked pub op is the \
|
||||
adopted channel's BiStream — call.published \
|
||||
chunks are rejected (ADR-047 amendment 4 §4.1)",
|
||||
false,
|
||||
),
|
||||
)
|
||||
.into();
|
||||
if let Err(err) = writer.write_frame(&rejection).await {
|
||||
warn!(error = %err, request_id = %request_id, "failed to write marked-op publish rejection");
|
||||
}
|
||||
}
|
||||
in_flight_sinks.lock().insert(request_id, entry);
|
||||
continue;
|
||||
}
|
||||
let validated = match &entry.publish_validator {
|
||||
Some(validator) => {
|
||||
if validator.is_valid(&chunk) {
|
||||
|
||||
+150
-21
@@ -178,16 +178,37 @@ pub fn op_register_handler(
|
||||
}
|
||||
|
||||
let mut spec = request.spec;
|
||||
if spec.channel_open.is_some() {
|
||||
return ResponseEnvelope::error(
|
||||
context.request_id,
|
||||
CallError::new(
|
||||
"MARKED_SPEC_UNSUPPORTED",
|
||||
format!(
|
||||
"op/register: `{}` carries the `channel_open` marker — marked ops \
|
||||
are channel-machinery ops (the data plane is the allocated \
|
||||
channel's `BiStream`, ADR-047 amendment 4); forward them with \
|
||||
`ChannelRelay::register_relay_openable` (a relaying hub) or \
|
||||
consume them directly (`ChannelClient`), never as an \
|
||||
announcement",
|
||||
spec.name
|
||||
),
|
||||
false,
|
||||
),
|
||||
);
|
||||
}
|
||||
spec.visibility = Visibility::Internal;
|
||||
let remote_name = spec.name.clone();
|
||||
|
||||
let handler = forwarding_stub_for_announced_op(
|
||||
let handler = forwarding_kind_for_announced_op(
|
||||
Arc::clone(&connection),
|
||||
remote_name,
|
||||
spec.op_type,
|
||||
);
|
||||
|
||||
connection.register_imported(HandlerRegistration::new(
|
||||
// The marked-refusal arm is unreachable here (marked
|
||||
// announcements were refused above); an invariant break
|
||||
// maps to the wire rather than a panic.
|
||||
let registered = connection.register_imported(HandlerRegistration::new(
|
||||
spec.clone(),
|
||||
handler,
|
||||
OperationProvenance::FromCall,
|
||||
@@ -195,6 +216,12 @@ pub fn op_register_handler(
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
if let Err(e) = registered {
|
||||
return ResponseEnvelope::error(
|
||||
context.request_id,
|
||||
CallError::internal(format!("op/register: overlay landing refused: {e}")),
|
||||
);
|
||||
}
|
||||
|
||||
ResponseEnvelope::ok(
|
||||
context.request_id,
|
||||
@@ -204,15 +231,14 @@ pub fn op_register_handler(
|
||||
})
|
||||
}
|
||||
|
||||
/// The forwarding stub for a peer-announced op. Query/Mutation ops get
|
||||
/// the `from_call` forwarding shape (nested `call.requested` back over
|
||||
/// channel 0, `forwarded_for` populated per ADR-032 §3). Announced
|
||||
/// Sub/Pub ops are registered as stubs that return
|
||||
/// `INVALID_OPERATION_TYPE` on invocation — nested composition is
|
||||
/// request/response-only (`OverlayOperationEnv`'s contract); the
|
||||
/// streaming/sink forwarding shapes ride on the `from_call` import
|
||||
/// path, which the announcing side can use in the other direction.
|
||||
fn forwarding_stub_for_announced_op(
|
||||
/// The forwarding handler kind for a peer-announced op. Query/Mutation
|
||||
/// get the `from_call` forwarding shape (nested `call.requested` back
|
||||
/// over channel 0, `forwarded_for` populated per ADR-032 §3); unmarked
|
||||
/// `Sub`/`Pub` get the real streaming/sink forwarding shapes (review
|
||||
/// 010 S-04's remedy: never accept-then-fail — the handler actually
|
||||
/// forwards). Marked specs refuse at this boundary (ADR-047 amendment
|
||||
/// 4 — checked before this function is reached).
|
||||
fn forwarding_kind_for_announced_op(
|
||||
connection: Arc<CallConnection>,
|
||||
remote_name: String,
|
||||
op_type: OperationType,
|
||||
@@ -221,17 +247,13 @@ fn forwarding_stub_for_announced_op(
|
||||
OperationType::Query | OperationType::Mutation => HandlerKind::Once(
|
||||
crate::client::make_forwarding_handler(connection, remote_name),
|
||||
),
|
||||
OperationType::Sub | OperationType::Pub => {
|
||||
HandlerKind::Once(make_handler(|_input, context| async move {
|
||||
ResponseEnvelope::error(
|
||||
context.request_id,
|
||||
CallError::invalid_operation_type(
|
||||
"peer-announced Sub/Pub ops are not invocable over nested \
|
||||
composition (request/response only)",
|
||||
OperationType::Sub => HandlerKind::Stream(
|
||||
crate::client::make_streaming_forwarding_handler(connection, remote_name),
|
||||
),
|
||||
)
|
||||
}))
|
||||
}
|
||||
OperationType::Pub => HandlerKind::Sink(crate::client::make_sink_forwarding_handler(
|
||||
connection,
|
||||
remote_name,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,4 +779,111 @@ mod tests {
|
||||
.expect("marker survives the announce");
|
||||
assert_eq!(marker.alpn, "alk/tunnel");
|
||||
}
|
||||
|
||||
// --- review 010 S-04's remedy (ADR-022, 2026-10-13 amendment) --------
|
||||
|
||||
/// Forward-through: an announced unmarked `Sub` op registers the
|
||||
/// real streaming forwarding shape, not an always-failing stub.
|
||||
#[tokio::test]
|
||||
async fn announced_unmarked_sub_registers_real_streaming_forward() {
|
||||
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||
let handler = op_register_handler(Arc::clone(&conn), Arc::new(OperationRegistry::new()));
|
||||
|
||||
let input = OpRegisterRequest {
|
||||
spec: OperationSpec::new(
|
||||
"worker/events",
|
||||
OperationType::Sub,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
),
|
||||
replace: false,
|
||||
}
|
||||
.to_json();
|
||||
let response = handler(input, test_context("req-s04-1")).await;
|
||||
assert!(response.result.is_ok(), "got {:?}", response.result);
|
||||
|
||||
let registration = conn
|
||||
.overlay_registration("worker/events")
|
||||
.expect("registered");
|
||||
assert!(
|
||||
matches!(registration.handler, HandlerKind::Stream(_)),
|
||||
"the announced sub op is a real forwarding handler, not a stub"
|
||||
);
|
||||
}
|
||||
|
||||
/// Forward-through: an announced unmarked `Pub` op registers the
|
||||
/// real sink forwarding shape.
|
||||
#[tokio::test]
|
||||
async fn announced_unmarked_pub_registers_real_sink_forward() {
|
||||
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||
let handler = op_register_handler(Arc::clone(&conn), Arc::new(OperationRegistry::new()));
|
||||
|
||||
let input = OpRegisterRequest {
|
||||
spec: OperationSpec::new(
|
||||
"worker/ingest",
|
||||
OperationType::Pub,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
),
|
||||
replace: false,
|
||||
}
|
||||
.to_json();
|
||||
let response = handler(input, test_context("req-s04-2")).await;
|
||||
assert!(response.result.is_ok(), "got {:?}", response.result);
|
||||
|
||||
let registration = conn
|
||||
.overlay_registration("worker/ingest")
|
||||
.expect("registered");
|
||||
assert!(
|
||||
matches!(registration.handler, HandlerKind::Sink(_)),
|
||||
"the announced pub op is a real forwarding handler, not a stub"
|
||||
);
|
||||
}
|
||||
|
||||
/// Marked-spec refusal (ADR-047 amendment 4 §4.8's wire side): a
|
||||
/// marked announcement is rejected with the typed code, no overlay
|
||||
/// entry, and the message names the sanctioned compositions.
|
||||
#[tokio::test]
|
||||
async fn announced_marked_spec_is_refused() {
|
||||
use crate::registry::spec::ChannelOpenSpec;
|
||||
|
||||
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||
let handler = op_register_handler(Arc::clone(&conn), Arc::new(OperationRegistry::new()));
|
||||
|
||||
let input = OpRegisterRequest {
|
||||
spec: OperationSpec::new(
|
||||
"channels/tunnel/direct",
|
||||
OperationType::Sub,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
)
|
||||
.with_channel_open(ChannelOpenSpec::new("alk/tunnel")),
|
||||
replace: false,
|
||||
}
|
||||
.to_json();
|
||||
let response = handler(input, test_context("req-s04-3")).await;
|
||||
let e = response.result.expect_err("marked announcement refused");
|
||||
assert_eq!(e.code, "MARKED_SPEC_UNSUPPORTED");
|
||||
assert!(
|
||||
e.message.contains("ChannelRelay"),
|
||||
"the refusal names the sanctioned composition: {}",
|
||||
e.message
|
||||
);
|
||||
assert!(
|
||||
!conn.overlay_contains("channels/tunnel/direct"),
|
||||
"the refused announcement lands nowhere"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,12 @@ pub struct HandlerRegistration {
|
||||
pub composition_authority: Option<CompositionAuthority>,
|
||||
pub scoped_env: Option<ScopedPeerEnv>,
|
||||
pub capabilities: Capabilities,
|
||||
/// Set only by the channels-layer wrapper builders — a marked
|
||||
/// (`channel_open`) spec is registered only through the
|
||||
/// channel-machinery seam (`register_openable*`, `register_pub_openable`,
|
||||
/// the relay). Generic registrations of marked specs are refused
|
||||
/// (ADR-047 amendment 4 §4.8 — the registered-but-incapable class).
|
||||
pub(crate) channel_wrapper: bool,
|
||||
}
|
||||
|
||||
impl HandlerRegistration {
|
||||
@@ -102,8 +108,17 @@ impl HandlerRegistration {
|
||||
composition_authority,
|
||||
scoped_env,
|
||||
capabilities,
|
||||
channel_wrapper: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The channel-machinery wrapper family's constructor seam
|
||||
/// (crate-internal — the only sanctioned registrations of marked
|
||||
/// specs).
|
||||
pub(crate) fn with_channel_wrapper(mut self) -> Self {
|
||||
self.channel_wrapper = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OperationRegistry {
|
||||
@@ -215,6 +230,23 @@ impl OperationRegistry {
|
||||
registration.spec.op_type, expected, actual
|
||||
));
|
||||
}
|
||||
if registration.spec.channel_open.is_some() && !registration.channel_wrapper {
|
||||
return Err(format!(
|
||||
"spec `{}` carries the `channel_open` marker — marked ops are registered \
|
||||
only through the channel-machinery seam (`ChannelCore::register_openable*`, \
|
||||
`ChannelCore::register_pub_openable`, `ChannelRelay::register_relay_openable`); \
|
||||
a generic registration cannot serve the marker's data plane (ADR-047 amendment 4 §4.8)",
|
||||
registration.spec.name
|
||||
));
|
||||
}
|
||||
if registration.spec.channel_open.is_some() && registration.spec.publish_schema.is_some() {
|
||||
return Err(format!(
|
||||
"spec `{}` carries both the `channel_open` marker and a `publish_schema` — \
|
||||
publish chunks never exist on a marked op (the data plane is the adopted \
|
||||
channel's `BiStream`); the field is dead machinery here (ADR-047 amendment 4 §4.2)",
|
||||
registration.spec.name
|
||||
));
|
||||
}
|
||||
Self::validate_and_cache_publish_schema(&mut self.validators.write(), ®istration.spec)?;
|
||||
Self::validate_and_cache_input_schema(
|
||||
&mut self.input_validators.write(),
|
||||
@@ -436,12 +468,21 @@ impl OperationRegistry {
|
||||
/// dispatch path (`Dispatcher::dispatch` Pub branch) call it, so the
|
||||
/// two paths cannot diverge (P-08: the `invoke_sink` path IS the
|
||||
/// wire path).
|
||||
///
|
||||
/// Marked `Pub` ops (ADR-047 amendment 4 §4.3): the
|
||||
/// wrapper-reserved input key ([`crate::registry::spec::
|
||||
/// RESERVED_OPEN_INPUT_KEY`]) is stripped **before**
|
||||
/// `input_schema` validation — the producing crate's schema never
|
||||
/// contains it — and the initiator-allocated channel id is handed
|
||||
/// to the wrapper via `context.metadata`. A marked pub op whose
|
||||
/// input lacks the reserved key is rejected here (`INVALID_INPUT`)
|
||||
/// — the id is mandatory, not optional plumbing.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub(crate) fn resolve_sink_handler(
|
||||
&self,
|
||||
name: &str,
|
||||
input: &Value,
|
||||
context: &OperationContext,
|
||||
input: &mut Value,
|
||||
context: &mut OperationContext,
|
||||
) -> Result<SinkHandler, ResponseEnvelope> {
|
||||
let request_id = context.request_id.clone();
|
||||
let registration = match self.registration(name) {
|
||||
@@ -477,6 +518,43 @@ impl OperationRegistry {
|
||||
return Err(ResponseEnvelope::forbidden(request_id, message));
|
||||
}
|
||||
|
||||
if registration.spec.channel_open.is_some()
|
||||
&& registration.spec.op_type == OperationType::Pub
|
||||
{
|
||||
let reserved_id = match input
|
||||
.as_object_mut()
|
||||
.and_then(|obj| obj.remove(crate::registry::spec::RESERVED_OPEN_INPUT_KEY))
|
||||
{
|
||||
Some(value) => match value.as_u64() {
|
||||
Some(id) if id <= u32::MAX as u64 => id as u32,
|
||||
_ => {
|
||||
return Err(ResponseEnvelope::error(
|
||||
request_id,
|
||||
CallError::invalid_input(format!(
|
||||
"marked pub open op: the reserved `{}` key must be a u32 \
|
||||
channel id (the initiator-allocated id; ADR-047 amendment 4 §4.3)",
|
||||
crate::registry::spec::RESERVED_OPEN_INPUT_KEY
|
||||
)),
|
||||
));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
return Err(ResponseEnvelope::error(
|
||||
request_id,
|
||||
CallError::invalid_input(format!(
|
||||
"marked pub open op input missing the reserved `{}` key \
|
||||
(the initiator-allocated channel id; ADR-047 amendment 4 §4.3)",
|
||||
crate::registry::spec::RESERVED_OPEN_INPUT_KEY
|
||||
)),
|
||||
));
|
||||
}
|
||||
};
|
||||
context.metadata.insert(
|
||||
crate::registry::spec::RESERVED_OPEN_METADATA_KEY.to_string(),
|
||||
serde_json::json!(reserved_id),
|
||||
);
|
||||
}
|
||||
|
||||
self.check_input_schema(®istration, input, &request_id)?;
|
||||
|
||||
match ®istration.handler {
|
||||
@@ -505,14 +583,16 @@ impl OperationRegistry {
|
||||
pub async fn invoke_sink(
|
||||
&self,
|
||||
name: &str,
|
||||
input: Value,
|
||||
mut input: Value,
|
||||
publish_stream: PublishStream,
|
||||
context: OperationContext,
|
||||
mut context: OperationContext,
|
||||
) -> ResponseEnvelope {
|
||||
match self.resolve_sink_handler(name, &input, &context) {
|
||||
Err(envelope) => envelope,
|
||||
Ok(sink_handler) => sink_handler(input, context, publish_stream).await,
|
||||
}
|
||||
let publish_stream_resolved =
|
||||
match self.resolve_sink_handler(name, &mut input, &mut context) {
|
||||
Err(envelope) => return envelope,
|
||||
Ok(sink_handler) => sink_handler,
|
||||
};
|
||||
publish_stream_resolved(input, context, publish_stream).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2942,3 +3022,85 @@ mod tests {
|
||||
assert_eq!(err.code, "INVALID_INPUT");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod marked_spec_gates {
|
||||
use super::*;
|
||||
|
||||
fn marked_spec(name: &str, op_type: OperationType) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
name,
|
||||
op_type,
|
||||
crate::registry::spec::Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
crate::registry::spec::AccessControl::default(),
|
||||
None,
|
||||
)
|
||||
.with_channel_open(crate::registry::spec::ChannelOpenSpec::new("alk/x"))
|
||||
}
|
||||
|
||||
/// ADR-047 amendment 4 §4.2 gate: `publish_schema` on a marked
|
||||
/// spec refuses at registration — publish chunks never exist on a
|
||||
/// marked op. (Registered through the sanctioned wrapper seam, so
|
||||
/// the §4.8 invariant is satisfied and this refusal is the one
|
||||
/// that fires.)
|
||||
#[test]
|
||||
fn marked_spec_with_publish_schema_refused() {
|
||||
let registry = OperationRegistry::new();
|
||||
let spec = marked_spec("channels/x/pub", OperationType::Pub)
|
||||
.with_publish_schema(serde_json::json!({ "type": "object" }));
|
||||
let registration = HandlerRegistration::new(
|
||||
spec,
|
||||
HandlerKind::Sink(make_sink_handler(|_input, _ctx, _stream| async {
|
||||
ResponseEnvelope::ok("r", serde_json::json!({}))
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
crate::core::types::Capabilities::new(),
|
||||
)
|
||||
.with_channel_wrapper();
|
||||
let e = registry.register(registration).expect_err("refused");
|
||||
assert!(e.contains("publish_schema"), "got: {e}");
|
||||
}
|
||||
|
||||
/// ADR-047 amendment 4 §4.8 gate: a marked spec through the
|
||||
/// generic registration path is refused — the registered-but-
|
||||
/// incapable class review 010 outlaws; the sanctioned seam is the
|
||||
/// channel wrapper family.
|
||||
#[test]
|
||||
fn marked_spec_refused_outside_the_wrapper_seam() {
|
||||
let registry = OperationRegistry::new();
|
||||
let spec = marked_spec("channels/x/pub", OperationType::Pub);
|
||||
let registration = HandlerRegistration::new(
|
||||
spec,
|
||||
HandlerKind::Sink(make_sink_handler(|_input, _ctx, _stream| async {
|
||||
ResponseEnvelope::ok("r", serde_json::json!({}))
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
crate::core::types::Capabilities::new(),
|
||||
);
|
||||
let e = registry.register(registration).expect_err("refused");
|
||||
assert!(e.contains("channel-machinery seam"), "got: {e}");
|
||||
|
||||
// A marked `Sub` spec — the same class.
|
||||
let registry2 = OperationRegistry::new();
|
||||
let spec2 = marked_spec("channels/x/sub", OperationType::Sub);
|
||||
let registration2 = HandlerRegistration::new(
|
||||
spec2,
|
||||
HandlerKind::Stream(make_streaming_handler(|_input, _ctx| {
|
||||
Box::pin(futures::stream::empty())
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
crate::core::types::Capabilities::new(),
|
||||
);
|
||||
let e2 = registry2.register(registration2).expect_err("refused");
|
||||
assert!(e2.contains("channel-machinery seam"), "got: {e2}");
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,28 @@ pub struct ChannelOpenSpec {
|
||||
pub alpn: Cow<'static, str>,
|
||||
}
|
||||
|
||||
/// The wrapper-reserved input key on a **marked `Pub` op's** input
|
||||
/// (ADR-047 amendment 4 §4.3): the initiator-allocated `channel_id` the
|
||||
/// responder adopts. The same key the open wrapper owns on the reply
|
||||
/// side (the channels layer's `RESERVED_REPLY_KEY`); the wrapper owns
|
||||
/// `channel_id` on both sides of a marked open op. Stripped
|
||||
/// pre-validation in the sink resolve path and delivered to the
|
||||
/// wrapper via `OperationContext.metadata`
|
||||
/// ([`RESERVED_OPEN_METADATA_KEY`]).
|
||||
pub const RESERVED_OPEN_INPUT_KEY: &str = "channel_id";
|
||||
|
||||
/// The `OperationContext.metadata` key the sink resolve path uses to
|
||||
/// hand the stripped reserved input value (the initiator-allocated
|
||||
/// channel id) to the marked-pub open wrapper (ADR-047 amendment 4
|
||||
/// §4.3).
|
||||
pub const RESERVED_OPEN_METADATA_KEY: &str = "channel_open_channel_id";
|
||||
|
||||
/// The wire error code for a `call.published` chunk arriving on a
|
||||
/// marked (`channel_open`) `Pub` op (ADR-047 amendment 4 §4.1): the
|
||||
/// marked op's data plane is the adopted channel's `BiStream`; publish
|
||||
/// chunks are rejected loudly and never consumed.
|
||||
pub const MARKED_PUB_CHUNKS_REJECTED: &str = "channel:publish_chunks_rejected";
|
||||
|
||||
impl ChannelOpenSpec {
|
||||
pub fn new(alpn: impl Into<Cow<'static, str>>) -> Self {
|
||||
Self { alpn: alpn.into() }
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! The stub guard (review 010 action 3): no `*:not_implemented` path
|
||||
//! may exist in `src/`. A handler that registers successfully and
|
||||
//! returns an error of this form on every invocation is a bug, not a
|
||||
//! documented limitation — shipping one is a hard failure per the
|
||||
//! repo ruling (reviews 010/011; four stubs shipped across nine
|
||||
//! published versions before this guard existed).
|
||||
|
||||
#[test]
|
||||
fn source_tree_has_no_not_implemented_path() {
|
||||
let manifest = env!("CARGO_MANIFEST_DIR");
|
||||
let src = std::path::Path::new(manifest).join("src");
|
||||
let mut offenders = Vec::new();
|
||||
walk(&src, &mut offenders);
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"no `not_implemented` string may appear in src/ — a registered surface \
|
||||
that always fails is the stub class (review 010 S-01..S-04); offended \
|
||||
by: {offenders:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn walk(dir: &std::path::Path, offenders: &mut Vec<String>) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
walk(&path, offenders);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
let contents = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for (line_no, line) in contents.lines().enumerate() {
|
||||
if line.contains("not_implemented") {
|
||||
offenders.push(format!(
|
||||
"{}:{}",
|
||||
path.strip_prefix(env!("CARGO_MANIFEST_DIR"))
|
||||
.unwrap_or(&path)
|
||||
.display(),
|
||||
line_no + 1
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user