13 Commits
Author SHA1 Message Date
glm-5.3-flash 1a493e9748 chore(release): 0.9.0 — the stub sweep
- version 0.8.1 -> 0.9.0 (minor bump: 5 breaking lints vs 0.8.1 —
  ManagerError::InvalidAdopt variant, RelayError::PubTypedOpen
  removal, register_imported* Result return, Establishment Debug
  derive dropped; all pre-1.0 permitted)
- CHANGELOG 0.9.0: S-01..S-04 from reviews 010/011 (the marked-pub
  open contract, channel/control, channel/resources/subscribe,
  op/register forward-through), the stub guard, and the
  verification summary
- open_pub_channel: a non-object input tears down the freshly
  allocated id before the early return (the slot is not parked
  against the ADR-040 bound; regression test added)
- register_imported_all: atomic — a refusal leaves the overlay
  untouched; atomicity documented

Verified: cargo test (697 + stub guard), clippy --all-targets
--all-features -- -D warnings, fmt --check, cargo doc,
wasm32-unknown-unknown check, fuzz corpus replay 5/5,
cargo publish --dry-run --allow-dirty clean.
2026-10-14 04:50:55 +00:00
glm-5.3-flash 4278c13b2d docs(research): phase-0 research — the topic broker's placement, shape, and the op-as-topic tangle
OQ-42 (the broker) is deferred(scope) on two uncommitted blockers: a
consumer crate committing to fan-out (its named candidate, alktrader,
drifted away) and the composition layer's ownership (OQ-43). With four
published dependents and alkgit/alkfs growing subscription surfaces on
alkblobs downstream, deferral now compounds: a later broker delivery
ripples 6-8 crates vs riding the v0.9 bump. This research disentangles
the fused 'op as topic' concept (topic identity vs the
schema/ACL-validated op surface), inventories the delivered primitives
the broker composes on, proposes the in-crate opt-in composition-module
placement amendment (ADR-047 Gap B) and the dependency-free TopicStore
durability seam (in-memory default; mem/sqlite/postgres adapters
downstream, never an engine dep in alkcall), and enumerates eight
proposed open questions (QP-1..8) with convergence criteria for Phase 1.

No behavior change; docs/research is excluded from the published package.

Verified: cargo test (697 ok), clippy -D warnings, fmt --check, cargo doc --no-deps
2026-10-13 19:47:42 +00:00
glm-5.3-flash 3ca7505abd docs(src): re-key live docs.rs comments off the alknet replacement
- src/channels/adapter.rs: OQ-41's QUIC-native substrate deferral now
  names the composition layer (owner OQ-43, today the alknet glue)
  instead of repo-pinning 'the downstream alknet crate' — matching the
  re-key convention already applied to docs/architecture
- src/client/mod.rs: AdapterError's downstream-adapter example renamed
  from the retired `alknet-http` to `alkhttp`
- src/client/from_call.rs: stale 'integration test in a later task'
  hedging replaced with the actual loopback coverage (relay_tests,
  hub_leg_tests, gate2_tests)

Verified: cargo test (697 ok incl. stub_guard), clippy -D warnings,
fmt --check
2026-10-13 11:32:12 +00:00
glm-5.3-flash ff07f92bdb docs(agents): fix stale references in architect.md and coordinator.md
architect.md:
- ADR-009 §"What this framework is NOT" → ADR-032 — the pointer was an
  alknet-numbering leftover; in-crate ADR-009 is BiStream-as-handler-leaf,
  the door-framework section lives in ADR-032. Same collision class the
  docs pass (5f019c0) fixed in the spec.
- OQ status list now includes deferred(scope), deferred(unclear),
  dissolved — matching the tracker's actual vocabulary and this prompt's
  own Safe-Exit section (they contradicted each other).
- tasks/architecture/ softened: this repo has no tasks/ dir; the OQ
  "Blocked on:" field is the tracker of record.
- Deferral-blocker examples re-keyed off alknet ("alknet-http crate
  spec" → the composition-layer crate spec, OQ-43; "alknet-agent crate
  spec" → "the agent crate's spec") — examples were teaching the exact
  repo-pinned blocker class just cleaned out of the OQs.

coordinator.md:
- Example spawn template said "implementation specialist for
  @alkdev/alknet" with alknet-era constraints ("feature flags for
  transports (tls, iroh, acme)") — agents copying the template would
  re-introduce the residue into every spawned prompt. Now names
  @alkdev/alkcall and points at AGENTS.md convention 11 (lean base
  crate, no quinn/iroh unless the feature is on).

Verification: repo-wide residue sweep — alknet mentions survive only in
AGENTS.md's intentional provenance/historical framing; no stale ADR
number pointers remain in .opencode/agents/; architect.md's internal
sections (Safe Exit, anti-patterns, status vocabulary) are mutually
consistent.
2026-10-13 11:16:31 +00:00
glm-5.3-flash f93ac3792d docs: align AGENTS.md with the alknet de-repo-pinning pass
AGENTS.md is auto-loaded by every agent, so its framing had to match the
re-keyed spec (5f019c0). Three alknet-era passages conflicted with it:

- Intro: "unification of alknet-call / alknet-channels" → "extraction
  and unification of the former ... crates" (same provenance framing as
  the spec docs).
- Convention 9: dropped "when the alknet mono-repo is reworked, it will
  consume alkcall's versions" — replaced with the current truth: the
  source-repo core crate is retired, the mono-repo is being replaced by
  thin glue (composition layer — OQ-43).
- Architecture Context: ADR range 001..054, OQ range OQ-01..43 with
  OQ-43 named; the porting bullet now states mono-repo = provenance not
  a live dependency, names the sibling host crates (alkhttp/alktls/
  alktty/alktunnels/alktrader) as the current homes of the alknet-era
  surfaces, and documents the citation convention ("alknet ADR-NNN
  (provenance)" / "alknet OQ-NN" — never in-crate authority).

Also lists ADR-054 (re-owned X.509 verifier-selection rule) in the key
ADR list so agents don't chase the demoted alknet ADR-034 as authority.

Verification: residual alknet-mention sweep of AGENTS.md (only marked
provenance/historical framing remains); section cross-check against
docs/architecture/README.md's provenance note and OQ-43 wording.
Docs-only change — no code paths touched.
2026-10-13 11:15:24 +00:00
glm-5.3-flash 5f019c02f1 docs: re-key architecture docs off the alknet mono-repo — speak as alkcall
The ported docs still framed alknet siblings (alknet-core/-call/-channels/
-http/-tls/-client) as live load-bearing context of this crate. With the
extraction complete and alknet headed for glue-level replacement, the docs
now speak as alkcall and cite the real sibling crates (alkhttp, alktls,
alktty, alktunnels, alktrader).

- ADR-054 (new): re-own the verifier-selection rule in-crate — three
  remote roles (public X.509 endpoint / transport relay / hub),
  ServerCertVerifier by PeerEntry presence, RemoteIdentity None
  load-bearing. Provenance: alknet ADR-034 (never ported), demoted to
  footnote. Resolves OQ-21's cross-repo authority gap.
- OQ-43 (new, 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, ADR-045 dial
  seam). README layering diagram redrawn with the composition layer as
  TBD-ownership instead of "alknet / alknode" in concrete.
- Spec docs reframed: titles, self-references, dial/TLS/from_jsonschema
  homes, malformed legacy cite rows removed; dead relative research
  paths qualified as source-repo paths.
- ADR amendments (append-only): 027 (alknet-http -> alkhttp), 031 (post-
  extraction state), 044 (split landed as modules in alkcall), 045
  (name normalization; citation-space note).
- Citation hygiene: unprefixed/colliding source-repo cites prefixed
  ("alknet ADR-NNN (provenance)") across 009/011/022/034/035/038/040/
  043/044/045; stale OQ cites corrected (session registries OQ-04, batch
  semantics OQ-03, verifier rule ADR-054); stale anchors fixed.
- README provenance note consolidated; citation conventions documented.

Verification: markdown link check (all local links resolve, all in-crate
ADR cites point at existing decisions/ files); table integrity check
(uniform row widths across open-questions.md and edited tables); residual
alknet-mention sweep (only provenance/historical/intentional framing
remains); architecture review subagent pass with zero remaining criticals.
Docs-only change — no code paths touched.
2026-10-13 10:53:47 +00:00
glm-5.3-flash 7b8a3e7558 docs: spec-level sync with the single-publish fix + port-debt repair
The spec docs had not caught up with the stub-sweep fix commits
(4eae18f..3317ea0), and the ported docs still carried alknet
numbering that now collides with alkcall's own ADR range.

Spec sync with the landed code:
- call-protocol.md: register_imported/register_imported_all return
  Result and refuse marked specs; marked-spec registration refusal
  contract spelled out (ADR-047 am. 4 §4.8, ADR-022 am. 2026-10-13)
- client-and-adapters.md: from_call flow gains the Pub sink-forwarding
  branch and the marked-op refusal (resolves OQ-37 — no from_call
  relay wrapper; sanctioned compositions are
  ChannelRelay::register_relay_openable / ChannelClient)
- ADR-051: "channel/control is not implemented" stale sentence
  replaced with the ADR-052 per-leg control contract
- OQ-37 resolved; OQ-22 split (primitive resolved; broker placement
  out of scope) with new OQ-42 for the downstream hub broker
  (deferred(scope), concretely blocked); OQ-24 resolved by the shipped
  add/strip surface; OQ-41 → deferred(scope) with blocking condition
- README: OQ bullets renumbered to tracker IDs; alknet-collision note
  rewritten (range 001..053, alknet prefix convention); new principle
  11 (implement-or-stop-advertising); AGENTS.md ADR range 001..053

Port-debt repair:
- 149 broken decision links across 17 docs — ported with alknet
  numbers/paths, most now remapped to alkcall files by slug; the
  rest link the alknet repo per the README convention
- stale OQ numbering rewritten to tracker IDs in call-protocol.md,
  call-README.md, operation-registry.md, client-and-adapters.md,
  ADR-025, channels-overview/README/wire, channel-client
- unambiguous "alknet ADR-NNN" labels where an alknet number collides
  with an alkcall ADR (052 TTY vs channel/control, 033 storage vs
  rust-canonical, 025/026/027+020 vault vs PeerEntry/auth ADRs)

Verified: cargo test (697 + guard), fmt, clippy --all-targets -D
warnings; full markdown link scan clean.
2026-10-13 09:34:37 +00:00
glm-5.3-flash 3317ea0963 docs: step-3 rewrites — OQ-39/40 resolved, C-08 closed, channel-operations.md put/control/resources contracts, review 010/011 closed with the S-01 sketch corrected
- open-questions.md: OQ-39 → ADR-052, OQ-40 → ADR-053 (resolutions replace
  the deferred-until-needed posture)
- channel-operations.md: the put contract (initiator allocates + streams,
  reserved input key, one response, acks-in-result sentence), the control
  routing (ADR-052), the enumerator aggregation (ADR-053), the
  connection-owner paragraph's pub-era correction, the ADR table rows
- ADR-051 §6: PubTypedOpen → the put translation; ADR-047's 2026-09-06
  amendment records its era honestly (the dynamic half is now ADR-053)
- review 010: all four findings resolved (with the S-01 sketch correction
  so the published record stops carrying the wrong-graph reading)
- review 011: closed (all seven pins implemented + gates landed)
- review 001 C-08: resolved note (odd/even + adopt, implemented)
- AGENTS.md: the 'Stub Guard' section — the not_implemented grep is part
  of cargo test; the registration invariants and the implement-or-stop
  ruling are release-blocking

Verified: cargo test (696+guard), clippy -D warnings, fmt, doc clean.
2026-10-13 09:04:13 +00:00
glm-5.3-flash 944c300c17 feat(channels): implement the single-publish fixes — S-01..S-04 (reviews 010/011; ADR-047 am. 4, ADR-052, ADR-053, ADR-022 am.)
S-01 — the marked-pub open contract (ADR-047 amendment 4):
- ChannelCore::register_pub_openable + PubOpenHandler (the put path's
  handler returns its result for the merged single response; the
  wrapper awaits inline, teardown is EOF-driven, handler_task stays
  None); 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):
  stripped pre-validation by resolve_sink_handler (the single source of
  truth, P-08), handed to the wrapper via OperationContext.metadata, a
  missing key rejected INVALID_INPUT; adopt parity enforced
  (ManagerError::InvalidAdopt) and adopt failures answer
  channel:open_failed / 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
- relay put-translation replaces RelayError::PubTypedOpen: hub
  allocates odd on the producer leg, opens the spoke op in the
  background (put-acks-in-result), one-direction pump, response-mirror
  with the producer's reserved key stripped
- ChannelClient::open_pub_channel + PubOpenReply (the initiator-
  allocates put open capability)

S-02 — channel/control implemented (ADR-052): establisher-contributed
ChannelControl (Establishment::with_control), manager-owned registry,
teardown removals, channel:no_control_handle capability answer.

S-03 — channel/resources/subscribe implemented (ADR-053): the
ResourceEnumerators registry (shared per peer), snapshot-then-changes
enumerators, scan-once aggregation, the empty seed; the
always-failing one-shot error stream removed.

S-04 — op/register forward-through (ADR-022 amendment): announced
unmarked Sub/Pub register the real streaming/sink forwarding shapes;
marked announcements refused with MARKED_SPEC_UNSUPPORTED; marked
specs refuse the generic registration path and register_imported
(Result-returning now).

Guard (review 010 action 3): tests/stub_guard.rs greps src/ for
not_implemented; the four pinned stub tests are deleted and the
marked-spec registration refusals are pinned by tests.

Verified: cargo test (696 + guard), clippy --all-targets -D warnings,
fmt --check, fuzz corpus replay.
2026-10-13 09:00:21 +00:00
glm-5.3-flash df4d4d3a9e docs(adr): amendments pinning the single-publish fix — marked-pub contract (047 am.4), control surface (052), resource enumerators (053), op/register forward-through + marked refusal (022 am.)
Per review 011 §6 sequencing: the four amendment documents drafted
before code. All pins, no options — the hedged alternatives are stated
as rejected with rationale:
- ADR-047 amendment 4: the marked-pub open contract (review 011 §1/§2's
  seven pins — marker semantics, publish_schema refusal, reserved input
  key + dispatch strip, wrapper-as-responder-of-record, ack-timing
  sentence, relay translation, client put-open capability) plus the
  registration invariants that make stubs unregistrable (§4.8).
- ADR-052 (new): channel/control control-handle surface — establisher-
  contributed ChannelControl, wrapper-owned registry, channel:no_control_handle.
- ADR-053 (new): channel/resources/subscribe live resource enumerators —
  snapshot-then-changes, scan-once aggregation, empty seed.
- ADR-022 amendment: announced unmarked Sub/Pub forward through the real
  forwarding shapes; marked announcements refused (MARKED_SPEC_UNSUPPORTED).
2026-10-13 08:27:50 +00:00
glm-5.3-flash 4eae18f539 fix(core): unused map-key iteration in Capabilities::zeroize tripped the release gate
clippy::for_kv_map under -D warnings; main has been ungreen since before
the 0.8.1 publish. Folded into the 0.9.0 publish per review 011, but main
stays green from now.

Verified: cargo test (684), clippy --all-targets -D warnings, fmt --check.
2026-10-13 07:49:47 +00:00
glm-5.3-flash 549893cbbf docs(review 011): downstream input on review 010's S-01 — the marked-pub open contract and the single-publish amendment requirements
From alkblobs (paused on S-01; its ADR-014 is the reviewed downstream
ruling):

- S-01's option-1 sketch (hand the OpenHandler the PublishStream) is
  the wrong shape for marked ops — it imports ADR-046's unmarked
  call.published semantics into the ADR-047 §2 marker case. The
  settled shape: initiator allocates (odd) and writes immediately;
  responder adopts (channel id crosses the open invocation, reserved
  input key + dispatch-layer strip recommended); wrapper as responder
  of record (await the handler's JoinHandle, merge establishment
  reply fields + handler result into the single response); zero
  call.published on marked ops (nonempty publish chunks rejected
  loudly); relay PubTypedOpen lifted.
- Seven items the amendment-4 publish must pin (incl.
  publish_schema refused at registration for marked ops; the put
  acks-in-result sentence; the client-side stream-then-open
  capability absent from ChannelClient today).
- Verification gates list; S-02/S-03/S-04 downstream
  non-requirements and remedy preferences; per-crate impact for the
  four published consumers (none register Pub-typed openables —
  S-01 is capability-additive; semver risk concentrates in any
  stop-advertising removals).

Docs-only change.
2026-10-13 07:37:18 +00:00
glm-5.3-flash 1793265b04 docs(review 010): stub sweep — four invocation-failing stubs in the published surface 2026-10-13 06:40:10 +00:00
68 changed files with 5316 additions and 1027 deletions

No files matched your search

+8 -4
View File
@@ -99,7 +99,8 @@ organized by theme. Each question has:
- **OQ-ID** (OQ-01, OQ-02, ...) — stable reference
- **Origin** — which spec doc(s) the question appeared in
- **Status** — open, resolved, partially resolved
- **Status** — open, resolved, partially resolved, deferred(scope),
deferred(unclear), dissolved
- **Priority** — high, medium, low
- **Resolution** — when resolved, what was decided and which ADR addresses it
- **Cross-references** — related OQs and ADRs
@@ -190,6 +191,8 @@ When you encounter a decision that genuinely can't be made:
1. Mark the OQ as `deferred(scope)` with a concrete blocking condition
2. Create a blocker task in `tasks/architecture/` naming the dependency
(where the project tracks tasks that way — otherwise the OQ's
`Blocked on:` field is the tracker of record)
3. Continue to decisions that *can* be made — do not stall on one question
### 6. Request Architecture Review
@@ -274,7 +277,7 @@ Door type classifies **reversal cost** (one-way vs two-way), not
urgency. A two-way door is a decision you make now and can revert
later — not a decision to defer. Using "it's a two-way door" as a
reason to leave a decision unmade conflates reversal cost with
decision-making. See ADR-009 §"What this framework is NOT" for the
decision-making. See ADR-032 §"What this framework is NOT" for the
full rationale.
Architecture decisions are the architect's, regardless of door type.
@@ -329,7 +332,8 @@ A decision should be `deferred(scope)` when:
- The use case isn't concrete (e.g., "we don't know what the agent crate
will need from the call protocol")
- The options depend on something that doesn't exist yet (e.g.,
"depends on the alknet-http crate spec")
"depends on the composition-layer crate spec — owner and shape not
committed, see OQ-43")
- The trade-off requires data that can only come from implementation
(e.g., "need performance benchmarks to choose between X and Y")
- The decision is genuinely not needed for the current scope (e.g., "the
@@ -373,7 +377,7 @@ A decision should be `deferred(unclear)` when:
(implies it's decided).
2. **State the blocking condition** (`deferred(scope)`) or
**investigation target** (`deferred(unclear)`) — what specific thing
would unblock this? Be concrete: "blocked on: alknet-agent crate spec
would unblock this? Be concrete: "blocked on: the agent crate's spec
exists" or "investigation: work through 2+ example outbound-dial use
cases (hub→worker, worker→hub) to see how verifier-selection +
provider + connector compose."
+3 -3
View File
@@ -191,7 +191,7 @@ also include:
Example prompt template:
```
You are an implementation specialist for the @alkdev/alknet project.
You are an implementation specialist for the @alkdev/alkcall project.
Your task: {{task}}
@@ -204,11 +204,11 @@ Your task: {{task}}
7. Push: git push origin $(git branch --show-current)
8. Notify: worktree({action: "notify", args: {message: "Task completed: {{task}}. <brief summary>", level: "info"}})
Key project constraints (@alkdev/alknet):
Key project constraints (@alkdev/alkcall):
- Rust: use cargo build, cargo clippy, cargo fmt, cargo test
- No comments in code
- anyhow::Result for application errors, thiserror for library error types
- Feature flags for transports (tls, iroh, acme)
- Base crate compiles lean (no quinn/iroh unless the feature is on — see AGENTS.md convention 11)
- Async via tokio runtime
- No panics in library code
```
+72 -24
View File
@@ -46,14 +46,15 @@ change `git config`, skip hooks, or use `git commit -i`.
## Project Conventions (Rust / RPC protocol crate)
This is the call + channels RPC crate — the unification of
`alknet-call` (structured JSON RPC: operations, streaming subscriptions,
service discovery) and `alknet-channels` (multiplexing proxy: N logical
channels over one transport stream, channel 0 pre-negotiated as
`alk/call`). The conventions below apply to all work in `src/` and
`tests/`. They mirror `.opencode/agents/implementation-specialist.md`
§Project Conventions and are repeated here so they apply to every
session, not just spawned implementation agents.
This is the call + channels RPC crate — the extraction and unification of
the former `alknet-call` (structured JSON RPC: operations, streaming
subscriptions, service discovery) and `alknet-channels` (multiplexing
proxy: N logical channels over one transport stream, channel 0
pre-negotiated as `alk/call`) crates. The conventions below apply to all
work in `src/` and `tests/`. They mirror
`.opencode/agents/implementation-specialist.md` §Project Conventions and
are repeated here so they apply to every session, not just spawned
implementation agents.
1. **No comments in code** unless the user explicitly asks. This is a
project-wide convention. Doc comments (`///`, `//!`) are fine and
@@ -130,11 +131,13 @@ session, not just spawned implementation agents.
`AuthContext`, `IdentityProvider`, `Identity`, `AuthToken`,
`Capabilities`, `OwnershipProvider`, `HandlerError`, `StreamError`)
live in this crate. They are the home for these types going forward —
do not add a separate `alkcore` dependency. When the alknet mono-repo
is reworked, it will consume alkcall's versions. Keep these types
lean (no TLS, no transport coupling, no endpoint/accept-loop); the
dial and the TLS config are concerns of the consumer, not of this
crate. See ADR-007, ADR-008, ADR-009.
do not add a separate `alkcore` dependency; the source-repo core crate
is retired (the alknet mono-repo is being replaced by thin glue over
alkcall and its sibling host crates — see OQ-43 in
`docs/architecture/open-questions.md` for the composition layer).
Keep these types lean (no TLS, no transport coupling, no
endpoint/accept-loop); the dial and the TLS config are concerns of
the consumer, not of this crate. See ADR-007, ADR-008, ADR-009.
10. **BAST documents for wire formats** — every binary wire format in
this crate carries a BAST (Binary Abstract Syntax Tree) document as
@@ -213,20 +216,53 @@ cargo clippy --target wasm32-unknown-unknown -- -D warnings
cargo semver-checks check-release # before a release
```
## Stub Guard (release-blocking)
Four registered-but-always-failing stubs shipped across nine published
versions (reviews 010/011). This class is now release-blocking in both
directions:
1. **Test guard**: `cargo test --test stub_guard` (part of `cargo
test`) fails if the string `not_implemented` appears anywhere in
`src/`. A "loud failure" stub is still a stub.
2. **Registration-time invariants** (ADR-047 amendment 4 §4.8): a
marked (`channel_open`) spec refuses the generic registration path
(`OperationRegistry::register`, `CallConnection::register_imported`,
`op/register`) and refuses `publish_schema`; the sanctioned
registrations are the channel wrapper family
(`register_openable*`, `register_pub_openable`,
`register_relay_openable`).
3. **The ruling**: implement-or-stop-advertising before any publish.
The only permitted deferral statement is "not this session" — never
"next version", "not v1", or any bucket that parks a decision on an
unknown future release. Every handler that registers on a
discoverable surface must be capable of succeeding.
## Architecture Context
- `docs/architecture/` — the authoritative spec. Read it before
non-trivial changes. ADRs are numbered 001..050; OQs (open questions)
track resolved/deferred decisions.
- This crate unifies `alknet-call` and `alknet-channels` from the
alknet mono-repo (`/workspace/@alkdev/alknet`). The source
architecture docs were ported from
`/workspace/@alkdev/alknet/docs/architecture/` and renumbered as
alkcall ADRs (001..048; ADR-049 and later were authored in this
crate). The ALPN
strings (`alk/call`,
`alk/channels`) are wire-stable going forward (renamed from
`alknet/` to `alk/` in v0.1.1, before the first published consumer).
non-trivial changes. ADRs are numbered 001..054; OQs (open questions)
track resolved/deferred decisions (OQ-01..43; OQ-43 tracks the
composition layer's ownership and shape after the alknet replacement,
deferred(scope)).
- This crate is the extraction of the former `alknet-call` and
`alknet-channels` crates plus the vendored core types (provenance:
the alknet mono-repo at `/workspace/@alkdev/alknet`). The source
architecture docs were ported and renumbered as alkcall ADRs
(001..048; ADR-049 and later were authored in this crate). The
mono-repo is **provenance, not a live dependency** — it is being
replaced by thin glue over alkcall and its sibling host crates
(`alkhttp`, `alktls`, `alktty`, `alktunnels`, `alktrader`), which are
the current homes of the surfaces old docs call `alknet-http`,
`alknet-tls`, `alknet-tty`, `alknet-tunnels`, `alknet-trader`. Docs
and ADRs cite the source repo as "alknet ADR-NNN (provenance)" /
"alknet OQ-NN" — a separately-numbered upstream space, never an
in-crate authority; anything that constrains this crate's surface is
re-owned by an in-crate ADR (e.g. ADR-054). See the provenance note
at the end of `docs/architecture/README.md`. The ALPN strings
(`alk/call`, `alk/channels`) are wire-stable going forward (renamed
from `alknet/` to `alk/` in v0.1.1, before the first published
consumer).
- Key ADRs that inform this crate's design:
**Call protocol:**
@@ -274,6 +310,14 @@ cargo semver-checks check-release # before a release
`from_connection` primary; dial lives in the consumer)
- ADR-041 — per-identity channel cap (256 per `PeerId` via
`ChannelLifecyclePolicy`)
- ADR-052 — `channel/control` control-handle surface
(establisher-contributed `ChannelControl`;
`channel:no_control_handle` capability answer)
- ADR-053 — `channel/resources/subscribe` live resource enumerators
(snapshot-then-changes; scan-once aggregation)
- ADR-047 amendment 4 — the marked-pub open contract and the
registration invariants (§4.8): marked specs refuse the generic
registration paths; stubs are unregistrable
**Shared (vendored core types):**
- ADR-001 — ALPN-based protocol dispatch
@@ -290,6 +334,10 @@ cargo semver-checks check-release # before a release
point `ChannelBidiStreamSource` implements)
- ADR-009 — `BiStream` as the handler leaf (`accept_bi` returns
`BiStream`)
- ADR-054 — outgoing-only X.509 and the three remote roles
(verifier selection by `PeerEntry` presence; `RemoteIdentity`
`None` semantics — re-owned in-crate from the source repo's
alknet ADR-034; the dial/composition layer implements it)
- If a TODO references a "Phase B" or a design direction that an ADR
has since decided against, the TODO is stale — remove it and align
+98
View File
@@ -4,6 +4,103 @@ All notable changes to this crate are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
this crate adheres to [Semantic Versioning](https://semver.org/).
## [0.9.0] - 2026-10-14
The stub sweep: four registered-but-always-failing stubs shipped across
nine published versions (reviews 010/011) are now real implementations
— the marked-pub open contract (S-01), `channel/control` (S-02),
`channel/resources/subscribe` (S-03), and `op/register`
forward-through (S-04). Every handler that registers on a discoverable
surface can now succeed; the four stubs are replaced and the class is
release-blocking in both directions (a loud-failure stub is still a
stub — the stub guard plus registration-time invariants hold the gate).
**Breaking (semver-relevant, hence the minor bump).** New variant
`ManagerError::InvalidAdopt`; `RelayError::PubTypedOpen` removed (the
relay put-translation now allocates and opens on the producer leg);
`register_imported`/`register_imported_all` return `Result<(), String>`
(beyond the boolean); `Establishment` no longer derives `Debug` (the
establisher-contributed `ChannelControl` inner is not `Debug`).
### Added
- **The marked-pub open contract — S-01 (ADR-047 amendment 4, review
011 §3).** The put path end-to-end:
- `ChannelCore::register_pub_openable` + `PubOpenHandler` — the
put's handler returns its result for the merged single response;
the wrapper awaits it inline, teardown is EOF-driven, no
`handler_task` is ever spawned. `register_openable*` refuses
`Pub`-typed specs; the wrapper entry points refuse unmarked /
non-`Pub` shapes.
- The reserved input key `channel_id` (the initiator-allocated id)
is stripped pre-validation by `resolve_sink_handler` (P-08, the
single source of truth), handed to the wrapper via
`OperationContext.metadata`; a missing key rejects
`INVALID_INPUT`; adopt parity is enforced
(`ManagerError::InvalidAdopt`) and adopt failures answer
`channel:open_failed` / reason `adopt_failed`.
- Zero `call.published` on marked ops: the first publish chunk on a
marked op is rejected loudly (`channel:publish_chunks_rejected`)
in both dispatch loops; the handler is never fed.
- The relay's put-translation (ADR-042): the hub allocates odd on
the producer leg, opens the spoke op in the background
(put-acks-in-result), pumps one direction only, and mirrors the
response with the producer's reserved key stripped.
- `ChannelClient::open_pub_channel` + `PubOpenReply` — the
initiator-allocated put open capability (odd ids on the connect
side, ADR-047 §5). A failed open tears the local adopt entry down,
including the pre-flight INVALID_INPUT path (the allocated id is
released, not parked against the ADR-040 bound).
- **`channel/control` implemented — S-02 (ADR-052).** The
establisher-contributed control handle (`Establishment::with_control`)
is registered manager-side at establishment, removed on teardown, and
reachable through the `channel/control` op; an establisher without a
control handle answers `channel:no_control_handle`.
- **`channel/resources/subscribe` implemented — S-03 (ADR-053).** The
per-peer-connection `ResourceEnumerators` registry with
snapshot-then-changes enumerators, scan-once aggregation over the
channel operations, and the empty seed; the always-failing one-shot
error stream is gone.
- **`op/register` forward-through — S-04 (ADR-022 amendment).**
Announced unmarked `Sub`/`Pub` operations register the real
streaming/sink forwarding shapes; marked announcements are refused
with `MARKED_SPEC_UNSUPPORTED`; marked specs refuse both the generic
registration path (`OperationRegistry::register`, `op/register`) and
the connection-overlay path (`CallConnection::register_imported`
— now returning `Result`; `register_imported_all` is atomic: a
refusal leaves the overlay untouched).
### Changed
- `HandlerRegistration` gained the private `channel_wrapper` field
(the channel wrapper family's plumbing); externally constructed
registrations stay impossible (`HandlerRegistration::new` unchanged).
### Fixed
- **`open_pub_channel` no longer parks a channel slot on the
pre-flight rejection.** The early-return on a non-object input now
tears down the freshly allocated id (regression:
`open_pub_channel_invalid_input_releases_the_allocated_id`).
- `Capabilities::zeroize`: an unused map-key iteration tripped
`clippy::for_kv_map` under `-D warnings`. No behavior change.
### Guard
- `tests/stub_guard.rs` — `cargo test` fails if `not_implemented`
appears anywhere under `src/`: the four former pinned-stub tests are
deleted and the marked-spec registration refusals are pinned by
tests instead.
### Verified
- 698 tests pass (including the stub guard); `clippy --all-targets
--all-features -- -D warnings`, `fmt --check`, `cargo doc`,
`wasm32-unknown-unknown` check, fuzz corpus replay
(`cargo test --manifest-path fuzz/shared/Cargo.toml`, 5/5), and
`cargo semver-checks` (5 breaking lints vs 0.8.1 — the minor-bump
classification above).
## [0.8.1] - 2026-09-28
Bug-fix release: a duplicate `adopt_channel`/`open_channel` no longer
@@ -737,6 +834,7 @@ Vendored core types (`Connection`, `ProtocolHandler`, `BiStream`,
(ADR-046), the channels protocol with openable-ALPNs-as-operations
(ADR-047), and the `ChannelClient` transport-agnostic client.
[0.9.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.9.0
[0.8.1]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.8.1
[0.8.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.8.0
[0.7.1]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.7.1
Generated
+1 -1
View File
@@ -27,7 +27,7 @@ dependencies = [
[[package]]
name = "alkcall"
version = "0.8.1"
version = "0.9.0"
dependencies = [
"async-trait",
"bytes",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "alkcall"
version = "0.8.1"
version = "0.9.0"
edition = "2021"
rust-version = "1.88"
license = "MIT OR Apache-2.0"
+77 -39
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-08-12
last_updated: 2026-10-13 (composition re-key, ADR-054, alkcall reframing of ported docs)
---
# alkcall
@@ -9,12 +9,13 @@ The call + channels RPC crate. Structured JSON RPC (operations, streaming
subscriptions, service discovery) and N-channel multiplexing over one
transport stream (channel 0 pre-negotiated as `alk/call`).
This crate unifies `alknet-call` and `alknet-channels` from the alknet
mono-repo, plus the vendored core types formerly in `alknet-core`. The
source architecture docs were ported from
`/workspace/@alkdev/alknet/docs/architecture/` and renumbered as alkcall
ADRs (ADR-001..051). The ALPN strings (`alk/call`, `alk/channels`)
are wire-stable and unchanged — see ADR-004.
This crate is the extraction and unification of the former `alknet-call`
and `alknet-channels` crates plus the vendored core types formerly in
`alknet-core` (see ADR-031). It is a pure protocol crate: no networking,
no transport dependencies — dial, TLS config, and composition live in
downstream crates. The ALPN strings (`alk/call`, `alk/channels`) are
wire-stable — see ADR-004. (Historical origin and cross-repo citation
conventions: see the provenance note at the end of this document.)
## Documents
@@ -69,7 +70,7 @@ are wire-stable and unchanged — see ADR-004.
| [024](decisions/024-peer-graph-routing-model.md) | Peer-Graph Routing Model | PeerCompositeEnv, PeerRef, AccessControl peer auth |
| [025](decisions/025-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | PeerId = Identity.id (stable) |
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | Metadata only, never used by ACL |
| [027](decisions/027-from-jsonschema-as-http-adapter.md) | from_jsonschema as HTTP Adapter | FromJsonSchema provenance stays; impl in alknet-http |
| [027](decisions/027-from-jsonschema-as-http-adapter.md) | from_jsonschema as HTTP Adapter | FromJsonSchema provenance stays; impl moved to alkhttp (ex-`alknet-http`) |
| [028](decisions/028-from-call-manual-free-function.md) | from_call Is a Manual Free Function | Assembly layer calls it after dial |
| [029](decisions/029-aggregated-peer-env-wiring.md) | Aggregated Peer-Environment Wiring | Dispatcher hub wiring |
| [030](decisions/030-peer-composite-env-peer-operations.md) | PeerCompositeEnv::peer_operations | OperationEnv::peer_operations override |
@@ -82,7 +83,7 @@ are wire-stable and unchanged — see ADR-004.
| [032](decisions/032-one-way-door-decision-framework.md) | One-Way Door Decision Framework | Reversal cost classification |
| [033](decisions/033-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Rust canonical, TS reference |
### Channels — ADR-034..045
### Channels, pub/sub, and later amendments — ADR-034..054
| ADR | Title | Relevance |
|-----|-------|-----------|
@@ -104,22 +105,36 @@ are wire-stable and unchanged — see ADR-004.
| [049](decisions/049-channel-open-establishment-phase.md) | Channel-Open Establishment Phase | `OpenEstablisher` + `register_openable_with_establisher` (awaited, bounded); typed `channel:open_failed` with `details.reason`; `Establishment.plan` (`ChannelPlan`) threaded to the `OpenHandler` (amendment 2); the `JoinHandle` data-plane lifetime contract |
| [050](decisions/050-pump-bidi-two-pump-helper.md) | `pump_bidi` Two-Pump Helper | `channels::pump_bidi` — shutdown-on-completion two-pump data plane pinned in one place (alknet ADR-078) |
| [051](decisions/051-channel-relay-and-hub-leg.md) | In-Tree Channel Relay (`ChannelRelay`) and the Hub-Leg Assembly | ADR-042 amended — the relay implementation is an alkcall export; translate hop = ADR-049 establisher (`register_relay_openable`), byte-forward hop = `pump_bidi`; implicit per-channel id mapping; two-phase registration (discover/stash → fork-register); hub-leg install template; ACL layering note |
| [052](decisions/052-channel-control-surface.md) | `channel/control` Control-Handle Surface | Establisher-contributed `ChannelControl` (`Establishment::with_control`), wrapper-installed/teardown-removed in the manager; `channel:no_control_handle` capability answer; resolves OQ-39 |
| [053](decisions/053-resource-enumerators.md) | Live Resource Enumerators | `ResourceEnumerators` (peer-shared registry); snapshot-then-changes enumerator contract; scan-once aggregation; empty seed; resolves OQ-40 |
| [054](decisions/054-outgoing-only-x509-three-remote-roles.md) | Outgoing-Only X.509 and the Three Remote Roles (Re-Owned) | Verifier-selection by `PeerEntry` presence; three remote roles; `RemoteIdentity` `None` semantics — re-owned in-crate (provenance: alknet ADR-034); resolves OQ-21's cross-repo authority gap |
## Relevant Open Questions
See [open-questions.md](open-questions.md) for the full tracker. Key
questions affecting this crate:
- **OQ-01**: Call protocol pub/sub primitive (partially resolved) —
ADR-046 adds the `Pub` primitive (producer→consumer streaming). The
fan-out/broker is deferred to channels (Gap B in ADR-047 is named
out-of-scope for alkcall; the hub composes the broker on top).
- **OQ-02**: Full channel-level flow-control windowing (deferred(scope))
- **OQ-22** (resolved) / **OQ-42** (deferred(scope)): The call-protocol
pub/sub primitive is delivered (ADR-046); the fan-out/message broker
is placed out of scope for alkcall — the hub composes it on the
`Pub`/`Sub` primitives — and its design remains deferred downstream,
blocked on a concrete producing-crate consumer (Gap B in ADR-047).
- **OQ-23** (deferred(scope)): Full channel-level flow-control windowing
— bounded-buffer is decided (ADR-040); full windowing blocked on a real
HOL-blocking deployment observation.
- **OQ-03**: Channels add/strip API shape (open) — whether the 8-byte
header add/strip is built into the read/write path or a standalone
utility.
- **OQ-24** (resolved): Channels add/strip API shape — integral to the
read/write path (handlers never see headers); low-level header
primitives stay in `channels::wire` for tooling.
- **OQ-37** (resolved): No `from_call` relay wrapper for marked ops —
marked announcements and marked-spec imports are refused (ADR-022
amendment 2026-10-13, ADR-047 amendment 4 §4.8); the sanctioned
compositions are `ChannelRelay::register_relay_openable` (ADR-051) and
`ChannelClient`.
- **OQ-43** (deferred(scope)): Composition-layer ownership and shape
after the alknet replacement — the named receiver for the
downstream-side deferrals (OQ-33 endpoint layer, OQ-41 QUIC-native
substrate, OQ-42 hub broker, OQ-19 persistence adapters). Not
blocking any alkcall API surface.
## Key Design Principles
@@ -143,6 +158,14 @@ questions affecting this crate:
channel 0. See ADR-036, ADR-037.
10. **Wire formats are stable**: EventEnvelope shape and the 8-byte chunk
header are one-way doors. See ADR-014, ADR-034.
11. **Implement-or-stop-advertising**: every registration on a
discoverable surface must be capable of succeeding — no
advertised-always-failing bodies, no deferral buckets ("not this
session" is the only permitted deferral statement). Marked
(`channel_open`) specs refuse the generic registration paths
(ADR-047 amendment 4 §4.8); the `not_implemented` guard test is
release-blocking (`tests/stub_guard.rs`). See reviews 010/011 for
the incident that pinned this.
## Roles and Composition
@@ -176,8 +199,9 @@ Within a single connection, direction is independent of role:
```
┌──────────────────────────────────────────────┐
│ alknet / alknode │
│ (networking + composition) │
│ Composition layer │
│ (today: the alknet glue — being replaced; │
│ future owner and shape → OQ-43) │
│ │
│ • QUIC / TCP+TLS dial and accept │
│ • Hub: ChannelsAdapter + Dispatcher + │
@@ -185,7 +209,7 @@ Within a single connection, direction is independent of role:
│ • Spoke: ChannelClient + CallConnection + │
│ from_call │
│ • Wires protocol crates' producers into │
│ registries, consumers into clients │
│ registries, consumers into clients │
└──────────────────┬───────────────────────────┘
│ depends on
┌──────────────┼──────────────┐
@@ -229,10 +253,12 @@ alkcall. They provide two halves:
(e.g. `trader_client.status().await` instead of
`call_connection.call("trader/status", ...).await`).
**alk/alknode** depends on alkcall + whichever protocol crates are
needed. It's the composition layer — the only place that knows about
network topology, peer routing, and which protocol crates are wired in.
Protocol crates never import a QUIC or TLS dependency.
**The composition layer** depends on alkcall + whichever protocol
crates are needed. It's the only place that knows about network
topology, peer routing, and which protocol crates are wired in.
Protocol crates never import a QUIC or TLS dependency. Today this
layer is the alknet glue; its post-replacement owner and shape is
tracked as OQ-43.
### Pattern for protocol crates
@@ -269,27 +295,39 @@ A protocol crate that uses channels (e.g. alktty) follows this pattern:
The protocol crate doesn't know which path it's on. It takes a
`BiStream` (or `AsyncRead + AsyncWrite`) and drives the session. The
two adapters are thin and live either in the protocol crate (behind
feature flags) or in the downstream alknet crate.
feature flags) or in the composition layer.
## References
- `@alkdev/alknet: docs/architecture/` — the source architecture docs
these were ported from (renumbered from alknet ADR-001..094 to alkcall
ADR-001..048; ADR-049 and later were authored in this crate)
- `@alkdev/alktype` — the binary struct engine; compiles BAST documents
(e.g. `chunk-header.bast.json`) into readers/writers/validators
- `@alkdev/alkhttp`, `@alkdev/alktls`, `@alkdev/alktty`,
`@alkdev/alktunnels`, `@alkdev/alktrader` — sibling crates; the
live homes of the surfaces older docs call `alknet-http`,
`alknet-tls`, `alknet-tty`, `alknet-tunnels`, `alknet-trader`
- `@alkdev/pubsub` — the TypeScript EventEnvelope prior art the call
wire format was derived from
> **Note**: The source ADRs and spec docs were ported from the parent
> `@alkdev/alknet` workspace where this crate originated. They are
> preserved here as the authoritative spec for alkcall; the alknet
> mono-repo will consume alkcall's versions when it is reworked.
> **Provenance and cross-repo citations.** The source ADRs and spec
> docs were ported from the parent `@alkdev/alknet` workspace where
> this crate originated, and renumbered to alkcall ADR-001..048
> (ADR-049 and later were authored in this crate). They are the
> authoritative spec here; the alknet mono-repo is being replaced by
> thin glue over alkcall and its siblings, so alknet-specific citations
> are expected to decay into footnotes over time, and anything that
> genuinely constrains alkcall's surface gets re-owned by an in-crate
> ADR (e.g. ADR-054 re-owns alknet ADR-034's verifier-selection rule).
>
> **Cross-references to non-ported ADRs**: Some spec docs and ADRs
> reference alknet ADRs by their original numbers (e.g., ADR-052 for
> TTY's wire format, ADR-082 for alknet-tls, ADR-086 for endpoint types).
> These are ADRs for sibling crates that are not part of alkcall. They
> retain their alknet numbering (052, 082, 086, etc.) — any ADR number
> outside the alkcall range 001..048 is an alknet source ADR, found at
> `/workspace/@alkdev/alknet/docs/architecture/decisions/`.
> **Citation conventions**: alkcall's own ADRs are `decisions/001..054`
> in this directory. Some ported docs also cite source-repo ADRs that
> were **not** ported (e.g. alknet ADR-052 for TTY's wire format). The
> alknet number space collides with alkcall's (alknet ADR-052 has
> nothing to do with alkcall's `channel/control` ADR-052), so unambiguous
> references carry the `alknet` prefix (e.g. "alknet ADR-052") and link
> to the alknet repo: `/workspace/@alkdev/alknet/docs/architecture/`
> (a few legacy cites without the prefix still mean the alknet ADR —
> check the link target). Dead relative paths like
> `docs/research/alknet-channels/...` live in the alknet repo, not this
> one. Similarly, `alknet OQ-NN` citations refer to the alknet repo's
> question tracker — this crate's OQ tracker runs its own numbering
> (OQ-01..43, see [open-questions.md](open-questions.md)).
+49 -50
View File
@@ -1,12 +1,12 @@
---
status: draft
last_updated: 2026-07-17
review: call/review-call passed 2026-06-23 — registry, protocol, ADR (005/012/014/015/016/017/022/023/024), security, and pattern-consistency checks all conformant; 159 unit/integration tests green; `cargo build`, `cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo test` clean. Call-completion gap (ADR-022 client/adapter surface) addressed 2026-06-26; ADR-024 migration landed. Transport generalization sweep (ADR-014 supersedes ADR-013; ADR-007 `from_stream`) synced 2026-07-09. Crate-extraction sweep (phases 0–5) landed 2026-07-17: `ConnectionCredentials`/`RemoteIdentity` in `alknet-core` (ADR-012); TLS helpers in `alknet-tls` (ADR-045 §5); dial in `alknet-client` (ADR-045); `alknet-call` is a pure protocol crate with no TLS/transport deps.
last_updated: 2026-10-13 (alkcall reframing — historical alknet framing moved to the provenance note)
review: call/review-call passed 2026-06-23 — registry, protocol, ADR (005/012/014/015/016/017/022/023/024), security, and pattern-consistency checks all conformant; 159 unit/integration tests green; `cargo build`, `cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo test` clean. Call-completion gap (ADR-022 client/adapter surface) addressed 2026-06-26; ADR-024 migration landed. Transport generalization sweep (ADR-014 supersedes ADR-013; ADR-007 `from_stream`) synced 2026-07-09. Crate-extraction sweep (phases 0–5) landed 2026-07-17: `ConnectionCredentials`/`RemoteIdentity` vendored into alkcall's core types (ADR-012); dial extracted as the composition layer's helper (ADR-045); alkcall is a pure protocol crate with no TLS/transport deps.
---
# alknet-call
# alkcall — Call Protocol
Structured RPC: operations, request/response, streaming subscriptions, and service discovery. Implements `ProtocolHandler` on ALPN `alk/call`. Runs over QUIC (quinn/iroh) and, via `Connection::from_stream` (ADR-007), over any `AsyncRead + AsyncWrite` transport. A pure protocol crate — no TLS or transport deps (the dial is in `alknet-client`, the TLS config is in `alknet-tls`).
Structured RPC: operations, request/response, streaming subscriptions, and service discovery. Implements `ProtocolHandler` on ALPN `alk/call`. Runs over QUIC (quinn/iroh) and, via `Connection::from_stream` (ADR-007), over any `AsyncRead + AsyncWrite` transport. A pure protocol crate — no TLS or transport deps. The dial and TLS config belong to the composition layer (the dial helper is `AlknetClient`; the TLS config types live in the sibling host crate `alktls` — see ADR-045 and OQ-43).
## Documents
@@ -14,7 +14,7 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
|----------|--------|-------------|
| [call-protocol.md](call-protocol.md) | draft | CallAdapter, hand-rolled EventEnvelope framing (no irpc — ADR-014), stream model, PendingRequestMap, bidirectional calls |
| [operation-registry.md](operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, hand-rolled framing (no irpc — ADR-014) |
| [client-and-adapters.md](client-and-adapters.md) | draft | CallClient (transport-agnostic `spawn_dispatch` primary; dial lives in `AlknetClient` per ADR-045), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (`from_jsonschema` in alknet-http per ADR-027) |
| [client-and-adapters.md](client-and-adapters.md) | draft | CallClient (transport-agnostic `spawn_dispatch` primary; dial lives in the composition layer's dial helper per ADR-045), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (`from_jsonschema` in alkhttp per ADR-027) |
## Applicable ADRs
@@ -22,54 +22,53 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
|-----|-------|-----------|
| [001](decisions/001-alpn-protocol-dispatch.md) | ALPN-Based Protocol Dispatch | CallAdapter registers on ALPN `alk/call` |
| [002](decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | CallAdapter implements ProtocolHandler |
| [003](decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-call depends on alknet-core (no irpc — ADR-014) |
| [013](decisions/013-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Adapter traits defined in Rust; TS is reference/browser adaptation |
| [004](decisions/004-auth-as-shared-core.md) | Auth as Shared Core | AuthContext passed to call handlers |
| [005](decisions/005-irpc-as-call-protocol-foundation.md) | ~~irpc as Call Protocol Foundation~~ | ~~Accepted~~ → **Superseded** by [ADR-014](decisions/064-irpc-never-integrated-hand-rolled-framing.md) (irpc was never integrated; framing is hand-rolled) |
| [064](decisions/064-irpc-never-integrated-hand-rolled-framing.md) | Hand-Rolled EventEnvelope Framing | Wire format, registry, dispatch are hand-rolled in alknet-call; supersedes ADR-013 |
| [065](decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | Generic single-stream connections; unblocks TCP+TLS/SSH/WT/wasm dispatch |
| [006](decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention | `alk/call` ALPN, one ALPN per connection |
| [007](decisions/007-bistream-type-definition.md) | BiStream Type Definition | CallAdapter receives Connection, not BiStream |
| [008](decisions/008-secret-service-integration.md) | Vault Integration Point | Vault accessed at assembly layer, not on the wire |
| [010](decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Static handler registration |
| [012](decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | Bidirectional streams, EventEnvelope, ID-based correlation |
| [014](decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Call protocol carries no secret material; capabilities injected at assembly layer |
| [015](decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
| [016](decisions/016-abort-cascade-for-nested-calls.md) | Abort Cascade for Nested Calls | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
| [017](decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction |
| [066](decisions/066-from-jsonschema-as-http-adapter.md) | `from_jsonschema` as HTTP-Backed Single-Endpoint Adapter in alknet-http | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf |
| [022](decisions/022-handler-registration-provenance-and-composition-authority.md) | Handler Registration, Provenance, and Composition Authority | Registration bundle carries provenance, composition authority, scoped env, capabilities |
| [023](decisions/023-operation-error-schemas.md) | Operation Error Schemas | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity |
| [024](decisions/024-operation-registry-layering.md) | Operation Registry Layering | Curated (static) + session/connection overlays (dynamic); `OperationEnv` as trait-object integration point; `OperationContext.env` split into `scoped_env` (data) and `env` (dispatch trait) |
| [028](decisions/028-callclient-peer-scoped-registry-filtering.md) | ~~Peer-Scoped Registry Filtering~~ | ~~Accepted~~ → **Superseded** by ADR-024 (flat-namespace single-peer model couldn't express head→N-workers; parallel auth system duplicated `AccessControl`) |
| [029](decisions/029-peer-graph-routing-model.md) | Peer-Graph Routing Model | Peer-keyed overlays + `PeerRef` routing; `AccessControl`-based peer authorization; retires `remote_safe`/`trusted_peer` |
| [030](decisions/030-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | `PeerId` source = `Identity.id` = `PeerEntry.peer_id` (stable); supersedes ADR-024's UUID source |
| [032](decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | `forwarded_for` on `OperationContext` and `call.requested`; metadata only, never used by `AccessControl::check` |
| [033](decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Storage Boundary and Repo/Adapter Pattern | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
| [089](decisions/089-alknetclient-native-dial-seam.md) | AlknetClient — Native Client Dial Seam | The dial is in `alknet-client`; `CallClient` is `spawn_dispatch` only; `alknet-call` is a pure protocol crate with no TLS/transport deps |
| [091](decisions/091-connectioncredentials-decouple-dial-from-call.md) | `ConnectionCredentials` — Decouple Dial from Call Protocol | `ConnectionCredentials`/`RemoteIdentity` in `alknet-core` (not `alknet-call`); `auth_token` is a per-request payload field |
| [031](decisions/031-crate-decomposition.md) | Crate Decomposition | alkcall is self-contained: call + channels subsystems + vendored core types (no irpc — ADR-014) |
| [033](decisions/033-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Adapter traits defined in Rust; TS is reference/browser adaptation |
| [003](decisions/003-auth-as-shared-core.md) | Auth as Shared Core | AuthContext passed to call handlers |
| [013](decisions/013-irpc-as-call-protocol-foundation.md) | ~~irpc as Call Protocol Foundation~~ | ~~Accepted~~ → **Superseded** by [ADR-014](decisions/014-irpc-never-integrated-hand-rolled-framing.md) (irpc was never integrated; framing is hand-rolled) |
| [014](decisions/014-irpc-never-integrated-hand-rolled-framing.md) | Hand-Rolled EventEnvelope Framing | Wire format, registry, dispatch are hand-rolled in alkcall; supersedes ADR-013 |
| [007](decisions/007-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | Generic single-stream connections; unblocks TCP+TLS/SSH/WT/wasm dispatch |
| [004](decisions/004-alpn-convention-and-connection-model.md) | ALPN String Convention | `alk/call` ALPN, one ALPN per connection |
| [005](decisions/005-bistream-type-definition.md) | BiStream Type Definition | CallAdapter receives Connection, not BiStream |
| [015](decisions/015-call-protocol-stream-model.md) | Call Protocol Stream Model | Bidirectional streams, EventEnvelope, ID-based correlation |
| [010](decisions/010-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Call protocol carries no secret material; capabilities injected at assembly layer |
| [017](decisions/017-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
| [020](decisions/020-abort-cascade-for-nested-calls.md) | Abort Cascade for Nested Calls | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
| [022](decisions/022-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction |
| [027](decisions/027-from-jsonschema-as-http-adapter.md) | `from_jsonschema` as HTTP-Backed Single-Endpoint Adapter | Moved `from_jsonschema` out of the call protocol (broken schema-only placeholder) to the HTTP host crate as a real reqwest-backed single-endpoint adapter — today `alkhttp` (ex-`alknet-http`); `FromJsonSchema` provenance stays in alkcall as a leaf |
| [018](decisions/018-handler-registration-provenance-and-composition-authority.md) | Handler Registration, Provenance, and Composition Authority | Registration bundle carries provenance, composition authority, scoped env, capabilities |
| [016](decisions/016-operation-error-schemas.md) | Operation Error Schemas | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity |
| [019](decisions/019-operation-registry-layering.md) | Operation Registry Layering | Curated (static) + session/connection overlays (dynamic); `OperationEnv` as trait-object integration point; `OperationContext.env` split into `scoped_env` (data) and `env` (dispatch trait) |
| [023](decisions/023-callclient-peer-scoped-registry-filtering.md) | ~~Peer-Scoped Registry Filtering~~ | ~~Accepted~~ → **Superseded** by ADR-024 (flat-namespace single-peer model couldn't express head→N-workers; parallel auth system duplicated `AccessControl`) |
| [024](decisions/024-peer-graph-routing-model.md) | Peer-Graph Routing Model | Peer-keyed overlays + `PeerRef` routing; `AccessControl`-based peer authorization; retires `remote_safe`/`trusted_peer` |
| [025](decisions/025-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | `PeerId` source = `Identity.id` = `PeerEntry.peer_id` (stable); supersedes ADR-024's UUID source |
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | `forwarded_for` on `OperationContext` and `call.requested`; metadata only, never used by `AccessControl::check` |
| [045](decisions/045-alknetclient-native-dial-seam.md) | Dial Seam — Dial Out of the Protocol Crate | The dial lives in the composition layer's dial helper (`AlknetClient`, today in the alknet glue — future owner OQ-43); `CallClient` is `spawn_dispatch` only; alkcall is a pure protocol crate with no TLS/transport deps |
| [012](decisions/012-connectioncredentials-decouple-dial-from-call.md) | `ConnectionCredentials` — Decouple Dial from Call Protocol | `ConnectionCredentials`/`RemoteIdentity` live with the vendored core types in alkcall (originally `alknet-core`); `auth_token` is a per-request payload field |
Provenance (source-repo ADRs, not ported): [alknet ADR-008 — Vault Integration Point](/workspace/@alkdev/alknet/docs/architecture/decisions/008-secret-service-integration.md) (superseded in-crate by ADR-010) and [alknet ADR-010 — ALPN Router and Endpoint](/workspace/@alkdev/alknet/docs/architecture/decisions/010-alpn-router-and-endpoint.md) (superseded in-crate by ADR-001's ALPN dispatch and the decision that alkcall has no endpoint layer) are historical origins only. [alknet ADR-033 — Storage Boundary and Repo/Adapter Pattern](/workspace/@alkdev/alknet/docs/architecture/decisions/033-storage-boundary-and-repo-adapter-pattern.md) is the repo/adapter pattern behind the `OwnershipProvider` persistence story (OQ-19); its persistence adapters belong to the composition layer (OQ-43).
## Relevant Open Questions
| OQ | Title | Status | Relevance |
|----|-------|--------|-----------|
| OQ-07 | Call protocol scope within a connection | resolved (ADR-015) | Stream model, multiplexing, scope |
| OQ-13 | Operation path format and routing scope | resolved | `/{service}/{op}` is the correct design; remote dispatch is a separate layer |
| OQ-14 | Batch operation semantics | resolved | Correlated `call.requested` events is the correct protocol design |
| OQ-16 | Safe vault operations for call protocol exposure | resolved (ADR-010) | None exposed for now |
| OQ-19 | Session-scoped operation registries | resolved | Agent-written operations overlaid on curated registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Generalized by ADR-019 to cover connection-scoped overlays. |
| OQ-25 | ~~Remote-safe marking shape~~ | **dissolved** (ADR-024) | `remote_safe`/`trusted_peer` retired; peer authorization is `AccessControl::check(peer_identity)` |
| OQ-26 | OperationAdapter error type (AdapterError variants) | **resolved** | `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`; `#[non_exhaustive]` |
| OQ-27 | from_call re-import trigger | **resolved** | `from_call` is a manual free function; the assembly layer calls it after the dial (in `AlknetClient`). `refresh()` is a genuine feature addition. See ADR-028. |
| OQ-28 | from_call namespace collision | **resolved** | Same-peer collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays) |
| OQ-29 | CallClient TLS client-auth | **resolved** | Wire quinn client-auth; key-type-aware server cert verification; fingerprint normalization |
| OQ-30 | `PeerRef::Any` routing policy | **resolved** | Insertion-order first-match; richer routing is a feature extension |
| OQ-31 | `services/list-peers` re-export semantics | **resolved** | Opt-in `services/list-peers`; `services/list` is "own ops only" |
| OQ-32 | Multi-hop federation | open (feature extension) | One-hop model is the commitment; multi-hop is a feature extension, not a deferral |
| OQ-33 | PeerId — crypto identity vs stable logical id | **resolved** (ADR-025) | `PeerId = Identity.id = PeerEntry.peer_id` (stable across key rotation) |
| OQ-34 | Persistent peer registry | **resolved** (ADR-025+033) | Core trait + in-memory default; persistence adapters are separate crates |
| OQ-35 | ~~API key asymmetry~~ | **dissolved** | `PeerEntry` supports multiple credential paths; `ApiKeyEntry` is for tokens that ARE the identity |
| OQ-37 | X.509 outgoing-only case | **resolved** (ADR-034) | Three remote roles (public X.509 endpoint, transport relay, hub); `PeerEntry` asymmetry correct; verifier by `PeerEntry` presence |
| OQ-01 | Call protocol scope within a connection | resolved (ADR-015) | Stream model, multiplexing, scope |
| OQ-02 | Operation path format and routing scope | resolved | `/{service}/{op}` is the correct design; remote dispatch is a separate layer |
| OQ-03 | Batch operation semantics | resolved | Correlated `call.requested` events is the correct protocol design |
| OQ-04 | Session-scoped operation registries | resolved | Agent-written operations overlaid on curated registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Generalized by ADR-019 to cover connection-scoped overlays. |
| OQ-09 | Safe vault operations for call protocol exposure | resolved (ADR-010) | None exposed for now |
| OQ-10 | ~~Remote-safe marking shape~~ | **dissolved** (ADR-024) | `remote_safe`/`trusted_peer` retired; peer authorization is `AccessControl::check(peer_identity)` |
| OQ-11 | OperationAdapter error type (AdapterError variants) | **resolved** | `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`; `#[non_exhaustive]` |
| OQ-12 | from_call re-import trigger | **resolved** | `from_call` is a manual free function; the assembly layer calls it after the dial (in the composition layer's dial helper, `AlknetClient` — today the alknet glue; OQ-43). `refresh()` is a genuine feature addition. See ADR-028. |
| OQ-13 | from_call namespace collision | **resolved** | Same-peer collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays) |
| OQ-14 | CallClient TLS client-auth | **resolved** | Wire quinn client-auth; key-type-aware server cert verification; fingerprint normalization |
| OQ-15 | `PeerRef::Any` routing policy | **resolved** | Insertion-order first-match; richer routing is a feature extension |
| OQ-16 | `services/list-peers` re-export semantics | **resolved** | Opt-in `services/list-peers`; `services/list` is "own ops only" |
| OQ-17 | Multi-hop federation | deferred(scope) (feature extension) | One-hop model is the commitment; multi-hop is a feature extension, not a deferral |
| OQ-18 | PeerId — crypto identity vs stable logical id | **resolved** (ADR-025) | `PeerId = Identity.id = PeerEntry.peer_id` (stable across key rotation) |
| OQ-19 | Persistent peer registry | **resolved** (ADR-025; persistence adapters are composition-layer work — OQ-43) | Core trait + in-memory default; persistence adapters are separate crates |
| OQ-20 | ~~API key asymmetry~~ | **dissolved** | `PeerEntry` supports multiple credential paths; `ApiKeyEntry` is for tokens that ARE the identity |
| OQ-21 | X.509 outgoing-only case | **resolved** (ADR-054; provenance: alknet ADR-034, not ported) | Three remote roles (public X.509 endpoint, transport relay, hub); `PeerEntry` asymmetry correct; verifier by `PeerEntry` presence — re-owned in-crate by ADR-054 |
## Key Design Principles
@@ -78,12 +77,12 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
3. **Stream-agnostic correlation**: PendingRequestMap correlates by request ID, not by stream. The protocol works with any stream arrangement.
4. **Operation registry is layered**: The curated layer (`Local` provenance) is static — registered at startup by the CLI binary, immutable for the process lifetime. Session (`Session`) and imported (`FromCall` etc.) ops are dynamic overlays at their respective scopes (per-session, per-connection). The registry supports JSON Schema discovery. See ADR-019.
5. **Hand-rolled dispatch (no irpc)**: Operations dispatch through the hand-rolled `OperationRegistry` (ADR-014). The call protocol is the external interface; internal handler dispatch uses `Handler`/`StreamingHandler` trait objects (ADR-021), not an irpc service.
6. **Local dispatch only**: The operation registry dispatches to local handlers. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer, not a modification to alknet-call's path format.
6. **Local dispatch only**: The operation registry dispatches to local handlers. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer, not a modification to alkcall's path format.
7. **No secret material on the wire**: The call protocol carries no private keys, API keys, mnemonics, or decrypted credentials. Handlers receive outbound credentials through `OperationContext.capabilities`, injected at the assembly layer. See ADR-010.
8. **Abort cascades to descendants**: `call.aborted` for a parent request cascades to all non-terminal descendants. Default `abort-dependents`; `continue-running` opt-in. See ADR-020.
9. **Internal calls switch authority context, not skip ACL**: The `internal` flag marks composition-originated calls. ACL runs against the handler's composition authority, not the caller's and not as a blanket skip. Operations have External/Internal visibility. Scoped composition env bounds reachability. See ADR-017, ADR-018.
10. **Provenance determines composition capability**: Only `Local` and `Session` ops can compose. Leaves (`FromOpenAPI`, `FromMCP`, `FromCall`, `FromJsonSchema`) are forwarding stubs — they don't get composition authority or a scoped env. The assembly layer is the sole grantor of composition authority. See ADR-018. (`FromJsonSchema` is now a real HTTP-forwarding leaf per ADR-027, not a schema-only placeholder.)
11. **Connection direction is independent of call direction**: Who opens the connection is a connection-layer concern, not a protocol-layer concern. Both sides can call each other once connected. The `CallAdapter` accepts connections; the `CallClient` takes them over (`spawn_dispatch` primary; dial in `AlknetClient` per ADR-045); both produce the same `CallConnection` and dispatch through the same loop. See ADR-022, [client-and-adapters.md](client-and-adapters.md).
12. **Peer authorization via `AccessControl`**: A remote peer's call is authorized by `AccessControl::check(peer_identity)` against the op's `AccessControl` — the same mechanism that gates every other call. No `remote_safe` flag, no `trusted_peer` bypass. An op with `AccessControl::default()` is callable by any peer; an op with `required_scopes` is callable only by peers whose `Identity.scopes` satisfy them; an op with `Visibility::Internal` is never callable from the wire. See ADR-024.
13. **Adapter trait lives with the types; implementations live with their transport**: `OperationAdapter` is in `alknet-call`; `from_call` is in `alknet-call` (QUIC); `from_jsonschema`/`from_openapi`/`from_mcp`/`to_openapi`/`to_mcp` are in `alknet-http` (reqwest / axum). `alknet-call` stays lean — no HTTP client, no HTTP server. (`from_jsonschema` was originally in `alknet-call` as a schema-only placeholder; ADR-027 moved it to `alknet-http` as a real HTTP-backed adapter.) See [client-and-adapters.md](client-and-adapters.md).
13. **Adapter trait lives with the types; implementations live with their transport**: `OperationAdapter` is in alkcall; `from_call` is in alkcall (QUIC); `from_jsonschema`/`from_openapi`/`from_mcp`/`to_openapi`/`to_mcp` are in the HTTP host crate, `alkhttp` (reqwest / axum). alkcall stays lean — no HTTP client, no HTTP server. (`from_jsonschema` was originally in the call protocol as a schema-only placeholder; ADR-027 moved it to `alkhttp` — then named `alknet-http` — as a real HTTP-backed adapter.) See [client-and-adapters.md](client-and-adapters.md).
14. **No handler reads outbound credentials from any source other than `OperationContext.capabilities`** (no-env-vars invariant): the credential injection path is vault → assembly layer → `Capabilities` → `HandlerRegistration.capabilities` → `OperationContext.capabilities` → handler. Downstream consumers' `std::env::var` reads are unreachable because the assembly layer never calls `Default::default()`. See ADR-010, [client-and-adapters.md](client-and-adapters.md).
+84 -61
View File
@@ -1,11 +1,11 @@
---
status: draft
last_updated: 2026-07-09
last_updated: 2026-10-13 (alkcall reframing)
---
# Call Protocol
The wire protocol, stream model, framing, and adapter that alknet-call implements on ALPN `alk/call`.
The wire protocol, stream model, framing, and adapter that alkcall implements on ALPN `alk/call`.
## What
@@ -15,7 +15,7 @@ The `CallAdapter` implements `ProtocolHandler` for ALPN `alk/call`. It receives
## Why
The call protocol is the primary programmatic interface to an alknet node. While SSH provides interactive shell access and HTTP provides REST APIs, the call protocol provides structured, discoverable RPC — the same interface that NAPI clients, MCP tools, and other automation consumers use.
The call protocol is the primary programmatic interface to an alk node. While SSH provides interactive shell access and HTTP provides REST APIs, the call protocol provides structured, discoverable RPC — the same interface that NAPI clients, MCP tools, and other automation consumers use.
The protocol must be:
- **Cross-language**: JSON wire format consumable from TypeScript, Python, any language
@@ -40,7 +40,7 @@ pub struct CallAdapter {
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
/// Layer 1 — optional session-overlay source (agent crate supplies this;
/// None for non-agent deployments). See ADR-019, OQ-19.
/// None for non-agent deployments). See ADR-019, OQ-04.
session_source: Option<Arc<dyn SessionOverlaySource + Send + Sync>>,
/// Default timeout for wire calls (30s). Composed calls inherit the
/// parent's remaining deadline via `OperationContext.deadline`.
@@ -57,8 +57,9 @@ impl CallAdapter {
default_timeout: Duration::from_secs(30) }
}
/// Agent deployment: supply a session-overlay source. The agent crate
/// implements `SessionOverlaySource`; alknet-call defines the trait.
/// Agent deployment: supply a session-overlay source. A downstream
/// consumer (e.g. the agent crate) implements `SessionOverlaySource`;
/// alkcall defines the trait.
pub fn with_session_source(mut self, source: Arc<dyn SessionOverlaySource + Send + Sync>) -> Self {
self.session_source = Some(source);
self
@@ -71,10 +72,11 @@ impl CallAdapter {
}
}
/// Session overlay integration point (ADR-019). Defined in alknet-call
/// because `CallAdapter` must name the type — alknet-call cannot depend on
/// alknet-agent (agent depends on call, not reverse). The agent crate
/// implements this trait; alknet-call defines it. This is the same pattern
/// Session overlay integration point (ADR-019). Defined in alkcall
/// because `CallAdapter` must name the type — alkcall cannot depend on
/// its downstream consumers (an agent depends on call, not reverse). The
/// consumer (e.g. the agent crate) implements this trait; alkcall
/// defines it. This is the same pattern
/// as `IdentityProvider` (ADR-003: core defines the trait, handlers impl it).
///
/// The session overlay is an `OperationEnv` impl that wraps the curated base
@@ -129,18 +131,16 @@ pub struct CallConnection {
impl CallConnection {
/// Register an imported operation into this connection's overlay
/// (Layer 2, ADR-019). Called by `from_call` after discovery.
pub fn register_imported(&self, registration: HandlerRegistration) {
let name = registration.spec.name.clone();
self.imported_operations.write().insert(name, registration);
}
/// Refuses marked (`channel_open`) specs — they are not
/// overlay-importable (ADR-047 amendment 4 §4.8).
pub fn register_imported(&self, registration: HandlerRegistration)
-> Result<(), String>;
/// Register multiple imported operations (bulk variant for `from_call`).
pub fn register_imported_all(&self, registrations: Vec<HandlerRegistration>) {
let mut overlay = self.imported_operations.write();
for reg in registrations {
overlay.insert(reg.spec.name.clone(), reg);
}
}
/// Same marked-spec refusal; on refusal the entries inserted before the
/// marked one stay in the overlay and the error is returned.
pub fn register_imported_all(&self, registrations: Vec<HandlerRegistration>)
-> Result<(), String>;
/// Build an `OperationEnv` impl for this connection's overlay. Used by
/// the `CallAdapter` when composing the root `OperationContext.env`.
@@ -167,6 +167,24 @@ Layer-0-only; runtime overlay registration uses `CallConnection` methods.
When the connection drops, the overlay (and all imported ops) is dropped —
no explicit deregistration needed.
**Marked-spec registration refusal** (ADR-047 amendment 4 §4.8; ADR-022
amendment 2026-10-13): every generic registration path —
`OperationRegistry::register`, `CallConnection::register_imported`,
`register_imported_all`, and the `op/register` announcement path — refuses
a marked (`channel_open`) spec loudly at registration time. Marked ops are
channel-machinery ops (the data plane is the allocated channel's
`BiStream`); a marked spec can never land as an advertised-but-unusable
overlay body. The sanctioned marked-op imports are the channel-wrapper
family: `register_openable*`, `register_pub_openable`,
`register_relay_openable`. `op/register` rejects a marked announcement
with the typed error `MARKED_SPEC_UNSUPPORTED` (ADR-016 error schema),
naming `ChannelRelay::register_relay_openable` (relaying hub) and
`ChannelClient` (direct consumption) as the sanctioned compositions.
Unmarked `Sub`/`Pub` announcements register the real forwarding handler
shapes (ADR-022 amendment 2026-10-13). This closes the stub class: an
advertised op is always a capable one — implement-or-stop-advertising is
release-blocking (`tests/stub_guard.rs`).
The adapter:
1. Accepts bidirectional streams on the connection
2. Reads length-prefixed JSON `EventEnvelope` frames from each stream
@@ -185,7 +203,7 @@ differs (accept vs dial). Peer authorization flows through the existing
(ADR-024 §3). The composition env is peer-keyed (`PeerCompositeEnv`,
ADR-024 §1) to handle head→N-workers routing. See
[client-and-adapters.md](client-and-adapters.md) for the `Dispatcher` mechanism
and [ADR-024](decisions/029-peer-graph-routing-model.md) for the
and [ADR-024](decisions/024-peer-graph-routing-model.md) for the
peer-graph routing model.
### Stream Model
@@ -218,7 +236,7 @@ The `Value` type is `serde_json::Value`. The envelope is JSON because it must be
Binary payloads (postcard, protobuf) are base64-encoded as a JSON string within the `payload` field. The convention is: if an operation's output schema specifies a binary field, the handler encodes it as a base64 string and the client decodes it. The `EventEnvelope` structure is not aware of this convention — it carries a `serde_json::Value` and does not interpret the payload. This is a handler-level concern, not a protocol-level concern.
This is hand-rolled length-prefixed JSON framing (ADR-014), coincidentally
the same shape irpc uses. The Rust implementation in alknet-call is
the same shape irpc uses. The Rust implementation in alkcall is
canonical — the `@alkdev/pubsub` TypeScript adapters serve as a reference
and browser adaptation, not a parallel implementation (see ADR-033).
@@ -265,7 +283,7 @@ The `payload` of a `call.requested` event has this shape:
- `operationId` — the operation to invoke, **with a leading slash** on the wire (e.g., `/fs/readFile`, `/agent/chat`, `/services/list`). This is the display form of the operation name. The registry stores names without the leading slash (`fs/readFile` — see [operation-registry.md](operation-registry.md#operationspec)); the wire format adds it. The `CallAdapter` strips the leading slash before registry lookup.
- `input` — the operation input, matching the operation's `input_schema` (JSON Schema). Always a `serde_json::Value`.
- `auth_token` — optional. If present, the `CallAdapter` resolves it via `IdentityProvider::resolve_from_token()` and the resulting `Identity` takes precedence over the connection-level identity for this request. See [Identity Resolution](#authcontext-and-identity-resolution) below.
- `forwarded_for` — optional (ADR-026). Present when a `from_call` forwarding handler propagates the originator's identity to a spoke. Carries a serialized `Identity` (id, scopes, resources) — the end user the hub authenticated. **Metadata only** — `AccessControl::check` never reads it; the spoke authorizes the hub (its direct caller), not the end user. The hub may set `forwarded_for: None` if it doesn't want to disclose the originator. See [ADR-026](decisions/032-forwarded-for-identity.md).
- `forwarded_for` — optional (ADR-026). Present when a `from_call` forwarding handler propagates the originator's identity to a spoke. Carries a serialized `Identity` (id, scopes, resources) — the end user the hub authenticated. **Metadata only** — `AccessControl::check` never reads it; the spoke authorizes the hub (its direct caller), not the end user. The hub may set `forwarded_for: None` if it doesn't want to disclose the originator. See [ADR-026](decisions/026-forwarded-for-identity.md).
The `call.requested` payload does **not** carry an abort policy field. The abort policy (`abort-dependents` vs `continue-running`, ADR-020) is set on `OperationContext` and propagated through `OperationEnv::invoke()` — the composing handler decides the child's policy, not the wire caller. See [Abort Cascade and Nested Calls](#abort-cascade-and-nested-calls) below.
@@ -336,7 +354,7 @@ The call protocol defines five top-level operations, expressed through event typ
| **batch** | multiple `call.requested` (different IDs) → multiple `call.responded` | Multiple operations in one round |
| **schema** | `call.requested` name `services/list` or `services/schema` → `call.responded` | Discover available operations |
Batch is not a separate event type — it's multiple `call.requested` events with different request IDs. The client sends them (on one or many streams) and correlates the responses by ID. See OQ-14.
Batch is not a separate event type — it's multiple `call.requested` events with different request IDs. The client sends them (on one or many streams) and correlates the responses by ID. See OQ-03.
### Bidirectional Calls
@@ -557,8 +575,8 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
- The call protocol does not depend on any database. `PendingRequestMap` is in-memory. Durable session storage is a consumer concern.
- Operation specs use JSON Schema. The envelope is always JSON. Binary payloads may be base64-encoded in the `payload` field.
- Batch is not a protocol primitive — multiple `call.requested` events with correlated IDs provide equivalent semantics. See OQ-14.
- The call protocol is transport-agnostic at the envelope level. The `EventEnvelope` framing can run over QUIC streams, WebSocket frames, or Worker `postMessage`. The `CallAdapter` is the `ProtocolHandler` implementation that receives a `Connection` (any transport — ADR-007) and dispatches `EventEnvelope` frames. **The `EventEnvelope` shape (`{ type, id, payload }`) was derived from the `@alkdev/pubsub` `EventEnvelope` (`/workspace/@alkdev/pubsub/src/types.ts`), which already has a working WebSocket client/server implementation (`event-target-websocket-client.ts` / `event-target-websocket-server.ts`) and a generalized "event target" abstraction. The call protocol refined the envelope with typed event names (`call.requested`, `call.responded`, etc.) and structured payloads; the delta is small and well-defined, making a browser (and Node) WebSocket client straightforward to derive from the pubsub prior art. See ADR-044, [ADR-048](decisions/048-websocket-native-session-not-gateway.md), and [websocket.md](../http/websocket.md).
- Batch is not a protocol primitive — multiple `call.requested` events with correlated IDs provide equivalent semantics. See OQ-03.
- The call protocol is transport-agnostic at the envelope level. The `EventEnvelope` framing can run over QUIC streams, WebSocket frames, or Worker `postMessage`. The `CallAdapter` is the `ProtocolHandler` implementation that receives a `Connection` (any transport — ADR-007) and dispatches `EventEnvelope` frames. **The `EventEnvelope` shape (`{ type, id, payload }`) was derived from the `@alkdev/pubsub` `EventEnvelope` (`/workspace/@alkdev/pubsub/src/types.ts`), which already has a working WebSocket client/server implementation (`event-target-websocket-client.ts` / `event-target-websocket-server.ts`) and a generalized "event target" abstraction. The call protocol refined the envelope with typed event names (`call.requested`, `call.responded`, etc.) and structured payloads; the delta is small and well-defined, making a browser (and Node) WebSocket client straightforward to derive from the pubsub prior art. Provenance: alknet ADR-044 and the alknet websocket spec (not ported) recorded the WebSocket-native session decision; the WebSocket carrier for the alk protocols is implemented in the sibling host crate `alkhttp` (its `websocket` feature), not in alkcall — alkcall stays transport- and host-blind.
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer. See ADR-014 and OQ-13.
- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials, raw tokens) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. The wire format carries `serde_json::Value` and cannot enforce this at the type level — the constraint is architectural, enforced by the operation registry and by convention. Operations that need to share public key material use a dedicated operation that returns only the public component. See ADR-010.
- **Abort cascades to descendants.** `call.aborted` for a parent request cascades to all non-terminal descendants in the call tree. Default policy is `abort-dependents`; `continue-running` is an opt-in. See ADR-020.
@@ -567,19 +585,19 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
| Decision | ADR | Summary |
|----------|-----|---------|
| Hand-rolled EventEnvelope framing (irpc never integrated) | [ADR-014](decisions/064-irpc-never-integrated-hand-rolled-framing.md) | Hand-rolled length-prefixed JSON framing, operation registry, dispatch; supersedes ADR-013 (irpc was never imported) |
| Call protocol stream model | [ADR-015](decisions/012-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
| ALPN per connection | [ADR-004](decisions/006-alpn-convention-and-connection-model.md) | `alk/call` is a distinct ALPN, one connection per ALPN |
| ProtocolHandler receives Connection | [ADR-005](decisions/007-bistream-type-definition.md) | CallAdapter gets Connection, can accept/open multiple streams |
| Vault integration point | [ADR-008](decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time |
| Secret material flow | [ADR-010](decisions/014-secret-material-flow-and-capability-injection.md) | Call protocol carries no secret material; capabilities injected at assembly layer |
| Privilege model and authority context | [ADR-017](decisions/015-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
| Abort cascade for nested calls | [ADR-020](decisions/016-abort-cascade-for-nested-calls.md) | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
| Call protocol client and adapter contract | [ADR-022](decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction. Client/adapter surface specced in [client-and-adapters.md](client-and-adapters.md) |
| Handler registration, provenance, and composition authority | [ADR-018](decisions/022-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
| Peer-graph routing model (supersedes ADR-023) | [ADR-024](decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; `AccessControl`-based peer authorization; retires `remote_safe`/`trusted_peer` |
| Forwarded-for identity | [ADR-026](decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
| Operation error schemas | [ADR-016](decisions/023-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details` |
| Hand-rolled EventEnvelope framing (irpc never integrated) | [ADR-014](decisions/014-irpc-never-integrated-hand-rolled-framing.md) | Hand-rolled length-prefixed JSON framing, operation registry, dispatch; supersedes ADR-013 (irpc was never imported) |
| Call protocol stream model | [ADR-015](decisions/015-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
| ALPN per connection | [ADR-004](decisions/004-alpn-convention-and-connection-model.md) | `alk/call` is a distinct ALPN, one connection per ALPN |
| ProtocolHandler receives Connection | [ADR-005](decisions/005-bistream-type-definition.md) | CallAdapter gets Connection, can accept/open multiple streams |
| Vault integration point | [ADR-010](decisions/010-secret-material-flow-and-capability-injection.md) | Vault is a capability source, accessed at assembly time (provenance: alknet ADR-008, superseded in-crate by ADR-010) |
| Secret material flow | [ADR-010](decisions/010-secret-material-flow-and-capability-injection.md) | Call protocol carries no secret material; capabilities injected at assembly layer |
| Privilege model and authority context | [ADR-017](decisions/017-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
| Abort cascade for nested calls | [ADR-020](decisions/020-abort-cascade-for-nested-calls.md) | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
| Call protocol client and adapter contract | [ADR-022](decisions/022-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction. Client/adapter surface specced in [client-and-adapters.md](client-and-adapters.md) |
| Handler registration, provenance, and composition authority | [ADR-018](decisions/018-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
| Peer-graph routing model (supersedes ADR-023) | [ADR-024](decisions/024-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; `AccessControl`-based peer authorization; retires `remote_safe`/`trusted_peer` |
| Forwarded-for identity | [ADR-026](decisions/026-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
| Operation error schemas | [ADR-016](decisions/016-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details` |
| Streaming handler for subscriptions | [ADR-021](decisions/021-streaming-handler-for-subscriptions.md) | `StreamingHandler` type, `invoke_streaming()` dispatch path, `INVALID_OPERATION_TYPE` protocol code; the server-side streaming branch in `handle_stream` |
| Publish operation type and HandlerKind::Sink | [ADR-046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | `OperationType::Pub` (producer→consumer streaming); `SinkHandler` + `HandlerKind::Sink`; `call.published` wire event; `invoke_sink()` dispatch; `Subscription` renamed to `Sub` |
@@ -587,44 +605,49 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
See [open-questions.md](open-questions.md) for full details.
- **OQ-13** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
- **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
- **OQ-16** (resolved by ADR-010): No vault operations are exposed over the call protocol for now.
- **OQ-19** (resolved): Session-scoped operation registries — agent-written operations overlaid on global registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait.
- **OQ-25** (dissolved by ADR-024): `remote_safe` marking shape — moot;
- **OQ-02** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
- **OQ-03** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
- **OQ-09** (resolved by ADR-010): No vault operations are exposed over the call protocol for now.
- **OQ-04** (resolved): Session-scoped operation registries — agent-written operations overlaid on global registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait.
- **OQ-10** (dissolved by ADR-024): `remote_safe` marking shape — moot;
`remote_safe`/`trusted_peer` retired; peer authorization is
`AccessControl::check(peer_identity)`.
- **OQ-26** (resolved): `OperationAdapter` error type — `AdapterError`
- **OQ-11** (resolved): `OperationAdapter` error type — `AdapterError`
variants (`DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`,
`SamePeerCollision`); `#[non_exhaustive]`. See
[client-and-adapters.md](client-and-adapters.md).
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
manual free function; the assembly layer calls it after the dial (in
`AlknetClient`). See
[ADR-028](decisions/069-from-call-manual-free-function.md).
- **OQ-28** (resolved): `from_call` namespace collision — same-peer collision
- **OQ-12** (resolved): `from_call` re-import trigger — `from_call` is a
manual free function; after the dial (in the composition layer's dial helper
`AlknetClient` — today the alknet glue; OQ-43). See
[ADR-028](decisions/028-from-call-manual-free-function.md).
- **OQ-13** (resolved): `from_call` namespace collision — same-peer collision
= error; cross-peer dissolved by ADR-024 (separate sub-overlays). See
[client-and-adapters.md](client-and-adapters.md).
- **OQ-29** (resolved): `CallClient` TLS client-auth — wire quinn client-auth
- **OQ-14** (resolved): `CallClient` TLS client-auth — wire quinn client-auth
(present Ed25519 key as raw public key client cert); key-type-aware server
cert verification; fingerprint normalization. See
[client-and-adapters.md](client-and-adapters.md).
- **OQ-30** (resolved): `PeerRef::Any` routing policy — insertion-order
- **OQ-15** (resolved): `PeerRef::Any` routing policy — insertion-order
first-match. See [client-and-adapters.md](client-and-adapters.md).
- **OQ-31** (resolved): `services/list-peers` re-export semantics — opt-in;
- **OQ-16** (resolved): `services/list-peers` re-export semantics — opt-in;
`services/list` is "own ops only." See [client-and-adapters.md](client-and-adapters.md).
- **OQ-32** (open, feature extension): Multi-hop federation — the one-hop
- **OQ-17** (deferred(scope), feature extension): Multi-hop federation — the one-hop
model is the architectural commitment; multi-hop is a feature extension
that doesn't break downstream. See [client-and-adapters.md](client-and-adapters.md).
- **OQ-33** (resolved by ADR-025): `PeerId` source — `Identity.id` from
- **OQ-18** (resolved by ADR-025): `PeerId` source — `Identity.id` from
`IdentityProvider` resolution (= `PeerEntry.peer_id`, stable across key
rotation), not a connection-assigned UUID.
- **OQ-34** (resolved by ADR-025 + ADR-033): Persistent peer registry —
- **OQ-19** (resolved by ADR-025): Persistent peer registry —
the storage boundary is `core trait + in-memory default`; persistence
adapters are separate crates.
- **OQ-37** (resolved by ADR-034): X.509 outgoing-only case — three remote
adapters are separate crates (composition-layer work — OQ-43; the
concrete adapter shapes were named in the source repo's research
(alknet, provenance), not re-specced here).
- **OQ-21** (resolved by ADR-054): X.509 outgoing-only case — three remote
roles named (public X.509 endpoint, transport relay, hub); pure-client
X.509 connections are not in the peer graph on the client side. See
X.509 connections are not in the peer graph on the client side.
Re-owned in-crate by ADR-054 (verifier selection by `PeerEntry`
presence; provenance: alknet ADR-034, not ported); ADR-025's
`PeerEntry` asymmetry is the surviving registry surface. See
[client-and-adapters.md](client-and-adapters.md).
## References
@@ -636,6 +659,6 @@ See [open-questions.md](open-questions.md) for full details.
- ADR-024: Peer-graph routing model (peer-keyed overlays + `PeerRef` routing)
- ADR-025: PeerEntry and Identity.id decoupling (`PeerId` source)
- ADR-026: Forwarded-for identity (`forwarded_for` on `call.requested` and `OperationContext`)
- ADR-034: Outgoing-only X.509 and the three peer roles
- ADR-054: Outgoing-only X.509 and the three remote roles (re-owned in-crate; verifier selection by `PeerEntry` presence)
- ADR-021: Streaming handler for subscriptions (server-side streaming dispatch path)
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`
- Reference implementation: this crate (`src/`); historical origin: the alknet / alknet-main call-core sources (provenance)
+22 -20
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-18
last_updated: 2026-10-13 (alkcall reframing)
---
# channel-client.md — ChannelClient
@@ -152,17 +152,19 @@ takes over channels establishment. The transport is the caller's concern:
`Connection::from_bidi(tls_stream, ...)` for TCP+TLS, a quinn `Connection`,
a WebTransport `BiStream`, an SSH `direct-tcpip` channel wrapped via
`from_bidi`, a WebSocket carrying `alk/channels` (the browser path per
ADR-044) — all produce a `Connection` that `from_connection` accepts
alknet ADR-044 — websocket-native session, provenance) — all produce a `Connection` that `from_connection` accepts
unchanged. This mirrors the server side's `ChannelsAdapter::handle(Connection)`, which is substrate-agnostic by the same mechanism.
The dial (QUIC, TCP+TLS, iroh) lives in `AlknetClient` (`alknet-client`,
The dial (QUIC, TCP+TLS, iroh) lives in the composition layer's dial
helper, `AlknetClient` (today in the alknet glue; future owner OQ-43 —
ADR-045), not on `ChannelClient`. Callers compose
`AlknetClient::dial_quic(...).await?` + `ChannelClient::from_connection(conn).await?`
— two lines, the dial then the take-over. Keeping the dial off
`ChannelClient` avoids `alknet-channels-call` depending on `alknet-client`;
`ChannelClient` avoids alkcall depending on a dial crate;
the protocol crates are parallel to the dial, not downstream of it.
The credential/verifier-selection rule (ADR-034) lives in the dial
The credential/verifier-selection rule (ADR-054, re-owned from the
source repo's alknet ADR-034) lives in the dial
(`AlknetClient`), not in `from_connection` — `from_connection` receives
an already-established, already-authenticated `Connection`, exactly as
`ChannelsAdapter::handle` does on the server side.
@@ -181,12 +183,13 @@ connect side's channel 0 (if the connect side registers any).
name follows the `CallClient` convention (the side that dialed), not a
request/response role.
## Relationship to `AlknetClient`
## Relationship to the composition layer's dial helper
`ChannelClient`'s *API* is transport-agnostic — `from_connection` takes a
pre-established `Connection`. The shared *dial+TLS* seam
(`AlknetClient`, OQ-55) is [`alknet-client`](../client/README.md), which
provides `AlknetClient` with three dial methods (`dial_quic` /
pre-established `Connection`. The shared *dial+TLS* seam is the
composition layer's dial helper, `AlknetClient` (ADR-045; today in the
alknet glue, future owner OQ-43), which
provides three dial methods (`dial_quic` /
`dial_tcp_tls` / `dial_iroh`), each producing a `Connection` that
`from_connection` consumes. The dial is transport-specific (QUIC,
TCP+TLS, iroh); the take-over (`from_connection`) is
@@ -194,10 +197,9 @@ transport-agnostic. The two concerns are separated.
`AlknetClient::dial_quic` is the dial that feeds `from_connection`. A
caller that needs transport selection (QUIC with TCP+TLS fallback) uses
`AlknetClient` directly; the fallback policy is a caller concern. See
[ADR-045](decisions/089-alknetclient-native-dial-seam.md) for the
full decision and [OQ-55](../../questions/055-alknetclient-establishment-extraction.md)
(resolved).
the dial helper directly; the fallback policy is a caller concern. See
[ADR-045](decisions/045-alknetclient-native-dial-seam.md) for the
full decision.
## Design Decisions
@@ -205,15 +207,15 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [043](decisions/043-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045, resolves OQ-55) |
| [043](decisions/043-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in the composition layer's dial helper (ADR-045) |
| [035](decisions/035-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | No `stream_types` on `open_channel`/`Channel`; handler owns sub-stream multiplexing |
## Open Questions
- **OQ-55** (resolved by ADR-045): `AlknetClient` core **dial+TLS seam**
— `alknet-client` with three dial methods. `ChannelClient`'s API is
transport-agnostic (`from_connection`); the dial is the shared seam.
See [ADR-045](decisions/045-alknetclient-native-dial-seam.md).
- **Dial seam ownership** (ADR-045): the dial helper (`AlknetClient`)
lives in the composition layer today; its post-alknet owner and shape
is OQ-43. `ChannelClient`'s API is transport-agnostic
(`from_connection`); the dial is the shared seam.
## References
@@ -225,6 +227,6 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
- ADR-039: ChannelManager (the shared state `ChannelClient` holds)
- ADR-047: openable ALPNs are operations (per-ALPN open ops dissolve
the generic `channel/open`)
- OQ-55: AlknetClient / client establishment extraction
- `docs/architecture/crates/call/client-and-adapters.md` — `CallClient` (the
- ADR-045: the dial seam (dial out of the protocol crate)
- [client-and-adapters.md](client-and-adapters.md) — `CallClient` (the
shape `ChannelClient` mirrors)
+121 -30
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-08-12
status: current
last_updated: 2026-10-13 (alkcall reframing)
---
# channel-operations.md — Channel Lifecycle on the Call Protocol
@@ -60,15 +60,18 @@ Response (`call.responded`):
|-------|------|---------|
| `channel_id` | u32 | Allocated by the connection owner (ADR-047 §5). In the `Sub` case, the responder (producer). Both sides route chunks with this ID to the new channel. |
**`channel_id` allocation (ADR-047 §5): connection-owner allocates.**
**`channel_id` allocation (ADR-047 §5, as amended for the put path by
ADR-047 amendment 4): connection-owner allocates.**
The side that holds the `ChannelManager` for that connection allocates
the `channel_id` via a monotonic `AtomicU32` (`next_id.fetch_add(1,
Relaxed)`) and returns it in the response. In the `Sub` case the
responder owns the connection and allocates; in the `Pub` case the
initiator owns and allocates. One round-trip before data flows — the
same round-trip the call protocol makes for every operation. All
current channel types (TTY, tunnel, SSH) already require a negotiation
round-trip, so the open round-trip is not additive latency.
the `channel_id` via a monotonic `AtomicU32` (odd/even split — connect
side starts at 1, accept at 2) and returns it in the response. In the
`Sub` case the responder owns the connection and allocates — one
round-trip before data flows (the same round-trip every call makes).
In the `Pub` case (the put contract above) the initiator owns,
allocates, and streams bytes **before** the open resolves — the
early-arrival parking covers the adopt race. All current channel
types (TTY, tunnel, SSH) already require a negotiation round-trip, so
the open round-trip is not additive latency.
**Error codes** (`CallError.code` strings):
@@ -110,19 +113,80 @@ the existing teardown machinery, unchanged. Head-of-line safety: the
serving loop spawns Once invocations as independent tasks, so a slow
establisher on one open op does not block other calls on channel 0.
### `channels/<alpn>/pub` — publish a binary stream
### `channels/<alpn>/pub` — publish a binary stream (the put contract, ADR-047 amendment 4)
`OperationType::Pub` (ADR-046). The initiator publishes a stream of
`call.published` events to the responder; the responder's `SinkHandler`
consumes them and returns a single `call.responded`. The
`channel_open` marker on the op spec tells the channels layer the
stream is binary — the `call.published` chunks carry binary payloads
on a data channel (not JSON on channel 0).
A marked `Pub`-typed open op is the **put** shape: the data plane is
the adopted channel's `BiStream` — never the call plane. The contract,
pinned end-to-end (ADR-047 amendment 4; review 011's corrections are
the authoritative shape — a serving-loop `PublishStream` sketch was
considered and rejected, it is ADR-046's *unmarked* pub semantics):
1. **The initiator allocates and writes immediately.** The putter
allocates the channel id on its own side (connect side → odd, per
the ADR-047 §5 odd/even split), inserts the **reserved input key
`"channel_id"`** into the open op's input, and streams bytes on the
channel before the open resolves — the early-arrival parking
(ADR-051's tested pre-adopt buffer) covers the responder-adopts
race. Zero `call.published` on marked ops: chunks on the call plane
are rejected loudly with `channel:publish_chunks_rejected` (the
first chunk writes the rejection in the initiator's direction; the
handler is never fed). An unmarked `Pub` op is an ordinary ADR-046
sink (JSON chunks, `publish_schema`); it does not ride the channel
machinery.
2. **The responder adopts.** `ChannelCore::register_pub_openable` is
the only registration seam: the wrapper strips the reserved key
pre-validation (dispatch layer, before `input_schema`) and adopts
the given id (`adopt_channel` — the remote parity range enforced;
failure replies `channel:open_failed` with reason `adopt_failed`).
3. **The wrapper is the responder of record:** `check_open` → adopt →
bounded establisher (offer validation + ack fields +
`Establishment::with_control`, ADR-049 amendment 3's mechanism) →
the `PubOpenHandler` runs on the adopted channel's `Connection`,
**awaited inline** → the standard completion teardown → **one
response**, merge order pinned:
```
{ "channel_id": <adopted> } → ack fields → handler result
```
(the handler result wins on key collision — its computed result
postdates the ack; a handler `Err` IS the response, and the channel
tears down).
4. **The ack-timing sentence (protocol semantics):** a sink op gets
exactly one `call.responded`, produced after the handler completes
— a putter stream-waits nothing; the establishment ack is
informative-in-result, never a gating ack. The fetch/put asymmetry
is direction-inherent: fetch (`Sub`) acks at establishment (the
`{channel_id}` response precedes any byte); put (`Pub`) acks in the
result. A putter that waits for the ack deadlocks — this sentence
is the contract, not per-consumer folklore.
5. **The putter-facing client shape:**
`ChannelClient::open_pub_channel(operation_id, input, alpn)`
allocates the odd id, sends the open op in the background, and
returns `(id, send, recv, reply)` immediately; `reply.resolve()`
is the awaited merged response.
**Error codes** (the pub path):
| code | meaning | retryable |
|------|---------|-----------|
| `channel:open_failed` | establishment failure (ADR-049 §3 vocabulary) or adoption failure — new reason `adopt_failed` (collisions, cap, out-of-range reserved ids; ADR-047 amendment 4 §4.4) | false |
| `channel:publish_chunks_rejected` | `call.published` chunks on a marked op (the data plane is the channel, ADR-047 amendment 4 §4.1) | false |
| `INVALID_INPUT` | absent/non-u32 reserved `channel_id` key on a marked pub op (ADR-047 amendment 4 §4.3) | false |
The hub-as-proxy pattern (ADR-042, ADR-047 §1) composes on top: a
worker publishes to the hub, the hub owns the resource, and consumers
subscribe from the hub. The broker (topic registry, `Pub`↔`Sub`
matching) is a hub concern, not an alkcall concern (ADR-047 §1, Gap B).
worker publishes to the hub, the hub owns the resource, consumers
subscribe from the hub. Marked pub openables relay through the put
translation (ADR-047 amendment 4 §4.7): the hub allocates its own
connect-side id, opens the spoke op in the background (put
acks-in-result — an establisher awaiting the spoke's response would
deadlock the leg), pumps consumer bytes → spoke leg, and mirrors the
spoke's single response with its reserved key stripped.
Registration invariants (ADR-047 amendment 4 §4.8 — the stub-class
guard): a marked spec refuses the generic registration path and
refuses `publish_schema`; `register_openable*` refuse `Pub`-typed
specs; `register_pub_openable` refuses unmarked/non-`Pub` specs.
## The generic ops (keyed by `channel_id`)
@@ -153,13 +217,13 @@ decrement.
chunks MUST be written and flushed before the `channel/close` operation
is sent on channel 0. The side closing must observe the data-channel
pump complete before issuing the call operation. For TTY this is the
exit-chunk-is-last invariant (ADR-055) carried forward — the exit
exit-chunk-is-last invariant (alknet ADR-055, provenance) carried forward — the exit
control message rides on TTY's `STREAM_CTRL_OUT` (stream_type 4, inside
TTY's 5-byte payload format); for tunnels it is the last data byte
before close. This invariant crosses two channels (the data channel and
channel 0), so the channels layer owns the ordering guarantee.
### `channel/control` — out-of-band control on channel 0
### `channel/control` — out-of-band control on channel 0 (implemented, ADR-052)
For control that doesn't need ordering relative to data (resize, signal,
keepalive):
@@ -174,11 +238,23 @@ keepalive):
}
```
The channels layer routes `message` to the handler's control handle for
`channel_id`. The `message` JSON is ALPN-specific; the channels layer
does not interpret it.
The channels layer routes `message` to the channel's control callback
(ADR-052): the ALPN crate contributes it through its open-op
establisher (`Establishment::with_control`); the wrapper installs it
under the allocated/adopted id and every teardown path removes it. The
`message` JSON is ALPN-specific; the channels layer does not interpret
it. The reply is `{ "control": <callback output> }`, or:
### `channel/resources/subscribe` — live resource discovery
| code | meaning |
|------|---------|
| `channel:unknown_channel` | no such channel (pre-existing class) |
| `channel:no_control_handle` | the channel's ALPN registered no control surface — a capability answer, not a failure (ADR-052 §3); data-ordered control rides the data channel (DP-4 below) |
| (callback's own code) | the callback's `CallError` verbatim |
Control that MUST be ordered relative to data rides the data channel's
`BiStream` (the control-message division below is unchanged).
### `channel/resources/subscribe` — live resource discovery (implemented, ADR-053)
**This is a `Sub` operation (ADR-021), not a polled Query.** The call
protocol has `StreamingHandler` / `invoke_streaming` (implemented and
@@ -193,8 +269,21 @@ start/stop.
}
```
The responder registers a `StreamingHandler` that emits a
`ResponseEnvelope` whenever the resource set changes. Each event:
Each openable ALPN crate provides a **resource enumerator**
(ADR-053 §1 — snapshot-then-changes: the first item is the current
payload, subsequent items are full replacements; the producing crate
owns its change detection) at assembly/install time:
`ResourceEnumerators::register(alpn, enumerator)`, one shared registry
per accepting peer. The handler aggregates the enumerators registered
at subscription start (the **scan-once** pin — a later registration is
invisible to an in-flight subscription; re-subscribe):
- **non-empty registry:** each enumerator item becomes one event,
`{ "output": { "resources": [ { "alpn": …, …payload } ] } }` — the
first events across the ALPNs are the initial snapshot;
- **empty registry:** exactly one `{ "resources": [] }` event, then
the stream stays open (subscribe, read the first event, cancel —
the poll use case).
```json
{
@@ -238,7 +327,7 @@ would pressure consumers toward the stale-poll path.
| Call operations on channel 0 (`channel/control`, `channel/close`) | Control that doesn't need ordering relative to data, or lifecycle events | resize, signal, keepalive, close |
| Data-ordered bytes on the data channel's `BiStream` (handler-internal framing) | Control that MUST be ordered relative to data | EOF before exit, flush before close |
The TTY crate's exit-chunk-is-last invariant (ADR-055) is the canonical
The TTY crate's exit-chunk-is-last invariant (alknet ADR-055, provenance) is the canonical
example of data-ordered control — it rides on TTY's `STREAM_CTRL_OUT`
(stream_type 4, inside TTY's 5-byte payload format) because it must
arrive after the last data on TTY's stdout stream_type, guaranteed by
@@ -251,7 +340,7 @@ follows is on channel 0 and is ordered after the data pump completes
the channels layer has no `stream_type` concept — it carries the
handler's framing transparently in the payload. TTY's `STREAM_CTRL_IN`
(stream_type 3) and `STREAM_CTRL_OUT` (stream_type 4) are stream_types
in TTY's 5-byte format (ADR-052, amended by Phase 7), not
in TTY's 5-byte format (alknet ADR-052, amended by Phase 7), not
channels-layer concepts. The channels layer routes by `channel_id` only;
the handler owns its sub-stream multiplexing on the `BiStream` it
receives.
@@ -471,6 +560,8 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [021](decisions/021-streaming-handler-for-subscriptions.md) | StreamingHandler | The machinery `channel/resources/subscribe` uses |
| [046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | Pub Operation Type | The `Pub`/`Sub` primitives the per-ALPN open ops build on |
| [049](decisions/049-channel-open-establishment-phase.md) | Channel-Open Establishment Phase | The establisher hook; typed `channel:open_failed`; `Establishment.plan` (amendment 2); the `JoinHandle` lifetime contract |
| [052](decisions/052-channel-control-surface.md) | `channel/control` Control-Handle Surface | The establisher-contributed `ChannelControl`; wrapper-install/teardown-removal; `channel:no_control_handle` |
| [053](decisions/053-resource-enumerators.md) | Live Resource Enumerators | The snapshot-then-changes enumerator contract; scan-once aggregation; the empty seed |
| [050](decisions/050-pump-bidi-two-pump-helper.md) | `pump_bidi` Two-Pump Helper | The data-plane helper openable-ALPN handlers await inline |
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens (and why the cap is per direct-caller, not per `forwarded_for`) |
| [011](decisions/011-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The parallel — a channel slot is a resource, the cap is a quota check; `resource_id_path` works again under per-ALPN ops |
+40 -34
View File
@@ -1,9 +1,9 @@
---
status: draft
last_updated: 2026-07-18
last_updated: 2026-10-13 (alkcall reframing)
---
# alknet-channels
# alkcall — Channels Protocol
A multiplexing proxy: a `ProtocolHandler` on `alk/channels` that
decomposes a single bidirectional transport stream into N logical channels,
@@ -20,46 +20,46 @@ handler owns its sub-stream multiplexing on the `BiStream` it receives.
| Document | Status | Description |
|----------|--------|-------------|
| [overview.md](overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, ALPN, transport agnosticism, WASM, relationship to existing crates |
| [overview.md](channels-overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, ALPN, transport agnosticism, WASM, relationship to existing crates |
| [channels-wire.md](channels-wire.md) | draft | The 8-byte chunk format (`[channel_id:u32 be][length:u32 be][payload]`), the add/strip composition, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
| [channels-connection.md](channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-008/074, as amended by ADR-035), `accept_bi` yields one `BiStream` per channel, recursive composition |
| [channels-adapter.md](channels-adapter.md) | draft | `ChannelsAdapter` (`ProtocolHandler` on `alk/channels`), `ChannelManager`, demux/mux contracts (REQ-CH-01..04), the two-pump pattern (ADR-078) |
| [channels-connection.md](channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-008 + `from_source`, as amended by ADR-035), `accept_bi` yields one `BiStream` per channel, recursive composition |
| [channels-adapter.md](channels-adapter.md) | draft | `ChannelsAdapter` (`ProtocolHandler` on `alk/channels`), `ChannelManager`, demux/mux contracts (REQ-CH-01..04), the two-pump pattern (ADR-050) |
| [channel-operations.md](channel-operations.md) | draft | `channel/open`, `channel/close`, `channel/control`, `channel/resources/subscribe` — call-protocol operations on channel 0, ACL flow, `direction` semantics, the hub relay contract (ADR-042) |
| [channel-client.md](channel-client.md) | draft | `ChannelClient` — the client side of a channels connection; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045); bidirectionality preserved |
| [channel-client.md](channel-client.md) | draft | `ChannelClient` — the client side of a channels connection; transport-agnostic `from_connection` primary; dial lives in the composition layer's dial helper (ADR-045); bidirectionality preserved |
## Applicable ADRs
| ADR | Title | Relevance |
|-----|-------|-----------|
| [071](decisions/071-channels-wire-format.md) | channels Wire Format — 8-Byte Chunk Header | The chunk format; channels layer has no `stream_type` concept (amended by ADR-035); substrate-agnostic; one-way door |
| [093](decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
| [072](decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alk/call` | Channel 0 = call protocol; no special control plane |
| [073](decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | `channel/open`/`close`/`control`/`resources/subscribe`; `direction` semantics; subscribe not poll |
| [074](decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `accept_bi` yields `BiStream` (amended by ADR-035 — `into_sub_streams` removed) |
| [075](decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 contracts |
| [076](decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Bounded-buffer (1 MiB default), 256-channel per-connection memory bound, monotonic IDs with wrap (DoS defense reframed by ADR-041) |
| [094](decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound; symmetric (both sides enforce); spoke caps hub (direct caller), not browser (forwarded_for is metadata) |
| [077](decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | TTY's two modes (direct vs channels); TTY always uses its 5-byte format, carried transparently in the channels payload |
| [078](decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | The two-pump deadlock contract; handler-level, not channels-layer |
| [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | The hub translates channel 0, byte-forwards data channels with ID rewrite |
| [080](decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045, resolves OQ-55) |
| [081](decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers, not sub-crates |
| [070](decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The `Connection` extension point `ChannelBidiStreamSource` implements |
| [092](decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream`; the transport-leaf decision ADR-035 builds on |
| [065](decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The transport-agnostic `Connection` the channels layer rides on |
| [052](decisions/052-alknet-tty-wire-format-and-two-carriage.md) | alknet-tty Wire Format | The 5-byte format carried transparently in the channels payload (control bidirectional via `STREAM_CTRL_IN`/`OUT` — Phase 7 amendment) |
| [049](decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler for Subscriptions | The machinery `channel/resources/subscribe` uses |
| [032](decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed channel opens |
| [003](decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-channels depends on alknet-core only; no handler-depends-on-handler |
| [034](decisions/034-channels-wire-format.md) | channels Wire Format — 8-Byte Chunk Header | The chunk format; channels layer has no `stream_type` concept (amended by ADR-035); substrate-agnostic; one-way door |
| [035](decisions/035-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
| [036](decisions/036-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alk/call` | Channel 0 = call protocol; no special control plane |
| [037](decisions/037-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | `channel/open`/`close`/`control`/`resources/subscribe`; `direction` semantics; subscribe not poll |
| [038](decisions/038-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `accept_bi` yields `BiStream` (amended by ADR-035 — `into_sub_streams` removed) |
| [039](decisions/039-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 contracts |
| [040](decisions/040-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Bounded-buffer (1 MiB default), 256-channel per-connection memory bound, monotonic IDs with wrap (DoS defense reframed by ADR-041) |
| [041](decisions/041-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound; symmetric (both sides enforce); spoke caps hub (direct caller), not browser (forwarded_for is metadata) |
| [050](decisions/050-pump-bidi-two-pump-helper.md) | `pump_bidi` — the Two-Pump Helper | The two-pump deadlock contract; handler-level, not channels-layer |
| [042](decisions/042-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | The hub translates channel 0, byte-forwards data channels with ID rewrite |
| [043](decisions/043-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, transport-agnostic `from_connection` primary; dial lives in the composition layer's dial helper (ADR-045) |
| [044](decisions/044-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient) modules inside alkcall; hub and worker are consumers, not sub-crates |
| [008](decisions/008-bidistreamsource-trait.md) | BidiStreamSource Trait | The `Connection` extension point `ChannelBidiStreamSource` implements |
| [009](decisions/009-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream`; the transport-leaf decision ADR-035 builds on |
| [007](decisions/007-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The transport-agnostic `Connection` the channels layer rides on |
| [021](decisions/021-streaming-handler-for-subscriptions.md) | StreamingHandler for Subscriptions | The machinery `channel/resources/subscribe` uses |
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed channel opens |
| [031](decisions/031-crate-decomposition.md) | Crate Decomposition | The channels subsystem lives inside alkcall with the call protocol and vendored core types; no handler-depends-on-handler |
Provenance (source-repo ADRs, not ported): [alknet ADR-077 — TTY Inside Channels, Sub-Streams Not Wire Format](/workspace/@alkdev/alknet/docs/architecture/decisions/077-tty-inside-channels.md): TTY's two modes (direct vs channels); TTY always uses its 5-byte format, carried transparently in the channels payload. [alknet ADR-052 — TTY Wire Format](/workspace/@alkdev/alknet/docs/architecture/decisions/052-alknet-tty-wire-format-and-two-carriage.md): the 5-byte format carried transparently in the channels payload (control bidirectional via `STREAM_CTRL_IN`/`OUT` — Phase 7 amendment). TTY is now a sibling protocol crate (`alktty`).
## Relevant Open Questions
| OQ | Title | Status | Relevance |
|----|-------|--------|-----------|
| OQ-55 | AlknetClient / Client Establishment Extraction | resolved (ADR-045) | `ChannelClient`'s API is decided (ADR-043): transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (`alknet-client`, ADR-045) |
| OQ-56 | Full channel-level flow-control windowing | deferred(scope) | Bounded-buffer is decided (ADR-040); full windowing is an extension blocked on "a real deployment observes HOL blocking on a saturated channel where bounded buffer is insufficient" |
| OQ-57 | Two-pump helper extraction to alknet-core | deferred(scope) | The shutdown-on-completion *contract* is decided (ADR-078); the *helper* extraction is blocked on a second two-pump handler existing (shape convergence) |
| OQ-68 | Add/strip API shape (built-in vs utility) | open | Whether the 8-byte header add/strip is built into the channels read/write path or exposed as a standalone utility. The *contract* is decided (ADR-035); the *function surface* is not |
| OQ-23 ("[tracker](open-questions.md)"; source: alknet OQ-56) | Full channel-level flow-control windowing | deferred(scope) | Bounded-buffer is decided (ADR-040); full windowing is an extension blocked on "a real deployment observes HOL blocking on a saturated channel where bounded buffer is insufficient" |
| Dial seam (source: alknet OQ-55) | Client dial helper extraction | resolved (ADR-045); ownership → OQ-43 | `ChannelClient`'s API is decided (ADR-043): transport-agnostic `from_connection` primary; the dial lives in the composition layer's dial helper (`AlknetClient` today; post-alknet owner and shape — OQ-43) |
| Two-pump helper (source: alknet OQ-57) | Two-pump helper extraction to a shared core crate | dissolved | The shutdown-on-completion *contract* is decided and the helper is in-tree (`channels::pump_bidi`, ADR-050); the extraction target (a mono-repo core crate being replaced) no longer exists — alkcall owns its helpers |
| OQ-24 ("[tracker](open-questions.md)"; source: alknet OQ-68) | Add/strip API shape (built-in vs utility) | resolved | Whether the 8-byte header add/strip is built into the channels read/write path or exposed as a standalone utility. The *contract* is decided (ADR-035); the shipped surface integrates the strip (see OQ-24 in [open-questions.md](open-questions.md)) |
## Key Design Principles
@@ -69,7 +69,7 @@ handler owns its sub-stream multiplexing on the `BiStream` it receives.
in how they're *opened* (negotiation via `channel/open` on channel 0)
and what *multiplexing layer* carries them (the 8-byte chunk format).
Once normalized, every channel is an ALPN routed through the same
`HandlerRegistry`. See [overview.md](overview.md) and ADR-034 (as
`HandlerRegistry`. See [overview.md](channels-overview.md) and ADR-034 (as
amended by ADR-035).
2. **Channel 0 is `alk/call` pre-negotiated, not a special control
@@ -136,6 +136,10 @@ handler owns its sub-stream multiplexing on the `BiStream` it receives.
## References
Source-repo research and history (alknet, provenance — these paths live
in the alknet repo, `/workspace/@alkdev/alknet/docs/research/...`, not
this one):
- `docs/research/alknet-channels/phase-0-findings.md` — Phase 0 research
(vision, hub motivation, wire format, negotiation, internals, DPs, OQs)
- `docs/research/alknet-channels/poc-summary.md` — the de-risk POC (28
@@ -146,8 +150,10 @@ handler owns its sub-stream multiplexing on the `BiStream` it receives.
surfaced the pure-channel-multiplexing resolution (ADR-035)
- `/workspace/alknet-channels-poc/` — the POC codebase
- `docs/research/alknet-tty/phase-0-findings.md` — the TTY crate's chunk
format (the seed of the channels generalization)
format (the seed of the channels generalization; TTY is now the
sibling crate `alktty`)
- `docs/research/alknet-ssh/phase-0-findings.md` — SSH's channel
multiplexer (the prior art for N-channel multiplexing)
- `docs/architecture/crates/hub/README.md` — the hub crate (the primary
consumer; the relay implementation's home)
- Source-repo hub crate (alknet, provenance — the composition layer's
hub wiring; the relay implementation itself is an alkcall export,
`ChannelRelay`, ADR-051)
+12 -8
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-18
last_updated: 2026-10-13 (alkcall reframing)
---
# channels-adapter.md — ChannelsAdapter and ChannelManager
@@ -69,7 +69,7 @@ transport's stream count differs.
## `ChannelManager`
```rust
// In alknet-channels-core:
// In alkcall (channels subsystem):
pub struct ChannelManager {
channels: Mutex<HashMap<u32, ChannelState>>,
handlers: Arc<HandlerRegistry>,
@@ -238,7 +238,7 @@ The runner's `select!` loop exits when all `MuxHandle` clones drop (the
`new_pumps` sender closes) — the natural shutdown signal. This matches the
dynamic `channel/open` model.
## The two-pump pattern (ADR-078 — documented here for handler authors)
## The two-pump pattern (ADR-050 — documented here for handler authors)
Handlers with a two-pump shape (two `tokio::io::copy` pumps, one per
direction — tunnel, SSH `direct-tcpip`) MUST shut down the opposite sink
@@ -262,7 +262,8 @@ tokio::try_join!(c2t, t2c)?;
The three-pump pattern (TTY's `pump_session`, coordinating via the
`exit_code` future) does not have this deadlock — the `exit_code` future is
the third signal. The two-pump pattern is documented in ADR-078; the
the third signal. The two-pump pattern is pinned in-tree by ADR-050
(`channels::pump_bidi`); the
shutdown-on-completion contract is a handler-level concern, not a
channels-layer one.
@@ -308,7 +309,8 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
- ADR-039: ChannelsAdapter and ChannelManager (the decision)
- ADR-035: channels pure channel multiplexing (the umbrella decision that
amends ADR-034/074/077)
amends ADR-034 and the source-repo connection/from_source + TTY-inside-channels decisions —
alknet ADR-074/077, provenance; the in-crate homes are ADR-007/008)
- ADR-036: channel 0 pre-negotiated (the `preinstall_channel_0` step)
- ADR-037: channel lifecycle operations (the ops registered on `call_ops`)
- ADR-038: ChannelBidiStreamSource (what the manager constructs per
@@ -318,7 +320,9 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
- ADR-041: per-identity channel cap (the `ChannelLifecyclePolicy`
consulted by the `channel/open` handler; the relay consequence for
hub-relayed channels)
- `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #4-#7
(REQ-CH-01..04, the two-pump deadlock)
- `docs/research/stream-unification/findings.md` — the research that
- Source-repo POC research (alknet provenance):
`/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md`
§Issues Surfaced #4-#7
(REQ-CH-01..04, the two-pump deadlock);
`/workspace/@alkdev/alknet/docs/research/stream-unification/findings.md` — the research that
surfaced the pure-multiplexing resolution
+15 -13
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-18
last_updated: 2026-10-13 (alkcall reframing)
---
# channels-connection.md — ChannelBidiStreamSource and `BiStream` Access
@@ -21,7 +21,7 @@ tunnel uses the `BiStream` as raw bytes, call length-prefixes JSON, SSH
runs its own channel protocol.
The `BiStream` is wrapped in a `ChannelBidiStreamSource` that implements
`alknet-core`'s `BidiStreamSource` trait (ADR-008), and a `Connection` is
alkcall's vendored-core `BidiStreamSource` trait (ADR-008), and a `Connection` is
constructed from it via `Connection::from_source(source, alpn)`. The
handler receives a `Connection`, calls `accept_bi()` once (yield-once per
channel), gets a `BiStream`, and drives its session — identical to how it
@@ -30,7 +30,7 @@ works on a top-level QUIC connection.
## `ChannelBidiStreamSource`
```rust
// In alknet-channels:
// In alkcall (channels subsystem):
pub struct ChannelBidiStreamSource {
// The reassembly buffer for this channel's payload bytes (one per
@@ -86,7 +86,7 @@ async fn handle(&self, connection: Connection, _auth: &AuthContext)
let (mut tcp_read, mut tcp_write) = tcp.into_split();
let (mut recv, mut send) = tokio::io::split(&mut bidi);
// Two-pump with shutdown-on-completion (ADR-078)
// Two-pump with shutdown-on-completion (ADR-050)
let c2t = async {
tokio::io::copy(&mut recv, &mut tcp_write).await?;
tcp_write.shutdown().await.ok();
@@ -158,11 +158,11 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [074](decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` is the only accessor |
| [093](decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `BiStream`-only |
| [070](decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The extension point `ChannelBidiStreamSource` implements |
| [092](decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream` (the transport-leaf decision this doc builds on) |
| [065](decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The yield-once path generalized for channels |
| [038](decisions/038-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` is the only accessor |
| [035](decisions/035-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `BiStream`-only |
| [008](decisions/008-bidistreamsource-trait.md) | BidiStreamSource Trait | The extension point `ChannelBidiStreamSource` implements |
| [009](decisions/009-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream` (the transport-leaf decision this doc builds on) |
| [007](decisions/007-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The yield-once path generalized for channels |
## References
@@ -171,9 +171,11 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
- ADR-008: BidiStreamSource trait
- ADR-009: `BiStream` as the handler leaf
- ADR-007: `Connection::from_stream` (the yield-once path generalized)
- ADR-077: TTY inside channels (TTY always uses its 5-byte format,
- Provenance (source-repo, not ported): [alknet ADR-077](/workspace/@alkdev/alknet/docs/architecture/decisions/077-tty-inside-channels.md) — TTY inside channels (TTY always uses its 5-byte format,
carried transparently in the channels payload)
- `docs/research/alknet-channels/poc-summary.md` §POC Target 2 (the
yield-once `Connection::from_stream` validation)
- `docs/research/stream-unification/findings.md` — the research that
- Source-repo POC research (alknet provenance):
`/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md`
§POC Target 2 (the
yield-once `Connection::from_stream` validation);
`/workspace/@alkdev/alknet/docs/research/stream-unification/findings.md` — the research that
surfaced the single-accessor resolution
+33 -26
View File
@@ -1,13 +1,13 @@
---
status: draft
last_updated: 2026-07-18
last_updated: 2026-10-13 (alkcall reframing)
---
# alknet-channels — Overview
# alkcall channels — Overview
## What
`alknet-channels` is a multiplexing proxy crate. It implements
The channels subsystem of alkcall is a multiplexing proxy. It implements
`ProtocolHandler` for the `alk/channels` ALPN: it receives one
bidirectional transport stream, reads 8-byte chunk headers, and routes each
chunk's payload to the right logical channel. Each channel is reassembled
@@ -28,7 +28,7 @@ multiplexing on the `BiStream` it receives.
### The problem: three multiplexing models that don't compose
Before channels, alknet had three multiplexing models:
Before channels, the alk stack had three multiplexing models:
| Model | Where | Mechanism |
|-------|-------|-----------|
@@ -89,7 +89,8 @@ on the `BiStream` the channels layer gives them (ADR-035).
inner layer's framing byte, carried transparently in the payload.
- **The control channel is handler-internal.** TTY sub-demuxes control
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN` /
`STREAM_CTRL_OUT` — ADR-052 amended by Phase 7). The channels layer
`STREAM_CTRL_OUT` — the TTY wire format, [alknet ADR-052](/workspace/@alkdev/alknet/docs/architecture/decisions/052-alknet-tty-wire-format-and-two-carriage.md)
amended by Phase 7; TTY is now the sibling crate `alktty`). The channels layer
doesn't carry control.
- **Recursive composition is literal.** A channel with ALPN
`alk/channels` runs another channels demux on its `BiStream`. The
@@ -110,8 +111,9 @@ The crate has two internal components (ADR-039):
`channel/open` operation handler closes over.
Each channel is presented to its handler as a `Connection` constructed via
`Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-008/074, as
amended by ADR-035). The handler calls `accept_bi()` once (yield-once per
`Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-008 for the
extension point, ADR-007 for the `Connection` constructors, as amended by
ADR-035). The handler calls `accept_bi()` once (yield-once per
channel) and gets a `BiStream` — identical to how it works on a top-level
QUIC connection.
@@ -139,7 +141,8 @@ Downstream crates depend on alkcall as a single dependency:
alktty / alktunnels / alktrader (protocol crates)
└── alkcall (call + channels protocols, no networking)
alknet / alknode (networking + composition)
composition layer (dial/accept + hub — today the alknet glue,
being replaced; future owner and shape → OQ-43)
├── alkcall
├── alktty (optional — whichever protocol crates are wired in)
├── alktunnels (optional)
@@ -147,7 +150,7 @@ alknet / alknode (networking + composition)
```
The hub and worker are **consumers** of alkcall, not sub-crates. The
downstream alknet crate IS the hub — it depends on alkcall and uses
composition layer IS the hub — it depends on alkcall and uses
channels as its substrate, with the relay logic (ADR-042) living
alongside its peer lifecycle and service discovery responsibilities.
A worker is any crate that uses `ChannelClient` to dial. There are no
@@ -168,7 +171,7 @@ stream:
|-----------|-----|
| QUIC bidi stream | `alk/channels` ALPN on a QUIC connection; one bidi stream carries all channels |
| TCP+TLS | `alk/channels` ALPN on a TLS connection; the TCP stream carries all channels |
| WebTransport | `alk/channels` session (deferred per ADR-044; the browser path uses WebSocket carrying `alk/channels`) |
| WebTransport | `alk/channels` session (deferred per alknet ADR-044 — websocket-native session, provenance — the browser path uses WebSocket carrying `alk/channels`) |
| SSH channel | channels connection riding inside an SSH `direct-tcpip` channel (channels-over-SSH) |
| Another channels connection | recursive composition (channel type `alk/channels` inside `alk/channels`) |
@@ -186,14 +189,14 @@ async shell (demux/mux) wraps this core with `read_exact`/`write_all` and
`mpsc` routing.
The `ChannelManager` is ALPN-blind, auth-blind, and transport-blind (ADR-
075) — pure byte routing with no platform or protocol dependencies. A WASM
039) — pure byte routing with no platform or protocol dependencies. A WASM
build can read chunks from a WebTransport `BiStream`, reassemble them, and
present `AsyncRead + AsyncWrite` handles to WASM-compatible handlers. The
handlers themselves may or may not be WASM-compatible (russh's client is;
`portable_pty` is not), but the channels layer is WASM-compatible by
construction.
The async shell and `alknet-core` dep graph are not fully WASM-clean yet
The async shell dep graph is not fully WASM-clean yet
(transitive `getrandom`/`rand` deps) — this is an implementation concern,
not an architecture concern. The sync core's WASM compatibility is validated.
@@ -225,9 +228,9 @@ A protocol crate can support two paths for its wire format:
The protocol crate doesn't know which path it's on — it takes a
`BiStream` (or `AsyncRead + AsyncWrite`) and drives the session. The
two adapters are thin and live either in the protocol crate (behind
feature flags) or in the downstream alknet crate.
feature flags) or in the composition layer.
### alknet-call
### Channel 0 is the call protocol
The call protocol runs on channel 0 exactly as on a top-level
`alk/call` connection. The `Dispatcher` receives a `Connection`
@@ -236,7 +239,7 @@ doesn't know it's inside channels. The call protocol's `EventEnvelope`
framing (ADR-014) is the channels payload; the channels layer carries
it transparently.
### alknet-hub / alknode
### Hub (composition layer)
The hub is the primary consumer. With channels, the hub holds one
channels connection per leg (browser↔hub, hub↔spoke) and relays
@@ -261,7 +264,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [040](decisions/040-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer (1 MiB), 256-channel cap, monotonic IDs |
| [041](decisions/041-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy`; per-connection `max_channels` reframed as a memory bound |
| [042](decisions/042-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels with ID rewrite |
| [043](decisions/043-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045, resolves OQ-55) |
| [043](decisions/043-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in the composition layer's dial helper (ADR-045) |
| [044](decisions/044-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers |
| [047](decisions/047-openable-alpns-are-operations.md) | Openable ALPNs Are Operations | `channel/open` dissolves into per-ALPN ops; `ChannelCore` wrapper; connection-owner allocates `channel_id` |
@@ -270,18 +273,22 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
Open questions are tracked in [open-questions.md](open-questions.md).
Key questions affecting this crate:
- **OQ-55** (resolved by ADR-045): `AlknetClient` core **dial+TLS seam**
— extracted as `alknet-client` with three dial methods.
`ChannelClient`'s API is transport-agnostic (`from_connection`); the
dial is the shared seam, now extracted. See
- **Dial seam** (ADR-045; source: alknet OQ-55): the dial+TLS seam is
the composition layer's dial helper (`AlknetClient` today — future
owner and shape OQ-43). `ChannelClient`'s API is transport-agnostic
(`from_connection`); the dial is the shared seam. See
[ADR-045](decisions/045-alknetclient-native-dial-seam.md).
- **OQ-56** (deferred(scope)): Full channel-level flow-control windowing —
- **OQ-23** ([tracker](open-questions.md); source: alknet OQ-56)
(deferred(scope)): Full channel-level flow-control windowing —
bounded-buffer is decided (ADR-040); full windowing is an extension
blocked on a real HOL-blocking deployment observation.
- **OQ-57** (deferred(scope)): Two-pump helper extraction to alknet-core —
the *contract* is decided (ADR-078); the *helper* is blocked on a second
two-pump handler existing.
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
- **Two-pump helper** (source: alknet OQ-57) (dissolved): the
contract is decided (the in-tree helper landed as
`channels::pump_bidi`, ADR-050); the extraction target (a mono-repo
core crate) no longer exists — alkcall owns its helpers.
- **OQ-24** ([tracker](open-questions.md); source: alknet OQ-68)
(resolved): Add/strip API shape — whether the 8-byte header
add/strip is built into the channels read/write path or exposed as a
standalone utility. The *contract* (channels strips, handler parses
payload) is decided (ADR-035); the *function surface* is not.
payload) is decided (ADR-035); the shipped surface integrates the
strip (see OQ-24 in [open-questions.md](open-questions.md)).
+31 -21
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-18
last_updated: 2026-10-13 (alkcall reframing)
---
# channels-wire.md — The 8-Byte Chunk Format
@@ -36,7 +36,7 @@ hand-rolled (the hot path); the BAST document is the contract.
The payload is opaque to the channels layer. The handler parses its own
framing from the payload — TTY's `[stream_type:u8][length:u32][payload]`
(5-byte format, ADR-052), call's length-prefixed JSON (`EventEnvelope`
(5-byte format, alknet ADR-052), call's length-prefixed JSON (`EventEnvelope`
framing, ADR-014), tunnel's raw bytes, SSH's channel protocol. The
channels layer carries the bytes transparently.
@@ -72,7 +72,7 @@ its framing entirely. See ADR-035 for the full cost/benefit analysis.
## `MAX_CHUNK_LEN`
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (ADR-052 §5). A chunk with
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (alknet ADR-052 §5). A chunk with
`length > MAX_CHUNK_LEN` returns `ChunkTooLarge` and does not corrupt the
stream — the demux drops the chunk and continues. The header is always
exactly 8 bytes, so the demux can always resync by reading the next
@@ -103,14 +103,15 @@ There is no channels-layer framing-disambiguation trick beyond the fixed
8-byte header. The channels layer does not interpret the payload — it
doesn't know if the payload is TTY chunks, call frames, or tunnel bytes.
Any framing disambiguation within the payload is the handler's concern
(see `tty-wire.md` §"Framing disambiguation" for TTY's first-byte trick,
(see the TTY wire format's "framing disambiguation" — alknet ADR-052,
provenance; TTY is now the sibling crate `alktty` — for TTY's first-byte trick,
which is internal to TTY's 5-byte format).
## Zero-length sentinel = EOF
A zero-length chunk (`length = 0`) is delivered as an empty payload,
which the reassembled stream interprets as EOF. This is the clean-shutdown
signal for a `channel_id` — the same convention as TTY (ADR-052
signal for a `channel_id` — the same convention as TTY (alknet ADR-052
§Sentinels), now at the channels layer (one sentinel per channel, not
per `(channel_id, stream_type)`).
@@ -141,13 +142,16 @@ experience. See ADR-034 §substrate modes (as amended by ADR-035), ADR-039.
**Implementation status:** the in-line substrate mode is implemented
(single bidi stream, header-demuxed N channels). The QUIC-native
multi-stream substrate (accept remaining bidi streams, read headers off
each) is deferred to the downstream alknet crate (OQ-41). The wire
each) is deferred to the composition layer's dial/accept half (OQ-41;
its owner — OQ-43). The wire
format and demux loop are correct for both substrates; only the outer
`accept_bi()` loop is missing.
## Wire-level invariants (REQ-CH-01, 02, 04, 05)
The de-risk POC (`docs/research/alknet-channels/poc-summary.md` §Issues
The de-risk POC (source-repo research, alknet provenance:
`/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md`
§Issues
Surfaced) surfaced invariants that hang channels silently if underspecified.
These are **contracts**, not implementation details — both sides must agree.
@@ -245,9 +249,13 @@ same shape, each level.
The exact API shape of the add/strip pair (built into the read/write path
vs. a standalone utility) is an implementation detail for the channels
crate, tracked as OQ-68. The *contract* — the channels layer strips its
8-byte header on read and the handler parses its own framing from the
payload — is decided; the *function surface* is not.
crate; the shipped surface resolves it (OQ-24, formerly alknet OQ-68):
the strip is integrated into the read/write path — the demux loop
consumes `read_header`/`write_chunk` so a handler's `BiStream` yields
payload chunks and never sees the 8-byte header — while the low-level
primitives (`parse_header`/`write_header`/`read_header`/`write_chunk`/
`write_eof` in `channels::wire`) stay exposed as the standalone utility
for tooling.
## Channel lifecycle (summary)
@@ -258,14 +266,14 @@ payload — is decided; the *function surface* is not.
| Control (out-of-band) | `channel/control` call operation on channel 0 (deferred — OQ-39) | ADR-037 |
| Close | `channel/close` call operation on channel 0; data chunks flushed before close | ADR-037, REQ-CH-06 |
### REQ-CH-06: exit-chunk-before-close ordering (generalizes ADR-055)
### REQ-CH-06: exit-chunk-before-close ordering (generalizes alknet ADR-055 — the TTY exit-chunk invariant, provenance)
The channel's data chunks MUST be written and flushed before the
`channel/close` operation is sent on channel 0. This is a wire-level
invariant: the side closing must observe the data-channel pump complete
before issuing the call operation.
For TTY this is the exit-chunk-is-last invariant (ADR-055) carried
For TTY this is the exit-chunk-is-last invariant (alknet ADR-055, provenance) carried
forward: the exit control message (on TTY's `STREAM_CTRL_OUT` stream_type
4, inside TTY's 5-byte payload) is the last data before `channel/close`.
For tunnels it is the last data byte before close. The channels layer's
@@ -293,23 +301,25 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
Open questions are tracked in [open-questions.md](open-questions.md).
Key questions affecting this doc:
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
add/strip is built into the channels read/write path or exposed as a
standalone utility. The *contract* (channels strips, handler parses
payload) is decided; the *function surface* is not.
- **OQ-24** ([tracker](open-questions.md); source: alknet OQ-68) (resolved): Add/strip API shape —
built into the read/write path (the demux loop strips headers; handlers
never see them), with the low-level header primitives exposed in
`channels::wire` for tooling.
## References
- ADR-034: channels wire format (the decision, amended by ADR-035 — 8-byte
header, no `stream_type`)
- ADR-035: channels pure channel multiplexing (the umbrella decision that
amends ADR-034/074/077)
amends ADR-034 and the source-repo connection/from_source decision —
alknet ADR-074, provenance; the in-crate homes are ADR-007/008)
- ADR-036: channel 0 pre-negotiated
- ADR-037: channel lifecycle operations
- ADR-040: backpressure, channel limits, ID reuse
- ADR-047: openable ALPNs are operations (per-ALPN open ops dissolve
the generic `channel/open`)
- `docs/research/alknet-channels/poc-summary.md` §POC Target 1, §Issues
Surfaced #4-#6 (REQ-CH-01, 02, 04)
- `docs/research/stream-unification/findings.md` — the research that
surfaced the 8-byte format decision
- Source-repo POC research (alknet provenance):
`/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md`
§POC Target 1, §Issues Surfaced #4-#6 (REQ-CH-01, 02, 04);
`/workspace/@alkdev/alknet/docs/research/stream-unification/findings.md`
— the research that surfaced the 8-byte format decision
+204 -170
View File
@@ -1,9 +1,9 @@
---
status: draft
last_updated: 2026-07-17
last_updated: 2026-10-13 (alkcall reframing)
---
# alknet-call — Client and Adapters
# alkcall — Client and Adapters
The outbound half of the call protocol: opening connections, importing remote
operations, and the adapter contract that ties import-style adapters together.
@@ -11,14 +11,15 @@ This document covers what ADR-022 specced but the server-side implementation
(`call-protocol.md`, `operation-registry.md`) did not include — the `CallClient`
that *opens* a connection, the `from_call` adapter, and the
`OperationAdapter` trait. (`from_jsonschema` was originally specced here
too, but ADR-027 moved it to `alknet-http` — see §"from_jsonschema" below.)
too, but ADR-027 moved it to the HTTP host crate — today `alkhttp`
(then named `alknet-http`) — see §"from_jsonschema" below.)
The server-side `CallAdapter` and `CallConnection`
dispatch loop are covered in `call-protocol.md`; this document covers the
client-side connection-establishment half and the adapter surface.
## What
This document specifies three components, all in `alknet-call`:
This document specifies three components, all in alkcall:
1. **`CallClient`** — takes over an established transport `Connection`
on ALPN `alk/call`, spawns the shared dispatch loop, and produces
@@ -35,20 +36,21 @@ This document specifies three components, all in `alknet-call`:
`from_openapi`, `from_mcp`, and `from_jsonschema` all implement.
> **`from_jsonschema` moved.** ADR-027 moved `from_jsonschema` from
> `alknet-call` to `alknet-http` and gave it a real reqwest-backed
> the call protocol to the HTTP host crate and gave it a real reqwest-backed
> forwarding handler (it was a broken schema-only placeholder before).
> It is now an HTTP-backed single-endpoint adapter for non-standard /
> non-OpenAPI / basic REST endpoints, functionally similar to
> `from_openapi` but one endpoint at a time. See
> [`crates/http/http-adapters.md`](../http/http-adapters.md) §"from_jsonschema".
> The `FromJsonSchema` provenance variant stays in `alknet-call`
> `from_openapi` but one endpoint at a time. It lives in `alkhttp`; its
> full spec is `alkhttp`'s `http-adapters.md` (original source-repo copy
> at `/workspace/@alkdev/alknet/docs/architecture/crates/http/http-adapters.md`,
> provenance). The `FromJsonSchema` provenance variant stays in alkcall
> (`OperationProvenance`); only the adapter implementation moved.
It also records two cross-cutting architectural mechanisms that the adapter
surface rests on:
- The **adapter location map** — which adapters live in `alknet-call` vs
`alknet-http`, and why.
- The **adapter location map** — which adapters live in alkcall vs
`alkhttp`, and why.
- The **no-env-vars invariant** — the architectural mechanism by which
downstream consumers' `std::env::var` credential reads are made unreachable.
@@ -65,23 +67,26 @@ downstream consumer: the runner pattern (a process that connects outward to a
hub and exposes local ops), the container-service rewrite, the bilateral
exchange, the NAPI projection, and the agent's cross-node tool dispatch all
require a `CallClient`. `from_call` is the #2 gap; the `OperationAdapter`
trait is the enabling gap for `alknet-http`'s `from_openapi`/`from_mcp`.
trait is the enabling gap for `alkhttp`'s `from_openapi`/`from_mcp`.
ADR-022 specced this surface. This document is the spec that operationally
fills the gap ADR-022 left to implementation: the `CallClient` API, the
`from_call` flow, the trait signature, the adapter location, the credential
invariant, and the bilateral pattern. The gap
analysis (`docs/research/alknet-call-completion/gap-analysis.md`) identified
analysis (source-repo research, now at
`/workspace/@alkdev/alknet/docs/research/alknet-call-completion/gap-analysis.md`)
identified
four decisions (DC-1..4) needed before implementation. DC-1 was initially
resolved by ADR-023 (`remote_safe`/`trusted_peer`), but a subsequent research
pass (`docs/research/alknet-call-peer-routing/findings.md`) found that
pass (source-repo research, now at
`/workspace/@alkdev/alknet/docs/research/alknet-call-peer-routing/findings.md`) found that
ADR-023's model was structurally broken for the head→N-workers pattern (the
primary use case) and that its parallel `remote_safe`/`trusted_peer`
authorization system duplicated the existing `AccessControl`/`Identity`
machinery. **ADR-024 supersedes ADR-023**: peer-keyed overlays + `PeerRef`
routing, and peer authorization through the existing `AccessControl::check(peer_identity)`.
DC-2/3/4 are two-way-door defaults recorded here (DC-2→OQ-27, DC-3→OQ-28
cross-peer dissolved / same-peer stays, DC-4→OQ-26).
DC-2/3/4 are two-way-door defaults recorded here (DC-2→OQ-12, DC-3→OQ-13
cross-peer dissolved / same-peer stays, DC-4→OQ-11).
## Architecture
@@ -101,7 +106,7 @@ ordered, reliable bidirectional stream — QUIC, TCP+TLS, WebTransport,
SSH `direct-tcpip`, a WebSocket (ADR-007 `Connection::from_stream` /
`from_bidi`). The primary constructor (`spawn_dispatch`) takes a
pre-established `Connection` from any transport; the dial lives in
`AlknetClient` (`alknet-client`, ADR-045). This mirrors
the composition layer's dial helper, `AlknetClient` (ADR-045). This mirrors
`ChannelClient::from_connection` (ADR-043) and is the client-side
analogue of the server-side generalization ADR-007 made.
@@ -136,7 +141,7 @@ the op's required scopes/resources are satisfied, the call dispatches; if not,
`FORBIDDEN` before the handler runs (capabilities never populated — the
security property). An op that should never be callable from the wire uses
`Visibility::Internal` (existing mechanism, `NOT_FOUND` before ACL). See
[ADR-024](decisions/029-peer-graph-routing-model.md) §3 for the full
[ADR-024](decisions/024-peer-graph-routing-model.md) §3 for the full
mapping of the three `remote_safe` cases to `AccessControl`/`Visibility`.
The connection is symmetric after establishment (ADR-022 §2): both sides can
@@ -176,14 +181,15 @@ authorization machinery that gates every other call. No `RemoteFilter`, no
primary constructor — it takes a pre-established `Connection`,
constructs a `CallConnection`, builds a `Dispatcher`, spawns the
dispatch task, and returns the live `CallConnection`. The dial lives in
`AlknetClient` (`alknet-client`, ADR-045): keeping a QUIC convenience
constructor on `CallClient` would make `alknet-call` depend on
`alknet-client`, contradicting the dep graph (the protocol crates are
the composition layer's dial helper, `AlknetClient` (ADR-045): keeping a QUIC convenience
constructor on `CallClient` would make alkcall depend on
the dial crate, contradicting the dep graph (the protocol crates are
parallel to the dial, not downstream of it). Callers compose
`AlknetClient::dial_quic` + `spawn_dispatch` — two lines, the dial then
the take-over. Tests use `spawn_dispatch` directly to wire mock/loopback
connections. The one-way-door surface is `spawn_dispatch`; the dial
lives in `alknet-client`.
lives in the composition layer (`AlknetClient` today — future owner
OQ-43).
This mirrors `ChannelClient::from_connection` (ADR-043) and is the
client-side analogue of the server-side generalization ADR-007 made.
@@ -212,7 +218,7 @@ pub type PeerId = String; // = Identity.id from IdentityProvider resolution
`OperationEnv` gains a peer-routing method with a `PeerRef` selector
(`Specific(PeerId)` / `Any`), default-impl for back-compat. See
[ADR-024](decisions/029-peer-graph-routing-model.md) §2 for the full
[ADR-024](decisions/024-peer-graph-routing-model.md) §2 for the full
`invoke_peer` signature and `ScopedPeerEnv` peer-qualified reachability. The
per-`CallConnection` overlay stays flat (one connection = one peer); the
peer-keying is at the aggregation layer (the head node's composition env).
@@ -225,14 +231,14 @@ single `AccessControl`-filtered handler (no `peer_scoped` variant, no
`remote_safe` filter — both retired by ADR-024). `services/list-peers`
is the opt-in for peer-attributed re-export listing (each peer's
sub-overlay listed with attribution, filtered by the calling peer's
authorization). See [ADR-024](decisions/029-peer-graph-routing-model.md) §6.
authorization). See [ADR-024](decisions/024-peer-graph-routing-model.md) §6.
### Credential sources for connections
The credential dimensions are split across two layers (ADR-012, amended
2026-07-17):
- **`ConnectionCredentials`** (in `alknet-core`, per ADR-012) — the
- **`ConnectionCredentials`** (vendored core types in alkcall, per ADR-012) — the
**transport-level** credential bundle, consumed by the dial
(`AlknetClient`). Carries the two transport-identity dimensions:
`local_identity` (the local node's `TlsIdentity`) and `remote_identity`
@@ -250,7 +256,7 @@ Credentials come from `Capabilities` (ADR-010), never from environment
variables. The transport-identity dimensions (ADR-022 §7):
```rust
// Transport-level (alknet-core, consumed by the dial — ADR-012)
// Transport-level (vendored core in alkcall, consumed by the dial — ADR-012)
pub struct ConnectionCredentials {
pub local_identity: Option<TlsIdentity>, // RFC 7250 raw key or X.509
pub remote_identity: Option<RemoteIdentity>, // expected fingerprint (None = CA path / fail-closed)
@@ -262,14 +268,15 @@ pub struct ConnectionCredentials {
// Dispatcher::resolve_identity reads payload.get("auth_token").
```
`RemoteIdentity` (ADR-022 §7, extended by ADR-034 §2) carries a
`RemoteIdentity` (ADR-022 §7, extended by alknet ADR-034 §2 — the
verifier-rule ADR, re-owned as ADR-054) carries a
fingerprint string the assembly layer derives from `Capabilities` when
the local node has a `PeerEntry` for the remote (the known-peer case →
fingerprint pin). `remote_identity: None` is the **public X.509
endpoint** case: the local node has no `PeerEntry` for the remote, so
there is no fingerprint to pin. Combined with an X.509 transport, `None`
selects CA verification (`WebPkiServerVerifier`) per the
verifier-selection rule in ADR-034 §3. Combined with an Ed25519
verifier-selection rule in ADR-054. Combined with an Ed25519
raw-key transport, `None` fails closed (raw-key remotes are always
known peers — no CA to fall back to). The `Option` is load-bearing, not
cosmetic: `Some(fingerprint)` means "pin this" (known peer), `None`
@@ -282,26 +289,28 @@ pub struct RemoteIdentity { pub fingerprint: String }
```
There is no call-protocol credential bundle. The transport dimensions
(`local_identity`, `remote_identity`) are in `ConnectionCredentials` in
`alknet-core` per ADR-012.
(`local_identity`, `remote_identity`) are in `ConnectionCredentials` with
the vendored core types in alkcall, per ADR-012.
- **TLS identity** — the local node's Ed25519 raw key (RFC 7250) or X.509 cert,
derived from the vault at startup (ADR-020, ADR-026, ADR-027).
derived from the vault at startup (vault ADRs live in the source repo —
alknet ADR-020/026/027, provenance).
- **Auth token** — an opaque call-protocol-level token, decrypted from the
vault or derived from a shared secret.
- **Remote identity verification** — the expected fingerprint/cert of the
remote node, stored as a capability. `Some` → fingerprint pin (known
peer with a `PeerEntry`); `None` → CA verification for X.509 remotes,
fail-closed for Ed25519 raw-key remotes (ADR-034 §2/§3). The `None`
fail-closed for Ed25519 raw-key remotes (ADR-054). The `None`
case is the public-X.509-endpoint path, not a missing field.
These are populated by the assembly layer at `CallClient` construction time
from vault-derived `Capabilities`. The credential path is the no-env-vars
invariant (below). The concrete shapes of `TlsIdentity`, `AuthToken`, and
`RemoteIdentity` are implementation-detail two-way doors; the one-way
constraints are that they come from `Capabilities`, not env vars (ADR-010).
**TLS client-auth presentation** (OQ-29 #1, wired): the client presents
constraints are that they come from `Capabilities`, not env vars (ADR-010),
and that `ConnectionCredentials` lives with the vendored core types in
alkcall (ADR-012).
**TLS client-auth presentation** (OQ-14 #1, wired): the client presents
its Ed25519 key as an RFC 7250 raw public key client cert — the client-side
equivalent of the server's `RawKeyCertResolver`. This is **wired now**, not
additive: it is what activates the `PeerEntry` fingerprint → `peer_id`
@@ -310,13 +319,13 @@ peer graph doesn't populate for quinn connections — `PeerId` resolution
fails because the server has no client cert to extract a fingerprint from.
The iroh path already works (iroh uses RFC 7250 raw keys and exchanges
Ed25519 public keys during the TLS handshake automatically); the gap was
quinn-only, and OQ-29 #1 resolves it by replacing `with_no_client_auth()`
quinn-only, and OQ-14 #1 resolves it by replacing `with_no_client_auth()`
with presenting the key. The one-way constraint (credentials from
`Capabilities`, not env vars, ADR-010) is unaffected — the `auth_token`
dimension flows through the call-protocol `auth_token` payload field, not
TLS, so the no-env-vars invariant holds independently of the TLS layer.
**Remote-identity verification** (OQ-29 #2, additive): verifying the
**Remote-identity verification** (OQ-14 #2, additive): verifying the
server's fingerprint against an expected value (`credentials.remote_identity`)
is **additive** — the server-side fingerprint extraction is what matters for
`PeerId`, not the client-side verification. The verifier for raw keys can
@@ -324,7 +333,8 @@ start as "accept any, extract fingerprint" and add fingerprint-pinning later.
This is a two-way-door remainder; the one-way constraint (credentials from
`Capabilities`, not env vars) is unaffected.
**Server cert verifier selection** (OQ-29 #2 + ADR-034 §3): the client-side
**Server cert verifier selection** (OQ-14 #2 + ADR-054 §2, re-owned
from the source repo's ADR-034): the client-side
`ServerCertVerifier` is selected by whether the local node has a `PeerEntry`
for the remote, not by key type alone. A pure-client
connection to a **public X.509 endpoint** (no `PeerEntry` on the local
@@ -339,7 +349,7 @@ with mixed Ed25519 + X.509 fingerprints) uses fingerprint pinning on
both cert paths and does enter the peer graph. An unknown Ed25519
raw-key remote fails closed (no CA to fall back to — raw-key remotes
are always known peers). See
[ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md)
[ADR-054](decisions/054-outgoing-only-x509-three-remote-roles.md)
for the verifier selection rule and the three-role naming.
### from_call
@@ -353,7 +363,7 @@ forwarding handlers. The discovery mechanism (`services/list` +
```rust
pub struct FromCallConfig {
/// Namespace prefix applied to imported operation names. Optional —
/// default no prefix. Collision on import is an error (DC-3, OQ-28),
/// default no prefix. Collision on import is an error (DC-3, OQ-13),
/// not last-wins.
pub namespace_prefix: Option<String>,
/// Optional filter — import only operations whose names match. None
@@ -384,31 +394,50 @@ The flow (ADR-022 §3):
sends `call.requested` via `CallConnection::call_with_payload()`, awaits
the single `call.responded` (or `call.error`), returns the
`ResponseEnvelope`.
- `Subscription` → a `StreamingHandler` (registered as
`HandlerKind::Stream`): calls `CallConnection::subscribe()`, which
returns `impl Stream<Item = ResponseEnvelope>` (the client-side
streaming path, already implemented), maps it to a
`BoxStream<ResponseEnvelope>`. The remote stream flows end-to-end:
each `call.responded` the remote sends becomes a stream item; the
remote's `call.completed` ends the stream (→ wire `call.completed`);
`call.aborted` drops the stream (cascade per ADR-020). No truncation,
no first-value fallback — a `from_call`-imported subscription forwards
the full remote stream.
- `provenance: FromCall`, `composition_authority: None`, `scoped_env: None`
(leaf — ADR-018).
- `Subscription` → a `StreamingHandler` (registered as
`HandlerKind::Stream`): calls `CallConnection::subscribe()`, which
returns `impl Stream<Item = ResponseEnvelope>` (the client-side
streaming path, already implemented), maps it to a
`BoxStream<ResponseEnvelope>`. The remote stream flows end-to-end:
each `call.responded` the remote sends becomes a stream item; the
remote's `call.completed` ends the stream (→ wire `call.completed`);
`call.aborted` drops the stream (cascade per ADR-020). No truncation,
no first-value fallback — a `from_call`-imported subscription forwards
the full remote stream.
- `Pub` → the sink forwarding shape (registered as `HandlerKind::Sink`,
ADR-046 + ADR-022 amendment 2026-10-13): a real forwarding body, not
an advertised-always-failing stub. A local `publish()` on the
imported op streams the `call.published` chunks across the
connection; the remote peer's `SinkHandler` consumes them.
- `provenance: FromCall`, `composition_authority: None`, `scoped_env: None`
(leaf — ADR-018).
4. The caller registers the bundles via
`CallConnection::register_imported_all()`.
**Re-import on reconnection** (DC-2, OQ-27): `from_call` is a free function;
the assembly layer calls it after the dial (in `AlknetClient`). The overlay
**Marked ops are not importable** (ADR-047 amendment 4 §4.8):
`from_call`'s discovery reconstructs the `channel_open` marker (ADR-047
Gap F) so the importing side can *report* the remote channel-machinery
ops, but `register_imported` / `register_imported_all` refuse marked
specs — the marker's data plane is the allocated channel's `BiStream`,
which has no overlay path (`OperationEnv::invoke` is request/response
only, ADR-046 §6). A hub that needs a marked op composes it with the
channel machinery instead: `ChannelRelay::register_relay_openable`
(ADR-051) on the relaying side, or direct consumption via `ChannelClient`
(ADR-043). Likewise `op/register` refuses a marked announcement with
`MARKED_SPEC_UNSUPPORTED` (ADR-022 amendment 2026-10-13). This resolves
OQ-37: there is no `from_call` relay wrapper for marked ops — the relay
composition is the sanctioned path.
**Re-import on reconnection** (DC-2, OQ-12): `from_call` is a free function;
the assembly layer calls it after the dial (in the composition layer's dial helper, `AlknetClient` — today the alknet glue; OQ-43). The overlay
is per-connection (Layer 2, ADR-019), so a stale overlay dies with the
connection; re-import on reconnect is naturally scoped to the new
connection. A `CallConnection::refresh()` method for mid-connection
re-discovery is a genuine feature addition — non-breaking, additive — if a
deployment needs manual re-discovery without drop-and-reconnect. See
[ADR-028](decisions/069-from-call-manual-free-function.md).
[ADR-028](decisions/028-from-call-manual-free-function.md).
**Namespace collision** (DC-3, OQ-28): under the peer-graph model (ADR-024),
**Namespace collision** (DC-3, OQ-13): under the peer-graph model (ADR-024),
cross-peer collision dissolves — same name on different peers is fine (they
live in separate peer sub-overlays, no prefix needed). Same-peer collision
stays an error (a peer shouldn't expose two ops with the same name).
@@ -432,40 +461,43 @@ forwarding. The spoke receives it as metadata on its `OperationContext` —
available for logging, auditing, per-user rate limiting, but never used by
`AccessControl::check` (the spoke authorizes the hub, its direct caller,
not the end user). The hub may set `forwarded_for: None` if it doesn't
want to disclose the originator. See [ADR-026](decisions/032-forwarded-for-identity.md).
want to disclose the originator. See [ADR-026](decisions/026-forwarded-for-identity.md).
### from_jsonschema
`from_jsonschema` was originally specified here (ADR-022 §5) as a
schema-only adapter in `alknet-call` — a placeholder handler returning
schema-only adapter in the call protocol — a placeholder handler returning
`NOT_FOUND`. That was broken: an op in the registry needs a real handler,
and the "schema-only, no handler" concept conflated schema validation
(a planning activity that doesn't need a registry entry) with operation
registration (which always needs a handler).
[ADR-027](decisions/066-from-jsonschema-as-http-adapter.md) moved
`from_jsonschema` to `alknet-http` as an HTTP-backed single-endpoint
[ADR-027](decisions/027-from-jsonschema-as-http-adapter.md) moved
`from_jsonschema` to the HTTP host crate — today `alkhttp`
(ex-`alknet-http`) — as an HTTP-backed single-endpoint
adapter: the caller supplies an `OperationSpec` + `HttpServiceConfig` +
path template + method, and the adapter builds one
`HandlerRegistration` with a real reqwest forwarding handler and
`FromJsonSchema` provenance. It is functionally similar to `from_openapi`
but one endpoint at a time, for non-standard / non-OpenAPI / basic REST
endpoints that don't have a full OpenAPI document. See
[`crates/http/http-adapters.md`](../http/http-adapters.md) §"from_jsonschema".
endpoints that don't have a full OpenAPI document. See `alkhttp`'s
`http-adapters.md` §"from_jsonschema" (its original source-repo copy at
`/workspace/@alkdev/alknet/docs/architecture/crates/http/http-adapters.md`,
provenance).
The schema-validation-without-a-handler use case (the original stated
purpose) is served by consuming `OperationSpec` directly — the spec
already carries the input/output JSON Schemas. No adapter, no registry
entry, no handler is needed for that.
The `FromJsonSchema` provenance variant stays in `alknet-call`
The `FromJsonSchema` provenance variant stays in alkcall
(`OperationProvenance` in `registry/registration.rs`); only the adapter
implementation moved.
### OperationAdapter trait
The shared shape across import-style adapters. The trait lives in
`alknet-call` (where the types live); the implementations live where their
alkcall (where the types live); the implementations live where their
transport dependencies live (see "Adapter Location Map" below).
```rust
@@ -482,7 +514,7 @@ which may be QUIC, TCP+TLS, or any other transport). Sync adapters
trait — their `import()` bodies contain no `.await` points. This is locked by
ADR-022 §5.
The **error type** (DC-4, OQ-26) is `Result<Vec<HandlerRegistration>,
The **error type** (DC-4, OQ-11) is `Result<Vec<HandlerRegistration>,
AdapterError>` where `AdapterError` is a crate-level enum covering the
failure modes real implementations hit: discovery transport failure
(`from_call` remote unreachable), schema parse failure (`from_openapi`,
@@ -495,13 +527,13 @@ door, recorded here.
Implementations:
- `FromCall` — call-protocol-backed, transport-agnostic (in
`alknet-call`). `from_call` discovers ops over a `CallConnection`,
alkcall). `from_call` discovers ops over a `CallConnection`,
which may be QUIC, TCP+TLS, or any transport `Connection::from_stream`
supports (ADR-007).
- `FromOpenAPI` — HTTP-backed (in `alknet-http`).
- `FromJsonSchema` — HTTP-backed, single-endpoint (in `alknet-http` per
ADR-027; was a broken schema-only placeholder in `alknet-call`).
- `FromMCP` — MCP streamable-HTTP-backed (in `alknet-http`, feature-gated).
- `FromOpenAPI` — HTTP-backed (in `alkhttp`).
- `FromJsonSchema` — HTTP-backed, single-endpoint (in `alkhttp` per
ADR-027; was a broken schema-only placeholder in the call protocol).
- `FromMCP` — MCP streamable-HTTP-backed (in `alkhttp`, feature-gated).
The `to_*` adapters (`to_openapi`, `to_mcp`) are outbound projections, not
`OperationAdapter` implementations — they consume the registry, they don't
@@ -510,18 +542,19 @@ produce entries for it (ADR-022 §5).
### Adapter Location Map
The decomposition principle: **the adapter trait lives where the types live
(`alknet-call`); the adapter implementations live where their transport
(alkcall); the adapter implementations live where their transport
dependencies live.**
```
alknet-call (lean — no HTTP client, no HTTP server)
alkcall (lean — no HTTP client, no HTTP server)
├── OperationAdapter trait (the contract — async, per ADR-022 §5)
├── from_call (transport-agnostic — discovers remote ops via
│ call protocol over any Connection)
└── CallClient (outbound connection take-over —
spawn_dispatch, transport-agnostic; dial in AlknetClient)
spawn_dispatch, transport-agnostic; dial in
the composition layer's helper, AlknetClient)
alknet-http (owns HTTP server + HTTP client — separate crate, separate Phase 0)
alkhttp (owns HTTP server + HTTP client — sibling host crate)
├── ProtocolHandler for h2/http1.1/h3 (axum server — inbound HTTP)
├── from_openapi (parse OpenAPI doc + reqwest forwarding handler)
├── from_jsonschema (single-endpoint reqwest forwarding handler — ADR-027)
@@ -531,28 +564,26 @@ alknet-http (owns HTTP server + HTTP client — separate crate, separate Phase 0
Not built: MCP stdio transport
— stdio = spawn arbitrary executable = built-in RCE ("download untrusted MCP servers")
— streamable HTTP is the only supported MCP transport in alknet
— streamable HTTP is the only supported MCP transport in the alk stack
— recorded as an explicit security position, not a feature gap
```
`alknet-call` never sees the HTTP client. The `from_openapi`/`from_mcp`
alkcall never sees the HTTP client. The `from_openapi`/`from_mcp`
forwarding handlers are opaque `Arc<dyn Handler>` from the registry's
perspective — constructed by `alknet_http::from_openapi()` at registration
perspective — constructed by `alkhttp::from_openapi()` at registration
time, stored in `HandlerRegistration`, dispatched by the `CallAdapter` which
doesn't know reqwest is involved. `alknet-call` stays lean (no reqwest, no
axum); `alknet-http` owns both HTTP directions.
doesn't know reqwest is involved. alkcall stays lean (no reqwest, no
axum); `alkhttp` owns both HTTP directions.
**ADR-031 dependency note**: `alknet-http` implementing `from_openapi`/
`from_mcp` means `alknet-http` depends on `alknet-call` (for `OperationSpec`,
**ADR-031 dependency note**: `alkhttp` implementing `from_openapi`/
`from_mcp` means `alkhttp` depends on alkcall (for `OperationSpec`,
`Handler`, `HandlerRegistration`, `OperationAdapter`). ADR-031's rule is "no
handler crate depends on another handler crate" — but `alknet-call` is both
a handler *and* the protocol foundation that `alknet-agent` and `alknet-napi`
already consume. `alknet-http` depending on `alknet-call` is "HTTP uses the
call protocol types," not "HTTP depends on SSH." This is within the spirit of
ADR-031 (`alknet-call` is protocol-foundation, not a peer handler). The
`alknet-http` spec should note this explicitly; a one-line amendment to
ADR-031 clarifying that `alknet-call` is a protocol-foundation crate is
deferred to the `alknet-http` Phase 0.
handler crate depends on another handler crate" — but alkcall is the
protocol foundation that every downstream consumer
(including the HTTP host and any agent crate) builds on.
`alkhttp` depending on alkcall is "HTTP uses the
call protocol types," not "HTTP depends on a sibling handler." This is within the spirit of
ADR-031 (alkcall is protocol-foundation, not a peer handler).
### No-Env-Vars Invariant
@@ -576,7 +607,7 @@ vault (seed)
→ reqwest request goes out with vault-derived credential
```
The `from_openapi`/`from_mcp` forwarding handlers (in `alknet-http`) are the
The `from_openapi`/`from_mcp` forwarding handlers (in `alkhttp`) are the
credential injection point. They read from `context.capabilities`, not from
`std::env::var`. The downstream consumers' `Default` impls reading env vars
are simply never called — the assembly layer constructs providers with
@@ -584,14 +615,14 @@ vault-derived credentials through the builder API, or the provider's HTTP
calls are routed through `from_openapi` operations that carry the credential
in `Capabilities`.
**This is a spec-level invariant in `alknet-call`, not a runtime convention.**
**This is a spec-level invariant in alkcall, not a runtime convention.**
The dispatch path (`build_root_context` and `OperationEnv::invoke()` per
ADR-018 §6) populates `OperationContext.capabilities` from the registration
bundle. The invariant is: *no handler reads outbound credentials from any
source other than `OperationContext.capabilities`.* This is already the
architectural intent of ADR-010; this document records it as an explicit
invariant that the `from_openapi`/`from_mcp` handler implementations (in
`alknet-http`) are verified against.
`alkhttp`) are verified against.
### Exchange-of-Operations Pattern (Runner / Container Service)
@@ -630,20 +661,21 @@ Bilateral: the container service ALSO runs from_call against the hub,
the other side's `External` ops. The #2 gap.
3. `OperationAdapter` trait — `from_call` implements it. The #3 gap (enabling,
not blocking — `from_call` can be built as a free function before the trait
exists, but the trait is needed for `alknet-http`'s adapters).
exists, but the trait is needed for `alkhttp`'s adapters).
**Why the container service doesn't need alknet-ssh**: under the call
**Why the container service doesn't need SSH**: under the call
protocol, the container service is a `CallClient` that dials the hub's
`alk/call` ALPN (over QUIC, TCP+TLS, or any transport) — no SSH in
the loop. SSH port
forwarding becomes the *transitional* mechanism for targets that can't run a
call-protocol client (the `alknet-ssh` phase-0 findings document this
call-protocol client (the source repo's SSH phase-0 findings — alknet,
provenance — document this
transition). Once the container service runs a `CallClient`, SSH is out of
the path entirely.
This is the "dev runner" pattern: a call-protocol client that connects back
to a hub and exposes core dev tools (bash, fs, etc.) as operations. The agent
service (`alknet-agent`, downstream) is the consumer that orchestrates these
crate (downstream) is the consumer that orchestrates these
via `env.invoke()`.
## Implementation Priority Order
@@ -654,13 +686,13 @@ Based on the gap analysis and the downstream unblock chain:
runner, no container service, no bilateral exchange. Reuses the existing
`CallConnection` for the dispatch loop; adds only the
connection-establishment + credential-handling half. The single
highest-value piece of work in the entire `alknet-call` completion.
highest-value piece of work in the entire call-completion effort.
2. **`from_call`** (critical, depends on `CallClient`) — consumes the
already-implemented `services/list` + `services/schema` discovery API.
3. **`OperationAdapter` trait** (enabling) — the async trait. Small,
standalone, unblocks `alknet-http` Phase 1 (including `from_jsonschema`
standalone, unblocks `alkhttp`'s adapter work (including `from_jsonschema`
per ADR-027).
4. **DC-1 resolution** (peer-graph routing model, ADR-024) — the
@@ -673,23 +705,27 @@ Based on the gap analysis and the downstream unblock chain:
## What This Completion Unblocks
| Downstream crate | What it needs from alknet-call | Status without completion |
Historical planning table (source-repo era; crate names as they were).
The completion has since landed in this crate — `alknet-http` is now
`alkhttp`.
| Downstream crate | What it needs from alkcall | Status without completion |
|-------------------|-------------------------------|--------------------------|
| alknet-http | `OperationAdapter` trait (to implement `from_openapi`/`from_mcp`) | Blocked — can't define HTTP-backed adapters without the trait |
| alknet-ssh | Stable alknet-call types (no adapter dependency) | Not blocked — ssh depends on alknet-core, not alknet-call's adapters. Proceeds in parallel. |
| alknet-agent | `CallClient` (tool dispatch), `from_call` (remote tool import), `OperationAdapter` (provider adapters) | Blocked on `CallClient` + `from_call` |
| alknet-http (→ alkhttp) | `OperationAdapter` trait (to implement `from_openapi`/`from_mcp`) | Blocked — can't define HTTP-backed adapters without the trait |
| alknet-ssh (historical) | Stable alkcall types (no adapter dependency) | Not blocked — ssh depends on the vendored core types, not alkcall's adapters. Proceeds in parallel. |
| alknet-agent (historical) | `CallClient` (tool dispatch), `from_call` (remote tool import), `OperationAdapter` (provider adapters) | Blocked on `CallClient` + `from_call` |
| Container service (dispatch rewrite) | `CallClient` + `from_call` | Blocked — this is the primary consumer |
| Runner pattern (dev runner, opencode runner) | `CallClient` + `from_call` | Blocked — the runner IS a `CallClient` |
| alknet-napi | `CallClient` (Node.js calls remote ops) | Blocked — NAPI projects `CallClient` to JS |
| alknet-napi (historical) | `CallClient` (Node.js calls remote ops) | Blocked — NAPI projects `CallClient` to JS |
## Constraints
- **No HTTP in alknet-call.** `from_openapi`/`from_mcp`/`from_jsonschema`/
`to_openapi`/`to_mcp` live in `alknet-http`. The `OperationAdapter`
- **No HTTP in alkcall.** `from_openapi`/`from_mcp`/`from_jsonschema`/
`to_openapi`/`to_mcp` live in `alkhttp`. The `OperationAdapter`
trait and the call-protocol-backed adapter (`from_call`, transport-
agnostic) live in `alknet-call`. `from_jsonschema` was originally
(mis)placed in `alknet-call` as a schema-only placeholder; ADR-027
moved it to `alknet-http` as a real HTTP-backed adapter. See Adapter
agnostic) live in alkcall. `from_jsonschema` was originally
(mis)placed in the call protocol as a schema-only placeholder; ADR-027
moved it to the HTTP host crate as a real HTTP-backed adapter. See Adapter
Location Map.
- **No secret material on the wire.** `ConnectionCredentials` carries vault-derived
material for the *outbound* connection (TLS identity); `auth_token` is a
@@ -710,17 +746,17 @@ Based on the gap analysis and the downstream unblock chain:
not a singular connection overlay. `invoke_peer()` routes to the right peer
via `PeerRef::Specific` / `PeerRef::Any` (ADR-024 §1-2).
- **`from_call` is a manual free function.** The assembly layer calls it
after the dial (in `AlknetClient`). The overlay is per-connection so
re-import on reconnect is naturally scoped (DC-2, OQ-27). See
[ADR-028](decisions/069-from-call-manual-free-function.md).
after the dial (in the composition layer's dial helper, `AlknetClient` — today the alknet glue; OQ-43). The overlay is per-connection so
re-import on reconnect is naturally scoped (DC-2, OQ-12). See
[ADR-028](decisions/028-from-call-manual-free-function.md).
- **`from_call` namespace collision is same-peer only.** Cross-peer collision
dissolves (same name on different peers is fine — separate sub-overlays,
ADR-024 §5). Same-peer collision stays an error. `namespace_prefix` is
optional local-naming sugar, not the disambiguation mechanism (DC-3, OQ-28).
optional local-naming sugar, not the disambiguation mechanism (DC-3, OQ-13).
- **`OperationAdapter::import()` returns `Result`.** Failures surface as
`AdapterError` (DC-4, OQ-26).
`AdapterError` (DC-4, OQ-11).
- **MCP stdio transport is not built.** Streamable HTTP is the only supported
MCP transport in alknet. stdio = spawn arbitrary executable = built-in RCE.
MCP transport in the alk stack. stdio = spawn arbitrary executable = built-in RCE.
Recorded as an explicit security position, not a feature gap.
- **Pure-client X.509 connections are not in the peer graph on the client
side.** A `CallClient` connection to a public X.509 endpoint with no
@@ -730,10 +766,10 @@ Based on the gap analysis and the downstream unblock chain:
invoked through the `CallConnection` handle. The client-side
`ServerCertVerifier` uses CA verification (`WebPkiServerVerifier`) for
such remotes; known peers (hub with `PeerEntry`) use fingerprint
pinning. See [ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md).
pinning. See [ADR-054](decisions/054-outgoing-only-x509-three-remote-roles.md).
- **`ConnectionCredentials.remote_identity: None` is load-bearing.** `None`
means "no `PeerEntry` for this remote → use CA verification (X.509)
or fail closed (Ed25519 raw key)" per the ADR-034 §3 verifier rule.
or fail closed (Ed25519 raw key)" per the verifier rule (ADR-054).
The implementation must not default `remote_identity` to a placeholder
to satisfy the field, and must not treat `None` as "skip verification"
— `None` + X.509 is CA verification, `None` + raw key is a hard
@@ -743,79 +779,73 @@ Based on the gap analysis and the downstream unblock chain:
| Decision | ADR | Summary |
|----------|-----|---------|
| Call protocol client and adapter contract | [ADR-022](decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction; trait is async; adapters produce `HandlerRegistration` bundles |
| `from_jsonschema` as HTTP-backed single-endpoint adapter in alknet-http | [ADR-027](decisions/066-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf |
| Peer-graph routing model (DC-1, supersedes ADR-023) | [ADR-024](decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via existing `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` |
| PeerEntry and Identity.id decoupling | [ADR-025](decisions/030-peerentry-and-identity-id-decoupling.md) | `PeerId` source changes from UUID to `Identity.id` (= `PeerEntry.peer_id`, stable across key rotation); `Identity.id` decoupled from crypto material on the fingerprint path |
| Forwarded-for identity | [ADR-026](decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; the `from_call` handler populates it; metadata only, never used by `AccessControl::check` |
| Storage boundary and repo/adapter pattern | [ADR-033](decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
| Secret material flow and capability injection | [ADR-010](decisions/014-secret-material-flow-and-capability-injection.md) | The no-env-vars invariant's foundation; capabilities injected at assembly layer |
| Handler registration, provenance, and composition authority | [ADR-018](decisions/022-handler-registration-provenance-and-composition-authority.md) | The registration bundle adapters produce; `composition_authority: None` for leaves |
| Operation registry layering | [ADR-019](decisions/024-operation-registry-layering.md) | Layer 2 per-connection overlay where `from_call` imports land |
| Privilege model and authority context | [ADR-017](decisions/015-privilege-model-and-authority-context.md) | Adapter-registered ops are `Internal` by default; default-deny posture |
| Abort cascade for nested calls | [ADR-020](decisions/016-abort-cascade-for-nested-calls.md) | Cross-node abort through `from_call` forwarding handler's `parent_request_id` |
| Operation error schemas | [ADR-016](decisions/023-operation-error-schemas.md) | `error_schemas` mirrored by `from_call` from remote op's spec |
| Streaming handler for subscriptions | [ADR-021](decisions/049-streaming-handler-for-subscriptions.md) | `from_call` `Subscription` ops register a `StreamingHandler` (`HandlerKind::Stream`) that calls `CallConnection::subscribe()` and forwards the remote stream; `Query`/`Mutation` stay `HandlerKind::Once` |
| TLS identity redesign | [ADR-027](decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | RFC 7250 raw key / X.509 cert dimensions of the local `TlsIdentity` (now carried by `ConnectionCredentials.local_identity`) |
| Outgoing-only X.509 and three peer roles | [ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Public X.509 endpoint is not a `PeerEntry` on the client side (no `PeerId`, not in peer graph); client-side verifier by `PeerEntry` presence (CA vs fingerprint pin); hub = mixed-fingerprint `PeerEntry` |
| HD derivation for encryption keys | [ADR-020](decisions/020-hd-derivation-for-encryption-keys.md) | Vault-derived TLS identity material |
| Vault key model | [ADR-026](decisions/026-vault-key-model-hd-derivation.md) | Vault-derived TLS identity material |
| Vault local-only dispatch | [ADR-025](decisions/025-vault-local-only-dispatch.md) | Vault access at assembly layer only; the credential injection path's first hop |
| Crate decomposition | [ADR-031](decisions/003-crate-decomposition.md) | `alknet-http` depends on `alknet-call` (protocol-foundation exception, noted in Adapter Location Map) |
| One-way door decision framework | [ADR-032](decisions/009-one-way-door-decision-framework.md) | Door-type classification for DC-1..4 |
| Call protocol client and adapter contract | [ADR-022](decisions/022-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction; trait is async; adapters produce `HandlerRegistration` bundles |
| `from_jsonschema` as HTTP-backed single-endpoint adapter | [ADR-027](decisions/027-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from the call protocol (broken schema-only placeholder) to the HTTP host crate as a real reqwest-backed single-endpoint adapter — today `alkhttp` (ex-`alknet-http`); `FromJsonSchema` provenance stays in alkcall as a leaf |
| Peer-graph routing model (DC-1, supersedes ADR-023) | [ADR-024](decisions/024-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via existing `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` |
| PeerEntry and Identity.id decoupling | [ADR-025](decisions/025-peerentry-and-identity-id-decoupling.md) | `PeerId` source changes from UUID to `Identity.id` (= `PeerEntry.peer_id`, stable across key rotation); `Identity.id` decoupled from crypto material on the fingerprint path |
| Forwarded-for identity | [ADR-026](decisions/026-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; the `from_call` handler populates it; metadata only, never used by `AccessControl::check` |
| Storage boundary and repo/adapter pattern | [alknet ADR-033](/workspace/@alkdev/alknet/docs/architecture/decisions/033-storage-boundary-and-repo-adapter-pattern.md) (provenance) | Core defines repo traits + in-memory defaults; persistence adapters are separate crates (composition-layer work — OQ-43) |
| Secret material flow and capability injection | [ADR-010](decisions/010-secret-material-flow-and-capability-injection.md) | The no-env-vars invariant's foundation; capabilities injected at assembly layer |
| Handler registration, provenance, and composition authority | [ADR-018](decisions/018-handler-registration-provenance-and-composition-authority.md) | The registration bundle adapters produce; `composition_authority: None` for leaves |
| Operation registry layering | [ADR-019](decisions/019-operation-registry-layering.md) | Layer 2 per-connection overlay where `from_call` imports land |
| Privilege model and authority context | [ADR-017](decisions/017-privilege-model-and-authority-context.md) | Adapter-registered ops are `Internal` by default; default-deny posture |
| Abort cascade for nested calls | [ADR-020](decisions/020-abort-cascade-for-nested-calls.md) | Cross-node abort through `from_call` forwarding handler's `parent_request_id` |
| Operation error schemas | [ADR-016](decisions/016-operation-error-schemas.md) | `error_schemas` mirrored by `from_call` from remote op's spec |
| Streaming handler for subscriptions | [ADR-021](decisions/021-streaming-handler-for-subscriptions.md) | `from_call` `Subscription` ops register a `StreamingHandler` (`HandlerKind::Stream`) that calls `CallConnection::subscribe()` and forwards the remote stream; `Query`/`Mutation` stay `HandlerKind::Once` |
| TLS identity redesign | [alknet ADR-027](/workspace/@alkdev/alknet/docs/architecture/decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | RFC 7250 raw key / X.509 cert dimensions of the local `TlsIdentity` (now carried by `ConnectionCredentials.local_identity`) |
| Outgoing-only X.509 and three remote roles | [ADR-054](decisions/054-outgoing-only-x509-three-remote-roles.md) (re-owned; provenance: [alknet ADR-034](/workspace/@alkdev/alknet/docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md)) | Public X.509 endpoint is not a `PeerEntry` on the client side (no `PeerId`, not in peer graph); client-side verifier by `PeerEntry` presence (CA vs fingerprint pin); hub = mixed-fingerprint `PeerEntry` |
| HD derivation for encryption keys | [alknet ADR-020](/workspace/@alkdev/alknet/docs/architecture/decisions/020-hd-derivation-for-encryption-keys.md) (provenance — vault ADRs live in the source repo) | Vault-derived TLS identity material |
| Vault key model | [alknet ADR-026](/workspace/@alkdev/alknet/docs/architecture/decisions/026-vault-key-model-hd-derivation.md) (provenance) | Vault-derived TLS identity material |
| Vault local-only dispatch | [alknet ADR-025](/workspace/@alkdev/alknet/docs/architecture/decisions/025-vault-local-only-dispatch.md) (provenance) | Vault access at assembly layer only; the credential injection path's first hop |
| Crate decomposition | [ADR-031](decisions/031-crate-decomposition.md) | `alkhttp` depends on alkcall (protocol-foundation exception, noted in Adapter Location Map) |
| One-way door decision framework | [ADR-032](decisions/032-one-way-door-decision-framework.md) | Door-type classification for DC-1..4 |
## Open Questions
See [open-questions.md](open-questions.md) for full details.
- **OQ-25** (dissolved by ADR-024): `remote_safe` marking shape — moot.
- **OQ-10** (dissolved by ADR-024): `remote_safe` marking shape — moot.
`remote_safe`/`trusted_peer` are retired; peer authorization is
`AccessControl::check(peer_identity)`. No marking to shape.
- **OQ-26** (resolved): `AdapterError` variants — `DiscoveryFailed`,
- **OQ-11** (resolved): `AdapterError` variants — `DiscoveryFailed`,
`SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`
(replaces flat `Conflict`). `#[non_exhaustive]`.
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
- **OQ-12** (resolved): `from_call` re-import trigger — `from_call` is a
manual free function; the assembly layer calls it after the dial (in
`AlknetClient`). A `CallConnection::refresh()` method is a genuine
feature addition — non-breaking, additive. See
[ADR-028](decisions/069-from-call-manual-free-function.md).
- **OQ-28** (resolved): `from_call` namespace collision — same-peer
[ADR-028](decisions/028-from-call-manual-free-function.md).
- **OQ-13** (resolved): `from_call` namespace collision — same-peer
collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays).
`namespace_prefix` is optional local-naming sugar.
- **OQ-29** (resolved): `CallClient` TLS client-auth — wire quinn
- **OQ-14** (resolved): `CallClient` TLS client-auth — wire quinn
client-auth (present Ed25519 key as raw public key client cert);
key-type-aware server cert verification (raw key = fingerprint match,
X.509 = CA verification); fingerprint normalization (`ed25519:` across
quinn/iroh). The iroh path already works; the gap was quinn-only.
See OQ-29 in open-questions.md.
- **OQ-30** (resolved): `PeerRef::Any` routing policy — insertion-order
See OQ-14 in open-questions.md.
- **OQ-15** (resolved): `PeerRef::Any` routing policy — insertion-order
first-match. A richer `RoutingPolicy` is a feature extension.
- **OQ-31** (resolved): `services/list-peers` — opt-in; `services/list`
- **OQ-16** (resolved): `services/list-peers` — opt-in; `services/list`
is "own ops only."
- **OQ-32** (open, feature extension): Multi-hop federation — the one-hop
- **OQ-17** (deferred(scope), feature extension): Multi-hop federation — the one-hop
model is the architectural commitment; multi-hop is a feature extension
that doesn't break downstream. The peer-keyed model extends to multi-hop
without redesign; petgraph is the candidate if path-finding becomes real
(ADR-024 §3.7).
- **OQ-33** (resolved by ADR-025): `PeerId` is a logical id. Source is
- **OQ-18** (resolved by ADR-025): `PeerId` is a logical id. Source is
`Identity.id` from `IdentityProvider` resolution (= `PeerEntry.peer_id`,
stable across key rotation). See OQ-33 in open-questions.md.
- **OQ-34** (resolved by ADR-025 + ADR-033): Persistent peer registry —
stable across key rotation). See OQ-18 in open-questions.md.
- **OQ-19** (resolved by ADR-025): Persistent peer registry —
the storage boundary is `core trait + in-memory default` (config-backed
`ConfigIdentityProvider` now; persistence adapters additive in separate
crates). See OQ-34 in open-questions.md.
- **OQ-35** (dissolved): the "API key asymmetry" framing was wrong;
crates). See OQ-19 in open-questions.md.
- **OQ-20** (dissolved): the "API key asymmetry" framing was wrong;
`PeerEntry` supports multiple credential paths (fingerprints +
auth_token_hash), `ApiKeyEntry` is for tokens that ARE the identity.
See OQ-35 in open-questions.md.
- **OQ-36** (resolved by ADR-035): Concrete persistence adapter shapes —
read-sync / write-async split (`IdentityStore` async write trait
extends the sync `IdentityProvider` read trait); SQLite adapter caches
in memory and uses honker NOTIFY/LISTEN for no-restart cache
invalidation; `alknet-store-sqlite` crate implements both
`IdentityStore` and `CredentialStore`. See ADR-035 and OQ-36 in
open-questions.md.
- **OQ-37** (resolved by ADR-034): X.509 outgoing-only case — three
See OQ-20 in open-questions.md.
- **OQ-21** (resolved by ADR-054): X.509 outgoing-only
case — three
remote roles named (public X.509 endpoint, transport relay, hub).
`PeerEntry` asymmetry is correct: a pure-client connection to a public
X.509 endpoint is **not** in the call-protocol peer graph on the
@@ -824,8 +854,10 @@ See [open-questions.md](open-questions.md) for full details.
land in the connection's Layer 2 overlay and are invoked through the
connection handle. The client-side `ServerCertVerifier` is selected
by `PeerEntry` presence: known peer → fingerprint pin; unknown X.509
remote → CA verification (`WebPkiServerVerifier`). See ADR-034 and
OQ-37 in open-questions.md.
remote → CA verification (`WebPkiServerVerifier`). The rule is
re-owned in-crate by ADR-054 (provenance: alknet ADR-034, "Outgoing-Only
X.509 and Three Peer Roles", not ported); ADR-025's
`PeerEntry` asymmetry is the registry surface. See OQ-21 in open-questions.md.
## References
@@ -837,11 +869,16 @@ See [open-questions.md](open-questions.md) for full details.
model (the server-side complement to this document)
- `operation-registry.md` — `HandlerRegistration`, provenance, capability
injection, service discovery (the discovery API `from_call` consumes)
- `docs/research/alknet-call-completion/gap-analysis.md` — DC-1..4, the
implementation-state audit, the downstream unblock chain
- `docs/research/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
- Source-repo research (alknet, provenance — these paths live in
`/workspace/@alkdev/alknet/docs/research/`, not this repo):
- `alknet-call-completion/gap-analysis.md` — DC-1..4, the
implementation-state audit, the downstream unblock chain
- `alknet-call-peer-routing/findings.md` — the peer-graph
routing research that identified ADR-023's structural gap and validated
the ADR-024 design via POC
- `alknet-ssh/phase-0-findings.md` — SSH Phase 0; confirms ssh depends on
the vendored core types, not alkcall's adapters, so it proceeds in
parallel with the completion
- `/workspace/@alkdev/operations/` — TypeScript prior art (`from_openapi.ts`,
`from_mcp.ts`, `from_schema.ts`, `scanner.ts`)
- `/workspace/@alkdev/dispatch/` — concrete downstream consumer (container
@@ -849,7 +886,4 @@ See [open-questions.md](open-questions.md) for full details.
- `/workspace/aisdk/` — downstream consumer (Rust port of Vercel AI SDK); the
no-env-vars invariant makes its `std::env::var` reads unreachable
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp); streamable HTTP transport for
`alknet-http`'s `from_mcp`/`to_mcp` (separate crate, separate Phase 0)
- `docs/research/alknet-ssh/phase-0-findings.md` — alknet-ssh Phase 0;
confirms ssh depends on alknet-core not alknet-call's adapters, so it
proceeds in parallel with this completion
`alkhttp`'s `from_mcp`/`to_mcp`
@@ -232,5 +232,5 @@ WebTransport stream).
- OQ-09: WASM target boundaries (resolution amended — the server-side
dispatch door is no longer closed by `Connection` being QUIC-bound)
- Transport generalization findings:
[`docs/research/transport-generalization/findings.md`](../../research/transport-generalization/findings.md)
[`docs/research/transport-generalization/findings.md`](/workspace/@alkdev/alknet/docs/research/transport-generalization/findings.md)
- Implementation commit: `865fef6` (2026-07-09)
@@ -295,8 +295,8 @@ the args are optional for their transport.
`from_bidi` constructors and the yield-once contract are preserved via
`StreamBidiStreamSource`)
- Channels POC summary:
[`docs/research/alknet-channels/poc-summary.md`](../../research/alknet-channels/poc-summary.md)
[`docs/research/alknet-channels/poc-summary.md`](/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md)
§"Issues Surfaced" #1 (OQ-CH-13 confirmed +EV), #2 (REQ-CORE-02)
- Channels Phase 0 findings:
[`docs/research/alknet-channels/phase-0-findings.md`](../../research/alknet-channels/phase-0-findings.md)
[`docs/research/alknet-channels/phase-0-findings.md`](/workspace/@alkdev/alknet/docs/research/alknet-channels/phase-0-findings.md)
§POC-Validated Requirements — REQ-CORE-01, REQ-CORE-02
@@ -49,7 +49,7 @@ handler picks a joining strategy per-handler:
| 1 | `BiStream` trait (`AsyncRead + AsyncWrite + Send + Unpin`) | `crates/alknet-core/src/types.rs:226` | **Vestigial in code.** Declared per ADR-005, named in ADR-008 as "a client-side / test convenience trait," but grep across the workspace finds **zero** consumers — no `impl BiStream`, no `dyn BiStream`, no `Box<dyn BiStream>`. The trait is the ecosystem convention (`tokio::net::TcpStream`, `TlsStream<TcpStream>`, `russh::Channel::into_stream()` all satisfy it natively) but it was never wired in. |
| 2 | `Connection` (yields `(SendStream, RecvStream)` via `accept_bi`) | `crates/alknet-core/src/types.rs:507` | The handler-facing abstraction. Leaf is split. |
| 3 | `SendStream` (AsyncWrite-only) + `RecvStream` (AsyncRead-only) | `crates/alknet-core/src/types.rs:228-294` | The actual leaves handlers receive. Each carries a quinn/iroh/generic enum (`SendStreamKind` / `RecvStreamKind`) and dispatches per-call. |
| 4 | `WsStream` trait (recv/send `axum::ws::Message`) | `crates/alknet-http/src/websocket/upgrade.rs:44` | Bypasses `Connection` entirely. The WS session runs its own dispatch loop directly over `axum::extract::ws::WebSocket`; `CallConnection::new_overlay_only` is used instead of `Connection::from_bidi`. ADR-044/048 already say "a WS message stream is another `BiStream`-satisfying transport" — the code does not. |
| 4 | `WsStream` trait (recv/send `axum::ws::Message`) | `crates/alknet-http/src/websocket/upgrade.rs:44` | Bypasses `Connection` entirely. The WS session runs its own dispatch loop directly over `axum::extract::ws::WebSocket`; `CallConnection::new_overlay_only` is used instead of `Connection::from_bidi`. alknet ADR-044/048 (alknet space, provenance) already say "a WS message stream is another `BiStream`-satisfying transport" — the code does not. |
| 5 | `MpscSendStream` / `MpscRecvStream` (channels POC) | `/workspace/alknet-channels-poc/src/mpsc_stream.rs` | Split mpsc-backed halves fed to `Connection::from_stream`. The channels POC's `TunnelHandler` consumes them directly as two `tokio::io::copy` pumps — the split shape is right for the tunnel, wrong for HTTP. |
The two Phase 6 issues are symptoms of one root: **the leaf type is
@@ -108,21 +108,21 @@ accepts any of these directly, no per-handler wrapper.
### The two-pump shape is unaffected
The tunnel handler (ADR-078) and the SSH `direct-tcpip` handler
The tunnel handler (alknet ADR-078, provenance — in-crate: ADR-050) and the SSH `direct-tcpip` handler
(future) use the split shape — two `tokio::io::copy` pumps, one per
direction. With `BiStream` as the leaf, these handlers call
`tokio::io::split(bidi)` to get `(ReadHalf, WriteHalf)` — the same
stdlib idiom `tokio::io::split` already provides for `TcpStream` and
`TlsStream<TcpStream>`. The split is a stdlib call at the handler
boundary, not a per-handler trait wrapper. ADR-078's
boundary, not a per-handler trait wrapper. alknet ADR-078's (alknet space, provenance)
shutdown-on-completion contract applies to the `ReadHalf`/`WriteHalf`
unchanged.
### The TTY named-sub-streams case (ADR-038, ADR-077)
### The TTY named-sub-streams case (ADR-038; alknet ADR-077, provenance — TTY inside channels)
ADR-038 specifies `into_sub_streams()` returning
`Vec<(u8, SubStreamHandle)>` where `SubStreamHandle` is
`Send(SendStream) | Recv(RecvStream)`. ADR-077's TTY-inside-channels
`Send(SendStream) | Recv(RecvStream)`. alknet ADR-077's (alknet space, provenance) TTY-inside-channels
mode destructures into five named handles (`stdin`, `stdout`,
`stderr`, `ctrl_in`, `ctrl_out`). **Every stream_type is
unidirectional** (ADR-034) — the typed-sub-stream leaves are
@@ -306,13 +306,13 @@ The `WsStream` trait (`upgrade.rs:44-49`), the bespoke `drive_ws_session`
loop, the `handle_inbound_envelope` / `dispatch_envelope_to_pending`
helpers, and the `run_ws_session` glue are removed. The session's
wire-level invariants (binary-only, protocol-level close on text,
`fail_all` pending on disconnect, ADR-048's `EventEnvelope` framing)
`fail_all` pending on disconnect, alknet ADR-048's `EventEnvelope` framing (alknet space, provenance))
move into `WsBidiStream`'s `AsyncRead` / `AsyncWrite` / `poll_shutdown`
impls and the standard call-protocol dispatch path.
`CallConnection::new_overlay_only` stays — it's the
connection-local-overlay construction for non-peer clients
(ADR-034 §4, ADR-044 §5), orthogonal to the transport seam. What
(alknet ADR-034 §4, alknet ADR-044 §5 — alknet space, provenance), orthogonal to the transport seam. What
changes is that the WS session feeds it through a `Connection` rather
than a parallel `WsStream` trait.
@@ -330,7 +330,7 @@ async fn accept_bi(&self) -> Result<BiStream, StreamError> {
`into_sub_streams()` is unchanged in shape — it still returns
`Vec<(u8, SubStreamHandle)>` with `SubStreamHandle::Send(SendStream) |
Recv(RecvStream)`, because TTY's named sub-streams are unidirectional
(ADR-034, ADR-077). The two paths (`accept_bi` for handlers that want
(ADR-034; alknet ADR-077, provenance). The two paths (`accept_bi` for handlers that want
the joined pair, `into_sub_streams` for handlers that want the typed
unidirectional sub-streams) are preserved per ADR-038.
@@ -348,7 +348,7 @@ The `webtransport.md` spec describes the "VPN-like without being a VPN"
path: a browser opens a WebTransport session to `/alk/ssh`, the h3
handler hands each bidi stream to `SshAdapter::handle` as a
`Connection`, the browser's WASM SSH parser speaks SSH over the
stream. WebTransport is deferred per ADR-044.
stream. WebTransport is deferred per alknet ADR-044 (alknet space, provenance).
With `BiStream` as the leaf, the same path exists over WebSocket in
v1: a browser opens a WS connection, `WsBidiStream` presents it as a
@@ -356,7 +356,7 @@ v1: a browser opens a WS connection, `WsBidiStream` presents it as a
runs the channels demux over it, each channel's `accept_bi` yields a
`BiStream` that `SshAdapter::handle` receives. The WASM SSH parser
runs over a `BiStream`-over-WS-message adapter on the browser side
(the same `WsBidiStream` shape, browser-implemented). ADR-044/048's
(the same `WsBidiStream` shape, browser-implemented). alknet ADR-044/048's (alknet space, provenance)
"WS message stream is another `BiStream`-satisfying transport" becomes
literal — the code does what the spec said.
@@ -379,7 +379,7 @@ ALPN-stream-proxy (ADR-040) is the channels-over-WebTransport shape,
not an `alknet-http` shape.
This is out of scope for this ADR (WebTransport is deferred per
ADR-044). It is recorded here because the unification is what makes
alknet ADR-044, alknet space — provenance). It is recorded here because the unification is what makes
the future extraction clean: once `accept_bi` returns `BiStream`, the
`h3` handler's "accept a WebTransport session, yield each stream as a
`BiStream` to the ALPN handler" shape is the same code as
@@ -405,7 +405,7 @@ redesign.
unidirectional sub-streams are the case that justifies keeping
`SendStream` / `RecvStream` (as thin newtypes, not quinn-welded
enums).
- **The two-pump pattern** (ADR-078) — preserved. Tunnel/SSH handlers
- **The two-pump pattern** (alknet ADR-078, provenance — in-crate: ADR-050) — preserved. Tunnel/SSH handlers
call `tokio::io::split(bidi)` for their two pump halves; the
shutdown-on-completion contract applies to the `ReadHalf` /
`WriteHalf` unchanged.
@@ -436,7 +436,7 @@ redesign.
lines of WS-specific dispatch glue are removed from
`alknet-http/websocket/upgrade.rs`. The WS session runs through
`Connection::from_bidi` + the call-protocol handler like any other
transport. ADR-044/048's "WS message stream is `BiStream`-satisfying"
transport. alknet ADR-044/048's (alknet space, provenance) "WS message stream is `BiStream`-satisfying"
becomes literal.
- One abstraction (`BiStream`) replaces five. The leaf type matches the
ecosystem convention (`russh::Channel::into_stream()`, `TcpStream`,
@@ -550,13 +550,13 @@ compilable:
`Connection::from_bidi` + the call-protocol handler. This is the
largest single change and can land after (1)-(3) — the WS path is
independent of the handler call-site updates.
5. **Update ADR-007, ADR-008, ADR-038, ADR-077** to reflect the
5. **Update ADR-007, ADR-008, ADR-038, alknet ADR-077 (provenance)** to reflect the
`BiStream` return shape. ADR-007's `from_stream` constructor is
removed; `from_bidi` is the only public stream constructor (the
rule: the split never crosses a crate boundary as part of a
constructor). ADR-008's `accept_bi` return type is amended. ADR-038's
`ChannelBidiStreamSource::accept_bi` return type is amended;
`into_sub_streams()` is unchanged. ADR-077's two-mode TTY design is
`into_sub_streams()` is unchanged. alknet ADR-077's (alknet space, provenance) two-mode TTY design is
unchanged (the modes differ in *how* the adapter gets sub-streams,
not in the leaf type).
6. **Update `findings.md` Phase 6.** The "deferred" status is
@@ -602,12 +602,12 @@ fix (unify the leaf so the wrapper moves into core).
`from_source` extension point are preserved)
- ADR-038: `ChannelBidiStreamSource` (amended — `accept_bi` returns
`BiStream`; `into_sub_streams()` is unchanged)
- ADR-077: TTY inside channels (unchanged — the two-mode design is
- alknet ADR-077 (provenance): TTY inside channels (unchanged — the two-mode design is
preserved; the modes differ in how the adapter gets sub-streams,
not in the leaf type)
- ADR-078: two-pump shutdown-on-completion (unchanged — the contract
- alknet ADR-078 (provenance — in-crate: ADR-050): two-pump shutdown-on-completion (unchanged — the contract
applies to `tokio::io::split(bidi)` halves)
- ADR-044, ADR-048: WebSocket is the v1 browser bidirectional path
- alknet ADR-044/048 (alknet space, provenance): WebSocket is the v1 browser bidirectional path
(this ADR makes the "WS message stream is `BiStream`-satisfying"
claim literal)
- `docs/research/alknet-crate-extraction/findings.md` Phase 6 — the
@@ -2,7 +2,7 @@
## Status
Accepted (resolves OQ-42; amends the `AccessControl::check` signature and
Accepted (resolves alknet OQ-42 — source-repo space, provenance; the question is retracked in-crate as OQ-30 — amends the `AccessControl::check` signature and
adds `OperationSpec.resource_id_path`. Does not amend ADR-017 or ADR-018 —
specific #4 confirms the composition authority stays static. Blocks lifted:
the alknet-docker, alknet-tty, opencode-runner wrapper, and
@@ -36,7 +36,7 @@ None of those crate specs can declare their `AccessControl` shapes until
the core model answers: **how does `AccessControl::check` learn whether
identity X owns runtime-spawned resource R?**
OQ-42 tracked this. The OQ resolved five sub-questions:
alknet OQ-42 (source-repo space, provenance; in-crate: OQ-30) tracked this. The OQ resolved five sub-questions:
1. Storage shape — reuse the repo/adapter pattern (ADR-033).
2. Integration point — `AccessControl::check` consults an ownership
@@ -88,7 +88,7 @@ model has no path from that knowledge to `check`.
### The options considered
Three integration points were considered (see OQ-42 for the full
Three integration points were considered (see alknet OQ-42, in-crate OQ-30, for the full
reasoning):
- **Option 1 — augment `Identity.resources` with a per-request snapshot.**
@@ -592,7 +592,7 @@ provider confirms it owns the container (dynamic).
## References
- OQ-42: Dynamic Resource Ownership for Runtime-Spawned Resources
- alknet OQ-42 (provenance; in-crate: OQ-30): Dynamic Resource Ownership for Runtime-Spawned Resources
(resolved by this ADR — the five sub-questions this ADR writes into
decision text)
- ADR-003: Auth as Shared Core (`IdentityProvider` — the first instance
@@ -625,9 +625,9 @@ provider confirms it owns the container (dynamic).
- ADR-022: Call Protocol Client and Adapter Contract (`from_call` — the
adapter that imports a peer's operations and re-registers them locally;
the proxy pattern's re-export mechanism)
- [auth.md](../crates/core/auth.md) (`Identity.resources`,
- [auth.md](/workspace/@alkdev/alknet/docs/architecture/crates/core/auth.md) (`Identity.resources`,
`AccessControl::check` interaction — both under edit by this decision)
- [operation-registry.md](../crates/call/operation-registry.md)
- [operation-registry.md](../operation-registry.md)
(`AccessControl`, `OperationSpec` — `resource_id_path` addition)
- [alknet-docker POC summary](../../research/alknet-docker/poc-summary.md)
- [alknet-docker POC summary](/workspace/@alkdev/alknet/docs/research/alknet-docker/poc-summary.md)
§"Open Unknowns" #3 (the research finding that surfaced this question)
@@ -79,7 +79,7 @@ were removed in commit `668d777` (2026-07-09). `irpc` may be re-added as
`iroh-blobs 0.103`), but that would be a *transitive* dependency of
`alknet-blobs`, not a direct dependency of `alknet-call` — alknet-call does
not import irpc and has no plans to. See
[`docs/research/transport-generalization/findings.md`](../../research/transport-generalization/findings.md)
[`docs/research/transport-generalization/findings.md`](/workspace/@alkdev/alknet/docs/research/transport-generalization/findings.md)
§3.1 for the removal trace.
## Decision
@@ -159,6 +159,6 @@ not import irpc and has no plans to. See
dispatch path)
- Call protocol wire format: `crates/alknet-call/src/protocol/wire.rs`
- Transport generalization findings:
[`docs/research/transport-generalization/findings.md`](../../research/transport-generalization/findings.md)
[`docs/research/transport-generalization/findings.md`](/workspace/@alkdev/alknet/docs/research/transport-generalization/findings.md)
§3.1 (dead `irpc` dep removal)
- Removal commit: `668d777` (2026-07-09)
@@ -302,8 +302,8 @@ Principle of least privilege.
- OQ-17: Abort cascade (the call tree is the agency chain — `parent_request_id`
traces principal → agent)
- OQ-19: Session-scoped registries (session operations are always `Internal`)
- [operation-registry.md](../crates/call/operation-registry.md)
- [call-protocol.md](../crates/call/call-protocol.md)
- [operation-registry.md](../operation-registry.md)
- [call-protocol.md](../call-protocol.md)
- TypeScript `@alkdev/operations` `buildEnv()` with `allowedNamespaces` — prior
art for scoped composition env
- POC at `/workspace/toolEnv` — demonstrated the sandbox-to-registry bridge with
@@ -253,5 +253,5 @@ not through the wire and not through `OperationSpec`.
tree and participate in cascade)
- `@alkdev/flowgraph` TypeScript package — prior art for call-graph tracking
with `descendants()`, `FailurePolicy`, reactive status propagation
- [call-protocol.md](../crates/call/call-protocol.md)
- [operation-registry.md](../crates/call/operation-registry.md)
- [call-protocol.md](../call-protocol.md)
- [operation-registry.md](../operation-registry.md)
@@ -2,7 +2,7 @@
## Status
Accepted (amended 2026-06-26, 2026-07-13, and 2026-07-16 — see "Amendments" below; the 2026-07-16 amendment per ADR-045 §5 removes `CallClient::connect`; amendment 2026-09-03 — the bootstrap-op set and the connect-side serving loop, see "Amendment (2026-09-03)" below; amendment 2026-09-04 — the `op/register` collision policy, in that amendment's "Collision policy" paragraph)
Accepted (amended 2026-06-26, 2026-07-13, and 2026-07-16 — see "Amendments" below; the 2026-07-16 amendment per ADR-045 §5 removes `CallClient::connect`; amendment 2026-09-03 — the bootstrap-op set and the connect-side serving loop, see "Amendment (2026-09-03)" below; amendment 2026-09-04 — the `op/register` collision policy, in that amendment's "Collision policy" paragraph; amendment 2026-10-13 — announced Sub/Pub forward-through and marked-spec refusal (review 010 S-04), see "Amendment (2026-10-13)" below)
## Context
@@ -349,9 +349,9 @@ same as `from_openapi` receives HTTP credentials.
- OQ-25..28: Two-way-door remainders from the call-completion gap analysis
(DC-1 shape, DC-4 error type, DC-2 re-import trigger, DC-3 namespace
collision — see [open-questions.md](../open-questions.md))
- [call-protocol.md](../crates/call/call-protocol.md)
- [operation-registry.md](../crates/call/operation-registry.md)
- [client-and-adapters.md](../crates/call/client-and-adapters.md) — the spec
- [call-protocol.md](../call-protocol.md)
- [operation-registry.md](../operation-registry.md)
- [client-and-adapters.md](../client-and-adapters.md) — the spec
that operationally fills the gap this ADR left to implementation
- `docs/research/alknet-call-completion/gap-analysis.md` — DC-1..4, the
decisions that needed resolution before implementation
@@ -524,7 +524,7 @@ overlays), not by a parallel boolean gate.
§5 showed `async fn import(&self) -> Vec<HandlerRegistration>` with no error
type. The trait returns `Result<Vec<HandlerRegistration>, AdapterError>`
where `AdapterError` is a crate-level enum. The *presence* of the error type
is recorded in [client-and-adapters.md](../crates/call/client-and-adapters.md);
is recorded in [client-and-adapters.md](../client-and-adapters.md);
the exact variants are the two-way-door remainder, tracked as OQ-26.
### DC-2 — from_call re-import on reconnection: manual free function
@@ -537,7 +537,7 @@ overlay dies with the connection. A `CallConnection::refresh()` method for
mid-connection re-discovery is a genuine feature addition — non-breaking,
additive — if a deployment needs manual re-discovery without
drop-and-reconnect. Two-way door; recorded in
[client-and-adapters.md](../crates/call/client-and-adapters.md); tracked as
[client-and-adapters.md](../client-and-adapters.md); tracked as
OQ-27. See [ADR-028](028-from-call-manual-free-function.md) for the full
rationale.
@@ -547,7 +547,7 @@ rationale.
collision = error**. A node importing from two remotes that both expose the
same unprefixed op name should fail loudly. The operator adds prefixes when
importing from multiple sources. Two-way door; recorded in
[client-and-adapters.md](../crates/call/client-and-adapters.md); tracked as
[client-and-adapters.md](../client-and-adapters.md); tracked as
OQ-28.
### Operational spec
@@ -556,7 +556,7 @@ The gap this ADR left to implementation — the `CallClient` API, the
`from_call` flow, the trait signature, the adapter location map, the
no-env-vars invariant, and the exchange-of-operations pattern — is
specified in
[client-and-adapters.md](../crates/call/client-and-adapters.md). That document
[client-and-adapters.md](../client-and-adapters.md). That document
is the operational complement to this ADR; this ADR remains the architectural
authority.
@@ -576,7 +576,7 @@ served by consuming `OperationSpec` directly. The §5 "adapters live in
alknet-call" one-way-door statement is corrected above to "the adapter
trait lives in `alknet-call`; implementations live with their transport."
See [ADR-027](027-from-jsonschema-as-http-adapter.md) and
[client-and-adapters.md](../crates/call/client-and-adapters.md) §"from_jsonschema".
[client-and-adapters.md](../client-and-adapters.md) §"from_jsonschema".
## Amendments (2026-07-13)
@@ -585,7 +585,7 @@ See [ADR-027](027-from-jsonschema-as-http-adapter.md) and
The §1 Decision framed `CallClient::connect(addr: SocketAddr,
credentials)` as the primary constructor and described it as "opens a
QUIC connection." The operational spec
([client-and-adapters.md](../crates/call/client-and-adapters.md))
([client-and-adapters.md](../client-and-adapters.md))
framed `spawn_dispatch(connection)` as the "lower-level API" that
`connect()` uses after the QUIC dial. That framing welded the
client-side one-way-door API to QUIC — the same welding ADR-007
@@ -617,9 +617,64 @@ existing code (which already has the right structure —
The door-type classification is updated: `spawn_dispatch` is one-way
(the handler-facing surface); ~~`connect` is two-way (additive
convenience)~~ `connect` is **removed** (ADR-045 §5). The
`AlknetClient` extraction (OQ-55) is **resolved** by ADR-045 — the
`AlknetClient` extraction (alknet OQ-55) is **resolved** by ADR-045 — the
shared dial seam is `alknet-client`; `spawn_dispatch` is the
protocol-crate take-over that consumes the dial's `Connection`.
See [client-and-adapters.md](../crates/call/client-and-adapters.md)
See [client-and-adapters.md](../client-and-adapters.md)
§"CallClient" for the reframed operational spec.
## Amendment (2026-10-13 — review 010 S-04: announced Sub/Pub forward-through, marked-spec refusal)
`op/register`'s announced Sub/Pub handling shipped as invocation-failing
stubs (`INVALID_OPERATION_TYPE` on every invocation of an advertised,
discoverable op — review 010 S-04, nine published versions). The
amendment replaces the stubs with the real forwarding machinery and
closes the marked-spec hole the stubs sat in:
1. **Unmarked `Sub` announcements** register as real
`HandlerKind::Stream` handlers
(`make_streaming_forwarding_handler`) — a nested composition that
cannot invoke them anyway (`env.invoke` is request/response-only,
ADR-046 §6), but the overlay now carries a genuine working forward
of the remote stream, not an advertised-always-failing body.
2. **Unmarked `Pub` announcements** register as real
`HandlerKind::Sink` handlers (`make_sink_forwarding_handler`) —
same posture; the shapes already exist in-tree (`from_call`'s own
import path).
3. **Marked (`channel_open`) announcements — any type — are refused at
`op/register`** with a typed error (`MARKED_SPEC_UNSUPPORTED`):
marked ops are channel-machinery ops by their own marker contract
(ADR-047 §2 — the data plane is the allocated channel's
`BiStream`). Forwarding a marked op is incoherent by the marker's
own semantics — a `Sub`-side forward would deliver the
`{channel_id}` ack of a channel that exists only on the remote
side; a `Pub`-side forward would stream call-plane chunks into a
surface alkcall's amendment 4 rejects. The announcing peer's
sanctioned compositions are the relay
(`ChannelRelay::register_relay_openable`) for a hub, and direct
consumption (`ChannelClient`) otherwise. The same refusal applies
to overlay imports generally: `CallConnection::register_imported`
and the generic registration path refuse marked specs that did not
come through the channel-machinery seam (ADR-047 amendment 4
§4.8), so a marked spec can no longer land anywhere as an
advertised-always-failing or advertised-but-unusable body.
The stub-era rationale ("nested composition is request/response-only;
the streaming/sink forwarding shapes ride on the `from_call` import
path") explained why the *stub body* existed, not why the *announcement
was accepted*; it was reviewed and rejected in review 010 (S-04) — the
rationale may justify not implementing nested streaming composition,
but not accepting and advertising an announcement that cannot serve it.
Door type: one-way. The refusal code and the marked-spec overlay
invariant are wire-visible (announcing peers observe the rejection) and
join the stable error set per ADR-016. The handler-kind mapping for
unmarked announcements reuses the existing forwarding shapes — no new
surface.
References: review 010 S-04 (the stub),
`src/registry/op_register.rs` (the handler), ADR-047 amendment 4 §4.8
(the marked-spec registration invariant this enforces on the wire
side), ADR-046 §6 (the composition boundary), ADR-051 (the relay
composition marked announcements are refused in favor of).
@@ -270,7 +270,7 @@ There are three credential types in the alknet auth model:
2. **X.509 cert** — for domain-facing endpoints (`api.alk.dev`, relays,
ACME/Let's Encrypt). Fingerprint format: `SHA256:<hex of DER>`. Requires
CA verification on the client side. The outgoing-only case (a client
connects to a public X.509 endpoint) is tracked as OQ-37.
connects to a public X.509 endpoint) is tracked as OQ-21.
3. **Bearer token** (auth_token) — for HTTP clients that can't do TLS
client-auth (browsers, curl), or as a secondary credential path. Carried
@@ -427,8 +427,9 @@ rotation (`PeerEntry`) or not (`ApiKeyEntry`).
paths, and `ApiKeyEntry` is for tokens that ARE the identity)
- OQ-29: CallClient TLS Client-Auth (resolved by this ADR's §6 fingerprint
normalization + the client-auth wiring decision recorded in OQ-29)
- OQ-37: X.509 outgoing-only case (the three auth types and how X.509
server identity fits — see OQ-37 in open-questions.md)
- OQ-21: X.509 outgoing-only case (the three auth types and how X.509
server identity fits — see OQ-21 in open-questions.md; the deciding
ADR, alknet ADR-034, was not ported into alkcall)
- `docs/research/alknet-storage-strategy/findings.md` §4 (the `PeerEntry`
model and resolution path)
- `docs/architecture/crates/core/auth.md` (the spec amended by this ADR)
@@ -178,3 +178,15 @@ endpoint.
established by `from_openapi`.
- Reuses `HttpServiceConfig` and `SharedHttpClient` from
`from_openapi` (in `alknet-http`).
## Amendment 2026-10-13: name normalization (`alknet-http` → `alkhttp`)
This ADR's decision text was authored when the HTTP host crate was
named `alknet-http` inside the alknet mono-repo. The HTTP host is now
the standalone sibling crate **`alkhttp`**, which wraps alkcall
(extraction context: ADR-031; the adapter-location rule is unchanged —
the trait lives in alkcall, HTTP-backed adapters live in `alkhttp`).
The title's "in alknet-http" and the body's crate-name references
should be read as "in `alkhttp`"; the References below link to the
source-repo originals (provenance). The `FromJsonSchema` provenance
variant remains in alkcall.
@@ -123,7 +123,7 @@ implementations are independent. The Amendment 1 protocol-foundation
exception remains for alknet-http/agent/napi (which use alknet-call's
`OperationSpec`/`Handler`/`OperationAdapter` types — actual type reuse,
not framing glue); it no longer covers alknet-tty. See
[ADR-057](057-alknet-tty-no-alknet-call-dep.md) for the full decision
[ADR-057](/workspace/@alkdev/alknet/docs/architecture/decisions/057-alknet-tty-no-alknet-call-dep.md) for the full decision
and the three options considered (duplicate / promote to core / use
alknet-call).
@@ -141,3 +141,30 @@ dep were removed in commit `668d777`. See
[ADR-014](014-irpc-never-integrated-hand-rolled-framing.md) for the full
record (ADR-013, which accepted "irpc as the call protocol foundation," is
superseded).
### Amendment 4 (2026-10-13): post-extraction state — alkcall is the unified protocol crate; sibling names normalized
The decomposition this ADR decided has been executed; this section
records where the named crates now live, so the decision text's
mono-repo framing reads as history:
- **alkcall** (this crate) is the unified extraction of `alknet-call`
+ `alknet-channels` + the vendored core types (the `alknet-core`
surfaces this crate's protocol depends on: `Connection`,
`BiStream`, `ProtocolHandler`, `AuthContext`, `IdentityProvider`,
`AuthToken`, `Capabilities`, `OwnershipProvider`, `HandlerError`,
`StreamError`). Two subsystems: `registry`/`protocol` (call) and the
channels subsystem (ADR-044's `channels-core`/`channels-call` split
landed as modules here).
- **Sibling host crates** (now standalone, all depending on alkcall):
`alknet-http` → `alkhttp`; `alknet-tls` → `alktls`;
`alknet-tty` → `alktty`; `alknet-tunnels` → `alktunnels`;
`alknet-trader` → `alktrader`.
- **The mono-repo (alknet) is being replaced** by thin glue over
alkcall and its siblings; its remaining roles (dial/accept helper
`AlknetClient`, endpoint layer, hub composition) move to the
composition layer — post-replacement owner and shape tracked as
OQ-43 in this crate's question tracker.
- The protocol-foundation rule (Amendment 1) carries over unchanged
with current names: `alkhttp` depends on alkcall for operation
registry / adapter / client types; peer-handler crates (e.g.
`alktty`, `alktunnels`, `alktrader`) depend on alkcall only.
@@ -22,7 +22,7 @@ JSON, tunnel's raw bytes, SSH's channel protocol).
The stream_type decomposition (unidirectional halves, mod 3 formula, 85
groups) is **removed from the channels layer**. The stream_type concept
survives in TTY's 5-byte format (ADR-052, amended by Phase 7), which the
survives in the TTY 5-byte format (alknet ADR-052, provenance — amended by Phase 7), which the
channels layer carries transparently in its payload. The total header
for a TTY chunk inside channels is 13 bytes (8 channels + 5 TTY), not
9; the two length fields are close but not identical
@@ -47,7 +47,7 @@ Two prior formats inform this design:
1. **SSH's channel multiplexer (RFC 4254)** — `ChannelId(u32)` with
string-named types negotiated per channel, all traffic interleaved on one
encrypted transport stream.
2. **alknet-tty's chunk format (ADR-052)** — `[stream_type: u8][length: u32 be]
2. **alknet-tty's chunk format (alknet ADR-052, provenance)** — `[stream_type: u8][length: u32 be]
[payload]`, a fixed set of four sub-streams (stdin/stdout/stderr/control)
within one bidi stream. Validated by two POCs and in production code.
@@ -100,18 +100,18 @@ client→server) and 4 (read, server→client), not one "bidirectional" 3.
```
9 bytes of header. The `channel_id` is the addition over TTY's 5-byte
format; `stream_type` and `length` are identical to TTY's fields (ADR-052),
format; `stream_type` and `length` are identical to TTY's fields (alknet ADR-052, provenance),
preserving the framing-disambiguation soundness property.
| field | width | meaning |
|-------|-------|---------|
| `channel_id` | u32 BE | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alk/call` (ADR-036). Channels 1..N are opened dynamically via `channel/open`. |
| `stream_type` | u8 | The unidirectional sub-stream within the channel. See "Stream types" below. |
| `length` | u32 BE | The payload length in bytes. 0 = EOF sentinel (same convention as TTY — ADR-052 §Sentinels). |
| `length` | u32 BE | The payload length in bytes. 0 = EOF sentinel (same convention as TTY — alknet ADR-052 §Sentinels (provenance)). |
### `MAX_CHUNK_LEN`
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (ADR-052 §5). A chunk with
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (alknet ADR-052 §5, provenance). A chunk with
`length > MAX_CHUNK_LEN` returns `ChunkTooLarge` and does not corrupt the
stream — the demux drops the chunk and continues. The header is always
exactly 9 bytes, so the demux can always resync by reading the next 9-byte
@@ -214,7 +214,7 @@ the cost of uniformity. For the intended use cases (TTY, SSH, tunnels, call
operations), this is noise. The high-throughput escape hatch is
multi-connection, not stripping the header.
### Framing disambiguation (carried from ADR-052 §5)
### Framing disambiguation (carried from alknet ADR-052 §5, provenance)
Channel 0 is just another channel — its chunks have `channel_id=0` in the
header. Disambiguation between channel 0 (call protocol) and data channels
@@ -225,7 +225,7 @@ first byte of a chunk payload from the server is unambiguous.
### Zero-length sentinel = EOF
A zero-length chunk is delivered as an empty `Bytes`, which the reassembled
stream interprets as EOF (same convention as TTY — ADR-052 §Sentinels). This
stream interprets as EOF (same convention as TTY — alknet ADR-052 §Sentinels (provenance)). This
is the clean-shutdown signal for a `(channel_id, stream_type)` pair.
### Sync core / async shell split
@@ -251,7 +251,7 @@ tokio-dependent shell.
- The hub relay correlates by `channel_id` in the header, uniformly across
substrates — no stream-ID-to-`channel_id` mapping per leg.
- WASM-compatible by construction — the pure core has no platform deps.
- The framing-disambiguation property from ADR-052 carries forward unchanged.
- The framing-disambiguation property from alknet ADR-052 (provenance) carries forward unchanged.
**Negative:**
- 9 bytes per chunk on all substrates, including QUIC where the transport
@@ -288,7 +288,7 @@ within the one-way format.
wire format is 8 bytes, not 9; `stream_type` removed from the channels
header; the stream_type decomposition is removed from the channels
layer; the handler owns its sub-stream multiplexing on the `BiStream`)
- ADR-052: alknet-tty wire format (the 5-byte format this generalizes;
- Source-repo provenance (not ported): alknet ADR-052 — alknet-tty wire format (the 5-byte format this generalizes;
amended by ADR-077 — scoped to direct TTY; **re-amended by ADR-035 —
TTY always uses its 5-byte format, carried transparently in the
channels payload**)
@@ -76,7 +76,7 @@ on the `BiStream` the channels layer gives them.
framing byte, carried transparently.
- **The control channel is handler-internal.** TTY sub-demuxes control
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN = 3`,
`STREAM_CTRL_OUT = 4` — ADR-052 amended by Phase 7). The channels layer
`STREAM_CTRL_OUT = 4` — alknet ADR-052 (provenance), amended by Phase 7). The channels layer
doesn't carry control. The "control isn't actually bidirectional" flaw
is fixed at the TTY layer, not the channels layer.
- **Recursive composition is literal.** A channel with ALPN
@@ -165,7 +165,7 @@ Three things changed since ADR-034/074/077 were accepted:
handler boundary — but there may be a generalized reason to expose it
(recursive composition, test helpers, the hub relay's `channel_id`
rewrite). The exact API shape is an implementation detail for the
channels crate, tracked as OQ-68. The *contract* — the channels layer
channels crate, tracked as alknet OQ-68 (source-repo space, provenance; in-crate: OQ-24). The *contract* — the channels layer
strips its 8-byte header on read and the handler parses its own framing
from the payload — is decided here; the *function surface* is not.
- **TTY's `wire.rs` adaptation:** TTY's `ChunkReader` currently reads from
@@ -174,10 +174,10 @@ Three things changed since ADR-034/074/077 were accepted:
stream_type constants, length validation, control message parsing — is
unchanged). This is an implementation concern for the channels + TTY
integration, not an architecture decision.
- **Full channel-level flow-control windowing (OQ-56):** unchanged. The
- **Full channel-level flow-control windowing (alknet OQ-56, provenance; in-crate: OQ-23):** unchanged. The
bounded-buffer backpressure (ADR-040) is the v1 mechanism; full
windowing is an additive extension that doesn't change the wire
format. OQ-56 stays deferred(scope).
format. alknet OQ-56 stays deferred(scope) in the source repo; this crate tracks it as OQ-23.
## Decision
@@ -194,7 +194,7 @@ handler's framing, carried transparently.
| field | offset | width | meaning |
|-------|--------|-------|---------|
| `channel_id` | 0 | 4 (BE) | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alk/call` (ADR-036). Channels 1..N are opened dynamically via `channel/open` (ADR-037). |
| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN` (16 MiB, matching TTY's cap — ADR-052 §5). |
| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN` (16 MiB, matching TTY's cap — alknet ADR-052 §5 (provenance)). |
The `stream_type` byte is **removed** from the channels header. The
channels layer has no `stream_type` concept — not in its header, not in
@@ -206,7 +206,7 @@ tunnel's raw bytes, SSH's channel protocol).
This amends ADR-034: the wire format is 8 bytes, not 9; the
`stream_type` decomposition (mod 3, unidirectional halves, 85 groups) is
removed from the channels layer. The stream_type concept survives in
TTY's 5-byte format (ADR-052, amended by Phase 7), which the channels
TTY's 5-byte format (alknet ADR-052, provenance — amended by Phase 7), which the channels
layer carries transparently.
### 2. `into_sub_streams()` is removed; `accept_bi` is the only accessor
@@ -430,7 +430,7 @@ inside-channels), re-introducing a separate inside-channels mode would be
a rewrite of TTY's session driver. The trade is one unified session
driver now vs. two-mode maintenance forever.
The add/strip API shape (OQ-68) is a **two-way door** — whether the
The add/strip API shape (alknet OQ-68, provenance; in-crate: OQ-24) is a **two-way door** — whether the
header add/strip is built into the read/write path or exposed as a
standalone utility is an implementation detail that can change without
breaking the wire format or the handler contract.
@@ -472,7 +472,7 @@ breaking the wire format or the handler contract.
`open_channel` and `Channel`)
- ADR-044: sub-crate decomposition (unchanged — 8-byte wire format in
`channels-core`; call-protocol coupling in `channels-call`)
- ADR-052: alknet-tty wire format (the 5-byte format carried
- Source-repo provenance (not ported): alknet ADR-052 — alknet-tty wire format (the 5-byte format carried
transparently in the channels payload; the control channel split
from Phase 7 is TTY-internal)
- `docs/research/stream-unification/findings.md` — the research that
@@ -15,7 +15,7 @@ Every handler — TTY, tunnel, SSH, call — receives a `Connection`, calls
`accept_bi()` once, gets a `BiStream`, and sub-multiplexes it however it
wants. The "typed handler path" (this ADR's motivating case for TTY) is
replaced by TTY sub-demuxing its `BiStream` via its own 5-byte format
(ADR-052) — the same code TTY runs in direct mode. The two-accessor
(alknet ADR-052, provenance) — the same code TTY runs in direct mode. The two-accessor
design (`accept_bi` for generic handlers, `into_sub_streams` for typed
handlers) collapses to one accessor.
@@ -109,7 +109,7 @@ per `ChannelManager` (`buffer_cap` field). This prevents memory exhaustion
without the complexity of SSH's sliding-window protocol.
Full channel-level windowing (SSH-style sliding-window per channel) is a
deferred extension, tracked as [OQ-56](../questions/056-full-channel-level-flow-control-windowing.md)
deferred extension, tracked as [alknet OQ-56](/workspace/@alkdev/alknet/docs/architecture/questions/056-full-channel-level-flow-control-windowing.md)
(deferred(scope)). It is blocked on a real deployment observing head-of-
line blocking where the bounded-buffer mitigation is insufficient. The
bounded-buffer decision is made; the extension is not.
@@ -201,7 +201,7 @@ DoS defense summary.
- Bounded-buffer backpressure does not eliminate head-of-line blocking — it
bounds the memory cost. A slow consumer still stalls its own channel's
demux reads. For the intended use cases (TTY, SSH, tunnels) this is
acceptable; full windowing is tracked as OQ-56 (deferred(scope)).
acceptable; full windowing is tracked as alknet OQ-56 (source-repo space, provenance; in-crate: OQ-23) (deferred(scope)).
## Door type
@@ -62,7 +62,7 @@ surface:
primary constructor and the one-way-door API. Takes a pre-established
`Connection` (produced by any transport — TCP+TLS via `from_bidi`,
WebTransport `BiStream`, SSH `direct-tcpip`, a quinn connection, a
WebSocket carrying `alk/channels` per ADR-044), installs channel 0,
WebSocket carrying `alk/channels` per alknet ADR-044 — the websocket-native-session ADR, provenance —), installs channel 0,
spawns the demux/mux, returns the client. Mirrors the server-side
`ChannelsAdapter::handle(Connection)` (substrate-agnostic) and the
existing `CallClient::spawn_dispatch(Connection)` pattern.
@@ -73,7 +73,7 @@ surface:
The dial+TLS seam (the transport-specific work each dial helper does —
verifier selection per ADR-034, handshake, produce a `Connection`) is the
correct scope of OQ-55's deferral. `AlknetClient` is the eventual shared
correct scope of alknet OQ-55's deferral. `AlknetClient` is the eventual shared
*dial*; `from_connection` is the shared *channels-take-over*. Separating
them now, before the one-way-door API is cast, is the point — not a
deferral. The "QUIC-only initially" framing is removed; it was the
@@ -81,7 +81,7 @@ anti-pattern this amendment corrects.
The door-type classification is unchanged: `from_connection` is one-way
(the handler-facing surface), `connect_quic` is two-way (additive
convenience). The `AlknetClient` extraction remains deferred (OQ-55) — but
convenience). The `AlknetClient` extraction remains deferred (alknet OQ-55) — but
what is deferred is the shared *dial*, not a QUIC-welded client API.
## Context
@@ -106,14 +106,14 @@ The phase-0 research (`docs/research/alknet-channels/phase-0-findings.md`
seam — the transport-specific work (open socket, TLS handshake, ADR-034
verifier selection, produce a `Connection`) that each transport's dial
helper rebuilds. Genuinely deferred — blocked on a second *transport's*
dial existing (OQ-55 tracks this correctly). A single QUIC dial
dial existing (alknet OQ-55 tracks this correctly). A single QUIC dial
(`connect_quic`) does not give enough information to extract the
transport-polymorphic dial seam; two different transport dials do.
This ADR decides #1 — `ChannelClient`, with `from_connection` as the
transport-agnostic primary constructor and `connect_quic` as a transport-
specific dial helper. #2 (the shared `AlknetClient` dial+TLS seam) stays
deferred per OQ-55.
deferred per alknet OQ-55.
## Decision
@@ -130,7 +130,7 @@ impl ChannelClient {
/// Transport-agnostic primary constructor. Takes a pre-established
/// `Connection` (any transport — TCP+TLS via `from_bidi`,
/// WebTransport BiStream, SSH direct-tcpip, a quinn connection, a
/// WebSocket per ADR-044), installs channel 0 (alk/call), spawns
/// WebSocket per alknet ADR-044 — websocket-native session, provenance —), installs channel 0 (alk/call), spawns
/// the demux/mux, and returns the client. Mirrors the server-side
/// `ChannelsAdapter::handle(Connection)`. This is the one-way-door
/// API surface — it must not be coupled to a transport (ADR-034,
@@ -223,10 +223,10 @@ sense — it's one endpoint of a bidirectional channels connection. The name
`ChannelClient` follows the `CallClient` convention (the side that dialed),
not a request/response role.
### Relationship to `AlknetClient` (OQ-55 — deferred)
### Relationship to `AlknetClient` (alknet OQ-55 — deferred)
`ChannelClient`'s *API* is transport-agnostic — `from_connection` takes
a pre-established `Connection`. What is deferred (OQ-55) is the shared
a pre-established `Connection`. What is deferred (alknet OQ-55) is the shared
*dial+TLS* seam (`AlknetClient`): the transport-specific work each dial
helper does — open a socket, run the TLS handshake, apply ADR-034's
verifier-selection rule, produce a `Connection`. That dial is genuinely
@@ -252,14 +252,14 @@ duplicated capability and not a QUIC-welded client API.
matching the call protocol's `CallAdapter`/`CallClient` shape.
- Bidirectionality is preserved — the client can both initiate and receive
`channel/open`.
- The `AlknetClient` deferral (OQ-55) is not blocked by `ChannelClient` —
- The `AlknetClient` deferral (alknet OQ-55) is not blocked by `ChannelClient` —
they are independent concerns. `ChannelClient` builds standalone; the
core extraction happens later when the blocker clears.
**Negative:**
- Each transport-specific dial helper duplicates ~20 lines of
verifier-selection boilerplate (from `connect_quic`). This is the known
cost of not extracting `AlknetClient` yet (OQ-55). Acceptable until the
cost of not extracting `AlknetClient` yet (alknet OQ-55). Acceptable until the
second transport's dial exists, at which point `AlknetClient` extracts
the shared dial+TLS seam. The `from_connection` API — the one-way-door
surface — is unaffected; only the dial helpers carry the duplication.
@@ -276,10 +276,10 @@ one-way-door primary constructor (transport-agnostic).
`from_connection`. Adding, removing, or changing a dial helper is cheap
and does not touch the one-way-door surface.
The `AlknetClient` extraction is a **deferred decision** (OQ-55,
The `AlknetClient` extraction is a **deferred decision** (alknet OQ-55,
deferred(scope)), not a door-type attribute. Its door type is two-way (the
extraction is a refactor, not a wire-format change), but it is not decided
in this ADR — see OQ-55 for the blocking condition. What is deferred is the
in this ADR — see alknet OQ-55 for the blocking condition. What is deferred is the
shared *dial+TLS* seam; `from_connection`'s transport-agnostic contract is
decided now.
@@ -292,7 +292,7 @@ decided now.
removed from `open_channel` and `Channel`; handler owns sub-stream
multiplexing)
- ADR-039: ChannelManager (the shared state `ChannelClient` holds)
- OQ-55: AlknetClient / client establishment extraction (the deferred core
- alknet OQ-55: AlknetClient / client establishment extraction (the deferred core
concern this ADR does NOT block on)
- `docs/research/alknet-channels/phase-0-findings.md` §OQ-CH-14 (the
research-scope question this ADR carries forward)
@@ -74,7 +74,7 @@ alknet-channels-core — the pure multiplexer (wire format, demux/mux,
There are no `channels-hub` or `channels-worker` sub-crates. The hub and
worker are **consumers** of channels, not sub-crates of it. The existing
`alknet-hub` crate (`docs/architecture/crates/hub/README.md`) IS the hub —
`alknet-hub` crate (source-repo path: docs/architecture/crates/hub/README.md — alknet, provenance) IS the hub —
it depends on `channels-call` and uses the channels protocol as its
substrate. A worker is just a worker — it depends on `channels-call` and
uses `ChannelClient` (ADR-043) to dial. The hub relay logic (ADR-042) lives
@@ -168,11 +168,11 @@ The dependency direction is: `alknet-hub` → `channels-call` →
### Relationship to `alknet-hub`
The existing `alknet-hub` crate (`docs/architecture/crates/hub/README.md`)
The existing `alknet-hub` crate (source-repo path: docs/architecture/crates/hub/README.md — alknet, provenance)
is the hub pattern: peer lifecycle, aggregated env, service discovery. With
channels as the substrate, `alknet-hub` gains a dependency on
`channels-call` and incorporates the relay logic (ADR-042). The hub spec
(`crates/hub/README.md`) will be updated to reflect that the hub uses
(that source-repo doc) will be updated to reflect that the hub uses
channels as its transport substrate — one channels connection per leg
(browser↔hub, hub↔spoke), with the relay translating `channel/open` and
byte-forwarding data channels. The hub's existing responsibilities (peer
@@ -247,5 +247,15 @@ the dependency direction (hub/worker → channels, not channels → hub/worker).
- ADR-043: ChannelClient (in `channels-call`; there is no
`channels-worker` sub-crate — a worker is any crate that uses
`ChannelClient` to dial)
- `docs/architecture/crates/hub/README.md` — the existing hub crate (the
- alknet hub crate (source-repo path docs/architecture/crates/hub/README.md, provenance) — the existing hub crate (the
relay's consumer)
## Amendment 2026-10-13: the split landed inside alkcall, not as separate crates
This ADR's decision text was authored when the channels protocol lived
in the mono-repo crate `alknet-channels` and framed the split as
sub-crates of it. The extraction (ADR-031, amendment 4) landed the
`channels-core` (pure multiplexer) / `channels-call` (call coupling:
channel 0 pre-negotiation, lifecycle ops, `ChannelCore`, `ChannelClient`)
decomposition as **modules inside alkcall** — downstream depends on
alkcall as a single crate. Hub and worker remain consumers (no
worker/hub sub-crate), as decided here.
@@ -1,8 +1,18 @@
# ADR-045: AlknetClient — the Native Client Dial Seam
> **Citation-space note (2026-10-13).** This ADR was authored in the
> alknet workspace before the extraction. Its `ADR-08x` references
> (endpoint types, `TlsClientConfig`, accept-loop runner, crypto
> provider) and its `OQ-55`/`OQ-58` references are **source-repo
> (alknet) space, not this crate's `decisions/`** — provenance; those
> ADRs were never ported. The verifier-selection rule formerly cited as
> "alknet ADR-034" is re-owned in-crate by ADR-054. This crate's own
> tracker/ADR numbers appear only where explicitly marked.
## Status
Accepted (resolves OQ-55; §3 and §5 amended 2026-07-16 by ADR-012 —
Accepted (resolves alknet OQ-55 — source-repo space, provenance;
§3 and §5 amended 2026-07-16 by ADR-012 —
the dial credential bundle is `ConnectionCredentials` (transport-level),
not `CallCredentials` (call-protocol-level); all three dial signatures
unify on `&ConnectionCredentials`; `dial_iroh`'s `node_id` parameter is
@@ -21,7 +31,7 @@ reader); `auth_token` is a per-request payload field; the `from_call`
OQ-55 deferred `AlknetClient::dial()` — the transport-polymorphic client
dial seam — blocked on "a second transport's real dial existing." The
reasoning (recorded in the OQ-55 file and in
[channel-client.md](../crates/channels/channel-client.md) §"Relationship
[channel-client.md](../channel-client.md) §"Relationship
to `AlknetClient`") was sound at the time: extracting a QUIC-shaped
connector and naming it `AlknetClient` would bake QUIC in as *the*
establishment shape — the same welding ADR-007 unwound on the server
@@ -81,7 +91,7 @@ contributing to the confusion that made `AlknetClient` hard to spec:
(client). *Server-side accept vs. client-side dial.* The endpoint
accepts connections and resolves identity from the incoming
connection; the client dials and presents identity (client cert)
while verifying the remote (ADR-034).
while verifying the remote (alknet ADR-034, provenance).
3. **ALPN-level category** — endpoint ALPN / entry-point ALPN
(ADR-086 §2). *Identity-gated vs. bootstrap, at the TLS layer.*
@@ -143,7 +153,7 @@ The symmetry:
|---------|---------------------------|-------------------------|
| Transports | `with_quinn` / `with_iroh` / `with_tcp_tls` — pre-built by the assembly layer | `with_quinn` / `with_iroh` / `with_tcp_tls` — pre-built by the assembly layer |
| Per-connection work | Accept → extract ALPN + fingerprint → `Connection` → `dispatch` | Dial → TLS handshake → `Connection` (ALPN + fingerprint carried) |
| Identity | Resolved *from* the incoming connection (fingerprint from client cert, or token on channel 0) | *Presented* (local `TlsIdentity` as client cert) + remote *verified* (ADR-034 — fingerprint pin or CA) |
| Identity | Resolved *from* the incoming connection (fingerprint from client cert, or token on channel 0) | *Presented* (local `TlsIdentity` as client cert) + remote *verified* (alknet ADR-034, provenance — fingerprint pin or CA) |
| What it does NOT do | Run protocols — handlers do | Run protocols — `CallClient` / `ChannelClient` do |
| Config | `TlsServerConfig` (per endpoint type, built by assembly) | `TlsClientConfig` (per-dial, built from `CallCredentials`) |
@@ -168,7 +178,7 @@ pub struct AlknetClient {
impl AlknetClient {
/// QUIC dial. Builds a `TlsClientConfig` from `credentials`
/// (ADR-034 verifier selection + ADR-084 provider), dials `addr`
/// (alknet ADR-034 verifier selection, provenance + alknet ADR-084 provider), dials `addr`
/// on `alpn`, returns a `Connection` via
/// `Connection::from_quinn_with_alpn`. Feature-gated on `quinn`.
#[cfg(feature = "quinn")]
@@ -215,7 +225,7 @@ identity + auth token bundle, from `Capabilities`). The two rustls dials
(QUIC, TCP+TLS) build a `TlsClientConfig` from the credentials; the
iroh dial uses the raw `Ed25519SecretKey` directly. This mirrors the
server side's "iroh shares the key, not the config" (ADR-082, ADR-087
§3) — the consistency is in the rule (ADR-034 verifier selection), not
§3) — the consistency is in the rule (the verifier-selection rule — alknet ADR-034, provenance; re-owned in-crate as ADR-054), not
in the type.
> **Amendment 2026-07-16 (ADR-012):** The dial credential bundle is
@@ -238,7 +248,7 @@ in the type.
> is derived from `creds.remote_identity.fingerprint`
> (`ed25519:<hex>` → `NodeId::from_bytes`), the same extraction pattern
> the rustls dials use for the verifier. The consistency is now in both
> the rule (ADR-034) and the type. `CallCredentials` stays in
> the rule (alknet ADR-034, provenance) and the type. `CallCredentials` stays in
> `alknet-call` as the call-protocol credential bundle; the
> `auth_token` is a per-request field on `call.requested` payloads
> (set by the caller or the `from_call` forwarding handler, resolved by
@@ -492,7 +502,7 @@ several possible native clients sharing the same wire protocols.
graph.
- **The iroh dial is the exception.** It does not use
`TlsClientConfig` — iroh has its own TLS. The dial helper applies
the same ADR-034 rule via iroh's API (NodeId match). The
the same verifier rule (alknet ADR-034, provenance) via iroh's API (NodeId match). The
consistency is in the rule, not in the type. This is the same
exception as the server side (ADR-082, ADR-087 §3) — unavoidable,
and isolated to one dial method.
@@ -528,24 +538,24 @@ dedicated ADR lands.
- OQ-55 (resolved by this ADR) — `AlknetClient` / client establishment
extraction
- [ADR-083](083-endpoint-as-accept-loop-runner.md) — `AlknetEndpoint`
- [ADR-083](/workspace/@alkdev/alknet/docs/architecture/decisions/083-endpoint-as-accept-loop-runner.md) — `AlknetEndpoint`
as multi-transport accept-loop runner; the server-side shape this
ADR mirrors on the client side
- [ADR-086](086-endpoint-types-and-entry-points.md) — endpoint types
- [ADR-086](/workspace/@alkdev/alknet/docs/architecture/decisions/086-endpoint-types-and-entry-points.md) — endpoint types
(native has QUIC + TCP+TLS + iroh); entry-point vs. endpoint ALPN
distinction (§2)
- [ADR-087](087-tlsclientconfig-not-blocked-on-dial.md) —
- [ADR-087](/workspace/@alkdev/alknet/docs/architecture/decisions/087-tlsclientconfig-not-blocked-on-dial.md) —
`TlsClientConfig` not blocked on the dial seam; breaks the circular
hedge; the TLS config is a prerequisite for the dial
- [ADR-082](082-alknet-tls-extraction.md) — `TlsServerConfig` /
- [ADR-082](/workspace/@alkdev/alknet/docs/architecture/decisions/082-alknet-tls-extraction.md) — `TlsServerConfig` /
`TlsClientConfig` in `alknet-tls`; "iroh shares the key, not the
config"
- [ADR-007](007-connection-from-stream-generic-single-stream.md) —
`Connection::from_stream` / `from_bidi`; the server-side
generalization whose client-side analogue this ADR completes
- [ADR-034](034-outgoing-only-x509-and-three-peer-roles.md) —
- [ADR-034](/workspace/@alkdev/alknet/docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md) —
client-side verifier selection (fingerprint pin vs CA vs fail-closed)
- [ADR-084](084-aws-lc-rs-crypto-provider.md) — aws-lc-rs crypto
- [ADR-084](/workspace/@alkdev/alknet/docs/architecture/decisions/084-aws-lc-rs-crypto-provider.md) — aws-lc-rs crypto
provider on all paths
- [ADR-043](043-channelclient.md) — `ChannelClient::from_connection`
(the take-over `AlknetClient` feeds)
@@ -555,3 +565,22 @@ dedicated ADR lands.
is the native analogue)
- `docs/architecture/crates/channels/channel-client.md` §"Relationship
to `AlknetClient`" — the deferral this ADR resolves
## Amendment 2026-10-13: name normalization (`alknet-client` → the composition layer's dial helper)
This ADR's decision text was authored when the dial helper lived in the
mono-repo crate `alknet-client`. The decision itself is name-independent
and unchanged: **the dial lives in the consumer/composition layer; the
protocol crates take over pre-established `Connection`s (`spawn_dispatch`
/ `from_connection`).** Current naming:
- `AlknetClient` remains the dial helper's type name, but its host is
the **composition layer** (today the alknet glue, which is being
replaced). The post-replacement owner and shape of the composition
layer — including this dial seam — is tracked as OQ-43.
- `ConnectionCredentials` / `RemoteIdentity` (ADR-012) live with the
vendored core types in alkcall.
- The client-side verifier-selection rule this ADR's dial applies
(fingerprint pin vs CA vs fail-closed) is re-owned in-crate by
ADR-054; the alknet ADR-034 and ADR-084 references below are
provenance.
@@ -49,6 +49,13 @@ primitive is the load-bearing call-protocol piece; the broker composes
on top of it later, in channels, the same way the hub relay composes on
top of existing call ops.
> **Status note (post-deferral)**: the channels work this deferral
> pointed at has since landed, and the placement is now decided — the
> broker is out of scope for alkcall (ADR-047 §"What this ADR decides",
> Gap B) and tracked downstream as OQ-42 (`deferred(scope)`, blocked on
> a concrete producing-crate consumer). Read "deferred to the channels
> session" below as this placement decision.
### Gap A from the research findings: `Pub` handler shape
The research findings' Gap A identified that the current
@@ -441,6 +448,13 @@ one-way addition (the field is part of the spec's public API); its
## Fan-out (deferred — OQ-22 §fan-out scope)
> **Status note (post-deferral)**: see OQ-22/OQ-42 in
> [open-questions.md](../open-questions.md) — the placement is decided
> (out of scope for alkcall, ADR-047 Gap B); the broker design and
> build are downstream (OQ-42, deferred(scope)). "The channels session
> will add the broker" below predates that placement; read it as "the
> downstream hub composes the broker."
The fan-out / broker mechanism (one producer, N consumers, topic
matching by `(op_name, params_hash)`) is **not** part of this ADR. It
is deferred to the channels session for these reasons:
@@ -14,7 +14,265 @@ per-op `description` on the listing, the dynamic half stays deferred
— see "Amendment (§6 listing enrichment, 2026-09-06)" below;
amendment 3 (2026-09-16, review 008 U-1) — the op-name convention
extends to the flavor form and discovery carries an explicit
`channel_open_alpn` for non-derivable names — see "Amendment 3" below)
`channel_open_alpn` for non-derivable names — see "Amendment 3" below;
amendment 4 (2026-10-13, reviews 010 S-01 / 011) — the marked-pub open
contract: the initiator-allocated channel, the adopt path, the wrapper
as responder of record, zero `call.published` on marked ops, the relay
translation, and the client-side put-open capability — see "Amendment 4"
below)
## Amendment 4 (the marked-pub open contract, 2026-10-13 — review 010 S-01, review 011)
`channels/<alpn>/pub` open ops were registered, advertised, and stubbed
(`channel:pub_open_not_implemented` on every invocation — review 010
S-01, shipped nine published versions). This amendment pins the
implemented contract in place of the stub; review 011 corrected the
remediation sketch before this amendment was drafted (the serving-loop
`PublishStream` shape is the **unmarked** pub semantics and does not
apply to marked ops).
The shape, in one sentence: **a marked pub open is an ordinary channel
open whose `channel_id` the initiator allocated on its own side** — the
initiator streams bytes on that channel before the open resolves (the
early-arrival parking covers the race), and the responder adopts and
serves the data plane as the responder of record.
### 4.1 Marker semantics (pin 1)
The marked pub op's data plane is the adopted channel's `BiStream` —
one direction, initiator → responder, with the ADR-040 bounded buffer
as the backpressure mechanism. **Zero `call.published` data on the call
plane for marked pub ops**: a nonempty publish-chunk stream on a marked
op is rejected loudly with a new wire error (code
`channel:publish_chunks_rejected`, retryable false, message stating
that the data plane is the adopted channel) written in the initiator's
direction when the first chunk arrives; the chunks are never consumed,
and the marked-pub flow continues to its own single response. Two
competing data paths on a marked op must never exist. Unmarked `Pub`
ops are unaffected — they remain the ADR-046 `call.published` stream
through `publish_schema` validation.
### 4.2 `publish_schema` refused at registration for marked ops (pin 2)
Publish chunks never exist on a marked op (§4.1), so `publish_schema`
is dead machinery on a marked spec — the registered-but-incapable-of-
mattering class review 010 outlaws. `OperationRegistry::register`
refuses a marked spec that carries `publish_schema` (registration-time
loudness, per the house pattern).
### 4.3 Reserved input key + dispatch-layer strip (pin 3)
The adopted channel id crosses the open invocation as the reserved
input key **`"channel_id"`** — the same key the wrapper owns on the
reply side (`RESERVED_REPLY_KEY`, ADR-049 amendment 3); the wrapper
owns the `channel_id` key on both sides of a marked open op. Placement
rationale (review 011 §1.2): schema validation runs in `invoke_*`
before any handler, so the reserved key must be stripped
**pre-validation** in the sink resolve path (`resolve_sink_handler` —
the single source of truth shared by the wire dispatch and
registry-level `invoke_sink`, per review 001 P-08) before
`input_schema` validation; the stripped id is handed to the wrapper via
`OperationContext.metadata` under the key `channel_open_channel_id`.
The producing crate's `input_schema` never contains the key, and
dispatch rejects a marked pub op whose input **lacks** it — the
initiator-allocated id is mandatory, not optional plumbing
(`INVALID_INPUT`, message naming the key). An op input carrying
`channel_id` on a NON-pub or unmarked op is not stripped (their
schemas own their full input surface; the strip and the requirement are
both scoped to marked pub ops).
### 4.4 The wrapper as responder of record (pin 4)
The marked-pub open registration is
`ChannelCore::register_pub_openable(spec, establisher, pub_open_handler,
registry, auth, timeout)`. The entry points refuse wrong shapes at
registration: the `Query`/`Mutation`/`Sub` wrapper entry points
(`register_openable*`) refuse `Pub`-typed specs, and
`register_pub_openable` refuses non-`Pub` or unmarked specs. The
handler type is a separate one from `OpenHandler`:
```rust
pub type PubOpenHandler = Arc<
dyn Fn(Value, Option<ChannelPlan>, Connection, AuthContext)
-> BoxFuture<'static, Result<Value, CallError>>
+ Send + Sync,
>;
```
(`JoinHandle<()>` — the `OpenHandler` return — cannot carry the
handler result the merged response needs; see §4.6 for why this is a
separate type rather than an `OpenHandler` change.)
The wrapper flow (the responder of record):
1. `policy.check_open(identity)` — unchanged (cap gate, pre-adopt).
2. **Adopt** the initiator-allocated id:
`manager.adopt_channel(reserved_id, alpn, None)` — the existing
demux/mux install + early-arrival drain; no new machinery. Adoption
failure (collision or cap) tears nothing down (nothing was created)
and replies `channel:open_failed` with a new reason **`adopt_failed`**
in the ADR-049 §3 vocabulary.
3. The establisher (bounded, ADR-049 §1/§2 machinery, unchanged) may
validate the offer and contribute ack fields
(`Establishment.reply_fields`); its plan threads to the handler.
4. Spawn the pub handler on the channel's `Connection` — the wrapper
**awaits the handler inline** (review 011 §1.3), which is what makes
R-02's lifetime contract structural here: the handler cannot return
before its pumps finish, so the teardown-at-birth trap does not
exist on this path, and the channel's `ChannelState.handler_task`
stays `None` (teardown is EOF-driven: a `channel/close` during the
put drops the demux sender, the handler's reads see EOF, the handler
finishes early, and the single response is still written — the
handler decides the semantics of a truncated stream).
5. **One response, merge order pinned:** `{ "channel_id": <adopted> }`
→ extended with the establisher's ack fields (ADR-049 amendment 3,
unchanged, including the reserved-key establisher-bug refusal) →
extended with the handler's result fields (handler result wins on
key collision — its computed result postdates the ack). A handler
returning `Err` responds that error directly and the channel tears
down; racing initiator bytes hit EOF and are discarded.
6. The standard completion teardown runs after the await (demux
sender drop, ledger take, `policy.on_close` — the same atomic gate
every teardown path runs, ADR-047 §7).
A marked pub op registered outside the wrapper family (a plain
`SinkHandler` on a marked pub spec) can never adopt — the bytes would
park and drop. That class is refused at registration (§4.8).
### 4.5 The ack-timing sentence (pin 5 — protocol semantics)
**A sink op gets exactly one `call.responded`, produced after the
handler completes — so a putter stream-waits nothing: the
establishment ack (the adopt + establishment reply fields) rides
informatively in that one result, never as a gating ack before the
byte stream.** The fetch/put asymmetry is direction-inherent: fetch
(`Sub`) acks at establishment — the `{channel_id}` response precedes
any byte (and the `{channel_id}` is the ack); put (`Pub`) acks in the
result. This is protocol semantics, not per-consumer folklore: a
consumer protocol layered on marked pub ops (alkblobs' put) cannot
wait for the ack without deadlocking (the response cannot exist until
the stream completes), and the amendment states it so no consumer
re-derives it.
### 4.6 Why `PubOpenHandler` is a separate type (pin record)
The alternative — changing `OpenHandler`'s return type — breaks every
published consumer's open handlers (alktty, alkhttp, alktunnels,
alkty, alksocks all register `Sub`-type openables) for zero benefit:
the `()`-returning shape is correct for the spawn-and-reconcile `Sub`
path (the channel outlives the open call; the wrapper never awaits
it), while the put path needs completion-in-response. The split keeps
S-01 capability-additive (review 011 §5: no shipped consumer registers
a `Pub`-typed openable); the put path's type is new API surface,
one-way like its siblings (ADR-046's handler-type door).
### 4.7 Relay translation replaces `PubTypedOpen` (pin 6)
`RelayError::PubTypedOpen` is removed. The hub's pub-openable
translation (the put-forwarding shape):
- The consumer-leg registration composes
`ChannelCore::register_pub_openable` with a relay establisher: the
hub **allocates** its connect-side id on the producer leg
(`open_channel` — odd, the implemented split), sends the open op
carrying the reserved key to the spoke **in the background** (never
awaited inside the establisher — the spoke's put response cannot
exist before its byte stream completes; awaiting it in the
establisher deadlocks the leg), and the `RelayPlan` carries the
spoke-leg send half, the early-arrival-drained read half, the
producer-leg entry reclaim guard (as before, ADR-051), and the
response receiver.
- The relay pub handler (the consumer-leg pub handler): pumps the
consumer-leg channel bytes → spoke send half (one direction, pub);
on consumer EOF, EOFs the spoke side; awaits the spoke's single
response; strips the producer's reserved reply key from the spoke
output (the hub's reply must carry the hub-allocated consumer-leg
id — a spoke `channel_id` merged through would shadow it, the same
reserved-key class the wrapper refuses) and returns the remainder as
the handler result — which the consumer-leg wrapper merges into the
single response with the pinned order.
- The spoke's open-time establishment failure surfaces in the final
response (put-acks-in-result), mapped through the existing
`map_spoke_error` table; the plan's reclaim guard tears down the
producer-leg entry on every handler path, as ADR-051 §6 already
pins for the sub direction.
- The hub-leg template's pub-typed assembly error becomes unreachable
(registered through the relay like the sub ones).
### 4.8 The registration invariants (the stub-class guard)
`OperationRegistry::register` enforces, at registration time:
- A marked spec with `publish_schema` → refused (§4.2).
- A marked spec registered through the generic path → refused: marked
specs are registered only through the channel-machinery seam
(`register_openable*`, `register_pub_openable`, the relay). The
wrapper-family registrations carry an internal registration flag the
generic paths cannot set; `CallConnection::register_imported` (the
overlay landing) and direct `registry.register` both hit the refusal
— for overlay imports because a marked op is unreachable by
composition (`OperationEnv::invoke` is request/response-only; the
marker's data plane has no overlay path), for direct registrations
because the channel machinery would silently not exist.
### 4.9 The client-side initiator-allocates capability (pin 7)
`ChannelClient::open_pub_channel(operation_id, input, alpn)`: allocates
the connect-side odd id locally (`open_channel` on its own manager),
inserts the reserved input key, sends the open op **in a background
task**, and returns `(id, send, recv, reply)` immediately — the caller
writes bytes on `send` without waiting for anything (put acks in
result, §4.5). `reply` is the awaited single merged response
(`Ok(Value)` — the full output; `Err(ChannelOpenError)` — the wire
error passthrough, with the local adopt entry torn down on failure).
The existing `open_channel`/`open_channel_with_reply` await the reply
before returning the halves — unusable for a put; this is the
putter-facing shape the wrapper is untestable end-to-end without.
### Door type
**One-way (wire-visible).** The reserved input key and its
strip-before-validation, the missing-key rejection, the
chunk-rejection code, the merge order, the single-response semantics,
and the ack-timing sentence are wire-visible from the first consumer
(alkblobs' put — which pends behind this amendment, so this is the
cheapest moment to pin; no shipped consumer rides any of it, review
011 §5). `PubOpenHandler`, `register_pub_openable`,
`open_pub_channel`, and the `adopt_failed` reason are new API surface,
one-way per the handler-API precedent. The early-arrival parking, the
wrapper internals, and the relay plan internals are two-way-door
details inside the one-way contract.
### Verification gates (implementation units, review 011 §3)
- known-length put e2e: offer + adopt + bytes → one response,
`{channel_id, ack fields, handler result}` merge order verified;
- unknown-length put e2e: the handler's digest rides the merged
response;
- publish-chunk rejection: a marked pub op carrying nonempty publish
chunks → loud rejection, no handler consumption;
- establishment failure: `channel:open_failed`, channel torn down,
ledger un-incremented, racing bytes EOF-discarded;
- `publish_schema` registration refusal on a marked op;
- relay translation: a hub translating a pub openable end-to-end.
- plus: missing reserved key rejection; `open_pub_channel` happy +
wire-failure paths (the client capability's gates).
### References
- Review 010 S-01 (the four stubs; the implement-or-stop-advertising
ruling), review 011 (the corrected shape this amendment pins)
- alkblobs ADR-014 (`014-marked-pub-put-contract-and-alkcall-gate`) —
the downstream ruling this amendment unblocks
- ADR-046 (the unmarked pub semantics that marked ops do NOT ride),
ADR-049 (establishment machinery + `RESERVED_REPLY_KEY` + merge),
ADR-040 (bounded buffer — the data-plane backpressure), ADR-050
(pumps), ADR-051 (the relay this amendment extends to pub), ADR-043
(`ChannelClient` — the `open_pub_channel` home)
- `src/channels/manager.rs` (`next_channel_id` odd/even;
`drain_early_arrivals`), `src/channels/operations.rs` (the wrapper
family), `src/channels/relay.rs`, `src/registry/registration.rs`
(`resolve_sink_handler` — the strip site)
## Amendment 3 (flavor-form open-op ids + explicit `channel_open_alpn`, 2026-09-16 — review 008 U-1)
@@ -108,7 +366,9 @@ half load-bearing for the first time and asked for a decision on the
static half. Decision: **both halves of the "static per-op" split gain
what is cheap today; the dynamic half stays deferred** (OQ-40, now
with a "load-bearing for alktunnels discovery UI" note in
`open-questions.md`; alktunnels v1 uses config-known op names):
`open-questions.md`; alktunnels v1 uses config-known op names).
*[Amendment 4 era, 2026-10-13: the dynamic half is now implemented —
ADR-053; this 2026-09-06 text records the state at its writing.]*
- `OperationSpec` gains `description: Option<String>` — a human-
readable op description, set via `with_description` at registration.
@@ -82,9 +82,12 @@ Consumer-side close → wrapper teardown → pump abort → send-half drop →
EOF chunk → spoke handler exits → spoke's own wrapper decrements its
ledger. Close propagates through the EOF cascade with correct ledger
accounting on both legs; the hub never sees a `channel/close` to
translate. Control stays per-leg (`channel/control` is not implemented
upstream, OQ-39 — a hub wanting cross-leg control translation composes
it downstream). This supersedes the ADR-042 §mapping bullet's
translate. Control stays per-leg (ADR-052): the control surface is
establisher-contributed (`Establishment::with_control`) on each leg's
own allocation — the hub never relays a `channel/control` between legs;
a hub wanting cross-leg control composes it with the per-leg control
handles, not with a translation surface. This supersedes the ADR-042
§mapping bullet's
close-translation sentence as an implementation matter: the mapping
entry the sentence's removal depended on does not exist.
@@ -205,11 +208,13 @@ same pin.
### 6. Bounds: rejected shapes and residual notes
- **Pub-typed open ops** — rejected loudly (`channel:pub_open_not_implemented`,
the C-08 blocker). The relay registers only `Query`/`Mutation`-typed
ops (plain forwarding) and `Sub`-typed marked ops (relay openables);
a discovered `Pub`-typed marked spec is an assembly error, not a
silent stub.
- **Pub-typed open ops** — ~~rejected loudly~~ **translated** (ADR-047
amendment 4 §4.7 — the put-forwarding translation: the hub allocates
its connect-side id, opens the spoke op in the background,
pumps the consumer bytes → spoke, and mirrors the spoke's single
response with its reserved key stripped; `PubTypedOpen` is removed).
The rejection remains for the genuinely-unsupported shapes: unmarked
specs (`UnmarkedSpec`) only.
- **Compounding establishment bounds** — each hop's establisher is
bounded independently (ADR-049 §2); an N-hop chain adds N bounds.
The consumer's dispatch deadline (`min(deadline, 10s)` per hop)
@@ -263,7 +268,6 @@ same pin.
`ChannelRelay` instead of re-deriving the hub shape.
- U-1's gate 2 (the review's silent-failure scenario — flavor-form ops
relaying as plain forwarding stubs) becomes a permanent e2e gate.
**Negative:**
- New pub API (`ChannelRelay`, the registration entry point, the
@@ -0,0 +1,149 @@
# ADR-052: The `channel/control` Control-Handle Surface
## Status
Accepted — amends ADR-037 (`channel/control`'s contract) and ADR-047
(amended §1: the generic ops stay). Resolves review 010 S-02 (the
`channel/control` stub shipped nine published versions; the stub was
pinned by tests and tracked as OQ-39) by implementing the surface the
ADR-037 text always promised. The stub's tracked design work (OQ-39)
is promoted here verbatim-ish: ALPN-crate-registered per-channel
control callbacks, a channels-layer callback registry keyed by
`channel_id`.
## Context
ADR-037 specified `channel/control` as the out-of-band control op on
channel 0 (resize, signal, keepalive — anything that does not need
ordering relative to data), with the channels layer routing the
ALPN-specific `message` to a handler's control handle. The
implementation shipped a registered, discoverable stub that checked
`channel` existence and then returned `channel:control_not_implemented`
on every invocation — which perversely returned *not found* for unknown
channels and *not implemented* for real ones (review 010 S-02). The
implementation was tracked as OQ-39 with the shape recorded; nothing
blocked it except that no ALPN crate had asked yet. alktty's move of
its control surface to the channels path (ADR-049 §5's migration list)
and the general stub ruling (review 010) make that answer invalid.
The design constraint from ADR-039: `ChannelManager` is auth-blind,
ALPN-blind, transport-blind. The control-handle surface must not
reintroduce ALPN interpretation into the manager.
## Decision
### 1. The control callback type and registry
```rust
pub type ChannelControl = Arc<
dyn Fn(Value) -> BoxFuture<'static, Result<Value, CallError>> + Send + Sync,
>;
```
The callback receives the `message` value (ALPN-specific JSON; the
channels layer does not interpret it) and returns the ALPN-specific
reply (or a `CallError` it wants surfaced verbatim — e.g.
`target_gone`). The live handle: `HashMap<u32, ChannelControl>` in the
`ChannelManager`'s internals (the same map discipline as `channels`;
`parking_lot` mutex).
The manager stays blind to what a callback does — it stores opaque
closures, exactly as `ChannelState` already stores opaque `handler_task`
handles.
### 2. Registration: the establisher contributes the control handle
The ALPN crate registers a control callback by returning it from its
open-op establisher: `Establishment::with_control(callback)` (builder,
alongside `with_reply_field` — ADR-049 amendment 3). The **wrapper**
installs it under the allocated/adopted id
(`manager.set_control_handle(id, callback)`) and every teardown path
removes it (`teardown_channel` drops the entry; `clear_all` drains
them) — same closure-lifecycle discipline as the opener ledger.
Why the establisher, not the handler: the establisher already returns
typed payloads the wrapper routes (`Establishment`), it runs where the
ALPN crate has prepared its backend (the resize target is the backend
the establisher dialled/planned), and adding a registration path
through the `OpenHandler` would require handing the handler the
manager and the id — a signature break across every published consumer
for zero benefit. The wrapper-owned installation also makes the
callback's lifetime exactly the channel's lifetime by construction.
### 3. The op: routing and error surface
`make_control_handler` (the ADR-037 op, registered by
`ChannelOps::register_on`) becomes: validate `channel_id` (unchanged)
→ `channel` exists? (unchanged; `channel:unknown_channel`) → callback
registered for the id? →
| case | reply |
|------|-------|
| unknown channel | `channel:unknown_channel` (existing class) |
| no callback registered | `channel:no_control_handle` — typed, retryable false, message: the channel's ALPN registered no control surface (`details: { alpn }`) |
| callback ok | `{ "control": <callback reply> }` |
| callback err | the callback's `CallError` verbatim |
`channel:no_control_handle` is a real capability answer, not a stub:
a consumer that calls `channel/control` on a channel whose ALPN chose
not to expose control learns exactly that. The `message` body is
ALPN-specific JSON (unchanged; the channels layer does not read it).
**The callback receives the message only** — no identity, no
`OperationContext`. The authorization home is the op's own
`access_control` (checked by the registry before the handler, per
ADR-010/017 — the same op-level ACL every other surface runs); a
per-owner resize is expressed as ADR-011 `resource_id_path` on the
`channel/control` spec for ALPNs that want it, not by widening the
callback signature. Wire-visibility: `channel:no_control_handle`
joins the stable error set (ADR-016 — consumers branch on it
deliberately); the callback's reply shape is the producing crate's
schema concern.
### 4. What this does not decide
Ordering-sensitive control rides the data channel (unchanged —
channel-operations.md's control-message division, DP-4 pinned). The
manager keeps its blindness; the enumerators registry of ADR-053 is
the analogous ALPN-level surface for discovery, not control.
## Consequences
**Positive:** the advertised op serves; S-02 dies; ALPN crates get
out-of-band control for free (resize/signal arrive as one routed
call-op instead of per-crate in-band framing); teardown-bound callback
lifetime is structural.
**Negative:** the manager's internals gain one map (opaque closures —
no blindness regression); a control callback racing teardown can
observe a torn-down backend through its own error (`CallError`) — the
same shape the handler EOF path already produces, not a new class.
## Door type
**One-way (wire-visible).** `channel:no_control_handle` and the reply
envelope shape join the stable surface once consumers branch on them.
The callback type, the establisher carrier, and the map are two-way-door
implementation details inside it (the `Establishment` carrier is
`#[non_exhaustive]` — ADR-049 amendment 2's posture).
## Verification gates
- `channel/control` routes to the establisher-contributed callback and
the reply is the callback's output (e2e over a real channels
connection);
- `channel:no_control_handle` on a channel whose ALPN registered none;
- `channel:unknown_channel` preserved (the pre-existing error);
- teardown removes the handle (post-teardown control answers
`channel:unknown_channel`, never a stale callback);
- callback `Err` passes through verbatim.
## References
- Review 010 S-02 (the stub; the implement ruling), review 011 §4
(downstream-safe; implement preferred)
- OQ-39 (the promoted design note this ADR resolves)
- ADR-037 (the op's contract), ADR-047 (openable ALPNs; the wrapper
this registration rides), ADR-049 (the establisher carrier this
registers through), ADR-039 (manager blindness), ADR-011
(`resource_id_path` — the per-owner control-gate lever)
@@ -0,0 +1,153 @@
# ADR-053: `channel/resources/subscribe` — Live Resource Enumerators
## Status
Accepted — implements ADR-037 §6's dynamic half and ADR-047 §6's
discovery split ("what is currently there"). Resolves review 010 S-03
(the `channel/resources/subscribe` stub: a fake one-element error
stream shipped nine published versions, pinned by tests, tracked as
OQ-40) by implementing the aggregated live subscription. OQ-40's
promotion: each ALPN crate provides a resource enumerator; the
channels layer aggregates them into a live `Stream`.
## Context
The static half is covered (ADR-047's 2026-09-06 amendment:
`OperationSpec.description` on `services/list` describes the op — not
the produced resource set). The dynamic half — "which tunnel resources
may I open, live" — was deferred (OQ-40) and then shipped as a stub; a
consumer discovering the op got a capability that does not exist
(review 010 S-03). The load-bearing consumer (alktunnels' discovery UI,
review 006 E-02) uses config-known op names today, but the stub ruling
makes deferral unavailable as a posture: registered means implementable.
ADR-037 §6 + ADR-047 §6 pinned the shape: each ALPN crate that
registers open ops provides a resource enumerator (which containers
are running; which TTY sessions are active); `channel/resources/subscribe`
aggregates across registered openable ALPNs; the data source lives in
the ALPN crate. What was never pinned: the registration seam, the
enumerator contract, and the aggregation semantics. This ADR pins them.
## Decision
### 1. The enumerator contract
```rust
pub type ResourceStream = Pin<Box<dyn Stream<Item = Value> + Send>>;
pub trait ResourceEnumerator: Send + Sync + 'static {
/// Snapshot-then-changes: the first item is the ALPN's complete
/// current resource payload; each subsequent item replaces it
/// (a full snapshot, not a delta). The channels layer does not
/// interpret the payload.
fn subscribe(&self) -> ResourceStream;
}
```
Each item is the **ALPN's full current resource payload** — an object
merged with `"alpn"` into the wire entry (`{ "resources": [{ "alpn":
"alk/tty", ...payload }] }`; a non-object payload wraps as
`{ "alpn": …, "data": … }`). The snapshot-then-changes contract lives
in the producing crate (a `watch` channel or similar is its own
machinery) — the channels layer never polls an enumerator mid-life.
### 2. Registration seam
```rust
pub struct ResourceEnumerators; // channels-call; RwLock<HashMap<String /*alpn*/, Arc<dyn ResourceEnumerator>>>
impl ResourceEnumerators {
pub fn new() -> Self;
pub fn register(&self, alpn: &str, enumerator: Arc<dyn ResourceEnumerator>);
pub fn alpns(&self) -> Vec<String>;
}
```
Registered by the composing crate at assembly/install time (the same
seam that composes the policy and the openables — the composer has the
backend handles). The registry is shared per accepting peer (like
`PerIdentityChannelPolicy`, ADR-041): one `Arc<ResourceEnumerators>`
across every channels connection that peer accepts.
**Scan-once:** the merged stream's enumerator set is scanned when the
subscription starts; ALPNs registered later are invisible to in-flight
subscriptions (re-subscribe to see them). Registrations happen at
install time, before any subscription — a late-registration notify
machinery is machinery nobody needs, and this pin prevents a future
half-design of it.
### 3. The op: aggregation semantics
`ChannelOperations::with_resource_enumerators(manager, policy,
enumerators)` threads the registry to the aggregate handler; the plain
`ChannelOperations::new(manager, policy)` constructor keeps an empty
registry. `make_resources_subscribe_handler` (the `Sub` op — ADR-021
machinery, unchanged) becomes a real subscription:
- **Non-empty registry:** merge every registered enumerator's stream
(`futures::stream::select_all`), each item mapped to a one-entry
`{ "resources": [{ "alpn": <alpn>, ...payload }] }` envelope. The
first events across the ALPNs are the initial snapshot (each
enumerator's first item); subsequent events are the live updates.
- **Empty registry:** one `{ "resources": [] }` envelope, then the
stream stays open (the poll use case — subscribe, read the first
event, cancel — gets its snapshot; per the scan-once pin, nothing
further arrives until re-subscribe).
- **Zero enumerators ever registered but the op discoverable** is
impossible to distinguish from "no resources right now" — by design:
the static half answers "which ALPNs accept open ops"
(`services/list`), the dynamic half answers "what is currently
there"; a peer registering openables without enumerators reports an
empty live resource set, which is the truthful answer for "the
channels layer does not interpret your resources" (ADR-035 posture).
The `access` preview stays dropped (ADR-047 §6 — the spec is the
authority). The documented wire shape (channel-operations.md) is
unchanged — this is the implementation of the documented op, not a new
wire surface.
## Consequences
**Positive:** the advertised op serves (S-03 dies); ALPN crates carry
their resource truth themselves (the channels layer stays ALPN-blind —
it maps and merges opaque values); the hub/dashboard consumer gets the
live subscription ADR-037 promised.
**Negative:** `ChannelOperations` gains a second constructor (additive
— `new` keeps the empty default); an ALPN crate that never registers
an enumerator serves an always-empty resource set — truthful, and
`services/list` still discloses the ops (the static half is not
encumbered by the dynamic one).
## Door type
**Two-way (implementation shape) inside the documented wire surface.**
The wire shape (`{ "resources": [...] }`, per-ALPN entries, no
`access` preview) is ADR-037/047's pre-existing one-way surface, now
actually served. The enumerator trait, the scan-once rule, the empty
seed, and the registration seam are revisable implementation details —
no consumer observes them beyond the stream of envelopes.
## Verification gates
- initial snapshot: N registered enumerators → N first events carrying
each ALPN's payload merged with `"alpn"`;
- live update: an enumerator's second item arrives as the next envelope
without re-subscription;
- empty registry: exactly one `{ "resources": [] }` envelope, stream
stays open, subscriber cancel works (abort cascade, ADR-020);
- late registration invisibility: a post-subscription registration does
not appear in an in-flight subscription (the scan-once pin);
- non-object payload wrapping.
## References
- Review 010 S-03 (the stub; the implement ruling), review 011 §4
(downstream-safe; implement preferred)
- OQ-40 (the promoted design note this ADR resolves; the "load-bearing
for alktunnels' discovery UI" note)
- ADR-037 §6 (the dynamic half's contract), ADR-047 §6 (the
discovery split; the 2026-09-06 amendment's static half)
- ADR-021 (the `StreamingHandler` machinery this op rides), ADR-039
(manager blindness — the enumerators' channels-call home), ADR-041
(the shared-per-peer construction pattern the registry follows)
@@ -0,0 +1,125 @@
# ADR-054: Outgoing-Only X.509 and the Three Remote Roles (Re-Owned In-Crate)
## Status
Accepted (re-owns the verifier-selection rule for alkcall's surface;
provenance: alknet ADR-034, not ported)
## Context
alkcall's connection surface depends on a verifier-selection rule and a
role taxonomy that were decided while this crate was a subdirectory of
the alknet mono-repo. The deciding ADR (alknet ADR-034, "Outgoing-Only
X.509 and Three Peer Roles") was never ported, so the rule's authority
lives in a cross-repo link — a dependency on a document outside this
crate for a constraint that binds this crate's public surface:
- `ConnectionCredentials.remote_identity: None` semantics (ADR-022 §7,
(ADR-022 §7; alknet ADR-034 §2/§3, provenance): `None` is a load-bearing state — the
public-X.509-endpoint path, not a missing field.
- Client-side `ServerCertVerifier` selection is by `PeerEntry`
presence, not by key type alone (OQ-14 #2).
- `PeerEntry` asymmetry: a pure-client connection to a public X.509
endpoint is not in the call-protocol peer graph on the client side
(no `PeerEntry`, no `PeerId`, no `PeerRef::Specific` routing).
The alknet mono-repo is being rewritten as a thin glue layer over this
crate and its siblings; cross-repo ADR links will not survive that
rewrite as authority. The rule must be owned here.
## Decision
alkcall re-owns the rule in-crate. Three things are adopted:
### 1. Three remote roles
The single label "X.509 endpoint" conflated three structurally
different remote roles:
- **Public X.509 endpoint** — a remote server reachable by domain
name, authenticated by a CA-issued X.509 cert. The local node is a
*client* of it (a third-party API, a public hub dialed over the
open internet, a peer that exposes domain + X.509 instead of an
Ed25519 raw key). The client authenticates to the server by bearer
token (`auth_token` payload field when riding the call protocol);
the server authenticates to the client by CA verification
(WebPKI), not fingerprint pinning.
- **Transport relay** — a connectivity-assistance node
(iroh's DERP-equivalent) that forwards encrypted datagrams between
peers who cannot directly connect. It is infrastructure, not an
application peer: no registered operations, no peer graph
participation, no `PeerEntry`. Its identity is the transport's
concern, not this crate's.
- **Hub / hosting node** — an application peer acting as a hub in a
hub-and-spoke topology. An ordinary `PeerEntry` holding mixed
fingerprints (Ed25519 raw key and/or X.509) — fingerprint pinning
applies on both cert paths.
### 2. Verifier selection is by `PeerEntry` presence, not key type
The client-side `ServerCertVerifier` is selected by whether the local
node holds a `PeerEntry` for the remote:
| Local `PeerEntry` for remote? | Remote cert | Verifier | Peer graph? |
|-------------------------------|-------------|----------|-------------|
| yes | Ed25519 or X.509 | fingerprint pin | in (`PeerId` resolved from client-cert fingerprint — ADR-025 §5) |
| no | X.509 | `WebPkiServerVerifier` (CA) | **not in** (no `PeerId`) |
| no | Ed25519 raw key | fail closed (no CA to fall back to) | not in |
### 3. `remote_identity: None` is load-bearing
`Some(fingerprint)` means "pin this" (known peer). `None` means "no
`PeerEntry` for this remote" — CA verification for X.509 remotes,
hard failure for Ed25519 raw-key remotes. An implementation must not
default `remote_identity` to a placeholder to satisfy the field, and
must not treat `None` as "skip verification."
### 4. Scope of the re-own
This ADR is the deciding authority for alkcall's surface
(`ConnectionCredentials`, `RemoteIdentity`, the `ServerCertVerifier`
selection the dial-composer implements, `PeerEntry` asymmetry in the
peer graph). It does not re-own alknet ADR-034's other content (ACME
issuance, cert rotation, the alktls config surface) — those remain
properties of the transport/composition layer (see OQ-43, composition
layer). alknet ADR-034 is demoted to provenance: historical origin of
this rule, cited as such.
## Consequences
Positive:
- The constraint binding alkcall's public surface is decided in this
crate's decision log; cross-repo link rot cannot strip the authority.
- OQ-21's "deciding ADR (alknet ADR-034, not ported)" caveat is
resolved — the surviving surfaces (`client-and-adapters.md`
§credentials, ADR-025's `PeerEntry` asymmetry) now cite an in-crate
ADR.
- The alknet rewrite can proceed without a documentation dependency
from this crate on the old repo's ADR numbering.
Negative:
- One more rule with a historical origin story in a different numbering
space; the provenance note (README §References) is the single place
that maps old to new.
## References
- [client-and-adapters.md](../client-and-adapters.md) — the working
spec carrying these semantics (§Credential sources for connections,
§Constraints "Pure-client X.509 connections", `remote_identity`
load-bearing rule)
- [ADR-022](022-call-protocol-client-and-adapter-contract.md) §7 —
transport-identity dimensions on the call protocol
- [ADR-025](025-peerentry-and-identity-id-decoupling.md) —
`PeerEntry` asymmetry, `PeerId` source, client-cert fingerprint →
`peer_id` resolution
- [ADR-012](012-connectioncredentials-decouple-dial-from-call.md) —
`ConnectionCredentials` shape (the dial consumes it; verifier
selection is applied there)
- OQ-21 (resolved by this ADR), OQ-43 (composition-layer ownership —
the transport/composition half mentioned in §4's scope carve-out)
- Provenance: alknet ADR-034, "Outgoing-Only X.509 and Three Peer
Roles" (`/workspace/@alkdev/alknet/docs/architecture/decisions/`),
retained as history, not authority
+47 -18
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-08-12
status: current
last_updated: 2026-10-13 (composition-layer re-key)
---
# Open Questions
@@ -35,13 +35,14 @@ status, priority, and (when resolved) a resolution citing the ADR.
| OQ-18 | PeerId — crypto identity vs stable logical id | resolved | high | ADR-025 — PeerId = Identity.id (stable) |
| OQ-19 | Persistent peer registry | resolved | medium | ADR-025 — core trait + in-memory default; persistence adapters separate |
| OQ-20 | ~~API key asymmetry~~ | dissolved | medium | PeerEntry supports multiple credential paths |
| OQ-21 | X.509 outgoing-only case | resolved | medium | Three remote roles; PeerEntry asymmetry correct |
| OQ-21 | X.509 outgoing-only case | resolved | medium | Three remote roles; PeerEntry asymmetry correct. Re-owned in-crate by ADR-054 — the verifier-selection rule that binds alkcall's surface (`RemoteIdentity: None` semantics, `ServerCertVerifier` by `PeerEntry` presence) is decided here, not by the un-ported source ADR (which is demoted to provenance). |
## Call Protocol — Pub/Sub
| OQ | Title | Status | Priority | Resolution |
|----|-------|--------|----------|------------|
| OQ-22 | Call protocol pub/sub primitive — pub to go with sub | partially resolved | high | ADR-046 resolves the primitive: `OperationType::Pub` + `HandlerKind::Sink` + `call.published` wire event + `invoke_sink()` dispatch path. The fan-out/broker mechanism (one producer, N consumers, topic matching) is deferred to the channels session — the call protocol is point-to-point; the broker is a routing concern that sits above it. ADR-047 §1 names the broker (Gap B) as out-of-scope for alkcall; the hub composes it on top of the `Pub`/`Sub` primitives. See §"Pub/Sub Gap" below. |
| OQ-22 | Call protocol pub/sub primitive — pub to go with sub | resolved | high | The question had two halves, both now decided. (1) The primitive: ADR-046 delivers `OperationType::Pub` + `HandlerKind::Sink` + `call.published` wire event + `invoke_sink()` dispatch + `CallConnection::publish()`. (2) Placement of the fan-out/broker: **out of scope for alkcall** — the call protocol is point-to-point; a topic registry that outlives individual calls is a routing concern that sits above it. The hub composes the broker on top of the `Pub`/`Sub` primitives (ADR-047 §"What this ADR decides" Gap B). The broker design itself is tracked as OQ-42. See §"Pub/Sub Gap" below. |
| OQ-42 | Hub message broker (topic registry, `Pub`↔`Sub` matching, N-consumer fan-out) | deferred(scope) | medium | Deferred downstream: the broker is a hub composition layering on alkcall's `Pub`/`Sub` primitives, not an alkcall deliverable (OQ-22 placement resolution, ADR-047 Gap B). **Blocked on**: a concrete producing crate committing to fan-out — a spec that requires one produced topic with N independent spoke subscribers (plausible first candidate: alktrader market-data distribution; ADR-046 §fan-out records the consumer-blind rationale). Not circular — the primitives it composes on are delivered and load-bearing (ADR-046), and no alkcall API surface advertises or depends on the broker. The hub itself lives in the composition layer (owner — OQ-43). |
### Pub/Sub Gap
@@ -59,32 +60,44 @@ type. `call.published` is the wire event carrying stream chunks.
the client method. The `Subscription` variant is renamed to `Sub`
(wire string `"sub"`).
**Fan-out deferred.** The broker (topic registry, `Pub`↔`Sub` matching,
N-consumer fan-out) is deferred to the channels session. The call
protocol is point-to-point (one initiator, one responder, a stream
between them); a topic registry that outlives individual calls is a
different lifecycle and a different concern. The broker's first
consumer is the channels `channel/resources/subscribe` operation
(ADR-037) and the hub-as-broker pattern (ADR-042). The `Pub` primitive
is the load-bearing piece the broker composes on.
**Placement decided: the broker is not an alkcall deliverable.** The
fan-out/broker mechanism (topic registry, `Pub`↔`Sub` matching,
N-consumer fan-out) sits above the call protocol: the call protocol is
point-to-point (one initiator, one responder, a stream between them);
a topic registry that outlives individual calls is a different
lifecycle and a different concern. The broker is a hub composition on
top of the `Pub`/`Sub` primitives — ADR-047 §"What this ADR decides"
(Gap B) places it out of scope for alkcall. The `Pub` primitive is the
load-bearing piece the broker composes on (delivered, ADR-046); ADR-053
served the broker's originally-named first consumer
(`channel/resources/subscribe`) with enumerators instead.
**The broker design is downstream work (OQ-42, deferred(scope)).** It
unblocks when a concrete producing crate commits to fan-out. The
original deferral's rationale (ADR-046 §fan-out — designing the broker
without its first real consumer produces a half-design) still holds:
the honest state is a tracked, concretely-blocked deferral, not a
pending promise. The "channels session" phrasing in ADR-046 §fan-out
should be read as this placement decision (the channels work has since
landed; what remains unbuilt is the downstream hub broker).
## Channels
| OQ | Title | Status | Priority | Resolution |
|----|-------|--------|----------|------------|
| OQ-23 | Full channel-level flow-control windowing | deferred(scope) | low | Bounded-buffer decided (ADR-040); full windowing blocked on HOL-blocking deployment observation |
| OQ-24 | Channels add/strip API shape | open | low | Whether the 8-byte header add/strip is built into the read/write path or a standalone utility. The contract (ADR-035) is decided; the function surface is not |
| OQ-24 | Channels add/strip API shape | resolved | low | Resolved by the shipped surface: the strip is **integrated into the read/write path** — the demux loop consumes `read_header`/`write_chunk` so a handler's `BiStream` yields payload chunks and never sees the 8-byte header (ADR-035; matches ADR-037's payload-parse contract). The low-level primitives (`parse_header`/`write_header`/`read_header`/`write_chunk`/`write_eof` in `channels::wire`) remain exposed as the standalone utility for tooling/codegen, but no handler-facing add/strip wrapper exists or is needed. |
| OQ-31 | `channel/open` ACL granularity | resolved | high | ADR-047 — `channel/open` dissolves into per-ALPN ops; each op has its own `access_control` |
| OQ-32 | Quota lifecycle (opener vs closer, transport drop) | resolved | high | ADR-047 §7 — the per-connection opener ledger; decrement on every teardown path, keyed by opener |
| OQ-33 | Per-identity connection cap (endpoint layer) | deferred(scope) | low | Named as a separate layer (ADR-047 §"ALPN category reframe" references the findings); belongs at `alknet-endpoint`, not channels. Named to stop the re-tangle |
| OQ-33 | Per-identity connection cap (endpoint layer) | deferred(scope) | low | Named as a separate layer (ADR-047 §"ALPN category reframe" references the findings); belongs at the endpoint/connect-accept layer of the composition stack, not channels. Named to stop the re-tangle. The endpoint layer's home is the composition layer (owner — OQ-43; today it lives in the alknet glue). |
| OQ-34 | `channel_open` marker wire format | resolved | medium | ADR-047 §2 — boolean `"channel_open": true` in `services/schema`; ALPN derivable from op name |
| OQ-35 | `OperationEnv::channel_manager()` coupling | resolved | high | ADR-047 §4 — extension trait `ChannelOperationEnv` in `channels-call`; call crate stays free of channels types |
| OQ-36 | `channel_id` allocation in Pub case | resolved | medium | ADR-047 §5 — "connection owner allocates" (the side that holds the `ChannelManager`); amends "responder allocates" |
| OQ-37 | `from_call` relay wrapper for marked ops | open | medium | ADR-047 §1 names it as a consumer (hub) concern; alkcall's `from_call` reconstructs the marker (Gap F resolved) so the consumer can branch on it |
| OQ-37 | `from_call` relay wrapper for marked ops | resolved | medium | ADR-022 amendment 2026-10-13 — there is no `from_call` relay wrapper, and there won't be one: marked (`channel_open`) announcements are refused with `MARKED_SPEC_UNSUPPORTED`, and `register_imported`/`register_imported_all` refuse marked specs (ADR-047 amendment 4 §4.8) — a marked op's data plane (the allocated channel's `BiStream`) has no overlay path. The sanctioned compositions are named in the refusal: `ChannelRelay::register_relay_openable` (ADR-051) for a relaying hub, `ChannelClient` (ADR-043) for direct consumption. `from_call`'s discovery still reconstructs the marker (ADR-047 Gap F) for reporting, but importing a marked spec is refused at the overlay landing. |
| OQ-38 | ALPN→path-segment mapping | resolved | low | ADR-047 §"Negative" — strip the `alk/` prefix; ALPNs without that prefix use the full ALPN string (rare, two-way-door) |
| OQ-39 | `channel/control` control-handle surface | open | medium | The `channel/control` handler currently returns `channel:control_not_implemented`. The control-handle surface (per-channel control callbacks registered by ALPN crates, routing `message` to the handler's control handle for `channel_id`) is real design work — each ALPN crate needs a way to register a control callback, and the channels layer needs a control-handle registry keyed by `channel_id`. Deferred until an ALPN crate (TTY, tunnel) needs out-of-band control. |
| OQ-40 | `channel/resources/subscribe` live subscription | open | medium | The `channel/resources/subscribe` handler currently returns `channel:resources_not_implemented`. The live subscription aggregated from ALPN-crate resource enumerators (ADR-047 §6) requires each ALPN crate to provide a resource enumerator, and the channels layer to aggregate them into a live `Stream` that emits on any change. The current stub is a one-shot error; the real implementation is deferred until a consumer (hub, dashboard) needs live resource discovery. **Load-bearing for alktunnels' discovery UI** (review 006 E-02): a consumer cannot distinguish produced tunnel resources by op name alone. The static half is covered (0.5.0: `OperationSpec.description` on `services/list` — describes the op, not the live resource set); the dynamic half stays deferred — alktunnels v1 uses config-known op names. |
| OQ-41 | QUIC-native multi-stream substrate | open | medium | Only the in-line substrate mode is implemented (single bidi stream, header-demuxed N channels). The QUIC-native multi-stream substrate (accept remaining bidi streams, read headers off each — ADR-034 §substrate modes) is deferred to the downstream alknet crate. The wire format and demux loop are correct for both substrates; only the outer `accept_bi()` loop is missing. The alknet crate owns the QUIC dial/accept loop and is the natural place for the multi-stream accept loop. This crate stays transport-agnostic (no QUIC dependency, WASM-compatible). |
| OQ-39 | `channel/control` control-handle surface | resolved | medium | ADR-052 — the control-handle surface is implemented: per-channel control callbacks contributed by the ALPN establisher (`Establishment::with_control`), wrapper-installed and teardown-removed in the manager; the op routes the ALPN-specific `message` and answers `channel:no_control_handle` when the channel's ALPN registered none. The stub (`channel:control_not_implemented`) was removed with the single-publish fix (reviews 010 S-02 / 011). |
| OQ-40 | `channel/resources/subscribe` live subscription | resolved | medium | ADR-053 — the live subscription is implemented: one `ResourceEnumerator` per openable ALPN (provided by the producing crate, snapshot-then-changes), aggregated by the handler over the scan-once snapshot; an empty registry emits one `{ "resources": [] }` seed and stays open. The one-shot error stub was removed with the single-publish fix (reviews 010 S-03 / 011). The alktunnels discovery-UI note (review 006 E-02) is served by the enumerator surface. |
| OQ-41 | QUIC-native multi-stream substrate | deferred(scope) | medium | **Deferred downstream** — the substrate lives in the dial/accept layer, which alkcall does not own. Only the in-line substrate mode is implemented (single bidi stream, header-demuxed N channels); the QUIC-native multi-stream mode (accept remaining bidi streams, read headers off each — ADR-034 §substrate modes) belongs in the composition layer's QUIC accept loop. **Blocked on**: the composition layer's multi-stream substrate integration (owner — OQ-43; today the alknet glue, being replaced). **Impacts**: none in-crate — the wire format and demux loop are correct for both substrates; alkcall stays transport-agnostic (no QUIC dependency, WASM-compatible). |
## Core Types
@@ -96,3 +109,19 @@ is the load-bearing piece the broker composes on.
| OQ-28 | Dynamic handler registration | resolved | low | ADR-019 — curated static, overlays dynamic |
| OQ-29 | Handler-level auth resolution observability | resolved | medium | set_identity() on Connection for observability |
| OQ-30 | Dynamic resource ownership | resolved | high | ADR-011 — OwnershipProvider, resource_id_path |
## Composition Layer
The layer above alkcall that owns dial/accept, hub composition, and
endpoint concerns. As of 2026-10 that layer is the alknet glue (the
alknet mono-repo's crates: its client dial helper `AlknetClient`, its
endpoint crate, its hub wiring); the mono-repo itself is being replaced
by thin glue over alkcall and its sibling protocol/host crates
(alkhttp, alktty, alktunnels, alktrader, alktls, …). Several deferred
questions name "downstream" as their receiver — this OQ is the named
receiver for all of them, so the deferrals survive the replacement
rather than being repo-pinned to a codebase slated for deletion.
| OQ | Title | Status | Priority | Resolution |
|----|-------|--------|----------|------------|
| OQ-43 | Composition-layer ownership and shape after the alknet replacement | deferred(scope) | medium | **Deferred scope**: who owns dial/accept/hub composition, the endpoint layer (OQ-33), the QUIC-native substrate (OQ-41), and persistence adapters (OQ-19) after the alknet mono-repo is replaced — and whether that composition is one thin glue workspace (an `alknode`-type crate consuming alkcall + the sibling crates) or several small glue binaries. **Blocked on**: the glue-workspace decision being committed (the stated direction is "a couple kloc of glue", but the shape — one workspace vs several binaries — and its ownership are not yet committed). **Impacts**: re-keys the deferrals OQ-33, OQ-41, OQ-42, OQ-19; determines the dial seam's future home (ADR-045's `AlknetClient`, today in the alknet glue); determines where hub-relay wiring templates are consumed (ADR-042, ADR-051). **Not blocking** any alkcall v0.9 API surface — alkcall is transport-blind and composition-blind by construction (ADR-031, ADR-035). **Cross-references**: OQ-33, OQ-41, OQ-42, OQ-19; ADR-045, ADR-031, ADR-042, ADR-051. |
+64 -58
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-09
last_updated: 2026-10-13 (alkcall reframing)
---
# Operation Registry
@@ -21,7 +21,7 @@ The operation registry provides:
- **Type safety**: JSON Schema for input and output enables validation and client code generation
- **Composability**: Handlers can invoke other operations through `OperationEnv` (local dispatch — remote dispatch is a separate architectural concern, see Constraints)
The registry design is informed by the `@alkdev/operations` TypeScript package, which demonstrated the same capabilities in JavaScript runtimes. The Rust implementation in alknet-call is canonical — it preserves the behavioral contract (namespace + operation name → invoke with input, return output) while defining the adapter contract (from_*, to_*) in Rust (see ADR-033).
The registry design is informed by the `@alkdev/operations` TypeScript package, which demonstrated the same capabilities in JavaScript runtimes. The Rust implementation in alkcall is canonical — it preserves the behavioral contract (namespace + operation name → invoke with input, return output) while defining the adapter contract (from_*, to_*) in Rust (see ADR-033).
## Architecture
@@ -90,7 +90,7 @@ Operation names use slash-based paths without a leading slash, aligned with URL
The `namespace` field is derived from the name: for `fs/readFile` it's `fs`, for `agent/chat` it's `agent`. It's a convenience accessor for ACL matching and service grouping.
Visibility (ADR-017) controls whether an operation is callable from the wire. `External` operations are wire-facing — they appear in `services/list` and accept `call.requested` from clients. `Internal` operations are composition-only — they return `NOT_FOUND` (not `FORBIDDEN`) when called from the wire, and do not appear in `services/list`. The assembly layer declares visibility at registration. All import adapters (`from_openapi`, `from_mcp`, `from_jsonschema`, `from_call`) register operations as `Internal` by default (they're composition material, not directly callable); the handler that composes them is `External`. (`from_jsonschema` is now a real HTTP-backed adapter in `alknet-http` per ADR-027, not the schema-only placeholder it was.)
Visibility (ADR-017) controls whether an operation is callable from the wire. `External` operations are wire-facing — they appear in `services/list` and accept `call.requested` from clients. `Internal` operations are composition-only — they return `NOT_FOUND` (not `FORBIDDEN`) when called from the wire, and do not appear in `services/list`. The assembly layer declares visibility at registration. All import adapters (`from_openapi`, `from_mcp`, `from_jsonschema`, `from_call`) register operations as `Internal` by default (they're composition material, not directly callable); the handler that composes them is `External`. (`from_jsonschema` is now a real HTTP-backed adapter in `alkhttp` per ADR-027, not the schema-only placeholder it was.)
### AccessControl
@@ -135,10 +135,13 @@ impl AccessControl {
The `OwnershipProvider` trait (read side, sync — called on the dispatch hot
path) and the `OwnershipStore` trait (write side, async — called by
handlers that manage resource lifecycles) are defined in `alknet-core` per
ADR-011's storage decision (fourth instance of the repo/adapter pattern,
ADR-033). See [auth.md](../core/auth.md) §"Ownership Provider and Store"
for the trait shapes and the in-memory default adapter.
handlers that manage resource lifecycles) live with the vendored core
types in alkcall, per
ADR-011's storage decision (fourth instance of the source repo's
repo/adapter pattern — alknet ADR-033, provenance, not ported). The
trait shapes and the in-memory default adapter are in this crate's core
module; the parent repo's `auth.md` (alknet) is historical origin, not
part of this spec.
**The ownership provider is carried on `OperationContext`** (or threaded
by the dispatcher), populated by the dispatch path from the registry's
@@ -440,7 +443,7 @@ pub enum OperationProvenance {
| `Session` | Yes (within sandbox) | Yes — scopes set at sandbox creation | Internal always |
> **`FromJsonSchema` provenance.** `from_jsonschema` is an HTTP-backed
> single-endpoint adapter in `alknet-http` (ADR-027): a real reqwest
> single-endpoint adapter in `alkhttp` (ADR-027): a real reqwest
> forwarding handler, not a schema-only placeholder. `FromJsonSchema`
> is a leaf, same trust model as `FromOpenAPI` (HTTP endpoint trusted;
> handler is a forwarding stub). Schema validation without a handler is
@@ -523,7 +526,7 @@ The `OperationEnv` trait is the universal composition mechanism. A handler calls
/// through its `OperationContext.env` (which implements this trait).
///
/// This must remain a trait, not a concrete type — session-scoped
/// registries (OQ-19) depend on wrapping the global env via trait
/// registries (OQ-04) depend on wrapping the global env via trait
/// layering. Making `OperationEnv` concrete or hardcoding the global
/// registry into the dispatch path would close the session-overlay
/// pattern.
@@ -604,8 +607,8 @@ The `parent` parameter propagates the calling context: the nested call gets `par
The `invoke_peer` / `peer_contains` methods (ADR-024 §2) take a `PeerRef`
selector and a `PeerId`. These types are defined alongside the
`PeerCompositeEnv` struct — see [client-and-adapters.md](client-and-adapters.md#peer-keyed-composition-env-adr-029)
and [ADR-024](decisions/029-peer-graph-routing-model.md) §2:
`PeerCompositeEnv` struct — see [client-and-adapters.md](client-and-adapters.md#peer-keyed-composition-env-adr-024)
and [ADR-024](decisions/024-peer-graph-routing-model.md) §2:
```rust
pub enum PeerRef {
@@ -727,7 +730,7 @@ impl OperationEnv for PeerCompositeEnv {
}
// Peer-keyed overlay: iterate peers in insertion order, dispatch to
// the first peer whose sub-overlay contains the op. This is the
// head→N-workers fan-out primitive (ADR-024 §2, OQ-30: insertion-
// head→N-workers fan-out primitive (ADR-024 §2, OQ-15: insertion-
// order first-match).
for peer_id in &self.connection_order {
if let Some(conn_env) = self.connections.get(peer_id) {
@@ -805,7 +808,7 @@ Two things happen in `invoke()`:
Future work may add remote call protocol dispatch as an additional backend. The handler-facing API stays the same.
**`OperationEnv` must remain a trait.** This is a constraint, not a suggestion. The trait-based design enables registry layering (ADR-019): the CallAdapter composes the root env per call from the curated base + active peer-keyed connection overlays + session overlay, and overlays wrap the base via trait layering. Session-scoped registries (OQ-19) and connection-scoped remote imports (ADR-022 `from_call`) are both overlays on the same base, using the same mechanism. The peer-keyed extension (`PeerCompositeEnv`, `invoke_peer`, ADR-024) composes on top of the same trait — it overrides the new peer-routing methods, not the base dispatch. Making `OperationEnv` concrete or hardcoding the global registry into the dispatch path would close both the session-overlay and connection-overlay patterns, and would prevent the peer-keyed routing model from composing. This is the same integration-point pattern as `IdentityProvider` (ADR-003). See OQ-19, ADR-019, and ADR-024.
**`OperationEnv` must remain a trait.** This is a constraint, not a suggestion. The trait-based design enables registry layering (ADR-019): the CallAdapter composes the root env per call from the curated base + active peer-keyed connection overlays + session overlay, and overlays wrap the base via trait layering. Session-scoped registries (OQ-04) and connection-scoped remote imports (ADR-022 `from_call`) are both overlays on the same base, using the same mechanism. The peer-keyed extension (`PeerCompositeEnv`, `invoke_peer`, ADR-024) composes on top of the same trait — it overrides the new peer-routing methods, not the base dispatch. Making `OperationEnv` concrete or hardcoding the global registry into the dispatch path would close both the session-overlay and connection-overlay patterns, and would prevent the peer-keyed routing model from composing. This is the same integration-point pattern as `IdentityProvider` (ADR-003). See OQ-04, ADR-019, and ADR-024.
### Service Discovery
@@ -849,7 +852,7 @@ error handling.
### Operation Registry (hand-rolled, no irpc)
The operation registry is hand-rolled in alknet-call. ADR-013 accepted
The operation registry is hand-rolled in alkcall. ADR-013 accepted
"irpc as the call protocol foundation," but no `.rs` file in the workspace
ever imported irpc — the wire format (`wire.rs`), the operation registry,
and the dispatch are all hand-rolled. ADR-014 supersedes ADR-013 and
@@ -858,7 +861,7 @@ protocol (external, JSON)" with "irpc services (internal, postcard)" is
moot — there is no irpc layer.
If a handler internally uses a postcard/binary RPC for in-process calls,
that's a handler-internal choice, not an alknet-call integration. The
that's a handler-internal choice, not an alkcall integration. The
operation registry's external interface is always JSON (the `EventEnvelope`
wire format); the internal handler dispatch is a `Handler` /
`StreamingHandler` trait object (ADR-021), not an irpc `Service`.
@@ -966,71 +969,74 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
| Decision | ADR | Summary |
|----------|-----|---------|
| Hand-rolled EventEnvelope framing (irpc never integrated) | [ADR-014](decisions/064-irpc-never-integrated-hand-rolled-framing.md) | Hand-rolled framing, registry, dispatch; supersedes ADR-013 |
| Call protocol stream model | [ADR-015](decisions/012-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
| Static handler registration | [ADR-010](decisions/010-alpn-router-and-endpoint.md) | `HandlerRegistry` (ALPN-level) immutable after construction; `OperationRegistry` layered by ADR-019 (curated immutable, session/imported dynamic) |
| Vault integration via assembly layer | [ADR-008](decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time |
| Secret material flow and capability injection | [ADR-010](decisions/014-secret-material-flow-and-capability-injection.md) | Capabilities carry outbound credentials; call protocol carries no secret material |
| Privilege model and authority context | [ADR-017](decisions/015-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; composition authority + scoped env |
| Handler registration, provenance, and composition authority | [ADR-018](decisions/022-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
| Operation registry layering | [ADR-019](decisions/024-operation-registry-layering.md) | Curated (static, immutable) + session and connection overlays (dynamic); `OperationEnv` as trait-object integration point; `OperationContext.env` split into `scoped_env` (data) and `env` (dispatch trait) |
| Operation error schemas | [ADR-016](decisions/023-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity for `from_openapi`/`to_openapi` |
| Call protocol client and adapter contract | [ADR-022](decisions/017-call-protocol-client-and-adapter-contract.md) | `from_call`/`OperationAdapter` produce `HandlerRegistration` bundles; adapter-registered ops are `Internal` leaves. Surface specced in [client-and-adapters.md](client-and-adapters.md) |
| `from_jsonschema` as HTTP-backed single-endpoint adapter | [ADR-027](decisions/066-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf (now handler-bearing, not "no handler") |
| Peer-graph routing model (supersedes ADR-023) | [ADR-024](decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` (the field this doc's `HandlerRegistration` previously gained) |
| Forwarded-for identity | [ADR-026](decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `OperationContext` and `call.requested`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
| Streaming handler for subscriptions | [ADR-021](decisions/049-streaming-handler-for-subscriptions.md) | `StreamingHandler` type alongside `Handler`; `HandlerKind` enum on `HandlerRegistration` validated against `op_type`; `invoke_streaming()` on `OperationRegistry`; `invoke()` and `OperationEnv::invoke()` error with `INVALID_OPERATION_TYPE` on `Subscription` ops; composition stays request/response-only, stream composition is handler-level |
| Dynamic resource ownership for runtime-spawned resources | [ADR-011](decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | `AccessControl::check` consults an `OwnershipProvider` (sync read trait, ADR-033 repo/adapter pattern); `OperationSpec` gains `resource_id_path` (JSON pointer into the input); proxy-only access pattern (spawner owns, proxy to share, teardown revokes); `list` = scope-gate + result-filter; teardown = automatic, handler-driven; composition = two orthogonal checks, ADR-017/022 unchanged |
| Publish operation type and HandlerKind::Sink | [ADR-046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | `OperationType::Pub` (producer→consumer streaming); `SinkHandler` + `HandlerKind::Sink` + `PublishStream`; `invoke_sink()` dispatch path; `call.published` wire event; `OperationSpec.publish_schema`; `Subscription` renamed to `Sub`; fan-out/broker deferred to channels |
| Hand-rolled EventEnvelope framing (irpc never integrated) | [ADR-014](decisions/014-irpc-never-integrated-hand-rolled-framing.md) | Hand-rolled framing, registry, dispatch; supersedes ADR-013 |
| Call protocol stream model | [ADR-015](decisions/015-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
| Static handler registration | [ADR-001](decisions/001-alpn-protocol-dispatch.md) | `HandlerRegistry` (ALPN-level) immutable after construction; `OperationRegistry` layered by ADR-019 (curated immutable, session/imported dynamic). Provenance: alknet ADR-010's endpoint/router layer is retired — alkcall has no endpoint layer (see OQ-43 for the endpoint layer's composition home) |
| Vault integration via assembly layer | [ADR-010](decisions/010-secret-material-flow-and-capability-injection.md) | Vault is a capability source, accessed at assembly time (provenance: alknet ADR-008, superseded in-crate) |
| Secret material flow and capability injection | [ADR-010](decisions/010-secret-material-flow-and-capability-injection.md) | Capabilities carry outbound credentials; call protocol carries no secret material |
| Privilege model and authority context | [ADR-017](decisions/017-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; composition authority + scoped env |
| Handler registration, provenance, and composition authority | [ADR-018](decisions/018-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
| Operation registry layering | [ADR-019](decisions/019-operation-registry-layering.md) | Curated (static, immutable) + session and connection overlays (dynamic); `OperationEnv` as trait-object integration point; `OperationContext.env` split into `scoped_env` (data) and `env` (dispatch trait) |
| Operation error schemas | [ADR-016](decisions/016-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity for `from_openapi`/`to_openapi` |
| Call protocol client and adapter contract | [ADR-022](decisions/022-call-protocol-client-and-adapter-contract.md) | `from_call`/`OperationAdapter` produce `HandlerRegistration` bundles; adapter-registered ops are `Internal` leaves. Surface specced in [client-and-adapters.md](client-and-adapters.md) |
| `from_jsonschema` as HTTP-backed single-endpoint adapter | [ADR-027](decisions/027-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from the call protocol (broken schema-only placeholder) to the HTTP host crate as a real reqwest-backed single-endpoint adapter — today `alkhttp` (ex-`alknet-http`); `FromJsonSchema` provenance stays in alkcall as a leaf (now handler-bearing, not "no handler") |
| Peer-graph routing model (supersedes ADR-023) | [ADR-024](decisions/024-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` (the field this doc's `HandlerRegistration` previously gained) |
| Forwarded-for identity | [ADR-026](decisions/026-forwarded-for-identity.md) | `forwarded_for` field on `OperationContext` and `call.requested`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
| Streaming handler for subscriptions | [ADR-021](decisions/021-streaming-handler-for-subscriptions.md) | `StreamingHandler` type alongside `Handler`; `HandlerKind` enum on `HandlerRegistration` validated against `op_type`; `invoke_streaming()` on `OperationRegistry`; `invoke()` and `OperationEnv::invoke()` error with `INVALID_OPERATION_TYPE` on `Subscription` ops; composition stays request/response-only, stream composition is handler-level |
| Dynamic resource ownership for runtime-spawned resources | [ADR-011](decisions/011-dynamic-resource-ownership-for-runtime-spawned-resources.md) | `AccessControl::check` consults an `OwnershipProvider` (sync read trait; the source repo's repo/adapter pattern — alknet ADR-033, provenance); `OperationSpec` gains `resource_id_path` (JSON pointer into the input); proxy-only access pattern (spawner owns, proxy to share, teardown revokes); `list` = scope-gate + result-filter; teardown = automatic, handler-driven; composition = two orthogonal checks, ADR-017/022 unchanged |
| Publish operation type and HandlerKind::Sink | [ADR-046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | `OperationType::Pub` (producer→consumer streaming); `SinkHandler` + `HandlerKind::Sink` + `PublishStream`; `invoke_sink()` dispatch path; `call.published` wire event; `OperationSpec.publish_schema`; `Subscription` renamed to `Sub`; fan-out/broker out of scope for alkcall (OQ-22 resolved, OQ-42 downstream) |
## Open Questions
See [open-questions.md](open-questions.md) for full details.
- **OQ-13** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
- **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
- **OQ-16** (resolved by ADR-010): No vault operations are exposed over the call protocol for now.
- **OQ-19** (resolved): Session-scoped operation registries — agent-written operations overlaid on the curated registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Session ops are `Session` provenance (ADR-018) — always `Internal`, compose under restricted authority scoped down at sandbox creation. Generalized by ADR-019 to cover connection-scoped overlays as well.
- **OQ-25** (dissolved by ADR-024): `remote_safe` marking shape — moot.
- **OQ-02** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
- **OQ-03** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
- **OQ-09** (resolved by ADR-010): No vault operations are exposed over the call protocol for now.
- **OQ-04** (resolved): Session-scoped operation registries — agent-written operations overlaid on the curated registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Session ops are `Session` provenance (ADR-018) — always `Internal`, compose under restricted authority scoped down at sandbox creation. Generalized by ADR-019 to cover connection-scoped overlays as well.
- **OQ-10** (dissolved by ADR-024): `remote_safe` marking shape — moot.
`remote_safe`/`trusted_peer` are retired; peer authorization is
`AccessControl::check(peer_identity)`, the existing mechanism. See
[client-and-adapters.md](client-and-adapters.md) and ADR-024 §3.
- **OQ-26** (resolved): `OperationAdapter` error type — `AdapterError`
- **OQ-11** (resolved): `OperationAdapter` error type — `AdapterError`
variants: `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`,
`SamePeerCollision` (replaces flat `Conflict`). `#[non_exhaustive]`. See
[client-and-adapters.md](client-and-adapters.md).
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
manual free function; the assembly layer calls it after the dial (in
`AlknetClient`). A `CallConnection::refresh()` method is a genuine
- **OQ-12** (resolved): `from_call` re-import trigger — `from_call` is a
manual free function; the assembly layer calls it after the dial (in the composition layer's dial helper
`AlknetClient` — today the alknet glue; OQ-43). A `CallConnection::refresh()` method is a genuine
feature addition — non-breaking, additive. See
[ADR-028](decisions/069-from-call-manual-free-function.md).
- **OQ-28** (resolved): `from_call` namespace collision — same-peer
[ADR-028](decisions/028-from-call-manual-free-function.md).
- **OQ-13** (resolved): `from_call` namespace collision — same-peer
collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays).
`namespace_prefix` is optional local-naming sugar. See
[client-and-adapters.md](client-and-adapters.md).
- **OQ-29..37** are tracked in [client-and-adapters.md](client-and-adapters.md)
(they concern the `CallClient` / adapter surface and peer-graph routing,
not the registry layering this document covers). In brief: OQ-29
(CallClient TLS client-auth) resolved; OQ-30 (`PeerRef::Any` routing
policy) resolved; OQ-31 (`services/list-peers` re-export semantics)
resolved; OQ-32 (multi-hop federation) open (feature extension);
OQ-33 (PeerId source) resolved by ADR-025; OQ-34 (persistent peer
registry) resolved by ADR-025+033; OQ-35 (API key asymmetry) dissolved;
OQ-36 (concrete persistence adapter shapes) resolved by ADR-035;
OQ-37 (X.509 outgoing-only) resolved by ADR-034.
- **OQ-42** (resolved by ADR-011): Dynamic resource ownership for
- The client-surface questions (the `CallClient` / adapter surface and
peer-graph routing) are tracked in
[client-and-adapters.md](client-and-adapters.md), not the registry
layering this document covers; in brief: OQ-14
(`CallClient` TLS client-auth) resolved; OQ-15 (`PeerRef::Any` routing
policy) resolved; OQ-16 (`services/list-peers` re-export semantics)
resolved; OQ-17 (multi-hop federation) deferred(scope) (feature
extension); OQ-18 (`PeerId` source) resolved by ADR-025; OQ-19
(persistent peer registry) resolved by ADR-025; OQ-20 (API key
asymmetry) dissolved; OQ-21 (X.509 outgoing-only) resolved by
ADR-054 — three remote roles, verifier by `PeerEntry` presence (the
original deciding ADR, alknet ADR-034, was not ported; ADR-054
re-owns the rule, provenance retained); see OQ-21 in
[open-questions.md](open-questions.md).
- **OQ-30** (resolved by ADR-011): Dynamic resource ownership for
runtime-spawned resources — `AccessControl::check` consults an
`OwnershipProvider`; `OperationSpec` gains `resource_id_path`; proxy-only
access pattern; four edge specifics pinned (`list`, teardown, fleet,
composition). See [auth.md](../core/auth.md) §"Ownership Provider and
Store" for the trait shapes.
composition). See ADR-011 for the trait shapes.
## References
- [call-protocol.md](call-protocol.md) — CallAdapter, EventEnvelope, stream model, PendingRequestMap
- ADR-014: Hand-rolled EventEnvelope framing (irpc never integrated; supersedes ADR-013)
- ADR-008: Vault integration point
- ADR-010: ALPN router and endpoint (static registration — applies to the `HandlerRegistry`, not the `OperationRegistry`; see ADR-019 for the distinction)
- ADR-010: Secret material flow and capability injection (the vault integration point)
- ADR-001: ALPN-based protocol dispatch (`HandlerRegistry` static registration)
- ADR-015: Call protocol stream model
- ADR-019: Operation registry layering (curated + session/connection overlays; `OperationEnv` as trait-object integration point)
- ADR-024: Peer-graph routing model (peer-keyed overlays + `PeerRef` routing; `PeerCompositeEnv` supersedes the singular-connection `CompositeOperationEnv`)
@@ -1038,4 +1044,4 @@ See [open-questions.md](open-questions.md) for full details.
- ADR-026: Forwarded-for identity (`forwarded_for` on `OperationContext` and `call.requested`; metadata only)
- ADR-021: Streaming handler for subscriptions (`StreamingHandler`, `HandlerKind`, `invoke_streaming()`, `INVALID_OPERATION_TYPE`)
- ADR-011: Dynamic resource ownership for runtime-spawned resources (`OwnershipProvider` consulted by `AccessControl::check`; `OperationSpec.resource_id_path`; proxy-only access pattern; composition = two orthogonal checks, ADR-017/022 unchanged)
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`
- Reference implementation: this crate (`src/`); historical origin: the alknet / alknet-main call-core sources (provenance)
+400
View File
@@ -0,0 +1,400 @@
# The Topic Broker: Placement, Shape, and the "Op as Topic" Tangle
**Status:** Phase 0 (exploration — captures the problem and the candidate
shapes; no decision taken, no ADR written. Implementation follows only
after this converges and Phase 1 produces the ADR + spec).
**Date:** 2026-10-13
**Research inputs:** operator recollection (2026-10-13, recorded and
disentangled in §3), the post-stub-sweep context (reviews 010/011, the
v0.9 release-plan discussion), alkcall ADRs 021/024/040/042/046/047/049/052/053
and OQ-22/39/40/42/43, the alkstore consumer inventory + ADR-015
(alkstore `/workspace/@alkdev/alkstore`), the TypeScript reference
implementation (`/workspace/@alkdev/pubsub`, `@alkdev/pubsub` — the
crates.io-unpublished ancestor of alkcall's `EventEnvelope`), and the
hub-relay composition (ADR-042, ADR-051).
---
## 1. Why this research exists
Two independent observations converged on the same question in the
v0.9 release-planning discussion:
1. **The sweep found its own neighbor.** The stub sweep removed the
last `not_implemented` surfaces and re-keyed the live docs — but the
OQ ledger still carries one deferral whose *premise expired and
whose escape route is itself unowned*. OQ-42 (the broker) is
`deferred(scope)`, parked on "a concrete producing crate committing
to fan-out," and its home is the composition layer (OQ-43) — whose
ownership and shape are *also* uncommitted. A deferral blocked on a
deferral, while two crates downstream (alkgit, alkfs) wait on what
it would produce, is the circular-hedging shape in paperwork form:
no false advertising on any API surface, no failing handler — just
a dependency chain where every link waits on a link that never
commits.
2. **The deferral was priced under a different dependency graph.**
ADR-046 §fan-out and ADR-047 Gap B ruled the broker out of alkcall
when the alternative was cheap: at that point, no downstream crate
built on the pub/sub vocabulary. Today, four published crates
consume alkcall, alkblobs' reactive ops surface is the sharpest
named need (the alkblobs consumer-remediation input, review 011),
and alkgit + alkfs (both paused on alkblobs) grow subscription
surfaces on top of it. If the broker is built later, the version
bump that delivers it ripples across every crate that grew a
subscription surface in the meantime — the operator's stated cost
estimate is 6–8 crates, vs riding the v0.9 bump that already touches
the 4 published dependents. Deferral here is not free; it
compounds.
So this document's job is narrow: disentangle the fused concept that
blocked honest specification (§3), inventory what already exists to
compose on (§5), assess whether the placement ruling should be amended
(§6), and enumerate the open questions that Phase 1 must resolve before
any code is written (§8).
## 2. Prior state of the question (what is decided today)
- **The primitives are delivered and load-bearing** (ADR-046): the
`Pub` operation type, `HandlerKind::Sink` + `SinkHandler`,
`invoke_sink()` dispatch, the `call.published` wire event (the
envelope's sixth event type, wire-stable), and
`CallConnection::publish()`.
- **The fan-out/broker was ruled out of scope for alkcall** (ADR-047
Gap B; ADR-046 §"Fan-out"): topic registry, `Pub`↔`Sub` matching by
`(op_name, params_hash)`, N-consumer fan-out — placed at the hub
composition layer, "the same way the hub relay composes on top of
call ops." OQ-22 resolved the primitive half; the broker is OQ-42.
- **The named first consumer drifted.** OQ-42's blocking condition
names "a concrete producing crate committing to fan-out (plausible
first candidate: alktrader market-data distribution)." alktrader has
since become a quant/backtest library: its market-data path is an
analytics input, not a live N-spoke distribution surface. No crate
currently matches the blocking condition — which is how OQ-42 ended
up uncommittable.
- **The "channels" consumer the original rationale named is served
differently.** ADR-046's deferral reason 2 was "the broker's first
consumer is channels" (`channel/resources/subscribe`, hub-as-broker
ADR-042). The discovery-UI half of `resources/subscribe` shipped as
the snapshot-then-changes enumerator surface (ADR-053) — live
resource enumeration, not a general topic broker. Hub relay
(ADR-042/051) composes call ops without a broker.
- **alkstore owns the durable substrate below all of this.** Its
consumer inventory (streams row) explicitly names alkstore streams as
the durable back of alkcall's pub/sub vocabulary — per-stream FIFO by
offset, per-consumer cursors, replay-on-attach — and pins the topic
dimension to *separate streams*, never per-key lanes within one
stream (alkstore ADR-015 §1).
## 3. The tangle: two ideas fused in "op as topic"
The operator's recollection (recorded verbatim, then split):
> "The 'op as a topic' was a bit hand wavy in hindsight. The general
> idea was to basically use a hash as a topic and for deduping
> purposes. The 'op as a topic' was mostly so one could call a
> subscription operation and receive a stream of events. The reason
> they were modeled like regular ops is that we can validate the input
> and provide a schema that is required for that particular
> subscription (same on the pub side). So it should allow for ACL
> patterns inside the pubsub implementation."
Two separable design needs got fused into one mechanism:
- **Need A — a topic identity for matching and dedup.** Something
hashable, stable, and comparable: the key used to route a published
event to matching subscribers, and (possibly) the dedup key that
prevents duplicate delivery. ADR-046 §fan-out's `(op_name,
params_hash)` is one instance of this.
- **Need B — a subscription surface with real operation properties.**
Subscriptions should be validatable (declare the input schema on the
subscribe side; declare the publish schema on the publish side), and
authorizable (`AccessControl::check` on both halves). The "model it
as a regular op" instinct exists so the *registry machinery* —
schema compile, ACL check, visibility, provenance — is inherited for
free rather than hand-rolled inside the broker.
Splitting them exposes a design space the fused formulation hid:
| Aspect | Need A (identity) | Need B (op surface) |
|---|---|---|
| Decides | which subscribers an event reaches; dedup | who may subscribe/publish; what shapes are accepted |
| Natural home | topic table / matching engine | registry registration (specs + ACL) |
| Failure mode if fused | topics inherit op-registry coupling they don't need; hash identity leaks op internals | ad-hoc topic surface re-implements schema + ACL machinery the registry already has |
The candidate resolutions for both are the Phase 1 subjects
(§8, QP-1/QP-3).
## 4. Consumer inventory (who waits on this)
Cross-referenced from the alkstore consumer inventory, review 011's
downstream input, and the release-plan discussion:
| Consumer | Need | Status |
|---|---|---|
| alkblobs | its reactive ops surface (Sink ops at their sharpest — REQ-2/ADR-008 lineage); ops-put works against the delivered `register_pub_openable` path post-fix (reviews 010/011), but cross-put fan-out ("many consumers of one blob stream") wants the broker | published-pending (unpublished crate, pre-first-release) |
| alkgit | repo-change subscriptions ("users and other apps subscribe to changes on a repo" — the operator's recorded need in alkstore's consumer inventory, streams row); paused on storage → alkblobs | paused |
| alkfs | the VFS workspace crate; its ancestor POC runs on honker notify; client-side cache invalidation (the "repo + cache invalidation" hot-path pattern the operator cites) wants topic-shaped invalidation signals | not yet decomposed |
| hub/relay deployments | one producer publishing, N spokes receiving (ADR-042's relay handles bytes-forwarding for channels; the *event-shaped* fan-out is the broker's) | composition layer, unowned (OQ-43) |
| alktrader | OQ-42's *original* named candidate | drifted — analytics, not distribution; do not re-park on it |
| family-wide | the reactivity requirement row (alkstore consumer inventory, streams): "watching for specific event types from several different places at once," durable, offline-tolerant | the named shape all of the above instantiate |
Note the family's shape consensus, recorded independently on both
sides: the topic dimension routes to **separate channels/streams — one
per topic** (alkstore ADR-015 §1; ADR-034 §substrate for wire
channels), never per-key lanes inside one stream. Whatever identity
model Phase 1 picks, it should preserve one-topic-one-delivery-pipe.
## 5. What exists to compose on (in-crate inventory)
Everything the broker needs as load-bearing inputs is already in this
crate and verified:
- **Delivery primitives** — `invoke_sink()` (`call.published`
sink-mode), `invoke_streaming()` (Sub, `call.responded`), the
wire-stable `EventEnvelope` framing with additive-event rule
(ADR-014: new event types may be added; existing shapes frozen).
- **Registry machinery for Need B** — `OperationSpec` (schema compile +
`publish_schema` validation), `AccessControl` + `required_scopes`,
`Visibility` (ADR-017), provenance (ADR-018), and the
registry-layering pattern (ADR-019) — session/connection overlays
for dynamic subscription attach/detach.
- **The in-crate composition precedents that weaken the placement
ruling** — `ChannelOperations` (channel lifecycle ops composed on
the call registry, ADR-047), establishers with `Establishment` plan
delivery (ADR-049/052), and `channel/resources/subscribe` — a live
subscription op with per-resource routing, snapshot-then-changes,
composed in-crate (ADR-053). None of these needed transport, ALPN,
or dial knowledge.
- **Peer vocabulary for cross-node fan-out** — ADR-024's peer-graph
routing model (`PeerRef` routing, peer-keyed overlays) is the
in-crate vocabulary for "deliver to N peers"; OQ-17 keeps
multi-hop out of scope.
- **Backpressure/lifecycle precedents** — bounded buffers, per-identity
caps, monotonic IDs (ADR-040/041), the abort cascade with
`continue-running` opt-in for slow-subscriber management (ADR-020).
- **The durable substrate downstream** — alkstore streams (per-consumer
offset, replay, trim), engines mem/sqlite/postgres under one
contract.
- **The TS reference** — `@alkdev/pubsub` (typed event-target: topic =
event type name; subscribe/publish/subscribeOnce vocabulary; the
`EventEnvelope` ancestor). Shape only; the Rust realization composes
on the registry instead of `CustomEvent`.
## 6. The placement ruling: amend ADR-047 Gap B?
The case for keeping the broker out of alkcall was: (1) point-to-point
call protocol, (2) the broker's lifecycle is routing-shaped, (3)
designing blind produces half-designs. Each has softened:
1. **Point-to-point is true of transport, not of composition.** The
registry-operations pattern (ADR-047) already puts composition ops
in-crate; ADR-053 ships a live subscriber op in-crate. A topic
registry is a *registry overlay + dispatch bookkeeping*, the same
class.
2. **The lifecycle concern dissolves at the module boundary.** A broker
module composed on a registry has exactly the lifecycle its
registry has; the "topic registry outlives individual calls"
objection describes a *deployment* property, satisfied by any
long-lived node, hub or not.
3. **The half-design risk is the real surviving constraint — and it
now has both halves.** The consumer side is no longer hypothetical
(alkblobs' ops surface, alkgit's subscription vocabulary recorded in
the alkstore inventory), and the substrate side exists (alkstore
streams, the TS reference). A Phase 1 spec against these is not a
blind design.
**Recommendation (non-final until §8 converges):** amend the placement
— the broker becomes an in-crate, opt-in composition module (the
`ChannelOperations` pattern): it registers topic ops into whatever
`OperationRegistry` the node owner points it at, carries no transport
or dial knowledge, and stays unregistered by default so no existing
surface silently gains it. What remains out of alkcall: engine wiring,
glue, dial — OQ-43's territory. The hub that fans out *across*
connections composes the module with its `CallConnection`s; within one
connection, the module composes locally. The durable event log behind
a topic's replay is a `TopicStore` trait seam (§7), not an in-crate
engine choice.
This also resolves OQ-42's uncommittable blocking condition: the
deferral's "wait for a consumer to commit" gate is replaced by shipped
consumers already on record.
## 7. The durability seam (the "not rocket surgery" constraint)
The operator's requirement: the trait seam must let a downstream
assembly add memory/sqlite/postgres adapters with the same ease as the
repo + cache-invalidation hot-path pattern — adapters are written once
per engine, not per deployment.
Shape to converge on in Phase 1:
- **In-memory is the default realization** — a `TopicBroker` with no
injected store behaves as the TS reference does: live fan-out,
stateless, zero durability.
- **The seam is one small trait** (working name `TopicStore`),
roughly covering: append a published event (returning the topic's
monotonically increasing position), read events at-or-after a
position for a topic, and per-subscriber cursor save/load. This maps
one-to-one onto the alkstore stream contract (`publish_with_key_tx`,
offset-ordered reads, per-consumer cursor saves — alkstore ADR-015
§2/§4), so the postgres/sqlite adapters are alkstore-streams-backed
and the memory adapter is the broker itself.
- **Reverse dependency discipline** — alkcall gains no alkstore
dependency. The seam stays dependency-free (plain trait, core-value
bytes crossing it, mirroring the vendored-core "lean, no engine"
posture); alkstore-backed adapters live downstream, where engines
already live (the OQ-43 glue consumes both crates).
- **Honest scope statement for durable mode:** durable delivery with
per-consumer replay is a *deployment* capability. The v1 broker
contract should specify the seam, not the durability guarantees
beyond it (ordering, dedup, retention pricing ride the store behind
the seam — for alkstore-backed adapters, those are ADR-015's pinned
rows verbatim).
## 8. Open questions (proposed — to be promoted into Phase 1's ledger)
These are the questions this research identifies as needing decisions
before any ADR or code. They're proposed in broker-local numbering
(QP-*) until Phase 1 promotes them as OQ-44+.
### QP-1: Topic identity — named topics, op-shaped topics, or a split?
Need A's model. Candidates:
- **(a) Named topics (strings).** topic = stream/event-type name; the
TS reference's shape; one-topic-one-pipe; alkstore-stream-aligned.
Matching is exact-name; wildcarding is optional and additive.
- **(b) Op-shaped (`(op_name, params_hash)`), ADR-046 §fan-out's
literal form.** Fan-out targets are Sub-typed ops; identity inherits
op internals. Pros: dedup key falls out; wire-consistency with
existing ops. Cons: the identity couples routing to op schema shape
(params_hash stability depends on the schema's canonicalization —
which is a real design burden, see §3's A/B split); one topic per op
per registrant, so cross-op co-subscription needs the hash of the
*matching rule* anyway.
- **(c) The split:** identity is named topics (a); "ops as topics"
survives only as Need B — a subscriber declares a topic *and* the
input schema it validates against, so per-subscription input
validation is a subscriber-side schema, validated at subscribe time
and at delivery time.
Working recommendation: **(c)** — identity as named topics, schema/ACL
inheritance via the registry on the two broker ops, dedup keying
re-derived from topic semantics rather than op params.
### QP-2: Dedup and delivery semantics
"A hash as a topic ... for deduping purposes" — what does dedup mean
here, precisely? Candidate framings to resolve: idempotent-publish
keys (producer-supplied event ids, dedup at the store seam), vs
subscriber-side dedup (at-least-once delivery + a dedup window), vs
no dedup contract in v1. The answer is pinned partly by the store
seam: durable adapters can offer producer-supplied-id dedup for free;
in-memory mode cannot (and shouldn't claim to). The contract must
state delivery semantics honestly per mode (live: at-most-once per
attached subscriber; durable-with-replay: at-least-once per cursor)
rather than promising a uniform thing two substrates can't both
provide.
### QP-3: Wire shape — new ops, new event type, or composed handler kinds?
- Broker ops registered into a registry (`topic/subscribe`,
`topic/publish`, optionally `topic/topics` listing) — additive, no
envelope change required: subscribe rides `call.responded` (Sub),
publish rides `call.published` (Sink).
- If wire-visible events beyond op names are wanted (e.g. per-topic
attach/detach events), they are *new envelope event types* — legal
under ADR-014's additive rule, but each one is a cross-language
deliverable to keep in sync; default to zero new event types.
### QP-4: ACL and schema on both halves
The operator's requirement, mapped to the registry: the broker ops
carry their own `AccessControl` (who may subscribe/publish at all) —
and each *topic's* authorization model (per-topic publish ACL, per-
topic subscribe ACL, maybe scope patterns) is the piece the fused
concept gestured at with "ACL patterns inside the pubsub." Decision
needed: per-topic ACL tables owned by the broker module vs topic ops
as individually registered specs (each topic's publish/subscribe ops
registered separately, inheriting the full registry ACL/visibility/
provenance machinery). The latter is the pure Need-B realization but
costs a registration per topic; the former is one registration pair
with a topic-table lookup. This materially changes the decomposition.
### QP-5: Subscription lifecycle
Attach (subscribe with optional position for durable mode), detach,
disconnect behavior (live mode: gone on transport drop; durable mode:
cursor persists, resubscribe at position — the alkstore streams row's
contract), subscriber cap policy (ADR-041 pattern), and interaction
with the abort cascade (ADR-020) when a subscriber's sink stream dies.
### QP-6: Cross-node fan-out and the hub's margin of work
Within one node: registry dispatch. Across nodes: the hub composes (it
holds `CallConnection`s; ADR-024 vocabulary). Decision: does v1 ship
any in-crate cross-connection fan-out helper (a `FanOut` that mirrors
`call.published` streams into peer connections), or does the hub wire
that itself from the primitives? ADR-042's relay exists for channels
byte-forwarding; its analog for event fan-out is un-specced — it is
either a small in-crate helper or hub-side composition, and the
placement ruling (§6) should state which explicitly so OQ-43 doesn't
inherit an ambiguity.
### QP-7: Backpressure across fan-out
One slow subscriber must not back-pressure the topic (the OQ-23/ADR-040
lesson carried into event delivery): per-subscriber bounded queues with
an explicit slow-subscriber policy (drop + event, drop + disconnect,
block-bounded). Policy default and configurability are to be pinned in
Phase 1; the ADR-040 bounded-buffer machinery is the reference.
### QP-8: Ordering guarantee
Per-topic FIFO (matches alkstore ADR-015 §1's pinned row and ADR-046's
per-stream delivery posture). The open part: does the broker promise
ordering *across* publishers to one topic (single sequencer required)
or only per-publisher (interleaving legal, no sequencer needed)? The
durable store behind the seam forces single-writer-per-topic-sequencer
for (a); in-memory could do either — but the contract has to match the
store's row, so this resolves with QP-2 rather than independently.
## 9. Recommendation and convergence criteria
**Direction (to carry into Phase 1, not final):** an in-crate,
opt-in broker composition module on the call registry — named topics
for identity (QP-1c), registry machinery providing schema + ACL on the
two broker ops (QP-3, QP-4 resolved as one topic-table vs per-topic
registration decision), a dependency-free `TopicStore` seam with the
broker itself as the in-memory adapter (QP-D from §7), per-topic FIFO
with an honest per-mode delivery contract (QP-2, QP-8), ADR-040-style
per-subscriber backpressure policy (QP-7), and an explicit statement
on the cross-node margin (QP-6). ADR-047 Gap B is amended by the Phase
1 ADR (which supersedes the placement ruling for this scope); OQ-42 is
resolved by that ADR; OQ-43 remains untouched (dial/engine/glue is
still the composition layer's).
**Convergence criteria — Phase 0 is done when:**
1. Every QP-* above carries a candidate resolution with its rejection
cases written down (the ADR-032 one-way-door discipline applies to
the wire-visible ones).
2. At least one concrete consumer surface has signed off on the topic
model — alkblobs' put-path ops for the publish half, the recorded
alkgit subscription vocabulary for the subscribe half (the review
011 downstream-input pattern: the consuming crate drives the
contract).
3. The QP-4 decision (topic-table vs per-topic registration) is made —
it is the largest decomposition determinant.
4. The placement amendment's blast radius is enumerated: which ADRs/
docs re-key (046 §fan-out, 047 Gap B, open-questions OQ-42, README
ADR tables; alkstore consumer-inventory rows are cross-repo notes,
updatable but not blocking).
**Out of scope for this research:** implementation, task decomposition,
version assignment, wire-format drafting. Nothing here creates a
`not_implemented` surface — the broker module does not exist and no
API names, docs, or changelog entries will advertise it until Phase 1's
ADR lands, per the implement-or-stop-advertising ruling (the only
permitted deferral statement is "not this session").
@@ -962,7 +962,11 @@ promises.
vice versa) as the smaller change; record the decision in ADR-047 §5
or a follow-on ADR. Then implement the adoption path: the
non-allocating side registers the remotely-assigned id in its
manager/mux.
manager/mux. **[RESOLVED 2026-10-06/13: the implemented odd/even
split (connect odd, accept even) + `ChannelManager::adopt_channel`;
the marked-pub contract rides it end-to-end — ADR-047 amendment 4;
the C-08-blocker stub class this decision previously gated on is
closed with reviews 010/011's single-publish fixes.]**
- C-12: reject `channel_id: 0` in the `channel/close` handler.
- C-13: `channel/close` must drain (signal EOF to the handler, await
pump completion) before `abort`/decrement; implement REQ-CH-06
+273
View File
@@ -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`)
+4 -3
View File
@@ -8,7 +8,8 @@
//! (single bidi stream, header-demuxed N channels — TCP+TLS,
//! WebTransport session, SSH `direct-tcpip`). The QUIC-native
//! multi-stream substrate (accept remaining bidi streams, read headers
//! off each) is deferred to the downstream alknet crate (OQ-41).
//! off each) belongs in the composition layer's QUIC accept loop
//! (OQ-41; composition-layer owner OQ-43 — today the alknet glue).
//! The wire format and demux loop are correct for both substrates;
//! only the outer `accept_bi()` loop is missing.
//!
@@ -103,8 +104,8 @@ impl ChannelsAdapter {
/// in-line substrate mode (single bidi stream, header-demuxed N
/// channels — TCP+TLS, WebTransport session, SSH `direct-tcpip`).
/// The QUIC-native multi-stream substrate (accept remaining bidi
/// streams, read headers off each) is deferred to the downstream
/// alknet crate (OQ-41). The wire format and demux loop are correct
/// streams, read headers off each) belongs in the composition
/// layer (OQ-41; owner OQ-43 — today the alknet glue). The wire format and demux loop are correct
/// for both substrates; only the outer `accept_bi()` loop is missing.
/// See ADR-034 §substrate modes.
async fn run_demux_loop(
+131 -2
View File
@@ -70,8 +70,8 @@ impl ChannelOpenError {
/// The establishment-failure reason code — `details.reason` of a
/// `channel:open_failed` error (`dial_failed`, `unknown_resource`,
/// `resource_shortage`, `handler_error`, `timeout`). `None` for
/// any other failure shape.
/// `resource_shortage`, `handler_error`, `timeout`, `adopt_failed`).
/// `None` for any other failure shape.
pub fn establishment_reason(&self) -> Option<&str> {
match self {
ChannelOpenError::CallFailed { error }
@@ -88,6 +88,30 @@ impl ChannelOpenError {
}
}
/// The awaited single merged response of a put open
/// ([`ChannelClient::open_pub_channel`], ADR-047 amendment 4 §4.9 —
/// the put ack rides in the result, ADR-047 amendment 4 §4.5). The
/// putter writes bytes on the returned `send` half (or spawns pumps)
/// and awaits this for the put's wire contract (e.g. alkblobs' put
/// replying `{ token, digest }`). Await this after (or alongside) the
/// byte stream, never before it — the response cannot exist before the
/// byte stream completes.
pub struct PubOpenReply {
rx: tokio::sync::oneshot::Receiver<Result<Value, ChannelOpenError>>,
}
impl PubOpenReply {
/// The putter-side await: the merged response output (`Ok`);
/// `Err` carries the wire `CallError` verbatim
/// (`channel:open_failed` + `details.reason` per ADR-049 §3).
pub async fn resolve(self) -> Result<Value, ChannelOpenError> {
match self.rx.await {
Ok(result) => result,
Err(_) => Err(ChannelOpenError::MissingChannelId),
}
}
}
/// The client-side handle for a channels connection. Constructed via
/// [`ChannelClient::from_connection`] from an established
/// `Connection`. Holds the `ChannelManager` (for the demux/mux state)
@@ -382,6 +406,111 @@ impl ChannelClient {
.map(|(send, recv)| (channel_id, out, send, recv))
}
/// The putter-facing open (ADR-047 amendment 4 §4.9 — the
/// initiator-allocates capability `open_channel`/`open_channel_with_reply`
/// cannot provide, because those await the open reply before
/// returning the halves and a put's bytes flow before the reply
/// exists, review 011 §2.7): allocates the connect-side odd id
/// locally, inserts the reserved input key, sends the open op in a
/// background task, and returns **immediately** — the caller
/// writes bytes on `send` without waiting for anything (the
/// put ack rides in the merged response; ADR-047 amendment 4 §4.5).
///
/// Returns `(channel_id, send, recv, reply)`; `reply` is the
/// awaited single merged response — `Ok(Value)` (the full output,
/// e.g. `{ token, digest }`) or the wire error. On a failed open
/// the local adopt entry is torn down and `send`/`recv` are
/// already EOF'd (teardown); the caller still holds them but every
/// write is EOF-shaped. The `recv` half is the (bidirectional)
/// channel's read side — a put that also receives reads it.
pub async fn open_pub_channel(
&self,
operation_id: &str,
input: Value,
alpn: &str,
) -> Result<(u32, MpscSendStream, MpscRecvStream, PubOpenReply), ChannelOpenError> {
use crate::registry::spec::RESERVED_OPEN_INPUT_KEY;
let (channel_id, send, recv) = self
.manager
.open_channel(
alpn,
// The opener ledger is inert on the connect side (it
// does not enforce the per-identity cap, ADR-041) —
// the record is observability only.
"channel-client",
None,
)
.await
.map_err(|e| match e {
crate::channels::manager::ManagerError::TooManyChannels { count, max } => {
ChannelOpenError::CallFailed {
error: crate::protocol::wire::CallError::new(
"channel:too_many_channels",
format!("per-connection channel cap exceeded: {count}/{max}"),
false,
)
.with_details(serde_json::json!({ "count": count, "max": max })),
}
}
other => ChannelOpenError::CallFailed {
error: crate::protocol::wire::CallError::new(
"channel:allocation_failed",
format!("channel allocation failed: {other}"),
false,
),
},
})?;
let mut op_input = input;
match op_input.as_object_mut() {
Some(obj) => {
obj.insert(
RESERVED_OPEN_INPUT_KEY.to_string(),
serde_json::json!(channel_id),
);
}
None => {
let _ = self.manager.teardown_channel(channel_id);
return Err(ChannelOpenError::CallFailed {
error: crate::protocol::wire::CallError::invalid_input(
"marked pub open op input must be an object (ADR-047 amendment 4 §4.3)",
),
});
}
}
let call_conn = {
let guard = self.call_connection.lock().await;
guard.as_ref().map(Arc::clone)
};
let manager = self.manager.clone();
let op_id = operation_id.to_string();
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let response = match &call_conn {
Some(conn) => conn.call(&op_id, op_input).await,
None => ResponseEnvelope::error(
"channel-client",
crate::protocol::wire::CallError::internal("channel client closed"),
),
};
// A failed open tears the allocator-side entry down: the
// responder refused, the channel is dead, and the local
// half must not linger in the connection's channel map.
if response.result.is_err() {
let _ = manager.teardown_channel(channel_id);
}
let reply = match response.result {
Ok(out) => Ok(out),
Err(error) => Err(ChannelOpenError::CallFailed { error }),
};
let _ = reply_tx.send(reply);
});
Ok((channel_id, send, recv, PubOpenReply { rx: reply_rx }))
}
/// Take the `CallConnection` — used by the consumer to register
/// imported ops (`from_call`) on the connection's overlay. After
/// this, `call_open_op` returns an error (the connection is owned
+92
View File
@@ -62,6 +62,7 @@ const TTY_OP: &str = "channels/tty/sub";
const TTY_ALPN: &str = "alk/tty";
const ALPN: &str = "alk/tunnel";
const PLAIN_OP: &str = "fs/read";
const PUT_OP: &str = "channels/put/bulk";
fn identity(id: &str, scopes: &[&str]) -> Identity {
Identity {
@@ -256,6 +257,49 @@ fn make_spoke_install(
)
.expect("spoke registers the tty open op");
// The marked pub openable (ADR-047 amendment 4 §4.7's
// relay-translation gate): a put handler that consumes the
// adopted channel's data plane to EOF and replies with the
// digest over the streamed bytes.
let put_spec = OperationSpec::new(
PUT_OP,
OperationType::Pub,
Visibility::External,
json!({ "type": "object" }),
json!({
"type": "object",
"properties": {
"channel_id": { "type": "integer" },
"digest": { "type": "string" }
}
}),
vec![],
AccessControl::default(),
None,
)
.with_channel_open(ChannelOpenSpec::new("alk/blobs"));
let put_handler: crate::channels::operations::PubOpenHandler =
Arc::new(move |_input, _plan, channel_conn, _auth| {
Box::pin(async move {
let mut bidi = channel_conn.accept_bi().await.map_err(|_| {
crate::protocol::wire::CallError::internal(
"spoke put: the data plane never opened",
)
})?;
let mut buf = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut bidi, &mut buf)
.await
.map_err(|e| {
crate::protocol::wire::CallError::internal(format!(
"spoke put read failed: {e}"
))
})?;
Ok(json!({ "digest": format!("spoke-{}", buf.len()) }))
})
});
core.register_pub_openable(put_spec, None, put_handler, &registry, auth.clone(), None)
.expect("spoke registers the marked pub openable");
registry
.register(HandlerRegistration::new(
plain_spec(PLAIN_OP),
@@ -740,3 +784,51 @@ async fn gate2_tty_standard_shape_relays_with_no_regression() {
"the tty relay's ledger decremented"
);
}
// --- gate: the pub-openables relay translation (ADR-047 amendment 4 §4.7) ---
/// A hub translating a pub openable end-to-end (review 011 §3): the
/// browser's put via `open_pub_channel` on the consumer leg; bytes
/// flow browser → hub → spoke (the hub never parses them); the
/// spoke's single response loses its reserved key and its result
/// fields ride the consumer reply; the consumer replies exactly once
/// with the hub-allocated consumer-leg id.
#[tokio::test]
async fn gate_pub_relay_put_end_to_end() {
let h = start().await;
let (id, mut send, _recv, reply) = h
.consumer
.open_pub_channel(PUT_OP, json!({ "path": "g" }), "alk/blobs")
.await
.expect("the put opens through the relayed pub openable");
send.write_all(b"payload!").await.expect("stream the bytes");
drop(send);
let merged = reply
.resolve()
.await
.expect("the put's single merged response");
assert_eq!(
merged["digest"],
json!("spoke-8"),
"the spoke's result rode the relay (the producer's reserved key stripped)"
);
assert_eq!(
merged["channel_id"],
json!(id),
"the reply carries the hub-allocated consumer-leg id"
);
assert_eq!(id % 2, 1, "the browser (connect side) allocated odd");
wait_for(
|| data_channels(&h.hub_consumer_manager) == 0,
"the consumer-leg put channel reclaimed at the wrapper's completion teardown",
)
.await;
wait_for(
|| data_channels(&h.hub_producer_manager) == 0,
"the producer-leg put channel reclaimed at the plan's drop guard",
)
.await;
}
+17 -9
View File
@@ -668,7 +668,11 @@ async fn template_plain_bundle_registration_failure_is_loud_at_install() {
/// call to any op — including the bootstrap discovery ops — does not
/// resolve). Never a silent stub.
#[tokio::test]
async fn template_pub_typed_marked_spec_is_loud_at_install() {
async fn template_pub_typed_marked_spec_registers_through_the_relay() {
// ADR-047 amendment 4 §4.7: the put-forwarding translation replaced
// the `PubTypedOpen` assembly error — a pub-typed marked spec in
// the stash composes through `register_pub_openable` and the op
// re-exposes on the consumer leg.
let pub_bundle = HandlerRegistration::new(
relay_spec(
"channels/x/pub",
@@ -685,15 +689,19 @@ async fn template_pub_typed_marked_spec_is_loud_at_install() {
);
let imports = HubLegImports::from_bundles(vec![pub_bundle]);
let consumer = start_hub(None, Some(imports), None, false).await;
let response = tokio::time::timeout(
std::time::Duration::from_secs(3),
consumer.call_open_op("services/list", json!({})),
)
.await;
let response = consumer.call_open_op("services/list", json!({})).await;
let out = response
.result
.expect("the leg dispatches; the op re-exposed");
let names: Vec<&str> = out["operations"]
.as_array()
.expect("operations")
.iter()
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
.collect();
assert!(
response.is_err(),
"the leg's channel 0 never dispatches after the loud assembly error, got {:?}",
response
names.contains(&"channels/x/pub"),
"the pub openable is re-exposed through the relay, got: {names:?}"
);
}
+76
View File
@@ -18,6 +18,9 @@ use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tracing::debug;
use crate::protocol::wire::CallError;
use serde_json::Value;
use super::mux::{MuxHandle, OpenerLedger};
use super::reassembly::{MpscRecvStream, MpscSendStream, DEFAULT_BUFFER_CAP};
@@ -38,6 +41,15 @@ pub enum ChannelSide {
/// `ChannelLifecyclePolicy` (ADR-041, amended by ADR-047 §7).
pub const DEFAULT_MAX_CHANNELS: usize = 256;
/// The per-channel out-of-band control callback (ADR-052): received
/// from the open-op establisher (`Establishment::with_control`),
/// installed by the open-op wrapper under the channel's id, routed to
/// by `channel/control`, removed by every teardown path. The manager
/// stays blind to what the closure does (ADR-039).
pub type ChannelControl = Arc<
dyn Fn(Value) -> futures::future::BoxFuture<'static, Result<Value, CallError>> + Send + Sync,
>;
/// The per-channel state — the reassembly sender (the demux feeds chunk
/// payloads into it; the handler reads them out via `MpscRecvStream`),
/// the handler task handle, and the ALPN (for observability).
@@ -62,6 +74,13 @@ pub enum ManagerError {
UnknownChannel(u32),
#[error("channel already exists: {0}")]
ChannelExists(u32),
#[error(
"channel {channel_id} is outside the remote side's allocation range (expected {expected})"
)]
InvalidAdopt {
channel_id: u32,
expected: &'static str,
},
}
/// The shared state for a channels connection. `Clone` (cheap — `Arc`
@@ -101,6 +120,10 @@ struct Inner {
/// drains them into the new receiver. `clear_all` drops the map.
early_arrivals: Mutex<HashMap<u32, VecDeque<Bytes>>>,
early_arrival_count: AtomicU64,
/// Per-channel out-of-band control callbacks (ADR-052). Installed
/// by the open-op wrapper from the establisher's
/// `Establishment::with_control`; removed by every teardown path.
control_handles: Mutex<HashMap<u32, ChannelControl>>,
}
/// The per-channel cap on parked early-arrival chunks (`route_payload`
@@ -151,6 +174,7 @@ impl ChannelManager {
dropped_unknown_chunks: AtomicU64::new(0),
early_arrivals: Mutex::new(HashMap::new()),
early_arrival_count: AtomicU64::new(0),
control_handles: Mutex::new(HashMap::new()),
}),
}
}
@@ -317,6 +341,32 @@ impl ChannelManager {
alpn: impl Into<String>,
handler_task: Option<JoinHandle<()>>,
) -> Result<(MpscSendStream, MpscRecvStream), ManagerError> {
// The adopt range is the remote side's allocation range (the
// odd/even split, ADR-047 §5): the connect side allocates odd,
// so the accept side adopts odd; the accept side allocates
// even, so the connect side adopts even. Adopting inside the
// local range would collide with this side's own monotonic
// allocator shortly after. The duplicate check comes first —
// adopting an id this side already holds returns
// `ChannelExists` and must never touch the live channel (the
// 0.8.1 regression pin).
let expected = match self.inner.side {
ChannelSide::Connect => "even (the accept side's allocated ids; ADR-047 §5)",
ChannelSide::Accept => "odd (the connect side's allocated ids; ADR-047 §5)",
};
if self.inner.channels.lock().contains_key(&channel_id) {
return Err(ManagerError::ChannelExists(channel_id));
}
let parity_ok = match self.inner.side {
ChannelSide::Connect => channel_id.is_multiple_of(2),
ChannelSide::Accept => !channel_id.is_multiple_of(2),
};
if !parity_ok {
return Err(ManagerError::InvalidAdopt {
channel_id,
expected,
});
}
let alpn = alpn.into();
let send = self
@@ -381,6 +431,30 @@ impl ChannelManager {
}
}
/// Install the out-of-band control callback for a channel
/// (ADR-052). Called by the open-op wrapper from the establisher's
/// `Establishment::with_control`. Replaces an existing callback for
/// the id (the establisher runs once per channel — replacement is
/// not an expected path; the last call wins).
pub fn set_control_handle(&self, channel_id: u32, control: ChannelControl) {
self.inner
.control_handles
.lock()
.insert(channel_id, control);
}
/// The control callback for a channel, for routing (the
/// `channel/control` handler). `None` when the channel's ALPN
/// registered no control surface (the op answers
/// `channel:no_control_handle`).
pub fn control_handle(&self, channel_id: u32) -> Option<ChannelControl> {
self.inner.control_handles.lock().get(&channel_id).cloned()
}
fn remove_control_handle(&self, channel_id: u32) {
self.inner.control_handles.lock().remove(&channel_id);
}
/// Install channel 0 (pre-negotiated as `alk/call`, ADR-036).
/// Channel 0 is special only in that it's pre-allocated (by
/// `channels-call`); the `ChannelsAdapter` hands the resulting
@@ -530,6 +604,7 @@ impl ChannelManager {
// Dropping `state.demux_sender` signals EOF to the
// handler's read half (REQ-CH-02).
drop(state.demux_sender);
self.remove_control_handle(channel_id);
Ok(state.handler_task)
}
None => Err(ManagerError::UnknownChannel(channel_id)),
@@ -550,6 +625,7 @@ impl ChannelManager {
task.abort();
}
}
self.inner.control_handles.lock().clear();
// The early-arrival buffers die with the connection too.
self.inner.early_arrivals.lock().clear();
// The opener ledger has the opener PeerIds.
+4
View File
@@ -31,6 +31,9 @@
//! as amended by ADR-051): translate hop + `pump_bidi` byte-forward
//! hop for from_call-imported marked specs re-exposed on a hub's
//! consumer-leg channel-0 registry.
//! - [`resource`]: `ResourceEnumerators` — the enumerator registry
//! behind `channel/resources/subscribe`'s live subscription
//! (ADR-053).
//! - [`hub_leg`]: `HubLegTemplate` — the hub-leg install template
//! (ADR-051 §5): fork + generic channel ops + bootstrap discovery +
//! plain bundles + relay openables + serving identity, composed into
@@ -53,5 +56,6 @@ pub mod policy;
pub mod pump;
pub mod reassembly;
pub mod relay;
pub mod resource;
pub mod source;
pub mod wire;
+790 -237
View File
File diff suppressed because it is too large. Load diff
+477
View File
@@ -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(&registry).expect("bootstrap discovery");
core.register_pub_openable(
pub_put_spec(),
Some(establisher),
handler,
&registry,
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
View File
@@ -35,11 +35,13 @@ use crate::registry::spec::{OperationSpec, OperationType};
use super::manager::ChannelManager;
use super::operations::{
ChannelCore, Establishment, EstablishmentError, OpenEstablisher, OpenHandler,
CHANNEL_OPEN_FAILED, RESERVED_REPLY_KEY,
ChannelCore, ChannelPlan, Establishment, EstablishmentError, OpenEstablisher, OpenHandler,
PubOpenHandler, CHANNEL_OPEN_FAILED, RESERVED_REPLY_KEY,
};
use super::pump::pump_bidi;
use super::reassembly::{MpscRecvStream, MpscSendStream};
use tokio::io::AsyncWriteExt;
/// The producer-leg surface the relay holds (ADR-051 §2): the
/// `CallConnection` for the translate hop's open call plus the
@@ -81,8 +83,17 @@ struct RelayPlan {
producer_manager: ChannelManager,
spoke_send: Option<MpscSendStream>,
spoke_recv: Option<MpscRecvStream>,
/// The put path (ADR-047 amendment 4 §4.7): the producer leg's
/// open-op response, awaited by the relay pub handler after the
/// consumer EOF (put-acks-in-result — the spoke's response cannot
/// exist before its byte stream completes, so the establisher
/// never awaits it).
pub_reply: Option<tokio::sync::oneshot::Receiver<CallConnectionResponse>>,
}
/// The producer leg's open-op response, carried on the put path's plan.
type CallConnectionResponse = crate::protocol::wire::ResponseEnvelope;
impl Drop for RelayPlan {
fn drop(&mut self) {
let _ = self.producer_manager.teardown_channel(self.spoke_id);
@@ -120,9 +131,11 @@ impl ChannelRelay {
/// produced open op.
/// - `spec` must carry the `channel_open` marker (reconstructed
/// WITH the marker by discovery, ADR-047 amendment 3). A
/// `Pub`-typed marked spec is a loud assembly error
/// ([`RelayError::PubTypedOpen`] — the C-08 blocker, ADR-051
/// §6); an unmarked spec is likewise an error — branch on the
/// `Pub`-typed marked spec composes through the put-forwarding
/// translation (`register_relay_pub_openable`; ADR-047 amendment 4
/// §4.7); an
/// unmarked spec is a
/// [`RelayError::UnmarkedSpec`] error — branch on the
/// marker before calling.
/// - The translate hop calls the producer leg with the spec's own
/// op name; a hub that re-names the consumer-visible op composes
@@ -145,14 +158,12 @@ impl ChannelRelay {
});
}
if spec.op_type == OperationType::Pub {
return Err(RelayError::PubTypedOpen {
message: format!(
"spec `{}` is a Pub-typed channel-open op — the Pub open path is \
not implemented (C-08 blocker, `channel:pub_open_not_implemented` \
class; ADR-051 §6)",
spec.name
),
});
return self.register_relay_pub_openable(
consumer_core,
consumer_registry,
spec,
consumer_auth,
);
}
let producer_call = Arc::clone(&self.producer_leg.call);
@@ -227,6 +238,76 @@ impl ChannelRelay {
)
.map_err(|e| RelayError::Registration { message: e })
}
/// Register a from_call-imported **marked `Pub`** spec as a relay
/// openable — the put-forwarding translation (ADR-047 amendment 4
/// §4.7). The consumer leg runs the full marked-pub wrapper (adopt
/// → establish → pub handler awaited inline); this side's compose:
///
/// - the establisher allocates the hub's connect-side id on the
/// producer leg (`open_channel` — an odd id, disjoint from the
/// spoke's even allocations), sends the open op **in the
/// background** (the spoke's put response cannot exist before
/// its byte stream completes; an establisher that awaited it
/// deadlocks the leg), and plans the spoke-leg streams + the
/// response's oneshot; the plan's Drop guard reclaims the
/// producer-leg entry on every handler path, as ADR-051 §6 pins.
/// - the handler pumps the consumer-leg bytes → the spoke leg
/// (one direction, pub), EOFs the spoke side on consumer EOF,
/// then awaits the spoke's single response, strips the
/// producer's reserved reply key (the consumer reply carries the
/// hub-allocated consumer-leg id; a spoke `channel_id` merged
/// through would shadow it — the same reserved-key class the
/// wrapper refuses), and returns the remainder — the consumer-leg
/// wrapper merges it with the pinned order (ack fields are none
/// here; the put acks in the spoke's result, §4.5).
///
/// A spoke open-time establishment failure surfaces verbatim in
/// the final response (put-acks-in-result; the spoke's mapped
/// `channel:open_failed` rides the response, not the establisher).
fn register_relay_pub_openable(
&self,
consumer_core: &ChannelCore,
consumer_registry: &OperationRegistry,
spec: OperationSpec,
consumer_auth: AuthContext,
) -> Result<(), RelayError> {
let producer_call = Arc::clone(&self.producer_leg.call);
let producer_manager = self.producer_leg.manager.clone();
let op_name = spec.name.clone();
let establisher: OpenEstablisher = Arc::new(move |input, per_call_auth| {
let producer_call = Arc::clone(&producer_call);
let producer_manager = producer_manager.clone();
let op_name = op_name.clone();
Box::pin(async move {
open_pub_on_producer_leg(
&producer_call,
&producer_manager,
&op_name,
input,
&per_call_auth,
)
.await
})
});
let pub_open_handler: PubOpenHandler =
Arc::new(move |_input, plan, channel_conn, _auth| {
Box::pin(async move { pub_relay_forward(channel_conn, plan).await })
});
consumer_core
.register_pub_openable(
spec,
Some(establisher),
pub_open_handler,
consumer_registry,
consumer_auth,
None,
)
.map_err(|e| RelayError::Registration { message: e })
}
}
/// The translate hop (ADR-051 §1 layer 1, §2): call the producer leg's
@@ -288,6 +369,7 @@ async fn open_on_producer_leg(
producer_manager: producer_manager.clone(),
spoke_send: Some(spoke_send),
spoke_recv: Some(spoke_recv),
pub_reply: None,
}) as super::operations::ChannelPlan);
Ok(if reply_fields.is_empty() {
establishment
@@ -334,6 +416,123 @@ fn map_spoke_error(spoke_error: &CallError) -> EstablishmentError {
}
}
/// The put path's translate hop (ADR-047 amendment 4 §4.7): allocate
/// the hub's connect-side channel id on the producer leg, send the
/// spoke's open op **in the background** with the reserved input key
/// set, and plan the spoke-leg streams + the response oneshot. The
/// establisher returns promptly; the spoke's response arrives when the
/// spoke's put completes (or fails) — put-acks-in-result.
async fn open_pub_on_producer_leg(
producer_call: &Arc<CallConnection>,
producer_manager: &ChannelManager,
op_name: &str,
input: Value,
per_call_auth: &AuthContext,
) -> Result<Establishment, EstablishmentError> {
let (spoke_id, spoke_send, spoke_recv) = producer_manager
.open_channel(
"alk/relay",
// The opener ledger is inert on the hub's connect side
// (the connect side does not enforce the per-identity cap,
// ADR-041) — the record is observability only.
"hub-relay",
None,
)
.await
.map_err(|e| EstablishmentError::ResourceShortage {
message: format!("producer leg: channel allocation failed: {e}"),
})?;
let mut hop_input = input.clone();
if let Some(obj) = hop_input.as_object_mut() {
obj.insert(
crate::registry::spec::RESERVED_OPEN_INPUT_KEY.to_string(),
serde_json::json!(spoke_id),
);
}
let payload = build_relay_payload(op_name, hop_input, per_call_auth);
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
let producer_call_task = Arc::clone(producer_call);
tokio::spawn(async move {
let _ = reply_tx.send(producer_call_task.call_with_payload(payload).await);
});
Ok(Establishment::new(Arc::new(RelayPlan {
spoke_id,
producer_manager: producer_manager.clone(),
spoke_send: Some(spoke_send),
spoke_recv: Some(spoke_recv),
pub_reply: Some(reply_rx),
}) as ChannelPlan))
}
/// The relay pub handler (ADR-047 amendment 4 §4.7): pump the
/// consumer-leg bytes → the spoke leg (one direction, pub — the
/// shutdown-on-completion contract, ADR-050's), then await the spoke's
/// single response and strip its reserved reply key. The plan's Drop
/// guard reclaims the producer-leg entry on every path.
async fn pub_relay_forward(
channel_conn: crate::core::types::Connection,
plan: Option<ChannelPlan>,
) -> Result<Value, CallError> {
let Some(plan) = plan else {
return Err(CallError::internal(
"relay: pub establisher produced no plan",
));
};
let Ok(relay_plan_arc) = plan.downcast::<RelayPlan>() else {
return Err(CallError::internal(
"relay: establisher plan of unexpected type",
));
};
let Ok(mut relay_plan) = Arc::try_unwrap(relay_plan_arc) else {
return Err(CallError::internal(
"relay: establisher plan held elsewhere; skipping pump",
));
};
let Some(mut spoke_send) = relay_plan.spoke_send.take() else {
return Err(CallError::internal(
"relay: pub plan missing the spoke send half",
));
};
relay_plan.spoke_recv.take();
let Ok(bidi) = channel_conn.accept_bi().await else {
return Err(CallError::internal(
"relay: consumer leg data plane never opened",
));
};
let (mut read_half, _write_half) = tokio::io::split(bidi);
// The one-direction pump: consumer bytes → spoke; shutdown on
// consumer EOF (the pump contract, ADR-050).
let _copied = tokio::io::copy(&mut read_half, &mut spoke_send)
.await
.unwrap_or(0);
let _ = spoke_send.shutdown().await;
drop(spoke_send);
match relay_plan.pub_reply.take() {
Some(rx) => match rx.await {
Ok(response) => match response.result {
Ok(mut out) => {
if let Some(obj) = out.as_object_mut() {
obj.remove(RESERVED_REPLY_KEY);
}
Ok(out)
}
Err(e) => Err(e),
},
Err(_) => Err(CallError::internal(
"relay: producer-leg open resolved without a response",
)),
},
None => Err(CallError::internal(
"relay: pub plan missing the response receiver",
)),
}
}
/// Build the `call.requested` payload for the translate hop: the hub
/// as caller, the consumer as `forwarded_for` (ADR-026 §3, ADR-032 §3
/// — the same shape `build_forwarded_payload` produces for the plain
@@ -357,18 +556,15 @@ fn build_relay_payload(op_name: &str, input: Value, per_call_auth: &AuthContext)
/// The assembly-time errors `ChannelRelay::register_relay_openable`
/// produces (ADR-051 §6 — the rejection postures are loud, never
/// silent stubs).
/// silent stubs; the `PubTypedOpen` variant was removed by ADR-047
/// amendment 4 §4.7 — pub-typed marked specs translate now, never
/// reject-and-strand).
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RelayError {
/// The spec carries no `channel_open` marker — the relay only
/// registers marked specs.
#[error("unmarked spec: {message}")]
UnmarkedSpec { message: String },
/// A `Pub`-typed marked spec — the Pub open path is not
/// implemented (C-08 blocker; `channel:pub_open_not_implemented`
/// class).
#[error("pub-typed open op: {message}")]
PubTypedOpen { message: String },
/// The consumer-leg registration itself failed (schema compile,
/// handler-kind mismatch).
#[error("registration failed: {message}")]
+25 -11
View File
@@ -89,15 +89,21 @@ async fn unmarked_spec_is_rejected_at_registration() {
}
#[tokio::test]
async fn pub_typed_marked_spec_is_rejected_at_registration() {
async fn pub_typed_marked_spec_registers_through_the_relay() {
// ADR-047 amendment 4 §4.7: the put-forwarding translation replaced
// the `PubTypedOpen` rejection — a pub-typed marked spec composes
// through `register_pub_openable` (the full marked-pub wrapper
// machinery) and lands on the consumer-leg registry, discoverable.
let producer_manager = manager_for_test();
let relay = ChannelRelay::new(ProducerLeg {
call: overlay_connection(),
manager: manager_for_test(),
manager: producer_manager,
});
let err = relay
let registry = OperationRegistry::new();
relay
.register_relay_openable(
&core_for_test(),
&OperationRegistry::new(),
&registry,
relay_spec(
"channels/x/pub",
OperationType::Pub,
@@ -105,12 +111,10 @@ async fn pub_typed_marked_spec_is_rejected_at_registration() {
),
AuthContext::anonymous(b"alk/call"),
)
.expect_err("Pub-typed marked spec must be rejected");
assert!(
matches!(err, RelayError::PubTypedOpen { .. }),
"got {err:?}"
);
assert!(err.to_string().contains("pub_open_not_implemented"));
.expect("the pub openable registers through the relay");
assert!(registry.registration("channels/x/pub").is_some());
let registration = registry.registration("channels/x/pub").expect("registered");
assert!(registration.spec.channel_open.is_some());
}
// --- ADR-051 §3: the reason mapping --------------------------------------
@@ -228,6 +232,7 @@ async fn relay_plan_drop_reclaims_the_adopted_producer_leg_entry() {
producer_manager: producer_manager.clone(),
spoke_send: Some(spoke_send),
spoke_recv: Some(spoke_recv),
pub_reply: None,
};
drop(plan);
@@ -256,7 +261,16 @@ fn manager_for_test() -> ChannelManager {
tokio::spawn(async move {
let _ = runner.run().await;
});
ChannelManager::with_defaults(handle, None)
// The producer leg's manager belongs to the hub, which is the
// connect side of the hub↔spoke channels connection (ADR-051 §4 —
// the spoke's ids are even, the hub's odd).
ChannelManager::new(
handle,
crate::channels::manager::DEFAULT_MAX_CHANNELS,
crate::channels::reassembly::DEFAULT_BUFFER_CAP,
None,
crate::channels::manager::ChannelSide::Connect,
)
}
fn core_for_test() -> ChannelCore {
+70
View File
@@ -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()
}
}
+3 -1
View File
@@ -1244,7 +1244,9 @@ mod tests {
/// `from_call` against a stub `CallConnection` (no real transport) returns
/// a `DiscoveryFailed` because `services/list` can't dispatch on a mock
/// connection. This verifies the error path rather than the happy path
/// (the happy path is covered by the integration test in a later task).
/// (the happy path is covered by the loopback integration tests
/// in `src/channels/relay_tests.rs`, `src/channels/hub_leg_tests.rs`,
/// and `src/channels/gate2_tests.rs`).
#[tokio::test]
async fn from_call_against_mock_connection_returns_discovery_failed() {
let conn = CallConnection::new(stub_connection());
+5 -2
View File
@@ -13,14 +13,17 @@ pub use from_call::{from_call, FromCallConfig};
// crate-internal surface for the `op/register` bootstrap path (review
// 004 F-05): the forwarding-handler constructor and the spec wire
// parser are shared with `registry::op_register`.
pub(crate) use from_call::{make_forwarding_handler, rebuild_spec_for};
pub(crate) use from_call::{
make_forwarding_handler, make_sink_forwarding_handler, make_streaming_forwarding_handler,
rebuild_spec_for,
};
use crate::registry::registration::HandlerRegistration;
/// Errors produced by [`OperationAdapter::import`].
///
/// The variant set is the v1 default (two-way-door remainder, OQ-26);
/// `#[non_exhaustive]` lets downstream adapters (e.g. `alknet-http`'s
/// `#[non_exhaustive]` lets downstream adapters (e.g. `alkhttp`'s
/// `from_openapi`/`from_mcp`) extend without breaking match arms. All
/// payloads are string messages — kept simple and `Send + Sync` by
/// construction.
+1 -1
View File
@@ -64,7 +64,7 @@ pub struct Capabilities {
impl Zeroize for Capabilities {
fn zeroize(&mut self) {
for (_, v) in self.entries.iter_mut() {
for v in self.entries.values_mut() {
v.zeroize();
}
self.entries.clear();
+2 -2
View File
@@ -538,7 +538,7 @@ mod tests {
None,
Capabilities::new(),
);
conn.register_imported(imported);
let _error = conn.register_imported(imported);
let peer_identity = Identity {
id: "worker-a".to_string(),
scopes: vec![],
@@ -607,7 +607,7 @@ mod tests {
None,
Capabilities::new(),
);
conn.register_imported(imported);
let _error = conn.register_imported(imported);
let context =
adapter.build_root_context("req-6".to_string(), "fs/readFile", None, None, &conn);
+52 -14
View File
@@ -159,9 +159,27 @@ impl CallConnection {
&self.pending
}
pub fn register_imported(&self, registration: HandlerRegistration) {
/// Land a peer-announced/discovered bundle on the connection
/// overlay. Marked (`channel_open`) specs refuse here: a marked op
/// is unreachable by composition (`OperationEnv::invoke` is
/// request/response-only; ADR-046 §6) and its data plane has no
/// overlay path — registration-time loudness, never an advertised
/// dead body (ADR-047 amendment 4 §4.8; ADR-022's 2026-10-13
/// amendment). Relay composition
/// (`ChannelRelay::register_relay_openable`) is the sanctioned
/// marked-op import.
pub fn register_imported(&self, registration: HandlerRegistration) -> Result<(), String> {
if registration.spec.channel_open.is_some() {
return Err(format!(
"register_imported: spec `{}` carries the `channel_open` marker — marked \
specs are not overlay-importable; compose `ChannelRelay` or consume via \
`ChannelClient` (ADR-047 amendment 4 §4.8)",
registration.spec.name
));
}
let name = registration.spec.name.clone();
self.imported_operations.write().insert(name, registration);
Ok(())
}
/// `true` when the connection overlay holds a registration for
@@ -176,11 +194,30 @@ impl CallConnection {
self.imported_operations.read().get(name).cloned()
}
pub fn register_imported_all(&self, registrations: Vec<HandlerRegistration>) {
/// Install a bundle of remote announcements on the connection
/// overlay. Atomic: if any registration carries the `channel_open`
/// marker, the overlay is left untouched (nothing partially
/// installed).
pub fn register_imported_all(
&self,
registrations: Vec<HandlerRegistration>,
) -> Result<(), String> {
let mut overlay = self.imported_operations.write();
for reg in registrations {
overlay.insert(reg.spec.name.clone(), reg);
for reg in &registrations {
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> {
@@ -975,7 +1012,7 @@ mod tests {
assert!(!env.contains("worker/exec"));
conn.register_imported(imported_registration("worker/exec"));
let _error = conn.register_imported(imported_registration("worker/exec"));
assert!(env.contains("worker/exec"));
assert!(!env.contains("worker/missing"));
@@ -986,11 +1023,12 @@ mod tests {
let conn = CallConnection::new(stub_connection());
let env = conn.overlay_env();
conn.register_imported_all(vec![
let registrations_result = conn.register_imported_all(vec![
imported_registration("worker/exec"),
imported_registration("worker/status"),
imported_registration("fs/readFile"),
]);
let _error = registrations_result;
assert!(env.contains("worker/exec"));
assert!(env.contains("worker/status"));
@@ -1001,7 +1039,7 @@ mod tests {
#[tokio::test]
async fn overlay_env_dispatches_to_imported_op() {
let conn = CallConnection::new(stub_connection());
conn.register_imported(imported_registration("worker/exec"));
let _error = conn.register_imported(imported_registration("worker/exec"));
let env = conn.overlay_env();
let scoped = ScopedPeerEnv::new(["worker/exec"]);
@@ -1018,7 +1056,7 @@ mod tests {
#[tokio::test]
async fn overlay_env_contains_returns_false_for_non_imported_op() {
let conn = CallConnection::new(stub_connection());
conn.register_imported(imported_registration("worker/exec"));
let _error = conn.register_imported(imported_registration("worker/exec"));
let env = conn.overlay_env();
assert!(!env.contains("worker/missing"));
@@ -1039,7 +1077,7 @@ mod tests {
#[tokio::test]
async fn overlay_env_reachability_check_returns_not_found_for_disallowed_op() {
let conn = CallConnection::new(stub_connection());
conn.register_imported(imported_registration("worker/exec"));
let _error = conn.register_imported(imported_registration("worker/exec"));
let env = conn.overlay_env();
let scoped = ScopedPeerEnv::empty();
@@ -1069,7 +1107,7 @@ mod tests {
}),
)
});
conn.register_imported(HandlerRegistration::new(
let _error = conn.register_imported(HandlerRegistration::new(
external_spec("worker/exec"),
HandlerKind::Once(inspect_handler),
OperationProvenance::FromCall,
@@ -1115,7 +1153,7 @@ mod tests {
Arc::new(RwLock::new(HashMap::new()));
{
let conn = CallConnection::new(stub_connection());
conn.register_imported(imported_registration("worker/exec"));
let _error = conn.register_imported(imported_registration("worker/exec"));
assert!(conn.overlay_env().contains("worker/exec"));
std::mem::swap(
&mut *captured.write(),
@@ -1380,14 +1418,14 @@ mod tests {
let conn = CallConnection::new_overlay_only(sample_identity("ws-peer"));
let env = conn.overlay_env();
assert!(!env.contains("worker/exec"));
conn.register_imported(imported_registration("worker/exec"));
let _error = conn.register_imported(imported_registration("worker/exec"));
assert!(env.contains("worker/exec"));
}
#[tokio::test]
async fn overlay_only_overlay_env_dispatches_imported_op() {
let conn = CallConnection::new_overlay_only(sample_identity("ws-peer"));
conn.register_imported(imported_registration("worker/exec"));
let _error = conn.register_imported(imported_registration("worker/exec"));
let env = conn.overlay_env();
let scoped = ScopedPeerEnv::new(["worker/exec"]);
@@ -1433,7 +1471,7 @@ mod tests {
let streaming_handler = make_streaming_handler(|input, ctx| {
futures::stream::iter(vec![ResponseEnvelope::ok(ctx.request_id, input)])
});
conn.register_imported(HandlerRegistration::new(
let _error = conn.register_imported(HandlerRegistration::new(
OperationSpec::new(
"events/stream",
OperationType::Sub,
+110 -2
View File
@@ -86,6 +86,11 @@ pub struct SinkDispatch {
pub(crate) handler: Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>,
pub(crate) chunk_tx: mpsc::Sender<Result<Value, CallError>>,
pub(crate) publish_validator: Option<jsonschema::Validator>,
/// Marked (`channel_open`) `Pub` op (ADR-047 amendment 4 §4.1):
/// publish chunks never feed the handler — the first
/// `call.published` chunk is rejected loudly in the initiator's
/// direction and the rest are ignored.
pub(crate) reject_publish: bool,
}
/// An in-flight Pub (Sink) on the single-stream dispatch loop
@@ -100,6 +105,11 @@ struct InFlightSink {
chunk_tx: mpsc::Sender<Result<Value, CallError>>,
publish_validator: Option<jsonschema::Validator>,
handler_handle: JoinHandle<()>,
/// Marked (`channel_open`) `Pub` op (ADR-047 amendment 4 §4.1):
/// the first `call.published` chunk is rejected loudly in the
/// initiator's direction and never fed to the handler.
reject_publish: bool,
reject_written: bool,
}
impl std::fmt::Debug for DispatchResult {
@@ -356,7 +366,7 @@ impl Dispatcher {
.get("forwarded_for")
.and_then(|v| serde_json::from_value::<Identity>(v.clone()).ok());
let input = payload.get("input").cloned().unwrap_or(Value::Null);
let mut input = payload.get("input").cloned().unwrap_or(Value::Null);
let op_type = self
.registry
@@ -391,9 +401,13 @@ impl Dispatcher {
context.deadline = None;
match self
.registry
.resolve_sink_handler(&operation_name, &input, &context)
.resolve_sink_handler(&operation_name, &mut input, &mut context)
{
Ok(sink_handler) => {
let reject_publish = self
.registry
.registration(&operation_name)
.is_some_and(|r| r.spec.channel_open.is_some());
let publish_validator = self.registry.publish_validator(&operation_name);
let (chunk_tx, chunk_rx) =
mpsc::channel::<Result<Value, CallError>>(PUBLISH_CHANNEL_BUFFER);
@@ -403,6 +417,7 @@ impl Dispatcher {
handler: Box::pin(handler),
chunk_tx,
publish_validator,
reject_publish,
})
}
Err(envelope) => StartedDispatch::Once(Box::pin(std::future::ready(envelope))),
@@ -657,6 +672,7 @@ impl Dispatcher {
handler,
mut chunk_tx,
publish_validator,
reject_publish,
} = sink;
let (abort_tx, mut abort_rx) = oneshot::channel::<()>();
@@ -668,6 +684,7 @@ impl Dispatcher {
let mut handler = handler.fuse();
let mut response: Option<ResponseEnvelope> = None;
let mut aborted = false;
let mut reject_written = false;
loop {
tokio::select! {
@@ -698,6 +715,31 @@ impl Dispatcher {
}
match envelope.r#type.as_str() {
EVENT_PUBLISHED => {
// Marked ops: the data plane is the adopted
// channel's `BiStream` — publish chunks are
// rejected loudly, never consumed (ADR-047
// amendment 4 §4.1). The handler continues on
// the channel plane; the initiator's pending
// call resolves with this rejection.
if reject_publish {
if !reject_written {
reject_written = true;
let rejection: EventEnvelope = ResponseEnvelope::error(
request_id.clone(),
CallError::new(
crate::registry::spec::MARKED_PUB_CHUNKS_REJECTED,
"the data plane of a marked pub op is the adopted \
channel's BiStream — call.published chunks are \
rejected (ADR-047 amendment 4 §4.1)",
false,
),
).into();
if let Err(err) = writer.write_frame(&rejection).await {
warn!(error = %err, request_id = %request_id, "failed to write marked-op publish rejection");
}
}
continue;
}
let chunk = envelope
.payload
.get("input")
@@ -923,6 +965,7 @@ impl Dispatcher {
handler,
chunk_tx,
publish_validator,
reject_publish,
} = sink;
let handle = Self::spawn_sink_response_writer(
&writer,
@@ -935,6 +978,8 @@ impl Dispatcher {
chunk_tx,
publish_validator,
handler_handle: handle,
reject_publish,
reject_written: false,
},
);
}
@@ -977,6 +1022,36 @@ impl Dispatcher {
.unwrap_or(Value::Null);
let entry = in_flight_sinks.lock().remove(&request_id);
if let Some(mut entry) = entry {
if entry.reject_publish {
// Marked ops: the data plane is the adopted
// channel's `BiStream` — publish chunks are
// rejected loudly, never consumed (ADR-047
// amendment 4 §4.1). The loud error is written
// once per request; the entry stays registered
// so the handler's own final response still gets
// written. The handler continues on the channel
// plane; the initiator's pending call resolves
// with this rejection immediately.
if !entry.reject_written {
entry.reject_written = true;
let rejection: EventEnvelope = ResponseEnvelope::error(
request_id.clone(),
CallError::new(
crate::registry::spec::MARKED_PUB_CHUNKS_REJECTED,
"the data plane of a marked pub op is the \
adopted channel's BiStream — call.published \
chunks are rejected (ADR-047 amendment 4 §4.1)",
false,
),
)
.into();
if let Err(err) = writer.write_frame(&rejection).await {
warn!(error = %err, request_id = %request_id, "failed to write marked-op publish rejection");
}
}
in_flight_sinks.lock().insert(request_id, entry);
continue;
}
let validated = match &entry.publish_validator {
Some(validator) => {
if validator.is_valid(&chunk) {
@@ -1146,6 +1221,7 @@ impl Dispatcher {
handler,
chunk_tx,
publish_validator,
reject_publish,
} = sink;
let handle = Self::spawn_sink_response_writer(
&writer,
@@ -1158,6 +1234,8 @@ impl Dispatcher {
chunk_tx,
publish_validator,
handler_handle: handle,
reject_publish,
reject_written: false,
},
);
}
@@ -1200,6 +1278,36 @@ impl Dispatcher {
.unwrap_or(Value::Null);
let entry = in_flight_sinks.lock().remove(&request_id);
if let Some(mut entry) = entry {
if entry.reject_publish {
// Marked ops: the data plane is the adopted
// channel's `BiStream` — publish chunks are
// rejected loudly, never consumed (ADR-047
// amendment 4 §4.1). The loud error is written
// once per request; the entry stays registered
// so the handler's own final response still gets
// written. The handler continues on the channel
// plane; the initiator's pending call resolves
// with this rejection immediately.
if !entry.reject_written {
entry.reject_written = true;
let rejection: EventEnvelope = ResponseEnvelope::error(
request_id.clone(),
CallError::new(
crate::registry::spec::MARKED_PUB_CHUNKS_REJECTED,
"the data plane of a marked pub op is the \
adopted channel's BiStream — call.published \
chunks are rejected (ADR-047 amendment 4 §4.1)",
false,
),
)
.into();
if let Err(err) = writer.write_frame(&rejection).await {
warn!(error = %err, request_id = %request_id, "failed to write marked-op publish rejection");
}
}
in_flight_sinks.lock().insert(request_id, entry);
continue;
}
let validated = match &entry.publish_validator {
Some(validator) => {
if validator.is_valid(&chunk) {
+151 -22
View File
@@ -178,16 +178,37 @@ pub fn op_register_handler(
}
let mut spec = request.spec;
if spec.channel_open.is_some() {
return ResponseEnvelope::error(
context.request_id,
CallError::new(
"MARKED_SPEC_UNSUPPORTED",
format!(
"op/register: `{}` carries the `channel_open` marker — marked ops \
are channel-machinery ops (the data plane is the allocated \
channel's `BiStream`, ADR-047 amendment 4); forward them with \
`ChannelRelay::register_relay_openable` (a relaying hub) or \
consume them directly (`ChannelClient`), never as an \
announcement",
spec.name
),
false,
),
);
}
spec.visibility = Visibility::Internal;
let remote_name = spec.name.clone();
let handler = forwarding_stub_for_announced_op(
let handler = forwarding_kind_for_announced_op(
Arc::clone(&connection),
remote_name,
spec.op_type,
);
connection.register_imported(HandlerRegistration::new(
// The marked-refusal arm is unreachable here (marked
// announcements were refused above); an invariant break
// maps to the wire rather than a panic.
let registered = connection.register_imported(HandlerRegistration::new(
spec.clone(),
handler,
OperationProvenance::FromCall,
@@ -195,6 +216,12 @@ pub fn op_register_handler(
None,
Capabilities::new(),
));
if let Err(e) = registered {
return ResponseEnvelope::error(
context.request_id,
CallError::internal(format!("op/register: overlay landing refused: {e}")),
);
}
ResponseEnvelope::ok(
context.request_id,
@@ -204,15 +231,14 @@ pub fn op_register_handler(
})
}
/// The forwarding stub for a peer-announced op. Query/Mutation ops get
/// the `from_call` forwarding shape (nested `call.requested` back over
/// channel 0, `forwarded_for` populated per ADR-032 §3). Announced
/// Sub/Pub ops are registered as stubs that return
/// `INVALID_OPERATION_TYPE` on invocation — nested composition is
/// request/response-only (`OverlayOperationEnv`'s contract); the
/// streaming/sink forwarding shapes ride on the `from_call` import
/// path, which the announcing side can use in the other direction.
fn forwarding_stub_for_announced_op(
/// The forwarding handler kind for a peer-announced op. Query/Mutation
/// get the `from_call` forwarding shape (nested `call.requested` back
/// over channel 0, `forwarded_for` populated per ADR-032 §3); unmarked
/// `Sub`/`Pub` get the real streaming/sink forwarding shapes (review
/// 010 S-04's remedy: never accept-then-fail — the handler actually
/// forwards). Marked specs refuse at this boundary (ADR-047 amendment
/// 4 — checked before this function is reached).
fn forwarding_kind_for_announced_op(
connection: Arc<CallConnection>,
remote_name: String,
op_type: OperationType,
@@ -221,17 +247,13 @@ fn forwarding_stub_for_announced_op(
OperationType::Query | OperationType::Mutation => HandlerKind::Once(
crate::client::make_forwarding_handler(connection, remote_name),
),
OperationType::Sub | OperationType::Pub => {
HandlerKind::Once(make_handler(|_input, context| async move {
ResponseEnvelope::error(
context.request_id,
CallError::invalid_operation_type(
"peer-announced Sub/Pub ops are not invocable over nested \
composition (request/response only)",
),
)
}))
}
OperationType::Sub => HandlerKind::Stream(
crate::client::make_streaming_forwarding_handler(connection, remote_name),
),
OperationType::Pub => HandlerKind::Sink(crate::client::make_sink_forwarding_handler(
connection,
remote_name,
)),
}
}
@@ -757,4 +779,111 @@ mod tests {
.expect("marker survives the announce");
assert_eq!(marker.alpn, "alk/tunnel");
}
// --- review 010 S-04's remedy (ADR-022, 2026-10-13 amendment) --------
/// Forward-through: an announced unmarked `Sub` op registers the
/// real streaming forwarding shape, not an always-failing stub.
#[tokio::test]
async fn announced_unmarked_sub_registers_real_streaming_forward() {
let conn = Arc::new(CallConnection::new(stub_connection()));
let handler = op_register_handler(Arc::clone(&conn), Arc::new(OperationRegistry::new()));
let input = OpRegisterRequest {
spec: OperationSpec::new(
"worker/events",
OperationType::Sub,
Visibility::External,
json!({}),
json!({}),
vec![],
AccessControl::default(),
None,
),
replace: false,
}
.to_json();
let response = handler(input, test_context("req-s04-1")).await;
assert!(response.result.is_ok(), "got {:?}", response.result);
let registration = conn
.overlay_registration("worker/events")
.expect("registered");
assert!(
matches!(registration.handler, HandlerKind::Stream(_)),
"the announced sub op is a real forwarding handler, not a stub"
);
}
/// Forward-through: an announced unmarked `Pub` op registers the
/// real sink forwarding shape.
#[tokio::test]
async fn announced_unmarked_pub_registers_real_sink_forward() {
let conn = Arc::new(CallConnection::new(stub_connection()));
let handler = op_register_handler(Arc::clone(&conn), Arc::new(OperationRegistry::new()));
let input = OpRegisterRequest {
spec: OperationSpec::new(
"worker/ingest",
OperationType::Pub,
Visibility::External,
json!({}),
json!({}),
vec![],
AccessControl::default(),
None,
),
replace: false,
}
.to_json();
let response = handler(input, test_context("req-s04-2")).await;
assert!(response.result.is_ok(), "got {:?}", response.result);
let registration = conn
.overlay_registration("worker/ingest")
.expect("registered");
assert!(
matches!(registration.handler, HandlerKind::Sink(_)),
"the announced pub op is a real forwarding handler, not a stub"
);
}
/// Marked-spec refusal (ADR-047 amendment 4 §4.8's wire side): a
/// marked announcement is rejected with the typed code, no overlay
/// entry, and the message names the sanctioned compositions.
#[tokio::test]
async fn announced_marked_spec_is_refused() {
use crate::registry::spec::ChannelOpenSpec;
let conn = Arc::new(CallConnection::new(stub_connection()));
let handler = op_register_handler(Arc::clone(&conn), Arc::new(OperationRegistry::new()));
let input = OpRegisterRequest {
spec: OperationSpec::new(
"channels/tunnel/direct",
OperationType::Sub,
Visibility::External,
json!({}),
json!({}),
vec![],
AccessControl::default(),
None,
)
.with_channel_open(ChannelOpenSpec::new("alk/tunnel")),
replace: false,
}
.to_json();
let response = handler(input, test_context("req-s04-3")).await;
let e = response.result.expect_err("marked announcement refused");
assert_eq!(e.code, "MARKED_SPEC_UNSUPPORTED");
assert!(
e.message.contains("ChannelRelay"),
"the refusal names the sanctioned composition: {}",
e.message
);
assert!(
!conn.overlay_contains("channels/tunnel/direct"),
"the refused announcement lands nowhere"
);
}
}
+170 -8
View File
@@ -84,6 +84,12 @@ pub struct HandlerRegistration {
pub composition_authority: Option<CompositionAuthority>,
pub scoped_env: Option<ScopedPeerEnv>,
pub capabilities: Capabilities,
/// Set only by the channels-layer wrapper builders — a marked
/// (`channel_open`) spec is registered only through the
/// channel-machinery seam (`register_openable*`, `register_pub_openable`,
/// the relay). Generic registrations of marked specs are refused
/// (ADR-047 amendment 4 §4.8 — the registered-but-incapable class).
pub(crate) channel_wrapper: bool,
}
impl HandlerRegistration {
@@ -102,8 +108,17 @@ impl HandlerRegistration {
composition_authority,
scoped_env,
capabilities,
channel_wrapper: false,
}
}
/// The channel-machinery wrapper family's constructor seam
/// (crate-internal — the only sanctioned registrations of marked
/// specs).
pub(crate) fn with_channel_wrapper(mut self) -> Self {
self.channel_wrapper = true;
self
}
}
pub struct OperationRegistry {
@@ -215,6 +230,23 @@ impl OperationRegistry {
registration.spec.op_type, expected, actual
));
}
if registration.spec.channel_open.is_some() && !registration.channel_wrapper {
return Err(format!(
"spec `{}` carries the `channel_open` marker — marked ops are registered \
only through the channel-machinery seam (`ChannelCore::register_openable*`, \
`ChannelCore::register_pub_openable`, `ChannelRelay::register_relay_openable`); \
a generic registration cannot serve the marker's data plane (ADR-047 amendment 4 §4.8)",
registration.spec.name
));
}
if registration.spec.channel_open.is_some() && registration.spec.publish_schema.is_some() {
return Err(format!(
"spec `{}` carries both the `channel_open` marker and a `publish_schema` — \
publish chunks never exist on a marked op (the data plane is the adopted \
channel's `BiStream`); the field is dead machinery here (ADR-047 amendment 4 §4.2)",
registration.spec.name
));
}
Self::validate_and_cache_publish_schema(&mut self.validators.write(), &registration.spec)?;
Self::validate_and_cache_input_schema(
&mut self.input_validators.write(),
@@ -436,12 +468,21 @@ impl OperationRegistry {
/// dispatch path (`Dispatcher::dispatch` Pub branch) call it, so the
/// two paths cannot diverge (P-08: the `invoke_sink` path IS the
/// wire path).
///
/// Marked `Pub` ops (ADR-047 amendment 4 §4.3): the
/// wrapper-reserved input key ([`crate::registry::spec::
/// RESERVED_OPEN_INPUT_KEY`]) is stripped **before**
/// `input_schema` validation — the producing crate's schema never
/// contains it — and the initiator-allocated channel id is handed
/// to the wrapper via `context.metadata`. A marked pub op whose
/// input lacks the reserved key is rejected here (`INVALID_INPUT`)
/// — the id is mandatory, not optional plumbing.
#[allow(clippy::result_large_err)]
pub(crate) fn resolve_sink_handler(
&self,
name: &str,
input: &Value,
context: &OperationContext,
input: &mut Value,
context: &mut OperationContext,
) -> Result<SinkHandler, ResponseEnvelope> {
let request_id = context.request_id.clone();
let registration = match self.registration(name) {
@@ -477,6 +518,43 @@ impl OperationRegistry {
return Err(ResponseEnvelope::forbidden(request_id, message));
}
if registration.spec.channel_open.is_some()
&& registration.spec.op_type == OperationType::Pub
{
let reserved_id = match input
.as_object_mut()
.and_then(|obj| obj.remove(crate::registry::spec::RESERVED_OPEN_INPUT_KEY))
{
Some(value) => match value.as_u64() {
Some(id) if id <= u32::MAX as u64 => id as u32,
_ => {
return Err(ResponseEnvelope::error(
request_id,
CallError::invalid_input(format!(
"marked pub open op: the reserved `{}` key must be a u32 \
channel id (the initiator-allocated id; ADR-047 amendment 4 §4.3)",
crate::registry::spec::RESERVED_OPEN_INPUT_KEY
)),
));
}
},
None => {
return Err(ResponseEnvelope::error(
request_id,
CallError::invalid_input(format!(
"marked pub open op input missing the reserved `{}` key \
(the initiator-allocated channel id; ADR-047 amendment 4 §4.3)",
crate::registry::spec::RESERVED_OPEN_INPUT_KEY
)),
));
}
};
context.metadata.insert(
crate::registry::spec::RESERVED_OPEN_METADATA_KEY.to_string(),
serde_json::json!(reserved_id),
);
}
self.check_input_schema(&registration, input, &request_id)?;
match &registration.handler {
@@ -505,14 +583,16 @@ impl OperationRegistry {
pub async fn invoke_sink(
&self,
name: &str,
input: Value,
mut input: Value,
publish_stream: PublishStream,
context: OperationContext,
mut context: OperationContext,
) -> ResponseEnvelope {
match self.resolve_sink_handler(name, &input, &context) {
Err(envelope) => envelope,
Ok(sink_handler) => sink_handler(input, context, publish_stream).await,
}
let publish_stream_resolved =
match self.resolve_sink_handler(name, &mut input, &mut context) {
Err(envelope) => return envelope,
Ok(sink_handler) => sink_handler,
};
publish_stream_resolved(input, context, publish_stream).await
}
}
@@ -2942,3 +3022,85 @@ mod tests {
assert_eq!(err.code, "INVALID_INPUT");
}
}
#[cfg(test)]
mod marked_spec_gates {
use super::*;
fn marked_spec(name: &str, op_type: OperationType) -> OperationSpec {
OperationSpec::new(
name,
op_type,
crate::registry::spec::Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
crate::registry::spec::AccessControl::default(),
None,
)
.with_channel_open(crate::registry::spec::ChannelOpenSpec::new("alk/x"))
}
/// ADR-047 amendment 4 §4.2 gate: `publish_schema` on a marked
/// spec refuses at registration — publish chunks never exist on a
/// marked op. (Registered through the sanctioned wrapper seam, so
/// the §4.8 invariant is satisfied and this refusal is the one
/// that fires.)
#[test]
fn marked_spec_with_publish_schema_refused() {
let registry = OperationRegistry::new();
let spec = marked_spec("channels/x/pub", OperationType::Pub)
.with_publish_schema(serde_json::json!({ "type": "object" }));
let registration = HandlerRegistration::new(
spec,
HandlerKind::Sink(make_sink_handler(|_input, _ctx, _stream| async {
ResponseEnvelope::ok("r", serde_json::json!({}))
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
)
.with_channel_wrapper();
let e = registry.register(registration).expect_err("refused");
assert!(e.contains("publish_schema"), "got: {e}");
}
/// ADR-047 amendment 4 §4.8 gate: a marked spec through the
/// generic registration path is refused — the registered-but-
/// incapable class review 010 outlaws; the sanctioned seam is the
/// channel wrapper family.
#[test]
fn marked_spec_refused_outside_the_wrapper_seam() {
let registry = OperationRegistry::new();
let spec = marked_spec("channels/x/pub", OperationType::Pub);
let registration = HandlerRegistration::new(
spec,
HandlerKind::Sink(make_sink_handler(|_input, _ctx, _stream| async {
ResponseEnvelope::ok("r", serde_json::json!({}))
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
);
let e = registry.register(registration).expect_err("refused");
assert!(e.contains("channel-machinery seam"), "got: {e}");
// A marked `Sub` spec — the same class.
let registry2 = OperationRegistry::new();
let spec2 = marked_spec("channels/x/sub", OperationType::Sub);
let registration2 = HandlerRegistration::new(
spec2,
HandlerKind::Stream(make_streaming_handler(|_input, _ctx| {
Box::pin(futures::stream::empty())
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
);
let e2 = registry2.register(registration2).expect_err("refused");
assert!(e2.contains("channel-machinery seam"), "got: {e2}");
}
}
+22
View File
@@ -49,6 +49,28 @@ pub struct ChannelOpenSpec {
pub alpn: Cow<'static, str>,
}
/// The wrapper-reserved input key on a **marked `Pub` op's** input
/// (ADR-047 amendment 4 §4.3): the initiator-allocated `channel_id` the
/// responder adopts. The same key the open wrapper owns on the reply
/// side (the channels layer's `RESERVED_REPLY_KEY`); the wrapper owns
/// `channel_id` on both sides of a marked open op. Stripped
/// pre-validation in the sink resolve path and delivered to the
/// wrapper via `OperationContext.metadata`
/// ([`RESERVED_OPEN_METADATA_KEY`]).
pub const RESERVED_OPEN_INPUT_KEY: &str = "channel_id";
/// The `OperationContext.metadata` key the sink resolve path uses to
/// hand the stripped reserved input value (the initiator-allocated
/// channel id) to the marked-pub open wrapper (ADR-047 amendment 4
/// §4.3).
pub const RESERVED_OPEN_METADATA_KEY: &str = "channel_open_channel_id";
/// The wire error code for a `call.published` chunk arriving on a
/// marked (`channel_open`) `Pub` op (ADR-047 amendment 4 §4.1): the
/// marked op's data plane is the adopted channel's `BiStream`; publish
/// chunks are rejected loudly and never consumed.
pub const MARKED_PUB_CHUNKS_REJECTED: &str = "channel:publish_chunks_rejected";
impl ChannelOpenSpec {
pub fn new(alpn: impl Into<Cow<'static, str>>) -> Self {
Self { alpn: alpn.into() }
+49
View File
@@ -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
));
}
}
}
}
}