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.
This commit is contained in:
glm-5.3-flash committed 2026-10-13 10:53:47 +00:00
1 parent 7b8a3e7558
commit 5f019c02f1
26 files changed
+661 -374

No files matched your search

+51 -33
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-10-13
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..053). 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 |
|-----|-------|-----------|
@@ -106,6 +107,7 @@ are wire-stable and unchanged — see ADR-004.
| [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
@@ -128,6 +130,11 @@ questions affecting this crate:
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
@@ -192,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 + │
@@ -245,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
@@ -285,31 +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**: alkcall's own ADRs are
> `decisions/001..053` in this directory. Some ported docs also cite
> sibling-crate ADRs that were **not** ported (e.g., alknet ADR-052 for
> TTY's wire format, alknet ADR-034 for outgoing-only X.509, alknet
> ADR-082 for alknet-tls, alknet ADR-086 for endpoint types). 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/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).
> 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)).
+17 -18
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,22 +22,20 @@ 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 |
| [031](decisions/031-crate-decomposition.md) | Crate Decomposition | alknet-call depends on alknet-core (no irpc — ADR-014) |
| [031](decisions/031-crate-decomposition.md) | Crate Decomposition | alkcall is self-contained: call + channels subsystems + vendored core types (no irpc — ADR-014) |
| [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 alknet-call; supersedes ADR-013 |
| [014](decisions/014-irpc-never-integrated-hand-rolled-framing.md) | Hand-Rolled EventEnvelope Framing | Wire format, registry, dispatch are hand-rolled in alkcall; supersedes ADR-013 |
| [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 |
| [008[008](/workspace/@alkdev/alknet/docs/architecture/decisions/008-secret-service-integration.md) | Vault Integration Point | Vault accessed at assembly layer, not on the wire |
| [010[010](/workspace/@alkdev/alknet/docs/architecture/decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Static handler registration |
| [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 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 |
| [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) |
@@ -45,9 +43,10 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
| [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` |
| [033[033](/workspace/@alkdev/alknet/docs/architecture/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 |
| [045](decisions/045-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 |
| [012](decisions/012-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 |
| [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
@@ -60,16 +59,16 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
| 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 `AlknetClient`). `refresh()` is a genuine feature addition. See ADR-028. |
| OQ-12 | from_call re-import trigger | **resolved** | `from_call` is a manual free function; the assembly layer calls it after the dial (in the composition layer's dial helper, `AlknetClient` — today the alknet glue; OQ-43). `refresh()` is a genuine feature addition. See ADR-028. |
| OQ-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 alknet-side) | Core trait + in-memory default; persistence adapters are separate crates |
| OQ-19 | Persistent peer registry | **resolved** (ADR-025; persistence adapters are composition-layer work — OQ-43) | Core trait + in-memory default; persistence adapters are separate crates |
| OQ-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** (alknet ADR-034, not ported) | Three remote roles (public X.509 endpoint, transport relay, hub); `PeerEntry` asymmetry correct; verifier by `PeerEntry` presence |
| OQ-21 | X.509 outgoing-only case | **resolved** (ADR-054; provenance: alknet ADR-034, not ported) | Three remote roles (public X.509 endpoint, transport relay, hub); `PeerEntry` asymmetry correct; verifier by `PeerEntry` presence — re-owned in-crate by ADR-054 |
## Key Design Principles
@@ -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).
+30 -26
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
@@ -234,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).
@@ -352,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
@@ -573,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 alknet ADR-044, [ADR-048](/workspace/@alkdev/alknet/docs/architecture/decisions/048-websocket-native-session-not-gateway.md), and [websocket.md](/workspace/@alkdev/alknet/docs/architecture/crates/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.
@@ -587,7 +589,7 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
| 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-008](/workspace/@alkdev/alknet/docs/architecture/decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time |
| Vault integration point | [ADR-010](decisions/010-secret-material-flow-and-capability-injection.md) | Vault is a capability source, accessed at assembly time (provenance: alknet ADR-008, superseded in-crate by ADR-010) |
| Secret material flow | [ADR-010](decisions/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 |
@@ -615,8 +617,8 @@ See [open-questions.md](open-questions.md) for full details.
`SamePeerCollision`); `#[non_exhaustive]`. See
[client-and-adapters.md](client-and-adapters.md).
- **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`). See
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
@@ -637,14 +639,16 @@ See [open-questions.md](open-questions.md) for full details.
rotation), not a connection-assigned UUID.
- **OQ-19** (resolved by ADR-025): Persistent peer registry —
the storage boundary is `core trait + in-memory default`; persistence
adapters are separate crates (the concrete adapter shapes were named in
alknet research, not re-specced here).
- **OQ-21** (resolved): 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. The
deciding ADR (alknet ADR-034, "Outgoing-Only X.509 and Three Peer
Roles") was not ported into alkcall; the surviving alkcall surface is
ADR-025's `PeerEntry` asymmetry. See [client-and-adapters.md](client-and-adapters.md).
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
@@ -655,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)
+21 -19
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`, alknet OQ-55) is [`alknet-client`](/workspace/@alkdev/alknet/docs/architecture/crates/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
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 and [OQ-55](/workspace/@alkdev/alknet/docs/architecture/questions/055-alknetclient-establishment-extraction.md)
(resolved).
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
- **alknet 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`)
- alknet 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)
+3 -3
View File
@@ -1,6 +1,6 @@
---
status: current
last_updated: 2026-10-13
last_updated: 2026-10-13 (alkcall reframing)
---
# channel-operations.md — Channel Lifecycle on the Call Protocol
@@ -217,7 +217,7 @@ 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
@@ -327,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
+24 -18
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,
@@ -22,10 +22,10 @@ handler owns its sub-stream multiplexing on the `BiStream` it receives.
|----------|--------|-------------|
| [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
@@ -39,27 +39,27 @@ handler owns its sub-stream multiplexing on the `BiStream` it receives.
| [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) |
| [077[077](/workspace/@alkdev/alknet/docs/architecture/decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | TTY's two modes (direct vs channels); TTY always uses its 5-byte format, carried transparently in the channels payload |
| [050](decisions/050-pump-bidi-two-pump-helper.md) | Two-Pump Shutdown-on-Completion Pattern | The two-pump deadlock contract; handler-level, not channels-layer |
| [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 `AlknetClient` (ADR-045, resolves OQ-55) |
| [044](decisions/044-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers, not sub-crates |
| [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 |
| [052[052](/workspace/@alkdev/alknet/docs/architecture/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) |
| [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 | alknet-channels depends on alknet-core only; no handler-depends-on-handler |
| [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 |
|----|-------|--------|-----------|
| alknet OQ-55 | AlknetClient / Client Establishment Extraction | resolved (ADR-045) | `ChannelClient`'s API is decided (ADR-043): transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (`alknet-client`, ADR-045) |
| OQ-23 (tracker; 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" |
| alknet OQ-57 | Two-pump helper extraction to alknet-core | deferred(scope) (alknet-scope) | The shutdown-on-completion *contract* is decided and the helper landed in-tree (`channels::pump_bidi`, ADR-050); any alknet-core extraction is alknet-scope |
| OQ-24 (tracker; 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 *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
@@ -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
+10 -8
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();
@@ -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 -27
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` — alknet 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,19 +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:
- **alknet 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-23** (tracker; alknet 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.
- **alknet OQ-57** (deferred(scope)): Two-pump helper extraction to alknet-core —
the *contract* is decided (the in-tree helper landed as
`channels::pump_bidi`, ADR-050); any extraction to alknet-core is
alknet-scope.
- **OQ-24** (tracker; alknet OQ-68) (resolved): 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)).
+18 -12
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
@@ -103,7 +103,8 @@ 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
@@ -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.
@@ -262,14 +266,14 @@ for tooling.
| 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
@@ -297,7 +301,7 @@ 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-24** (tracker; alknet OQ-68) (resolved): Add/strip API shape —
- **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.
@@ -307,13 +311,15 @@ Key questions affecting this doc:
- 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
+121 -102
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`](/workspace/@alkdev/alknet/docs/architecture/crates/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,16 +67,19 @@ 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`
@@ -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.
@@ -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.
@@ -232,7 +238,7 @@ authorization). See [ADR-024](decisions/024-peer-graph-routing-model.md) §6.
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,25 +289,27 @@ 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 (alknet 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).
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
@@ -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-14 #2 + alknet 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](/workspace/@alkdev/alknet/docs/architecture/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
@@ -419,7 +429,7 @@ 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 `AlknetClient`). The overlay
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
@@ -456,35 +466,38 @@ want to disclose the originator. See [ADR-026](decisions/026-forwarded-for-ident
### 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/027-from-jsonschema-as-http-adapter.md) moved
`from_jsonschema` to `alknet-http` as an HTTP-backed single-endpoint
`from_jsonschema` to the HTTP host crate — today `alkhttp`
(ex-`alknet-http`) — as an HTTP-backed single-endpoint
adapter: the caller supplies an `OperationSpec` + `HttpServiceConfig` +
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`](/workspace/@alkdev/alknet/docs/architecture/crates/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
@@ -514,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
@@ -529,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)
@@ -550,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
@@ -595,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
@@ -603,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)
@@ -649,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
@@ -673,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
@@ -692,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
@@ -729,7 +746,7 @@ 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
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
@@ -739,7 +756,7 @@ Based on the gap analysis and the downstream unblock chain:
- **`OperationAdapter::import()` returns `Result`.** Failures surface as
`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
@@ -749,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](/workspace/@alkdev/alknet/docs/architecture/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
@@ -763,11 +780,11 @@ Based on the gap analysis and the downstream unblock chain:
| Decision | ADR | Summary |
|----------|-----|---------|
| Call protocol client and adapter contract | [ADR-022](decisions/022-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction; trait is async; adapters produce `HandlerRegistration` bundles |
| `from_jsonschema` as HTTP-backed single-endpoint adapter in alknet-http | [ADR-027](decisions/027-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf |
| `from_jsonschema` as HTTP-backed single-endpoint adapter | [ADR-027](decisions/027-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from the call protocol (broken schema-only placeholder) to the HTTP host crate as a real reqwest-backed single-endpoint adapter — today `alkhttp` (ex-`alknet-http`); `FromJsonSchema` provenance stays in alkcall as a leaf |
| Peer-graph routing model (DC-1, supersedes ADR-023) | [ADR-024](decisions/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) | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
| Storage boundary and repo/adapter pattern | [alknet ADR-033](/workspace/@alkdev/alknet/docs/architecture/decisions/033-storage-boundary-and-repo-adapter-pattern.md) (provenance) | Core defines repo traits + in-memory defaults; persistence adapters are separate crates (composition-layer work — OQ-43) |
| Secret material flow and capability injection | [ADR-010](decisions/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 |
@@ -776,11 +793,11 @@ Based on the gap analysis and the downstream unblock chain:
| 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 peer roles | [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) | Vault-derived TLS identity material |
| Vault key model | [alknet ADR-026](/workspace/@alkdev/alknet/docs/architecture/decisions/026-vault-key-model-hd-derivation.md) | Vault-derived TLS identity material |
| Vault local-only dispatch | [alknet ADR-025](/workspace/@alkdev/alknet/docs/architecture/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/031-crate-decomposition.md) | `alknet-http` depends on `alknet-call` (protocol-foundation exception, noted in Adapter Location Map) |
| 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
@@ -827,7 +844,7 @@ See [open-questions.md](open-questions.md) for full details.
`PeerEntry` supports multiple credential paths (fingerprints +
auth_token_hash), `ApiKeyEntry` is for tokens that ARE the identity.
See OQ-20 in open-questions.md.
- **OQ-21** (resolved, alknet ADR-034 — not ported): X.509 outgoing-only
- **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
@@ -837,10 +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`). The deciding ADR
(alknet ADR-034, "Outgoing-Only X.509 and Three Peer Roles") was not
ported into alkcall; the surviving alkcall surface is ADR-025's
`PeerEntry` asymmetry. See OQ-21 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
@@ -852,11 +869,16 @@ See [open-questions.md](open-questions.md) for full details.
model (the server-side complement to this document)
- `operation-registry.md` — `HandlerRegistration`, provenance, capability
injection, service discovery (the discovery API `from_call` consumes)
- `docs/research/alknet-call-completion/gap-analysis.md` — DC-1..4, the
- Source-repo research (alknet, provenance — these paths live in
`/workspace/@alkdev/alknet/docs/research/`, not this repo):
- `alknet-call-completion/gap-analysis.md` — DC-1..4, the
implementation-state audit, the downstream unblock chain
- `docs/research/alknet-call-peer-routing/findings.md` — the peer-graph
- `alknet-call-peer-routing/findings.md` — the peer-graph
routing research that identified ADR-023's structural gap and validated
the ADR-024 design via POC
- `alknet-ssh/phase-0-findings.md` — SSH Phase 0; confirms ssh depends on
the vendored core types, not alkcall's adapters, so it proceeds in
parallel with the completion
- `/workspace/@alkdev/operations/` — TypeScript prior art (`from_openapi.ts`,
`from_mcp.ts`, `from_schema.ts`, `scanner.ts`)
- `/workspace/@alkdev/dispatch/` — concrete downstream consumer (container
@@ -864,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`
@@ -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
@@ -617,7 +617,7 @@ 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`.
@@ -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.
@@ -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](/workspace/@alkdev/alknet/docs/architecture/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
@@ -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.
@@ -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.
@@ -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
+21 -8
View File
@@ -1,6 +1,6 @@
---
status: current
last_updated: 2026-10-13
last_updated: 2026-10-13 (composition-layer re-key)
---
# Open Questions
@@ -35,17 +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 | 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 an alknet-hub composition on top of 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. |
| 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
@@ -92,7 +89,7 @@ landed; what remains unbuilt is the downstream hub broker).
| 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" |
@@ -100,7 +97,7 @@ landed; what remains unbuilt is the downstream hub broker).
| 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 | 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 alknet's QUIC accept loop. **Blocked on**: alknet's multi-stream substrate integration. **Impacts**: none in-crate — the wire format and demux loop are correct for both substrates; alkcall stays transport-agnostic (no QUIC dependency, WASM-compatible). |
| OQ-41 | QUIC-native multi-stream substrate | deferred(scope) | medium | **Deferred downstream** — the substrate lives in the dial/accept layer, which alkcall does not own. Only the in-line substrate mode is implemented (single bidi stream, header-demuxed N channels); the QUIC-native multi-stream mode (accept remaining bidi streams, read headers off each — ADR-034 §substrate modes) belongs in the composition layer's QUIC accept loop. **Blocked on**: the composition layer's multi-stream substrate integration (owner — OQ-43; today the alknet glue, being replaced). **Impacts**: none in-crate — the wire format and demux loop are correct for both substrates; alkcall stays transport-agnostic (no QUIC dependency, WASM-compatible). |
## Core Types
@@ -112,3 +109,19 @@ landed; what remains unbuilt is the downstream hub broker).
| 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. |
+30 -25
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,
alknet ADR-033 — not ported). See [auth.md](/workspace/@alkdev/alknet/docs/architecture/crates/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,7 +607,7 @@ 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)
`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
@@ -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`.
@@ -968,19 +971,19 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
|----------|-----|---------|
| 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-010](/workspace/@alkdev/alknet/docs/architecture/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](/workspace/@alkdev/alknet/docs/architecture/decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time |
| 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 `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf (now handler-bearing, not "no handler") |
| `from_jsonschema` as HTTP-backed single-endpoint adapter | [ADR-027](decisions/027-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from the call protocol (broken schema-only placeholder) to the HTTP host crate as a real reqwest-backed single-endpoint adapter — today `alkhttp` (ex-`alknet-http`); `FromJsonSchema` provenance stays in alkcall as a leaf (now handler-bearing, not "no handler") |
| Peer-graph routing model (supersedes ADR-023) | [ADR-024](decisions/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, alknet ADR-033 repo/adapter pattern); `OperationSpec` gains `resource_id_path` (JSON pointer into the input); proxy-only access pattern (spawner owns, proxy to share, teardown revokes); `list` = scope-gate + result-filter; teardown = automatic, handler-driven; composition = two orthogonal checks, ADR-017/022 unchanged |
| Dynamic resource ownership for runtime-spawned resources | [ADR-011](decisions/011-dynamic-resource-ownership-for-runtime-spawned-resources.md) | `AccessControl::check` consults an `OwnershipProvider` (sync read trait; the source repo's repo/adapter pattern — alknet ADR-033, provenance); `OperationSpec` gains `resource_id_path` (JSON pointer into the input); proxy-only access pattern (spawner owns, proxy to share, teardown revokes); `list` = scope-gate + result-filter; teardown = automatic, handler-driven; composition = two orthogonal checks, ADR-017/022 unchanged |
| Publish operation type and HandlerKind::Sink | [ADR-046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | `OperationType::Pub` (producer→consumer streaming); `SinkHandler` + `HandlerKind::Sink` + `PublishStream`; `invoke_sink()` dispatch path; `call.published` wire event; `OperationSpec.publish_schema`; `Subscription` renamed to `Sub`; fan-out/broker out of scope for alkcall (OQ-22 resolved, OQ-42 downstream) |
## Open Questions
@@ -1000,8 +1003,8 @@ See [open-questions.md](open-questions.md) for full details.
`SamePeerCollision` (replaces flat `Conflict`). `#[non_exhaustive]`. See
[client-and-adapters.md](client-and-adapters.md).
- **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
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/028-from-call-manual-free-function.md).
- **OQ-13** (resolved): `from_call` namespace collision — same-peer
@@ -1017,9 +1020,11 @@ See [open-questions.md](open-questions.md) for full details.
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 — the
deciding ADR (alknet ADR-034, not ported) named the three remote
roles; see OQ-21 in [open-questions.md](open-questions.md).
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
@@ -1030,8 +1035,8 @@ See [open-questions.md](open-questions.md) for full details.
- [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`)
@@ -1039,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)