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
|
- **OQ-ID** (OQ-01, OQ-02, ...) — stable reference
|
||||||
- **Origin** — which spec doc(s) the question appeared in
|
- **Origin** — which spec doc(s) the question appeared in
|
||||||
- **Status** — open, resolved, partially resolved
|
- **Status** — open, resolved, partially resolved, deferred(scope),
|
||||||
|
deferred(unclear), dissolved
|
||||||
- **Priority** — high, medium, low
|
- **Priority** — high, medium, low
|
||||||
- **Resolution** — when resolved, what was decided and which ADR addresses it
|
- **Resolution** — when resolved, what was decided and which ADR addresses it
|
||||||
- **Cross-references** — related OQs and ADRs
|
- **Cross-references** — related OQs and ADRs
|
||||||
@@ -190,6 +191,8 @@ When you encounter a decision that genuinely can't be made:
|
|||||||
|
|
||||||
1. Mark the OQ as `deferred(scope)` with a concrete blocking condition
|
1. Mark the OQ as `deferred(scope)` with a concrete blocking condition
|
||||||
2. Create a blocker task in `tasks/architecture/` naming the dependency
|
2. Create a blocker task in `tasks/architecture/` naming the dependency
|
||||||
|
(where the project tracks tasks that way — otherwise the OQ's
|
||||||
|
`Blocked on:` field is the tracker of record)
|
||||||
3. Continue to decisions that *can* be made — do not stall on one question
|
3. Continue to decisions that *can* be made — do not stall on one question
|
||||||
|
|
||||||
### 6. Request Architecture Review
|
### 6. Request Architecture Review
|
||||||
@@ -274,7 +277,7 @@ Door type classifies **reversal cost** (one-way vs two-way), not
|
|||||||
urgency. A two-way door is a decision you make now and can revert
|
urgency. A two-way door is a decision you make now and can revert
|
||||||
later — not a decision to defer. Using "it's a two-way door" as a
|
later — not a decision to defer. Using "it's a two-way door" as a
|
||||||
reason to leave a decision unmade conflates reversal cost with
|
reason to leave a decision unmade conflates reversal cost with
|
||||||
decision-making. See ADR-009 §"What this framework is NOT" for the
|
decision-making. See ADR-032 §"What this framework is NOT" for the
|
||||||
full rationale.
|
full rationale.
|
||||||
|
|
||||||
Architecture decisions are the architect's, regardless of door type.
|
Architecture decisions are the architect's, regardless of door type.
|
||||||
@@ -329,7 +332,8 @@ A decision should be `deferred(scope)` when:
|
|||||||
- The use case isn't concrete (e.g., "we don't know what the agent crate
|
- The use case isn't concrete (e.g., "we don't know what the agent crate
|
||||||
will need from the call protocol")
|
will need from the call protocol")
|
||||||
- The options depend on something that doesn't exist yet (e.g.,
|
- The options depend on something that doesn't exist yet (e.g.,
|
||||||
"depends on the alknet-http crate spec")
|
"depends on the composition-layer crate spec — owner and shape not
|
||||||
|
committed, see OQ-43")
|
||||||
- The trade-off requires data that can only come from implementation
|
- The trade-off requires data that can only come from implementation
|
||||||
(e.g., "need performance benchmarks to choose between X and Y")
|
(e.g., "need performance benchmarks to choose between X and Y")
|
||||||
- The decision is genuinely not needed for the current scope (e.g., "the
|
- The decision is genuinely not needed for the current scope (e.g., "the
|
||||||
@@ -373,7 +377,7 @@ A decision should be `deferred(unclear)` when:
|
|||||||
(implies it's decided).
|
(implies it's decided).
|
||||||
2. **State the blocking condition** (`deferred(scope)`) or
|
2. **State the blocking condition** (`deferred(scope)`) or
|
||||||
**investigation target** (`deferred(unclear)`) — what specific thing
|
**investigation target** (`deferred(unclear)`) — what specific thing
|
||||||
would unblock this? Be concrete: "blocked on: alknet-agent crate spec
|
would unblock this? Be concrete: "blocked on: the agent crate's spec
|
||||||
exists" or "investigation: work through 2+ example outbound-dial use
|
exists" or "investigation: work through 2+ example outbound-dial use
|
||||||
cases (hub→worker, worker→hub) to see how verifier-selection +
|
cases (hub→worker, worker→hub) to see how verifier-selection +
|
||||||
provider + connector compose."
|
provider + connector compose."
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ also include:
|
|||||||
Example prompt template:
|
Example prompt template:
|
||||||
|
|
||||||
```
|
```
|
||||||
You are an implementation specialist for the @alkdev/alknet project.
|
You are an implementation specialist for the @alkdev/alkcall project.
|
||||||
|
|
||||||
Your task: {{task}}
|
Your task: {{task}}
|
||||||
|
|
||||||
@@ -204,11 +204,11 @@ Your task: {{task}}
|
|||||||
7. Push: git push origin $(git branch --show-current)
|
7. Push: git push origin $(git branch --show-current)
|
||||||
8. Notify: worktree({action: "notify", args: {message: "Task completed: {{task}}. <brief summary>", level: "info"}})
|
8. Notify: worktree({action: "notify", args: {message: "Task completed: {{task}}. <brief summary>", level: "info"}})
|
||||||
|
|
||||||
Key project constraints (@alkdev/alknet):
|
Key project constraints (@alkdev/alkcall):
|
||||||
- Rust: use cargo build, cargo clippy, cargo fmt, cargo test
|
- Rust: use cargo build, cargo clippy, cargo fmt, cargo test
|
||||||
- No comments in code
|
- No comments in code
|
||||||
- anyhow::Result for application errors, thiserror for library error types
|
- anyhow::Result for application errors, thiserror for library error types
|
||||||
- Feature flags for transports (tls, iroh, acme)
|
- Base crate compiles lean (no quinn/iroh unless the feature is on — see AGENTS.md convention 11)
|
||||||
- Async via tokio runtime
|
- Async via tokio runtime
|
||||||
- No panics in library code
|
- No panics in library code
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -46,14 +46,15 @@ change `git config`, skip hooks, or use `git commit -i`.
|
|||||||
|
|
||||||
## Project Conventions (Rust / RPC protocol crate)
|
## Project Conventions (Rust / RPC protocol crate)
|
||||||
|
|
||||||
This is the call + channels RPC crate — the unification of
|
This is the call + channels RPC crate — the extraction and unification of
|
||||||
`alknet-call` (structured JSON RPC: operations, streaming subscriptions,
|
the former `alknet-call` (structured JSON RPC: operations, streaming
|
||||||
service discovery) and `alknet-channels` (multiplexing proxy: N logical
|
subscriptions, service discovery) and `alknet-channels` (multiplexing
|
||||||
channels over one transport stream, channel 0 pre-negotiated as
|
proxy: N logical channels over one transport stream, channel 0
|
||||||
`alk/call`). The conventions below apply to all work in `src/` and
|
pre-negotiated as `alk/call`) crates. The conventions below apply to all
|
||||||
`tests/`. They mirror `.opencode/agents/implementation-specialist.md`
|
work in `src/` and `tests/`. They mirror
|
||||||
§Project Conventions and are repeated here so they apply to every
|
`.opencode/agents/implementation-specialist.md` §Project Conventions and
|
||||||
session, not just spawned implementation agents.
|
are repeated here so they apply to every session, not just spawned
|
||||||
|
implementation agents.
|
||||||
|
|
||||||
1. **No comments in code** unless the user explicitly asks. This is a
|
1. **No comments in code** unless the user explicitly asks. This is a
|
||||||
project-wide convention. Doc comments (`///`, `//!`) are fine and
|
project-wide convention. Doc comments (`///`, `//!`) are fine and
|
||||||
@@ -130,11 +131,13 @@ session, not just spawned implementation agents.
|
|||||||
`AuthContext`, `IdentityProvider`, `Identity`, `AuthToken`,
|
`AuthContext`, `IdentityProvider`, `Identity`, `AuthToken`,
|
||||||
`Capabilities`, `OwnershipProvider`, `HandlerError`, `StreamError`)
|
`Capabilities`, `OwnershipProvider`, `HandlerError`, `StreamError`)
|
||||||
live in this crate. They are the home for these types going forward —
|
live in this crate. They are the home for these types going forward —
|
||||||
do not add a separate `alkcore` dependency. When the alknet mono-repo
|
do not add a separate `alkcore` dependency; the source-repo core crate
|
||||||
is reworked, it will consume alkcall's versions. Keep these types
|
is retired (the alknet mono-repo is being replaced by thin glue over
|
||||||
lean (no TLS, no transport coupling, no endpoint/accept-loop); the
|
alkcall and its sibling host crates — see OQ-43 in
|
||||||
dial and the TLS config are concerns of the consumer, not of this
|
`docs/architecture/open-questions.md` for the composition layer).
|
||||||
crate. See ADR-007, ADR-008, ADR-009.
|
Keep these types lean (no TLS, no transport coupling, no
|
||||||
|
endpoint/accept-loop); the dial and the TLS config are concerns of
|
||||||
|
the consumer, not of this crate. See ADR-007, ADR-008, ADR-009.
|
||||||
|
|
||||||
10. **BAST documents for wire formats** — every binary wire format in
|
10. **BAST documents for wire formats** — every binary wire format in
|
||||||
this crate carries a BAST (Binary Abstract Syntax Tree) document as
|
this crate carries a BAST (Binary Abstract Syntax Tree) document as
|
||||||
@@ -213,20 +216,53 @@ cargo clippy --target wasm32-unknown-unknown -- -D warnings
|
|||||||
cargo semver-checks check-release # before a release
|
cargo semver-checks check-release # before a release
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Stub Guard (release-blocking)
|
||||||
|
|
||||||
|
Four registered-but-always-failing stubs shipped across nine published
|
||||||
|
versions (reviews 010/011). This class is now release-blocking in both
|
||||||
|
directions:
|
||||||
|
|
||||||
|
1. **Test guard**: `cargo test --test stub_guard` (part of `cargo
|
||||||
|
test`) fails if the string `not_implemented` appears anywhere in
|
||||||
|
`src/`. A "loud failure" stub is still a stub.
|
||||||
|
2. **Registration-time invariants** (ADR-047 amendment 4 §4.8): a
|
||||||
|
marked (`channel_open`) spec refuses the generic registration path
|
||||||
|
(`OperationRegistry::register`, `CallConnection::register_imported`,
|
||||||
|
`op/register`) and refuses `publish_schema`; the sanctioned
|
||||||
|
registrations are the channel wrapper family
|
||||||
|
(`register_openable*`, `register_pub_openable`,
|
||||||
|
`register_relay_openable`).
|
||||||
|
3. **The ruling**: implement-or-stop-advertising before any publish.
|
||||||
|
The only permitted deferral statement is "not this session" — never
|
||||||
|
"next version", "not v1", or any bucket that parks a decision on an
|
||||||
|
unknown future release. Every handler that registers on a
|
||||||
|
discoverable surface must be capable of succeeding.
|
||||||
|
|
||||||
## Architecture Context
|
## Architecture Context
|
||||||
|
|
||||||
- `docs/architecture/` — the authoritative spec. Read it before
|
- `docs/architecture/` — the authoritative spec. Read it before
|
||||||
non-trivial changes. ADRs are numbered 001..050; OQs (open questions)
|
non-trivial changes. ADRs are numbered 001..054; OQs (open questions)
|
||||||
track resolved/deferred decisions.
|
track resolved/deferred decisions (OQ-01..43; OQ-43 tracks the
|
||||||
- This crate unifies `alknet-call` and `alknet-channels` from the
|
composition layer's ownership and shape after the alknet replacement,
|
||||||
alknet mono-repo (`/workspace/@alkdev/alknet`). The source
|
deferred(scope)).
|
||||||
architecture docs were ported from
|
- This crate is the extraction of the former `alknet-call` and
|
||||||
`/workspace/@alkdev/alknet/docs/architecture/` and renumbered as
|
`alknet-channels` crates plus the vendored core types (provenance:
|
||||||
alkcall ADRs (001..048; ADR-049 and later were authored in this
|
the alknet mono-repo at `/workspace/@alkdev/alknet`). The source
|
||||||
crate). The ALPN
|
architecture docs were ported and renumbered as alkcall ADRs
|
||||||
strings (`alk/call`,
|
(001..048; ADR-049 and later were authored in this crate). The
|
||||||
`alk/channels`) are wire-stable going forward (renamed from
|
mono-repo is **provenance, not a live dependency** — it is being
|
||||||
`alknet/` to `alk/` in v0.1.1, before the first published consumer).
|
replaced by thin glue over alkcall and its sibling host crates
|
||||||
|
(`alkhttp`, `alktls`, `alktty`, `alktunnels`, `alktrader`), which are
|
||||||
|
the current homes of the surfaces old docs call `alknet-http`,
|
||||||
|
`alknet-tls`, `alknet-tty`, `alknet-tunnels`, `alknet-trader`. Docs
|
||||||
|
and ADRs cite the source repo as "alknet ADR-NNN (provenance)" /
|
||||||
|
"alknet OQ-NN" — a separately-numbered upstream space, never an
|
||||||
|
in-crate authority; anything that constrains this crate's surface is
|
||||||
|
re-owned by an in-crate ADR (e.g. ADR-054). See the provenance note
|
||||||
|
at the end of `docs/architecture/README.md`. The ALPN strings
|
||||||
|
(`alk/call`, `alk/channels`) are wire-stable going forward (renamed
|
||||||
|
from `alknet/` to `alk/` in v0.1.1, before the first published
|
||||||
|
consumer).
|
||||||
- Key ADRs that inform this crate's design:
|
- Key ADRs that inform this crate's design:
|
||||||
|
|
||||||
**Call protocol:**
|
**Call protocol:**
|
||||||
@@ -274,6 +310,14 @@ cargo semver-checks check-release # before a release
|
|||||||
`from_connection` primary; dial lives in the consumer)
|
`from_connection` primary; dial lives in the consumer)
|
||||||
- ADR-041 — per-identity channel cap (256 per `PeerId` via
|
- ADR-041 — per-identity channel cap (256 per `PeerId` via
|
||||||
`ChannelLifecyclePolicy`)
|
`ChannelLifecyclePolicy`)
|
||||||
|
- ADR-052 — `channel/control` control-handle surface
|
||||||
|
(establisher-contributed `ChannelControl`;
|
||||||
|
`channel:no_control_handle` capability answer)
|
||||||
|
- ADR-053 — `channel/resources/subscribe` live resource enumerators
|
||||||
|
(snapshot-then-changes; scan-once aggregation)
|
||||||
|
- ADR-047 amendment 4 — the marked-pub open contract and the
|
||||||
|
registration invariants (§4.8): marked specs refuse the generic
|
||||||
|
registration paths; stubs are unregistrable
|
||||||
|
|
||||||
**Shared (vendored core types):**
|
**Shared (vendored core types):**
|
||||||
- ADR-001 — ALPN-based protocol dispatch
|
- ADR-001 — ALPN-based protocol dispatch
|
||||||
@@ -290,6 +334,10 @@ cargo semver-checks check-release # before a release
|
|||||||
point `ChannelBidiStreamSource` implements)
|
point `ChannelBidiStreamSource` implements)
|
||||||
- ADR-009 — `BiStream` as the handler leaf (`accept_bi` returns
|
- ADR-009 — `BiStream` as the handler leaf (`accept_bi` returns
|
||||||
`BiStream`)
|
`BiStream`)
|
||||||
|
- ADR-054 — outgoing-only X.509 and the three remote roles
|
||||||
|
(verifier selection by `PeerEntry` presence; `RemoteIdentity`
|
||||||
|
`None` semantics — re-owned in-crate from the source repo's
|
||||||
|
alknet ADR-034; the dial/composition layer implements it)
|
||||||
|
|
||||||
- If a TODO references a "Phase B" or a design direction that an ADR
|
- If a TODO references a "Phase B" or a design direction that an ADR
|
||||||
has since decided against, the TODO is stale — remove it and align
|
has since decided against, the TODO is stale — remove it and align
|
||||||
|
|||||||
@@ -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
|
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||||
this crate adheres to [Semantic Versioning](https://semver.org/).
|
this crate adheres to [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-10-14
|
||||||
|
|
||||||
|
The stub sweep: four registered-but-always-failing stubs shipped across
|
||||||
|
nine published versions (reviews 010/011) are now real implementations
|
||||||
|
— the marked-pub open contract (S-01), `channel/control` (S-02),
|
||||||
|
`channel/resources/subscribe` (S-03), and `op/register`
|
||||||
|
forward-through (S-04). Every handler that registers on a discoverable
|
||||||
|
surface can now succeed; the four stubs are replaced and the class is
|
||||||
|
release-blocking in both directions (a loud-failure stub is still a
|
||||||
|
stub — the stub guard plus registration-time invariants hold the gate).
|
||||||
|
|
||||||
|
**Breaking (semver-relevant, hence the minor bump).** New variant
|
||||||
|
`ManagerError::InvalidAdopt`; `RelayError::PubTypedOpen` removed (the
|
||||||
|
relay put-translation now allocates and opens on the producer leg);
|
||||||
|
`register_imported`/`register_imported_all` return `Result<(), String>`
|
||||||
|
(beyond the boolean); `Establishment` no longer derives `Debug` (the
|
||||||
|
establisher-contributed `ChannelControl` inner is not `Debug`).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **The marked-pub open contract — S-01 (ADR-047 amendment 4, review
|
||||||
|
011 §3).** The put path end-to-end:
|
||||||
|
- `ChannelCore::register_pub_openable` + `PubOpenHandler` — the
|
||||||
|
put's handler returns its result for the merged single response;
|
||||||
|
the wrapper awaits it inline, teardown is EOF-driven, no
|
||||||
|
`handler_task` is ever spawned. `register_openable*` refuses
|
||||||
|
`Pub`-typed specs; the wrapper entry points refuse unmarked /
|
||||||
|
non-`Pub` shapes.
|
||||||
|
- The reserved input key `channel_id` (the initiator-allocated id)
|
||||||
|
is stripped pre-validation by `resolve_sink_handler` (P-08, the
|
||||||
|
single source of truth), handed to the wrapper via
|
||||||
|
`OperationContext.metadata`; a missing key rejects
|
||||||
|
`INVALID_INPUT`; adopt parity is enforced
|
||||||
|
(`ManagerError::InvalidAdopt`) and adopt failures answer
|
||||||
|
`channel:open_failed` / reason `adopt_failed`.
|
||||||
|
- Zero `call.published` on marked ops: the first publish chunk on a
|
||||||
|
marked op is rejected loudly (`channel:publish_chunks_rejected`)
|
||||||
|
in both dispatch loops; the handler is never fed.
|
||||||
|
- The relay's put-translation (ADR-042): the hub allocates odd on
|
||||||
|
the producer leg, opens the spoke op in the background
|
||||||
|
(put-acks-in-result), pumps one direction only, and mirrors the
|
||||||
|
response with the producer's reserved key stripped.
|
||||||
|
- `ChannelClient::open_pub_channel` + `PubOpenReply` — the
|
||||||
|
initiator-allocated put open capability (odd ids on the connect
|
||||||
|
side, ADR-047 §5). A failed open tears the local adopt entry down,
|
||||||
|
including the pre-flight INVALID_INPUT path (the allocated id is
|
||||||
|
released, not parked against the ADR-040 bound).
|
||||||
|
- **`channel/control` implemented — S-02 (ADR-052).** The
|
||||||
|
establisher-contributed control handle (`Establishment::with_control`)
|
||||||
|
is registered manager-side at establishment, removed on teardown, and
|
||||||
|
reachable through the `channel/control` op; an establisher without a
|
||||||
|
control handle answers `channel:no_control_handle`.
|
||||||
|
- **`channel/resources/subscribe` implemented — S-03 (ADR-053).** The
|
||||||
|
per-peer-connection `ResourceEnumerators` registry with
|
||||||
|
snapshot-then-changes enumerators, scan-once aggregation over the
|
||||||
|
channel operations, and the empty seed; the always-failing one-shot
|
||||||
|
error stream is gone.
|
||||||
|
- **`op/register` forward-through — S-04 (ADR-022 amendment).**
|
||||||
|
Announced unmarked `Sub`/`Pub` operations register the real
|
||||||
|
streaming/sink forwarding shapes; marked announcements are refused
|
||||||
|
with `MARKED_SPEC_UNSUPPORTED`; marked specs refuse both the generic
|
||||||
|
registration path (`OperationRegistry::register`, `op/register`) and
|
||||||
|
the connection-overlay path (`CallConnection::register_imported`
|
||||||
|
— now returning `Result`; `register_imported_all` is atomic: a
|
||||||
|
refusal leaves the overlay untouched).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `HandlerRegistration` gained the private `channel_wrapper` field
|
||||||
|
(the channel wrapper family's plumbing); externally constructed
|
||||||
|
registrations stay impossible (`HandlerRegistration::new` unchanged).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`open_pub_channel` no longer parks a channel slot on the
|
||||||
|
pre-flight rejection.** The early-return on a non-object input now
|
||||||
|
tears down the freshly allocated id (regression:
|
||||||
|
`open_pub_channel_invalid_input_releases_the_allocated_id`).
|
||||||
|
- `Capabilities::zeroize`: an unused map-key iteration tripped
|
||||||
|
`clippy::for_kv_map` under `-D warnings`. No behavior change.
|
||||||
|
|
||||||
|
### Guard
|
||||||
|
|
||||||
|
- `tests/stub_guard.rs` — `cargo test` fails if `not_implemented`
|
||||||
|
appears anywhere under `src/`: the four former pinned-stub tests are
|
||||||
|
deleted and the marked-spec registration refusals are pinned by
|
||||||
|
tests instead.
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
|
||||||
|
- 698 tests pass (including the stub guard); `clippy --all-targets
|
||||||
|
--all-features -- -D warnings`, `fmt --check`, `cargo doc`,
|
||||||
|
`wasm32-unknown-unknown` check, fuzz corpus replay
|
||||||
|
(`cargo test --manifest-path fuzz/shared/Cargo.toml`, 5/5), and
|
||||||
|
`cargo semver-checks` (5 breaking lints vs 0.8.1 — the minor-bump
|
||||||
|
classification above).
|
||||||
|
|
||||||
## [0.8.1] - 2026-09-28
|
## [0.8.1] - 2026-09-28
|
||||||
|
|
||||||
Bug-fix release: a duplicate `adopt_channel`/`open_channel` no longer
|
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-046), the channels protocol with openable-ALPNs-as-operations
|
||||||
(ADR-047), and the `ChannelClient` transport-agnostic client.
|
(ADR-047), and the `ChannelClient` transport-agnostic client.
|
||||||
|
|
||||||
|
[0.9.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.9.0
|
||||||
[0.8.1]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.8.1
|
[0.8.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.8.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.8.0
|
||||||
[0.7.1]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.7.1
|
[0.7.1]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.7.1
|
||||||
|
|||||||
Generated
+1
-1
@@ -27,7 +27,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alkcall"
|
name = "alkcall"
|
||||||
version = "0.8.1"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "alkcall"
|
name = "alkcall"
|
||||||
version = "0.8.1"
|
version = "0.9.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.88"
|
rust-version = "1.88"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
+77
-39
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-08-12
|
last_updated: 2026-10-13 (composition re-key, ADR-054, alkcall reframing of ported docs)
|
||||||
---
|
---
|
||||||
|
|
||||||
# alkcall
|
# alkcall
|
||||||
@@ -9,12 +9,13 @@ The call + channels RPC crate. Structured JSON RPC (operations, streaming
|
|||||||
subscriptions, service discovery) and N-channel multiplexing over one
|
subscriptions, service discovery) and N-channel multiplexing over one
|
||||||
transport stream (channel 0 pre-negotiated as `alk/call`).
|
transport stream (channel 0 pre-negotiated as `alk/call`).
|
||||||
|
|
||||||
This crate unifies `alknet-call` and `alknet-channels` from the alknet
|
This crate is the extraction and unification of the former `alknet-call`
|
||||||
mono-repo, plus the vendored core types formerly in `alknet-core`. The
|
and `alknet-channels` crates plus the vendored core types formerly in
|
||||||
source architecture docs were ported from
|
`alknet-core` (see ADR-031). It is a pure protocol crate: no networking,
|
||||||
`/workspace/@alkdev/alknet/docs/architecture/` and renumbered as alkcall
|
no transport dependencies — dial, TLS config, and composition live in
|
||||||
ADRs (ADR-001..051). The ALPN strings (`alk/call`, `alk/channels`)
|
downstream crates. The ALPN strings (`alk/call`, `alk/channels`) are
|
||||||
are wire-stable and unchanged — see ADR-004.
|
wire-stable — see ADR-004. (Historical origin and cross-repo citation
|
||||||
|
conventions: see the provenance note at the end of this document.)
|
||||||
|
|
||||||
## Documents
|
## Documents
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ are wire-stable and unchanged — see ADR-004.
|
|||||||
| [024](decisions/024-peer-graph-routing-model.md) | Peer-Graph Routing Model | PeerCompositeEnv, PeerRef, AccessControl peer auth |
|
| [024](decisions/024-peer-graph-routing-model.md) | Peer-Graph Routing Model | PeerCompositeEnv, PeerRef, AccessControl peer auth |
|
||||||
| [025](decisions/025-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | PeerId = Identity.id (stable) |
|
| [025](decisions/025-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | PeerId = Identity.id (stable) |
|
||||||
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | Metadata only, never used by ACL |
|
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | Metadata only, never used by ACL |
|
||||||
| [027](decisions/027-from-jsonschema-as-http-adapter.md) | from_jsonschema as HTTP Adapter | FromJsonSchema provenance stays; impl in alknet-http |
|
| [027](decisions/027-from-jsonschema-as-http-adapter.md) | from_jsonschema as HTTP Adapter | FromJsonSchema provenance stays; impl moved to alkhttp (ex-`alknet-http`) |
|
||||||
| [028](decisions/028-from-call-manual-free-function.md) | from_call Is a Manual Free Function | Assembly layer calls it after dial |
|
| [028](decisions/028-from-call-manual-free-function.md) | from_call Is a Manual Free Function | Assembly layer calls it after dial |
|
||||||
| [029](decisions/029-aggregated-peer-env-wiring.md) | Aggregated Peer-Environment Wiring | Dispatcher hub wiring |
|
| [029](decisions/029-aggregated-peer-env-wiring.md) | Aggregated Peer-Environment Wiring | Dispatcher hub wiring |
|
||||||
| [030](decisions/030-peer-composite-env-peer-operations.md) | PeerCompositeEnv::peer_operations | OperationEnv::peer_operations override |
|
| [030](decisions/030-peer-composite-env-peer-operations.md) | PeerCompositeEnv::peer_operations | OperationEnv::peer_operations override |
|
||||||
@@ -82,7 +83,7 @@ are wire-stable and unchanged — see ADR-004.
|
|||||||
| [032](decisions/032-one-way-door-decision-framework.md) | One-Way Door Decision Framework | Reversal cost classification |
|
| [032](decisions/032-one-way-door-decision-framework.md) | One-Way Door Decision Framework | Reversal cost classification |
|
||||||
| [033](decisions/033-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Rust canonical, TS reference |
|
| [033](decisions/033-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Rust canonical, TS reference |
|
||||||
|
|
||||||
### Channels — ADR-034..045
|
### Channels, pub/sub, and later amendments — ADR-034..054
|
||||||
|
|
||||||
| ADR | Title | Relevance |
|
| ADR | Title | Relevance |
|
||||||
|-----|-------|-----------|
|
|-----|-------|-----------|
|
||||||
@@ -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 |
|
| [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) |
|
| [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 |
|
| [051](decisions/051-channel-relay-and-hub-leg.md) | In-Tree Channel Relay (`ChannelRelay`) and the Hub-Leg Assembly | ADR-042 amended — the relay implementation is an alkcall export; translate hop = ADR-049 establisher (`register_relay_openable`), byte-forward hop = `pump_bidi`; implicit per-channel id mapping; two-phase registration (discover/stash → fork-register); hub-leg install template; ACL layering note |
|
||||||
|
| [052](decisions/052-channel-control-surface.md) | `channel/control` Control-Handle Surface | Establisher-contributed `ChannelControl` (`Establishment::with_control`), wrapper-installed/teardown-removed in the manager; `channel:no_control_handle` capability answer; resolves OQ-39 |
|
||||||
|
| [053](decisions/053-resource-enumerators.md) | Live Resource Enumerators | `ResourceEnumerators` (peer-shared registry); snapshot-then-changes enumerator contract; scan-once aggregation; empty seed; resolves OQ-40 |
|
||||||
|
| [054](decisions/054-outgoing-only-x509-three-remote-roles.md) | Outgoing-Only X.509 and the Three Remote Roles (Re-Owned) | Verifier-selection by `PeerEntry` presence; three remote roles; `RemoteIdentity` `None` semantics — re-owned in-crate (provenance: alknet ADR-034); resolves OQ-21's cross-repo authority gap |
|
||||||
|
|
||||||
## Relevant Open Questions
|
## Relevant Open Questions
|
||||||
|
|
||||||
See [open-questions.md](open-questions.md) for the full tracker. Key
|
See [open-questions.md](open-questions.md) for the full tracker. Key
|
||||||
questions affecting this crate:
|
questions affecting this crate:
|
||||||
|
|
||||||
- **OQ-01**: Call protocol pub/sub primitive (partially resolved) —
|
- **OQ-22** (resolved) / **OQ-42** (deferred(scope)): The call-protocol
|
||||||
ADR-046 adds the `Pub` primitive (producer→consumer streaming). The
|
pub/sub primitive is delivered (ADR-046); the fan-out/message broker
|
||||||
fan-out/broker is deferred to channels (Gap B in ADR-047 is named
|
is placed out of scope for alkcall — the hub composes it on the
|
||||||
out-of-scope for alkcall; the hub composes the broker on top).
|
`Pub`/`Sub` primitives — and its design remains deferred downstream,
|
||||||
- **OQ-02**: Full channel-level flow-control windowing (deferred(scope))
|
blocked on a concrete producing-crate consumer (Gap B in ADR-047).
|
||||||
|
- **OQ-23** (deferred(scope)): Full channel-level flow-control windowing
|
||||||
— bounded-buffer is decided (ADR-040); full windowing blocked on a real
|
— bounded-buffer is decided (ADR-040); full windowing blocked on a real
|
||||||
HOL-blocking deployment observation.
|
HOL-blocking deployment observation.
|
||||||
- **OQ-03**: Channels add/strip API shape (open) — whether the 8-byte
|
- **OQ-24** (resolved): Channels add/strip API shape — integral to the
|
||||||
header add/strip is built into the read/write path or a standalone
|
read/write path (handlers never see headers); low-level header
|
||||||
utility.
|
primitives stay in `channels::wire` for tooling.
|
||||||
|
- **OQ-37** (resolved): No `from_call` relay wrapper for marked ops —
|
||||||
|
marked announcements and marked-spec imports are refused (ADR-022
|
||||||
|
amendment 2026-10-13, ADR-047 amendment 4 §4.8); the sanctioned
|
||||||
|
compositions are `ChannelRelay::register_relay_openable` (ADR-051) and
|
||||||
|
`ChannelClient`.
|
||||||
|
- **OQ-43** (deferred(scope)): Composition-layer ownership and shape
|
||||||
|
after the alknet replacement — the named receiver for the
|
||||||
|
downstream-side deferrals (OQ-33 endpoint layer, OQ-41 QUIC-native
|
||||||
|
substrate, OQ-42 hub broker, OQ-19 persistence adapters). Not
|
||||||
|
blocking any alkcall API surface.
|
||||||
|
|
||||||
## Key Design Principles
|
## Key Design Principles
|
||||||
|
|
||||||
@@ -143,6 +158,14 @@ questions affecting this crate:
|
|||||||
channel 0. See ADR-036, ADR-037.
|
channel 0. See ADR-036, ADR-037.
|
||||||
10. **Wire formats are stable**: EventEnvelope shape and the 8-byte chunk
|
10. **Wire formats are stable**: EventEnvelope shape and the 8-byte chunk
|
||||||
header are one-way doors. See ADR-014, ADR-034.
|
header are one-way doors. See ADR-014, ADR-034.
|
||||||
|
11. **Implement-or-stop-advertising**: every registration on a
|
||||||
|
discoverable surface must be capable of succeeding — no
|
||||||
|
advertised-always-failing bodies, no deferral buckets ("not this
|
||||||
|
session" is the only permitted deferral statement). Marked
|
||||||
|
(`channel_open`) specs refuse the generic registration paths
|
||||||
|
(ADR-047 amendment 4 §4.8); the `not_implemented` guard test is
|
||||||
|
release-blocking (`tests/stub_guard.rs`). See reviews 010/011 for
|
||||||
|
the incident that pinned this.
|
||||||
|
|
||||||
## Roles and Composition
|
## Roles and Composition
|
||||||
|
|
||||||
@@ -176,8 +199,9 @@ Within a single connection, direction is independent of role:
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────┐
|
||||||
│ alknet / alknode │
|
│ Composition layer │
|
||||||
│ (networking + composition) │
|
│ (today: the alknet glue — being replaced; │
|
||||||
|
│ future owner and shape → OQ-43) │
|
||||||
│ │
|
│ │
|
||||||
│ • QUIC / TCP+TLS dial and accept │
|
│ • QUIC / TCP+TLS dial and accept │
|
||||||
│ • Hub: ChannelsAdapter + Dispatcher + │
|
│ • Hub: ChannelsAdapter + Dispatcher + │
|
||||||
@@ -185,7 +209,7 @@ Within a single connection, direction is independent of role:
|
|||||||
│ • Spoke: ChannelClient + CallConnection + │
|
│ • Spoke: ChannelClient + CallConnection + │
|
||||||
│ from_call │
|
│ from_call │
|
||||||
│ • Wires protocol crates' producers into │
|
│ • Wires protocol crates' producers into │
|
||||||
│ registries, consumers into clients │
|
│ registries, consumers into clients │
|
||||||
└──────────────────┬───────────────────────────┘
|
└──────────────────┬───────────────────────────┘
|
||||||
│ depends on
|
│ depends on
|
||||||
┌──────────────┼──────────────┐
|
┌──────────────┼──────────────┐
|
||||||
@@ -229,10 +253,12 @@ alkcall. They provide two halves:
|
|||||||
(e.g. `trader_client.status().await` instead of
|
(e.g. `trader_client.status().await` instead of
|
||||||
`call_connection.call("trader/status", ...).await`).
|
`call_connection.call("trader/status", ...).await`).
|
||||||
|
|
||||||
**alk/alknode** depends on alkcall + whichever protocol crates are
|
**The composition layer** depends on alkcall + whichever protocol
|
||||||
needed. It's the composition layer — the only place that knows about
|
crates are needed. It's the only place that knows about network
|
||||||
network topology, peer routing, and which protocol crates are wired in.
|
topology, peer routing, and which protocol crates are wired in.
|
||||||
Protocol crates never import a QUIC or TLS dependency.
|
Protocol crates never import a QUIC or TLS dependency. Today this
|
||||||
|
layer is the alknet glue; its post-replacement owner and shape is
|
||||||
|
tracked as OQ-43.
|
||||||
|
|
||||||
### Pattern for protocol crates
|
### Pattern for protocol crates
|
||||||
|
|
||||||
@@ -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
|
The protocol crate doesn't know which path it's on. It takes a
|
||||||
`BiStream` (or `AsyncRead + AsyncWrite`) and drives the session. The
|
`BiStream` (or `AsyncRead + AsyncWrite`) and drives the session. The
|
||||||
two adapters are thin and live either in the protocol crate (behind
|
two adapters are thin and live either in the protocol crate (behind
|
||||||
feature flags) or in the downstream alknet crate.
|
feature flags) or in the composition layer.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- `@alkdev/alknet: docs/architecture/` — the source architecture docs
|
|
||||||
these were ported from (renumbered from alknet ADR-001..094 to alkcall
|
|
||||||
ADR-001..048; ADR-049 and later were authored in this crate)
|
|
||||||
- `@alkdev/alktype` — the binary struct engine; compiles BAST documents
|
- `@alkdev/alktype` — the binary struct engine; compiles BAST documents
|
||||||
(e.g. `chunk-header.bast.json`) into readers/writers/validators
|
(e.g. `chunk-header.bast.json`) into readers/writers/validators
|
||||||
|
- `@alkdev/alkhttp`, `@alkdev/alktls`, `@alkdev/alktty`,
|
||||||
|
`@alkdev/alktunnels`, `@alkdev/alktrader` — sibling crates; the
|
||||||
|
live homes of the surfaces older docs call `alknet-http`,
|
||||||
|
`alknet-tls`, `alknet-tty`, `alknet-tunnels`, `alknet-trader`
|
||||||
- `@alkdev/pubsub` — the TypeScript EventEnvelope prior art the call
|
- `@alkdev/pubsub` — the TypeScript EventEnvelope prior art the call
|
||||||
wire format was derived from
|
wire format was derived from
|
||||||
|
|
||||||
> **Note**: The source ADRs and spec docs were ported from the parent
|
> **Provenance and cross-repo citations.** The source ADRs and spec
|
||||||
> `@alkdev/alknet` workspace where this crate originated. They are
|
> docs were ported from the parent `@alkdev/alknet` workspace where
|
||||||
> preserved here as the authoritative spec for alkcall; the alknet
|
> this crate originated, and renumbered to alkcall ADR-001..048
|
||||||
> mono-repo will consume alkcall's versions when it is reworked.
|
> (ADR-049 and later were authored in this crate). They are the
|
||||||
|
> authoritative spec here; the alknet mono-repo is being replaced by
|
||||||
|
> thin glue over alkcall and its siblings, so alknet-specific citations
|
||||||
|
> are expected to decay into footnotes over time, and anything that
|
||||||
|
> genuinely constrains alkcall's surface gets re-owned by an in-crate
|
||||||
|
> ADR (e.g. ADR-054 re-owns alknet ADR-034's verifier-selection rule).
|
||||||
>
|
>
|
||||||
> **Cross-references to non-ported ADRs**: Some spec docs and ADRs
|
> **Citation conventions**: alkcall's own ADRs are `decisions/001..054`
|
||||||
> reference alknet ADRs by their original numbers (e.g., ADR-052 for
|
> in this directory. Some ported docs also cite source-repo ADRs that
|
||||||
> TTY's wire format, ADR-082 for alknet-tls, ADR-086 for endpoint types).
|
> were **not** ported (e.g. alknet ADR-052 for TTY's wire format). The
|
||||||
> These are ADRs for sibling crates that are not part of alkcall. They
|
> alknet number space collides with alkcall's (alknet ADR-052 has
|
||||||
> retain their alknet numbering (052, 082, 086, etc.) — any ADR number
|
> nothing to do with alkcall's `channel/control` ADR-052), so unambiguous
|
||||||
> outside the alkcall range 001..048 is an alknet source ADR, found at
|
> references carry the `alknet` prefix (e.g. "alknet ADR-052") and link
|
||||||
> `/workspace/@alkdev/alknet/docs/architecture/decisions/`.
|
> to the alknet repo: `/workspace/@alkdev/alknet/docs/architecture/`
|
||||||
|
> (a few legacy cites without the prefix still mean the alknet ADR —
|
||||||
|
> check the link target). Dead relative paths like
|
||||||
|
> `docs/research/alknet-channels/...` live in the alknet repo, not this
|
||||||
|
> one. Similarly, `alknet OQ-NN` citations refer to the alknet repo's
|
||||||
|
> question tracker — this crate's OQ tracker runs its own numbering
|
||||||
|
> (OQ-01..43, see [open-questions.md](open-questions.md)).
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-17
|
last_updated: 2026-10-13 (alkcall reframing — historical alknet framing moved to the provenance note)
|
||||||
review: call/review-call passed 2026-06-23 — registry, protocol, ADR (005/012/014/015/016/017/022/023/024), security, and pattern-consistency checks all conformant; 159 unit/integration tests green; `cargo build`, `cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo test` clean. Call-completion gap (ADR-022 client/adapter surface) addressed 2026-06-26; ADR-024 migration landed. Transport generalization sweep (ADR-014 supersedes ADR-013; ADR-007 `from_stream`) synced 2026-07-09. Crate-extraction sweep (phases 0–5) landed 2026-07-17: `ConnectionCredentials`/`RemoteIdentity` in `alknet-core` (ADR-012); TLS helpers in `alknet-tls` (ADR-045 §5); dial in `alknet-client` (ADR-045); `alknet-call` is a pure protocol crate with no TLS/transport deps.
|
review: call/review-call passed 2026-06-23 — registry, protocol, ADR (005/012/014/015/016/017/022/023/024), security, and pattern-consistency checks all conformant; 159 unit/integration tests green; `cargo build`, `cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo test` clean. Call-completion gap (ADR-022 client/adapter surface) addressed 2026-06-26; ADR-024 migration landed. Transport generalization sweep (ADR-014 supersedes ADR-013; ADR-007 `from_stream`) synced 2026-07-09. Crate-extraction sweep (phases 0–5) landed 2026-07-17: `ConnectionCredentials`/`RemoteIdentity` vendored into alkcall's core types (ADR-012); dial extracted as the composition layer's helper (ADR-045); alkcall is a pure protocol crate with no TLS/transport deps.
|
||||||
---
|
---
|
||||||
|
|
||||||
# alknet-call
|
# alkcall — Call Protocol
|
||||||
|
|
||||||
Structured RPC: operations, request/response, streaming subscriptions, and service discovery. Implements `ProtocolHandler` on ALPN `alk/call`. Runs over QUIC (quinn/iroh) and, via `Connection::from_stream` (ADR-007), over any `AsyncRead + AsyncWrite` transport. A pure protocol crate — no TLS or transport deps (the dial is in `alknet-client`, the TLS config is in `alknet-tls`).
|
Structured RPC: operations, request/response, streaming subscriptions, and service discovery. Implements `ProtocolHandler` on ALPN `alk/call`. Runs over QUIC (quinn/iroh) and, via `Connection::from_stream` (ADR-007), over any `AsyncRead + AsyncWrite` transport. A pure protocol crate — no TLS or transport deps. The dial and TLS config belong to the composition layer (the dial helper is `AlknetClient`; the TLS config types live in the sibling host crate `alktls` — see ADR-045 and OQ-43).
|
||||||
|
|
||||||
## Documents
|
## Documents
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
|
|||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
| [call-protocol.md](call-protocol.md) | draft | CallAdapter, hand-rolled EventEnvelope framing (no irpc — ADR-014), stream model, PendingRequestMap, bidirectional calls |
|
| [call-protocol.md](call-protocol.md) | draft | CallAdapter, hand-rolled EventEnvelope framing (no irpc — ADR-014), stream model, PendingRequestMap, bidirectional calls |
|
||||||
| [operation-registry.md](operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, hand-rolled framing (no irpc — ADR-014) |
|
| [operation-registry.md](operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, hand-rolled framing (no irpc — ADR-014) |
|
||||||
| [client-and-adapters.md](client-and-adapters.md) | draft | CallClient (transport-agnostic `spawn_dispatch` primary; dial lives in `AlknetClient` per ADR-045), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (`from_jsonschema` in alknet-http per ADR-027) |
|
| [client-and-adapters.md](client-and-adapters.md) | draft | CallClient (transport-agnostic `spawn_dispatch` primary; dial lives in the composition layer's dial helper per ADR-045), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (`from_jsonschema` in alkhttp per ADR-027) |
|
||||||
|
|
||||||
## Applicable ADRs
|
## Applicable ADRs
|
||||||
|
|
||||||
@@ -22,54 +22,53 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
|
|||||||
|-----|-------|-----------|
|
|-----|-------|-----------|
|
||||||
| [001](decisions/001-alpn-protocol-dispatch.md) | ALPN-Based Protocol Dispatch | CallAdapter registers on ALPN `alk/call` |
|
| [001](decisions/001-alpn-protocol-dispatch.md) | ALPN-Based Protocol Dispatch | CallAdapter registers on ALPN `alk/call` |
|
||||||
| [002](decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | CallAdapter implements ProtocolHandler |
|
| [002](decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | CallAdapter implements ProtocolHandler |
|
||||||
| [003](decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-call depends on alknet-core (no irpc — ADR-014) |
|
| [031](decisions/031-crate-decomposition.md) | Crate Decomposition | alkcall is self-contained: call + channels subsystems + vendored core types (no irpc — ADR-014) |
|
||||||
| [013](decisions/013-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Adapter traits defined in Rust; TS is reference/browser adaptation |
|
| [033](decisions/033-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Adapter traits defined in Rust; TS is reference/browser adaptation |
|
||||||
| [004](decisions/004-auth-as-shared-core.md) | Auth as Shared Core | AuthContext passed to call handlers |
|
| [003](decisions/003-auth-as-shared-core.md) | Auth as Shared Core | AuthContext passed to call handlers |
|
||||||
| [005](decisions/005-irpc-as-call-protocol-foundation.md) | ~~irpc as Call Protocol Foundation~~ | ~~Accepted~~ → **Superseded** by [ADR-014](decisions/064-irpc-never-integrated-hand-rolled-framing.md) (irpc was never integrated; framing is hand-rolled) |
|
| [013](decisions/013-irpc-as-call-protocol-foundation.md) | ~~irpc as Call Protocol Foundation~~ | ~~Accepted~~ → **Superseded** by [ADR-014](decisions/014-irpc-never-integrated-hand-rolled-framing.md) (irpc was never integrated; framing is hand-rolled) |
|
||||||
| [064](decisions/064-irpc-never-integrated-hand-rolled-framing.md) | Hand-Rolled EventEnvelope Framing | Wire format, registry, dispatch are hand-rolled in alknet-call; supersedes ADR-013 |
|
| [014](decisions/014-irpc-never-integrated-hand-rolled-framing.md) | Hand-Rolled EventEnvelope Framing | Wire format, registry, dispatch are hand-rolled in alkcall; supersedes ADR-013 |
|
||||||
| [065](decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | Generic single-stream connections; unblocks TCP+TLS/SSH/WT/wasm dispatch |
|
| [007](decisions/007-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | Generic single-stream connections; unblocks TCP+TLS/SSH/WT/wasm dispatch |
|
||||||
| [006](decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention | `alk/call` ALPN, one ALPN per connection |
|
| [004](decisions/004-alpn-convention-and-connection-model.md) | ALPN String Convention | `alk/call` ALPN, one ALPN per connection |
|
||||||
| [007](decisions/007-bistream-type-definition.md) | BiStream Type Definition | CallAdapter receives Connection, not BiStream |
|
| [005](decisions/005-bistream-type-definition.md) | BiStream Type Definition | CallAdapter receives Connection, not BiStream |
|
||||||
| [008](decisions/008-secret-service-integration.md) | Vault Integration Point | Vault accessed at assembly layer, not on the wire |
|
| [015](decisions/015-call-protocol-stream-model.md) | Call Protocol Stream Model | Bidirectional streams, EventEnvelope, ID-based correlation |
|
||||||
| [010](decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Static handler registration |
|
| [010](decisions/010-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Call protocol carries no secret material; capabilities injected at assembly layer |
|
||||||
| [012](decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | Bidirectional streams, EventEnvelope, ID-based correlation |
|
| [017](decisions/017-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
|
||||||
| [014](decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Call protocol carries no secret material; capabilities injected at assembly layer |
|
| [020](decisions/020-abort-cascade-for-nested-calls.md) | Abort Cascade for Nested Calls | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
|
||||||
| [015](decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
|
| [022](decisions/022-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction |
|
||||||
| [016](decisions/016-abort-cascade-for-nested-calls.md) | Abort Cascade for Nested Calls | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
|
| [027](decisions/027-from-jsonschema-as-http-adapter.md) | `from_jsonschema` as HTTP-Backed Single-Endpoint Adapter | Moved `from_jsonschema` out of the call protocol (broken schema-only placeholder) to the HTTP host crate as a real reqwest-backed single-endpoint adapter — today `alkhttp` (ex-`alknet-http`); `FromJsonSchema` provenance stays in alkcall as a leaf |
|
||||||
| [017](decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction |
|
| [018](decisions/018-handler-registration-provenance-and-composition-authority.md) | Handler Registration, Provenance, and Composition Authority | Registration bundle carries provenance, composition authority, scoped env, capabilities |
|
||||||
| [066](decisions/066-from-jsonschema-as-http-adapter.md) | `from_jsonschema` as HTTP-Backed Single-Endpoint Adapter in alknet-http | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf |
|
| [016](decisions/016-operation-error-schemas.md) | Operation Error Schemas | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity |
|
||||||
| [022](decisions/022-handler-registration-provenance-and-composition-authority.md) | Handler Registration, Provenance, and Composition Authority | Registration bundle carries provenance, composition authority, scoped env, capabilities |
|
| [019](decisions/019-operation-registry-layering.md) | Operation Registry Layering | Curated (static) + session/connection overlays (dynamic); `OperationEnv` as trait-object integration point; `OperationContext.env` split into `scoped_env` (data) and `env` (dispatch trait) |
|
||||||
| [023](decisions/023-operation-error-schemas.md) | Operation Error Schemas | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity |
|
| [023](decisions/023-callclient-peer-scoped-registry-filtering.md) | ~~Peer-Scoped Registry Filtering~~ | ~~Accepted~~ → **Superseded** by ADR-024 (flat-namespace single-peer model couldn't express head→N-workers; parallel auth system duplicated `AccessControl`) |
|
||||||
| [024](decisions/024-operation-registry-layering.md) | Operation Registry Layering | Curated (static) + session/connection overlays (dynamic); `OperationEnv` as trait-object integration point; `OperationContext.env` split into `scoped_env` (data) and `env` (dispatch trait) |
|
| [024](decisions/024-peer-graph-routing-model.md) | Peer-Graph Routing Model | Peer-keyed overlays + `PeerRef` routing; `AccessControl`-based peer authorization; retires `remote_safe`/`trusted_peer` |
|
||||||
| [028](decisions/028-callclient-peer-scoped-registry-filtering.md) | ~~Peer-Scoped Registry Filtering~~ | ~~Accepted~~ → **Superseded** by ADR-024 (flat-namespace single-peer model couldn't express head→N-workers; parallel auth system duplicated `AccessControl`) |
|
| [025](decisions/025-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | `PeerId` source = `Identity.id` = `PeerEntry.peer_id` (stable); supersedes ADR-024's UUID source |
|
||||||
| [029](decisions/029-peer-graph-routing-model.md) | Peer-Graph Routing Model | Peer-keyed overlays + `PeerRef` routing; `AccessControl`-based peer authorization; retires `remote_safe`/`trusted_peer` |
|
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | `forwarded_for` on `OperationContext` and `call.requested`; metadata only, never used by `AccessControl::check` |
|
||||||
| [030](decisions/030-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | `PeerId` source = `Identity.id` = `PeerEntry.peer_id` (stable); supersedes ADR-024's UUID source |
|
| [045](decisions/045-alknetclient-native-dial-seam.md) | Dial Seam — Dial Out of the Protocol Crate | The dial lives in the composition layer's dial helper (`AlknetClient`, today in the alknet glue — future owner OQ-43); `CallClient` is `spawn_dispatch` only; alkcall is a pure protocol crate with no TLS/transport deps |
|
||||||
| [032](decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | `forwarded_for` on `OperationContext` and `call.requested`; metadata only, never used by `AccessControl::check` |
|
| [012](decisions/012-connectioncredentials-decouple-dial-from-call.md) | `ConnectionCredentials` — Decouple Dial from Call Protocol | `ConnectionCredentials`/`RemoteIdentity` live with the vendored core types in alkcall (originally `alknet-core`); `auth_token` is a per-request payload field |
|
||||||
| [033](decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Storage Boundary and Repo/Adapter Pattern | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
|
|
||||||
| [089](decisions/089-alknetclient-native-dial-seam.md) | AlknetClient — Native Client Dial Seam | The dial is in `alknet-client`; `CallClient` is `spawn_dispatch` only; `alknet-call` is a pure protocol crate with no TLS/transport deps |
|
Provenance (source-repo ADRs, not ported): [alknet ADR-008 — Vault Integration Point](/workspace/@alkdev/alknet/docs/architecture/decisions/008-secret-service-integration.md) (superseded in-crate by ADR-010) and [alknet ADR-010 — ALPN Router and Endpoint](/workspace/@alkdev/alknet/docs/architecture/decisions/010-alpn-router-and-endpoint.md) (superseded in-crate by ADR-001's ALPN dispatch and the decision that alkcall has no endpoint layer) are historical origins only. [alknet ADR-033 — Storage Boundary and Repo/Adapter Pattern](/workspace/@alkdev/alknet/docs/architecture/decisions/033-storage-boundary-and-repo-adapter-pattern.md) is the repo/adapter pattern behind the `OwnershipProvider` persistence story (OQ-19); its persistence adapters belong to the composition layer (OQ-43).
|
||||||
| [091](decisions/091-connectioncredentials-decouple-dial-from-call.md) | `ConnectionCredentials` — Decouple Dial from Call Protocol | `ConnectionCredentials`/`RemoteIdentity` in `alknet-core` (not `alknet-call`); `auth_token` is a per-request payload field |
|
|
||||||
|
|
||||||
## Relevant Open Questions
|
## Relevant Open Questions
|
||||||
|
|
||||||
| OQ | Title | Status | Relevance |
|
| OQ | Title | Status | Relevance |
|
||||||
|----|-------|--------|-----------|
|
|----|-------|--------|-----------|
|
||||||
| OQ-07 | Call protocol scope within a connection | resolved (ADR-015) | Stream model, multiplexing, scope |
|
| OQ-01 | Call protocol scope within a connection | resolved (ADR-015) | Stream model, multiplexing, scope |
|
||||||
| OQ-13 | Operation path format and routing scope | resolved | `/{service}/{op}` is the correct design; remote dispatch is a separate layer |
|
| OQ-02 | Operation path format and routing scope | resolved | `/{service}/{op}` is the correct design; remote dispatch is a separate layer |
|
||||||
| OQ-14 | Batch operation semantics | resolved | Correlated `call.requested` events is the correct protocol design |
|
| OQ-03 | Batch operation semantics | resolved | Correlated `call.requested` events is the correct protocol design |
|
||||||
| OQ-16 | Safe vault operations for call protocol exposure | resolved (ADR-010) | None exposed for now |
|
| OQ-04 | Session-scoped operation registries | resolved | Agent-written operations overlaid on curated registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Generalized by ADR-019 to cover connection-scoped overlays. |
|
||||||
| OQ-19 | Session-scoped operation registries | resolved | Agent-written operations overlaid on curated registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Generalized by ADR-019 to cover connection-scoped overlays. |
|
| OQ-09 | Safe vault operations for call protocol exposure | resolved (ADR-010) | None exposed for now |
|
||||||
| OQ-25 | ~~Remote-safe marking shape~~ | **dissolved** (ADR-024) | `remote_safe`/`trusted_peer` retired; peer authorization is `AccessControl::check(peer_identity)` |
|
| OQ-10 | ~~Remote-safe marking shape~~ | **dissolved** (ADR-024) | `remote_safe`/`trusted_peer` retired; peer authorization is `AccessControl::check(peer_identity)` |
|
||||||
| OQ-26 | OperationAdapter error type (AdapterError variants) | **resolved** | `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`; `#[non_exhaustive]` |
|
| OQ-11 | OperationAdapter error type (AdapterError variants) | **resolved** | `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`; `#[non_exhaustive]` |
|
||||||
| OQ-27 | from_call re-import trigger | **resolved** | `from_call` is a manual free function; the assembly layer calls it after the dial (in `AlknetClient`). `refresh()` is a genuine feature addition. See ADR-028. |
|
| OQ-12 | from_call re-import trigger | **resolved** | `from_call` is a manual free function; the assembly layer calls it after the dial (in the composition layer's dial helper, `AlknetClient` — today the alknet glue; OQ-43). `refresh()` is a genuine feature addition. See ADR-028. |
|
||||||
| OQ-28 | from_call namespace collision | **resolved** | Same-peer collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays) |
|
| OQ-13 | from_call namespace collision | **resolved** | Same-peer collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays) |
|
||||||
| OQ-29 | CallClient TLS client-auth | **resolved** | Wire quinn client-auth; key-type-aware server cert verification; fingerprint normalization |
|
| OQ-14 | CallClient TLS client-auth | **resolved** | Wire quinn client-auth; key-type-aware server cert verification; fingerprint normalization |
|
||||||
| OQ-30 | `PeerRef::Any` routing policy | **resolved** | Insertion-order first-match; richer routing is a feature extension |
|
| OQ-15 | `PeerRef::Any` routing policy | **resolved** | Insertion-order first-match; richer routing is a feature extension |
|
||||||
| OQ-31 | `services/list-peers` re-export semantics | **resolved** | Opt-in `services/list-peers`; `services/list` is "own ops only" |
|
| OQ-16 | `services/list-peers` re-export semantics | **resolved** | Opt-in `services/list-peers`; `services/list` is "own ops only" |
|
||||||
| OQ-32 | Multi-hop federation | open (feature extension) | One-hop model is the commitment; multi-hop is a feature extension, not a deferral |
|
| OQ-17 | Multi-hop federation | deferred(scope) (feature extension) | One-hop model is the commitment; multi-hop is a feature extension, not a deferral |
|
||||||
| OQ-33 | PeerId — crypto identity vs stable logical id | **resolved** (ADR-025) | `PeerId = Identity.id = PeerEntry.peer_id` (stable across key rotation) |
|
| OQ-18 | PeerId — crypto identity vs stable logical id | **resolved** (ADR-025) | `PeerId = Identity.id = PeerEntry.peer_id` (stable across key rotation) |
|
||||||
| OQ-34 | Persistent peer registry | **resolved** (ADR-025+033) | Core trait + in-memory default; persistence adapters are separate crates |
|
| OQ-19 | Persistent peer registry | **resolved** (ADR-025; persistence adapters are composition-layer work — OQ-43) | Core trait + in-memory default; persistence adapters are separate crates |
|
||||||
| OQ-35 | ~~API key asymmetry~~ | **dissolved** | `PeerEntry` supports multiple credential paths; `ApiKeyEntry` is for tokens that ARE the identity |
|
| OQ-20 | ~~API key asymmetry~~ | **dissolved** | `PeerEntry` supports multiple credential paths; `ApiKeyEntry` is for tokens that ARE the identity |
|
||||||
| OQ-37 | X.509 outgoing-only case | **resolved** (ADR-034) | Three remote roles (public X.509 endpoint, transport relay, hub); `PeerEntry` asymmetry correct; verifier by `PeerEntry` presence |
|
| OQ-21 | X.509 outgoing-only case | **resolved** (ADR-054; provenance: alknet ADR-034, not ported) | Three remote roles (public X.509 endpoint, transport relay, hub); `PeerEntry` asymmetry correct; verifier by `PeerEntry` presence — re-owned in-crate by ADR-054 |
|
||||||
|
|
||||||
## Key Design Principles
|
## Key Design Principles
|
||||||
|
|
||||||
@@ -78,12 +77,12 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
|
|||||||
3. **Stream-agnostic correlation**: PendingRequestMap correlates by request ID, not by stream. The protocol works with any stream arrangement.
|
3. **Stream-agnostic correlation**: PendingRequestMap correlates by request ID, not by stream. The protocol works with any stream arrangement.
|
||||||
4. **Operation registry is layered**: The curated layer (`Local` provenance) is static — registered at startup by the CLI binary, immutable for the process lifetime. Session (`Session`) and imported (`FromCall` etc.) ops are dynamic overlays at their respective scopes (per-session, per-connection). The registry supports JSON Schema discovery. See ADR-019.
|
4. **Operation registry is layered**: The curated layer (`Local` provenance) is static — registered at startup by the CLI binary, immutable for the process lifetime. Session (`Session`) and imported (`FromCall` etc.) ops are dynamic overlays at their respective scopes (per-session, per-connection). The registry supports JSON Schema discovery. See ADR-019.
|
||||||
5. **Hand-rolled dispatch (no irpc)**: Operations dispatch through the hand-rolled `OperationRegistry` (ADR-014). The call protocol is the external interface; internal handler dispatch uses `Handler`/`StreamingHandler` trait objects (ADR-021), not an irpc service.
|
5. **Hand-rolled dispatch (no irpc)**: Operations dispatch through the hand-rolled `OperationRegistry` (ADR-014). The call protocol is the external interface; internal handler dispatch uses `Handler`/`StreamingHandler` trait objects (ADR-021), not an irpc service.
|
||||||
6. **Local dispatch only**: The operation registry dispatches to local handlers. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer, not a modification to alknet-call's path format.
|
6. **Local dispatch only**: The operation registry dispatches to local handlers. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer, not a modification to alkcall's path format.
|
||||||
7. **No secret material on the wire**: The call protocol carries no private keys, API keys, mnemonics, or decrypted credentials. Handlers receive outbound credentials through `OperationContext.capabilities`, injected at the assembly layer. See ADR-010.
|
7. **No secret material on the wire**: The call protocol carries no private keys, API keys, mnemonics, or decrypted credentials. Handlers receive outbound credentials through `OperationContext.capabilities`, injected at the assembly layer. See ADR-010.
|
||||||
8. **Abort cascades to descendants**: `call.aborted` for a parent request cascades to all non-terminal descendants. Default `abort-dependents`; `continue-running` opt-in. See ADR-020.
|
8. **Abort cascades to descendants**: `call.aborted` for a parent request cascades to all non-terminal descendants. Default `abort-dependents`; `continue-running` opt-in. See ADR-020.
|
||||||
9. **Internal calls switch authority context, not skip ACL**: The `internal` flag marks composition-originated calls. ACL runs against the handler's composition authority, not the caller's and not as a blanket skip. Operations have External/Internal visibility. Scoped composition env bounds reachability. See ADR-017, ADR-018.
|
9. **Internal calls switch authority context, not skip ACL**: The `internal` flag marks composition-originated calls. ACL runs against the handler's composition authority, not the caller's and not as a blanket skip. Operations have External/Internal visibility. Scoped composition env bounds reachability. See ADR-017, ADR-018.
|
||||||
10. **Provenance determines composition capability**: Only `Local` and `Session` ops can compose. Leaves (`FromOpenAPI`, `FromMCP`, `FromCall`, `FromJsonSchema`) are forwarding stubs — they don't get composition authority or a scoped env. The assembly layer is the sole grantor of composition authority. See ADR-018. (`FromJsonSchema` is now a real HTTP-forwarding leaf per ADR-027, not a schema-only placeholder.)
|
10. **Provenance determines composition capability**: Only `Local` and `Session` ops can compose. Leaves (`FromOpenAPI`, `FromMCP`, `FromCall`, `FromJsonSchema`) are forwarding stubs — they don't get composition authority or a scoped env. The assembly layer is the sole grantor of composition authority. See ADR-018. (`FromJsonSchema` is now a real HTTP-forwarding leaf per ADR-027, not a schema-only placeholder.)
|
||||||
11. **Connection direction is independent of call direction**: Who opens the connection is a connection-layer concern, not a protocol-layer concern. Both sides can call each other once connected. The `CallAdapter` accepts connections; the `CallClient` takes them over (`spawn_dispatch` primary; dial in `AlknetClient` per ADR-045); both produce the same `CallConnection` and dispatch through the same loop. See ADR-022, [client-and-adapters.md](client-and-adapters.md).
|
11. **Connection direction is independent of call direction**: Who opens the connection is a connection-layer concern, not a protocol-layer concern. Both sides can call each other once connected. The `CallAdapter` accepts connections; the `CallClient` takes them over (`spawn_dispatch` primary; dial in `AlknetClient` per ADR-045); both produce the same `CallConnection` and dispatch through the same loop. See ADR-022, [client-and-adapters.md](client-and-adapters.md).
|
||||||
12. **Peer authorization via `AccessControl`**: A remote peer's call is authorized by `AccessControl::check(peer_identity)` against the op's `AccessControl` — the same mechanism that gates every other call. No `remote_safe` flag, no `trusted_peer` bypass. An op with `AccessControl::default()` is callable by any peer; an op with `required_scopes` is callable only by peers whose `Identity.scopes` satisfy them; an op with `Visibility::Internal` is never callable from the wire. See ADR-024.
|
12. **Peer authorization via `AccessControl`**: A remote peer's call is authorized by `AccessControl::check(peer_identity)` against the op's `AccessControl` — the same mechanism that gates every other call. No `remote_safe` flag, no `trusted_peer` bypass. An op with `AccessControl::default()` is callable by any peer; an op with `required_scopes` is callable only by peers whose `Identity.scopes` satisfy them; an op with `Visibility::Internal` is never callable from the wire. See ADR-024.
|
||||||
13. **Adapter trait lives with the types; implementations live with their transport**: `OperationAdapter` is in `alknet-call`; `from_call` is in `alknet-call` (QUIC); `from_jsonschema`/`from_openapi`/`from_mcp`/`to_openapi`/`to_mcp` are in `alknet-http` (reqwest / axum). `alknet-call` stays lean — no HTTP client, no HTTP server. (`from_jsonschema` was originally in `alknet-call` as a schema-only placeholder; ADR-027 moved it to `alknet-http` as a real HTTP-backed adapter.) See [client-and-adapters.md](client-and-adapters.md).
|
13. **Adapter trait lives with the types; implementations live with their transport**: `OperationAdapter` is in alkcall; `from_call` is in alkcall (QUIC); `from_jsonschema`/`from_openapi`/`from_mcp`/`to_openapi`/`to_mcp` are in the HTTP host crate, `alkhttp` (reqwest / axum). alkcall stays lean — no HTTP client, no HTTP server. (`from_jsonschema` was originally in the call protocol as a schema-only placeholder; ADR-027 moved it to `alkhttp` — then named `alknet-http` — as a real HTTP-backed adapter.) See [client-and-adapters.md](client-and-adapters.md).
|
||||||
14. **No handler reads outbound credentials from any source other than `OperationContext.capabilities`** (no-env-vars invariant): the credential injection path is vault → assembly layer → `Capabilities` → `HandlerRegistration.capabilities` → `OperationContext.capabilities` → handler. Downstream consumers' `std::env::var` reads are unreachable because the assembly layer never calls `Default::default()`. See ADR-010, [client-and-adapters.md](client-and-adapters.md).
|
14. **No handler reads outbound credentials from any source other than `OperationContext.capabilities`** (no-env-vars invariant): the credential injection path is vault → assembly layer → `Capabilities` → `HandlerRegistration.capabilities` → `OperationContext.capabilities` → handler. Downstream consumers' `std::env::var` reads are unreachable because the assembly layer never calls `Default::default()`. See ADR-010, [client-and-adapters.md](client-and-adapters.md).
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-09
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# Call Protocol
|
# Call Protocol
|
||||||
|
|
||||||
The wire protocol, stream model, framing, and adapter that alknet-call implements on ALPN `alk/call`.
|
The wire protocol, stream model, framing, and adapter that alkcall implements on ALPN `alk/call`.
|
||||||
|
|
||||||
## What
|
## What
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ The `CallAdapter` implements `ProtocolHandler` for ALPN `alk/call`. It receives
|
|||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
The call protocol is the primary programmatic interface to an alknet node. While SSH provides interactive shell access and HTTP provides REST APIs, the call protocol provides structured, discoverable RPC — the same interface that NAPI clients, MCP tools, and other automation consumers use.
|
The call protocol is the primary programmatic interface to an alk node. While SSH provides interactive shell access and HTTP provides REST APIs, the call protocol provides structured, discoverable RPC — the same interface that NAPI clients, MCP tools, and other automation consumers use.
|
||||||
|
|
||||||
The protocol must be:
|
The protocol must be:
|
||||||
- **Cross-language**: JSON wire format consumable from TypeScript, Python, any language
|
- **Cross-language**: JSON wire format consumable from TypeScript, Python, any language
|
||||||
@@ -40,7 +40,7 @@ pub struct CallAdapter {
|
|||||||
registry: Arc<OperationRegistry>,
|
registry: Arc<OperationRegistry>,
|
||||||
identity_provider: Arc<dyn IdentityProvider>,
|
identity_provider: Arc<dyn IdentityProvider>,
|
||||||
/// Layer 1 — optional session-overlay source (agent crate supplies this;
|
/// Layer 1 — optional session-overlay source (agent crate supplies this;
|
||||||
/// None for non-agent deployments). See ADR-019, OQ-19.
|
/// None for non-agent deployments). See ADR-019, OQ-04.
|
||||||
session_source: Option<Arc<dyn SessionOverlaySource + Send + Sync>>,
|
session_source: Option<Arc<dyn SessionOverlaySource + Send + Sync>>,
|
||||||
/// Default timeout for wire calls (30s). Composed calls inherit the
|
/// Default timeout for wire calls (30s). Composed calls inherit the
|
||||||
/// parent's remaining deadline via `OperationContext.deadline`.
|
/// parent's remaining deadline via `OperationContext.deadline`.
|
||||||
@@ -57,8 +57,9 @@ impl CallAdapter {
|
|||||||
default_timeout: Duration::from_secs(30) }
|
default_timeout: Duration::from_secs(30) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Agent deployment: supply a session-overlay source. The agent crate
|
/// Agent deployment: supply a session-overlay source. A downstream
|
||||||
/// implements `SessionOverlaySource`; alknet-call defines the trait.
|
/// consumer (e.g. the agent crate) implements `SessionOverlaySource`;
|
||||||
|
/// alkcall defines the trait.
|
||||||
pub fn with_session_source(mut self, source: Arc<dyn SessionOverlaySource + Send + Sync>) -> Self {
|
pub fn with_session_source(mut self, source: Arc<dyn SessionOverlaySource + Send + Sync>) -> Self {
|
||||||
self.session_source = Some(source);
|
self.session_source = Some(source);
|
||||||
self
|
self
|
||||||
@@ -71,10 +72,11 @@ impl CallAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Session overlay integration point (ADR-019). Defined in alknet-call
|
/// Session overlay integration point (ADR-019). Defined in alkcall
|
||||||
/// because `CallAdapter` must name the type — alknet-call cannot depend on
|
/// because `CallAdapter` must name the type — alkcall cannot depend on
|
||||||
/// alknet-agent (agent depends on call, not reverse). The agent crate
|
/// its downstream consumers (an agent depends on call, not reverse). The
|
||||||
/// implements this trait; alknet-call defines it. This is the same pattern
|
/// consumer (e.g. the agent crate) implements this trait; alkcall
|
||||||
|
/// defines it. This is the same pattern
|
||||||
/// as `IdentityProvider` (ADR-003: core defines the trait, handlers impl it).
|
/// as `IdentityProvider` (ADR-003: core defines the trait, handlers impl it).
|
||||||
///
|
///
|
||||||
/// The session overlay is an `OperationEnv` impl that wraps the curated base
|
/// The session overlay is an `OperationEnv` impl that wraps the curated base
|
||||||
@@ -129,18 +131,16 @@ pub struct CallConnection {
|
|||||||
impl CallConnection {
|
impl CallConnection {
|
||||||
/// Register an imported operation into this connection's overlay
|
/// Register an imported operation into this connection's overlay
|
||||||
/// (Layer 2, ADR-019). Called by `from_call` after discovery.
|
/// (Layer 2, ADR-019). Called by `from_call` after discovery.
|
||||||
pub fn register_imported(&self, registration: HandlerRegistration) {
|
/// Refuses marked (`channel_open`) specs — they are not
|
||||||
let name = registration.spec.name.clone();
|
/// overlay-importable (ADR-047 amendment 4 §4.8).
|
||||||
self.imported_operations.write().insert(name, registration);
|
pub fn register_imported(&self, registration: HandlerRegistration)
|
||||||
}
|
-> Result<(), String>;
|
||||||
|
|
||||||
/// Register multiple imported operations (bulk variant for `from_call`).
|
/// Register multiple imported operations (bulk variant for `from_call`).
|
||||||
pub fn register_imported_all(&self, registrations: Vec<HandlerRegistration>) {
|
/// Same marked-spec refusal; on refusal the entries inserted before the
|
||||||
let mut overlay = self.imported_operations.write();
|
/// marked one stay in the overlay and the error is returned.
|
||||||
for reg in registrations {
|
pub fn register_imported_all(&self, registrations: Vec<HandlerRegistration>)
|
||||||
overlay.insert(reg.spec.name.clone(), reg);
|
-> Result<(), String>;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build an `OperationEnv` impl for this connection's overlay. Used by
|
/// Build an `OperationEnv` impl for this connection's overlay. Used by
|
||||||
/// the `CallAdapter` when composing the root `OperationContext.env`.
|
/// the `CallAdapter` when composing the root `OperationContext.env`.
|
||||||
@@ -167,6 +167,24 @@ Layer-0-only; runtime overlay registration uses `CallConnection` methods.
|
|||||||
When the connection drops, the overlay (and all imported ops) is dropped —
|
When the connection drops, the overlay (and all imported ops) is dropped —
|
||||||
no explicit deregistration needed.
|
no explicit deregistration needed.
|
||||||
|
|
||||||
|
**Marked-spec registration refusal** (ADR-047 amendment 4 §4.8; ADR-022
|
||||||
|
amendment 2026-10-13): every generic registration path —
|
||||||
|
`OperationRegistry::register`, `CallConnection::register_imported`,
|
||||||
|
`register_imported_all`, and the `op/register` announcement path — refuses
|
||||||
|
a marked (`channel_open`) spec loudly at registration time. Marked ops are
|
||||||
|
channel-machinery ops (the data plane is the allocated channel's
|
||||||
|
`BiStream`); a marked spec can never land as an advertised-but-unusable
|
||||||
|
overlay body. The sanctioned marked-op imports are the channel-wrapper
|
||||||
|
family: `register_openable*`, `register_pub_openable`,
|
||||||
|
`register_relay_openable`. `op/register` rejects a marked announcement
|
||||||
|
with the typed error `MARKED_SPEC_UNSUPPORTED` (ADR-016 error schema),
|
||||||
|
naming `ChannelRelay::register_relay_openable` (relaying hub) and
|
||||||
|
`ChannelClient` (direct consumption) as the sanctioned compositions.
|
||||||
|
Unmarked `Sub`/`Pub` announcements register the real forwarding handler
|
||||||
|
shapes (ADR-022 amendment 2026-10-13). This closes the stub class: an
|
||||||
|
advertised op is always a capable one — implement-or-stop-advertising is
|
||||||
|
release-blocking (`tests/stub_guard.rs`).
|
||||||
|
|
||||||
The adapter:
|
The adapter:
|
||||||
1. Accepts bidirectional streams on the connection
|
1. Accepts bidirectional streams on the connection
|
||||||
2. Reads length-prefixed JSON `EventEnvelope` frames from each stream
|
2. Reads length-prefixed JSON `EventEnvelope` frames from each stream
|
||||||
@@ -185,7 +203,7 @@ differs (accept vs dial). Peer authorization flows through the existing
|
|||||||
(ADR-024 §3). The composition env is peer-keyed (`PeerCompositeEnv`,
|
(ADR-024 §3). The composition env is peer-keyed (`PeerCompositeEnv`,
|
||||||
ADR-024 §1) to handle head→N-workers routing. See
|
ADR-024 §1) to handle head→N-workers routing. See
|
||||||
[client-and-adapters.md](client-and-adapters.md) for the `Dispatcher` mechanism
|
[client-and-adapters.md](client-and-adapters.md) for the `Dispatcher` mechanism
|
||||||
and [ADR-024](decisions/029-peer-graph-routing-model.md) for the
|
and [ADR-024](decisions/024-peer-graph-routing-model.md) for the
|
||||||
peer-graph routing model.
|
peer-graph routing model.
|
||||||
|
|
||||||
### Stream Model
|
### Stream Model
|
||||||
@@ -218,7 +236,7 @@ The `Value` type is `serde_json::Value`. The envelope is JSON because it must be
|
|||||||
Binary payloads (postcard, protobuf) are base64-encoded as a JSON string within the `payload` field. The convention is: if an operation's output schema specifies a binary field, the handler encodes it as a base64 string and the client decodes it. The `EventEnvelope` structure is not aware of this convention — it carries a `serde_json::Value` and does not interpret the payload. This is a handler-level concern, not a protocol-level concern.
|
Binary payloads (postcard, protobuf) are base64-encoded as a JSON string within the `payload` field. The convention is: if an operation's output schema specifies a binary field, the handler encodes it as a base64 string and the client decodes it. The `EventEnvelope` structure is not aware of this convention — it carries a `serde_json::Value` and does not interpret the payload. This is a handler-level concern, not a protocol-level concern.
|
||||||
|
|
||||||
This is hand-rolled length-prefixed JSON framing (ADR-014), coincidentally
|
This is hand-rolled length-prefixed JSON framing (ADR-014), coincidentally
|
||||||
the same shape irpc uses. The Rust implementation in alknet-call is
|
the same shape irpc uses. The Rust implementation in alkcall is
|
||||||
canonical — the `@alkdev/pubsub` TypeScript adapters serve as a reference
|
canonical — the `@alkdev/pubsub` TypeScript adapters serve as a reference
|
||||||
and browser adaptation, not a parallel implementation (see ADR-033).
|
and browser adaptation, not a parallel implementation (see ADR-033).
|
||||||
|
|
||||||
@@ -265,7 +283,7 @@ The `payload` of a `call.requested` event has this shape:
|
|||||||
- `operationId` — the operation to invoke, **with a leading slash** on the wire (e.g., `/fs/readFile`, `/agent/chat`, `/services/list`). This is the display form of the operation name. The registry stores names without the leading slash (`fs/readFile` — see [operation-registry.md](operation-registry.md#operationspec)); the wire format adds it. The `CallAdapter` strips the leading slash before registry lookup.
|
- `operationId` — the operation to invoke, **with a leading slash** on the wire (e.g., `/fs/readFile`, `/agent/chat`, `/services/list`). This is the display form of the operation name. The registry stores names without the leading slash (`fs/readFile` — see [operation-registry.md](operation-registry.md#operationspec)); the wire format adds it. The `CallAdapter` strips the leading slash before registry lookup.
|
||||||
- `input` — the operation input, matching the operation's `input_schema` (JSON Schema). Always a `serde_json::Value`.
|
- `input` — the operation input, matching the operation's `input_schema` (JSON Schema). Always a `serde_json::Value`.
|
||||||
- `auth_token` — optional. If present, the `CallAdapter` resolves it via `IdentityProvider::resolve_from_token()` and the resulting `Identity` takes precedence over the connection-level identity for this request. See [Identity Resolution](#authcontext-and-identity-resolution) below.
|
- `auth_token` — optional. If present, the `CallAdapter` resolves it via `IdentityProvider::resolve_from_token()` and the resulting `Identity` takes precedence over the connection-level identity for this request. See [Identity Resolution](#authcontext-and-identity-resolution) below.
|
||||||
- `forwarded_for` — optional (ADR-026). Present when a `from_call` forwarding handler propagates the originator's identity to a spoke. Carries a serialized `Identity` (id, scopes, resources) — the end user the hub authenticated. **Metadata only** — `AccessControl::check` never reads it; the spoke authorizes the hub (its direct caller), not the end user. The hub may set `forwarded_for: None` if it doesn't want to disclose the originator. See [ADR-026](decisions/032-forwarded-for-identity.md).
|
- `forwarded_for` — optional (ADR-026). Present when a `from_call` forwarding handler propagates the originator's identity to a spoke. Carries a serialized `Identity` (id, scopes, resources) — the end user the hub authenticated. **Metadata only** — `AccessControl::check` never reads it; the spoke authorizes the hub (its direct caller), not the end user. The hub may set `forwarded_for: None` if it doesn't want to disclose the originator. See [ADR-026](decisions/026-forwarded-for-identity.md).
|
||||||
|
|
||||||
The `call.requested` payload does **not** carry an abort policy field. The abort policy (`abort-dependents` vs `continue-running`, ADR-020) is set on `OperationContext` and propagated through `OperationEnv::invoke()` — the composing handler decides the child's policy, not the wire caller. See [Abort Cascade and Nested Calls](#abort-cascade-and-nested-calls) below.
|
The `call.requested` payload does **not** carry an abort policy field. The abort policy (`abort-dependents` vs `continue-running`, ADR-020) is set on `OperationContext` and propagated through `OperationEnv::invoke()` — the composing handler decides the child's policy, not the wire caller. See [Abort Cascade and Nested Calls](#abort-cascade-and-nested-calls) below.
|
||||||
|
|
||||||
@@ -336,7 +354,7 @@ The call protocol defines five top-level operations, expressed through event typ
|
|||||||
| **batch** | multiple `call.requested` (different IDs) → multiple `call.responded` | Multiple operations in one round |
|
| **batch** | multiple `call.requested` (different IDs) → multiple `call.responded` | Multiple operations in one round |
|
||||||
| **schema** | `call.requested` name `services/list` or `services/schema` → `call.responded` | Discover available operations |
|
| **schema** | `call.requested` name `services/list` or `services/schema` → `call.responded` | Discover available operations |
|
||||||
|
|
||||||
Batch is not a separate event type — it's multiple `call.requested` events with different request IDs. The client sends them (on one or many streams) and correlates the responses by ID. See OQ-14.
|
Batch is not a separate event type — it's multiple `call.requested` events with different request IDs. The client sends them (on one or many streams) and correlates the responses by ID. See OQ-03.
|
||||||
|
|
||||||
### Bidirectional Calls
|
### Bidirectional Calls
|
||||||
|
|
||||||
@@ -557,8 +575,8 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
|
|||||||
|
|
||||||
- The call protocol does not depend on any database. `PendingRequestMap` is in-memory. Durable session storage is a consumer concern.
|
- The call protocol does not depend on any database. `PendingRequestMap` is in-memory. Durable session storage is a consumer concern.
|
||||||
- Operation specs use JSON Schema. The envelope is always JSON. Binary payloads may be base64-encoded in the `payload` field.
|
- Operation specs use JSON Schema. The envelope is always JSON. Binary payloads may be base64-encoded in the `payload` field.
|
||||||
- Batch is not a protocol primitive — multiple `call.requested` events with correlated IDs provide equivalent semantics. See OQ-14.
|
- Batch is not a protocol primitive — multiple `call.requested` events with correlated IDs provide equivalent semantics. See OQ-03.
|
||||||
- The call protocol is transport-agnostic at the envelope level. The `EventEnvelope` framing can run over QUIC streams, WebSocket frames, or Worker `postMessage`. The `CallAdapter` is the `ProtocolHandler` implementation that receives a `Connection` (any transport — ADR-007) and dispatches `EventEnvelope` frames. **The `EventEnvelope` shape (`{ type, id, payload }`) was derived from the `@alkdev/pubsub` `EventEnvelope` (`/workspace/@alkdev/pubsub/src/types.ts`), which already has a working WebSocket client/server implementation (`event-target-websocket-client.ts` / `event-target-websocket-server.ts`) and a generalized "event target" abstraction. The call protocol refined the envelope with typed event names (`call.requested`, `call.responded`, etc.) and structured payloads; the delta is small and well-defined, making a browser (and Node) WebSocket client straightforward to derive from the pubsub prior art. See ADR-044, [ADR-048](decisions/048-websocket-native-session-not-gateway.md), and [websocket.md](../http/websocket.md).
|
- The call protocol is transport-agnostic at the envelope level. The `EventEnvelope` framing can run over QUIC streams, WebSocket frames, or Worker `postMessage`. The `CallAdapter` is the `ProtocolHandler` implementation that receives a `Connection` (any transport — ADR-007) and dispatches `EventEnvelope` frames. **The `EventEnvelope` shape (`{ type, id, payload }`) was derived from the `@alkdev/pubsub` `EventEnvelope` (`/workspace/@alkdev/pubsub/src/types.ts`), which already has a working WebSocket client/server implementation (`event-target-websocket-client.ts` / `event-target-websocket-server.ts`) and a generalized "event target" abstraction. The call protocol refined the envelope with typed event names (`call.requested`, `call.responded`, etc.) and structured payloads; the delta is small and well-defined, making a browser (and Node) WebSocket client straightforward to derive from the pubsub prior art. Provenance: alknet ADR-044 and the alknet websocket spec (not ported) recorded the WebSocket-native session decision; the WebSocket carrier for the alk protocols is implemented in the sibling host crate `alkhttp` (its `websocket` feature), not in alkcall — alkcall stays transport- and host-blind.
|
||||||
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer. See ADR-014 and OQ-13.
|
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer. See ADR-014 and OQ-13.
|
||||||
- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials, raw tokens) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. The wire format carries `serde_json::Value` and cannot enforce this at the type level — the constraint is architectural, enforced by the operation registry and by convention. Operations that need to share public key material use a dedicated operation that returns only the public component. See ADR-010.
|
- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials, raw tokens) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. The wire format carries `serde_json::Value` and cannot enforce this at the type level — the constraint is architectural, enforced by the operation registry and by convention. Operations that need to share public key material use a dedicated operation that returns only the public component. See ADR-010.
|
||||||
- **Abort cascades to descendants.** `call.aborted` for a parent request cascades to all non-terminal descendants in the call tree. Default policy is `abort-dependents`; `continue-running` is an opt-in. See ADR-020.
|
- **Abort cascades to descendants.** `call.aborted` for a parent request cascades to all non-terminal descendants in the call tree. Default policy is `abort-dependents`; `continue-running` is an opt-in. See ADR-020.
|
||||||
@@ -567,19 +585,19 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
|
|||||||
|
|
||||||
| Decision | ADR | Summary |
|
| Decision | ADR | Summary |
|
||||||
|----------|-----|---------|
|
|----------|-----|---------|
|
||||||
| Hand-rolled EventEnvelope framing (irpc never integrated) | [ADR-014](decisions/064-irpc-never-integrated-hand-rolled-framing.md) | Hand-rolled length-prefixed JSON framing, operation registry, dispatch; supersedes ADR-013 (irpc was never imported) |
|
| Hand-rolled EventEnvelope framing (irpc never integrated) | [ADR-014](decisions/014-irpc-never-integrated-hand-rolled-framing.md) | Hand-rolled length-prefixed JSON framing, operation registry, dispatch; supersedes ADR-013 (irpc was never imported) |
|
||||||
| Call protocol stream model | [ADR-015](decisions/012-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
|
| Call protocol stream model | [ADR-015](decisions/015-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
|
||||||
| ALPN per connection | [ADR-004](decisions/006-alpn-convention-and-connection-model.md) | `alk/call` is a distinct ALPN, one connection per ALPN |
|
| ALPN per connection | [ADR-004](decisions/004-alpn-convention-and-connection-model.md) | `alk/call` is a distinct ALPN, one connection per ALPN |
|
||||||
| ProtocolHandler receives Connection | [ADR-005](decisions/007-bistream-type-definition.md) | CallAdapter gets Connection, can accept/open multiple streams |
|
| ProtocolHandler receives Connection | [ADR-005](decisions/005-bistream-type-definition.md) | CallAdapter gets Connection, can accept/open multiple streams |
|
||||||
| Vault integration point | [ADR-008](decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time |
|
| Vault integration point | [ADR-010](decisions/010-secret-material-flow-and-capability-injection.md) | Vault is a capability source, accessed at assembly time (provenance: alknet ADR-008, superseded in-crate by ADR-010) |
|
||||||
| Secret material flow | [ADR-010](decisions/014-secret-material-flow-and-capability-injection.md) | Call protocol carries no secret material; capabilities injected at assembly layer |
|
| Secret material flow | [ADR-010](decisions/010-secret-material-flow-and-capability-injection.md) | Call protocol carries no secret material; capabilities injected at assembly layer |
|
||||||
| Privilege model and authority context | [ADR-017](decisions/015-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
|
| Privilege model and authority context | [ADR-017](decisions/017-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
|
||||||
| Abort cascade for nested calls | [ADR-020](decisions/016-abort-cascade-for-nested-calls.md) | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
|
| Abort cascade for nested calls | [ADR-020](decisions/020-abort-cascade-for-nested-calls.md) | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
|
||||||
| Call protocol client and adapter contract | [ADR-022](decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction. Client/adapter surface specced in [client-and-adapters.md](client-and-adapters.md) |
|
| Call protocol client and adapter contract | [ADR-022](decisions/022-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction. Client/adapter surface specced in [client-and-adapters.md](client-and-adapters.md) |
|
||||||
| Handler registration, provenance, and composition authority | [ADR-018](decisions/022-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
|
| Handler registration, provenance, and composition authority | [ADR-018](decisions/018-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
|
||||||
| Peer-graph routing model (supersedes ADR-023) | [ADR-024](decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; `AccessControl`-based peer authorization; retires `remote_safe`/`trusted_peer` |
|
| Peer-graph routing model (supersedes ADR-023) | [ADR-024](decisions/024-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; `AccessControl`-based peer authorization; retires `remote_safe`/`trusted_peer` |
|
||||||
| Forwarded-for identity | [ADR-026](decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
|
| Forwarded-for identity | [ADR-026](decisions/026-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
|
||||||
| Operation error schemas | [ADR-016](decisions/023-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details` |
|
| Operation error schemas | [ADR-016](decisions/016-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details` |
|
||||||
| Streaming handler for subscriptions | [ADR-021](decisions/021-streaming-handler-for-subscriptions.md) | `StreamingHandler` type, `invoke_streaming()` dispatch path, `INVALID_OPERATION_TYPE` protocol code; the server-side streaming branch in `handle_stream` |
|
| Streaming handler for subscriptions | [ADR-021](decisions/021-streaming-handler-for-subscriptions.md) | `StreamingHandler` type, `invoke_streaming()` dispatch path, `INVALID_OPERATION_TYPE` protocol code; the server-side streaming branch in `handle_stream` |
|
||||||
| Publish operation type and HandlerKind::Sink | [ADR-046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | `OperationType::Pub` (producer→consumer streaming); `SinkHandler` + `HandlerKind::Sink`; `call.published` wire event; `invoke_sink()` dispatch; `Subscription` renamed to `Sub` |
|
| Publish operation type and HandlerKind::Sink | [ADR-046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | `OperationType::Pub` (producer→consumer streaming); `SinkHandler` + `HandlerKind::Sink`; `call.published` wire event; `invoke_sink()` dispatch; `Subscription` renamed to `Sub` |
|
||||||
|
|
||||||
@@ -587,44 +605,49 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
|
|||||||
|
|
||||||
See [open-questions.md](open-questions.md) for full details.
|
See [open-questions.md](open-questions.md) for full details.
|
||||||
|
|
||||||
- **OQ-13** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
|
- **OQ-02** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
|
||||||
- **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
|
- **OQ-03** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
|
||||||
- **OQ-16** (resolved by ADR-010): No vault operations are exposed over the call protocol for now.
|
- **OQ-09** (resolved by ADR-010): No vault operations are exposed over the call protocol for now.
|
||||||
- **OQ-19** (resolved): Session-scoped operation registries — agent-written operations overlaid on global registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait.
|
- **OQ-04** (resolved): Session-scoped operation registries — agent-written operations overlaid on global registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait.
|
||||||
- **OQ-25** (dissolved by ADR-024): `remote_safe` marking shape — moot;
|
- **OQ-10** (dissolved by ADR-024): `remote_safe` marking shape — moot;
|
||||||
`remote_safe`/`trusted_peer` retired; peer authorization is
|
`remote_safe`/`trusted_peer` retired; peer authorization is
|
||||||
`AccessControl::check(peer_identity)`.
|
`AccessControl::check(peer_identity)`.
|
||||||
- **OQ-26** (resolved): `OperationAdapter` error type — `AdapterError`
|
- **OQ-11** (resolved): `OperationAdapter` error type — `AdapterError`
|
||||||
variants (`DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`,
|
variants (`DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`,
|
||||||
`SamePeerCollision`); `#[non_exhaustive]`. See
|
`SamePeerCollision`); `#[non_exhaustive]`. See
|
||||||
[client-and-adapters.md](client-and-adapters.md).
|
[client-and-adapters.md](client-and-adapters.md).
|
||||||
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
|
- **OQ-12** (resolved): `from_call` re-import trigger — `from_call` is a
|
||||||
manual free function; the assembly layer calls it after the dial (in
|
manual free function; after the dial (in the composition layer's dial helper
|
||||||
`AlknetClient`). See
|
`AlknetClient` — today the alknet glue; OQ-43). See
|
||||||
[ADR-028](decisions/069-from-call-manual-free-function.md).
|
[ADR-028](decisions/028-from-call-manual-free-function.md).
|
||||||
- **OQ-28** (resolved): `from_call` namespace collision — same-peer collision
|
- **OQ-13** (resolved): `from_call` namespace collision — same-peer collision
|
||||||
= error; cross-peer dissolved by ADR-024 (separate sub-overlays). See
|
= error; cross-peer dissolved by ADR-024 (separate sub-overlays). See
|
||||||
[client-and-adapters.md](client-and-adapters.md).
|
[client-and-adapters.md](client-and-adapters.md).
|
||||||
- **OQ-29** (resolved): `CallClient` TLS client-auth — wire quinn client-auth
|
- **OQ-14** (resolved): `CallClient` TLS client-auth — wire quinn client-auth
|
||||||
(present Ed25519 key as raw public key client cert); key-type-aware server
|
(present Ed25519 key as raw public key client cert); key-type-aware server
|
||||||
cert verification; fingerprint normalization. See
|
cert verification; fingerprint normalization. See
|
||||||
[client-and-adapters.md](client-and-adapters.md).
|
[client-and-adapters.md](client-and-adapters.md).
|
||||||
- **OQ-30** (resolved): `PeerRef::Any` routing policy — insertion-order
|
- **OQ-15** (resolved): `PeerRef::Any` routing policy — insertion-order
|
||||||
first-match. See [client-and-adapters.md](client-and-adapters.md).
|
first-match. See [client-and-adapters.md](client-and-adapters.md).
|
||||||
- **OQ-31** (resolved): `services/list-peers` re-export semantics — opt-in;
|
- **OQ-16** (resolved): `services/list-peers` re-export semantics — opt-in;
|
||||||
`services/list` is "own ops only." See [client-and-adapters.md](client-and-adapters.md).
|
`services/list` is "own ops only." See [client-and-adapters.md](client-and-adapters.md).
|
||||||
- **OQ-32** (open, feature extension): Multi-hop federation — the one-hop
|
- **OQ-17** (deferred(scope), feature extension): Multi-hop federation — the one-hop
|
||||||
model is the architectural commitment; multi-hop is a feature extension
|
model is the architectural commitment; multi-hop is a feature extension
|
||||||
that doesn't break downstream. See [client-and-adapters.md](client-and-adapters.md).
|
that doesn't break downstream. See [client-and-adapters.md](client-and-adapters.md).
|
||||||
- **OQ-33** (resolved by ADR-025): `PeerId` source — `Identity.id` from
|
- **OQ-18** (resolved by ADR-025): `PeerId` source — `Identity.id` from
|
||||||
`IdentityProvider` resolution (= `PeerEntry.peer_id`, stable across key
|
`IdentityProvider` resolution (= `PeerEntry.peer_id`, stable across key
|
||||||
rotation), not a connection-assigned UUID.
|
rotation), not a connection-assigned UUID.
|
||||||
- **OQ-34** (resolved by ADR-025 + ADR-033): Persistent peer registry —
|
- **OQ-19** (resolved by ADR-025): Persistent peer registry —
|
||||||
the storage boundary is `core trait + in-memory default`; persistence
|
the storage boundary is `core trait + in-memory default`; persistence
|
||||||
adapters are separate crates.
|
adapters are separate crates (composition-layer work — OQ-43; the
|
||||||
- **OQ-37** (resolved by ADR-034): X.509 outgoing-only case — three remote
|
concrete adapter shapes were named in the source repo's research
|
||||||
|
(alknet, provenance), not re-specced here).
|
||||||
|
- **OQ-21** (resolved by ADR-054): X.509 outgoing-only case — three remote
|
||||||
roles named (public X.509 endpoint, transport relay, hub); pure-client
|
roles named (public X.509 endpoint, transport relay, hub); pure-client
|
||||||
X.509 connections are not in the peer graph on the client side. See
|
X.509 connections are not in the peer graph on the client side.
|
||||||
|
Re-owned in-crate by ADR-054 (verifier selection by `PeerEntry`
|
||||||
|
presence; provenance: alknet ADR-034, not ported); ADR-025's
|
||||||
|
`PeerEntry` asymmetry is the surviving registry surface. See
|
||||||
[client-and-adapters.md](client-and-adapters.md).
|
[client-and-adapters.md](client-and-adapters.md).
|
||||||
|
|
||||||
## References
|
## References
|
||||||
@@ -636,6 +659,6 @@ See [open-questions.md](open-questions.md) for full details.
|
|||||||
- ADR-024: Peer-graph routing model (peer-keyed overlays + `PeerRef` routing)
|
- ADR-024: Peer-graph routing model (peer-keyed overlays + `PeerRef` routing)
|
||||||
- ADR-025: PeerEntry and Identity.id decoupling (`PeerId` source)
|
- ADR-025: PeerEntry and Identity.id decoupling (`PeerId` source)
|
||||||
- ADR-026: Forwarded-for identity (`forwarded_for` on `call.requested` and `OperationContext`)
|
- ADR-026: Forwarded-for identity (`forwarded_for` on `call.requested` and `OperationContext`)
|
||||||
- ADR-034: Outgoing-only X.509 and the three peer roles
|
- ADR-054: Outgoing-only X.509 and the three remote roles (re-owned in-crate; verifier selection by `PeerEntry` presence)
|
||||||
- ADR-021: Streaming handler for subscriptions (server-side streaming dispatch path)
|
- ADR-021: Streaming handler for subscriptions (server-side streaming dispatch path)
|
||||||
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`
|
- Reference implementation: this crate (`src/`); historical origin: the alknet / alknet-main call-core sources (provenance)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-18
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# channel-client.md — ChannelClient
|
# channel-client.md — ChannelClient
|
||||||
@@ -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`,
|
`Connection::from_bidi(tls_stream, ...)` for TCP+TLS, a quinn `Connection`,
|
||||||
a WebTransport `BiStream`, an SSH `direct-tcpip` channel wrapped via
|
a WebTransport `BiStream`, an SSH `direct-tcpip` channel wrapped via
|
||||||
`from_bidi`, a WebSocket carrying `alk/channels` (the browser path per
|
`from_bidi`, a WebSocket carrying `alk/channels` (the browser path per
|
||||||
ADR-044) — all produce a `Connection` that `from_connection` accepts
|
alknet ADR-044 — websocket-native session, provenance) — all produce a `Connection` that `from_connection` accepts
|
||||||
unchanged. This mirrors the server side's `ChannelsAdapter::handle(Connection)`, which is substrate-agnostic by the same mechanism.
|
unchanged. This mirrors the server side's `ChannelsAdapter::handle(Connection)`, which is substrate-agnostic by the same mechanism.
|
||||||
|
|
||||||
The dial (QUIC, TCP+TLS, iroh) lives in `AlknetClient` (`alknet-client`,
|
The dial (QUIC, TCP+TLS, iroh) lives in the composition layer's dial
|
||||||
|
helper, `AlknetClient` (today in the alknet glue; future owner OQ-43 —
|
||||||
ADR-045), not on `ChannelClient`. Callers compose
|
ADR-045), not on `ChannelClient`. Callers compose
|
||||||
`AlknetClient::dial_quic(...).await?` + `ChannelClient::from_connection(conn).await?`
|
`AlknetClient::dial_quic(...).await?` + `ChannelClient::from_connection(conn).await?`
|
||||||
— two lines, the dial then the take-over. Keeping the dial off
|
— two lines, the dial then the take-over. Keeping the dial off
|
||||||
`ChannelClient` avoids `alknet-channels-call` depending on `alknet-client`;
|
`ChannelClient` avoids alkcall depending on a dial crate;
|
||||||
the protocol crates are parallel to the dial, not downstream of it.
|
the protocol crates are parallel to the dial, not downstream of it.
|
||||||
|
|
||||||
The credential/verifier-selection rule (ADR-034) lives in the dial
|
The credential/verifier-selection rule (ADR-054, re-owned from the
|
||||||
|
source repo's alknet ADR-034) lives in the dial
|
||||||
(`AlknetClient`), not in `from_connection` — `from_connection` receives
|
(`AlknetClient`), not in `from_connection` — `from_connection` receives
|
||||||
an already-established, already-authenticated `Connection`, exactly as
|
an already-established, already-authenticated `Connection`, exactly as
|
||||||
`ChannelsAdapter::handle` does on the server side.
|
`ChannelsAdapter::handle` does on the server side.
|
||||||
@@ -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
|
name follows the `CallClient` convention (the side that dialed), not a
|
||||||
request/response role.
|
request/response role.
|
||||||
|
|
||||||
## Relationship to `AlknetClient`
|
## Relationship to the composition layer's dial helper
|
||||||
|
|
||||||
`ChannelClient`'s *API* is transport-agnostic — `from_connection` takes a
|
`ChannelClient`'s *API* is transport-agnostic — `from_connection` takes a
|
||||||
pre-established `Connection`. The shared *dial+TLS* seam
|
pre-established `Connection`. The shared *dial+TLS* seam is the
|
||||||
(`AlknetClient`, OQ-55) is [`alknet-client`](../client/README.md), which
|
composition layer's dial helper, `AlknetClient` (ADR-045; today in the
|
||||||
provides `AlknetClient` with three dial methods (`dial_quic` /
|
alknet glue, future owner OQ-43), which
|
||||||
|
provides three dial methods (`dial_quic` /
|
||||||
`dial_tcp_tls` / `dial_iroh`), each producing a `Connection` that
|
`dial_tcp_tls` / `dial_iroh`), each producing a `Connection` that
|
||||||
`from_connection` consumes. The dial is transport-specific (QUIC,
|
`from_connection` consumes. The dial is transport-specific (QUIC,
|
||||||
TCP+TLS, iroh); the take-over (`from_connection`) is
|
TCP+TLS, iroh); the take-over (`from_connection`) is
|
||||||
@@ -194,10 +197,9 @@ transport-agnostic. The two concerns are separated.
|
|||||||
|
|
||||||
`AlknetClient::dial_quic` is the dial that feeds `from_connection`. A
|
`AlknetClient::dial_quic` is the dial that feeds `from_connection`. A
|
||||||
caller that needs transport selection (QUIC with TCP+TLS fallback) uses
|
caller that needs transport selection (QUIC with TCP+TLS fallback) uses
|
||||||
`AlknetClient` directly; the fallback policy is a caller concern. See
|
the dial helper directly; the fallback policy is a caller concern. See
|
||||||
[ADR-045](decisions/089-alknetclient-native-dial-seam.md) for the
|
[ADR-045](decisions/045-alknetclient-native-dial-seam.md) for the
|
||||||
full decision and [OQ-55](../../questions/055-alknetclient-establishment-extraction.md)
|
full decision.
|
||||||
(resolved).
|
|
||||||
|
|
||||||
## Design Decisions
|
## Design Decisions
|
||||||
|
|
||||||
@@ -205,15 +207,15 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
|
|
||||||
| ADR | Decision | Summary |
|
| ADR | Decision | Summary |
|
||||||
|-----|----------|---------|
|
|-----|----------|---------|
|
||||||
| [043](decisions/043-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045, resolves OQ-55) |
|
| [043](decisions/043-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in the composition layer's dial helper (ADR-045) |
|
||||||
| [035](decisions/035-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | No `stream_types` on `open_channel`/`Channel`; handler owns sub-stream multiplexing |
|
| [035](decisions/035-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | No `stream_types` on `open_channel`/`Channel`; handler owns sub-stream multiplexing |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
- **OQ-55** (resolved by ADR-045): `AlknetClient` core **dial+TLS seam**
|
- **Dial seam ownership** (ADR-045): the dial helper (`AlknetClient`)
|
||||||
— `alknet-client` with three dial methods. `ChannelClient`'s API is
|
lives in the composition layer today; its post-alknet owner and shape
|
||||||
transport-agnostic (`from_connection`); the dial is the shared seam.
|
is OQ-43. `ChannelClient`'s API is transport-agnostic
|
||||||
See [ADR-045](decisions/045-alknetclient-native-dial-seam.md).
|
(`from_connection`); the dial is the shared seam.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -225,6 +227,6 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
- ADR-039: ChannelManager (the shared state `ChannelClient` holds)
|
- ADR-039: ChannelManager (the shared state `ChannelClient` holds)
|
||||||
- ADR-047: openable ALPNs are operations (per-ALPN open ops dissolve
|
- ADR-047: openable ALPNs are operations (per-ALPN open ops dissolve
|
||||||
the generic `channel/open`)
|
the generic `channel/open`)
|
||||||
- OQ-55: AlknetClient / client establishment extraction
|
- ADR-045: the dial seam (dial out of the protocol crate)
|
||||||
- `docs/architecture/crates/call/client-and-adapters.md` — `CallClient` (the
|
- [client-and-adapters.md](client-and-adapters.md) — `CallClient` (the
|
||||||
shape `ChannelClient` mirrors)
|
shape `ChannelClient` mirrors)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: current
|
||||||
last_updated: 2026-08-12
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# channel-operations.md — Channel Lifecycle on the Call Protocol
|
# channel-operations.md — Channel Lifecycle on the Call Protocol
|
||||||
@@ -60,15 +60,18 @@ Response (`call.responded`):
|
|||||||
|-------|------|---------|
|
|-------|------|---------|
|
||||||
| `channel_id` | u32 | Allocated by the connection owner (ADR-047 §5). In the `Sub` case, the responder (producer). Both sides route chunks with this ID to the new channel. |
|
| `channel_id` | u32 | Allocated by the connection owner (ADR-047 §5). In the `Sub` case, the responder (producer). Both sides route chunks with this ID to the new channel. |
|
||||||
|
|
||||||
**`channel_id` allocation (ADR-047 §5): connection-owner allocates.**
|
**`channel_id` allocation (ADR-047 §5, as amended for the put path by
|
||||||
|
ADR-047 amendment 4): connection-owner allocates.**
|
||||||
The side that holds the `ChannelManager` for that connection allocates
|
The side that holds the `ChannelManager` for that connection allocates
|
||||||
the `channel_id` via a monotonic `AtomicU32` (`next_id.fetch_add(1,
|
the `channel_id` via a monotonic `AtomicU32` (odd/even split — connect
|
||||||
Relaxed)`) and returns it in the response. In the `Sub` case the
|
side starts at 1, accept at 2) and returns it in the response. In the
|
||||||
responder owns the connection and allocates; in the `Pub` case the
|
`Sub` case the responder owns the connection and allocates — one
|
||||||
initiator owns and allocates. One round-trip before data flows — the
|
round-trip before data flows (the same round-trip every call makes).
|
||||||
same round-trip the call protocol makes for every operation. All
|
In the `Pub` case (the put contract above) the initiator owns,
|
||||||
current channel types (TTY, tunnel, SSH) already require a negotiation
|
allocates, and streams bytes **before** the open resolves — the
|
||||||
round-trip, so the open round-trip is not additive latency.
|
early-arrival parking covers the adopt race. All current channel
|
||||||
|
types (TTY, tunnel, SSH) already require a negotiation round-trip, so
|
||||||
|
the open round-trip is not additive latency.
|
||||||
|
|
||||||
**Error codes** (`CallError.code` strings):
|
**Error codes** (`CallError.code` strings):
|
||||||
|
|
||||||
@@ -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
|
serving loop spawns Once invocations as independent tasks, so a slow
|
||||||
establisher on one open op does not block other calls on channel 0.
|
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
|
A marked `Pub`-typed open op is the **put** shape: the data plane is
|
||||||
`call.published` events to the responder; the responder's `SinkHandler`
|
the adopted channel's `BiStream` — never the call plane. The contract,
|
||||||
consumes them and returns a single `call.responded`. The
|
pinned end-to-end (ADR-047 amendment 4; review 011's corrections are
|
||||||
`channel_open` marker on the op spec tells the channels layer the
|
the authoritative shape — a serving-loop `PublishStream` sketch was
|
||||||
stream is binary — the `call.published` chunks carry binary payloads
|
considered and rejected, it is ADR-046's *unmarked* pub semantics):
|
||||||
on a data channel (not JSON on channel 0).
|
|
||||||
|
1. **The initiator allocates and writes immediately.** The putter
|
||||||
|
allocates the channel id on its own side (connect side → odd, per
|
||||||
|
the ADR-047 §5 odd/even split), inserts the **reserved input key
|
||||||
|
`"channel_id"`** into the open op's input, and streams bytes on the
|
||||||
|
channel before the open resolves — the early-arrival parking
|
||||||
|
(ADR-051's tested pre-adopt buffer) covers the responder-adopts
|
||||||
|
race. Zero `call.published` on marked ops: chunks on the call plane
|
||||||
|
are rejected loudly with `channel:publish_chunks_rejected` (the
|
||||||
|
first chunk writes the rejection in the initiator's direction; the
|
||||||
|
handler is never fed). An unmarked `Pub` op is an ordinary ADR-046
|
||||||
|
sink (JSON chunks, `publish_schema`); it does not ride the channel
|
||||||
|
machinery.
|
||||||
|
2. **The responder adopts.** `ChannelCore::register_pub_openable` is
|
||||||
|
the only registration seam: the wrapper strips the reserved key
|
||||||
|
pre-validation (dispatch layer, before `input_schema`) and adopts
|
||||||
|
the given id (`adopt_channel` — the remote parity range enforced;
|
||||||
|
failure replies `channel:open_failed` with reason `adopt_failed`).
|
||||||
|
3. **The wrapper is the responder of record:** `check_open` → adopt →
|
||||||
|
bounded establisher (offer validation + ack fields +
|
||||||
|
`Establishment::with_control`, ADR-049 amendment 3's mechanism) →
|
||||||
|
the `PubOpenHandler` runs on the adopted channel's `Connection`,
|
||||||
|
**awaited inline** → the standard completion teardown → **one
|
||||||
|
response**, merge order pinned:
|
||||||
|
|
||||||
|
```
|
||||||
|
{ "channel_id": <adopted> } → ack fields → handler result
|
||||||
|
```
|
||||||
|
|
||||||
|
(the handler result wins on key collision — its computed result
|
||||||
|
postdates the ack; a handler `Err` IS the response, and the channel
|
||||||
|
tears down).
|
||||||
|
4. **The ack-timing sentence (protocol semantics):** a sink op gets
|
||||||
|
exactly one `call.responded`, produced after the handler completes
|
||||||
|
— a putter stream-waits nothing; the establishment ack is
|
||||||
|
informative-in-result, never a gating ack. The fetch/put asymmetry
|
||||||
|
is direction-inherent: fetch (`Sub`) acks at establishment (the
|
||||||
|
`{channel_id}` response precedes any byte); put (`Pub`) acks in the
|
||||||
|
result. A putter that waits for the ack deadlocks — this sentence
|
||||||
|
is the contract, not per-consumer folklore.
|
||||||
|
5. **The putter-facing client shape:**
|
||||||
|
`ChannelClient::open_pub_channel(operation_id, input, alpn)`
|
||||||
|
allocates the odd id, sends the open op in the background, and
|
||||||
|
returns `(id, send, recv, reply)` immediately; `reply.resolve()`
|
||||||
|
is the awaited merged response.
|
||||||
|
|
||||||
|
**Error codes** (the pub path):
|
||||||
|
|
||||||
|
| code | meaning | retryable |
|
||||||
|
|------|---------|-----------|
|
||||||
|
| `channel:open_failed` | establishment failure (ADR-049 §3 vocabulary) or adoption failure — new reason `adopt_failed` (collisions, cap, out-of-range reserved ids; ADR-047 amendment 4 §4.4) | false |
|
||||||
|
| `channel:publish_chunks_rejected` | `call.published` chunks on a marked op (the data plane is the channel, ADR-047 amendment 4 §4.1) | false |
|
||||||
|
| `INVALID_INPUT` | absent/non-u32 reserved `channel_id` key on a marked pub op (ADR-047 amendment 4 §4.3) | false |
|
||||||
|
|
||||||
The hub-as-proxy pattern (ADR-042, ADR-047 §1) composes on top: a
|
The hub-as-proxy pattern (ADR-042, ADR-047 §1) composes on top: a
|
||||||
worker publishes to the hub, the hub owns the resource, and consumers
|
worker publishes to the hub, the hub owns the resource, consumers
|
||||||
subscribe from the hub. The broker (topic registry, `Pub`↔`Sub`
|
subscribe from the hub. Marked pub openables relay through the put
|
||||||
matching) is a hub concern, not an alkcall concern (ADR-047 §1, Gap B).
|
translation (ADR-047 amendment 4 §4.7): the hub allocates its own
|
||||||
|
connect-side id, opens the spoke op in the background (put
|
||||||
|
acks-in-result — an establisher awaiting the spoke's response would
|
||||||
|
deadlock the leg), pumps consumer bytes → spoke leg, and mirrors the
|
||||||
|
spoke's single response with its reserved key stripped.
|
||||||
|
|
||||||
|
Registration invariants (ADR-047 amendment 4 §4.8 — the stub-class
|
||||||
|
guard): a marked spec refuses the generic registration path and
|
||||||
|
refuses `publish_schema`; `register_openable*` refuse `Pub`-typed
|
||||||
|
specs; `register_pub_openable` refuses unmarked/non-`Pub` specs.
|
||||||
|
|
||||||
## The generic ops (keyed by `channel_id`)
|
## The generic ops (keyed by `channel_id`)
|
||||||
|
|
||||||
@@ -153,13 +217,13 @@ decrement.
|
|||||||
chunks MUST be written and flushed before the `channel/close` operation
|
chunks MUST be written and flushed before the `channel/close` operation
|
||||||
is sent on channel 0. The side closing must observe the data-channel
|
is sent on channel 0. The side closing must observe the data-channel
|
||||||
pump complete before issuing the call operation. For TTY this is the
|
pump complete before issuing the call operation. For TTY this is the
|
||||||
exit-chunk-is-last invariant (ADR-055) carried forward — the exit
|
exit-chunk-is-last invariant (alknet ADR-055, provenance) carried forward — the exit
|
||||||
control message rides on TTY's `STREAM_CTRL_OUT` (stream_type 4, inside
|
control message rides on TTY's `STREAM_CTRL_OUT` (stream_type 4, inside
|
||||||
TTY's 5-byte payload format); for tunnels it is the last data byte
|
TTY's 5-byte payload format); for tunnels it is the last data byte
|
||||||
before close. This invariant crosses two channels (the data channel and
|
before close. This invariant crosses two channels (the data channel and
|
||||||
channel 0), so the channels layer owns the ordering guarantee.
|
channel 0), so the channels layer owns the ordering guarantee.
|
||||||
|
|
||||||
### `channel/control` — out-of-band control on channel 0
|
### `channel/control` — out-of-band control on channel 0 (implemented, ADR-052)
|
||||||
|
|
||||||
For control that doesn't need ordering relative to data (resize, signal,
|
For control that doesn't need ordering relative to data (resize, signal,
|
||||||
keepalive):
|
keepalive):
|
||||||
@@ -174,11 +238,23 @@ keepalive):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The channels layer routes `message` to the handler's control handle for
|
The channels layer routes `message` to the channel's control callback
|
||||||
`channel_id`. The `message` JSON is ALPN-specific; the channels layer
|
(ADR-052): the ALPN crate contributes it through its open-op
|
||||||
does not interpret it.
|
establisher (`Establishment::with_control`); the wrapper installs it
|
||||||
|
under the allocated/adopted id and every teardown path removes it. The
|
||||||
|
`message` JSON is ALPN-specific; the channels layer does not interpret
|
||||||
|
it. The reply is `{ "control": <callback output> }`, or:
|
||||||
|
|
||||||
### `channel/resources/subscribe` — live resource discovery
|
| code | meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `channel:unknown_channel` | no such channel (pre-existing class) |
|
||||||
|
| `channel:no_control_handle` | the channel's ALPN registered no control surface — a capability answer, not a failure (ADR-052 §3); data-ordered control rides the data channel (DP-4 below) |
|
||||||
|
| (callback's own code) | the callback's `CallError` verbatim |
|
||||||
|
|
||||||
|
Control that MUST be ordered relative to data rides the data channel's
|
||||||
|
`BiStream` (the control-message division below is unchanged).
|
||||||
|
|
||||||
|
### `channel/resources/subscribe` — live resource discovery (implemented, ADR-053)
|
||||||
|
|
||||||
**This is a `Sub` operation (ADR-021), not a polled Query.** The call
|
**This is a `Sub` operation (ADR-021), not a polled Query.** The call
|
||||||
protocol has `StreamingHandler` / `invoke_streaming` (implemented and
|
protocol has `StreamingHandler` / `invoke_streaming` (implemented and
|
||||||
@@ -193,8 +269,21 @@ start/stop.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The responder registers a `StreamingHandler` that emits a
|
Each openable ALPN crate provides a **resource enumerator**
|
||||||
`ResponseEnvelope` whenever the resource set changes. Each event:
|
(ADR-053 §1 — snapshot-then-changes: the first item is the current
|
||||||
|
payload, subsequent items are full replacements; the producing crate
|
||||||
|
owns its change detection) at assembly/install time:
|
||||||
|
`ResourceEnumerators::register(alpn, enumerator)`, one shared registry
|
||||||
|
per accepting peer. The handler aggregates the enumerators registered
|
||||||
|
at subscription start (the **scan-once** pin — a later registration is
|
||||||
|
invisible to an in-flight subscription; re-subscribe):
|
||||||
|
|
||||||
|
- **non-empty registry:** each enumerator item becomes one event,
|
||||||
|
`{ "output": { "resources": [ { "alpn": …, …payload } ] } }` — the
|
||||||
|
first events across the ALPNs are the initial snapshot;
|
||||||
|
- **empty registry:** exactly one `{ "resources": [] }` event, then
|
||||||
|
the stream stays open (subscribe, read the first event, cancel —
|
||||||
|
the poll use case).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -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 |
|
| Call operations on channel 0 (`channel/control`, `channel/close`) | Control that doesn't need ordering relative to data, or lifecycle events | resize, signal, keepalive, close |
|
||||||
| Data-ordered bytes on the data channel's `BiStream` (handler-internal framing) | Control that MUST be ordered relative to data | EOF before exit, flush before close |
|
| Data-ordered bytes on the data channel's `BiStream` (handler-internal framing) | Control that MUST be ordered relative to data | EOF before exit, flush before close |
|
||||||
|
|
||||||
The TTY crate's exit-chunk-is-last invariant (ADR-055) is the canonical
|
The TTY crate's exit-chunk-is-last invariant (alknet ADR-055, provenance) is the canonical
|
||||||
example of data-ordered control — it rides on TTY's `STREAM_CTRL_OUT`
|
example of data-ordered control — it rides on TTY's `STREAM_CTRL_OUT`
|
||||||
(stream_type 4, inside TTY's 5-byte payload format) because it must
|
(stream_type 4, inside TTY's 5-byte payload format) because it must
|
||||||
arrive after the last data on TTY's stdout stream_type, guaranteed by
|
arrive after the last data on TTY's stdout stream_type, guaranteed by
|
||||||
@@ -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
|
the channels layer has no `stream_type` concept — it carries the
|
||||||
handler's framing transparently in the payload. TTY's `STREAM_CTRL_IN`
|
handler's framing transparently in the payload. TTY's `STREAM_CTRL_IN`
|
||||||
(stream_type 3) and `STREAM_CTRL_OUT` (stream_type 4) are stream_types
|
(stream_type 3) and `STREAM_CTRL_OUT` (stream_type 4) are stream_types
|
||||||
in TTY's 5-byte format (ADR-052, amended by Phase 7), not
|
in TTY's 5-byte format (alknet ADR-052, amended by Phase 7), not
|
||||||
channels-layer concepts. The channels layer routes by `channel_id` only;
|
channels-layer concepts. The channels layer routes by `channel_id` only;
|
||||||
the handler owns its sub-stream multiplexing on the `BiStream` it
|
the handler owns its sub-stream multiplexing on the `BiStream` it
|
||||||
receives.
|
receives.
|
||||||
@@ -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 |
|
| [021](decisions/021-streaming-handler-for-subscriptions.md) | StreamingHandler | The machinery `channel/resources/subscribe` uses |
|
||||||
| [046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | Pub Operation Type | The `Pub`/`Sub` primitives the per-ALPN open ops build on |
|
| [046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | Pub Operation Type | The `Pub`/`Sub` primitives the per-ALPN open ops build on |
|
||||||
| [049](decisions/049-channel-open-establishment-phase.md) | Channel-Open Establishment Phase | The establisher hook; typed `channel:open_failed`; `Establishment.plan` (amendment 2); the `JoinHandle` lifetime contract |
|
| [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 |
|
| [050](decisions/050-pump-bidi-two-pump-helper.md) | `pump_bidi` Two-Pump Helper | The data-plane helper openable-ALPN handlers await inline |
|
||||||
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens (and why the cap is per direct-caller, not per `forwarded_for`) |
|
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens (and why the cap is per direct-caller, not per `forwarded_for`) |
|
||||||
| [011](decisions/011-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The parallel — a channel slot is a resource, the cap is a quota check; `resource_id_path` works again under per-ALPN ops |
|
| [011](decisions/011-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The parallel — a channel slot is a resource, the cap is a quota check; `resource_id_path` works again under per-ALPN ops |
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-18
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# alknet-channels
|
# alkcall — Channels Protocol
|
||||||
|
|
||||||
A multiplexing proxy: a `ProtocolHandler` on `alk/channels` that
|
A multiplexing proxy: a `ProtocolHandler` on `alk/channels` that
|
||||||
decomposes a single bidirectional transport stream into N logical channels,
|
decomposes a single bidirectional transport stream into N logical channels,
|
||||||
@@ -20,46 +20,46 @@ handler owns its sub-stream multiplexing on the `BiStream` it receives.
|
|||||||
|
|
||||||
| Document | Status | Description |
|
| Document | Status | Description |
|
||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
| [overview.md](overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, ALPN, transport agnosticism, WASM, relationship to existing crates |
|
| [overview.md](channels-overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, ALPN, transport agnosticism, WASM, relationship to existing crates |
|
||||||
| [channels-wire.md](channels-wire.md) | draft | The 8-byte chunk format (`[channel_id:u32 be][length:u32 be][payload]`), the add/strip composition, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
|
| [channels-wire.md](channels-wire.md) | draft | The 8-byte chunk format (`[channel_id:u32 be][length:u32 be][payload]`), the add/strip composition, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
|
||||||
| [channels-connection.md](channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-008/074, as amended by ADR-035), `accept_bi` yields one `BiStream` per channel, recursive composition |
|
| [channels-connection.md](channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-008 + `from_source`, as amended by ADR-035), `accept_bi` yields one `BiStream` per channel, recursive composition |
|
||||||
| [channels-adapter.md](channels-adapter.md) | draft | `ChannelsAdapter` (`ProtocolHandler` on `alk/channels`), `ChannelManager`, demux/mux contracts (REQ-CH-01..04), the two-pump pattern (ADR-078) |
|
| [channels-adapter.md](channels-adapter.md) | draft | `ChannelsAdapter` (`ProtocolHandler` on `alk/channels`), `ChannelManager`, demux/mux contracts (REQ-CH-01..04), the two-pump pattern (ADR-050) |
|
||||||
| [channel-operations.md](channel-operations.md) | draft | `channel/open`, `channel/close`, `channel/control`, `channel/resources/subscribe` — call-protocol operations on channel 0, ACL flow, `direction` semantics, the hub relay contract (ADR-042) |
|
| [channel-operations.md](channel-operations.md) | draft | `channel/open`, `channel/close`, `channel/control`, `channel/resources/subscribe` — call-protocol operations on channel 0, ACL flow, `direction` semantics, the hub relay contract (ADR-042) |
|
||||||
| [channel-client.md](channel-client.md) | draft | `ChannelClient` — the client side of a channels connection; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045); bidirectionality preserved |
|
| [channel-client.md](channel-client.md) | draft | `ChannelClient` — the client side of a channels connection; transport-agnostic `from_connection` primary; dial lives in the composition layer's dial helper (ADR-045); bidirectionality preserved |
|
||||||
|
|
||||||
## Applicable ADRs
|
## Applicable ADRs
|
||||||
|
|
||||||
| ADR | Title | Relevance |
|
| ADR | Title | Relevance |
|
||||||
|-----|-------|-----------|
|
|-----|-------|-----------|
|
||||||
| [071](decisions/071-channels-wire-format.md) | channels Wire Format — 8-Byte Chunk Header | The chunk format; channels layer has no `stream_type` concept (amended by ADR-035); substrate-agnostic; one-way door |
|
| [034](decisions/034-channels-wire-format.md) | channels Wire Format — 8-Byte Chunk Header | The chunk format; channels layer has no `stream_type` concept (amended by ADR-035); substrate-agnostic; one-way door |
|
||||||
| [093](decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
|
| [035](decisions/035-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
|
||||||
| [072](decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alk/call` | Channel 0 = call protocol; no special control plane |
|
| [036](decisions/036-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alk/call` | Channel 0 = call protocol; no special control plane |
|
||||||
| [073](decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | `channel/open`/`close`/`control`/`resources/subscribe`; `direction` semantics; subscribe not poll |
|
| [037](decisions/037-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | `channel/open`/`close`/`control`/`resources/subscribe`; `direction` semantics; subscribe not poll |
|
||||||
| [074](decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `accept_bi` yields `BiStream` (amended by ADR-035 — `into_sub_streams` removed) |
|
| [038](decisions/038-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `accept_bi` yields `BiStream` (amended by ADR-035 — `into_sub_streams` removed) |
|
||||||
| [075](decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 contracts |
|
| [039](decisions/039-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 contracts |
|
||||||
| [076](decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Bounded-buffer (1 MiB default), 256-channel per-connection memory bound, monotonic IDs with wrap (DoS defense reframed by ADR-041) |
|
| [040](decisions/040-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Bounded-buffer (1 MiB default), 256-channel per-connection memory bound, monotonic IDs with wrap (DoS defense reframed by ADR-041) |
|
||||||
| [094](decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound; symmetric (both sides enforce); spoke caps hub (direct caller), not browser (forwarded_for is metadata) |
|
| [041](decisions/041-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound; symmetric (both sides enforce); spoke caps hub (direct caller), not browser (forwarded_for is metadata) |
|
||||||
| [077](decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | TTY's two modes (direct vs channels); TTY always uses its 5-byte format, carried transparently in the channels payload |
|
| [050](decisions/050-pump-bidi-two-pump-helper.md) | `pump_bidi` — the Two-Pump Helper | The two-pump deadlock contract; handler-level, not channels-layer |
|
||||||
| [078](decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | The two-pump deadlock contract; handler-level, not channels-layer |
|
| [042](decisions/042-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | The hub translates channel 0, byte-forwards data channels with ID rewrite |
|
||||||
| [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | The hub translates channel 0, byte-forwards data channels with ID rewrite |
|
| [043](decisions/043-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, transport-agnostic `from_connection` primary; dial lives in the composition layer's dial helper (ADR-045) |
|
||||||
| [080](decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045, resolves OQ-55) |
|
| [044](decisions/044-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient) modules inside alkcall; hub and worker are consumers, not sub-crates |
|
||||||
| [081](decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers, not sub-crates |
|
| [008](decisions/008-bidistreamsource-trait.md) | BidiStreamSource Trait | The `Connection` extension point `ChannelBidiStreamSource` implements |
|
||||||
| [070](decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The `Connection` extension point `ChannelBidiStreamSource` implements |
|
| [009](decisions/009-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream`; the transport-leaf decision ADR-035 builds on |
|
||||||
| [092](decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream`; the transport-leaf decision ADR-035 builds on |
|
| [007](decisions/007-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The transport-agnostic `Connection` the channels layer rides on |
|
||||||
| [065](decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The transport-agnostic `Connection` the channels layer rides on |
|
| [021](decisions/021-streaming-handler-for-subscriptions.md) | StreamingHandler for Subscriptions | The machinery `channel/resources/subscribe` uses |
|
||||||
| [052](decisions/052-alknet-tty-wire-format-and-two-carriage.md) | alknet-tty Wire Format | The 5-byte format carried transparently in the channels payload (control bidirectional via `STREAM_CTRL_IN`/`OUT` — Phase 7 amendment) |
|
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed channel opens |
|
||||||
| [049](decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler for Subscriptions | The machinery `channel/resources/subscribe` uses |
|
| [031](decisions/031-crate-decomposition.md) | Crate Decomposition | The channels subsystem lives inside alkcall with the call protocol and vendored core types; no handler-depends-on-handler |
|
||||||
| [032](decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed channel opens |
|
|
||||||
| [003](decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-channels depends on alknet-core only; no handler-depends-on-handler |
|
Provenance (source-repo ADRs, not ported): [alknet ADR-077 — TTY Inside Channels, Sub-Streams Not Wire Format](/workspace/@alkdev/alknet/docs/architecture/decisions/077-tty-inside-channels.md): TTY's two modes (direct vs channels); TTY always uses its 5-byte format, carried transparently in the channels payload. [alknet ADR-052 — TTY Wire Format](/workspace/@alkdev/alknet/docs/architecture/decisions/052-alknet-tty-wire-format-and-two-carriage.md): the 5-byte format carried transparently in the channels payload (control bidirectional via `STREAM_CTRL_IN`/`OUT` — Phase 7 amendment). TTY is now a sibling protocol crate (`alktty`).
|
||||||
|
|
||||||
## Relevant Open Questions
|
## Relevant Open Questions
|
||||||
|
|
||||||
| OQ | Title | Status | Relevance |
|
| OQ | Title | Status | Relevance |
|
||||||
|----|-------|--------|-----------|
|
|----|-------|--------|-----------|
|
||||||
| OQ-55 | AlknetClient / Client Establishment Extraction | resolved (ADR-045) | `ChannelClient`'s API is decided (ADR-043): transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (`alknet-client`, ADR-045) |
|
| OQ-23 ("[tracker](open-questions.md)"; source: alknet OQ-56) | Full channel-level flow-control windowing | deferred(scope) | Bounded-buffer is decided (ADR-040); full windowing is an extension blocked on "a real deployment observes HOL blocking on a saturated channel where bounded buffer is insufficient" |
|
||||||
| OQ-56 | Full channel-level flow-control windowing | deferred(scope) | Bounded-buffer is decided (ADR-040); full windowing is an extension blocked on "a real deployment observes HOL blocking on a saturated channel where bounded buffer is insufficient" |
|
| Dial seam (source: alknet OQ-55) | Client dial helper extraction | resolved (ADR-045); ownership → OQ-43 | `ChannelClient`'s API is decided (ADR-043): transport-agnostic `from_connection` primary; the dial lives in the composition layer's dial helper (`AlknetClient` today; post-alknet owner and shape — OQ-43) |
|
||||||
| OQ-57 | Two-pump helper extraction to alknet-core | deferred(scope) | The shutdown-on-completion *contract* is decided (ADR-078); the *helper* extraction is blocked on a second two-pump handler existing (shape convergence) |
|
| Two-pump helper (source: alknet OQ-57) | Two-pump helper extraction to a shared core crate | dissolved | The shutdown-on-completion *contract* is decided and the helper is in-tree (`channels::pump_bidi`, ADR-050); the extraction target (a mono-repo core crate being replaced) no longer exists — alkcall owns its helpers |
|
||||||
| OQ-68 | Add/strip API shape (built-in vs utility) | open | Whether the 8-byte header add/strip is built into the channels read/write path or exposed as a standalone utility. The *contract* is decided (ADR-035); the *function surface* is not |
|
| OQ-24 ("[tracker](open-questions.md)"; source: alknet OQ-68) | Add/strip API shape (built-in vs utility) | resolved | Whether the 8-byte header add/strip is built into the channels read/write path or exposed as a standalone utility. The *contract* is decided (ADR-035); the shipped surface integrates the strip (see OQ-24 in [open-questions.md](open-questions.md)) |
|
||||||
|
|
||||||
## Key Design Principles
|
## Key Design Principles
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ handler owns its sub-stream multiplexing on the `BiStream` it receives.
|
|||||||
in how they're *opened* (negotiation via `channel/open` on channel 0)
|
in how they're *opened* (negotiation via `channel/open` on channel 0)
|
||||||
and what *multiplexing layer* carries them (the 8-byte chunk format).
|
and what *multiplexing layer* carries them (the 8-byte chunk format).
|
||||||
Once normalized, every channel is an ALPN routed through the same
|
Once normalized, every channel is an ALPN routed through the same
|
||||||
`HandlerRegistry`. See [overview.md](overview.md) and ADR-034 (as
|
`HandlerRegistry`. See [overview.md](channels-overview.md) and ADR-034 (as
|
||||||
amended by ADR-035).
|
amended by ADR-035).
|
||||||
|
|
||||||
2. **Channel 0 is `alk/call` pre-negotiated, not a special control
|
2. **Channel 0 is `alk/call` pre-negotiated, not a special control
|
||||||
@@ -136,6 +136,10 @@ handler owns its sub-stream multiplexing on the `BiStream` it receives.
|
|||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
|
Source-repo research and history (alknet, provenance — these paths live
|
||||||
|
in the alknet repo, `/workspace/@alkdev/alknet/docs/research/...`, not
|
||||||
|
this one):
|
||||||
|
|
||||||
- `docs/research/alknet-channels/phase-0-findings.md` — Phase 0 research
|
- `docs/research/alknet-channels/phase-0-findings.md` — Phase 0 research
|
||||||
(vision, hub motivation, wire format, negotiation, internals, DPs, OQs)
|
(vision, hub motivation, wire format, negotiation, internals, DPs, OQs)
|
||||||
- `docs/research/alknet-channels/poc-summary.md` — the de-risk POC (28
|
- `docs/research/alknet-channels/poc-summary.md` — the de-risk POC (28
|
||||||
@@ -146,8 +150,10 @@ handler owns its sub-stream multiplexing on the `BiStream` it receives.
|
|||||||
surfaced the pure-channel-multiplexing resolution (ADR-035)
|
surfaced the pure-channel-multiplexing resolution (ADR-035)
|
||||||
- `/workspace/alknet-channels-poc/` — the POC codebase
|
- `/workspace/alknet-channels-poc/` — the POC codebase
|
||||||
- `docs/research/alknet-tty/phase-0-findings.md` — the TTY crate's chunk
|
- `docs/research/alknet-tty/phase-0-findings.md` — the TTY crate's chunk
|
||||||
format (the seed of the channels generalization)
|
format (the seed of the channels generalization; TTY is now the
|
||||||
|
sibling crate `alktty`)
|
||||||
- `docs/research/alknet-ssh/phase-0-findings.md` — SSH's channel
|
- `docs/research/alknet-ssh/phase-0-findings.md` — SSH's channel
|
||||||
multiplexer (the prior art for N-channel multiplexing)
|
multiplexer (the prior art for N-channel multiplexing)
|
||||||
- `docs/architecture/crates/hub/README.md` — the hub crate (the primary
|
- Source-repo hub crate (alknet, provenance — the composition layer's
|
||||||
consumer; the relay implementation's home)
|
hub wiring; the relay implementation itself is an alkcall export,
|
||||||
|
`ChannelRelay`, ADR-051)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-18
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# channels-adapter.md — ChannelsAdapter and ChannelManager
|
# channels-adapter.md — ChannelsAdapter and ChannelManager
|
||||||
@@ -69,7 +69,7 @@ transport's stream count differs.
|
|||||||
## `ChannelManager`
|
## `ChannelManager`
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// In alknet-channels-core:
|
// In alkcall (channels subsystem):
|
||||||
pub struct ChannelManager {
|
pub struct ChannelManager {
|
||||||
channels: Mutex<HashMap<u32, ChannelState>>,
|
channels: Mutex<HashMap<u32, ChannelState>>,
|
||||||
handlers: Arc<HandlerRegistry>,
|
handlers: Arc<HandlerRegistry>,
|
||||||
@@ -238,7 +238,7 @@ The runner's `select!` loop exits when all `MuxHandle` clones drop (the
|
|||||||
`new_pumps` sender closes) — the natural shutdown signal. This matches the
|
`new_pumps` sender closes) — the natural shutdown signal. This matches the
|
||||||
dynamic `channel/open` model.
|
dynamic `channel/open` model.
|
||||||
|
|
||||||
## The two-pump pattern (ADR-078 — documented here for handler authors)
|
## The two-pump pattern (ADR-050 — documented here for handler authors)
|
||||||
|
|
||||||
Handlers with a two-pump shape (two `tokio::io::copy` pumps, one per
|
Handlers with a two-pump shape (two `tokio::io::copy` pumps, one per
|
||||||
direction — tunnel, SSH `direct-tcpip`) MUST shut down the opposite sink
|
direction — tunnel, SSH `direct-tcpip`) MUST shut down the opposite sink
|
||||||
@@ -262,7 +262,8 @@ tokio::try_join!(c2t, t2c)?;
|
|||||||
|
|
||||||
The three-pump pattern (TTY's `pump_session`, coordinating via the
|
The three-pump pattern (TTY's `pump_session`, coordinating via the
|
||||||
`exit_code` future) does not have this deadlock — the `exit_code` future is
|
`exit_code` future) does not have this deadlock — the `exit_code` future is
|
||||||
the third signal. The two-pump pattern is documented in ADR-078; the
|
the third signal. The two-pump pattern is pinned in-tree by ADR-050
|
||||||
|
(`channels::pump_bidi`); the
|
||||||
shutdown-on-completion contract is a handler-level concern, not a
|
shutdown-on-completion contract is a handler-level concern, not a
|
||||||
channels-layer one.
|
channels-layer one.
|
||||||
|
|
||||||
@@ -308,7 +309,8 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
|
|
||||||
- ADR-039: ChannelsAdapter and ChannelManager (the decision)
|
- ADR-039: ChannelsAdapter and ChannelManager (the decision)
|
||||||
- ADR-035: channels pure channel multiplexing (the umbrella decision that
|
- ADR-035: channels pure channel multiplexing (the umbrella decision that
|
||||||
amends ADR-034/074/077)
|
amends ADR-034 and the source-repo connection/from_source + TTY-inside-channels decisions —
|
||||||
|
alknet ADR-074/077, provenance; the in-crate homes are ADR-007/008)
|
||||||
- ADR-036: channel 0 pre-negotiated (the `preinstall_channel_0` step)
|
- ADR-036: channel 0 pre-negotiated (the `preinstall_channel_0` step)
|
||||||
- ADR-037: channel lifecycle operations (the ops registered on `call_ops`)
|
- ADR-037: channel lifecycle operations (the ops registered on `call_ops`)
|
||||||
- ADR-038: ChannelBidiStreamSource (what the manager constructs per
|
- ADR-038: ChannelBidiStreamSource (what the manager constructs per
|
||||||
@@ -318,7 +320,9 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
- ADR-041: per-identity channel cap (the `ChannelLifecyclePolicy`
|
- ADR-041: per-identity channel cap (the `ChannelLifecyclePolicy`
|
||||||
consulted by the `channel/open` handler; the relay consequence for
|
consulted by the `channel/open` handler; the relay consequence for
|
||||||
hub-relayed channels)
|
hub-relayed channels)
|
||||||
- `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #4-#7
|
- Source-repo POC research (alknet provenance):
|
||||||
(REQ-CH-01..04, the two-pump deadlock)
|
`/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md`
|
||||||
- `docs/research/stream-unification/findings.md` — the research that
|
§Issues Surfaced #4-#7
|
||||||
|
(REQ-CH-01..04, the two-pump deadlock);
|
||||||
|
`/workspace/@alkdev/alknet/docs/research/stream-unification/findings.md` — the research that
|
||||||
surfaced the pure-multiplexing resolution
|
surfaced the pure-multiplexing resolution
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-18
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# channels-connection.md — ChannelBidiStreamSource and `BiStream` Access
|
# channels-connection.md — ChannelBidiStreamSource and `BiStream` Access
|
||||||
@@ -21,7 +21,7 @@ tunnel uses the `BiStream` as raw bytes, call length-prefixes JSON, SSH
|
|||||||
runs its own channel protocol.
|
runs its own channel protocol.
|
||||||
|
|
||||||
The `BiStream` is wrapped in a `ChannelBidiStreamSource` that implements
|
The `BiStream` is wrapped in a `ChannelBidiStreamSource` that implements
|
||||||
`alknet-core`'s `BidiStreamSource` trait (ADR-008), and a `Connection` is
|
alkcall's vendored-core `BidiStreamSource` trait (ADR-008), and a `Connection` is
|
||||||
constructed from it via `Connection::from_source(source, alpn)`. The
|
constructed from it via `Connection::from_source(source, alpn)`. The
|
||||||
handler receives a `Connection`, calls `accept_bi()` once (yield-once per
|
handler receives a `Connection`, calls `accept_bi()` once (yield-once per
|
||||||
channel), gets a `BiStream`, and drives its session — identical to how it
|
channel), gets a `BiStream`, and drives its session — identical to how it
|
||||||
@@ -30,7 +30,7 @@ works on a top-level QUIC connection.
|
|||||||
## `ChannelBidiStreamSource`
|
## `ChannelBidiStreamSource`
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// In alknet-channels:
|
// In alkcall (channels subsystem):
|
||||||
|
|
||||||
pub struct ChannelBidiStreamSource {
|
pub struct ChannelBidiStreamSource {
|
||||||
// The reassembly buffer for this channel's payload bytes (one per
|
// The reassembly buffer for this channel's payload bytes (one per
|
||||||
@@ -86,7 +86,7 @@ async fn handle(&self, connection: Connection, _auth: &AuthContext)
|
|||||||
let (mut tcp_read, mut tcp_write) = tcp.into_split();
|
let (mut tcp_read, mut tcp_write) = tcp.into_split();
|
||||||
let (mut recv, mut send) = tokio::io::split(&mut bidi);
|
let (mut recv, mut send) = tokio::io::split(&mut bidi);
|
||||||
|
|
||||||
// Two-pump with shutdown-on-completion (ADR-078)
|
// Two-pump with shutdown-on-completion (ADR-050)
|
||||||
let c2t = async {
|
let c2t = async {
|
||||||
tokio::io::copy(&mut recv, &mut tcp_write).await?;
|
tokio::io::copy(&mut recv, &mut tcp_write).await?;
|
||||||
tcp_write.shutdown().await.ok();
|
tcp_write.shutdown().await.ok();
|
||||||
@@ -158,11 +158,11 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
|
|
||||||
| ADR | Decision | Summary |
|
| ADR | Decision | Summary |
|
||||||
|-----|----------|---------|
|
|-----|----------|---------|
|
||||||
| [074](decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` is the only accessor |
|
| [038](decisions/038-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` is the only accessor |
|
||||||
| [093](decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `BiStream`-only |
|
| [035](decisions/035-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `BiStream`-only |
|
||||||
| [070](decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The extension point `ChannelBidiStreamSource` implements |
|
| [008](decisions/008-bidistreamsource-trait.md) | BidiStreamSource Trait | The extension point `ChannelBidiStreamSource` implements |
|
||||||
| [092](decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream` (the transport-leaf decision this doc builds on) |
|
| [009](decisions/009-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream` (the transport-leaf decision this doc builds on) |
|
||||||
| [065](decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The yield-once path generalized for channels |
|
| [007](decisions/007-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The yield-once path generalized for channels |
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -171,9 +171,11 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
- ADR-008: BidiStreamSource trait
|
- ADR-008: BidiStreamSource trait
|
||||||
- ADR-009: `BiStream` as the handler leaf
|
- ADR-009: `BiStream` as the handler leaf
|
||||||
- ADR-007: `Connection::from_stream` (the yield-once path generalized)
|
- ADR-007: `Connection::from_stream` (the yield-once path generalized)
|
||||||
- ADR-077: TTY inside channels (TTY always uses its 5-byte format,
|
- Provenance (source-repo, not ported): [alknet ADR-077](/workspace/@alkdev/alknet/docs/architecture/decisions/077-tty-inside-channels.md) — TTY inside channels (TTY always uses its 5-byte format,
|
||||||
carried transparently in the channels payload)
|
carried transparently in the channels payload)
|
||||||
- `docs/research/alknet-channels/poc-summary.md` §POC Target 2 (the
|
- Source-repo POC research (alknet provenance):
|
||||||
yield-once `Connection::from_stream` validation)
|
`/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md`
|
||||||
- `docs/research/stream-unification/findings.md` — the research that
|
§POC Target 2 (the
|
||||||
|
yield-once `Connection::from_stream` validation);
|
||||||
|
`/workspace/@alkdev/alknet/docs/research/stream-unification/findings.md` — the research that
|
||||||
surfaced the single-accessor resolution
|
surfaced the single-accessor resolution
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-18
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# alknet-channels — Overview
|
# alkcall channels — Overview
|
||||||
|
|
||||||
## What
|
## What
|
||||||
|
|
||||||
`alknet-channels` is a multiplexing proxy crate. It implements
|
The channels subsystem of alkcall is a multiplexing proxy. It implements
|
||||||
`ProtocolHandler` for the `alk/channels` ALPN: it receives one
|
`ProtocolHandler` for the `alk/channels` ALPN: it receives one
|
||||||
bidirectional transport stream, reads 8-byte chunk headers, and routes each
|
bidirectional transport stream, reads 8-byte chunk headers, and routes each
|
||||||
chunk's payload to the right logical channel. Each channel is reassembled
|
chunk's payload to the right logical channel. Each channel is reassembled
|
||||||
@@ -28,7 +28,7 @@ multiplexing on the `BiStream` it receives.
|
|||||||
|
|
||||||
### The problem: three multiplexing models that don't compose
|
### The problem: three multiplexing models that don't compose
|
||||||
|
|
||||||
Before channels, alknet had three multiplexing models:
|
Before channels, the alk stack had three multiplexing models:
|
||||||
|
|
||||||
| Model | Where | Mechanism |
|
| Model | Where | Mechanism |
|
||||||
|-------|-------|-----------|
|
|-------|-------|-----------|
|
||||||
@@ -89,7 +89,8 @@ on the `BiStream` the channels layer gives them (ADR-035).
|
|||||||
inner layer's framing byte, carried transparently in the payload.
|
inner layer's framing byte, carried transparently in the payload.
|
||||||
- **The control channel is handler-internal.** TTY sub-demuxes control
|
- **The control channel is handler-internal.** TTY sub-demuxes control
|
||||||
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN` /
|
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN` /
|
||||||
`STREAM_CTRL_OUT` — ADR-052 amended by Phase 7). The channels layer
|
`STREAM_CTRL_OUT` — the TTY wire format, [alknet ADR-052](/workspace/@alkdev/alknet/docs/architecture/decisions/052-alknet-tty-wire-format-and-two-carriage.md)
|
||||||
|
amended by Phase 7; TTY is now the sibling crate `alktty`). The channels layer
|
||||||
doesn't carry control.
|
doesn't carry control.
|
||||||
- **Recursive composition is literal.** A channel with ALPN
|
- **Recursive composition is literal.** A channel with ALPN
|
||||||
`alk/channels` runs another channels demux on its `BiStream`. The
|
`alk/channels` runs another channels demux on its `BiStream`. The
|
||||||
@@ -110,8 +111,9 @@ The crate has two internal components (ADR-039):
|
|||||||
`channel/open` operation handler closes over.
|
`channel/open` operation handler closes over.
|
||||||
|
|
||||||
Each channel is presented to its handler as a `Connection` constructed via
|
Each channel is presented to its handler as a `Connection` constructed via
|
||||||
`Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-008/074, as
|
`Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-008 for the
|
||||||
amended by ADR-035). The handler calls `accept_bi()` once (yield-once per
|
extension point, ADR-007 for the `Connection` constructors, as amended by
|
||||||
|
ADR-035). The handler calls `accept_bi()` once (yield-once per
|
||||||
channel) and gets a `BiStream` — identical to how it works on a top-level
|
channel) and gets a `BiStream` — identical to how it works on a top-level
|
||||||
QUIC connection.
|
QUIC connection.
|
||||||
|
|
||||||
@@ -139,7 +141,8 @@ Downstream crates depend on alkcall as a single dependency:
|
|||||||
alktty / alktunnels / alktrader (protocol crates)
|
alktty / alktunnels / alktrader (protocol crates)
|
||||||
└── alkcall (call + channels protocols, no networking)
|
└── alkcall (call + channels protocols, no networking)
|
||||||
|
|
||||||
alknet / alknode (networking + composition)
|
composition layer (dial/accept + hub — today the alknet glue,
|
||||||
|
being replaced; future owner and shape → OQ-43)
|
||||||
├── alkcall
|
├── alkcall
|
||||||
├── alktty (optional — whichever protocol crates are wired in)
|
├── alktty (optional — whichever protocol crates are wired in)
|
||||||
├── alktunnels (optional)
|
├── alktunnels (optional)
|
||||||
@@ -147,7 +150,7 @@ alknet / alknode (networking + composition)
|
|||||||
```
|
```
|
||||||
|
|
||||||
The hub and worker are **consumers** of alkcall, not sub-crates. The
|
The hub and worker are **consumers** of alkcall, not sub-crates. The
|
||||||
downstream alknet crate IS the hub — it depends on alkcall and uses
|
composition layer IS the hub — it depends on alkcall and uses
|
||||||
channels as its substrate, with the relay logic (ADR-042) living
|
channels as its substrate, with the relay logic (ADR-042) living
|
||||||
alongside its peer lifecycle and service discovery responsibilities.
|
alongside its peer lifecycle and service discovery responsibilities.
|
||||||
A worker is any crate that uses `ChannelClient` to dial. There are no
|
A worker is any crate that uses `ChannelClient` to dial. There are no
|
||||||
@@ -168,7 +171,7 @@ stream:
|
|||||||
|-----------|-----|
|
|-----------|-----|
|
||||||
| QUIC bidi stream | `alk/channels` ALPN on a QUIC connection; one bidi stream carries all channels |
|
| QUIC bidi stream | `alk/channels` ALPN on a QUIC connection; one bidi stream carries all channels |
|
||||||
| TCP+TLS | `alk/channels` ALPN on a TLS connection; the TCP stream carries all channels |
|
| TCP+TLS | `alk/channels` ALPN on a TLS connection; the TCP stream carries all channels |
|
||||||
| WebTransport | `alk/channels` session (deferred per ADR-044; the browser path uses WebSocket carrying `alk/channels`) |
|
| WebTransport | `alk/channels` session (deferred per alknet ADR-044 — websocket-native session, provenance — the browser path uses WebSocket carrying `alk/channels`) |
|
||||||
| SSH channel | channels connection riding inside an SSH `direct-tcpip` channel (channels-over-SSH) |
|
| SSH channel | channels connection riding inside an SSH `direct-tcpip` channel (channels-over-SSH) |
|
||||||
| Another channels connection | recursive composition (channel type `alk/channels` inside `alk/channels`) |
|
| Another channels connection | recursive composition (channel type `alk/channels` inside `alk/channels`) |
|
||||||
|
|
||||||
@@ -186,14 +189,14 @@ async shell (demux/mux) wraps this core with `read_exact`/`write_all` and
|
|||||||
`mpsc` routing.
|
`mpsc` routing.
|
||||||
|
|
||||||
The `ChannelManager` is ALPN-blind, auth-blind, and transport-blind (ADR-
|
The `ChannelManager` is ALPN-blind, auth-blind, and transport-blind (ADR-
|
||||||
075) — pure byte routing with no platform or protocol dependencies. A WASM
|
039) — pure byte routing with no platform or protocol dependencies. A WASM
|
||||||
build can read chunks from a WebTransport `BiStream`, reassemble them, and
|
build can read chunks from a WebTransport `BiStream`, reassemble them, and
|
||||||
present `AsyncRead + AsyncWrite` handles to WASM-compatible handlers. The
|
present `AsyncRead + AsyncWrite` handles to WASM-compatible handlers. The
|
||||||
handlers themselves may or may not be WASM-compatible (russh's client is;
|
handlers themselves may or may not be WASM-compatible (russh's client is;
|
||||||
`portable_pty` is not), but the channels layer is WASM-compatible by
|
`portable_pty` is not), but the channels layer is WASM-compatible by
|
||||||
construction.
|
construction.
|
||||||
|
|
||||||
The async shell and `alknet-core` dep graph are not fully WASM-clean yet
|
The async shell dep graph is not fully WASM-clean yet
|
||||||
(transitive `getrandom`/`rand` deps) — this is an implementation concern,
|
(transitive `getrandom`/`rand` deps) — this is an implementation concern,
|
||||||
not an architecture concern. The sync core's WASM compatibility is validated.
|
not an architecture concern. The sync core's WASM compatibility is validated.
|
||||||
|
|
||||||
@@ -225,9 +228,9 @@ A protocol crate can support two paths for its wire format:
|
|||||||
The protocol crate doesn't know which path it's on — it takes a
|
The protocol crate doesn't know which path it's on — it takes a
|
||||||
`BiStream` (or `AsyncRead + AsyncWrite`) and drives the session. The
|
`BiStream` (or `AsyncRead + AsyncWrite`) and drives the session. The
|
||||||
two adapters are thin and live either in the protocol crate (behind
|
two adapters are thin and live either in the protocol crate (behind
|
||||||
feature flags) or in the downstream alknet crate.
|
feature flags) or in the composition layer.
|
||||||
|
|
||||||
### alknet-call
|
### Channel 0 is the call protocol
|
||||||
|
|
||||||
The call protocol runs on channel 0 exactly as on a top-level
|
The call protocol runs on channel 0 exactly as on a top-level
|
||||||
`alk/call` connection. The `Dispatcher` receives a `Connection`
|
`alk/call` connection. The `Dispatcher` receives a `Connection`
|
||||||
@@ -236,7 +239,7 @@ doesn't know it's inside channels. The call protocol's `EventEnvelope`
|
|||||||
framing (ADR-014) is the channels payload; the channels layer carries
|
framing (ADR-014) is the channels payload; the channels layer carries
|
||||||
it transparently.
|
it transparently.
|
||||||
|
|
||||||
### alknet-hub / alknode
|
### Hub (composition layer)
|
||||||
|
|
||||||
The hub is the primary consumer. With channels, the hub holds one
|
The hub is the primary consumer. With channels, the hub holds one
|
||||||
channels connection per leg (browser↔hub, hub↔spoke) and relays
|
channels connection per leg (browser↔hub, hub↔spoke) and relays
|
||||||
@@ -261,7 +264,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
| [040](decisions/040-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer (1 MiB), 256-channel cap, monotonic IDs |
|
| [040](decisions/040-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer (1 MiB), 256-channel cap, monotonic IDs |
|
||||||
| [041](decisions/041-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy`; per-connection `max_channels` reframed as a memory bound |
|
| [041](decisions/041-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy`; per-connection `max_channels` reframed as a memory bound |
|
||||||
| [042](decisions/042-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels with ID rewrite |
|
| [042](decisions/042-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels with ID rewrite |
|
||||||
| [043](decisions/043-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045, resolves OQ-55) |
|
| [043](decisions/043-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in the composition layer's dial helper (ADR-045) |
|
||||||
| [044](decisions/044-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers |
|
| [044](decisions/044-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers |
|
||||||
| [047](decisions/047-openable-alpns-are-operations.md) | Openable ALPNs Are Operations | `channel/open` dissolves into per-ALPN ops; `ChannelCore` wrapper; connection-owner allocates `channel_id` |
|
| [047](decisions/047-openable-alpns-are-operations.md) | Openable ALPNs Are Operations | `channel/open` dissolves into per-ALPN ops; `ChannelCore` wrapper; connection-owner allocates `channel_id` |
|
||||||
|
|
||||||
@@ -270,18 +273,22 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
Open questions are tracked in [open-questions.md](open-questions.md).
|
Open questions are tracked in [open-questions.md](open-questions.md).
|
||||||
Key questions affecting this crate:
|
Key questions affecting this crate:
|
||||||
|
|
||||||
- **OQ-55** (resolved by ADR-045): `AlknetClient` core **dial+TLS seam**
|
- **Dial seam** (ADR-045; source: alknet OQ-55): the dial+TLS seam is
|
||||||
— extracted as `alknet-client` with three dial methods.
|
the composition layer's dial helper (`AlknetClient` today — future
|
||||||
`ChannelClient`'s API is transport-agnostic (`from_connection`); the
|
owner and shape OQ-43). `ChannelClient`'s API is transport-agnostic
|
||||||
dial is the shared seam, now extracted. See
|
(`from_connection`); the dial is the shared seam. See
|
||||||
[ADR-045](decisions/045-alknetclient-native-dial-seam.md).
|
[ADR-045](decisions/045-alknetclient-native-dial-seam.md).
|
||||||
- **OQ-56** (deferred(scope)): Full channel-level flow-control windowing —
|
- **OQ-23** ([tracker](open-questions.md); source: alknet OQ-56)
|
||||||
|
(deferred(scope)): Full channel-level flow-control windowing —
|
||||||
bounded-buffer is decided (ADR-040); full windowing is an extension
|
bounded-buffer is decided (ADR-040); full windowing is an extension
|
||||||
blocked on a real HOL-blocking deployment observation.
|
blocked on a real HOL-blocking deployment observation.
|
||||||
- **OQ-57** (deferred(scope)): Two-pump helper extraction to alknet-core —
|
- **Two-pump helper** (source: alknet OQ-57) (dissolved): the
|
||||||
the *contract* is decided (ADR-078); the *helper* is blocked on a second
|
contract is decided (the in-tree helper landed as
|
||||||
two-pump handler existing.
|
`channels::pump_bidi`, ADR-050); the extraction target (a mono-repo
|
||||||
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
|
core crate) no longer exists — alkcall owns its helpers.
|
||||||
|
- **OQ-24** ([tracker](open-questions.md); source: alknet OQ-68)
|
||||||
|
(resolved): Add/strip API shape — whether the 8-byte header
|
||||||
add/strip is built into the channels read/write path or exposed as a
|
add/strip is built into the channels read/write path or exposed as a
|
||||||
standalone utility. The *contract* (channels strips, handler parses
|
standalone utility. The *contract* (channels strips, handler parses
|
||||||
payload) is decided (ADR-035); the *function surface* is not.
|
payload) is decided (ADR-035); the shipped surface integrates the
|
||||||
|
strip (see OQ-24 in [open-questions.md](open-questions.md)).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-18
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# channels-wire.md — The 8-Byte Chunk Format
|
# channels-wire.md — The 8-Byte Chunk Format
|
||||||
@@ -36,7 +36,7 @@ hand-rolled (the hot path); the BAST document is the contract.
|
|||||||
|
|
||||||
The payload is opaque to the channels layer. The handler parses its own
|
The payload is opaque to the channels layer. The handler parses its own
|
||||||
framing from the payload — TTY's `[stream_type:u8][length:u32][payload]`
|
framing from the payload — TTY's `[stream_type:u8][length:u32][payload]`
|
||||||
(5-byte format, ADR-052), call's length-prefixed JSON (`EventEnvelope`
|
(5-byte format, alknet ADR-052), call's length-prefixed JSON (`EventEnvelope`
|
||||||
framing, ADR-014), tunnel's raw bytes, SSH's channel protocol. The
|
framing, ADR-014), tunnel's raw bytes, SSH's channel protocol. The
|
||||||
channels layer carries the bytes transparently.
|
channels layer carries the bytes transparently.
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ its framing entirely. See ADR-035 for the full cost/benefit analysis.
|
|||||||
|
|
||||||
## `MAX_CHUNK_LEN`
|
## `MAX_CHUNK_LEN`
|
||||||
|
|
||||||
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (ADR-052 §5). A chunk with
|
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (alknet ADR-052 §5). A chunk with
|
||||||
`length > MAX_CHUNK_LEN` returns `ChunkTooLarge` and does not corrupt the
|
`length > MAX_CHUNK_LEN` returns `ChunkTooLarge` and does not corrupt the
|
||||||
stream — the demux drops the chunk and continues. The header is always
|
stream — the demux drops the chunk and continues. The header is always
|
||||||
exactly 8 bytes, so the demux can always resync by reading the next
|
exactly 8 bytes, so the demux can always resync by reading the next
|
||||||
@@ -103,14 +103,15 @@ There is no channels-layer framing-disambiguation trick beyond the fixed
|
|||||||
8-byte header. The channels layer does not interpret the payload — it
|
8-byte header. The channels layer does not interpret the payload — it
|
||||||
doesn't know if the payload is TTY chunks, call frames, or tunnel bytes.
|
doesn't know if the payload is TTY chunks, call frames, or tunnel bytes.
|
||||||
Any framing disambiguation within the payload is the handler's concern
|
Any framing disambiguation within the payload is the handler's concern
|
||||||
(see `tty-wire.md` §"Framing disambiguation" for TTY's first-byte trick,
|
(see the TTY wire format's "framing disambiguation" — alknet ADR-052,
|
||||||
|
provenance; TTY is now the sibling crate `alktty` — for TTY's first-byte trick,
|
||||||
which is internal to TTY's 5-byte format).
|
which is internal to TTY's 5-byte format).
|
||||||
|
|
||||||
## Zero-length sentinel = EOF
|
## Zero-length sentinel = EOF
|
||||||
|
|
||||||
A zero-length chunk (`length = 0`) is delivered as an empty payload,
|
A zero-length chunk (`length = 0`) is delivered as an empty payload,
|
||||||
which the reassembled stream interprets as EOF. This is the clean-shutdown
|
which the reassembled stream interprets as EOF. This is the clean-shutdown
|
||||||
signal for a `channel_id` — the same convention as TTY (ADR-052
|
signal for a `channel_id` — the same convention as TTY (alknet ADR-052
|
||||||
§Sentinels), now at the channels layer (one sentinel per channel, not
|
§Sentinels), now at the channels layer (one sentinel per channel, not
|
||||||
per `(channel_id, stream_type)`).
|
per `(channel_id, stream_type)`).
|
||||||
|
|
||||||
@@ -141,13 +142,16 @@ experience. See ADR-034 §substrate modes (as amended by ADR-035), ADR-039.
|
|||||||
**Implementation status:** the in-line substrate mode is implemented
|
**Implementation status:** the in-line substrate mode is implemented
|
||||||
(single bidi stream, header-demuxed N channels). The QUIC-native
|
(single bidi stream, header-demuxed N channels). The QUIC-native
|
||||||
multi-stream substrate (accept remaining bidi streams, read headers off
|
multi-stream substrate (accept remaining bidi streams, read headers off
|
||||||
each) is deferred to the downstream alknet crate (OQ-41). The wire
|
each) is deferred to the composition layer's dial/accept half (OQ-41;
|
||||||
|
its owner — OQ-43). The wire
|
||||||
format and demux loop are correct for both substrates; only the outer
|
format and demux loop are correct for both substrates; only the outer
|
||||||
`accept_bi()` loop is missing.
|
`accept_bi()` loop is missing.
|
||||||
|
|
||||||
## Wire-level invariants (REQ-CH-01, 02, 04, 05)
|
## Wire-level invariants (REQ-CH-01, 02, 04, 05)
|
||||||
|
|
||||||
The de-risk POC (`docs/research/alknet-channels/poc-summary.md` §Issues
|
The de-risk POC (source-repo research, alknet provenance:
|
||||||
|
`/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md`
|
||||||
|
§Issues
|
||||||
Surfaced) surfaced invariants that hang channels silently if underspecified.
|
Surfaced) surfaced invariants that hang channels silently if underspecified.
|
||||||
These are **contracts**, not implementation details — both sides must agree.
|
These are **contracts**, not implementation details — both sides must agree.
|
||||||
|
|
||||||
@@ -245,9 +249,13 @@ same shape, each level.
|
|||||||
|
|
||||||
The exact API shape of the add/strip pair (built into the read/write path
|
The exact API shape of the add/strip pair (built into the read/write path
|
||||||
vs. a standalone utility) is an implementation detail for the channels
|
vs. a standalone utility) is an implementation detail for the channels
|
||||||
crate, tracked as OQ-68. The *contract* — the channels layer strips its
|
crate; the shipped surface resolves it (OQ-24, formerly alknet OQ-68):
|
||||||
8-byte header on read and the handler parses its own framing from the
|
the strip is integrated into the read/write path — the demux loop
|
||||||
payload — is decided; the *function surface* is not.
|
consumes `read_header`/`write_chunk` so a handler's `BiStream` yields
|
||||||
|
payload chunks and never sees the 8-byte header — while the low-level
|
||||||
|
primitives (`parse_header`/`write_header`/`read_header`/`write_chunk`/
|
||||||
|
`write_eof` in `channels::wire`) stay exposed as the standalone utility
|
||||||
|
for tooling.
|
||||||
|
|
||||||
## Channel lifecycle (summary)
|
## Channel lifecycle (summary)
|
||||||
|
|
||||||
@@ -258,14 +266,14 @@ payload — is decided; the *function surface* is not.
|
|||||||
| Control (out-of-band) | `channel/control` call operation on channel 0 (deferred — OQ-39) | ADR-037 |
|
| Control (out-of-band) | `channel/control` call operation on channel 0 (deferred — OQ-39) | ADR-037 |
|
||||||
| Close | `channel/close` call operation on channel 0; data chunks flushed before close | ADR-037, REQ-CH-06 |
|
| Close | `channel/close` call operation on channel 0; data chunks flushed before close | ADR-037, REQ-CH-06 |
|
||||||
|
|
||||||
### REQ-CH-06: exit-chunk-before-close ordering (generalizes ADR-055)
|
### REQ-CH-06: exit-chunk-before-close ordering (generalizes alknet ADR-055 — the TTY exit-chunk invariant, provenance)
|
||||||
|
|
||||||
The channel's data chunks MUST be written and flushed before the
|
The channel's data chunks MUST be written and flushed before the
|
||||||
`channel/close` operation is sent on channel 0. This is a wire-level
|
`channel/close` operation is sent on channel 0. This is a wire-level
|
||||||
invariant: the side closing must observe the data-channel pump complete
|
invariant: the side closing must observe the data-channel pump complete
|
||||||
before issuing the call operation.
|
before issuing the call operation.
|
||||||
|
|
||||||
For TTY this is the exit-chunk-is-last invariant (ADR-055) carried
|
For TTY this is the exit-chunk-is-last invariant (alknet ADR-055, provenance) carried
|
||||||
forward: the exit control message (on TTY's `STREAM_CTRL_OUT` stream_type
|
forward: the exit control message (on TTY's `STREAM_CTRL_OUT` stream_type
|
||||||
4, inside TTY's 5-byte payload) is the last data before `channel/close`.
|
4, inside TTY's 5-byte payload) is the last data before `channel/close`.
|
||||||
For tunnels it is the last data byte before close. The channels layer's
|
For tunnels it is the last data byte before close. The channels layer's
|
||||||
@@ -293,23 +301,25 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
Open questions are tracked in [open-questions.md](open-questions.md).
|
Open questions are tracked in [open-questions.md](open-questions.md).
|
||||||
Key questions affecting this doc:
|
Key questions affecting this doc:
|
||||||
|
|
||||||
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
|
- **OQ-24** ([tracker](open-questions.md); source: alknet OQ-68) (resolved): Add/strip API shape —
|
||||||
add/strip is built into the channels read/write path or exposed as a
|
built into the read/write path (the demux loop strips headers; handlers
|
||||||
standalone utility. The *contract* (channels strips, handler parses
|
never see them), with the low-level header primitives exposed in
|
||||||
payload) is decided; the *function surface* is not.
|
`channels::wire` for tooling.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- ADR-034: channels wire format (the decision, amended by ADR-035 — 8-byte
|
- ADR-034: channels wire format (the decision, amended by ADR-035 — 8-byte
|
||||||
header, no `stream_type`)
|
header, no `stream_type`)
|
||||||
- ADR-035: channels pure channel multiplexing (the umbrella decision that
|
- ADR-035: channels pure channel multiplexing (the umbrella decision that
|
||||||
amends ADR-034/074/077)
|
amends ADR-034 and the source-repo connection/from_source decision —
|
||||||
|
alknet ADR-074, provenance; the in-crate homes are ADR-007/008)
|
||||||
- ADR-036: channel 0 pre-negotiated
|
- ADR-036: channel 0 pre-negotiated
|
||||||
- ADR-037: channel lifecycle operations
|
- ADR-037: channel lifecycle operations
|
||||||
- ADR-040: backpressure, channel limits, ID reuse
|
- ADR-040: backpressure, channel limits, ID reuse
|
||||||
- ADR-047: openable ALPNs are operations (per-ALPN open ops dissolve
|
- ADR-047: openable ALPNs are operations (per-ALPN open ops dissolve
|
||||||
the generic `channel/open`)
|
the generic `channel/open`)
|
||||||
- `docs/research/alknet-channels/poc-summary.md` §POC Target 1, §Issues
|
- Source-repo POC research (alknet provenance):
|
||||||
Surfaced #4-#6 (REQ-CH-01, 02, 04)
|
`/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md`
|
||||||
- `docs/research/stream-unification/findings.md` — the research that
|
§POC Target 1, §Issues Surfaced #4-#6 (REQ-CH-01, 02, 04);
|
||||||
surfaced the 8-byte format decision
|
`/workspace/@alkdev/alknet/docs/research/stream-unification/findings.md`
|
||||||
|
— the research that surfaced the 8-byte format decision
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-17
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# alknet-call — Client and Adapters
|
# alkcall — Client and Adapters
|
||||||
|
|
||||||
The outbound half of the call protocol: opening connections, importing remote
|
The outbound half of the call protocol: opening connections, importing remote
|
||||||
operations, and the adapter contract that ties import-style adapters together.
|
operations, and the adapter contract that ties import-style adapters together.
|
||||||
@@ -11,14 +11,15 @@ This document covers what ADR-022 specced but the server-side implementation
|
|||||||
(`call-protocol.md`, `operation-registry.md`) did not include — the `CallClient`
|
(`call-protocol.md`, `operation-registry.md`) did not include — the `CallClient`
|
||||||
that *opens* a connection, the `from_call` adapter, and the
|
that *opens* a connection, the `from_call` adapter, and the
|
||||||
`OperationAdapter` trait. (`from_jsonschema` was originally specced here
|
`OperationAdapter` trait. (`from_jsonschema` was originally specced here
|
||||||
too, but ADR-027 moved it to `alknet-http` — see §"from_jsonschema" below.)
|
too, but ADR-027 moved it to the HTTP host crate — today `alkhttp`
|
||||||
|
(then named `alknet-http`) — see §"from_jsonschema" below.)
|
||||||
The server-side `CallAdapter` and `CallConnection`
|
The server-side `CallAdapter` and `CallConnection`
|
||||||
dispatch loop are covered in `call-protocol.md`; this document covers the
|
dispatch loop are covered in `call-protocol.md`; this document covers the
|
||||||
client-side connection-establishment half and the adapter surface.
|
client-side connection-establishment half and the adapter surface.
|
||||||
|
|
||||||
## What
|
## What
|
||||||
|
|
||||||
This document specifies three components, all in `alknet-call`:
|
This document specifies three components, all in alkcall:
|
||||||
|
|
||||||
1. **`CallClient`** — takes over an established transport `Connection`
|
1. **`CallClient`** — takes over an established transport `Connection`
|
||||||
on ALPN `alk/call`, spawns the shared dispatch loop, and produces
|
on ALPN `alk/call`, spawns the shared dispatch loop, and produces
|
||||||
@@ -35,20 +36,21 @@ This document specifies three components, all in `alknet-call`:
|
|||||||
`from_openapi`, `from_mcp`, and `from_jsonschema` all implement.
|
`from_openapi`, `from_mcp`, and `from_jsonschema` all implement.
|
||||||
|
|
||||||
> **`from_jsonschema` moved.** ADR-027 moved `from_jsonschema` from
|
> **`from_jsonschema` moved.** ADR-027 moved `from_jsonschema` from
|
||||||
> `alknet-call` to `alknet-http` and gave it a real reqwest-backed
|
> the call protocol to the HTTP host crate and gave it a real reqwest-backed
|
||||||
> forwarding handler (it was a broken schema-only placeholder before).
|
> forwarding handler (it was a broken schema-only placeholder before).
|
||||||
> It is now an HTTP-backed single-endpoint adapter for non-standard /
|
> It is now an HTTP-backed single-endpoint adapter for non-standard /
|
||||||
> non-OpenAPI / basic REST endpoints, functionally similar to
|
> non-OpenAPI / basic REST endpoints, functionally similar to
|
||||||
> `from_openapi` but one endpoint at a time. See
|
> `from_openapi` but one endpoint at a time. It lives in `alkhttp`; its
|
||||||
> [`crates/http/http-adapters.md`](../http/http-adapters.md) §"from_jsonschema".
|
> full spec is `alkhttp`'s `http-adapters.md` (original source-repo copy
|
||||||
> The `FromJsonSchema` provenance variant stays in `alknet-call`
|
> at `/workspace/@alkdev/alknet/docs/architecture/crates/http/http-adapters.md`,
|
||||||
|
> provenance). The `FromJsonSchema` provenance variant stays in alkcall
|
||||||
> (`OperationProvenance`); only the adapter implementation moved.
|
> (`OperationProvenance`); only the adapter implementation moved.
|
||||||
|
|
||||||
It also records two cross-cutting architectural mechanisms that the adapter
|
It also records two cross-cutting architectural mechanisms that the adapter
|
||||||
surface rests on:
|
surface rests on:
|
||||||
|
|
||||||
- The **adapter location map** — which adapters live in `alknet-call` vs
|
- The **adapter location map** — which adapters live in alkcall vs
|
||||||
`alknet-http`, and why.
|
`alkhttp`, and why.
|
||||||
- The **no-env-vars invariant** — the architectural mechanism by which
|
- The **no-env-vars invariant** — the architectural mechanism by which
|
||||||
downstream consumers' `std::env::var` credential reads are made unreachable.
|
downstream consumers' `std::env::var` credential reads are made unreachable.
|
||||||
|
|
||||||
@@ -65,23 +67,26 @@ downstream consumer: the runner pattern (a process that connects outward to a
|
|||||||
hub and exposes local ops), the container-service rewrite, the bilateral
|
hub and exposes local ops), the container-service rewrite, the bilateral
|
||||||
exchange, the NAPI projection, and the agent's cross-node tool dispatch all
|
exchange, the NAPI projection, and the agent's cross-node tool dispatch all
|
||||||
require a `CallClient`. `from_call` is the #2 gap; the `OperationAdapter`
|
require a `CallClient`. `from_call` is the #2 gap; the `OperationAdapter`
|
||||||
trait is the enabling gap for `alknet-http`'s `from_openapi`/`from_mcp`.
|
trait is the enabling gap for `alkhttp`'s `from_openapi`/`from_mcp`.
|
||||||
|
|
||||||
ADR-022 specced this surface. This document is the spec that operationally
|
ADR-022 specced this surface. This document is the spec that operationally
|
||||||
fills the gap ADR-022 left to implementation: the `CallClient` API, the
|
fills the gap ADR-022 left to implementation: the `CallClient` API, the
|
||||||
`from_call` flow, the trait signature, the adapter location, the credential
|
`from_call` flow, the trait signature, the adapter location, the credential
|
||||||
invariant, and the bilateral pattern. The gap
|
invariant, and the bilateral pattern. The gap
|
||||||
analysis (`docs/research/alknet-call-completion/gap-analysis.md`) identified
|
analysis (source-repo research, now at
|
||||||
|
`/workspace/@alkdev/alknet/docs/research/alknet-call-completion/gap-analysis.md`)
|
||||||
|
identified
|
||||||
four decisions (DC-1..4) needed before implementation. DC-1 was initially
|
four decisions (DC-1..4) needed before implementation. DC-1 was initially
|
||||||
resolved by ADR-023 (`remote_safe`/`trusted_peer`), but a subsequent research
|
resolved by ADR-023 (`remote_safe`/`trusted_peer`), but a subsequent research
|
||||||
pass (`docs/research/alknet-call-peer-routing/findings.md`) found that
|
pass (source-repo research, now at
|
||||||
|
`/workspace/@alkdev/alknet/docs/research/alknet-call-peer-routing/findings.md`) found that
|
||||||
ADR-023's model was structurally broken for the head→N-workers pattern (the
|
ADR-023's model was structurally broken for the head→N-workers pattern (the
|
||||||
primary use case) and that its parallel `remote_safe`/`trusted_peer`
|
primary use case) and that its parallel `remote_safe`/`trusted_peer`
|
||||||
authorization system duplicated the existing `AccessControl`/`Identity`
|
authorization system duplicated the existing `AccessControl`/`Identity`
|
||||||
machinery. **ADR-024 supersedes ADR-023**: peer-keyed overlays + `PeerRef`
|
machinery. **ADR-024 supersedes ADR-023**: peer-keyed overlays + `PeerRef`
|
||||||
routing, and peer authorization through the existing `AccessControl::check(peer_identity)`.
|
routing, and peer authorization through the existing `AccessControl::check(peer_identity)`.
|
||||||
DC-2/3/4 are two-way-door defaults recorded here (DC-2→OQ-27, DC-3→OQ-28
|
DC-2/3/4 are two-way-door defaults recorded here (DC-2→OQ-12, DC-3→OQ-13
|
||||||
cross-peer dissolved / same-peer stays, DC-4→OQ-26).
|
cross-peer dissolved / same-peer stays, DC-4→OQ-11).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -101,7 +106,7 @@ ordered, reliable bidirectional stream — QUIC, TCP+TLS, WebTransport,
|
|||||||
SSH `direct-tcpip`, a WebSocket (ADR-007 `Connection::from_stream` /
|
SSH `direct-tcpip`, a WebSocket (ADR-007 `Connection::from_stream` /
|
||||||
`from_bidi`). The primary constructor (`spawn_dispatch`) takes a
|
`from_bidi`). The primary constructor (`spawn_dispatch`) takes a
|
||||||
pre-established `Connection` from any transport; the dial lives in
|
pre-established `Connection` from any transport; the dial lives in
|
||||||
`AlknetClient` (`alknet-client`, ADR-045). This mirrors
|
the composition layer's dial helper, `AlknetClient` (ADR-045). This mirrors
|
||||||
`ChannelClient::from_connection` (ADR-043) and is the client-side
|
`ChannelClient::from_connection` (ADR-043) and is the client-side
|
||||||
analogue of the server-side generalization ADR-007 made.
|
analogue of the server-side generalization ADR-007 made.
|
||||||
|
|
||||||
@@ -136,7 +141,7 @@ the op's required scopes/resources are satisfied, the call dispatches; if not,
|
|||||||
`FORBIDDEN` before the handler runs (capabilities never populated — the
|
`FORBIDDEN` before the handler runs (capabilities never populated — the
|
||||||
security property). An op that should never be callable from the wire uses
|
security property). An op that should never be callable from the wire uses
|
||||||
`Visibility::Internal` (existing mechanism, `NOT_FOUND` before ACL). See
|
`Visibility::Internal` (existing mechanism, `NOT_FOUND` before ACL). See
|
||||||
[ADR-024](decisions/029-peer-graph-routing-model.md) §3 for the full
|
[ADR-024](decisions/024-peer-graph-routing-model.md) §3 for the full
|
||||||
mapping of the three `remote_safe` cases to `AccessControl`/`Visibility`.
|
mapping of the three `remote_safe` cases to `AccessControl`/`Visibility`.
|
||||||
|
|
||||||
The connection is symmetric after establishment (ADR-022 §2): both sides can
|
The connection is symmetric after establishment (ADR-022 §2): both sides can
|
||||||
@@ -176,14 +181,15 @@ authorization machinery that gates every other call. No `RemoteFilter`, no
|
|||||||
primary constructor — it takes a pre-established `Connection`,
|
primary constructor — it takes a pre-established `Connection`,
|
||||||
constructs a `CallConnection`, builds a `Dispatcher`, spawns the
|
constructs a `CallConnection`, builds a `Dispatcher`, spawns the
|
||||||
dispatch task, and returns the live `CallConnection`. The dial lives in
|
dispatch task, and returns the live `CallConnection`. The dial lives in
|
||||||
`AlknetClient` (`alknet-client`, ADR-045): keeping a QUIC convenience
|
the composition layer's dial helper, `AlknetClient` (ADR-045): keeping a QUIC convenience
|
||||||
constructor on `CallClient` would make `alknet-call` depend on
|
constructor on `CallClient` would make alkcall depend on
|
||||||
`alknet-client`, contradicting the dep graph (the protocol crates are
|
the dial crate, contradicting the dep graph (the protocol crates are
|
||||||
parallel to the dial, not downstream of it). Callers compose
|
parallel to the dial, not downstream of it). Callers compose
|
||||||
`AlknetClient::dial_quic` + `spawn_dispatch` — two lines, the dial then
|
`AlknetClient::dial_quic` + `spawn_dispatch` — two lines, the dial then
|
||||||
the take-over. Tests use `spawn_dispatch` directly to wire mock/loopback
|
the take-over. Tests use `spawn_dispatch` directly to wire mock/loopback
|
||||||
connections. The one-way-door surface is `spawn_dispatch`; the dial
|
connections. The one-way-door surface is `spawn_dispatch`; the dial
|
||||||
lives in `alknet-client`.
|
lives in the composition layer (`AlknetClient` today — future owner
|
||||||
|
OQ-43).
|
||||||
|
|
||||||
This mirrors `ChannelClient::from_connection` (ADR-043) and is the
|
This mirrors `ChannelClient::from_connection` (ADR-043) and is the
|
||||||
client-side analogue of the server-side generalization ADR-007 made.
|
client-side analogue of the server-side generalization ADR-007 made.
|
||||||
@@ -212,7 +218,7 @@ pub type PeerId = String; // = Identity.id from IdentityProvider resolution
|
|||||||
|
|
||||||
`OperationEnv` gains a peer-routing method with a `PeerRef` selector
|
`OperationEnv` gains a peer-routing method with a `PeerRef` selector
|
||||||
(`Specific(PeerId)` / `Any`), default-impl for back-compat. See
|
(`Specific(PeerId)` / `Any`), default-impl for back-compat. See
|
||||||
[ADR-024](decisions/029-peer-graph-routing-model.md) §2 for the full
|
[ADR-024](decisions/024-peer-graph-routing-model.md) §2 for the full
|
||||||
`invoke_peer` signature and `ScopedPeerEnv` peer-qualified reachability. The
|
`invoke_peer` signature and `ScopedPeerEnv` peer-qualified reachability. The
|
||||||
per-`CallConnection` overlay stays flat (one connection = one peer); the
|
per-`CallConnection` overlay stays flat (one connection = one peer); the
|
||||||
peer-keying is at the aggregation layer (the head node's composition env).
|
peer-keying is at the aggregation layer (the head node's composition env).
|
||||||
@@ -225,14 +231,14 @@ single `AccessControl`-filtered handler (no `peer_scoped` variant, no
|
|||||||
`remote_safe` filter — both retired by ADR-024). `services/list-peers`
|
`remote_safe` filter — both retired by ADR-024). `services/list-peers`
|
||||||
is the opt-in for peer-attributed re-export listing (each peer's
|
is the opt-in for peer-attributed re-export listing (each peer's
|
||||||
sub-overlay listed with attribution, filtered by the calling peer's
|
sub-overlay listed with attribution, filtered by the calling peer's
|
||||||
authorization). See [ADR-024](decisions/029-peer-graph-routing-model.md) §6.
|
authorization). See [ADR-024](decisions/024-peer-graph-routing-model.md) §6.
|
||||||
|
|
||||||
### Credential sources for connections
|
### Credential sources for connections
|
||||||
|
|
||||||
The credential dimensions are split across two layers (ADR-012, amended
|
The credential dimensions are split across two layers (ADR-012, amended
|
||||||
2026-07-17):
|
2026-07-17):
|
||||||
|
|
||||||
- **`ConnectionCredentials`** (in `alknet-core`, per ADR-012) — the
|
- **`ConnectionCredentials`** (vendored core types in alkcall, per ADR-012) — the
|
||||||
**transport-level** credential bundle, consumed by the dial
|
**transport-level** credential bundle, consumed by the dial
|
||||||
(`AlknetClient`). Carries the two transport-identity dimensions:
|
(`AlknetClient`). Carries the two transport-identity dimensions:
|
||||||
`local_identity` (the local node's `TlsIdentity`) and `remote_identity`
|
`local_identity` (the local node's `TlsIdentity`) and `remote_identity`
|
||||||
@@ -250,7 +256,7 @@ Credentials come from `Capabilities` (ADR-010), never from environment
|
|||||||
variables. The transport-identity dimensions (ADR-022 §7):
|
variables. The transport-identity dimensions (ADR-022 §7):
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// Transport-level (alknet-core, consumed by the dial — ADR-012)
|
// Transport-level (vendored core in alkcall, consumed by the dial — ADR-012)
|
||||||
pub struct ConnectionCredentials {
|
pub struct ConnectionCredentials {
|
||||||
pub local_identity: Option<TlsIdentity>, // RFC 7250 raw key or X.509
|
pub local_identity: Option<TlsIdentity>, // RFC 7250 raw key or X.509
|
||||||
pub remote_identity: Option<RemoteIdentity>, // expected fingerprint (None = CA path / fail-closed)
|
pub remote_identity: Option<RemoteIdentity>, // expected fingerprint (None = CA path / fail-closed)
|
||||||
@@ -262,14 +268,15 @@ pub struct ConnectionCredentials {
|
|||||||
// Dispatcher::resolve_identity reads payload.get("auth_token").
|
// Dispatcher::resolve_identity reads payload.get("auth_token").
|
||||||
```
|
```
|
||||||
|
|
||||||
`RemoteIdentity` (ADR-022 §7, extended by ADR-034 §2) carries a
|
`RemoteIdentity` (ADR-022 §7, extended by alknet ADR-034 §2 — the
|
||||||
|
verifier-rule ADR, re-owned as ADR-054) carries a
|
||||||
fingerprint string the assembly layer derives from `Capabilities` when
|
fingerprint string the assembly layer derives from `Capabilities` when
|
||||||
the local node has a `PeerEntry` for the remote (the known-peer case →
|
the local node has a `PeerEntry` for the remote (the known-peer case →
|
||||||
fingerprint pin). `remote_identity: None` is the **public X.509
|
fingerprint pin). `remote_identity: None` is the **public X.509
|
||||||
endpoint** case: the local node has no `PeerEntry` for the remote, so
|
endpoint** case: the local node has no `PeerEntry` for the remote, so
|
||||||
there is no fingerprint to pin. Combined with an X.509 transport, `None`
|
there is no fingerprint to pin. Combined with an X.509 transport, `None`
|
||||||
selects CA verification (`WebPkiServerVerifier`) per the
|
selects CA verification (`WebPkiServerVerifier`) per the
|
||||||
verifier-selection rule in ADR-034 §3. Combined with an Ed25519
|
verifier-selection rule in ADR-054. Combined with an Ed25519
|
||||||
raw-key transport, `None` fails closed (raw-key remotes are always
|
raw-key transport, `None` fails closed (raw-key remotes are always
|
||||||
known peers — no CA to fall back to). The `Option` is load-bearing, not
|
known peers — no CA to fall back to). The `Option` is load-bearing, not
|
||||||
cosmetic: `Some(fingerprint)` means "pin this" (known peer), `None`
|
cosmetic: `Some(fingerprint)` means "pin this" (known peer), `None`
|
||||||
@@ -282,26 +289,28 @@ pub struct RemoteIdentity { pub fingerprint: String }
|
|||||||
```
|
```
|
||||||
|
|
||||||
There is no call-protocol credential bundle. The transport dimensions
|
There is no call-protocol credential bundle. The transport dimensions
|
||||||
(`local_identity`, `remote_identity`) are in `ConnectionCredentials` in
|
(`local_identity`, `remote_identity`) are in `ConnectionCredentials` with
|
||||||
`alknet-core` per ADR-012.
|
the vendored core types in alkcall, per ADR-012.
|
||||||
|
|
||||||
- **TLS identity** — the local node's Ed25519 raw key (RFC 7250) or X.509 cert,
|
- **TLS identity** — the local node's Ed25519 raw key (RFC 7250) or X.509 cert,
|
||||||
derived from the vault at startup (ADR-020, ADR-026, ADR-027).
|
derived from the vault at startup (vault ADRs live in the source repo —
|
||||||
|
alknet ADR-020/026/027, provenance).
|
||||||
- **Auth token** — an opaque call-protocol-level token, decrypted from the
|
- **Auth token** — an opaque call-protocol-level token, decrypted from the
|
||||||
vault or derived from a shared secret.
|
vault or derived from a shared secret.
|
||||||
- **Remote identity verification** — the expected fingerprint/cert of the
|
- **Remote identity verification** — the expected fingerprint/cert of the
|
||||||
remote node, stored as a capability. `Some` → fingerprint pin (known
|
remote node, stored as a capability. `Some` → fingerprint pin (known
|
||||||
peer with a `PeerEntry`); `None` → CA verification for X.509 remotes,
|
peer with a `PeerEntry`); `None` → CA verification for X.509 remotes,
|
||||||
fail-closed for Ed25519 raw-key remotes (ADR-034 §2/§3). The `None`
|
fail-closed for Ed25519 raw-key remotes (ADR-054). The `None`
|
||||||
case is the public-X.509-endpoint path, not a missing field.
|
case is the public-X.509-endpoint path, not a missing field.
|
||||||
|
|
||||||
These are populated by the assembly layer at `CallClient` construction time
|
These are populated by the assembly layer at `CallClient` construction time
|
||||||
from vault-derived `Capabilities`. The credential path is the no-env-vars
|
from vault-derived `Capabilities`. The credential path is the no-env-vars
|
||||||
invariant (below). The concrete shapes of `TlsIdentity`, `AuthToken`, and
|
invariant (below). The concrete shapes of `TlsIdentity`, `AuthToken`, and
|
||||||
`RemoteIdentity` are implementation-detail two-way doors; the one-way
|
`RemoteIdentity` are implementation-detail two-way doors; the one-way
|
||||||
constraints are that they come from `Capabilities`, not env vars (ADR-010).
|
constraints are that they come from `Capabilities`, not env vars (ADR-010),
|
||||||
|
and that `ConnectionCredentials` lives with the vendored core types in
|
||||||
**TLS client-auth presentation** (OQ-29 #1, wired): the client presents
|
alkcall (ADR-012).
|
||||||
|
**TLS client-auth presentation** (OQ-14 #1, wired): the client presents
|
||||||
its Ed25519 key as an RFC 7250 raw public key client cert — the client-side
|
its Ed25519 key as an RFC 7250 raw public key client cert — the client-side
|
||||||
equivalent of the server's `RawKeyCertResolver`. This is **wired now**, not
|
equivalent of the server's `RawKeyCertResolver`. This is **wired now**, not
|
||||||
additive: it is what activates the `PeerEntry` fingerprint → `peer_id`
|
additive: it is what activates the `PeerEntry` fingerprint → `peer_id`
|
||||||
@@ -310,13 +319,13 @@ peer graph doesn't populate for quinn connections — `PeerId` resolution
|
|||||||
fails because the server has no client cert to extract a fingerprint from.
|
fails because the server has no client cert to extract a fingerprint from.
|
||||||
The iroh path already works (iroh uses RFC 7250 raw keys and exchanges
|
The iroh path already works (iroh uses RFC 7250 raw keys and exchanges
|
||||||
Ed25519 public keys during the TLS handshake automatically); the gap was
|
Ed25519 public keys during the TLS handshake automatically); the gap was
|
||||||
quinn-only, and OQ-29 #1 resolves it by replacing `with_no_client_auth()`
|
quinn-only, and OQ-14 #1 resolves it by replacing `with_no_client_auth()`
|
||||||
with presenting the key. The one-way constraint (credentials from
|
with presenting the key. The one-way constraint (credentials from
|
||||||
`Capabilities`, not env vars, ADR-010) is unaffected — the `auth_token`
|
`Capabilities`, not env vars, ADR-010) is unaffected — the `auth_token`
|
||||||
dimension flows through the call-protocol `auth_token` payload field, not
|
dimension flows through the call-protocol `auth_token` payload field, not
|
||||||
TLS, so the no-env-vars invariant holds independently of the TLS layer.
|
TLS, so the no-env-vars invariant holds independently of the TLS layer.
|
||||||
|
|
||||||
**Remote-identity verification** (OQ-29 #2, additive): verifying the
|
**Remote-identity verification** (OQ-14 #2, additive): verifying the
|
||||||
server's fingerprint against an expected value (`credentials.remote_identity`)
|
server's fingerprint against an expected value (`credentials.remote_identity`)
|
||||||
is **additive** — the server-side fingerprint extraction is what matters for
|
is **additive** — the server-side fingerprint extraction is what matters for
|
||||||
`PeerId`, not the client-side verification. The verifier for raw keys can
|
`PeerId`, not the client-side verification. The verifier for raw keys can
|
||||||
@@ -324,7 +333,8 @@ start as "accept any, extract fingerprint" and add fingerprint-pinning later.
|
|||||||
This is a two-way-door remainder; the one-way constraint (credentials from
|
This is a two-way-door remainder; the one-way constraint (credentials from
|
||||||
`Capabilities`, not env vars) is unaffected.
|
`Capabilities`, not env vars) is unaffected.
|
||||||
|
|
||||||
**Server cert verifier selection** (OQ-29 #2 + ADR-034 §3): the client-side
|
**Server cert verifier selection** (OQ-14 #2 + ADR-054 §2, re-owned
|
||||||
|
from the source repo's ADR-034): the client-side
|
||||||
`ServerCertVerifier` is selected by whether the local node has a `PeerEntry`
|
`ServerCertVerifier` is selected by whether the local node has a `PeerEntry`
|
||||||
for the remote, not by key type alone. A pure-client
|
for the remote, not by key type alone. A pure-client
|
||||||
connection to a **public X.509 endpoint** (no `PeerEntry` on the local
|
connection to a **public X.509 endpoint** (no `PeerEntry` on the local
|
||||||
@@ -339,7 +349,7 @@ with mixed Ed25519 + X.509 fingerprints) uses fingerprint pinning on
|
|||||||
both cert paths and does enter the peer graph. An unknown Ed25519
|
both cert paths and does enter the peer graph. An unknown Ed25519
|
||||||
raw-key remote fails closed (no CA to fall back to — raw-key remotes
|
raw-key remote fails closed (no CA to fall back to — raw-key remotes
|
||||||
are always known peers). See
|
are always known peers). See
|
||||||
[ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md)
|
[ADR-054](decisions/054-outgoing-only-x509-three-remote-roles.md)
|
||||||
for the verifier selection rule and the three-role naming.
|
for the verifier selection rule and the three-role naming.
|
||||||
|
|
||||||
### from_call
|
### from_call
|
||||||
@@ -353,7 +363,7 @@ forwarding handlers. The discovery mechanism (`services/list` +
|
|||||||
```rust
|
```rust
|
||||||
pub struct FromCallConfig {
|
pub struct FromCallConfig {
|
||||||
/// Namespace prefix applied to imported operation names. Optional —
|
/// Namespace prefix applied to imported operation names. Optional —
|
||||||
/// default no prefix. Collision on import is an error (DC-3, OQ-28),
|
/// default no prefix. Collision on import is an error (DC-3, OQ-13),
|
||||||
/// not last-wins.
|
/// not last-wins.
|
||||||
pub namespace_prefix: Option<String>,
|
pub namespace_prefix: Option<String>,
|
||||||
/// Optional filter — import only operations whose names match. None
|
/// Optional filter — import only operations whose names match. None
|
||||||
@@ -384,31 +394,50 @@ The flow (ADR-022 §3):
|
|||||||
sends `call.requested` via `CallConnection::call_with_payload()`, awaits
|
sends `call.requested` via `CallConnection::call_with_payload()`, awaits
|
||||||
the single `call.responded` (or `call.error`), returns the
|
the single `call.responded` (or `call.error`), returns the
|
||||||
`ResponseEnvelope`.
|
`ResponseEnvelope`.
|
||||||
- `Subscription` → a `StreamingHandler` (registered as
|
- `Subscription` → a `StreamingHandler` (registered as
|
||||||
`HandlerKind::Stream`): calls `CallConnection::subscribe()`, which
|
`HandlerKind::Stream`): calls `CallConnection::subscribe()`, which
|
||||||
returns `impl Stream<Item = ResponseEnvelope>` (the client-side
|
returns `impl Stream<Item = ResponseEnvelope>` (the client-side
|
||||||
streaming path, already implemented), maps it to a
|
streaming path, already implemented), maps it to a
|
||||||
`BoxStream<ResponseEnvelope>`. The remote stream flows end-to-end:
|
`BoxStream<ResponseEnvelope>`. The remote stream flows end-to-end:
|
||||||
each `call.responded` the remote sends becomes a stream item; the
|
each `call.responded` the remote sends becomes a stream item; the
|
||||||
remote's `call.completed` ends the stream (→ wire `call.completed`);
|
remote's `call.completed` ends the stream (→ wire `call.completed`);
|
||||||
`call.aborted` drops the stream (cascade per ADR-020). No truncation,
|
`call.aborted` drops the stream (cascade per ADR-020). No truncation,
|
||||||
no first-value fallback — a `from_call`-imported subscription forwards
|
no first-value fallback — a `from_call`-imported subscription forwards
|
||||||
the full remote stream.
|
the full remote stream.
|
||||||
- `provenance: FromCall`, `composition_authority: None`, `scoped_env: None`
|
- `Pub` → the sink forwarding shape (registered as `HandlerKind::Sink`,
|
||||||
(leaf — ADR-018).
|
ADR-046 + ADR-022 amendment 2026-10-13): a real forwarding body, not
|
||||||
|
an advertised-always-failing stub. A local `publish()` on the
|
||||||
|
imported op streams the `call.published` chunks across the
|
||||||
|
connection; the remote peer's `SinkHandler` consumes them.
|
||||||
|
- `provenance: FromCall`, `composition_authority: None`, `scoped_env: None`
|
||||||
|
(leaf — ADR-018).
|
||||||
4. The caller registers the bundles via
|
4. The caller registers the bundles via
|
||||||
`CallConnection::register_imported_all()`.
|
`CallConnection::register_imported_all()`.
|
||||||
|
|
||||||
**Re-import on reconnection** (DC-2, OQ-27): `from_call` is a free function;
|
**Marked ops are not importable** (ADR-047 amendment 4 §4.8):
|
||||||
the assembly layer calls it after the dial (in `AlknetClient`). The overlay
|
`from_call`'s discovery reconstructs the `channel_open` marker (ADR-047
|
||||||
|
Gap F) so the importing side can *report* the remote channel-machinery
|
||||||
|
ops, but `register_imported` / `register_imported_all` refuse marked
|
||||||
|
specs — the marker's data plane is the allocated channel's `BiStream`,
|
||||||
|
which has no overlay path (`OperationEnv::invoke` is request/response
|
||||||
|
only, ADR-046 §6). A hub that needs a marked op composes it with the
|
||||||
|
channel machinery instead: `ChannelRelay::register_relay_openable`
|
||||||
|
(ADR-051) on the relaying side, or direct consumption via `ChannelClient`
|
||||||
|
(ADR-043). Likewise `op/register` refuses a marked announcement with
|
||||||
|
`MARKED_SPEC_UNSUPPORTED` (ADR-022 amendment 2026-10-13). This resolves
|
||||||
|
OQ-37: there is no `from_call` relay wrapper for marked ops — the relay
|
||||||
|
composition is the sanctioned path.
|
||||||
|
|
||||||
|
**Re-import on reconnection** (DC-2, OQ-12): `from_call` is a free function;
|
||||||
|
the assembly layer calls it after the dial (in the composition layer's dial helper, `AlknetClient` — today the alknet glue; OQ-43). The overlay
|
||||||
is per-connection (Layer 2, ADR-019), so a stale overlay dies with the
|
is per-connection (Layer 2, ADR-019), so a stale overlay dies with the
|
||||||
connection; re-import on reconnect is naturally scoped to the new
|
connection; re-import on reconnect is naturally scoped to the new
|
||||||
connection. A `CallConnection::refresh()` method for mid-connection
|
connection. A `CallConnection::refresh()` method for mid-connection
|
||||||
re-discovery is a genuine feature addition — non-breaking, additive — if a
|
re-discovery is a genuine feature addition — non-breaking, additive — if a
|
||||||
deployment needs manual re-discovery without drop-and-reconnect. See
|
deployment needs manual re-discovery without drop-and-reconnect. See
|
||||||
[ADR-028](decisions/069-from-call-manual-free-function.md).
|
[ADR-028](decisions/028-from-call-manual-free-function.md).
|
||||||
|
|
||||||
**Namespace collision** (DC-3, OQ-28): under the peer-graph model (ADR-024),
|
**Namespace collision** (DC-3, OQ-13): under the peer-graph model (ADR-024),
|
||||||
cross-peer collision dissolves — same name on different peers is fine (they
|
cross-peer collision dissolves — same name on different peers is fine (they
|
||||||
live in separate peer sub-overlays, no prefix needed). Same-peer collision
|
live in separate peer sub-overlays, no prefix needed). Same-peer collision
|
||||||
stays an error (a peer shouldn't expose two ops with the same name).
|
stays an error (a peer shouldn't expose two ops with the same name).
|
||||||
@@ -432,40 +461,43 @@ forwarding. The spoke receives it as metadata on its `OperationContext` —
|
|||||||
available for logging, auditing, per-user rate limiting, but never used by
|
available for logging, auditing, per-user rate limiting, but never used by
|
||||||
`AccessControl::check` (the spoke authorizes the hub, its direct caller,
|
`AccessControl::check` (the spoke authorizes the hub, its direct caller,
|
||||||
not the end user). The hub may set `forwarded_for: None` if it doesn't
|
not the end user). The hub may set `forwarded_for: None` if it doesn't
|
||||||
want to disclose the originator. See [ADR-026](decisions/032-forwarded-for-identity.md).
|
want to disclose the originator. See [ADR-026](decisions/026-forwarded-for-identity.md).
|
||||||
|
|
||||||
### from_jsonschema
|
### from_jsonschema
|
||||||
|
|
||||||
`from_jsonschema` was originally specified here (ADR-022 §5) as a
|
`from_jsonschema` was originally specified here (ADR-022 §5) as a
|
||||||
schema-only adapter in `alknet-call` — a placeholder handler returning
|
schema-only adapter in the call protocol — a placeholder handler returning
|
||||||
`NOT_FOUND`. That was broken: an op in the registry needs a real handler,
|
`NOT_FOUND`. That was broken: an op in the registry needs a real handler,
|
||||||
and the "schema-only, no handler" concept conflated schema validation
|
and the "schema-only, no handler" concept conflated schema validation
|
||||||
(a planning activity that doesn't need a registry entry) with operation
|
(a planning activity that doesn't need a registry entry) with operation
|
||||||
registration (which always needs a handler).
|
registration (which always needs a handler).
|
||||||
|
|
||||||
[ADR-027](decisions/066-from-jsonschema-as-http-adapter.md) moved
|
[ADR-027](decisions/027-from-jsonschema-as-http-adapter.md) moved
|
||||||
`from_jsonschema` to `alknet-http` as an HTTP-backed single-endpoint
|
`from_jsonschema` to the HTTP host crate — today `alkhttp`
|
||||||
|
(ex-`alknet-http`) — as an HTTP-backed single-endpoint
|
||||||
adapter: the caller supplies an `OperationSpec` + `HttpServiceConfig` +
|
adapter: the caller supplies an `OperationSpec` + `HttpServiceConfig` +
|
||||||
path template + method, and the adapter builds one
|
path template + method, and the adapter builds one
|
||||||
`HandlerRegistration` with a real reqwest forwarding handler and
|
`HandlerRegistration` with a real reqwest forwarding handler and
|
||||||
`FromJsonSchema` provenance. It is functionally similar to `from_openapi`
|
`FromJsonSchema` provenance. It is functionally similar to `from_openapi`
|
||||||
but one endpoint at a time, for non-standard / non-OpenAPI / basic REST
|
but one endpoint at a time, for non-standard / non-OpenAPI / basic REST
|
||||||
endpoints that don't have a full OpenAPI document. See
|
endpoints that don't have a full OpenAPI document. See `alkhttp`'s
|
||||||
[`crates/http/http-adapters.md`](../http/http-adapters.md) §"from_jsonschema".
|
`http-adapters.md` §"from_jsonschema" (its original source-repo copy at
|
||||||
|
`/workspace/@alkdev/alknet/docs/architecture/crates/http/http-adapters.md`,
|
||||||
|
provenance).
|
||||||
|
|
||||||
The schema-validation-without-a-handler use case (the original stated
|
The schema-validation-without-a-handler use case (the original stated
|
||||||
purpose) is served by consuming `OperationSpec` directly — the spec
|
purpose) is served by consuming `OperationSpec` directly — the spec
|
||||||
already carries the input/output JSON Schemas. No adapter, no registry
|
already carries the input/output JSON Schemas. No adapter, no registry
|
||||||
entry, no handler is needed for that.
|
entry, no handler is needed for that.
|
||||||
|
|
||||||
The `FromJsonSchema` provenance variant stays in `alknet-call`
|
The `FromJsonSchema` provenance variant stays in alkcall
|
||||||
(`OperationProvenance` in `registry/registration.rs`); only the adapter
|
(`OperationProvenance` in `registry/registration.rs`); only the adapter
|
||||||
implementation moved.
|
implementation moved.
|
||||||
|
|
||||||
### OperationAdapter trait
|
### OperationAdapter trait
|
||||||
|
|
||||||
The shared shape across import-style adapters. The trait lives in
|
The shared shape across import-style adapters. The trait lives in
|
||||||
`alknet-call` (where the types live); the implementations live where their
|
alkcall (where the types live); the implementations live where their
|
||||||
transport dependencies live (see "Adapter Location Map" below).
|
transport dependencies live (see "Adapter Location Map" below).
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
@@ -482,7 +514,7 @@ which may be QUIC, TCP+TLS, or any other transport). Sync adapters
|
|||||||
trait — their `import()` bodies contain no `.await` points. This is locked by
|
trait — their `import()` bodies contain no `.await` points. This is locked by
|
||||||
ADR-022 §5.
|
ADR-022 §5.
|
||||||
|
|
||||||
The **error type** (DC-4, OQ-26) is `Result<Vec<HandlerRegistration>,
|
The **error type** (DC-4, OQ-11) is `Result<Vec<HandlerRegistration>,
|
||||||
AdapterError>` where `AdapterError` is a crate-level enum covering the
|
AdapterError>` where `AdapterError` is a crate-level enum covering the
|
||||||
failure modes real implementations hit: discovery transport failure
|
failure modes real implementations hit: discovery transport failure
|
||||||
(`from_call` remote unreachable), schema parse failure (`from_openapi`,
|
(`from_call` remote unreachable), schema parse failure (`from_openapi`,
|
||||||
@@ -495,13 +527,13 @@ door, recorded here.
|
|||||||
|
|
||||||
Implementations:
|
Implementations:
|
||||||
- `FromCall` — call-protocol-backed, transport-agnostic (in
|
- `FromCall` — call-protocol-backed, transport-agnostic (in
|
||||||
`alknet-call`). `from_call` discovers ops over a `CallConnection`,
|
alkcall). `from_call` discovers ops over a `CallConnection`,
|
||||||
which may be QUIC, TCP+TLS, or any transport `Connection::from_stream`
|
which may be QUIC, TCP+TLS, or any transport `Connection::from_stream`
|
||||||
supports (ADR-007).
|
supports (ADR-007).
|
||||||
- `FromOpenAPI` — HTTP-backed (in `alknet-http`).
|
- `FromOpenAPI` — HTTP-backed (in `alkhttp`).
|
||||||
- `FromJsonSchema` — HTTP-backed, single-endpoint (in `alknet-http` per
|
- `FromJsonSchema` — HTTP-backed, single-endpoint (in `alkhttp` per
|
||||||
ADR-027; was a broken schema-only placeholder in `alknet-call`).
|
ADR-027; was a broken schema-only placeholder in the call protocol).
|
||||||
- `FromMCP` — MCP streamable-HTTP-backed (in `alknet-http`, feature-gated).
|
- `FromMCP` — MCP streamable-HTTP-backed (in `alkhttp`, feature-gated).
|
||||||
|
|
||||||
The `to_*` adapters (`to_openapi`, `to_mcp`) are outbound projections, not
|
The `to_*` adapters (`to_openapi`, `to_mcp`) are outbound projections, not
|
||||||
`OperationAdapter` implementations — they consume the registry, they don't
|
`OperationAdapter` implementations — they consume the registry, they don't
|
||||||
@@ -510,18 +542,19 @@ produce entries for it (ADR-022 §5).
|
|||||||
### Adapter Location Map
|
### Adapter Location Map
|
||||||
|
|
||||||
The decomposition principle: **the adapter trait lives where the types live
|
The decomposition principle: **the adapter trait lives where the types live
|
||||||
(`alknet-call`); the adapter implementations live where their transport
|
(alkcall); the adapter implementations live where their transport
|
||||||
dependencies live.**
|
dependencies live.**
|
||||||
|
|
||||||
```
|
```
|
||||||
alknet-call (lean — no HTTP client, no HTTP server)
|
alkcall (lean — no HTTP client, no HTTP server)
|
||||||
├── OperationAdapter trait (the contract — async, per ADR-022 §5)
|
├── OperationAdapter trait (the contract — async, per ADR-022 §5)
|
||||||
├── from_call (transport-agnostic — discovers remote ops via
|
├── from_call (transport-agnostic — discovers remote ops via
|
||||||
│ call protocol over any Connection)
|
│ call protocol over any Connection)
|
||||||
└── CallClient (outbound connection take-over —
|
└── CallClient (outbound connection take-over —
|
||||||
spawn_dispatch, transport-agnostic; dial in AlknetClient)
|
spawn_dispatch, transport-agnostic; dial in
|
||||||
|
the composition layer's helper, AlknetClient)
|
||||||
|
|
||||||
alknet-http (owns HTTP server + HTTP client — separate crate, separate Phase 0)
|
alkhttp (owns HTTP server + HTTP client — sibling host crate)
|
||||||
├── ProtocolHandler for h2/http1.1/h3 (axum server — inbound HTTP)
|
├── ProtocolHandler for h2/http1.1/h3 (axum server — inbound HTTP)
|
||||||
├── from_openapi (parse OpenAPI doc + reqwest forwarding handler)
|
├── from_openapi (parse OpenAPI doc + reqwest forwarding handler)
|
||||||
├── from_jsonschema (single-endpoint reqwest forwarding handler — ADR-027)
|
├── from_jsonschema (single-endpoint reqwest forwarding handler — ADR-027)
|
||||||
@@ -531,28 +564,26 @@ alknet-http (owns HTTP server + HTTP client — separate crate, separate Phase 0
|
|||||||
|
|
||||||
Not built: MCP stdio transport
|
Not built: MCP stdio transport
|
||||||
— stdio = spawn arbitrary executable = built-in RCE ("download untrusted MCP servers")
|
— stdio = spawn arbitrary executable = built-in RCE ("download untrusted MCP servers")
|
||||||
— streamable HTTP is the only supported MCP transport in alknet
|
— streamable HTTP is the only supported MCP transport in the alk stack
|
||||||
— recorded as an explicit security position, not a feature gap
|
— recorded as an explicit security position, not a feature gap
|
||||||
```
|
```
|
||||||
|
|
||||||
`alknet-call` never sees the HTTP client. The `from_openapi`/`from_mcp`
|
alkcall never sees the HTTP client. The `from_openapi`/`from_mcp`
|
||||||
forwarding handlers are opaque `Arc<dyn Handler>` from the registry's
|
forwarding handlers are opaque `Arc<dyn Handler>` from the registry's
|
||||||
perspective — constructed by `alknet_http::from_openapi()` at registration
|
perspective — constructed by `alkhttp::from_openapi()` at registration
|
||||||
time, stored in `HandlerRegistration`, dispatched by the `CallAdapter` which
|
time, stored in `HandlerRegistration`, dispatched by the `CallAdapter` which
|
||||||
doesn't know reqwest is involved. `alknet-call` stays lean (no reqwest, no
|
doesn't know reqwest is involved. alkcall stays lean (no reqwest, no
|
||||||
axum); `alknet-http` owns both HTTP directions.
|
axum); `alkhttp` owns both HTTP directions.
|
||||||
|
|
||||||
**ADR-031 dependency note**: `alknet-http` implementing `from_openapi`/
|
**ADR-031 dependency note**: `alkhttp` implementing `from_openapi`/
|
||||||
`from_mcp` means `alknet-http` depends on `alknet-call` (for `OperationSpec`,
|
`from_mcp` means `alkhttp` depends on alkcall (for `OperationSpec`,
|
||||||
`Handler`, `HandlerRegistration`, `OperationAdapter`). ADR-031's rule is "no
|
`Handler`, `HandlerRegistration`, `OperationAdapter`). ADR-031's rule is "no
|
||||||
handler crate depends on another handler crate" — but `alknet-call` is both
|
handler crate depends on another handler crate" — but alkcall is the
|
||||||
a handler *and* the protocol foundation that `alknet-agent` and `alknet-napi`
|
protocol foundation that every downstream consumer
|
||||||
already consume. `alknet-http` depending on `alknet-call` is "HTTP uses the
|
(including the HTTP host and any agent crate) builds on.
|
||||||
call protocol types," not "HTTP depends on SSH." This is within the spirit of
|
`alkhttp` depending on alkcall is "HTTP uses the
|
||||||
ADR-031 (`alknet-call` is protocol-foundation, not a peer handler). The
|
call protocol types," not "HTTP depends on a sibling handler." This is within the spirit of
|
||||||
`alknet-http` spec should note this explicitly; a one-line amendment to
|
ADR-031 (alkcall is protocol-foundation, not a peer handler).
|
||||||
ADR-031 clarifying that `alknet-call` is a protocol-foundation crate is
|
|
||||||
deferred to the `alknet-http` Phase 0.
|
|
||||||
|
|
||||||
### No-Env-Vars Invariant
|
### No-Env-Vars Invariant
|
||||||
|
|
||||||
@@ -576,7 +607,7 @@ vault (seed)
|
|||||||
→ reqwest request goes out with vault-derived credential
|
→ reqwest request goes out with vault-derived credential
|
||||||
```
|
```
|
||||||
|
|
||||||
The `from_openapi`/`from_mcp` forwarding handlers (in `alknet-http`) are the
|
The `from_openapi`/`from_mcp` forwarding handlers (in `alkhttp`) are the
|
||||||
credential injection point. They read from `context.capabilities`, not from
|
credential injection point. They read from `context.capabilities`, not from
|
||||||
`std::env::var`. The downstream consumers' `Default` impls reading env vars
|
`std::env::var`. The downstream consumers' `Default` impls reading env vars
|
||||||
are simply never called — the assembly layer constructs providers with
|
are simply never called — the assembly layer constructs providers with
|
||||||
@@ -584,14 +615,14 @@ vault-derived credentials through the builder API, or the provider's HTTP
|
|||||||
calls are routed through `from_openapi` operations that carry the credential
|
calls are routed through `from_openapi` operations that carry the credential
|
||||||
in `Capabilities`.
|
in `Capabilities`.
|
||||||
|
|
||||||
**This is a spec-level invariant in `alknet-call`, not a runtime convention.**
|
**This is a spec-level invariant in alkcall, not a runtime convention.**
|
||||||
The dispatch path (`build_root_context` and `OperationEnv::invoke()` per
|
The dispatch path (`build_root_context` and `OperationEnv::invoke()` per
|
||||||
ADR-018 §6) populates `OperationContext.capabilities` from the registration
|
ADR-018 §6) populates `OperationContext.capabilities` from the registration
|
||||||
bundle. The invariant is: *no handler reads outbound credentials from any
|
bundle. The invariant is: *no handler reads outbound credentials from any
|
||||||
source other than `OperationContext.capabilities`.* This is already the
|
source other than `OperationContext.capabilities`.* This is already the
|
||||||
architectural intent of ADR-010; this document records it as an explicit
|
architectural intent of ADR-010; this document records it as an explicit
|
||||||
invariant that the `from_openapi`/`from_mcp` handler implementations (in
|
invariant that the `from_openapi`/`from_mcp` handler implementations (in
|
||||||
`alknet-http`) are verified against.
|
`alkhttp`) are verified against.
|
||||||
|
|
||||||
### Exchange-of-Operations Pattern (Runner / Container Service)
|
### Exchange-of-Operations Pattern (Runner / Container Service)
|
||||||
|
|
||||||
@@ -630,20 +661,21 @@ Bilateral: the container service ALSO runs from_call against the hub,
|
|||||||
the other side's `External` ops. The #2 gap.
|
the other side's `External` ops. The #2 gap.
|
||||||
3. `OperationAdapter` trait — `from_call` implements it. The #3 gap (enabling,
|
3. `OperationAdapter` trait — `from_call` implements it. The #3 gap (enabling,
|
||||||
not blocking — `from_call` can be built as a free function before the trait
|
not blocking — `from_call` can be built as a free function before the trait
|
||||||
exists, but the trait is needed for `alknet-http`'s adapters).
|
exists, but the trait is needed for `alkhttp`'s adapters).
|
||||||
|
|
||||||
**Why the container service doesn't need alknet-ssh**: under the call
|
**Why the container service doesn't need SSH**: under the call
|
||||||
protocol, the container service is a `CallClient` that dials the hub's
|
protocol, the container service is a `CallClient` that dials the hub's
|
||||||
`alk/call` ALPN (over QUIC, TCP+TLS, or any transport) — no SSH in
|
`alk/call` ALPN (over QUIC, TCP+TLS, or any transport) — no SSH in
|
||||||
the loop. SSH port
|
the loop. SSH port
|
||||||
forwarding becomes the *transitional* mechanism for targets that can't run a
|
forwarding becomes the *transitional* mechanism for targets that can't run a
|
||||||
call-protocol client (the `alknet-ssh` phase-0 findings document this
|
call-protocol client (the source repo's SSH phase-0 findings — alknet,
|
||||||
|
provenance — document this
|
||||||
transition). Once the container service runs a `CallClient`, SSH is out of
|
transition). Once the container service runs a `CallClient`, SSH is out of
|
||||||
the path entirely.
|
the path entirely.
|
||||||
|
|
||||||
This is the "dev runner" pattern: a call-protocol client that connects back
|
This is the "dev runner" pattern: a call-protocol client that connects back
|
||||||
to a hub and exposes core dev tools (bash, fs, etc.) as operations. The agent
|
to a hub and exposes core dev tools (bash, fs, etc.) as operations. The agent
|
||||||
service (`alknet-agent`, downstream) is the consumer that orchestrates these
|
crate (downstream) is the consumer that orchestrates these
|
||||||
via `env.invoke()`.
|
via `env.invoke()`.
|
||||||
|
|
||||||
## Implementation Priority Order
|
## Implementation Priority Order
|
||||||
@@ -654,13 +686,13 @@ Based on the gap analysis and the downstream unblock chain:
|
|||||||
runner, no container service, no bilateral exchange. Reuses the existing
|
runner, no container service, no bilateral exchange. Reuses the existing
|
||||||
`CallConnection` for the dispatch loop; adds only the
|
`CallConnection` for the dispatch loop; adds only the
|
||||||
connection-establishment + credential-handling half. The single
|
connection-establishment + credential-handling half. The single
|
||||||
highest-value piece of work in the entire `alknet-call` completion.
|
highest-value piece of work in the entire call-completion effort.
|
||||||
|
|
||||||
2. **`from_call`** (critical, depends on `CallClient`) — consumes the
|
2. **`from_call`** (critical, depends on `CallClient`) — consumes the
|
||||||
already-implemented `services/list` + `services/schema` discovery API.
|
already-implemented `services/list` + `services/schema` discovery API.
|
||||||
|
|
||||||
3. **`OperationAdapter` trait** (enabling) — the async trait. Small,
|
3. **`OperationAdapter` trait** (enabling) — the async trait. Small,
|
||||||
standalone, unblocks `alknet-http` Phase 1 (including `from_jsonschema`
|
standalone, unblocks `alkhttp`'s adapter work (including `from_jsonschema`
|
||||||
per ADR-027).
|
per ADR-027).
|
||||||
|
|
||||||
4. **DC-1 resolution** (peer-graph routing model, ADR-024) — the
|
4. **DC-1 resolution** (peer-graph routing model, ADR-024) — the
|
||||||
@@ -673,23 +705,27 @@ Based on the gap analysis and the downstream unblock chain:
|
|||||||
|
|
||||||
## What This Completion Unblocks
|
## What This Completion Unblocks
|
||||||
|
|
||||||
| Downstream crate | What it needs from alknet-call | Status without completion |
|
Historical planning table (source-repo era; crate names as they were).
|
||||||
|
The completion has since landed in this crate — `alknet-http` is now
|
||||||
|
`alkhttp`.
|
||||||
|
|
||||||
|
| Downstream crate | What it needs from alkcall | Status without completion |
|
||||||
|-------------------|-------------------------------|--------------------------|
|
|-------------------|-------------------------------|--------------------------|
|
||||||
| alknet-http | `OperationAdapter` trait (to implement `from_openapi`/`from_mcp`) | Blocked — can't define HTTP-backed adapters without the trait |
|
| alknet-http (→ alkhttp) | `OperationAdapter` trait (to implement `from_openapi`/`from_mcp`) | Blocked — can't define HTTP-backed adapters without the trait |
|
||||||
| alknet-ssh | Stable alknet-call types (no adapter dependency) | Not blocked — ssh depends on alknet-core, not alknet-call's adapters. Proceeds in parallel. |
|
| alknet-ssh (historical) | Stable alkcall types (no adapter dependency) | Not blocked — ssh depends on the vendored core types, not alkcall's adapters. Proceeds in parallel. |
|
||||||
| alknet-agent | `CallClient` (tool dispatch), `from_call` (remote tool import), `OperationAdapter` (provider adapters) | Blocked on `CallClient` + `from_call` |
|
| alknet-agent (historical) | `CallClient` (tool dispatch), `from_call` (remote tool import), `OperationAdapter` (provider adapters) | Blocked on `CallClient` + `from_call` |
|
||||||
| Container service (dispatch rewrite) | `CallClient` + `from_call` | Blocked — this is the primary consumer |
|
| Container service (dispatch rewrite) | `CallClient` + `from_call` | Blocked — this is the primary consumer |
|
||||||
| Runner pattern (dev runner, opencode runner) | `CallClient` + `from_call` | Blocked — the runner IS a `CallClient` |
|
| Runner pattern (dev runner, opencode runner) | `CallClient` + `from_call` | Blocked — the runner IS a `CallClient` |
|
||||||
| alknet-napi | `CallClient` (Node.js calls remote ops) | Blocked — NAPI projects `CallClient` to JS |
|
| alknet-napi (historical) | `CallClient` (Node.js calls remote ops) | Blocked — NAPI projects `CallClient` to JS |
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|
||||||
- **No HTTP in alknet-call.** `from_openapi`/`from_mcp`/`from_jsonschema`/
|
- **No HTTP in alkcall.** `from_openapi`/`from_mcp`/`from_jsonschema`/
|
||||||
`to_openapi`/`to_mcp` live in `alknet-http`. The `OperationAdapter`
|
`to_openapi`/`to_mcp` live in `alkhttp`. The `OperationAdapter`
|
||||||
trait and the call-protocol-backed adapter (`from_call`, transport-
|
trait and the call-protocol-backed adapter (`from_call`, transport-
|
||||||
agnostic) live in `alknet-call`. `from_jsonschema` was originally
|
agnostic) live in alkcall. `from_jsonschema` was originally
|
||||||
(mis)placed in `alknet-call` as a schema-only placeholder; ADR-027
|
(mis)placed in the call protocol as a schema-only placeholder; ADR-027
|
||||||
moved it to `alknet-http` as a real HTTP-backed adapter. See Adapter
|
moved it to the HTTP host crate as a real HTTP-backed adapter. See Adapter
|
||||||
Location Map.
|
Location Map.
|
||||||
- **No secret material on the wire.** `ConnectionCredentials` carries vault-derived
|
- **No secret material on the wire.** `ConnectionCredentials` carries vault-derived
|
||||||
material for the *outbound* connection (TLS identity); `auth_token` is a
|
material for the *outbound* connection (TLS identity); `auth_token` is a
|
||||||
@@ -710,17 +746,17 @@ Based on the gap analysis and the downstream unblock chain:
|
|||||||
not a singular connection overlay. `invoke_peer()` routes to the right peer
|
not a singular connection overlay. `invoke_peer()` routes to the right peer
|
||||||
via `PeerRef::Specific` / `PeerRef::Any` (ADR-024 §1-2).
|
via `PeerRef::Specific` / `PeerRef::Any` (ADR-024 §1-2).
|
||||||
- **`from_call` is a manual free function.** The assembly layer calls it
|
- **`from_call` is a manual free function.** The assembly layer calls it
|
||||||
after the dial (in `AlknetClient`). The overlay is per-connection so
|
after the dial (in the composition layer's dial helper, `AlknetClient` — today the alknet glue; OQ-43). The overlay is per-connection so
|
||||||
re-import on reconnect is naturally scoped (DC-2, OQ-27). See
|
re-import on reconnect is naturally scoped (DC-2, OQ-12). See
|
||||||
[ADR-028](decisions/069-from-call-manual-free-function.md).
|
[ADR-028](decisions/028-from-call-manual-free-function.md).
|
||||||
- **`from_call` namespace collision is same-peer only.** Cross-peer collision
|
- **`from_call` namespace collision is same-peer only.** Cross-peer collision
|
||||||
dissolves (same name on different peers is fine — separate sub-overlays,
|
dissolves (same name on different peers is fine — separate sub-overlays,
|
||||||
ADR-024 §5). Same-peer collision stays an error. `namespace_prefix` is
|
ADR-024 §5). Same-peer collision stays an error. `namespace_prefix` is
|
||||||
optional local-naming sugar, not the disambiguation mechanism (DC-3, OQ-28).
|
optional local-naming sugar, not the disambiguation mechanism (DC-3, OQ-13).
|
||||||
- **`OperationAdapter::import()` returns `Result`.** Failures surface as
|
- **`OperationAdapter::import()` returns `Result`.** Failures surface as
|
||||||
`AdapterError` (DC-4, OQ-26).
|
`AdapterError` (DC-4, OQ-11).
|
||||||
- **MCP stdio transport is not built.** Streamable HTTP is the only supported
|
- **MCP stdio transport is not built.** Streamable HTTP is the only supported
|
||||||
MCP transport in alknet. stdio = spawn arbitrary executable = built-in RCE.
|
MCP transport in the alk stack. stdio = spawn arbitrary executable = built-in RCE.
|
||||||
Recorded as an explicit security position, not a feature gap.
|
Recorded as an explicit security position, not a feature gap.
|
||||||
- **Pure-client X.509 connections are not in the peer graph on the client
|
- **Pure-client X.509 connections are not in the peer graph on the client
|
||||||
side.** A `CallClient` connection to a public X.509 endpoint with no
|
side.** A `CallClient` connection to a public X.509 endpoint with no
|
||||||
@@ -730,10 +766,10 @@ Based on the gap analysis and the downstream unblock chain:
|
|||||||
invoked through the `CallConnection` handle. The client-side
|
invoked through the `CallConnection` handle. The client-side
|
||||||
`ServerCertVerifier` uses CA verification (`WebPkiServerVerifier`) for
|
`ServerCertVerifier` uses CA verification (`WebPkiServerVerifier`) for
|
||||||
such remotes; known peers (hub with `PeerEntry`) use fingerprint
|
such remotes; known peers (hub with `PeerEntry`) use fingerprint
|
||||||
pinning. See [ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md).
|
pinning. See [ADR-054](decisions/054-outgoing-only-x509-three-remote-roles.md).
|
||||||
- **`ConnectionCredentials.remote_identity: None` is load-bearing.** `None`
|
- **`ConnectionCredentials.remote_identity: None` is load-bearing.** `None`
|
||||||
means "no `PeerEntry` for this remote → use CA verification (X.509)
|
means "no `PeerEntry` for this remote → use CA verification (X.509)
|
||||||
or fail closed (Ed25519 raw key)" per the ADR-034 §3 verifier rule.
|
or fail closed (Ed25519 raw key)" per the verifier rule (ADR-054).
|
||||||
The implementation must not default `remote_identity` to a placeholder
|
The implementation must not default `remote_identity` to a placeholder
|
||||||
to satisfy the field, and must not treat `None` as "skip verification"
|
to satisfy the field, and must not treat `None` as "skip verification"
|
||||||
— `None` + X.509 is CA verification, `None` + raw key is a hard
|
— `None` + X.509 is CA verification, `None` + raw key is a hard
|
||||||
@@ -743,79 +779,73 @@ Based on the gap analysis and the downstream unblock chain:
|
|||||||
|
|
||||||
| Decision | ADR | Summary |
|
| Decision | ADR | Summary |
|
||||||
|----------|-----|---------|
|
|----------|-----|---------|
|
||||||
| Call protocol client and adapter contract | [ADR-022](decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction; trait is async; adapters produce `HandlerRegistration` bundles |
|
| Call protocol client and adapter contract | [ADR-022](decisions/022-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction; trait is async; adapters produce `HandlerRegistration` bundles |
|
||||||
| `from_jsonschema` as HTTP-backed single-endpoint adapter in alknet-http | [ADR-027](decisions/066-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf |
|
| `from_jsonschema` as HTTP-backed single-endpoint adapter | [ADR-027](decisions/027-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from the call protocol (broken schema-only placeholder) to the HTTP host crate as a real reqwest-backed single-endpoint adapter — today `alkhttp` (ex-`alknet-http`); `FromJsonSchema` provenance stays in alkcall as a leaf |
|
||||||
| Peer-graph routing model (DC-1, supersedes ADR-023) | [ADR-024](decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via existing `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` |
|
| Peer-graph routing model (DC-1, supersedes ADR-023) | [ADR-024](decisions/024-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via existing `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` |
|
||||||
| PeerEntry and Identity.id decoupling | [ADR-025](decisions/030-peerentry-and-identity-id-decoupling.md) | `PeerId` source changes from UUID to `Identity.id` (= `PeerEntry.peer_id`, stable across key rotation); `Identity.id` decoupled from crypto material on the fingerprint path |
|
| PeerEntry and Identity.id decoupling | [ADR-025](decisions/025-peerentry-and-identity-id-decoupling.md) | `PeerId` source changes from UUID to `Identity.id` (= `PeerEntry.peer_id`, stable across key rotation); `Identity.id` decoupled from crypto material on the fingerprint path |
|
||||||
| Forwarded-for identity | [ADR-026](decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; the `from_call` handler populates it; metadata only, never used by `AccessControl::check` |
|
| Forwarded-for identity | [ADR-026](decisions/026-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; the `from_call` handler populates it; metadata only, never used by `AccessControl::check` |
|
||||||
| Storage boundary and repo/adapter pattern | [ADR-033](decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
|
| Storage boundary and repo/adapter pattern | [alknet ADR-033](/workspace/@alkdev/alknet/docs/architecture/decisions/033-storage-boundary-and-repo-adapter-pattern.md) (provenance) | Core defines repo traits + in-memory defaults; persistence adapters are separate crates (composition-layer work — OQ-43) |
|
||||||
| Secret material flow and capability injection | [ADR-010](decisions/014-secret-material-flow-and-capability-injection.md) | The no-env-vars invariant's foundation; capabilities injected at assembly layer |
|
| Secret material flow and capability injection | [ADR-010](decisions/010-secret-material-flow-and-capability-injection.md) | The no-env-vars invariant's foundation; capabilities injected at assembly layer |
|
||||||
| Handler registration, provenance, and composition authority | [ADR-018](decisions/022-handler-registration-provenance-and-composition-authority.md) | The registration bundle adapters produce; `composition_authority: None` for leaves |
|
| Handler registration, provenance, and composition authority | [ADR-018](decisions/018-handler-registration-provenance-and-composition-authority.md) | The registration bundle adapters produce; `composition_authority: None` for leaves |
|
||||||
| Operation registry layering | [ADR-019](decisions/024-operation-registry-layering.md) | Layer 2 per-connection overlay where `from_call` imports land |
|
| Operation registry layering | [ADR-019](decisions/019-operation-registry-layering.md) | Layer 2 per-connection overlay where `from_call` imports land |
|
||||||
| Privilege model and authority context | [ADR-017](decisions/015-privilege-model-and-authority-context.md) | Adapter-registered ops are `Internal` by default; default-deny posture |
|
| Privilege model and authority context | [ADR-017](decisions/017-privilege-model-and-authority-context.md) | Adapter-registered ops are `Internal` by default; default-deny posture |
|
||||||
| Abort cascade for nested calls | [ADR-020](decisions/016-abort-cascade-for-nested-calls.md) | Cross-node abort through `from_call` forwarding handler's `parent_request_id` |
|
| Abort cascade for nested calls | [ADR-020](decisions/020-abort-cascade-for-nested-calls.md) | Cross-node abort through `from_call` forwarding handler's `parent_request_id` |
|
||||||
| Operation error schemas | [ADR-016](decisions/023-operation-error-schemas.md) | `error_schemas` mirrored by `from_call` from remote op's spec |
|
| Operation error schemas | [ADR-016](decisions/016-operation-error-schemas.md) | `error_schemas` mirrored by `from_call` from remote op's spec |
|
||||||
| Streaming handler for subscriptions | [ADR-021](decisions/049-streaming-handler-for-subscriptions.md) | `from_call` `Subscription` ops register a `StreamingHandler` (`HandlerKind::Stream`) that calls `CallConnection::subscribe()` and forwards the remote stream; `Query`/`Mutation` stay `HandlerKind::Once` |
|
| Streaming handler for subscriptions | [ADR-021](decisions/021-streaming-handler-for-subscriptions.md) | `from_call` `Subscription` ops register a `StreamingHandler` (`HandlerKind::Stream`) that calls `CallConnection::subscribe()` and forwards the remote stream; `Query`/`Mutation` stay `HandlerKind::Once` |
|
||||||
| TLS identity redesign | [ADR-027](decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | RFC 7250 raw key / X.509 cert dimensions of the local `TlsIdentity` (now carried by `ConnectionCredentials.local_identity`) |
|
| TLS identity redesign | [alknet ADR-027](/workspace/@alkdev/alknet/docs/architecture/decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | RFC 7250 raw key / X.509 cert dimensions of the local `TlsIdentity` (now carried by `ConnectionCredentials.local_identity`) |
|
||||||
| Outgoing-only X.509 and three peer roles | [ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Public X.509 endpoint is not a `PeerEntry` on the client side (no `PeerId`, not in peer graph); client-side verifier by `PeerEntry` presence (CA vs fingerprint pin); hub = mixed-fingerprint `PeerEntry` |
|
| Outgoing-only X.509 and three remote roles | [ADR-054](decisions/054-outgoing-only-x509-three-remote-roles.md) (re-owned; provenance: [alknet ADR-034](/workspace/@alkdev/alknet/docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md)) | Public X.509 endpoint is not a `PeerEntry` on the client side (no `PeerId`, not in peer graph); client-side verifier by `PeerEntry` presence (CA vs fingerprint pin); hub = mixed-fingerprint `PeerEntry` |
|
||||||
| HD derivation for encryption keys | [ADR-020](decisions/020-hd-derivation-for-encryption-keys.md) | Vault-derived TLS identity material |
|
| HD derivation for encryption keys | [alknet ADR-020](/workspace/@alkdev/alknet/docs/architecture/decisions/020-hd-derivation-for-encryption-keys.md) (provenance — vault ADRs live in the source repo) | Vault-derived TLS identity material |
|
||||||
| Vault key model | [ADR-026](decisions/026-vault-key-model-hd-derivation.md) | Vault-derived TLS identity material |
|
| Vault key model | [alknet ADR-026](/workspace/@alkdev/alknet/docs/architecture/decisions/026-vault-key-model-hd-derivation.md) (provenance) | Vault-derived TLS identity material |
|
||||||
| Vault local-only dispatch | [ADR-025](decisions/025-vault-local-only-dispatch.md) | Vault access at assembly layer only; the credential injection path's first hop |
|
| Vault local-only dispatch | [alknet ADR-025](/workspace/@alkdev/alknet/docs/architecture/decisions/025-vault-local-only-dispatch.md) (provenance) | Vault access at assembly layer only; the credential injection path's first hop |
|
||||||
| Crate decomposition | [ADR-031](decisions/003-crate-decomposition.md) | `alknet-http` depends on `alknet-call` (protocol-foundation exception, noted in Adapter Location Map) |
|
| Crate decomposition | [ADR-031](decisions/031-crate-decomposition.md) | `alkhttp` depends on alkcall (protocol-foundation exception, noted in Adapter Location Map) |
|
||||||
| One-way door decision framework | [ADR-032](decisions/009-one-way-door-decision-framework.md) | Door-type classification for DC-1..4 |
|
| One-way door decision framework | [ADR-032](decisions/032-one-way-door-decision-framework.md) | Door-type classification for DC-1..4 |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
See [open-questions.md](open-questions.md) for full details.
|
See [open-questions.md](open-questions.md) for full details.
|
||||||
|
|
||||||
- **OQ-25** (dissolved by ADR-024): `remote_safe` marking shape — moot.
|
- **OQ-10** (dissolved by ADR-024): `remote_safe` marking shape — moot.
|
||||||
`remote_safe`/`trusted_peer` are retired; peer authorization is
|
`remote_safe`/`trusted_peer` are retired; peer authorization is
|
||||||
`AccessControl::check(peer_identity)`. No marking to shape.
|
`AccessControl::check(peer_identity)`. No marking to shape.
|
||||||
- **OQ-26** (resolved): `AdapterError` variants — `DiscoveryFailed`,
|
- **OQ-11** (resolved): `AdapterError` variants — `DiscoveryFailed`,
|
||||||
`SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`
|
`SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`
|
||||||
(replaces flat `Conflict`). `#[non_exhaustive]`.
|
(replaces flat `Conflict`). `#[non_exhaustive]`.
|
||||||
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
|
- **OQ-12** (resolved): `from_call` re-import trigger — `from_call` is a
|
||||||
manual free function; the assembly layer calls it after the dial (in
|
manual free function; the assembly layer calls it after the dial (in
|
||||||
`AlknetClient`). A `CallConnection::refresh()` method is a genuine
|
`AlknetClient`). A `CallConnection::refresh()` method is a genuine
|
||||||
feature addition — non-breaking, additive. See
|
feature addition — non-breaking, additive. See
|
||||||
[ADR-028](decisions/069-from-call-manual-free-function.md).
|
[ADR-028](decisions/028-from-call-manual-free-function.md).
|
||||||
- **OQ-28** (resolved): `from_call` namespace collision — same-peer
|
- **OQ-13** (resolved): `from_call` namespace collision — same-peer
|
||||||
collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays).
|
collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays).
|
||||||
`namespace_prefix` is optional local-naming sugar.
|
`namespace_prefix` is optional local-naming sugar.
|
||||||
- **OQ-29** (resolved): `CallClient` TLS client-auth — wire quinn
|
- **OQ-14** (resolved): `CallClient` TLS client-auth — wire quinn
|
||||||
client-auth (present Ed25519 key as raw public key client cert);
|
client-auth (present Ed25519 key as raw public key client cert);
|
||||||
key-type-aware server cert verification (raw key = fingerprint match,
|
key-type-aware server cert verification (raw key = fingerprint match,
|
||||||
X.509 = CA verification); fingerprint normalization (`ed25519:` across
|
X.509 = CA verification); fingerprint normalization (`ed25519:` across
|
||||||
quinn/iroh). The iroh path already works; the gap was quinn-only.
|
quinn/iroh). The iroh path already works; the gap was quinn-only.
|
||||||
See OQ-29 in open-questions.md.
|
See OQ-14 in open-questions.md.
|
||||||
- **OQ-30** (resolved): `PeerRef::Any` routing policy — insertion-order
|
- **OQ-15** (resolved): `PeerRef::Any` routing policy — insertion-order
|
||||||
first-match. A richer `RoutingPolicy` is a feature extension.
|
first-match. A richer `RoutingPolicy` is a feature extension.
|
||||||
- **OQ-31** (resolved): `services/list-peers` — opt-in; `services/list`
|
- **OQ-16** (resolved): `services/list-peers` — opt-in; `services/list`
|
||||||
is "own ops only."
|
is "own ops only."
|
||||||
- **OQ-32** (open, feature extension): Multi-hop federation — the one-hop
|
- **OQ-17** (deferred(scope), feature extension): Multi-hop federation — the one-hop
|
||||||
model is the architectural commitment; multi-hop is a feature extension
|
model is the architectural commitment; multi-hop is a feature extension
|
||||||
that doesn't break downstream. The peer-keyed model extends to multi-hop
|
that doesn't break downstream. The peer-keyed model extends to multi-hop
|
||||||
without redesign; petgraph is the candidate if path-finding becomes real
|
without redesign; petgraph is the candidate if path-finding becomes real
|
||||||
(ADR-024 §3.7).
|
(ADR-024 §3.7).
|
||||||
- **OQ-33** (resolved by ADR-025): `PeerId` is a logical id. Source is
|
- **OQ-18** (resolved by ADR-025): `PeerId` is a logical id. Source is
|
||||||
`Identity.id` from `IdentityProvider` resolution (= `PeerEntry.peer_id`,
|
`Identity.id` from `IdentityProvider` resolution (= `PeerEntry.peer_id`,
|
||||||
stable across key rotation). See OQ-33 in open-questions.md.
|
stable across key rotation). See OQ-18 in open-questions.md.
|
||||||
- **OQ-34** (resolved by ADR-025 + ADR-033): Persistent peer registry —
|
- **OQ-19** (resolved by ADR-025): Persistent peer registry —
|
||||||
the storage boundary is `core trait + in-memory default` (config-backed
|
the storage boundary is `core trait + in-memory default` (config-backed
|
||||||
`ConfigIdentityProvider` now; persistence adapters additive in separate
|
`ConfigIdentityProvider` now; persistence adapters additive in separate
|
||||||
crates). See OQ-34 in open-questions.md.
|
crates). See OQ-19 in open-questions.md.
|
||||||
- **OQ-35** (dissolved): the "API key asymmetry" framing was wrong;
|
- **OQ-20** (dissolved): the "API key asymmetry" framing was wrong;
|
||||||
`PeerEntry` supports multiple credential paths (fingerprints +
|
`PeerEntry` supports multiple credential paths (fingerprints +
|
||||||
auth_token_hash), `ApiKeyEntry` is for tokens that ARE the identity.
|
auth_token_hash), `ApiKeyEntry` is for tokens that ARE the identity.
|
||||||
See OQ-35 in open-questions.md.
|
See OQ-20 in open-questions.md.
|
||||||
- **OQ-36** (resolved by ADR-035): Concrete persistence adapter shapes —
|
- **OQ-21** (resolved by ADR-054): X.509 outgoing-only
|
||||||
read-sync / write-async split (`IdentityStore` async write trait
|
case — three
|
||||||
extends the sync `IdentityProvider` read trait); SQLite adapter caches
|
|
||||||
in memory and uses honker NOTIFY/LISTEN for no-restart cache
|
|
||||||
invalidation; `alknet-store-sqlite` crate implements both
|
|
||||||
`IdentityStore` and `CredentialStore`. See ADR-035 and OQ-36 in
|
|
||||||
open-questions.md.
|
|
||||||
- **OQ-37** (resolved by ADR-034): X.509 outgoing-only case — three
|
|
||||||
remote roles named (public X.509 endpoint, transport relay, hub).
|
remote roles named (public X.509 endpoint, transport relay, hub).
|
||||||
`PeerEntry` asymmetry is correct: a pure-client connection to a public
|
`PeerEntry` asymmetry is correct: a pure-client connection to a public
|
||||||
X.509 endpoint is **not** in the call-protocol peer graph on the
|
X.509 endpoint is **not** in the call-protocol peer graph on the
|
||||||
@@ -824,8 +854,10 @@ See [open-questions.md](open-questions.md) for full details.
|
|||||||
land in the connection's Layer 2 overlay and are invoked through the
|
land in the connection's Layer 2 overlay and are invoked through the
|
||||||
connection handle. The client-side `ServerCertVerifier` is selected
|
connection handle. The client-side `ServerCertVerifier` is selected
|
||||||
by `PeerEntry` presence: known peer → fingerprint pin; unknown X.509
|
by `PeerEntry` presence: known peer → fingerprint pin; unknown X.509
|
||||||
remote → CA verification (`WebPkiServerVerifier`). See ADR-034 and
|
remote → CA verification (`WebPkiServerVerifier`). The rule is
|
||||||
OQ-37 in open-questions.md.
|
re-owned in-crate by ADR-054 (provenance: alknet ADR-034, "Outgoing-Only
|
||||||
|
X.509 and Three Peer Roles", not ported); ADR-025's
|
||||||
|
`PeerEntry` asymmetry is the registry surface. See OQ-21 in open-questions.md.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -837,11 +869,16 @@ See [open-questions.md](open-questions.md) for full details.
|
|||||||
model (the server-side complement to this document)
|
model (the server-side complement to this document)
|
||||||
- `operation-registry.md` — `HandlerRegistration`, provenance, capability
|
- `operation-registry.md` — `HandlerRegistration`, provenance, capability
|
||||||
injection, service discovery (the discovery API `from_call` consumes)
|
injection, service discovery (the discovery API `from_call` consumes)
|
||||||
- `docs/research/alknet-call-completion/gap-analysis.md` — DC-1..4, the
|
- Source-repo research (alknet, provenance — these paths live in
|
||||||
implementation-state audit, the downstream unblock chain
|
`/workspace/@alkdev/alknet/docs/research/`, not this repo):
|
||||||
- `docs/research/alknet-call-peer-routing/findings.md` — the peer-graph
|
- `alknet-call-completion/gap-analysis.md` — DC-1..4, the
|
||||||
routing research that identified ADR-023's structural gap and validated
|
implementation-state audit, the downstream unblock chain
|
||||||
the ADR-024 design via POC
|
- `alknet-call-peer-routing/findings.md` — the peer-graph
|
||||||
|
routing research that identified ADR-023's structural gap and validated
|
||||||
|
the ADR-024 design via POC
|
||||||
|
- `alknet-ssh/phase-0-findings.md` — SSH Phase 0; confirms ssh depends on
|
||||||
|
the vendored core types, not alkcall's adapters, so it proceeds in
|
||||||
|
parallel with the completion
|
||||||
- `/workspace/@alkdev/operations/` — TypeScript prior art (`from_openapi.ts`,
|
- `/workspace/@alkdev/operations/` — TypeScript prior art (`from_openapi.ts`,
|
||||||
`from_mcp.ts`, `from_schema.ts`, `scanner.ts`)
|
`from_mcp.ts`, `from_schema.ts`, `scanner.ts`)
|
||||||
- `/workspace/@alkdev/dispatch/` — concrete downstream consumer (container
|
- `/workspace/@alkdev/dispatch/` — concrete downstream consumer (container
|
||||||
@@ -849,7 +886,4 @@ See [open-questions.md](open-questions.md) for full details.
|
|||||||
- `/workspace/aisdk/` — downstream consumer (Rust port of Vercel AI SDK); the
|
- `/workspace/aisdk/` — downstream consumer (Rust port of Vercel AI SDK); the
|
||||||
no-env-vars invariant makes its `std::env::var` reads unreachable
|
no-env-vars invariant makes its `std::env::var` reads unreachable
|
||||||
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp); streamable HTTP transport for
|
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp); streamable HTTP transport for
|
||||||
`alknet-http`'s `from_mcp`/`to_mcp` (separate crate, separate Phase 0)
|
`alkhttp`'s `from_mcp`/`to_mcp`
|
||||||
- `docs/research/alknet-ssh/phase-0-findings.md` — alknet-ssh Phase 0;
|
|
||||||
confirms ssh depends on alknet-core not alknet-call's adapters, so it
|
|
||||||
proceeds in parallel with this completion
|
|
||||||
@@ -232,5 +232,5 @@ WebTransport stream).
|
|||||||
- OQ-09: WASM target boundaries (resolution amended — the server-side
|
- OQ-09: WASM target boundaries (resolution amended — the server-side
|
||||||
dispatch door is no longer closed by `Connection` being QUIC-bound)
|
dispatch door is no longer closed by `Connection` being QUIC-bound)
|
||||||
- Transport generalization findings:
|
- Transport generalization findings:
|
||||||
[`docs/research/transport-generalization/findings.md`](../../research/transport-generalization/findings.md)
|
[`docs/research/transport-generalization/findings.md`](/workspace/@alkdev/alknet/docs/research/transport-generalization/findings.md)
|
||||||
- Implementation commit: `865fef6` (2026-07-09)
|
- Implementation commit: `865fef6` (2026-07-09)
|
||||||
@@ -295,8 +295,8 @@ the args are optional for their transport.
|
|||||||
`from_bidi` constructors and the yield-once contract are preserved via
|
`from_bidi` constructors and the yield-once contract are preserved via
|
||||||
`StreamBidiStreamSource`)
|
`StreamBidiStreamSource`)
|
||||||
- Channels POC summary:
|
- Channels POC summary:
|
||||||
[`docs/research/alknet-channels/poc-summary.md`](../../research/alknet-channels/poc-summary.md)
|
[`docs/research/alknet-channels/poc-summary.md`](/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md)
|
||||||
§"Issues Surfaced" #1 (OQ-CH-13 confirmed +EV), #2 (REQ-CORE-02)
|
§"Issues Surfaced" #1 (OQ-CH-13 confirmed +EV), #2 (REQ-CORE-02)
|
||||||
- Channels Phase 0 findings:
|
- Channels Phase 0 findings:
|
||||||
[`docs/research/alknet-channels/phase-0-findings.md`](../../research/alknet-channels/phase-0-findings.md)
|
[`docs/research/alknet-channels/phase-0-findings.md`](/workspace/@alkdev/alknet/docs/research/alknet-channels/phase-0-findings.md)
|
||||||
§POC-Validated Requirements — REQ-CORE-01, REQ-CORE-02
|
§POC-Validated Requirements — REQ-CORE-01, REQ-CORE-02
|
||||||
@@ -49,7 +49,7 @@ handler picks a joining strategy per-handler:
|
|||||||
| 1 | `BiStream` trait (`AsyncRead + AsyncWrite + Send + Unpin`) | `crates/alknet-core/src/types.rs:226` | **Vestigial in code.** Declared per ADR-005, named in ADR-008 as "a client-side / test convenience trait," but grep across the workspace finds **zero** consumers — no `impl BiStream`, no `dyn BiStream`, no `Box<dyn BiStream>`. The trait is the ecosystem convention (`tokio::net::TcpStream`, `TlsStream<TcpStream>`, `russh::Channel::into_stream()` all satisfy it natively) but it was never wired in. |
|
| 1 | `BiStream` trait (`AsyncRead + AsyncWrite + Send + Unpin`) | `crates/alknet-core/src/types.rs:226` | **Vestigial in code.** Declared per ADR-005, named in ADR-008 as "a client-side / test convenience trait," but grep across the workspace finds **zero** consumers — no `impl BiStream`, no `dyn BiStream`, no `Box<dyn BiStream>`. The trait is the ecosystem convention (`tokio::net::TcpStream`, `TlsStream<TcpStream>`, `russh::Channel::into_stream()` all satisfy it natively) but it was never wired in. |
|
||||||
| 2 | `Connection` (yields `(SendStream, RecvStream)` via `accept_bi`) | `crates/alknet-core/src/types.rs:507` | The handler-facing abstraction. Leaf is split. |
|
| 2 | `Connection` (yields `(SendStream, RecvStream)` via `accept_bi`) | `crates/alknet-core/src/types.rs:507` | The handler-facing abstraction. Leaf is split. |
|
||||||
| 3 | `SendStream` (AsyncWrite-only) + `RecvStream` (AsyncRead-only) | `crates/alknet-core/src/types.rs:228-294` | The actual leaves handlers receive. Each carries a quinn/iroh/generic enum (`SendStreamKind` / `RecvStreamKind`) and dispatches per-call. |
|
| 3 | `SendStream` (AsyncWrite-only) + `RecvStream` (AsyncRead-only) | `crates/alknet-core/src/types.rs:228-294` | The actual leaves handlers receive. Each carries a quinn/iroh/generic enum (`SendStreamKind` / `RecvStreamKind`) and dispatches per-call. |
|
||||||
| 4 | `WsStream` trait (recv/send `axum::ws::Message`) | `crates/alknet-http/src/websocket/upgrade.rs:44` | Bypasses `Connection` entirely. The WS session runs its own dispatch loop directly over `axum::extract::ws::WebSocket`; `CallConnection::new_overlay_only` is used instead of `Connection::from_bidi`. ADR-044/048 already say "a WS message stream is another `BiStream`-satisfying transport" — the code does not. |
|
| 4 | `WsStream` trait (recv/send `axum::ws::Message`) | `crates/alknet-http/src/websocket/upgrade.rs:44` | Bypasses `Connection` entirely. The WS session runs its own dispatch loop directly over `axum::extract::ws::WebSocket`; `CallConnection::new_overlay_only` is used instead of `Connection::from_bidi`. alknet ADR-044/048 (alknet space, provenance) already say "a WS message stream is another `BiStream`-satisfying transport" — the code does not. |
|
||||||
| 5 | `MpscSendStream` / `MpscRecvStream` (channels POC) | `/workspace/alknet-channels-poc/src/mpsc_stream.rs` | Split mpsc-backed halves fed to `Connection::from_stream`. The channels POC's `TunnelHandler` consumes them directly as two `tokio::io::copy` pumps — the split shape is right for the tunnel, wrong for HTTP. |
|
| 5 | `MpscSendStream` / `MpscRecvStream` (channels POC) | `/workspace/alknet-channels-poc/src/mpsc_stream.rs` | Split mpsc-backed halves fed to `Connection::from_stream`. The channels POC's `TunnelHandler` consumes them directly as two `tokio::io::copy` pumps — the split shape is right for the tunnel, wrong for HTTP. |
|
||||||
|
|
||||||
The two Phase 6 issues are symptoms of one root: **the leaf type is
|
The two Phase 6 issues are symptoms of one root: **the leaf type is
|
||||||
@@ -108,21 +108,21 @@ accepts any of these directly, no per-handler wrapper.
|
|||||||
|
|
||||||
### The two-pump shape is unaffected
|
### The two-pump shape is unaffected
|
||||||
|
|
||||||
The tunnel handler (ADR-078) and the SSH `direct-tcpip` handler
|
The tunnel handler (alknet ADR-078, provenance — in-crate: ADR-050) and the SSH `direct-tcpip` handler
|
||||||
(future) use the split shape — two `tokio::io::copy` pumps, one per
|
(future) use the split shape — two `tokio::io::copy` pumps, one per
|
||||||
direction. With `BiStream` as the leaf, these handlers call
|
direction. With `BiStream` as the leaf, these handlers call
|
||||||
`tokio::io::split(bidi)` to get `(ReadHalf, WriteHalf)` — the same
|
`tokio::io::split(bidi)` to get `(ReadHalf, WriteHalf)` — the same
|
||||||
stdlib idiom `tokio::io::split` already provides for `TcpStream` and
|
stdlib idiom `tokio::io::split` already provides for `TcpStream` and
|
||||||
`TlsStream<TcpStream>`. The split is a stdlib call at the handler
|
`TlsStream<TcpStream>`. The split is a stdlib call at the handler
|
||||||
boundary, not a per-handler trait wrapper. ADR-078's
|
boundary, not a per-handler trait wrapper. alknet ADR-078's (alknet space, provenance)
|
||||||
shutdown-on-completion contract applies to the `ReadHalf`/`WriteHalf`
|
shutdown-on-completion contract applies to the `ReadHalf`/`WriteHalf`
|
||||||
unchanged.
|
unchanged.
|
||||||
|
|
||||||
### The TTY named-sub-streams case (ADR-038, ADR-077)
|
### The TTY named-sub-streams case (ADR-038; alknet ADR-077, provenance — TTY inside channels)
|
||||||
|
|
||||||
ADR-038 specifies `into_sub_streams()` returning
|
ADR-038 specifies `into_sub_streams()` returning
|
||||||
`Vec<(u8, SubStreamHandle)>` where `SubStreamHandle` is
|
`Vec<(u8, SubStreamHandle)>` where `SubStreamHandle` is
|
||||||
`Send(SendStream) | Recv(RecvStream)`. ADR-077's TTY-inside-channels
|
`Send(SendStream) | Recv(RecvStream)`. alknet ADR-077's (alknet space, provenance) TTY-inside-channels
|
||||||
mode destructures into five named handles (`stdin`, `stdout`,
|
mode destructures into five named handles (`stdin`, `stdout`,
|
||||||
`stderr`, `ctrl_in`, `ctrl_out`). **Every stream_type is
|
`stderr`, `ctrl_in`, `ctrl_out`). **Every stream_type is
|
||||||
unidirectional** (ADR-034) — the typed-sub-stream leaves are
|
unidirectional** (ADR-034) — the typed-sub-stream leaves are
|
||||||
@@ -306,13 +306,13 @@ The `WsStream` trait (`upgrade.rs:44-49`), the bespoke `drive_ws_session`
|
|||||||
loop, the `handle_inbound_envelope` / `dispatch_envelope_to_pending`
|
loop, the `handle_inbound_envelope` / `dispatch_envelope_to_pending`
|
||||||
helpers, and the `run_ws_session` glue are removed. The session's
|
helpers, and the `run_ws_session` glue are removed. The session's
|
||||||
wire-level invariants (binary-only, protocol-level close on text,
|
wire-level invariants (binary-only, protocol-level close on text,
|
||||||
`fail_all` pending on disconnect, ADR-048's `EventEnvelope` framing)
|
`fail_all` pending on disconnect, alknet ADR-048's `EventEnvelope` framing (alknet space, provenance))
|
||||||
move into `WsBidiStream`'s `AsyncRead` / `AsyncWrite` / `poll_shutdown`
|
move into `WsBidiStream`'s `AsyncRead` / `AsyncWrite` / `poll_shutdown`
|
||||||
impls and the standard call-protocol dispatch path.
|
impls and the standard call-protocol dispatch path.
|
||||||
|
|
||||||
`CallConnection::new_overlay_only` stays — it's the
|
`CallConnection::new_overlay_only` stays — it's the
|
||||||
connection-local-overlay construction for non-peer clients
|
connection-local-overlay construction for non-peer clients
|
||||||
(ADR-034 §4, ADR-044 §5), orthogonal to the transport seam. What
|
(alknet ADR-034 §4, alknet ADR-044 §5 — alknet space, provenance), orthogonal to the transport seam. What
|
||||||
changes is that the WS session feeds it through a `Connection` rather
|
changes is that the WS session feeds it through a `Connection` rather
|
||||||
than a parallel `WsStream` trait.
|
than a parallel `WsStream` trait.
|
||||||
|
|
||||||
@@ -330,7 +330,7 @@ async fn accept_bi(&self) -> Result<BiStream, StreamError> {
|
|||||||
`into_sub_streams()` is unchanged in shape — it still returns
|
`into_sub_streams()` is unchanged in shape — it still returns
|
||||||
`Vec<(u8, SubStreamHandle)>` with `SubStreamHandle::Send(SendStream) |
|
`Vec<(u8, SubStreamHandle)>` with `SubStreamHandle::Send(SendStream) |
|
||||||
Recv(RecvStream)`, because TTY's named sub-streams are unidirectional
|
Recv(RecvStream)`, because TTY's named sub-streams are unidirectional
|
||||||
(ADR-034, ADR-077). The two paths (`accept_bi` for handlers that want
|
(ADR-034; alknet ADR-077, provenance). The two paths (`accept_bi` for handlers that want
|
||||||
the joined pair, `into_sub_streams` for handlers that want the typed
|
the joined pair, `into_sub_streams` for handlers that want the typed
|
||||||
unidirectional sub-streams) are preserved per ADR-038.
|
unidirectional sub-streams) are preserved per ADR-038.
|
||||||
|
|
||||||
@@ -348,7 +348,7 @@ The `webtransport.md` spec describes the "VPN-like without being a VPN"
|
|||||||
path: a browser opens a WebTransport session to `/alk/ssh`, the h3
|
path: a browser opens a WebTransport session to `/alk/ssh`, the h3
|
||||||
handler hands each bidi stream to `SshAdapter::handle` as a
|
handler hands each bidi stream to `SshAdapter::handle` as a
|
||||||
`Connection`, the browser's WASM SSH parser speaks SSH over the
|
`Connection`, the browser's WASM SSH parser speaks SSH over the
|
||||||
stream. WebTransport is deferred per ADR-044.
|
stream. WebTransport is deferred per alknet ADR-044 (alknet space, provenance).
|
||||||
|
|
||||||
With `BiStream` as the leaf, the same path exists over WebSocket in
|
With `BiStream` as the leaf, the same path exists over WebSocket in
|
||||||
v1: a browser opens a WS connection, `WsBidiStream` presents it as a
|
v1: a browser opens a WS connection, `WsBidiStream` presents it as a
|
||||||
@@ -356,7 +356,7 @@ v1: a browser opens a WS connection, `WsBidiStream` presents it as a
|
|||||||
runs the channels demux over it, each channel's `accept_bi` yields a
|
runs the channels demux over it, each channel's `accept_bi` yields a
|
||||||
`BiStream` that `SshAdapter::handle` receives. The WASM SSH parser
|
`BiStream` that `SshAdapter::handle` receives. The WASM SSH parser
|
||||||
runs over a `BiStream`-over-WS-message adapter on the browser side
|
runs over a `BiStream`-over-WS-message adapter on the browser side
|
||||||
(the same `WsBidiStream` shape, browser-implemented). ADR-044/048's
|
(the same `WsBidiStream` shape, browser-implemented). alknet ADR-044/048's (alknet space, provenance)
|
||||||
"WS message stream is another `BiStream`-satisfying transport" becomes
|
"WS message stream is another `BiStream`-satisfying transport" becomes
|
||||||
literal — the code does what the spec said.
|
literal — the code does what the spec said.
|
||||||
|
|
||||||
@@ -379,7 +379,7 @@ ALPN-stream-proxy (ADR-040) is the channels-over-WebTransport shape,
|
|||||||
not an `alknet-http` shape.
|
not an `alknet-http` shape.
|
||||||
|
|
||||||
This is out of scope for this ADR (WebTransport is deferred per
|
This is out of scope for this ADR (WebTransport is deferred per
|
||||||
ADR-044). It is recorded here because the unification is what makes
|
alknet ADR-044, alknet space — provenance). It is recorded here because the unification is what makes
|
||||||
the future extraction clean: once `accept_bi` returns `BiStream`, the
|
the future extraction clean: once `accept_bi` returns `BiStream`, the
|
||||||
`h3` handler's "accept a WebTransport session, yield each stream as a
|
`h3` handler's "accept a WebTransport session, yield each stream as a
|
||||||
`BiStream` to the ALPN handler" shape is the same code as
|
`BiStream` to the ALPN handler" shape is the same code as
|
||||||
@@ -405,7 +405,7 @@ redesign.
|
|||||||
unidirectional sub-streams are the case that justifies keeping
|
unidirectional sub-streams are the case that justifies keeping
|
||||||
`SendStream` / `RecvStream` (as thin newtypes, not quinn-welded
|
`SendStream` / `RecvStream` (as thin newtypes, not quinn-welded
|
||||||
enums).
|
enums).
|
||||||
- **The two-pump pattern** (ADR-078) — preserved. Tunnel/SSH handlers
|
- **The two-pump pattern** (alknet ADR-078, provenance — in-crate: ADR-050) — preserved. Tunnel/SSH handlers
|
||||||
call `tokio::io::split(bidi)` for their two pump halves; the
|
call `tokio::io::split(bidi)` for their two pump halves; the
|
||||||
shutdown-on-completion contract applies to the `ReadHalf` /
|
shutdown-on-completion contract applies to the `ReadHalf` /
|
||||||
`WriteHalf` unchanged.
|
`WriteHalf` unchanged.
|
||||||
@@ -436,7 +436,7 @@ redesign.
|
|||||||
lines of WS-specific dispatch glue are removed from
|
lines of WS-specific dispatch glue are removed from
|
||||||
`alknet-http/websocket/upgrade.rs`. The WS session runs through
|
`alknet-http/websocket/upgrade.rs`. The WS session runs through
|
||||||
`Connection::from_bidi` + the call-protocol handler like any other
|
`Connection::from_bidi` + the call-protocol handler like any other
|
||||||
transport. ADR-044/048's "WS message stream is `BiStream`-satisfying"
|
transport. alknet ADR-044/048's (alknet space, provenance) "WS message stream is `BiStream`-satisfying"
|
||||||
becomes literal.
|
becomes literal.
|
||||||
- One abstraction (`BiStream`) replaces five. The leaf type matches the
|
- One abstraction (`BiStream`) replaces five. The leaf type matches the
|
||||||
ecosystem convention (`russh::Channel::into_stream()`, `TcpStream`,
|
ecosystem convention (`russh::Channel::into_stream()`, `TcpStream`,
|
||||||
@@ -550,13 +550,13 @@ compilable:
|
|||||||
`Connection::from_bidi` + the call-protocol handler. This is the
|
`Connection::from_bidi` + the call-protocol handler. This is the
|
||||||
largest single change and can land after (1)-(3) — the WS path is
|
largest single change and can land after (1)-(3) — the WS path is
|
||||||
independent of the handler call-site updates.
|
independent of the handler call-site updates.
|
||||||
5. **Update ADR-007, ADR-008, ADR-038, ADR-077** to reflect the
|
5. **Update ADR-007, ADR-008, ADR-038, alknet ADR-077 (provenance)** to reflect the
|
||||||
`BiStream` return shape. ADR-007's `from_stream` constructor is
|
`BiStream` return shape. ADR-007's `from_stream` constructor is
|
||||||
removed; `from_bidi` is the only public stream constructor (the
|
removed; `from_bidi` is the only public stream constructor (the
|
||||||
rule: the split never crosses a crate boundary as part of a
|
rule: the split never crosses a crate boundary as part of a
|
||||||
constructor). ADR-008's `accept_bi` return type is amended. ADR-038's
|
constructor). ADR-008's `accept_bi` return type is amended. ADR-038's
|
||||||
`ChannelBidiStreamSource::accept_bi` return type is amended;
|
`ChannelBidiStreamSource::accept_bi` return type is amended;
|
||||||
`into_sub_streams()` is unchanged. ADR-077's two-mode TTY design is
|
`into_sub_streams()` is unchanged. alknet ADR-077's (alknet space, provenance) two-mode TTY design is
|
||||||
unchanged (the modes differ in *how* the adapter gets sub-streams,
|
unchanged (the modes differ in *how* the adapter gets sub-streams,
|
||||||
not in the leaf type).
|
not in the leaf type).
|
||||||
6. **Update `findings.md` Phase 6.** The "deferred" status is
|
6. **Update `findings.md` Phase 6.** The "deferred" status is
|
||||||
@@ -602,12 +602,12 @@ fix (unify the leaf so the wrapper moves into core).
|
|||||||
`from_source` extension point are preserved)
|
`from_source` extension point are preserved)
|
||||||
- ADR-038: `ChannelBidiStreamSource` (amended — `accept_bi` returns
|
- ADR-038: `ChannelBidiStreamSource` (amended — `accept_bi` returns
|
||||||
`BiStream`; `into_sub_streams()` is unchanged)
|
`BiStream`; `into_sub_streams()` is unchanged)
|
||||||
- ADR-077: TTY inside channels (unchanged — the two-mode design is
|
- alknet ADR-077 (provenance): TTY inside channels (unchanged — the two-mode design is
|
||||||
preserved; the modes differ in how the adapter gets sub-streams,
|
preserved; the modes differ in how the adapter gets sub-streams,
|
||||||
not in the leaf type)
|
not in the leaf type)
|
||||||
- ADR-078: two-pump shutdown-on-completion (unchanged — the contract
|
- alknet ADR-078 (provenance — in-crate: ADR-050): two-pump shutdown-on-completion (unchanged — the contract
|
||||||
applies to `tokio::io::split(bidi)` halves)
|
applies to `tokio::io::split(bidi)` halves)
|
||||||
- ADR-044, ADR-048: WebSocket is the v1 browser bidirectional path
|
- alknet ADR-044/048 (alknet space, provenance): WebSocket is the v1 browser bidirectional path
|
||||||
(this ADR makes the "WS message stream is `BiStream`-satisfying"
|
(this ADR makes the "WS message stream is `BiStream`-satisfying"
|
||||||
claim literal)
|
claim literal)
|
||||||
- `docs/research/alknet-crate-extraction/findings.md` Phase 6 — the
|
- `docs/research/alknet-crate-extraction/findings.md` Phase 6 — the
|
||||||
|
|||||||
+7
-7
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted (resolves OQ-42; amends the `AccessControl::check` signature and
|
Accepted (resolves alknet OQ-42 — source-repo space, provenance; the question is retracked in-crate as OQ-30 — amends the `AccessControl::check` signature and
|
||||||
adds `OperationSpec.resource_id_path`. Does not amend ADR-017 or ADR-018 —
|
adds `OperationSpec.resource_id_path`. Does not amend ADR-017 or ADR-018 —
|
||||||
specific #4 confirms the composition authority stays static. Blocks lifted:
|
specific #4 confirms the composition authority stays static. Blocks lifted:
|
||||||
the alknet-docker, alknet-tty, opencode-runner wrapper, and
|
the alknet-docker, alknet-tty, opencode-runner wrapper, and
|
||||||
@@ -36,7 +36,7 @@ None of those crate specs can declare their `AccessControl` shapes until
|
|||||||
the core model answers: **how does `AccessControl::check` learn whether
|
the core model answers: **how does `AccessControl::check` learn whether
|
||||||
identity X owns runtime-spawned resource R?**
|
identity X owns runtime-spawned resource R?**
|
||||||
|
|
||||||
OQ-42 tracked this. The OQ resolved five sub-questions:
|
alknet OQ-42 (source-repo space, provenance; in-crate: OQ-30) tracked this. The OQ resolved five sub-questions:
|
||||||
|
|
||||||
1. Storage shape — reuse the repo/adapter pattern (ADR-033).
|
1. Storage shape — reuse the repo/adapter pattern (ADR-033).
|
||||||
2. Integration point — `AccessControl::check` consults an ownership
|
2. Integration point — `AccessControl::check` consults an ownership
|
||||||
@@ -88,7 +88,7 @@ model has no path from that knowledge to `check`.
|
|||||||
|
|
||||||
### The options considered
|
### The options considered
|
||||||
|
|
||||||
Three integration points were considered (see OQ-42 for the full
|
Three integration points were considered (see alknet OQ-42, in-crate OQ-30, for the full
|
||||||
reasoning):
|
reasoning):
|
||||||
|
|
||||||
- **Option 1 — augment `Identity.resources` with a per-request snapshot.**
|
- **Option 1 — augment `Identity.resources` with a per-request snapshot.**
|
||||||
@@ -592,7 +592,7 @@ provider confirms it owns the container (dynamic).
|
|||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- OQ-42: Dynamic Resource Ownership for Runtime-Spawned Resources
|
- alknet OQ-42 (provenance; in-crate: OQ-30): Dynamic Resource Ownership for Runtime-Spawned Resources
|
||||||
(resolved by this ADR — the five sub-questions this ADR writes into
|
(resolved by this ADR — the five sub-questions this ADR writes into
|
||||||
decision text)
|
decision text)
|
||||||
- ADR-003: Auth as Shared Core (`IdentityProvider` — the first instance
|
- ADR-003: Auth as Shared Core (`IdentityProvider` — the first instance
|
||||||
@@ -625,9 +625,9 @@ provider confirms it owns the container (dynamic).
|
|||||||
- ADR-022: Call Protocol Client and Adapter Contract (`from_call` — the
|
- ADR-022: Call Protocol Client and Adapter Contract (`from_call` — the
|
||||||
adapter that imports a peer's operations and re-registers them locally;
|
adapter that imports a peer's operations and re-registers them locally;
|
||||||
the proxy pattern's re-export mechanism)
|
the proxy pattern's re-export mechanism)
|
||||||
- [auth.md](../crates/core/auth.md) (`Identity.resources`,
|
- [auth.md](/workspace/@alkdev/alknet/docs/architecture/crates/core/auth.md) (`Identity.resources`,
|
||||||
`AccessControl::check` interaction — both under edit by this decision)
|
`AccessControl::check` interaction — both under edit by this decision)
|
||||||
- [operation-registry.md](../crates/call/operation-registry.md)
|
- [operation-registry.md](../operation-registry.md)
|
||||||
(`AccessControl`, `OperationSpec` — `resource_id_path` addition)
|
(`AccessControl`, `OperationSpec` — `resource_id_path` addition)
|
||||||
- [alknet-docker POC summary](../../research/alknet-docker/poc-summary.md)
|
- [alknet-docker POC summary](/workspace/@alkdev/alknet/docs/research/alknet-docker/poc-summary.md)
|
||||||
§"Open Unknowns" #3 (the research finding that surfaced this question)
|
§"Open Unknowns" #3 (the research finding that surfaced this question)
|
||||||
@@ -79,7 +79,7 @@ were removed in commit `668d777` (2026-07-09). `irpc` may be re-added as
|
|||||||
`iroh-blobs 0.103`), but that would be a *transitive* dependency of
|
`iroh-blobs 0.103`), but that would be a *transitive* dependency of
|
||||||
`alknet-blobs`, not a direct dependency of `alknet-call` — alknet-call does
|
`alknet-blobs`, not a direct dependency of `alknet-call` — alknet-call does
|
||||||
not import irpc and has no plans to. See
|
not import irpc and has no plans to. See
|
||||||
[`docs/research/transport-generalization/findings.md`](../../research/transport-generalization/findings.md)
|
[`docs/research/transport-generalization/findings.md`](/workspace/@alkdev/alknet/docs/research/transport-generalization/findings.md)
|
||||||
§3.1 for the removal trace.
|
§3.1 for the removal trace.
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
@@ -159,6 +159,6 @@ not import irpc and has no plans to. See
|
|||||||
dispatch path)
|
dispatch path)
|
||||||
- Call protocol wire format: `crates/alknet-call/src/protocol/wire.rs`
|
- Call protocol wire format: `crates/alknet-call/src/protocol/wire.rs`
|
||||||
- Transport generalization findings:
|
- Transport generalization findings:
|
||||||
[`docs/research/transport-generalization/findings.md`](../../research/transport-generalization/findings.md)
|
[`docs/research/transport-generalization/findings.md`](/workspace/@alkdev/alknet/docs/research/transport-generalization/findings.md)
|
||||||
§3.1 (dead `irpc` dep removal)
|
§3.1 (dead `irpc` dep removal)
|
||||||
- Removal commit: `668d777` (2026-07-09)
|
- Removal commit: `668d777` (2026-07-09)
|
||||||
@@ -302,8 +302,8 @@ Principle of least privilege.
|
|||||||
- OQ-17: Abort cascade (the call tree is the agency chain — `parent_request_id`
|
- OQ-17: Abort cascade (the call tree is the agency chain — `parent_request_id`
|
||||||
traces principal → agent)
|
traces principal → agent)
|
||||||
- OQ-19: Session-scoped registries (session operations are always `Internal`)
|
- OQ-19: Session-scoped registries (session operations are always `Internal`)
|
||||||
- [operation-registry.md](../crates/call/operation-registry.md)
|
- [operation-registry.md](../operation-registry.md)
|
||||||
- [call-protocol.md](../crates/call/call-protocol.md)
|
- [call-protocol.md](../call-protocol.md)
|
||||||
- TypeScript `@alkdev/operations` `buildEnv()` with `allowedNamespaces` — prior
|
- TypeScript `@alkdev/operations` `buildEnv()` with `allowedNamespaces` — prior
|
||||||
art for scoped composition env
|
art for scoped composition env
|
||||||
- POC at `/workspace/toolEnv` — demonstrated the sandbox-to-registry bridge with
|
- POC at `/workspace/toolEnv` — demonstrated the sandbox-to-registry bridge with
|
||||||
|
|||||||
@@ -253,5 +253,5 @@ not through the wire and not through `OperationSpec`.
|
|||||||
tree and participate in cascade)
|
tree and participate in cascade)
|
||||||
- `@alkdev/flowgraph` TypeScript package — prior art for call-graph tracking
|
- `@alkdev/flowgraph` TypeScript package — prior art for call-graph tracking
|
||||||
with `descendants()`, `FailurePolicy`, reactive status propagation
|
with `descendants()`, `FailurePolicy`, reactive status propagation
|
||||||
- [call-protocol.md](../crates/call/call-protocol.md)
|
- [call-protocol.md](../call-protocol.md)
|
||||||
- [operation-registry.md](../crates/call/operation-registry.md)
|
- [operation-registry.md](../operation-registry.md)
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted (amended 2026-06-26, 2026-07-13, and 2026-07-16 — see "Amendments" below; the 2026-07-16 amendment per ADR-045 §5 removes `CallClient::connect`; amendment 2026-09-03 — the bootstrap-op set and the connect-side serving loop, see "Amendment (2026-09-03)" below; amendment 2026-09-04 — the `op/register` collision policy, in that amendment's "Collision policy" paragraph)
|
Accepted (amended 2026-06-26, 2026-07-13, and 2026-07-16 — see "Amendments" below; the 2026-07-16 amendment per ADR-045 §5 removes `CallClient::connect`; amendment 2026-09-03 — the bootstrap-op set and the connect-side serving loop, see "Amendment (2026-09-03)" below; amendment 2026-09-04 — the `op/register` collision policy, in that amendment's "Collision policy" paragraph; amendment 2026-10-13 — announced Sub/Pub forward-through and marked-spec refusal (review 010 S-04), see "Amendment (2026-10-13)" below)
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
@@ -349,9 +349,9 @@ same as `from_openapi` receives HTTP credentials.
|
|||||||
- OQ-25..28: Two-way-door remainders from the call-completion gap analysis
|
- OQ-25..28: Two-way-door remainders from the call-completion gap analysis
|
||||||
(DC-1 shape, DC-4 error type, DC-2 re-import trigger, DC-3 namespace
|
(DC-1 shape, DC-4 error type, DC-2 re-import trigger, DC-3 namespace
|
||||||
collision — see [open-questions.md](../open-questions.md))
|
collision — see [open-questions.md](../open-questions.md))
|
||||||
- [call-protocol.md](../crates/call/call-protocol.md)
|
- [call-protocol.md](../call-protocol.md)
|
||||||
- [operation-registry.md](../crates/call/operation-registry.md)
|
- [operation-registry.md](../operation-registry.md)
|
||||||
- [client-and-adapters.md](../crates/call/client-and-adapters.md) — the spec
|
- [client-and-adapters.md](../client-and-adapters.md) — the spec
|
||||||
that operationally fills the gap this ADR left to implementation
|
that operationally fills the gap this ADR left to implementation
|
||||||
- `docs/research/alknet-call-completion/gap-analysis.md` — DC-1..4, the
|
- `docs/research/alknet-call-completion/gap-analysis.md` — DC-1..4, the
|
||||||
decisions that needed resolution before implementation
|
decisions that needed resolution before implementation
|
||||||
@@ -524,7 +524,7 @@ overlays), not by a parallel boolean gate.
|
|||||||
§5 showed `async fn import(&self) -> Vec<HandlerRegistration>` with no error
|
§5 showed `async fn import(&self) -> Vec<HandlerRegistration>` with no error
|
||||||
type. The trait returns `Result<Vec<HandlerRegistration>, AdapterError>`
|
type. The trait returns `Result<Vec<HandlerRegistration>, AdapterError>`
|
||||||
where `AdapterError` is a crate-level enum. The *presence* of the error type
|
where `AdapterError` is a crate-level enum. The *presence* of the error type
|
||||||
is recorded in [client-and-adapters.md](../crates/call/client-and-adapters.md);
|
is recorded in [client-and-adapters.md](../client-and-adapters.md);
|
||||||
the exact variants are the two-way-door remainder, tracked as OQ-26.
|
the exact variants are the two-way-door remainder, tracked as OQ-26.
|
||||||
|
|
||||||
### DC-2 — from_call re-import on reconnection: manual free function
|
### DC-2 — from_call re-import on reconnection: manual free function
|
||||||
@@ -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,
|
mid-connection re-discovery is a genuine feature addition — non-breaking,
|
||||||
additive — if a deployment needs manual re-discovery without
|
additive — if a deployment needs manual re-discovery without
|
||||||
drop-and-reconnect. Two-way door; recorded in
|
drop-and-reconnect. Two-way door; recorded in
|
||||||
[client-and-adapters.md](../crates/call/client-and-adapters.md); tracked as
|
[client-and-adapters.md](../client-and-adapters.md); tracked as
|
||||||
OQ-27. See [ADR-028](028-from-call-manual-free-function.md) for the full
|
OQ-27. See [ADR-028](028-from-call-manual-free-function.md) for the full
|
||||||
rationale.
|
rationale.
|
||||||
|
|
||||||
@@ -547,7 +547,7 @@ rationale.
|
|||||||
collision = error**. A node importing from two remotes that both expose the
|
collision = error**. A node importing from two remotes that both expose the
|
||||||
same unprefixed op name should fail loudly. The operator adds prefixes when
|
same unprefixed op name should fail loudly. The operator adds prefixes when
|
||||||
importing from multiple sources. Two-way door; recorded in
|
importing from multiple sources. Two-way door; recorded in
|
||||||
[client-and-adapters.md](../crates/call/client-and-adapters.md); tracked as
|
[client-and-adapters.md](../client-and-adapters.md); tracked as
|
||||||
OQ-28.
|
OQ-28.
|
||||||
|
|
||||||
### Operational spec
|
### Operational spec
|
||||||
@@ -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
|
`from_call` flow, the trait signature, the adapter location map, the
|
||||||
no-env-vars invariant, and the exchange-of-operations pattern — is
|
no-env-vars invariant, and the exchange-of-operations pattern — is
|
||||||
specified in
|
specified in
|
||||||
[client-and-adapters.md](../crates/call/client-and-adapters.md). That document
|
[client-and-adapters.md](../client-and-adapters.md). That document
|
||||||
is the operational complement to this ADR; this ADR remains the architectural
|
is the operational complement to this ADR; this ADR remains the architectural
|
||||||
authority.
|
authority.
|
||||||
|
|
||||||
@@ -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
|
alknet-call" one-way-door statement is corrected above to "the adapter
|
||||||
trait lives in `alknet-call`; implementations live with their transport."
|
trait lives in `alknet-call`; implementations live with their transport."
|
||||||
See [ADR-027](027-from-jsonschema-as-http-adapter.md) and
|
See [ADR-027](027-from-jsonschema-as-http-adapter.md) and
|
||||||
[client-and-adapters.md](../crates/call/client-and-adapters.md) §"from_jsonschema".
|
[client-and-adapters.md](../client-and-adapters.md) §"from_jsonschema".
|
||||||
|
|
||||||
## Amendments (2026-07-13)
|
## Amendments (2026-07-13)
|
||||||
|
|
||||||
@@ -585,7 +585,7 @@ See [ADR-027](027-from-jsonschema-as-http-adapter.md) and
|
|||||||
The §1 Decision framed `CallClient::connect(addr: SocketAddr,
|
The §1 Decision framed `CallClient::connect(addr: SocketAddr,
|
||||||
credentials)` as the primary constructor and described it as "opens a
|
credentials)` as the primary constructor and described it as "opens a
|
||||||
QUIC connection." The operational spec
|
QUIC connection." The operational spec
|
||||||
([client-and-adapters.md](../crates/call/client-and-adapters.md))
|
([client-and-adapters.md](../client-and-adapters.md))
|
||||||
framed `spawn_dispatch(connection)` as the "lower-level API" that
|
framed `spawn_dispatch(connection)` as the "lower-level API" that
|
||||||
`connect()` uses after the QUIC dial. That framing welded the
|
`connect()` uses after the QUIC dial. That framing welded the
|
||||||
client-side one-way-door API to QUIC — the same welding ADR-007
|
client-side one-way-door API to QUIC — the same welding ADR-007
|
||||||
@@ -617,9 +617,64 @@ existing code (which already has the right structure —
|
|||||||
The door-type classification is updated: `spawn_dispatch` is one-way
|
The door-type classification is updated: `spawn_dispatch` is one-way
|
||||||
(the handler-facing surface); ~~`connect` is two-way (additive
|
(the handler-facing surface); ~~`connect` is two-way (additive
|
||||||
convenience)~~ `connect` is **removed** (ADR-045 §5). The
|
convenience)~~ `connect` is **removed** (ADR-045 §5). The
|
||||||
`AlknetClient` extraction (OQ-55) is **resolved** by ADR-045 — the
|
`AlknetClient` extraction (alknet OQ-55) is **resolved** by ADR-045 — the
|
||||||
shared dial seam is `alknet-client`; `spawn_dispatch` is the
|
shared dial seam is `alknet-client`; `spawn_dispatch` is the
|
||||||
protocol-crate take-over that consumes the dial's `Connection`.
|
protocol-crate take-over that consumes the dial's `Connection`.
|
||||||
|
|
||||||
See [client-and-adapters.md](../crates/call/client-and-adapters.md)
|
See [client-and-adapters.md](../client-and-adapters.md)
|
||||||
§"CallClient" for the reframed operational spec.
|
§"CallClient" for the reframed operational spec.
|
||||||
|
|
||||||
|
## Amendment (2026-10-13 — review 010 S-04: announced Sub/Pub forward-through, marked-spec refusal)
|
||||||
|
|
||||||
|
`op/register`'s announced Sub/Pub handling shipped as invocation-failing
|
||||||
|
stubs (`INVALID_OPERATION_TYPE` on every invocation of an advertised,
|
||||||
|
discoverable op — review 010 S-04, nine published versions). The
|
||||||
|
amendment replaces the stubs with the real forwarding machinery and
|
||||||
|
closes the marked-spec hole the stubs sat in:
|
||||||
|
|
||||||
|
1. **Unmarked `Sub` announcements** register as real
|
||||||
|
`HandlerKind::Stream` handlers
|
||||||
|
(`make_streaming_forwarding_handler`) — a nested composition that
|
||||||
|
cannot invoke them anyway (`env.invoke` is request/response-only,
|
||||||
|
ADR-046 §6), but the overlay now carries a genuine working forward
|
||||||
|
of the remote stream, not an advertised-always-failing body.
|
||||||
|
2. **Unmarked `Pub` announcements** register as real
|
||||||
|
`HandlerKind::Sink` handlers (`make_sink_forwarding_handler`) —
|
||||||
|
same posture; the shapes already exist in-tree (`from_call`'s own
|
||||||
|
import path).
|
||||||
|
3. **Marked (`channel_open`) announcements — any type — are refused at
|
||||||
|
`op/register`** with a typed error (`MARKED_SPEC_UNSUPPORTED`):
|
||||||
|
marked ops are channel-machinery ops by their own marker contract
|
||||||
|
(ADR-047 §2 — the data plane is the allocated channel's
|
||||||
|
`BiStream`). Forwarding a marked op is incoherent by the marker's
|
||||||
|
own semantics — a `Sub`-side forward would deliver the
|
||||||
|
`{channel_id}` ack of a channel that exists only on the remote
|
||||||
|
side; a `Pub`-side forward would stream call-plane chunks into a
|
||||||
|
surface alkcall's amendment 4 rejects. The announcing peer's
|
||||||
|
sanctioned compositions are the relay
|
||||||
|
(`ChannelRelay::register_relay_openable`) for a hub, and direct
|
||||||
|
consumption (`ChannelClient`) otherwise. The same refusal applies
|
||||||
|
to overlay imports generally: `CallConnection::register_imported`
|
||||||
|
and the generic registration path refuse marked specs that did not
|
||||||
|
come through the channel-machinery seam (ADR-047 amendment 4
|
||||||
|
§4.8), so a marked spec can no longer land anywhere as an
|
||||||
|
advertised-always-failing or advertised-but-unusable body.
|
||||||
|
|
||||||
|
The stub-era rationale ("nested composition is request/response-only;
|
||||||
|
the streaming/sink forwarding shapes ride on the `from_call` import
|
||||||
|
path") explained why the *stub body* existed, not why the *announcement
|
||||||
|
was accepted*; it was reviewed and rejected in review 010 (S-04) — the
|
||||||
|
rationale may justify not implementing nested streaming composition,
|
||||||
|
but not accepting and advertising an announcement that cannot serve it.
|
||||||
|
|
||||||
|
Door type: one-way. The refusal code and the marked-spec overlay
|
||||||
|
invariant are wire-visible (announcing peers observe the rejection) and
|
||||||
|
join the stable error set per ADR-016. The handler-kind mapping for
|
||||||
|
unmarked announcements reuses the existing forwarding shapes — no new
|
||||||
|
surface.
|
||||||
|
|
||||||
|
References: review 010 S-04 (the stub),
|
||||||
|
`src/registry/op_register.rs` (the handler), ADR-047 amendment 4 §4.8
|
||||||
|
(the marked-spec registration invariant this enforces on the wire
|
||||||
|
side), ADR-046 §6 (the composition boundary), ADR-051 (the relay
|
||||||
|
composition marked announcements are refused in favor of).
|
||||||
@@ -270,7 +270,7 @@ There are three credential types in the alknet auth model:
|
|||||||
2. **X.509 cert** — for domain-facing endpoints (`api.alk.dev`, relays,
|
2. **X.509 cert** — for domain-facing endpoints (`api.alk.dev`, relays,
|
||||||
ACME/Let's Encrypt). Fingerprint format: `SHA256:<hex of DER>`. Requires
|
ACME/Let's Encrypt). Fingerprint format: `SHA256:<hex of DER>`. Requires
|
||||||
CA verification on the client side. The outgoing-only case (a client
|
CA verification on the client side. The outgoing-only case (a client
|
||||||
connects to a public X.509 endpoint) is tracked as OQ-37.
|
connects to a public X.509 endpoint) is tracked as OQ-21.
|
||||||
|
|
||||||
3. **Bearer token** (auth_token) — for HTTP clients that can't do TLS
|
3. **Bearer token** (auth_token) — for HTTP clients that can't do TLS
|
||||||
client-auth (browsers, curl), or as a secondary credential path. Carried
|
client-auth (browsers, curl), or as a secondary credential path. Carried
|
||||||
@@ -427,8 +427,9 @@ rotation (`PeerEntry`) or not (`ApiKeyEntry`).
|
|||||||
paths, and `ApiKeyEntry` is for tokens that ARE the identity)
|
paths, and `ApiKeyEntry` is for tokens that ARE the identity)
|
||||||
- OQ-29: CallClient TLS Client-Auth (resolved by this ADR's §6 fingerprint
|
- OQ-29: CallClient TLS Client-Auth (resolved by this ADR's §6 fingerprint
|
||||||
normalization + the client-auth wiring decision recorded in OQ-29)
|
normalization + the client-auth wiring decision recorded in OQ-29)
|
||||||
- OQ-37: X.509 outgoing-only case (the three auth types and how X.509
|
- OQ-21: X.509 outgoing-only case (the three auth types and how X.509
|
||||||
server identity fits — see OQ-37 in open-questions.md)
|
server identity fits — see OQ-21 in open-questions.md; the deciding
|
||||||
|
ADR, alknet ADR-034, was not ported into alkcall)
|
||||||
- `docs/research/alknet-storage-strategy/findings.md` §4 (the `PeerEntry`
|
- `docs/research/alknet-storage-strategy/findings.md` §4 (the `PeerEntry`
|
||||||
model and resolution path)
|
model and resolution path)
|
||||||
- `docs/architecture/crates/core/auth.md` (the spec amended by this ADR)
|
- `docs/architecture/crates/core/auth.md` (the spec amended by this ADR)
|
||||||
|
|||||||
@@ -177,4 +177,16 @@ endpoint.
|
|||||||
and no-env-vars invariant ([ADR-010](010-secret-material-flow-and-capability-injection.md))
|
and no-env-vars invariant ([ADR-010](010-secret-material-flow-and-capability-injection.md))
|
||||||
established by `from_openapi`.
|
established by `from_openapi`.
|
||||||
- Reuses `HttpServiceConfig` and `SharedHttpClient` from
|
- Reuses `HttpServiceConfig` and `SharedHttpClient` from
|
||||||
`from_openapi` (in `alknet-http`).
|
`from_openapi` (in `alknet-http`).
|
||||||
|
|
||||||
|
## Amendment 2026-10-13: name normalization (`alknet-http` → `alkhttp`)
|
||||||
|
|
||||||
|
This ADR's decision text was authored when the HTTP host crate was
|
||||||
|
named `alknet-http` inside the alknet mono-repo. The HTTP host is now
|
||||||
|
the standalone sibling crate **`alkhttp`**, which wraps alkcall
|
||||||
|
(extraction context: ADR-031; the adapter-location rule is unchanged —
|
||||||
|
the trait lives in alkcall, HTTP-backed adapters live in `alkhttp`).
|
||||||
|
The title's "in alknet-http" and the body's crate-name references
|
||||||
|
should be read as "in `alkhttp`"; the References below link to the
|
||||||
|
source-repo originals (provenance). The `FromJsonSchema` provenance
|
||||||
|
variant remains in alkcall.
|
||||||
@@ -123,7 +123,7 @@ implementations are independent. The Amendment 1 protocol-foundation
|
|||||||
exception remains for alknet-http/agent/napi (which use alknet-call's
|
exception remains for alknet-http/agent/napi (which use alknet-call's
|
||||||
`OperationSpec`/`Handler`/`OperationAdapter` types — actual type reuse,
|
`OperationSpec`/`Handler`/`OperationAdapter` types — actual type reuse,
|
||||||
not framing glue); it no longer covers alknet-tty. See
|
not framing glue); it no longer covers alknet-tty. See
|
||||||
[ADR-057](057-alknet-tty-no-alknet-call-dep.md) for the full decision
|
[ADR-057](/workspace/@alkdev/alknet/docs/architecture/decisions/057-alknet-tty-no-alknet-call-dep.md) for the full decision
|
||||||
and the three options considered (duplicate / promote to core / use
|
and the three options considered (duplicate / promote to core / use
|
||||||
alknet-call).
|
alknet-call).
|
||||||
|
|
||||||
@@ -140,4 +140,31 @@ The dead `irpc` / `irpc-derive` workspace deps and the `alknet-call` consumer
|
|||||||
dep were removed in commit `668d777`. See
|
dep were removed in commit `668d777`. See
|
||||||
[ADR-014](014-irpc-never-integrated-hand-rolled-framing.md) for the full
|
[ADR-014](014-irpc-never-integrated-hand-rolled-framing.md) for the full
|
||||||
record (ADR-013, which accepted "irpc as the call protocol foundation," is
|
record (ADR-013, which accepted "irpc as the call protocol foundation," is
|
||||||
superseded).
|
superseded).
|
||||||
|
### Amendment 4 (2026-10-13): post-extraction state — alkcall is the unified protocol crate; sibling names normalized
|
||||||
|
|
||||||
|
The decomposition this ADR decided has been executed; this section
|
||||||
|
records where the named crates now live, so the decision text's
|
||||||
|
mono-repo framing reads as history:
|
||||||
|
|
||||||
|
- **alkcall** (this crate) is the unified extraction of `alknet-call`
|
||||||
|
+ `alknet-channels` + the vendored core types (the `alknet-core`
|
||||||
|
surfaces this crate's protocol depends on: `Connection`,
|
||||||
|
`BiStream`, `ProtocolHandler`, `AuthContext`, `IdentityProvider`,
|
||||||
|
`AuthToken`, `Capabilities`, `OwnershipProvider`, `HandlerError`,
|
||||||
|
`StreamError`). Two subsystems: `registry`/`protocol` (call) and the
|
||||||
|
channels subsystem (ADR-044's `channels-core`/`channels-call` split
|
||||||
|
landed as modules here).
|
||||||
|
- **Sibling host crates** (now standalone, all depending on alkcall):
|
||||||
|
`alknet-http` → `alkhttp`; `alknet-tls` → `alktls`;
|
||||||
|
`alknet-tty` → `alktty`; `alknet-tunnels` → `alktunnels`;
|
||||||
|
`alknet-trader` → `alktrader`.
|
||||||
|
- **The mono-repo (alknet) is being replaced** by thin glue over
|
||||||
|
alkcall and its siblings; its remaining roles (dial/accept helper
|
||||||
|
`AlknetClient`, endpoint layer, hub composition) move to the
|
||||||
|
composition layer — post-replacement owner and shape tracked as
|
||||||
|
OQ-43 in this crate's question tracker.
|
||||||
|
- The protocol-foundation rule (Amendment 1) carries over unchanged
|
||||||
|
with current names: `alkhttp` depends on alkcall for operation
|
||||||
|
registry / adapter / client types; peer-handler crates (e.g.
|
||||||
|
`alktty`, `alktunnels`, `alktrader`) depend on alkcall only.
|
||||||
@@ -22,7 +22,7 @@ JSON, tunnel's raw bytes, SSH's channel protocol).
|
|||||||
|
|
||||||
The stream_type decomposition (unidirectional halves, mod 3 formula, 85
|
The stream_type decomposition (unidirectional halves, mod 3 formula, 85
|
||||||
groups) is **removed from the channels layer**. The stream_type concept
|
groups) is **removed from the channels layer**. The stream_type concept
|
||||||
survives in TTY's 5-byte format (ADR-052, amended by Phase 7), which the
|
survives in the TTY 5-byte format (alknet ADR-052, provenance — amended by Phase 7), which the
|
||||||
channels layer carries transparently in its payload. The total header
|
channels layer carries transparently in its payload. The total header
|
||||||
for a TTY chunk inside channels is 13 bytes (8 channels + 5 TTY), not
|
for a TTY chunk inside channels is 13 bytes (8 channels + 5 TTY), not
|
||||||
9; the two length fields are close but not identical
|
9; the two length fields are close but not identical
|
||||||
@@ -47,7 +47,7 @@ Two prior formats inform this design:
|
|||||||
1. **SSH's channel multiplexer (RFC 4254)** — `ChannelId(u32)` with
|
1. **SSH's channel multiplexer (RFC 4254)** — `ChannelId(u32)` with
|
||||||
string-named types negotiated per channel, all traffic interleaved on one
|
string-named types negotiated per channel, all traffic interleaved on one
|
||||||
encrypted transport stream.
|
encrypted transport stream.
|
||||||
2. **alknet-tty's chunk format (ADR-052)** — `[stream_type: u8][length: u32 be]
|
2. **alknet-tty's chunk format (alknet ADR-052, provenance)** — `[stream_type: u8][length: u32 be]
|
||||||
[payload]`, a fixed set of four sub-streams (stdin/stdout/stderr/control)
|
[payload]`, a fixed set of four sub-streams (stdin/stdout/stderr/control)
|
||||||
within one bidi stream. Validated by two POCs and in production code.
|
within one bidi stream. Validated by two POCs and in production code.
|
||||||
|
|
||||||
@@ -100,18 +100,18 @@ client→server) and 4 (read, server→client), not one "bidirectional" 3.
|
|||||||
```
|
```
|
||||||
|
|
||||||
9 bytes of header. The `channel_id` is the addition over TTY's 5-byte
|
9 bytes of header. The `channel_id` is the addition over TTY's 5-byte
|
||||||
format; `stream_type` and `length` are identical to TTY's fields (ADR-052),
|
format; `stream_type` and `length` are identical to TTY's fields (alknet ADR-052, provenance),
|
||||||
preserving the framing-disambiguation soundness property.
|
preserving the framing-disambiguation soundness property.
|
||||||
|
|
||||||
| field | width | meaning |
|
| field | width | meaning |
|
||||||
|-------|-------|---------|
|
|-------|-------|---------|
|
||||||
| `channel_id` | u32 BE | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alk/call` (ADR-036). Channels 1..N are opened dynamically via `channel/open`. |
|
| `channel_id` | u32 BE | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alk/call` (ADR-036). Channels 1..N are opened dynamically via `channel/open`. |
|
||||||
| `stream_type` | u8 | The unidirectional sub-stream within the channel. See "Stream types" below. |
|
| `stream_type` | u8 | The unidirectional sub-stream within the channel. See "Stream types" below. |
|
||||||
| `length` | u32 BE | The payload length in bytes. 0 = EOF sentinel (same convention as TTY — ADR-052 §Sentinels). |
|
| `length` | u32 BE | The payload length in bytes. 0 = EOF sentinel (same convention as TTY — alknet ADR-052 §Sentinels (provenance)). |
|
||||||
|
|
||||||
### `MAX_CHUNK_LEN`
|
### `MAX_CHUNK_LEN`
|
||||||
|
|
||||||
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (ADR-052 §5). A chunk with
|
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (alknet ADR-052 §5, provenance). A chunk with
|
||||||
`length > MAX_CHUNK_LEN` returns `ChunkTooLarge` and does not corrupt the
|
`length > MAX_CHUNK_LEN` returns `ChunkTooLarge` and does not corrupt the
|
||||||
stream — the demux drops the chunk and continues. The header is always
|
stream — the demux drops the chunk and continues. The header is always
|
||||||
exactly 9 bytes, so the demux can always resync by reading the next 9-byte
|
exactly 9 bytes, so the demux can always resync by reading the next 9-byte
|
||||||
@@ -214,7 +214,7 @@ the cost of uniformity. For the intended use cases (TTY, SSH, tunnels, call
|
|||||||
operations), this is noise. The high-throughput escape hatch is
|
operations), this is noise. The high-throughput escape hatch is
|
||||||
multi-connection, not stripping the header.
|
multi-connection, not stripping the header.
|
||||||
|
|
||||||
### Framing disambiguation (carried from ADR-052 §5)
|
### Framing disambiguation (carried from alknet ADR-052 §5, provenance)
|
||||||
|
|
||||||
Channel 0 is just another channel — its chunks have `channel_id=0` in the
|
Channel 0 is just another channel — its chunks have `channel_id=0` in the
|
||||||
header. Disambiguation between channel 0 (call protocol) and data channels
|
header. Disambiguation between channel 0 (call protocol) and data channels
|
||||||
@@ -225,7 +225,7 @@ first byte of a chunk payload from the server is unambiguous.
|
|||||||
### Zero-length sentinel = EOF
|
### Zero-length sentinel = EOF
|
||||||
|
|
||||||
A zero-length chunk is delivered as an empty `Bytes`, which the reassembled
|
A zero-length chunk is delivered as an empty `Bytes`, which the reassembled
|
||||||
stream interprets as EOF (same convention as TTY — ADR-052 §Sentinels). This
|
stream interprets as EOF (same convention as TTY — alknet ADR-052 §Sentinels (provenance)). This
|
||||||
is the clean-shutdown signal for a `(channel_id, stream_type)` pair.
|
is the clean-shutdown signal for a `(channel_id, stream_type)` pair.
|
||||||
|
|
||||||
### Sync core / async shell split
|
### Sync core / async shell split
|
||||||
@@ -251,7 +251,7 @@ tokio-dependent shell.
|
|||||||
- The hub relay correlates by `channel_id` in the header, uniformly across
|
- The hub relay correlates by `channel_id` in the header, uniformly across
|
||||||
substrates — no stream-ID-to-`channel_id` mapping per leg.
|
substrates — no stream-ID-to-`channel_id` mapping per leg.
|
||||||
- WASM-compatible by construction — the pure core has no platform deps.
|
- WASM-compatible by construction — the pure core has no platform deps.
|
||||||
- The framing-disambiguation property from ADR-052 carries forward unchanged.
|
- The framing-disambiguation property from alknet ADR-052 (provenance) carries forward unchanged.
|
||||||
|
|
||||||
**Negative:**
|
**Negative:**
|
||||||
- 9 bytes per chunk on all substrates, including QUIC where the transport
|
- 9 bytes per chunk on all substrates, including QUIC where the transport
|
||||||
@@ -288,7 +288,7 @@ within the one-way format.
|
|||||||
wire format is 8 bytes, not 9; `stream_type` removed from the channels
|
wire format is 8 bytes, not 9; `stream_type` removed from the channels
|
||||||
header; the stream_type decomposition is removed from the channels
|
header; the stream_type decomposition is removed from the channels
|
||||||
layer; the handler owns its sub-stream multiplexing on the `BiStream`)
|
layer; the handler owns its sub-stream multiplexing on the `BiStream`)
|
||||||
- ADR-052: alknet-tty wire format (the 5-byte format this generalizes;
|
- Source-repo provenance (not ported): alknet ADR-052 — alknet-tty wire format (the 5-byte format this generalizes;
|
||||||
amended by ADR-077 — scoped to direct TTY; **re-amended by ADR-035 —
|
amended by ADR-077 — scoped to direct TTY; **re-amended by ADR-035 —
|
||||||
TTY always uses its 5-byte format, carried transparently in the
|
TTY always uses its 5-byte format, carried transparently in the
|
||||||
channels payload**)
|
channels payload**)
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ on the `BiStream` the channels layer gives them.
|
|||||||
framing byte, carried transparently.
|
framing byte, carried transparently.
|
||||||
- **The control channel is handler-internal.** TTY sub-demuxes control
|
- **The control channel is handler-internal.** TTY sub-demuxes control
|
||||||
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN = 3`,
|
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN = 3`,
|
||||||
`STREAM_CTRL_OUT = 4` — ADR-052 amended by Phase 7). The channels layer
|
`STREAM_CTRL_OUT = 4` — alknet ADR-052 (provenance), amended by Phase 7). The channels layer
|
||||||
doesn't carry control. The "control isn't actually bidirectional" flaw
|
doesn't carry control. The "control isn't actually bidirectional" flaw
|
||||||
is fixed at the TTY layer, not the channels layer.
|
is fixed at the TTY layer, not the channels layer.
|
||||||
- **Recursive composition is literal.** A channel with ALPN
|
- **Recursive composition is literal.** A channel with ALPN
|
||||||
@@ -165,7 +165,7 @@ Three things changed since ADR-034/074/077 were accepted:
|
|||||||
handler boundary — but there may be a generalized reason to expose it
|
handler boundary — but there may be a generalized reason to expose it
|
||||||
(recursive composition, test helpers, the hub relay's `channel_id`
|
(recursive composition, test helpers, the hub relay's `channel_id`
|
||||||
rewrite). The exact API shape is an implementation detail for the
|
rewrite). The exact API shape is an implementation detail for the
|
||||||
channels crate, tracked as OQ-68. The *contract* — the channels layer
|
channels crate, tracked as alknet OQ-68 (source-repo space, provenance; in-crate: OQ-24). The *contract* — the channels layer
|
||||||
strips its 8-byte header on read and the handler parses its own framing
|
strips its 8-byte header on read and the handler parses its own framing
|
||||||
from the payload — is decided here; the *function surface* is not.
|
from the payload — is decided here; the *function surface* is not.
|
||||||
- **TTY's `wire.rs` adaptation:** TTY's `ChunkReader` currently reads from
|
- **TTY's `wire.rs` adaptation:** TTY's `ChunkReader` currently reads from
|
||||||
@@ -174,10 +174,10 @@ Three things changed since ADR-034/074/077 were accepted:
|
|||||||
stream_type constants, length validation, control message parsing — is
|
stream_type constants, length validation, control message parsing — is
|
||||||
unchanged). This is an implementation concern for the channels + TTY
|
unchanged). This is an implementation concern for the channels + TTY
|
||||||
integration, not an architecture decision.
|
integration, not an architecture decision.
|
||||||
- **Full channel-level flow-control windowing (OQ-56):** unchanged. The
|
- **Full channel-level flow-control windowing (alknet OQ-56, provenance; in-crate: OQ-23):** unchanged. The
|
||||||
bounded-buffer backpressure (ADR-040) is the v1 mechanism; full
|
bounded-buffer backpressure (ADR-040) is the v1 mechanism; full
|
||||||
windowing is an additive extension that doesn't change the wire
|
windowing is an additive extension that doesn't change the wire
|
||||||
format. OQ-56 stays deferred(scope).
|
format. alknet OQ-56 stays deferred(scope) in the source repo; this crate tracks it as OQ-23.
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ handler's framing, carried transparently.
|
|||||||
| field | offset | width | meaning |
|
| field | offset | width | meaning |
|
||||||
|-------|--------|-------|---------|
|
|-------|--------|-------|---------|
|
||||||
| `channel_id` | 0 | 4 (BE) | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alk/call` (ADR-036). Channels 1..N are opened dynamically via `channel/open` (ADR-037). |
|
| `channel_id` | 0 | 4 (BE) | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alk/call` (ADR-036). Channels 1..N are opened dynamically via `channel/open` (ADR-037). |
|
||||||
| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN` (16 MiB, matching TTY's cap — ADR-052 §5). |
|
| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN` (16 MiB, matching TTY's cap — alknet ADR-052 §5 (provenance)). |
|
||||||
|
|
||||||
The `stream_type` byte is **removed** from the channels header. The
|
The `stream_type` byte is **removed** from the channels header. The
|
||||||
channels layer has no `stream_type` concept — not in its header, not in
|
channels layer has no `stream_type` concept — not in its header, not in
|
||||||
@@ -206,7 +206,7 @@ tunnel's raw bytes, SSH's channel protocol).
|
|||||||
This amends ADR-034: the wire format is 8 bytes, not 9; the
|
This amends ADR-034: the wire format is 8 bytes, not 9; the
|
||||||
`stream_type` decomposition (mod 3, unidirectional halves, 85 groups) is
|
`stream_type` decomposition (mod 3, unidirectional halves, 85 groups) is
|
||||||
removed from the channels layer. The stream_type concept survives in
|
removed from the channels layer. The stream_type concept survives in
|
||||||
TTY's 5-byte format (ADR-052, amended by Phase 7), which the channels
|
TTY's 5-byte format (alknet ADR-052, provenance — amended by Phase 7), which the channels
|
||||||
layer carries transparently.
|
layer carries transparently.
|
||||||
|
|
||||||
### 2. `into_sub_streams()` is removed; `accept_bi` is the only accessor
|
### 2. `into_sub_streams()` is removed; `accept_bi` is the only accessor
|
||||||
@@ -430,7 +430,7 @@ inside-channels), re-introducing a separate inside-channels mode would be
|
|||||||
a rewrite of TTY's session driver. The trade is one unified session
|
a rewrite of TTY's session driver. The trade is one unified session
|
||||||
driver now vs. two-mode maintenance forever.
|
driver now vs. two-mode maintenance forever.
|
||||||
|
|
||||||
The add/strip API shape (OQ-68) is a **two-way door** — whether the
|
The add/strip API shape (alknet OQ-68, provenance; in-crate: OQ-24) is a **two-way door** — whether the
|
||||||
header add/strip is built into the read/write path or exposed as a
|
header add/strip is built into the read/write path or exposed as a
|
||||||
standalone utility is an implementation detail that can change without
|
standalone utility is an implementation detail that can change without
|
||||||
breaking the wire format or the handler contract.
|
breaking the wire format or the handler contract.
|
||||||
@@ -472,7 +472,7 @@ breaking the wire format or the handler contract.
|
|||||||
`open_channel` and `Channel`)
|
`open_channel` and `Channel`)
|
||||||
- ADR-044: sub-crate decomposition (unchanged — 8-byte wire format in
|
- ADR-044: sub-crate decomposition (unchanged — 8-byte wire format in
|
||||||
`channels-core`; call-protocol coupling in `channels-call`)
|
`channels-core`; call-protocol coupling in `channels-call`)
|
||||||
- ADR-052: alknet-tty wire format (the 5-byte format carried
|
- Source-repo provenance (not ported): alknet ADR-052 — alknet-tty wire format (the 5-byte format carried
|
||||||
transparently in the channels payload; the control channel split
|
transparently in the channels payload; the control channel split
|
||||||
from Phase 7 is TTY-internal)
|
from Phase 7 is TTY-internal)
|
||||||
- `docs/research/stream-unification/findings.md` — the research that
|
- `docs/research/stream-unification/findings.md` — the research that
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Every handler — TTY, tunnel, SSH, call — receives a `Connection`, calls
|
|||||||
`accept_bi()` once, gets a `BiStream`, and sub-multiplexes it however it
|
`accept_bi()` once, gets a `BiStream`, and sub-multiplexes it however it
|
||||||
wants. The "typed handler path" (this ADR's motivating case for TTY) is
|
wants. The "typed handler path" (this ADR's motivating case for TTY) is
|
||||||
replaced by TTY sub-demuxing its `BiStream` via its own 5-byte format
|
replaced by TTY sub-demuxing its `BiStream` via its own 5-byte format
|
||||||
(ADR-052) — the same code TTY runs in direct mode. The two-accessor
|
(alknet ADR-052, provenance) — the same code TTY runs in direct mode. The two-accessor
|
||||||
design (`accept_bi` for generic handlers, `into_sub_streams` for typed
|
design (`accept_bi` for generic handlers, `into_sub_streams` for typed
|
||||||
handlers) collapses to one accessor.
|
handlers) collapses to one accessor.
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ per `ChannelManager` (`buffer_cap` field). This prevents memory exhaustion
|
|||||||
without the complexity of SSH's sliding-window protocol.
|
without the complexity of SSH's sliding-window protocol.
|
||||||
|
|
||||||
Full channel-level windowing (SSH-style sliding-window per channel) is a
|
Full channel-level windowing (SSH-style sliding-window per channel) is a
|
||||||
deferred extension, tracked as [OQ-56](../questions/056-full-channel-level-flow-control-windowing.md)
|
deferred extension, tracked as [alknet OQ-56](/workspace/@alkdev/alknet/docs/architecture/questions/056-full-channel-level-flow-control-windowing.md)
|
||||||
(deferred(scope)). It is blocked on a real deployment observing head-of-
|
(deferred(scope)). It is blocked on a real deployment observing head-of-
|
||||||
line blocking where the bounded-buffer mitigation is insufficient. The
|
line blocking where the bounded-buffer mitigation is insufficient. The
|
||||||
bounded-buffer decision is made; the extension is not.
|
bounded-buffer decision is made; the extension is not.
|
||||||
@@ -201,7 +201,7 @@ DoS defense summary.
|
|||||||
- Bounded-buffer backpressure does not eliminate head-of-line blocking — it
|
- Bounded-buffer backpressure does not eliminate head-of-line blocking — it
|
||||||
bounds the memory cost. A slow consumer still stalls its own channel's
|
bounds the memory cost. A slow consumer still stalls its own channel's
|
||||||
demux reads. For the intended use cases (TTY, SSH, tunnels) this is
|
demux reads. For the intended use cases (TTY, SSH, tunnels) this is
|
||||||
acceptable; full windowing is tracked as OQ-56 (deferred(scope)).
|
acceptable; full windowing is tracked as alknet OQ-56 (source-repo space, provenance; in-crate: OQ-23) (deferred(scope)).
|
||||||
|
|
||||||
## Door type
|
## Door type
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ surface:
|
|||||||
primary constructor and the one-way-door API. Takes a pre-established
|
primary constructor and the one-way-door API. Takes a pre-established
|
||||||
`Connection` (produced by any transport — TCP+TLS via `from_bidi`,
|
`Connection` (produced by any transport — TCP+TLS via `from_bidi`,
|
||||||
WebTransport `BiStream`, SSH `direct-tcpip`, a quinn connection, a
|
WebTransport `BiStream`, SSH `direct-tcpip`, a quinn connection, a
|
||||||
WebSocket carrying `alk/channels` per ADR-044), installs channel 0,
|
WebSocket carrying `alk/channels` per alknet ADR-044 — the websocket-native-session ADR, provenance —), installs channel 0,
|
||||||
spawns the demux/mux, returns the client. Mirrors the server-side
|
spawns the demux/mux, returns the client. Mirrors the server-side
|
||||||
`ChannelsAdapter::handle(Connection)` (substrate-agnostic) and the
|
`ChannelsAdapter::handle(Connection)` (substrate-agnostic) and the
|
||||||
existing `CallClient::spawn_dispatch(Connection)` pattern.
|
existing `CallClient::spawn_dispatch(Connection)` pattern.
|
||||||
@@ -73,7 +73,7 @@ surface:
|
|||||||
|
|
||||||
The dial+TLS seam (the transport-specific work each dial helper does —
|
The dial+TLS seam (the transport-specific work each dial helper does —
|
||||||
verifier selection per ADR-034, handshake, produce a `Connection`) is the
|
verifier selection per ADR-034, handshake, produce a `Connection`) is the
|
||||||
correct scope of OQ-55's deferral. `AlknetClient` is the eventual shared
|
correct scope of alknet OQ-55's deferral. `AlknetClient` is the eventual shared
|
||||||
*dial*; `from_connection` is the shared *channels-take-over*. Separating
|
*dial*; `from_connection` is the shared *channels-take-over*. Separating
|
||||||
them now, before the one-way-door API is cast, is the point — not a
|
them now, before the one-way-door API is cast, is the point — not a
|
||||||
deferral. The "QUIC-only initially" framing is removed; it was the
|
deferral. The "QUIC-only initially" framing is removed; it was the
|
||||||
@@ -81,7 +81,7 @@ anti-pattern this amendment corrects.
|
|||||||
|
|
||||||
The door-type classification is unchanged: `from_connection` is one-way
|
The door-type classification is unchanged: `from_connection` is one-way
|
||||||
(the handler-facing surface), `connect_quic` is two-way (additive
|
(the handler-facing surface), `connect_quic` is two-way (additive
|
||||||
convenience). The `AlknetClient` extraction remains deferred (OQ-55) — but
|
convenience). The `AlknetClient` extraction remains deferred (alknet OQ-55) — but
|
||||||
what is deferred is the shared *dial*, not a QUIC-welded client API.
|
what is deferred is the shared *dial*, not a QUIC-welded client API.
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
@@ -106,14 +106,14 @@ The phase-0 research (`docs/research/alknet-channels/phase-0-findings.md`
|
|||||||
seam — the transport-specific work (open socket, TLS handshake, ADR-034
|
seam — the transport-specific work (open socket, TLS handshake, ADR-034
|
||||||
verifier selection, produce a `Connection`) that each transport's dial
|
verifier selection, produce a `Connection`) that each transport's dial
|
||||||
helper rebuilds. Genuinely deferred — blocked on a second *transport's*
|
helper rebuilds. Genuinely deferred — blocked on a second *transport's*
|
||||||
dial existing (OQ-55 tracks this correctly). A single QUIC dial
|
dial existing (alknet OQ-55 tracks this correctly). A single QUIC dial
|
||||||
(`connect_quic`) does not give enough information to extract the
|
(`connect_quic`) does not give enough information to extract the
|
||||||
transport-polymorphic dial seam; two different transport dials do.
|
transport-polymorphic dial seam; two different transport dials do.
|
||||||
|
|
||||||
This ADR decides #1 — `ChannelClient`, with `from_connection` as the
|
This ADR decides #1 — `ChannelClient`, with `from_connection` as the
|
||||||
transport-agnostic primary constructor and `connect_quic` as a transport-
|
transport-agnostic primary constructor and `connect_quic` as a transport-
|
||||||
specific dial helper. #2 (the shared `AlknetClient` dial+TLS seam) stays
|
specific dial helper. #2 (the shared `AlknetClient` dial+TLS seam) stays
|
||||||
deferred per OQ-55.
|
deferred per alknet OQ-55.
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ impl ChannelClient {
|
|||||||
/// Transport-agnostic primary constructor. Takes a pre-established
|
/// Transport-agnostic primary constructor. Takes a pre-established
|
||||||
/// `Connection` (any transport — TCP+TLS via `from_bidi`,
|
/// `Connection` (any transport — TCP+TLS via `from_bidi`,
|
||||||
/// WebTransport BiStream, SSH direct-tcpip, a quinn connection, a
|
/// WebTransport BiStream, SSH direct-tcpip, a quinn connection, a
|
||||||
/// WebSocket per ADR-044), installs channel 0 (alk/call), spawns
|
/// WebSocket per alknet ADR-044 — websocket-native session, provenance —), installs channel 0 (alk/call), spawns
|
||||||
/// the demux/mux, and returns the client. Mirrors the server-side
|
/// the demux/mux, and returns the client. Mirrors the server-side
|
||||||
/// `ChannelsAdapter::handle(Connection)`. This is the one-way-door
|
/// `ChannelsAdapter::handle(Connection)`. This is the one-way-door
|
||||||
/// API surface — it must not be coupled to a transport (ADR-034,
|
/// API surface — it must not be coupled to a transport (ADR-034,
|
||||||
@@ -223,10 +223,10 @@ sense — it's one endpoint of a bidirectional channels connection. The name
|
|||||||
`ChannelClient` follows the `CallClient` convention (the side that dialed),
|
`ChannelClient` follows the `CallClient` convention (the side that dialed),
|
||||||
not a request/response role.
|
not a request/response role.
|
||||||
|
|
||||||
### Relationship to `AlknetClient` (OQ-55 — deferred)
|
### Relationship to `AlknetClient` (alknet OQ-55 — deferred)
|
||||||
|
|
||||||
`ChannelClient`'s *API* is transport-agnostic — `from_connection` takes
|
`ChannelClient`'s *API* is transport-agnostic — `from_connection` takes
|
||||||
a pre-established `Connection`. What is deferred (OQ-55) is the shared
|
a pre-established `Connection`. What is deferred (alknet OQ-55) is the shared
|
||||||
*dial+TLS* seam (`AlknetClient`): the transport-specific work each dial
|
*dial+TLS* seam (`AlknetClient`): the transport-specific work each dial
|
||||||
helper does — open a socket, run the TLS handshake, apply ADR-034's
|
helper does — open a socket, run the TLS handshake, apply ADR-034's
|
||||||
verifier-selection rule, produce a `Connection`. That dial is genuinely
|
verifier-selection rule, produce a `Connection`. That dial is genuinely
|
||||||
@@ -252,14 +252,14 @@ duplicated capability and not a QUIC-welded client API.
|
|||||||
matching the call protocol's `CallAdapter`/`CallClient` shape.
|
matching the call protocol's `CallAdapter`/`CallClient` shape.
|
||||||
- Bidirectionality is preserved — the client can both initiate and receive
|
- Bidirectionality is preserved — the client can both initiate and receive
|
||||||
`channel/open`.
|
`channel/open`.
|
||||||
- The `AlknetClient` deferral (OQ-55) is not blocked by `ChannelClient` —
|
- The `AlknetClient` deferral (alknet OQ-55) is not blocked by `ChannelClient` —
|
||||||
they are independent concerns. `ChannelClient` builds standalone; the
|
they are independent concerns. `ChannelClient` builds standalone; the
|
||||||
core extraction happens later when the blocker clears.
|
core extraction happens later when the blocker clears.
|
||||||
|
|
||||||
**Negative:**
|
**Negative:**
|
||||||
- Each transport-specific dial helper duplicates ~20 lines of
|
- Each transport-specific dial helper duplicates ~20 lines of
|
||||||
verifier-selection boilerplate (from `connect_quic`). This is the known
|
verifier-selection boilerplate (from `connect_quic`). This is the known
|
||||||
cost of not extracting `AlknetClient` yet (OQ-55). Acceptable until the
|
cost of not extracting `AlknetClient` yet (alknet OQ-55). Acceptable until the
|
||||||
second transport's dial exists, at which point `AlknetClient` extracts
|
second transport's dial exists, at which point `AlknetClient` extracts
|
||||||
the shared dial+TLS seam. The `from_connection` API — the one-way-door
|
the shared dial+TLS seam. The `from_connection` API — the one-way-door
|
||||||
surface — is unaffected; only the dial helpers carry the duplication.
|
surface — is unaffected; only the dial helpers carry the duplication.
|
||||||
@@ -276,10 +276,10 @@ one-way-door primary constructor (transport-agnostic).
|
|||||||
`from_connection`. Adding, removing, or changing a dial helper is cheap
|
`from_connection`. Adding, removing, or changing a dial helper is cheap
|
||||||
and does not touch the one-way-door surface.
|
and does not touch the one-way-door surface.
|
||||||
|
|
||||||
The `AlknetClient` extraction is a **deferred decision** (OQ-55,
|
The `AlknetClient` extraction is a **deferred decision** (alknet OQ-55,
|
||||||
deferred(scope)), not a door-type attribute. Its door type is two-way (the
|
deferred(scope)), not a door-type attribute. Its door type is two-way (the
|
||||||
extraction is a refactor, not a wire-format change), but it is not decided
|
extraction is a refactor, not a wire-format change), but it is not decided
|
||||||
in this ADR — see OQ-55 for the blocking condition. What is deferred is the
|
in this ADR — see alknet OQ-55 for the blocking condition. What is deferred is the
|
||||||
shared *dial+TLS* seam; `from_connection`'s transport-agnostic contract is
|
shared *dial+TLS* seam; `from_connection`'s transport-agnostic contract is
|
||||||
decided now.
|
decided now.
|
||||||
|
|
||||||
@@ -292,7 +292,7 @@ decided now.
|
|||||||
removed from `open_channel` and `Channel`; handler owns sub-stream
|
removed from `open_channel` and `Channel`; handler owns sub-stream
|
||||||
multiplexing)
|
multiplexing)
|
||||||
- ADR-039: ChannelManager (the shared state `ChannelClient` holds)
|
- ADR-039: ChannelManager (the shared state `ChannelClient` holds)
|
||||||
- OQ-55: AlknetClient / client establishment extraction (the deferred core
|
- alknet OQ-55: AlknetClient / client establishment extraction (the deferred core
|
||||||
concern this ADR does NOT block on)
|
concern this ADR does NOT block on)
|
||||||
- `docs/research/alknet-channels/phase-0-findings.md` §OQ-CH-14 (the
|
- `docs/research/alknet-channels/phase-0-findings.md` §OQ-CH-14 (the
|
||||||
research-scope question this ADR carries forward)
|
research-scope question this ADR carries forward)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ alknet-channels-core — the pure multiplexer (wire format, demux/mux,
|
|||||||
|
|
||||||
There are no `channels-hub` or `channels-worker` sub-crates. The hub and
|
There are no `channels-hub` or `channels-worker` sub-crates. The hub and
|
||||||
worker are **consumers** of channels, not sub-crates of it. The existing
|
worker are **consumers** of channels, not sub-crates of it. The existing
|
||||||
`alknet-hub` crate (`docs/architecture/crates/hub/README.md`) IS the hub —
|
`alknet-hub` crate (source-repo path: docs/architecture/crates/hub/README.md — alknet, provenance) IS the hub —
|
||||||
it depends on `channels-call` and uses the channels protocol as its
|
it depends on `channels-call` and uses the channels protocol as its
|
||||||
substrate. A worker is just a worker — it depends on `channels-call` and
|
substrate. A worker is just a worker — it depends on `channels-call` and
|
||||||
uses `ChannelClient` (ADR-043) to dial. The hub relay logic (ADR-042) lives
|
uses `ChannelClient` (ADR-043) to dial. The hub relay logic (ADR-042) lives
|
||||||
@@ -168,11 +168,11 @@ The dependency direction is: `alknet-hub` → `channels-call` →
|
|||||||
|
|
||||||
### Relationship to `alknet-hub`
|
### Relationship to `alknet-hub`
|
||||||
|
|
||||||
The existing `alknet-hub` crate (`docs/architecture/crates/hub/README.md`)
|
The existing `alknet-hub` crate (source-repo path: docs/architecture/crates/hub/README.md — alknet, provenance)
|
||||||
is the hub pattern: peer lifecycle, aggregated env, service discovery. With
|
is the hub pattern: peer lifecycle, aggregated env, service discovery. With
|
||||||
channels as the substrate, `alknet-hub` gains a dependency on
|
channels as the substrate, `alknet-hub` gains a dependency on
|
||||||
`channels-call` and incorporates the relay logic (ADR-042). The hub spec
|
`channels-call` and incorporates the relay logic (ADR-042). The hub spec
|
||||||
(`crates/hub/README.md`) will be updated to reflect that the hub uses
|
(that source-repo doc) will be updated to reflect that the hub uses
|
||||||
channels as its transport substrate — one channels connection per leg
|
channels as its transport substrate — one channels connection per leg
|
||||||
(browser↔hub, hub↔spoke), with the relay translating `channel/open` and
|
(browser↔hub, hub↔spoke), with the relay translating `channel/open` and
|
||||||
byte-forwarding data channels. The hub's existing responsibilities (peer
|
byte-forwarding data channels. The hub's existing responsibilities (peer
|
||||||
@@ -247,5 +247,15 @@ the dependency direction (hub/worker → channels, not channels → hub/worker).
|
|||||||
- ADR-043: ChannelClient (in `channels-call`; there is no
|
- ADR-043: ChannelClient (in `channels-call`; there is no
|
||||||
`channels-worker` sub-crate — a worker is any crate that uses
|
`channels-worker` sub-crate — a worker is any crate that uses
|
||||||
`ChannelClient` to dial)
|
`ChannelClient` to dial)
|
||||||
- `docs/architecture/crates/hub/README.md` — the existing hub crate (the
|
- alknet hub crate (source-repo path docs/architecture/crates/hub/README.md, provenance) — the existing hub crate (the
|
||||||
relay's consumer)
|
relay's consumer)
|
||||||
|
## Amendment 2026-10-13: the split landed inside alkcall, not as separate crates
|
||||||
|
|
||||||
|
This ADR's decision text was authored when the channels protocol lived
|
||||||
|
in the mono-repo crate `alknet-channels` and framed the split as
|
||||||
|
sub-crates of it. The extraction (ADR-031, amendment 4) landed the
|
||||||
|
`channels-core` (pure multiplexer) / `channels-call` (call coupling:
|
||||||
|
channel 0 pre-negotiation, lifecycle ops, `ChannelCore`, `ChannelClient`)
|
||||||
|
decomposition as **modules inside alkcall** — downstream depends on
|
||||||
|
alkcall as a single crate. Hub and worker remain consumers (no
|
||||||
|
worker/hub sub-crate), as decided here.
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
# ADR-045: AlknetClient — the Native Client Dial Seam
|
# ADR-045: AlknetClient — the Native Client Dial Seam
|
||||||
|
|
||||||
|
> **Citation-space note (2026-10-13).** This ADR was authored in the
|
||||||
|
> alknet workspace before the extraction. Its `ADR-08x` references
|
||||||
|
> (endpoint types, `TlsClientConfig`, accept-loop runner, crypto
|
||||||
|
> provider) and its `OQ-55`/`OQ-58` references are **source-repo
|
||||||
|
> (alknet) space, not this crate's `decisions/`** — provenance; those
|
||||||
|
> ADRs were never ported. The verifier-selection rule formerly cited as
|
||||||
|
> "alknet ADR-034" is re-owned in-crate by ADR-054. This crate's own
|
||||||
|
> tracker/ADR numbers appear only where explicitly marked.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted (resolves OQ-55; §3 and §5 amended 2026-07-16 by ADR-012 —
|
Accepted (resolves alknet OQ-55 — source-repo space, provenance;
|
||||||
|
§3 and §5 amended 2026-07-16 by ADR-012 —
|
||||||
the dial credential bundle is `ConnectionCredentials` (transport-level),
|
the dial credential bundle is `ConnectionCredentials` (transport-level),
|
||||||
not `CallCredentials` (call-protocol-level); all three dial signatures
|
not `CallCredentials` (call-protocol-level); all three dial signatures
|
||||||
unify on `&ConnectionCredentials`; `dial_iroh`'s `node_id` parameter is
|
unify on `&ConnectionCredentials`; `dial_iroh`'s `node_id` parameter is
|
||||||
@@ -21,7 +31,7 @@ reader); `auth_token` is a per-request payload field; the `from_call`
|
|||||||
OQ-55 deferred `AlknetClient::dial()` — the transport-polymorphic client
|
OQ-55 deferred `AlknetClient::dial()` — the transport-polymorphic client
|
||||||
dial seam — blocked on "a second transport's real dial existing." The
|
dial seam — blocked on "a second transport's real dial existing." The
|
||||||
reasoning (recorded in the OQ-55 file and in
|
reasoning (recorded in the OQ-55 file and in
|
||||||
[channel-client.md](../crates/channels/channel-client.md) §"Relationship
|
[channel-client.md](../channel-client.md) §"Relationship
|
||||||
to `AlknetClient`") was sound at the time: extracting a QUIC-shaped
|
to `AlknetClient`") was sound at the time: extracting a QUIC-shaped
|
||||||
connector and naming it `AlknetClient` would bake QUIC in as *the*
|
connector and naming it `AlknetClient` would bake QUIC in as *the*
|
||||||
establishment shape — the same welding ADR-007 unwound on the server
|
establishment shape — the same welding ADR-007 unwound on the server
|
||||||
@@ -81,7 +91,7 @@ contributing to the confusion that made `AlknetClient` hard to spec:
|
|||||||
(client). *Server-side accept vs. client-side dial.* The endpoint
|
(client). *Server-side accept vs. client-side dial.* The endpoint
|
||||||
accepts connections and resolves identity from the incoming
|
accepts connections and resolves identity from the incoming
|
||||||
connection; the client dials and presents identity (client cert)
|
connection; the client dials and presents identity (client cert)
|
||||||
while verifying the remote (ADR-034).
|
while verifying the remote (alknet ADR-034, provenance).
|
||||||
3. **ALPN-level category** — endpoint ALPN / entry-point ALPN
|
3. **ALPN-level category** — endpoint ALPN / entry-point ALPN
|
||||||
(ADR-086 §2). *Identity-gated vs. bootstrap, at the TLS layer.*
|
(ADR-086 §2). *Identity-gated vs. bootstrap, at the TLS layer.*
|
||||||
|
|
||||||
@@ -143,7 +153,7 @@ The symmetry:
|
|||||||
|---------|---------------------------|-------------------------|
|
|---------|---------------------------|-------------------------|
|
||||||
| Transports | `with_quinn` / `with_iroh` / `with_tcp_tls` — pre-built by the assembly layer | `with_quinn` / `with_iroh` / `with_tcp_tls` — pre-built by the assembly layer |
|
| Transports | `with_quinn` / `with_iroh` / `with_tcp_tls` — pre-built by the assembly layer | `with_quinn` / `with_iroh` / `with_tcp_tls` — pre-built by the assembly layer |
|
||||||
| Per-connection work | Accept → extract ALPN + fingerprint → `Connection` → `dispatch` | Dial → TLS handshake → `Connection` (ALPN + fingerprint carried) |
|
| Per-connection work | Accept → extract ALPN + fingerprint → `Connection` → `dispatch` | Dial → TLS handshake → `Connection` (ALPN + fingerprint carried) |
|
||||||
| Identity | Resolved *from* the incoming connection (fingerprint from client cert, or token on channel 0) | *Presented* (local `TlsIdentity` as client cert) + remote *verified* (ADR-034 — fingerprint pin or CA) |
|
| Identity | Resolved *from* the incoming connection (fingerprint from client cert, or token on channel 0) | *Presented* (local `TlsIdentity` as client cert) + remote *verified* (alknet ADR-034, provenance — fingerprint pin or CA) |
|
||||||
| What it does NOT do | Run protocols — handlers do | Run protocols — `CallClient` / `ChannelClient` do |
|
| What it does NOT do | Run protocols — handlers do | Run protocols — `CallClient` / `ChannelClient` do |
|
||||||
| Config | `TlsServerConfig` (per endpoint type, built by assembly) | `TlsClientConfig` (per-dial, built from `CallCredentials`) |
|
| Config | `TlsServerConfig` (per endpoint type, built by assembly) | `TlsClientConfig` (per-dial, built from `CallCredentials`) |
|
||||||
|
|
||||||
@@ -168,7 +178,7 @@ pub struct AlknetClient {
|
|||||||
|
|
||||||
impl AlknetClient {
|
impl AlknetClient {
|
||||||
/// QUIC dial. Builds a `TlsClientConfig` from `credentials`
|
/// QUIC dial. Builds a `TlsClientConfig` from `credentials`
|
||||||
/// (ADR-034 verifier selection + ADR-084 provider), dials `addr`
|
/// (alknet ADR-034 verifier selection, provenance + alknet ADR-084 provider), dials `addr`
|
||||||
/// on `alpn`, returns a `Connection` via
|
/// on `alpn`, returns a `Connection` via
|
||||||
/// `Connection::from_quinn_with_alpn`. Feature-gated on `quinn`.
|
/// `Connection::from_quinn_with_alpn`. Feature-gated on `quinn`.
|
||||||
#[cfg(feature = "quinn")]
|
#[cfg(feature = "quinn")]
|
||||||
@@ -215,7 +225,7 @@ identity + auth token bundle, from `Capabilities`). The two rustls dials
|
|||||||
(QUIC, TCP+TLS) build a `TlsClientConfig` from the credentials; the
|
(QUIC, TCP+TLS) build a `TlsClientConfig` from the credentials; the
|
||||||
iroh dial uses the raw `Ed25519SecretKey` directly. This mirrors the
|
iroh dial uses the raw `Ed25519SecretKey` directly. This mirrors the
|
||||||
server side's "iroh shares the key, not the config" (ADR-082, ADR-087
|
server side's "iroh shares the key, not the config" (ADR-082, ADR-087
|
||||||
§3) — the consistency is in the rule (ADR-034 verifier selection), not
|
§3) — the consistency is in the rule (the verifier-selection rule — alknet ADR-034, provenance; re-owned in-crate as ADR-054), not
|
||||||
in the type.
|
in the type.
|
||||||
|
|
||||||
> **Amendment 2026-07-16 (ADR-012):** The dial credential bundle is
|
> **Amendment 2026-07-16 (ADR-012):** The dial credential bundle is
|
||||||
@@ -238,7 +248,7 @@ in the type.
|
|||||||
> is derived from `creds.remote_identity.fingerprint`
|
> is derived from `creds.remote_identity.fingerprint`
|
||||||
> (`ed25519:<hex>` → `NodeId::from_bytes`), the same extraction pattern
|
> (`ed25519:<hex>` → `NodeId::from_bytes`), the same extraction pattern
|
||||||
> the rustls dials use for the verifier. The consistency is now in both
|
> the rustls dials use for the verifier. The consistency is now in both
|
||||||
> the rule (ADR-034) and the type. `CallCredentials` stays in
|
> the rule (alknet ADR-034, provenance) and the type. `CallCredentials` stays in
|
||||||
> `alknet-call` as the call-protocol credential bundle; the
|
> `alknet-call` as the call-protocol credential bundle; the
|
||||||
> `auth_token` is a per-request field on `call.requested` payloads
|
> `auth_token` is a per-request field on `call.requested` payloads
|
||||||
> (set by the caller or the `from_call` forwarding handler, resolved by
|
> (set by the caller or the `from_call` forwarding handler, resolved by
|
||||||
@@ -492,7 +502,7 @@ several possible native clients sharing the same wire protocols.
|
|||||||
graph.
|
graph.
|
||||||
- **The iroh dial is the exception.** It does not use
|
- **The iroh dial is the exception.** It does not use
|
||||||
`TlsClientConfig` — iroh has its own TLS. The dial helper applies
|
`TlsClientConfig` — iroh has its own TLS. The dial helper applies
|
||||||
the same ADR-034 rule via iroh's API (NodeId match). The
|
the same verifier rule (alknet ADR-034, provenance) via iroh's API (NodeId match). The
|
||||||
consistency is in the rule, not in the type. This is the same
|
consistency is in the rule, not in the type. This is the same
|
||||||
exception as the server side (ADR-082, ADR-087 §3) — unavoidable,
|
exception as the server side (ADR-082, ADR-087 §3) — unavoidable,
|
||||||
and isolated to one dial method.
|
and isolated to one dial method.
|
||||||
@@ -528,24 +538,24 @@ dedicated ADR lands.
|
|||||||
|
|
||||||
- OQ-55 (resolved by this ADR) — `AlknetClient` / client establishment
|
- OQ-55 (resolved by this ADR) — `AlknetClient` / client establishment
|
||||||
extraction
|
extraction
|
||||||
- [ADR-083](083-endpoint-as-accept-loop-runner.md) — `AlknetEndpoint`
|
- [ADR-083](/workspace/@alkdev/alknet/docs/architecture/decisions/083-endpoint-as-accept-loop-runner.md) — `AlknetEndpoint`
|
||||||
as multi-transport accept-loop runner; the server-side shape this
|
as multi-transport accept-loop runner; the server-side shape this
|
||||||
ADR mirrors on the client side
|
ADR mirrors on the client side
|
||||||
- [ADR-086](086-endpoint-types-and-entry-points.md) — endpoint types
|
- [ADR-086](/workspace/@alkdev/alknet/docs/architecture/decisions/086-endpoint-types-and-entry-points.md) — endpoint types
|
||||||
(native has QUIC + TCP+TLS + iroh); entry-point vs. endpoint ALPN
|
(native has QUIC + TCP+TLS + iroh); entry-point vs. endpoint ALPN
|
||||||
distinction (§2)
|
distinction (§2)
|
||||||
- [ADR-087](087-tlsclientconfig-not-blocked-on-dial.md) —
|
- [ADR-087](/workspace/@alkdev/alknet/docs/architecture/decisions/087-tlsclientconfig-not-blocked-on-dial.md) —
|
||||||
`TlsClientConfig` not blocked on the dial seam; breaks the circular
|
`TlsClientConfig` not blocked on the dial seam; breaks the circular
|
||||||
hedge; the TLS config is a prerequisite for the dial
|
hedge; the TLS config is a prerequisite for the dial
|
||||||
- [ADR-082](082-alknet-tls-extraction.md) — `TlsServerConfig` /
|
- [ADR-082](/workspace/@alkdev/alknet/docs/architecture/decisions/082-alknet-tls-extraction.md) — `TlsServerConfig` /
|
||||||
`TlsClientConfig` in `alknet-tls`; "iroh shares the key, not the
|
`TlsClientConfig` in `alknet-tls`; "iroh shares the key, not the
|
||||||
config"
|
config"
|
||||||
- [ADR-007](007-connection-from-stream-generic-single-stream.md) —
|
- [ADR-007](007-connection-from-stream-generic-single-stream.md) —
|
||||||
`Connection::from_stream` / `from_bidi`; the server-side
|
`Connection::from_stream` / `from_bidi`; the server-side
|
||||||
generalization whose client-side analogue this ADR completes
|
generalization whose client-side analogue this ADR completes
|
||||||
- [ADR-034](034-outgoing-only-x509-and-three-peer-roles.md) —
|
- [ADR-034](/workspace/@alkdev/alknet/docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md) —
|
||||||
client-side verifier selection (fingerprint pin vs CA vs fail-closed)
|
client-side verifier selection (fingerprint pin vs CA vs fail-closed)
|
||||||
- [ADR-084](084-aws-lc-rs-crypto-provider.md) — aws-lc-rs crypto
|
- [ADR-084](/workspace/@alkdev/alknet/docs/architecture/decisions/084-aws-lc-rs-crypto-provider.md) — aws-lc-rs crypto
|
||||||
provider on all paths
|
provider on all paths
|
||||||
- [ADR-043](043-channelclient.md) — `ChannelClient::from_connection`
|
- [ADR-043](043-channelclient.md) — `ChannelClient::from_connection`
|
||||||
(the take-over `AlknetClient` feeds)
|
(the take-over `AlknetClient` feeds)
|
||||||
@@ -554,4 +564,23 @@ dedicated ADR lands.
|
|||||||
- OQ-58 — worker registration flow (the HTTP path; `alk/register`
|
- OQ-58 — worker registration flow (the HTTP path; `alk/register`
|
||||||
is the native analogue)
|
is the native analogue)
|
||||||
- `docs/architecture/crates/channels/channel-client.md` §"Relationship
|
- `docs/architecture/crates/channels/channel-client.md` §"Relationship
|
||||||
to `AlknetClient`" — the deferral this ADR resolves
|
to `AlknetClient`" — the deferral this ADR resolves
|
||||||
|
|
||||||
|
## Amendment 2026-10-13: name normalization (`alknet-client` → the composition layer's dial helper)
|
||||||
|
|
||||||
|
This ADR's decision text was authored when the dial helper lived in the
|
||||||
|
mono-repo crate `alknet-client`. The decision itself is name-independent
|
||||||
|
and unchanged: **the dial lives in the consumer/composition layer; the
|
||||||
|
protocol crates take over pre-established `Connection`s (`spawn_dispatch`
|
||||||
|
/ `from_connection`).** Current naming:
|
||||||
|
|
||||||
|
- `AlknetClient` remains the dial helper's type name, but its host is
|
||||||
|
the **composition layer** (today the alknet glue, which is being
|
||||||
|
replaced). The post-replacement owner and shape of the composition
|
||||||
|
layer — including this dial seam — is tracked as OQ-43.
|
||||||
|
- `ConnectionCredentials` / `RemoteIdentity` (ADR-012) live with the
|
||||||
|
vendored core types in alkcall.
|
||||||
|
- The client-side verifier-selection rule this ADR's dial applies
|
||||||
|
(fingerprint pin vs CA vs fail-closed) is re-owned in-crate by
|
||||||
|
ADR-054; the alknet ADR-034 and ADR-084 references below are
|
||||||
|
provenance.
|
||||||
@@ -49,6 +49,13 @@ primitive is the load-bearing call-protocol piece; the broker composes
|
|||||||
on top of it later, in channels, the same way the hub relay composes on
|
on top of it later, in channels, the same way the hub relay composes on
|
||||||
top of existing call ops.
|
top of existing call ops.
|
||||||
|
|
||||||
|
> **Status note (post-deferral)**: the channels work this deferral
|
||||||
|
> pointed at has since landed, and the placement is now decided — the
|
||||||
|
> broker is out of scope for alkcall (ADR-047 §"What this ADR decides",
|
||||||
|
> Gap B) and tracked downstream as OQ-42 (`deferred(scope)`, blocked on
|
||||||
|
> a concrete producing-crate consumer). Read "deferred to the channels
|
||||||
|
> session" below as this placement decision.
|
||||||
|
|
||||||
### Gap A from the research findings: `Pub` handler shape
|
### Gap A from the research findings: `Pub` handler shape
|
||||||
|
|
||||||
The research findings' Gap A identified that the current
|
The research findings' Gap A identified that the current
|
||||||
@@ -441,6 +448,13 @@ one-way addition (the field is part of the spec's public API); its
|
|||||||
|
|
||||||
## Fan-out (deferred — OQ-22 §fan-out scope)
|
## Fan-out (deferred — OQ-22 §fan-out scope)
|
||||||
|
|
||||||
|
> **Status note (post-deferral)**: see OQ-22/OQ-42 in
|
||||||
|
> [open-questions.md](../open-questions.md) — the placement is decided
|
||||||
|
> (out of scope for alkcall, ADR-047 Gap B); the broker design and
|
||||||
|
> build are downstream (OQ-42, deferred(scope)). "The channels session
|
||||||
|
> will add the broker" below predates that placement; read it as "the
|
||||||
|
> downstream hub composes the broker."
|
||||||
|
|
||||||
The fan-out / broker mechanism (one producer, N consumers, topic
|
The fan-out / broker mechanism (one producer, N consumers, topic
|
||||||
matching by `(op_name, params_hash)`) is **not** part of this ADR. It
|
matching by `(op_name, params_hash)`) is **not** part of this ADR. It
|
||||||
is deferred to the channels session for these reasons:
|
is deferred to the channels session for these reasons:
|
||||||
|
|||||||
@@ -14,7 +14,265 @@ per-op `description` on the listing, the dynamic half stays deferred
|
|||||||
— see "Amendment (§6 listing enrichment, 2026-09-06)" below;
|
— see "Amendment (§6 listing enrichment, 2026-09-06)" below;
|
||||||
amendment 3 (2026-09-16, review 008 U-1) — the op-name convention
|
amendment 3 (2026-09-16, review 008 U-1) — the op-name convention
|
||||||
extends to the flavor form and discovery carries an explicit
|
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)
|
## 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
|
static half. Decision: **both halves of the "static per-op" split gain
|
||||||
what is cheap today; the dynamic half stays deferred** (OQ-40, now
|
what is cheap today; the dynamic half stays deferred** (OQ-40, now
|
||||||
with a "load-bearing for alktunnels discovery UI" note in
|
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-
|
- `OperationSpec` gains `description: Option<String>` — a human-
|
||||||
readable op description, set via `with_description` at registration.
|
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
|
EOF chunk → spoke handler exits → spoke's own wrapper decrements its
|
||||||
ledger. Close propagates through the EOF cascade with correct ledger
|
ledger. Close propagates through the EOF cascade with correct ledger
|
||||||
accounting on both legs; the hub never sees a `channel/close` to
|
accounting on both legs; the hub never sees a `channel/close` to
|
||||||
translate. Control stays per-leg (`channel/control` is not implemented
|
translate. Control stays per-leg (ADR-052): the control surface is
|
||||||
upstream, OQ-39 — a hub wanting cross-leg control translation composes
|
establisher-contributed (`Establishment::with_control`) on each leg's
|
||||||
it downstream). This supersedes the ADR-042 §mapping bullet'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
|
close-translation sentence as an implementation matter: the mapping
|
||||||
entry the sentence's removal depended on does not exist.
|
entry the sentence's removal depended on does not exist.
|
||||||
|
|
||||||
@@ -205,11 +208,13 @@ same pin.
|
|||||||
|
|
||||||
### 6. Bounds: rejected shapes and residual notes
|
### 6. Bounds: rejected shapes and residual notes
|
||||||
|
|
||||||
- **Pub-typed open ops** — rejected loudly (`channel:pub_open_not_implemented`,
|
- **Pub-typed open ops** — ~~rejected loudly~~ **translated** (ADR-047
|
||||||
the C-08 blocker). The relay registers only `Query`/`Mutation`-typed
|
amendment 4 §4.7 — the put-forwarding translation: the hub allocates
|
||||||
ops (plain forwarding) and `Sub`-typed marked ops (relay openables);
|
its connect-side id, opens the spoke op in the background,
|
||||||
a discovered `Pub`-typed marked spec is an assembly error, not a
|
pumps the consumer bytes → spoke, and mirrors the spoke's single
|
||||||
silent stub.
|
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
|
- **Compounding establishment bounds** — each hop's establisher is
|
||||||
bounded independently (ADR-049 §2); an N-hop chain adds N bounds.
|
bounded independently (ADR-049 §2); an N-hop chain adds N bounds.
|
||||||
The consumer's dispatch deadline (`min(deadline, 10s)` per hop)
|
The consumer's dispatch deadline (`min(deadline, 10s)` per hop)
|
||||||
@@ -263,7 +268,6 @@ same pin.
|
|||||||
`ChannelRelay` instead of re-deriving the hub shape.
|
`ChannelRelay` instead of re-deriving the hub shape.
|
||||||
- U-1's gate 2 (the review's silent-failure scenario — flavor-form ops
|
- U-1's gate 2 (the review's silent-failure scenario — flavor-form ops
|
||||||
relaying as plain forwarding stubs) becomes a permanent e2e gate.
|
relaying as plain forwarding stubs) becomes a permanent e2e gate.
|
||||||
|
|
||||||
**Negative:**
|
**Negative:**
|
||||||
|
|
||||||
- New pub API (`ChannelRelay`, the registration entry point, the
|
- 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
|
status: current
|
||||||
last_updated: 2026-08-12
|
last_updated: 2026-10-13 (composition-layer re-key)
|
||||||
---
|
---
|
||||||
|
|
||||||
# Open Questions
|
# Open Questions
|
||||||
@@ -35,13 +35,14 @@ status, priority, and (when resolved) a resolution citing the ADR.
|
|||||||
| OQ-18 | PeerId — crypto identity vs stable logical id | resolved | high | ADR-025 — PeerId = Identity.id (stable) |
|
| OQ-18 | PeerId — crypto identity vs stable logical id | resolved | high | ADR-025 — PeerId = Identity.id (stable) |
|
||||||
| OQ-19 | Persistent peer registry | resolved | medium | ADR-025 — core trait + in-memory default; persistence adapters separate |
|
| OQ-19 | Persistent peer registry | resolved | medium | ADR-025 — core trait + in-memory default; persistence adapters separate |
|
||||||
| OQ-20 | ~~API key asymmetry~~ | dissolved | medium | PeerEntry supports multiple credential paths |
|
| OQ-20 | ~~API key asymmetry~~ | dissolved | medium | PeerEntry supports multiple credential paths |
|
||||||
| OQ-21 | X.509 outgoing-only case | resolved | medium | Three remote roles; PeerEntry asymmetry correct |
|
| OQ-21 | X.509 outgoing-only case | resolved | medium | Three remote roles; PeerEntry asymmetry correct. Re-owned in-crate by ADR-054 — the verifier-selection rule that binds alkcall's surface (`RemoteIdentity: None` semantics, `ServerCertVerifier` by `PeerEntry` presence) is decided here, not by the un-ported source ADR (which is demoted to provenance). |
|
||||||
|
|
||||||
## Call Protocol — Pub/Sub
|
## Call Protocol — Pub/Sub
|
||||||
|
|
||||||
| OQ | Title | Status | Priority | Resolution |
|
| OQ | Title | Status | Priority | Resolution |
|
||||||
|----|-------|--------|----------|------------|
|
|----|-------|--------|----------|------------|
|
||||||
| OQ-22 | Call protocol pub/sub primitive — pub to go with sub | partially resolved | high | ADR-046 resolves the primitive: `OperationType::Pub` + `HandlerKind::Sink` + `call.published` wire event + `invoke_sink()` dispatch path. The fan-out/broker mechanism (one producer, N consumers, topic matching) is deferred to the channels session — the call protocol is point-to-point; the broker is a routing concern that sits above it. ADR-047 §1 names the broker (Gap B) as out-of-scope for alkcall; the hub composes it on top of the `Pub`/`Sub` primitives. See §"Pub/Sub Gap" below. |
|
| OQ-22 | Call protocol pub/sub primitive — pub to go with sub | resolved | high | The question had two halves, both now decided. (1) The primitive: ADR-046 delivers `OperationType::Pub` + `HandlerKind::Sink` + `call.published` wire event + `invoke_sink()` dispatch + `CallConnection::publish()`. (2) Placement of the fan-out/broker: **out of scope for alkcall** — the call protocol is point-to-point; a topic registry that outlives individual calls is a routing concern that sits above it. The hub composes the broker on top of the `Pub`/`Sub` primitives (ADR-047 §"What this ADR decides" Gap B). The broker design itself is tracked as OQ-42. See §"Pub/Sub Gap" below. |
|
||||||
|
| OQ-42 | Hub message broker (topic registry, `Pub`↔`Sub` matching, N-consumer fan-out) | deferred(scope) | medium | Deferred downstream: the broker is a hub composition layering on alkcall's `Pub`/`Sub` primitives, not an alkcall deliverable (OQ-22 placement resolution, ADR-047 Gap B). **Blocked on**: a concrete producing crate committing to fan-out — a spec that requires one produced topic with N independent spoke subscribers (plausible first candidate: alktrader market-data distribution; ADR-046 §fan-out records the consumer-blind rationale). Not circular — the primitives it composes on are delivered and load-bearing (ADR-046), and no alkcall API surface advertises or depends on the broker. The hub itself lives in the composition layer (owner — OQ-43). |
|
||||||
|
|
||||||
### Pub/Sub Gap
|
### Pub/Sub Gap
|
||||||
|
|
||||||
@@ -59,32 +60,44 @@ type. `call.published` is the wire event carrying stream chunks.
|
|||||||
the client method. The `Subscription` variant is renamed to `Sub`
|
the client method. The `Subscription` variant is renamed to `Sub`
|
||||||
(wire string `"sub"`).
|
(wire string `"sub"`).
|
||||||
|
|
||||||
**Fan-out deferred.** The broker (topic registry, `Pub`↔`Sub` matching,
|
**Placement decided: the broker is not an alkcall deliverable.** The
|
||||||
N-consumer fan-out) is deferred to the channels session. The call
|
fan-out/broker mechanism (topic registry, `Pub`↔`Sub` matching,
|
||||||
protocol is point-to-point (one initiator, one responder, a stream
|
N-consumer fan-out) sits above the call protocol: the call protocol is
|
||||||
between them); a topic registry that outlives individual calls is a
|
point-to-point (one initiator, one responder, a stream between them);
|
||||||
different lifecycle and a different concern. The broker's first
|
a topic registry that outlives individual calls is a different
|
||||||
consumer is the channels `channel/resources/subscribe` operation
|
lifecycle and a different concern. The broker is a hub composition on
|
||||||
(ADR-037) and the hub-as-broker pattern (ADR-042). The `Pub` primitive
|
top of the `Pub`/`Sub` primitives — ADR-047 §"What this ADR decides"
|
||||||
is the load-bearing piece the broker composes on.
|
(Gap B) places it out of scope for alkcall. The `Pub` primitive is the
|
||||||
|
load-bearing piece the broker composes on (delivered, ADR-046); ADR-053
|
||||||
|
served the broker's originally-named first consumer
|
||||||
|
(`channel/resources/subscribe`) with enumerators instead.
|
||||||
|
|
||||||
|
**The broker design is downstream work (OQ-42, deferred(scope)).** It
|
||||||
|
unblocks when a concrete producing crate commits to fan-out. The
|
||||||
|
original deferral's rationale (ADR-046 §fan-out — designing the broker
|
||||||
|
without its first real consumer produces a half-design) still holds:
|
||||||
|
the honest state is a tracked, concretely-blocked deferral, not a
|
||||||
|
pending promise. The "channels session" phrasing in ADR-046 §fan-out
|
||||||
|
should be read as this placement decision (the channels work has since
|
||||||
|
landed; what remains unbuilt is the downstream hub broker).
|
||||||
|
|
||||||
## Channels
|
## Channels
|
||||||
|
|
||||||
| OQ | Title | Status | Priority | Resolution |
|
| OQ | Title | Status | Priority | Resolution |
|
||||||
|----|-------|--------|----------|------------|
|
|----|-------|--------|----------|------------|
|
||||||
| OQ-23 | Full channel-level flow-control windowing | deferred(scope) | low | Bounded-buffer decided (ADR-040); full windowing blocked on HOL-blocking deployment observation |
|
| OQ-23 | Full channel-level flow-control windowing | deferred(scope) | low | Bounded-buffer decided (ADR-040); full windowing blocked on HOL-blocking deployment observation |
|
||||||
| OQ-24 | Channels add/strip API shape | open | low | Whether the 8-byte header add/strip is built into the read/write path or a standalone utility. The contract (ADR-035) is decided; the function surface is not |
|
| OQ-24 | Channels add/strip API shape | resolved | low | Resolved by the shipped surface: the strip is **integrated into the read/write path** — the demux loop consumes `read_header`/`write_chunk` so a handler's `BiStream` yields payload chunks and never sees the 8-byte header (ADR-035; matches ADR-037's payload-parse contract). The low-level primitives (`parse_header`/`write_header`/`read_header`/`write_chunk`/`write_eof` in `channels::wire`) remain exposed as the standalone utility for tooling/codegen, but no handler-facing add/strip wrapper exists or is needed. |
|
||||||
| OQ-31 | `channel/open` ACL granularity | resolved | high | ADR-047 — `channel/open` dissolves into per-ALPN ops; each op has its own `access_control` |
|
| OQ-31 | `channel/open` ACL granularity | resolved | high | ADR-047 — `channel/open` dissolves into per-ALPN ops; each op has its own `access_control` |
|
||||||
| OQ-32 | Quota lifecycle (opener vs closer, transport drop) | resolved | high | ADR-047 §7 — the per-connection opener ledger; decrement on every teardown path, keyed by opener |
|
| OQ-32 | Quota lifecycle (opener vs closer, transport drop) | resolved | high | ADR-047 §7 — the per-connection opener ledger; decrement on every teardown path, keyed by opener |
|
||||||
| OQ-33 | Per-identity connection cap (endpoint layer) | deferred(scope) | low | Named as a separate layer (ADR-047 §"ALPN category reframe" references the findings); belongs at `alknet-endpoint`, not channels. Named to stop the re-tangle |
|
| OQ-33 | Per-identity connection cap (endpoint layer) | deferred(scope) | low | Named as a separate layer (ADR-047 §"ALPN category reframe" references the findings); belongs at the endpoint/connect-accept layer of the composition stack, not channels. Named to stop the re-tangle. The endpoint layer's home is the composition layer (owner — OQ-43; today it lives in the alknet glue). |
|
||||||
| OQ-34 | `channel_open` marker wire format | resolved | medium | ADR-047 §2 — boolean `"channel_open": true` in `services/schema`; ALPN derivable from op name |
|
| OQ-34 | `channel_open` marker wire format | resolved | medium | ADR-047 §2 — boolean `"channel_open": true` in `services/schema`; ALPN derivable from op name |
|
||||||
| OQ-35 | `OperationEnv::channel_manager()` coupling | resolved | high | ADR-047 §4 — extension trait `ChannelOperationEnv` in `channels-call`; call crate stays free of channels types |
|
| OQ-35 | `OperationEnv::channel_manager()` coupling | resolved | high | ADR-047 §4 — extension trait `ChannelOperationEnv` in `channels-call`; call crate stays free of channels types |
|
||||||
| OQ-36 | `channel_id` allocation in Pub case | resolved | medium | ADR-047 §5 — "connection owner allocates" (the side that holds the `ChannelManager`); amends "responder allocates" |
|
| OQ-36 | `channel_id` allocation in Pub case | resolved | medium | ADR-047 §5 — "connection owner allocates" (the side that holds the `ChannelManager`); amends "responder allocates" |
|
||||||
| OQ-37 | `from_call` relay wrapper for marked ops | open | medium | ADR-047 §1 names it as a consumer (hub) concern; alkcall's `from_call` reconstructs the marker (Gap F resolved) so the consumer can branch on it |
|
| OQ-37 | `from_call` relay wrapper for marked ops | resolved | medium | ADR-022 amendment 2026-10-13 — there is no `from_call` relay wrapper, and there won't be one: marked (`channel_open`) announcements are refused with `MARKED_SPEC_UNSUPPORTED`, and `register_imported`/`register_imported_all` refuse marked specs (ADR-047 amendment 4 §4.8) — a marked op's data plane (the allocated channel's `BiStream`) has no overlay path. The sanctioned compositions are named in the refusal: `ChannelRelay::register_relay_openable` (ADR-051) for a relaying hub, `ChannelClient` (ADR-043) for direct consumption. `from_call`'s discovery still reconstructs the marker (ADR-047 Gap F) for reporting, but importing a marked spec is refused at the overlay landing. |
|
||||||
| OQ-38 | ALPN→path-segment mapping | resolved | low | ADR-047 §"Negative" — strip the `alk/` prefix; ALPNs without that prefix use the full ALPN string (rare, two-way-door) |
|
| OQ-38 | ALPN→path-segment mapping | resolved | low | ADR-047 §"Negative" — strip the `alk/` prefix; ALPNs without that prefix use the full ALPN string (rare, two-way-door) |
|
||||||
| OQ-39 | `channel/control` control-handle surface | open | medium | The `channel/control` handler currently returns `channel:control_not_implemented`. The control-handle surface (per-channel control callbacks registered by ALPN crates, routing `message` to the handler's control handle for `channel_id`) is real design work — each ALPN crate needs a way to register a control callback, and the channels layer needs a control-handle registry keyed by `channel_id`. Deferred until an ALPN crate (TTY, tunnel) needs out-of-band control. |
|
| OQ-39 | `channel/control` control-handle surface | resolved | medium | ADR-052 — the control-handle surface is implemented: per-channel control callbacks contributed by the ALPN establisher (`Establishment::with_control`), wrapper-installed and teardown-removed in the manager; the op routes the ALPN-specific `message` and answers `channel:no_control_handle` when the channel's ALPN registered none. The stub (`channel:control_not_implemented`) was removed with the single-publish fix (reviews 010 S-02 / 011). |
|
||||||
| OQ-40 | `channel/resources/subscribe` live subscription | open | medium | The `channel/resources/subscribe` handler currently returns `channel:resources_not_implemented`. The live subscription aggregated from ALPN-crate resource enumerators (ADR-047 §6) requires each ALPN crate to provide a resource enumerator, and the channels layer to aggregate them into a live `Stream` that emits on any change. The current stub is a one-shot error; the real implementation is deferred until a consumer (hub, dashboard) needs live resource discovery. **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-40 | `channel/resources/subscribe` live subscription | resolved | medium | ADR-053 — the live subscription is implemented: one `ResourceEnumerator` per openable ALPN (provided by the producing crate, snapshot-then-changes), aggregated by the handler over the scan-once snapshot; an empty registry emits one `{ "resources": [] }` seed and stays open. The one-shot error stub was removed with the single-publish fix (reviews 010 S-03 / 011). The alktunnels discovery-UI note (review 006 E-02) is served by the enumerator surface. |
|
||||||
| OQ-41 | QUIC-native multi-stream substrate | open | medium | Only the in-line substrate mode is implemented (single bidi stream, header-demuxed N channels). The QUIC-native multi-stream substrate (accept remaining bidi streams, read headers off each — ADR-034 §substrate modes) is deferred to the downstream alknet crate. The wire format and demux loop are correct for both substrates; only the outer `accept_bi()` loop is missing. The alknet crate owns the QUIC dial/accept loop and is the natural place for the multi-stream accept loop. This crate stays transport-agnostic (no QUIC dependency, WASM-compatible). |
|
| OQ-41 | QUIC-native multi-stream substrate | deferred(scope) | medium | **Deferred downstream** — the substrate lives in the dial/accept layer, which alkcall does not own. Only the in-line substrate mode is implemented (single bidi stream, header-demuxed N channels); the QUIC-native multi-stream mode (accept remaining bidi streams, read headers off each — ADR-034 §substrate modes) belongs in the composition layer's QUIC accept loop. **Blocked on**: the composition layer's multi-stream substrate integration (owner — OQ-43; today the alknet glue, being replaced). **Impacts**: none in-crate — the wire format and demux loop are correct for both substrates; alkcall stays transport-agnostic (no QUIC dependency, WASM-compatible). |
|
||||||
|
|
||||||
## Core Types
|
## Core Types
|
||||||
|
|
||||||
@@ -95,4 +108,20 @@ is the load-bearing piece the broker composes on.
|
|||||||
| OQ-27 | ALPN string naming convention | resolved | medium | ADR-004 — alk/ prefix |
|
| OQ-27 | ALPN string naming convention | resolved | medium | ADR-004 — alk/ prefix |
|
||||||
| OQ-28 | Dynamic handler registration | resolved | low | ADR-019 — curated static, overlays dynamic |
|
| OQ-28 | Dynamic handler registration | resolved | low | ADR-019 — curated static, overlays dynamic |
|
||||||
| OQ-29 | Handler-level auth resolution observability | resolved | medium | set_identity() on Connection for observability |
|
| OQ-29 | Handler-level auth resolution observability | resolved | medium | set_identity() on Connection for observability |
|
||||||
| OQ-30 | Dynamic resource ownership | resolved | high | ADR-011 — OwnershipProvider, resource_id_path |
|
| OQ-30 | Dynamic resource ownership | resolved | high | ADR-011 — OwnershipProvider, resource_id_path |
|
||||||
|
|
||||||
|
## Composition Layer
|
||||||
|
|
||||||
|
The layer above alkcall that owns dial/accept, hub composition, and
|
||||||
|
endpoint concerns. As of 2026-10 that layer is the alknet glue (the
|
||||||
|
alknet mono-repo's crates: its client dial helper `AlknetClient`, its
|
||||||
|
endpoint crate, its hub wiring); the mono-repo itself is being replaced
|
||||||
|
by thin glue over alkcall and its sibling protocol/host crates
|
||||||
|
(alkhttp, alktty, alktunnels, alktrader, alktls, …). Several deferred
|
||||||
|
questions name "downstream" as their receiver — this OQ is the named
|
||||||
|
receiver for all of them, so the deferrals survive the replacement
|
||||||
|
rather than being repo-pinned to a codebase slated for deletion.
|
||||||
|
|
||||||
|
| OQ | Title | Status | Priority | Resolution |
|
||||||
|
|----|-------|--------|----------|------------|
|
||||||
|
| OQ-43 | Composition-layer ownership and shape after the alknet replacement | deferred(scope) | medium | **Deferred scope**: who owns dial/accept/hub composition, the endpoint layer (OQ-33), the QUIC-native substrate (OQ-41), and persistence adapters (OQ-19) after the alknet mono-repo is replaced — and whether that composition is one thin glue workspace (an `alknode`-type crate consuming alkcall + the sibling crates) or several small glue binaries. **Blocked on**: the glue-workspace decision being committed (the stated direction is "a couple kloc of glue", but the shape — one workspace vs several binaries — and its ownership are not yet committed). **Impacts**: re-keys the deferrals OQ-33, OQ-41, OQ-42, OQ-19; determines the dial seam's future home (ADR-045's `AlknetClient`, today in the alknet glue); determines where hub-relay wiring templates are consumed (ADR-042, ADR-051). **Not blocking** any alkcall v0.9 API surface — alkcall is transport-blind and composition-blind by construction (ADR-031, ADR-035). **Cross-references**: OQ-33, OQ-41, OQ-42, OQ-19; ADR-045, ADR-031, ADR-042, ADR-051. |
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-09
|
last_updated: 2026-10-13 (alkcall reframing)
|
||||||
---
|
---
|
||||||
|
|
||||||
# Operation Registry
|
# Operation Registry
|
||||||
@@ -21,7 +21,7 @@ The operation registry provides:
|
|||||||
- **Type safety**: JSON Schema for input and output enables validation and client code generation
|
- **Type safety**: JSON Schema for input and output enables validation and client code generation
|
||||||
- **Composability**: Handlers can invoke other operations through `OperationEnv` (local dispatch — remote dispatch is a separate architectural concern, see Constraints)
|
- **Composability**: Handlers can invoke other operations through `OperationEnv` (local dispatch — remote dispatch is a separate architectural concern, see Constraints)
|
||||||
|
|
||||||
The registry design is informed by the `@alkdev/operations` TypeScript package, which demonstrated the same capabilities in JavaScript runtimes. The Rust implementation in alknet-call is canonical — it preserves the behavioral contract (namespace + operation name → invoke with input, return output) while defining the adapter contract (from_*, to_*) in Rust (see ADR-033).
|
The registry design is informed by the `@alkdev/operations` TypeScript package, which demonstrated the same capabilities in JavaScript runtimes. The Rust implementation in alkcall is canonical — it preserves the behavioral contract (namespace + operation name → invoke with input, return output) while defining the adapter contract (from_*, to_*) in Rust (see ADR-033).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -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.
|
The `namespace` field is derived from the name: for `fs/readFile` it's `fs`, for `agent/chat` it's `agent`. It's a convenience accessor for ACL matching and service grouping.
|
||||||
|
|
||||||
Visibility (ADR-017) controls whether an operation is callable from the wire. `External` operations are wire-facing — they appear in `services/list` and accept `call.requested` from clients. `Internal` operations are composition-only — they return `NOT_FOUND` (not `FORBIDDEN`) when called from the wire, and do not appear in `services/list`. The assembly layer declares visibility at registration. All import adapters (`from_openapi`, `from_mcp`, `from_jsonschema`, `from_call`) register operations as `Internal` by default (they're composition material, not directly callable); the handler that composes them is `External`. (`from_jsonschema` is now a real HTTP-backed adapter in `alknet-http` per ADR-027, not the schema-only placeholder it was.)
|
Visibility (ADR-017) controls whether an operation is callable from the wire. `External` operations are wire-facing — they appear in `services/list` and accept `call.requested` from clients. `Internal` operations are composition-only — they return `NOT_FOUND` (not `FORBIDDEN`) when called from the wire, and do not appear in `services/list`. The assembly layer declares visibility at registration. All import adapters (`from_openapi`, `from_mcp`, `from_jsonschema`, `from_call`) register operations as `Internal` by default (they're composition material, not directly callable); the handler that composes them is `External`. (`from_jsonschema` is now a real HTTP-backed adapter in `alkhttp` per ADR-027, not the schema-only placeholder it was.)
|
||||||
|
|
||||||
### AccessControl
|
### AccessControl
|
||||||
|
|
||||||
@@ -135,10 +135,13 @@ impl AccessControl {
|
|||||||
|
|
||||||
The `OwnershipProvider` trait (read side, sync — called on the dispatch hot
|
The `OwnershipProvider` trait (read side, sync — called on the dispatch hot
|
||||||
path) and the `OwnershipStore` trait (write side, async — called by
|
path) and the `OwnershipStore` trait (write side, async — called by
|
||||||
handlers that manage resource lifecycles) are defined in `alknet-core` per
|
handlers that manage resource lifecycles) live with the vendored core
|
||||||
ADR-011's storage decision (fourth instance of the repo/adapter pattern,
|
types in alkcall, per
|
||||||
ADR-033). See [auth.md](../core/auth.md) §"Ownership Provider and Store"
|
ADR-011's storage decision (fourth instance of the source repo's
|
||||||
for the trait shapes and the in-memory default adapter.
|
repo/adapter pattern — alknet ADR-033, provenance, not ported). The
|
||||||
|
trait shapes and the in-memory default adapter are in this crate's core
|
||||||
|
module; the parent repo's `auth.md` (alknet) is historical origin, not
|
||||||
|
part of this spec.
|
||||||
|
|
||||||
**The ownership provider is carried on `OperationContext`** (or threaded
|
**The ownership provider is carried on `OperationContext`** (or threaded
|
||||||
by the dispatcher), populated by the dispatch path from the registry's
|
by the dispatcher), populated by the dispatch path from the registry's
|
||||||
@@ -440,7 +443,7 @@ pub enum OperationProvenance {
|
|||||||
| `Session` | Yes (within sandbox) | Yes — scopes set at sandbox creation | Internal always |
|
| `Session` | Yes (within sandbox) | Yes — scopes set at sandbox creation | Internal always |
|
||||||
|
|
||||||
> **`FromJsonSchema` provenance.** `from_jsonschema` is an HTTP-backed
|
> **`FromJsonSchema` provenance.** `from_jsonschema` is an HTTP-backed
|
||||||
> single-endpoint adapter in `alknet-http` (ADR-027): a real reqwest
|
> single-endpoint adapter in `alkhttp` (ADR-027): a real reqwest
|
||||||
> forwarding handler, not a schema-only placeholder. `FromJsonSchema`
|
> forwarding handler, not a schema-only placeholder. `FromJsonSchema`
|
||||||
> is a leaf, same trust model as `FromOpenAPI` (HTTP endpoint trusted;
|
> is a leaf, same trust model as `FromOpenAPI` (HTTP endpoint trusted;
|
||||||
> handler is a forwarding stub). Schema validation without a handler is
|
> handler is a forwarding stub). Schema validation without a handler is
|
||||||
@@ -523,7 +526,7 @@ The `OperationEnv` trait is the universal composition mechanism. A handler calls
|
|||||||
/// through its `OperationContext.env` (which implements this trait).
|
/// through its `OperationContext.env` (which implements this trait).
|
||||||
///
|
///
|
||||||
/// This must remain a trait, not a concrete type — session-scoped
|
/// This must remain a trait, not a concrete type — session-scoped
|
||||||
/// registries (OQ-19) depend on wrapping the global env via trait
|
/// registries (OQ-04) depend on wrapping the global env via trait
|
||||||
/// layering. Making `OperationEnv` concrete or hardcoding the global
|
/// layering. Making `OperationEnv` concrete or hardcoding the global
|
||||||
/// registry into the dispatch path would close the session-overlay
|
/// registry into the dispatch path would close the session-overlay
|
||||||
/// pattern.
|
/// pattern.
|
||||||
@@ -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`
|
The `invoke_peer` / `peer_contains` methods (ADR-024 §2) take a `PeerRef`
|
||||||
selector and a `PeerId`. These types are defined alongside the
|
selector and a `PeerId`. These types are defined alongside the
|
||||||
`PeerCompositeEnv` struct — see [client-and-adapters.md](client-and-adapters.md#peer-keyed-composition-env-adr-029)
|
`PeerCompositeEnv` struct — see [client-and-adapters.md](client-and-adapters.md#peer-keyed-composition-env-adr-024)
|
||||||
and [ADR-024](decisions/029-peer-graph-routing-model.md) §2:
|
and [ADR-024](decisions/024-peer-graph-routing-model.md) §2:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub enum PeerRef {
|
pub enum PeerRef {
|
||||||
@@ -727,7 +730,7 @@ impl OperationEnv for PeerCompositeEnv {
|
|||||||
}
|
}
|
||||||
// Peer-keyed overlay: iterate peers in insertion order, dispatch to
|
// Peer-keyed overlay: iterate peers in insertion order, dispatch to
|
||||||
// the first peer whose sub-overlay contains the op. This is the
|
// the first peer whose sub-overlay contains the op. This is the
|
||||||
// head→N-workers fan-out primitive (ADR-024 §2, OQ-30: insertion-
|
// head→N-workers fan-out primitive (ADR-024 §2, OQ-15: insertion-
|
||||||
// order first-match).
|
// order first-match).
|
||||||
for peer_id in &self.connection_order {
|
for peer_id in &self.connection_order {
|
||||||
if let Some(conn_env) = self.connections.get(peer_id) {
|
if let Some(conn_env) = self.connections.get(peer_id) {
|
||||||
@@ -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.
|
Future work may add remote call protocol dispatch as an additional backend. The handler-facing API stays the same.
|
||||||
|
|
||||||
**`OperationEnv` must remain a trait.** This is a constraint, not a suggestion. The trait-based design enables registry layering (ADR-019): the CallAdapter composes the root env per call from the curated base + active peer-keyed connection overlays + session overlay, and overlays wrap the base via trait layering. Session-scoped registries (OQ-19) and connection-scoped remote imports (ADR-022 `from_call`) are both overlays on the same base, using the same mechanism. The peer-keyed extension (`PeerCompositeEnv`, `invoke_peer`, ADR-024) composes on top of the same trait — it overrides the new peer-routing methods, not the base dispatch. Making `OperationEnv` concrete or hardcoding the global registry into the dispatch path would close both the session-overlay and connection-overlay patterns, and would prevent the peer-keyed routing model from composing. This is the same integration-point pattern as `IdentityProvider` (ADR-003). See OQ-19, ADR-019, and ADR-024.
|
**`OperationEnv` must remain a trait.** This is a constraint, not a suggestion. The trait-based design enables registry layering (ADR-019): the CallAdapter composes the root env per call from the curated base + active peer-keyed connection overlays + session overlay, and overlays wrap the base via trait layering. Session-scoped registries (OQ-04) and connection-scoped remote imports (ADR-022 `from_call`) are both overlays on the same base, using the same mechanism. The peer-keyed extension (`PeerCompositeEnv`, `invoke_peer`, ADR-024) composes on top of the same trait — it overrides the new peer-routing methods, not the base dispatch. Making `OperationEnv` concrete or hardcoding the global registry into the dispatch path would close both the session-overlay and connection-overlay patterns, and would prevent the peer-keyed routing model from composing. This is the same integration-point pattern as `IdentityProvider` (ADR-003). See OQ-04, ADR-019, and ADR-024.
|
||||||
|
|
||||||
### Service Discovery
|
### Service Discovery
|
||||||
|
|
||||||
@@ -849,7 +852,7 @@ error handling.
|
|||||||
|
|
||||||
### Operation Registry (hand-rolled, no irpc)
|
### Operation Registry (hand-rolled, no irpc)
|
||||||
|
|
||||||
The operation registry is hand-rolled in alknet-call. ADR-013 accepted
|
The operation registry is hand-rolled in alkcall. ADR-013 accepted
|
||||||
"irpc as the call protocol foundation," but no `.rs` file in the workspace
|
"irpc as the call protocol foundation," but no `.rs` file in the workspace
|
||||||
ever imported irpc — the wire format (`wire.rs`), the operation registry,
|
ever imported irpc — the wire format (`wire.rs`), the operation registry,
|
||||||
and the dispatch are all hand-rolled. ADR-014 supersedes ADR-013 and
|
and the dispatch are all hand-rolled. ADR-014 supersedes ADR-013 and
|
||||||
@@ -858,7 +861,7 @@ protocol (external, JSON)" with "irpc services (internal, postcard)" is
|
|||||||
moot — there is no irpc layer.
|
moot — there is no irpc layer.
|
||||||
|
|
||||||
If a handler internally uses a postcard/binary RPC for in-process calls,
|
If a handler internally uses a postcard/binary RPC for in-process calls,
|
||||||
that's a handler-internal choice, not an alknet-call integration. The
|
that's a handler-internal choice, not an alkcall integration. The
|
||||||
operation registry's external interface is always JSON (the `EventEnvelope`
|
operation registry's external interface is always JSON (the `EventEnvelope`
|
||||||
wire format); the internal handler dispatch is a `Handler` /
|
wire format); the internal handler dispatch is a `Handler` /
|
||||||
`StreamingHandler` trait object (ADR-021), not an irpc `Service`.
|
`StreamingHandler` trait object (ADR-021), not an irpc `Service`.
|
||||||
@@ -966,71 +969,74 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
|
|||||||
|
|
||||||
| Decision | ADR | Summary |
|
| Decision | ADR | Summary |
|
||||||
|----------|-----|---------|
|
|----------|-----|---------|
|
||||||
| Hand-rolled EventEnvelope framing (irpc never integrated) | [ADR-014](decisions/064-irpc-never-integrated-hand-rolled-framing.md) | Hand-rolled framing, registry, dispatch; supersedes ADR-013 |
|
| Hand-rolled EventEnvelope framing (irpc never integrated) | [ADR-014](decisions/014-irpc-never-integrated-hand-rolled-framing.md) | Hand-rolled framing, registry, dispatch; supersedes ADR-013 |
|
||||||
| Call protocol stream model | [ADR-015](decisions/012-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
|
| Call protocol stream model | [ADR-015](decisions/015-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
|
||||||
| Static handler registration | [ADR-010](decisions/010-alpn-router-and-endpoint.md) | `HandlerRegistry` (ALPN-level) immutable after construction; `OperationRegistry` layered by ADR-019 (curated immutable, session/imported dynamic) |
|
| Static handler registration | [ADR-001](decisions/001-alpn-protocol-dispatch.md) | `HandlerRegistry` (ALPN-level) immutable after construction; `OperationRegistry` layered by ADR-019 (curated immutable, session/imported dynamic). Provenance: alknet ADR-010's endpoint/router layer is retired — alkcall has no endpoint layer (see OQ-43 for the endpoint layer's composition home) |
|
||||||
| Vault integration via assembly layer | [ADR-008](decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time |
|
| Vault integration via assembly layer | [ADR-010](decisions/010-secret-material-flow-and-capability-injection.md) | Vault is a capability source, accessed at assembly time (provenance: alknet ADR-008, superseded in-crate) |
|
||||||
| Secret material flow and capability injection | [ADR-010](decisions/014-secret-material-flow-and-capability-injection.md) | Capabilities carry outbound credentials; call protocol carries no secret material |
|
| Secret material flow and capability injection | [ADR-010](decisions/010-secret-material-flow-and-capability-injection.md) | Capabilities carry outbound credentials; call protocol carries no secret material |
|
||||||
| Privilege model and authority context | [ADR-017](decisions/015-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; composition authority + scoped env |
|
| Privilege model and authority context | [ADR-017](decisions/017-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; composition authority + scoped env |
|
||||||
| Handler registration, provenance, and composition authority | [ADR-018](decisions/022-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
|
| Handler registration, provenance, and composition authority | [ADR-018](decisions/018-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
|
||||||
| Operation registry layering | [ADR-019](decisions/024-operation-registry-layering.md) | Curated (static, immutable) + session and connection overlays (dynamic); `OperationEnv` as trait-object integration point; `OperationContext.env` split into `scoped_env` (data) and `env` (dispatch trait) |
|
| Operation registry layering | [ADR-019](decisions/019-operation-registry-layering.md) | Curated (static, immutable) + session and connection overlays (dynamic); `OperationEnv` as trait-object integration point; `OperationContext.env` split into `scoped_env` (data) and `env` (dispatch trait) |
|
||||||
| Operation error schemas | [ADR-016](decisions/023-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity for `from_openapi`/`to_openapi` |
|
| Operation error schemas | [ADR-016](decisions/016-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity for `from_openapi`/`to_openapi` |
|
||||||
| Call protocol client and adapter contract | [ADR-022](decisions/017-call-protocol-client-and-adapter-contract.md) | `from_call`/`OperationAdapter` produce `HandlerRegistration` bundles; adapter-registered ops are `Internal` leaves. Surface specced in [client-and-adapters.md](client-and-adapters.md) |
|
| Call protocol client and adapter contract | [ADR-022](decisions/022-call-protocol-client-and-adapter-contract.md) | `from_call`/`OperationAdapter` produce `HandlerRegistration` bundles; adapter-registered ops are `Internal` leaves. Surface specced in [client-and-adapters.md](client-and-adapters.md) |
|
||||||
| `from_jsonschema` as HTTP-backed single-endpoint adapter | [ADR-027](decisions/066-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf (now handler-bearing, not "no handler") |
|
| `from_jsonschema` as HTTP-backed single-endpoint adapter | [ADR-027](decisions/027-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from the call protocol (broken schema-only placeholder) to the HTTP host crate as a real reqwest-backed single-endpoint adapter — today `alkhttp` (ex-`alknet-http`); `FromJsonSchema` provenance stays in alkcall as a leaf (now handler-bearing, not "no handler") |
|
||||||
| Peer-graph routing model (supersedes ADR-023) | [ADR-024](decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` (the field this doc's `HandlerRegistration` previously gained) |
|
| Peer-graph routing model (supersedes ADR-023) | [ADR-024](decisions/024-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` (the field this doc's `HandlerRegistration` previously gained) |
|
||||||
| Forwarded-for identity | [ADR-026](decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `OperationContext` and `call.requested`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
|
| Forwarded-for identity | [ADR-026](decisions/026-forwarded-for-identity.md) | `forwarded_for` field on `OperationContext` and `call.requested`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
|
||||||
| Streaming handler for subscriptions | [ADR-021](decisions/049-streaming-handler-for-subscriptions.md) | `StreamingHandler` type alongside `Handler`; `HandlerKind` enum on `HandlerRegistration` validated against `op_type`; `invoke_streaming()` on `OperationRegistry`; `invoke()` and `OperationEnv::invoke()` error with `INVALID_OPERATION_TYPE` on `Subscription` ops; composition stays request/response-only, stream composition is handler-level |
|
| Streaming handler for subscriptions | [ADR-021](decisions/021-streaming-handler-for-subscriptions.md) | `StreamingHandler` type alongside `Handler`; `HandlerKind` enum on `HandlerRegistration` validated against `op_type`; `invoke_streaming()` on `OperationRegistry`; `invoke()` and `OperationEnv::invoke()` error with `INVALID_OPERATION_TYPE` on `Subscription` ops; composition stays request/response-only, stream composition is handler-level |
|
||||||
| Dynamic resource ownership for runtime-spawned resources | [ADR-011](decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | `AccessControl::check` consults an `OwnershipProvider` (sync read trait, ADR-033 repo/adapter pattern); `OperationSpec` gains `resource_id_path` (JSON pointer into the input); proxy-only access pattern (spawner owns, proxy to share, teardown revokes); `list` = scope-gate + result-filter; teardown = automatic, handler-driven; composition = two orthogonal checks, ADR-017/022 unchanged |
|
| Dynamic resource ownership for runtime-spawned resources | [ADR-011](decisions/011-dynamic-resource-ownership-for-runtime-spawned-resources.md) | `AccessControl::check` consults an `OwnershipProvider` (sync read trait; the source repo's repo/adapter pattern — alknet ADR-033, provenance); `OperationSpec` gains `resource_id_path` (JSON pointer into the input); proxy-only access pattern (spawner owns, proxy to share, teardown revokes); `list` = scope-gate + result-filter; teardown = automatic, handler-driven; composition = two orthogonal checks, ADR-017/022 unchanged |
|
||||||
| Publish operation type and HandlerKind::Sink | [ADR-046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | `OperationType::Pub` (producer→consumer streaming); `SinkHandler` + `HandlerKind::Sink` + `PublishStream`; `invoke_sink()` dispatch path; `call.published` wire event; `OperationSpec.publish_schema`; `Subscription` renamed to `Sub`; fan-out/broker deferred to channels |
|
| Publish operation type and HandlerKind::Sink | [ADR-046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | `OperationType::Pub` (producer→consumer streaming); `SinkHandler` + `HandlerKind::Sink` + `PublishStream`; `invoke_sink()` dispatch path; `call.published` wire event; `OperationSpec.publish_schema`; `Subscription` renamed to `Sub`; fan-out/broker out of scope for alkcall (OQ-22 resolved, OQ-42 downstream) |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
See [open-questions.md](open-questions.md) for full details.
|
See [open-questions.md](open-questions.md) for full details.
|
||||||
|
|
||||||
- **OQ-13** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
|
- **OQ-02** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
|
||||||
- **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
|
- **OQ-03** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
|
||||||
- **OQ-16** (resolved by ADR-010): No vault operations are exposed over the call protocol for now.
|
- **OQ-09** (resolved by ADR-010): No vault operations are exposed over the call protocol for now.
|
||||||
- **OQ-19** (resolved): Session-scoped operation registries — agent-written operations overlaid on the curated registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Session ops are `Session` provenance (ADR-018) — always `Internal`, compose under restricted authority scoped down at sandbox creation. Generalized by ADR-019 to cover connection-scoped overlays as well.
|
- **OQ-04** (resolved): Session-scoped operation registries — agent-written operations overlaid on the curated registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Session ops are `Session` provenance (ADR-018) — always `Internal`, compose under restricted authority scoped down at sandbox creation. Generalized by ADR-019 to cover connection-scoped overlays as well.
|
||||||
- **OQ-25** (dissolved by ADR-024): `remote_safe` marking shape — moot.
|
- **OQ-10** (dissolved by ADR-024): `remote_safe` marking shape — moot.
|
||||||
`remote_safe`/`trusted_peer` are retired; peer authorization is
|
`remote_safe`/`trusted_peer` are retired; peer authorization is
|
||||||
`AccessControl::check(peer_identity)`, the existing mechanism. See
|
`AccessControl::check(peer_identity)`, the existing mechanism. See
|
||||||
[client-and-adapters.md](client-and-adapters.md) and ADR-024 §3.
|
[client-and-adapters.md](client-and-adapters.md) and ADR-024 §3.
|
||||||
- **OQ-26** (resolved): `OperationAdapter` error type — `AdapterError`
|
- **OQ-11** (resolved): `OperationAdapter` error type — `AdapterError`
|
||||||
variants: `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`,
|
variants: `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`,
|
||||||
`SamePeerCollision` (replaces flat `Conflict`). `#[non_exhaustive]`. See
|
`SamePeerCollision` (replaces flat `Conflict`). `#[non_exhaustive]`. See
|
||||||
[client-and-adapters.md](client-and-adapters.md).
|
[client-and-adapters.md](client-and-adapters.md).
|
||||||
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
|
- **OQ-12** (resolved): `from_call` re-import trigger — `from_call` is a
|
||||||
manual free function; the assembly layer calls it after the dial (in
|
manual free function; the assembly layer calls it after the dial (in the composition layer's dial helper
|
||||||
`AlknetClient`). A `CallConnection::refresh()` method is a genuine
|
`AlknetClient` — today the alknet glue; OQ-43). A `CallConnection::refresh()` method is a genuine
|
||||||
feature addition — non-breaking, additive. See
|
feature addition — non-breaking, additive. See
|
||||||
[ADR-028](decisions/069-from-call-manual-free-function.md).
|
[ADR-028](decisions/028-from-call-manual-free-function.md).
|
||||||
- **OQ-28** (resolved): `from_call` namespace collision — same-peer
|
- **OQ-13** (resolved): `from_call` namespace collision — same-peer
|
||||||
collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays).
|
collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays).
|
||||||
`namespace_prefix` is optional local-naming sugar. See
|
`namespace_prefix` is optional local-naming sugar. See
|
||||||
[client-and-adapters.md](client-and-adapters.md).
|
[client-and-adapters.md](client-and-adapters.md).
|
||||||
- **OQ-29..37** are tracked in [client-and-adapters.md](client-and-adapters.md)
|
- The client-surface questions (the `CallClient` / adapter surface and
|
||||||
(they concern the `CallClient` / adapter surface and peer-graph routing,
|
peer-graph routing) are tracked in
|
||||||
not the registry layering this document covers). In brief: OQ-29
|
[client-and-adapters.md](client-and-adapters.md), not the registry
|
||||||
(CallClient TLS client-auth) resolved; OQ-30 (`PeerRef::Any` routing
|
layering this document covers; in brief: OQ-14
|
||||||
policy) resolved; OQ-31 (`services/list-peers` re-export semantics)
|
(`CallClient` TLS client-auth) resolved; OQ-15 (`PeerRef::Any` routing
|
||||||
resolved; OQ-32 (multi-hop federation) open (feature extension);
|
policy) resolved; OQ-16 (`services/list-peers` re-export semantics)
|
||||||
OQ-33 (PeerId source) resolved by ADR-025; OQ-34 (persistent peer
|
resolved; OQ-17 (multi-hop federation) deferred(scope) (feature
|
||||||
registry) resolved by ADR-025+033; OQ-35 (API key asymmetry) dissolved;
|
extension); OQ-18 (`PeerId` source) resolved by ADR-025; OQ-19
|
||||||
OQ-36 (concrete persistence adapter shapes) resolved by ADR-035;
|
(persistent peer registry) resolved by ADR-025; OQ-20 (API key
|
||||||
OQ-37 (X.509 outgoing-only) resolved by ADR-034.
|
asymmetry) dissolved; OQ-21 (X.509 outgoing-only) resolved by
|
||||||
- **OQ-42** (resolved by ADR-011): Dynamic resource ownership for
|
ADR-054 — three remote roles, verifier by `PeerEntry` presence (the
|
||||||
|
original deciding ADR, alknet ADR-034, was not ported; ADR-054
|
||||||
|
re-owns the rule, provenance retained); see OQ-21 in
|
||||||
|
[open-questions.md](open-questions.md).
|
||||||
|
- **OQ-30** (resolved by ADR-011): Dynamic resource ownership for
|
||||||
runtime-spawned resources — `AccessControl::check` consults an
|
runtime-spawned resources — `AccessControl::check` consults an
|
||||||
`OwnershipProvider`; `OperationSpec` gains `resource_id_path`; proxy-only
|
`OwnershipProvider`; `OperationSpec` gains `resource_id_path`; proxy-only
|
||||||
access pattern; four edge specifics pinned (`list`, teardown, fleet,
|
access pattern; four edge specifics pinned (`list`, teardown, fleet,
|
||||||
composition). See [auth.md](../core/auth.md) §"Ownership Provider and
|
composition). See ADR-011 for the trait shapes.
|
||||||
Store" for the trait shapes.
|
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- [call-protocol.md](call-protocol.md) — CallAdapter, EventEnvelope, stream model, PendingRequestMap
|
- [call-protocol.md](call-protocol.md) — CallAdapter, EventEnvelope, stream model, PendingRequestMap
|
||||||
- ADR-014: Hand-rolled EventEnvelope framing (irpc never integrated; supersedes ADR-013)
|
- ADR-014: Hand-rolled EventEnvelope framing (irpc never integrated; supersedes ADR-013)
|
||||||
- ADR-008: Vault integration point
|
- ADR-010: Secret material flow and capability injection (the vault integration point)
|
||||||
- ADR-010: ALPN router and endpoint (static registration — applies to the `HandlerRegistry`, not the `OperationRegistry`; see ADR-019 for the distinction)
|
- ADR-001: ALPN-based protocol dispatch (`HandlerRegistry` static registration)
|
||||||
- ADR-015: Call protocol stream model
|
- ADR-015: Call protocol stream model
|
||||||
- ADR-019: Operation registry layering (curated + session/connection overlays; `OperationEnv` as trait-object integration point)
|
- ADR-019: Operation registry layering (curated + session/connection overlays; `OperationEnv` as trait-object integration point)
|
||||||
- ADR-024: Peer-graph routing model (peer-keyed overlays + `PeerRef` routing; `PeerCompositeEnv` supersedes the singular-connection `CompositeOperationEnv`)
|
- ADR-024: Peer-graph routing model (peer-keyed overlays + `PeerRef` routing; `PeerCompositeEnv` supersedes the singular-connection `CompositeOperationEnv`)
|
||||||
@@ -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-026: Forwarded-for identity (`forwarded_for` on `OperationContext` and `call.requested`; metadata only)
|
||||||
- ADR-021: Streaming handler for subscriptions (`StreamingHandler`, `HandlerKind`, `invoke_streaming()`, `INVALID_OPERATION_TYPE`)
|
- ADR-021: Streaming handler for subscriptions (`StreamingHandler`, `HandlerKind`, `invoke_streaming()`, `INVALID_OPERATION_TYPE`)
|
||||||
- ADR-011: Dynamic resource ownership for runtime-spawned resources (`OwnershipProvider` consulted by `AccessControl::check`; `OperationSpec.resource_id_path`; proxy-only access pattern; composition = two orthogonal checks, ADR-017/022 unchanged)
|
- ADR-011: Dynamic resource ownership for runtime-spawned resources (`OwnershipProvider` consulted by `AccessControl::check`; `OperationSpec.resource_id_path`; proxy-only access pattern; composition = two orthogonal checks, ADR-017/022 unchanged)
|
||||||
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`
|
- Reference implementation: this crate (`src/`); historical origin: the alknet / alknet-main call-core sources (provenance)
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
# The Topic Broker: Placement, Shape, and the "Op as Topic" Tangle
|
||||||
|
|
||||||
|
**Status:** Phase 0 (exploration — captures the problem and the candidate
|
||||||
|
shapes; no decision taken, no ADR written. Implementation follows only
|
||||||
|
after this converges and Phase 1 produces the ADR + spec).
|
||||||
|
**Date:** 2026-10-13
|
||||||
|
**Research inputs:** operator recollection (2026-10-13, recorded and
|
||||||
|
disentangled in §3), the post-stub-sweep context (reviews 010/011, the
|
||||||
|
v0.9 release-plan discussion), alkcall ADRs 021/024/040/042/046/047/049/052/053
|
||||||
|
and OQ-22/39/40/42/43, the alkstore consumer inventory + ADR-015
|
||||||
|
(alkstore `/workspace/@alkdev/alkstore`), the TypeScript reference
|
||||||
|
implementation (`/workspace/@alkdev/pubsub`, `@alkdev/pubsub` — the
|
||||||
|
crates.io-unpublished ancestor of alkcall's `EventEnvelope`), and the
|
||||||
|
hub-relay composition (ADR-042, ADR-051).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why this research exists
|
||||||
|
|
||||||
|
Two independent observations converged on the same question in the
|
||||||
|
v0.9 release-planning discussion:
|
||||||
|
|
||||||
|
1. **The sweep found its own neighbor.** The stub sweep removed the
|
||||||
|
last `not_implemented` surfaces and re-keyed the live docs — but the
|
||||||
|
OQ ledger still carries one deferral whose *premise expired and
|
||||||
|
whose escape route is itself unowned*. OQ-42 (the broker) is
|
||||||
|
`deferred(scope)`, parked on "a concrete producing crate committing
|
||||||
|
to fan-out," and its home is the composition layer (OQ-43) — whose
|
||||||
|
ownership and shape are *also* uncommitted. A deferral blocked on a
|
||||||
|
deferral, while two crates downstream (alkgit, alkfs) wait on what
|
||||||
|
it would produce, is the circular-hedging shape in paperwork form:
|
||||||
|
no false advertising on any API surface, no failing handler — just
|
||||||
|
a dependency chain where every link waits on a link that never
|
||||||
|
commits.
|
||||||
|
|
||||||
|
2. **The deferral was priced under a different dependency graph.**
|
||||||
|
ADR-046 §fan-out and ADR-047 Gap B ruled the broker out of alkcall
|
||||||
|
when the alternative was cheap: at that point, no downstream crate
|
||||||
|
built on the pub/sub vocabulary. Today, four published crates
|
||||||
|
consume alkcall, alkblobs' reactive ops surface is the sharpest
|
||||||
|
named need (the alkblobs consumer-remediation input, review 011),
|
||||||
|
and alkgit + alkfs (both paused on alkblobs) grow subscription
|
||||||
|
surfaces on top of it. If the broker is built later, the version
|
||||||
|
bump that delivers it ripples across every crate that grew a
|
||||||
|
subscription surface in the meantime — the operator's stated cost
|
||||||
|
estimate is 6–8 crates, vs riding the v0.9 bump that already touches
|
||||||
|
the 4 published dependents. Deferral here is not free; it
|
||||||
|
compounds.
|
||||||
|
|
||||||
|
So this document's job is narrow: disentangle the fused concept that
|
||||||
|
blocked honest specification (§3), inventory what already exists to
|
||||||
|
compose on (§5), assess whether the placement ruling should be amended
|
||||||
|
(§6), and enumerate the open questions that Phase 1 must resolve before
|
||||||
|
any code is written (§8).
|
||||||
|
|
||||||
|
## 2. Prior state of the question (what is decided today)
|
||||||
|
|
||||||
|
- **The primitives are delivered and load-bearing** (ADR-046): the
|
||||||
|
`Pub` operation type, `HandlerKind::Sink` + `SinkHandler`,
|
||||||
|
`invoke_sink()` dispatch, the `call.published` wire event (the
|
||||||
|
envelope's sixth event type, wire-stable), and
|
||||||
|
`CallConnection::publish()`.
|
||||||
|
- **The fan-out/broker was ruled out of scope for alkcall** (ADR-047
|
||||||
|
Gap B; ADR-046 §"Fan-out"): topic registry, `Pub`↔`Sub` matching by
|
||||||
|
`(op_name, params_hash)`, N-consumer fan-out — placed at the hub
|
||||||
|
composition layer, "the same way the hub relay composes on top of
|
||||||
|
call ops." OQ-22 resolved the primitive half; the broker is OQ-42.
|
||||||
|
- **The named first consumer drifted.** OQ-42's blocking condition
|
||||||
|
names "a concrete producing crate committing to fan-out (plausible
|
||||||
|
first candidate: alktrader market-data distribution)." alktrader has
|
||||||
|
since become a quant/backtest library: its market-data path is an
|
||||||
|
analytics input, not a live N-spoke distribution surface. No crate
|
||||||
|
currently matches the blocking condition — which is how OQ-42 ended
|
||||||
|
up uncommittable.
|
||||||
|
- **The "channels" consumer the original rationale named is served
|
||||||
|
differently.** ADR-046's deferral reason 2 was "the broker's first
|
||||||
|
consumer is channels" (`channel/resources/subscribe`, hub-as-broker
|
||||||
|
ADR-042). The discovery-UI half of `resources/subscribe` shipped as
|
||||||
|
the snapshot-then-changes enumerator surface (ADR-053) — live
|
||||||
|
resource enumeration, not a general topic broker. Hub relay
|
||||||
|
(ADR-042/051) composes call ops without a broker.
|
||||||
|
- **alkstore owns the durable substrate below all of this.** Its
|
||||||
|
consumer inventory (streams row) explicitly names alkstore streams as
|
||||||
|
the durable back of alkcall's pub/sub vocabulary — per-stream FIFO by
|
||||||
|
offset, per-consumer cursors, replay-on-attach — and pins the topic
|
||||||
|
dimension to *separate streams*, never per-key lanes within one
|
||||||
|
stream (alkstore ADR-015 §1).
|
||||||
|
|
||||||
|
## 3. The tangle: two ideas fused in "op as topic"
|
||||||
|
|
||||||
|
The operator's recollection (recorded verbatim, then split):
|
||||||
|
|
||||||
|
> "The 'op as a topic' was a bit hand wavy in hindsight. The general
|
||||||
|
> idea was to basically use a hash as a topic and for deduping
|
||||||
|
> purposes. The 'op as a topic' was mostly so one could call a
|
||||||
|
> subscription operation and receive a stream of events. The reason
|
||||||
|
> they were modeled like regular ops is that we can validate the input
|
||||||
|
> and provide a schema that is required for that particular
|
||||||
|
> subscription (same on the pub side). So it should allow for ACL
|
||||||
|
> patterns inside the pubsub implementation."
|
||||||
|
|
||||||
|
Two separable design needs got fused into one mechanism:
|
||||||
|
|
||||||
|
- **Need A — a topic identity for matching and dedup.** Something
|
||||||
|
hashable, stable, and comparable: the key used to route a published
|
||||||
|
event to matching subscribers, and (possibly) the dedup key that
|
||||||
|
prevents duplicate delivery. ADR-046 §fan-out's `(op_name,
|
||||||
|
params_hash)` is one instance of this.
|
||||||
|
- **Need B — a subscription surface with real operation properties.**
|
||||||
|
Subscriptions should be validatable (declare the input schema on the
|
||||||
|
subscribe side; declare the publish schema on the publish side), and
|
||||||
|
authorizable (`AccessControl::check` on both halves). The "model it
|
||||||
|
as a regular op" instinct exists so the *registry machinery* —
|
||||||
|
schema compile, ACL check, visibility, provenance — is inherited for
|
||||||
|
free rather than hand-rolled inside the broker.
|
||||||
|
|
||||||
|
Splitting them exposes a design space the fused formulation hid:
|
||||||
|
|
||||||
|
| Aspect | Need A (identity) | Need B (op surface) |
|
||||||
|
|---|---|---|
|
||||||
|
| Decides | which subscribers an event reaches; dedup | who may subscribe/publish; what shapes are accepted |
|
||||||
|
| Natural home | topic table / matching engine | registry registration (specs + ACL) |
|
||||||
|
| Failure mode if fused | topics inherit op-registry coupling they don't need; hash identity leaks op internals | ad-hoc topic surface re-implements schema + ACL machinery the registry already has |
|
||||||
|
|
||||||
|
The candidate resolutions for both are the Phase 1 subjects
|
||||||
|
(§8, QP-1/QP-3).
|
||||||
|
|
||||||
|
## 4. Consumer inventory (who waits on this)
|
||||||
|
|
||||||
|
Cross-referenced from the alkstore consumer inventory, review 011's
|
||||||
|
downstream input, and the release-plan discussion:
|
||||||
|
|
||||||
|
| Consumer | Need | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| alkblobs | its reactive ops surface (Sink ops at their sharpest — REQ-2/ADR-008 lineage); ops-put works against the delivered `register_pub_openable` path post-fix (reviews 010/011), but cross-put fan-out ("many consumers of one blob stream") wants the broker | published-pending (unpublished crate, pre-first-release) |
|
||||||
|
| alkgit | repo-change subscriptions ("users and other apps subscribe to changes on a repo" — the operator's recorded need in alkstore's consumer inventory, streams row); paused on storage → alkblobs | paused |
|
||||||
|
| alkfs | the VFS workspace crate; its ancestor POC runs on honker notify; client-side cache invalidation (the "repo + cache invalidation" hot-path pattern the operator cites) wants topic-shaped invalidation signals | not yet decomposed |
|
||||||
|
| hub/relay deployments | one producer publishing, N spokes receiving (ADR-042's relay handles bytes-forwarding for channels; the *event-shaped* fan-out is the broker's) | composition layer, unowned (OQ-43) |
|
||||||
|
| alktrader | OQ-42's *original* named candidate | drifted — analytics, not distribution; do not re-park on it |
|
||||||
|
| family-wide | the reactivity requirement row (alkstore consumer inventory, streams): "watching for specific event types from several different places at once," durable, offline-tolerant | the named shape all of the above instantiate |
|
||||||
|
|
||||||
|
Note the family's shape consensus, recorded independently on both
|
||||||
|
sides: the topic dimension routes to **separate channels/streams — one
|
||||||
|
per topic** (alkstore ADR-015 §1; ADR-034 §substrate for wire
|
||||||
|
channels), never per-key lanes inside one stream. Whatever identity
|
||||||
|
model Phase 1 picks, it should preserve one-topic-one-delivery-pipe.
|
||||||
|
|
||||||
|
## 5. What exists to compose on (in-crate inventory)
|
||||||
|
|
||||||
|
Everything the broker needs as load-bearing inputs is already in this
|
||||||
|
crate and verified:
|
||||||
|
|
||||||
|
- **Delivery primitives** — `invoke_sink()` (`call.published`
|
||||||
|
sink-mode), `invoke_streaming()` (Sub, `call.responded`), the
|
||||||
|
wire-stable `EventEnvelope` framing with additive-event rule
|
||||||
|
(ADR-014: new event types may be added; existing shapes frozen).
|
||||||
|
- **Registry machinery for Need B** — `OperationSpec` (schema compile +
|
||||||
|
`publish_schema` validation), `AccessControl` + `required_scopes`,
|
||||||
|
`Visibility` (ADR-017), provenance (ADR-018), and the
|
||||||
|
registry-layering pattern (ADR-019) — session/connection overlays
|
||||||
|
for dynamic subscription attach/detach.
|
||||||
|
- **The in-crate composition precedents that weaken the placement
|
||||||
|
ruling** — `ChannelOperations` (channel lifecycle ops composed on
|
||||||
|
the call registry, ADR-047), establishers with `Establishment` plan
|
||||||
|
delivery (ADR-049/052), and `channel/resources/subscribe` — a live
|
||||||
|
subscription op with per-resource routing, snapshot-then-changes,
|
||||||
|
composed in-crate (ADR-053). None of these needed transport, ALPN,
|
||||||
|
or dial knowledge.
|
||||||
|
- **Peer vocabulary for cross-node fan-out** — ADR-024's peer-graph
|
||||||
|
routing model (`PeerRef` routing, peer-keyed overlays) is the
|
||||||
|
in-crate vocabulary for "deliver to N peers"; OQ-17 keeps
|
||||||
|
multi-hop out of scope.
|
||||||
|
- **Backpressure/lifecycle precedents** — bounded buffers, per-identity
|
||||||
|
caps, monotonic IDs (ADR-040/041), the abort cascade with
|
||||||
|
`continue-running` opt-in for slow-subscriber management (ADR-020).
|
||||||
|
- **The durable substrate downstream** — alkstore streams (per-consumer
|
||||||
|
offset, replay, trim), engines mem/sqlite/postgres under one
|
||||||
|
contract.
|
||||||
|
- **The TS reference** — `@alkdev/pubsub` (typed event-target: topic =
|
||||||
|
event type name; subscribe/publish/subscribeOnce vocabulary; the
|
||||||
|
`EventEnvelope` ancestor). Shape only; the Rust realization composes
|
||||||
|
on the registry instead of `CustomEvent`.
|
||||||
|
|
||||||
|
## 6. The placement ruling: amend ADR-047 Gap B?
|
||||||
|
|
||||||
|
The case for keeping the broker out of alkcall was: (1) point-to-point
|
||||||
|
call protocol, (2) the broker's lifecycle is routing-shaped, (3)
|
||||||
|
designing blind produces half-designs. Each has softened:
|
||||||
|
|
||||||
|
1. **Point-to-point is true of transport, not of composition.** The
|
||||||
|
registry-operations pattern (ADR-047) already puts composition ops
|
||||||
|
in-crate; ADR-053 ships a live subscriber op in-crate. A topic
|
||||||
|
registry is a *registry overlay + dispatch bookkeeping*, the same
|
||||||
|
class.
|
||||||
|
2. **The lifecycle concern dissolves at the module boundary.** A broker
|
||||||
|
module composed on a registry has exactly the lifecycle its
|
||||||
|
registry has; the "topic registry outlives individual calls"
|
||||||
|
objection describes a *deployment* property, satisfied by any
|
||||||
|
long-lived node, hub or not.
|
||||||
|
3. **The half-design risk is the real surviving constraint — and it
|
||||||
|
now has both halves.** The consumer side is no longer hypothetical
|
||||||
|
(alkblobs' ops surface, alkgit's subscription vocabulary recorded in
|
||||||
|
the alkstore inventory), and the substrate side exists (alkstore
|
||||||
|
streams, the TS reference). A Phase 1 spec against these is not a
|
||||||
|
blind design.
|
||||||
|
|
||||||
|
**Recommendation (non-final until §8 converges):** amend the placement
|
||||||
|
— the broker becomes an in-crate, opt-in composition module (the
|
||||||
|
`ChannelOperations` pattern): it registers topic ops into whatever
|
||||||
|
`OperationRegistry` the node owner points it at, carries no transport
|
||||||
|
or dial knowledge, and stays unregistered by default so no existing
|
||||||
|
surface silently gains it. What remains out of alkcall: engine wiring,
|
||||||
|
glue, dial — OQ-43's territory. The hub that fans out *across*
|
||||||
|
connections composes the module with its `CallConnection`s; within one
|
||||||
|
connection, the module composes locally. The durable event log behind
|
||||||
|
a topic's replay is a `TopicStore` trait seam (§7), not an in-crate
|
||||||
|
engine choice.
|
||||||
|
|
||||||
|
This also resolves OQ-42's uncommittable blocking condition: the
|
||||||
|
deferral's "wait for a consumer to commit" gate is replaced by shipped
|
||||||
|
consumers already on record.
|
||||||
|
|
||||||
|
## 7. The durability seam (the "not rocket surgery" constraint)
|
||||||
|
|
||||||
|
The operator's requirement: the trait seam must let a downstream
|
||||||
|
assembly add memory/sqlite/postgres adapters with the same ease as the
|
||||||
|
repo + cache-invalidation hot-path pattern — adapters are written once
|
||||||
|
per engine, not per deployment.
|
||||||
|
|
||||||
|
Shape to converge on in Phase 1:
|
||||||
|
|
||||||
|
- **In-memory is the default realization** — a `TopicBroker` with no
|
||||||
|
injected store behaves as the TS reference does: live fan-out,
|
||||||
|
stateless, zero durability.
|
||||||
|
- **The seam is one small trait** (working name `TopicStore`),
|
||||||
|
roughly covering: append a published event (returning the topic's
|
||||||
|
monotonically increasing position), read events at-or-after a
|
||||||
|
position for a topic, and per-subscriber cursor save/load. This maps
|
||||||
|
one-to-one onto the alkstore stream contract (`publish_with_key_tx`,
|
||||||
|
offset-ordered reads, per-consumer cursor saves — alkstore ADR-015
|
||||||
|
§2/§4), so the postgres/sqlite adapters are alkstore-streams-backed
|
||||||
|
and the memory adapter is the broker itself.
|
||||||
|
- **Reverse dependency discipline** — alkcall gains no alkstore
|
||||||
|
dependency. The seam stays dependency-free (plain trait, core-value
|
||||||
|
bytes crossing it, mirroring the vendored-core "lean, no engine"
|
||||||
|
posture); alkstore-backed adapters live downstream, where engines
|
||||||
|
already live (the OQ-43 glue consumes both crates).
|
||||||
|
- **Honest scope statement for durable mode:** durable delivery with
|
||||||
|
per-consumer replay is a *deployment* capability. The v1 broker
|
||||||
|
contract should specify the seam, not the durability guarantees
|
||||||
|
beyond it (ordering, dedup, retention pricing ride the store behind
|
||||||
|
the seam — for alkstore-backed adapters, those are ADR-015's pinned
|
||||||
|
rows verbatim).
|
||||||
|
|
||||||
|
## 8. Open questions (proposed — to be promoted into Phase 1's ledger)
|
||||||
|
|
||||||
|
These are the questions this research identifies as needing decisions
|
||||||
|
before any ADR or code. They're proposed in broker-local numbering
|
||||||
|
(QP-*) until Phase 1 promotes them as OQ-44+.
|
||||||
|
|
||||||
|
### QP-1: Topic identity — named topics, op-shaped topics, or a split?
|
||||||
|
|
||||||
|
Need A's model. Candidates:
|
||||||
|
|
||||||
|
- **(a) Named topics (strings).** topic = stream/event-type name; the
|
||||||
|
TS reference's shape; one-topic-one-pipe; alkstore-stream-aligned.
|
||||||
|
Matching is exact-name; wildcarding is optional and additive.
|
||||||
|
- **(b) Op-shaped (`(op_name, params_hash)`), ADR-046 §fan-out's
|
||||||
|
literal form.** Fan-out targets are Sub-typed ops; identity inherits
|
||||||
|
op internals. Pros: dedup key falls out; wire-consistency with
|
||||||
|
existing ops. Cons: the identity couples routing to op schema shape
|
||||||
|
(params_hash stability depends on the schema's canonicalization —
|
||||||
|
which is a real design burden, see §3's A/B split); one topic per op
|
||||||
|
per registrant, so cross-op co-subscription needs the hash of the
|
||||||
|
*matching rule* anyway.
|
||||||
|
- **(c) The split:** identity is named topics (a); "ops as topics"
|
||||||
|
survives only as Need B — a subscriber declares a topic *and* the
|
||||||
|
input schema it validates against, so per-subscription input
|
||||||
|
validation is a subscriber-side schema, validated at subscribe time
|
||||||
|
and at delivery time.
|
||||||
|
|
||||||
|
Working recommendation: **(c)** — identity as named topics, schema/ACL
|
||||||
|
inheritance via the registry on the two broker ops, dedup keying
|
||||||
|
re-derived from topic semantics rather than op params.
|
||||||
|
|
||||||
|
### QP-2: Dedup and delivery semantics
|
||||||
|
|
||||||
|
"A hash as a topic ... for deduping purposes" — what does dedup mean
|
||||||
|
here, precisely? Candidate framings to resolve: idempotent-publish
|
||||||
|
keys (producer-supplied event ids, dedup at the store seam), vs
|
||||||
|
subscriber-side dedup (at-least-once delivery + a dedup window), vs
|
||||||
|
no dedup contract in v1. The answer is pinned partly by the store
|
||||||
|
seam: durable adapters can offer producer-supplied-id dedup for free;
|
||||||
|
in-memory mode cannot (and shouldn't claim to). The contract must
|
||||||
|
state delivery semantics honestly per mode (live: at-most-once per
|
||||||
|
attached subscriber; durable-with-replay: at-least-once per cursor)
|
||||||
|
rather than promising a uniform thing two substrates can't both
|
||||||
|
provide.
|
||||||
|
|
||||||
|
### QP-3: Wire shape — new ops, new event type, or composed handler kinds?
|
||||||
|
|
||||||
|
- Broker ops registered into a registry (`topic/subscribe`,
|
||||||
|
`topic/publish`, optionally `topic/topics` listing) — additive, no
|
||||||
|
envelope change required: subscribe rides `call.responded` (Sub),
|
||||||
|
publish rides `call.published` (Sink).
|
||||||
|
- If wire-visible events beyond op names are wanted (e.g. per-topic
|
||||||
|
attach/detach events), they are *new envelope event types* — legal
|
||||||
|
under ADR-014's additive rule, but each one is a cross-language
|
||||||
|
deliverable to keep in sync; default to zero new event types.
|
||||||
|
|
||||||
|
### QP-4: ACL and schema on both halves
|
||||||
|
|
||||||
|
The operator's requirement, mapped to the registry: the broker ops
|
||||||
|
carry their own `AccessControl` (who may subscribe/publish at all) —
|
||||||
|
and each *topic's* authorization model (per-topic publish ACL, per-
|
||||||
|
topic subscribe ACL, maybe scope patterns) is the piece the fused
|
||||||
|
concept gestured at with "ACL patterns inside the pubsub." Decision
|
||||||
|
needed: per-topic ACL tables owned by the broker module vs topic ops
|
||||||
|
as individually registered specs (each topic's publish/subscribe ops
|
||||||
|
registered separately, inheriting the full registry ACL/visibility/
|
||||||
|
provenance machinery). The latter is the pure Need-B realization but
|
||||||
|
costs a registration per topic; the former is one registration pair
|
||||||
|
with a topic-table lookup. This materially changes the decomposition.
|
||||||
|
|
||||||
|
### QP-5: Subscription lifecycle
|
||||||
|
|
||||||
|
Attach (subscribe with optional position for durable mode), detach,
|
||||||
|
disconnect behavior (live mode: gone on transport drop; durable mode:
|
||||||
|
cursor persists, resubscribe at position — the alkstore streams row's
|
||||||
|
contract), subscriber cap policy (ADR-041 pattern), and interaction
|
||||||
|
with the abort cascade (ADR-020) when a subscriber's sink stream dies.
|
||||||
|
|
||||||
|
### QP-6: Cross-node fan-out and the hub's margin of work
|
||||||
|
|
||||||
|
Within one node: registry dispatch. Across nodes: the hub composes (it
|
||||||
|
holds `CallConnection`s; ADR-024 vocabulary). Decision: does v1 ship
|
||||||
|
any in-crate cross-connection fan-out helper (a `FanOut` that mirrors
|
||||||
|
`call.published` streams into peer connections), or does the hub wire
|
||||||
|
that itself from the primitives? ADR-042's relay exists for channels
|
||||||
|
byte-forwarding; its analog for event fan-out is un-specced — it is
|
||||||
|
either a small in-crate helper or hub-side composition, and the
|
||||||
|
placement ruling (§6) should state which explicitly so OQ-43 doesn't
|
||||||
|
inherit an ambiguity.
|
||||||
|
|
||||||
|
### QP-7: Backpressure across fan-out
|
||||||
|
|
||||||
|
One slow subscriber must not back-pressure the topic (the OQ-23/ADR-040
|
||||||
|
lesson carried into event delivery): per-subscriber bounded queues with
|
||||||
|
an explicit slow-subscriber policy (drop + event, drop + disconnect,
|
||||||
|
block-bounded). Policy default and configurability are to be pinned in
|
||||||
|
Phase 1; the ADR-040 bounded-buffer machinery is the reference.
|
||||||
|
|
||||||
|
### QP-8: Ordering guarantee
|
||||||
|
|
||||||
|
Per-topic FIFO (matches alkstore ADR-015 §1's pinned row and ADR-046's
|
||||||
|
per-stream delivery posture). The open part: does the broker promise
|
||||||
|
ordering *across* publishers to one topic (single sequencer required)
|
||||||
|
or only per-publisher (interleaving legal, no sequencer needed)? The
|
||||||
|
durable store behind the seam forces single-writer-per-topic-sequencer
|
||||||
|
for (a); in-memory could do either — but the contract has to match the
|
||||||
|
store's row, so this resolves with QP-2 rather than independently.
|
||||||
|
|
||||||
|
## 9. Recommendation and convergence criteria
|
||||||
|
|
||||||
|
**Direction (to carry into Phase 1, not final):** an in-crate,
|
||||||
|
opt-in broker composition module on the call registry — named topics
|
||||||
|
for identity (QP-1c), registry machinery providing schema + ACL on the
|
||||||
|
two broker ops (QP-3, QP-4 resolved as one topic-table vs per-topic
|
||||||
|
registration decision), a dependency-free `TopicStore` seam with the
|
||||||
|
broker itself as the in-memory adapter (QP-D from §7), per-topic FIFO
|
||||||
|
with an honest per-mode delivery contract (QP-2, QP-8), ADR-040-style
|
||||||
|
per-subscriber backpressure policy (QP-7), and an explicit statement
|
||||||
|
on the cross-node margin (QP-6). ADR-047 Gap B is amended by the Phase
|
||||||
|
1 ADR (which supersedes the placement ruling for this scope); OQ-42 is
|
||||||
|
resolved by that ADR; OQ-43 remains untouched (dial/engine/glue is
|
||||||
|
still the composition layer's).
|
||||||
|
|
||||||
|
**Convergence criteria — Phase 0 is done when:**
|
||||||
|
|
||||||
|
1. Every QP-* above carries a candidate resolution with its rejection
|
||||||
|
cases written down (the ADR-032 one-way-door discipline applies to
|
||||||
|
the wire-visible ones).
|
||||||
|
2. At least one concrete consumer surface has signed off on the topic
|
||||||
|
model — alkblobs' put-path ops for the publish half, the recorded
|
||||||
|
alkgit subscription vocabulary for the subscribe half (the review
|
||||||
|
011 downstream-input pattern: the consuming crate drives the
|
||||||
|
contract).
|
||||||
|
3. The QP-4 decision (topic-table vs per-topic registration) is made —
|
||||||
|
it is the largest decomposition determinant.
|
||||||
|
4. The placement amendment's blast radius is enumerated: which ADRs/
|
||||||
|
docs re-key (046 §fan-out, 047 Gap B, open-questions OQ-42, README
|
||||||
|
ADR tables; alkstore consumer-inventory rows are cross-repo notes,
|
||||||
|
updatable but not blocking).
|
||||||
|
|
||||||
|
**Out of scope for this research:** implementation, task decomposition,
|
||||||
|
version assignment, wire-format drafting. Nothing here creates a
|
||||||
|
`not_implemented` surface — the broker module does not exist and no
|
||||||
|
API names, docs, or changelog entries will advertise it until Phase 1's
|
||||||
|
ADR lands, per the implement-or-stop-advertising ruling (the only
|
||||||
|
permitted deferral statement is "not this session").
|
||||||
@@ -962,7 +962,11 @@ promises.
|
|||||||
vice versa) as the smaller change; record the decision in ADR-047 §5
|
vice versa) as the smaller change; record the decision in ADR-047 §5
|
||||||
or a follow-on ADR. Then implement the adoption path: the
|
or a follow-on ADR. Then implement the adoption path: the
|
||||||
non-allocating side registers the remotely-assigned id in its
|
non-allocating side registers the remotely-assigned id in its
|
||||||
manager/mux.
|
manager/mux. **[RESOLVED 2026-10-06/13: the implemented odd/even
|
||||||
|
split (connect odd, accept even) + `ChannelManager::adopt_channel`;
|
||||||
|
the marked-pub contract rides it end-to-end — ADR-047 amendment 4;
|
||||||
|
the C-08-blocker stub class this decision previously gated on is
|
||||||
|
closed with reviews 010/011's single-publish fixes.]**
|
||||||
- C-12: reject `channel_id: 0` in the `channel/close` handler.
|
- C-12: reject `channel_id: 0` in the `channel/close` handler.
|
||||||
- C-13: `channel/close` must drain (signal EOF to the handler, await
|
- C-13: `channel/close` must drain (signal EOF to the handler, await
|
||||||
pump completion) before `abort`/decrement; implement REQ-CH-06
|
pump completion) before `abort`/decrement; implement REQ-CH-06
|
||||||
|
|||||||
@@ -0,0 +1,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,
|
//! (single bidi stream, header-demuxed N channels — TCP+TLS,
|
||||||
//! WebTransport session, SSH `direct-tcpip`). The QUIC-native
|
//! WebTransport session, SSH `direct-tcpip`). The QUIC-native
|
||||||
//! multi-stream substrate (accept remaining bidi streams, read headers
|
//! 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;
|
//! The wire format and demux loop are correct for both substrates;
|
||||||
//! only the outer `accept_bi()` loop is missing.
|
//! only the outer `accept_bi()` loop is missing.
|
||||||
//!
|
//!
|
||||||
@@ -103,8 +104,8 @@ impl ChannelsAdapter {
|
|||||||
/// in-line substrate mode (single bidi stream, header-demuxed N
|
/// in-line substrate mode (single bidi stream, header-demuxed N
|
||||||
/// channels — TCP+TLS, WebTransport session, SSH `direct-tcpip`).
|
/// channels — TCP+TLS, WebTransport session, SSH `direct-tcpip`).
|
||||||
/// The QUIC-native multi-stream substrate (accept remaining bidi
|
/// The QUIC-native multi-stream substrate (accept remaining bidi
|
||||||
/// streams, read headers off each) is deferred to the downstream
|
/// streams, read headers off each) belongs in the composition
|
||||||
/// alknet crate (OQ-41). The wire format and demux loop are correct
|
/// 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.
|
/// for both substrates; only the outer `accept_bi()` loop is missing.
|
||||||
/// See ADR-034 §substrate modes.
|
/// See ADR-034 §substrate modes.
|
||||||
async fn run_demux_loop(
|
async fn run_demux_loop(
|
||||||
|
|||||||
+131
-2
@@ -70,8 +70,8 @@ impl ChannelOpenError {
|
|||||||
|
|
||||||
/// The establishment-failure reason code — `details.reason` of a
|
/// The establishment-failure reason code — `details.reason` of a
|
||||||
/// `channel:open_failed` error (`dial_failed`, `unknown_resource`,
|
/// `channel:open_failed` error (`dial_failed`, `unknown_resource`,
|
||||||
/// `resource_shortage`, `handler_error`, `timeout`). `None` for
|
/// `resource_shortage`, `handler_error`, `timeout`, `adopt_failed`).
|
||||||
/// any other failure shape.
|
/// `None` for any other failure shape.
|
||||||
pub fn establishment_reason(&self) -> Option<&str> {
|
pub fn establishment_reason(&self) -> Option<&str> {
|
||||||
match self {
|
match self {
|
||||||
ChannelOpenError::CallFailed { error }
|
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
|
/// The client-side handle for a channels connection. Constructed via
|
||||||
/// [`ChannelClient::from_connection`] from an established
|
/// [`ChannelClient::from_connection`] from an established
|
||||||
/// `Connection`. Holds the `ChannelManager` (for the demux/mux state)
|
/// `Connection`. Holds the `ChannelManager` (for the demux/mux state)
|
||||||
@@ -382,6 +406,111 @@ impl ChannelClient {
|
|||||||
.map(|(send, recv)| (channel_id, out, send, recv))
|
.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
|
/// Take the `CallConnection` — used by the consumer to register
|
||||||
/// imported ops (`from_call`) on the connection's overlay. After
|
/// imported ops (`from_call`) on the connection's overlay. After
|
||||||
/// this, `call_open_op` returns an error (the connection is owned
|
/// 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 TTY_ALPN: &str = "alk/tty";
|
||||||
const ALPN: &str = "alk/tunnel";
|
const ALPN: &str = "alk/tunnel";
|
||||||
const PLAIN_OP: &str = "fs/read";
|
const PLAIN_OP: &str = "fs/read";
|
||||||
|
const PUT_OP: &str = "channels/put/bulk";
|
||||||
|
|
||||||
fn identity(id: &str, scopes: &[&str]) -> Identity {
|
fn identity(id: &str, scopes: &[&str]) -> Identity {
|
||||||
Identity {
|
Identity {
|
||||||
@@ -256,6 +257,49 @@ fn make_spoke_install(
|
|||||||
)
|
)
|
||||||
.expect("spoke registers the tty open op");
|
.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
|
registry
|
||||||
.register(HandlerRegistration::new(
|
.register(HandlerRegistration::new(
|
||||||
plain_spec(PLAIN_OP),
|
plain_spec(PLAIN_OP),
|
||||||
@@ -740,3 +784,51 @@ async fn gate2_tty_standard_shape_relays_with_no_regression() {
|
|||||||
"the tty relay's ledger decremented"
|
"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
|
/// call to any op — including the bootstrap discovery ops — does not
|
||||||
/// resolve). Never a silent stub.
|
/// resolve). Never a silent stub.
|
||||||
#[tokio::test]
|
#[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(
|
let pub_bundle = HandlerRegistration::new(
|
||||||
relay_spec(
|
relay_spec(
|
||||||
"channels/x/pub",
|
"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 imports = HubLegImports::from_bundles(vec![pub_bundle]);
|
||||||
let consumer = start_hub(None, Some(imports), None, false).await;
|
let consumer = start_hub(None, Some(imports), None, false).await;
|
||||||
let response = tokio::time::timeout(
|
let response = consumer.call_open_op("services/list", json!({})).await;
|
||||||
std::time::Duration::from_secs(3),
|
let out = response
|
||||||
consumer.call_open_op("services/list", json!({})),
|
.result
|
||||||
)
|
.expect("the leg dispatches; the op re-exposed");
|
||||||
.await;
|
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!(
|
assert!(
|
||||||
response.is_err(),
|
names.contains(&"channels/x/pub"),
|
||||||
"the leg's channel 0 never dispatches after the loud assembly error, got {:?}",
|
"the pub openable is re-exposed through the relay, got: {names:?}"
|
||||||
response
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ use tokio::sync::mpsc;
|
|||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::protocol::wire::CallError;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use super::mux::{MuxHandle, OpenerLedger};
|
use super::mux::{MuxHandle, OpenerLedger};
|
||||||
use super::reassembly::{MpscRecvStream, MpscSendStream, DEFAULT_BUFFER_CAP};
|
use super::reassembly::{MpscRecvStream, MpscSendStream, DEFAULT_BUFFER_CAP};
|
||||||
|
|
||||||
@@ -38,6 +41,15 @@ pub enum ChannelSide {
|
|||||||
/// `ChannelLifecyclePolicy` (ADR-041, amended by ADR-047 §7).
|
/// `ChannelLifecyclePolicy` (ADR-041, amended by ADR-047 §7).
|
||||||
pub const DEFAULT_MAX_CHANNELS: usize = 256;
|
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
|
/// The per-channel state — the reassembly sender (the demux feeds chunk
|
||||||
/// payloads into it; the handler reads them out via `MpscRecvStream`),
|
/// payloads into it; the handler reads them out via `MpscRecvStream`),
|
||||||
/// the handler task handle, and the ALPN (for observability).
|
/// the handler task handle, and the ALPN (for observability).
|
||||||
@@ -62,6 +74,13 @@ pub enum ManagerError {
|
|||||||
UnknownChannel(u32),
|
UnknownChannel(u32),
|
||||||
#[error("channel already exists: {0}")]
|
#[error("channel already exists: {0}")]
|
||||||
ChannelExists(u32),
|
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`
|
/// 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.
|
/// drains them into the new receiver. `clear_all` drops the map.
|
||||||
early_arrivals: Mutex<HashMap<u32, VecDeque<Bytes>>>,
|
early_arrivals: Mutex<HashMap<u32, VecDeque<Bytes>>>,
|
||||||
early_arrival_count: AtomicU64,
|
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`
|
/// The per-channel cap on parked early-arrival chunks (`route_payload`
|
||||||
@@ -151,6 +174,7 @@ impl ChannelManager {
|
|||||||
dropped_unknown_chunks: AtomicU64::new(0),
|
dropped_unknown_chunks: AtomicU64::new(0),
|
||||||
early_arrivals: Mutex::new(HashMap::new()),
|
early_arrivals: Mutex::new(HashMap::new()),
|
||||||
early_arrival_count: AtomicU64::new(0),
|
early_arrival_count: AtomicU64::new(0),
|
||||||
|
control_handles: Mutex::new(HashMap::new()),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -317,6 +341,32 @@ impl ChannelManager {
|
|||||||
alpn: impl Into<String>,
|
alpn: impl Into<String>,
|
||||||
handler_task: Option<JoinHandle<()>>,
|
handler_task: Option<JoinHandle<()>>,
|
||||||
) -> Result<(MpscSendStream, MpscRecvStream), ManagerError> {
|
) -> 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 alpn = alpn.into();
|
||||||
|
|
||||||
let send = self
|
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).
|
/// Install channel 0 (pre-negotiated as `alk/call`, ADR-036).
|
||||||
/// Channel 0 is special only in that it's pre-allocated (by
|
/// Channel 0 is special only in that it's pre-allocated (by
|
||||||
/// `channels-call`); the `ChannelsAdapter` hands the resulting
|
/// `channels-call`); the `ChannelsAdapter` hands the resulting
|
||||||
@@ -530,6 +604,7 @@ impl ChannelManager {
|
|||||||
// Dropping `state.demux_sender` signals EOF to the
|
// Dropping `state.demux_sender` signals EOF to the
|
||||||
// handler's read half (REQ-CH-02).
|
// handler's read half (REQ-CH-02).
|
||||||
drop(state.demux_sender);
|
drop(state.demux_sender);
|
||||||
|
self.remove_control_handle(channel_id);
|
||||||
Ok(state.handler_task)
|
Ok(state.handler_task)
|
||||||
}
|
}
|
||||||
None => Err(ManagerError::UnknownChannel(channel_id)),
|
None => Err(ManagerError::UnknownChannel(channel_id)),
|
||||||
@@ -550,6 +625,7 @@ impl ChannelManager {
|
|||||||
task.abort();
|
task.abort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.inner.control_handles.lock().clear();
|
||||||
// The early-arrival buffers die with the connection too.
|
// The early-arrival buffers die with the connection too.
|
||||||
self.inner.early_arrivals.lock().clear();
|
self.inner.early_arrivals.lock().clear();
|
||||||
// The opener ledger has the opener PeerIds.
|
// The opener ledger has the opener PeerIds.
|
||||||
|
|||||||
@@ -31,6 +31,9 @@
|
|||||||
//! as amended by ADR-051): translate hop + `pump_bidi` byte-forward
|
//! as amended by ADR-051): translate hop + `pump_bidi` byte-forward
|
||||||
//! hop for from_call-imported marked specs re-exposed on a hub's
|
//! hop for from_call-imported marked specs re-exposed on a hub's
|
||||||
//! consumer-leg channel-0 registry.
|
//! 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
|
//! - [`hub_leg`]: `HubLegTemplate` — the hub-leg install template
|
||||||
//! (ADR-051 §5): fork + generic channel ops + bootstrap discovery +
|
//! (ADR-051 §5): fork + generic channel ops + bootstrap discovery +
|
||||||
//! plain bundles + relay openables + serving identity, composed into
|
//! plain bundles + relay openables + serving identity, composed into
|
||||||
@@ -53,5 +56,6 @@ pub mod policy;
|
|||||||
pub mod pump;
|
pub mod pump;
|
||||||
pub mod reassembly;
|
pub mod reassembly;
|
||||||
pub mod relay;
|
pub mod relay;
|
||||||
|
pub mod resource;
|
||||||
pub mod source;
|
pub mod source;
|
||||||
pub mod wire;
|
pub mod wire;
|
||||||
+790
-237
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::manager::ChannelManager;
|
||||||
use super::operations::{
|
use super::operations::{
|
||||||
ChannelCore, Establishment, EstablishmentError, OpenEstablisher, OpenHandler,
|
ChannelCore, ChannelPlan, Establishment, EstablishmentError, OpenEstablisher, OpenHandler,
|
||||||
CHANNEL_OPEN_FAILED, RESERVED_REPLY_KEY,
|
PubOpenHandler, CHANNEL_OPEN_FAILED, RESERVED_REPLY_KEY,
|
||||||
};
|
};
|
||||||
use super::pump::pump_bidi;
|
use super::pump::pump_bidi;
|
||||||
|
|
||||||
use super::reassembly::{MpscRecvStream, MpscSendStream};
|
use super::reassembly::{MpscRecvStream, MpscSendStream};
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
/// The producer-leg surface the relay holds (ADR-051 §2): the
|
/// The producer-leg surface the relay holds (ADR-051 §2): the
|
||||||
/// `CallConnection` for the translate hop's open call plus the
|
/// `CallConnection` for the translate hop's open call plus the
|
||||||
@@ -81,8 +83,17 @@ struct RelayPlan {
|
|||||||
producer_manager: ChannelManager,
|
producer_manager: ChannelManager,
|
||||||
spoke_send: Option<MpscSendStream>,
|
spoke_send: Option<MpscSendStream>,
|
||||||
spoke_recv: Option<MpscRecvStream>,
|
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 {
|
impl Drop for RelayPlan {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = self.producer_manager.teardown_channel(self.spoke_id);
|
let _ = self.producer_manager.teardown_channel(self.spoke_id);
|
||||||
@@ -120,9 +131,11 @@ impl ChannelRelay {
|
|||||||
/// produced open op.
|
/// produced open op.
|
||||||
/// - `spec` must carry the `channel_open` marker (reconstructed
|
/// - `spec` must carry the `channel_open` marker (reconstructed
|
||||||
/// WITH the marker by discovery, ADR-047 amendment 3). A
|
/// WITH the marker by discovery, ADR-047 amendment 3). A
|
||||||
/// `Pub`-typed marked spec is a loud assembly error
|
/// `Pub`-typed marked spec composes through the put-forwarding
|
||||||
/// ([`RelayError::PubTypedOpen`] — the C-08 blocker, ADR-051
|
/// translation (`register_relay_pub_openable`; ADR-047 amendment 4
|
||||||
/// §6); an unmarked spec is likewise an error — branch on the
|
/// §4.7); an
|
||||||
|
/// unmarked spec is a
|
||||||
|
/// [`RelayError::UnmarkedSpec`] error — branch on the
|
||||||
/// marker before calling.
|
/// marker before calling.
|
||||||
/// - The translate hop calls the producer leg with the spec's own
|
/// - The translate hop calls the producer leg with the spec's own
|
||||||
/// op name; a hub that re-names the consumer-visible op composes
|
/// op name; a hub that re-names the consumer-visible op composes
|
||||||
@@ -145,14 +158,12 @@ impl ChannelRelay {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if spec.op_type == OperationType::Pub {
|
if spec.op_type == OperationType::Pub {
|
||||||
return Err(RelayError::PubTypedOpen {
|
return self.register_relay_pub_openable(
|
||||||
message: format!(
|
consumer_core,
|
||||||
"spec `{}` is a Pub-typed channel-open op — the Pub open path is \
|
consumer_registry,
|
||||||
not implemented (C-08 blocker, `channel:pub_open_not_implemented` \
|
spec,
|
||||||
class; ADR-051 §6)",
|
consumer_auth,
|
||||||
spec.name
|
);
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let producer_call = Arc::clone(&self.producer_leg.call);
|
let producer_call = Arc::clone(&self.producer_leg.call);
|
||||||
@@ -227,6 +238,76 @@ impl ChannelRelay {
|
|||||||
)
|
)
|
||||||
.map_err(|e| RelayError::Registration { message: e })
|
.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
|
/// 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(),
|
producer_manager: producer_manager.clone(),
|
||||||
spoke_send: Some(spoke_send),
|
spoke_send: Some(spoke_send),
|
||||||
spoke_recv: Some(spoke_recv),
|
spoke_recv: Some(spoke_recv),
|
||||||
|
pub_reply: None,
|
||||||
}) as super::operations::ChannelPlan);
|
}) as super::operations::ChannelPlan);
|
||||||
Ok(if reply_fields.is_empty() {
|
Ok(if reply_fields.is_empty() {
|
||||||
establishment
|
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
|
/// Build the `call.requested` payload for the translate hop: the hub
|
||||||
/// as caller, the consumer as `forwarded_for` (ADR-026 §3, ADR-032 §3
|
/// as caller, the consumer as `forwarded_for` (ADR-026 §3, ADR-032 §3
|
||||||
/// — the same shape `build_forwarded_payload` produces for the plain
|
/// — 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`
|
/// The assembly-time errors `ChannelRelay::register_relay_openable`
|
||||||
/// produces (ADR-051 §6 — the rejection postures are loud, never
|
/// 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)]
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
pub enum RelayError {
|
pub enum RelayError {
|
||||||
/// The spec carries no `channel_open` marker — the relay only
|
/// The spec carries no `channel_open` marker — the relay only
|
||||||
/// registers marked specs.
|
/// registers marked specs.
|
||||||
#[error("unmarked spec: {message}")]
|
#[error("unmarked spec: {message}")]
|
||||||
UnmarkedSpec { message: String },
|
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,
|
/// The consumer-leg registration itself failed (schema compile,
|
||||||
/// handler-kind mismatch).
|
/// handler-kind mismatch).
|
||||||
#[error("registration failed: {message}")]
|
#[error("registration failed: {message}")]
|
||||||
|
|||||||
+25
-11
@@ -89,15 +89,21 @@ async fn unmarked_spec_is_rejected_at_registration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 {
|
let relay = ChannelRelay::new(ProducerLeg {
|
||||||
call: overlay_connection(),
|
call: overlay_connection(),
|
||||||
manager: manager_for_test(),
|
manager: producer_manager,
|
||||||
});
|
});
|
||||||
let err = relay
|
let registry = OperationRegistry::new();
|
||||||
|
relay
|
||||||
.register_relay_openable(
|
.register_relay_openable(
|
||||||
&core_for_test(),
|
&core_for_test(),
|
||||||
&OperationRegistry::new(),
|
®istry,
|
||||||
relay_spec(
|
relay_spec(
|
||||||
"channels/x/pub",
|
"channels/x/pub",
|
||||||
OperationType::Pub,
|
OperationType::Pub,
|
||||||
@@ -105,12 +111,10 @@ async fn pub_typed_marked_spec_is_rejected_at_registration() {
|
|||||||
),
|
),
|
||||||
AuthContext::anonymous(b"alk/call"),
|
AuthContext::anonymous(b"alk/call"),
|
||||||
)
|
)
|
||||||
.expect_err("Pub-typed marked spec must be rejected");
|
.expect("the pub openable registers through the relay");
|
||||||
assert!(
|
assert!(registry.registration("channels/x/pub").is_some());
|
||||||
matches!(err, RelayError::PubTypedOpen { .. }),
|
let registration = registry.registration("channels/x/pub").expect("registered");
|
||||||
"got {err:?}"
|
assert!(registration.spec.channel_open.is_some());
|
||||||
);
|
|
||||||
assert!(err.to_string().contains("pub_open_not_implemented"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ADR-051 §3: the reason mapping --------------------------------------
|
// --- 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(),
|
producer_manager: producer_manager.clone(),
|
||||||
spoke_send: Some(spoke_send),
|
spoke_send: Some(spoke_send),
|
||||||
spoke_recv: Some(spoke_recv),
|
spoke_recv: Some(spoke_recv),
|
||||||
|
pub_reply: None,
|
||||||
};
|
};
|
||||||
drop(plan);
|
drop(plan);
|
||||||
|
|
||||||
@@ -256,7 +261,16 @@ fn manager_for_test() -> ChannelManager {
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = runner.run().await;
|
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 {
|
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
|
/// `from_call` against a stub `CallConnection` (no real transport) returns
|
||||||
/// a `DiscoveryFailed` because `services/list` can't dispatch on a mock
|
/// a `DiscoveryFailed` because `services/list` can't dispatch on a mock
|
||||||
/// connection. This verifies the error path rather than the happy path
|
/// 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]
|
#[tokio::test]
|
||||||
async fn from_call_against_mock_connection_returns_discovery_failed() {
|
async fn from_call_against_mock_connection_returns_discovery_failed() {
|
||||||
let conn = CallConnection::new(stub_connection());
|
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
|
// crate-internal surface for the `op/register` bootstrap path (review
|
||||||
// 004 F-05): the forwarding-handler constructor and the spec wire
|
// 004 F-05): the forwarding-handler constructor and the spec wire
|
||||||
// parser are shared with `registry::op_register`.
|
// 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;
|
use crate::registry::registration::HandlerRegistration;
|
||||||
|
|
||||||
/// Errors produced by [`OperationAdapter::import`].
|
/// Errors produced by [`OperationAdapter::import`].
|
||||||
///
|
///
|
||||||
/// The variant set is the v1 default (two-way-door remainder, OQ-26);
|
/// 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
|
/// `from_openapi`/`from_mcp`) extend without breaking match arms. All
|
||||||
/// payloads are string messages — kept simple and `Send + Sync` by
|
/// payloads are string messages — kept simple and `Send + Sync` by
|
||||||
/// construction.
|
/// construction.
|
||||||
|
|||||||
+1
-1
@@ -64,7 +64,7 @@ pub struct Capabilities {
|
|||||||
|
|
||||||
impl Zeroize for Capabilities {
|
impl Zeroize for Capabilities {
|
||||||
fn zeroize(&mut self) {
|
fn zeroize(&mut self) {
|
||||||
for (_, v) in self.entries.iter_mut() {
|
for v in self.entries.values_mut() {
|
||||||
v.zeroize();
|
v.zeroize();
|
||||||
}
|
}
|
||||||
self.entries.clear();
|
self.entries.clear();
|
||||||
|
|||||||
@@ -538,7 +538,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
Capabilities::new(),
|
||||||
);
|
);
|
||||||
conn.register_imported(imported);
|
let _error = conn.register_imported(imported);
|
||||||
let peer_identity = Identity {
|
let peer_identity = Identity {
|
||||||
id: "worker-a".to_string(),
|
id: "worker-a".to_string(),
|
||||||
scopes: vec![],
|
scopes: vec![],
|
||||||
@@ -607,7 +607,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
Capabilities::new(),
|
||||||
);
|
);
|
||||||
conn.register_imported(imported);
|
let _error = conn.register_imported(imported);
|
||||||
|
|
||||||
let context =
|
let context =
|
||||||
adapter.build_root_context("req-6".to_string(), "fs/readFile", None, None, &conn);
|
adapter.build_root_context("req-6".to_string(), "fs/readFile", None, None, &conn);
|
||||||
|
|||||||
+52
-14
@@ -159,9 +159,27 @@ impl CallConnection {
|
|||||||
&self.pending
|
&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();
|
let name = registration.spec.name.clone();
|
||||||
self.imported_operations.write().insert(name, registration);
|
self.imported_operations.write().insert(name, registration);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `true` when the connection overlay holds a registration for
|
/// `true` when the connection overlay holds a registration for
|
||||||
@@ -176,11 +194,30 @@ impl CallConnection {
|
|||||||
self.imported_operations.read().get(name).cloned()
|
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();
|
let mut overlay = self.imported_operations.write();
|
||||||
for reg in registrations {
|
for reg in ®istrations {
|
||||||
overlay.insert(reg.spec.name.clone(), reg);
|
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> {
|
pub fn overlay_env(&self) -> Arc<dyn OperationEnv + Send + Sync> {
|
||||||
@@ -975,7 +1012,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(!env.contains("worker/exec"));
|
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/exec"));
|
||||||
assert!(!env.contains("worker/missing"));
|
assert!(!env.contains("worker/missing"));
|
||||||
@@ -986,11 +1023,12 @@ mod tests {
|
|||||||
let conn = CallConnection::new(stub_connection());
|
let conn = CallConnection::new(stub_connection());
|
||||||
let env = conn.overlay_env();
|
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/exec"),
|
||||||
imported_registration("worker/status"),
|
imported_registration("worker/status"),
|
||||||
imported_registration("fs/readFile"),
|
imported_registration("fs/readFile"),
|
||||||
]);
|
]);
|
||||||
|
let _error = registrations_result;
|
||||||
|
|
||||||
assert!(env.contains("worker/exec"));
|
assert!(env.contains("worker/exec"));
|
||||||
assert!(env.contains("worker/status"));
|
assert!(env.contains("worker/status"));
|
||||||
@@ -1001,7 +1039,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn overlay_env_dispatches_to_imported_op() {
|
async fn overlay_env_dispatches_to_imported_op() {
|
||||||
let conn = CallConnection::new(stub_connection());
|
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 env = conn.overlay_env();
|
||||||
|
|
||||||
let scoped = ScopedPeerEnv::new(["worker/exec"]);
|
let scoped = ScopedPeerEnv::new(["worker/exec"]);
|
||||||
@@ -1018,7 +1056,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn overlay_env_contains_returns_false_for_non_imported_op() {
|
async fn overlay_env_contains_returns_false_for_non_imported_op() {
|
||||||
let conn = CallConnection::new(stub_connection());
|
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 env = conn.overlay_env();
|
||||||
|
|
||||||
assert!(!env.contains("worker/missing"));
|
assert!(!env.contains("worker/missing"));
|
||||||
@@ -1039,7 +1077,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn overlay_env_reachability_check_returns_not_found_for_disallowed_op() {
|
async fn overlay_env_reachability_check_returns_not_found_for_disallowed_op() {
|
||||||
let conn = CallConnection::new(stub_connection());
|
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 env = conn.overlay_env();
|
||||||
|
|
||||||
let scoped = ScopedPeerEnv::empty();
|
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"),
|
external_spec("worker/exec"),
|
||||||
HandlerKind::Once(inspect_handler),
|
HandlerKind::Once(inspect_handler),
|
||||||
OperationProvenance::FromCall,
|
OperationProvenance::FromCall,
|
||||||
@@ -1115,7 +1153,7 @@ mod tests {
|
|||||||
Arc::new(RwLock::new(HashMap::new()));
|
Arc::new(RwLock::new(HashMap::new()));
|
||||||
{
|
{
|
||||||
let conn = CallConnection::new(stub_connection());
|
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"));
|
assert!(conn.overlay_env().contains("worker/exec"));
|
||||||
std::mem::swap(
|
std::mem::swap(
|
||||||
&mut *captured.write(),
|
&mut *captured.write(),
|
||||||
@@ -1380,14 +1418,14 @@ mod tests {
|
|||||||
let conn = CallConnection::new_overlay_only(sample_identity("ws-peer"));
|
let conn = CallConnection::new_overlay_only(sample_identity("ws-peer"));
|
||||||
let env = conn.overlay_env();
|
let env = conn.overlay_env();
|
||||||
assert!(!env.contains("worker/exec"));
|
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/exec"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn overlay_only_overlay_env_dispatches_imported_op() {
|
async fn overlay_only_overlay_env_dispatches_imported_op() {
|
||||||
let conn = CallConnection::new_overlay_only(sample_identity("ws-peer"));
|
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 env = conn.overlay_env();
|
||||||
|
|
||||||
let scoped = ScopedPeerEnv::new(["worker/exec"]);
|
let scoped = ScopedPeerEnv::new(["worker/exec"]);
|
||||||
@@ -1433,7 +1471,7 @@ mod tests {
|
|||||||
let streaming_handler = make_streaming_handler(|input, ctx| {
|
let streaming_handler = make_streaming_handler(|input, ctx| {
|
||||||
futures::stream::iter(vec![ResponseEnvelope::ok(ctx.request_id, input)])
|
futures::stream::iter(vec![ResponseEnvelope::ok(ctx.request_id, input)])
|
||||||
});
|
});
|
||||||
conn.register_imported(HandlerRegistration::new(
|
let _error = conn.register_imported(HandlerRegistration::new(
|
||||||
OperationSpec::new(
|
OperationSpec::new(
|
||||||
"events/stream",
|
"events/stream",
|
||||||
OperationType::Sub,
|
OperationType::Sub,
|
||||||
|
|||||||
+110
-2
@@ -86,6 +86,11 @@ pub struct SinkDispatch {
|
|||||||
pub(crate) handler: Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>,
|
pub(crate) handler: Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>,
|
||||||
pub(crate) chunk_tx: mpsc::Sender<Result<Value, CallError>>,
|
pub(crate) chunk_tx: mpsc::Sender<Result<Value, CallError>>,
|
||||||
pub(crate) publish_validator: Option<jsonschema::Validator>,
|
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
|
/// An in-flight Pub (Sink) on the single-stream dispatch loop
|
||||||
@@ -100,6 +105,11 @@ struct InFlightSink {
|
|||||||
chunk_tx: mpsc::Sender<Result<Value, CallError>>,
|
chunk_tx: mpsc::Sender<Result<Value, CallError>>,
|
||||||
publish_validator: Option<jsonschema::Validator>,
|
publish_validator: Option<jsonschema::Validator>,
|
||||||
handler_handle: JoinHandle<()>,
|
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 {
|
impl std::fmt::Debug for DispatchResult {
|
||||||
@@ -356,7 +366,7 @@ impl Dispatcher {
|
|||||||
.get("forwarded_for")
|
.get("forwarded_for")
|
||||||
.and_then(|v| serde_json::from_value::<Identity>(v.clone()).ok());
|
.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
|
let op_type = self
|
||||||
.registry
|
.registry
|
||||||
@@ -391,9 +401,13 @@ impl Dispatcher {
|
|||||||
context.deadline = None;
|
context.deadline = None;
|
||||||
match self
|
match self
|
||||||
.registry
|
.registry
|
||||||
.resolve_sink_handler(&operation_name, &input, &context)
|
.resolve_sink_handler(&operation_name, &mut input, &mut context)
|
||||||
{
|
{
|
||||||
Ok(sink_handler) => {
|
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 publish_validator = self.registry.publish_validator(&operation_name);
|
||||||
let (chunk_tx, chunk_rx) =
|
let (chunk_tx, chunk_rx) =
|
||||||
mpsc::channel::<Result<Value, CallError>>(PUBLISH_CHANNEL_BUFFER);
|
mpsc::channel::<Result<Value, CallError>>(PUBLISH_CHANNEL_BUFFER);
|
||||||
@@ -403,6 +417,7 @@ impl Dispatcher {
|
|||||||
handler: Box::pin(handler),
|
handler: Box::pin(handler),
|
||||||
chunk_tx,
|
chunk_tx,
|
||||||
publish_validator,
|
publish_validator,
|
||||||
|
reject_publish,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(envelope) => StartedDispatch::Once(Box::pin(std::future::ready(envelope))),
|
Err(envelope) => StartedDispatch::Once(Box::pin(std::future::ready(envelope))),
|
||||||
@@ -657,6 +672,7 @@ impl Dispatcher {
|
|||||||
handler,
|
handler,
|
||||||
mut chunk_tx,
|
mut chunk_tx,
|
||||||
publish_validator,
|
publish_validator,
|
||||||
|
reject_publish,
|
||||||
} = sink;
|
} = sink;
|
||||||
|
|
||||||
let (abort_tx, mut abort_rx) = oneshot::channel::<()>();
|
let (abort_tx, mut abort_rx) = oneshot::channel::<()>();
|
||||||
@@ -668,6 +684,7 @@ impl Dispatcher {
|
|||||||
let mut handler = handler.fuse();
|
let mut handler = handler.fuse();
|
||||||
let mut response: Option<ResponseEnvelope> = None;
|
let mut response: Option<ResponseEnvelope> = None;
|
||||||
let mut aborted = false;
|
let mut aborted = false;
|
||||||
|
let mut reject_written = false;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -698,6 +715,31 @@ impl Dispatcher {
|
|||||||
}
|
}
|
||||||
match envelope.r#type.as_str() {
|
match envelope.r#type.as_str() {
|
||||||
EVENT_PUBLISHED => {
|
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
|
let chunk = envelope
|
||||||
.payload
|
.payload
|
||||||
.get("input")
|
.get("input")
|
||||||
@@ -923,6 +965,7 @@ impl Dispatcher {
|
|||||||
handler,
|
handler,
|
||||||
chunk_tx,
|
chunk_tx,
|
||||||
publish_validator,
|
publish_validator,
|
||||||
|
reject_publish,
|
||||||
} = sink;
|
} = sink;
|
||||||
let handle = Self::spawn_sink_response_writer(
|
let handle = Self::spawn_sink_response_writer(
|
||||||
&writer,
|
&writer,
|
||||||
@@ -935,6 +978,8 @@ impl Dispatcher {
|
|||||||
chunk_tx,
|
chunk_tx,
|
||||||
publish_validator,
|
publish_validator,
|
||||||
handler_handle: handle,
|
handler_handle: handle,
|
||||||
|
reject_publish,
|
||||||
|
reject_written: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -977,6 +1022,36 @@ impl Dispatcher {
|
|||||||
.unwrap_or(Value::Null);
|
.unwrap_or(Value::Null);
|
||||||
let entry = in_flight_sinks.lock().remove(&request_id);
|
let entry = in_flight_sinks.lock().remove(&request_id);
|
||||||
if let Some(mut entry) = entry {
|
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 {
|
let validated = match &entry.publish_validator {
|
||||||
Some(validator) => {
|
Some(validator) => {
|
||||||
if validator.is_valid(&chunk) {
|
if validator.is_valid(&chunk) {
|
||||||
@@ -1146,6 +1221,7 @@ impl Dispatcher {
|
|||||||
handler,
|
handler,
|
||||||
chunk_tx,
|
chunk_tx,
|
||||||
publish_validator,
|
publish_validator,
|
||||||
|
reject_publish,
|
||||||
} = sink;
|
} = sink;
|
||||||
let handle = Self::spawn_sink_response_writer(
|
let handle = Self::spawn_sink_response_writer(
|
||||||
&writer,
|
&writer,
|
||||||
@@ -1158,6 +1234,8 @@ impl Dispatcher {
|
|||||||
chunk_tx,
|
chunk_tx,
|
||||||
publish_validator,
|
publish_validator,
|
||||||
handler_handle: handle,
|
handler_handle: handle,
|
||||||
|
reject_publish,
|
||||||
|
reject_written: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1200,6 +1278,36 @@ impl Dispatcher {
|
|||||||
.unwrap_or(Value::Null);
|
.unwrap_or(Value::Null);
|
||||||
let entry = in_flight_sinks.lock().remove(&request_id);
|
let entry = in_flight_sinks.lock().remove(&request_id);
|
||||||
if let Some(mut entry) = entry {
|
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 {
|
let validated = match &entry.publish_validator {
|
||||||
Some(validator) => {
|
Some(validator) => {
|
||||||
if validator.is_valid(&chunk) {
|
if validator.is_valid(&chunk) {
|
||||||
|
|||||||
+151
-22
@@ -178,16 +178,37 @@ pub fn op_register_handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut spec = request.spec;
|
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;
|
spec.visibility = Visibility::Internal;
|
||||||
let remote_name = spec.name.clone();
|
let remote_name = spec.name.clone();
|
||||||
|
|
||||||
let handler = forwarding_stub_for_announced_op(
|
let handler = forwarding_kind_for_announced_op(
|
||||||
Arc::clone(&connection),
|
Arc::clone(&connection),
|
||||||
remote_name,
|
remote_name,
|
||||||
spec.op_type,
|
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(),
|
spec.clone(),
|
||||||
handler,
|
handler,
|
||||||
OperationProvenance::FromCall,
|
OperationProvenance::FromCall,
|
||||||
@@ -195,6 +216,12 @@ pub fn op_register_handler(
|
|||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
Capabilities::new(),
|
||||||
));
|
));
|
||||||
|
if let Err(e) = registered {
|
||||||
|
return ResponseEnvelope::error(
|
||||||
|
context.request_id,
|
||||||
|
CallError::internal(format!("op/register: overlay landing refused: {e}")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ResponseEnvelope::ok(
|
ResponseEnvelope::ok(
|
||||||
context.request_id,
|
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 forwarding handler kind for a peer-announced op. Query/Mutation
|
||||||
/// the `from_call` forwarding shape (nested `call.requested` back over
|
/// get the `from_call` forwarding shape (nested `call.requested` back
|
||||||
/// channel 0, `forwarded_for` populated per ADR-032 §3). Announced
|
/// over channel 0, `forwarded_for` populated per ADR-032 §3); unmarked
|
||||||
/// Sub/Pub ops are registered as stubs that return
|
/// `Sub`/`Pub` get the real streaming/sink forwarding shapes (review
|
||||||
/// `INVALID_OPERATION_TYPE` on invocation — nested composition is
|
/// 010 S-04's remedy: never accept-then-fail — the handler actually
|
||||||
/// request/response-only (`OverlayOperationEnv`'s contract); the
|
/// forwards). Marked specs refuse at this boundary (ADR-047 amendment
|
||||||
/// streaming/sink forwarding shapes ride on the `from_call` import
|
/// 4 — checked before this function is reached).
|
||||||
/// path, which the announcing side can use in the other direction.
|
fn forwarding_kind_for_announced_op(
|
||||||
fn forwarding_stub_for_announced_op(
|
|
||||||
connection: Arc<CallConnection>,
|
connection: Arc<CallConnection>,
|
||||||
remote_name: String,
|
remote_name: String,
|
||||||
op_type: OperationType,
|
op_type: OperationType,
|
||||||
@@ -221,17 +247,13 @@ fn forwarding_stub_for_announced_op(
|
|||||||
OperationType::Query | OperationType::Mutation => HandlerKind::Once(
|
OperationType::Query | OperationType::Mutation => HandlerKind::Once(
|
||||||
crate::client::make_forwarding_handler(connection, remote_name),
|
crate::client::make_forwarding_handler(connection, remote_name),
|
||||||
),
|
),
|
||||||
OperationType::Sub | OperationType::Pub => {
|
OperationType::Sub => HandlerKind::Stream(
|
||||||
HandlerKind::Once(make_handler(|_input, context| async move {
|
crate::client::make_streaming_forwarding_handler(connection, remote_name),
|
||||||
ResponseEnvelope::error(
|
),
|
||||||
context.request_id,
|
OperationType::Pub => HandlerKind::Sink(crate::client::make_sink_forwarding_handler(
|
||||||
CallError::invalid_operation_type(
|
connection,
|
||||||
"peer-announced Sub/Pub ops are not invocable over nested \
|
remote_name,
|
||||||
composition (request/response only)",
|
)),
|
||||||
),
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,4 +779,111 @@ mod tests {
|
|||||||
.expect("marker survives the announce");
|
.expect("marker survives the announce");
|
||||||
assert_eq!(marker.alpn, "alk/tunnel");
|
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 composition_authority: Option<CompositionAuthority>,
|
||||||
pub scoped_env: Option<ScopedPeerEnv>,
|
pub scoped_env: Option<ScopedPeerEnv>,
|
||||||
pub capabilities: Capabilities,
|
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 {
|
impl HandlerRegistration {
|
||||||
@@ -102,8 +108,17 @@ impl HandlerRegistration {
|
|||||||
composition_authority,
|
composition_authority,
|
||||||
scoped_env,
|
scoped_env,
|
||||||
capabilities,
|
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 {
|
pub struct OperationRegistry {
|
||||||
@@ -215,6 +230,23 @@ impl OperationRegistry {
|
|||||||
registration.spec.op_type, expected, actual
|
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_publish_schema(&mut self.validators.write(), ®istration.spec)?;
|
||||||
Self::validate_and_cache_input_schema(
|
Self::validate_and_cache_input_schema(
|
||||||
&mut self.input_validators.write(),
|
&mut self.input_validators.write(),
|
||||||
@@ -436,12 +468,21 @@ impl OperationRegistry {
|
|||||||
/// dispatch path (`Dispatcher::dispatch` Pub branch) call it, so the
|
/// dispatch path (`Dispatcher::dispatch` Pub branch) call it, so the
|
||||||
/// two paths cannot diverge (P-08: the `invoke_sink` path IS the
|
/// two paths cannot diverge (P-08: the `invoke_sink` path IS the
|
||||||
/// wire path).
|
/// 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)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub(crate) fn resolve_sink_handler(
|
pub(crate) fn resolve_sink_handler(
|
||||||
&self,
|
&self,
|
||||||
name: &str,
|
name: &str,
|
||||||
input: &Value,
|
input: &mut Value,
|
||||||
context: &OperationContext,
|
context: &mut OperationContext,
|
||||||
) -> Result<SinkHandler, ResponseEnvelope> {
|
) -> Result<SinkHandler, ResponseEnvelope> {
|
||||||
let request_id = context.request_id.clone();
|
let request_id = context.request_id.clone();
|
||||||
let registration = match self.registration(name) {
|
let registration = match self.registration(name) {
|
||||||
@@ -477,6 +518,43 @@ impl OperationRegistry {
|
|||||||
return Err(ResponseEnvelope::forbidden(request_id, message));
|
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)?;
|
self.check_input_schema(®istration, input, &request_id)?;
|
||||||
|
|
||||||
match ®istration.handler {
|
match ®istration.handler {
|
||||||
@@ -505,14 +583,16 @@ impl OperationRegistry {
|
|||||||
pub async fn invoke_sink(
|
pub async fn invoke_sink(
|
||||||
&self,
|
&self,
|
||||||
name: &str,
|
name: &str,
|
||||||
input: Value,
|
mut input: Value,
|
||||||
publish_stream: PublishStream,
|
publish_stream: PublishStream,
|
||||||
context: OperationContext,
|
mut context: OperationContext,
|
||||||
) -> ResponseEnvelope {
|
) -> ResponseEnvelope {
|
||||||
match self.resolve_sink_handler(name, &input, &context) {
|
let publish_stream_resolved =
|
||||||
Err(envelope) => envelope,
|
match self.resolve_sink_handler(name, &mut input, &mut context) {
|
||||||
Ok(sink_handler) => sink_handler(input, context, publish_stream).await,
|
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");
|
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>,
|
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 {
|
impl ChannelOpenSpec {
|
||||||
pub fn new(alpn: impl Into<Cow<'static, str>>) -> Self {
|
pub fn new(alpn: impl Into<Cow<'static, str>>) -> Self {
|
||||||
Self { alpn: alpn.into() }
|
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