Compare commits

...
65 Commits
Author SHA1 Message Date
glm-5.2 a5d5d037dd docs(typedef): sync specs with ADRs 099-102 (19 kinds, aligned-mode restrictions, read factory)
- schema-layer.md: 17→19 kinds, add Int64/Uint64 to table + TypeDefKind
  enum, update method tables (12 fixed-size kinds, 8-byte alignment for
  i64/u64/f64), add ADR-099 to design decisions.
- layout-engine.md: document ADR-100 (non-final inline variable field
  rejection), ADR-102 (TUnion rejected in aligned mode), ADR-101
  (sequential_reader returns owned reader). Update TUnion section to
  note aligned-mode rejection. Add ADRs to design decisions table.
- data-access.md: add I64/U64 to FieldValue enum, update
  sequential_reader() signature (factory, ADR-101).
- validation.md: 17→19 kinds, update TypedefEngine struct (no stored
  reader in Layout::Packed), update sequential_reader() signature.
- overview.md: 17→19 kinds, add ADRs 099-102 to design decisions.
- README.md (typedef): 17→19 kinds, add ADRs 099-102 to applicable ADRs.
- README.md (top-level): add ADRs 099-102 to ADR table, update typedef
  doc descriptions (17→19 kinds).
2026-07-22 09:37:12 +00:00
glm-5.2 51806f4469 fix(typedef): Int64/Uint64, aligned-mode restrictions, read factory (ADR 099-102)
ADR-099 — Int64/Uint64 as first-class kinds:
- schema.rs: add Int64/Uint64 to TypeDefKind (19 kinds), type_size=8,
  natural_alignment=8, is_fixed_size=true, needs_endian=true, FromStr
- data_access.rs: add read_i64/write_i64/read_u64/write_u64 + tests
- sequential_reader.rs: add I64/U64 to FieldValue + dispatch arms + test
- engine.rs: add I64/U64 read_field/write_field dispatch arms
- validation.rs: add Int64/Uint64 validators (as_i64/as_u64 check) +
  registration + tests. Hand-written (not macro) to avoid clippy
  'comparison useless due to type limits' for the full-range check.

ADR-100 — reject non-final inline length-prefixed variable fields:
- offset_map.rs: compute_struct now checks if a variable-length field
  using inline length-prefixing (no maxLength, no offset-indirect) is
  non-final → TypedefError::Offset at compile time. Prevents silent
  data corruption (write_string clobbering subsequent fields).

ADR-101 — packed-mode read factory:
- engine.rs: Layout::Packed no longer stores a SequentialReader.
  sequential_reader() returns Option<SequentialReader> (owned, fresh)
  instead of Option<&SequentialReader>. The reader's cursor state is
  consumer-owned.

ADR-102 — reject TUnion in aligned mode:
- offset_map.rs: compute_field returns TypedefError::Offset for
  TypeDefKind::Union. Removed the dead compute_union_field,
  variant_size_range, and find_discriminator_field methods (had three
  bugs: no variant offsets, first-variant discriminator offset,
  misaligned variant region).

295 tests pass, clippy clean, workspace builds clean.
2026-07-22 09:37:05 +00:00
glm-5.2 c819d99f1d docs(typedef): ADRs 099-102 — Int64/Uint64, aligned-mode restrictions, read factory
ADR-099: Int64/Uint64 as first-class kinds. The POC targets (SFTP
offset: u64, metatensor data_offsets: u64) require 64-bit integers.
The prior Uint64 addition was removed because it was half-finished;
this ADR specifies the complete addition (layout + validator + API).

ADR-100: Reject non-final inline length-prefixed variable fields in
aligned mode. The OffsetMap reserves only 4 bytes (the length prefix),
but write_string writes prefix+data inline — clobbering subsequent
fields. Non-final variable fields must use maxLength or offset-indirect.

ADR-101: Packed-mode read API — engine as SequentialReader factory.
engine.sequential_reader() returned &SequentialReader but read_next
needs &mut self — dead API. Now returns an owned fresh reader.

ADR-102: Reject TUnion in aligned mode for v1. The aligned-mode union
code had three bugs (no variant offsets, first-variant discriminator
offset, misaligned variant region). Unions are the protocol pattern;
mmap formats use structs/arrays. Reversible when a consumer needs it.
2026-07-22 09:36:55 +00:00
glm-5.2 7603f98799 docs(typedef): sync specs with the implemented API surface
The code introduced concrete public types and a unified API during
implementation that the specs described only conceptually. Sync the
specs to match the code:

- schema-layer.md: document the TypeDefKind enum and its inherent methods;
  add a Schema-Layer Public API section (get_typedef_kind vs
  get_typedef_kind_loose, annotation parsers, Endian/VariableEncoding/
  DiscriminatorKind, normalize_refs/resolve_ref/resolve_ref_or_inline);
  fix the TRecord layout (values are encoded by their declared kind, not
  universally value_len-prefixed); fix the alignment default list.
- layout-engine.md: document the LayoutMode enum and the ByteRange/
  FieldPosition/PackedLayout/OffsetMap public types with their actual
  signatures; update the LayoutBuilder/SequentialReader/OffsetMap
  component descriptions with the real new/build/compute signatures.
- data-access.md: document the FieldValue enum; add a Higher-level
  read/write section (TypedefEngine::read_field/write_field,
  SequentialReader::read_next/read_field); fix primitive signatures to
  include field_path and Endian; replace the wrong
  read_union_discriminator pseudo-code with the actual tunion module API
  (read_byte_discriminator/read_field_discriminator/resolve_variant/
  discriminator_size) and the UnionDispatch struct.
- validation.md: fix the TypedefEngine struct (add endian/schema fields,
  mark Layout as private); add the real compile signature (&mut Value,
  LayoutMode) and mode-appropriate accessors; fix engine.validate(buffer)
  -> engine.validate_json(&Value)/is_valid_json (the validator operates
  on serde_json::Value, not byte buffers — matches ADR-098).
- overview.md: remove the stale ~1,900 lines / 26 tests line count.
- ADR-097 §3a: correct the TRecord layout (no separate value_len prefix;
  the value is encoded by its declared TypeDef:* kind).
2026-07-21 13:40:12 +00:00
glm-5.2 14d9cf281f fix(typedef): remove unintended TypeDef:Uint64 from the engine
TypeDef:Uint64 was never specified in any ADR and was a partial, incomplete
addition: type_size() returned None (so is_fixed_size() was false),
OffsetMap::compute rejected it, LayoutBuilder::build would unreachable!()
panic on it, and the validator did not register a TypeDef:Uint64 keyword.
Only the SequentialReader path worked, and only because it dispatched
directly to data_access::read_u64 without consulting type_size().

Remove the variant from TypeDefKind, the read_u64/write_u64 primitives,
FieldValue::U64, the sequential-reader and engine dispatch arms, and the
associated tests. The engine now has exactly 17 first-class kinds, matching
the spec. All 286 tests pass; the workspace builds clean.
2026-07-21 13:40:03 +00:00
deepseek-v4-pro ce7ef1e31f refactor(typedef): introduce TypeDefKind enum for integer dispatch
Replace all string-based TypeDef:* kind matching with a 17-variant
TypeDefKind enum. The enum provides compile-time exhaustiveness
checking, integer discriminant dispatch (jump table), and type-safe
methods (type_size, natural_alignment, is_fixed_size, etc.).

- Add TypeDefKind enum with FromStr, Display, and helper methods
- Add get_typedef_kind_enum() and get_typedef_kind_loose_enum()
- Convert DiscriminatorKind::Byte.disc_type from String to TypeDefKind
- Convert FieldPosition.kind from String to TypeDefKind
- Convert all 8 dispatch sites from string matching to enum matching
- Remove legacy string-based type_size/natural_alignment/is_fixed_size
  wrapper functions — all call sites use enum methods directly
- Update tests to use enum variants
2026-07-21 12:07:39 +00:00
deepseek-v4-pro 72aa79b6ee refactor(typedef): deduplicate shared code, fix bugs, add macros
Bug fixes:
- sequential_reader: use get_typedef_kind_loose instead of strict
  get_typedef_kind so object-annotation form schemas parse correctly
- validation: string_factory/bytes_factory accept object-annotation form

Deduplication (moved to schema.rs):
- get_typedef_kind_loose (was in 3 files)
- is_fixed_size replaces local is_fixed_kind (was in 2 files)
- resolve_ref_or_inline + resolve_ref (was in 2 files)
- U32_SIZE and DISCRIMINATOR_PATH constants (were in 3-4 files)

New macros (src/macros.rs):
- define_int_validator!, define_uint_validator!,
  define_float_validator!, define_type_validator!
  (eliminate ~200 lines of boilerplate in validation.rs)
- define_read_write_endian!, define_read_write_ne!
  (eliminate ~320 lines of boilerplate in data_access.rs)

Net: ~1,170 lines removed, 288 tests pass, clippy clean
2026-07-21 11:32:22 +00:00
glm-5.2 998f6b6dc9 docs(typedef/review-typedef): complete review checkpoint — crate is spec-conformant
All verification green: 288 tests pass, clippy clean, fmt clean, workspace
unaffected. Crate structure matches spec (9 source files, 4 integration test
files). All 17 TypeDef kinds registered. Both layout modes work. TUnion
supports both discriminator kinds. No unwrap() in production code.
2026-07-21 10:43:19 +00:00
glm-5.2 0d39b7f12a style(typedef): apply cargo fmt across all source and test files
Resolve formatting differences flagged by cargo fmt --check during the
review checkpoint. No functional changes.
2026-07-21 10:42:57 +00:00
glm-5.2 bf868157d3 fix(typedef): resolve pre-existing clippy warnings in test code
- sequential_reader.rs: change test helper write_u32/write_string param from
  &mut Vec<u8> to &mut [u8] (clippy::ptr_arg)
- validation.rs: replace 3.14/2.71 with 3.5/2.5 in test instances to avoid
  clippy::approx_constant (f32::consts::PI approximation)
2026-07-21 10:40:43 +00:00
glm-5.2 937df02f85 test(typedef/tests): add comprehensive integration tests and POC round-trip tests 2026-07-21 10:39:27 +00:00
glm-5.2 db8f5d863d feat(typedef/engine): implement TypedefEngine integrating layout and validation 2026-07-21 10:29:05 +00:00
glm-5.2 1344157d59 feat(typedef): re-export Gen 4 layout engine types from lib.rs
Re-export ByteRange, OffsetMap, FieldPosition, LayoutBuilder, PackedLayout,
FieldValue, SequentialReader, and UnionDispatch at the crate root so consumers
can access the layout engine types without module-qualified paths. The
discriminator read functions (read_byte_discriminator, etc.) remain
module-qualified under alknet_typedef::tunion.
2026-07-21 10:23:22 +00:00
glm-5.2 11c10c0bb1 feat(typedef/layout-builder): implement packed sequential LayoutBuilder for protocol write-side 2026-07-21 10:22:24 +00:00
glm-5.2 835bed1c6f feat(typedef/sequential-reader): implement packed sequential SequentialReader for protocol read-side 2026-07-21 10:17:10 +00:00
glm-5.2 f754ae9cbe feat(typedef/offset-map): implement aligned static OffsetMap with natural alignment 2026-07-21 10:16:37 +00:00
glm-5.2 4b8e5c163a feat(typedef/tunion): implement TUnion discriminator dispatch for byte-offset and field-name 2026-07-21 10:14:50 +00:00
glm-5.2 1c6a705987 feat(typedef): re-export build_validator from validation module
Re-export validation::build_validator at the crate root so consumers can
call alknet_typedef::build_validator(schema) directly. The data_access
functions remain module-qualified (alknet_typedef::data_access::read_u32)
since there are 28 of them and re-exporting all would be noisy.
2026-07-21 10:03:28 +00:00
glm-5.2 03e1c5ee41 feat(typedef/data-access): implement primitive read/write for 17 TypeDef kinds with endianness 2026-07-21 10:02:13 +00:00
glm-5.2 f41387eeed feat(typedef/validation): implement custom keyword validators for all 17 TypeDef kinds 2026-07-21 10:01:53 +00:00
glm-5.2 28d4068e0e feat(typedef): re-export schema layer public API from lib.rs
Re-export Endian, VariableEncoding, DiscriminatorKind, and the parse_*
functions plus normalize_refs from the schema module so they're accessible
at the crate root alongside TypedefError.
2026-07-21 09:57:08 +00:00
glm-5.2 ac36a1cee3 feat(typedef/schema-types): implement TypeDef kind detection, annotations, Endian, $ref normalization 2026-07-21 09:56:40 +00:00
glm-5.2 109d743f9f feat(typedef/error-type): implement TypedefError enum with Schema, Offset, Access, Validation variants 2026-07-21 09:55:46 +00:00
glm-5.2 44f221e8cc feat(typedef/crate-init): initialize alknet-typedef crate skeleton
Created crates/alknet-typedef/ with Cargo.toml depending on jsonschema 0.46
(default-features = false for WASM-cleanliness) and serde_json with
preserve_order. Added src/lib.rs with module declarations for all 9 modules
and skeleton source files for each. Added crate to workspace members list.

Verified: cargo check, clippy -D warnings, and build --workspace all succeed.
Dependency tree confirmed free of tokio/reqwest/rustls (WASM-clean).
2026-07-21 09:54:05 +00:00
deepseek-v4-pro db100f9849 feat(typedef): add implementation task decomposition (12 tasks, 7 generations)
Break the alknet-typedef architecture specs into atomic, dependency-ordered
implementation tasks covering crate init, error types, schema layer, data
access, both layout modes (aligned static + packed sequential), TUnion
dispatch, jsonschema custom keyword validators, TypedefEngine integration,
comprehensive tests, and a final review checkpoint.

Validated: 12 tasks, 0 cycles, 7 parallel generations.
2026-07-21 09:36:30 +00:00
deepseek-v4-pro dd232c3d47 fix(typedef): fix code examples and cross-doc inconsistencies from second review
- Fix code examples hardcoding little-endian: read_string, write_string,
  read_string_indirect now take endian parameter and use match on Endian.
- Fix TUnion dispatch examples: remove undefined functions (read_u8,
  read_field, read_struct, read_f32_raw), remove Value returns
  (contradicts 'no intermediate Value tree'), add endian-aware
  discriminator reading for Uint8/Uint16/Uint32.
- Fix read_f32 example: inline the endian-aware conversion instead of
  calling undefined read_f32_raw; document it as aligned-mode only.
- Fix architecture README: 16→17 kinds in schema-layer and validation
  descriptions.
- Fix ADR-095: clarify validation operates on Value instances, not raw
  byte buffers directly. Fix 'defense in depth' paragraph.
- Fix ADR-097: add §3a defining TRecord 'values' property shape.
- Fix ADR-098: TTimestamp format ISO 8601→RFC 3339.
- Tighten OQ-071 impacts field: state what IS blocked, not just what
  isn't.
2026-07-21 08:42:13 +00:00
deepseek-v4-pro 01cc3a0367 docs(typedef): clarify OQ-070 WASM vs no_std distinction
WASM (wasm32-unknown-unknown) has std via wasm-bindgen and is not
blocked by this OQ. The crate is WASM-clean by construction. This OQ
is about bare-metal embedded targets only.
2026-07-21 08:05:24 +00:00
deepseek-v4-pro c6ab00d141 fix(typedef): resolve spec inconsistencies from sanity check
- Remove TEnum 'always LE' exception (no POC basis, contradicts ADR-097).
  TEnum now follows schema endianness like all other fixed-size types.
- Document TEnum design change: u32 index is a deliberate deviation from
  TypeBox's string enum for binary efficiency.
- Document TBytes as alknet-typedef addition (not in TypeBox typedef.ts).
- Fix kind count inconsistency: all docs now consistently say 17 kinds.
- Fix TTimestamp validation contradiction: clarify data-access layer vs
  jsonschema validator responsibility.
- Add TEnum read/write coverage to data-access.md.
- Add TEnum endianness cross-reference to layout-engine.md.
- Clarify TBytes binary-vs-JSON representation in validation.md.
2026-07-21 07:44:58 +00:00
deepseek-v4-pro a941d86c3a docs(typedef): add $ref normalization step for TypeBox interop
TypeBox generates bare-name $ref values ("$ref": "Read") within
$defs blocks. The jsonschema crate requires full JSON Pointer paths
("$ref": "#/$defs/Read"). Verified by generating actual TypeBox
output and testing against jsonschema v0.46.5 — bare-name refs fail
with 'Resource is not present in a registry'.

Add a ~20-line normalize_refs() pre-processing step that rewrites
bare-name refs to full JSON Pointer paths at schema load time. The
normalization is idempotent — full paths pass through unchanged.
2026-07-20 12:27:52 +00:00
deepseek-v4-pro 85c5590001 docs(architecture): add alknet-typedef crate specs, ADRs 095-098, and OQs 069-071
Add the alknet-typedef architecture specification — the binary struct
engine that takes JSON Schema with TypeDef:* custom keywords and produces
offset maps, read/write functions, and validation.

Specs (docs/architecture/crates/typedef/):
- overview.md: purpose, 'schema is the format' principle, consumers, scope
- schema-layer.md: 17 TypeDef:* kinds, jsonschema integration, annotations
- layout-engine.md: two layout modes, three variable-length strategies
- data-access.md: read/write, TUnion dispatch, field paths, zero-copy
- validation.md: custom keyword validators, TypedefError, TypedefEngine

ADRs:
- 095: Purpose, scope, and the jsonschema engine
- 096: Two layout modes — packed sequential vs aligned static
- 097: Schema annotations — endianness, alignment, encoding, TUnion
- 098: Error handling and validation strategy

OQs (deferred(scope)):
- 069: Arrays of variable-length-element structs
- 070: no_std + alloc support
- 071: Builder API for schema construction

Index updates: README doc table + ADR table, open-questions.md theme
table + Deferred/Blocked section, overview.md crate graph.

Grounded in the alknet-typedef POC (26 tests passing) and the
call-channels-unification research. Reviewed by architecture-reviewer;
all critical issues, warnings, and suggestions addressed.
2026-07-20 11:57:03 +00:00
deepseek-v4-pro bf0f827bf4 docs(research): add FFI and WASM ABI section to alknet-typedef findings
Covers schema-driven cross-language interfaces, WASM linear memory
model fit, cross-language schema portability, defense in depth
(Rust + WASM sandbox + schema validation), and implications for
the call crate's WASM-friendliness.
2026-07-20 09:49:21 +00:00
deepseek-v4-pro 076d5adfec docs(research): add alknet-typedef findings with POC results
POC 1 (core offset computation) and POC 2 (russh-sftp round-trip)
are complete with 26 passing tests. Key architectural finding:
two layout modes are needed — packed sequential for protocol wire
formats (LayoutBuilder/SequentialReader) and aligned static for
mmap-friendly formats (OffsetMap).

The jsonschema crate's custom keyword API handles all 16 TypeDef:*
kinds. TUnion byte-offset discriminator dispatch confirmed against
russh-sftp's own serialization byte-for-byte.
2026-07-20 09:27:21 +00:00
deepseek-v4-pro 3543c1bb7a findings: known gaps, wire format family, and alknet-typedef unification
- Add Known gaps section (A-G): Pub handler shape, hub broker spec,
  from_call relay wrapper, channel_id allocation in Pub case,
  OperationEnv coupling, channel_open wire format, resource_id_path
  ACL vs handler ownership
- Add Wire format family section: call JSON, call binary, channels,
  TTY all share [discriminant][length][payload] shape; binary call
  frame is 9 bytes vs JSON's ~80+
- Add alknet-typedef section: JSON Schema with TypeDef:* custom
  keywords as the binary struct engine, replacing per-protocol serde
  structs, typebox-rs, and per-handler wire format parsers
- Cross-reference Gap E resolution in open questions
2026-07-19 20:21:54 +00:00
deepseek-v4-pro 7cdff8c127 findings: Pub/Sub replaces open/expose — OperationType::Pub/Sub carries direction, ChannelDirection enum dissolves, marker simplifies to just alpn, hub is the broker matching Pub↔Sub by (op_name, params_hash) 2026-07-19 17:57:26 +00:00
deepseek-v4-pro 9d855a774d findings: dissolve the third category — TTY/tunnel/etc are just call apps, not a separate TLS-layer category; the endpoint determines framing, not the app 2026-07-19 17:10:32 +00:00
deepseek-v4-pro 90a8fa7328 findings: reframe — channels is call with a binary data plane, not 'call + data channels'; retire 'call++' for 'binary-stream call apps'; two modes: default call (JSON) and channels (binary framing) 2026-07-19 17:09:16 +00:00
deepseek-v4-pro 8ca2d0632d findings: expose is specced, not deferred — subscription model as matching mechanism, (op_name, params_hash) pubsub, stream dedup, step-by-step proxy walk-through 2026-07-19 16:50:02 +00:00
deepseek-v4-pro 0ef277730f findings: reframe relay flow with producer/consumer terminology, hub-as-proxy pattern, concrete opencode-on-remote example, N-consumer fan-out sketch 2026-07-19 16:33:17 +00:00
deepseek-v4-pro 7c1af0d71f findings: add terminology section — three role axes (deployment/call/data-plane), retire ALPN-server/client for producer/consumer, define assembly layer 2026-07-19 15:00:24 +00:00
deepseek-v4-pro 9a58714519 findings: address review gaps — per-connection plumbing, wire-visible marker, FromCall relay wrapper, defer expose, fix discovery filtering, WS dual-mode, nits 2026-07-19 14:46:04 +00:00
alkimiadev f73c6035b5 doc(fix) fixed factual error regarding model license 2026-07-19 14:21:39 +00:00
glm-5.2 74c1007bdf docs(research): call-channels-unification findings — openable ALPNs are operations
Iterating in docs/research/ per the stream-unification pattern; syncs
to docs/architecture/ and the ADRs only after it settles.

Working through the `channel/open` ACL granularity gap surfaced a
larger unification: channels is "call + data channels" ("call++"),
and the ALPN crates served under channels are call-consuming apps in
the same shape alknet-docker is a call-consuming app. The lineage
(call → docker → tty → channels) closes here.

Records three gaps from an outside review + the ALPN-category tangle
that fell out of working the first one:

- Gap 1: `channel/open` ACL granularity underspecified. Resolved by
  "an openable ALPN is an operation" — per-ALPN ops in
  `channels/<alpn>/open` and `.../expose` on the call
  OperationRegistry, with a `channel_open` marker on OperationSpec
  (registry metadata, not auth machinery). Two verbs (open/expose)
  give the two direction values separate ACLs. Avoids re-committing
  ADR-028's parallel-authorization structural miss one layer down.
- Gap 2: quota accounting leaks (ADR-094). Responder-initiated close
  decrements the wrong ledger; transport drop never decrements.
  Fix: per-connection opener ledger in channels-call (channels-core
  stays auth-blind), decremented on every teardown path. The trait
  shape survives.
- Gap 3: connection-count DoS is an unowned layer. New OQ against
  alknet-endpoint, deferred(scope) — named to stop the re-tangle.
- Gap 4: ALPN category blur (ADR-086 §4). The "channels data-channel
  ALPNs" category reframes to "call++ apps" — they inherit call's
  auth by construction; the data-channel part is the channel_open
  marker. SSH stays distinct.

Includes the hub-relay + worker-expose flow walked end-to-end under
the new model (both hold), the ADR plan (ADR-095 + amendments to
073/094/086/048/057 + clarification to 058), per-crate changes, and
six OQs with concrete resolution paths.

ChannelCore seam (wrapper shape) is the architecture decision; the
exact API shape is POC-flagged.
2026-07-19 14:12:44 +00:00
glm-5.2 0fcd5bc322 docs(adr): 094 — per-identity channel cap as DoS defense
ADR-076 framed the per-connection max_channels=256 cap as the DoS
defense, but a peer can open an unbounded number of transport
connections, so a per-connection cap bounds a connection's
reassembly-buffer cost, not a peer's total channels. The only coherent
unit for a channel DoS defense is the identity.

ADR-094 records the corrected design: a ChannelLifecyclePolicy trait
in channels-call (where the identity is already on OperationContext),
consulted by the channel/open handler (after AccessControl::check,
before allocation) and the channel/close handler (after the drain
completes). Default is PerIdentityChannelPolicy::new(256) — 256 per
PeerId across all the peer's connections, shared via Arc across every
channels connection a peer accepts. The cap is a peer concern (not
hub-specific), symmetric (both sides enforce), and lives in
channels-call because the channels layer is auth-blind by design
(ADR-075) — that is what makes it WASM-compatible, transport-agnostic,
and ALPN-blind.

For the hub-relay path (ADR-079), the spoke sees the hub as the direct
caller (ADR-032 — forwarded_for is metadata, not authority, for the cap
as for AccessControl::check), so the spoke caps the hub, not the
browser. A spoke serving a high-fan-out hub sets the hub peer's cap
higher via with_per_identity_caps — the spoke's own policy, not the
hub's. Recursive channels do not bypass the cap (the same policy can
be wired into the inner ChannelOperations).

ADR-076 is amended: the per-connection max_channels is reframed as a
per-connection memory bound (still returns channel:too_many_channels
when hit), the "DoS defense summary" table is removed, and the
"per-connection, not per-peer" line (the channels layer confessing a
hole and hoping the layer above would fill it) is corrected.

Spec docs updated to reference ADR-094: channel-operations.md gains a
"Per-identity channel cap" section (trait, default, enforcement
point, relay consequence, recursion); channels-adapter.md adds the
policy check as step 3 of the channel/open handler and the decrement
in channel/close; hub README adds the channel_policy field on Hub,
the with_channel_policy builder, a dedicated subsection, and the
inbound-peer-vs-hub-as-caller distinction; channels/README.md adds
ADR-094 to the Applicable ADRs table and a 9th Key Design Principle;
docs/architecture/README.md adds a Current State note and the ADR
table row.
2026-07-19 11:17:57 +00:00
glm-5.2 762d9c7bd2 docs(architecture): remove removed-thing remnants from spec docs
Spec docs should describe WHAT IS, not WHAT WAS. ADRs and OQ files are
historical records by design and are left alone; the ADR-index Status
column records ADR status (stable fact). What changed in the specs:

- API code blocks no longer list removed methods. ChannelClient::connect_quic
  (channel-client.md) and CallClient::connect (client-and-adapters.md) are
  gone from the impl blocks; surrounding prose describes the current
  from_connection / spawn_dispatch primary + AlknetClient dial shape.
- Amendment (ADR-093): stream_types field is removed blockquotes dropped
  from channel-client.md and channel-operations.md (the code already
  reflects the current state).
- Historical is-removed / reversed-by / amended-by prose rewritten to
  current state across channels crate, call crate, hub, tls, core,
  endpoint, client READMEs, and the top-level README/overview.
- Dropped the EndpointError — removed subsection from endpoint/README.md
  (the type doesn't exist anymore, so it shouldn't have a subsection).
- Replaced stale connect() references in flow descriptions with the dial
  (in AlknetClient) since connect() is no longer a method.
- Removed strikethrough ADR-028 / from_jsonschema-clause rows from
  client-and-adapters.md and operation-registry.md ADR tables.
- Rewrote the ADR-066 update blockquote in operation-registry.md to
  describe FromJsonSchema's current shape.

19 files modified, net -116 lines. No ADRs or OQ files touched.
2026-07-19 04:07:49 +00:00
glm-5.2 a3cb44968e docs(adr): 093 — channels pure channel multiplexing (8-byte header, no stream_type)
Prune the channels spec to reflect the stream-unification resolution
(docs/research/stream-unification/findings.md): the channels wire format
goes from 9 bytes to 8 bytes, the channels layer no longer carries a
stream_type concept, into_sub_streams() is removed, and TTY always uses
its 5-byte format (carried transparently in the channels payload).

ADR-093 is the umbrella decision (the channels-layer consequence of
ADR-092's BiStream handler leaf): every channel is a BiStream, the
handler owns its sub-stream multiplexing, the channels layer routes by
channel_id only. Amends ADR-071 (8-byte header, no stream_type),
ADR-074 (into_sub_streams removed, accept_bi yields BiStream), reverses
ADR-077 (TTY always 5-byte), and the channels-facing clauses of
ADR-072/073/075/076/080/081. Adds ADR-092 forward-reference note
(into_sub_streams preservation subsequently reversed by ADR-093) and
the missing ADR-092 cross-reference on ADR-070.

Adds OQ-68 (add/strip API shape — built-in vs utility; the contract is
decided in ADR-093, the function surface is open; two-way door, low
priority, decision-ready when the channels crate's implementation
begins).

Rewrites the 7 channels spec docs (README, overview, channels-wire,
channels-connection, channels-adapter, channel-operations, channel-client)
to describe the post-amendment shape as current, with the 8-byte header,
the add/strip composition, single accept_bi accessor, BiStream per
channel, and TTY-always-5-byte.

Touch-up cross-references in hub README, client README, ADR-085, and
the OQ-45/47/65 question files (TTY-internal stream_type 3 →
STREAM_CTRL_IN; channels 9-byte → 8-byte).
2026-07-18 18:10:17 +00:00
glm-5.2 c2b7055a64 refactor(tty): split control channel into STREAM_CTRL_IN/STREAM_CTRL_OUT halves (Phase 7)
The single STREAM_CONTROL = 3 was documented as bidirectional but the
adapter had to ignore Exit from the client because the two directions
were indistinguishable on the same stream_type — half-duplex in
disguise. Phase 7 splits it into two halves so the bidirectionality is
literal on the wire.

Changes:
- wire.rs: STREAM_CTRL_IN = 3 (client→server), STREAM_CTRL_OUT = 4
  (server→client); InvalidStreamType bound > 3 → > 4; Chunk::control
  → Chunk::ctrl_in/ctrl_out; ChunkWriter::write_control_json →
  write_ctrl_in_json/write_ctrl_out_json; tests split accordingly.
- control.rs: ControlMessage doc updated with the stream_type column;
  JSON shape unchanged.
- adapter.rs: pump_client_to_backend dispatches on STREAM_CTRL_IN
  (Resize/Signal/Eof; Exit on ctrl_in is a protocol violation,
  ignored); send_exit_chunk emits on STREAM_CTRL_OUT; STREAM_CTRL_OUT
  from the client is a protocol violation, ignored. 3 new tests for
  the direction enforcement; existing tests updated to the new
  stream_types.
- negotiation.rs: framing-disambiguation doc updated (server-sent
  stream_type set is {1, 2, 4}).
- alknet-tty-local/tests: common/mod.rs, pty.rs, pipe.rs updated to
  the new constants.

Specs:
- ADR-052 amended (§4a 'Control channel split (Phase 7 amendment)').
- tty-wire.md + tty-adapter.md updated (last_updated 2026-07-18).

Verification:
- cargo test -p alknet-tty: 65 passed (was 61; +4 new tests).
- cargo test -p alknet-tty-local: 19 passed.
- cargo test --workspace --all-features: 1017 passed, 0 failed.
- cargo clippy --workspace --all-features: clean.
- cargo fmt --all: clean.
2026-07-18 17:02:59 +00:00
glm-5.2 859ad35896 fix(http/test-helper): branch on op_type in full_registry_with_ops (ADR-049 kind validation)
The to_mcp test helper full_registry_with_ops always registered ops
with HandlerKind::Once(make_echo_handler()) regardless of op_type.
When the search_returns_access_control_filtered_ops_excluding_subscriptions
test passed OperationType::Subscription for "events/stream", the
registry's kind validation (tightened in commit 9c81129, ADR-049)
rejected it with "handler kind mismatch: Subscription requires
HandlerKind::Stream (got HandlerKind::Once)" — panicking in
register().unwrap() before the test could run.

This was a pre-existing test-helper bug (predates Phase 6; verified by
stashing Phase 6 and reproducing on the develop baseline) but it
blocked Phase 9's 'Done when' criterion (cargo test -p alknet-http
passes).

Fix: added a handler_kind_for(op_type) helper that branches on op_type
(HandlerKind::Stream(make_echo_streaming_handler()) for Subscription,
HandlerKind::Once(make_echo_handler()) for Query/Mutation) and used
it in both register loops of full_registry_with_ops. The streaming
echo handler yields the input back as a single call.responded frame —
sufficient because the test only verifies that the MCP search tool
*excludes* Subscription ops from its listing; it never invokes the
handler.

Result: cargo test --workspace --all-features is fully green (1008
tests, 0 failures). Phase 9's 'Done when' criterion is met. The
findings doc's Phase 9 entry is updated to record the fix.

Closes Phase 9.
2026-07-18 16:14:39 +00:00
glm-5.2 5902c8aca9 docs(research): mark Phase 6 done, Phase 9 subsumed; note pre-existing to_mcp test failure
Phase 6 (BiStream unification) is complete (commit b60a584). Update the
findings doc to reflect what actually shipped and how it overlaps with
the remaining phases:

- Phase 6: marked Done. Added a 'What was done (cross-crate)' section
  listing the actual changes per crate (alknet-core, alknet-http,
  alknet-tty, alknet-call), so the doc records the implementation
  shape not just the plan.

- Phase 9: marked Done — subsumed by Phase 6. ADR-092's migration
  step 2 includes the alknet-http call-site update (drop QuicStream),
  so Phase 6's call-site work landed Phase 9's deliverable. grep
  confirms no QuicStream/QuicStreamDuplex remains.

- Phase 9: added a 'Pre-existing test failure to fix in a follow-up'
  note for the to_mcp::tests::search_returns_access_control_filtered_ops_excluding_subscriptions
  failure. The bug is in the test helper full_registry_with_ops
  (to_mcp.rs:501-516) — it always uses HandlerKind::Once even for
  OperationType::Subscription, which the registry's kind validation
  (tightened in commit 9c81129, ADR-049) rejects. Predates Phase 6
  (verified by stashing); blocks Phase 9's 'Done when' criterion
  (cargo test -p alknet-http passes) and needs a small follow-up.

- Intermediate-states table: updated rows 6, 7, 8, 9. Phase 6 and 9
  marked Done; Phase 7 and 8 noted as unchanged by Phase 6 (Phase 7's
  work is in wire.rs/control.rs which Phase 6 didn't touch; Phase 8 is
  docs-only).
2026-07-18 16:09:09 +00:00
glm-5.2 b60a5844ba refactor(core,http,tty,call): unify stream leaf — BiStream as the handler leaf (Phase 6)
Implement ADR-092 across the workspace: accept_bi/open_bi return BiStream
(a concrete AsyncRead + AsyncWrite + Send + Unpin newtype), not the split
(SendStream, RecvStream) pair. The join moves into core's BidiStreamSource
impls (quinn/iroh via tokio::io::join, single-stream via boxed AsyncReadWrite);
handlers receive the joined BiStream and never see the pair.

Core (alknet-core/src/types.rs):
- Add concrete BiStream struct boxing Box<dyn AsyncReadWrite + Unpin>,
  with AsyncRead + AsyncWrite impls. from_joined (pub, for downstream
  crates that produce split halves naturally — channels reassembly, tests)
  and from_bidi (pub(crate), for Connection::from_bidi) constructors.
- Change BidiStreamSource::accept_bi/open_bi return types from
  (SendStream, RecvStream) to BiStream. Update QuinnBidiStreamSource,
  IrohBidiStreamSource, StreamBidiStreamSource impls to do the join once.
- Collapse SendStream/RecvStream to thin newtypes over
  Box<dyn Async* + Send + Unpin>. Remove SendStreamKind/RecvStreamKind
  enums and the quinn/iroh per-call dispatch (the join happens once in the
  BidiStreamSource impl now). Keep SendStream::from_stream /
  RecvStream::from_stream per-half boxing for into_sub_streams() (ADR-074)
  and the future channels reassembly path.
- Remove Connection::from_stream (split-pair constructor). Promote
  Connection::from_bidi to the only public stream constructor (the rule:
  the split never crosses a crate boundary as part of a constructor).
- Update Connection::accept_bi/open_bi to return BiStream. Update
  from_source_tests and tests modules to use from_bidi and BiStream;
  add a SinkEmpty test helper (AsyncRead EOF + AsyncWrite discard) for
  Connection-level-only test connections.

alknet-http (server/adapter.rs):
- Drop the 44-line QuicStream wrapper — accept_bi returns BiStream which
  is already AsyncRead + AsyncWrite. HttpAdapter::handle becomes 4 lines.
- Drop the 38-line QuicStreamDuplex test helper — tests use a single
  tokio::io::duplex whose ends are each AsyncRead + AsyncWrite natively.
- Remove unused std::io / std::pin::Pin imports.

alknet-tty (adapter.rs):
- TtyAdapter::handle splits the BiStream from accept_bi via
  tokio::io::split for drive_session's separate AsyncWrite/AsyncRead args
  (the stdlib idiom for TcpStream-style duplex streams).

alknet-call (protocol/*, client/*):
- Dispatcher::run_loop accept_bi site: take BiStream, pass to handle_stream.
- Dispatcher::handle_stream signature: take BiStream, split internally via
  tokio::io::split (was: take SendStream + RecvStream separately).
- CallConnection::call_with_payload / subscribe_with_payload / write_envelope:
  split the BiStream from open_bi via tokio::io::split at the call site.
- write_request / read_stream_until_closed: generic over AsyncWrite/AsyncRead
  (were: concrete SendStream/RecvStream) — accepts the ReadHalf/WriteHalf
  from tokio::io::split directly.
- Add protocol/test_support.rs with sink_empty_connection() (replaces the
  5 duplicated stub_connection() fns that used Connection::from_stream).
- Update all test stubs (call_client.rs, protocol/connection.rs,
  protocol/dispatch.rs, protocol/adapter.rs, client/from_call.rs) to use
  Connection::from_bidi + the shared sink_empty_connection() helper.
- Test handle_stream call sites: build BiStream::from_joined(recv, send)
  from the existing BufReader<Cursor> + duplex pair.

Workspace test status: all 9 crates pass (116 + 307 + 18 + 3 + 17 + 301 +
34 + 61 + 23 + 5 + 6 + 8 + 82 + 4 + 3 + 6 + 12 + 1 = 1007 tests pass). One
pre-existing failure remains in alknet-http
(adapters::to_mcp::tests::search_returns_access_control_filtered_ops_excluding_subscriptions
— handler kind mismatch, unrelated to Phase 6, fails on develop baseline).
2026-07-18 15:59:01 +00:00
glm-5.2 249370345f docs(research): add phases 6-9 — stream unification, TTY control fix, channels spec, http fix
Insert four new phases between the call prune (5) and the old http fix:
- Phase 6: Core stream unification (BiStream as handler leaf, ADR-092)
- Phase 7: TTY control-channel bidirectionality fix (STREAM_CTRL_IN/OUT)
- Phase 8: Channels spec cleanup (8-byte wire format, no stream_type)
- Phase 9: HTTP fix — drop QuicStream wrapper (now unnecessary after BiStream)

The old Phase 6 (http fix, deferred) is replaced — BiStream makes the
QuicStream wrapper dead code. Total: 10 phases (0-9).
2026-07-18 15:00:35 +00:00
glm-5.2 f03e38326c docs(research): resolve 8-vs-9-byte question — channels wire format is 8 bytes
Settle the open question: channels header is [channel_id:u32][length:u32]
(8 bytes) with opaque payload. The 9-byte alternative (including
stream_type in the channels header) is rejected — it leaks a handler
concept into the channels layer. The handler owns its framing entirely
within the payload. TTY's 5-byte format composes as payload bytes;
total header for TTY inside channels is 13 bytes (8 + 5).
2026-07-18 14:04:44 +00:00
glm-5.2 073bbba06a docs(research): rewrite stream-unification findings — channels as pure channel multiplexing
The previous framing ('mod 2 vs mod 3 vs mod 4 for the stream_type
space within a channel') was a symptom. The actual question is the
separation of concerns between the channels layer and the handler.

Resolution: the channels layer routes by channel_id only; handlers
own their sub-multiplexing on the BiStream they receive. Every
channel is a BiStream. The 'pass a stream to/from any ALPN' objective
becomes universal, not qualified.

The wire formats compose by construction: the 9-byte channels header
is the 5-byte TTY header with channel_id:u32 prepended. The channels
layer adds channel_id on write, strips it on read, hands the inner
5 bytes to the TTY handler. TTY's wire.rs works as-is. The
'double-chunking' objection (ADR-077's reason for rejecting
sub-multiplex inside channels) was about a 14-byte double-header; the
actual composition is 9 bytes total, shared across both layers because
the length prefix is shared.

This dissolves:
- The mod 2/3/4 question at the channels layer (the channels layer
  has no stream_type concept).
- The 'control isn't actually bidirectional' TTY flaw (TTY owns its
  sub-streams; stream_type 3 = ctrl_in, 4 = ctrl_out at the TTY layer).
- The 'into_sub_streams() as a second-class accessor' (removed;
  accept_bi is the only accessor, yields one BiStream per channel).
- The recursive composition question (made cleaner — strip a prefix
  at every level, uniform shape).
- The 'merge and split stderr' confusion (stderr is a handler concern;
  the channels layer carries bytes; TTY owns the stdout/stderr
  distinction).

ADR-077 is reversed: TTY always uses its 5-byte format, the channels
layer carries it transparently. The two-mode TTY design is preserved
but differs only in BiStream source, not in parsing.

No production constraint (develop branch is a rewrite, no one is
using this version yet). The decision is purely 'what's cleanest.'

One open sub-question: 8 bytes vs 9 bytes for the channels wire format.
9 bytes preserves TTY's wire.rs via literal strip/add; 8 bytes is more
uniform across inner layers but requires rewriting TTY's format.
Default assumption: 9 bytes (the strip/add property is the elegant one).

ADR-093 is ready to draft. The structural question is resolved.
2026-07-18 07:32:53 +00:00
glm-5.2 b5397f61aa docs(research): rewrite stream-unification findings — focus on the multiplexing layer
The previous draft was mixing two layers (transport leaf and stream_type
multiplexing) and including side-topics (WsBidiStream home, etc.) that
weren't load-bearing, which confused agents into conflating tokio::io::
join/split (ADR-092's layer, settled) with the demux/mux stream_type
layer (this doc's layer, in progress).

Rewrite to be focused:

- Layering section upfront separates transport leaf (ADR-092, settled),
  multiplexing (this doc), and channel protocol (ADR-072/073, settled).
  The two questions that got conflated are explicitly separated.
- Drop the ADR-092 recap (it's in the ADR, not this doc's concern).
- Drop the 'five abstractions' table (ADR-092's framing, not this doc's).
- Drop the WS open question (irrelevant to multiplexing).
- Record that POC 1 (stderr split/recombine) is already answered by the
  existing POC evidence: per-stream_type independent demux/mux (verified
  in demux.rs:91-109, 161-181 and mux.rs:58-63, 152-177) means the
  'unused write half' is an idle mpsc channel, not a wart. The mod-2
  framing is trivially clean. No new POC needed.
- The mod-2-vs-mod-3 question is settled by existing evidence; ADR-093
  is ready to draft.
- The one open question that benefits from a POC is POC 2
  (TTY-direct-as-channels, for the format-convergence / retire-5-byte
  call). POC 3 (recursive composition) is low leverage, deferred.
- The TTY control channel flaw is flagged as implementation-lag, not a
  design question (fix specified in ADR-077, subsumed by mod-4 instance
  framing).

This drops the scope to what the doc is actually about: the stream_type
convention and the TTY/channels convergence.
2026-07-18 05:51:54 +00:00
glm-5.2 909935ded3 docs(research): add stream-unification findings — the leaf, the instance, the convergence
Captures the deep dive that started as the alknet-crate-extraction
Phase 6 tangle and surfaced a layered issue:

1. Transport leaf split (ADR-092, drafted+pushed separately) — accept_bi
   returns BiStream, from_stream removed, from_bidi is the only public
   stream constructor. Load-bearing, separable.
2. Control channel 'isn't actually bidirectional' in TTY code (wire.rs
   STREAM_CONTROL=3 one stream both sides write). ADR-071 already fixes
   at wire-format level (3=ctrl_in, 4=ctrl_out); TTY code lags.
3. ADR-071's mod-3 stream_type decomposition is structural but
   asymmetric (stderr baked into group shape). Cleaner framing is
   mod 2/mod 4 by instance: an instance is a bidirectional unit addressed
   as a contiguous block of stream_types. No control: 128
   instances/channel (mod 2). With control: 64 instances/channel
   (mod 4). Combined address space ~255*128 or ~255*64.
4. TTY and channels should converge on one format. Channels was
   written after TTY as a natural extension (5-byte + channel_id:u32 =
   9-byte). Whether TTY-direct retires the 5-byte format is a bigger
   call — backward compat — flagged as POC candidate.
5. Recursive multiplexing follows: each instance can be a channels
   connection. Unbounded, uniform per level via the instance framing.

Following the research-then-sync pattern: iterate here, fix
inter-document drift, sync to specs only after it settles. ADR-092 is
pushed because it's load-bearing and separable; ADR-093 (multiplexing
redesign) and ADR-094 (retire 5-byte format) draft after POCs validate.

POC candidates (ordered by leverage):
- POC 1: stderr split/recombine (load-bearing for mod 2 vs mod 3)
- POC 2: TTY-direct-as-channels (load-bearing for format convergence)
- POC 3: recursive composition (low leverage, deferred)
2026-07-18 04:59:23 +00:00
glm-5.2 528cfa0367 docs(adr): 092 — remove Connection::from_stream, from_bidi is the only public constructor
The earlier draft kept from_stream as an 'escape hatch' for already-split
transports. That bakes the split into the constructor API — the same
split-leaf shape pushed one step earlier. The cleaner normalization: the
split never crosses a crate boundary as part of a constructor.

- Connection::from_bidi is the only public stream constructor.
- Connection::from_stream(send, recv, ...) is removed.
- The channels reassembly path joins MpscSendStream/MpscRecvStream itself
  via tokio::io::join (one line) and calls from_bidi.
- The call crate's 5 test stub sites do tokio::io::split(x) then
  from_stream — they become from_bidi(x) directly. The split was always
  gratuitous at the call site.
- SendStream::from_stream / RecvStream::from_stream (per-half boxing for
  into_sub_streams() and the SubStreamHandle leaves) are retained — not
  constructors that feed Connection.
2026-07-18 03:37:22 +00:00
glm-5.2 f8d4650dce docs(adr): 092 — BiStream as the handler leaf, unify split-pair accept_bi
The crate-extraction findings Phase 6 deferred the alknet-http rework
on the grounds that the QuicStream wrapper (44 lines) is a necessary
adapter. The finding was right about the symptom, wrong about the
cause: the root is that the leaf type is split, so every consumer
re-joins or bypasses. Five abstractions exist for one concept
(BiStream trait vestigial in code, Connection, SendStream/RecvStream,
WsStream, MpscSendStream/MpscRecvStream).

ADR-092 resurrects ADR-007's BiStream as a concrete newtype leaf
(the bounds survive, the trait becomes a concrete struct for
Pin<&mut Self> projection), moves the join into core's quinn/iroh
BidiStreamSource impls once, and removes the per-handler wrappers:

- HttpAdapter::handle drops QuicStream (44 lines) and QuicStreamDuplex
  (38 lines); serve_io is unchanged.
- WebSocket runs through Connection::from_bidi + the call-protocol
  handler. WsBidiStream (~50-80 lines) implements AsyncRead/AsyncWrite
  over axum WS binary messages. WsStream trait, drive_ws_session loop,
  and ~150 lines of dispatch glue removed. ADR-044/048's 'WS message
  stream is BiStream-satisfying' becomes literal.
- Tunnel/SSH handlers call tokio::io::split(bidi) for their two pumps
  (same stdlib idiom as TcpStream/TlsStream). ADR-078 preserved.
- SendStream/RecvStream collapse to thin newtypes (quinn enum dispatch
  gone); retained for ADR-074 into_sub_streams() and channels
  reassembly (ADR-071 unidirectional sub-streams).
- 'VPN-like without being a VPN' over WS in v1 becomes real: the
  webtransport.md path, over WS, now. WASM SSH parser implements
  BiStream over a WS-message adapter on the browser side.
- WebTransport h3 extraction recorded as a future channels-variant
  move enabled by the unification (out of scope per ADR-044).

Amends ADR-065 (from_bidi primary, from_stream escape hatch),
ADR-070 (accept_bi returns BiStream), ADR-074
(ChannelBidiStreamSource::accept_bi returns BiStream; into_sub_streams
unchanged). ADR-077 two-mode TTY design preserved. Resolves the
findings.md Phase 6 deferral.

Three open questions recorded with defaults (WsBidiStream home,
SendStream/RecvStream long-term home, from_stream vs from_bidi
primacy) — none blocking.
2026-07-18 03:09:38 +00:00
glm-5.2 3b10fc1817 refactor(core,tls,client): align ConnectionCredentials field name with ADR-091 (tls_identity -> local_identity)
ADR-091 decided `ConnectionCredentials.local_identity`; the code implemented
`tls_identity` (tasks/core/connection-credentials.md deferred the rename as
"path of least resistance" during the extraction). The tangle that made the
rename hard no longer exists, so align the code with the decision.

Scope is the `ConnectionCredentials` field + builder only:
- alknet-core/credentials.rs: field, with_local_identity, doc, test
- alknet-tls/client.rs: field access in TlsClientConfig::new, test builders, docs
- alknet-client/dial/quinn.rs: test builder

NOT renamed (distinct concepts sharing the words):
- StaticConfig.tls_identity (server-side static config; ADR-082/027/083)
- TlsIdentity enum type name
- alknet-tls server fn params named tls_identity (&TlsIdentity value)

Also fixes dial_iroh.rs doc comments that claimed the local key is extracted
from creds.local_identity — the key is actually on the pre-built iroh endpoint
(set at with_iroh time); the dial reads only creds.remote_identity and ignores
creds.local_identity (per client/README.md §iroh).

Architecture specs updated to match (call/client-and-adapters.md, tls/README.md,
client/README.md). Historical ADR context describing the old CallCredentials
field stays as-is; tasks/ and docs/research/ are historical artifacts.

Resolves follow-up #2 from the post-extraction spec sync (c6eef73).
2026-07-17 15:32:04 +00:00
glm-5.2 c6eef730e4 docs(architecture): sync specs to post-extraction state (phases 0-5)
The crate-extraction migration (phases 0-5) is complete in the code;
the specs still carried forward/migration framing ("was welded",
"after the refactor", "currently duplicated", "does not exist yet",
"What moves from X to Y" tables, "Implementation ordering") that
described the migration rather than the resulting state. Updated 10
spec files to describe the current state cleanly.

Spec/code mismatches fixed:
- core/README.md: a stale paragraph said CallCredentials "stays in
  alknet-call" while ADR-091 Am. 2026-07-17 removed it. Now consistent.
- tls/README.md: TlsClientConfig API described a planned
  ClientVerifierContext + for_tcp_tls(&self) + rustls_config(&self);
  the actual code is new(&ConnectionCredentials, alpn) +
  for_quinn(self) + into_rustls_config(self). Updated to match.
- client/README.md, call/client-and-adapters.md: ConnectionCredentials
  field is tls_identity / with_tls_identity in the code, not
  local_identity / with_local_identity. Updated the specs describing
  the current API (ADR-091 body keeps local_identity as the decided
  name).
- client/README.md: dial_iroh description said the local key is
  "extracted from creds.local_identity" — the code uses the pre-built
  iroh endpoint's key (set at with_iroh time) and reads only
  creds.remote_identity for the NodeId. Fixed.
- overview.md: said core has "no quinn/iroh deps" — core keeps
  quinn/iroh for Connection::from_quinn/from_iroh. Fixed.
- call/client-and-adapters.md: a /// doc-comment block and
  pub struct RemoteIdentity were floating outside any code fence
  (orphaned closing backticks). Fixed.
- tls/README.md: TlsError sketch shows the full ADR-088 6-variant
  enum; the code has a simplified 3-variant enum. Added an
  implementation note flagging the divergence; ADR-088 shape kept as
  target.
- call/README.md: review note said "ADR-029 migration pending" (stale
  — migration landed). Updated to reflect phase 5 completion (pure
  protocol crate, no TLS/transport deps, verified against Cargo.toml).

Migration framing removed (present-state descriptions instead):
- tls/README.md: "What moves from" tables -> module-contents tables;
  "Implementation ordering / greenfield" section removed; "after the
  refactor" section -> "What AlknetEndpoint does"; references to
  extraction-source files (alknet-core/src/endpoint.rs,
  alknet-call/src/client/call_client.rs) replaced with current file
  locations (alknet-tls/src/{server,client,pem,signing}.rs).
- endpoint/README.md: "was two things welded" framing removed;
  "after the extraction" section -> "What alknet-core looks like".
- core/endpoint.md: "Historical summary" section removed; clean
  deprecation pointer.
- README.md, overview.md, open-questions.md: dates + present-tense
  cleanup.
2026-07-17 14:51:28 +00:00
deepseek-v4-pro ddc577cd3e docs: update Phase 6 findings — QuicStream wrapper is necessary, deferred
SendStream implements only AsyncWrite, RecvStream implements only
AsyncRead. The QuicStream wrapper (44 lines) is a necessary adapter
that combines the split pair from accept_bi() into a single
AsyncRead+AsyncWrite type. Phase 6 is deferred — removing it would
require a new BidiStream abstraction or restructuring HttpAdapter::handle.
2026-07-17 14:09:21 +00:00
deepseek-v4-pro 4fc6854846 chore(call): prune connect, TLS helpers, CallCredentials, and dead paths (Phase 5)
- Remove RemoteIdentity, CallCredentials, ClientError, connect() from call_client.rs
- Remove all TLS helpers (build_quinn_client_config, build_client_auth,
  select_server_verifier, FingerprintPinVerifier, RawKeyClientCertResolver,
  NoClientCertResolver, Ed25519SigningKey, cert/key loaders)
- Remove credentials_auth_token dead path from from_call.rs
  (OpSummary field, build_bundles, make_forwarding_handler,
  make_streaming_forwarding_handler, build_forwarded_payload)
- Remove 2 dead-path tests (build_forwarded_payload_sets_auth_token,
  streaming_forwarding_handler_sets_auth_token)
- Update mod.rs re-exports to only CallClient
- Remove quinn feature and TLS deps from Cargo.toml
- Delete tests/two_node_call.rs (used connect() + CallCredentials)
- alknet-call is now a pure protocol crate
2026-07-17 14:01:51 +00:00
deepseek-v4-pro a0dbe4fd3c chore(core): prune endpoint.rs and heavy deps (Phase 4)
- Delete crates/alknet-core/src/endpoint.rs (1606 lines)
- Remove pub mod endpoint from lib.rs, update doc comment
- Remove rcgen, rustls-pemfile, rustls-acme deps from Cargo.toml
- Remove acme feature
- Update fingerprint.rs doc comment references to alknet-endpoint/alknet-tls
2026-07-17 13:21:54 +00:00
deepseek-v4-pro 83a94ec03f chore(client): mark all client tasks as completed
Phase 3 complete: alknet-client crate implemented with:
- AlknetClient struct with builder methods (with_quinn, with_tcp_tls, with_iroh, with_socks5_proxy)
- ClientDialError enum with 5 variants
- dial_quic, dial_tcp_tls, dial_iroh methods
- SOCKS5 proxy support (Socks5ProxyConfig, Socks5Credentials, Socks5UdpSocket)
- Unit tests (18) + integration test (3)
- All feature combos compile and test clean
- Workspace build and tests pass
2026-07-17 12:49:50 +00:00
deepseek-v4-pro 2a75724dde style(client): cargo fmt 2026-07-17 12:49:40 +00:00
deepseek-v4-pro 759c627ca4 test(client): add unit tests and integration test for alknet-client
- AlknetClient construction tests: new(), Default, Send+Sync, Debug
- ClientDialError tests: #[from] conversion, display formatting, Send+Sync
- dial_quic error path tests: NoTransport, TlsConfig on invalid creds
- dial_tcp_tls error path tests: NoTransport
- dial_iroh error path tests: NoTransport, extract_iroh_endpoint_id
- SOCKS5 proxy error display test (feature-gated)
- Integration test: dial_and_takeover.rs

Task: client/tests
2026-07-17 12:47:40 +00:00
deepseek-v4-pro 82ee37e206 feat(client): implement alknet-client crate — AlknetClient, dial methods, error type, SOCKS5 proxy
- Initialize alknet-client crate with Cargo.toml, deps, feature flags
- Implement ClientDialError enum with 5 variants (TlsConfig, Connect, Handshake, NoTransport, Proxy)
- Implement AlknetClient struct with builder methods (with_quinn, with_tcp_tls, with_iroh, with_socks5_proxy)
- Implement dial_quic — QUIC dial via quinn, producing a Connection
- Implement dial_tcp_tls — TCP+TLS dial via tokio-rustls, producing a Connection
- Implement dial_iroh — iroh dial, producing a Connection
- Implement SOCKS5 proxy support — Socks5ProxyConfig, Socks5Credentials, Socks5UdpSocket, proxy integration
- Add into_rustls_config() to TlsClientConfig in alknet-tls
- Add alknet-client to workspace members

Tasks: client/crate-init, client/error-type, client/client-core,
client/dial-quic, client/dial-tcp-tls, client/dial-iroh, client/socks5-proxy
2026-07-17 12:42:47 +00:00
142 changed files with 23859 additions and 4562 deletions

No files matched your search

Generated
+304 -19
View File
@@ -37,6 +37,20 @@ dependencies = [
"subtle",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"getrandom 0.3.4",
"once_cell",
"serde",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -53,13 +67,7 @@ dependencies = [
"alknet-core",
"async-trait",
"futures",
"hex",
"parking_lot",
"quinn",
"rcgen",
"rustls",
"rustls-native-certs",
"rustls-pemfile",
"serde",
"serde_json",
"thiserror 2.0.18",
@@ -68,6 +76,24 @@ dependencies = [
"uuid",
]
[[package]]
name = "alknet-client"
version = "0.1.0"
dependencies = [
"alknet-core",
"alknet-tls",
"fast-socks5",
"hex",
"iroh",
"quinn",
"rustls",
"rustls-pki-types",
"thiserror 2.0.18",
"tokio",
"tokio-rustls",
"tracing",
]
[[package]]
name = "alknet-core"
version = "0.1.0"
@@ -81,10 +107,7 @@ dependencies = [
"iroh",
"quinn",
"rand 0.8.6",
"rcgen",
"rustls",
"rustls-acme",
"rustls-pemfile",
"rustls-pki-types",
"serde",
"serde_json",
@@ -202,6 +225,14 @@ dependencies = [
"tracing",
]
[[package]]
name = "alknet-typedef"
version = "0.1.0"
dependencies = [
"jsonschema",
"serde_json",
]
[[package]]
name = "alknet-vault"
version = "0.1.0"
@@ -535,6 +566,21 @@ dependencies = [
"zeroize",
]
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitcoin_hashes"
version = "0.14.2"
@@ -610,12 +656,24 @@ dependencies = [
"piper",
]
[[package]]
name = "borrow-or-share"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytecount"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
[[package]]
name = "bytes"
version = "1.12.0"
@@ -1236,6 +1294,15 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
dependencies = [
"serde",
]
[[package]]
name = "embedded-io"
version = "0.4.0"
@@ -1296,6 +1363,32 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fancy-regex"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277"
dependencies = [
"bit-set",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "fast-socks5"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9545787d8304a71e1bf1b711705070a4c400cce9b332c4a11800627b7c9a2067"
dependencies = [
"anyhow",
"async-trait",
"log",
"socket2 0.5.10",
"thiserror 1.0.69",
"tokio",
"tokio-stream",
]
[[package]]
name = "fastbloom"
version = "0.14.1"
@@ -1343,6 +1436,17 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fluent-uri"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e"
dependencies = [
"borrow-or-share",
"ref-cast",
"serde",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1364,6 +1468,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fraction"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872"
dependencies = [
"lazy_static",
"num",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
@@ -1602,6 +1716,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
@@ -1834,7 +1959,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"socket2 0.6.4",
"tokio",
"tower-service",
"tracing",
@@ -1986,7 +2111,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
@@ -2006,7 +2131,7 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
"socket2",
"socket2 0.6.4",
"widestring",
"windows-registry",
"windows-result",
@@ -2289,6 +2414,42 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonschema"
version = "0.46.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0a699d3e77675e6aa4bfffe3b907c8b5f7ed3241f9965bffb25475ad4b08d05"
dependencies = [
"ahash",
"bytecount",
"data-encoding",
"email_address",
"fancy-regex",
"fraction",
"getrandom 0.3.4",
"idna",
"itoa",
"jsonschema-regex",
"num-cmp",
"num-traits",
"percent-encoding",
"referencing",
"regex",
"serde",
"serde_json",
"unicode-general-category",
"uuid-simd",
]
[[package]]
name = "jsonschema-regex"
version = "0.46.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbd1086b01b9349fd4ef9a07433965af64c8ce8159abe633a189e4ff817bd13"
dependencies = [
"regex-syntax",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -2359,7 +2520,7 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9"
dependencies = [
"hashbrown",
"hashbrown 0.17.1",
]
[[package]]
@@ -2395,6 +2556,12 @@ version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "micromap"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
[[package]]
name = "mime"
version = "0.3.17"
@@ -2595,7 +2762,7 @@ dependencies = [
"objc2-system-configuration",
"pin-project-lite",
"serde",
"socket2",
"socket2 0.6.4",
"time",
"tokio",
"tokio-util",
@@ -2642,7 +2809,7 @@ dependencies = [
"pin-project-lite",
"rustc-hash",
"rustls",
"socket2",
"socket2 0.6.4",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
@@ -2685,7 +2852,7 @@ checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e"
dependencies = [
"cfg_aliases 0.2.1",
"libc",
"socket2",
"socket2 0.6.4",
"tracing",
"windows-sys 0.61.2",
]
@@ -2699,6 +2866,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
@@ -2709,6 +2890,21 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-cmp"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -2724,6 +2920,27 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2896,6 +3113,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "outref"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "papaya"
version = "0.2.4"
@@ -3213,7 +3436,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"socket2 0.6.4",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -3253,7 +3476,7 @@ dependencies = [
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2",
"socket2 0.6.4",
"tracing",
"windows-sys 0.60.2",
]
@@ -3407,6 +3630,35 @@ dependencies = [
"syn",
]
[[package]]
name = "referencing"
version = "0.46.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbf332a2f81899f6836f22c03da73dae8a664c32e3016b84692c23cddadc95d"
dependencies = [
"ahash",
"fluent-uri",
"getrandom 0.3.4",
"hashbrown 0.16.1",
"itoa",
"micromap",
"parking_lot",
"percent-encoding",
"serde_json",
]
[[package]]
name = "regex"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
@@ -3895,6 +4147,7 @@ version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"indexmap",
"itoa",
"memchr",
"serde",
@@ -4093,6 +4346,16 @@ version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.4"
@@ -4388,7 +4651,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"socket2 0.6.4",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -4682,6 +4945,12 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-general-category"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -4755,6 +5024,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "uuid-simd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
dependencies = [
"outref",
"vsimd",
]
[[package]]
name = "valuable"
version = "0.1.1"
@@ -4804,6 +5083,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsimd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
[[package]]
name = "walkdir"
version = "2.5.0"
+2
View File
@@ -8,6 +8,8 @@ members = [
"crates/alknet-tls",
"crates/alknet-tty",
"crates/alknet-tty-local",
"crates/alknet-client",
"crates/alknet-typedef",
]
resolver = "2"
+2 -11
View File
@@ -3,15 +3,14 @@ name = "alknet-call"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Structured RPC over QUIC on ALPN `alknet/call`: operations, streaming subscriptions, service discovery"
description = "Structured RPC over ALPN `alknet/call`: operations, streaming subscriptions, service discovery"
repository.workspace = true
[lib]
name = "alknet_call"
[features]
default = ["quinn"]
quinn = ["dep:quinn", "dep:rustls", "dep:rustls-native-certs", "dep:rustls-pemfile", "alknet-core/quinn"]
default = []
[dependencies]
alknet-core = { path = "../alknet-core" }
@@ -24,11 +23,3 @@ thiserror = "2"
uuid = { version = "1", features = ["v4"] }
futures = "0.3"
parking_lot = "0.12"
quinn = { version = "0.11", optional = true }
rustls = { version = "0.23", optional = true, features = ["aws_lc_rs"] }
rustls-native-certs = { version = "0.8", optional = true }
rustls-pemfile = { version = "2", optional = true }
[dev-dependencies]
rcgen = "0.13"
hex = "0.4"
+10 -743
View File
@@ -1,8 +1,7 @@
//! `CallClient`: the outbound connection opener (ADR-017 §1).
//!
//! Opens a QUIC connection to a remote node on ALPN `alknet/call`, performs
//! credential setup, and produces a [`CallConnection`] running the shared
//! dispatch loop (delegated to [`crate::protocol::dispatch::Dispatcher`]).
//! Runs the shared dispatch loop over a pre-established `Connection`
//! (delegated to [`crate::protocol::dispatch::Dispatcher`]).
//! `CallClient` is the connection-establishment half; `CallAdapter`'s accept
//! path is the inbound half; both produce a `CallConnection` and hand it to
//! the same `Dispatcher::run_loop` (ADR-017 §1).
@@ -12,93 +11,21 @@
//! (initiates outgoing calls via `CallConnection::call()`/`subscribe()`/
//! `abort()`) and a callee (dispatches incoming calls against its registry).
//!
//! Transport-level connection establishment (QUIC dial, TCP+TLS, iroh) is
//! handled by `alknet-client`; `CallClient::spawn_dispatch` takes a
//! pre-established `Connection` and runs the call protocol over it.
//!
//! See `docs/architecture/crates/call/client-and-adapters.md` for the spec.
use std::net::SocketAddr;
use std::sync::Arc;
use alknet_core::auth::IdentityProvider;
use alknet_core::config::TlsIdentity;
use alknet_core::types::Connection;
use crate::protocol::connection::CallConnection;
use crate::protocol::dispatch::Dispatcher;
use crate::registry::registration::OperationRegistry;
/// Expected identity of the remote node (ADR-017 §7, extended by ADR-034 §2).
/// 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 raw-key transport, `None` fails closed (raw-key
/// remotes are always known peers — no CA to fall back to).
///
/// The `Option` is therefore load-bearing, not cosmetic: `Some(fingerprint)`
/// means "pin this" (known peer), `None` means "trust the CA or fail"
/// (unknown remote). An implementer must not default `remote_identity` to a
/// placeholder value to "satisfy" the field — `None` is a real state that
/// drives verifier selection.
#[derive(Debug, Clone)]
pub struct RemoteIdentity {
pub fingerprint: String,
}
/// Credentials for an outbound `alknet/call` connection (ADR-017 §7). All
/// three dimensions come from `Capabilities` (ADR-014), never from environment
/// variables — see the No-Env-Vars Invariant in
/// `docs/architecture/crates/call/client-and-adapters.md`.
#[derive(Debug, Clone, Default)]
pub struct CallCredentials {
/// The local node's TLS identity (RFC 7250 raw key or X.509), derived
/// from the vault at startup.
pub tls_identity: Option<TlsIdentity>,
/// Opaque call-protocol-level auth token, decrypted from the vault.
pub auth_token: Option<alknet_core::auth::AuthToken>,
/// 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). `None` is the public-X.509-endpoint state, not a
/// missing field — must not be defaulted to a placeholder.
pub remote_identity: Option<RemoteIdentity>,
}
impl CallCredentials {
pub fn new() -> Self {
Self::default()
}
pub fn with_tls_identity(mut self, tls_identity: TlsIdentity) -> Self {
self.tls_identity = Some(tls_identity);
self
}
pub fn with_auth_token(mut self, token: alknet_core::auth::AuthToken) -> Self {
self.auth_token = Some(token);
self
}
pub fn with_remote_identity(mut self, remote: RemoteIdentity) -> Self {
self.remote_identity = Some(remote);
self
}
}
/// Errors produced by [`CallClient::connect`].
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ClientError {
#[error("transport error: {message}")]
Transport { message: String },
#[error("tls setup error: {message}")]
TlsSetup { message: String },
#[error("connection closed")]
ConnectionClosed,
}
/// Outbound `alknet/call` connection opener (the #1 gap, ADR-017 §1).
///
/// Peer authorization flows through the existing `AccessControl::check` gate
@@ -128,50 +55,11 @@ impl CallClient {
&self.identity_provider
}
/// Open a QUIC connection to `addr` on ALPN `alknet/call`, perform
/// credential handshake, and return a `CallConnection` running the shared
/// dispatch loop. Credentials come from `Capabilities` (ADR-014), not env
/// vars — the no-env-vars invariant.
///
/// The dispatch loop runs on a spawned task; the returned `CallConnection`
/// is live until the remote closes the connection or the caller drops it.
/// The caller can immediately use `call()`/`subscribe()`/`abort()` on the
/// returned connection, and the remote peer can call back into this
/// `CallClient`'s registry (connection symmetry, ADR-017 §2).
#[cfg(feature = "quinn")]
pub async fn connect(
&self,
addr: SocketAddr,
credentials: CallCredentials,
) -> Result<CallConnection, ClientError> {
let alpn = b"alknet/call".to_vec();
let client_config = build_quinn_client_config(&credentials, &alpn)
.map_err(|e| ClientError::TlsSetup { message: e })?;
let bind_addr: SocketAddr = "0.0.0.0:0".parse().expect("valid bind addr");
let endpoint = quinn::Endpoint::client(bind_addr).map_err(|e| ClientError::Transport {
message: e.to_string(),
})?;
let connection = endpoint
.connect_with(client_config, addr, "alknet")
.map_err(|e| ClientError::Transport {
message: e.to_string(),
})?
.await
.map_err(|e| ClientError::Transport {
message: e.to_string(),
})?;
let connection = Connection::from_quinn_with_alpn(connection, alpn);
Ok(self.spawn_dispatch(connection))
}
/// Run the shared dispatch loop over a pre-established `Connection`. The
/// `CallClient` spawns the dispatcher task and returns a live
/// `CallConnection` the caller can use immediately. Used by `connect()`
/// (after the QUIC dial completes) and by integration tests that wire a
/// mock/loopback `Connection` directly.
/// `CallConnection` the caller can use immediately. Used by the assembly
/// layer after `AlknetClient::dial_*` + `spawn_dispatch` and by
/// integration tests that wire a mock/loopback `Connection` directly.
pub fn spawn_dispatch(&self, connection: Connection) -> CallConnection {
let call_connection = Arc::new(CallConnection::new(connection));
let dispatcher = Dispatcher::new(
@@ -186,386 +74,6 @@ impl CallClient {
}
}
#[cfg(feature = "quinn")]
fn build_quinn_client_config(
credentials: &CallCredentials,
alpn: &[u8],
) -> Result<quinn::ClientConfig, String> {
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let client_auth = build_client_auth(&provider, &credentials.tls_identity)?;
let verifier = select_server_verifier(&provider, &credentials.remote_identity)?;
let mut config = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| e.to_string())?
.dangerous()
.with_custom_certificate_verifier(verifier)
.with_client_cert_resolver(client_auth);
config.alpn_protocols = vec![alpn.to_vec()];
config.enable_early_data = true;
Ok(quinn::ClientConfig::new(Arc::new(
quinn::crypto::rustls::QuicClientConfig::try_from(config).map_err(|e| e.to_string())?,
)))
}
/// Build the client-auth cert resolver that presents the local node's TLS
/// identity. For `TlsIdentity::RawKey` the Ed25519 key is presented as an RFC
/// 7250 raw public key client cert (`only_raw_public_keys() == true`) — the
/// client-side equivalent of the server's `RawKeyCertResolver`. For X.509 the
/// cert chain + key are loaded from disk. `None` (no `tls_identity` configured)
/// resolves to no client cert (the server gets nothing to fingerprint).
#[cfg(feature = "quinn")]
fn build_client_auth(
provider: &Arc<rustls::crypto::CryptoProvider>,
tls_identity: &Option<TlsIdentity>,
) -> Result<Arc<dyn rustls::client::ResolvesClientCert>, String> {
match tls_identity {
Some(TlsIdentity::RawKey(secret_key)) => {
let signing_key = Arc::new(Ed25519SigningKey::new(secret_key.clone()));
let spki = signing_key.spki_public_key();
let cert = rustls::pki_types::CertificateDer::from(spki.to_vec());
let certified_key = Arc::new(rustls::sign::CertifiedKey::new(vec![cert], signing_key));
Ok(Arc::new(RawKeyClientCertResolver::new(certified_key)))
}
Some(TlsIdentity::X509 { cert, key }) => {
let cert_chain = load_cert_chain(cert).map_err(|e| e.to_string())?;
let key_der = load_private_key(key).map_err(|e| e.to_string())?;
let certified_key = rustls::sign::CertifiedKey::from_der(cert_chain, key_der, provider)
.map_err(|e| e.to_string())?;
Ok(Arc::new(RawKeyClientCertResolver::new(Arc::new(
certified_key,
))))
}
Some(TlsIdentity::SelfSigned) | None => Ok(Arc::new(NoClientCertResolver)),
Some(TlsIdentity::Acme { .. }) => {
Err("ACME TLS identity is server-only; cannot be used for client auth".to_string())
}
}
}
/// Select the server cert verifier by `remote_identity` presence (ADR-034 §3).
///
/// - `Some(fingerprint)` → known peer → `FingerprintPinVerifier` (fingerprint
/// match). The fingerprint IS the trust anchor.
/// - `None` → no `PeerEntry` for the remote → `WebPkiServerVerifier` (CA
/// verification) for X.509 remotes. For Ed25519 raw-key remotes the
/// `WebPkiServerVerifier` fails closed at handshake time (raw-key remotes
/// have no CA to fall back to — ADR-034 §2 assumption 1). `None` is the
/// public-X.509-endpoint state, not "skip verification."
#[cfg(feature = "quinn")]
fn select_server_verifier(
provider: &Arc<rustls::crypto::CryptoProvider>,
remote_identity: &Option<RemoteIdentity>,
) -> Result<Arc<dyn rustls::client::danger::ServerCertVerifier>, String> {
match remote_identity {
Some(ri) => Ok(Arc::new(FingerprintPinVerifier::new(
ri.fingerprint.clone(),
provider.signature_verification_algorithms,
))),
None => {
let roots = load_platform_root_cert_store()?;
let verifier = rustls::client::WebPkiServerVerifier::builder_with_provider(
Arc::new(roots),
Arc::clone(provider),
)
.build()
.map_err(|e| e.to_string())?;
Ok(verifier)
}
}
}
/// Load the platform's trusted root certificates into a `RootCertStore` for
/// `WebPkiServerVerifier` (the `None` + X.509 CA-verification path). Falls back
/// to the aws-lc-rs built-in `webpki-roots` if the platform store is empty
/// (e.g. in a container with no system CA bundle).
#[cfg(feature = "quinn")]
fn load_platform_root_cert_store() -> Result<rustls::RootCertStore, String> {
let mut roots = rustls::RootCertStore::empty();
let result = rustls_native_certs::load_native_certs();
for err in &result.errors {
tracing::warn!(error = ?err, "failed to load a native root cert");
}
for cert in &result.certs {
roots
.add(cert.clone())
.map_err(|e| format!("failed to add native root cert: {e}"))?;
}
Ok(roots)
}
#[cfg(feature = "quinn")]
fn load_cert_chain(
path: &std::path::Path,
) -> Result<Vec<rustls::pki_types::CertificateDer<'static>>, String> {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let mut reader = std::io::BufReader::new(bytes.as_slice());
rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())
}
#[cfg(feature = "quinn")]
fn load_private_key(
path: &std::path::Path,
) -> Result<rustls::pki_types::PrivateKeyDer<'static>, String> {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let mut reader = std::io::BufReader::new(bytes.as_slice());
match rustls_pemfile::private_key(&mut reader) {
Ok(Some(key)) => Ok(key),
Ok(None) => Err("no private key found in file".to_string()),
Err(e) => Err(e.to_string()),
}
}
/// Client cert resolver that presents a single RFC 7250 raw public key (or
/// X.509 cert chain). For raw keys `only_raw_public_keys()` returns `true` so
/// rustls negotiates the RFC 7250 ClientCertificateType extension.
#[cfg(feature = "quinn")]
struct RawKeyClientCertResolver {
key: Arc<rustls::sign::CertifiedKey>,
raw_public_keys: bool,
}
#[cfg(feature = "quinn")]
impl RawKeyClientCertResolver {
fn new(key: Arc<rustls::sign::CertifiedKey>) -> Self {
let raw_public_keys = key.cert.len() == 1 && is_ed25519_spki(&key.cert[0]);
Self {
key,
raw_public_keys,
}
}
}
#[cfg(feature = "quinn")]
fn is_ed25519_spki(cert_der: &rustls::pki_types::CertificateDer<'_>) -> bool {
alknet_core::fingerprint::extract_ed25519_raw_key_from_spki(cert_der.as_ref()).is_some()
}
#[cfg(feature = "quinn")]
impl std::fmt::Debug for RawKeyClientCertResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RawKeyClientCertResolver")
.field("raw_public_keys", &self.raw_public_keys)
.finish()
}
}
#[cfg(feature = "quinn")]
impl rustls::client::ResolvesClientCert for RawKeyClientCertResolver {
fn resolve(
&self,
_root_hint_subjects: &[&[u8]],
_sigschemes: &[rustls::SignatureScheme],
) -> Option<Arc<rustls::sign::CertifiedKey>> {
Some(Arc::clone(&self.key))
}
fn only_raw_public_keys(&self) -> bool {
self.raw_public_keys
}
fn has_certs(&self) -> bool {
true
}
}
/// Client cert resolver that presents no client cert (the `tls_identity: None`
/// or `SelfSigned` path). The server gets nothing to fingerprint — the
/// `PeerEntry` fingerprint → `peer_id` resolution path is not activated for
/// this connection.
#[cfg(feature = "quinn")]
struct NoClientCertResolver;
#[cfg(feature = "quinn")]
impl std::fmt::Debug for NoClientCertResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NoClientCertResolver").finish()
}
}
#[cfg(feature = "quinn")]
impl rustls::client::ResolvesClientCert for NoClientCertResolver {
fn resolve(
&self,
_root_hint_subjects: &[&[u8]],
_sigschemes: &[rustls::SignatureScheme],
) -> Option<Arc<rustls::sign::CertifiedKey>> {
None
}
fn has_certs(&self) -> bool {
false
}
}
/// `ServerCertVerifier` that pins a specific fingerprint (ADR-034 §3, the
/// known-peer path). For `ed25519:<hex>` remotes the raw Ed25519 pub key is
/// extracted from the presented cert and matched against the pinned
/// fingerprint; for `SHA256:<hex>` remotes the cert DER is hashed and matched
/// against the pinned fingerprint. No match → verification failure (the
/// connection is rejected). The fingerprint IS the trust anchor — there is no
/// CA verification and no name verification, only the fingerprint pin.
///
/// Handshake signatures are still verified (using the aws-lc-rs default
/// signature verification algorithms) so that a stolen-but-stale fingerprint
/// can't be replayed with a forged signature: the presenter must prove
/// possession of the private key corresponding to the pinned public key.
#[cfg(feature = "quinn")]
struct FingerprintPinVerifier {
fingerprint: String,
supported: rustls::crypto::WebPkiSupportedAlgorithms,
}
#[cfg(feature = "quinn")]
impl FingerprintPinVerifier {
fn new(fingerprint: String, supported: rustls::crypto::WebPkiSupportedAlgorithms) -> Self {
Self {
fingerprint,
supported,
}
}
}
#[cfg(feature = "quinn")]
impl std::fmt::Debug for FingerprintPinVerifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FingerprintPinVerifier")
.field("fingerprint", &self.fingerprint)
.finish()
}
}
#[cfg(feature = "quinn")]
impl rustls::client::danger::ServerCertVerifier for FingerprintPinVerifier {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
let presented = alknet_core::fingerprint::fingerprint_from_cert_der(end_entity.as_ref())
.ok_or(rustls::Error::General(
"fingerprint pin: failed to compute fingerprint from presented cert".to_string(),
))?;
if presented == self.fingerprint {
Ok(rustls::client::danger::ServerCertVerified::assertion())
} else {
Err(rustls::Error::General(format!(
"fingerprint pin mismatch: expected {} got {}",
self.fingerprint, presented
)))
}
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
if alknet_core::fingerprint::extract_ed25519_raw_key_from_spki(cert.as_ref()).is_some() {
let spki = rustls::pki_types::SubjectPublicKeyInfoDer::from(cert.as_ref().to_vec());
rustls::crypto::verify_tls13_signature_with_raw_key(
message,
&spki,
dss,
&self.supported,
)
} else {
rustls::crypto::verify_tls12_signature(message, cert, dss, &self.supported)
}
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
if alknet_core::fingerprint::extract_ed25519_raw_key_from_spki(cert.as_ref()).is_some() {
let spki = rustls::pki_types::SubjectPublicKeyInfoDer::from(cert.as_ref().to_vec());
rustls::crypto::verify_tls13_signature_with_raw_key(
message,
&spki,
dss,
&self.supported,
)
} else {
rustls::crypto::verify_tls13_signature(message, cert, dss, &self.supported)
}
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
self.supported.supported_schemes()
}
}
#[cfg(feature = "quinn")]
#[derive(Clone)]
struct Ed25519SigningKey {
key: alknet_core::config::Ed25519SecretKey,
}
#[cfg(feature = "quinn")]
impl Ed25519SigningKey {
fn new(key: alknet_core::config::Ed25519SecretKey) -> Self {
Self { key }
}
fn spki_public_key(&self) -> rustls::pki_types::SubjectPublicKeyInfoDer<'static> {
rustls::sign::public_key_to_spki(
&rustls::pki_types::alg_id::ED25519,
self.key.public().as_bytes(),
)
}
}
#[cfg(feature = "quinn")]
impl std::fmt::Debug for Ed25519SigningKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Ed25519SigningKey").finish()
}
}
#[cfg(feature = "quinn")]
impl rustls::sign::SigningKey for Ed25519SigningKey {
fn choose_scheme(
&self,
offered: &[rustls::SignatureScheme],
) -> Option<Box<dyn rustls::sign::Signer>> {
if offered.contains(&rustls::SignatureScheme::ED25519) {
Some(Box::new(self.clone()))
} else {
None
}
}
fn algorithm(&self) -> rustls::SignatureAlgorithm {
rustls::SignatureAlgorithm::ED25519
}
fn public_key(&self) -> Option<rustls::pki_types::SubjectPublicKeyInfoDer<'_>> {
Some(self.spki_public_key())
}
}
#[cfg(feature = "quinn")]
impl rustls::sign::Signer for Ed25519SigningKey {
fn sign(&self, message: &[u8]) -> Result<Vec<u8>, rustls::Error> {
Ok(self.key.sign(message).to_bytes().to_vec())
}
fn scheme(&self) -> rustls::SignatureScheme {
rustls::SignatureScheme::ED25519
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -577,16 +85,8 @@ mod tests {
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use alknet_core::auth::Identity;
use alknet_core::types::Capabilities;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
fn stub_connection() -> Connection {
Connection::from_stream(
tokio::io::sink(),
tokio::io::empty(),
b"alknet/call".to_vec(),
Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321)),
)
}
use crate::protocol::sink_empty_connection as stub_connection;
fn external_spec(name: &str) -> OperationSpec {
OperationSpec::new(
@@ -649,19 +149,6 @@ mod tests {
.await
}
#[test]
fn call_credentials_builder_methods() {
let creds = CallCredentials::new().with_remote_identity(RemoteIdentity {
fingerprint: "SHA256:abc".to_string(),
});
assert_eq!(
creds.remote_identity.as_ref().unwrap().fingerprint,
"SHA256:abc"
);
assert!(creds.tls_identity.is_none());
assert!(creds.auth_token.is_none());
}
#[tokio::test]
async fn external_op_dispatches_and_populates_capabilities() {
let registry = registry_with_caps();
@@ -706,225 +193,5 @@ mod tests {
fn call_client_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<CallClient>();
assert_send_sync::<CallCredentials>();
assert_send_sync::<RemoteIdentity>();
}
#[cfg(feature = "quinn")]
fn build_ed25519_spki_der(raw_key: &[u8; 32]) -> Vec<u8> {
let spki = rustls::sign::public_key_to_spki(&rustls::pki_types::alg_id::ED25519, raw_key);
spki.to_vec()
}
#[cfg(feature = "quinn")]
fn build_x509_cert_der() -> rustls::pki_types::CertificateDer<'static> {
let key_pair = rcgen::KeyPair::generate().expect("key gen");
let params = rcgen::CertificateParams::default();
let cert = params.self_signed(&key_pair).expect("self-signed cert");
cert.der().clone()
}
#[cfg(feature = "quinn")]
fn aws_lc_rs_provider() -> Arc<rustls::crypto::CryptoProvider> {
Arc::new(rustls::crypto::aws_lc_rs::default_provider())
}
#[cfg(feature = "quinn")]
fn verify_pin(
verifier: &FingerprintPinVerifier,
cert_der: rustls::pki_types::CertificateDer<'_>,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
use rustls::client::danger::ServerCertVerifier;
let server_name: rustls::pki_types::ServerName<'static> =
"alknet".try_into().expect("server name");
verifier.verify_server_cert(
&cert_der,
&[],
&server_name,
&[],
rustls::pki_types::UnixTime::now(),
)
}
#[cfg(feature = "quinn")]
#[test]
fn fingerprint_pin_verifier_matches_correct_ed25519_fingerprint() {
let sk = alknet_core::config::Ed25519SecretKey::generate();
let raw_key = sk.public().to_bytes();
let spki_der = build_ed25519_spki_der(&raw_key);
let fingerprint =
alknet_core::fingerprint::fingerprint_from_cert_der(&spki_der).expect("fingerprint");
let verifier = FingerprintPinVerifier::new(
fingerprint,
aws_lc_rs_provider().signature_verification_algorithms,
);
let cert = rustls::pki_types::CertificateDer::from(spki_der);
let result = verify_pin(&verifier, cert);
assert!(
result.is_ok(),
"FingerprintPinVerifier must accept a cert whose fingerprint matches the pin"
);
}
#[cfg(feature = "quinn")]
#[test]
fn fingerprint_pin_verifier_rejects_wrong_ed25519_fingerprint() {
let sk = alknet_core::config::Ed25519SecretKey::generate();
let raw_key = sk.public().to_bytes();
let spki_der = build_ed25519_spki_der(&raw_key);
let other_sk = alknet_core::config::Ed25519SecretKey::generate();
let other_fp = format!("ed25519:{}", hex::encode(other_sk.public().to_bytes()));
let verifier = FingerprintPinVerifier::new(
other_fp,
aws_lc_rs_provider().signature_verification_algorithms,
);
let cert = rustls::pki_types::CertificateDer::from(spki_der);
let result = verify_pin(&verifier, cert);
assert!(
result.is_err(),
"FingerprintPinVerifier must reject a cert whose fingerprint does not match the pin"
);
}
#[cfg(feature = "quinn")]
#[test]
fn fingerprint_pin_verifier_matches_correct_sha256_fingerprint() {
let cert_der = build_x509_cert_der();
let fingerprint = alknet_core::fingerprint::fingerprint_from_cert_der(cert_der.as_ref())
.expect("fingerprint");
let verifier = FingerprintPinVerifier::new(
fingerprint,
aws_lc_rs_provider().signature_verification_algorithms,
);
let result = verify_pin(&verifier, cert_der);
assert!(
result.is_ok(),
"FingerprintPinVerifier must accept an X.509 cert whose SHA256 fingerprint matches"
);
}
#[cfg(feature = "quinn")]
#[test]
fn fingerprint_pin_verifier_rejects_wrong_sha256_fingerprint() {
let cert_der = build_x509_cert_der();
let verifier = FingerprintPinVerifier::new(
"SHA256:0000000000000000000000000000000000000000000000000000000000000000".to_string(),
aws_lc_rs_provider().signature_verification_algorithms,
);
let result = verify_pin(&verifier, cert_der);
assert!(
result.is_err(),
"FingerprintPinVerifier must reject an X.509 cert whose SHA256 does not match"
);
}
#[cfg(feature = "quinn")]
#[test]
fn select_server_verifier_returns_ca_verifier_for_none() {
let provider = aws_lc_rs_provider();
let remote_identity: Option<RemoteIdentity> = None;
let verifier = select_server_verifier(&provider, &remote_identity);
assert!(
verifier.is_ok(),
"select_server_verifier must succeed for None (CA path)"
);
let debug = format!("{:?}", verifier.unwrap());
assert!(
debug.contains("WebPkiServerVerifier"),
"None must select WebPkiServerVerifier (CA verification), got: {debug}"
);
}
#[cfg(feature = "quinn")]
#[test]
fn select_server_verifier_returns_fingerprint_pin_for_some() {
let provider = aws_lc_rs_provider();
let remote_identity = Some(RemoteIdentity {
fingerprint: "ed25519:abc".to_string(),
});
let verifier = select_server_verifier(&provider, &remote_identity);
assert!(
verifier.is_ok(),
"select_server_verifier must succeed for Some (fingerprint pin path)"
);
let debug = format!("{:?}", verifier.unwrap());
assert!(
debug.contains("FingerprintPinVerifier"),
"Some must select FingerprintPinVerifier, got: {debug}"
);
}
#[cfg(feature = "quinn")]
#[test]
fn build_client_auth_presents_ed25519_raw_key_without_error() {
let provider = aws_lc_rs_provider();
let sk = alknet_core::config::Ed25519SecretKey::generate();
let tls_identity = Some(alknet_core::config::TlsIdentity::RawKey(sk));
let resolver = build_client_auth(&provider, &tls_identity);
assert!(
resolver.is_ok(),
"build_client_auth must build a resolver for a RawKey identity"
);
let resolver = resolver.unwrap();
assert!(
resolver.only_raw_public_keys(),
"RawKey client auth resolver must present raw public keys (RFC 7250)"
);
assert!(
resolver.has_certs(),
"RawKey client auth resolver must report it has a cert to present"
);
}
#[cfg(feature = "quinn")]
#[test]
fn build_client_auth_none_resolves_to_no_client_cert() {
let provider = aws_lc_rs_provider();
let tls_identity: Option<alknet_core::config::TlsIdentity> = None;
let resolver = build_client_auth(&provider, &tls_identity)
.expect("build_client_auth must succeed for None");
assert!(
!resolver.has_certs(),
"NoClientCertResolver must report no certs (no client cert presented)"
);
}
#[cfg(feature = "quinn")]
#[test]
fn build_quinn_client_config_with_raw_key_identity_builds_without_error() {
let sk = alknet_core::config::Ed25519SecretKey::generate();
let credentials = CallCredentials::new()
.with_tls_identity(alknet_core::config::TlsIdentity::RawKey(sk))
.with_remote_identity(RemoteIdentity {
fingerprint: "ed25519:deadbeef".to_string(),
});
let config = build_quinn_client_config(&credentials, b"alknet/call");
assert!(
config.is_ok(),
"build_quinn_client_config must build with a RawKey identity + pinned fingerprint"
);
}
#[cfg(feature = "quinn")]
#[test]
fn build_quinn_client_config_with_no_remote_identity_builds_without_error() {
let sk = alknet_core::config::Ed25519SecretKey::generate();
let credentials =
CallCredentials::new().with_tls_identity(alknet_core::config::TlsIdentity::RawKey(sk));
let config = build_quinn_client_config(&credentials, b"alknet/call");
assert!(
config.is_ok(),
"build_quinn_client_config must build for the None + CA-verification path"
);
}
#[test]
fn remote_identity_none_is_load_bearing_not_defaulted() {
let creds = CallCredentials::new();
assert!(
creds.remote_identity.is_none(),
"CallCredentials::new() must keep remote_identity as None (the load-bearing \
public-X.509-endpoint state), not default it to a placeholder"
);
}
}
+25 -114
View File
@@ -73,7 +73,7 @@ impl FromCallConfig {
/// v1 defaults (two-way doors recorded in `client-and-adapters.md`):
/// - auto-on-reconnect: the overlay is per-connection (Layer 2, ADR-024), so
/// re-import on reconnect is naturally scoped; the assembly layer calls
/// `from_call` immediately after `connect()`.
/// `from_call` immediately after `AlknetClient::dial_*` + `spawn_dispatch`.
/// - same-peer collision = error: two ops with the same name from the same
/// peer (after applying the optional prefix) → `AdapterError::SamePeerCollision`.
/// Cross-peer collision dissolves (ADR-029 §5).
@@ -127,15 +127,10 @@ fn build_bundles(
OperationType::Subscription => HandlerKind::Stream(make_streaming_forwarding_handler(
Arc::new(op_summary.connection.clone()),
remote_name,
op_summary.credentials_auth_token.clone(),
)),
OperationType::Query | OperationType::Mutation => {
HandlerKind::Once(make_forwarding_handler(
Arc::new(op_summary.connection.clone()),
remote_name,
op_summary.credentials_auth_token.clone(),
))
}
OperationType::Query | OperationType::Mutation => HandlerKind::Once(
make_forwarding_handler(Arc::new(op_summary.connection.clone()), remote_name),
),
};
bundles.push(HandlerRegistration::new(
spec,
@@ -155,7 +150,6 @@ struct OpSummary {
name: String,
schema: Value,
connection: CallConnection,
credentials_auth_token: Option<String>,
}
async fn discover_operations(connection: &CallConnection) -> Result<Vec<OpSummary>, AdapterError> {
@@ -182,7 +176,6 @@ async fn discover_operations(connection: &CallConnection) -> Result<Vec<OpSummar
name: name.to_string(),
schema,
connection: connection.clone(),
credentials_auth_token: None,
});
}
Ok(summaries)
@@ -329,27 +322,19 @@ fn parse_access_control(v: &Value) -> AccessControl {
/// Per ADR-032 §3, the handler populates `forwarded_for` on the
/// `call.requested` payload from the hub's `OperationContext.identity` (the
/// end user the hub authenticated). The hub authenticates as itself when
/// forwarding — the `credentials_auth_token`, when present, is the hub's own
/// call-protocol-level token placed in the payload's `auth_token` field. The
/// spoke authorizes the hub (its direct caller); `forwarded_for` is metadata,
/// never read by `AccessControl::check`.
/// forwarding. The spoke authorizes the hub (its direct caller);
/// `forwarded_for` is metadata, never read by `AccessControl::check`.
///
/// If `context.identity` is `None` (the hub chose not to disclose, or has not
/// authenticated an originator), `forwarded_for` is omitted — the spoke
/// receives only the hub's identity.
fn make_forwarding_handler(
connection: Arc<CallConnection>,
remote_name: String,
credentials_auth_token: Option<String>,
) -> Handler {
fn make_forwarding_handler(connection: Arc<CallConnection>, remote_name: String) -> Handler {
use crate::registry::registration::make_handler;
make_handler(move |input, context| {
let connection = Arc::clone(&connection);
let remote_name = remote_name.clone();
let auth_token = credentials_auth_token.clone();
async move {
let payload =
build_forwarded_payload(&remote_name, input, &context, auth_token.as_deref());
let payload = build_forwarded_payload(&remote_name, input, &context);
// The forwarding handler invokes the remote op via the
// CallConnection. The parent_request_id participates in the abort
// cascade (ADR-016 §6): if the parent is aborted, the cascade
@@ -372,27 +357,24 @@ fn make_forwarding_handler(
/// `call.aborted` drops it (ADR-049 §8). No truncation, no first-value
/// fallback.
///
/// `forwarded_for` is populated from `context.identity` (ADR-032 §3) and
/// `auth_token` from the hub's own call-protocol token, exactly as the
/// request/response forwarding handler does — both via `build_forwarded_payload`
/// (no new payload-construction code). The `subscribe_with_payload` path
/// registers the request in `PendingRequestMap`, so the abort cascade
/// (ADR-016 §6) is already wired: a parent abort drops the
/// `SubscriptionStream`, which sends `call.aborted` to the remote node.
/// `forwarded_for` is populated from `context.identity` (ADR-032 §3), exactly
/// as the request/response forwarding handler does — both via
/// `build_forwarded_payload` (no new payload-construction code). The
/// `subscribe_with_payload` path registers the request in
/// `PendingRequestMap`, so the abort cascade (ADR-016 §6) is already wired:
/// a parent abort drops the `SubscriptionStream`, which sends `call.aborted`
/// to the remote node.
fn make_streaming_forwarding_handler(
connection: Arc<CallConnection>,
remote_name: String,
credentials_auth_token: Option<String>,
) -> StreamingHandler {
use crate::registry::registration::make_streaming_handler;
use futures::stream::{once, StreamExt};
make_streaming_handler(move |input, context| {
let connection = Arc::clone(&connection);
let remote_name = remote_name.clone();
let auth_token = credentials_auth_token.clone();
once(async move {
let payload =
build_forwarded_payload(&remote_name, input, &context, auth_token.as_deref());
let payload = build_forwarded_payload(&remote_name, input, &context);
connection.subscribe_with_payload(payload).await
})
.flatten()
@@ -402,14 +384,8 @@ fn make_streaming_forwarding_handler(
/// Build the `call.requested` payload for a forwarded call, populating
/// `forwarded_for` from the hub's `OperationContext.identity` (ADR-032 §3).
/// `forwarded_for` is omitted when `context.identity` is `None` (the hub
/// chooses not to disclose the originator). The `auth_token` field is set to
/// the hub's own call-protocol token when present.
fn build_forwarded_payload(
operation_id: &str,
input: Value,
context: &OperationContext,
auth_token: Option<&str>,
) -> Value {
/// chooses not to disclose the originator).
fn build_forwarded_payload(operation_id: &str, input: Value, context: &OperationContext) -> Value {
let mut payload = serde_json::Map::new();
payload.insert(
"operationId".to_string(),
@@ -421,9 +397,6 @@ fn build_forwarded_payload(
payload.insert("forwarded_for".to_string(), value);
}
}
if let Some(token) = auth_token {
payload.insert("auth_token".to_string(), Value::String(token.to_string()));
}
Value::Object(payload)
}
@@ -436,17 +409,9 @@ mod tests {
use alknet_core::auth::Identity;
use alknet_core::types::Capabilities;
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Mutex as StdMutex;
fn stub_connection() -> alknet_core::types::Connection {
alknet_core::types::Connection::from_stream(
tokio::io::sink(),
tokio::io::empty(),
b"alknet/call".to_vec(),
Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321)),
)
}
use crate::protocol::sink_empty_connection as stub_connection;
fn sample_schema_json(name: &str, op_type: &str) -> Value {
json!({
@@ -571,7 +536,6 @@ mod tests {
let handler = make_forwarding_handler(
Arc::new(CallConnection::new(stub_connection())),
"worker/echo".to_string(),
None,
);
let reg = HandlerRegistration::new(
spec,
@@ -638,33 +602,22 @@ mod tests {
#[test]
fn build_forwarded_payload_populates_forwarded_for_from_context_identity() {
let ctx = test_context(Some(alice_identity()));
let payload = build_forwarded_payload("fs/readFile", json!({"p": 1}), &ctx, None);
let payload = build_forwarded_payload("fs/readFile", json!({"p": 1}), &ctx);
assert_eq!(payload["operationId"], "fs/readFile");
assert_eq!(payload["input"], json!({"p": 1}));
let forwarded_for = payload.get("forwarded_for").expect("forwarded_for present");
assert_eq!(forwarded_for["id"], "alice");
assert_eq!(forwarded_for["scopes"][0], "fs:read");
assert!(payload.get("auth_token").is_none());
}
#[test]
fn build_forwarded_payload_omits_forwarded_for_when_context_identity_is_none() {
let ctx = test_context(None);
let payload = build_forwarded_payload("fs/readFile", json!({}), &ctx, None);
let payload = build_forwarded_payload("fs/readFile", json!({}), &ctx);
assert!(payload.get("forwarded_for").is_none());
assert!(payload.get("auth_token").is_none());
assert_eq!(payload["operationId"], "fs/readFile");
}
#[test]
fn build_forwarded_payload_sets_auth_token_when_provided() {
let ctx = test_context(Some(alice_identity()));
let payload =
build_forwarded_payload("fs/readFile", json!({}), &ctx, Some("alk_hub_token"));
assert_eq!(payload["auth_token"], "alk_hub_token");
assert_eq!(payload["forwarded_for"]["id"], "alice");
}
/// Verify the forwarding handler actually populates `forwarded_for` on
/// the wire payload it sends. We intercept the payload by using a handler
/// that records the payload passed to `call_with_payload`. Since
@@ -687,7 +640,7 @@ mod tests {
let captured = Arc::clone(&captured);
let remote_name = "fs/readFile".to_string();
async move {
let payload = build_forwarded_payload(&remote_name, input, &context, None);
let payload = build_forwarded_payload(&remote_name, input, &context);
*captured.lock().unwrap() = Some(payload.clone());
let response = conn.call_with_payload(payload).await;
ResponseEnvelope {
@@ -718,7 +671,7 @@ mod tests {
let captured = Arc::clone(&captured);
let remote_name = "fs/readFile".to_string();
async move {
let payload = build_forwarded_payload(&remote_name, input, &context, None);
let payload = build_forwarded_payload(&remote_name, input, &context);
*captured.lock().unwrap() = Some(payload.clone());
let response = conn.call_with_payload(payload).await;
ResponseEnvelope {
@@ -745,7 +698,6 @@ mod tests {
name: name.to_string(),
schema: sample_schema_json(name, "query"),
connection: conn.clone(),
credentials_auth_token: None,
}
}
@@ -754,7 +706,6 @@ mod tests {
name: name.to_string(),
schema: sample_schema_json(name, op_type),
connection: conn.clone(),
credentials_auth_token: None,
}
}
@@ -949,7 +900,7 @@ mod tests {
let remote_name = "events/stream".to_string();
use futures::stream::{once, StreamExt};
once(async move {
let payload = build_forwarded_payload(&remote_name, input, &context, None);
let payload = build_forwarded_payload(&remote_name, input, &context);
*captured.lock().unwrap() = Some(payload.clone());
conn.subscribe_with_payload(payload).await
})
@@ -999,7 +950,7 @@ mod tests {
let remote_name = "events/stream".to_string();
use futures::stream::{once, StreamExt};
once(async move {
let payload = build_forwarded_payload(&remote_name, input, &context, None);
let payload = build_forwarded_payload(&remote_name, input, &context);
*captured.lock().unwrap() = Some(payload.clone());
conn.subscribe_with_payload(payload).await
})
@@ -1018,45 +969,6 @@ mod tests {
assert_eq!(payload["operationId"], "events/stream");
}
/// The streaming forwarding handler populates `auth_token` when the hub's
/// own call-protocol token is provided.
#[tokio::test]
async fn streaming_forwarding_handler_sets_auth_token_when_provided() {
use futures::stream::StreamExt;
let conn = Arc::new(CallConnection::new(stub_connection()));
let captured_payload = Arc::new(StdMutex::new(None::<Value>));
let captured = Arc::clone(&captured_payload);
let handler: StreamingHandler = {
let conn = Arc::clone(&conn);
make_streaming_handler(move |input, context| {
let conn = Arc::clone(&conn);
let captured = Arc::clone(&captured);
let remote_name = "events/stream".to_string();
use futures::stream::{once, StreamExt};
once(async move {
let payload = build_forwarded_payload(
&remote_name,
input,
&context,
Some("alk_hub_token"),
);
*captured.lock().unwrap() = Some(payload.clone());
conn.subscribe_with_payload(payload).await
})
.flatten()
})
};
let ctx = test_context(Some(alice_identity()));
let mut stream = handler(json!({}), ctx);
let _ = stream.next().await;
let payload = captured_payload.lock().unwrap().clone().expect("captured");
assert_eq!(payload["auth_token"], "alk_hub_token");
assert_eq!(payload["forwarded_for"]["id"], "alice");
}
/// `make_streaming_forwarding_handler` produces a `StreamingHandler` (not a
/// `Handler`) — verifies the helper returns the right type and that
/// `build_bundles` wires it into `HandlerKind::Stream`.
@@ -1065,7 +977,6 @@ mod tests {
let handler = make_streaming_forwarding_handler(
Arc::new(CallConnection::new(stub_connection())),
"events/stream".to_string(),
None,
);
let reg = HandlerRegistration::new(
OperationSpec::new(
+1 -1
View File
@@ -9,7 +9,7 @@
mod call_client;
mod from_call;
pub use call_client::{CallClient, CallCredentials, ClientError, RemoteIdentity};
pub use call_client::CallClient;
pub use from_call::{from_call, FromCallConfig};
use crate::registry::registration::HandlerRegistration;
+1 -1
View File
@@ -1,4 +1,4 @@
//! alknet-call: Structured RPC over QUIC — operations, streaming, service discovery.
//! alknet-call: Structured RPC — operations, streaming, service discovery.
//!
//! Implements [`alknet_core::types::ProtocolHandler`] on ALPN `alknet/call`.
//!
+8 -19
View File
@@ -140,10 +140,9 @@ impl CallAdapter {
pub(crate) async fn handle_stream(
&self,
connection: Arc<CallConnection>,
send: alknet_core::types::SendStream,
recv: alknet_core::types::RecvStream,
stream: alknet_core::types::BiStream,
) {
self.dispatcher.handle_stream(connection, send, recv).await;
self.dispatcher.handle_stream(connection, stream).await;
}
}
@@ -179,10 +178,11 @@ mod tests {
use alknet_core::auth::AuthToken;
use alknet_core::types::Capabilities;
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Mutex as StdMutex;
use std::time::{Duration, Instant};
use crate::protocol::sink_empty_connection as stub_connection;
struct StaticIdentityProvider {
tokens: StdMutex<HashMap<String, Identity>>,
}
@@ -290,15 +290,6 @@ mod tests {
})
}
fn stub_connection() -> Connection {
Connection::from_stream(
tokio::io::sink(),
tokio::io::empty(),
b"alknet/call".to_vec(),
Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321)),
)
}
#[test]
fn alpn_returns_alknet_call() {
let registry = Arc::new(OperationRegistry::new());
@@ -1193,10 +1184,9 @@ mod tests {
let frame = encode_frame(&EventEnvelope::aborted("parent-1"));
let recv = tokio::io::BufReader::new(std::io::Cursor::new(frame));
let (send, _recv_sink) = tokio::io::duplex(64);
let send = alknet_core::types::SendStream::from_stream(send);
let recv = alknet_core::types::RecvStream::from_stream(recv);
let stream = alknet_core::types::BiStream::from_joined(recv, send);
adapter.handle_stream(conn.clone(), send, recv).await;
adapter.handle_stream(conn.clone(), stream).await;
let pending = conn.pending().lock();
assert!(
@@ -1233,10 +1223,9 @@ mod tests {
let frame = encode_frame(&EventEnvelope::aborted("does-not-exist"));
let recv = tokio::io::BufReader::new(std::io::Cursor::new(frame));
let (send, _recv_sink) = tokio::io::duplex(64);
let send = alknet_core::types::SendStream::from_stream(send);
let recv = alknet_core::types::RecvStream::from_stream(recv);
let stream = alknet_core::types::BiStream::from_joined(recv, send);
adapter.handle_stream(conn.clone(), send, recv).await;
adapter.handle_stream(conn.clone(), stream).await;
let pending = conn.pending().lock();
assert!(
+27 -21
View File
@@ -16,6 +16,7 @@ use alknet_core::types::Connection;
use futures::stream::Stream;
use parking_lot::{Mutex, RwLock};
use serde_json::Value;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::sync::mpsc;
use super::pending::PendingRequestMap;
@@ -126,13 +127,17 @@ impl CallConnection {
}
};
let (send, recv) = match connection.open_bi().await {
Ok(pair) => pair,
// `open_bi` returns a `BiStream` (ADR-092); split it into halves for
// the call protocol's separate write (request) and read (response)
// pumps. The split is the stdlib idiom; no per-handler wrapper.
let stream = match connection.open_bi().await {
Ok(s) => s,
Err(err) => {
let call_error = CallError::internal(format!("failed to open stream: {err}"));
return ResponseEnvelope::error(request_id, call_error);
}
};
let (recv, send) = tokio::io::split(stream);
let receiver = {
let mut pending = self.pending.lock();
@@ -197,13 +202,16 @@ impl CallConnection {
}
};
let (send, recv) = match connection.open_bi().await {
Ok(pair) => pair,
// `open_bi` returns a `BiStream` (ADR-092); split for the separate
// write (request) and read (subscription events) pumps.
let stream = match connection.open_bi().await {
Ok(s) => s,
Err(err) => {
let call_error = CallError::internal(format!("failed to open stream: {err}"));
return SubscriptionStream::closed(request_id, call_error);
}
};
let (recv, send) = tokio::io::split(stream);
let receiver = {
let mut pending = self.pending.lock();
@@ -235,12 +243,15 @@ impl CallConnection {
self.pending.lock().handle_aborted(request_id);
}
async fn write_request(
async fn write_request<W>(
&self,
send: alknet_core::types::SendStream,
send: W,
request_id: &str,
payload: Value,
) -> Result<(), String> {
) -> Result<(), String>
where
W: AsyncWrite + Unpin,
{
let envelope = EventEnvelope::requested(request_id, payload);
let mut writer = FrameFramedWriter::new(send);
writer
@@ -254,10 +265,13 @@ impl CallConnection {
.connection
.as_ref()
.ok_or_else(|| "no underlying connection (overlay-only)".to_string())?;
let (send, _recv) = connection
// `open_bi` returns a `BiStream` (ADR-092). We only need the write
// half to send the envelope; split and drop the read half.
let stream = connection
.open_bi()
.await
.map_err(|e| format!("failed to open stream: {e}"))?;
let (_recv, send) = tokio::io::split(stream);
let mut writer = FrameFramedWriter::new(send);
writer
.write_frame(envelope)
@@ -266,10 +280,10 @@ impl CallConnection {
}
}
async fn read_stream_until_closed(
recv: alknet_core::types::RecvStream,
pending: &Arc<Mutex<PendingRequestMap>>,
) {
async fn read_stream_until_closed<R>(recv: R, pending: &Arc<Mutex<PendingRequestMap>>)
where
R: AsyncRead + Unpin,
{
let mut reader = FrameFramedReader::new(recv);
while let Ok(envelope) = reader.read_frame().await {
dispatch_envelope(pending, envelope);
@@ -458,17 +472,9 @@ mod tests {
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use alknet_core::types::Capabilities;
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::{Duration, Instant};
fn stub_connection() -> Connection {
Connection::from_stream(
tokio::io::sink(),
tokio::io::empty(),
b"alknet/call".to_vec(),
Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321)),
)
}
use crate::protocol::sink_empty_connection as stub_connection;
fn external_spec(name: &str) -> OperationSpec {
OperationSpec::new(
+19 -28
View File
@@ -285,9 +285,13 @@ impl Dispatcher {
pub(crate) async fn handle_stream(
&self,
connection: Arc<CallConnection>,
send: alknet_core::types::SendStream,
recv: alknet_core::types::RecvStream,
stream: alknet_core::types::BiStream,
) {
// `stream` is a `BiStream` (ADR-092) — `AsyncRead + AsyncWrite + Send
// + Unpin`. Split into the read and write halves the call protocol's
// frame reader/writer consume. The split is the stdlib idiom; no
// per-handler wrapper.
let (recv, send) = tokio::io::split(stream);
let mut reader = FrameFramedReader::new(recv);
let mut writer = FrameFramedWriter::new(send);
@@ -404,11 +408,11 @@ impl Dispatcher {
loop {
match quic.accept_bi().await {
Ok((send, recv)) => {
Ok(stream) => {
let conn = Arc::clone(&connection);
let dispatcher = self.clone();
tokio::spawn(async move {
dispatcher.handle_stream(conn, send, recv).await;
dispatcher.handle_stream(conn, stream).await;
});
}
Err(StreamError::ConnectionClosed) => break,
@@ -458,17 +462,9 @@ mod tests {
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
use alknet_core::types::Capabilities;
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Mutex as StdMutex;
fn stub_connection() -> alknet_core::types::Connection {
alknet_core::types::Connection::from_stream(
tokio::io::sink(),
tokio::io::empty(),
b"alknet/call".to_vec(),
Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321)),
)
}
use crate::protocol::sink_empty_connection as stub_connection;
struct StaticIdentityProvider {
tokens: StdMutex<HashMap<String, Identity>>,
@@ -1180,10 +1176,9 @@ mod tests {
);
let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request)));
let (send, mut sink) = tokio::io::duplex(8 * 1024);
let send = alknet_core::types::SendStream::from_stream(send);
let recv = alknet_core::types::RecvStream::from_stream(recv);
let stream = alknet_core::types::BiStream::from_joined(recv, send);
dp.handle_stream(conn, send, recv).await;
dp.handle_stream(conn, stream).await;
let frames = read_all_frames(&mut sink).await;
assert_eq!(frames.len(), 4, "3 responded + 1 completed");
@@ -1219,10 +1214,9 @@ mod tests {
);
let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request)));
let (send, mut sink) = tokio::io::duplex(8 * 1024);
let send = alknet_core::types::SendStream::from_stream(send);
let recv = alknet_core::types::RecvStream::from_stream(recv);
let stream = alknet_core::types::BiStream::from_joined(recv, send);
dp.handle_stream(conn, send, recv).await;
dp.handle_stream(conn, stream).await;
let frames = read_all_frames(&mut sink).await;
assert_eq!(frames.len(), 2, "1 responded + 1 error, no completed");
@@ -1255,10 +1249,9 @@ mod tests {
);
let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request)));
let (send, mut sink) = tokio::io::duplex(8 * 1024);
let send = alknet_core::types::SendStream::from_stream(send);
let recv = alknet_core::types::RecvStream::from_stream(recv);
let stream = alknet_core::types::BiStream::from_joined(recv, send);
dp.handle_stream(conn, send, recv).await;
dp.handle_stream(conn, stream).await;
let frames = read_all_frames(&mut sink).await;
assert_eq!(frames.len(), 1, "query: exactly one frame, no completed");
@@ -1286,10 +1279,9 @@ mod tests {
);
let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request)));
let (send, mut sink) = tokio::io::duplex(8 * 1024);
let send = alknet_core::types::SendStream::from_stream(send);
let recv = alknet_core::types::RecvStream::from_stream(recv);
let stream = alknet_core::types::BiStream::from_joined(recv, send);
dp.handle_stream(conn, send, recv).await;
dp.handle_stream(conn, stream).await;
let frames = read_all_frames(&mut sink).await;
assert_eq!(frames.len(), 1, "unknown op: single error, no completed");
@@ -1346,13 +1338,12 @@ mod tests {
);
let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request)));
let (send, _sink) = tokio::io::duplex(8 * 1024);
let send = alknet_core::types::SendStream::from_stream(send);
let recv = alknet_core::types::RecvStream::from_stream(recv);
let stream = alknet_core::types::BiStream::from_joined(recv, send);
let conn_clone = Arc::clone(&conn);
let dp_clone = dp.clone();
let handle = tokio::spawn(async move {
dp_clone.handle_stream(conn_clone, send, recv).await;
dp_clone.handle_stream(conn_clone, stream).await;
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
+6
View File
@@ -10,3 +10,9 @@ pub mod connection;
pub mod dispatch;
pub mod pending;
pub mod wire;
#[cfg(test)]
mod test_support;
#[cfg(test)]
pub(crate) use test_support::sink_empty_connection;
@@ -0,0 +1,66 @@
//! Shared test helpers for the call protocol's inline `#[cfg(test)]`
//! modules. Kept here (not in each test module) so the `stub_connection()`
//! shape is defined once — `Connection::from_stream` was removed (ADR-092)
//! and every test stub that previously called it now calls
//! `Connection::from_bidi(SinkEmpty, ...)` via `sink_empty_connection()`.
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::pin::Pin;
use std::task::{Context, Poll};
use alknet_core::types::Connection;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
/// A test-only `AsyncRead + AsyncWrite` pair equivalent to
/// `tokio::io::sink() + tokio::io::empty()`: reads yield EOF immediately
/// (zero bytes), writes discard. Exists because `Connection::from_bidi`
/// (ADR-092 — the only public stream constructor, replacing
/// `from_stream`) requires a single value that implements both traits.
/// Used only to construct a `Connection` for tests that exercise
/// `Connection`-level state (alpn, addr, identity, dispatcher run loop
/// with an immediately-closed accept stream) without ever reading or
/// writing real bytes.
pub(crate) struct SinkEmpty;
impl AsyncRead for SinkEmpty {
fn poll_read(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
_buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
// EOF immediately — mirrors `tokio::io::empty()`.
Poll::Ready(Ok(()))
}
}
impl AsyncWrite for SinkEmpty {
fn poll_write(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
// Discard — mirrors `tokio::io::sink()`.
Poll::Ready(Ok(buf.len()))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
}
/// Construct a `Connection` whose `accept_bi` yields a `SinkEmpty` once,
/// then `ConnectionClosed`. Used by tests that need a `Connection` for
/// `CallConnection::new(conn)` or `adapter.handle(conn, &auth)` without
/// exercising the wire protocol — `SinkEmpty` reads EOF (so the dispatch
/// loop closes immediately) and discards writes.
pub(crate) fn sink_empty_connection() -> Connection {
Connection::from_bidi(
SinkEmpty,
b"alknet/call".to_vec(),
Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321)),
)
}
-331
View File
@@ -1,331 +0,0 @@
//! Integration test: two-node `alknet/call` round-trip over a real QUIC
//! loopback. A `CallAdapter` server accepts, a `CallClient` connects, and
//! the client calls back into the server (connection symmetry, ADR-017 §2).
//! Verifies the shared dispatch loop works end-to-end.
#![cfg(feature = "quinn")]
use std::sync::Arc;
use std::time::Duration;
use alknet_call::client::{CallClient, CallCredentials, RemoteIdentity};
use alknet_call::protocol::adapter::CallAdapter;
use alknet_call::protocol::wire::ResponseEnvelope;
use alknet_call::registry::discovery::{
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
};
use alknet_call::registry::registration::{
make_handler, Handler, HandlerKind, HandlerRegistration, OperationProvenance, OperationRegistry,
};
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use alknet_core::auth::{Identity, IdentityProvider};
use alknet_core::types::{Capabilities, Connection, ProtocolHandler};
struct NoopIdentityProvider;
impl IdentityProvider for NoopIdentityProvider {
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
None
}
fn resolve_from_token(&self, _: &alknet_core::auth::AuthToken) -> Option<Identity> {
None
}
}
fn external_spec(name: &str) -> OperationSpec {
OperationSpec::new(
name,
OperationType::Query,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
)
}
fn echo_handler() -> Handler {
make_handler(|input, context| async move { ResponseEnvelope::ok(context.request_id, input) })
}
/// Build a raw quinn server endpoint with a self-signed cert and the
/// `CallAdapter` accepting `alknet/call` connections. Returns
/// `(bound_addr, server_fingerprint, join_handle)` — the fingerprint is the
/// `SHA256:<hex>` of the self-signed cert DER, which the client pins via
/// `CallCredentials::with_remote_identity` (the known-peer path, ADR-034 §3).
/// The accept loop spawns a task per connection that hands the connection to
/// `CallAdapter::handle`.
async fn build_raw_quinn_server(
registry: Arc<OperationRegistry>,
) -> (std::net::SocketAddr, String, tokio::task::JoinHandle<()>) {
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdentityProvider);
let adapter = Arc::new(CallAdapter::new(
Arc::clone(&registry),
Arc::clone(&provider),
));
let key_pair = rcgen::KeyPair::generate().expect("key gen");
let params = rcgen::CertificateParams::default();
let cert = params.self_signed(&key_pair).expect("self-signed cert");
let cert_der = cert.der().clone();
let fingerprint = alknet_core::fingerprint::fingerprint_from_cert_der(cert_der.as_ref())
.expect("cert produces fingerprint");
let key_der = rustls::pki_types::PrivateKeyDer::Pkcs8(
rustls::pki_types::PrivatePkcs8KeyDer::from(key_pair.serialize_der()),
);
let provider_crypto = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let mut server_config = rustls::ServerConfig::builder_with_provider(provider_crypto)
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(vec![cert_der], key_der)
.unwrap();
server_config.alpn_protocols = vec![b"alknet/call".to_vec()];
server_config.max_early_data_size = u32::MAX;
let quic_server_config =
quinn::crypto::rustls::QuicServerConfig::try_from(server_config).unwrap();
let quinn_server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_server_config));
let quinn_endpoint =
quinn::Endpoint::server(quinn_server_config, "127.0.0.1:0".parse().unwrap())
.expect("server bind");
let bound_addr = quinn_endpoint.local_addr().expect("local addr");
let join = tokio::spawn(async move {
while let Some(incoming) = quinn_endpoint.accept().await {
let adapter = Arc::clone(&adapter);
tokio::spawn(async move {
let connecting = match incoming.accept() {
Ok(c) => c,
Err(_) => return,
};
let conn = match connecting.await {
Ok(c) => c,
Err(_) => return,
};
let alpn = b"alknet/call".to_vec();
let conn = Connection::from_quinn_with_alpn(conn, alpn.clone());
let auth = alknet_core::auth::AuthContext {
identity: None,
alpn,
remote_addr: conn.remote_addr(),
tls_client_fingerprint: None,
};
let _ = adapter.handle(conn, &auth).await;
});
}
});
(bound_addr, fingerprint, join)
}
/// Build the server's registry: an echo op, a secret op, and the
/// services/list + services/schema discovery handlers.
fn build_server_registry() -> Arc<OperationRegistry> {
let mut registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
external_spec("server/echo"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
external_spec("server/secret"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new().with_api_key("google", "server-secret".to_string()),
))
.unwrap();
let discovery_registry = Arc::new(registry);
let list_handler = services_list_handler(Arc::clone(&discovery_registry));
let schema_handler = services_schema_handler(Arc::clone(&discovery_registry));
let mut full = OperationRegistry::new();
full.register(HandlerRegistration::new(
external_spec("server/echo"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
full.register(HandlerRegistration::new(
external_spec("server/secret"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new().with_api_key("google", "server-secret".to_string()),
))
.unwrap();
full.register(HandlerRegistration::new(
services_list_spec(),
HandlerKind::Once(list_handler),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
full.register(HandlerRegistration::new(
services_schema_spec(),
HandlerKind::Once(schema_handler),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(full)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn two_node_call_round_trip() {
let server_registry = build_server_registry();
let (server_addr, server_fingerprint, _server_join) =
build_raw_quinn_server(Arc::clone(&server_registry)).await;
// Client side: a CallClient with its own ops so the server can call back
// (connection symmetry). Pin the server's self-signed cert fingerprint
// (the known-peer path, ADR-034 §3) — `WebPkiServerVerifier` would reject
// it as UnknownIssuer since the self-signed cert is not in the platform
// root store.
let mut client_registry = OperationRegistry::new();
client_registry
.register(HandlerRegistration::new(
external_spec("client/echo"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let client_registry = Arc::new(client_registry);
let client = CallClient::new(Arc::clone(&client_registry), Arc::new(NoopIdentityProvider));
let credentials = CallCredentials::new().with_remote_identity(RemoteIdentity {
fingerprint: server_fingerprint,
});
let conn = tokio::time::timeout(
Duration::from_secs(5),
client.connect(server_addr, credentials),
)
.await
.expect("connect did not time out")
.expect("connect succeeds");
// Outbound call: client -> server's echo op.
let response = tokio::time::timeout(
Duration::from_secs(5),
conn.call("server/echo", serde_json::json!({"hi": 1})),
)
.await
.expect("call did not time out");
assert_eq!(response.result, Ok(serde_json::json!({"hi": 1})));
// Peer authorization is enforced by the AccessControl gate in
// OperationRegistry::invoke (ADR-029 §3) — exercised by the unit tests in
// `registry/registration.rs`. This integration test focuses on the QUIC
// connect path + shared dispatch loop working end-to-end (the call above
// proves the CallClient opened a real connection, the shared loop
// dispatched, and the CallConnection::call() round-tripped).
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn from_call_discovers_and_forwards_over_quic_loopback() {
use alknet_call::client::{from_call, FromCallConfig};
use alknet_call::registry::context::ScopedPeerEnv;
let server_registry = build_server_registry();
let (server_addr, server_fingerprint, _server_join) =
build_raw_quinn_server(Arc::clone(&server_registry)).await;
// Client with an empty registry — from_call will populate its overlay.
// Pin the server's self-signed cert fingerprint (ADR-034 §3 known-peer
// path).
let client_registry = Arc::new(OperationRegistry::new());
let client = CallClient::new(Arc::clone(&client_registry), Arc::new(NoopIdentityProvider));
let credentials = CallCredentials::new().with_remote_identity(RemoteIdentity {
fingerprint: server_fingerprint,
});
let conn = tokio::time::timeout(
Duration::from_secs(5),
client.connect(server_addr, credentials),
)
.await
.expect("connect did not time out")
.expect("connect succeeds");
// from_call discovers the server's External ops (server/echo, server/secret
// — both External; services/list + services/schema themselves are External
// too) and builds FromCall forwarding-handler bundles. Register them in the
// connection's Layer 2 overlay.
let bundles = tokio::time::timeout(
Duration::from_secs(5),
from_call(&conn, FromCallConfig::new()),
)
.await
.expect("from_call did not time out")
.expect("from_call succeeds");
assert!(
!bundles.is_empty(),
"from_call must discover at least the server/echo op"
);
conn.register_imported_all(bundles);
// The overlay now contains the discovered ops. Verify the forwarding path
// by invoking the overlay env directly with a scoped context that allows
// server/echo — this is how a composing handler would call the imported op.
let env = conn.overlay_env();
assert!(
env.contains("server/echo"),
"overlay must contain the imported server/echo op"
);
// Build a minimal parent context to invoke the overlay env (mirrors how a
// composing handler dispatches a child).
let scoped = ScopedPeerEnv::new(["server/echo"]);
let parent = alknet_call::registry::context::OperationContext {
request_id: "parent-1".to_string(),
parent_request_id: None,
identity: None,
handler_identity: None,
forwarded_for: None,
capabilities: Capabilities::new(),
metadata: Default::default(),
scoped_env: scoped,
env: env.clone(),
abort_policy: alknet_call::registry::context::AbortPolicy::default(),
deadline: Some(std::time::Instant::now() + Duration::from_secs(30)),
internal: true,
ownership: None,
};
let response = tokio::time::timeout(
Duration::from_secs(5),
env.invoke(
"server",
"echo",
serde_json::json!({"from_call": true}),
&parent,
),
)
.await
.expect("overlay invoke did not time out");
assert_eq!(
response.result,
Ok(serde_json::json!({"from_call": true})),
"from_call forwarding handler must round-trip the input to the remote op"
);
}
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "alknet-client"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Native client dial seam — multi-transport dialer that produces Connections for protocol take-overs"
repository.workspace = true
[lib]
name = "alknet_client"
[features]
default = []
quinn = ["dep:quinn", "alknet-tls/quinn", "alknet-core/quinn"]
tcp = ["dep:tokio-rustls", "alknet-tls/tcp"]
iroh = ["dep:iroh", "alknet-core/iroh"]
socks5 = ["dep:fast-socks5"]
[dependencies]
alknet-core = { path = "../alknet-core" }
alknet-tls = { path = "../alknet-tls" }
tokio = { version = "1", features = ["full"] }
thiserror = "2"
tracing = "0.1"
quinn = { version = "0.11", optional = true }
tokio-rustls = { version = "0.26", optional = true }
iroh = { version = "1.0", optional = true, default-features = false, features = ["tls-aws-lc-rs"] }
fast-socks5 = { version = "1", optional = true }
rustls = "0.23"
rustls-pki-types = "1"
hex = "0.4"
+154
View File
@@ -0,0 +1,154 @@
//! `AlknetClient` — native client dial seam, the client-side analogue of
//! `AlknetEndpoint`. Holds pre-built transport handles, all optional — the
//! client dials with whichever transport the remote endpoint type implies.
use std::fmt;
#[cfg(feature = "iroh")]
use iroh;
#[cfg(feature = "quinn")]
use quinn;
#[cfg(feature = "tcp")]
use tokio_rustls;
#[cfg(feature = "socks5")]
use crate::socks5::Socks5ProxyConfig;
/// Native client dial seam — multi-transport dialer that produces
/// `Connection`s for protocol take-overs.
///
/// Holds pre-built transport handles, all optional — the client dials
/// with whichever transport the remote endpoint type implies. The
/// builder mirrors `AlknetEndpoint`'s `with_quinn` / `with_iroh` /
/// `with_tcp_tls` (ADR-083) — the assembly layer builds the transport
/// handles and hands them to the client via builder methods.
pub struct AlknetClient {
#[cfg(feature = "quinn")]
pub(crate) quinn: Option<quinn::Endpoint>,
#[cfg(feature = "tcp")]
pub(crate) tcp_connector: Option<tokio_rustls::TlsConnector>,
#[cfg(feature = "iroh")]
pub(crate) iroh: Option<iroh::Endpoint>,
/// When set, `dial_quic` and `dial_tcp_tls` route through this
/// SOCKS5 proxy (UDP ASSOCIATE / CONNECT respectively). `dial_iroh`
/// forces relay-only via an HTTP-to-SOCKS5 bridge — see ADR-090 §5.
/// Feature-gated on `socks5`.
#[cfg(feature = "socks5")]
pub(crate) socks5: Option<Socks5ProxyConfig>,
}
impl AlknetClient {
/// Create a new `AlknetClient` with no transport handles configured.
/// Use the builder methods to add transports.
pub fn new() -> Self {
Self {
#[cfg(feature = "quinn")]
quinn: None,
#[cfg(feature = "tcp")]
tcp_connector: None,
#[cfg(feature = "iroh")]
iroh: None,
#[cfg(feature = "socks5")]
socks5: None,
}
}
/// Set the QUIC transport handle. The assembly layer builds a
/// `quinn::Endpoint` (with or without a SOCKS5 proxy — the proxy
/// is applied inside `dial_quic`, not at construction time) and
/// hands it to the client.
#[cfg(feature = "quinn")]
pub fn with_quinn(mut self, endpoint: quinn::Endpoint) -> Self {
self.quinn = Some(endpoint);
self
}
/// Set the TCP+TLS transport handle. The assembly layer builds a
/// `tokio_rustls::TlsConnector` and hands it to the client.
#[cfg(feature = "tcp")]
pub fn with_tcp_tls(mut self, connector: tokio_rustls::TlsConnector) -> Self {
self.tcp_connector = Some(connector);
self
}
/// Set the iroh transport handle. The assembly layer builds an
/// `iroh::Endpoint` and hands it to the client.
#[cfg(feature = "iroh")]
pub fn with_iroh(mut self, endpoint: iroh::Endpoint) -> Self {
self.iroh = Some(endpoint);
self
}
/// Set the SOCKS5 proxy for all subsequent dials. When set, every
/// dial routes its transport through this proxy: UDP ASSOCIATE for
/// `dial_quic`, CONNECT for `dial_tcp_tls`, and force-relay-only +
/// HTTP-to-SOCKS5 bridge for `dial_iroh` (ADR-090 §5).
/// Feature-gated on `socks5`.
#[cfg(feature = "socks5")]
pub fn with_socks5_proxy(mut self, proxy: Socks5ProxyConfig) -> Self {
self.socks5 = Some(proxy);
self
}
}
impl Default for AlknetClient {
fn default() -> Self {
Self::new()
}
}
impl fmt::Debug for AlknetClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
#[allow(unused_mut)]
let mut configured: Vec<&str> = Vec::new();
#[cfg(feature = "quinn")]
if self.quinn.is_some() {
configured.push("quinn");
}
#[cfg(feature = "tcp")]
if self.tcp_connector.is_some() {
configured.push("tcp");
}
#[cfg(feature = "iroh")]
if self.iroh.is_some() {
configured.push("iroh");
}
#[cfg(feature = "socks5")]
if self.socks5.is_some() {
configured.push("socks5");
}
f.debug_struct("AlknetClient")
.field("transports", &configured)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_creates_empty_client() {
let client = AlknetClient::new();
let debug = format!("{:?}", client);
assert!(debug.contains("AlknetClient"));
}
#[test]
fn default_delegates_to_new() {
let _client = AlknetClient::default();
}
#[test]
fn alknet_client_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<AlknetClient>();
}
#[test]
fn debug_lists_configured_transports() {
let client = AlknetClient::new();
let debug = format!("{:?}", client);
assert!(!debug.is_empty());
}
}
+132
View File
@@ -0,0 +1,132 @@
//! `dial_iroh` — iroh dial, producing a `Connection`.
//!
//! Feature-gated on `iroh`. The iroh path does NOT use `TlsClientConfig` —
//! iroh has its own TLS (shares the `Ed25519SecretKey`, not the rustls config
//! — ADR-087 §3, ADR-089 §3). The local key is set on the pre-built iroh
//! endpoint at `with_iroh` time (the assembly layer reads it from
//! `StaticConfig` and feeds it to `iroh::Endpoint::builder().secret_key()`);
//! the dial consumes only `creds.remote_identity` (deriving the remote
//! `EndpointId` from `creds.remote_identity.fingerprint`) and ignores
//! `creds.local_identity`.
use alknet_core::credentials::ConnectionCredentials;
use alknet_core::types::Connection;
use crate::client::AlknetClient;
use crate::error::ClientDialError;
impl AlknetClient {
/// Iroh dial. Dials on `alpn` via the iroh endpoint. The iroh path
/// does NOT use `TlsClientConfig` — iroh has its own TLS (shares the
/// `Ed25519SecretKey`, not the rustls config — ADR-087 §3, ADR-089
/// §3). The local key is on the pre-built iroh endpoint (set at
/// `with_iroh` time); the dial consumes only
/// `creds.remote_identity` and ignores `creds.local_identity`.
/// The remote `EndpointId` is derived from
/// `creds.remote_identity.fingerprint`
/// (`ed25519:<hex>` → `EndpointId::from_bytes`). The verifier is iroh's
/// `EndpointId` match (fingerprint pin by another name — ADR-034 §3).
/// An unknown iroh remote fails closed (no CA). Feature-gated on
/// `iroh`.
#[cfg(feature = "iroh")]
pub async fn dial_iroh(
&self,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError> {
let endpoint = self
.iroh
.as_ref()
.ok_or(ClientDialError::NoTransport { transport: "iroh" })?;
let node_id = match &creds.remote_identity {
Some(ri) => extract_iroh_endpoint_id(&ri.fingerprint)
.map_err(|e| ClientDialError::TlsConfig(alknet_tls::TlsError::Config(e)))?,
None => {
return Err(ClientDialError::TlsConfig(alknet_tls::TlsError::Config(
"iroh requires a known remote (remote_identity must be Some); \
unknown iroh remotes fail closed (ADR-034 §3)"
.into(),
)));
}
};
let conn = endpoint
.connect(node_id, alpn)
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
Ok(Connection::from_iroh(conn))
}
}
/// Extract an `iroh::EndpointId` from a fingerprint string.
///
/// Supports two formats:
/// - `"ed25519:<hex>"` — raw Ed25519 public key (64 hex chars)
/// - `"SHA256:<base64>"` — SHA-256 hash of the cert (for X.509; not valid for iroh)
///
/// For iroh, only the `ed25519:` prefix is valid — iroh uses Ed25519 keys.
fn extract_iroh_endpoint_id(fingerprint: &str) -> Result<iroh::EndpointId, String> {
if let Some(hex_str) = fingerprint.strip_prefix("ed25519:") {
let bytes =
hex::decode(hex_str).map_err(|e| format!("invalid ed25519 fingerprint hex: {e}"))?;
if bytes.len() != 32 {
return Err(format!(
"invalid ed25519 fingerprint length: expected 32 bytes, got {}",
bytes.len()
));
}
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| "invalid ed25519 fingerprint length".to_string())?;
iroh::EndpointId::from_bytes(&arr).map_err(|e| format!("invalid iroh EndpointId: {e}"))
} else {
Err(format!(
"iroh requires an ed25519: fingerprint, got: {}",
fingerprint
))
}
}
#[cfg(all(test, feature = "iroh"))]
mod tests {
use super::*;
#[tokio::test]
async fn dial_iroh_no_transport_error() {
let client = AlknetClient::new();
let creds = ConnectionCredentials::new();
let result = client.dial_iroh(b"test/alpn", &creds).await;
assert!(matches!(result, Err(ClientDialError::NoTransport { .. })));
}
#[test]
fn extract_iroh_endpoint_id_valid_ed25519() {
let hex_key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let fingerprint = format!("ed25519:{}", hex_key);
let result = extract_iroh_endpoint_id(&fingerprint);
assert!(result.is_ok());
}
#[test]
fn extract_iroh_endpoint_id_rejects_sha256() {
let fingerprint = "SHA256:abc123";
let result = extract_iroh_endpoint_id(fingerprint);
assert!(result.is_err());
}
#[test]
fn extract_iroh_endpoint_id_rejects_invalid_hex() {
let fingerprint = "ed25519:nothex";
let result = extract_iroh_endpoint_id(fingerprint);
assert!(result.is_err());
}
#[test]
fn extract_iroh_endpoint_id_rejects_wrong_length() {
let fingerprint = "ed25519:aaaa";
let result = extract_iroh_endpoint_id(fingerprint);
assert!(result.is_err());
}
}
+12
View File
@@ -0,0 +1,12 @@
//! Dial methods for `AlknetClient` — one per transport.
//!
//! Each dial method is feature-gated on the corresponding transport feature.
//! All three are unified on `&ConnectionCredentials` (ADR-091) and return a
//! `Connection` for protocol take-overs to consume.
#[cfg(feature = "iroh")]
pub mod iroh;
#[cfg(feature = "quinn")]
pub mod quinn;
#[cfg(feature = "tcp")]
pub mod tcp_tls;
+120
View File
@@ -0,0 +1,120 @@
//! `dial_quic` — QUIC dial via quinn, producing a `Connection`.
//!
//! Feature-gated on `quinn`. Builds a `TlsClientConfig` from
//! `ConnectionCredentials`, constructs a `quinn::ClientConfig`, dials
//! `addr` on `alpn`, and returns a `Connection` via
//! `Connection::from_quinn_with_alpn`.
use std::net::SocketAddr;
#[cfg(feature = "socks5")]
use std::sync::Arc;
use alknet_core::credentials::ConnectionCredentials;
use alknet_core::types::Connection;
use alknet_tls::client::TlsClientConfig;
use crate::client::AlknetClient;
use crate::error::ClientDialError;
impl AlknetClient {
/// QUIC dial. Builds a `TlsClientConfig` from `creds`
/// (ADR-034 verifier selection + ADR-084 provider), dials `addr`
/// on `alpn`, returns a `Connection` via
/// `Connection::from_quinn_with_alpn`. The `server_name` is the
/// TLS SNI / name (for X.509; ignored for raw-key pinning).
/// Feature-gated on `quinn`.
#[cfg(feature = "quinn")]
pub async fn dial_quic(
&self,
addr: SocketAddr,
server_name: &str,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError> {
let tls_config = TlsClientConfig::new(creds, alpn)?;
let client_config = tls_config.for_quinn()?;
#[cfg(feature = "socks5")]
let conn = if let Some(proxy) = &self.socks5 {
let socket = crate::socks5::Socks5UdpSocket::bind(proxy).await?;
let endpoint = quinn::Endpoint::new_with_abstract_socket(
quinn::EndpointConfig::default(),
None,
Arc::new(socket),
Arc::new(quinn::TokioRuntime),
)
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
endpoint
.connect_with(client_config, addr, server_name)
.map_err(|e| ClientDialError::Connect(e.to_string()))?
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?
} else {
let endpoint = self
.quinn
.as_ref()
.ok_or(ClientDialError::NoTransport { transport: "quinn" })?;
endpoint
.connect_with(client_config, addr, server_name)
.map_err(|e| ClientDialError::Connect(e.to_string()))?
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?
};
#[cfg(not(feature = "socks5"))]
let conn = {
let endpoint = self
.quinn
.as_ref()
.ok_or(ClientDialError::NoTransport { transport: "quinn" })?;
endpoint
.connect_with(client_config, addr, server_name)
.map_err(|e| ClientDialError::Connect(e.to_string()))?
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?
};
Ok(Connection::from_quinn_with_alpn(conn, alpn.to_vec()))
}
}
#[cfg(all(test, feature = "quinn"))]
mod tests {
use super::*;
#[tokio::test]
async fn dial_quic_no_transport_error() {
let client = AlknetClient::new();
let creds = ConnectionCredentials::new();
let result = client
.dial_quic(
"127.0.0.1:0".parse().unwrap(),
"localhost",
b"test/alpn",
&creds,
)
.await;
assert!(matches!(result, Err(ClientDialError::NoTransport { .. })));
}
#[tokio::test]
async fn dial_quic_tls_config_error_on_acme_identity() {
use alknet_core::config::{AcmeDirectory, TlsIdentity};
let creds = ConnectionCredentials::new().with_local_identity(TlsIdentity::Acme {
domains: vec!["example.com".into()],
directory: AcmeDirectory::Staging,
cache_dir: std::path::PathBuf::from("/tmp"),
contact: vec![],
});
let client = AlknetClient::new();
let result = client
.dial_quic(
"127.0.0.1:0".parse().unwrap(),
"localhost",
b"test/alpn",
&creds,
)
.await;
assert!(matches!(result, Err(ClientDialError::TlsConfig(_))));
}
}
+106
View File
@@ -0,0 +1,106 @@
//! `dial_tcp_tls` — TCP+TLS dial via tokio-rustls, producing a `Connection`.
//!
//! Feature-gated on `tcp`. Builds a `TlsClientConfig` from
//! `ConnectionCredentials`, connects a `TcpStream` to `addr`, wraps with
//! `TlsConnector` using `host` as the SNI, and returns a `Connection` via
//! `Connection::from_bidi` (ADR-065).
use std::net::SocketAddr;
use std::sync::Arc;
use alknet_core::credentials::ConnectionCredentials;
use alknet_core::types::Connection;
use alknet_tls::client::TlsClientConfig;
use tokio::net::TcpStream;
use crate::client::AlknetClient;
use crate::error::ClientDialError;
impl AlknetClient {
/// TCP+TLS dial. Builds a `TlsClientConfig` from `creds`,
/// connects a `TcpStream` to `addr`, wraps with `TlsConnector`
/// using `host` as the SNI, returns a `Connection` via
/// `Connection::from_bidi` (ADR-065). Feature-gated on `tcp`.
#[cfg(feature = "tcp")]
pub async fn dial_tcp_tls(
&self,
host: &str,
addr: SocketAddr,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError> {
let tls_config = TlsClientConfig::new(creds, alpn)?;
let connector = match &self.tcp_connector {
Some(c) => c.clone(),
None => {
let rustls_config = Arc::new(tls_config.into_rustls_config());
tokio_rustls::TlsConnector::from(rustls_config)
}
};
#[cfg(feature = "socks5")]
let tls_stream = if let Some(proxy) = &self.socks5 {
let mut tcp = TcpStream::connect(proxy.addr)
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
crate::socks5::socks5_connect(&mut tcp, proxy, addr)
.await
.map_err(ClientDialError::Proxy)?;
let server_name: rustls::pki_types::ServerName = host.to_owned().try_into().map_err(
|e: rustls::pki_types::InvalidDnsNameError| ClientDialError::Connect(e.to_string()),
)?;
connector
.connect(server_name, tcp)
.await
.map_err(|e| ClientDialError::Handshake(e.to_string()))?
} else {
let tcp_stream = TcpStream::connect(addr)
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
let server_name: rustls::pki_types::ServerName = host.to_owned().try_into().map_err(
|e: rustls::pki_types::InvalidDnsNameError| ClientDialError::Connect(e.to_string()),
)?;
connector
.connect(server_name, tcp_stream)
.await
.map_err(|e| ClientDialError::Handshake(e.to_string()))?
};
#[cfg(not(feature = "socks5"))]
let tls_stream = {
let tcp_stream = TcpStream::connect(addr)
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
let server_name: rustls::pki_types::ServerName = host.to_owned().try_into().map_err(
|e: rustls::pki_types::InvalidDnsNameError| ClientDialError::Connect(e.to_string()),
)?;
connector
.connect(server_name, tcp_stream)
.await
.map_err(|e| ClientDialError::Handshake(e.to_string()))?
};
Ok(Connection::from_bidi(tls_stream, alpn.to_vec(), Some(addr)))
}
}
#[cfg(all(test, feature = "tcp"))]
mod tests {
use super::*;
#[tokio::test]
async fn dial_tcp_tls_no_transport_error() {
let client = AlknetClient::new();
let creds = ConnectionCredentials::new();
let result = client
.dial_tcp_tls(
"localhost",
"127.0.0.1:0".parse().unwrap(),
b"test/alpn",
&creds,
)
.await;
assert!(matches!(result, Err(ClientDialError::Connect(_))));
}
}
+83
View File
@@ -0,0 +1,83 @@
//! `ClientDialError` — error type for all three dial methods.
use thiserror::Error;
/// Errors produced by `AlknetClient` dial methods.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ClientDialError {
/// TLS config construction failure — `TlsClientConfig::new` failed
/// (verifier build, cert load, provider init). Wraps `TlsError`
/// from alknet-tls.
#[error("TLS config construction: {0}")]
TlsConfig(#[from] alknet_tls::TlsError),
/// Transport connect failure — quinn connect, TcpStream::connect,
/// or iroh connect. The transport's own error type, stringified.
#[error("transport connect: {0}")]
Connect(String),
/// TLS handshake failure — the handshake started but failed
/// (rejected cert, ALPN mismatch, unknown raw-key remote
/// fail-closed). Distinct from TlsConfig (which is pre-handshake).
#[error("TLS handshake: {0}")]
Handshake(String),
/// No transport handle configured for the requested dial — e.g.,
/// `dial_quic` called but `with_quinn` was not set.
#[error("no transport handle configured for {transport}")]
NoTransport { transport: &'static str },
/// SOCKS5 proxy failure — handshake rejected, UDP ASSOCIATE
/// unsupported, auth failed, or the proxy closed the control
/// connection (ADR-090). The dial did not reach the remote; the
/// caller decides whether to fall back to a direct dial or
/// surface the error. The dial never silently falls back — that
/// would defeat the privacy posture.
#[cfg(feature = "socks5")]
#[error("SOCKS5 proxy: {0}")]
Proxy(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tls_config_from_tls_error() {
let err = alknet_tls::TlsError::Config("test".into());
let dial_err: ClientDialError = err.into();
assert!(matches!(dial_err, ClientDialError::TlsConfig(_)));
}
#[test]
fn no_transport_displays_transport_name() {
let err = ClientDialError::NoTransport { transport: "quinn" };
assert!(err.to_string().contains("quinn"));
}
#[test]
fn connect_displays_message() {
let err = ClientDialError::Connect("connection refused".into());
assert!(err.to_string().contains("connection refused"));
}
#[test]
fn handshake_displays_message() {
let err = ClientDialError::Handshake("certificate rejected".into());
assert!(err.to_string().contains("certificate rejected"));
}
#[cfg(feature = "socks5")]
#[test]
fn proxy_displays_message() {
let err = ClientDialError::Proxy("UDP ASSOCIATE rejected".into());
assert!(err.to_string().contains("UDP ASSOCIATE rejected"));
}
#[test]
fn client_dial_error_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<ClientDialError>();
}
}
+23
View File
@@ -0,0 +1,23 @@
//! alknet-client: Native client dial seam — multi-transport dialer that
//! produces `Connection`s for protocol take-overs.
//!
//! `AlknetClient` is the client-side analogue of `AlknetEndpoint`: a
//! multi-transport dialer that takes pre-built transport handles (quinn,
//! TCP+TLS, iroh), dials a remote `AlknetEndpoint` on a chosen ALPN, and
//! produces a `Connection`. The protocol take-overs
//! (`CallClient::spawn_dispatch`, `ChannelClient::from_connection`)
//! consume the `Connection` — the dial is below the protocol.
//!
//! An optional SOCKS5 proxy (ADR-090) routes the dials through a proxy
//! to hide the client's real IP from the hub.
pub mod client;
pub mod dial;
pub mod error;
#[cfg(feature = "socks5")]
pub mod socks5;
pub use client::AlknetClient;
pub use error::ClientDialError;
#[cfg(feature = "socks5")]
pub use socks5::{Socks5Credentials, Socks5ProxyConfig};
+461
View File
@@ -0,0 +1,461 @@
//! SOCKS5 proxy support for `AlknetClient` (ADR-090).
//!
//! When a proxy is configured via `with_socks5_proxy`, the rustls dials
//! route their transport through the proxy — the hub sees the proxy's IP,
//! not the client's.
//!
//! Feature-gated on `socks5`. The `Socks5UdpSocket` additionally requires
//! the `quinn` feature (it implements `quinn::AsyncUdpSocket`).
use std::net::SocketAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
#[cfg(feature = "quinn")]
use std::io;
#[cfg(feature = "quinn")]
use std::pin::Pin;
#[cfg(feature = "quinn")]
use std::sync::{Arc, Mutex};
#[cfg(feature = "quinn")]
use std::task::{Context, Poll, Waker};
#[cfg(feature = "quinn")]
use crate::error::ClientDialError;
/// Configuration for a SOCKS5 proxy (ADR-090).
///
/// When set on `AlknetClient` via `with_socks5_proxy`, all rustls dials
/// route their transport through this proxy: UDP ASSOCIATE for `dial_quic`,
/// CONNECT for `dial_tcp_tls`. The proxy config comes from `Capabilities` /
/// the assembly layer (ADR-014), never from environment variables.
#[derive(Debug, Clone)]
pub struct Socks5ProxyConfig {
/// The proxy's TCP address (where the SOCKS5 control connection
/// connects). For UDP ASSOCIATE (the QUIC dial), the proxy replies
/// with a UDP relay address that may differ; the dial uses that.
pub addr: SocketAddr,
/// Optional username/password auth (RFC 1929). None = no-auth.
pub credentials: Option<Socks5Credentials>,
}
/// SOCKS5 username/password credentials (RFC 1929).
#[derive(Debug, Clone)]
pub struct Socks5Credentials {
pub username: String,
pub password: String,
}
/// A `quinn::AsyncUdpSocket` implementation that tunnels QUIC datagrams
/// through a SOCKS5 UDP ASSOCIATE tunnel.
///
/// The implementation follows the pattern validated by the quinn-proxy PoC
/// (`docs/research/quinn-quic-proxy/findings.md`).
///
/// Requires both `socks5` and `quinn` features.
#[cfg(feature = "quinn")]
pub struct Socks5UdpSocket {
socket: std::net::UdpSocket,
relay_addr: SocketAddr,
local_addr: SocketAddr,
_control: TcpStream,
}
#[cfg(feature = "quinn")]
impl Socks5UdpSocket {
/// Perform the SOCKS5 UDP ASSOCIATE handshake and return a socket
/// that tunnels QUIC datagrams through the proxy.
pub async fn bind(proxy: &Socks5ProxyConfig) -> Result<Self, ClientDialError> {
let mut control = TcpStream::connect(proxy.addr)
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
socks5_handshake(&mut control, proxy).await?;
let relay_addr = socks5_udp_associate(&mut control).await?;
let socket = std::net::UdpSocket::bind("0.0.0.0:0")
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
socket
.set_nonblocking(true)
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
let local_addr = socket
.local_addr()
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
Ok(Self {
socket,
relay_addr,
local_addr,
_control: control,
})
}
}
#[cfg(feature = "quinn")]
impl quinn::AsyncUdpSocket for Socks5UdpSocket {
fn create_io_poller(self: Arc<Self>) -> Pin<Box<dyn quinn::UdpPoller>> {
Box::pin(UdpPollerImpl {
socket: self.socket.try_clone().ok(),
waker: Mutex::new(None),
})
}
fn try_send(&self, transmit: &quinn::udp::Transmit) -> io::Result<()> {
let mut buf = Vec::with_capacity(10 + transmit.contents.len());
buf.extend_from_slice(&[0u8, 0, 0]);
match transmit.destination {
SocketAddr::V4(addr) => {
buf.push(0x01);
buf.extend_from_slice(&addr.ip().octets());
buf.extend_from_slice(&addr.port().to_be_bytes());
}
SocketAddr::V6(addr) => {
buf.push(0x04);
buf.extend_from_slice(&addr.ip().octets());
buf.extend_from_slice(&addr.port().to_be_bytes());
}
}
buf.extend_from_slice(transmit.contents);
let sent = self.socket.send_to(&buf, self.relay_addr)?;
if sent < buf.len() {
return Err(io::Error::new(io::ErrorKind::WouldBlock, "partial send"));
}
Ok(())
}
fn poll_recv(
&self,
_cx: &mut Context,
bufs: &mut [io::IoSliceMut<'_>],
meta: &mut [quinn::udp::RecvMeta],
) -> Poll<io::Result<usize>> {
let mut buf = [0u8; 65536];
match self.socket.recv_from(&mut buf) {
Ok((n, _src)) => {
if n < 10 {
return Poll::Ready(Ok(0));
}
let header_end = 3;
let atyp = buf[header_end];
let addr_len: usize = match atyp {
0x01 => 4,
0x04 => 16,
_ => return Poll::Ready(Ok(0)),
};
let payload_start = header_end + 1 + addr_len + 2;
if n < payload_start {
return Poll::Ready(Ok(0));
}
let payload = &buf[payload_start..n];
let copy_len = payload.len().min(bufs.iter().map(|b| b.len()).sum());
let mut offset = 0;
for b in bufs.iter_mut() {
let end = (offset + b.len()).min(copy_len);
if offset < end {
b.copy_from_slice(&payload[offset..end]);
}
offset = end;
if offset >= copy_len {
break;
}
}
meta[0] = quinn::udp::RecvMeta {
len: copy_len,
stride: copy_len,
addr: self.relay_addr,
ecn: None,
dst_ip: None,
};
Poll::Ready(Ok(1))
}
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => Poll::Pending,
Err(e) => Poll::Ready(Err(e)),
}
}
fn local_addr(&self) -> io::Result<SocketAddr> {
Ok(self.local_addr)
}
fn may_fragment(&self) -> bool {
false
}
}
#[cfg(feature = "quinn")]
struct UdpPollerImpl {
socket: Option<std::net::UdpSocket>,
waker: Mutex<Option<Waker>>,
}
#[cfg(feature = "quinn")]
impl std::fmt::Debug for UdpPollerImpl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UdpPollerImpl").finish()
}
}
#[cfg(feature = "quinn")]
impl quinn::UdpPoller for UdpPollerImpl {
fn poll_writable(self: Pin<&mut Self>, cx: &mut Context) -> Poll<io::Result<()>> {
if let Some(ref socket) = self.socket {
match socket.send_to(
&[],
socket
.local_addr()
.ok()
.unwrap_or_else(|| "0.0.0.0:0".parse().unwrap()),
) {
Ok(_) => Poll::Ready(Ok(())),
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
*self.waker.lock().unwrap() = Some(cx.waker().clone());
Poll::Pending
}
Err(e) => Poll::Ready(Err(e)),
}
} else {
Poll::Ready(Ok(()))
}
}
}
#[cfg(feature = "quinn")]
impl std::fmt::Debug for Socks5UdpSocket {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Socks5UdpSocket")
.field("relay_addr", &self.relay_addr)
.field("local_addr", &self.local_addr)
.finish()
}
}
/// Perform the SOCKS5 handshake (greeting + auth).
#[cfg(feature = "quinn")]
async fn socks5_handshake(
stream: &mut TcpStream,
proxy: &Socks5ProxyConfig,
) -> Result<(), ClientDialError> {
if let Some(creds) = &proxy.credentials {
stream
.write_all(&[0x05, 0x01, 0x02])
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
let mut resp = [0u8; 2];
stream
.read_exact(&mut resp)
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
if resp[0] != 0x05 || resp[1] != 0x02 {
return Err(ClientDialError::Proxy(
"SOCKS5 server does not support username/password auth".into(),
));
}
let mut auth_msg = Vec::with_capacity(3 + creds.username.len() + creds.password.len());
auth_msg.push(0x01);
auth_msg.push(creds.username.len() as u8);
auth_msg.extend_from_slice(creds.username.as_bytes());
auth_msg.push(creds.password.len() as u8);
auth_msg.extend_from_slice(creds.password.as_bytes());
stream
.write_all(&auth_msg)
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
let mut auth_resp = [0u8; 2];
stream
.read_exact(&mut auth_resp)
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
if auth_resp[1] != 0x00 {
return Err(ClientDialError::Proxy(
"SOCKS5 username/password authentication failed".into(),
));
}
} else {
stream
.write_all(&[0x05, 0x01, 0x00])
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
let mut resp = [0u8; 2];
stream
.read_exact(&mut resp)
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
if resp[0] != 0x05 || resp[1] != 0x00 {
return Err(ClientDialError::Proxy(
"SOCKS5 server rejected no-auth method".into(),
));
}
}
Ok(())
}
/// Perform the SOCKS5 UDP ASSOCIATE request and return the relay address.
#[cfg(feature = "quinn")]
async fn socks5_udp_associate(stream: &mut TcpStream) -> Result<SocketAddr, ClientDialError> {
let req = vec![0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0];
stream
.write_all(&req)
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
let mut resp = [0u8; 10];
stream
.read_exact(&mut resp)
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
if resp[0] != 0x05 {
return Err(ClientDialError::Proxy(
"invalid SOCKS5 version in reply".into(),
));
}
if resp[1] != 0x00 {
return Err(ClientDialError::Proxy(format!(
"SOCKS5 UDP ASSOCIATE rejected with code {}",
resp[1]
)));
}
let bind_port = u16::from_be_bytes([resp[8], resp[9]]);
let bind_addr = match resp[3] {
0x01 => {
let mut addr = [0u8; 4];
stream
.read_exact(&mut addr)
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
SocketAddr::new(std::net::Ipv4Addr::from(addr).into(), bind_port)
}
0x04 => {
let mut addr = [0u8; 16];
stream
.read_exact(&mut addr)
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
SocketAddr::new(std::net::Ipv6Addr::from(addr).into(), bind_port)
}
_ => {
return Err(ClientDialError::Proxy(format!(
"unsupported address type in UDP ASSOCIATE reply: {}",
resp[3]
)))
}
};
Ok(bind_addr)
}
/// Perform the SOCKS5 CONNECT handshake to the target address.
pub async fn socks5_connect(
stream: &mut TcpStream,
proxy: &Socks5ProxyConfig,
target: SocketAddr,
) -> Result<(), String> {
socks5_handshake_connect(stream, proxy).await?;
let mut req = vec![0x05, 0x01, 0x00];
match target {
SocketAddr::V4(addr) => {
req.push(0x01);
req.extend_from_slice(&addr.ip().octets());
req.extend_from_slice(&addr.port().to_be_bytes());
}
SocketAddr::V6(addr) => {
req.push(0x04);
req.extend_from_slice(&addr.ip().octets());
req.extend_from_slice(&addr.port().to_be_bytes());
}
}
stream
.write_all(&req)
.await
.map_err(|e| format!("SOCKS5 CONNECT write: {e}"))?;
let mut resp = [0u8; 10];
stream
.read_exact(&mut resp)
.await
.map_err(|e| format!("SOCKS5 CONNECT read: {e}"))?;
if resp[0] != 0x05 {
return Err("invalid SOCKS5 version in CONNECT reply".into());
}
if resp[1] != 0x00 {
return Err(format!("SOCKS5 CONNECT rejected with code {}", resp[1]));
}
match resp[3] {
0x01 => {
let mut _addr = [0u8; 4];
stream
.read_exact(&mut _addr)
.await
.map_err(|e| format!("SOCKS5 CONNECT bind addr read: {e}"))?;
}
0x04 => {
let mut _addr = [0u8; 16];
stream
.read_exact(&mut _addr)
.await
.map_err(|e| format!("SOCKS5 CONNECT bind addr read: {e}"))?;
}
_ => {}
}
Ok(())
}
async fn socks5_handshake_connect(
stream: &mut TcpStream,
proxy: &Socks5ProxyConfig,
) -> Result<(), String> {
if let Some(creds) = &proxy.credentials {
stream
.write_all(&[0x05, 0x01, 0x02])
.await
.map_err(|e| format!("SOCKS5 greeting write: {e}"))?;
let mut resp = [0u8; 2];
stream
.read_exact(&mut resp)
.await
.map_err(|e| format!("SOCKS5 greeting read: {e}"))?;
if resp[0] != 0x05 || resp[1] != 0x02 {
return Err("SOCKS5 server does not support username/password auth".into());
}
let mut auth_msg = Vec::with_capacity(3 + creds.username.len() + creds.password.len());
auth_msg.push(0x01);
auth_msg.push(creds.username.len() as u8);
auth_msg.extend_from_slice(creds.username.as_bytes());
auth_msg.push(creds.password.len() as u8);
auth_msg.extend_from_slice(creds.password.as_bytes());
stream
.write_all(&auth_msg)
.await
.map_err(|e| format!("SOCKS5 auth write: {e}"))?;
let mut auth_resp = [0u8; 2];
stream
.read_exact(&mut auth_resp)
.await
.map_err(|e| format!("SOCKS5 auth read: {e}"))?;
if auth_resp[1] != 0x00 {
return Err("SOCKS5 username/password authentication failed".into());
}
} else {
stream
.write_all(&[0x05, 0x01, 0x00])
.await
.map_err(|e| format!("SOCKS5 greeting write: {e}"))?;
let mut resp = [0u8; 2];
stream
.read_exact(&mut resp)
.await
.map_err(|e| format!("SOCKS5 greeting read: {e}"))?;
if resp[0] != 0x05 || resp[1] != 0x00 {
return Err("SOCKS5 server rejected no-auth method".into());
}
}
Ok(())
}
@@ -0,0 +1,28 @@
//! Integration test: dial + take-over composition.
//!
//! Verifies that the AlknetClient type exists and compiles correctly.
//! Full end-to-end dial tests (with a real quinn endpoint) are tested
//! in the assembly layer integration tests (future hub/worker tests).
use alknet_client::AlknetClient;
use alknet_core::credentials::ConnectionCredentials;
#[tokio::test]
async fn alknet_client_new_creates_empty_client() {
let client = AlknetClient::new();
let creds = ConnectionCredentials::new();
let _ = (client, creds);
}
#[test]
fn alknet_client_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<AlknetClient>();
}
#[test]
fn client_dial_error_is_send_sync() {
use alknet_client::ClientDialError;
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<ClientDialError>();
}
+1 -5
View File
@@ -3,7 +3,7 @@ name = "alknet-core"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Core library for ALPN-based protocol dispatch: ProtocolHandler trait, Connection, auth, config, and multi-connectivity endpoint"
description = "Core library for ALPN-based protocol dispatch: ProtocolHandler trait, Connection, auth, config, and transport-level credentials"
repository.workspace = true
[lib]
@@ -13,7 +13,6 @@ name = "alknet_core"
default = ["quinn"]
quinn = ["dep:quinn"]
iroh = ["dep:iroh"]
acme = ["dep:rustls-acme"]
[dependencies]
tokio = { version = "1", features = ["full"] }
@@ -21,7 +20,6 @@ quinn = { version = "0.11", optional = true }
iroh = { version = "1.0", optional = true, default-features = false, features = ["tls-aws-lc-rs"] }
rustls = "0.23"
rustls-pki-types = "1"
rustls-pemfile = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
@@ -35,9 +33,7 @@ futures = "0.3"
sha2 = "0.10"
hex = "0.4"
rand = "0.8"
rcgen = "0.13"
ed25519-dalek = { version = "2", features = ["rand_core"] }
rustls-acme = { version = "0.12", optional = true, features = ["aws-lc-rs"] }
[dev-dependencies]
tempfile = "3"
+6 -6
View File
@@ -1,7 +1,7 @@
//! Transport-level credential bundle for outbound connections (ADR-091).
//!
//! `ConnectionCredentials` carries the two dimensions the dial consumes:
//! the local node's TLS identity and the expected remote identity.
//! the local node's identity and the expected remote identity.
//! It is transport-agnostic — consumed by `alknet-tls` (TLS setup) and
//! `alknet-client` (dial).
@@ -36,9 +36,9 @@ pub struct RemoteIdentity {
/// `docs/architecture/crates/call/client-and-adapters.md`.
#[derive(Debug, Clone, Default)]
pub struct ConnectionCredentials {
/// The local node's TLS identity (RFC 7250 raw key or X.509), derived
/// The local node's identity (RFC 7250 raw key or X.509), derived
/// from the vault at startup.
pub tls_identity: Option<TlsIdentity>,
pub local_identity: Option<TlsIdentity>,
/// 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
@@ -52,8 +52,8 @@ impl ConnectionCredentials {
Self::default()
}
pub fn with_tls_identity(mut self, tls_identity: TlsIdentity) -> Self {
self.tls_identity = Some(tls_identity);
pub fn with_local_identity(mut self, local_identity: TlsIdentity) -> Self {
self.local_identity = Some(local_identity);
self
}
@@ -76,7 +76,7 @@ mod tests {
creds.remote_identity.as_ref().unwrap().fingerprint,
"SHA256:abc"
);
assert!(creds.tls_identity.is_none());
assert!(creds.local_identity.is_none());
}
#[test]
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -10,9 +10,9 @@
//! relationship). Not used for arbitrary public APIs (those use CA
//! verification via `WebPkiServerVerifier`, not fingerprint pinning).
//!
//! Shared by the server-side endpoint (`alknet_core::endpoint`, which extracts
//! Shared by the server-side endpoint (`alknet-endpoint`, which extracts
//! the fingerprint from the presented client cert for `PeerEntry` resolution)
//! and the client-side `FingerprintPinVerifier` in `alknet_call::client`
//! and the client-side `FingerprintPinVerifier` in `alknet-tls`
//! (which matches the server's presented cert against a pinned fingerprint).
use sha2::{Digest, Sha256};
+3 -3
View File
@@ -3,13 +3,13 @@
//! Every handler crate depends on this crate. It provides the
//! [`ProtocolHandler`][crate::types::ProtocolHandler] trait, the
//! [`Connection`][crate::types::Connection] wrapper, auth primitives,
//! hot-reloadable configuration, and the [`AlknetEndpoint`][crate::endpoint::AlknetEndpoint]
//! that dispatches incoming QUIC connections by ALPN string.
//! hot-reloadable configuration, and transport-level credential types
//! ([`ConnectionCredentials`][crate::credentials::ConnectionCredentials],
//! [`RemoteIdentity`][crate::credentials::RemoteIdentity]).
pub mod auth;
pub mod config;
pub mod credentials;
pub mod endpoint;
pub mod fingerprint;
pub mod ownership;
pub mod store;
+233 -150
View File
@@ -223,143 +223,193 @@ pub trait ProtocolHandler: Send + Sync + 'static {
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError>;
}
pub trait BiStream: AsyncRead + AsyncWrite + Send + Unpin {}
// --- BiStream: the handler leaf (ADR-092) ---------------------------------
//
// `accept_bi`/`open_bi` yield `BiStream`, a concrete newtype that boxes the
// joined inner transport. The join happens once in the `BidiStreamSource`
// impl (quinn/iroh via `tokio::io::join`, single-stream via the input
// `AsyncRead + AsyncWrite` boxed directly). The split never crosses a crate
// boundary as part of a constructor: `Connection::from_bidi` is the only
// public stream constructor; `Connection::from_stream` is removed.
enum SendStreamKind {
#[cfg(feature = "quinn")]
Quinn(quinn::SendStream),
#[cfg(feature = "iroh")]
Iroh(iroh::endpoint::SendStream),
Stream(Box<dyn AsyncWrite + Send + Unpin>),
/// Internal helper trait — the union of `AsyncRead + AsyncWrite + Send +
/// Unpin`. Not public; exists only to give `BiStream` a single boxed field.
trait AsyncReadWrite: AsyncRead + AsyncWrite + Send {}
impl<T: AsyncRead + AsyncWrite + Send> AsyncReadWrite for T {}
/// The handler leaf — a bidirectional byte stream (ADR-092).
///
/// `accept_bi`/`open_bi` return a `BiStream`, not a split
/// `(SendStream, RecvStream)` pair. Handlers that want the split halves call
/// `tokio::io::split(&mut *stream)` (the 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.
///
/// `BiStream: AsyncRead + AsyncWrite + Send + Unpin` by construction. The
/// old `pub trait BiStream: AsyncRead + AsyncWrite + Send + Unpin {}`
/// (ADR-007) is removed — the trait was never consumed, and the concrete
/// struct carries the same trait bounds forward as implied bounds, not a
/// marker trait. The name and the bounds survive; the shape becomes a
/// concrete leaf.
pub struct BiStream {
inner: Box<dyn AsyncReadWrite + Unpin>,
}
enum RecvStreamKind {
#[cfg(feature = "quinn")]
Quinn(quinn::RecvStream),
#[cfg(feature = "iroh")]
Iroh(iroh::endpoint::RecvStream),
Stream(Box<dyn AsyncRead + Send + Unpin>),
impl BiStream {
/// Join a read half and a write half into a single `BiStream`. The join
/// happens once, in the `BidiStreamSource` impl — handlers receive the
/// joined `BiStream` and never see the pair.
///
/// Public so that downstream crates (the channels reassembly path, tests
/// that construct a `BiStream` from independent halves) can join their
/// own halves. The rule this normalizes: **the split never crosses a
/// crate boundary as part of a constructor** — `Connection::from_bidi`
/// takes a joined `BiStream`, and `BiStream::from_joined` is the join.
/// A crate that produces split halves naturally (channels reassembly)
/// joins them itself via this constructor, then hands the `BiStream` to
/// `Connection::from_bidi` (or yields it from its own
/// `BidiStreamSource::accept_bi` impl).
pub fn from_joined<R, W>(reader: R, writer: W) -> Self
where
R: AsyncRead + Send + Unpin + 'static,
W: AsyncWrite + Send + Unpin + 'static,
{
Self {
inner: Box::new(tokio::io::join(reader, writer)),
}
}
/// Wrap a single value that is already `AsyncRead + AsyncWrite` (e.g.
/// `tokio::io::DuplexStream`, `TlsStream<TcpStream>`,
/// `russh::Channel::into_stream()`). Used by the single-stream
/// `BidiStreamSource` impl and by `Connection::from_bidi`.
pub(crate) fn from_bidi<S>(stream: S) -> Self
where
S: AsyncRead + AsyncWrite + Send + Unpin + 'static,
{
Self {
inner: Box::new(stream),
}
}
}
impl AsyncRead for BiStream {
fn poll_read(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<io::Result<()>> {
std::pin::Pin::new(self.inner.as_mut()).poll_read(cx, buf)
}
}
impl AsyncWrite for BiStream {
fn poll_write(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<io::Result<usize>> {
std::pin::Pin::new(self.inner.as_mut()).poll_write(cx, buf)
}
fn poll_flush(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
std::pin::Pin::new(self.inner.as_mut()).poll_flush(cx)
}
fn poll_shutdown(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
std::pin::Pin::new(self.inner.as_mut()).poll_shutdown(cx)
}
}
// --- SendStream / RecvStream: thin newtypes (ADR-092) ---------------------
//
// These remain as the typed-sub-stream leaves for `into_sub_streams()`
// (ADR-074) and the channels reassembly path's `SubStreamHandle` leaves
// (future). They never cross a crate boundary as part of a `Connection`
// constructor — `Connection::from_bidi` is the only public constructor and
// takes a joined `BiStream`. The quinn-welded `SendStreamKind` /
// `RecvStreamKind` enums are gone; the quinn/iroh dispatch moves into the
// `BidiStreamSource` impls (the join happens once, there).
pub struct SendStream {
kind: SendStreamKind,
inner: Box<dyn AsyncWrite + Send + Unpin>,
}
pub struct RecvStream {
kind: RecvStreamKind,
inner: Box<dyn AsyncRead + Send + Unpin>,
}
impl SendStream {
#[cfg(feature = "quinn")]
fn from_quinn(stream: quinn::SendStream) -> Self {
Self {
kind: SendStreamKind::Quinn(stream),
}
}
#[cfg(feature = "iroh")]
fn from_iroh(stream: iroh::endpoint::SendStream) -> Self {
Self {
kind: SendStreamKind::Iroh(stream),
}
}
/// Box a write half into the thin `SendStream` newtype. Used by
/// `into_sub_streams()` (ADR-074) and the channels reassembly path.
/// Not a constructor that feeds `Connection` — the split never crosses
/// a crate boundary as part of a constructor (ADR-092).
pub fn from_stream(stream: impl AsyncWrite + Send + Unpin + 'static) -> Self {
Self {
kind: SendStreamKind::Stream(Box::new(stream)),
inner: Box::new(stream),
}
}
}
impl RecvStream {
#[cfg(feature = "quinn")]
fn from_quinn(stream: quinn::RecvStream) -> Self {
Self {
kind: RecvStreamKind::Quinn(stream),
}
}
#[cfg(feature = "iroh")]
fn from_iroh(stream: iroh::endpoint::RecvStream) -> Self {
Self {
kind: RecvStreamKind::Iroh(stream),
}
}
/// Box a read half into the thin `RecvStream` newtype. Used by
/// `into_sub_streams()` (ADR-074) and the channels reassembly path.
/// Not a constructor that feeds `Connection` — the split never crosses
/// a crate boundary as part of a constructor (ADR-092).
pub fn from_stream(stream: impl AsyncRead + Send + Unpin + 'static) -> Self {
Self {
kind: RecvStreamKind::Stream(Box::new(stream)),
inner: Box::new(stream),
}
}
}
impl AsyncWrite for SendStream {
fn poll_write(
self: std::pin::Pin<&mut Self>,
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<io::Result<usize>> {
match &mut self.get_mut().kind {
#[cfg(feature = "quinn")]
SendStreamKind::Quinn(s) => AsyncWrite::poll_write(std::pin::Pin::new(s), cx, buf),
#[cfg(feature = "iroh")]
SendStreamKind::Iroh(s) => AsyncWrite::poll_write(std::pin::Pin::new(s), cx, buf),
SendStreamKind::Stream(s) => {
AsyncWrite::poll_write(std::pin::Pin::new(s.as_mut()), cx, buf)
}
}
std::pin::Pin::new(self.inner.as_mut()).poll_write(cx, buf)
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
match &mut self.get_mut().kind {
#[cfg(feature = "quinn")]
SendStreamKind::Quinn(s) => AsyncWrite::poll_flush(std::pin::Pin::new(s), cx),
#[cfg(feature = "iroh")]
SendStreamKind::Iroh(s) => AsyncWrite::poll_flush(std::pin::Pin::new(s), cx),
SendStreamKind::Stream(s) => AsyncWrite::poll_flush(std::pin::Pin::new(s.as_mut()), cx),
}
std::pin::Pin::new(self.inner.as_mut()).poll_flush(cx)
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
match &mut self.get_mut().kind {
#[cfg(feature = "quinn")]
SendStreamKind::Quinn(s) => AsyncWrite::poll_shutdown(std::pin::Pin::new(s), cx),
#[cfg(feature = "iroh")]
SendStreamKind::Iroh(s) => AsyncWrite::poll_shutdown(std::pin::Pin::new(s), cx),
SendStreamKind::Stream(s) => AsyncWrite::poll_shutdown(std::pin::Pin::new(s), cx),
}
std::pin::Pin::new(self.inner.as_mut()).poll_shutdown(cx)
}
}
impl AsyncRead for RecvStream {
fn poll_read(
self: std::pin::Pin<&mut Self>,
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<io::Result<()>> {
match &mut self.get_mut().kind {
#[cfg(feature = "quinn")]
RecvStreamKind::Quinn(s) => AsyncRead::poll_read(std::pin::Pin::new(s), cx, buf),
#[cfg(feature = "iroh")]
RecvStreamKind::Iroh(s) => AsyncRead::poll_read(std::pin::Pin::new(s), cx, buf),
RecvStreamKind::Stream(s) => {
AsyncRead::poll_read(std::pin::Pin::new(s.as_mut()), cx, buf)
}
}
std::pin::Pin::new(self.inner.as_mut()).poll_read(cx, buf)
}
}
/// Yield bidirectional streams to a `Connection`. Downstream crates implement
/// this trait to add connection shapes (channels, a future transport, a test
/// double beyond the `from_stream` case) without editing `alknet-core`. See
/// double beyond the single-stream case) without editing `alknet-core`. See
/// ADR-070 for the full rationale and ADR-065 for the yield-once contract the
/// `StreamBidiStreamSource` impl preserves.
/// `StreamBidiStreamSource` impl preserves. The return type is `BiStream`
/// (ADR-092) — the join happens once, in the impl, not per-handler.
#[async_trait]
pub trait BidiStreamSource: Send + Sync + 'static {
/// Yield the next bidirectional stream this connection provides.
@@ -372,14 +422,14 @@ pub trait BidiStreamSource: Send + Sync + 'static {
/// `ConnectionClosed` on all subsequent calls.
/// - Channels: yields one bidi stream per channel, `ConnectionClosed`
/// when the channels connection closes.
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
async fn accept_bi(&self) -> Result<BiStream, StreamError>;
/// Open a bidirectional stream to the peer.
///
/// Single-stream sources return `StreamClosed` (a single stream cannot
/// open new application streams — ADR-065). QUIC and channels sources
/// open new streams.
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
async fn open_bi(&self) -> Result<BiStream, StreamError>;
/// The peer's address, if available. Informational (NAT/proxy).
fn remote_addr(&self) -> Option<SocketAddr>;
@@ -401,22 +451,22 @@ struct QuinnBidiStreamSource {
#[cfg(feature = "quinn")]
#[async_trait]
impl BidiStreamSource for QuinnBidiStreamSource {
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
let (send, recv) = self
.conn
.accept_bi()
.await
.map_err(map_quinn_connection_error)?;
Ok((SendStream::from_quinn(send), RecvStream::from_quinn(recv)))
Ok(BiStream::from_joined(recv, send))
}
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
async fn open_bi(&self) -> Result<BiStream, StreamError> {
let (send, recv) = self
.conn
.open_bi()
.await
.map_err(map_quinn_connection_error)?;
Ok((SendStream::from_quinn(send), RecvStream::from_quinn(recv)))
Ok(BiStream::from_joined(recv, send))
}
fn remote_addr(&self) -> Option<SocketAddr> {
@@ -439,22 +489,22 @@ struct IrohBidiStreamSource {
#[cfg(feature = "iroh")]
#[async_trait]
impl BidiStreamSource for IrohBidiStreamSource {
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
let (send, recv) = self
.conn
.accept_bi()
.await
.map_err(map_iroh_connection_error)?;
Ok((SendStream::from_iroh(send), RecvStream::from_iroh(recv)))
Ok(BiStream::from_joined(recv, send))
}
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
async fn open_bi(&self) -> Result<BiStream, StreamError> {
let (send, recv) = self
.conn
.open_bi()
.await
.map_err(map_iroh_connection_error)?;
Ok((SendStream::from_iroh(send), RecvStream::from_iroh(recv)))
Ok(BiStream::from_joined(recv, send))
}
fn remote_addr(&self) -> Option<SocketAddr> {
@@ -469,25 +519,25 @@ impl BidiStreamSource for IrohBidiStreamSource {
/// Single-stream `BidiStreamSource` (TCP+TLS, SSH channel, WebTransport
/// stream, wasm stream — ADR-065). Crate-private; constructed via
/// `Connection::from_stream` / `from_bidi` (no feature gate). `accept_bi`
/// yields the underlying stream once, then `ConnectionClosed`; `open_bi`
/// returns `StreamClosed`.
/// `Connection::from_bidi` (no feature gate). `accept_bi` yields the
/// underlying `BiStream` once, then `ConnectionClosed`; `open_bi` returns
/// `StreamClosed`.
struct StreamBidiStreamSource {
stream: Mutex<Option<(SendStream, RecvStream)>>,
stream: Mutex<Option<BiStream>>,
remote_addr: Option<SocketAddr>,
}
#[async_trait]
impl BidiStreamSource for StreamBidiStreamSource {
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
let mut guard = self.stream.lock().expect("stream mutex poisoned");
match guard.take() {
Some(pair) => Ok(pair),
Some(stream) => Ok(stream),
None => Err(StreamError::ConnectionClosed),
}
}
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
async fn open_bi(&self) -> Result<BiStream, StreamError> {
Err(StreamError::StreamClosed)
}
@@ -535,37 +585,30 @@ impl Connection {
}
}
/// Construct a `Connection` from a pre-split read/write pair.
/// `accept_bi()` yields this pair once, then returns `ConnectionClosed`.
/// `open_bi()` returns `StreamClosed` (a single stream can't open new streams).
pub fn from_stream(
send: impl AsyncWrite + Send + Unpin + 'static,
recv: impl AsyncRead + Send + Unpin + 'static,
alpn: Vec<u8>,
remote_addr: Option<SocketAddr>,
) -> Self {
Self {
source: Box::new(StreamBidiStreamSource {
stream: Mutex::new(Some((
SendStream::from_stream(send),
RecvStream::from_stream(recv),
))),
remote_addr,
}),
alpn,
identity: OnceLock::new(),
}
}
/// Convenience for a single bidirectional stream (e.g. `TlsStream<TcpStream>`).
/// Splits internally via `tokio::io::split`.
/// Construct a `Connection` from a single bidirectional stream (e.g.
/// `tokio::io::DuplexStream`, `TlsStream<TcpStream>`,
/// `russh::Channel::into_stream()`). The stream is wrapped in a
/// `BiStream` (ADR-092) and yielded by `accept_bi` once, then
/// `ConnectionClosed`. `open_bi` returns `StreamClosed` (a single
/// stream can't open new application streams — ADR-065).
///
/// This is the only public stream constructor (ADR-092): the split
/// never crosses a crate boundary as part of a constructor. Handlers
/// that want the split halves call `tokio::io::split(&mut *stream)` on
/// the `BiStream` they receive from `accept_bi`.
pub fn from_bidi(
stream: impl AsyncRead + AsyncWrite + Send + Unpin + 'static,
alpn: Vec<u8>,
remote_addr: Option<SocketAddr>,
) -> Self {
let (recv, send) = tokio::io::split(stream);
Self::from_stream(send, recv, alpn, remote_addr)
Self {
source: Box::new(StreamBidiStreamSource {
stream: Mutex::new(Some(BiStream::from_bidi(stream))),
remote_addr,
}),
alpn,
identity: OnceLock::new(),
}
}
/// Construct from a caller-supplied `BidiStreamSource` impl. The
@@ -591,12 +634,14 @@ impl Connection {
///
/// Handlers that loop `accept_bi` (e.g. `TtyAdapter`) get one session
/// per single-stream connection; handlers that call once (e.g.
/// `HttpAdapter`) get the stream directly. Both are correct.
pub async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
/// `HttpAdapter`) get the stream directly. Both are correct. The
/// return type is `BiStream` (ADR-092); handlers that want the split
/// halves call `tokio::io::split` on the `BiStream`.
pub async fn accept_bi(&self) -> Result<BiStream, StreamError> {
self.source.accept_bi().await
}
pub async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
pub async fn open_bi(&self) -> Result<BiStream, StreamError> {
self.source.open_bi().await
}
@@ -657,21 +702,21 @@ mod from_source_tests {
/// delegates to a caller-supplied impl. Not a built-in — the whole
/// point of `from_source` is that a non-core type can drive `Connection`.
struct RecordingSource {
stream: Mutex<Option<(SendStream, RecvStream)>>,
stream: Mutex<Option<BiStream>>,
addr: Option<SocketAddr>,
closed: Arc<Mutex<Option<(u32, String)>>>,
}
#[async_trait]
impl BidiStreamSource for RecordingSource {
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
match self.stream.lock().expect("mock mutex poisoned").take() {
Some(pair) => Ok(pair),
Some(stream) => Ok(stream),
None => Err(StreamError::ConnectionClosed),
}
}
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
async fn open_bi(&self) -> Result<BiStream, StreamError> {
Err(StreamError::StreamClosed)
}
@@ -693,19 +738,16 @@ mod from_source_tests {
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
// One duplex: the mock holds end `a` (split into send_a/recv_a); the
// test driver holds end `b` (split into send_b/recv_b) to echo back.
// One duplex: the mock holds end `a`; the test driver holds end `b`
// (split into send_b/recv_b) to echo back. The mock's `accept_bi`
// yields end `a` as a `BiStream`; the driver reads/writes end `b`.
let (a, b) = tokio::io::duplex(64);
let (recv_a, send_a) = tokio::io::split(a);
let (mut recv_b, mut send_b) = tokio::io::split(b);
let addr = Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7777));
let recorded = Arc::new(Mutex::new(None));
let conn = Connection::from_source(
RecordingSource {
stream: Mutex::new(Some((
SendStream::from_stream(send_a),
RecvStream::from_stream(recv_a),
))),
stream: Mutex::new(Some(BiStream::from_bidi(a))),
addr,
closed: Arc::clone(&recorded),
},
@@ -718,22 +760,22 @@ mod from_source_tests {
// remote_addr delegates to RecordingSource::remote_addr.
assert_eq!(conn.remote_addr(), addr);
// accept_bi delegates to RecordingSource::accept_bi and yields the pair.
let (mut send, mut recv) = conn.accept_bi().await.expect("first accept_bi yields");
// accept_bi delegates to RecordingSource::accept_bi and yields a BiStream.
let mut stream = conn.accept_bi().await.expect("first accept_bi yields");
// Write via the mock's SendStream -> arrives at the driver's recv_b.
send.write_all(b"hello").await.expect("write round-trips");
// Write via the mock's BiStream -> arrives at the driver's recv_b.
stream.write_all(b"hello").await.expect("write round-trips");
let mut buf = [0u8; 5];
recv_b.read_exact(&mut buf).await.expect("driver reads");
assert_eq!(&buf, b"hello");
// Driver writes back -> arrives at the mock's RecvStream.
// Driver writes back -> arrives at the mock's BiStream.
send_b
.write_all(b"world")
.await
.expect("driver writes back");
let mut buf = [0u8; 5];
recv.read_exact(&mut buf).await.expect("read round-trips");
stream.read_exact(&mut buf).await.expect("read round-trips");
assert_eq!(&buf, b"world");
// Second accept_bi delegates to RecordingSource::accept_bi -> ConnectionClosed.
@@ -763,11 +805,52 @@ mod from_source_tests {
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::pin::Pin;
use std::task::{Context, Poll};
/// A test-only `AsyncRead + AsyncWrite` pair equivalent to
/// `tokio::io::sink()` + `tokio::io::empty()`: reads yield EOF
/// immediately (zero bytes), writes discard. Exists because
/// `Connection::from_bidi` requires a single value that implements
/// both traits (ADR-092 — the split-pair `from_stream` constructor is
/// removed). Used only to construct a `Connection` for tests that
/// exercise `Connection`-level state (alpn, addr, identity) without
/// ever reading or writing the stream.
struct SinkEmpty;
impl AsyncRead for SinkEmpty {
fn poll_read(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
_buf: &mut tokio::io::ReadBuf<'_>,
) -> Poll<io::Result<()>> {
// EOF immediately — mirrors `tokio::io::empty()`.
Poll::Ready(Ok(()))
}
}
impl AsyncWrite for SinkEmpty {
fn poll_write(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
// Discard — mirrors `tokio::io::sink()`.
Poll::Ready(Ok(buf.len()))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
}
fn test_connection() -> Connection {
Connection::from_stream(
tokio::io::sink(),
tokio::io::empty(),
Connection::from_bidi(
SinkEmpty,
b"alknet/test".to_vec(),
Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 1234)),
)
@@ -863,7 +946,7 @@ mod tests {
}
#[test]
fn connection_remote_alpn_and_addr_from_stream() {
fn connection_remote_alpn_and_addr_from_bidi() {
let conn = test_connection();
assert_eq!(conn.remote_alpn(), b"alknet/test");
assert_eq!(
+34 -3
View File
@@ -432,7 +432,8 @@ mod tests {
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
};
use alknet_call::registry::registration::{
make_handler, HandlerKind, HandlerRegistration, OperationProvenance, OperationRegistry,
make_handler, make_streaming_handler, HandlerKind, HandlerRegistration,
OperationProvenance, OperationRegistry,
};
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
@@ -498,6 +499,36 @@ mod tests {
)
}
/// A streaming echo handler: yields the input back as a single
/// `call.responded` frame, then the stream ends. Used for
/// `OperationType::Subscription` ops in `full_registry_with_ops` —
/// the registry's kind validation (ADR-049) requires
/// `HandlerKind::Stream` for `Subscription` ops; using
/// `HandlerKind::Once` is rejected with `"handler kind mismatch:
/// Subscription requires HandlerKind::Stream (got HandlerKind::Once)"`.
/// The test only verifies that the MCP `search` tool *excludes*
/// Subscription ops from its listing — it never invokes the handler —
/// so a single-frame echo is sufficient.
fn make_echo_streaming_handler() -> alknet_call::registry::registration::StreamingHandler {
make_streaming_handler(|input, context| {
futures::stream::iter(vec![ResponseEnvelope::ok(context.request_id, input)])
})
}
/// Build a `HandlerKind` matching the op's `OperationType`: `Once` for
/// Query/Mutation, `Stream` for Subscription. The registry's kind
/// validation (ADR-049) rejects a mismatch, so the helper must branch
/// — using `HandlerKind::Once` for a `Subscription` op panics in
/// `register().unwrap()`.
fn handler_kind_for(op_type: OperationType) -> HandlerKind {
match op_type {
OperationType::Subscription => HandlerKind::Stream(make_echo_streaming_handler()),
OperationType::Query | OperationType::Mutation => {
HandlerKind::Once(make_echo_handler())
}
}
}
fn full_registry_with_ops(
specs: Vec<(String, OperationType, AccessControl)>,
) -> Arc<OperationRegistry> {
@@ -506,7 +537,7 @@ mod tests {
inner
.register(HandlerRegistration::new(
external_spec(&name, op_type, acl),
HandlerKind::Once(make_echo_handler()),
handler_kind_for(op_type),
OperationProvenance::Local,
None,
None,
@@ -521,7 +552,7 @@ mod tests {
dispatch_registry
.register(HandlerRegistration::new(
external_spec(&op.name, op.op_type, op.access_control.clone()),
HandlerKind::Once(make_echo_handler()),
handler_kind_for(op.op_type),
OperationProvenance::Local,
None,
None,
+30 -141
View File
@@ -8,9 +8,7 @@
//! from `gateway_routes`; `/openapi.json` serves the `to_openapi` projection
//! of the registry.
use std::io;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use async_trait::async_trait;
@@ -229,12 +227,14 @@ impl ProtocolHandler for HttpAdapter {
let _ = connection.set_identity(identity);
}
let (send, recv) = connection
// `accept_bi` returns a `BiStream` (ADR-092) — already
// `AsyncRead + AsyncWrite + Send + Unpin`. No wrapper needed; pass
// it directly to `serve_io` via `TokioIo::new`.
let stream = connection
.accept_bi()
.await
.map_err(stream_error_to_handler)?;
let io = QuicStream::new(send, recv);
self.serve_io(io).await
self.serve_io(stream).await
}
}
@@ -268,51 +268,6 @@ fn stream_error_to_handler(e: StreamError) -> HandlerError {
HandlerError::from(e)
}
struct QuicStream {
send: alknet_core::types::SendStream,
recv: alknet_core::types::RecvStream,
}
impl QuicStream {
fn new(send: alknet_core::types::SendStream, recv: alknet_core::types::RecvStream) -> Self {
Self { send, recv }
}
}
impl AsyncRead for QuicStream {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<io::Result<()>> {
Pin::new(&mut self.recv).poll_read(cx, buf)
}
}
impl AsyncWrite for QuicStream {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<io::Result<usize>> {
Pin::new(&mut self.send).poll_write(cx, buf)
}
fn poll_flush(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
Pin::new(&mut self.send).poll_flush(cx)
}
fn poll_shutdown(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
Pin::new(&mut self.send).poll_shutdown(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -418,29 +373,26 @@ mod tests {
async fn send_request_and_read_response(
request: &[u8],
) -> (String, tokio::task::JoinHandle<()>) {
let (mut client_send, server_recv) = duplex(8 * 1024);
let (server_send, mut client_recv) = duplex(8 * 1024);
let server_io = QuicStreamDuplex {
read: server_recv,
write: server_send,
};
// One duplex: `server_io` is the server's end (passed to `serve_io`);
// `client_io` is the client's end (writes requests, reads responses).
// `tokio::io::duplex` yields two `DuplexStream`s, each
// `AsyncRead + AsyncWrite + Send + Unpin` — the same bounds `BiStream`
// exposes (ADR-092), so no wrapper is needed.
let (server_io, mut client_io) = duplex(8 * 1024);
let adapter = HttpAdapter::new(provider(), empty_registry());
let handle = tokio::spawn(async move {
adapter.serve_io(server_io).await.ok();
});
client_send.write_all(request).await.unwrap();
client_send.flush().await.unwrap();
client_io.write_all(request).await.unwrap();
client_io.flush().await.unwrap();
let mut response = Vec::new();
let mut buf = [0u8; 4096];
loop {
match tokio::time::timeout(
std::time::Duration::from_secs(5),
client_recv.read(&mut buf),
)
.await
match tokio::time::timeout(std::time::Duration::from_secs(5), client_io.read(&mut buf))
.await
{
Ok(Ok(0)) => break,
Ok(Ok(n)) => response.extend_from_slice(&buf[..n]),
@@ -453,45 +405,6 @@ mod tests {
(response_str, handle)
}
struct QuicStreamDuplex {
read: tokio::io::DuplexStream,
write: tokio::io::DuplexStream,
}
impl AsyncRead for QuicStreamDuplex {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<io::Result<()>> {
Pin::new(&mut self.read).poll_read(cx, buf)
}
}
impl AsyncWrite for QuicStreamDuplex {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<io::Result<usize>> {
Pin::new(&mut self.write).poll_write(cx, buf)
}
fn poll_flush(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
Pin::new(&mut self.write).poll_flush(cx)
}
fn poll_shutdown(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
Pin::new(&mut self.write).poll_shutdown(cx)
}
}
#[tokio::test]
async fn handle_serves_http_request_over_mock_quic_stream() {
let request = b"GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
@@ -509,29 +422,21 @@ mod tests {
let extra = Router::new().route("/v1/foo", get(|| async { (StatusCode::OK, "foo-body") }));
let adapter = HttpAdapter::new(provider(), empty_registry()).with_extra_routes(extra);
let (mut client_send, server_recv) = duplex(8 * 1024);
let (server_send, mut client_recv) = duplex(8 * 1024);
let server_io = QuicStreamDuplex {
read: server_recv,
write: server_send,
};
let (server_io, mut client_io) = duplex(8 * 1024);
let handle = tokio::spawn(async move {
adapter.serve_io(server_io).await.ok();
});
let request = b"GET /v1/foo HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
client_send.write_all(request).await.unwrap();
client_send.flush().await.unwrap();
client_io.write_all(request).await.unwrap();
client_io.flush().await.unwrap();
let mut response = Vec::new();
let mut buf = [0u8; 4096];
loop {
match tokio::time::timeout(
std::time::Duration::from_secs(5),
client_recv.read(&mut buf),
)
.await
match tokio::time::timeout(std::time::Duration::from_secs(5), client_io.read(&mut buf))
.await
{
Ok(Ok(0)) => break,
Ok(Ok(n)) => response.extend_from_slice(&buf[..n]),
@@ -556,29 +461,21 @@ mod tests {
);
let adapter = HttpAdapter::new(provider(), empty_registry()).with_extra_routes(extra);
let (mut client_send, server_recv) = duplex(8 * 1024);
let (server_send, mut client_recv) = duplex(8 * 1024);
let server_io = QuicStreamDuplex {
read: server_recv,
write: server_send,
};
let (server_io, mut client_io) = duplex(8 * 1024);
let handle = tokio::spawn(async move {
adapter.serve_io(server_io).await.ok();
});
let request = b"GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
client_send.write_all(request).await.unwrap();
client_send.flush().await.unwrap();
client_io.write_all(request).await.unwrap();
client_io.flush().await.unwrap();
let mut response = Vec::new();
let mut buf = [0u8; 4096];
loop {
match tokio::time::timeout(
std::time::Duration::from_secs(5),
client_recv.read(&mut buf),
)
.await
match tokio::time::timeout(std::time::Duration::from_secs(5), client_io.read(&mut buf))
.await
{
Ok(Ok(0)) => break,
Ok(Ok(n)) => response.extend_from_slice(&buf[..n]),
@@ -597,25 +494,17 @@ mod tests {
}
async fn serve_and_read(adapter: HttpAdapter, request: &[u8]) -> String {
let (mut client_send, server_recv) = duplex(8 * 1024);
let (server_send, mut client_recv) = duplex(8 * 1024);
let server_io = QuicStreamDuplex {
read: server_recv,
write: server_send,
};
let (server_io, mut client_io) = duplex(8 * 1024);
let handle = tokio::spawn(async move {
adapter.serve_io(server_io).await.ok();
});
client_send.write_all(request).await.unwrap();
client_send.flush().await.unwrap();
client_io.write_all(request).await.unwrap();
client_io.flush().await.unwrap();
let mut response = Vec::new();
let mut buf = [0u8; 4096];
loop {
match tokio::time::timeout(
std::time::Duration::from_secs(5),
client_recv.read(&mut buf),
)
.await
match tokio::time::timeout(std::time::Duration::from_secs(5), client_io.read(&mut buf))
.await
{
Ok(Ok(0)) => break,
Ok(Ok(n)) => response.extend_from_slice(&buf[..n]),
+11 -5
View File
@@ -23,7 +23,7 @@ impl TlsClientConfig {
pub fn new(credentials: &ConnectionCredentials, alpn: &[u8]) -> Result<Self, TlsError> {
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let client_auth = build_client_auth(&provider, &credentials.tls_identity)?;
let client_auth = build_client_auth(&provider, &credentials.local_identity)?;
let verifier = select_server_verifier(&provider, &credentials.remote_identity)?;
let mut config = rustls::ClientConfig::builder_with_provider(provider)
@@ -48,13 +48,19 @@ impl TlsClientConfig {
.map_err(|e| TlsError::Config(e.to_string()))?,
)))
}
/// Consume the config and return the inner `rustls::ClientConfig`.
/// Used by `dial_tcp_tls` to build a `TlsConnector`.
pub fn into_rustls_config(self) -> rustls::ClientConfig {
self.rustls_config
}
}
/// Build the client-auth cert resolver that presents the local node's TLS
/// identity. For `TlsIdentity::RawKey` the Ed25519 key is presented as an RFC
/// 7250 raw public key client cert (`only_raw_public_keys() == true`) — the
/// client-side equivalent of the server's `RawKeyCertResolver`. For X.509 the
/// cert chain + key are loaded from disk. `None` (no `tls_identity` configured)
/// cert chain + key are loaded from disk. `None` (no `local_identity` configured)
/// resolves to no client cert (the server gets nothing to fingerprint).
fn build_client_auth(
provider: &Arc<rustls::crypto::CryptoProvider>,
@@ -187,7 +193,7 @@ impl rustls::client::ResolvesClientCert for RawKeyClientCertResolver {
}
}
/// Client cert resolver that presents no client cert (the `tls_identity: None`
/// Client cert resolver that presents no client cert (the `local_identity: None`
/// or `SelfSigned` path). The server gets nothing to fingerprint — the
/// `PeerEntry` fingerprint → `peer_id` resolution path is not activated for
/// this connection.
@@ -490,7 +496,7 @@ mod tests {
fn build_quinn_client_config_with_raw_key_identity_builds_without_error() {
let sk = Ed25519SecretKey::generate();
let credentials = ConnectionCredentials::new()
.with_tls_identity(TlsIdentity::RawKey(sk))
.with_local_identity(TlsIdentity::RawKey(sk))
.with_remote_identity(RemoteIdentity {
fingerprint: "ed25519:deadbeef".to_string(),
});
@@ -504,7 +510,7 @@ mod tests {
#[test]
fn build_quinn_client_config_with_no_remote_identity_builds_without_error() {
let sk = Ed25519SecretKey::generate();
let credentials = ConnectionCredentials::new().with_tls_identity(TlsIdentity::RawKey(sk));
let credentials = ConnectionCredentials::new().with_local_identity(TlsIdentity::RawKey(sk));
let config = TlsClientConfig::new(&credentials, b"alknet/call")
.expect("TlsClientConfig::new must build for CA-verification path");
let quinn_config = config.for_quinn().expect("for_quinn must convert");
+9 -6
View File
@@ -21,7 +21,9 @@ use std::sync::Arc;
use alknet_core::auth::Identity;
use alknet_tty::adapter::drive_session;
use alknet_tty::backend::TtyBackend;
use alknet_tty::wire::{ChunkReader, STREAM_CONTROL, STREAM_STDERR, STREAM_STDOUT};
use alknet_tty::wire::{
ChunkReader, STREAM_CTRL_IN, STREAM_CTRL_OUT, STREAM_STDERR, STREAM_STDOUT,
};
use bytes::Bytes;
use tokio::io::duplex;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -70,10 +72,11 @@ impl ClientSide {
self.write.flush().await.unwrap();
}
/// Write a control chunk (stream_type 3) carrying a serialized
/// `ControlMessage` JSON payload.
/// Write a client→server control chunk (`STREAM_CTRL_IN`, stream_type
/// 3) carrying a serialized `ControlMessage` JSON payload (`Resize`,
/// `Signal`, or `Eof`).
pub async fn write_control(&mut self, json: &[u8]) {
self.write_chunk(STREAM_CONTROL, json).await;
self.write_chunk(STREAM_CTRL_IN, json).await;
}
/// Read one raw chunk from the server. Returns the `stream_type`
@@ -145,7 +148,7 @@ impl ClientSide {
stderr.extend_from_slice(&bytes);
}
}
STREAM_CONTROL => {
STREAM_CTRL_OUT => {
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
if v["type"] == "exit" {
return Some((stdout, stderr, v["code"].as_i64().unwrap() as i32));
@@ -177,7 +180,7 @@ impl ClientSide {
stderr.extend_from_slice(&bytes);
}
}
STREAM_CONTROL => {
STREAM_CTRL_OUT => {
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
if v["type"] == "exit" {
return Some((stdout, stderr, v["code"].as_i64().unwrap() as i32));
+1 -1
View File
@@ -199,7 +199,7 @@ async fn pipe_echo_emits_stdout_chunk_then_sentinel() {
if st == STREAM_STDOUT && !bytes.is_empty() {
saw_nonempty_stdout = true;
}
if st == alknet_tty::wire::STREAM_CONTROL {
if st == alknet_tty::wire::STREAM_CTRL_OUT {
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
if v["type"] == "exit" {
break;
+2 -2
View File
@@ -17,7 +17,7 @@ mod common;
use std::sync::Arc;
use std::time::Duration;
use alknet_tty::wire::{STREAM_CONTROL, STREAM_STDIN};
use alknet_tty::wire::{STREAM_CTRL_OUT, STREAM_STDIN};
use alknet_tty_local::LocalTtyBackend;
use common::{negotiate_pty_json, spawn_session};
@@ -245,7 +245,7 @@ async fn pty_exit_chunk_is_last() {
if saw_exit {
panic!("chunk arrived after exit: stream_type={st}, bytes={bytes:?} (ADR-055)");
}
if st == STREAM_CONTROL {
if st == STREAM_CTRL_OUT {
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
if v["type"] == "exit" {
assert_eq!(v["code"], 0);
+161 -30
View File
@@ -22,19 +22,34 @@
//! (stream_type 2) when `TtyHandle.stderr` is `Some`. On backend stdout
//! EOF, emit a zero-length stdout sentinel.
//! - **B. client → backend**: stdin chunks (stream_type 0) →
//! `TtyHandle.stdin`; control chunks (stream_type 3) →
//! `ControlMessage` dispatch (`Resize`, `Signal`, `Eof`; `Exit` is
//! server→client only and ignored). Zero-length stdin chunk or
//! read-half close → EOF to backend stdin.
//! `TtyHandle.stdin`; client→server control chunks (stream_type 3,
//! `STREAM_CTRL_IN`) → `ControlMessage` dispatch (`Resize`, `Signal`,
//! `Eof`). `STREAM_CTRL_OUT` (stream_type 4) from the client is a
//! protocol violation (it's the server→client half) and is ignored;
//! `Exit` arriving on `STREAM_CTRL_IN` is likewise a protocol
//! violation and ignored. Zero-length stdin chunk or read-half close
//! → EOF to backend stdin.
//! - **C. exit → exit chunk**: await `TtyHandle.exit_code`; on resolve,
//! enqueue `{"type":"exit","code":N}` as a control chunk (stream_type
//! 3). On `TtyError` → `{"type":"exit","code":-1}`.
//! enqueue `{"type":"exit","code":N}` as a server→client control
//! chunk (stream_type 4, `STREAM_CTRL_OUT`). On `TtyError` →
//! `{"type":"exit","code":-1}`.
//!
//! The adapter enforces the **exit-chunk-is-last** invariant (ADR-055):
//! it waits for BOTH the stdout/stderr pumps to complete AND `exit_code`
//! to resolve before enqueueing the exit chunk. A drainer task writes
//! chunks to the client in arrival order; the exit chunk is last.
//!
//! # Bidirectional control channel (Phase 7)
//!
//! The control channel is split into two halves so it is genuinely
//! bidirectional on the wire: `STREAM_CTRL_IN = 3` carries client→server
//! control (`Resize`, `Signal`, `Eof`); `STREAM_CTRL_OUT = 4` carries
//! server→client control (`Exit`). The previous single `STREAM_CONTROL =
//! 3` was documented as "bidirectional" but the adapter ignored `Exit`
//! from the client because the two directions were indistinguishable on
//! the same stream_type. The split makes the bidirectionality explicit
//! — see `docs/research/alknet-crate-extraction/findings.md` Phase 7.
//!
//! # Cancel cleanup (ADR-056)
//!
//! On connection drop or stream reset, the pump tasks are dropped, which
@@ -63,7 +78,7 @@ use crate::control::ControlMessage;
use crate::negotiation::{
error_response_bytes, NegotiateRequest, NegotiationError, NegotiationReader, NegotiationWriter,
};
use crate::wire::{Chunk, ChunkReader, ChunkWriter, RawError, STREAM_CONTROL, STREAM_STDIN};
use crate::wire::{Chunk, ChunkReader, ChunkWriter, RawError, STREAM_CTRL_IN, STREAM_STDIN};
/// The scope required to open a `alknet/tty` session (ADR-050). A two-way-door
/// choice (reversible: a deployment-configured scope, not a wire-format
@@ -118,8 +133,8 @@ impl ProtocolHandler for TtyAdapter {
let _ = connection.set_identity(identity);
}
loop {
let (send, recv) = match connection.accept_bi().await {
Ok(pair) => pair,
let stream = match connection.accept_bi().await {
Ok(stream) => stream,
Err(StreamError::ConnectionClosed) => break,
Err(StreamError::StreamClosed) => break,
Err(e) => return Err(HandlerError::from(e)),
@@ -128,7 +143,14 @@ impl ProtocolHandler for TtyAdapter {
let ownership = self.ownership.clone();
let identity = auth.identity.clone();
tokio::spawn(async move {
let _ = drive_session(send, recv, backends, ownership, identity).await;
// `stream` is a `BiStream` (ADR-092) — `AsyncRead + AsyncWrite
// + Send + Unpin`. Split into halves for `drive_session`
// (which takes separate `AsyncWrite` + `AsyncRead` args). The
// split is the stdlib idiom for `TcpStream`-style duplex
// streams; no per-handler wrapper.
let (client_read, client_write) = tokio::io::split(stream);
let _ =
drive_session(client_write, client_read, backends, ownership, identity).await;
});
}
Ok(())
@@ -164,10 +186,10 @@ async fn send_negotiation_error<W: AsyncWrite + Unpin>(
/// Drive a `alknet/tty` session end-to-end over a bidi stream.
///
/// `client_send` / `client_recv` are the two halves of the bidi stream (QUIC
/// `SendStream` / `RecvStream`). Returns when the session is complete (exit
/// chunk sent, stream closed) or when the stream is reset (cancel-cleanup
/// path — no exit chunk sent).
/// `client_send` / `client_recv` are the two halves of the bidi stream
/// (split from the `BiStream` yielded by `accept_bi` via `tokio::io::split`).
/// Returns when the session is complete (exit chunk sent, stream closed) or
/// when the stream is reset (cancel-cleanup path — no exit chunk sent).
///
/// This is the per-stream session driver — the counterpart to the POC's
/// `session::drive_session` (`/workspace/alknet-tty-poc/src/session.rs`),
@@ -371,7 +393,7 @@ async fn send_exit_chunk(writer_tx: &mpsc::Sender<Chunk>, code: i32) {
let exit_msg = ControlMessage::Exit { code };
match exit_msg.to_json() {
Ok(json) => {
let chunk = Chunk::control(json);
let chunk = Chunk::ctrl_out(json);
if writer_tx.send(chunk).await.is_err() {
debug!("tty: writer channel closed before exit chunk");
}
@@ -416,9 +438,25 @@ async fn pump_stderr(
debug!("tty: stderr pump done");
}
/// Pump client chunks → backend: stdin chunks → `TtyHandle.stdin`, control
/// chunks → `ControlMessage` dispatch. On client read-half close or a
/// zero-length stdin chunk, signal EOF to the backend's stdin.
/// Pump client chunks → backend: stdin chunks → `TtyHandle.stdin`,
/// client→server control chunks (`STREAM_CTRL_IN`, stream_type 3) →
/// `ControlMessage` dispatch. On client read-half close or a zero-length
/// stdin chunk, signal EOF to the backend's stdin.
///
/// # Direction enforcement (Phase 7)
///
/// The control channel is split into two halves. This pump reads from
/// the client, so it dispatches only `STREAM_CTRL_IN` (client→server):
///
/// - `Resize` / `Signal` / `Eof` → forward to the backend's control
/// handle (`TtyControlHandle::resize` / `signal` / `stdin.shutdown`).
/// - `Exit` arriving on `STREAM_CTRL_IN` is a protocol violation
/// (`Exit` is server→client only, belongs on `STREAM_CTRL_OUT`); the
/// adapter ignores it. (The previous single `STREAM_CONTROL = 3`
/// couldn't distinguish the two directions, so `Exit` from the client
/// was always ignored — the split makes the rejection explicit.)
/// - `STREAM_CTRL_OUT` (stream_type 4) from the client is a protocol
/// violation (it's the server→client half); the adapter ignores it.
async fn pump_client_to_backend<R>(
client_read: R,
mut stdin: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
@@ -439,7 +477,7 @@ async fn pump_client_to_backend<R>(
break;
}
}
STREAM_CONTROL => match ControlMessage::from_slice(&chunk.bytes) {
STREAM_CTRL_IN => match ControlMessage::from_slice(&chunk.bytes) {
Ok(ControlMessage::Resize {
cols,
rows,
@@ -460,12 +498,21 @@ async fn pump_client_to_backend<R>(
debug!("tty: client stdin EOF (eof control)");
}
Ok(ControlMessage::Exit { .. }) => {
debug!("tty: ignoring Exit control from client (server→client only)");
debug!(
"tty: ignoring Exit control on STREAM_CTRL_IN \
(server→client only; belongs on STREAM_CTRL_OUT)"
);
}
Err(e) => {
debug!("tty: ignoring unknown control type: {e}");
}
},
crate::wire::STREAM_CTRL_OUT => {
debug!(
"tty: ignoring STREAM_CTRL_OUT (stream_type 4) from client \
(server→client half; client should not write on it)"
);
}
other => {
debug!("tty: ignoring stream_type {other} from client");
}
@@ -861,7 +908,7 @@ mod tests {
assert!(bytes.is_empty());
let (st, bytes) = client.read_chunk().await;
assert_eq!(st, crate::wire::STREAM_CONTROL);
assert_eq!(st, crate::wire::STREAM_CTRL_OUT);
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["type"], "exit");
assert_eq!(v["code"], 0);
@@ -892,7 +939,7 @@ mod tests {
loop {
let (st, bytes) = client.read_chunk().await;
if st == crate::wire::STREAM_CONTROL {
if st == crate::wire::STREAM_CTRL_OUT {
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
if v["type"] == "exit" {
assert_eq!(v["code"], 7);
@@ -952,13 +999,13 @@ mod tests {
client
.write_chunk(
crate::wire::STREAM_CONTROL,
crate::wire::STREAM_CTRL_IN,
br#"{"type":"resize","cols":100,"rows":50}"#,
)
.await;
client
.write_chunk(
crate::wire::STREAM_CONTROL,
crate::wire::STREAM_CTRL_IN,
br#"{"type":"signal","name":"INT"}"#,
)
.await;
@@ -996,7 +1043,7 @@ mod tests {
client.write_negotiation(TEST_NEG).await;
client
.write_chunk(crate::wire::STREAM_CONTROL, br#"{"type":"unknown"}"#)
.write_chunk(crate::wire::STREAM_CTRL_IN, br#"{"type":"unknown"}"#)
.await;
let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx");
@@ -1018,6 +1065,9 @@ mod tests {
#[tokio::test]
async fn exit_control_from_client_ignored() {
// `Exit` is server→client only (belongs on `STREAM_CTRL_OUT`).
// Sending it on `STREAM_CTRL_IN` (client→server) is a protocol
// violation; the adapter ignores it and keeps pumping stdout.
let (backend, _control, _cancel) = TestBackend::builder().build();
let backends = make_backends(backend.clone());
let (mut client, server) = make_client_and_server();
@@ -1030,7 +1080,7 @@ mod tests {
client.write_negotiation(TEST_NEG).await;
client
.write_chunk(crate::wire::STREAM_CONTROL, br#"{"type":"exit","code":99}"#)
.write_chunk(crate::wire::STREAM_CTRL_IN, br#"{"type":"exit","code":99}"#)
.await;
let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx");
@@ -1050,6 +1100,87 @@ mod tests {
let _ = session.await;
}
#[tokio::test]
async fn ctrl_out_from_client_ignored() {
// `STREAM_CTRL_OUT` (stream_type 4) is the server→client half.
// The client writing on it is a protocol violation; the adapter
// ignores the chunk and keeps pumping stdout (Phase 7).
let (backend, _control, _cancel) = TestBackend::builder().build();
let backends = make_backends(backend.clone());
let (mut client, server) = make_client_and_server();
let identity = identity_with_scope(TTY_OPEN_SCOPE);
let session = tokio::spawn(async move {
drive_session_server(server, backends, None, identity).await;
});
client.write_negotiation(TEST_NEG).await;
// Bogus: a client writing on the server→client control half.
client
.write_chunk(
crate::wire::STREAM_CTRL_OUT,
br#"{"type":"exit","code":99}"#,
)
.await;
let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx");
let _ = backend.take_stderr_tx().await;
stdout_tx
.send(Bytes::from_static(b"after-bogus-ctrl-out"))
.await
.unwrap();
drop(stdout_tx);
let exit_tx = backend.take_exit_tx().await.expect("exit tx");
exit_tx.send(Ok(0)).unwrap();
let (st, bytes) = client.read_chunk().await;
assert_eq!(st, STREAM_STDOUT);
assert_eq!(bytes.as_ref(), b"after-bogus-ctrl-out");
let _ = session.await;
}
#[tokio::test]
async fn exit_chunk_arrives_on_ctrl_out_not_ctrl_in() {
// Verifies the adapter emits `Exit` on `STREAM_CTRL_OUT` (4), not
// `STREAM_CTRL_IN` (3) — the Phase 7 bidirectionality fix. A client
// distinguishing the two halves can route exit vs. control
// without parsing the JSON tag first.
let (backend, _control, _cancel) = TestBackend::builder().build();
let backends = make_backends(backend.clone());
let (mut client, server) = make_client_and_server();
let identity = identity_with_scope(TTY_OPEN_SCOPE);
let session = tokio::spawn(async move {
drive_session_server(server, backends, None, identity).await;
});
client.write_negotiation(TEST_NEG).await;
let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx");
let _ = backend.take_stderr_tx().await;
drop(stdout_tx);
let exit_tx = backend.take_exit_tx().await.expect("exit tx");
exit_tx.send(Ok(42)).unwrap();
let (st, bytes) = client.read_chunk().await;
assert_eq!(st, STREAM_STDOUT);
assert!(bytes.is_empty());
let (st, bytes) = client.read_chunk().await;
assert_eq!(
st,
crate::wire::STREAM_CTRL_OUT,
"exit chunk must arrive on STREAM_CTRL_OUT (4), not STREAM_CTRL_IN (3)"
);
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["type"], "exit");
assert_eq!(v["code"], 42);
let _ = session.await;
}
#[tokio::test]
async fn unknown_backend_error() {
let (backend, _control, _cancel) = TestBackend::builder().build();
@@ -1177,7 +1308,7 @@ mod tests {
loop {
let (st, bytes) = client.read_chunk().await;
if st == crate::wire::STREAM_CONTROL {
if st == crate::wire::STREAM_CTRL_OUT {
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["type"], "exit");
assert_eq!(v["code"], -1);
@@ -1287,7 +1418,7 @@ mod tests {
loop {
let (st, bytes) = client.read_chunk().await;
if st == crate::wire::STREAM_CONTROL {
if st == crate::wire::STREAM_CTRL_OUT {
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["type"], "exit");
assert_eq!(v["code"], 0);
@@ -1339,7 +1470,7 @@ mod tests {
assert_eq!(bytes.as_ref(), b"err");
saw_stderr = true;
}
crate::wire::STREAM_CONTROL => {
crate::wire::STREAM_CTRL_OUT => {
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["type"], "exit");
saw_exit = true;
@@ -1372,7 +1503,7 @@ mod tests {
client.write_chunk(STREAM_STDIN, b"first").await;
client
.write_chunk(crate::wire::STREAM_CONTROL, br#"{"type":"eof"}"#)
.write_chunk(crate::wire::STREAM_CTRL_IN, br#"{"type":"eof"}"#)
.await;
let mut received = Vec::new();
+41 -20
View File
@@ -1,4 +1,14 @@
//! Control messages carried in `stream_type 3` chunks (ADR-052).
//! Control messages carried in `stream_type 3` (`ctrl_in`) and
//! `stream_type 4` (`ctrl_out`) chunks (ADR-052, amended Phase 7).
//!
//! The control channel is split into two halves so it is genuinely
//! bidirectional on the wire: `STREAM_CTRL_IN = 3` carries client→server
//! control (`Resize`, `Signal`, `Eof`); `STREAM_CTRL_OUT = 4` carries
//! server→client control (`Exit`). The previous single
//! `STREAM_CONTROL = 3` was documented as "bidirectional" but the adapter
//! ignored `Exit` from the client because it had no way to distinguish
//! the two directions on the same stream_type — see
//! `docs/research/alknet-crate-extraction/findings.md` Phase 7.
//!
//! Control chunks carry a JSON payload tagged by `type`. The schema is the
//! POC's `ControlMessage` (`/workspace/alknet-tty-poc/src/control.rs`):
@@ -23,20 +33,30 @@
use serde::{Deserialize, Serialize};
/// A control message riding on `stream_type 3`.
/// A control message riding on `STREAM_CTRL_IN` (stream_type 3,
/// client→server) or `STREAM_CTRL_OUT` (stream_type 4, server→client).
///
/// Direction and mapping (per `tty-wire.md` §"Control Channel"):
///
/// | direction | variant | maps to |
/// |----------------|----------|----------------------------------------------------|
/// | client→server | `Resize` | SSH `window-change`, docker exec resize, `ioctl` |
/// | client→server | `Signal` | SSH `signal`, docker exec signal, `kill(-pgid, n)` |
/// | client→server | `Eof` | SSH channel EOF, docker stdin close, `ChildStdin` |
/// | server→client | `Exit` | the completion signal (ADR-055) |
/// | direction | stream_type | variant | maps to |
/// |----------------|-----------------|----------|----------------------------------------------------|
/// | client→server | `STREAM_CTRL_IN` (3) | `Resize` | SSH `window-change`, docker exec resize, `ioctl` |
/// | client→server | `STREAM_CTRL_IN` (3) | `Signal` | SSH `signal`, docker exec signal, `kill(-pgid, n)` |
/// | client→server | `STREAM_CTRL_IN` (3) | `Eof` | SSH channel EOF, docker stdin close, `ChildStdin` |
/// | server→client | `STREAM_CTRL_OUT` (4) | `Exit` | the completion signal (ADR-055) |
///
/// The direction is enforced by the adapter, not by this enum: a `Resize`
/// arriving on `STREAM_CTRL_OUT` is a protocol violation (the adapter
/// ignores it), and an `Exit` arriving on `STREAM_CTRL_IN` is likewise a
/// protocol violation (the adapter ignores it). The split is what makes
/// the control channel genuinely bidirectional — the previous single
/// `STREAM_CONTROL = 3` was documented as "bidirectional" but the
/// adapter had to ignore `Exit` from the client because the two
/// directions were indistinguishable on the same stream_type.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ControlMessage {
/// Terminal window resize (client→server).
/// Terminal window resize (client→server, `STREAM_CTRL_IN`).
///
/// `pixel_width`/`pixel_height` default to 0 (most terminals don't
/// report pixel dimensions; SSH's `pty_request` carries them for
@@ -49,23 +69,24 @@ pub enum ControlMessage {
#[serde(default)]
pixel_height: u16,
},
/// Forward a signal to the child process group (client→server).
/// Forward a signal to the child process group (client→server,
/// `STREAM_CTRL_IN`).
///
/// `name` is an uppercase string from the supported set (see
/// [`signal_from_name`]). Unknown names fall back to the backend's
/// default kill in the adapter (tty-local.md REQ-TTY-02).
Signal { name: String },
/// Client stdin is done (client→server). The server closes the
/// backend's stdin (`ChildStdin::drop` / PTY writer close) but keeps
/// pumping stdout + the exit chunk. See `tty-wire.md` §"Stdin
/// Closure".
/// Client stdin is done (client→server, `STREAM_CTRL_IN`). The
/// server closes the backend's stdin (`ChildStdin::drop` / PTY writer
/// close) but keeps pumping stdout + the exit chunk. See
/// `tty-wire.md` §"Stdin Closure".
Eof,
/// Process exit code (server→client). The exit chunk is the last
/// control chunk before stream close (ADR-055). `code` is `i32`
/// matching `std::process::ExitStatus::code()`; negative values are
/// signal-terminated (e.g., `-9` for SIGKILL on Unix). `-1` is the
/// adapter's best-effort "backend could not determine the exit code"
/// sentinel (ADR-055 §4).
/// Process exit code (server→client, `STREAM_CTRL_OUT`). The exit
/// chunk is the last control chunk before stream close (ADR-055).
/// `code` is `i32` matching `std::process::ExitStatus::code()`;
/// negative values are signal-terminated (e.g., `-9` for SIGKILL on
/// Unix). `-1` is the adapter's best-effort "backend could not
/// determine the exit code" sentinel (ADR-055 §4).
Exit { code: i32 },
}
+4 -2
View File
@@ -21,8 +21,10 @@
//! - An error frame's 4-byte big-endian length prefix starts with `0x00`
//! because error frames MUST be under 16 MiB ([`MAX_CHUNK_LEN`]) so the
//! high byte is zero (a wire-format invariant, not an assumption).
//! - A raw chunk's first byte is a `stream_type` in `{1, 2, 3}` —
//! `0` (stdin from server) is invalid, so `0x00` is unambiguous.
//! - A raw chunk's first byte is a `stream_type`. The server never sends
//! `0` (stdin — client→server only) or `3` (`STREAM_CTRL_IN` —
//! client→server only), so the server-sent set is `{1, 2, 4}`
//! (stdout, stderr, `STREAM_CTRL_OUT`); `0x00` is unambiguous.
//!
//! See ADR-052 §5 and `tty-wire.md` §"Constraints".
+84 -27
View File
@@ -7,10 +7,20 @@
//! ```
//!
//! `stream_type`:
//! - 0 = stdin (client→server, raw bytes)
//! - 1 = stdout (server→client, raw bytes)
//! - 2 = stderr (server→client, raw bytes)
//! - 3 = control (bidirectional, JSON control message — see [`crate::control`])
//! - 0 = stdin (client→server, raw bytes)
//! - 1 = stdout (server→client, raw bytes)
//! - 2 = stderr (server→client, raw bytes)
//! - 3 = ctrl_in (client→server, JSON control message — see [`crate::control`])
//! - 4 = ctrl_out (server→client, JSON control message — see [`crate::control`])
//!
//! The control channel is split into two halves so it is genuinely
//! bidirectional on the wire: `STREAM_CTRL_IN = 3` carries client→server
//! control (resize, signal, eof); `STREAM_CTRL_OUT = 4` carries
//! server→client control (exit). The previous single `STREAM_CONTROL = 3`
//! was documented as "bidirectional" but the adapter ignored `Exit` from
//! the client because it had no way to distinguish the two directions on
//! the same stream_type — see `docs/research/alknet-crate-extraction/
//! findings.md` Phase 7.
//!
//! Zero-length data chunks are sentinels: a zero-length stdin chunk is EOF
//! from the client; a zero-length stdout chunk is "drained" from the
@@ -29,8 +39,11 @@ pub const STREAM_STDIN: u8 = 0;
pub const STREAM_STDOUT: u8 = 1;
/// stderr channel (server→client, raw bytes).
pub const STREAM_STDERR: u8 = 2;
/// control channel (bidirectional, JSON control message).
pub const STREAM_CONTROL: u8 = 3;
/// Control channel, client→server half (JSON control message —
/// `Resize`, `Signal`, `Eof`).
pub const STREAM_CTRL_IN: u8 = 3;
/// Control channel, server→client half (JSON control message — `Exit`).
pub const STREAM_CTRL_OUT: u8 = 4;
/// Chunk header length in bytes: 1 byte `stream_type` + 4 bytes `length`.
pub const CHUNK_HEADER_LEN: usize = 5;
@@ -54,7 +67,7 @@ pub enum RawError {
/// The peer closed the stream cleanly (unexpected EOF on header or payload).
#[error("connection closed")]
ConnectionClosed,
/// The chunk header's `stream_type` byte was > 3.
/// The chunk header's `stream_type` byte was > 4.
#[error("invalid chunk header: stream type {0}")]
InvalidStreamType(u8),
/// The chunk payload length exceeded `MAX_CHUNK_LEN`.
@@ -66,11 +79,12 @@ pub enum RawError {
/// payload bytes.
///
/// Construct with [`Chunk::stdin`], [`Chunk::stdout`], [`Chunk::stderr`],
/// or [`Chunk::control`] for the four fixed channels.
/// [`Chunk::ctrl_in`], or [`Chunk::ctrl_out`] for the five fixed
/// channels.
#[derive(Debug, Clone)]
pub struct Chunk {
/// The channel: one of [`STREAM_STDIN`], [`STREAM_STDOUT`],
/// [`STREAM_STDERR`], [`STREAM_CONTROL`].
/// [`STREAM_STDERR`], [`STREAM_CTRL_IN`], [`STREAM_CTRL_OUT`].
pub stream_type: u8,
/// The payload bytes (raw for data channels, UTF-8 JSON for control).
pub bytes: bytes::Bytes,
@@ -101,10 +115,19 @@ impl Chunk {
}
}
/// A control chunk (stream_type 3).
pub fn control(bytes: bytes::Bytes) -> Self {
/// A client→server control chunk (stream_type 3) — `Resize`, `Signal`,
/// or `Eof`.
pub fn ctrl_in(bytes: bytes::Bytes) -> Self {
Self {
stream_type: STREAM_CONTROL,
stream_type: STREAM_CTRL_IN,
bytes,
}
}
/// A server→client control chunk (stream_type 4) — `Exit`.
pub fn ctrl_out(bytes: bytes::Bytes) -> Self {
Self {
stream_type: STREAM_CTRL_OUT,
bytes,
}
}
@@ -113,7 +136,7 @@ impl Chunk {
/// Reads raw chunks from an [`AsyncRead`] transport.
///
/// [`ChunkReader::read_chunk`] reads the 5-byte header, validates the
/// `stream_type` (≤ 3, else [`RawError::InvalidStreamType`]) and the
/// `stream_type` (≤ 4, else [`RawError::InvalidStreamType`]) and the
/// payload length (≤ [`MAX_CHUNK_LEN`], else [`RawError::ChunkTooLarge`]),
/// then reads the payload. On a clean `UnexpectedEof` reading either the
/// header or the payload, it returns [`RawError::ConnectionClosed`] — the
@@ -148,7 +171,7 @@ impl<R: AsyncRead + Unpin> ChunkReader<R> {
}
let stream_type = self.header[0];
if stream_type > 3 {
if stream_type > 4 {
return Err(RawError::InvalidStreamType(stream_type));
}
@@ -183,9 +206,10 @@ impl<R: AsyncRead + Unpin> ChunkReader<R> {
/// Writes raw chunks to an [`AsyncWrite`] transport.
///
/// [`ChunkWriter::write_chunk`] writes the 5-byte header then the payload
/// (if non-empty), then flushes. [`ChunkWriter::write_stdin`] and
/// [`ChunkWriter::write_control_json`] are convenience helpers for the
/// two most common write paths.
/// (if non-empty), then flushes. [`ChunkWriter::write_stdin`],
/// [`ChunkWriter::write_ctrl_in_json`], and
/// [`ChunkWriter::write_ctrl_out_json`] are convenience helpers for the
/// most common write paths.
pub struct ChunkWriter<W: AsyncWrite + Unpin> {
writer: W,
}
@@ -229,10 +253,24 @@ impl<W: AsyncWrite + Unpin> ChunkWriter<W> {
Ok(())
}
/// Write a control chunk (stream_type 3) carrying a JSON payload.
pub async fn write_control_json(&mut self, json: &[u8]) -> Result<(), RawError> {
/// Write a client→server control chunk (stream_type 3) carrying a JSON
/// payload (`Resize`, `Signal`, or `Eof`).
pub async fn write_ctrl_in_json(&mut self, json: &[u8]) -> Result<(), RawError> {
let mut header = [0u8; CHUNK_HEADER_LEN];
header[0] = STREAM_CONTROL;
header[0] = STREAM_CTRL_IN;
let len = json.len() as u32;
header[1..].copy_from_slice(&len.to_be_bytes());
self.writer.write_all(&header).await?;
self.writer.write_all(json).await?;
self.writer.flush().await?;
Ok(())
}
/// Write a server→client control chunk (stream_type 4) carrying a JSON
/// payload (`Exit`).
pub async fn write_ctrl_out_json(&mut self, json: &[u8]) -> Result<(), RawError> {
let mut header = [0u8; CHUNK_HEADER_LEN];
header[0] = STREAM_CTRL_OUT;
let len = json.len() as u32;
header[1..].copy_from_slice(&len.to_be_bytes());
self.writer.write_all(&header).await?;
@@ -281,8 +319,13 @@ mod tests {
}
#[tokio::test]
async fn round_trip_control() {
round_trip(STREAM_CONTROL, br#"{"type":"eof"}"#).await;
async fn round_trip_ctrl_in() {
round_trip(STREAM_CTRL_IN, br#"{"type":"eof"}"#).await;
}
#[tokio::test]
async fn round_trip_ctrl_out() {
round_trip(STREAM_CTRL_OUT, br#"{"type":"exit","code":0}"#).await;
}
#[tokio::test]
@@ -303,27 +346,41 @@ mod tests {
}
#[tokio::test]
async fn round_trip_write_control_json_helper() {
async fn round_trip_write_ctrl_in_json_helper() {
let (mut a, mut b) = duplex(8 * 1024);
let mut writer = ChunkWriter::new(&mut a);
let mut reader = ChunkReader::new(&mut b);
let json = br#"{"type":"resize","cols":80,"rows":24}"#;
writer.write_control_json(json).await.unwrap();
writer.write_ctrl_in_json(json).await.unwrap();
let read = reader.read_chunk().await.unwrap();
assert_eq!(read.stream_type, STREAM_CONTROL);
assert_eq!(read.stream_type, STREAM_CTRL_IN);
assert_eq!(read.bytes.as_ref(), json);
}
#[tokio::test]
async fn round_trip_write_ctrl_out_json_helper() {
let (mut a, mut b) = duplex(8 * 1024);
let mut writer = ChunkWriter::new(&mut a);
let mut reader = ChunkReader::new(&mut b);
let json = br#"{"type":"exit","code":0}"#;
writer.write_ctrl_out_json(json).await.unwrap();
let read = reader.read_chunk().await.unwrap();
assert_eq!(read.stream_type, STREAM_CTRL_OUT);
assert_eq!(read.bytes.as_ref(), json);
}
#[tokio::test]
async fn invalid_stream_type() {
let (mut a, mut b) = duplex(8 * 1024);
a.write_all(&[4u8, 0, 0, 0, 0]).await.unwrap();
// 5 is one past the highest valid stream_type (4 = STREAM_CTRL_OUT).
a.write_all(&[5u8, 0, 0, 0, 0]).await.unwrap();
a.flush().await.unwrap();
let mut reader = ChunkReader::new(&mut b);
let err = reader.read_chunk().await.unwrap_err();
assert!(matches!(err, RawError::InvalidStreamType(4)));
assert!(matches!(err, RawError::InvalidStreamType(5)));
}
#[tokio::test]
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "alknet-typedef"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Binary struct engine: takes a JSON Schema with TypeDef:* custom keywords and produces an offset map, read/write functions, and validation"
repository.workspace = true
[lib]
name = "alknet_typedef"
[features]
default = []
[dependencies]
jsonschema = { version = "0.46", default-features = false }
serde_json = { version = "1", features = ["preserve_order"] }
+632
View File
@@ -0,0 +1,632 @@
//! Data access layer: primitive read/write functions for all 17 TypeDef
//! kinds with endianness support, bounds checking, and zero-copy access.
//!
//! These are the building blocks used by the layout types ([`crate::offset_map`],
//! [`crate::layout_builder`], [`crate::sequential_reader`]) and the
//! [`crate::engine::TypedefEngine`]. Each function operates on a raw byte
//! buffer at a caller-provided offset and returns a [`TypedefError::Access`]
//! carrying the field path on bounds or encoding failures.
//!
//! # Conventions
//!
//! - All multi-byte types respect the [`Endian`] parameter passed by the caller.
//! - Bounds checks ensure `buffer.len() >= offset + size`; failures produce
//! [`TypedefError::Access`] with a descriptive `reason`.
//! - Read functions for variable-length types return slices borrowing from
//! the input buffer — no allocation.
//! - No `unwrap()` / `expect()` on fallible operations.
use crate::error::TypedefError;
use crate::schema::Endian;
const U32_SIZE: usize = 4;
fn check_bounds(
buffer_len: usize,
start: usize,
end: usize,
field_path: &str,
) -> Result<(), TypedefError> {
if end < start || buffer_len < end {
return Err(TypedefError::Access {
field_path: field_path.to_string(),
reason: format!(
"buffer bounds check failed: need bytes [{start}..{end}), buffer has {buffer_len}"
),
});
}
Ok(())
}
fn access_err(field_path: &str, reason: impl Into<String>) -> TypedefError {
TypedefError::Access {
field_path: field_path.to_string(),
reason: reason.into(),
}
}
pub(crate) fn read_array<const N: usize>(
buffer: &[u8],
offset: usize,
field_path: &str,
) -> Result<[u8; N], TypedefError> {
let end = offset.checked_add(N).ok_or_else(|| {
access_err(
field_path,
format!("offset {offset} + size {N} overflows usize"),
)
})?;
check_bounds(buffer.len(), offset, end, field_path)?;
let slice = buffer.get(offset..end).ok_or_else(|| {
access_err(
field_path,
format!(
"slice [{offset}..{end}) unavailable in buffer of length {}",
buffer.len()
),
)
})?;
slice.try_into().map_err(|_| {
access_err(
field_path,
format!("internal: try_into failed for {N}-byte slice"),
)
})
}
pub(crate) fn write_array<const N: usize>(
buffer: &mut [u8],
offset: usize,
bytes: [u8; N],
field_path: &str,
) -> Result<(), TypedefError> {
let end = offset.checked_add(N).ok_or_else(|| {
access_err(
field_path,
format!("offset {offset} + size {N} overflows usize"),
)
})?;
check_bounds(buffer.len(), offset, end, field_path)?;
let dest = buffer.get_mut(offset..end).ok_or_else(|| {
access_err(
field_path,
format!("mutable slice [{offset}..{end}) unavailable"),
)
})?;
dest.copy_from_slice(&bytes);
Ok(())
}
fn u32_from(bytes: [u8; U32_SIZE], endian: Endian) -> u32 {
match endian {
Endian::Little => u32::from_le_bytes(bytes),
Endian::Big => u32::from_be_bytes(bytes),
}
}
fn u32_to(value: u32, endian: Endian) -> [u8; U32_SIZE] {
match endian {
Endian::Little => value.to_le_bytes(),
Endian::Big => value.to_be_bytes(),
}
}
// ---------------------------------------------------------------------------
// Fixed-size read functions
// ---------------------------------------------------------------------------
define_read_write_ne!(i8, read_i8, write_i8, 1, |bytes: [u8; 1]| bytes[0] as i8);
define_read_write_endian!(i16, read_i16, write_i16, 2);
define_read_write_endian!(i32, read_i32, write_i32, 4);
define_read_write_endian!(i64, read_i64, write_i64, 8);
define_read_write_ne!(u8, read_u8, write_u8, 1, |bytes: [u8; 1]| bytes[0]);
define_read_write_endian!(u16, read_u16, write_u16, 2);
define_read_write_endian!(u32, read_u32, write_u32, 4);
define_read_write_endian!(u64, read_u64, write_u64, 8);
define_read_write_endian!(f32, read_f32, write_f32, 4);
define_read_write_endian!(f64, read_f64, write_f64, 8);
/// Read a `bool` at `offset` from `buffer`.
///
/// `0x00` decodes to `false`, `0x01` decodes to `true`. Any other byte value
/// produces [`TypedefError::Access`] with a reason of the form
/// `"invalid boolean byte 0x02 at offset {offset}"`.
pub fn read_bool(buffer: &[u8], offset: usize, field_path: &str) -> Result<bool, TypedefError> {
let bytes: [u8; 1] = read_array(buffer, offset, field_path)?;
match bytes[0] {
0x00 => Ok(false),
0x01 => Ok(true),
other => Err(access_err(
field_path,
format!("invalid boolean byte 0x{other:02X} at offset {offset}"),
)),
}
}
/// Write a `bool` `value` at `offset` into `buffer`.
///
/// `false` is encoded as `0x00`, `true` as `0x01`.
pub fn write_bool(
buffer: &mut [u8],
offset: usize,
value: bool,
field_path: &str,
) -> Result<(), TypedefError> {
write_array(
buffer,
offset,
[if value { 0x01 } else { 0x00 }],
field_path,
)
}
/// Read a `TEnum` index (`u32`) at `offset` from `buffer`, applying `endian`.
///
/// The caller maps the returned index to the schema's `"enum"` array entry.
pub fn read_enum(
buffer: &[u8],
offset: usize,
field_path: &str,
endian: Endian,
) -> Result<u32, TypedefError> {
read_u32(buffer, offset, field_path, endian)
}
/// Write a `TEnum` index (`u32`) `value` at `offset` into `buffer`, applying `endian`.
pub fn write_enum(
buffer: &mut [u8],
offset: usize,
value: u32,
field_path: &str,
endian: Endian,
) -> Result<(), TypedefError> {
write_u32(buffer, offset, value, field_path, endian)
}
// ---------------------------------------------------------------------------
// Variable-length read/write (inline length-prefixing)
// ---------------------------------------------------------------------------
/// Read a length-prefixed UTF-8 string borrowing from `buffer`.
///
/// Wire format: `[length: u32][UTF-8 bytes]`. The length prefix respects
/// `endian`. Returns a `&'a str` that borrows from the input buffer — no
/// allocation. Invalid UTF-8 produces [`TypedefError::Access`].
pub fn read_string<'a>(
buffer: &'a [u8],
offset: usize,
field_path: &str,
endian: Endian,
) -> Result<&'a str, TypedefError> {
let bytes = read_bytes(buffer, offset, field_path, endian)?;
std::str::from_utf8(bytes).map_err(|e| {
access_err(
field_path,
format!("invalid UTF-8 in string at offset {offset}: {e}"),
)
})
}
/// Read length-prefixed raw bytes borrowing from `buffer`.
///
/// Wire format: `[length: u32][raw bytes]`. The length prefix respects
/// `endian`. Returns a `&'a [u8]` slice that borrows from the input buffer.
pub fn read_bytes<'a>(
buffer: &'a [u8],
offset: usize,
field_path: &str,
endian: Endian,
) -> Result<&'a [u8], TypedefError> {
let len_bytes: [u8; U32_SIZE] = read_array(buffer, offset, field_path)?;
let len = u32_from(len_bytes, endian) as usize;
let data_start = offset.checked_add(U32_SIZE).ok_or_else(|| {
access_err(
field_path,
format!("offset {offset} + {U32_SIZE} overflows usize"),
)
})?;
let data_end = data_start.checked_add(len).ok_or_else(|| {
access_err(
field_path,
format!("data_start {data_start} + length {len} overflows usize"),
)
})?;
check_bounds(buffer.len(), data_start, data_end, field_path)?;
Ok(&buffer[data_start..data_end])
}
/// Write a length-prefixed UTF-8 string into `buffer` at `offset`.
///
/// Wire format: `[length: u32][UTF-8 bytes]`. The length prefix respects
/// `endian`. Returns the total number of bytes written
/// (`4 + value.len()`) so the caller can advance the cursor.
pub fn write_string(
buffer: &mut [u8],
offset: usize,
value: &str,
field_path: &str,
endian: Endian,
) -> Result<usize, TypedefError> {
write_bytes(buffer, offset, value.as_bytes(), field_path, endian)
}
/// Write length-prefixed raw bytes into `buffer` at `offset`.
///
/// Wire format: `[length: u32][raw bytes]`. The length prefix respects
/// `endian`. Returns the total number of bytes written (`4 + value.len()`).
pub fn write_bytes(
buffer: &mut [u8],
offset: usize,
value: &[u8],
field_path: &str,
endian: Endian,
) -> Result<usize, TypedefError> {
let data_len = value.len();
let total = U32_SIZE.checked_add(data_len).ok_or_else(|| {
access_err(
field_path,
format!("prefix {U32_SIZE} + data length {data_len} overflows usize"),
)
})?;
let end = offset.checked_add(total).ok_or_else(|| {
access_err(
field_path,
format!("offset {offset} + total {total} overflows usize"),
)
})?;
check_bounds(buffer.len(), offset, end, field_path)?;
write_array(buffer, offset, u32_to(data_len as u32, endian), field_path)?;
let data_start = offset + U32_SIZE;
let dest = buffer.get_mut(data_start..end).ok_or_else(|| {
access_err(
field_path,
format!("mutable data slice [{data_start}..{end}) unavailable"),
)
})?;
dest.copy_from_slice(value);
Ok(total)
}
// ---------------------------------------------------------------------------
// Variable-length read (offset indirection)
// ---------------------------------------------------------------------------
/// Read an offset-indirect string.
///
/// The 8-byte struct at `buffer[offset..offset+8]` is
/// `{ data_offset: u32, data_length: u32 }` (endian-aware). The actual UTF-8
/// bytes live in `data_region[data_offset..data_offset+data_length]`. Returns
/// a `&'a str` borrowing from `data_region`. Invalid UTF-8 produces
/// [`TypedefError::Access`].
pub fn read_string_indirect<'a>(
buffer: &'a [u8],
offset: usize,
data_region: &'a [u8],
field_path: &str,
endian: Endian,
) -> Result<&'a str, TypedefError> {
let bytes = read_bytes_indirect(buffer, offset, data_region, field_path, endian)?;
std::str::from_utf8(bytes).map_err(|e| {
access_err(
field_path,
format!("invalid UTF-8 in offset-indirect string: {e}"),
)
})
}
/// Read offset-indirect raw bytes.
///
/// The 8-byte struct at `buffer[offset..offset+8]` is
/// `{ data_offset: u32, data_length: u32 }` (endian-aware). Returns a
/// `&'a [u8]` slice of `data_region[data_offset..data_offset+data_length]`.
pub fn read_bytes_indirect<'a>(
buffer: &'a [u8],
offset: usize,
data_region: &'a [u8],
field_path: &str,
endian: Endian,
) -> Result<&'a [u8], TypedefError> {
let struct_end = offset
.checked_add(8)
.ok_or_else(|| access_err(field_path, format!("offset {offset} + 8 overflows usize")))?;
check_bounds(buffer.len(), offset, struct_end, field_path)?;
let off_bytes: [u8; U32_SIZE] = buffer[offset..offset + U32_SIZE]
.try_into()
.map_err(|_| access_err(field_path, "internal: try_into failed for data_offset"))?;
let len_bytes: [u8; U32_SIZE] = buffer[offset + U32_SIZE..offset + 8]
.try_into()
.map_err(|_| access_err(field_path, "internal: try_into failed for data_length"))?;
let data_offset = u32_from(off_bytes, endian) as usize;
let data_length = u32_from(len_bytes, endian) as usize;
let data_end = data_offset.checked_add(data_length).ok_or_else(|| {
access_err(
field_path,
format!("data_offset {data_offset} + data_length {data_length} overflows usize"),
)
})?;
check_bounds(data_region.len(), data_offset, data_end, field_path)?;
Ok(&data_region[data_offset..data_end])
}
#[cfg(test)]
mod tests {
use super::*;
const LE: Endian = Endian::Little;
const BE: Endian = Endian::Big;
#[test]
fn read_write_u8_round_trip() {
let mut buf = [0u8; 1];
write_u8(&mut buf, 0, 0xAB, "f").unwrap();
assert_eq!(read_u8(&buf, 0, "f").unwrap(), 0xAB);
}
#[test]
fn read_write_i8_round_trip() {
let mut buf = [0u8; 1];
write_i8(&mut buf, 0, -42, "f").unwrap();
assert_eq!(read_i8(&buf, 0, "f").unwrap(), -42);
}
#[test]
fn read_write_u16_endianness() {
let mut buf = [0u8; 2];
write_u16(&mut buf, 0, 0x1234, "f", LE).unwrap();
assert_eq!(buf, [0x34, 0x12]);
assert_eq!(read_u16(&buf, 0, "f", LE).unwrap(), 0x1234);
write_u16(&mut buf, 0, 0x1234, "f", BE).unwrap();
assert_eq!(buf, [0x12, 0x34]);
assert_eq!(read_u16(&buf, 0, "f", BE).unwrap(), 0x1234);
}
#[test]
fn read_write_i16_endianness() {
let mut buf = [0u8; 2];
write_i16(&mut buf, 0, -1, "f", LE).unwrap();
assert_eq!(buf, [0xFF, 0xFF]);
assert_eq!(read_i16(&buf, 0, "f", LE).unwrap(), -1);
}
#[test]
fn read_write_u32_endianness() {
let mut buf = [0u8; 4];
write_u32(&mut buf, 0, 0x01020304, "f", LE).unwrap();
assert_eq!(buf, [0x04, 0x03, 0x02, 0x01]);
assert_eq!(read_u32(&buf, 0, "f", LE).unwrap(), 0x01020304);
write_u32(&mut buf, 0, 0x01020304, "f", BE).unwrap();
assert_eq!(buf, [0x01, 0x02, 0x03, 0x04]);
assert_eq!(read_u32(&buf, 0, "f", BE).unwrap(), 0x01020304);
}
#[test]
fn read_write_i32_endianness() {
let mut buf = [0u8; 4];
write_i32(&mut buf, 0, i32::MIN, "f", BE).unwrap();
assert_eq!(read_i32(&buf, 0, "f", BE).unwrap(), i32::MIN);
}
#[test]
fn read_write_i64_endianness() {
let mut buf = [0u8; 8];
write_i64(&mut buf, 0, i64::MIN, "f", BE).unwrap();
assert_eq!(read_i64(&buf, 0, "f", BE).unwrap(), i64::MIN);
write_i64(&mut buf, 0, i64::MAX, "f", LE).unwrap();
assert_eq!(read_i64(&buf, 0, "f", LE).unwrap(), i64::MAX);
}
#[test]
fn read_write_u64_endianness() {
let mut buf = [0u8; 8];
write_u64(&mut buf, 0, 0x0102030405060708, "f", LE).unwrap();
assert_eq!(buf, [0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01]);
assert_eq!(read_u64(&buf, 0, "f", LE).unwrap(), 0x0102030405060708);
write_u64(&mut buf, 0, 0x0102030405060708, "f", BE).unwrap();
assert_eq!(buf, [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
assert_eq!(read_u64(&buf, 0, "f", BE).unwrap(), 0x0102030405060708);
}
#[test]
fn read_write_f32_round_trip() {
let mut buf = [0u8; 4];
let value: f32 = std::f32::consts::PI;
write_f32(&mut buf, 0, value, "f", LE).unwrap();
let read = read_f32(&buf, 0, "f", LE).unwrap();
assert!(
(read - value).abs() < 1e-6,
"le mismatch: {read} vs {value}"
);
write_f32(&mut buf, 0, value, "f", BE).unwrap();
let read = read_f32(&buf, 0, "f", BE).unwrap();
assert!(
(read - value).abs() < 1e-6,
"be mismatch: {read} vs {value}"
);
}
#[test]
fn read_write_f64_round_trip() {
let mut buf = [0u8; 8];
let value: f64 = std::f64::consts::PI;
write_f64(&mut buf, 0, value, "f", LE).unwrap();
assert_eq!(read_f64(&buf, 0, "f", LE).unwrap(), value);
write_f64(&mut buf, 0, value, "f", BE).unwrap();
assert_eq!(read_f64(&buf, 0, "f", BE).unwrap(), value);
}
#[test]
fn read_write_bool_round_trip() {
let mut buf = [0u8; 1];
write_bool(&mut buf, 0, false, "f").unwrap();
assert_eq!(buf[0], 0x00);
assert!(!read_bool(&buf, 0, "f").unwrap());
write_bool(&mut buf, 0, true, "f").unwrap();
assert_eq!(buf[0], 0x01);
assert!(read_bool(&buf, 0, "f").unwrap());
}
#[test]
fn read_bool_rejects_invalid_byte() {
let buf = [0x02u8];
let err = read_bool(&buf, 0, "f").unwrap_err();
match err {
TypedefError::Access { field_path, reason } => {
assert_eq!(field_path, "f");
assert!(reason.contains("0x02"), "reason: {reason}");
assert!(reason.contains("offset 0"), "reason: {reason}");
}
other => panic!("expected Access, got {other:?}"),
}
}
#[test]
fn read_write_enum_round_trip() {
let mut buf = [0u8; 4];
write_enum(&mut buf, 0, 7, "f", LE).unwrap();
assert_eq!(read_enum(&buf, 0, "f", LE).unwrap(), 7);
write_enum(&mut buf, 0, 7, "f", BE).unwrap();
assert_eq!(read_enum(&buf, 0, "f", BE).unwrap(), 7);
}
#[test]
fn bounds_failure_returns_access_error() {
let buf = [0u8; 2];
let err = read_u32(&buf, 0, "header.id", LE).unwrap_err();
match err {
TypedefError::Access { field_path, reason } => {
assert_eq!(field_path, "header.id");
assert!(reason.contains("bounds"), "reason: {reason}");
}
other => panic!("expected Access, got {other:?}"),
}
}
#[test]
fn write_bounds_failure_returns_access_error() {
let mut buf = [0u8; 2];
let err = write_u32(&mut buf, 0, 1, "header.id", LE).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }));
}
#[test]
fn read_string_round_trip_and_zero_copy() {
let mut buf = vec![0u8; 32];
let written = write_string(&mut buf, 0, "hello", "name", LE).unwrap();
assert_eq!(written, 4 + 5);
let s = read_string(&buf, 0, "name", LE).unwrap();
assert_eq!(s, "hello");
assert!(std::ptr::eq(s.as_ptr(), buf.as_ptr().wrapping_add(4)));
}
#[test]
fn read_string_be_length_prefix() {
let mut buf = vec![0u8; 16];
write_string(&mut buf, 0, "abc", "name", BE).unwrap();
assert_eq!(buf[0..4], [0x00, 0x00, 0x00, 0x03]);
assert_eq!(read_string(&buf, 0, "name", BE).unwrap(), "abc");
}
#[test]
fn read_bytes_round_trip_and_zero_copy() {
let mut buf = vec![0u8; 32];
let payload = [0xAA, 0xBB, 0xCC, 0xDD];
let written = write_bytes(&mut buf, 0, &payload, "data", LE).unwrap();
assert_eq!(written, 4 + 4);
let bytes = read_bytes(&buf, 0, "data", LE).unwrap();
assert_eq!(bytes, &payload[..]);
}
#[test]
fn read_string_invalid_utf8() {
let mut buf = vec![0u8; 16];
write_bytes(&mut buf, 0, &[0xFF, 0xFE, 0xFD], "name", LE).unwrap();
let err = read_string(&buf, 0, "name", LE).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }));
}
#[test]
fn read_string_bounds_failure_on_prefix() {
let buf = [0u8; 2];
let err = read_string(&buf, 0, "name", LE).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }));
}
#[test]
fn read_string_bounds_failure_on_data() {
let mut buf = vec![0u8; 6];
let _ = write_bytes(&mut buf, 0, &[0x00; 32], "name", LE);
let len_bytes = (100u32).to_le_bytes();
buf[0..4].copy_from_slice(&len_bytes);
let err = read_string(&buf, 0, "name", LE).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }));
}
#[test]
fn write_string_bounds_failure() {
let mut buf = vec![0u8; 4];
let err = write_string(&mut buf, 0, "hello", "name", LE).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }));
}
#[test]
fn read_string_indirect_round_trip() {
let data_region = b"the quick brown fox";
let mut index = [0u8; 8];
write_u32(&mut index, 0, 4, "idx.off", LE).unwrap();
write_u32(&mut index, 4, 11, "idx.len", LE).unwrap();
let s = read_string_indirect(&index, 0, data_region, "msg", LE).unwrap();
assert_eq!(s, "quick brown");
}
#[test]
fn read_bytes_indirect_round_trip() {
let data_region: &[u8] = b"HEADERbody-payloadTAIL";
let mut index = [0u8; 8];
write_u32(&mut index, 0, 6, "idx.off", BE).unwrap();
write_u32(&mut index, 4, 12, "idx.len", BE).unwrap();
let bytes = read_bytes_indirect(&index, 0, data_region, "blob", BE).unwrap();
assert_eq!(bytes, b"body-payload");
}
#[test]
fn read_bytes_indirect_bounds_failure_on_index() {
let buf = [0u8; 4];
let data_region = b"anything";
let err = read_bytes_indirect(&buf, 0, data_region, "blob", LE).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }));
}
#[test]
fn read_bytes_indirect_bounds_failure_on_data_region() {
let mut buf = [0u8; 8];
write_u32(&mut buf, 0, 100, "idx.off", LE).unwrap();
write_u32(&mut buf, 4, 10, "idx.len", LE).unwrap();
let data_region = b"too short";
let err = read_bytes_indirect(&buf, 0, data_region, "blob", LE).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }));
}
#[test]
fn read_at_nonzero_offset() {
let mut buf = vec![0u8; 16];
write_u32(&mut buf, 8, 0xDEADBEEF, "header.id", BE).unwrap();
assert_eq!(read_u32(&buf, 8, "header.id", BE).unwrap(), 0xDEADBEEF);
}
#[test]
fn write_bytes_zero_length() {
let mut buf = vec![0u8; 8];
let written = write_bytes(&mut buf, 0, &[], "data", LE).unwrap();
assert_eq!(written, 4);
assert_eq!(buf[0..4], [0, 0, 0, 0]);
let bytes = read_bytes(&buf, 0, "data", LE).unwrap();
assert!(bytes.is_empty());
}
}
+754
View File
@@ -0,0 +1,754 @@
//! `TypedefEngine` — the compiled form of a schema.
//!
//! Combines the layout engine (both packed and aligned modes) and the
//! jsonschema validator into a single struct. Built once at schema load
//! time via [`TypedefEngine::compile`]. Used for repeated read/write/
//! validate operations at access time.
//!
//! See [validation.md](../../docs/architecture/crates/typedef/validation.md)
//! §"The TypedefEngine struct" and
//! [overview.md](../../docs/architecture/crates/typedef/overview.md).
use crate::data_access;
use crate::error::TypedefError;
use crate::layout_builder::LayoutBuilder;
use crate::offset_map::OffsetMap;
use crate::schema::{self, get_typedef_kind_loose_enum, Endian, TypeDefKind};
use crate::sequential_reader::{FieldValue, SequentialReader};
use crate::validation;
use serde_json::Value;
use std::fmt;
/// The layout mode selected at engine construction time.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LayoutMode {
/// Packed sequential — for protocol wire formats (SFTP, channels, TTY).
Packed,
/// Aligned static — for mmap-friendly formats (metatensor, safetensors).
Aligned,
}
/// The layout strategy — packed sequential or aligned static.
///
/// Carries the layout-specific handles needed for read/write access in
/// the selected mode. The consumer chooses the mode at construction time
/// via [`TypedefEngine::compile`]; the engine then exposes only the
/// APIs that make sense for that mode.
#[derive(Debug)]
enum Layout {
/// Packed sequential layout. The write-side is [`LayoutBuilder`]; the
/// read-side is a fresh [`SequentialReader`] constructed on demand
/// (ADR-101 — the reader has mutable cursor state that the consumer
/// owns, so the engine is a factory, not a holder).
Packed {
builder: LayoutBuilder,
},
/// Aligned static layout. Field offsets are precomputed in an
/// [`OffsetMap`] for random access.
Aligned { offset_map: OffsetMap },
}
/// The compiled form of a typedef schema. Combines the layout engine
/// (both packed and aligned modes) and the jsonschema validator.
///
/// Built once at schema load time via [`TypedefEngine::compile`].
/// Used for repeated read/write/validate operations at access time.
///
/// The consumer selects the layout mode at construction time. The engine
/// then exposes mode-appropriate accessors: [`TypedefEngine::offset_map`]
/// for aligned mode, [`TypedefEngine::layout_builder`] and
/// [`TypedefEngine::sequential_reader`] for packed mode. The
/// jsonschema validator is mode-agnostic and always available.
pub struct TypedefEngine {
layout: Layout,
validator: jsonschema::Validator,
endian: Endian,
schema: Value,
}
impl TypedefEngine {
/// Compile a schema into a [`TypedefEngine`].
///
/// This is the expensive operation — it parses the schema, normalizes
/// `$ref` values, computes the layout, and builds the jsonschema
/// validator. Call once at load time; use the returned engine for
/// repeated operations.
///
/// The `mode` parameter selects the layout strategy. The same schema
/// can be compiled in either mode.
///
/// # Errors
///
/// Returns [`TypedefError::Schema`] if the schema is malformed or the
/// underlying layout/validator construction fails. The error is
/// propagated from [`LayoutBuilder::new`], [`SequentialReader::new`],
/// [`OffsetMap::compute`], or [`validation::build_validator`].
pub fn compile(schema: &mut Value, mode: LayoutMode) -> Result<Self, TypedefError> {
schema::normalize_refs(schema);
let endian = Endian::from_schema(schema);
let layout = match mode {
LayoutMode::Packed => {
let builder = LayoutBuilder::new(schema)?;
Layout::Packed { builder }
}
LayoutMode::Aligned => {
let offset_map = OffsetMap::compute(schema)?;
Layout::Aligned { offset_map }
}
};
let validator = validation::build_validator(schema)?;
Ok(Self {
layout,
validator,
endian,
schema: schema.clone(),
})
}
/// The schema's endianness.
pub fn endian(&self) -> Endian {
self.endian
}
/// The layout mode this engine was compiled with.
pub fn mode(&self) -> LayoutMode {
match self.layout {
Layout::Packed { .. } => LayoutMode::Packed,
Layout::Aligned { .. } => LayoutMode::Aligned,
}
}
/// Access the aligned offset map. Returns `None` if compiled in
/// packed mode.
pub fn offset_map(&self) -> Option<&OffsetMap> {
match &self.layout {
Layout::Aligned { offset_map } => Some(offset_map),
Layout::Packed { .. } => None,
}
}
/// Access the layout builder (write-side of packed mode).
/// Returns `None` if compiled in aligned mode.
pub fn layout_builder(&self) -> Option<&LayoutBuilder> {
match &self.layout {
Layout::Packed { builder, .. } => Some(builder),
Layout::Aligned { .. } => None,
}
}
/// Construct a fresh [`SequentialReader`] for packed-mode reads
/// (ADR-101). Each call returns a new reader with the cursor at
/// position 0. The consumer owns the reader and calls
/// `read_next`/`read_field`/`reset` on it directly.
///
/// Returns `None` if compiled in aligned mode.
pub fn sequential_reader(&self) -> Option<SequentialReader> {
match &self.layout {
Layout::Packed { .. } => SequentialReader::new(&self.schema).ok(),
Layout::Aligned { .. } => None,
}
}
/// Validate a JSON value against the schema. The jsonschema validator
/// is already compiled — this is a fast check.
///
/// Returns `Ok(())` if valid, `Err(TypedefError::Validation(...))` if
/// invalid.
pub fn validate_json(&self, instance: &Value) -> Result<(), TypedefError> {
self.validator
.validate(instance)
.map_err(|e| TypedefError::Validation(e.to_owned()))
}
/// Check if a JSON value is valid against the schema.
pub fn is_valid_json(&self, instance: &Value) -> bool {
self.validator.is_valid(instance)
}
/// Read a field from a buffer at its computed offset (aligned mode).
///
/// Looks up the field's byte range in the [`OffsetMap`] and reads the
/// appropriate type using the [`crate::data_access`] functions. Works
/// for fixed-size primitive kinds and length-prefixed `String`/
/// `Bytes`/`Timestamp` fields.
///
/// Returns an error if compiled in packed mode — use
/// [`TypedefEngine::sequential_reader`] for packed mode. Also
/// returns an error for composite kinds (`Struct`, `Union`, `Array`,
/// `Record`) — those are better handled via the layout-specific APIs.
///
/// # Errors
///
/// - [`TypedefError::Access`] if compiled in packed mode.
/// - [`TypedefError::Offset`] if `field_path` is not in the offset map.
/// - [`TypedefError::Access`] for buffer-too-short or invalid data,
/// propagated from [`crate::data_access`].
pub fn read_field<'a>(
&self,
buffer: &'a [u8],
field_path: &str,
) -> Result<FieldValue<'a>, TypedefError> {
let offset_map = match &self.layout {
Layout::Aligned { offset_map } => offset_map,
Layout::Packed { .. } => {
return Err(TypedefError::Access {
field_path: field_path.to_string(),
reason: "read_field is only available in aligned mode; \
use sequential_reader() for packed mode"
.to_string(),
});
}
};
let range = offset_map
.get(field_path)
.ok_or_else(|| TypedefError::Offset {
field_path: field_path.to_string(),
reason: "field not found in offset map".to_string(),
})?;
let field_schema =
lookup_field_schema(&self.schema, field_path).ok_or_else(|| TypedefError::Offset {
field_path: field_path.to_string(),
reason: "field schema not found in schema tree".to_string(),
})?;
let kind = get_typedef_kind_loose_enum(field_schema).ok_or_else(|| TypedefError::Offset {
field_path: field_path.to_string(),
reason: "field schema has no TypeDef:* kind".to_string(),
})?;
let endian = self.endian;
match kind {
TypeDefKind::Int8 => {
let v = data_access::read_i8(buffer, range.start, field_path)?;
Ok(FieldValue::I8(v))
}
TypeDefKind::Int16 => {
let v = data_access::read_i16(buffer, range.start, field_path, endian)?;
Ok(FieldValue::I16(v))
}
TypeDefKind::Int32 => {
let v = data_access::read_i32(buffer, range.start, field_path, endian)?;
Ok(FieldValue::I32(v))
}
TypeDefKind::Int64 => {
let v = data_access::read_i64(buffer, range.start, field_path, endian)?;
Ok(FieldValue::I64(v))
}
TypeDefKind::Uint8 => {
let v = data_access::read_u8(buffer, range.start, field_path)?;
Ok(FieldValue::U8(v))
}
TypeDefKind::Uint16 => {
let v = data_access::read_u16(buffer, range.start, field_path, endian)?;
Ok(FieldValue::U16(v))
}
TypeDefKind::Uint32 => {
let v = data_access::read_u32(buffer, range.start, field_path, endian)?;
Ok(FieldValue::U32(v))
}
TypeDefKind::Uint64 => {
let v = data_access::read_u64(buffer, range.start, field_path, endian)?;
Ok(FieldValue::U64(v))
}
TypeDefKind::Float32 => {
let v = data_access::read_f32(buffer, range.start, field_path, endian)?;
Ok(FieldValue::F32(v))
}
TypeDefKind::Float64 => {
let v = data_access::read_f64(buffer, range.start, field_path, endian)?;
Ok(FieldValue::F64(v))
}
TypeDefKind::Boolean => {
let v = data_access::read_bool(buffer, range.start, field_path)?;
Ok(FieldValue::Bool(v))
}
TypeDefKind::Enum => {
let v = data_access::read_enum(buffer, range.start, field_path, endian)?;
Ok(FieldValue::Enum(v))
}
TypeDefKind::String => {
let v = data_access::read_string(buffer, range.start, field_path, endian)?;
Ok(FieldValue::String(v))
}
TypeDefKind::Bytes => {
let v = data_access::read_bytes(buffer, range.start, field_path, endian)?;
Ok(FieldValue::Bytes(v))
}
TypeDefKind::Timestamp => {
let v = data_access::read_string(buffer, range.start, field_path, endian)?;
Ok(FieldValue::String(v))
}
TypeDefKind::Struct => Ok(FieldValue::Struct {
start: range.start,
end: range.end,
}),
TypeDefKind::Union | TypeDefKind::Array | TypeDefKind::Record => {
Err(TypedefError::Access {
field_path: field_path.to_string(),
reason: "read_field does not support composite types; \
use the layout-specific APIs"
.to_string(),
})
}
}
}
/// Write a field to a buffer at its computed offset (aligned mode).
///
/// Looks up the field's byte range in the [`OffsetMap`] and writes the
/// appropriate type using the [`crate::data_access`] functions. Works
/// for fixed-size primitive kinds and length-prefixed `String`/
/// `Bytes`/`Timestamp` fields.
///
/// Returns an error if compiled in packed mode — use
/// [`TypedefEngine::layout_builder`] for packed mode. Also returns an
/// error for composite kinds (`Struct`, `Union`, `Array`, `Record`).
///
/// # Errors
///
/// - [`TypedefError::Access`] if compiled in packed mode.
/// - [`TypedefError::Offset`] if `field_path` is not in the offset map.
/// - [`TypedefError::Access`] for buffer-too-short or invalid data,
/// propagated from [`crate::data_access`].
pub fn write_field(
&self,
buffer: &mut [u8],
field_path: &str,
value: &FieldValue<'_>,
) -> Result<(), TypedefError> {
let offset_map = match &self.layout {
Layout::Aligned { offset_map } => offset_map,
Layout::Packed { .. } => {
return Err(TypedefError::Access {
field_path: field_path.to_string(),
reason: "write_field is only available in aligned mode; \
use layout_builder() for packed mode"
.to_string(),
});
}
};
let range = offset_map
.get(field_path)
.ok_or_else(|| TypedefError::Offset {
field_path: field_path.to_string(),
reason: "field not found in offset map".to_string(),
})?;
let endian = self.endian;
match value {
FieldValue::I8(v) => data_access::write_i8(buffer, range.start, *v, field_path),
FieldValue::I16(v) => {
data_access::write_i16(buffer, range.start, *v, field_path, endian)
}
FieldValue::I32(v) => {
data_access::write_i32(buffer, range.start, *v, field_path, endian)
}
FieldValue::I64(v) => {
data_access::write_i64(buffer, range.start, *v, field_path, endian)
}
FieldValue::U8(v) => data_access::write_u8(buffer, range.start, *v, field_path),
FieldValue::U16(v) => {
data_access::write_u16(buffer, range.start, *v, field_path, endian)
}
FieldValue::U32(v) => {
data_access::write_u32(buffer, range.start, *v, field_path, endian)
}
FieldValue::U64(v) => {
data_access::write_u64(buffer, range.start, *v, field_path, endian)
}
FieldValue::F32(v) => {
data_access::write_f32(buffer, range.start, *v, field_path, endian)
}
FieldValue::F64(v) => {
data_access::write_f64(buffer, range.start, *v, field_path, endian)
}
FieldValue::Bool(v) => data_access::write_bool(buffer, range.start, *v, field_path),
FieldValue::Enum(v) => {
data_access::write_enum(buffer, range.start, *v, field_path, endian)
}
FieldValue::String(v) => {
data_access::write_string(buffer, range.start, v, field_path, endian)?;
Ok(())
}
FieldValue::Bytes(v) => {
data_access::write_bytes(buffer, range.start, v, field_path, endian)?;
Ok(())
}
FieldValue::Struct { .. } | FieldValue::Union { .. } | FieldValue::Array { .. } => {
Err(TypedefError::Access {
field_path: field_path.to_string(),
reason: "write_field does not support composite types; \
use the layout-specific APIs"
.to_string(),
})
}
}
}
}
impl fmt::Debug for TypedefEngine {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TypedefEngine")
.field("layout", &self.layout)
.field("validator", &"<jsonschema::Validator>")
.field("endian", &self.endian)
.field("schema", &self.schema)
.finish()
}
}
/// Walk a schema tree to find the node for a dotted field path.
///
/// Splits `field_path` on `.` and descends into `schema["properties"][segment]`
/// at each step. Returns `None` if any segment is missing or the schema is
/// not an object. Does not resolve `$ref` pointers — the engine stores the
/// normalized schema, and the aligned offset map only records paths for
/// inline fields, so refs at intermediate levels are not expected here.
fn lookup_field_schema<'a>(schema: &'a Value, field_path: &str) -> Option<&'a Value> {
let mut current = schema;
for segment in field_path.split('.') {
current = current.as_object()?.get("properties")?.get(segment)?;
}
Some(current)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn fixed_struct_schema() -> Value {
json!({
"TypeDef:Struct": true,
"endian": "little",
"properties": {
"flag": { "TypeDef:Uint8": true },
"id": { "TypeDef:Uint32": true },
"score": { "TypeDef:Float32": true },
"tag": { "TypeDef:String": true }
}
})
}
#[test]
fn compile_aligned_builds_offset_map() {
let mut schema = fixed_struct_schema();
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
assert_eq!(engine.mode(), LayoutMode::Aligned);
assert!(engine.offset_map().is_some());
assert!(engine.layout_builder().is_none());
assert!(engine.sequential_reader().is_none());
}
#[test]
fn compile_packed_builds_builder_and_reader() {
let mut schema = fixed_struct_schema();
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
assert_eq!(engine.mode(), LayoutMode::Packed);
assert!(engine.layout_builder().is_some());
assert!(engine.sequential_reader().is_some());
assert!(engine.offset_map().is_none());
}
#[test]
fn compile_normalizes_refs() {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": {
"child": { "$ref": "Child" }
},
"$defs": {
"Child": {
"TypeDef:Struct": true,
"properties": { "x": { "TypeDef:Uint8": true } }
}
}
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
assert_eq!(
engine.schema["properties"]["child"]["$ref"],
json!("#/$defs/Child")
);
}
#[test]
fn endian_parsed_from_schema() {
let mut schema = json!({
"TypeDef:Struct": true,
"endian": "big",
"properties": { "id": { "TypeDef:Uint32": true } }
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
assert_eq!(engine.endian(), Endian::Big);
}
#[test]
fn endian_defaults_to_little() {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": { "id": { "TypeDef:Uint32": true } }
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
assert_eq!(engine.endian(), Endian::Little);
}
#[test]
fn validate_json_accepts_valid_instance() {
let mut schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": {
"id": { "TypeDef:Uint32": true, "type": "integer" }
},
"required": ["id"]
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
assert!(engine.validate_json(&json!({"id": 42})).is_ok());
}
#[test]
fn validate_json_rejects_invalid_instance() {
let mut schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": {
"id": { "TypeDef:Uint32": true, "type": "integer" }
},
"required": ["id"]
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
let err = engine.validate_json(&json!({"id": -1})).unwrap_err();
assert!(matches!(err, TypedefError::Validation(_)), "got {err:?}");
}
#[test]
fn is_valid_json_returns_bool() {
let mut schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": {
"id": { "TypeDef:Uint32": true, "type": "integer" }
},
"required": ["id"]
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
assert!(engine.is_valid_json(&json!({"id": 42})));
assert!(!engine.is_valid_json(&json!({"id": -1})));
}
#[test]
fn read_field_aligned_reads_fixed_fields() {
let mut schema = json!({
"TypeDef:Struct": true,
"endian": "little",
"properties": {
"flag": { "TypeDef:Uint8": true },
"id": { "TypeDef:Uint32": true }
}
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
let mut buf = vec![0u8; 8];
buf[0] = 0xAB;
buf[4..8].copy_from_slice(&0x01020304u32.to_le_bytes());
assert_eq!(
engine.read_field(&buf, "flag").unwrap(),
FieldValue::U8(0xAB)
);
assert_eq!(
engine.read_field(&buf, "id").unwrap(),
FieldValue::U32(0x01020304)
);
}
#[test]
fn read_field_aligned_reads_string_length_prefixed() {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": {
"name": { "TypeDef:String": true }
}
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
let mut buf = vec![0u8; 32];
let len_bytes = 5u32.to_le_bytes();
buf[0..4].copy_from_slice(&len_bytes);
buf[4..9].copy_from_slice(b"hello");
assert_eq!(
engine.read_field(&buf, "name").unwrap(),
FieldValue::String("hello")
);
}
#[test]
fn read_field_returns_access_error_in_packed_mode() {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": { "id": { "TypeDef:Uint32": true } }
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
let buf = [0u8; 4];
let err = engine.read_field(&buf, "id").unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn read_field_returns_offset_error_for_missing_field() {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": { "id": { "TypeDef:Uint32": true } }
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
let buf = [0u8; 4];
let err = engine.read_field(&buf, "missing").unwrap_err();
assert!(matches!(err, TypedefError::Offset { .. }), "got {err:?}");
}
#[test]
fn read_field_returns_error_for_composite_types() {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": {
"vals": {
"TypeDef:Array": true,
"items": { "TypeDef:Uint32": true }
}
}
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
let buf = [0u8; 8];
let err = engine.read_field(&buf, "vals").unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn write_field_aligned_writes_fixed_fields() {
let mut schema = json!({
"TypeDef:Struct": true,
"endian": "little",
"properties": {
"flag": { "TypeDef:Uint8": true },
"id": { "TypeDef:Uint32": true }
}
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
let mut buf = vec![0u8; 8];
engine
.write_field(&mut buf, "flag", &FieldValue::U8(0xAB))
.unwrap();
engine
.write_field(&mut buf, "id", &FieldValue::U32(0x01020304))
.unwrap();
assert_eq!(buf[0], 0xAB);
assert_eq!(&buf[4..8], &0x01020304u32.to_le_bytes());
assert_eq!(
engine.read_field(&buf, "flag").unwrap(),
FieldValue::U8(0xAB)
);
assert_eq!(
engine.read_field(&buf, "id").unwrap(),
FieldValue::U32(0x01020304)
);
}
#[test]
fn write_field_round_trips_string() {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": {
"name": { "TypeDef:String": true }
}
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
let mut buf = vec![0u8; 32];
engine
.write_field(&mut buf, "name", &FieldValue::String("hello"))
.unwrap();
assert_eq!(
engine.read_field(&buf, "name").unwrap(),
FieldValue::String("hello")
);
}
#[test]
fn write_field_returns_access_error_in_packed_mode() {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": { "id": { "TypeDef:Uint32": true } }
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
let mut buf = [0u8; 4];
let err = engine
.write_field(&mut buf, "id", &FieldValue::U32(1))
.unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn write_field_returns_offset_error_for_missing_field() {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": { "id": { "TypeDef:Uint32": true } }
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
let mut buf = [0u8; 4];
let err = engine
.write_field(&mut buf, "missing", &FieldValue::U32(1))
.unwrap_err();
assert!(matches!(err, TypedefError::Offset { .. }), "got {err:?}");
}
#[test]
fn write_field_returns_error_for_composite_value() {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": { "id": { "TypeDef:Uint32": true } }
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
let mut buf = [0u8; 8];
let err = engine
.write_field(&mut buf, "id", &FieldValue::Struct { start: 0, end: 4 })
.unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn compile_returns_schema_error_for_invalid_top_level() {
let mut schema = json!({ "type": "object", "properties": {} });
let err = TypedefEngine::compile(&mut schema, LayoutMode::Packed).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
}
#[test]
fn debug_formats_without_panicking() {
let mut schema = fixed_struct_schema();
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
let s = format!("{engine:?}");
assert!(s.contains("TypedefEngine"));
assert!(s.contains("Aligned"));
}
#[test]
fn lookup_field_schema_walks_dotted_path() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"header": {
"TypeDef:Struct": true,
"properties": {
"version": { "TypeDef:Uint8": true }
}
}
}
});
let node = lookup_field_schema(&schema, "header.version").expect("found");
assert_eq!(node, &json!({ "TypeDef:Uint8": true }));
assert!(lookup_field_schema(&schema, "header.missing").is_none());
assert!(lookup_field_schema(&schema, "missing").is_none());
}
#[test]
fn typedef_kind_loose_recognizes_object_form() {
let node = json!({ "TypeDef:String": { "encoding": "offset-indirect" } });
assert_eq!(
get_typedef_kind_loose_enum(&node),
Some(TypeDefKind::String)
);
}
}
+45
View File
@@ -0,0 +1,45 @@
//! Error types for the typedef engine.
//!
//! Decided in ADR-098: a single `TypedefError` enum covers all error
//! conditions across the engine's three phases (schema parsing, offset
//! computation, read/write) plus validation.
use std::fmt;
/// Errors produced by the typedef engine across all phases.
#[derive(Debug)]
pub enum TypedefError {
/// Schema parsing errors — invalid JSON, missing required keywords,
/// unknown `TypeDef:*` kinds, malformed annotations.
Schema(String),
/// Offset computation errors — field not found, type not supported
/// for offset computation, recursive depth exceeded.
Offset { field_path: String, reason: String },
/// Read/write errors — buffer too short, invalid UTF-8, value out
/// of range for the target type.
Access { field_path: String, reason: String },
/// Validation errors — delegated to the `jsonschema` crate.
/// The `'static` lifetime is correct: the validator owns its schema
/// reference and lives for the lifetime of the `TypedefEngine`.
Validation(jsonschema::ValidationError<'static>),
}
impl fmt::Display for TypedefError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TypedefError::Schema(msg) => write!(f, "schema error: {msg}"),
TypedefError::Offset { field_path, reason } => {
write!(f, "offset error at {field_path}: {reason}")
}
TypedefError::Access { field_path, reason } => {
write!(f, "access error at {field_path}: {reason}")
}
TypedefError::Validation(inner) => write!(f, "validation error: {inner}"),
}
}
}
impl std::error::Error for TypedefError {}
File diff suppressed because it is too large. Load diff
+47
View File
@@ -0,0 +1,47 @@
//! alknet-typedef: The binary struct engine.
//!
//! Takes a JSON Schema with `TypeDef:*` custom keywords and produces
//! an offset map, read/write functions, and validation — all driven
//! by the schema. The schema is the format definition; the engine is
//! generic.
//!
//! ## Architecture
//!
//! - **Schema layer** ([`schema`]): TypeDef kind detection, annotation
//! parsing, `$ref` normalization, endianness.
//! - **Layout engine** ([`offset_map`], [`layout_builder`],
//! [`sequential_reader`]): Two layout modes — aligned static for
//! mmap-friendly formats, packed sequential for protocol wire formats.
//! - **Data access** ([`data_access`]): Typed read/write at computed
//! offsets, zero-copy for fixed-size types.
//! - **TUnion dispatch** ([`tunion`]): Byte-offset and field-name
//! discriminator dispatch.
//! - **Validation** ([`validation`]): Custom keyword validators for all
//! 17 `TypeDef:*` kinds, delegated to the `jsonschema` crate.
//! - **Engine** ([`engine`]): `TypedefEngine` — the compiled form of a
//! schema, combining layout and validation.
#[macro_use]
mod macros;
pub mod data_access;
pub mod engine;
pub mod error;
pub mod layout_builder;
pub mod offset_map;
pub mod schema;
pub mod sequential_reader;
pub mod tunion;
pub mod validation;
pub use engine::{LayoutMode, TypedefEngine};
pub use error::TypedefError;
pub use layout_builder::{FieldPosition, LayoutBuilder, PackedLayout};
pub use offset_map::{ByteRange, OffsetMap};
pub use schema::{
get_typedef_kind_loose, get_typedef_kind_loose_enum, normalize_refs, parse_align,
parse_discriminator, parse_encoding, parse_endian, parse_max_length, resolve_ref,
resolve_ref_or_inline, DiscriminatorKind, Endian, TypeDefKind, VariableEncoding,
};
pub use sequential_reader::{FieldValue, SequentialReader};
pub use tunion::UnionDispatch;
pub use validation::build_validator;
+251
View File
@@ -0,0 +1,251 @@
//! Macros for generating repetitive code across the 17 TypeDef kinds.
//!
//! These macros eliminate boilerplate in validation, data access, and
//! dispatch. Each macro takes a compact specification and generates the
//! full implementation, ensuring consistency across all types.
// ---------------------------------------------------------------------------
// Validation macros
// ---------------------------------------------------------------------------
/// Generate a signed integer validator struct and its factory closure.
#[macro_export]
macro_rules! define_int_validator {
($validator_struct:ident, $factory_fn:ident, $keyword:literal, $min:literal, $max:literal) => {
struct $validator_struct;
impl jsonschema::Keyword for $validator_struct {
fn validate<'i>(
&self,
instance: &'i serde_json::Value,
) -> Result<(), jsonschema::ValidationError<'i>> {
match instance.as_i64() {
Some(n) if ($min..=$max).contains(&n) => Ok(()),
_ => Err(jsonschema::ValidationError::custom(concat!(
"expected an integer in range [",
stringify!($min),
", ",
stringify!($max),
"]"
))),
}
}
fn is_valid(&self, instance: &serde_json::Value) -> bool {
instance
.as_i64()
.is_some_and(|n| ($min..=$max).contains(&n))
}
}
fn $factory_fn<'a>(
_parent: &'a serde_json::Map<String, serde_json::Value>,
value: &'a serde_json::Value,
_path: jsonschema::paths::Location,
) -> Result<Box<dyn jsonschema::Keyword>, jsonschema::ValidationError<'a>> {
if value.as_bool() == Some(true) {
Ok(Box::new($validator_struct))
} else {
Err(jsonschema::ValidationError::schema(concat!(
$keyword,
" must be set to true"
)))
}
}
};
}
/// Generate an unsigned integer validator struct and its factory closure.
#[macro_export]
macro_rules! define_uint_validator {
($validator_struct:ident, $factory_fn:ident, $keyword:literal, $max:literal) => {
struct $validator_struct;
impl jsonschema::Keyword for $validator_struct {
fn validate<'i>(
&self,
instance: &'i serde_json::Value,
) -> Result<(), jsonschema::ValidationError<'i>> {
match instance.as_u64() {
Some(n) if n <= $max => Ok(()),
_ => Err(jsonschema::ValidationError::custom(concat!(
"expected an unsigned integer in range [0, ",
stringify!($max),
"]"
))),
}
}
fn is_valid(&self, instance: &serde_json::Value) -> bool {
instance.as_u64().is_some_and(|n| n <= $max)
}
}
fn $factory_fn<'a>(
_parent: &'a serde_json::Map<String, serde_json::Value>,
value: &'a serde_json::Value,
_path: jsonschema::paths::Location,
) -> Result<Box<dyn jsonschema::Keyword>, jsonschema::ValidationError<'a>> {
if value.as_bool() == Some(true) {
Ok(Box::new($validator_struct))
} else {
Err(jsonschema::ValidationError::schema(concat!(
$keyword,
" must be set to true"
)))
}
}
};
}
/// Generate a float validator struct and its factory closure.
#[macro_export]
macro_rules! define_float_validator {
($validator_struct:ident, $factory_fn:ident, $keyword:literal, $error_msg:literal) => {
struct $validator_struct;
impl jsonschema::Keyword for $validator_struct {
fn validate<'i>(
&self,
instance: &'i serde_json::Value,
) -> Result<(), jsonschema::ValidationError<'i>> {
match instance.as_f64() {
Some(f) if f.is_finite() => Ok(()),
_ => Err(jsonschema::ValidationError::custom($error_msg)),
}
}
fn is_valid(&self, instance: &serde_json::Value) -> bool {
instance.as_f64().is_some_and(|f| f.is_finite())
}
}
fn $factory_fn<'a>(
_parent: &'a serde_json::Map<String, serde_json::Value>,
value: &'a serde_json::Value,
_path: jsonschema::paths::Location,
) -> Result<Box<dyn jsonschema::Keyword>, jsonschema::ValidationError<'a>> {
if value.as_bool() == Some(true) {
Ok(Box::new($validator_struct))
} else {
Err(jsonschema::ValidationError::schema(concat!(
$keyword,
" must be set to true"
)))
}
}
};
}
/// Generate a simple type-check validator (object/array/boolean) and its factory.
#[macro_export]
macro_rules! define_type_validator {
($validator_struct:ident, $factory_fn:ident, $keyword:literal, $check_method:ident, $error_msg:literal) => {
struct $validator_struct;
impl jsonschema::Keyword for $validator_struct {
fn validate<'i>(
&self,
instance: &'i serde_json::Value,
) -> Result<(), jsonschema::ValidationError<'i>> {
if instance.$check_method() {
Ok(())
} else {
Err(jsonschema::ValidationError::custom($error_msg))
}
}
fn is_valid(&self, instance: &serde_json::Value) -> bool {
instance.$check_method()
}
}
fn $factory_fn<'a>(
_parent: &'a serde_json::Map<String, serde_json::Value>,
value: &'a serde_json::Value,
_path: jsonschema::paths::Location,
) -> Result<Box<dyn jsonschema::Keyword>, jsonschema::ValidationError<'a>> {
if value.as_bool() == Some(true) {
Ok(Box::new($validator_struct))
} else {
Err(jsonschema::ValidationError::schema(concat!(
$keyword,
" must be set to true"
)))
}
}
};
}
// ---------------------------------------------------------------------------
// Data access macros
// ---------------------------------------------------------------------------
/// Generate a pair of read/write functions for a fixed-size endian-sensitive type.
#[macro_export]
macro_rules! define_read_write_endian {
($rust_ty:ty, $read_name:ident, $write_name:ident, $size:literal) => {
#[doc = concat!(
"Read a `",
stringify!($rust_ty),
"` at `offset` from `buffer`, applying `endian`."
)]
pub fn $read_name(
buffer: &[u8],
offset: usize,
field_path: &str,
endian: $crate::Endian,
) -> Result<$rust_ty, $crate::TypedefError> {
let bytes: [u8; $size] = $crate::data_access::read_array(buffer, offset, field_path)?;
Ok(match endian {
$crate::Endian::Little => <$rust_ty>::from_le_bytes(bytes),
$crate::Endian::Big => <$rust_ty>::from_be_bytes(bytes),
})
}
#[doc = concat!(
"Write a `",
stringify!($rust_ty),
"` `value` at `offset` into `buffer`, applying `endian`."
)]
pub fn $write_name(
buffer: &mut [u8],
offset: usize,
value: $rust_ty,
field_path: &str,
endian: $crate::Endian,
) -> Result<(), $crate::TypedefError> {
let bytes = match endian {
$crate::Endian::Little => value.to_le_bytes(),
$crate::Endian::Big => value.to_be_bytes(),
};
$crate::data_access::write_array(buffer, offset, bytes, field_path)
}
};
}
/// Generate a pair of read/write functions for a fixed-size endian-insensitive type.
#[macro_export]
macro_rules! define_read_write_ne {
($rust_ty:ty, $read_name:ident, $write_name:ident, $size:literal, $read_expr:expr) => {
#[doc = concat!(
"Read a `",
stringify!($rust_ty),
"` at `offset` from `buffer`."
)]
pub fn $read_name(
buffer: &[u8],
offset: usize,
field_path: &str,
) -> Result<$rust_ty, $crate::TypedefError> {
let bytes: [u8; $size] = $crate::data_access::read_array(buffer, offset, field_path)?;
Ok($read_expr(bytes))
}
#[doc = concat!(
"Write a `",
stringify!($rust_ty),
"` `value` at `offset` into `buffer`."
)]
pub fn $write_name(
buffer: &mut [u8],
offset: usize,
value: $rust_ty,
field_path: &str,
) -> Result<(), $crate::TypedefError> {
$crate::data_access::write_array(buffer, offset, value.to_ne_bytes(), field_path)
}
};
}
+873
View File
@@ -0,0 +1,873 @@
//! Aligned static `OffsetMap` — Mode 2 of the two layout modes (ADR-096).
//!
//! Fields have fixed positions with natural alignment padding.
//! Variable-length fields get a 4-byte length prefix at a known offset;
//! the variable data is not included in the static layout. Used for
//! mmap-friendly formats (metatensor, safetensors).
//!
//! The offset computation is a recursive walk of the schema JSON. Nested
//! structs propagate field path prefixes (producing dotted paths like
//! `"header.version"`). Alignment padding is inserted before each field
//! to satisfy the field's alignment requirement (natural alignment by
//! default, overridable via the `"align"` annotation).
use crate::error::TypedefError;
use crate::schema::{
get_typedef_kind, get_typedef_kind_loose_enum, parse_align, parse_encoding,
parse_max_length, resolve_ref_or_inline, TypeDefKind, VariableEncoding,
};
use serde_json::Value;
/// A byte range within a buffer.
///
/// Produced by [`OffsetMap::compute`] for each field in a schema. The
/// range is half-open: `start..end`. `end - start` is the field's byte
/// size in the static layout (for variable-length fields, this is the
/// size of the length prefix, the `{offset, length}` pair, or the
/// `maxLength` reservation — not the variable data itself).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ByteRange {
/// Inclusive start byte offset.
pub start: usize,
/// Exclusive end byte offset.
pub end: usize,
}
impl ByteRange {
/// Byte length of the range (`end - start`).
pub fn len(&self) -> usize {
self.end - self.start
}
/// True if the range covers zero bytes.
pub fn is_empty(&self) -> bool {
self.end == self.start
}
}
/// A flat table of `(field_path, byte_range)` pairs computed from a schema.
///
/// Fields have fixed positions with natural alignment padding.
/// Used for mmap-friendly formats (metatensor, safetensors) where random
/// access by field path is required — the consumer can read field N
/// without reading fields `0..N-1` first.
///
/// Construct via [`OffsetMap::compute`]. Variable-length fields appear
/// in the table as their fixed-position portion only (length prefix,
/// `{offset, length}` pair, or `maxLength` reservation); the variable
/// data lives outside the static layout.
#[derive(Debug)]
pub struct OffsetMap {
fields: Vec<(String, ByteRange)>,
total_size: usize,
}
impl OffsetMap {
/// Compute the offset map from a schema JSON value.
///
/// Walks the schema recursively, computing byte positions for each
/// field based on type sizes, field order, and alignment. The
/// top-level schema must be a `TypeDef:Struct`.
///
/// # Errors
///
/// Returns [`TypedefError::Schema`] if the top-level schema is not a
/// `TypeDef:Struct` or has no `TypeDef:*` kind, or if the schema is
/// malformed (missing `properties`, unknown kind, etc.).
///
/// Returns [`TypedefError::Offset`] for unsupported type combinations
/// encountered during the walk.
pub fn compute(schema: &Value) -> Result<Self, TypedefError> {
let kind = get_typedef_kind(schema)
.and_then(|s| s.parse::<TypeDefKind>().ok())
.ok_or_else(|| {
TypedefError::Schema("top-level schema has no TypeDef:* kind".to_string())
})?;
if kind != TypeDefKind::Struct {
return Err(TypedefError::Schema(format!(
"OffsetMap::compute requires a TypeDef:Struct at the top level, got {kind}"
)));
}
let mut ctx = ComputeCtx {
root: schema,
fields: Vec::new(),
offset: 0,
};
let (total, _align) = ctx.compute_struct(schema, "", 1)?;
Ok(Self {
fields: ctx.fields,
total_size: total,
})
}
/// Look up a field's byte range by dotted path (e.g., `"header.version"`).
///
/// Returns `None` if no field with the given path was recorded. For
/// TUnion byte-offset discriminators, the discriminator is recorded
/// under the synthetic path `"__discriminator"` (qualified by the
/// union field's path, e.g. `"payload.__discriminator"`).
pub fn get(&self, field_path: &str) -> Option<&ByteRange> {
self.fields
.iter()
.find(|(path, _)| path == field_path)
.map(|(_, range)| range)
}
/// The total size of the struct in bytes (including trailing alignment padding).
pub fn total_size(&self) -> usize {
self.total_size
}
/// Iterate over all `(field_path, byte_range)` pairs in insertion order.
///
/// Field order matches the schema's `properties` order (preserved by
/// `serde_json`'s `preserve_order` feature). Nested struct fields
/// appear after their parent's path prefix.
pub fn iter(&self) -> impl Iterator<Item = &(String, ByteRange)> {
self.fields.iter()
}
}
/// Mutable context threaded through the recursive offset computation.
///
/// Carries the running `offset`, the accumulating `fields` vec, and a
/// reference to the root schema for `$ref` resolution. Grouping these
/// keeps the recursive helper signatures small.
struct ComputeCtx<'a> {
root: &'a Value,
fields: Vec<(String, ByteRange)>,
offset: usize,
}
/// Result of laying out a single field: its alignment.
struct FieldLayout {
align: usize,
}
impl<'a> ComputeCtx<'a> {
/// Recurse into a `TypeDef:Struct`, appending `(field_path, ByteRange)`
/// pairs to `self.fields` and advancing `self.offset`.
///
/// Returns `(total_size, alignment)` where `total_size` includes
/// trailing alignment padding and `alignment` is the struct's
/// effective alignment (its own `align` annotation, or the max of its
/// fields' alignments).
///
/// `struct_schema` is the schema of the struct to walk. `prefix` is the
/// dotted path prefix for nested fields (empty at the top level).
/// `parent_struct_align` is the default alignment a field inherits
/// when it specifies neither its own `align` annotation nor a natural
/// alignment larger than the default.
fn compute_struct(
&mut self,
struct_schema: &Value,
prefix: &str,
parent_struct_align: usize,
) -> Result<(usize, usize), TypedefError> {
let obj = struct_schema
.as_object()
.ok_or_else(|| TypedefError::Schema("struct schema is not an object".to_string()))?;
let properties = obj
.get("properties")
.and_then(|v| v.as_object())
.ok_or_else(|| {
TypedefError::Schema("struct schema has no 'properties' object".to_string())
})?;
let struct_default_align = parse_align(struct_schema).unwrap_or(parent_struct_align);
let mut max_align: usize = 1;
let struct_start = self.offset;
let field_schemas: Vec<(String, Value)> = properties
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let field_count = field_schemas.len();
for (i, (field_name, field_schema)) in field_schemas.iter().enumerate() {
let field_path = if prefix.is_empty() {
field_name.clone()
} else {
format!("{prefix}.{field_name}")
};
// ADR-100: reject non-final inline length-prefixed variable fields.
// The OffsetMap reserves only 4 bytes (the length prefix), but
// data_access::write_string writes prefix+data inline — clobbering
// subsequent fields. Only allowed as the last field in the struct.
if i < field_count - 1 {
if let Some(kind) = get_typedef_kind_loose_enum(field_schema) {
if kind.is_variable_length() {
let keyword_value = field_schema
.as_object()
.and_then(|o| {
o.keys()
.find(|k| k.starts_with("TypeDef:"))
.and_then(|k| o.get(k))
})
.cloned()
.unwrap_or(Value::Bool(true));
let encoding = parse_encoding(&keyword_value);
let max_length = parse_max_length(field_schema);
let is_inline_length_prefixed =
encoding == VariableEncoding::LengthPrefixed && max_length.is_none();
if is_inline_length_prefixed {
return Err(TypedefError::Offset {
field_path: field_path.clone(),
reason: format!(
"non-final inline length-prefixed variable field \
({kind}) in aligned mode: the variable data would \
clobber subsequent fields. Use `maxLength` \
(fixed-size reservation) or \
`\"encoding\": \"offset-indirect\"`, or move this \
field to the last position in the struct. (ADR-100)"
),
});
}
}
}
}
let layout = self.compute_field(field_schema, &field_path, struct_default_align)?;
if layout.align > max_align {
max_align = layout.align;
}
}
let effective_align = parse_align(struct_schema).unwrap_or(max_align).max(1);
align_up(&mut self.offset, effective_align);
let total = self.offset - struct_start;
Ok((total, effective_align))
}
/// Compute the layout for a single field, advancing `self.offset`
/// and appending any field paths to `self.fields`.
fn compute_field(
&mut self,
field_schema: &Value,
field_path: &str,
struct_default_align: usize,
) -> Result<FieldLayout, TypedefError> {
let kind = get_typedef_kind_loose_enum(field_schema).ok_or_else(|| TypedefError::Offset {
field_path: field_path.to_string(),
reason: "field schema has no TypeDef:* kind".to_string(),
})?;
match kind {
TypeDefKind::Struct => {
self.compute_struct_field(field_schema, field_path, struct_default_align)
}
TypeDefKind::Union => Err(TypedefError::Offset {
field_path: field_path.to_string(),
reason: "TUnion is not supported in aligned static mode (ADR-102). \
Unions are the protocol dispatch pattern — use packed sequential \
mode (LayoutMode::Packed) for TUnion fields, or restructure as \
a struct with an explicit discriminator field."
.to_string(),
}),
TypeDefKind::Array => {
self.compute_array_field(field_schema, field_path, struct_default_align)
}
TypeDefKind::String
| TypeDefKind::Bytes
| TypeDefKind::Record
| TypeDefKind::Timestamp => {
self.compute_variable_field(field_schema, field_path, struct_default_align)
}
k if k.is_fixed_size() => {
self.compute_fixed_field(k, field_schema, field_path, struct_default_align)
}
_ => unreachable!("all TypeDefKind variants are covered above"),
}
}
/// Compute the layout for a fixed-size primitive field.
fn compute_fixed_field(
&mut self,
kind: TypeDefKind,
field_schema: &Value,
field_path: &str,
struct_default_align: usize,
) -> Result<FieldLayout, TypedefError> {
let size = kind.type_size().ok_or_else(|| TypedefError::Offset {
field_path: field_path.to_string(),
reason: format!("type_size returned None for fixed kind {kind}"),
})?;
let natural = kind.natural_alignment();
let align = field_alignment(field_schema, struct_default_align, natural);
align_up(&mut self.offset, align);
let start = self.offset;
self.offset += size;
self.push(field_path, start, start + size);
Ok(FieldLayout { align })
}
/// Compute the layout for a nested `TypeDef:Struct` field.
///
/// Probes the nested struct's layout at a temporary offset of 0 to
/// determine its total size and alignment, aligns the parent offset,
/// then shifts the nested fields to their final positions.
fn compute_struct_field(
&mut self,
field_schema: &Value,
field_path: &str,
struct_default_align: usize,
) -> Result<FieldLayout, TypedefError> {
let inner_parent_align = parse_align(field_schema).unwrap_or(struct_default_align);
let mut probe = ComputeCtx {
root: self.root,
fields: Vec::new(),
offset: 0,
};
let (inner_total, inner_align) =
probe.compute_struct(field_schema, field_path, inner_parent_align)?;
let align = field_alignment(field_schema, struct_default_align, inner_align);
align_up(&mut self.offset, align);
let struct_start = self.offset;
for (path, range) in probe.fields {
self.fields.push((
path,
ByteRange {
start: struct_start + range.start,
end: struct_start + range.end,
},
));
}
self.offset = struct_start + inner_total;
Ok(FieldLayout { align })
}
/// Compute the layout for a `TypeDef:Array` field.
fn compute_array_field(
&mut self,
field_schema: &Value,
field_path: &str,
struct_default_align: usize,
) -> Result<FieldLayout, TypedefError> {
let obj = field_schema
.as_object()
.ok_or_else(|| TypedefError::Offset {
field_path: field_path.to_string(),
reason: "array schema is not an object".to_string(),
})?;
let items = obj.get("items").ok_or_else(|| TypedefError::Offset {
field_path: field_path.to_string(),
reason: "TArray is missing 'items'".to_string(),
})?;
let element_schema =
resolve_ref_or_inline(items, self.root).ok_or_else(|| TypedefError::Offset {
field_path: field_path.to_string(),
reason: "could not resolve TArray items schema".to_string(),
})?;
let elem_kind = get_typedef_kind(element_schema)
.and_then(|s| s.parse::<TypeDefKind>().ok())
.ok_or_else(|| TypedefError::Offset {
field_path: field_path.to_string(),
reason: "TArray element schema has no TypeDef:* kind".to_string(),
})?;
if !elem_kind.is_fixed_size() {
return Err(TypedefError::Offset {
field_path: field_path.to_string(),
reason: format!(
"TArray of variable-length element kind {elem_kind} is not supported (OQ-069)"
),
});
}
let elem_size = elem_kind.type_size().ok_or_else(|| TypedefError::Offset {
field_path: field_path.to_string(),
reason: format!("element kind {elem_kind} has no fixed size"),
})?;
let elem_natural = elem_kind.natural_alignment();
let elem_align = field_alignment(element_schema, struct_default_align, elem_natural);
let stride = round_up(elem_size, elem_align);
let min_items = obj
.get("minItems")
.and_then(|v| v.as_u64())
.map(|n| n as usize);
let max_items = obj
.get("maxItems")
.and_then(|v| v.as_u64())
.map(|n| n as usize);
let fixed_count = match (min_items, max_items) {
(Some(mn), Some(mx)) if mn == mx => Some(mn),
_ => None,
};
let array_align = field_alignment(field_schema, struct_default_align, elem_align);
if let Some(count) = fixed_count {
align_up(&mut self.offset, array_align);
let start = self.offset;
for i in 0..count {
let elem_start = start + i * stride;
let elem_end = elem_start + elem_size;
let elem_path = format!("{field_path}[{i}]");
self.push(&elem_path, elem_start, elem_end);
}
let array_size = count * stride;
self.offset = start + array_size;
Ok(FieldLayout { align: array_align })
} else {
let count_prefix_align = array_align.max(4);
align_up(&mut self.offset, count_prefix_align);
let start = self.offset;
self.push(field_path, start, start + 4);
self.offset = start + 4;
Ok(FieldLayout {
align: count_prefix_align,
})
}
}
/// Compute the layout for a variable-length field (String/Bytes/Record/Timestamp).
///
/// In aligned static mode, three strategies are supported:
/// - `maxLength` reservation: `maxLength` bytes at a fixed offset.
/// - `offset-indirect` encoding: an 8-byte `{offset: u32, length: u32}` pair.
/// - inline length-prefixing (default): a 4-byte length prefix.
fn compute_variable_field(
&mut self,
field_schema: &Value,
field_path: &str,
struct_default_align: usize,
) -> Result<FieldLayout, TypedefError> {
let keyword_value = field_schema
.as_object()
.and_then(|o| {
o.keys()
.find(|k| k.starts_with("TypeDef:"))
.and_then(|k| o.get(k))
})
.cloned()
.unwrap_or(Value::Bool(true));
let encoding = parse_encoding(&keyword_value);
let max_length = parse_max_length(field_schema);
let (size, natural) = match (max_length, encoding) {
(Some(max_len), _) => (max_len, 1),
(None, VariableEncoding::OffsetIndirect) => (8, 4),
(None, VariableEncoding::LengthPrefixed) => (4, 4),
};
let align = field_alignment(field_schema, struct_default_align, natural);
align_up(&mut self.offset, align);
let start = self.offset;
self.offset += size;
self.push(field_path, start, start + size);
Ok(FieldLayout { align })
}
/// Push a `(field_path, ByteRange)` pair onto the fields vec.
fn push(&mut self, path: &str, start: usize, end: usize) {
self.fields
.push((path.to_string(), ByteRange { start, end }));
}
}
/// Resolve the field's alignment: field-level `align` annotation,
/// then the struct default, then the natural alignment.
fn field_alignment(field_schema: &Value, struct_default_align: usize, natural: usize) -> usize {
if let Some(a) = parse_align(field_schema) {
return a.max(1);
}
struct_default_align.max(natural).max(1)
}
/// Round `offset` up to the next multiple of `align`. No-op if `align <= 1`.
fn align_up(offset: &mut usize, align: usize) {
if align <= 1 {
return;
}
let rem = *offset % align;
if rem != 0 {
*offset += align - rem;
}
}
/// Round `n` up to the next multiple of `align`.
fn round_up(n: usize, align: usize) -> usize {
if align <= 1 {
return n;
}
let rem = n % align;
if rem == 0 {
n
} else {
n + align - rem
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn map(schema: &Value) -> OffsetMap {
OffsetMap::compute(schema).expect("offset map computation")
}
#[test]
fn simple_fixed_fields_natural_alignment() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"flag": { "TypeDef:Uint8": true },
"id": { "TypeDef:Uint32": true }
}
});
let m = map(&schema);
assert_eq!(m.get("flag"), Some(&ByteRange { start: 0, end: 1 }));
assert_eq!(m.get("id"), Some(&ByteRange { start: 4, end: 8 }));
assert_eq!(m.total_size(), 8);
}
#[test]
fn u8_then_u32_three_bytes_padding() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"a": { "TypeDef:Uint8": true },
"b": { "TypeDef:Uint32": true }
}
});
let m = map(&schema);
assert_eq!(m.get("a"), Some(&ByteRange { start: 0, end: 1 }));
assert_eq!(m.get("b"), Some(&ByteRange { start: 4, end: 8 }));
}
#[test]
fn nested_struct_dotted_paths() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"header": {
"TypeDef:Struct": true,
"properties": {
"magic": { "TypeDef:Uint32": true },
"version": { "TypeDef:Uint8": true }
}
},
"body": { "TypeDef:Uint32": true }
}
});
let m = map(&schema);
assert_eq!(m.get("header.magic"), Some(&ByteRange { start: 0, end: 4 }));
assert_eq!(
m.get("header.version"),
Some(&ByteRange { start: 4, end: 5 })
);
assert_eq!(m.get("body"), Some(&ByteRange { start: 8, end: 12 }));
assert_eq!(m.total_size(), 12);
}
#[test]
fn array_fixed_count_element_offsets() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"vals": {
"TypeDef:Array": true,
"items": { "TypeDef:Uint32": true },
"minItems": 3,
"maxItems": 3
}
}
});
let m = map(&schema);
assert_eq!(m.get("vals[0]"), Some(&ByteRange { start: 0, end: 4 }));
assert_eq!(m.get("vals[1]"), Some(&ByteRange { start: 4, end: 8 }));
assert_eq!(m.get("vals[2]"), Some(&ByteRange { start: 8, end: 12 }));
assert_eq!(m.total_size(), 12);
}
#[test]
fn array_variable_count_length_prefix() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"vals": {
"TypeDef:Array": true,
"items": { "TypeDef:Uint32": true }
}
}
});
let m = map(&schema);
assert_eq!(m.get("vals"), Some(&ByteRange { start: 0, end: 4 }));
assert_eq!(m.total_size(), 4);
}
#[test]
fn variable_string_length_prefix_at_known_offset() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"id": { "TypeDef:Uint32": true },
"name": { "TypeDef:String": true }
}
});
let m = map(&schema);
assert_eq!(m.get("id"), Some(&ByteRange { start: 0, end: 4 }));
assert_eq!(m.get("name"), Some(&ByteRange { start: 4, end: 8 }));
assert_eq!(m.total_size(), 8);
}
#[test]
fn variable_string_max_length_reservation() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"id": { "TypeDef:Uint32": true },
"name": { "TypeDef:String": true, "maxLength": 256 }
}
});
let m = map(&schema);
assert_eq!(m.get("id"), Some(&ByteRange { start: 0, end: 4 }));
assert_eq!(m.get("name"), Some(&ByteRange { start: 4, end: 260 }));
assert_eq!(m.total_size(), 260);
}
#[test]
fn variable_string_offset_indirect_eight_bytes() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"id": { "TypeDef:Uint32": true },
"blob": { "TypeDef:String": { "encoding": "offset-indirect" } }
}
});
let m = map(&schema);
assert_eq!(m.get("id"), Some(&ByteRange { start: 0, end: 4 }));
assert_eq!(m.get("blob"), Some(&ByteRange { start: 4, end: 12 }));
assert_eq!(m.total_size(), 12);
}
#[test]
fn union_byte_discriminator_rejected_in_aligned_mode() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"payload": {
"TypeDef:Union": true,
"discriminator": {
"kind": "byte",
"offset": 0,
"type": "TypeDef:Uint8"
},
"mapping": {
"5": { "$ref": "#/$defs/Read" },
"6": { "$ref": "#/$defs/Write" }
}
}
},
"$defs": {
"Read": {
"TypeDef:Struct": true,
"properties": {
"handle": { "TypeDef:Uint32": true },
"length": { "TypeDef:Uint32": true }
}
},
"Write": {
"TypeDef:Struct": true,
"properties": {
"handle": { "TypeDef:Uint32": true },
"length": { "TypeDef:Uint32": true },
"data": { "TypeDef:Uint32": true }
}
}
}
});
let err = OffsetMap::compute(&schema).unwrap_err();
assert!(matches!(err, TypedefError::Offset { .. }), "got {err:?}");
let reason = match err {
TypedefError::Offset { reason, .. } => reason,
_ => unreachable!(),
};
assert!(reason.contains("ADR-102"), "reason: {reason}");
}
#[test]
fn union_field_name_discriminator_rejected_in_aligned_mode() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"event": {
"TypeDef:Union": true,
"discriminator": { "kind": "field", "name": "type" },
"mapping": {
"read": { "$ref": "#/$defs/Read" },
"write": { "$ref": "#/$defs/Write" }
}
}
},
"$defs": {
"Read": {
"TypeDef:Struct": true,
"properties": {
"type": { "TypeDef:Uint8": true },
"handle": { "TypeDef:Uint32": true }
}
},
"Write": {
"TypeDef:Struct": true,
"properties": {
"type": { "TypeDef:Uint8": true },
"handle": { "TypeDef:Uint32": true },
"length": { "TypeDef:Uint32": true }
}
}
}
});
let err = OffsetMap::compute(&schema).unwrap_err();
assert!(matches!(err, TypedefError::Offset { .. }), "got {err:?}");
}
#[test]
fn non_final_inline_string_rejected_in_aligned_mode() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"name": { "TypeDef:String": true },
"id": { "TypeDef:Uint32": true }
}
});
let err = OffsetMap::compute(&schema).unwrap_err();
match err {
TypedefError::Offset { field_path, reason } => {
assert_eq!(field_path, "name");
assert!(reason.contains("ADR-100"), "reason: {reason}");
}
other => panic!("expected Offset, got {other:?}"),
}
}
#[test]
fn final_inline_string_allowed_in_aligned_mode() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"id": { "TypeDef:Uint32": true },
"name": { "TypeDef:String": true }
}
});
let m = map(&schema);
assert_eq!(m.get("id"), Some(&ByteRange { start: 0, end: 4 }));
assert_eq!(m.get("name"), Some(&ByteRange { start: 4, end: 8 }));
}
#[test]
fn non_final_maxlength_string_allowed_in_aligned_mode() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"name": { "TypeDef:String": true, "maxLength": 256 },
"id": { "TypeDef:Uint32": true }
}
});
let m = map(&schema);
assert_eq!(m.get("name"), Some(&ByteRange { start: 0, end: 256 }));
assert_eq!(m.get("id"), Some(&ByteRange { start: 256, end: 260 }));
}
#[test]
fn non_final_offset_indirect_string_allowed_in_aligned_mode() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"blob": { "TypeDef:String": { "encoding": "offset-indirect" } },
"id": { "TypeDef:Uint32": true }
}
});
let m = map(&schema);
assert_eq!(m.get("blob"), Some(&ByteRange { start: 0, end: 8 }));
assert_eq!(m.get("id"), Some(&ByteRange { start: 8, end: 12 }));
}
#[test]
fn struct_level_align_rounds_up_total() {
let schema = json!({
"TypeDef:Struct": true,
"align": 16,
"properties": {
"flag": { "TypeDef:Uint8": true }
}
});
let m = map(&schema);
assert_eq!(m.get("flag"), Some(&ByteRange { start: 0, end: 1 }));
assert_eq!(m.total_size(), 16);
}
#[test]
fn field_level_align_overrides_struct_default() {
let schema = json!({
"TypeDef:Struct": true,
"align": 1,
"properties": {
"tag": { "TypeDef:Uint8": true },
"flag": { "TypeDef:Uint8": true, "align": 16 },
"id": { "TypeDef:Uint32": true }
}
});
let m = map(&schema);
assert_eq!(m.get("tag"), Some(&ByteRange { start: 0, end: 1 }));
assert_eq!(m.get("flag"), Some(&ByteRange { start: 16, end: 17 }));
assert_eq!(m.get("id"), Some(&ByteRange { start: 20, end: 24 }));
assert_eq!(m.total_size(), 24);
}
#[test]
fn field_align_smaller_than_struct_default() {
let schema = json!({
"TypeDef:Struct": true,
"align": 8,
"properties": {
"a": { "TypeDef:Uint8": true },
"b": { "TypeDef:Uint32": true, "align": 1 }
}
});
let m = map(&schema);
assert_eq!(m.get("a"), Some(&ByteRange { start: 0, end: 1 }));
assert_eq!(m.get("b"), Some(&ByteRange { start: 1, end: 5 }));
assert_eq!(m.total_size(), 8);
}
#[test]
fn iter_returns_all_paths_in_order() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"a": { "TypeDef:Uint8": true },
"b": { "TypeDef:Uint32": true }
}
});
let m = map(&schema);
let paths: Vec<&String> = m.iter().map(|(p, _)| p).collect();
assert_eq!(paths, vec!["a", "b"]);
}
#[test]
fn compute_rejects_non_struct_top_level() {
let schema =
json!({ "TypeDef:Union": true, "discriminator": { "kind": "byte" }, "mapping": {} });
let err = OffsetMap::compute(&schema).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn compute_rejects_missing_typedef_kind() {
let schema = json!({ "type": "object", "properties": {} });
let err = OffsetMap::compute(&schema).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn byte_range_len_and_is_empty() {
let r = ByteRange { start: 4, end: 8 };
assert_eq!(r.len(), 4);
assert!(!r.is_empty());
let empty = ByteRange { start: 5, end: 5 };
assert_eq!(empty.len(), 0);
assert!(empty.is_empty());
}
}
+812
View File
@@ -0,0 +1,812 @@
//! Schema layer: TypeDef kind detection, annotation parsing, `$ref`
//! normalization, and the `Endian` enum.
//!
//! Per ADR-097 and the schema-layer spec. This module provides the
//! foundational types and functions that every other module depends on:
//! `TypeDef:*` kind detection, fixed byte-size lookups, natural alignment,
//! schema annotation parsing (`encoding`, `align`, `maxLength`, `endian`),
//! TUnion discriminator parsing, and `$ref` normalization from TypeBox
//! bare-name refs to JSON Pointer refs.
use crate::error::TypedefError;
use serde_json::Value;
use std::fmt;
use std::str::FromStr;
const TYPEDEF_PREFIX: &str = "TypeDef:";
pub(crate) const U32_SIZE: usize = 4;
pub(crate) const DISCRIMINATOR_PATH: &str = "__discriminator";
const BYTE_DISCRIMINATOR_TYPES: &[TypeDefKind] = &[
TypeDefKind::Uint8,
TypeDefKind::Uint16,
TypeDefKind::Uint32,
];
/// The 19 `TypeDef:*` kinds recognized by the engine.
///
/// Each variant corresponds to a `TypeDef:<name>` JSON Schema keyword.
/// The enum provides compile-time exhaustiveness checking and integer
/// discriminant dispatch (jump table) instead of string comparison.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TypeDefKind {
Int8,
Int16,
Int32,
Int64,
Uint8,
Uint16,
Uint32,
Uint64,
Float32,
Float64,
Boolean,
Enum,
String,
Bytes,
Struct,
Union,
Array,
Record,
Timestamp,
}
impl TypeDefKind {
/// The JSON Schema keyword string, e.g. `"TypeDef:Int8"`.
pub fn as_str(self) -> &'static str {
match self {
TypeDefKind::Int8 => "TypeDef:Int8",
TypeDefKind::Int16 => "TypeDef:Int16",
TypeDefKind::Int32 => "TypeDef:Int32",
TypeDefKind::Int64 => "TypeDef:Int64",
TypeDefKind::Uint8 => "TypeDef:Uint8",
TypeDefKind::Uint16 => "TypeDef:Uint16",
TypeDefKind::Uint32 => "TypeDef:Uint32",
TypeDefKind::Uint64 => "TypeDef:Uint64",
TypeDefKind::Float32 => "TypeDef:Float32",
TypeDefKind::Float64 => "TypeDef:Float64",
TypeDefKind::Boolean => "TypeDef:Boolean",
TypeDefKind::Enum => "TypeDef:Enum",
TypeDefKind::String => "TypeDef:String",
TypeDefKind::Bytes => "TypeDef:Bytes",
TypeDefKind::Struct => "TypeDef:Struct",
TypeDefKind::Union => "TypeDef:Union",
TypeDefKind::Array => "TypeDef:Array",
TypeDefKind::Record => "TypeDef:Record",
TypeDefKind::Timestamp => "TypeDef:Timestamp",
}
}
/// Fixed byte size, or `None` for variable-size / composite kinds.
pub fn type_size(self) -> Option<usize> {
match self {
TypeDefKind::Float32 | TypeDefKind::Int32 | TypeDefKind::Uint32 | TypeDefKind::Enum => {
Some(4)
}
TypeDefKind::Float64 | TypeDefKind::Int64 | TypeDefKind::Uint64 => Some(8),
TypeDefKind::Int8 | TypeDefKind::Uint8 | TypeDefKind::Boolean => Some(1),
TypeDefKind::Int16 | TypeDefKind::Uint16 => Some(2),
TypeDefKind::String
| TypeDefKind::Bytes
| TypeDefKind::Struct
| TypeDefKind::Union
| TypeDefKind::Array
| TypeDefKind::Record
| TypeDefKind::Timestamp => None,
}
}
/// Natural alignment: 1 for u8/i8/bool, 2 for u16/i16, 4 for u32/i32/f32/enum,
/// 8 for u64/i64/f64, 4 for variable-length (u32 length prefix), 1 for
/// composites.
pub fn natural_alignment(self) -> usize {
match self {
TypeDefKind::Int8 | TypeDefKind::Uint8 | TypeDefKind::Boolean => 1,
TypeDefKind::Int16 | TypeDefKind::Uint16 => 2,
TypeDefKind::Int32
| TypeDefKind::Uint32
| TypeDefKind::Float32
| TypeDefKind::Enum => 4,
TypeDefKind::Float64 | TypeDefKind::Int64 | TypeDefKind::Uint64 => 8,
TypeDefKind::String
| TypeDefKind::Bytes
| TypeDefKind::Record
| TypeDefKind::Timestamp => 4,
TypeDefKind::Struct | TypeDefKind::Union | TypeDefKind::Array => 1,
}
}
/// Returns `true` for fixed-size primitive kinds.
pub fn is_fixed_size(self) -> bool {
matches!(
self,
TypeDefKind::Float32
| TypeDefKind::Float64
| TypeDefKind::Int8
| TypeDefKind::Int16
| TypeDefKind::Int32
| TypeDefKind::Int64
| TypeDefKind::Uint8
| TypeDefKind::Uint16
| TypeDefKind::Uint32
| TypeDefKind::Uint64
| TypeDefKind::Boolean
| TypeDefKind::Enum
)
}
/// Returns `true` for kinds whose read/write functions need an `Endian` parameter.
pub fn needs_endian(self) -> bool {
matches!(
self,
TypeDefKind::Int16
| TypeDefKind::Int32
| TypeDefKind::Int64
| TypeDefKind::Uint16
| TypeDefKind::Uint32
| TypeDefKind::Uint64
| TypeDefKind::Float32
| TypeDefKind::Float64
| TypeDefKind::Enum
| TypeDefKind::String
| TypeDefKind::Bytes
| TypeDefKind::Timestamp
)
}
/// Returns `true` for composite kinds (Struct, Union, Array, Record).
pub fn is_composite(self) -> bool {
matches!(
self,
TypeDefKind::Struct | TypeDefKind::Union | TypeDefKind::Array | TypeDefKind::Record
)
}
/// Returns `true` for variable-length kinds (String, Bytes, Timestamp, Record).
pub fn is_variable_length(self) -> bool {
matches!(
self,
TypeDefKind::String
| TypeDefKind::Bytes
| TypeDefKind::Timestamp
| TypeDefKind::Record
)
}
}
impl fmt::Display for TypeDefKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for TypeDefKind {
type Err = TypedefError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"TypeDef:Int8" => Ok(TypeDefKind::Int8),
"TypeDef:Int16" => Ok(TypeDefKind::Int16),
"TypeDef:Int32" => Ok(TypeDefKind::Int32),
"TypeDef:Int64" => Ok(TypeDefKind::Int64),
"TypeDef:Uint8" => Ok(TypeDefKind::Uint8),
"TypeDef:Uint16" => Ok(TypeDefKind::Uint16),
"TypeDef:Uint32" => Ok(TypeDefKind::Uint32),
"TypeDef:Uint64" => Ok(TypeDefKind::Uint64),
"TypeDef:Float32" => Ok(TypeDefKind::Float32),
"TypeDef:Float64" => Ok(TypeDefKind::Float64),
"TypeDef:Boolean" => Ok(TypeDefKind::Boolean),
"TypeDef:Enum" => Ok(TypeDefKind::Enum),
"TypeDef:String" => Ok(TypeDefKind::String),
"TypeDef:Bytes" => Ok(TypeDefKind::Bytes),
"TypeDef:Struct" => Ok(TypeDefKind::Struct),
"TypeDef:Union" => Ok(TypeDefKind::Union),
"TypeDef:Array" => Ok(TypeDefKind::Array),
"TypeDef:Record" => Ok(TypeDefKind::Record),
"TypeDef:Timestamp" => Ok(TypeDefKind::Timestamp),
other => Err(TypedefError::Schema(format!(
"unknown TypeDef kind: {other}"
))),
}
}
}
/// Returns the `TypeDef:*` kind string if the schema node declares one.
/// Returns `None` if the node has no `TypeDef:*` keyword.
///
/// A TypeDef kind is recognized when the schema object has a key starting
/// with `TypeDef:` whose value is `true`. (Object form with annotations
/// like `{ "encoding": "..." }` is handled by the annotation parsers, not
/// here — `get_typedef_kind` only checks for the presence of the keyword.)
pub fn get_typedef_kind(node: &Value) -> Option<&str> {
let obj = node.as_object()?;
for key in obj.keys() {
if key.starts_with(TYPEDEF_PREFIX) && obj.get(key) == Some(&Value::Bool(true)) {
return Some(key.as_str());
}
}
None
}
/// Returns the `TypeDefKind` enum variant if the schema node declares one
/// (boolean form only, like `get_typedef_kind`).
pub fn get_typedef_kind_enum(node: &Value) -> Option<TypeDefKind> {
get_typedef_kind(node).and_then(|s| s.parse().ok())
}
/// Byte endianness for multi-byte integer and float fields.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Endian {
Little,
Big,
}
impl Endian {
/// Parse from the schema's `"endian"` annotation. Defaults to `Little`
/// if the annotation is absent or unrecognized.
pub fn from_schema(schema: &Value) -> Self {
parse_endian(schema)
}
}
/// The encoding strategy for a variable-length type.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VariableEncoding {
/// `[length: u32][data]` — the default. Length prefix at a known offset,
/// variable data follows immediately.
LengthPrefixed,
/// `{offset: u32, length: u32}` pointing into a separate data region.
/// The metatensor blob tensor pattern.
OffsetIndirect,
}
/// Parse the `"encoding"` annotation from a variable-length type's keyword value.
///
/// The keyword value may be `true` (shorthand for length-prefixed) or an
/// object with an `"encoding"` field. Defaults to `LengthPrefixed` when
/// absent or unrecognized.
pub fn parse_encoding(keyword_value: &Value) -> VariableEncoding {
match keyword_value {
Value::Bool(true) => VariableEncoding::LengthPrefixed,
Value::Object(obj) => {
let encoding = obj.get("encoding").and_then(Value::as_str);
match encoding {
Some("offset-indirect") => VariableEncoding::OffsetIndirect,
_ => VariableEncoding::LengthPrefixed,
}
}
_ => VariableEncoding::LengthPrefixed,
}
}
/// Parse the `"align"` annotation from a schema node. Returns `None` if not
/// specified or not a non-negative integer.
pub fn parse_align(node: &Value) -> Option<usize> {
let n = node.as_object()?.get("align")?.as_u64()?;
Some(n as usize)
}
/// Parse the `"maxLength"` annotation (standard JSON Schema keyword).
/// Returns `None` if not specified or not a non-negative integer.
pub fn parse_max_length(node: &Value) -> Option<usize> {
let n = node.as_object()?.get("maxLength")?.as_u64()?;
Some(n as usize)
}
/// Parse the `"endian"` annotation. Defaults to `Little` if absent or
/// unrecognized. Operates on any node, not just the root.
pub fn parse_endian(node: &Value) -> Endian {
match node
.as_object()
.and_then(|o| o.get("endian"))
.and_then(Value::as_str)
{
Some("big") => Endian::Big,
_ => Endian::Little,
}
}
/// The kind of TUnion discriminator.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiscriminatorKind {
/// Byte-offset discriminator: a fixed-size integer at a known byte offset.
/// Mapping keys are stringified integers. Used by SFTP type bytes and
/// call protocol event types.
Byte {
/// Byte position of the discriminator within the union's buffer.
offset: usize,
/// The `TypeDef:*` kind of the discriminator (typically
/// `TypeDef:Uint8`).
disc_type: TypeDefKind,
},
/// Field-name discriminator: a named field within the struct. Mapping keys
/// are string values matching the discriminator field's value. The
/// typedef.ts pattern.
Field {
/// The field name that holds the discriminator value.
name: String,
},
}
/// Parse the `"discriminator"` annotation from a TUnion schema node.
///
/// Returns [`TypedefError::Schema`] for malformed discriminators (unknown
/// `kind`, missing required `name`, or an unsupported discriminator `type`).
pub fn parse_discriminator(node: &Value) -> Result<DiscriminatorKind, TypedefError> {
let obj = node.as_object().ok_or_else(|| {
TypedefError::Schema("discriminator requires a schema object".to_string())
})?;
let disc = obj.get("discriminator").ok_or_else(|| {
TypedefError::Schema("union is missing 'discriminator' annotation".to_string())
})?;
let disc_obj = disc
.as_object()
.ok_or_else(|| TypedefError::Schema("'discriminator' must be an object".to_string()))?;
let kind = disc_obj
.get("kind")
.and_then(Value::as_str)
.ok_or_else(|| TypedefError::Schema("discriminator is missing 'kind' field".to_string()))?;
match kind {
"byte" => {
let offset = disc_obj.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize;
let disc_type_str = disc_obj
.get("type")
.and_then(Value::as_str)
.unwrap_or("TypeDef:Uint8");
let disc_type: TypeDefKind = disc_type_str.parse().map_err(|_| {
TypedefError::Schema(format!(
"discriminator 'type' must be one of {BYTE_DISCRIMINATOR_TYPES:?}, got {disc_type_str:?}"
))
})?;
if !BYTE_DISCRIMINATOR_TYPES.contains(&disc_type) {
return Err(TypedefError::Schema(format!(
"discriminator 'type' must be one of {BYTE_DISCRIMINATOR_TYPES:?}, got {disc_type:?}"
)));
}
Ok(DiscriminatorKind::Byte {
offset,
disc_type,
})
}
"field" => {
let name = disc_obj
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| {
TypedefError::Schema(
"field discriminator is missing required 'name' field".to_string(),
)
})?
.to_string();
Ok(DiscriminatorKind::Field { name })
}
other => Err(TypedefError::Schema(format!(
"unknown discriminator 'kind': {other:?} (expected \"byte\" or \"field\")"
))),
}
}
/// Detect a `TypeDef:*` kind from a schema node, accepting either the
/// boolean form (`{ "TypeDef:String": true }`) or the object-annotation
/// form (`{ "TypeDef:String": { "encoding": "..." } }`).
///
/// [`get_typedef_kind`] only recognizes the boolean form; layout computation
/// and engine dispatch also need to recognize the object form so that
/// variable-length encoding annotations don't hide the kind.
pub fn get_typedef_kind_loose(node: &Value) -> Option<&str> {
let obj = node.as_object()?;
for key in obj.keys() {
if key.starts_with(TYPEDEF_PREFIX) && obj.get(key).is_some_and(|v| !v.is_null()) {
return Some(key.as_str());
}
}
None
}
/// Like [`get_typedef_kind_loose`] but returns the parsed [`TypeDefKind`] enum.
pub fn get_typedef_kind_loose_enum(node: &Value) -> Option<TypeDefKind> {
get_typedef_kind_loose(node).and_then(|s| s.parse().ok())
}
/// Resolve a `$ref` against the root schema, or return the inline schema.
///
/// If `node` has a `"$ref"` key, parse the JSON Pointer and walk `root`.
/// Otherwise, return `node` itself (it's an inline schema).
pub fn resolve_ref_or_inline<'a>(node: &'a Value, root: &'a Value) -> Option<&'a Value> {
let obj = node.as_object()?;
if let Some(Value::String(ref_path)) = obj.get("$ref") {
return resolve_ref(root, ref_path);
}
Some(node)
}
/// Resolve a JSON Pointer `$ref` (e.g., `"#/$defs/Read"`) against `root`.
pub fn resolve_ref<'a>(root: &'a Value, ref_path: &str) -> Option<&'a Value> {
let stripped = ref_path.strip_prefix('#').unwrap_or(ref_path);
let stripped = stripped.strip_prefix('/').unwrap_or(stripped);
if stripped.is_empty() {
return Some(root);
}
let mut current = root;
for segment in stripped.split('/') {
let decoded = segment.replace("~1", "/").replace("~0", "~");
if let Ok(idx) = decoded.parse::<usize>() {
current = current.get(idx)?;
} else {
current = current.get(&decoded)?;
}
}
Some(current)
}
/// Walk the schema tree. For every `"$ref"` whose value is a bare name
/// (no `#` prefix), rewrite it to `"#/$defs/<name>"`. Full JSON Pointer refs
/// (starting with `#`) pass through unchanged. Idempotent.
pub fn normalize_refs(schema: &mut Value) {
normalize_refs_recursive(schema);
}
fn normalize_refs_recursive(node: &mut Value) {
if let Value::Object(obj) = node {
if let Some(Value::String(ref s)) = obj.get("$ref") {
if !s.starts_with('#') && !s.is_empty() {
let new_ref = format!("#/$defs/{s}");
if let Some(slot) = obj.get_mut("$ref") {
*slot = Value::String(new_ref);
}
}
}
for value in obj.values_mut() {
normalize_refs_recursive(value);
}
} else if let Value::Array(arr) = node {
for item in arr.iter_mut() {
normalize_refs_recursive(item);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn get_typedef_kind_detects_bool_keyword() {
let schema = json!({"TypeDef:Uint32": true});
assert_eq!(get_typedef_kind(&schema), Some("TypeDef:Uint32"));
}
#[test]
fn get_typedef_kind_ignores_object_keyword() {
let schema = json!({"TypeDef:String": {"encoding": "length-prefixed"}});
assert_eq!(get_typedef_kind(&schema), None);
}
#[test]
fn get_typedef_kind_none_for_plain_schema() {
let schema = json!({"type": "object", "properties": {}});
assert_eq!(get_typedef_kind(&schema), None);
}
#[test]
fn type_size_fixed_kinds() {
assert_eq!(TypeDefKind::Float32.type_size(), Some(4));
assert_eq!(TypeDefKind::Float64.type_size(), Some(8));
assert_eq!(TypeDefKind::Int8.type_size(), Some(1));
assert_eq!(TypeDefKind::Int16.type_size(), Some(2));
assert_eq!(TypeDefKind::Int32.type_size(), Some(4));
assert_eq!(TypeDefKind::Int64.type_size(), Some(8));
assert_eq!(TypeDefKind::Uint8.type_size(), Some(1));
assert_eq!(TypeDefKind::Uint16.type_size(), Some(2));
assert_eq!(TypeDefKind::Uint32.type_size(), Some(4));
assert_eq!(TypeDefKind::Uint64.type_size(), Some(8));
assert_eq!(TypeDefKind::Boolean.type_size(), Some(1));
assert_eq!(TypeDefKind::Enum.type_size(), Some(4));
}
#[test]
fn type_size_variable_and_composite_kinds() {
for kind in [
TypeDefKind::String,
TypeDefKind::Bytes,
TypeDefKind::Struct,
TypeDefKind::Union,
TypeDefKind::Array,
TypeDefKind::Record,
TypeDefKind::Timestamp,
] {
assert_eq!(kind.type_size(), None, "failed for {kind}");
}
}
#[test]
fn type_size_unknown_kind_returns_none() {
assert!("TypeDef:Uint128".parse::<TypeDefKind>().is_err());
assert!("TypeDef:Int128".parse::<TypeDefKind>().is_err());
assert!("not-a-typedef".parse::<TypeDefKind>().is_err());
}
#[test]
fn natural_alignment_matches_spec() {
assert_eq!(TypeDefKind::Int8.natural_alignment(), 1);
assert_eq!(TypeDefKind::Uint8.natural_alignment(), 1);
assert_eq!(TypeDefKind::Boolean.natural_alignment(), 1);
assert_eq!(TypeDefKind::Int16.natural_alignment(), 2);
assert_eq!(TypeDefKind::Uint16.natural_alignment(), 2);
assert_eq!(TypeDefKind::Int32.natural_alignment(), 4);
assert_eq!(TypeDefKind::Uint32.natural_alignment(), 4);
assert_eq!(TypeDefKind::Float32.natural_alignment(), 4);
assert_eq!(TypeDefKind::Enum.natural_alignment(), 4);
assert_eq!(TypeDefKind::Float64.natural_alignment(), 8);
assert_eq!(TypeDefKind::Int64.natural_alignment(), 8);
assert_eq!(TypeDefKind::Uint64.natural_alignment(), 8);
assert_eq!(TypeDefKind::String.natural_alignment(), 4);
assert_eq!(TypeDefKind::Bytes.natural_alignment(), 4);
assert_eq!(TypeDefKind::Record.natural_alignment(), 4);
assert_eq!(TypeDefKind::Timestamp.natural_alignment(), 4);
assert_eq!(TypeDefKind::Struct.natural_alignment(), 1);
assert_eq!(TypeDefKind::Union.natural_alignment(), 1);
assert_eq!(TypeDefKind::Array.natural_alignment(), 1);
}
#[test]
fn is_fixed_size_classifies_correctly() {
for kind in [
TypeDefKind::Float32,
TypeDefKind::Float64,
TypeDefKind::Int8,
TypeDefKind::Int16,
TypeDefKind::Int32,
TypeDefKind::Int64,
TypeDefKind::Uint8,
TypeDefKind::Uint16,
TypeDefKind::Uint32,
TypeDefKind::Uint64,
TypeDefKind::Boolean,
TypeDefKind::Enum,
] {
assert!(kind.is_fixed_size(), "expected fixed: {kind}");
}
for kind in [
TypeDefKind::String,
TypeDefKind::Bytes,
TypeDefKind::Struct,
TypeDefKind::Union,
TypeDefKind::Array,
TypeDefKind::Record,
TypeDefKind::Timestamp,
] {
assert!(!kind.is_fixed_size(), "expected variable: {kind}");
}
}
#[test]
fn endian_from_schema_defaults_to_little() {
assert_eq!(Endian::from_schema(&json!({})), Endian::Little);
assert_eq!(
Endian::from_schema(&json!({"endian": "little"})),
Endian::Little
);
assert_eq!(
Endian::from_schema(&json!({"endian": "weird"})),
Endian::Little
);
}
#[test]
fn endian_from_schema_big() {
assert_eq!(Endian::from_schema(&json!({"endian": "big"})), Endian::Big);
}
#[test]
fn parse_encoding_shorthand_true() {
assert_eq!(
parse_encoding(&json!(true)),
VariableEncoding::LengthPrefixed
);
}
#[test]
fn parse_encoding_object_length_prefixed() {
assert_eq!(
parse_encoding(&json!({"encoding": "length-prefixed"})),
VariableEncoding::LengthPrefixed
);
}
#[test]
fn parse_encoding_object_offset_indirect() {
assert_eq!(
parse_encoding(&json!({"encoding": "offset-indirect"})),
VariableEncoding::OffsetIndirect
);
}
#[test]
fn parse_encoding_unknown_defaults_to_length_prefixed() {
assert_eq!(
parse_encoding(&json!({"encoding": "weird"})),
VariableEncoding::LengthPrefixed
);
assert_eq!(parse_encoding(&json!(42)), VariableEncoding::LengthPrefixed);
assert_eq!(
parse_encoding(&json!(null)),
VariableEncoding::LengthPrefixed
);
}
#[test]
fn parse_align_returns_value() {
assert_eq!(parse_align(&json!({"align": 256})), Some(256));
assert_eq!(parse_align(&json!({"align": 0})), Some(0));
}
#[test]
fn parse_align_none_when_absent() {
assert_eq!(parse_align(&json!({})), None);
assert_eq!(parse_align(&json!({"align": "not-a-number"})), None);
}
#[test]
fn parse_max_length_returns_value() {
assert_eq!(parse_max_length(&json!({"maxLength": 1024})), Some(1024));
}
#[test]
fn parse_max_length_none_when_absent() {
assert_eq!(parse_max_length(&json!({})), None);
assert_eq!(parse_max_length(&json!({"maxLength": "x"})), None);
}
#[test]
fn parse_endian_alias_matches_from_schema() {
assert_eq!(parse_endian(&json!({"endian": "big"})), Endian::Big);
assert_eq!(parse_endian(&json!({})), Endian::Little);
}
#[test]
fn parse_discriminator_byte_default_offset_and_type() {
let schema = json!({"discriminator": {"kind": "byte"}});
let disc = parse_discriminator(&schema).expect("byte discriminator");
assert_eq!(
disc,
DiscriminatorKind::Byte {
offset: 0,
disc_type: TypeDefKind::Uint8,
}
);
}
#[test]
fn parse_discriminator_byte_explicit() {
let schema = json!({
"discriminator": {"kind": "byte", "offset": 4, "type": "TypeDef:Uint16"}
});
let disc = parse_discriminator(&schema).expect("byte discriminator");
assert_eq!(
disc,
DiscriminatorKind::Byte {
offset: 4,
disc_type: TypeDefKind::Uint16,
}
);
}
#[test]
fn parse_discriminator_field() {
let schema = json!({"discriminator": {"kind": "field", "name": "type"}});
let disc = parse_discriminator(&schema).expect("field discriminator");
assert_eq!(
disc,
DiscriminatorKind::Field {
name: "type".to_string()
}
);
}
#[test]
fn parse_discriminator_missing_discriminator_is_error() {
let schema = json!({"TypeDef:Union": true});
assert!(matches!(
parse_discriminator(&schema),
Err(TypedefError::Schema(_))
));
}
#[test]
fn parse_discriminator_field_missing_name_is_error() {
let schema = json!({"discriminator": {"kind": "field"}});
assert!(matches!(
parse_discriminator(&schema),
Err(TypedefError::Schema(_))
));
}
#[test]
fn parse_discriminator_unknown_kind_is_error() {
let schema = json!({"discriminator": {"kind": "magic"}});
assert!(matches!(
parse_discriminator(&schema),
Err(TypedefError::Schema(_))
));
}
#[test]
fn parse_discriminator_byte_invalid_type_is_error() {
let schema = json!({
"discriminator": {"kind": "byte", "type": "TypeDef:Float32"}
});
assert!(matches!(
parse_discriminator(&schema),
Err(TypedefError::Schema(_))
));
}
#[test]
fn normalize_refs_rewrites_bare_name() {
let mut schema = json!({"$ref": "Read"});
normalize_refs(&mut schema);
assert_eq!(schema, json!({"$ref": "#/$defs/Read"}));
}
#[test]
fn normalize_refs_leaves_pointer_ref_unchanged() {
let mut schema = json!({"$ref": "#/$defs/Read"});
normalize_refs(&mut schema);
assert_eq!(schema, json!({"$ref": "#/$defs/Read"}));
}
#[test]
fn normalize_refs_is_idempotent() {
let mut schema = json!({"$ref": "Read"});
normalize_refs(&mut schema);
normalize_refs(&mut schema);
assert_eq!(schema, json!({"$ref": "#/$defs/Read"}));
}
#[test]
fn normalize_refs_walks_nested_objects() {
let mut schema = json!({
"properties": {
"child": {"$ref": "Child"},
"other": {"$ref": "#/$defs/Other"}
},
"items": [
{"$ref": "InArray"},
{"foo": {"$ref": "Deep"}}
]
});
normalize_refs(&mut schema);
assert_eq!(
schema,
json!({
"properties": {
"child": {"$ref": "#/$defs/Child"},
"other": {"$ref": "#/$defs/Other"}
},
"items": [
{"$ref": "#/$defs/InArray"},
{"foo": {"$ref": "#/$defs/Deep"}}
]
})
);
}
#[test]
fn normalize_refs_preserves_sibling_keys() {
let mut schema = json!({
"$ref": "Read",
"typedef:annotation": "kept"
});
normalize_refs(&mut schema);
assert_eq!(
schema,
json!({
"$ref": "#/$defs/Read",
"typedef:annotation": "kept"
})
);
}
}
File diff suppressed because it is too large. Load diff
+645
View File
@@ -0,0 +1,645 @@
//! TUnion discriminator dispatch (ADR-097 §4).
//!
//! TUnion supports two discriminator kinds: byte-offset (protocol
//! dispatch, e.g., SFTP type bytes) and field-name (typedef.ts string
//! pattern). This module reads the discriminator value from a byte
//! buffer, looks up the variant schema in the union's `mapping`, and
//! reports the offset where the variant struct begins.
//!
//! All reads go through [`crate::data_access`] so bounds checks and
//! endianness handling are uniform with the rest of the engine.
use crate::data_access::{read_enum, read_string, read_u16, read_u32, read_u8};
use crate::error::TypedefError;
use crate::schema::{get_typedef_kind, parse_discriminator, DiscriminatorKind, Endian, TypeDefKind, DISCRIMINATOR_PATH, U32_SIZE};
use serde_json::Value;
const STRING_PREFIX_SIZE: usize = 4;
/// The result of reading a TUnion discriminator.
#[derive(Debug, Clone)]
pub struct UnionDispatch {
/// The mapping key (stringified discriminator value for byte-offset,
/// string value for field-name).
pub key: String,
/// The byte offset where the variant struct starts.
pub variant_offset: usize,
/// The size of the discriminator in bytes.
pub discriminator_size: usize,
}
/// Read the discriminator value from a byte-offset TUnion.
///
/// The discriminator is a fixed-size integer at a known byte offset.
/// Returns the mapping key (as a string) and the variant struct offset.
///
/// This is the SFTP `Packet` enum pattern — byte 0 is the type byte,
/// bytes 1..N are the variant struct. The call protocol's event type
/// dispatch uses the same pattern.
///
/// # Errors
///
/// - [`TypedefError::Schema`] if the discriminator annotation is missing
/// or malformed, or if the discriminator `type` is not one of
/// `TypeDef:Uint8` / `TypeDef:Uint16` / `TypeDef:Uint32`.
/// - [`TypedefError::Access`] if the buffer is too short to contain the
/// discriminator, or if the read value is not present in the union's
/// `mapping`.
pub fn read_byte_discriminator(
buffer: &[u8],
union_schema: &Value,
endian: Endian,
) -> Result<UnionDispatch, TypedefError> {
let disc = parse_discriminator(union_schema)?;
let (offset, disc_type) = match disc {
DiscriminatorKind::Byte { offset, disc_type } => (offset, disc_type),
DiscriminatorKind::Field { .. } => {
return Err(TypedefError::Schema(
"read_byte_discriminator requires a byte-offset discriminator".to_string(),
));
}
};
let (disc_value, discriminator_size) = match disc_type {
TypeDefKind::Uint8 => (u32::from(read_u8(buffer, offset, DISCRIMINATOR_PATH)?), 1),
TypeDefKind::Uint16 => (
u32::from(read_u16(buffer, offset, DISCRIMINATOR_PATH, endian)?),
2,
),
TypeDefKind::Uint32 => (read_u32(buffer, offset, DISCRIMINATOR_PATH, endian)?, 4),
other => {
return Err(TypedefError::Schema(format!(
"unsupported byte discriminator type: {other}"
)));
}
};
let key = disc_value.to_string();
verify_mapping_key(union_schema, &key, DISCRIMINATOR_PATH, &key)?;
let variant_offset =
offset
.checked_add(discriminator_size)
.ok_or_else(|| TypedefError::Access {
field_path: DISCRIMINATOR_PATH.to_string(),
reason: format!(
"offset {offset} + discriminator_size {discriminator_size} overflows usize"
),
})?;
Ok(UnionDispatch {
key,
variant_offset,
discriminator_size,
})
}
/// Read the discriminator value from a field-name TUnion.
///
/// The discriminator is a named field within the struct — its offset
/// is computed like any other field. The consumer provides the
/// discriminator field's offset (from the OffsetMap or LayoutBuilder).
///
/// This is the typedef.ts `TUnion` pattern — the discriminator is a
/// field like any other, and the mapping keys are string values.
///
/// # Errors
///
/// - [`TypedefError::Schema`] if the discriminator annotation is missing
/// or malformed, the discriminator field is not declared in
/// `properties`, the field has no `TypeDef:*` kind, or the field's
/// kind is not one of `TypeDef:String` / `TypeDef:Uint8` /
/// `TypeDef:Enum`.
/// - [`TypedefError::Access`] if the buffer is too short to contain the
/// discriminator field, or if the read value is not present in the
/// union's `mapping`.
pub fn read_field_discriminator(
buffer: &[u8],
union_schema: &Value,
disc_field_offset: usize,
endian: Endian,
) -> Result<UnionDispatch, TypedefError> {
let disc = parse_discriminator(union_schema)?;
let name = match disc {
DiscriminatorKind::Field { name } => name,
DiscriminatorKind::Byte { .. } => {
return Err(TypedefError::Schema(
"read_field_discriminator requires a field-name discriminator".to_string(),
));
}
};
let field_schema = union_schema
.get("properties")
.and_then(Value::as_object)
.and_then(|props| props.get(&name))
.ok_or_else(|| {
TypedefError::Schema(format!(
"discriminator field '{name}' not found in union properties"
))
})?;
let kind = get_typedef_kind(field_schema)
.and_then(|s| s.parse::<TypeDefKind>().ok())
.ok_or_else(|| {
TypedefError::Schema(format!(
"discriminator field '{name}' has no TypeDef:* kind"
))
})?;
let (key, discriminator_field_size) = match kind {
TypeDefKind::String => {
let s = read_string(buffer, disc_field_offset, &name, endian)?;
let size =
STRING_PREFIX_SIZE
.checked_add(s.len())
.ok_or_else(|| TypedefError::Access {
field_path: name.clone(),
reason: format!(
"string prefix {STRING_PREFIX_SIZE} + data length {} overflows usize",
s.len()
),
})?;
(s.to_string(), size)
}
TypeDefKind::Uint8 => {
let v = read_u8(buffer, disc_field_offset, &name)?;
(v.to_string(), 1)
}
TypeDefKind::Enum => {
let v = read_enum(buffer, disc_field_offset, &name, endian)?;
(v.to_string(), U32_SIZE)
}
other => {
return Err(TypedefError::Schema(format!(
"unsupported discriminator field type: {other}"
)));
}
};
verify_mapping_key(union_schema, &key, &name, &key)?;
let variant_offset = disc_field_offset
.checked_add(discriminator_field_size)
.ok_or_else(|| TypedefError::Access {
field_path: name.clone(),
reason: format!(
"disc_field_offset {disc_field_offset} + discriminator_field_size {discriminator_field_size} overflows usize"
),
})?;
Ok(UnionDispatch {
key,
variant_offset,
discriminator_size: discriminator_field_size,
})
}
/// Look up a variant schema from the union's mapping.
///
/// Returns the variant schema. Inline schemas are returned directly.
/// `$ref` pointers of the form `"#/$defs/<name>"` are resolved against
/// the `union_schema`'s own `$defs` block (when the union schema is the
/// schema root). For nested unions whose `$defs` live on an ancestor,
/// the caller (typically `TypedefEngine::compile`) is expected to
/// resolve refs before reaching this function, or to inline the
/// variant schemas into the mapping at load time.
///
/// # Errors
///
/// - [`TypedefError::Schema`] if the union has no `mapping` object, the
/// `key` is not present, a `$ref` is malformed, or a `$ref` cannot be
/// resolved against the union schema's own `$defs`.
pub fn resolve_variant<'a>(union_schema: &'a Value, key: &str) -> Result<&'a Value, TypedefError> {
let mapping = union_schema
.get("mapping")
.and_then(Value::as_object)
.ok_or_else(|| TypedefError::Schema("union is missing 'mapping' object".to_string()))?;
let variant = mapping
.get(key)
.ok_or_else(|| TypedefError::Schema(format!("unknown mapping key: {key}")))?;
let ref_str = match variant.get("$ref").and_then(Value::as_str) {
Some(r) => r,
None => return Ok(variant),
};
let pointer = ref_str
.strip_prefix('#')
.ok_or_else(|| TypedefError::Schema(format!("unsupported $ref form: {ref_str}")))?;
let resolved = resolve_json_pointer(union_schema, pointer).ok_or_else(|| {
TypedefError::Schema(format!(
"cannot resolve $ref {ref_str} against union schema; ensure refs are inlined or the union schema contains $defs"
))
})?;
Ok(resolved)
}
/// Get the discriminator size in bytes for a byte-offset discriminator.
///
/// Returns 1 for `TypeDef:Uint8`, 2 for `TypeDef:Uint16`, and 4 for
/// `TypeDef:Uint32`. Field-name discriminators have no fixed size and
/// produce a [`TypedefError::Schema`].
///
/// # Errors
///
/// - [`TypedefError::Schema`] if the discriminator annotation is
/// missing/malformed, the discriminator `type` is unsupported, or the
/// discriminator is a field-name discriminator.
pub fn discriminator_size(union_schema: &Value) -> Result<usize, TypedefError> {
let disc = parse_discriminator(union_schema)?;
match disc {
DiscriminatorKind::Byte { disc_type, .. } => match disc_type {
TypeDefKind::Uint8 => Ok(1),
TypeDefKind::Uint16 => Ok(2),
TypeDefKind::Uint32 => Ok(4),
other => Err(TypedefError::Schema(format!(
"unsupported byte discriminator type: {other}"
))),
},
DiscriminatorKind::Field { .. } => Err(TypedefError::Schema(
"field-name discriminator has no fixed size".to_string(),
)),
}
}
fn verify_mapping_key(
union_schema: &Value,
key: &str,
field_path: &str,
raw_value: &str,
) -> Result<(), TypedefError> {
let in_mapping = union_schema
.get("mapping")
.and_then(Value::as_object)
.map(|m| m.contains_key(key))
.unwrap_or(false);
if in_mapping {
Ok(())
} else {
Err(TypedefError::Access {
field_path: field_path.to_string(),
reason: format!("unknown discriminator value: {raw_value}"),
})
}
}
fn resolve_json_pointer<'a>(root: &'a Value, pointer: &str) -> Option<&'a Value> {
if pointer.is_empty() {
return Some(root);
}
let trimmed = pointer.strip_prefix('/')?;
let mut current = root;
for unescaped in trimmed.split('/') {
let segment = unescape_json_pointer_token(unescaped)?;
current = current.get(&segment)?;
}
Some(current)
}
fn unescape_json_pointer_token(token: &str) -> Option<String> {
let mut out = String::with_capacity(token.len());
let mut chars = token.chars();
while let Some(c) = chars.next() {
match c {
'~' => match chars.next() {
Some('0') => out.push('~'),
Some('1') => out.push('/'),
_ => return None,
},
other => out.push(other),
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const LE: Endian = Endian::Little;
const BE: Endian = Endian::Big;
fn byte_union_schema(offset: usize, disc_type: &str) -> Value {
json!({
"TypeDef:Union": true,
"discriminator": {"kind": "byte", "offset": offset, "type": disc_type},
"mapping": {
"5": {"TypeDef:Struct": true, "properties": {"id": {"TypeDef:Uint32": true}}},
"6": {"TypeDef:Struct": true, "properties": {"len": {"TypeDef:Uint16": true}}}
}
})
}
fn field_union_schema(field_name: &str, field_kind: &str) -> Value {
let field_schema = match field_kind {
"TypeDef:Enum" => json!({
"TypeDef:Enum": true,
"enum": ["read", "write"]
}),
_ => json!({field_kind: true}),
};
let (key_a, key_b) = match field_kind {
"TypeDef:String" => ("read", "write"),
_ => ("0", "1"),
};
json!({
"TypeDef:Union": true,
"discriminator": {"kind": "field", "name": field_name},
"properties": {
field_name: field_schema
},
"mapping": {
key_a: {"TypeDef:Struct": true, "properties": {"n": {"TypeDef:Uint32": true}}},
key_b: {"TypeDef:Struct": true, "properties": {"m": {"TypeDef:Uint16": true}}}
}
})
}
#[test]
fn read_byte_discriminator_uint8_default_offset() {
let schema = byte_union_schema(0, "TypeDef:Uint8");
let buf = [5u8, 0xAA, 0xBB, 0xCC];
let d = read_byte_discriminator(&buf, &schema, LE).expect("read");
assert_eq!(d.key, "5");
assert_eq!(d.variant_offset, 1);
assert_eq!(d.discriminator_size, 1);
}
#[test]
fn read_byte_discriminator_uint8_big_endian() {
let schema = byte_union_schema(0, "TypeDef:Uint8");
let buf = [6u8];
let d = read_byte_discriminator(&buf, &schema, BE).expect("read");
assert_eq!(d.key, "6");
assert_eq!(d.variant_offset, 1);
}
#[test]
fn read_byte_discriminator_uint16_little_endian() {
let schema = byte_union_schema(2, "TypeDef:Uint16");
let mut buf = vec![0u8; 4];
buf[2..4].copy_from_slice(&5u16.to_le_bytes());
let d = read_byte_discriminator(&buf, &schema, LE).expect("read");
assert_eq!(d.key, "5");
assert_eq!(d.variant_offset, 4);
assert_eq!(d.discriminator_size, 2);
}
#[test]
fn read_byte_discriminator_uint16_big_endian() {
let schema = byte_union_schema(0, "TypeDef:Uint16");
let buf = [0x00, 0x06, 0xAA, 0xBB];
let d = read_byte_discriminator(&buf, &schema, BE).expect("read");
assert_eq!(d.key, "6");
assert_eq!(d.variant_offset, 2);
}
#[test]
fn read_byte_discriminator_uint32_little_endian() {
let schema = byte_union_schema(0, "TypeDef:Uint32");
let mut buf = vec![0u8; 8];
buf[0..4].copy_from_slice(&5u32.to_le_bytes());
let d = read_byte_discriminator(&buf, &schema, LE).expect("read");
assert_eq!(d.key, "5");
assert_eq!(d.variant_offset, 4);
assert_eq!(d.discriminator_size, 4);
}
#[test]
fn read_byte_discriminator_uint32_big_endian() {
let schema = byte_union_schema(0, "TypeDef:Uint32");
let mut buf = vec![0u8; 8];
buf[0..4].copy_from_slice(&6u32.to_be_bytes());
let d = read_byte_discriminator(&buf, &schema, BE).expect("read");
assert_eq!(d.key, "6");
assert_eq!(d.variant_offset, 4);
}
#[test]
fn read_byte_discriminator_unknown_value_is_access_error() {
let schema = byte_union_schema(0, "TypeDef:Uint8");
let buf = [99u8];
let err = read_byte_discriminator(&buf, &schema, LE).unwrap_err();
match err {
TypedefError::Access { field_path, reason } => {
assert_eq!(field_path, DISCRIMINATOR_PATH);
assert!(reason.contains("99"), "reason: {reason}");
}
other => panic!("expected Access, got {other:?}"),
}
}
#[test]
fn read_byte_discriminator_buffer_too_short_is_access_error() {
let schema = byte_union_schema(4, "TypeDef:Uint32");
let buf = [0u8; 2];
let err = read_byte_discriminator(&buf, &schema, LE).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }));
}
#[test]
fn read_byte_discriminator_field_kind_is_schema_error() {
let schema = field_union_schema("type", "TypeDef:String");
let buf = [0u8; 16];
let err = read_byte_discriminator(&buf, &schema, LE).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn read_field_discriminator_string() {
let schema = field_union_schema("type", "TypeDef:String");
let mut buf = vec![0u8; 32];
let value = "read";
let len_bytes = (value.len() as u32).to_le_bytes();
buf[0..4].copy_from_slice(&len_bytes);
buf[4..4 + value.len()].copy_from_slice(value.as_bytes());
let d = read_field_discriminator(&buf, &schema, 0, LE).expect("read");
assert_eq!(d.key, "read");
assert_eq!(d.variant_offset, 4 + value.len());
assert_eq!(d.discriminator_size, 4 + value.len());
}
#[test]
fn read_field_discriminator_uint8() {
let schema = field_union_schema("type", "TypeDef:Uint8");
let mut buf = vec![0u8; 8];
buf[0] = 0;
let d = read_field_discriminator(&buf, &schema, 0, LE).expect("read");
assert_eq!(d.key, "0");
assert_eq!(d.variant_offset, 1);
assert_eq!(d.discriminator_size, 1);
}
#[test]
fn read_field_discriminator_enum() {
let schema = field_union_schema("type", "TypeDef:Enum");
let mut buf = vec![0u8; 8];
buf[0..4].copy_from_slice(&0u32.to_le_bytes());
let d = read_field_discriminator(&buf, &schema, 0, LE).expect("read");
assert_eq!(d.key, "0");
assert_eq!(d.variant_offset, 4);
assert_eq!(d.discriminator_size, 4);
}
#[test]
fn read_field_discriminator_string_big_endian() {
let schema = field_union_schema("type", "TypeDef:String");
let mut buf = vec![0u8; 32];
let value = "write";
let len_bytes = (value.len() as u32).to_be_bytes();
buf[0..4].copy_from_slice(&len_bytes);
buf[4..4 + value.len()].copy_from_slice(value.as_bytes());
let d = read_field_discriminator(&buf, &schema, 0, BE).expect("read");
assert_eq!(d.key, "write");
assert_eq!(d.variant_offset, 4 + value.len());
}
#[test]
fn read_field_discriminator_unknown_value_is_access_error() {
let schema = field_union_schema("type", "TypeDef:Uint8");
let mut buf = vec![0u8; 8];
buf[0] = 99;
let err = read_field_discriminator(&buf, &schema, 0, LE).unwrap_err();
match err {
TypedefError::Access { field_path, reason } => {
assert_eq!(field_path, "type");
assert!(reason.contains("99"), "reason: {reason}");
}
other => panic!("expected Access, got {other:?}"),
}
}
#[test]
fn read_field_discriminator_field_not_found_is_schema_error() {
let schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "field", "name": "missing"},
"properties": {"other": {"TypeDef:Uint8": true}},
"mapping": {"5": {"TypeDef:Struct": true}}
});
let buf = [0u8; 4];
let err = read_field_discriminator(&buf, &schema, 0, LE).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn read_field_discriminator_no_typedef_kind_is_schema_error() {
let schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "field", "name": "type"},
"properties": {"type": {"type": "string"}},
"mapping": {"read": {"TypeDef:Struct": true}}
});
let buf = [0u8; 4];
let err = read_field_discriminator(&buf, &schema, 0, LE).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn read_field_discriminator_unsupported_kind_is_schema_error() {
let schema = field_union_schema("type", "TypeDef:Float32");
let buf = [0u8; 8];
let err = read_field_discriminator(&buf, &schema, 0, LE).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn read_field_discriminator_byte_kind_is_schema_error() {
let schema = byte_union_schema(0, "TypeDef:Uint8");
let buf = [5u8];
let err = read_field_discriminator(&buf, &schema, 0, LE).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn resolve_variant_inline_schema() {
let schema = byte_union_schema(0, "TypeDef:Uint8");
let variant = resolve_variant(&schema, "5").expect("resolve");
assert_eq!(
variant.get("TypeDef:Struct").and_then(Value::as_bool),
Some(true)
);
}
#[test]
fn resolve_variant_ref_against_own_defs() {
let schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "byte"},
"mapping": {
"5": {"$ref": "#/$defs/Read"}
},
"$defs": {
"Read": {"TypeDef:Struct": true, "properties": {"id": {"TypeDef:Uint32": true}}}
}
});
let variant = resolve_variant(&schema, "5").expect("resolve");
assert_eq!(
variant.get("TypeDef:Struct").and_then(Value::as_bool),
Some(true)
);
}
#[test]
fn resolve_variant_unknown_key_is_schema_error() {
let schema = byte_union_schema(0, "TypeDef:Uint8");
let err = resolve_variant(&schema, "999").unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn resolve_variant_missing_mapping_is_schema_error() {
let schema = json!({"TypeDef:Union": true, "discriminator": {"kind": "byte"}});
let err = resolve_variant(&schema, "5").unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn resolve_variant_unresolvable_ref_is_schema_error() {
let schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "byte"},
"mapping": {
"5": {"$ref": "#/$defs/Read"}
}
});
let err = resolve_variant(&schema, "5").unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn discriminator_size_uint8() {
let schema = byte_union_schema(0, "TypeDef:Uint8");
assert_eq!(discriminator_size(&schema).unwrap(), 1);
}
#[test]
fn discriminator_size_uint16() {
let schema = byte_union_schema(0, "TypeDef:Uint16");
assert_eq!(discriminator_size(&schema).unwrap(), 2);
}
#[test]
fn discriminator_size_uint32() {
let schema = byte_union_schema(0, "TypeDef:Uint32");
assert_eq!(discriminator_size(&schema).unwrap(), 4);
}
#[test]
fn discriminator_size_field_kind_is_schema_error() {
let schema = field_union_schema("type", "TypeDef:String");
let err = discriminator_size(&schema).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn discriminator_size_missing_discriminator_is_schema_error() {
let schema = json!({"TypeDef:Union": true});
let err = discriminator_size(&schema).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
}
+630
View File
@@ -0,0 +1,630 @@
//! Custom keyword validators for all 17 `TypeDef:*` kinds, registered
//! via `jsonschema::options().with_keyword(...)`.
//!
//! Per ADR-098: the `jsonschema` crate handles all structural validation;
//! the custom keywords only validate leaf type constraints. Each validator
//! is a small (~10 line) struct implementing [`jsonschema::Keyword`].
//!
//! The factory closures reject schemas where the keyword is not set to
//! `true` (returning [`jsonschema::ValidationError::schema`]). A few
//! factories read parent context (e.g. `maxLength`) to pass into the
//! validator struct.
use crate::error::TypedefError;
use jsonschema::{Keyword, ValidationError};
use serde_json::{Map, Value};
/// Build a jsonschema validator with all 17 `TypeDef:*` custom keywords
/// registered.
///
/// The returned validator can validate JSON representations of data
/// against the schema's type constraints. Structural validation
/// (`properties`, `required`, `items`, `enum`, ...) is handled by
/// jsonschema's built-in keywords; the custom keywords only check leaf
/// type constraints (range, finiteness, RFC 3339 shape, ...).
///
/// # Errors
///
/// Returns [`TypedefError::Schema`] if the schema is malformed or the
/// underlying jsonschema validator cannot be built.
pub fn build_validator(schema: &Value) -> Result<jsonschema::Validator, TypedefError> {
jsonschema::options()
.with_keyword("TypeDef:Float32", float32_factory)
.with_keyword("TypeDef:Float64", float64_factory)
.with_keyword("TypeDef:Int8", int8_factory)
.with_keyword("TypeDef:Int16", int16_factory)
.with_keyword("TypeDef:Int32", int32_factory)
.with_keyword("TypeDef:Int64", int64_factory)
.with_keyword("TypeDef:Uint8", uint8_factory)
.with_keyword("TypeDef:Uint16", uint16_factory)
.with_keyword("TypeDef:Uint32", uint32_factory)
.with_keyword("TypeDef:Uint64", uint64_factory)
.with_keyword("TypeDef:Boolean", boolean_factory)
.with_keyword("TypeDef:String", string_factory)
.with_keyword("TypeDef:Bytes", bytes_factory)
.with_keyword("TypeDef:Enum", enum_factory)
.with_keyword("TypeDef:Struct", struct_factory)
.with_keyword("TypeDef:Union", union_factory)
.with_keyword("TypeDef:Array", array_factory)
.with_keyword("TypeDef:Record", record_factory)
.with_keyword("TypeDef:Timestamp", timestamp_factory)
.build(schema)
.map_err(|e| TypedefError::Schema(format!("validator build failed: {e}")))
}
// ---------------------------------------------------------------------------
// Numeric validators (generated via macros)
// ---------------------------------------------------------------------------
define_int_validator!(Int8Validator, int8_factory, "TypeDef:Int8", -128, 127);
define_int_validator!(Int16Validator, int16_factory, "TypeDef:Int16", -32768, 32767);
define_int_validator!(Int32Validator, int32_factory, "TypeDef:Int32", -2147483648, 2147483647);
define_uint_validator!(Uint8Validator, uint8_factory, "TypeDef:Uint8", 255);
define_uint_validator!(Uint16Validator, uint16_factory, "TypeDef:Uint16", 65535);
define_uint_validator!(Uint32Validator, uint32_factory, "TypeDef:Uint32", 4294967295);
// Int64/Uint64 use the full i64/u64 range, so the macro's `n <= $max` check
// is always true (clippy: "comparison useless due to type limits"). Write
// them directly — the validator just checks that the JSON value is an
// integer in the right range.
struct Int64Validator;
impl Keyword for Int64Validator {
fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> {
match instance.as_i64() {
Some(_) => Ok(()),
None => Err(ValidationError::custom("expected an i64 integer")),
}
}
fn is_valid(&self, instance: &Value) -> bool {
instance.as_i64().is_some()
}
}
fn int64_factory<'a>(
_parent: &'a Map<String, Value>,
value: &'a Value,
_path: jsonschema::paths::Location,
) -> Result<Box<dyn Keyword>, ValidationError<'a>> {
if value.as_bool() == Some(true) {
Ok(Box::new(Int64Validator))
} else {
Err(ValidationError::schema("TypeDef:Int64 must be set to true"))
}
}
struct Uint64Validator;
impl Keyword for Uint64Validator {
fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> {
match instance.as_u64() {
Some(_) => Ok(()),
None => Err(ValidationError::custom("expected a u64 integer")),
}
}
fn is_valid(&self, instance: &Value) -> bool {
instance.as_u64().is_some()
}
}
fn uint64_factory<'a>(
_parent: &'a Map<String, Value>,
value: &'a Value,
_path: jsonschema::paths::Location,
) -> Result<Box<dyn Keyword>, ValidationError<'a>> {
if value.as_bool() == Some(true) {
Ok(Box::new(Uint64Validator))
} else {
Err(ValidationError::schema("TypeDef:Uint64 must be set to true"))
}
}
define_float_validator!(
Float32Validator,
float32_factory,
"TypeDef:Float32",
"expected a finite f32-compatible number"
);
define_float_validator!(
Float64Validator,
float64_factory,
"TypeDef:Float64",
"expected a finite f64 number"
);
// ---------------------------------------------------------------------------
// String and binary validators (hand-written: need maxLength from parent)
// ---------------------------------------------------------------------------
struct StringValidator {
max_length: Option<usize>,
}
impl Keyword for StringValidator {
fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> {
match instance.as_str() {
Some(s) => {
if let Some(max) = self.max_length {
if s.len() > max {
return Err(ValidationError::custom(format!(
"string byte length {} exceeds maxLength {max}",
s.len()
)));
}
}
Ok(())
}
None => Err(ValidationError::custom("expected a string")),
}
}
fn is_valid(&self, instance: &Value) -> bool {
instance
.as_str()
.is_some_and(|s| self.max_length.is_none_or(|max| s.len() <= max))
}
}
struct BytesValidator {
max_length: Option<usize>,
}
impl Keyword for BytesValidator {
fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> {
match instance.as_str() {
Some(s) => {
if let Some(max) = self.max_length {
if s.len() > max {
return Err(ValidationError::custom(format!(
"bytes length {} exceeds maxLength {max}",
s.len()
)));
}
}
Ok(())
}
None => Err(ValidationError::custom("expected a string for bytes")),
}
}
fn is_valid(&self, instance: &Value) -> bool {
instance
.as_str()
.is_some_and(|s| self.max_length.is_none_or(|max| s.len() <= max))
}
}
/// `TypeDef:Enum` is a layout marker — the built-in `enum` keyword
/// handles value-membership validation. The custom keyword exists solely
/// for the layout engine to recognize the type as a fixed-size u32 index.
struct EnumValidator;
impl Keyword for EnumValidator {
fn validate<'i>(&self, _instance: &'i Value) -> Result<(), ValidationError<'i>> {
Ok(())
}
fn is_valid(&self, _instance: &Value) -> bool {
true
}
}
struct TimestampValidator;
impl Keyword for TimestampValidator {
fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> {
match instance.as_str() {
Some(s) if is_rfc3339_timestamp(s) => Ok(()),
_ => Err(ValidationError::custom(
"expected an RFC 3339 timestamp string",
)),
}
}
fn is_valid(&self, instance: &Value) -> bool {
instance.as_str().is_some_and(is_rfc3339_timestamp)
}
}
/// Simple RFC 3339 / ISO 8601 datetime check: `YYYY-MM-DDTHH:MM:SS`
/// optionally followed by `Z` or a timezone offset.
fn is_rfc3339_timestamp(s: &str) -> bool {
let parts: Vec<&str> = s.splitn(2, 'T').collect();
if parts.len() != 2 {
return false;
}
let date_parts: Vec<&str> = parts[0].split('-').collect();
if date_parts.len() != 3 {
return false;
}
let time_part = parts[1];
let time_clean = if let Some(pos) = time_part.find(['Z', '+']) {
&time_part[..pos]
} else if let Some(pos) = time_part.rfind('-') {
if pos >= 8 {
&time_part[..pos]
} else {
time_part
}
} else {
time_part
};
let time_parts: Vec<&str> = time_clean.split(':').collect();
if time_parts.len() < 2 || time_parts.len() > 3 {
return false;
}
date_parts[0].parse::<u16>().is_ok_and(|y| y > 0)
&& date_parts[1]
.parse::<u8>()
.is_ok_and(|m| (1..=12).contains(&m))
&& date_parts[2]
.parse::<u8>()
.is_ok_and(|d| (1..=31).contains(&d))
&& time_parts[0].parse::<u8>().is_ok_and(|h| h <= 23)
&& time_parts[1].parse::<u8>().is_ok_and(|m| m <= 59)
}
// ---------------------------------------------------------------------------
// Composite validators (generated via macros)
// ---------------------------------------------------------------------------
define_type_validator!(StructValidator, struct_factory, "TypeDef:Struct", is_object, "expected an object");
define_type_validator!(UnionValidator, union_factory, "TypeDef:Union", is_object, "expected an object for union");
define_type_validator!(ArrayValidator, array_factory, "TypeDef:Array", is_array, "expected an array");
define_type_validator!(RecordValidator, record_factory, "TypeDef:Record", is_object, "expected an object for record");
define_type_validator!(BooleanValidator, boolean_factory, "TypeDef:Boolean", is_boolean, "expected a boolean");
// ---------------------------------------------------------------------------
// Factory closures for non-macro-generated validators
// ---------------------------------------------------------------------------
fn string_factory<'a>(
parent: &'a Map<String, Value>,
value: &'a Value,
_path: jsonschema::paths::Location,
) -> Result<Box<dyn Keyword>, ValidationError<'a>> {
if !value.is_boolean() && !value.is_object() {
return Err(ValidationError::schema(
"TypeDef:String must be set to true or an annotation object",
));
}
let max_length = parent
.get("maxLength")
.and_then(Value::as_u64)
.map(|n| n as usize);
Ok(Box::new(StringValidator { max_length }))
}
fn bytes_factory<'a>(
parent: &'a Map<String, Value>,
value: &'a Value,
_path: jsonschema::paths::Location,
) -> Result<Box<dyn Keyword>, ValidationError<'a>> {
if !value.is_boolean() && !value.is_object() {
return Err(ValidationError::schema(
"TypeDef:Bytes must be set to true or an annotation object",
));
}
let max_length = parent
.get("maxLength")
.and_then(Value::as_u64)
.map(|n| n as usize);
Ok(Box::new(BytesValidator { max_length }))
}
fn enum_factory<'a>(
_parent: &'a Map<String, Value>,
value: &'a Value,
_path: jsonschema::paths::Location,
) -> Result<Box<dyn Keyword>, ValidationError<'a>> {
if value.as_bool() == Some(true) {
Ok(Box::new(EnumValidator))
} else {
Err(ValidationError::schema("TypeDef:Enum must be set to true"))
}
}
fn timestamp_factory<'a>(
_parent: &'a Map<String, Value>,
value: &'a Value,
_path: jsonschema::paths::Location,
) -> Result<Box<dyn Keyword>, ValidationError<'a>> {
if value.as_bool() == Some(true) {
Ok(Box::new(TimestampValidator))
} else {
Err(ValidationError::schema(
"TypeDef:Timestamp must be set to true",
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn validator_for(schema: &Value) -> jsonschema::Validator {
build_validator(schema).expect("validator should build")
}
#[test]
fn validates_valid_struct_instance() {
let schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": {
"id": { "TypeDef:Uint32": true, "type": "integer" },
"score": { "TypeDef:Float32": true, "type": "number" },
"flag": { "TypeDef:Uint8": true, "type": "integer" },
"count": { "TypeDef:Uint16": true, "type": "integer" }
},
"required": ["id", "score", "flag", "count"]
});
let validator = validator_for(&schema);
let instance = json!({
"id": 42,
"score": 3.5,
"flag": 1,
"count": 1000
});
assert!(validator.is_valid(&instance));
}
#[test]
fn rejects_uint32_out_of_range() {
let schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": { "id": { "TypeDef:Uint32": true, "type": "integer" } },
"required": ["id"]
});
let validator = validator_for(&schema);
assert!(!validator.is_valid(&json!({"id": -1})));
assert!(!validator.is_valid(&json!({"id": 5_000_000_000u64})));
}
#[test]
fn validates_int8_range() {
let schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": { "val": { "TypeDef:Int8": true, "type": "integer" } },
"required": ["val"]
});
let validator = validator_for(&schema);
assert!(validator.is_valid(&json!({"val": 0})));
assert!(validator.is_valid(&json!({"val": 127})));
assert!(validator.is_valid(&json!({"val": -128})));
assert!(!validator.is_valid(&json!({"val": 128})));
assert!(!validator.is_valid(&json!({"val": -129})));
}
#[test]
fn validates_int16_and_int32_ranges() {
let schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": {
"i16": { "TypeDef:Int16": true, "type": "integer" },
"i32": { "TypeDef:Int32": true, "type": "integer" }
},
"required": ["i16", "i32"]
});
let validator = validator_for(&schema);
assert!(validator.is_valid(&json!({"i16": 32767, "i32": 2147483647})));
assert!(validator.is_valid(&json!({"i16": -32768, "i32": -2147483648})));
assert!(!validator.is_valid(&json!({"i16": 32768, "i32": 0})));
assert!(!validator.is_valid(&json!({"i16": 0, "i32": 2147483648u64})));
}
#[test]
fn validates_uint16_and_uint32_ranges() {
let schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": {
"u16": { "TypeDef:Uint16": true, "type": "integer" },
"u32": { "TypeDef:Uint32": true, "type": "integer" }
},
"required": ["u16", "u32"]
});
let validator = validator_for(&schema);
assert!(validator.is_valid(&json!({"u16": 65535, "u32": 4294967295u64})));
assert!(!validator.is_valid(&json!({"u16": 65536, "u32": 0})));
assert!(!validator.is_valid(&json!({"u16": -1, "u32": 0})));
}
#[test]
fn validates_int64_range() {
let schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": { "val": { "TypeDef:Int64": true, "type": "integer" } },
"required": ["val"]
});
let validator = validator_for(&schema);
assert!(validator.is_valid(&json!({"val": 0})));
assert!(validator.is_valid(&json!({"val": 9223372036854775807i64})));
assert!(validator.is_valid(&json!({"val": -9223372036854775808i64})));
assert!(!validator.is_valid(&json!({"val": "x"})));
}
#[test]
fn validates_uint64_range() {
let schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": { "val": { "TypeDef:Uint64": true, "type": "integer" } },
"required": ["val"]
});
let validator = validator_for(&schema);
assert!(validator.is_valid(&json!({"val": 0})));
assert!(validator.is_valid(&json!({"val": 18446744073709551615u64})));
assert!(!validator.is_valid(&json!({"val": -1})));
assert!(!validator.is_valid(&json!({"val": "x"})));
}
#[test]
fn validates_float_finiteness() {
let schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": {
"f32": { "TypeDef:Float32": true, "type": "number" },
"f64": { "TypeDef:Float64": true, "type": "number" }
},
"required": ["f32", "f64"]
});
let validator = validator_for(&schema);
assert!(validator.is_valid(&json!({"f32": 3.5, "f64": 2.5})));
assert!(validator.is_valid(&json!({"f32": 0, "f64": 0})));
assert!(!validator.is_valid(&json!({"f32": "x", "f64": 0})));
}
#[test]
fn validates_boolean() {
let schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": { "active": { "TypeDef:Boolean": true, "type": "boolean" } },
"required": ["active"]
});
let validator = validator_for(&schema);
assert!(validator.is_valid(&json!({"active": true})));
assert!(validator.is_valid(&json!({"active": false})));
assert!(!validator.is_valid(&json!({"active": "yes"})));
}
#[test]
fn validates_string_and_maxlength() {
let schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": {
"name": { "TypeDef:String": true, "type": "string", "maxLength": 5 }
},
"required": ["name"]
});
let validator = validator_for(&schema);
assert!(validator.is_valid(&json!({"name": "hi"})));
assert!(validator.is_valid(&json!({"name": "hello"})));
assert!(!validator.is_valid(&json!({"name": "toolong"})));
assert!(!validator.is_valid(&json!({"name": 42})));
}
#[test]
fn validates_bytes_and_maxlength() {
let schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": {
"blob": { "TypeDef:Bytes": true, "type": "string", "maxLength": 4 }
},
"required": ["blob"]
});
let validator = validator_for(&schema);
assert!(validator.is_valid(&json!({"blob": "abcd"})));
assert!(!validator.is_valid(&json!({"blob": "abcde"})));
assert!(!validator.is_valid(&json!({"blob": 42})));
}
#[test]
fn enum_validator_is_noop_and_builtin_enum_handles_membership() {
let schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": {
"status": {
"TypeDef:Enum": true,
"type": "string",
"enum": ["ok", "error", "pending"]
}
},
"required": ["status"]
});
let validator = validator_for(&schema);
assert!(validator.is_valid(&json!({"status": "ok"})));
assert!(validator.is_valid(&json!({"status": "error"})));
assert!(!validator.is_valid(&json!({"status": "unknown"})));
}
#[test]
fn validates_timestamp_rfc3339() {
let schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": {
"created_at": { "TypeDef:Timestamp": true, "type": "string" }
},
"required": ["created_at"]
});
let validator = validator_for(&schema);
assert!(validator.is_valid(&json!({"created_at": "2026-07-20T15:30:00Z"})));
assert!(validator.is_valid(&json!({"created_at": "2026-07-20T15:30:00"})));
assert!(validator.is_valid(&json!({"created_at": "2026-07-20T15:30:00+02:00"})));
assert!(!validator.is_valid(&json!({"created_at": "not-a-date"})));
}
#[test]
fn validates_array_type() {
let schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": {
"items": {
"TypeDef:Array": true,
"type": "array",
"items": { "TypeDef:Uint8": true, "type": "integer" }
}
},
"required": ["items"]
});
let validator = validator_for(&schema);
assert!(validator.is_valid(&json!({"items": [1, 2, 3]})));
assert!(!validator.is_valid(&json!({"items": "not-array"})));
}
#[test]
fn validates_record_type() {
let schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": {
"counts": {
"TypeDef:Record": true,
"type": "object",
"additionalProperties": { "TypeDef:Uint32": true, "type": "integer" }
}
},
"required": ["counts"]
});
let validator = validator_for(&schema);
assert!(validator.is_valid(&json!({"counts": {"a": 1, "b": 2}})));
assert!(!validator.is_valid(&json!({"counts": "not-object"})));
}
#[test]
fn validates_union_type() {
let schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": {
"packet": {
"TypeDef:Union": true,
"type": "object",
"properties": {
"type": { "type": "string" }
},
"required": ["type"]
}
},
"required": ["packet"]
});
let validator = validator_for(&schema);
assert!(validator.is_valid(&json!({"packet": {"type": "read"}})));
assert!(!validator.is_valid(&json!({"packet": "not-object"})));
}
#[test]
fn build_validator_returns_schema_error_for_malformed_keyword() {
let schema = json!({"TypeDef:Uint32": "not-a-bool"});
let err = build_validator(&schema).expect_err("should fail");
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
}
#[test]
fn build_validator_maps_build_error_to_typedef_error() {
let schema = json!([1, 2, 3]);
let err = build_validator(&schema).expect_err("schema must be an object");
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
}
}
@@ -0,0 +1,389 @@
//! Integration tests for the `TypedefEngine` public API.
//!
//! Exercises the engine across both layout modes, the convenience
//! accessors, validation convenience methods, and the aligned-mode
//! `read_field` / `write_field` round-trip for the fixed-size primitive
//! kinds and length-prefixed `String` / `Bytes`.
use alknet_typedef::*;
use serde_json::json;
fn mixed_fixed_struct_schema() -> serde_json::Value {
json!({
"TypeDef:Struct": true,
"endian": "little",
"properties": {
"flag": { "TypeDef:Uint8": true },
"id": { "TypeDef:Uint32": true },
"score": { "TypeDef:Float32": true },
"tag": { "TypeDef:String": true }
}
})
}
#[test]
fn compile_aligned_builds_engine_with_offset_map() -> Result<(), TypedefError> {
let mut schema = mixed_fixed_struct_schema();
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
assert_eq!(engine.mode(), LayoutMode::Aligned);
assert!(engine.offset_map().is_some());
assert!(engine.layout_builder().is_none());
assert!(engine.sequential_reader().is_none());
Ok(())
}
#[test]
fn compile_packed_builds_engine_with_builder_and_reader() -> Result<(), TypedefError> {
let mut schema = mixed_fixed_struct_schema();
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed)?;
assert_eq!(engine.mode(), LayoutMode::Packed);
assert!(engine.offset_map().is_none());
assert!(engine.layout_builder().is_some());
assert!(engine.sequential_reader().is_some());
Ok(())
}
#[test]
fn compile_normalizes_bare_name_refs() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": {
"child": { "$ref": "Child" }
},
"$defs": {
"Child": {
"TypeDef:Struct": true,
"properties": { "x": { "TypeDef:Uint8": true } }
}
}
});
let _engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed)?;
assert_eq!(
schema["properties"]["child"]["$ref"],
json!("#/$defs/Child")
);
Ok(())
}
#[test]
fn compile_leaves_full_pointer_refs_unchanged() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": {
"child": { "$ref": "#/$defs/Child" }
},
"$defs": {
"Child": {
"TypeDef:Struct": true,
"properties": { "x": { "TypeDef:Uint8": true } }
}
}
});
let _engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed)?;
assert_eq!(
schema["properties"]["child"]["$ref"],
json!("#/$defs/Child")
);
Ok(())
}
#[test]
fn compile_returns_schema_error_when_no_typedef_kind() {
let mut schema = json!({ "type": "object", "properties": {} });
let err = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
}
#[test]
fn endian_parsed_from_schema_big() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"endian": "big",
"properties": { "id": { "TypeDef:Uint32": true } }
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed)?;
assert_eq!(engine.endian(), Endian::Big);
Ok(())
}
#[test]
fn endian_defaults_to_little() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": { "id": { "TypeDef:Uint32": true } }
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed)?;
assert_eq!(engine.endian(), Endian::Little);
Ok(())
}
#[test]
fn validate_json_accepts_valid_instance() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": {
"id": { "TypeDef:Uint32": true, "type": "integer" }
},
"required": ["id"]
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
assert!(engine.validate_json(&json!({"id": 42})).is_ok());
Ok(())
}
#[test]
fn validate_json_rejects_invalid_instance() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": {
"id": { "TypeDef:Uint32": true, "type": "integer" }
},
"required": ["id"]
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
let err = engine.validate_json(&json!({"id": -1})).unwrap_err();
assert!(matches!(err, TypedefError::Validation(_)), "got {err:?}");
Ok(())
}
#[test]
fn is_valid_json_returns_bool() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"type": "object",
"properties": {
"id": { "TypeDef:Uint32": true, "type": "integer" }
},
"required": ["id"]
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
assert!(engine.is_valid_json(&json!({"id": 42})));
assert!(!engine.is_valid_json(&json!({"id": -1})));
Ok(())
}
#[test]
fn read_write_aligned_round_trips_all_fixed_size_kinds() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"endian": "little",
"properties": {
"i8": { "TypeDef:Int8": true },
"u8": { "TypeDef:Uint8": true },
"i16": { "TypeDef:Int16": true },
"u16": { "TypeDef:Uint16": true },
"i32": { "TypeDef:Int32": true },
"u32": { "TypeDef:Uint32": true },
"i64": { "TypeDef:Int64": true },
"u64": { "TypeDef:Uint64": true },
"f32": { "TypeDef:Float32": true },
"f64": { "TypeDef:Float64": true },
"b": { "TypeDef:Boolean": true },
"e": { "TypeDef:Enum": true }
}
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
let offset_map = engine.offset_map().expect("aligned mode has offset_map");
let mut buffer = vec![0u8; offset_map.total_size()];
engine.write_field(&mut buffer, "i8", &FieldValue::I8(-127))?;
engine.write_field(&mut buffer, "u8", &FieldValue::U8(0xAB))?;
engine.write_field(&mut buffer, "i16", &FieldValue::I16(-32000))?;
engine.write_field(&mut buffer, "u16", &FieldValue::U16(0xBEEF))?;
engine.write_field(&mut buffer, "i32", &FieldValue::I32(-2_000_000_007))?;
engine.write_field(&mut buffer, "u32", &FieldValue::U32(0xDEADBEEF))?;
engine.write_field(&mut buffer, "i64", &FieldValue::I64(-9_000_000_000_000_000_000))?;
engine.write_field(&mut buffer, "u64", &FieldValue::U64(0x0102030405060708))?;
engine.write_field(&mut buffer, "f32", &FieldValue::F32(1.5))?;
engine.write_field(&mut buffer, "f64", &FieldValue::F64(2.5))?;
engine.write_field(&mut buffer, "b", &FieldValue::Bool(true))?;
engine.write_field(&mut buffer, "e", &FieldValue::Enum(7))?;
assert_eq!(engine.read_field(&buffer, "i8")?, FieldValue::I8(-127));
assert_eq!(engine.read_field(&buffer, "u8")?, FieldValue::U8(0xAB));
assert_eq!(engine.read_field(&buffer, "i16")?, FieldValue::I16(-32000));
assert_eq!(engine.read_field(&buffer, "u16")?, FieldValue::U16(0xBEEF));
assert_eq!(
engine.read_field(&buffer, "i32")?,
FieldValue::I32(-2_000_000_007)
);
assert_eq!(
engine.read_field(&buffer, "u32")?,
FieldValue::U32(0xDEADBEEF)
);
assert_eq!(
engine.read_field(&buffer, "i64")?,
FieldValue::I64(-9_000_000_000_000_000_000)
);
assert_eq!(
engine.read_field(&buffer, "u64")?,
FieldValue::U64(0x0102030405060708)
);
assert_eq!(engine.read_field(&buffer, "f32")?, FieldValue::F32(1.5));
assert_eq!(engine.read_field(&buffer, "f64")?, FieldValue::F64(2.5));
assert_eq!(engine.read_field(&buffer, "b")?, FieldValue::Bool(true));
assert_eq!(engine.read_field(&buffer, "e")?, FieldValue::Enum(7));
Ok(())
}
#[test]
fn read_write_aligned_round_trips_string() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": {
"name": { "TypeDef:String": true }
}
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
let offset_map = engine.offset_map().expect("aligned mode has offset_map");
let mut buffer = vec![0u8; offset_map.total_size() + 64];
engine.write_field(&mut buffer, "name", &FieldValue::String("hello world"))?;
assert_eq!(
engine.read_field(&buffer, "name")?,
FieldValue::String("hello world")
);
Ok(())
}
#[test]
fn read_write_aligned_round_trips_bytes() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": {
"blob": { "TypeDef:Bytes": true }
}
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
let offset_map = engine.offset_map().expect("aligned mode has offset_map");
let payload = b"the quick brown fox".to_vec();
let mut buffer = vec![0u8; offset_map.total_size() + payload.len()];
engine.write_field(&mut buffer, "blob", &FieldValue::Bytes(&payload))?;
assert_eq!(
engine.read_field(&buffer, "blob")?,
FieldValue::Bytes(&payload)
);
Ok(())
}
#[test]
fn read_field_returns_access_error_in_packed_mode() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": { "id": { "TypeDef:Uint32": true } }
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed)?;
let buffer = [0u8; 4];
let err = engine.read_field(&buffer, "id").unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
Ok(())
}
#[test]
fn write_field_returns_access_error_in_packed_mode() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": { "id": { "TypeDef:Uint32": true } }
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed)?;
let mut buffer = [0u8; 4];
let err = engine
.write_field(&mut buffer, "id", &FieldValue::U32(1))
.unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
Ok(())
}
#[test]
fn read_field_returns_offset_error_for_missing_path() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": { "id": { "TypeDef:Uint32": true } }
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
let buffer = [0u8; 8];
let err = engine.read_field(&buffer, "missing").unwrap_err();
assert!(matches!(err, TypedefError::Offset { .. }), "got {err:?}");
Ok(())
}
#[test]
fn write_field_returns_offset_error_for_missing_path() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": { "id": { "TypeDef:Uint32": true } }
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
let mut buffer = [0u8; 8];
let err = engine
.write_field(&mut buffer, "missing", &FieldValue::U32(1))
.unwrap_err();
assert!(matches!(err, TypedefError::Offset { .. }), "got {err:?}");
Ok(())
}
#[test]
fn read_field_returns_access_error_for_composite_types() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": {
"vals": {
"TypeDef:Array": true,
"items": { "TypeDef:Uint32": true }
}
}
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
let buffer = [0u8; 8];
let err = engine.read_field(&buffer, "vals").unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
Ok(())
}
#[test]
fn write_field_returns_access_error_for_composite_value() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": { "id": { "TypeDef:Uint32": true } }
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
let mut buffer = [0u8; 8];
let err = engine
.write_field(&mut buffer, "id", &FieldValue::Struct { start: 0, end: 4 })
.unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
Ok(())
}
#[test]
fn read_field_aligned_reads_nested_struct_byte_range() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": {
"header": {
"TypeDef:Struct": true,
"properties": {
"version": { "TypeDef:Uint8": true },
"magic": { "TypeDef:Uint32": true }
}
}
}
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
let offset_map = engine.offset_map().expect("aligned mode");
let mut buffer = vec![0u8; offset_map.total_size()];
engine.write_field(&mut buffer, "header.version", &FieldValue::U8(3))?;
engine.write_field(&mut buffer, "header.magic", &FieldValue::U32(0xCAFEBABE))?;
assert_eq!(
engine.read_field(&buffer, "header.version")?,
FieldValue::U8(3)
);
assert_eq!(
engine.read_field(&buffer, "header.magic")?,
FieldValue::U32(0xCAFEBABE)
);
Ok(())
}
+415
View File
@@ -0,0 +1,415 @@
//! Error path integration tests for `alknet-typedef`.
//!
//! Exercises the `TypedefError` variants across the crate:
//! `Access` (buffer too short, invalid UTF-8, invalid boolean byte,
//! unknown discriminator value), `Schema` (missing TypeDef kind,
//! malformed discriminator annotation), and `Offset` (missing
//! variable-length field size in `LayoutBuilder::build`).
use alknet_typedef::data_access;
use alknet_typedef::tunion;
use alknet_typedef::*;
use serde_json::json;
use std::collections::HashMap;
#[test]
fn read_u32_buffer_too_short_returns_access_error() {
let buffer = [0u8; 2];
let err = data_access::read_u32(&buffer, 0, "header.id", Endian::Little).unwrap_err();
match err {
TypedefError::Access { field_path, reason } => {
assert_eq!(field_path, "header.id");
assert!(reason.contains("bounds"), "reason: {reason}");
}
other => panic!("expected Access, got {other:?}"),
}
}
#[test]
fn read_u16_buffer_too_short_returns_access_error() {
let buffer = [0u8; 1];
let err = data_access::read_u16(&buffer, 0, "tag", Endian::Little).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn read_u64_buffer_too_short_returns_access_error() {
let buffer = [0u8; 4];
let err = data_access::read_u64(&buffer, 0, "offset", Endian::Big).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn read_f32_buffer_too_short_returns_access_error() {
let buffer = [0u8; 2];
let err = data_access::read_f32(&buffer, 0, "score", Endian::Little).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn read_f64_buffer_too_short_returns_access_error() {
let buffer = [0u8; 4];
let err = data_access::read_f64(&buffer, 0, "score", Endian::Little).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn read_i32_buffer_too_short_returns_access_error() {
let buffer = [0u8; 2];
let err = data_access::read_i32(&buffer, 0, "id", Endian::Little).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn read_bool_buffer_too_short_returns_access_error() {
let buffer: [u8; 0] = [];
let err = data_access::read_bool(&buffer, 0, "flag").unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn read_string_buffer_too_short_on_prefix_returns_access_error() {
let buffer = [0u8; 2];
let err = data_access::read_string(&buffer, 0, "name", Endian::Little).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn read_string_buffer_too_short_on_data_returns_access_error() {
let mut buffer = vec![0u8; 6];
buffer[0..4].copy_from_slice(&100u32.to_le_bytes());
let err = data_access::read_string(&buffer, 0, "name", Endian::Little).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn read_bytes_buffer_too_short_on_data_returns_access_error() {
let mut buffer = vec![0u8; 5];
buffer[0..4].copy_from_slice(&100u32.to_le_bytes());
let err = data_access::read_bytes(&buffer, 0, "blob", Endian::Little).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn read_string_invalid_utf8_returns_access_error() {
let mut buffer = vec![0u8; 16];
let invalid = [0xFFu8, 0xFE, 0xFD];
let _ = data_access::write_bytes(&mut buffer, 0, &invalid, "name", Endian::Little);
let err = data_access::read_string(&buffer, 0, "name", Endian::Little).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn read_bool_invalid_byte_returns_access_error() {
let buffer = [0x02u8];
let err = data_access::read_bool(&buffer, 0, "flag").unwrap_err();
match err {
TypedefError::Access { field_path, reason } => {
assert_eq!(field_path, "flag");
assert!(reason.contains("0x02"), "reason: {reason}");
}
other => panic!("expected Access, got {other:?}"),
}
}
#[test]
fn read_bool_zero_is_false() -> Result<(), TypedefError> {
let buffer = [0x00u8];
assert!(!data_access::read_bool(&buffer, 0, "flag")?);
Ok(())
}
#[test]
fn read_bool_one_is_true() -> Result<(), TypedefError> {
let buffer = [0x01u8];
assert!(data_access::read_bool(&buffer, 0, "flag")?);
Ok(())
}
#[test]
fn read_bool_three_is_access_error() {
let buffer = [0x03u8];
let err = data_access::read_bool(&buffer, 0, "flag").unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn write_u32_buffer_too_short_returns_access_error() {
let mut buffer = [0u8; 2];
let err = data_access::write_u32(&mut buffer, 0, 1, "id", Endian::Little).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn write_string_buffer_too_short_returns_access_error() {
let mut buffer = vec![0u8; 4];
let err =
data_access::write_string(&mut buffer, 0, "hello", "name", Endian::Little).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn compile_missing_typedef_kind_returns_schema_error() {
let mut schema = json!({ "type": "object", "properties": {} });
let err = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
}
#[test]
fn offset_map_compute_missing_typedef_kind_returns_schema_error() {
let schema = json!({ "type": "object", "properties": {} });
let err = OffsetMap::compute(&schema).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
}
#[test]
fn offset_map_compute_non_struct_top_level_returns_schema_error() {
let schema = json!({ "TypeDef:Uint32": true });
let err = OffsetMap::compute(&schema).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
}
#[test]
fn layout_builder_new_missing_typedef_kind_returns_schema_error() {
let schema = json!({ "type": "object", "properties": {} });
let err = LayoutBuilder::new(&schema).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
}
#[test]
fn layout_builder_new_non_struct_top_level_returns_schema_error() {
let schema = json!({ "TypeDef:Uint32": true });
let err = LayoutBuilder::new(&schema).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
}
#[test]
fn parse_discriminator_missing_returns_schema_error() {
let schema = json!({"TypeDef:Union": true});
let err = parse_discriminator(&schema).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
}
#[test]
fn parse_discriminator_field_missing_name_returns_schema_error() {
let schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "field"}
});
let err = parse_discriminator(&schema).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
}
#[test]
fn parse_discriminator_unknown_kind_returns_schema_error() {
let schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "magic"}
});
let err = parse_discriminator(&schema).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
}
#[test]
fn parse_discriminator_byte_invalid_type_returns_schema_error() {
let schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "byte", "type": "TypeDef:Float32"}
});
let err = parse_discriminator(&schema).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
}
#[test]
fn read_byte_discriminator_unknown_value_returns_access_error() -> Result<(), TypedefError> {
let union_schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "byte", "type": "TypeDef:Uint8"},
"mapping": {"5": {"TypeDef:Struct": true, "properties": {"x": {"TypeDef:Uint8": true}}}}
});
let buffer = [99u8, 0x00, 0x00];
let err = tunion::read_byte_discriminator(&buffer, &union_schema, Endian::Little).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
Ok(())
}
#[test]
fn read_byte_discriminator_buffer_too_short_returns_access_error() -> Result<(), TypedefError> {
let union_schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "byte", "offset": 4, "type": "TypeDef:Uint32"},
"mapping": {"5": {"TypeDef:Struct": true, "properties": {"x": {"TypeDef:Uint8": true}}}}
});
let buffer = [0u8; 2];
let err = tunion::read_byte_discriminator(&buffer, &union_schema, Endian::Little).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
Ok(())
}
#[test]
fn read_field_discriminator_unknown_value_returns_access_error() -> Result<(), TypedefError> {
let union_schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "field", "name": "type"},
"properties": {"type": {"TypeDef:Uint8": true}},
"mapping": {"0": {"TypeDef:Struct": true, "properties": {"x": {"TypeDef:Uint8": true}}}}
});
let mut buffer = vec![0u8; 8];
buffer[0] = 99;
let err =
tunion::read_field_discriminator(&buffer, &union_schema, 0, Endian::Little).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
Ok(())
}
#[test]
fn layout_builder_missing_var_size_returns_offset_error() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"name": { "TypeDef:String": true }
}
});
let builder = LayoutBuilder::new(&schema).expect("builder");
let empty: HashMap<String, usize> = HashMap::new();
let err = builder.build(&empty).unwrap_err();
match err {
TypedefError::Offset { field_path, reason } => {
assert_eq!(field_path, "name");
assert!(
reason.contains("missing variable-length field size"),
"reason: {reason}"
);
}
other => panic!("expected Offset, got {other:?}"),
}
}
#[test]
fn layout_builder_missing_array_data_size_returns_offset_error() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"vals": {
"TypeDef:Array": true,
"items": { "TypeDef:Uint32": true }
}
}
});
let builder = LayoutBuilder::new(&schema).expect("builder");
let empty: HashMap<String, usize> = HashMap::new();
let err = builder.build(&empty).unwrap_err();
assert!(matches!(err, TypedefError::Offset { .. }), "got {err:?}");
}
#[test]
fn layout_builder_missing_discriminator_value_returns_offset_error() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"payload": {
"TypeDef:Union": true,
"discriminator": {"kind": "byte", "type": "TypeDef:Uint8"},
"mapping": {"5": {"$ref": "#/$defs/Read"}}
}
},
"$defs": {
"Read": {"TypeDef:Struct": true, "properties": {"x": {"TypeDef:Uint8": true}}}
}
});
let builder = LayoutBuilder::new(&schema).expect("builder");
let empty: HashMap<String, usize> = HashMap::new();
let err = builder.build(&empty).unwrap_err();
assert!(matches!(err, TypedefError::Offset { .. }), "got {err:?}");
}
#[test]
fn layout_builder_unknown_discriminator_value_returns_offset_error() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"payload": {
"TypeDef:Union": true,
"discriminator": {"kind": "byte", "type": "TypeDef:Uint8"},
"mapping": {"5": {"$ref": "#/$defs/Read"}}
}
},
"$defs": {
"Read": {"TypeDef:Struct": true, "properties": {"x": {"TypeDef:Uint8": true}}}
}
});
let builder = LayoutBuilder::new(&schema).expect("builder");
let mut vs = HashMap::new();
vs.insert("payload.__discriminator".to_string(), 99);
let err = builder.build(&vs).unwrap_err();
match err {
TypedefError::Offset { reason, .. } => {
assert!(reason.contains("99"), "reason: {reason}");
}
other => panic!("expected Offset, got {other:?}"),
}
}
#[test]
fn sequential_reader_buffer_too_short_returns_access_error() {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"id": { "TypeDef:Uint32": true }
}
});
let buffer = [0u8; 2];
let mut reader = SequentialReader::new(&schema).unwrap();
let err = reader.read_next(&buffer).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn sequential_reader_unknown_field_returns_schema_error() {
let schema = json!({
"TypeDef:Struct": true,
"properties": { "a": { "TypeDef:Uint8": true } }
});
let buffer = [0u8; 4];
let mut reader = SequentialReader::new(&schema).unwrap();
let err = reader.read_field(&buffer, "missing").unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
}
#[test]
fn sequential_reader_new_non_struct_returns_schema_error() {
let schema = json!({ "TypeDef:Uint32": true });
let err = SequentialReader::new(&schema).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
}
#[test]
fn read_string_indirect_data_region_too_short_returns_access_error() {
let mut index = [0u8; 8];
let _ = data_access::write_u32(&mut index, 0, 100, "idx.off", Endian::Little);
let _ = data_access::write_u32(&mut index, 4, 10, "idx.len", Endian::Little);
let data_region = b"too short";
let err = data_access::read_bytes_indirect(&index, 0, data_region, "blob", Endian::Little)
.unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn read_bytes_indirect_index_too_short_returns_access_error() {
let buffer = [0u8; 4];
let data_region = b"anything";
let err = data_access::read_bytes_indirect(&buffer, 0, data_region, "blob", Endian::Little)
.unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
#[test]
fn read_string_indirect_invalid_utf8_returns_access_error() {
let data_region: &[u8] = &[0xFF, 0xFE, 0xFD];
let mut index = [0u8; 8];
let _ = data_access::write_u32(&mut index, 0, 0, "idx.off", Endian::Little);
let _ = data_access::write_u32(&mut index, 4, 3, "idx.len", Endian::Little);
let err = data_access::read_string_indirect(&index, 0, data_region, "name", Endian::Little)
.unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
}
@@ -0,0 +1,535 @@
//! POC round-trip tests adapted from `/workspace/alknet-typedef-poc/`.
//!
//! These tests re-validate the byte-identical round-trip behaviour that
//! the POC verified: fixed-size primitives, length-prefixed strings and
//! bytes, nested structs, big-endian, alignment padding, packed-layout
//! `LayoutBuilder` with `data_access` writes, and `SequentialReader`
//! walks. Each test writes values to a buffer at computed offsets and
//! reads them back, asserting both the values and (where applicable)
//! the byte positions.
use alknet_typedef::data_access;
use alknet_typedef::tunion;
use alknet_typedef::*;
use serde_json::json;
use std::collections::HashMap;
fn var_sizes(pairs: &[(&str, usize)]) -> HashMap<String, usize> {
pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect()
}
#[test]
fn fixed_size_round_trip_via_offset_map() -> Result<(), TypedefError> {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"id": { "TypeDef:Uint32": true },
"score": { "TypeDef:Float32": true },
"flag": { "TypeDef:Uint8": true },
"count": { "TypeDef:Uint16": true }
}
});
let offset_map = OffsetMap::compute(&schema)?;
let mut buffer = vec![0u8; offset_map.total_size()];
let id_range = offset_map.get("id").expect("id range");
data_access::write_u32(&mut buffer, id_range.start, 42, "id", Endian::Little)?;
let score_range = offset_map.get("score").expect("score range");
data_access::write_f32(&mut buffer, score_range.start, 1.5, "score", Endian::Little)?;
let flag_range = offset_map.get("flag").expect("flag range");
data_access::write_u8(&mut buffer, flag_range.start, 1, "flag")?;
let count_range = offset_map.get("count").expect("count range");
data_access::write_u16(
&mut buffer,
count_range.start,
1000,
"count",
Endian::Little,
)?;
assert_eq!(
data_access::read_u32(&buffer, id_range.start, "id", Endian::Little)?,
42
);
let score = data_access::read_f32(&buffer, score_range.start, "score", Endian::Little)?;
assert!((score - 1.5).abs() < 0.001, "score: {score}");
assert_eq!(data_access::read_u8(&buffer, flag_range.start, "flag")?, 1);
assert_eq!(
data_access::read_u16(&buffer, count_range.start, "count", Endian::Little)?,
1000
);
Ok(())
}
#[test]
fn fixed_size_round_trip_via_engine_aligned() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"endian": "little",
"properties": {
"id": { "TypeDef:Uint32": true },
"score": { "TypeDef:Float32": true },
"flag": { "TypeDef:Uint8": true }
}
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
let offset_map = engine.offset_map().expect("aligned mode has offset_map");
let mut buffer = vec![0u8; offset_map.total_size()];
engine.write_field(&mut buffer, "id", &FieldValue::U32(42))?;
engine.write_field(&mut buffer, "score", &FieldValue::F32(1.5))?;
engine.write_field(&mut buffer, "flag", &FieldValue::U8(1))?;
assert_eq!(engine.read_field(&buffer, "id")?, FieldValue::U32(42));
let score = match engine.read_field(&buffer, "score")? {
FieldValue::F32(f) => f,
other => panic!("expected F32, got {other:?}"),
};
assert!((score - 1.5).abs() < 0.001);
assert_eq!(engine.read_field(&buffer, "flag")?, FieldValue::U8(1));
Ok(())
}
#[test]
fn string_round_trip_via_data_access() -> Result<(), TypedefError> {
let mut buffer = vec![0u8; 32];
let written = data_access::write_string(&mut buffer, 0, "hello", "name", Endian::Little)?;
assert_eq!(written, 4 + 5);
assert_eq!(buffer[0..4], 5u32.to_le_bytes());
assert_eq!(&buffer[4..9], b"hello");
assert_eq!(
data_access::read_string(&buffer, 0, "name", Endian::Little)?,
"hello"
);
Ok(())
}
#[test]
fn string_round_trip_via_engine_aligned() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": {
"name": { "TypeDef:String": true }
}
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
let offset_map = engine.offset_map().expect("aligned mode has offset_map");
let mut buffer = vec![0u8; offset_map.total_size() + 64];
engine.write_field(&mut buffer, "name", &FieldValue::String("hello"))?;
assert_eq!(
engine.read_field(&buffer, "name")?,
FieldValue::String("hello")
);
Ok(())
}
#[test]
fn bytes_round_trip_via_data_access() -> Result<(), TypedefError> {
let payload = [0xAA, 0xBB, 0xCC, 0xDD];
let mut buffer = vec![0u8; 32];
let written = data_access::write_bytes(&mut buffer, 0, &payload, "data", Endian::Little)?;
assert_eq!(written, 4 + 4);
assert_eq!(buffer[0..4], 4u32.to_le_bytes());
assert_eq!(&buffer[4..8], &payload);
assert_eq!(
data_access::read_bytes(&buffer, 0, "data", Endian::Little)?,
&payload[..]
);
Ok(())
}
#[test]
fn nested_struct_round_trip_via_offset_map() -> Result<(), TypedefError> {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"header": {
"TypeDef:Struct": true,
"properties": {
"version": { "TypeDef:Uint32": true },
"magic": { "TypeDef:Uint32": true }
}
},
"payload": { "TypeDef:Bytes": true }
}
});
let offset_map = OffsetMap::compute(&schema)?;
let header_version = offset_map.get("header.version").expect("header.version");
let header_magic = offset_map.get("header.magic").expect("header.magic");
let payload_prefix = offset_map.get("payload").expect("payload");
assert_eq!(header_version.start, 0);
assert_eq!(header_magic.start, 4);
assert_eq!(payload_prefix.start, 8);
let data = b"body-data".to_vec();
let mut buffer = vec![0u8; offset_map.total_size() + data.len()];
data_access::write_u32(
&mut buffer,
header_version.start,
1,
"header.version",
Endian::Little,
)?;
data_access::write_u32(
&mut buffer,
header_magic.start,
0xCAFEBABE,
"header.magic",
Endian::Little,
)?;
data_access::write_bytes(
&mut buffer,
payload_prefix.start,
&data,
"payload",
Endian::Little,
)?;
assert_eq!(
data_access::read_u32(
&buffer,
header_version.start,
"header.version",
Endian::Little
)?,
1
);
assert_eq!(
data_access::read_u32(&buffer, header_magic.start, "header.magic", Endian::Little)?,
0xCAFEBABE
);
assert_eq!(
data_access::read_bytes(&buffer, payload_prefix.start, "payload", Endian::Little)?,
&data[..]
);
Ok(())
}
#[test]
fn nested_struct_round_trip_via_engine_aligned() -> Result<(), TypedefError> {
let mut schema = json!({
"TypeDef:Struct": true,
"properties": {
"header": {
"TypeDef:Struct": true,
"properties": {
"version": { "TypeDef:Uint8": true },
"flags": { "TypeDef:Uint8": true }
}
},
"payload_len": { "TypeDef:Uint32": true }
}
});
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
let offset_map = engine.offset_map().expect("aligned mode");
assert_eq!(offset_map.get("header.version").unwrap().start, 0);
assert_eq!(offset_map.get("header.flags").unwrap().start, 1);
assert_eq!(offset_map.get("payload_len").unwrap().start, 4);
let mut buffer = vec![0u8; offset_map.total_size()];
engine.write_field(&mut buffer, "header.version", &FieldValue::U8(1))?;
engine.write_field(&mut buffer, "header.flags", &FieldValue::U8(0x0F))?;
engine.write_field(&mut buffer, "payload_len", &FieldValue::U32(1024))?;
assert_eq!(
engine.read_field(&buffer, "header.version")?,
FieldValue::U8(1)
);
assert_eq!(
engine.read_field(&buffer, "header.flags")?,
FieldValue::U8(0x0F)
);
assert_eq!(
engine.read_field(&buffer, "payload_len")?,
FieldValue::U32(1024)
);
Ok(())
}
#[test]
fn big_endian_round_trip_via_offset_map() -> Result<(), TypedefError> {
let schema = json!({
"TypeDef:Struct": true,
"endian": "big",
"properties": {
"id": { "TypeDef:Uint32": true },
"offset": { "TypeDef:Float64": true }
}
});
let offset_map = OffsetMap::compute(&schema)?;
let endian = Endian::from_schema(&schema);
assert_eq!(endian, Endian::Big);
let id_range = offset_map.get("id").expect("id");
let offset_range = offset_map.get("offset").expect("offset");
assert_eq!(id_range.start, 0);
assert_eq!(offset_range.start, 8);
let value: f64 = std::f64::consts::PI;
let mut buffer = vec![0u8; offset_map.total_size()];
data_access::write_u32(&mut buffer, id_range.start, 0x01020304, "id", endian)?;
data_access::write_f64(&mut buffer, offset_range.start, value, "offset", endian)?;
assert_eq!(&buffer[0..4], &[0x01, 0x02, 0x03, 0x04]);
assert_eq!(&buffer[4..8], &[0x00, 0x00, 0x00, 0x00]);
assert_eq!(&buffer[8..16], value.to_be_bytes());
assert_eq!(
data_access::read_u32(&buffer, id_range.start, "id", endian)?,
0x01020304
);
let read = data_access::read_f64(&buffer, offset_range.start, "offset", endian)?;
assert!((read - value).abs() < 1e-12);
Ok(())
}
#[test]
fn alignment_padding_round_trip_u8_then_u32() -> Result<(), TypedefError> {
let schema = json!({
"TypeDef:Struct": true,
"properties": {
"flag": { "TypeDef:Uint8": true },
"id": { "TypeDef:Uint32": true }
}
});
let offset_map = OffsetMap::compute(&schema)?;
let flag_range = offset_map.get("flag").expect("flag");
let id_range = offset_map.get("id").expect("id");
assert_eq!(flag_range.start, 0);
assert_eq!(flag_range.end, 1);
assert_eq!(id_range.start, 4);
assert_eq!(id_range.end, 8);
assert_eq!(offset_map.total_size(), 8);
let mut buffer = vec![0u8; offset_map.total_size()];
data_access::write_u8(&mut buffer, flag_range.start, 0xAB, "flag")?;
data_access::write_u32(
&mut buffer,
id_range.start,
0x01020304,
"id",
Endian::Little,
)?;
assert_eq!(buffer[0], 0xAB);
assert_eq!(&buffer[1..4], &[0x00, 0x00, 0x00]);
assert_eq!(&buffer[4..8], 0x01020304u32.to_le_bytes());
assert_eq!(
data_access::read_u8(&buffer, flag_range.start, "flag")?,
0xAB
);
assert_eq!(
data_access::read_u32(&buffer, id_range.start, "id", Endian::Little)?,
0x01020304
);
Ok(())
}
#[test]
fn packed_layout_round_trip_via_layout_builder() -> Result<(), TypedefError> {
let schema = json!({
"TypeDef:Struct": true,
"endian": "little",
"properties": {
"flag": { "TypeDef:Uint8": true },
"id": { "TypeDef:Uint32": true },
"payload": { "TypeDef:String": true }
}
});
let builder = LayoutBuilder::new(&schema)?;
let layout = builder.build(&var_sizes(&[("payload", 10)]))?;
let flag_pos = layout.get("flag").expect("flag");
let id_pos = layout.get("id").expect("id");
let payload_pos = layout.get("payload").expect("payload");
assert_eq!(flag_pos.offset, 0);
assert_eq!(id_pos.offset, 1);
assert_eq!(payload_pos.offset, 5);
assert_eq!(layout.total_size(), 19);
let payload_str = "ten bytes!";
let payload_bytes = payload_str.as_bytes();
assert_eq!(payload_bytes.len(), 10);
let mut buffer = vec![0u8; layout.total_size()];
data_access::write_u8(&mut buffer, flag_pos.offset, 0xAB, "flag")?;
data_access::write_u32(&mut buffer, id_pos.offset, 0x01020304, "id", Endian::Little)?;
data_access::write_string(
&mut buffer,
payload_pos.offset,
payload_str,
"payload",
Endian::Little,
)?;
assert_eq!(buffer[0], 0xAB);
assert_eq!(&buffer[1..5], 0x01020304u32.to_le_bytes());
assert_eq!(&buffer[5..9], 10u32.to_le_bytes());
assert_eq!(&buffer[9..19], payload_bytes);
assert_eq!(
data_access::read_u8(&buffer, flag_pos.offset, "flag")?,
0xAB
);
assert_eq!(
data_access::read_u32(&buffer, id_pos.offset, "id", Endian::Little)?,
0x01020304
);
assert_eq!(
data_access::read_string(&buffer, payload_pos.offset, "payload", Endian::Little)?,
payload_str
);
Ok(())
}
#[test]
fn sequential_reader_round_trip_packed_buffer() -> Result<(), TypedefError> {
let schema = json!({
"TypeDef:Struct": true,
"endian": "little",
"properties": {
"id": { "TypeDef:Uint8": true },
"name": { "TypeDef:String": true },
"tail": { "TypeDef:Uint8": true }
}
});
let builder = LayoutBuilder::new(&schema)?;
let payload = "hello";
let layout = builder.build(&var_sizes(&[("name", payload.len())]))?;
let mut buffer = vec![0u8; layout.total_size()];
data_access::write_u8(&mut buffer, 0, 7, "id")?;
data_access::write_string(&mut buffer, 1, payload, "name", Endian::Little)?;
let after = 1 + 4 + payload.len();
data_access::write_u8(&mut buffer, after, 99, "tail")?;
let mut reader = SequentialReader::new(&schema)?;
assert_eq!(reader.endian(), Endian::Little);
assert_eq!(reader.position(), 0);
let (name, value) = reader.read_next(&buffer)?.expect("field 0");
assert_eq!(name, "id");
assert_eq!(value, FieldValue::U8(7));
assert_eq!(reader.position(), 1);
let (name, value) = reader.read_next(&buffer)?.expect("field 1");
assert_eq!(name, "name");
assert_eq!(value, FieldValue::String("hello"));
assert_eq!(reader.position(), after);
let (name, value) = reader.read_next(&buffer)?.expect("field 2");
assert_eq!(name, "tail");
assert_eq!(value, FieldValue::U8(99));
assert_eq!(reader.position(), after + 1);
assert!(reader.read_next(&buffer)?.is_none());
Ok(())
}
#[test]
fn sequential_reader_read_field_walks_preceding_fields() -> Result<(), TypedefError> {
let schema = json!({
"TypeDef:Struct": true,
"endian": "little",
"properties": {
"a": { "TypeDef:Uint8": true },
"b": { "TypeDef:Uint32": true },
"c": { "TypeDef:Uint8": true }
}
});
let mut buffer = vec![0u8; 16];
data_access::write_u8(&mut buffer, 0, 1, "a")?;
data_access::write_u32(&mut buffer, 1, 0xDEADBEEF, "b", Endian::Little)?;
data_access::write_u8(&mut buffer, 5, 9, "c")?;
let mut reader = SequentialReader::new(&schema)?;
let value = reader.read_field(&buffer, "c")?;
assert_eq!(value, FieldValue::U8(9));
assert_eq!(reader.position(), 6);
reader.reset();
let value = reader.read_field(&buffer, "b")?;
assert_eq!(value, FieldValue::U32(0xDEADBEEF));
Ok(())
}
#[test]
fn tunion_byte_offset_discriminator_dispatch() -> Result<(), TypedefError> {
let union_schema = json!({
"TypeDef:Union": true,
"discriminator": {
"kind": "byte",
"offset": 0,
"type": "TypeDef:Uint8"
},
"mapping": {
"5": { "$ref": "#/$defs/Read" },
"6": { "$ref": "#/$defs/Write" }
},
"$defs": {
"Read": {
"TypeDef:Struct": true,
"properties": {
"handle": { "TypeDef:Uint32": true },
"length": { "TypeDef:Uint32": true }
}
},
"Write": {
"TypeDef:Struct": true,
"properties": {
"handle": { "TypeDef:Uint32": true },
"length": { "TypeDef:Uint32": true },
"data": { "TypeDef:Uint32": true }
}
}
}
});
let mut buffer = vec![0u8; 32];
buffer[0] = 5;
data_access::write_u32(&mut buffer, 1, 0x01020304, "Read.handle", Endian::Big)?;
data_access::write_u32(&mut buffer, 5, 4096, "Read.length", Endian::Big)?;
let dispatch = tunion::read_byte_discriminator(&buffer, &union_schema, Endian::Big)?;
assert_eq!(dispatch.key, "5");
assert_eq!(dispatch.variant_offset, 1);
assert_eq!(dispatch.discriminator_size, 1);
let variant = tunion::resolve_variant(&union_schema, &dispatch.key)?;
assert_eq!(
variant
.get("TypeDef:Struct")
.and_then(serde_json::Value::as_bool),
Some(true)
);
Ok(())
}
#[test]
fn tunion_byte_offset_discriminator_size_lookup() -> Result<(), TypedefError> {
let u8_schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "byte", "type": "TypeDef:Uint8"},
"mapping": {}
});
let u16_schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "byte", "type": "TypeDef:Uint16"},
"mapping": {}
});
let u32_schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "byte", "type": "TypeDef:Uint32"},
"mapping": {}
});
assert_eq!(tunion::discriminator_size(&u8_schema)?, 1);
assert_eq!(tunion::discriminator_size(&u16_schema)?, 2);
assert_eq!(tunion::discriminator_size(&u32_schema)?, 4);
Ok(())
}
@@ -0,0 +1,323 @@
//! Integration tests for TUnion discriminator dispatch.
//!
//! Exercises both discriminator kinds end-to-end: byte-offset (SFTP
//! pattern) and field-name (typedef.ts pattern). Verifies that
//! `read_byte_discriminator` / `read_field_discriminator` produce the
//! correct mapping key and variant offset, that `resolve_variant`
//! follows `$ref` pointers, and that `discriminator_size` reports the
//! right fixed sizes.
use alknet_typedef::data_access;
use alknet_typedef::tunion;
use alknet_typedef::{Endian, TypedefError};
use serde_json::json;
fn sftp_like_byte_union() -> serde_json::Value {
json!({
"TypeDef:Union": true,
"discriminator": {
"kind": "byte",
"offset": 0,
"type": "TypeDef:Uint8"
},
"mapping": {
"5": { "$ref": "#/$defs/Read" },
"6": { "$ref": "#/$defs/Write" }
},
"$defs": {
"Read": {
"TypeDef:Struct": true,
"properties": {
"handle": { "TypeDef:Uint32": true },
"length": { "TypeDef:Uint32": true }
}
},
"Write": {
"TypeDef:Struct": true,
"properties": {
"handle": { "TypeDef:Uint32": true },
"length": { "TypeDef:Uint32": true },
"data": { "TypeDef:Uint32": true }
}
}
}
})
}
#[test]
fn read_byte_discriminator_uint8_dispatches_to_read() -> Result<(), TypedefError> {
let union_schema = sftp_like_byte_union();
let mut buffer = vec![0u8; 16];
buffer[0] = 5;
data_access::write_u32(&mut buffer, 1, 0x01020304, "Read.handle", Endian::Big)?;
let dispatch = tunion::read_byte_discriminator(&buffer, &union_schema, Endian::Big)?;
assert_eq!(dispatch.key, "5");
assert_eq!(dispatch.variant_offset, 1);
assert_eq!(dispatch.discriminator_size, 1);
let variant = tunion::resolve_variant(&union_schema, &dispatch.key)?;
assert_eq!(
variant
.get("TypeDef:Struct")
.and_then(serde_json::Value::as_bool),
Some(true)
);
Ok(())
}
#[test]
fn read_byte_discriminator_uint8_dispatches_to_write() -> Result<(), TypedefError> {
let union_schema = sftp_like_byte_union();
let mut buffer = vec![0u8; 16];
buffer[0] = 6;
data_access::write_u32(&mut buffer, 1, 0xDEADBEEF, "Write.handle", Endian::Big)?;
let dispatch = tunion::read_byte_discriminator(&buffer, &union_schema, Endian::Big)?;
assert_eq!(dispatch.key, "6");
assert_eq!(dispatch.variant_offset, 1);
assert_eq!(dispatch.discriminator_size, 1);
let variant = tunion::resolve_variant(&union_schema, &dispatch.key)?;
let props = variant
.get("properties")
.and_then(serde_json::Value::as_object)
.expect("variant has properties");
assert!(props.contains_key("data"));
Ok(())
}
#[test]
fn read_byte_discriminator_uint16_little_endian() -> Result<(), TypedefError> {
let schema = json!({
"TypeDef:Union": true,
"discriminator": {
"kind": "byte",
"offset": 2,
"type": "TypeDef:Uint16"
},
"mapping": {
"5": {"TypeDef:Struct": true, "properties": {"id": {"TypeDef:Uint32": true}}}
}
});
let mut buffer = vec![0u8; 16];
buffer[2..4].copy_from_slice(&5u16.to_le_bytes());
let dispatch = tunion::read_byte_discriminator(&buffer, &schema, Endian::Little)?;
assert_eq!(dispatch.key, "5");
assert_eq!(dispatch.variant_offset, 4);
assert_eq!(dispatch.discriminator_size, 2);
Ok(())
}
#[test]
fn read_byte_discriminator_uint32_big_endian() -> Result<(), TypedefError> {
let schema = json!({
"TypeDef:Union": true,
"discriminator": {
"kind": "byte",
"offset": 0,
"type": "TypeDef:Uint32"
},
"mapping": {
"101": {"TypeDef:Struct": true, "properties": {"id": {"TypeDef:Uint32": true}}}
}
});
let mut buffer = vec![0u8; 16];
buffer[0..4].copy_from_slice(&101u32.to_be_bytes());
let dispatch = tunion::read_byte_discriminator(&buffer, &schema, Endian::Big)?;
assert_eq!(dispatch.key, "101");
assert_eq!(dispatch.variant_offset, 4);
assert_eq!(dispatch.discriminator_size, 4);
Ok(())
}
#[test]
fn read_byte_discriminator_unknown_value_returns_access_error() -> Result<(), TypedefError> {
let union_schema = sftp_like_byte_union();
let buffer = [99u8, 0x00, 0x00, 0x00];
let err = tunion::read_byte_discriminator(&buffer, &union_schema, Endian::Big).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
Ok(())
}
#[test]
fn read_field_discriminator_string_dispatches_to_read() -> Result<(), TypedefError> {
let union_schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "field", "name": "type"},
"properties": {
"type": { "TypeDef:String": true }
},
"mapping": {
"read": {"$ref": "#/$defs/Read"},
"write": {"$ref": "#/$defs/Write"}
},
"$defs": {
"Read": {
"TypeDef:Struct": true,
"properties": {
"handle": { "TypeDef:Uint32": true },
"length": { "TypeDef:Uint32": true }
}
},
"Write": {
"TypeDef:Struct": true,
"properties": {
"handle": { "TypeDef:Uint32": true },
"data": { "TypeDef:Bytes": true }
}
}
}
});
let value = "read";
let mut buffer = vec![0u8; 32];
data_access::write_string(&mut buffer, 0, value, "type", Endian::Little)?;
let dispatch = tunion::read_field_discriminator(&buffer, &union_schema, 0, Endian::Little)?;
assert_eq!(dispatch.key, "read");
assert_eq!(dispatch.variant_offset, 4 + value.len());
assert_eq!(dispatch.discriminator_size, 4 + value.len());
let variant = tunion::resolve_variant(&union_schema, &dispatch.key)?;
assert_eq!(
variant
.get("TypeDef:Struct")
.and_then(serde_json::Value::as_bool),
Some(true)
);
Ok(())
}
#[test]
fn read_field_discriminator_string_dispatches_to_write() -> Result<(), TypedefError> {
let union_schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "field", "name": "type"},
"properties": {
"type": { "TypeDef:String": true }
},
"mapping": {
"read": {"$ref": "#/$defs/Read"},
"write": {"$ref": "#/$defs/Write"}
},
"$defs": {
"Read": {
"TypeDef:Struct": true,
"properties": {"x": {"TypeDef:Uint8": true}}
},
"Write": {
"TypeDef:Struct": true,
"properties": {"y": {"TypeDef:Uint16": true}}
}
}
});
let value = "write";
let mut buffer = vec![0u8; 32];
data_access::write_string(&mut buffer, 0, value, "type", Endian::Little)?;
let dispatch = tunion::read_field_discriminator(&buffer, &union_schema, 0, Endian::Little)?;
assert_eq!(dispatch.key, "write");
assert_eq!(dispatch.variant_offset, 4 + value.len());
let variant = tunion::resolve_variant(&union_schema, &dispatch.key)?;
let props = variant
.get("properties")
.and_then(serde_json::Value::as_object)
.expect("variant has properties");
assert!(props.contains_key("y"));
assert!(!props.contains_key("x"));
Ok(())
}
#[test]
fn read_field_discriminator_uint8_field() -> Result<(), TypedefError> {
let union_schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "field", "name": "tag"},
"properties": {
"tag": { "TypeDef:Uint8": true }
},
"mapping": {
"0": {"TypeDef:Struct": true, "properties": {"a": {"TypeDef:Uint32": true}}},
"1": {"TypeDef:Struct": true, "properties": {"b": {"TypeDef:Uint16": true}}}
}
});
let mut buffer = vec![0u8; 8];
buffer[0] = 0;
let dispatch = tunion::read_field_discriminator(&buffer, &union_schema, 0, Endian::Little)?;
assert_eq!(dispatch.key, "0");
assert_eq!(dispatch.variant_offset, 1);
assert_eq!(dispatch.discriminator_size, 1);
buffer[0] = 1;
let dispatch = tunion::read_field_discriminator(&buffer, &union_schema, 0, Endian::Little)?;
assert_eq!(dispatch.key, "1");
Ok(())
}
#[test]
fn read_field_discriminator_unknown_value_returns_access_error() -> Result<(), TypedefError> {
let union_schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "field", "name": "tag"},
"properties": {
"tag": { "TypeDef:Uint8": true }
},
"mapping": {
"0": {"TypeDef:Struct": true, "properties": {"a": {"TypeDef:Uint32": true}}}
}
});
let mut buffer = vec![0u8; 8];
buffer[0] = 99;
let err =
tunion::read_field_discriminator(&buffer, &union_schema, 0, Endian::Little).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
Ok(())
}
#[test]
fn discriminator_size_returns_correct_values() -> Result<(), TypedefError> {
let u8_schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "byte", "type": "TypeDef:Uint8"},
"mapping": {}
});
let u16_schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "byte", "type": "TypeDef:Uint16"},
"mapping": {}
});
let u32_schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "byte", "type": "TypeDef:Uint32"},
"mapping": {}
});
assert_eq!(tunion::discriminator_size(&u8_schema)?, 1);
assert_eq!(tunion::discriminator_size(&u16_schema)?, 2);
assert_eq!(tunion::discriminator_size(&u32_schema)?, 4);
Ok(())
}
#[test]
fn discriminator_size_field_kind_returns_schema_error() {
let schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "field", "name": "type"},
"properties": {"type": {"TypeDef:Uint8": true}},
"mapping": {}
});
let err = tunion::discriminator_size(&schema).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
}
#[test]
fn resolve_variant_returns_schema_error_for_unknown_key() {
let union_schema = sftp_like_byte_union();
let err = tunion::resolve_variant(&union_schema, "999").unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
}
#[test]
fn parse_discriminator_missing_returns_schema_error() {
let schema = json!({"TypeDef:Union": true});
let err = alknet_typedef::parse_discriminator(&schema).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
}
+120 -59
View File
@@ -1,12 +1,48 @@
---
status: draft
last_updated: 2026-07-16
last_updated: 2026-07-19
---
# Alknet Architecture
## Current State
**Per-identity channel cap added (ADR-094, 2026-07-19).** The
channels-layer `max_channels = 256` cap (ADR-076) was framed as the
per-connection DoS defense. On review this is not a DoS defense at
all — a peer can open an unbounded number of transport connections,
so a per-connection cap bounds a connection's reassembly-buffer cost,
not a peer's total channels. The only coherent unit for a channel
DoS defense is the identity. [ADR-094](decisions/094-per-identity-channel-cap.md)
records the corrected design: a `ChannelLifecyclePolicy` trait in
`channels-call` (where the identity is already on `OperationContext`),
consulted by the `channel/open` handler (after `AccessControl::check`,
before allocation) and the `channel/close` handler (after the drain
completes). Default: `PerIdentityChannelPolicy::new(256)` — 256 per
`PeerId` across all the peer's connections (no "NoOp default + wire it
later"). The policy `Arc` is shared across every channels connection
a peer accepts, which is what makes the cap per-identity, not
per-connection. ADR-076 is amended — the per-connection `max_channels`
is reframed as a memory bound, the "DoS defense summary" table is
removed, and the "per-connection, not per-peer — a peer can open more
channels on a second connection" line (the channels layer confessing
a hole and hoping the layer above would fill it) is corrected. The
cap lives in `channels-call`, not `channels-core`, because the
channels layer is auth-blind by design (ADR-075 — that is what makes
it WASM-compatible, transport-agnostic, and ALPN-blind). The cap is a
peer concern, not a hub-specific concern — any accepting peer (worker
or hub) enforces it, the same way it enforces `AccessControl::check`.
For the hub-relay path (ADR-079), the spoke sees the hub as the direct
caller (ADR-032 — `forwarded_for` is metadata, not authority, for the
cap as for `AccessControl::check`), so the spoke caps the hub, not the
browser; a spoke serving a high-fan-out hub sets the hub peer's cap
higher via `with_per_identity_caps`. The "assembly layer" hedging
pattern (putting the hard question off on a fictional later that
turns out to be exactly the same problem) is actively avoided — the
default is secure out of the box, and per-peer-role overrides are
explicit opt-ins. See [ADR-094](decisions/094-per-identity-channel-cap.md)
and the amended [ADR-076](decisions/076-backpressure-channel-limits-id-reuse.md).
**Client-dial SOCKS5 proxy seam added (ADR-090, 2026-07-16).**
`AlknetClient` (ADR-089) gains an optional SOCKS5 proxy
(`with_socks5_proxy`) so a native client can hide its real IP from the
@@ -78,78 +114,86 @@ The [overview.md](overview.md) crate graph and ALPN registry are
rewritten to match.
**alknet-channels specs drafted.** The alknet-channels crate (multiplexing
proxy — `ProtocolHandler` on `alknet/channels`, 9-byte chunk format, N
proxy — `ProtocolHandler` on `alknet/channels`, 8-byte chunk format, N
channels over transport stream(s), channel 0 pre-negotiated as
`alknet/call`) now has architecture specs:
[crates/channels/](crates/channels/) (overview, channels-wire,
channels-connection, channels-adapter, channel-operations, channel-client)
and eleven ADRs — [ADR-071](decisions/071-channels-wire-format.md) (9-byte
chunk header; revised for substrate simplification — the header is used in
all substrates including QUIC native, not just in-line; and stream_type
decomposition — every stream_type is unidirectional, grouped in threes:
0/1/2 = data write/read/err, 3/4/5 = control write/read/err, `% 3` formula;
resolves the TTY control channel's "not actually bidirectional" flaw),
and twelve ADRs — [ADR-071](decisions/071-channels-wire-format.md) (8-byte
chunk header; amended by ADR-093 — the channels layer has no `stream_type`
concept; the handler owns its sub-stream multiplexing on the `BiStream`),
[ADR-072](decisions/072-channel-0-pre-negotiated-call.md)
(channel 0 = `alknet/call` pre-negotiated, stream_types [0,1] — call frames
bidirectional via 0=in, 1=out),
(channel 0 = `alknet/call` pre-negotiated; the call protocol's
`EventEnvelope` framing is the channels payload, carried transparently),
[ADR-073](decisions/073-channel-lifecycle-operations.md) (channel
lifecycle operations on the call protocol — `channel/open`/`close`/
`control`/`resources/subscribe`; `channel/resources/subscribe` is a
`Subscription` operation using the already-implemented `StreamingHandler`
machinery, not a polled `Query`; the `direction` field pins who is the
ALPN-server; the control-message division is call-ops for orchestration,
`stream_type 3`/`4` for data-ordered control),
ALPN-server; amended by ADR-093 — `stream_types` field removed from
`channel/open`, `stream_type` field removed from `channel/control`),
[ADR-074](decisions/074-channelconnection-bidistreamsource.md)
(`ChannelBidiStreamSource` implements `BidiStreamSource` — ADR-070's
extension point; `into_sub_streams()` with `SubStreamHandle` enum (Send/Recv
per unidirectional stream_type); `accept_bi()` generic path for tunnel/SSH),
extension point; amended by ADR-093 — `into_sub_streams()` removed;
`accept_bi()` is the only accessor, yields one `BiStream` per channel),
[ADR-075](decisions/075-channelsadapter-and-channelmanager.md)
(`ChannelsAdapter` substrate-agnostic demux loop (reads 9-byte headers off
(`ChannelsAdapter` substrate-agnostic demux loop (reads 8-byte headers off
every bidi stream, regardless of substrate) + `ChannelManager`
reassemble/allocate split; REQ-CH-01..04 wire-level invariants pinned:
shutdown emits zero-length sentinel, transport close drops all senders, mux
dynamic registration, lenient unknown-`channel_id`),
[ADR-076](decisions/076-backpressure-channel-limits-id-reuse.md)
(bounded-buffer backpressure 1 MiB default, 256-channel cap, monotonic IDs
with wrap-around),
[ADR-077](decisions/077-tty-inside-channels.md) (TTY inside channels uses
sub-streams, not its own 5-byte wire format; 5 sub-streams [0,1,2,3,4]
with control properly bidirectional via 3 (write) + 4 (read); ADR-052's
scope amended to direct-connect TTY only; `channels` feature on alknet-tty),
(bounded-buffer backpressure 1 MiB default per channel, 256-channel cap,
monotonic IDs with wrap-around; amended by ADR-093 — per-`channel_id`,
not per-`(channel_id, stream_type)`),
[ADR-077](decisions/077-tty-inside-channels.md) (TTY inside channels —
**reversed by ADR-093**: TTY always uses its 5-byte format, carried
transparently in the channels payload; the two-mode design is preserved
but differs only in `BiStream` source, not in parsing; the control channel
split is TTY-internal, not channels-layer),
[ADR-078](decisions/078-two-pump-shutdown-on-completion.md) (two-pump
handlers MUST shut down the opposite sink on pump completion — the
deadlock contract the POC surfaced; handler-level, not channels-layer;
core helper extraction deferred per OQ-57),
[ADR-079](decisions/079-hub-relay-translate-not-forward.md) (hub relay
translates `channel/open` on channel 0 with `forwarded_for` — ADR-032;
data channels byte-forwarded with `channel_id` rewrite; the hub never runs
protocol-specific handlers),
data channels byte-forwarded with `channel_id` rewrite (4-byte field
rewrite within the 8-byte header); the hub never runs protocol-specific
handlers),
[ADR-080](decisions/080-channelclient.md) (`ChannelClient`,
transport-agnostic `from_connection` primary; `connect_quic` removed
per ADR-089 §5 (dial extracted to `AlknetClient`),
bidirectionality preserved; `AlknetClient` dial-seam extracted as
`alknet-client` per ADR-089, resolving OQ-55),
transport-agnostic `from_connection` primary; dial lives in
`AlknetClient` (`alknet-client`, ADR-089, resolving OQ-55);
bidirectionality preserved; no `stream_types` on `open_channel`/`Channel`
per ADR-093),
[ADR-081](decisions/081-channels-subcrate-decomposition.md) (sub-crate
decomposition — `channels-core` (pure multiplexer, depends on alknet-core
only, no call dependency) / `channels-call` (channel 0 pre-negotiation +
lifecycle op registrations, depends on channels-core + alknet-call) /
`channels-hub` (relay) / `channels-worker` (ChannelClient); isolates the
call-protocol coupling from the pure multiplexer). The specs are grounded
in the completed de-risk POC
(`docs/research/alknet-channels/poc-summary.md`, 28 tests passing, three
validated targets: chunk format + demux/mux, per-channel `Connection`
presentation, tunnel handler). The core prerequisite — ADR-070
(`BidiStreamSource` trait + `Connection::from_source`) — is landed and
implemented. The spec work converted three research hedges into decisions:
call-protocol coupling from the pure multiplexer; amended by ADR-093 —
8-byte wire format, `ChannelSubStreams`/`SubStreamHandle` removed),
[ADR-093](decisions/093-channels-pure-channel-multiplexing.md) (the
umbrella decision: channels layer is pure channel multiplexing — 8-byte
header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only,
TTY always 5-byte; amends ADR-071/074/077 and the channels-facing clauses
of ADR-072/073/075/076/080/081). The specs are grounded in the completed
de-risk POC (`docs/research/alknet-channels/poc-summary.md`, 28 tests
passing, three validated targets: chunk format + demux/mux, per-channel
`Connection` presentation, tunnel handler) and the stream-unification
research (`docs/research/stream-unification/findings.md`, which surfaced
the pure-multiplexing resolution). The core prerequisite — ADR-070
(`BidiStreamSource` trait + `Connection::from_source`) + ADR-092
(`BiStream` as the handler leaf) — is landed and implemented. The spec
work converted three research hedges into decisions:
`channel/resources` is subscribe from day one (not poll-for-v1), channel
ID allocation is server-assigned (not "if zero-RTT needed"), and
backpressure is bounded-buffer (not "if HOL blocking becomes a problem").
Two genuine deferrals: OQ-56 (full windowing — blocked on a real HOL-
blocking observation) and OQ-57 (two-pump helper extraction — blocked on a
second two-pump handler). The TTY integration (ADR-077) amends ADR-052's
scope — the 5-byte format is unchanged for direct `alknet/tty` connections;
inside channels, TTY uses `into_sub_streams()` and the channels layer's
de-chunking, with control properly bidirectional via stream_types 3/4.
blocking observation) and OQ-57 (two-pump helper extraction — blocked on
a second two-pump handler). ADR-093 is the channels-layer consequence of
ADR-092's `BiStream` handler-leaf decision — every channel is a
`BiStream`, the handler owns its sub-stream multiplexing, the channels
layer has no `stream_type` concept.
**Pre-implementation of the storage/repo pattern.** The project has completed a pivot from a three-layer model to an ALPN-as-service model. The greenfield workspace contains `alknet-vault` (stable — implementation complete and verified, local-only by construction per ADR-025, HD-derivation key model per ADR-026) and research/reference material. Foundational ADRs (001–035) are in place, with the call crate implemented and reviewed.
@@ -210,7 +254,7 @@ adapter location map is now consistent: all HTTP-backed adapters
|----------|--------|-------------|
| [overview.md](overview.md) | draft | Workspace-level overview, crate graph (core mono-repo scope per ADR-085), hub/worker model, shared types, design principles |
| [open-questions.md](open-questions.md) | draft | OQ index — theme-grouped tables + Deferred/Blocked section; per-OQ files in [`questions/`](questions/) |
| [crates/core/README.md](crates/core/README.md) | draft | alknet-core crate index — shared types + auth + config (endpoint extracted to `alknet-endpoint` per ADR-083 Am. 2026-07-15; `ConnectionCredentials`/`RemoteIdentity` moved here from `alknet-call` per ADR-091; `CallCredentials` removed per ADR-091 Am. 2026-07-17) |
| [crates/core/README.md](crates/core/README.md) | draft | alknet-core crate index — shared types + auth + config (endpoint in `alknet-endpoint` per ADR-083 Am. 2026-07-15; `ConnectionCredentials`/`RemoteIdentity` here per ADR-091) |
| [crates/core/core-types.md](crates/core/core-types.md) | draft | ProtocolHandler, HandlerError, Connection (`Box<dyn BidiStreamSource>` — ADR-070), BidiStreamSource trait, BiStream, StreamError |
| [crates/core/endpoint.md](crates/core/endpoint.md) | deprecated | Endpoint spec — **moved to `alknet-endpoint`** (ADR-083 Am. 2026-07-15); see [`crates/endpoint/README.md`](crates/endpoint/README.md) |
| [crates/core/auth.md](crates/core/auth.md) | draft | AuthContext (incl. `anonymous` constructor), Identity, IdentityProvider, AuthToken, resolution flow |
@@ -218,7 +262,7 @@ adapter location map is now consistent: all HTTP-backed adapters
| [crates/call/README.md](crates/call/README.md) | draft | alknet-call crate index |
| [crates/call/call-protocol.md](crates/call/call-protocol.md) | draft | CallAdapter, hand-rolled EventEnvelope framing (no irpc — ADR-064), stream model, PendingRequestMap, bidirectional calls, streaming subscribe example |
| [crates/call/operation-registry.md](crates/call/operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, capability injection, service discovery (hand-rolled, no irpc) |
| [crates/call/client-and-adapters.md](crates/call/client-and-adapters.md) | draft | CallClient (transport-agnostic `spawn_dispatch` primary; `connect` removed per ADR-089 §5 — dial extracted to `AlknetClient`), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (from_jsonschema moved to alknet-http per ADR-066) |
| [crates/call/client-and-adapters.md](crates/call/client-and-adapters.md) | draft | CallClient (transport-agnostic `spawn_dispatch` primary; dial in `AlknetClient` per ADR-089), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (`from_jsonschema` in alknet-http per ADR-066) |
| [crates/http/README.md](crates/http/README.md) | draft | alknet-http crate index |
| [crates/http/overview.md](crates/http/overview.md) | draft | Crate purpose, two roles (server + client host), dependencies, adapter location map |
| [crates/http/http-server.md](crates/http/http-server.md) | draft | HttpAdapter for h2/http1.1 + WebSocket upgrade route, axum over QUIC, Bearer auth, stealth, /healthz |
@@ -242,16 +286,22 @@ adapter location map is now consistent: all HTTP-backed adapters
| [crates/vault/service.md](crates/vault/service.md) | stable | VaultServiceHandle lifecycle, direct dispatch, cache, error model |
| [crates/vault/protocol.md](crates/vault/protocol.md) | stable | DerivedKey redaction, KeyType, serialization behavior |
| [crates/hub/README.md](crates/hub/README.md) | draft | alknet-hub crate — composes a subset of three endpoint types (web/native/iroh — ADR-086), channels substrate (ADR-079 relay), worker registration flow (OQ-58), identity over transports, aggregated peer env, connection lifecycle, service discovery |
| [crates/tls/README.md](crates/tls/README.md) | reviewed | alknet-tls crate — shared TLS config (`TlsServerConfig` + `TlsClientConfig`) shared across quinn + TCP+TLS + iroh; one cert, one ACME state machine, N transports; split ALPN lists per endpoint type (ADR-086, resolves OQ-62); `FingerprintPinVerifier` moved here from `alknet-call` (ADR-089 §5); `webpki-roots` fallback for empty platform stores (ADR-088 §5); fixes cert-reuse welding in `alknet-core/endpoint.rs` (ADR-082) |
| [crates/client/README.md](crates/client/README.md) | draft | alknet-client crate — the native client dial seam (`AlknetClient`), client-side analogue of `AlknetEndpoint`; three dials (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key) unified on `&ConnectionCredentials` (ADR-091); optional SOCKS5 proxy (ADR-090 — UDP ASSOCIATE for QUIC, CONNECT for TCP+TLS, force-relay-only + HTTP-to-SOCKS5 bridge for iroh; OQ-67 resolved); produces `Connection` for `CallClient`/`ChannelClient` take-over; `CallClient::connect`/`ChannelClient::connect_quic` removed (dial centralized here); `alknet/register` named (wire protocol deferred, OQ-66) |
| [crates/endpoint/README.md](crates/endpoint/README.md) | draft | alknet-endpoint crate — the server-side accept-loop runner (`AlknetEndpoint`), extracted from `alknet-core` (ADR-083 Am. 2026-07-15); takes pre-built transports via `with_quinn`/`with_iroh`/`with_tcp_tls`; public `dispatch` for SSH/WT; `EndpointError` removed (vestigial); handler crates no longer transitively link quinn/iroh |
| [crates/channels/README.md](crates/channels/README.md) | draft | alknet-channels crate — multiplexing proxy, 9-byte chunk format, N channels over one transport stream |
| [crates/tls/README.md](crates/tls/README.md) | reviewed | alknet-tls crate — shared TLS config (`TlsServerConfig` + `TlsClientConfig`) shared across quinn + TCP+TLS + iroh; one cert, one ACME state machine, N transports; split ALPN lists per endpoint type (ADR-086, resolves OQ-62); `FingerprintPinVerifier` in `alknet-tls` (ADR-089 §5); `webpki-roots` fallback for empty platform stores (ADR-088 §5); isolates cert-reuse from transport wrappers (ADR-082) |
| [crates/client/README.md](crates/client/README.md) | draft | alknet-client crate — the native client dial seam (`AlknetClient`), client-side analogue of `AlknetEndpoint`; three dials (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key) unified on `&ConnectionCredentials` (ADR-091); optional SOCKS5 proxy (ADR-090 — UDP ASSOCIATE for QUIC, CONNECT for TCP+TLS, force-relay-only + HTTP-to-SOCKS5 bridge for iroh; OQ-67 resolved); produces `Connection` for `CallClient`/`ChannelClient` take-over; dial centralized here; `alknet/register` named (wire protocol deferred, OQ-66) |
| [crates/endpoint/README.md](crates/endpoint/README.md) | draft | alknet-endpoint crate — the server-side accept-loop runner (`AlknetEndpoint`), extracted from `alknet-core` (ADR-083 Am. 2026-07-15); takes pre-built transports via `with_quinn`/`with_iroh`/`with_tcp_tls`; public `dispatch` for SSH/WT; handler crates no longer transitively link quinn/iroh |
| [crates/channels/README.md](crates/channels/README.md) | draft | alknet-channels crate — multiplexing proxy, 8-byte chunk format, N channels over one transport stream |
| [crates/channels/overview.md](crates/channels/overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, transport agnosticism, WASM, relationship to existing crates |
| [crates/channels/channels-wire.md](crates/channels/channels-wire.md) | draft | 9-byte chunk format, stream types, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
| [crates/channels/channels-connection.md](crates/channels/channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource`), `into_sub_streams()` typed accessor, recursive composition |
| [crates/channels/channels-wire.md](crates/channels/channels-wire.md) | draft | 8-byte chunk format, the add/strip composition, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
| [crates/channels/channels-connection.md](crates/channels/channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource`), `accept_bi` yields `BiStream`, recursive composition |
| [crates/channels/channels-adapter.md](crates/channels/channels-adapter.md) | draft | `ChannelsAdapter`, `ChannelManager`, demux/mux contracts (REQ-CH-01..04), two-pump pattern (ADR-078) |
| [crates/channels/channel-operations.md](crates/channels/channel-operations.md) | draft | `channel/open`/`close`/`control`/`resources/subscribe`, ACL flow, `direction` semantics, hub relay contract (ADR-079) |
| [crates/channels/channel-client.md](crates/channels/channel-client.md) | draft | `ChannelClient` — client side of a channels connection, transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); bidirectionality preserved |
| [crates/channels/channel-client.md](crates/channels/channel-client.md) | draft | `ChannelClient` — client side of a channels connection, transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-089); bidirectionality preserved |
| [crates/typedef/README.md](crates/typedef/README.md) | draft | alknet-typedef crate — binary struct engine; JSON Schema with `TypeDef:*` custom keywords → offset map + read/write + validation |
| [crates/typedef/overview.md](crates/typedef/overview.md) | draft | Crate purpose, "schema is the format" principle, dependencies, consumers, scope boundaries |
| [crates/typedef/schema-layer.md](crates/typedef/schema-layer.md) | draft | The 19 `TypeDef:*` kinds, jsonschema custom keyword integration, TypeBox interop, schema annotations |
| [crates/typedef/layout-engine.md](crates/typedef/layout-engine.md) | draft | Offset computation, two layout modes (packed sequential vs aligned static), alignment, endianness, variable-length handling |
| [crates/typedef/data-access.md](crates/typedef/data-access.md) | draft | Read/write functions, TUnion dispatch, field paths, zero-copy access, length-prefix reading |
| [crates/typedef/validation.md](crates/typedef/validation.md) | draft | Custom keyword validators for all 19 `TypeDef:*` kinds, `TypedefError`, load-time vs access-time validation |
## ADR Table
@@ -327,31 +377,42 @@ adapter location map is now consistent: all HTTP-backed adapters
| [068](decisions/068-peer-composite-env-peer-operations.md) | PeerCompositeEnv::peer_operations Override | Proposed |
| [069](decisions/069-from-call-manual-free-function.md) | from_call Is a Manual Free Function, Not Auto-Wired | Proposed |
| [070](decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait — Open Connection for Extension | Accepted |
| [071](decisions/071-channels-wire-format.md) | alknet-channels Wire Format — 9-Byte Chunk Header | Accepted |
| [072](decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alknet/call` | Accepted |
| [073](decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | Accepted |
| [074](decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Accepted |
| [075](decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Accepted |
| [076](decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Accepted |
| [077](decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | Accepted (amends ADR-052 scope — 5-byte format scoped to direct TTY) |
| [071](decisions/071-channels-wire-format.md) | alknet-channels Wire Format — 8-Byte Chunk Header | Accepted (amended by ADR-093 — 8-byte header, no `stream_type`) |
| [072](decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alknet/call` | Accepted (amended by ADR-093 — channel 0's `stream_types` field removed; the call protocol's framing is the channels payload) |
| [073](decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | Accepted (amended by ADR-093 — `stream_types` field removed from `channel/open`; `stream_type` field removed from `channel/control`) |
| [074](decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Accepted (amended by ADR-093 — `into_sub_streams()` removed; `accept_bi` yields `BiStream`) |
| [075](decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Accepted (amended by ADR-093 — 8-byte headers, one reassembly buffer per channel) |
| [076](decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Accepted (amended by ADR-093 — per-`channel_id`, not per-`(channel_id, stream_type)`; amended by ADR-094 — per-connection `max_channels` reframed as a memory bound, not a DoS defense; per-identity DoS defense lives in `channels-call` via `ChannelLifecyclePolicy`) |
| [077](decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | Accepted (reversed by ADR-093 — TTY always uses its 5-byte format, carried transparently) |
| [078](decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | Accepted |
| [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | Accepted |
| [080](decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | Accepted |
| [081](decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | Accepted |
| [080](decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | Accepted (amended by ADR-093 — `stream_types` field removed from `open_channel` and `Channel`) |
| [081](decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | Accepted (amended by ADR-093 — 8-byte wire format; `ChannelSubStreams`/`SubStreamHandle` removed) |
| [082](decisions/082-alknet-tls-extraction.md) | alknet-tls Crate Extraction | Accepted (amended — endpoint signature superseded by ADR-083) |
| [083](decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as Multi-Transport Accept-Loop Runner with Public Dispatch | Accepted (revised — TCP+TLS is an owned transport, not external; amended 2026-07-15 — endpoint extracted from `alknet-core` into `alknet-endpoint`; `EndpointError` removed — both variants vestigial, `shutdown()` infallible) |
| [084](decisions/084-aws-lc-rs-crypto-provider.md) | aws-lc-rs as the TLS Crypto Provider | Accepted |
| [085](decisions/085-workspace-scope-core-vs-consumer-repos.md) | Workspace Scope — Core vs. Consumer Repos | Accepted |
| [086](decisions/086-endpoint-types-and-entry-points.md) | Endpoint Types and Entry Points | Accepted |
| [087](decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `TlsClientConfig` Not Blocked on Dial Seam | Accepted (§5 amended by ADR-089 — `FingerprintPinVerifier` moves to `alknet-tls`; `alknet-call` sheds TLS deps; input framing amended by ADR-091 — `ClientVerifierContext` derived from `ConnectionCredentials`, not `CallCredentials`) |
| [087](decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `TlsClientConfig` Not Blocked on Dial Seam | Accepted (§5 amended by ADR-089 — `FingerprintPinVerifier` in `alknet-tls`; `alknet-call` sheds TLS deps; input framing amended by ADR-091 — `TlsClientConfig::new` takes `ConnectionCredentials`, not `CallCredentials`) |
| [088](decisions/088-tlserror-shape.md) | `TlsError` Shape — Single Enum, Owned by `alknet-tls` | Accepted (§5 added — `webpki-roots` fallback when platform store is empty; §7 references ADR-089 for handshake-error surfacing) |
| [089](decisions/089-alknetclient-native-dial-seam.md) | AlknetClient — Native Client Dial Seam | Accepted (resolves OQ-55; `CallClient::connect` / `ChannelClient::connect_quic` removed; §3/§5 amended by ADR-091 — dial takes `ConnectionCredentials`, not `CallCredentials`; `CallCredentials` removed per ADR-091 Am. 2026-07-17; `FingerprintPinVerifier` moved to `alknet-tls`; `ClientError` removed; `alknet-call` sheds TLS deps) |
| [090](decisions/090-client-dial-socks5-proxy-seam.md) | Client-Dial SOCKS5 Proxy Seam | Accepted (§5 amended 2026-07-16 — OQ-67 resolved: iroh force-relay-only + HTTP-to-SOCKS5 bridge) |
| [091](decisions/091-connectioncredentials-decouple-dial-from-call.md) | `ConnectionCredentials` — Decouple Dial Credentials from Call Protocol | Accepted (amends ADR-089 §3/§5 and ADR-087 input framing; dial takes `ConnectionCredentials` not `CallCredentials`; all three dial signatures unified; `dial_iroh`'s `node_id` derived from `remote_identity`; `auth_token` is a per-request payload field; `CallCredentials` removed per Am. 2026-07-17) |
| [092](decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf — Unify the Split-Pair `accept_bi` | Accepted (amends ADR-070's `accept_bi` return type; amends ADR-065's `from_stream`/`from_bidi` constructors; amends ADR-074's `ChannelBidiStreamSource::accept_bi` return type; `Connection::from_stream` removed; `from_bidi` is the only public stream constructor) |
| [093](decisions/093-channels-pure-channel-multiplexing.md) | alknet-channels — Pure Channel Multiplexing (8-Byte Header, No `stream_type`) | Accepted (amends ADR-071 — 8-byte header; ADR-074 — `into_sub_streams` removed; reverses ADR-077 — TTY always uses its 5-byte format; amends the channels-facing clauses of ADR-072/073/075/076/080/081) |
| [094](decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap as DoS Defense | Accepted (amends ADR-076 — per-connection `max_channels` reframed as a memory bound; 256 per `PeerId` enforced via `ChannelLifecyclePolicy` in `channels-call`; symmetric; spoke caps hub as direct caller) |
| [095](decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md) | alknet-typedef — Purpose, Scope, and the jsonschema Engine | Accepted |
| [096](decisions/096-two-layout-modes-packed-vs-aligned.md) | Two Layout Modes — Packed Sequential vs Aligned Static | Accepted |
| [097](decisions/097-schema-annotations.md) | Schema Annotations — Endianness, Alignment, Encoding, and TUnion Discriminators | Accepted |
| [098](decisions/098-error-handling-validation-strategy.md) | Error Handling and Validation Strategy | Accepted |
| [099](decisions/099-int64-uint64-first-class-kinds.md) | Int64/Uint64 as First-Class Kinds | Accepted |
| [100](decisions/100-reject-non-final-inline-length-prefixed-in-aligned-mode.md) | Reject Non-Final Inline Length-Prefixed Variable Fields in Aligned Mode | Accepted |
| [101](decisions/101-packed-mode-read-factory.md) | Packed-Mode Read API — Engine as SequentialReader Factory | Accepted |
| [102](decisions/102-reject-tunion-in-aligned-mode.md) | Reject TUnion in Aligned Mode for v1 | Accepted |
## Open Questions
Open questions are tracked in [open-questions.md](open-questions.md) — an index of theme-grouped tables (67 OQs across 20 themes) with a cross-theme [Deferred / Blocked](open-questions.md#deferred--blocked) section surfacing the safe-exit deferrals. Each OQ lives in its own file under [`questions/`](questions/) (`NNN-slug.md`, mirroring the ADR convention).
Open questions are tracked in [open-questions.md](open-questions.md) — an index of theme-grouped tables (71 OQs across 21 themes) with a cross-theme [Deferred / Blocked](open-questions.md#deferred--blocked) section surfacing the safe-exit deferrals. Each OQ lives in its own file under [`questions/`](questions/) (`NNN-slug.md`, mirroring the ADR convention).
## Document Lifecycle
+9 -7
View File
@@ -1,12 +1,12 @@
---
status: draft
last_updated: 2026-07-09
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-017 client/adapter surface) addressed 2026-06-26; ADR-029 migration pending. Transport generalization sweep (ADR-064 supersedes ADR-005; ADR-065 `from_stream`) synced 2026-07-09.
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-017 client/adapter surface) addressed 2026-06-26; ADR-029 migration landed. Transport generalization sweep (ADR-064 supersedes ADR-005; ADR-065 `from_stream`) synced 2026-07-09. Crate-extraction sweep (phases 0–5) landed 2026-07-17: `ConnectionCredentials`/`RemoteIdentity` in `alknet-core` (ADR-091); TLS helpers in `alknet-tls` (ADR-089 §5); dial in `alknet-client` (ADR-089); `alknet-call` is a pure protocol crate with no TLS/transport deps.
---
# alknet-call
Structured RPC: operations, request/response, streaming subscriptions, and service discovery. Implements `ProtocolHandler` on ALPN `alknet/call`. Runs over QUIC (quinn/iroh) and, via `Connection::from_stream` (ADR-065), over any `AsyncRead + AsyncWrite` transport.
Structured RPC: operations, request/response, streaming subscriptions, and service discovery. Implements `ProtocolHandler` on ALPN `alknet/call`. Runs over QUIC (quinn/iroh) and, via `Connection::from_stream` (ADR-065), 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`).
## 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-064), stream model, PendingRequestMap, bidirectional calls |
| [operation-registry.md](operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, hand-rolled framing (no irpc — ADR-064) |
| [client-and-adapters.md](client-and-adapters.md) | draft | CallClient (transport-agnostic `spawn_dispatch` primary; `connect` removed per ADR-089 §5 — dial extracted to `AlknetClient`), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (from_jsonschema moved to alknet-http per ADR-066) |
| [client-and-adapters.md](client-and-adapters.md) | draft | CallClient (transport-agnostic `spawn_dispatch` primary; dial lives in `AlknetClient` per ADR-089), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (`from_jsonschema` in alknet-http per ADR-066) |
## Applicable ADRs
@@ -36,7 +36,7 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
| [014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Call protocol carries no secret material; capabilities injected at assembly layer |
| [015](../../decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
| [016](../../decisions/016-abort-cascade-for-nested-calls.md) | Abort Cascade for Nested Calls | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
| [017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction. ~~`from_jsonschema` clause superseded by ADR-066~~ |
| [017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction |
| [066](../../decisions/066-from-jsonschema-as-http-adapter.md) | `from_jsonschema` as HTTP-Backed Single-Endpoint Adapter in alknet-http | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf |
| [022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | Handler Registration, Provenance, and Composition Authority | Registration bundle carries provenance, composition authority, scoped env, capabilities |
| [023](../../decisions/023-operation-error-schemas.md) | Operation Error Schemas | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity |
@@ -46,6 +46,8 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
| [030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | `PeerId` source = `Identity.id` = `PeerEntry.peer_id` (stable); supersedes ADR-029's UUID source |
| [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | `forwarded_for` on `OperationContext` and `call.requested`; metadata only, never used by `AccessControl::check` |
| [033](../../decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Storage Boundary and Repo/Adapter Pattern | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
| [089](../../decisions/089-alknetclient-native-dial-seam.md) | AlknetClient — Native Client Dial Seam | The dial is in `alknet-client`; `CallClient` is `spawn_dispatch` only; `alknet-call` is a pure protocol crate with no TLS/transport deps |
| [091](../../decisions/091-connectioncredentials-decouple-dial-from-call.md) | `ConnectionCredentials` — Decouple Dial from Call Protocol | `ConnectionCredentials`/`RemoteIdentity` in `alknet-core` (not `alknet-call`); `auth_token` is a per-request payload field |
## Relevant Open Questions
@@ -58,7 +60,7 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
| OQ-19 | Session-scoped operation registries | resolved | Agent-written operations overlaid on curated registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Generalized by ADR-024 to cover connection-scoped overlays. |
| OQ-25 | ~~Remote-safe marking shape~~ | **dissolved** (ADR-029) | `remote_safe`/`trusted_peer` retired; peer authorization is `AccessControl::check(peer_identity)` |
| OQ-26 | OperationAdapter error type (AdapterError variants) | **resolved** | `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`; `#[non_exhaustive]` |
| OQ-27 | from_call re-import trigger | **resolved** | `from_call` is a manual free function; the assembly layer calls it after `connect()`. `refresh()` is a genuine feature addition. See ADR-069. |
| OQ-27 | from_call re-import trigger | **resolved** | `from_call` is a manual free function; the assembly layer calls it after the dial (in `AlknetClient`). `refresh()` is a genuine feature addition. See ADR-069. |
| OQ-28 | from_call namespace collision | **resolved** | Same-peer collision = error; cross-peer dissolved by ADR-029 (separate sub-overlays) |
| OQ-29 | CallClient TLS client-auth | **resolved** | Wire quinn client-auth; key-type-aware server cert verification; fingerprint normalization |
| OQ-30 | `PeerRef::Any` routing policy | **resolved** | Insertion-order first-match; richer routing is a feature extension |
@@ -81,7 +83,7 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
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-016.
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-015, ADR-022.
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-022. (`FromJsonSchema` is now a real HTTP-forwarding leaf per ADR-066, 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; `connect` removed per ADR-089 §5 — dial extracted to `AlknetClient`); both produce the same `CallConnection` and dispatch through the same loop. See ADR-017, [client-and-adapters.md](client-and-adapters.md).
11. **Connection direction is independent of call direction**: Who opens the connection is a connection-layer concern, not a protocol-layer concern. Both sides can call each other once connected. The `CallAdapter` accepts connections; the `CallClient` takes them over (`spawn_dispatch` primary; dial in `AlknetClient` per ADR-089); both produce the same `CallConnection` and dispatch through the same loop. See ADR-017, [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-029.
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-066 moved it to `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-014, [client-and-adapters.md](client-and-adapters.md).
@@ -177,11 +177,10 @@ The adapter:
The dispatch loop is **shared** with `CallClient` (ADR-017 §1): both
`CallAdapter::handle` (accept path) and `CallClient::spawn_dispatch`
(connect path — the dial is now `AlknetClient::dial_*` per ADR-089 §5;
`CallClient::connect` is removed) construct a `Dispatcher`
(`protocol/dispatch.rs`) and call `run_loop` — the dispatch half is one
implementation, the connection-establishment half differs (accept vs
dial). Peer authorization flows through the existing
(connect path — the dial is `AlknetClient::dial_*` per ADR-089) construct
a `Dispatcher` (`protocol/dispatch.rs`) and call `run_loop` — the
dispatch half is one implementation, the connection-establishment half
differs (accept vs dial). Peer authorization flows through the existing
`AccessControl::check(peer_identity)` — no `RemoteFilter`/`remote_safe` gate
(ADR-029 §3). The composition env is peer-keyed (`PeerCompositeEnv`,
ADR-029 §1) to handle head→N-workers routing. See
@@ -595,8 +594,9 @@ See [open-questions.md](../../open-questions.md) for full details.
variants (`DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`,
`SamePeerCollision`); `#[non_exhaustive]`. See
[client-and-adapters.md](client-and-adapters.md).
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a manual
free function; the assembly layer calls it after `connect()`. See
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
manual free function; the assembly layer calls it after the dial (in
`AlknetClient`). See
[ADR-069](../../decisions/069-from-call-manual-free-function.md).
- **OQ-28** (resolved): `from_call` namespace collision — same-peer collision
= error; cross-peer dissolved by ADR-029 (separate sub-overlays). See
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-09
last_updated: 2026-07-17
---
# alknet-call — Client and Adapters
@@ -23,8 +23,8 @@ This document specifies three components, all in `alknet-call`:
1. **`CallClient`** — takes over an established transport `Connection`
on ALPN `alknet/call`, spawns the shared dispatch loop, and produces
a `CallConnection`. Transport-agnostic (`spawn_dispatch` primary;
`connect` removed per ADR-089 §5 — dial extracted to `AlknetClient`);
the dispatch loop is shared with the server-side `CallAdapter`
dial lives in `AlknetClient` per ADR-089); the dispatch loop is
shared with the server-side `CallAdapter`
(ADR-017 §1); `CallClient` is the connection-take-over half, not a
parallel protocol implementation.
2. **`from_call`** — discovers operations on a remote call-protocol endpoint
@@ -100,10 +100,10 @@ the producer on the inbound side. Both produce the same
ordered, reliable bidirectional stream — QUIC, TCP+TLS, WebTransport,
SSH `direct-tcpip`, a WebSocket (ADR-065 `Connection::from_stream` /
`from_bidi`). The primary constructor (`spawn_dispatch`) takes a
pre-established `Connection` from any transport; the QUIC convenience
(`connect`) dials QUIC and calls `spawn_dispatch`. This mirrors
`ChannelClient::from_connection` / `connect_quic` (ADR-080) and is the
client-side analogue of the server-side generalization ADR-065 made.
pre-established `Connection` from any transport; the dial lives in
`AlknetClient` (`alknet-client`, ADR-089). This mirrors
`ChannelClient::from_connection` (ADR-080) and is the client-side
analogue of the server-side generalization ADR-065 made.
```rust
pub struct CallClient {
@@ -123,22 +123,6 @@ impl CallClient {
/// API surface (ADR-017 Am. 2026-07-13) — it must not be coupled to
/// a transport.
pub fn spawn_dispatch(&self, connection: Connection) -> CallConnection;
/// **REMOVED per ADR-089 §5.** The dial is extracted into
/// `AlknetClient` (`alknet-client`); `connect` is deleted, not
/// delegated, to avoid `alknet-call` depending on `alknet-client`
/// and to let `alknet-call` shed its TLS/transport deps entirely.
/// Callers compose `AlknetClient::dial_quic(...).await?` +
/// `CallClient::new(...).spawn_dispatch(conn)`. `ClientError` is
/// removed (it was produced only by `connect`). `CallCredentials`
/// is removed (its `auth_token` field had no reader; `auth_token`
/// is a per-request payload field — ADR-091, amended 2026-07-17).
#[cfg(feature = "quinn")]
pub async fn connect(
&self,
addr: SocketAddr,
credentials: CallCredentials, // REMOVED — CallCredentials is removed
) -> Result<CallConnection, ClientError>;
}
```
@@ -191,19 +175,17 @@ authorization machinery that gates every other call. No `RemoteFilter`, no
`CallClient::spawn_dispatch(connection)` is the transport-agnostic
primary constructor — it takes a pre-established `Connection`,
constructs a `CallConnection`, builds a `Dispatcher`, spawns the
dispatch task, and returns the live `CallConnection`. `connect()` is
**removed** per ADR-089 §5: the dial is extracted into `AlknetClient`
(`alknet-client`), and keeping a QUIC convenience constructor on
`CallClient` would make `alknet-call` depend on `alknet-client`,
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`.
dispatch task, and returns the live `CallConnection`. The dial lives in
`AlknetClient` (`alknet-client`, ADR-089): keeping a QUIC convenience
constructor on `CallClient` would make `alknet-call` depend on
`alknet-client`, 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`.
This mirrors `ChannelClient::from_connection` (ADR-080; its
`connect_quic` is likewise removed per ADR-089 §5) and is the
This mirrors `ChannelClient::from_connection` (ADR-080) and is the
client-side analogue of the server-side generalization ADR-065 made.
The call protocol, like the channels protocol, is transport-agnostic —
`Connection::from_stream` / `from_bidi` (ADR-065) accept any
@@ -238,37 +220,31 @@ peer-keying is at the aggregation layer (the head node's composition env).
#### services/list
`services/list` filters by `AccessControl::check(calling_peer_identity)` —
the calling peer sees only ops it is authorized to call. The
`services_list_handler` / `services_list_handler_peer_scoped` split collapses
to a single `AccessControl`-filtered handler (the `peer_scoped` variant and
the `remote_safe` filter are removed). `services/list-peers` is the opt-in for
peer-attributed re-export listing (each peer's sub-overlay listed with
attribution, filtered by the calling peer's authorization). See
[ADR-029](../../decisions/029-peer-graph-routing-model.md) §6.
the calling peer sees only ops it is authorized to call. There is a
single `AccessControl`-filtered handler (no `peer_scoped` variant, no
`remote_safe` filter — both retired by ADR-029). `services/list-peers`
is the opt-in for peer-attributed re-export listing (each peer's
sub-overlay listed with attribution, filtered by the calling peer's
authorization). See [ADR-029](../../decisions/029-peer-graph-routing-model.md) §6.
### Credential sources for connections
The credential dimensions are split across two layers (ADR-091, amended
2026-07-17):
- **`ConnectionCredentials`** (in `alknet-core`, moved from
`alknet-call` per ADR-091) — 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` (the expected fingerprint). The
dial does not depend on the call protocol for this type.
- **`ConnectionCredentials`** (in `alknet-core`, per ADR-091) — 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`
(the expected fingerprint). The dial does not depend on the call
protocol for this type.
- **`auth_token`** — a **per-request payload field**, not a
call-protocol credential bundle. `Dispatcher::resolve_identity`
reads `payload.get("auth_token")` on each `call.requested` payload.
Browsers send it directly in the WebSocket call payload; the HTTP
gateway resolves the bearer token to an `Identity` at its boundary
(the call layer sees the identity, not the token). `CallCredentials`
is **removed** (its `auth_token` field had no reader — `connect()`
read only `tls_identity` + `remote_identity`; `spawn_dispatch` takes
no credentials; the `from_call` forwarding path's `auth_token` source
was `OpSummary.credentials_auth_token: Option<String>`, always
`None`, never connected to `CallCredentials.auth_token`). See
ADR-091 (amended 2026-07-17) for the full trace.
(the call layer sees the identity, not the token). See ADR-091 for
the credential-bundle decoupling.
Credentials come from `Capabilities` (ADR-014), never from environment
variables. The transport-identity dimensions (ADR-017 §7):
@@ -276,7 +252,7 @@ variables. The transport-identity dimensions (ADR-017 §7):
```rust
// Transport-level (alknet-core, consumed by the dial — ADR-091)
pub struct ConnectionCredentials {
pub local_identity: Option<TlsIdentity>, // RFC 7250 raw key or X.509
pub local_identity: Option<TlsIdentity>, // RFC 7250 raw key or X.509
pub remote_identity: Option<RemoteIdentity>, // expected fingerprint (None = CA path / fail-closed)
}
@@ -286,31 +262,29 @@ pub struct ConnectionCredentials {
// Dispatcher::resolve_identity reads payload.get("auth_token").
```
There is no call-protocol credential bundle. `CallCredentials` is
removed. The transport dimensions (`local_identity`, `remote_identity`)
moved to `ConnectionCredentials` in `alknet-core` per ADR-091.
`RemoteIdentity` (ADR-017 §7, extended by ADR-034 §2) 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
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`
means "trust the CA or fail" (unknown remote). An implementer must not
default `remote_identity` to a placeholder value to "satisfy" the field
— `None` is a real state that drives verifier selection.
/// Expected identity of the remote node (ADR-017 §7, extended by
/// ADR-034 §2). 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
/// raw-key transport, `None` fails closed (raw-key remotes are always
/// known peers — no CA to fall back to).
///
/// The `Option` is therefore load-bearing, not cosmetic: `Some(fingerprint)`
/// means "pin this" (known peer), `None` means "trust the CA or fail"
/// (unknown remote). An implementer must not default `remote_identity`
/// to a placeholder value to "satisfy" the field — `None` is a real
/// state that drives verifier selection.
```rust
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-091.
- **TLS identity** — the local node's Ed25519 raw key (RFC 7250) or X.509 cert,
derived from the vault at startup (ADR-020, ADR-026, ADR-027).
- **Auth token** — an opaque call-protocol-level token, decrypted from the
@@ -426,12 +400,12 @@ The flow (ADR-017 §3):
`CallConnection::register_imported_all()`.
**Re-import on reconnection** (DC-2, OQ-27): `from_call` is a free function;
the assembly layer calls it after `connect()`. The overlay is per-connection
(Layer 2, ADR-024), so a stale overlay dies with the connection; re-import on
reconnect is naturally scoped to the new connection. A
`CallConnection::refresh()` method for mid-connection re-discovery is a
genuine feature addition — non-breaking, additive — if a deployment needs
manual re-discovery without drop-and-reconnect. See
the assembly layer calls it after the dial (in `AlknetClient`). The overlay
is per-connection (Layer 2, ADR-024), so a stale overlay dies with the
connection; re-import on reconnect is naturally scoped to the new
connection. A `CallConnection::refresh()` method for mid-connection
re-discovery is a genuine feature addition — non-breaking, additive — if a
deployment needs manual re-discovery without drop-and-reconnect. See
[ADR-069](../../decisions/069-from-call-manual-free-function.md).
**Namespace collision** (DC-3, OQ-28): under the peer-graph model (ADR-029),
@@ -544,8 +518,8 @@ alknet-call (lean — no HTTP client, no HTTP server)
├── OperationAdapter trait (the contract — async, per ADR-017 §5)
├── from_call (transport-agnostic — discovers remote ops via
│ call protocol over any Connection)
└── CallClient (outbound connection take-over — spawn_dispatch
transport-agnostic, connect QUIC convenience)
└── CallClient (outbound connection take-over —
spawn_dispatch, transport-agnostic; dial in AlknetClient)
alknet-http (owns HTTP server + HTTP client — separate crate, separate Phase 0)
├── ProtocolHandler for h2/http1.1/h3 (axum server — inbound HTTP)
@@ -735,9 +709,9 @@ Based on the gap analysis and the downstream unblock chain:
holds a `PeerCompositeEnv` with `connections: HashMap<PeerId, Arc<dyn OperationEnv>>`,
not a singular connection overlay. `invoke_peer()` routes to the right peer
via `PeerRef::Specific` / `PeerRef::Any` (ADR-029 §1-2).
- **`from_call` is a manual free function.** The assembly layer calls it after
`connect()`. The overlay is per-connection so re-import on reconnect is
naturally scoped (DC-2, OQ-27). See
- **`from_call` is a manual free function.** The assembly layer calls it
after the dial (in `AlknetClient`). The overlay is per-connection so
re-import on reconnect is naturally scoped (DC-2, OQ-27). See
[ADR-069](../../decisions/069-from-call-manual-free-function.md).
- **`from_call` namespace collision is same-peer only.** Cross-peer collision
dissolves (same name on different peers is fine — separate sub-overlays,
@@ -769,13 +743,12 @@ Based on the gap analysis and the downstream unblock chain:
| Decision | ADR | Summary |
|----------|-----|---------|
| Call protocol client and adapter contract | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction; trait is async; adapters produce `HandlerRegistration` bundles. ~~`from_jsonschema` clause superseded by ADR-066~~ |
| Call protocol client and adapter contract | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction; trait is async; adapters produce `HandlerRegistration` bundles |
| `from_jsonschema` as HTTP-backed single-endpoint adapter in alknet-http | [ADR-066](../../decisions/066-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf |
| Peer-graph routing model (DC-1, supersedes ADR-028) | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via existing `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` |
| PeerEntry and Identity.id decoupling | [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | `PeerId` source changes from UUID to `Identity.id` (= `PeerEntry.peer_id`, stable across key rotation); `Identity.id` decoupled from crypto material on the fingerprint path |
| Forwarded-for identity | [ADR-032](../../decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; the `from_call` handler populates it; metadata only, never used by `AccessControl::check` |
| Storage boundary and repo/adapter pattern | [ADR-033](../../decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
| ~~Peer-scoped registry filtering~~ (superseded) | ~~[ADR-028](../../decisions/028-callclient-peer-scoped-registry-filtering.md)~~ | ~~Default-deny; `remote_safe: bool`; trusted-peer opt-in~~ — superseded by ADR-029 (flat-namespace single-peer model couldn't express head→N-workers; parallel auth system duplicated existing `AccessControl`) |
| Secret material flow and capability injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | The no-env-vars invariant's foundation; capabilities injected at assembly layer |
| Handler registration, provenance, and composition authority | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | The registration bundle adapters produce; `composition_authority: None` for leaves |
| Operation registry layering | [ADR-024](../../decisions/024-operation-registry-layering.md) | Layer 2 per-connection overlay where `from_call` imports land |
@@ -783,7 +756,7 @@ Based on the gap analysis and the downstream unblock chain:
| Abort cascade for nested calls | [ADR-016](../../decisions/016-abort-cascade-for-nested-calls.md) | Cross-node abort through `from_call` forwarding handler's `parent_request_id` |
| Operation error schemas | [ADR-023](../../decisions/023-operation-error-schemas.md) | `error_schemas` mirrored by `from_call` from remote op's spec |
| Streaming handler for subscriptions | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `from_call` `Subscription` ops register a `StreamingHandler` (`HandlerKind::Stream`) that calls `CallConnection::subscribe()` and forwards the remote stream; `Query`/`Mutation` stay `HandlerKind::Once` |
| TLS identity redesign | [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | RFC 7250 raw key / X.509 cert dimensions of `CallCredentials` |
| TLS identity redesign | [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | RFC 7250 raw key / X.509 cert dimensions of the local `TlsIdentity` (now carried by `ConnectionCredentials.local_identity`) |
| Outgoing-only X.509 and three peer roles | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Public X.509 endpoint is not a `PeerEntry` on the client side (no `PeerId`, not in peer graph); client-side verifier by `PeerEntry` presence (CA vs fingerprint pin); hub = mixed-fingerprint `PeerEntry` |
| HD derivation for encryption keys | [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) | Vault-derived TLS identity material |
| Vault key model | [ADR-026](../../decisions/026-vault-key-model-hd-derivation.md) | Vault-derived TLS identity material |
@@ -801,10 +774,11 @@ See [open-questions.md](../../open-questions.md) for full details.
- **OQ-26** (resolved): `AdapterError` variants — `DiscoveryFailed`,
`SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`
(replaces flat `Conflict`). `#[non_exhaustive]`.
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a manual
free function; the assembly layer calls it after `connect()`. A
`CallConnection::refresh()` method is a genuine feature addition —
non-breaking, additive. See [ADR-069](../../decisions/069-from-call-manual-free-function.md).
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
manual free function; the assembly layer calls it after the dial (in
`AlknetClient`). A `CallConnection::refresh()` method is a genuine
feature addition — non-breaking, additive. See
[ADR-069](../../decisions/069-from-call-manual-free-function.md).
- **OQ-28** (resolved): `from_call` namespace collision — same-peer
collision = error; cross-peer dissolved by ADR-029 (separate sub-overlays).
`namespace_prefix` is optional local-naming sugar.
@@ -825,8 +799,7 @@ See [open-questions.md](../../open-questions.md) for full details.
(ADR-029 §3.7).
- **OQ-33** (resolved by ADR-030): `PeerId` is a logical id. Source is
`Identity.id` from `IdentityProvider` resolution (= `PeerEntry.peer_id`,
stable across key rotation), not a connection-assigned UUID. The UUID
workaround is removed. See OQ-33 in open-questions.md.
stable across key rotation). See OQ-33 in open-questions.md.
- **OQ-34** (resolved by ADR-030 + ADR-033): Persistent peer registry —
the storage boundary is `core trait + in-memory default` (config-backed
`ConfigIdentityProvider` now; persistence adapters additive in separate
@@ -858,9 +831,8 @@ See [open-questions.md](../../open-questions.md) for full details.
- ADR-017: Call Protocol Client and Adapter Contract (the spec this document
operationally fills)
- ADR-029: Peer-Graph Routing Model (supersedes ADR-028; resolves DC-1 with
peer-keyed overlays + `AccessControl`-based peer authorization)
- ~~ADR-028~~: Peer-Scoped Registry Filtering (superseded by ADR-029)
- ADR-029: Peer-Graph Routing Model (resolves DC-1 with peer-keyed overlays
+ `AccessControl`-based peer authorization)
- `call-protocol.md` — `CallAdapter`, `CallConnection`, dispatch loop, stream
model (the server-side complement to this document)
- `operation-registry.md` — `HandlerRegistration`, provenance, capability
@@ -397,15 +397,13 @@ pub enum OperationProvenance {
| `FromJsonSchema` | No (leaf) | No | Internal |
| `Session` | Yes (within sandbox) | Yes — scopes set at sandbox creation | Internal always |
> **ADR-066 update.** `FromJsonSchema` was originally a schema-only
> provenance with no handler (the old row read "N/A (no handler) /
> N/A"). ADR-066 moved `from_jsonschema` to `alknet-http` as a real
> HTTP-backed single-endpoint adapter with a reqwest forwarding
> handler. `FromJsonSchema` is now a leaf, same trust model as
> `FromOpenAPI` (HTTP endpoint trusted; handler is a forwarding stub).
> The "schema-only, no handler" concept is removed — schema validation
> without a handler is served by consuming `OperationSpec` directly,
> not by registering a placeholder op.
> **`FromJsonSchema` provenance.** `from_jsonschema` is an HTTP-backed
> single-endpoint adapter in `alknet-http` (ADR-066): 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
> served by consuming `OperationSpec` directly, not by registering a
> placeholder op.
#### CompositionAuthority
@@ -929,11 +927,10 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
| Handler registration, provenance, and composition authority | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
| Operation registry layering | [ADR-024](../../decisions/024-operation-registry-layering.md) | Curated (static, immutable) + session and connection overlays (dynamic); `OperationEnv` as trait-object integration point; `OperationContext.env` split into `scoped_env` (data) and `env` (dispatch trait) |
| Operation error schemas | [ADR-023](../../decisions/023-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity for `from_openapi`/`to_openapi` |
| Call protocol client and adapter contract | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `from_call`/`OperationAdapter` produce `HandlerRegistration` bundles; adapter-registered ops are `Internal` leaves. Surface specced in [client-and-adapters.md](client-and-adapters.md). ~~`from_jsonschema` clause superseded by ADR-066~~ |
| Call protocol client and adapter contract | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `from_call`/`OperationAdapter` produce `HandlerRegistration` bundles; adapter-registered ops are `Internal` leaves. Surface specced in [client-and-adapters.md](client-and-adapters.md) |
| `from_jsonschema` as HTTP-backed single-endpoint adapter | [ADR-066](../../decisions/066-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf (now handler-bearing, not "no handler") |
| Peer-graph routing model (supersedes ADR-028) | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` (the field this doc's `HandlerRegistration` previously gained) |
| Forwarded-for identity | [ADR-032](../../decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `OperationContext` and `call.requested`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
| ~~Peer-scoped registry filtering~~ (superseded) | ~~[ADR-028](../../decisions/028-callclient-peer-scoped-registry-filtering.md)~~ | ~~`remote_safe` marking on `HandlerRegistration`~~ — superseded by ADR-029 |
| Streaming handler for subscriptions | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `StreamingHandler` type alongside `Handler`; `HandlerKind` enum on `HandlerRegistration` validated against `op_type`; `invoke_streaming()` on `OperationRegistry`; `invoke()` and `OperationEnv::invoke()` error with `INVALID_OPERATION_TYPE` on `Subscription` ops; composition stays request/response-only, stream composition is handler-level |
| Dynamic resource ownership for runtime-spawned resources | [ADR-050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | `AccessControl::check` consults an `OwnershipProvider` (sync read trait, ADR-033 repo/adapter pattern); `OperationSpec` gains `resource_id_path` (JSON pointer into the input); proxy-only access pattern (spawner owns, proxy to share, teardown revokes); `list` = scope-gate + result-filter; teardown = automatic, handler-driven; composition = two orthogonal checks, ADR-015/022 unchanged |
@@ -953,10 +950,11 @@ See [open-questions.md](../../open-questions.md) for full details.
variants: `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`,
`SamePeerCollision` (replaces flat `Conflict`). `#[non_exhaustive]`. See
[client-and-adapters.md](client-and-adapters.md).
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a manual
free function; the assembly layer calls it after `connect()`. A
`CallConnection::refresh()` method is a genuine feature addition —
non-breaking, additive. See [ADR-069](../../decisions/069-from-call-manual-free-function.md).
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
manual free function; the assembly layer calls it after the dial (in
`AlknetClient`). A `CallConnection::refresh()` method is a genuine
feature addition — non-breaking, additive. See
[ADR-069](../../decisions/069-from-call-manual-free-function.md).
- **OQ-28** (resolved): `from_call` namespace collision — same-peer
collision = error; cross-peer dissolved by ADR-029 (separate sub-overlays).
`namespace_prefix` is optional local-naming sugar. See
+59 -30
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-12
last_updated: 2026-07-18
---
# alknet-channels
@@ -12,38 +12,42 @@ each carrying a different ALPN. Channel 0 is pre-negotiated as `alknet/call`
channel 0 and routed through the same `HandlerRegistry` as top-level
connections. The channels layer is a re-framing proxy — it converts between
"one transport stream carrying N channels" (the wire) and "N independent
`AsyncRead + AsyncWrite` handles" (what handlers see) — and it does no
protocol work itself.
`BiStream` handles" (what handlers see) — and it does no protocol work
itself. The channels layer has no `stream_type` concept (ADR-093); the
handler owns its sub-stream multiplexing on the `BiStream` it receives.
## Documents
| Document | Status | Description |
|----------|--------|-------------|
| [overview.md](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 9-byte chunk format (`[channel_id:u32 be][stream_type:u8][length:u32 be][payload]`), stream types, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
| [channels-connection.md](channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-070/074), `into_sub_streams()` typed destructure, recursive composition |
| [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-070/074, as amended by ADR-093), `accept_bi` yields one `BiStream` per channel, recursive composition |
| [channels-adapter.md](channels-adapter.md) | draft | `ChannelsAdapter` (`ProtocolHandler` on `alknet/channels`), `ChannelManager`, demux/mux contracts (REQ-CH-01..04), the two-pump pattern (ADR-078) |
| [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-079) |
| [channel-client.md](channel-client.md) | draft | `ChannelClient` — the client side of a channels connection; transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); 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 `AlknetClient` (ADR-089); bidirectionality preserved |
## Applicable ADRs
| ADR | Title | Relevance |
|-----|-------|-----------|
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format — 9-Byte Chunk Header | The chunk format; unidirectional stream_types in groups of 3; substrate-agnostic; one-way door |
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alknet/call` | Channel 0 = call protocol, stream_types [0,1]; no special control plane |
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format — 8-Byte Chunk Header | The chunk format; channels layer has no `stream_type` concept (amended by ADR-093); substrate-agnostic; one-way door |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alknet/call` | Channel 0 = call protocol; no special control plane |
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | `channel/open`/`close`/`control`/`resources/subscribe`; `direction` semantics; subscribe not poll |
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `into_sub_streams()` with `SubStreamHandle` enum (Send/Recv) |
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `accept_bi` yields `BiStream` (amended by ADR-093 — `into_sub_streams` removed) |
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 contracts |
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Bounded-buffer (1 MiB default), 256-channel cap, monotonic IDs with wrap |
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | TTY's two modes (direct vs channels); 5 sub-streams; control bidirectional via 3/4; amends ADR-052 scope |
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Bounded-buffer (1 MiB default), 256-channel per-connection memory bound, monotonic IDs with wrap (DoS defense reframed by ADR-094) |
| [094](../../decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound; symmetric (both sides enforce); spoke caps hub (direct caller), not browser (forwarded_for is metadata) |
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | TTY's two modes (direct vs channels); TTY always uses its 5-byte format, carried transparently in the channels payload |
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | The two-pump deadlock contract; handler-level, not channels-layer |
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | The hub translates channel 0, byte-forwards data channels with ID rewrite |
| [080](../../decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
| [080](../../decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-089, resolves OQ-55) |
| [081](../../decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers, not sub-crates |
| [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The `Connection` extension point `ChannelBidiStreamSource` implements |
| [092](../../decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream`; the transport-leaf decision ADR-093 builds on |
| [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The transport-agnostic `Connection` the channels layer rides on |
| [052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | alknet-tty Wire Format | The 5-byte format the 9-byte format generalizes (amended by ADR-077 — scoped to direct TTY) |
| [052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | alknet-tty Wire Format | The 5-byte format carried transparently in the channels payload (control bidirectional via `STREAM_CTRL_IN`/`OUT` — Phase 7 amendment) |
| [049](../../decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler for Subscriptions | The machinery `channel/resources/subscribe` uses |
| [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed channel opens |
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-channels depends on alknet-core only; no handler-depends-on-handler |
@@ -52,19 +56,21 @@ protocol work itself.
| OQ | Title | Status | Relevance |
|----|-------|--------|-----------|
| OQ-55 | AlknetClient / Client Establishment Extraction | resolved (ADR-089) | `ChannelClient`'s API is decided (ADR-080): transport-agnostic `from_connection` primary; `connect_quic` removed (ADR-089 §5). `AlknetClient` core extraction is now resolved — the native dial seam is `alknet-client` (ADR-089) |
| OQ-55 | AlknetClient / Client Establishment Extraction | resolved (ADR-089) | `ChannelClient`'s API is decided (ADR-080): transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (`alknet-client`, ADR-089) |
| OQ-56 | Full channel-level flow-control windowing | deferred(scope) | Bounded-buffer is decided (ADR-076); full windowing is an extension blocked on "a real deployment observes HOL blocking on a saturated channel where bounded buffer is insufficient" |
| OQ-57 | Two-pump helper extraction to alknet-core | deferred(scope) | The shutdown-on-completion *contract* is decided (ADR-078); the *helper* extraction is blocked on a second two-pump handler existing (shape convergence) |
| OQ-68 | Add/strip API shape (built-in vs utility) | open | Whether the 8-byte header add/strip is built into the channels read/write path or exposed as a standalone utility. The *contract* is decided (ADR-093); the *function surface* is not |
## Key Design Principles
1. **Streams are streams.** A TTY session, an SSH channel, a forwarded TCP
connection, a QUIC bidi stream — they're all `AsyncRead + AsyncWrite`
handles. The differences are only in how they're *opened* (negotiation
via `channel/open` on channel 0) and what *multiplexing layer* carries
them (the 9-byte chunk format). Once normalized, every channel is an
ALPN routed through the same `HandlerRegistry`. See
[overview.md](overview.md) and ADR-071.
connection, a QUIC bidi stream — they're all `BiStream` (a concrete
`AsyncRead + AsyncWrite` newtype, per ADR-092). The differences are only
in how they're *opened* (negotiation via `channel/open` on channel 0)
and what *multiplexing layer* carries them (the 8-byte chunk format).
Once normalized, every channel is an ALPN routed through the same
`HandlerRegistry`. See [overview.md](overview.md) and ADR-071 (as
amended by ADR-093).
2. **Channel 0 is `alknet/call` pre-negotiated, not a special control
plane.** The call protocol runs on channel 0 exactly as on a top-level
@@ -76,23 +82,31 @@ protocol work itself.
3. **The channels layer is a re-framing proxy, not a protocol engine.** It
converts between "one transport stream carrying N channels" (the wire)
and "N independent `AsyncRead + AsyncWrite` handles" (what handlers
see). It does no ALPN-specific parsing, no auth, no transport coupling.
This makes it WASM-compatible and transport-agnostic by construction.
See [channels-adapter.md](channels-adapter.md) and ADR-075.
and "N independent `BiStream` handles" (what handlers see). It does no
ALPN-specific parsing, no auth, no transport coupling, and carries no
`stream_type` concept (ADR-093). This makes it WASM-compatible and
transport-agnostic by construction. See [channels-adapter.md](channels-adapter.md)
and ADR-075.
4. **`channel/resources/subscribe` is a `Subscription`, not a polled
4. **The handler owns its sub-stream multiplexing.** The channels layer
yields one `BiStream` per channel; the handler sub-multiplexes it
however it wants (TTY's 5-byte format, call's length-prefixed JSON,
tunnel's raw bytes, SSH's channel protocol). The channels layer carries
the bytes transparently. See [channels-connection.md](channels-connection.md)
and ADR-093.
5. **`channel/resources/subscribe` is a `Subscription`, not a polled
`Query`.** The call protocol has `StreamingHandler` / `invoke_streaming`
(ADR-049, implemented and tested). The first consumer (the hub
aggregating worker resources) needs live updates. Polling would be built
and immediately reworked. See ADR-073.
5. **Bidirectional open.** Either side can open a channel to the other,
6. **Bidirectional open.** Either side can open a channel to the other,
just like the call protocol's operation overlay. The `direction` field
on `channel/open` pins who is the ALPN-server vs ALPN-client. See
ADR-073 §Direction semantics.
6. **Wire-level invariants are contracts, not implementation details.**
7. **Wire-level invariants are contracts, not implementation details.**
The POC surfaced five invariants (REQ-CH-01..04, plus REQ-CH-06 for
close ordering) that hang channels silently if underspecified: shutdown
emits a zero-length sentinel; transport close drops all senders; the mux
@@ -101,11 +115,24 @@ protocol work itself.
`channel/close`. See [channels-wire.md](channels-wire.md) and
[channels-adapter.md](channels-adapter.md).
7. **The hub translates, not transparently forwards.** The hub terminates
8. **The hub translates, not transparently forwards.** The hub terminates
channel 0 on both legs, runs `AccessControl::check`, and re-issues
`channel/open` on the spoke leg with `forwarded_for` (ADR-032). Data
channels are byte-forwarded with `channel_id` rewrite. This preserves
the auth model. See ADR-079.
channels are byte-forwarded with `channel_id` rewrite (a 4-byte rewrite
within the 8-byte header). This preserves the auth model. See ADR-079.
9. **The channel cap is per-identity, not per-connection.** A channel
slot is a resource; the cap on how many an identity may hold open is
a quota check, parallel to `OwnershipProvider::owns` (ADR-050) for
spawned resources. The cap lives in `channels-call` (the channels
layer is auth-blind by ADR-075 — no identity, no scopes), consulted
by the `channel/open` and `channel/close` handlers after
`AccessControl::check`. The default is `PerIdentityChannelPolicy::
new(256)` — 256 per `PeerId` across all the peer's connections. The
per-connection `max_channels` (ADR-076) is a memory bound, not a
DoS defense. The cap is symmetric (both sides enforce); the spoke
caps the hub as direct caller, not the browser as `forwarded_for`
(metadata, not authority — ADR-032). See ADR-094.
## References
@@ -115,6 +142,8 @@ protocol work itself.
tests, three validated targets, REQ-CH-01..06 wire-level invariants
surfaced; REQ-CH-07 is a cosmetic clippy item, not a wire invariant)
- `docs/research/alknet-channels/poc-plan.md` — the POC plan
- `docs/research/stream-unification/findings.md` — the research that
surfaced the pure-channel-multiplexing resolution (ADR-093)
- `/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)
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-12
last_updated: 2026-07-18
---
# channel-client.md — ChannelClient
@@ -33,49 +33,27 @@ impl ChannelClient {
/// `Connection` on ALPN `alknet/channels`. This is the
/// transport-agnostic primary constructor: the caller (or a
/// transport-specific dial helper) produces the `Connection` —
/// via `Connection::from_stream`/`from_bidi` (TCP+TLS,
/// WebTransport, SSH `direct-tcpip`), a quinn connection, or any
/// other `AsyncRead + AsyncWrite` source — and this method takes
/// over: installs channel 0 (`alknet/call`), spawns the demux/mux,
/// and returns the client. Mirrors the server side's
/// transport-agnostic `ChannelsAdapter::handle(Connection)` and
/// via `Connection::from_bidi` (TCP+TLS, WebTransport, SSH
/// `direct-tcpip`), a quinn connection, or any other `AsyncRead +
/// AsyncWrite` source — and this method takes over: installs
/// channel 0 (`alknet/call`), spawns the demux/mux, and returns
/// the client. Mirrors the server side's transport-agnostic
/// `ChannelsAdapter::handle(Connection)` and
/// `CallClient::spawn_dispatch(Connection)`.
///
/// This is the one-way-door API surface (ADR-080). It must not be
/// coupled to a transport — the channels protocol is
/// transport-agnostic (ADR-071, ADR-065), and the client side is
/// half of that protocol.
/// transport-agnostic (ADR-071, as amended by ADR-093; ADR-065,
/// ADR-092), and the client side is half of that protocol.
pub async fn from_connection(connection: Connection)
-> Result<Self, ChannelError>;
/// QUIC convenience constructor. Dials a QUIC connection to `addr`
/// on ALPN `alknet/channels` (using `credentials` for the TLS
/// handshake — ADR-034 verifier selection), then calls
/// `from_connection`. This is the "I just want QUIC" one-liner;
/// it is additive over `from_connection` and is a two-way door —
/// `connect_tcp_tls`, `connect_webtransport`, etc. can be added
/// alongside it without touching the one-way-door surface.
///
/// **REMOVED per ADR-089 §5.** The dial is extracted into
/// `AlknetClient` (`alknet-client`); `connect_quic` is deleted,
/// not delegated, to avoid `alknet-channels-call` depending on
/// `alknet-client`. Callers compose `AlknetClient::dial_quic` +
/// `from_connection`. See "Relationship to `AlknetClient`" below.
/// The `CallCredentials` parameter is moot — `CallCredentials` is
/// removed per ADR-091 (amended 2026-07-17); the dial consumes
/// `ConnectionCredentials` from `alknet-core`.
pub async fn connect_quic(
addr: SocketAddr,
credentials: CallCredentials, // REMOVED — CallCredentials is removed
) -> Result<Self, ChannelError>;
/// Open a data channel with the given ALPN and params. Sends
/// `channel/open` on channel 0, waits for the response, and returns
/// the channel.
pub async fn open_channel(
&self,
alpn: &str,
stream_types: &[u8],
params: Value,
direction: ChannelDirection,
) -> Result<Channel, ChannelError>;
@@ -99,9 +77,8 @@ pub enum ChannelDirection {
pub struct Channel {
pub channel_id: u32,
pub stream_types: Vec<u8>,
/// The sub-streams, accessible via accept_bi() (ADR-074 generic path)
/// or into_sub_streams() (ADR-074 typed path).
/// The channel's BiStream, accessible via the BidiStreamSource
/// (accept_bi — ADR-074 as amended by ADR-093).
pub source: ChannelBidiStreamSource,
}
@@ -124,8 +101,8 @@ pub struct ResourceEntry {
## Transport-agnostic by construction
`ChannelClient` is the client side of the channels protocol. The channels
protocol is transport-agnostic (ADR-071 substrate modes;
`Connection::from_stream`/`from_bidi`/`from_source` from ADR-065/070 take
protocol is transport-agnostic (ADR-071 substrate modes, as amended by
ADR-093; `Connection::from_bidi`/`from_source` from ADR-065/070/092 take
any `AsyncRead + AsyncWrite`). The client side must not be welded to a
transport — that would repeat the server-side welding ADR-065 explicitly
unwound.
@@ -135,18 +112,16 @@ the one-way-door API surface. It takes a pre-established `Connection` and
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_stream`, a WebSocket carrying `alknet/channels` (the browser path per
`from_bidi`, a WebSocket carrying `alknet/channels` (the browser path per
ADR-044) — 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.
`connect_quic(addr, credentials)` was a **convenience** constructor —
dial QUIC, then `from_connection`. It is **removed** per ADR-089 §5:
keeping it as a thin wrapper over `AlknetClient::dial_quic` would make
`alknet-channels-call` depend on `alknet-client`, contradicting the dep
graph (the protocol crates are parallel to the dial, not downstream of
it). Callers compose `AlknetClient::dial_quic(...).await?` +
`ChannelClient::from_connection(conn).await?` — two lines, the dial
then the take-over.
The dial (QUIC, TCP+TLS, iroh) lives in `AlknetClient` (`alknet-client`,
ADR-089), 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`;
the protocol crates are parallel to the dial, not downstream of it.
The credential/verifier-selection rule (ADR-034) lives in the dial
(`AlknetClient`), not in `from_connection` — `from_connection` receives
@@ -168,21 +143,20 @@ populates what operations they expose).
name follows the `CallClient` convention (the side that dialed), not a
request/response role.
## Relationship to `AlknetClient` (ADR-089 — resolved)
## Relationship to `AlknetClient`
`ChannelClient`'s *API* is transport-agnostic — `from_connection` takes a
pre-established `Connection`. The shared *dial+TLS* seam
(`AlknetClient`, OQ-55) is now extracted: [`alknet-client`](../client/README.md)
(`AlknetClient`, OQ-55) is [`alknet-client`](../client/README.md), which
provides `AlknetClient` with 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
transport-agnostic. The two concerns are separated.
`connect_quic` is removed (see above) — `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
`AlknetClient::dial_quic` is the dial that feeds `from_connection`. A
caller that needs transport selection (QUIC with TCP+TLS fallback) uses
`AlknetClient` directly; the fallback policy is a caller concern. See
[ADR-089](../../decisions/089-alknetclient-native-dial-seam.md) for the
full decision and [OQ-55](../../questions/055-alknetclient-establishment-extraction.md)
(resolved).
@@ -193,21 +167,23 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; `connect_quic` convenience **removed** per ADR-089 §5 (dial extracted to `AlknetClient`); `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-089, resolves OQ-55) |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | No `stream_types` on `open_channel`/`Channel`; handler owns sub-stream multiplexing |
## Open Questions
- **OQ-55** (resolved by ADR-089): `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
[ADR-089](../../decisions/089-alknetclient-native-dial-seam.md).
— `alknet-client` with three dial methods. `ChannelClient`'s API is
transport-agnostic (`from_connection`); the dial is the shared seam.
See [ADR-089](../../decisions/089-alknetclient-native-dial-seam.md).
## References
- ADR-080: ChannelClient (the decision)
- ADR-093: channels pure channel multiplexing (no `stream_types`)
- ADR-073: channel lifecycle operations (`open_channel` sends `channel/open`)
- ADR-074: ChannelBidiStreamSource (what `Channel.source` wraps)
- ADR-074: ChannelBidiStreamSource (what `Channel.source` wraps, as
amended by ADR-093 — `accept_bi` yields a `BiStream`)
- ADR-075: ChannelManager (the shared state `ChannelClient` holds)
- OQ-55: AlknetClient / client establishment extraction
- `docs/architecture/crates/call/client-and-adapters.md` — `CallClient` (the
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-12
last_updated: 2026-07-18
---
# channel-operations.md — Channel Lifecycle on the Call Protocol
@@ -22,7 +22,6 @@ Request (on channel 0):
"operation": "channel/open",
"input": {
"alpn": "alknet/tty",
"stream_types": [0, 1, 2, 3],
"params": { "backend": "docker", "cmd": ["bash"], "container": "abc123" },
"direction": "initiator-to-responder"
}
@@ -32,7 +31,6 @@ Request (on channel 0):
| field | type | meaning |
|-------|------|---------|
| `alpn` | string | The ALPN the channel will carry. Responder looks this up in its `HandlerRegistry`. |
| `stream_types` | `[u8]` | Which sub-stream types this channel will use. E.g. `[0,1,2,3,4]` for TTY (data in/out/err + control in/out), `[0,1]` for a tunnel, `[0,1]` for channel 0. See ADR-071 §stream_type decomposition. |
| `params` | object | ALPN-specific parameters. For `alknet/tty` this is `NegotiateRequest`. For `alknet/tunnel` this is the target resource. The channels layer does not interpret `params`. |
| `direction` | string | `initiator-to-responder` or `responder-to-initiator`. See "Direction semantics" below. |
@@ -41,8 +39,7 @@ Response:
```json
{
"output": {
"channel_id": 7,
"stream_types": [0, 1, 2, 3]
"channel_id": 7
}
}
```
@@ -50,7 +47,6 @@ Response:
| field | type | meaning |
|-------|------|---------|
| `channel_id` | u32 | Server-assigned (DP-1). The responder allocates via monotonic `AtomicU32`. |
| `stream_types` | `[u8]` | The negotiated set — the responder may narrow the initiator's requested set. |
**Channel ID allocation: server-assigned (DP-1).** One round-trip before
data flows — the same round-trip the call protocol makes for every
@@ -66,7 +62,6 @@ negotiation round-trip, so the open round-trip is not additive latency.
| `channel:allocation_failed` | Handler allocate failed | true (often transient) |
| `channel:invalid_params` | `params` JSON didn't satisfy the ALPN's expectations | false |
| `channel:too_many_channels` | Per-connection channel limit hit (ADR-076) | false |
| `channel:stream_type_unavailable` | Responder can't provide a requested `stream_type` | false |
### `channel/close` — tear down a channel
@@ -78,7 +73,7 @@ negotiation round-trip, so the open round-trip is not additive latency.
```
The responder (the side that didn't send the close) drains its reassembled
streams for `channel_id`, signals EOF to the handler, and returns
stream for `channel_id`, signals EOF to the handler, and returns
`{ "closed": true }`. The `channel_id` is eligible for reuse after the drain
completes (ADR-076 — monotonic IDs with wrap-around, not a free-list).
`reason` is free-form for observability — not semantically required.
@@ -87,9 +82,11 @@ completes (ADR-076 — monotonic IDs with wrap-around, not a free-list).
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; for tunnels it is the last data byte
before close. This invariant crosses two channels (the data channel and
channel 0), so the channels layer owns the ordering guarantee.
invariant (ADR-055) carried forward — the exit control message rides on
TTY's `STREAM_CTRL_OUT` (stream_type 4, inside TTY's 5-byte payload
format); for tunnels it is the last data byte before close. This invariant
crosses two channels (the data channel and channel 0), so the channels
layer owns the ordering guarantee.
### `channel/control` — out-of-band control on channel 0
@@ -101,7 +98,6 @@ keepalive):
"operation": "channel/control",
"input": {
"channel_id": 7,
"stream_type": 3,
"message": { "type": "resize", "cols": 80, "rows": 24 }
}
}
@@ -113,7 +109,7 @@ not interpret it.
### `channel/resources/subscribe` — live resource discovery
**This is a `Subscription` operation (ADR-049), not a polled `Query`.** The
**This is a `Subscription` operation (ADR-049), not a polled Query.** The
call protocol has `StreamingHandler` / `invoke_streaming` (implemented and
tested). The first consumer (the hub aggregating worker resources) needs
live updates when workers connect/disconnect or containers start/stop.
@@ -192,14 +188,26 @@ collision-prone client-assigned alternative.
| Control path | When | Examples |
|--------------|------|----------|
| 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 |
| `stream_type 3` chunks on the data channel | Control that MUST be ordered relative to data | EOF before exit, flush before close |
| Data-ordered bytes on the data channel's `BiStream` (handler-internal framing) | Control that MUST be ordered relative to data | EOF before exit, flush before close |
The TTY crate's exit-chunk-is-last invariant (ADR-055) is the canonical
example of data-ordered control — it rides on `stream_type 3` because it
must arrive after the last stdin chunk, guaranteed by chunk ordering within
`(channel_id, stream_type)`, not by a call-protocol round-trip. The
`channel/close` operation that follows is on channel 0 and is ordered after
the data pump completes (REQ-CH-06).
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 TTY's
per-stream_type chunk ordering within its own 5-byte format, not by a
call-protocol round-trip. The `channel/close` operation that follows is
on channel 0 and is ordered after the data pump completes (REQ-CH-06).
**The control-message division is handler-internal.** Under ADR-093, the
channels layer has no `stream_type` concept — it carries the handler's
framing transparently in the payload. TTY's `STREAM_CTRL_IN` (stream_type
3) and `STREAM_CTRL_OUT` (stream_type 4) are stream_types in TTY's 5-byte
format (ADR-052, amended by Phase 7), not channels-layer concepts. The
channels layer routes by `channel_id` only; the handler owns its
sub-stream multiplexing on the `BiStream` it receives. The
"bidirectional control channel" property is a TTY-layer concern, fixed
at the TTY layer by Phase 7's split — the channels layer doesn't know
about it.
## ACL flow (end-to-end)
@@ -227,6 +235,150 @@ The hub ran **zero** protocol-specific auth. It ran `channel/open`'s
`AccessControl::check` (call-protocol machinery) and forwarded. The channels
layer inherited the auth model by being a call-protocol operation.
## Per-identity channel cap (ADR-094)
A channel slot is a resource. The cap on how many channels an identity
may hold open is a quota check on that resource — parallel to
`OwnershipProvider::owns` (ADR-050) for spawned resources. Same
primitive, different resource. The cap is a **peer concern**, not a
hub-specific concern: any accepting peer (worker or hub) enforces the
cap on its inbound channels, just as it enforces `AccessControl::check`
on `channel/open`. The cap is also **symmetric** — both sides of a
channels connection enforce their cap on the other's channels.
### Why the cap is not in the channels layer
`ChannelManager` (ADR-075) is auth-blind by design — no auth state, no
identity, no scopes. That decision is load-bearing (it is what makes
the channels layer WASM-compatible, transport-agnostic, and
ALPN-blind). So the per-identity cap lives in `channels-call`, where
the identity is already on `OperationContext` (the same place
`AccessControl::check` runs). The channels layer (`channels-core`) is
unchanged. See ADR-094 §"Why the channels layer cannot hold the cap".
The channels-layer per-connection `max_channels = 256` (ADR-076) is
a **per-connection memory bound** (limits one connection's
reassembly-buffer cost), not a DoS defense. A peer can open an
unbounded number of transport connections, so a per-connection cap is
not a per-peer DoS defense. The per-identity DoS defense is the cap
documented here; see ADR-094 for the corrected DoS-defense framing.
### The `ChannelLifecyclePolicy` trait
```rust
/// Per-identity channel lifecycle policy. Consulted by the
/// `channel/open` handler (after `AccessControl::check`, before
/// allocation) and the `channel/close` handler (after deallocation).
/// Both handlers have the identity via `OperationContext`.
pub trait ChannelLifecyclePolicy: Send + Sync + 'static {
/// Before channel allocation. Deny with `channel:too_many_channels`
/// (ADR-073) when the identity is over its cap. The identity is
/// the direct caller (the peer that opened this channels
/// connection); `forwarded_for` is metadata and is NOT consulted
/// (ADR-032).
fn check_open(&self, identity: &Identity) -> Result<(), ChannelError>;
/// After channel deallocation. Decrement the per-identity count.
/// Called by the `channel/close` handler after the drain completes
/// (ADR-076 §channel-id-reuse).
fn on_close(&self, identity: &Identity);
}
```
### Default: `PerIdentityChannelPolicy::new(256)`
The default constructor enforces 256 per identity out of the box — no
"NoOp default + wire it later." A channels-accepting peer that
constructs `ChannelOperations::new(manager)` with no policy argument
gets `PerIdentityChannelPolicy::new(256)`. The default is secure;
opt-outs are explicit:
- `PerIdentityChannelPolicy::new(cap)` — shared per-identity state
(`HashMap<PeerId, usize>` + cap), constructed **once per accepting
peer** and shared (via `Arc`) across every channels connection that
peer accepts. The sharing is what makes the cap per-identity, not
per-connection.
- `PerIdentityChannelPolicy::with_per_identity_caps(mapping)` —
per-peer-role variant: `HashMap<PeerId, usize>` overrides the
default cap for specific peers. Used by a spoke that serves a
high-fan-out hub (the hub peer's cap is set higher than a worker
peer's cap — see "Relay consequence" below).
- `NoCap` — no cap. Explicit opt-out for tests, POCs, and trusted
single-peer deployments. Not the default.
The policy is constructed once and passed to `ChannelOperations` at
registration time:
```rust
let policy = Arc::new(PerIdentityChannelPolicy::new(256));
let channel_ops = ChannelOperations::new(manager, policy);
channel_ops.register_on(&mut call_registry)?;
```
### Enforcement point: between `AccessControl::check` and allocation
The `channel/open` handler (above) gains the policy check after ACL
and before `next_id.fetch_add`:
1. ACL is already checked by `OperationRegistry::invoke` (the existing
`AccessControl::check` path — unchanged).
2. **NEW:** `policy.check_open(&op_ctx.identity)?` — deny with
`channel:too_many_channels` if over cap.
3. Allocate the `channel_id` via `next_id.fetch_add(1, Relaxed)`
(DP-1: server-assigned — unchanged).
4. Construct the `ChannelBidiStreamSource`, spawn the handler, record
the `ChannelState` (unchanged).
5. Return the `channel_id`.
The `channel/close` handler gains the decrement after the drain
completes (the same point ADR-076 marks the `channel_id` as eligible
for reuse):
1. Drain the reassembly buffer for `channel_id` (existing — ADR-076
§channel-id-reuse).
2. **NEW:** `policy.on_close(&op_ctx.identity)` — decrement the
per-identity count.
3. Return `{ "closed": true }` (unchanged).
### Relay consequence: the spoke caps the hub, not the browser
When the hub relays a browser's channel to a spoke (ADR-079), the
spoke sees the hub as the direct caller. `forwarded_for` carries the
browser's identity as metadata (ADR-032 — `forwarded_for` is not
authority; `AccessControl::check` never reads it). The channel cap
follows the same shape: the spoke's `ChannelLifecyclePolicy` is
consulted with the **hub's** identity, not the browser's. The spoke
asks "does the hub have access to open another channel?" and the
hub's quota on the spoke reflects the aggregate of all relayed
channels. The hub's per-browser caps are the hub's own concern
(enforced on the browser leg by the hub's own policy), not the
spoke's.
This is correct and consistent — the spoke authorizes the hub for
container access the same way it authorizes any peer, and the hub's
browser-relay ACL is the hub's own layer. The channel cap follows the
same pattern as any other resource ACL.
**Deployment consequence:** a spoke that serves a hub relaying for
many browsers must set the hub peer's cap higher than a worker peer's
cap, or the spoke denies legitimate relayed channels when the hub's
aggregate count exceeds a worker-sized cap. This is a per-peer-role
policy, set by the spoke via `with_per_identity_caps`. The
architecture provides the mechanism; the deployment sets the numbers.
This is not a flaw — it is the same shape as any per-peer ACL (a
spoke may authorize one peer for 1000 containers and another for 10;
the channel cap is the same kind of per-peer policy).
### Recursive channels do not bypass the cap
A recursive `alknet/channels`-inside-`alknet/channels` channel runs a
new `ChannelsAdapter` with a new `ChannelManager`. If the same
`ChannelLifecyclePolicy` is wired into the inner `ChannelOperations`,
the inner channels are counted against the same identity. Recursion
is not a bypass; the 13-byte-per-chunk overhead is the documented
cost (ADR-093), and the cap behavior is unchanged. Recursive channels
are an edge case for edge cases and not specced further.
## Hub relay contract (ADR-079 — summary)
The hub **translates**, not transparently forwards:
@@ -259,13 +411,17 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | The four ops; `direction` pinned; subscribe not poll |
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call` |
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
| [094](../../decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | No `stream_types` on `channel/open`; no `stream_type` on `channel/control`; handler owns sub-stream multiplexing |
| [049](../../decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler | The machinery `channel/resources/subscribe` uses |
| [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens |
| [050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The ownership store the spoke queries |
| [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens (and why the cap is per direct-caller, not per `forwarded_for`) |
| [050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The parallel — a channel slot is a resource, the cap is a quota check |
## References
- ADR-073: channel lifecycle operations (the decision)
- ADR-094: per-identity channel cap (the cap, the trait, the relay
consequence)
- ADR-079: hub relay (the translate contract)
- `docs/research/alknet-channels/phase-0-findings.md` §Channel Open
Negotiation, §ACL and Security Model
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-12
last_updated: 2026-07-18
---
# channels-adapter.md — ChannelsAdapter and ChannelManager
@@ -8,13 +8,15 @@ last_updated: 2026-07-12
The two internal components of the channels crate: the read/demux half
(`ChannelsAdapter`) and the reassemble/allocate half (`ChannelManager`).
ADR-075 is the decision; this doc specifies the contracts and the demux/mux
invariants.
invariants. The channels layer has no `stream_type` concept (ADR-093) —
the demux routes by `channel_id` only, and the reassembly buffer is one
per channel (not per `(channel_id, stream_type)`).
## The split
| Component | Role | What it knows |
|-----------|------|---------------|
| `ChannelsAdapter` | `ProtocolHandler` on `alknet/channels`; reads 9-byte chunk headers off every bidi stream the transport yields and routes to `ChannelManager`. Substrate-agnostic (ADR-071 §substrate modes). | The transport stream(s); the `ChannelManager` handle. ALPN-blind. |
| `ChannelsAdapter` | `ProtocolHandler` on `alknet/channels`; reads 8-byte chunk headers off every bidi stream the transport yields and routes to `ChannelManager`. Substrate-agnostic (ADR-071 §substrate modes, as amended by ADR-093). | The transport stream(s); the `ChannelManager` handle. ALPN-blind. |
| `ChannelManager` | Shared state; holds `channel_id → ChannelState`, `HandlerRegistry`. Constructs `ChannelBidiStreamSource` per channel. What `channel/open` closes over (in `channels-call`). | The channel map; the handler registry for ALPN lookup. ALPN-blind (looks up ALPNs, doesn't parse their protocols). |
The split mirrors the TTY crate's `ChunkReader`/`ChunkWriter` + adapter
@@ -34,33 +36,35 @@ impl ProtocolHandler for ChannelsAdapter {
// 1. Channel 0 is pre-negotiated (ADR-072). The first bidi stream
// the transport yields is channel 0. The consumer (channels-call)
// installs the CallAdapter on it.
let (send, recv) = connection.accept_bi().await?;
self.manager.preinstall_channel_0(send, recv, auth).await?;
let bidi = connection.accept_bi().await?;
self.manager.preinstall_channel_0(bidi, auth).await?;
// 2. Accept remaining bidi streams and read 9-byte headers off each.
// 2. Accept remaining bidi streams and read 8-byte headers off each.
// On an in-line transport, accept_bi() yields once and the header
// demuxes N channels from that stream. On QUIC native, accept_bi()
// yields repeatedly — each stream carries one logical channel.
// Same code path, same wire format (ADR-071 §substrate modes).
// Same code path, same wire format (ADR-071 §substrate modes,
// as amended by ADR-093).
self.manager.run_demux_loop(connection).await
}
}
```
The `preinstall_channel_0` step (provided by `channels-call`, ADR-081)
constructs the reassembly buffers for `channel_id = 0` using stream_types
[0, 1] (ADR-072), wraps them as a `Connection` via `Connection::from_source`
with a `ChannelBidiStreamSource` (ADR-074), and hands that `Connection` to
the `CallAdapter`. The `ChannelsAdapter` in `channels-core` exposes the
hook; `channels-call` provides the implementation.
constructs the reassembly buffer for `channel_id = 0`, wraps it as a
`Connection` via `Connection::from_source` with a
`ChannelBidiStreamSource` (ADR-074, as amended by ADR-093 — `accept_bi`
yields a `BiStream`), and hands that `Connection` to the `CallAdapter`.
The `ChannelsAdapter` in `channels-core` exposes the hook; `channels-call`
provides the implementation.
`run_demux_loop` continues accepting bidi streams from the transport. For
each stream, it reads 9-byte headers and routes payloads to the matching
`(channel_id, stream_type)` reassembly buffer. On an in-line transport,
there is only one stream (channel 0 rides inside it via the header); the
header demuxes all channels. On QUIC, each subsequent stream is a new
channel; the header's `channel_id` correlates it. The loop is the same;
only the transport's stream count differs.
each stream, it reads 8-byte headers and routes payloads to the matching
`channel_id`'s reassembly buffer. On an in-line transport, there is only
one stream (channel 0 rides inside it via the header); the header demuxes
all channels. On QUIC, each subsequent stream is a new channel; the
header's `channel_id` correlates it. The loop is the same; only the
transport's stream count differs.
## `ChannelManager`
@@ -74,14 +78,23 @@ pub struct ChannelManager {
// call-protocol-blind.
next_id: AtomicU32, // monotonic; wraps at u32::MAX
buffer_cap: usize, // default 1 MiB (ADR-076)
max_channels: usize, // default 256 (ADR-076)
max_channels: usize, // default 256 (ADR-076) — per-connection
// memory bound, NOT a DoS defense. The
// per-identity DoS defense is the
// ChannelLifecyclePolicy consulted by the
// channel/open handler in channels-call
// (ADR-094). The auth-blindness that forces
// the cap out of this struct is ADR-075's
// "no auth state" rule.
}
struct ChannelState {
alpn: String,
streams: HashMap<u8, ReassemblyBuffer>,
/// One reassembly buffer per channel (not per (channel_id, stream_type) —
/// the channels layer has no stream_type concept per ADR-093). Yields
/// a BiStream to the handler.
reassembly: ReassemblyBuffer,
handler_task: JoinHandle<()>,
stream_types: Vec<u8>,
}
```
@@ -90,13 +103,13 @@ struct ChannelState {
all hold a handle.
> **Type-name convention:** `ChannelManager`, `ChannelsAdapter`,
> `ChannelBidiStreamSource`, `ChannelSubStreams`, and `ChannelClient` are
> the public API surface (contract). `ReassemblyBuffer`, `Demux`,
> `MuxHandle`/`MuxRunner`, `MpscSendStream`/`MpscRecvStream`, and
> `ChannelOperations` are illustrative internal type names — the channels
> crate's implementation may name them differently. The contracts are the
> invariants (REQ-CH-01..04, 06) and the public API; the internal names are
> not contractual.
> `ChannelBidiStreamSource`, and `ChannelClient` are the public API
> surface (contract). `ReassemblyBuffer`, `Demux`, `MuxHandle`/`MuxRunner`,
> `MpscSendStream`/`MpscRecvStream`, and `ChannelOperations` are
> illustrative internal type names — the channels crate's implementation
> may name them differently. The contracts are the invariants
> (REQ-CH-01..04, 06) and the public API; the internal names are not
> contractual.
### `ChannelManager` is ALPN-blind and auth-blind
@@ -107,8 +120,9 @@ The `ChannelManager` deliberately does **not** hold:
their crates and register on the same registry.
- **No ALPN-specific parsing.** It does not parse `NegotiateRequest` JSON,
SSH frames, or tunnel target strings. It hands `params` JSON to the
handler and gets back a handler task; it hands `stream_type 3` JSON to the
handler's control handle.
handler and gets back a handler task. The channels layer carries the
handler's framing transparently in the payload — it does not interpret
the payload bytes.
- **No auth state.** Auth lives in the `OperationContext` that the call
protocol passes to `channel/open`. The `ChannelManager` doesn't check
scopes or ownership — that's `AccessControl::check` in
@@ -116,6 +130,10 @@ The `ChannelManager` deliberately does **not** hold:
- **No transport coupling.** It talks to the transport only through the
`ChannelsAdapter`'s read loop and the per-channel write pumps, both of
which use `AsyncRead + AsyncWrite`.
- **No `stream_type` concept.** Per ADR-093, the channels layer routes by
`channel_id` only. There is one reassembly buffer per channel (yielding
a `BiStream`), not one per `(channel_id, stream_type)`. The handler
owns its sub-stream multiplexing on the `BiStream` it receives.
This is what makes the channels layer WASM-compatible and transport-agnostic
— the `ChannelManager` is pure byte routing with no platform or protocol
@@ -125,34 +143,62 @@ dependencies.
The `channel/open` (and `channel/close`, `channel/control`,
`channel/resources/subscribe`) operations are registered on the call
protocol's `OperationRegistry` at assembly time:
protocol's `OperationRegistry` at registration time. The
`ChannelOperations` constructor takes a `ChannelLifecyclePolicy`
(ADR-094) — the default is `PerIdentityChannelPolicy::new(256)` (a
real per-identity cap, not NoOp):
```rust
let channel_ops = ChannelOperations::new(manager.clone());
let policy = Arc::new(PerIdentityChannelPolicy::new(256));
let channel_ops = ChannelOperations::new(manager.clone(), policy);
channel_ops.register_on(&mut call_registry)?;
```
The same `Arc<PerIdentityChannelPolicy>` is shared across every
channels connection this peer accepts — that is what makes the cap
per-identity, not per-connection. A hub constructs one policy and
shares it across all worker and browser legs; a worker accepting
direct channels constructs one policy and shares it across whatever
connections it accepts. See ADR-094 for the policy trait and the
default/opt-out variants.
The `channel/open` handler (ADR-073):
1. ACL is already checked by `OperationRegistry::invoke` before this handler
runs.
2. Looks up the ALPN in `HandlerRegistry` → `channel:unknown_alpn` if
missing.
3. Allocates the `channel_id` via `next_id.fetch_add(1, Relaxed)` (DP-1:
server-assigned).
4. Constructs the `ChannelBidiStreamSource` (ADR-074) for the negotiated
`stream_types`.
5. Spawns the handler task — `tokio::spawn(handler.handle(conn, &auth))`.
3. **Per-identity cap check (ADR-094):**
`policy.check_open(&op_ctx.identity)?` — deny with
`channel:too_many_channels` if the identity is over its cap. The
identity is the direct caller (the peer on this channels
connection); `forwarded_for` is metadata and is NOT consulted
(ADR-032). For the hub-relay path, the spoke sees the hub as the
direct caller — the hub's quota on the spoke reflects the aggregate
of all relayed channels (ADR-094 §5).
4. Allocates the `channel_id` via `next_id.fetch_add(1, Relaxed)` (DP-1:
server-assigned). The per-connection `max_channels` (ADR-076) is
checked here too — the per-connection memory bound; if hit, the same
`channel:too_many_channels` error is returned (which cap fired first
is an implementation detail — ADR-094 §4).
5. Constructs the `ChannelBidiStreamSource` (ADR-074, as amended by
ADR-093) — one reassembly buffer, yielding a `BiStream`.
6. Spawns the handler task — `tokio::spawn(handler.handle(conn, &auth))`.
Identical to what `TtyAdapter::handle` does today, but on a
channels-backed `Connection`.
6. Records the `ChannelState`.
7. Returns the `channel_id`.
7. Records the `ChannelState`.
8. Returns the `channel_id`.
The `channel/close` handler (ADR-073) gains a symmetric
`policy.on_close(&op_ctx.identity)` call after the drain completes
(the same point ADR-076 marks the `channel_id` as eligible for reuse)
— decrementing the per-identity count.
## Demux invariants (REQ-CH-02, 04)
### REQ-CH-02: transport close → all channel senders drop → all handlers see EOF
On transport EOF, `run_demux_loop` clears the `channels` map, dropping all
`ReassemblyBuffer` senders. Every handler's reassembled `RecvStream` sees
`ReassemblyBuffer` senders. Every handler's reassembled `BiStream` sees
EOF even without an explicit zero-length sentinel on the wire. Without this,
`read_to_end` / `tokio::io::copy` in handlers hangs forever waiting for a
sender that never drops. This is a teardown invariant of the
@@ -160,11 +206,10 @@ sender that never drops. This is a teardown invariant of the
### REQ-CH-04: lenient unknown-`channel_id` handling
A chunk with an unallocated `channel_id` (or `stream_type`) is dropped with
a debug log and an error counter (exposed via `Demux::stats()`), and the
demux continues. This matches SSH's behavior and survives transient
mis-ordering during teardown. Validated by the POC
(`demux_unknown_channel_drops_lenient`).
A chunk with an unallocated `channel_id` is dropped with a debug log and
an error counter (exposed via `Demux::stats()`), and the demux continues.
This matches SSH's behavior and survives transient mis-ordering during
teardown. Validated by the POC (`demux_unknown_channel_drops_lenient`).
## Mux invariants (REQ-CH-03)
@@ -177,8 +222,8 @@ after the run loop starts.
The mux is split into:
- **`MuxHandle`** — clone-able, `register(channel_id, stream_type) ->
Sender<Bytes>` callable at any time after the runner starts.
- **`MuxHandle`** — clone-able, `register(channel_id) -> Sender<Bytes>`
callable at any time after the runner starts.
- **`MuxRunner`** — owns the transport, `select!`s on new-pump registrations
and per-channel write pumps.
@@ -222,19 +267,20 @@ channels connections:
```rust
// For channel_id=7 on browser side, channel_id=12 on spoke side:
tokio::spawn(async move {
let (b_send, b_recv) = browser_mgr.open_channel_stream(7, stream_type).await;
let (s_send, s_recv) = spoke_mgr.open_channel_stream(12, stream_type).await;
let mut b_bidi = browser_mgr.open_channel_stream(7).await;
let mut s_bidi = spoke_mgr.open_channel_stream(12).await;
tokio::join!(
pump(b_recv, s_send), // browser → spoke (with channel_id rewrite)
pump(s_recv, b_send), // spoke → browser (with channel_id rewrite)
pump(&mut b_bidi, &mut s_bidi), // browser → spoke (with channel_id rewrite)
pump(&mut s_bidi, &mut b_bidi), // spoke → browser (with channel_id rewrite)
);
});
```
The relay reads opaque bytes off one `ChannelManager`'s reassembled stream
and writes them onto the other's write-half, which re-chunks them with the
other leg's `channel_id`. The relay does not parse the bytes — it doesn't
know if they're TTY chunks, SSH frames, or tunnel data. The hub translates
The relay reads opaque bytes off one `ChannelManager`'s reassembled
`BiStream` and writes them onto the other's write-half, which re-chunks
them with the other leg's `channel_id` (a 4-byte rewrite within the
8-byte header). The relay does not parse the payload — it doesn't know if
the bytes are TTY chunks, SSH frames, or tunnel data. The hub translates
`channel/open` on channel 0 (re-issues on the spoke leg with
`forwarded_for`); data channels are byte-forwarded with `channel_id`
rewrite. See ADR-079 for the full relay contract.
@@ -246,16 +292,27 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | The split; the contracts |
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer, 256-channel cap, monotonic IDs |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, one reassembly buffer per channel |
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer, 256-channel per-connection memory bound, monotonic IDs (DoS defense reframed by ADR-094) |
| [094](../../decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound |
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract |
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
## References
- ADR-075: ChannelsAdapter and ChannelManager (the decision)
- ADR-093: channels pure channel multiplexing (the umbrella decision that
amends ADR-071/074/077)
- ADR-072: channel 0 pre-negotiated (the `preinstall_channel_0` step)
- ADR-073: channel lifecycle operations (the ops registered on `call_ops`)
- ADR-074: ChannelBidiStreamSource (what the manager constructs per channel)
- ADR-076: backpressure and limits (`buffer_cap`, `max_channels`)
- ADR-074: ChannelBidiStreamSource (what the manager constructs per
channel, as amended by ADR-093)
- ADR-076: backpressure and limits (`buffer_cap`, `max_channels` — the
per-connection memory bound)
- ADR-094: 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)
(REQ-CH-01..04, the two-pump deadlock)
- `docs/research/stream-unification/findings.md` — the research that
surfaced the pure-multiplexing resolution
@@ -1,37 +1,31 @@
---
status: draft
last_updated: 2026-07-12
last_updated: 2026-07-18
---
# channels-connection.md — ChannelBidiStreamSource and Sub-Stream Access
# channels-connection.md — ChannelBidiStreamSource and `BiStream` Access
How a reassembled channel is presented to its handler as a `Connection`.
ADR-074 is the decision; this doc specifies the API shape and the two
access paths.
ADR-074 (amended by ADR-093) is the decision; this doc specifies the API
shape — one accessor, one `BiStream` per channel.
## What
Each channel is reassembled into a set of **unidirectional** handles — one
per active `stream_type` (declared at `channel/open` time, ADR-073). Every
stream_type is unidirectional (ADR-071 §stream_type decomposition);
bidirectionality is two stream_types (write + read), not one shared
"bidirectional" stream. Write stream_types (`% 3 == 0`) carry a
`SendStream`; read stream_types (`% 3 == 1 or 2`) carry a `RecvStream`.
Each channel is reassembled into a `BiStream` — a single duplex
(`AsyncRead + AsyncWrite`) byte stream. The channels layer strips its
8-byte header (`channel_id` + `length`) on read, hands the payload to the
reassembled `BiStream`, and the handler parses its own framing from the
payload. The handler sub-multiplexes its `BiStream` however it wants —
TTY sub-demuxes `stream_type` from its `BiStream` via its 5-byte format,
tunnel uses the `BiStream` as raw bytes, call length-prefixes JSON, SSH
runs its own channel protocol.
These handles are wrapped as a `ChannelBidiStreamSource` that implements
The `BiStream` is wrapped in a `ChannelBidiStreamSource` that implements
`alknet-core`'s `BidiStreamSource` trait (ADR-070), and a `Connection` is
constructed from it via `Connection::from_source(source, alpn)`.
The handler receives a `Connection` and can either:
1. Call `accept_bi()` once to get the main data pair (`stream_type` 0/1) —
the generic handler path (tunnel, SSH).
2. Call `into_sub_streams()` on the `ChannelBidiStreamSource` to get all
active sub-streams as typed `(stream_type, SubStreamHandle)` tuples —
the typed handler path (TTY, which needs stdin/stdout/stderr/control-in/
control-out).
Both paths operate on the same reassembly buffers; the difference is how the
handler accesses them.
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
works on a top-level QUIC connection.
## `ChannelBidiStreamSource`
@@ -39,29 +33,30 @@ handler accesses them.
// In alknet-channels:
pub struct ChannelBidiStreamSource {
// The reassembly buffers for this channel's active stream_types,
// plus the mux handle for writing back onto the transport.
// Constructed by ChannelManager::build_channel_connection (ADR-075).
// The reassembly buffer for this channel's payload bytes (one per
// channel_id, not per (channel_id, stream_type) — the channels layer
// has no stream_type concept), plus the mux handle for writing back
// onto the transport. Constructed by ChannelManager::build_channel_connection
// (ADR-075).
...
}
#[async_trait]
impl BidiStreamSource for ChannelBidiStreamSource {
async fn accept_bi(&self)
-> Result<(SendStream, RecvStream), StreamError>
-> Result<BiStream, StreamError>
{
// Yields the (stream_type 0, stream_type 1) pair on first call,
// Yields the channel's BiStream on first call,
// ConnectionClosed on subsequent calls. Yield-once per channel,
// matching the POC's validated shape.
}
async fn open_bi(&self)
-> Result<(SendStream, RecvStream), StreamError>
-> Result<BiStream, StreamError>
{
// StreamClosed — a single channel cannot open new application
// streams (same as ADR-065's Stream backend). Additional sub-streams
// (stream_type 2, 3) are accessed via into_sub_streams(), not
// open_bi().
// streams (same as ADR-065's Stream backend). The handler owns
// its sub-stream multiplexing on the BiStream it received.
}
fn remote_addr(&self) -> Option<SocketAddr> { ... }
@@ -75,19 +70,21 @@ whole channels connection). The `ChannelManager` (ADR-075) constructs one
per channel at `channel/open` time and wraps it in a `Connection` via
`from_source`.
## The generic path: `accept_bi()`
## The single path: `accept_bi()`
For handlers that only need the main data pair (`stream_type` 0 = data-in,
`stream_type` 1 = data-out):
Every handler — TTY, tunnel, SSH, call — receives a `Connection`, calls
`accept_bi()` once, gets a `BiStream`, and sub-multiplexes it however it
wants. There is one accessor.
```rust
// Tunnel handler — ~15 lines, zero channels-layer awareness
async fn handle(&self, connection: Connection, _auth: &AuthContext)
-> Result<(), HandlerError>
{
let (mut send, mut recv) = connection.accept_bi().await?;
let mut bidi = connection.accept_bi().await?;
let mut tcp = TcpStream::connect(target).await?;
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)
let c2t = async {
@@ -105,98 +102,53 @@ async fn handle(&self, connection: Connection, _auth: &AuthContext)
}
```
The handler calls `accept_bi()` once, gets the `(SendStream, RecvStream)`
pair, and pumps. It does not know it's inside a channels connection — the
`Connection` looks like any other. This is the path the POC's `EchoHandler`
and `TunnelHandler` validated.
`accept_bi()` is yield-once: the first call returns the 0/1 pair; subsequent
calls return `ConnectionClosed`. This matches the POC's validated shape and
the `StreamBidiStreamSource` yield-once contract (ADR-070).
## The typed path: `into_sub_streams()`
For handlers that need `stream_type` 2 (stderr) or 3 (control) in addition
to 0/1:
```rust
// In alknet-channels-core:
pub struct ChannelSubStreams {
/// (stream_type, handle) for each active stream_type. Each handle is
/// unidirectional: write stream_types (0, 3, 6, ...) carry a SendStream;
/// read stream_types (1, 2, 4, 5, 7, ...) carry a RecvStream.
/// See ADR-071 §stream_type decomposition.
pub streams: Vec<(u8, SubStreamHandle)>,
}
pub enum SubStreamHandle {
Send(SendStream), // write half (stream_type % 3 == 0)
Recv(RecvStream), // read half (stream_type % 3 == 1 or 2)
}
impl ChannelBidiStreamSource {
/// Returns all active sub-streams, keyed by stream_type. Consumes the
/// source — call this instead of accept_bi() if the handler needs
/// direct access to stream_types 2/3/4.
pub fn into_sub_streams(self) -> ChannelSubStreams { ... }
// TTY handler (inside-channels mode) — the SAME code as direct
// mode, just a different BiStream source.
async fn handle(&self, connection: Connection, _auth: &AuthContext)
-> Result<(), HandlerError>
{
let mut bidi = connection.accept_bi().await?;
// drive_session reads the 5-byte TTY chunks off `bidi` — the same
// code as direct mode. The channels layer stripped its 8-byte
// header; TTY's 5-byte format is the payload.
drive_session(bidi, backends, ownership, identity).await
}
```
The handler crate destructures `ChannelSubStreams` into its typed names:
The handler calls `accept_bi()` once, gets a `BiStream`, and pumps. It
does not know it's inside a channels connection — the `Connection` looks
like any other. This is the path the POC's `EchoHandler` and
`TunnelHandler` validated.
```rust
// In alknet-tty (inside-channels mode, ADR-077):
let sub = channel_source.into_sub_streams();
let stdin = sub.get_send(0).unwrap(); // SendStream (write, client→server)
let stdout = sub.get_recv(1).unwrap(); // RecvStream (read, server→client)
let stderr = sub.get_recv(2); // Option<RecvStream> (read, optional)
let ctrl_in = sub.get_send(3).unwrap(); // SendStream (write, client→server)
let ctrl_out = sub.get_recv(4).unwrap();// RecvStream (read, server→client)
```
**Every stream_type is unidirectional** (ADR-071). The channels crate
exposes `(stream_type, SubStreamHandle)` tuples. The handler crate maps
stream_types to its typed names. This preserves ADR-003's
no-handler-depends-on-another-handler rule and keeps the channels crate
ALPN-blind.
`into_sub_streams()` consumes the source — a handler can't call both
`accept_bi()` and `into_sub_streams()`. This is by design: the sub-streams
include the 0/1 pair, so `into_sub_streams()` is the superset.
## Choosing the path
| Handler shape | Path | Examples |
|---------------|------|---------|
| Main data pair only (0/1) | `accept_bi()` | tunnel, SSH (SSH multiplexes internally) |
| Needs stderr/control (2/3/4) | `into_sub_streams()` | TTY (stdin/stdout/stderr/ctrl-in/ctrl-out) |
The handler chooses based on its ALPN's `stream_type` set (declared at
`channel/open` time). The `ChannelsAdapter` (ADR-075) passes the handler a
`Connection` (via `from_source`); handlers that need sub-streams access the
`ChannelBidiStreamSource` via a channels-crate extension trait or downcast
(exact ergonomics are an implementation detail for the channels crate; the
contract is that both paths are available and the handler crate chooses).
`accept_bi()` is yield-once: the first call returns the `BiStream`;
subsequent calls return `ConnectionClosed`. This matches the POC's
validated shape and the `StreamBidiStreamSource` yield-once contract
(ADR-070, ADR-092).
## Recursive composition
A `ChannelBidiStreamSource` is a `BidiStreamSource`, and `Connection::
from_source` wraps it. A handler that is itself `alknet/channels` can open a
sub-channels connection on a data channel — `alknet/channels` inside
`alknet/channels`. This is allowed (the `Connection` abstraction permits it)
but not a feature designed for. The primary use case is one level of
multiplexing. Recursive composition is a natural consequence of the
abstraction, not a goal.
A `ChannelBidiStreamSource` is a `BidiStreamSource`, and
`Connection::from_source` wraps it. A handler that is itself
`alknet/channels` can open a sub-channels connection on a data channel —
`alknet/channels` inside `alknet/channels`. The outer layer strips its
8-byte header; the inner layer parses its own 8-byte header from the
payload. Each level is the same shape: `BiStream → accept_bi → N
BiStreams`. The recursion is unbounded and uniform at every level.
This is a property, not a feature. The primary use case is one level of
multiplexing. But the add/strip composition makes it cleaner than
ADR-071's group framing did — the recursion is the same operation
(strip an 8-byte header) at every level, not a different framing per
level.
## What does NOT change
- **`ProtocolHandler` trait** (ADR-002) — handlers still receive a
`Connection` and call `accept_bi()`. The `ChannelBidiStreamSource` is
internal to the channels crate; handlers see a `Connection`.
- **`SendStream` / `RecvStream`** (ADR-007) — unchanged. They continue to
wrap their internal sources. `ChannelBidiStreamSource` constructs them via
the existing `from_stream` constructors, backed by mpsc reassembly
buffers.
- **`BiStream`** (ADR-092) — the leaf type `accept_bi` returns. The
channels layer yields `BiStream`s; handlers parse them per their ALPN.
- **`HandlerRegistry`** — unchanged. The channels layer looks up ALPNs in
the same registry as top-level connections.
@@ -206,15 +158,22 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi`; `into_sub_streams()` accessor |
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` is the only accessor |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `BiStream`-only |
| [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The extension point `ChannelBidiStreamSource` implements |
| [092](../../decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream` (the transport-leaf decision this doc builds on) |
| [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The yield-once path generalized for channels |
## References
- ADR-074: ChannelConnection (the decision)
- ADR-093: channels pure channel multiplexing (the umbrella decision)
- ADR-070: BidiStreamSource trait
- ADR-065: `Connection::from_stream`
- ADR-077: TTY inside channels (the primary consumer of `into_sub_streams`)
- ADR-092: `BiStream` as the handler leaf
- ADR-065: `Connection::from_stream` (the yield-once path generalized)
- ADR-077: 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)
yield-once `Connection::from_stream` validation)
- `docs/research/stream-unification/findings.md` — the research that
surfaced the single-accessor resolution
+176 -137
View File
@@ -1,147 +1,135 @@
---
status: draft
last_updated: 2026-07-12
last_updated: 2026-07-18
---
# channels-wire.md — The 9-Byte Chunk Format
# channels-wire.md — The 8-Byte Chunk Format
The wire format for `alknet/channels`: a 9-byte chunk header that
multiplexes N logical channels, each with up to 256 sub-stream types, over
a single ordered, reliable bidirectional transport stream. ADR-071 is the
The wire format for `alknet/channels`: an 8-byte chunk header that
multiplexes N logical channels over a single ordered, reliable
bidirectional transport stream. ADR-071 (amended by ADR-093) is the
decision; this doc specifies the format and the wire-level invariants.
The channels layer has no `stream_type` concept — not in its header, not
in its code, not in its mental model. The handler owns its sub-stream
multiplexing on the `BiStream` the channels layer gives it.
## Chunk header
```
[channel_id: u32 be][stream_type: u8][length: u32 be][payload bytes]
[channel_id: u32 BE][length: u32 BE][payload bytes]
```
9 bytes of header, followed by `length` bytes of payload.
8 bytes of header, followed by `length` bytes of opaque payload.
| field | offset | width | meaning |
|-------|--------|-------|---------|
| `channel_id` | 0 | 4 (BE) | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alknet/call` (ADR-072). Channels 1..N are opened dynamically via `channel/open` (ADR-073). |
| `stream_type` | 4 | 1 | The sub-stream within the channel. See "Stream types" below. |
| `length` | 5 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN`. |
| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN`. |
This is a 4-byte extension of alknet-tty's 5-byte format (ADR-052): the
`channel_id` prefix is added; `stream_type` and `length` are identical. The
`ChunkReader` / `ChunkWriter` pattern, the framing-disambiguation trick,
and the zero-length sentinel convention all carry forward from TTY.
The payload is opaque to the channels layer. The handler parses its own
framing from the payload — TTY's `[stream_type:u8][length:u32][payload]`
(5-byte format, ADR-052), call's length-prefixed JSON (`EventEnvelope`
framing, ADR-064), tunnel's raw bytes, SSH's channel protocol. The
channels layer carries the bytes transparently.
### How the wire formats compose
The channels 8-byte header and the handler's framing compose by layering:
```
channels: [channel_id:u32 BE][length:u32 BE][payload]
= 8-byte header + opaque payload
8 bytes
TTY inside channels:
[channel_id:u32][ch_len:u32][stream_type:u8][tty_len:u32][payload]
4 bytes 4 bytes 1 byte 4 bytes N bytes
\_________ __________/ \_________ _____________/
| |
channels header TTY chunk (5+N bytes)
(8 bytes) carried as channels payload
```
The channels layer reads its 8-byte header (`channel_id` + `length`),
reads `length` bytes of payload, and hands the payload to the handler.
The handler parses its own framing from the payload — TTY reads its
5-byte header (`stream_type` + `length`) from the payload bytes.
The two length fields are close but not identical: `ch_len = tty_len + 5`.
This is a small amount of waste per chunk (the channels `length` is always
5 bytes more than TTY's `length`), but the trade-off is clean separation
of concerns: the channels layer has no `stream_type` concept — not in
its header, not in its code, not in its mental model. The handler owns
its framing entirely. See ADR-093 for the full cost/benefit analysis.
## `MAX_CHUNK_LEN`
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (ADR-052 §5). A chunk with
`length > MAX_CHUNK_LEN` returns `ChunkTooLarge` and does not corrupt the
stream — the demux drops the chunk and continues. The header is always
exactly 9 bytes, so the demux can always resync by reading the next 9-byte
header.
## Stream types — unidirectional, grouped in threes
**Every stream_type is unidirectional.** Bidirectionality is two
stream_types (write + read), not one "bidirectional" stream_type. The
stream_types are grouped in threes:
| Group | stream_type | direction | purpose |
|-------|-------------|-----------|---------|
| Data | 0 | write (client→server) | data in (stdin equivalent) |
| | 1 | read (server→client) | data out (stdout equivalent) |
| | 2 | read (server→client) | data err (stderr equivalent, optional) |
| Control | 3 | write (client→server) | control in (ALPN-specific format) |
| | 4 | read (server→client) | control out (ALPN-specific format) |
| | 5 | read (server→client) | control err (optional) |
| Future | 6/7/8 | write/read/read | next group, same pattern |
| | ... | | |
**Formula:** `stream_type % 3 == 0` → write half (in), `stream_type % 3 ==
1` → read half (out), `stream_type % 3 == 2` → diagnostic read half (err).
256 values / 3 = 85 groups. The `u32` channel_id space combined with 85
stream_type groups is effectively unlimited for the intended use cases.
**Why unidirectional:** each stream_type gets its own reassembly buffer, its
own flow control, its own EOF. Control is bidirectional via two halves
(3 in, 4 out), not one shared stream both sides write to. This resolves the
TTY control channel's "not actually bidirectional" flaw (ADR-077).
**Control payload format is ALPN-specific.** The channels layer is blind to
what stream_types 3/4/5 carry — it reassembles bytes and delivers them to
the handler. TTY happens to use JSON for its control channel; another ALPN
might use a binary format. The channels layer does not mandate JSON on
control stream_types, the same way it doesn't mandate a format for data
stream_types.
Not all channels use all sub-streams. The active set is declared at
`channel/open` time (ADR-073 `stream_types` field) and fixed for the
channel's lifetime.
| Channel ALPN | Active stream_types | Why |
|--------------|---------------------|-----|
| `alknet/call` (channel 0) | [0, 1] | call frames bidirectional via 0=in, 1=out |
| `alknet/tty` | [0, 1, 2, 3, 4] | data in/out/err + control in/out |
| `alknet/tunnel` | [0, 1] | data in/out only (no channels-layer control needed) |
| `alknet/ssh` | [0, 1] | SSH multiplexes internally, including its own control |
## Substrate modes — same wire format, different stream counts
The 9-byte header is used in all substrates, on every bidi stream. The
difference between substrates is only **how many bidi streams the transport
yields**:
| Substrate | Transport | Streams | Header role |
|-----------|-----------|---------|--------------|
| In-line | TCP+TLS, WebTransport session, SSH `direct-tcpip` | 1 | Header demuxes N channels from that 1 stream |
| Native | QUIC (quinn/iroh) | N | Each stream carries 1 logical channel; header provides `stream_type` + `channel_id` correlation |
| Multi-connection | Any, N connections | N × M | Each connection is self-contained (own channel 0, own demux); header is per-connection |
The `ChannelsAdapter::handle` loop: `accept_bi()` → for each stream, read
the 9-byte header → route by `(channel_id, stream_type)` → reassemble. On
an in-line transport, `accept_bi()` yields once then `ConnectionClosed` —
the header does all the demux. On QUIC, `accept_bi()` yields repeatedly —
each stream is a channel, and the header provides `stream_type` and
`channel_id` correlation. Same code path, same wire format, same handler
experience. See ADR-071 §substrate modes, ADR-075.
exactly 8 bytes, so the demux can always resync by reading the next
8-byte header.
## Channel 0 — pre-negotiated `alknet/call`
Channel 0 is not a special "control plane" with its own framing. It is
`alknet/call` pre-negotiated (ADR-072): both sides know `channel_id = 0` is
routed to the `CallAdapter` without an explicit `channel/open` exchange.
`alknet/call` pre-negotiated (ADR-072): both sides know `channel_id = 0`
is routed to the `CallAdapter` without an explicit `channel/open`
exchange.
Channel 0 uses stream_types [0, 1] — call frames bidirectional via 0=in
(client→server), 1=out (server→client). The call protocol's `(SendStream,
RecvStream)` pair maps directly: `SendStream` backed by stream_type 0,
`RecvStream` backed by stream_type 1. stream_types 2-255 on channel 0 are
reserved for future call-protocol sub-streams.
Channel 0's chunks have `channel_id = 0` in the 8-byte header — same
format as every other channel. The call protocol's `EventEnvelope` JSON
framing (ADR-064) is the payload; the channels layer carries it
transparently. Disambiguation between channel 0 and data channels is by
`channel_id`, not by a special first-byte trick.
Channel 0's chunks have `channel_id = 0` in the header — same format as
every other channel. Disambiguation between channel 0 and data channels is
by `channel_id`, not by a special first-byte trick.
## Framing disambiguation
## Framing disambiguation (from ADR-052 §5)
The 9-byte header is always exactly 9 bytes. `length` is bounded by
`MAX_CHUNK_LEN`. The demux reads 9 bytes, parses the header, reads
The 8-byte header is always exactly 8 bytes. `length` is bounded by
`MAX_CHUNK_LEN`. The demux reads 8 bytes, parses the header, reads
`length` bytes of payload, and routes. If a chunk is dropped (e.g.,
`ChunkTooLarge`), the demux resyncs by reading the next 9-byte header —
`ChunkTooLarge`), the demux resyncs by reading the next 8-byte header —
the format is self-synchronizing.
Within a channel, `stream_type` 0 (write half) from the server is invalid,
so `0x00` as the first byte of a chunk payload from the server is
unambiguous (carried from ADR-052 §5).
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,
which is internal to TTY's 5-byte format).
## Zero-length sentinel = EOF
A zero-length chunk (`length = 0`) is delivered as an empty `Bytes`, which
the reassembled stream interprets as EOF. This is the clean-shutdown signal
for a `(channel_id, stream_type)` pair — same convention as TTY (ADR-052
§Sentinels).
A zero-length chunk (`length = 0`) is delivered as an empty payload,
which the reassembled stream interprets as EOF. This is the clean-shutdown
signal for a `channel_id` — the same convention as TTY (ADR-052
§Sentinels), now at the channels layer (one sentinel per channel, not
per `(channel_id, stream_type)`).
The sentinel is emitted by the write side's `AsyncWrite::shutdown` (see
REQ-CH-01 below) and consumed by the read side's `AsyncRead::poll_read` as
EOF.
## Substrate modes — same wire format, different stream counts
The 8-byte header is used in all substrates, on every bidi stream. The
difference between substrates is only **how many bidi streams the
transport yields**:
| Substrate | Transport | Streams | Header role |
|-----------|-----------|---------|--------------|
| In-line | TCP+TLS, WebTransport session, SSH `direct-tcpip` | 1 | Header demuxes N channels from that 1 stream |
| Native | QUIC (quinn/iroh) | N | Each stream carries 1 logical channel; header provides `channel_id` correlation |
| Multi-connection | Any, N connections | N × M | Each connection is self-contained (own channel 0, own demux); header is per-connection |
The `ChannelsAdapter::handle` loop: `accept_bi()` → for each stream, read
the 8-byte header → route by `channel_id` → reassemble into a `BiStream`.
On an in-line transport, `accept_bi()` yields once then
`ConnectionClosed` — the header does all the demux. On QUIC, `accept_bi()`
yields repeatedly — each stream is a channel, and the header provides
`channel_id` correlation. Same code path, same wire format, same handler
experience. See ADR-071 §substrate modes (as amended by ADR-093), ADR-075.
## Wire-level invariants (REQ-CH-01, 02, 04, 05)
The de-risk POC (`docs/research/alknet-channels/poc-summary.md` §Issues
@@ -151,22 +139,23 @@ These are **contracts**, not implementation details — both sides must agree.
### REQ-CH-01: `AsyncWrite::shutdown` emits a zero-length sentinel
The reassembled stream's write half (`MpscSendStream` or equivalent) MUST
send an empty `Bytes` (the EOF sentinel) before dropping the sender on
send an empty payload (the EOF sentinel) before dropping the sender on
`AsyncWrite::shutdown`. Without this, the demux never sees EOF on the
channel's `stream_type`, and `tokio::io::copy` in the handler never
channel, and `tokio::io::copy` in the handler never
completes — the session hangs.
The TTY crate's `pump_session` emits the zero-length stdout sentinel
explicitly via `Chunk::stdout(Bytes::new())`; the channels layer's
per-channel write pump does NOT forward a sentinel on sender-drop, so the
send adapter must. Both sides must agree on this convention, or channels
hang on clean shutdown.
explicitly via its own 5-byte format's zero-length chunk; the channels
layer's per-channel write pump does NOT forward a sentinel on
sender-drop, so the send adapter must. Both sides must agree on this
convention, or channels hang on clean shutdown.
### REQ-CH-02: transport close → all channel senders drop → all handlers see EOF
The demux loop MUST clear its `channels` map on transport EOF, dropping all
`ReassemblyBuffer` senders. Every handler's reassembled `RecvStream` sees
EOF even without an explicit zero-length sentinel arriving on the wire.
The demux loop MUST clear its `channels` map on transport EOF, dropping
all `ReassemblyBuffer` senders. Every handler's reassembled `BiStream`
sees EOF even without an explicit zero-length sentinel arriving on the
wire.
Without this, `read_to_end` / `tokio::io::copy` in handlers hangs forever
waiting for a sender that never drops because the demux task is holding the
@@ -174,11 +163,11 @@ map. This is a teardown invariant of the `ChannelsAdapter::handle` contract.
### REQ-CH-04: lenient unknown-`channel_id` handling with error counter
A chunk with an unallocated `channel_id` (or `stream_type` on an allocated
channel) is dropped with a debug log and an error counter (exposed via
`Demux::stats()`), and the demux continues. This matches SSH's behavior and
survives transient mis-ordering during teardown (a chunk for a channel that
was just closed may arrive after the close is processed).
A chunk with an unallocated `channel_id` is dropped with a debug log and
an error counter (exposed via `Demux::stats()`), and the demux continues.
This matches SSH's behavior and survives transient mis-ordering during
teardown (a chunk for a channel that was just closed may arrive after
the close is processed).
The alternative (strict — close the transport on unknown `channel_id`) is
fragile during teardown and catches bugs at the cost of reliability. The
@@ -187,10 +176,10 @@ fragility.
### REQ-CH-05: bounded-buffer backpressure does not deadlock
Each `(channel_id, stream_type)` has an independent bounded `mpsc` buffer
(default 1 MiB — ADR-076). A slow reader on one channel does not block
another channel's reads — the demux's per-chunk route awaits the matching
sender without holding a global lock.
Each `channel_id` has an independent bounded `mpsc` buffer (default 1 MiB
— ADR-076). A slow reader on one channel does not block another channel's
reads — the demux's per-chunk route awaits the matching sender without
holding a global lock.
The 1 MiB `tunnel_large_payload` POC test exercised this end-to-end: a
channel writer faster than the TCP echo server consumer, with no deadlock
@@ -204,17 +193,16 @@ The wire format's core is pure byte manipulation:
```rust
// wire.rs — sync core, no async, no platform deps, WASM-clean
const CHUNK_HEADER_LEN: usize = 9;
const CHUNK_HEADER_LEN: usize = 8;
const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024;
pub struct ChunkHeader {
pub channel_id: u32,
pub stream_type: u8,
pub length: u32,
}
pub fn parse_header(buf: &[u8; 9]) -> Result<ChunkHeader, ChunkError> { ... }
pub fn write_header(channel_id: u32, stream_type: u8, length: u32, out: &mut [u8; 9]) { ... }
pub fn parse_header(buf: &[u8; 8]) -> Result<ChunkHeader, ChunkError> { ... }
pub fn write_header(channel_id: u32, length: u32, out: &mut [u8; 8]) { ... }
```
The async shell (demux/mux — see [channels-adapter.md](channels-adapter.md))
@@ -223,13 +211,35 @@ routing. The split keeps the WASM-compatible core separate from the
tokio-dependent shell. The POC validated the sync core compiles under
`wasm32-unknown-unknown`.
## The add/strip composition
Each layer has its own add/strip pair. The channels layer:
`add_channel_id(channel_id, payload_bytes) -> chunk` on write (prepends
the 8-byte header); `strip_channel_id(chunk) -> (channel_id,
payload_bytes)` on read (strips the 8-byte header, returns the payload).
The handler layer (e.g. TTY) parses its own framing from the payload
bytes per its existing `wire.rs`. The handler doesn't know or care that
a `channel_id` was stripped before it saw the bytes.
The composition is uniform — the same shape at every level. This is SSH's
model (layered headers, each layer strips its own at its boundary),
applied to channels. A `alknet/channels`-inside-`alknet/channels`
recursive composition is the outer layer stripping its 8-byte header, the
inner layer parsing its own 8-byte header from the payload — same code,
same shape, each level.
The exact API shape of the add/strip pair (built into the read/write path
vs. a standalone utility) is an implementation detail for the channels
crate, tracked as OQ-68. The *contract* — the channels layer strips its
8-byte header on read and the handler parses its own framing from the
payload — is decided; the *function surface* is not.
## Channel lifecycle (summary)
| Phase | Mechanism | Reference |
|-------|-----------|-----------|
| Open | `channel/open` call operation on channel 0; responder allocates `channel_id`, returns it | ADR-073 |
| Data | chunks with `channel_id` routed to reassembly buffers; handler sees `AsyncRead + AsyncWrite` | this doc, [channels-connection.md](channels-connection.md) |
| Control (data-ordered) | `stream_type 3` (write) and `stream_type 4` (read) chunks on the data channel (JSON, in-order with data) | ADR-073 §DP-4 |
| Data | chunks with `channel_id` routed to reassembly buffers; handler sees a `BiStream` | this doc, [channels-connection.md](channels-connection.md) |
| Control (out-of-band) | `channel/control` call operation on channel 0 | ADR-073 |
| Close | `channel/close` call operation on channel 0; data chunks flushed before close | ADR-073, REQ-CH-06 |
@@ -240,24 +250,53 @@ The channel's data chunks MUST be written and flushed before the
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 forward:
the exit control message on `stream_type 4` (read, server→client) is the
last data before `channel/close`. For tunnels it is the last data byte
before close. The channels layer's close handler observes the pump
completion; the call operation is issued after.
For TTY this is the exit-chunk-is-last invariant (ADR-055) 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
close handler observes the pump completion; the call operation is issued
after.
This invariant crosses two channels (the data channel and channel 0), so
the channels layer owns the ordering guarantee — it is not a handler
concern.
concern. The control-message division (data-ordered control vs
out-of-band control) is now entirely handler-internal: TTY's
`STREAM_CTRL_IN` / `STREAM_CTRL_OUT` are stream_types in TTY's 5-byte
payload format, not channels-layer concepts.
## Design Decisions
All design decisions are documented as ADRs in [decisions/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format | 8-byte chunk header (amended by ADR-093); channels layer has no `stream_type` concept; one-way door |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
## Open Questions
Open questions are tracked in [open-questions.md](../../open-questions.md).
Key questions affecting this doc:
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
add/strip is built into the channels read/write path or exposed as a
standalone utility. The *contract* (channels strips, handler parses
payload) is decided; the *function surface* is not.
## References
- ADR-071: channels wire format (the decision)
- ADR-052: alknet-tty wire format (the 5-byte format this generalizes;
amended by ADR-077 — scoped to direct TTY)
- ADR-071: channels wire format (the decision, amended by ADR-093 — 8-byte
header, no `stream_type`)
- ADR-093: channels pure channel multiplexing (the umbrella decision that
amends ADR-071/074/077)
- ADR-052: alknet-tty wire format (the 5-byte format carried
transparently in the channels payload)
- ADR-072: channel 0 pre-negotiated
- ADR-073: channel lifecycle operations
- ADR-076: backpressure, channel limits, ID reuse
- `docs/research/alknet-channels/poc-summary.md` §POC Target 1, §Issues
Surfaced #4-#6 (REQ-CH-01, 02, 04)
- `crates/alknet-tty/src/wire.rs` — the 5-byte format implementation
- `docs/research/stream-unification/findings.md` — the research that
surfaced the 8-byte format decision
- `crates/alknet-tty/src/wire.rs` — the 5-byte format implementation
(carried transparently in the channels payload)
+76 -39
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-12
last_updated: 2026-07-18
---
# alknet-channels — Overview
@@ -9,17 +9,20 @@ last_updated: 2026-07-12
`alknet-channels` is a multiplexing proxy crate. It implements
`ProtocolHandler` for the `alknet/channels` ALPN: it receives one
bidirectional transport stream, reads 9-byte chunk headers, and routes each
bidirectional transport stream, reads 8-byte chunk headers, and routes each
chunk's payload to the right logical channel. Each channel is reassembled
into an `AsyncRead + AsyncWrite` pair and presented to its handler as a
`Connection` — the handler doesn't know it's inside a channels connection.
into a `BiStream` (a concrete `AsyncRead + AsyncWrite` newtype, per
ADR-092) and presented to its handler as a `Connection` — the handler
doesn't know it's inside a channels connection.
Channel 0 is pre-negotiated as `alknet/call` (ADR-072). Every other channel
is opened dynamically via `channel/open` on channel 0 (ADR-073) and routed
through the same `HandlerRegistry` as top-level connections. The channels
layer does no protocol work itself — it is a re-framing proxy that converts
between "one transport stream carrying N channels" (the wire) and "N
independent stream handles" (what handlers see).
independent `BiStream` handles" (what handlers see). The channels layer has
no `stream_type` concept (ADR-093) — the handler owns its sub-stream
multiplexing on the `BiStream` it receives.
## Why
@@ -46,13 +49,13 @@ With `alknet/channels`, one connection carries everything:
```
Browser ──WebTransport──► Hub ──QUIC──► Spoke
alknet/channels alknet/channels
┌─────────────┐ ┌─────────────┐
│ ch0: call │ │ ch0: call │
│ ch1: tty │ relay │ ch1: tty │
│ ch2: ssh │ ◄─────► │ ch2: ssh │
│ ch3: tunnel │ │ ch3: tunnel │
└─────────────┘ └─────────────┘
alknet/channels alknet/channels
┌─────────────┐ ┌─────────────┐
│ ch0: call │ │ ch0: call │
│ ch1: tty │ relay │ ch1: tty │
│ ch2: ssh │ ◄─────► │ ch2: ssh │
│ ch3: tunnel │ │ ch3: tunnel │
└─────────────┘ └─────────────┘
```
The hub's relay is channel-by-channel byte forwarding (with `channel_id`
@@ -70,12 +73,35 @@ The collapse is at three levels:
`AccessControl`, and `forwarded_for` machinery govern channel lifecycle
with no new auth.
### The separation: channels layer is pure channel multiplexing
The channels layer's job is "one connection carries N channels, routed by
`channel_id`." It does not know about TTY's sub-streams, SSH's channel
protocol, or how call frames its JSON. Handlers own their sub-multiplexing
on the `BiStream` the channels layer gives them (ADR-093).
- **Every channel is a `BiStream`.** `accept_bi()` yields one `BiStream`
per channel (per ADR-092). The handler sub-multiplexes it however it
wants — TTY's 5-byte format, call's length-prefixed JSON, tunnel's raw
bytes, SSH's channel protocol.
- **The channels layer has no `stream_type` concept.** Not in its 8-byte
header, not in its code, not in its mental model. `stream_type` is the
inner layer's framing byte, carried transparently in the payload.
- **The control channel is handler-internal.** TTY sub-demuxes control
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN` /
`STREAM_CTRL_OUT` — ADR-052 amended by Phase 7). The channels layer
doesn't carry control.
- **Recursive composition is literal.** A channel with ALPN
`alknet/channels` runs another channels demux on its `BiStream`. The
outer layer strips its 8-byte header; the inner layer parses its own
8-byte header from the payload.
## Architecture
The crate has two internal components (ADR-075):
- **`ChannelsAdapter`** — implements `ProtocolHandler` for
`alknet/channels`. Its `handle()` receives one `Connection`, reads 9-byte
`alknet/channels`. Its `handle()` receives one `Connection`, reads 8-byte
chunk headers, and routes chunks to the `ChannelManager`. The read/demux
half.
- **`ChannelManager`** — the shared state. Holds `channel_id →
@@ -84,9 +110,10 @@ The crate has two internal components (ADR-075):
`channel/open` operation handler closes over.
Each channel is presented to its handler as a `Connection` constructed via
`Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-070/074). The
handler calls `accept_bi()` once (yield-once per channel) and drives its
session — identical to how it works on a top-level QUIC connection.
`Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-070/074, as
amended by ADR-093). 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.
See [channels-adapter.md](channels-adapter.md) for the full adapter/manager
design.
@@ -96,7 +123,7 @@ design.
```
alknet-channels-core
├── alknet-core (ProtocolHandler, Connection, HandlerRegistry,
│ BidiStreamSource, SendStream, RecvStream, AuthContext)
│ BidiStreamSource, BiStream, AuthContext)
├── tokio (spawn, mpsc, io)
├── bytes (Bytes for chunk payloads)
├── async-trait
@@ -159,7 +186,7 @@ stream:
The same wire format, the same chunk reassembly, the same `Connection`
abstraction. The transport is a parameter, not a design constraint.
`Connection::from_stream` / `from_source` (ADR-065/070) handles the
`Connection::from_bidi` / `from_source` (ADR-065/070/092) handles the
transport-agnostic `Connection` construction.
## WASM compatibility
@@ -189,7 +216,9 @@ not an architecture concern. The sync core's WASM compatibility is validated.
Unchanged. The call protocol remains JSON-only, `EventEnvelope`-based. It
runs on channel 0 exactly as on a top-level `alknet/call` connection. The
`CallAdapter` receives a `Connection` backed by channel-0 chunk reassembly
and dispatches operations — it doesn't know it's inside channels.
and dispatches operations — it doesn't know it's inside channels. The call
protocol's `EventEnvelope` framing (ADR-064) is the channels payload; the
channels layer carries it transparently.
What changes: the call protocol gains a new class of operations — channel
lifecycle (ADR-073). These are registered on the `OperationRegistry` at
@@ -198,24 +227,27 @@ assembly time and dispatched through the existing `OperationContext` /
### alknet-tty
The TTY crate gains a `channels` feature (ADR-077) that enables
inside-channels mode. In direct mode (`alknet/tty` ALPN on a top-level
connection), the TTY adapter uses its own 5-byte wire format (ADR-052,
unchanged). In channels mode (`channel/open` with ALPN `alknet/tty`), the
adapter receives `ChannelSubStreams` (ADR-074) — four named
`SendStream`/`RecvStream` pairs for stream_types 0-3 — and pumps without
chunk parsing. The `TtyBackend` trait and `TtyHandle` are unchanged;
backends don't know which mode the adapter is in.
The TTY crate gains a `channels` feature that enables inside-channels
mode. In both direct mode (`alknet/tty` ALPN on a top-level connection) and
inside-channels mode (`channel/open` with ALPN `alknet/tty`), the TTY
adapter uses its own 5-byte wire format (ADR-052). The two modes differ
only in *where the `BiStream` comes from* — a top-level connection vs a
channels-backed `Connection`. The same `wire.rs` code runs in both modes
(ADR-077): the channels layer strips its 8-byte header and hands TTY the
payload bytes; TTY parses its 5-byte header from the payload. The
`TtyBackend` trait and `TtyHandle` are unchanged; backends don't know
which mode the adapter is in.
### alknet-ssh (future)
SSH as a channel type: an `alknet/ssh` channel carries the SSH binary
protocol over stream_types 0 and 1. The channels layer hands the
reassembled stream to `SshAdapter`, which feeds it to russh. SSH as a
channels transport: an SSH `direct-tcpip` channel could carry a channels
connection (channels-over-SSH). The SSH crate doesn't need to know about
channels — it implements `ProtocolHandler` for `alknet/ssh` and accepts a
`Connection`.
protocol on its `BiStream`. The channels layer hands the reassembled
`BiStream` to `SshAdapter`, which feeds it to russh. SSH as a channels
transport: an SSH `direct-tcpip` channel could carry a channels connection
(channels-over-SSH). The SSH crate doesn't need to know about channels —
it implements `ProtocolHandler` for `alknet/ssh` and accepts a
`Connection`. SSH multiplexes internally (its own channel protocol rides
the channels payload transparently).
### alknet-docker
@@ -240,16 +272,17 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format | 9-byte chunk header; unidirectional stream_types in groups of 3; one-way door |
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call`, stream_types [0,1] |
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format | 8-byte chunk header (amended by ADR-093); channels layer has no `stream_type` concept; one-way door |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call` |
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | `channel/open`/`close`/`control`/`resources/subscribe`; subscribe not poll; `direction` pinned |
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; `into_sub_streams()` with `SubStreamHandle` enum |
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` (amended by ADR-093 — `into_sub_streams` removed) |
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 |
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer (1 MiB), 256-channel cap, monotonic IDs |
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels | Two modes (direct vs channels); 5 sub-streams; control bidirectional via 3/4 |
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels | TTY's two modes (direct vs channels); TTY always uses its 5-byte format, carried transparently in the channels payload |
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract; handler-level |
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels with ID rewrite |
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-089, resolves OQ-55) |
| [081](../../decisions/081-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers |
## Open Questions
@@ -267,4 +300,8 @@ Key questions affecting this crate:
blocked on a real HOL-blocking deployment observation.
- **OQ-57** (deferred(scope)): Two-pump helper extraction to alknet-core —
the *contract* is decided (ADR-078); the *helper* is blocked on a second
two-pump handler existing.
two-pump handler existing.
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
add/strip is built into the channels read/write path or exposed as a
standalone utility. The *contract* (channels strips, handler parses
payload) is decided (ADR-093); the *function surface* is not.
+42 -44
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-16
last_updated: 2026-07-17
---
# alknet-client
@@ -20,16 +20,17 @@ HTTP-to-SOCKS5 bridge for iroh).
## What
`AlknetClient` is the dial. Before this crate, each protocol client
(`CallClient::connect`, `ChannelClient::connect_quic`) built its own
QUIC dial inline — building a `TlsClientConfig`, constructing a
`quinn::Endpoint`, calling `connect_with`, wrapping as a `Connection`.
The dial boilerplate was duplicated, and there was no place for a
second transport's dial (TCP+TLS, iroh) to live without each protocol
client growing its own per-transport dial helper. Those convenience
constructors are removed (see "Relationship to `CallClient` /
`ChannelClient`" below); `AlknetClient` is the single dial home, and
the protocol crates shed their TLS/transport deps entirely.
`AlknetClient` is the dial. It owns the transport-specific work each
outbound connection needs — building a `TlsClientConfig`, constructing
a `quinn::Endpoint`, calling `connect_with`, wrapping as a
`Connection` — for each of three transports (QUIC, TCP+TLS, iroh).
Centralizing the dial in one crate keeps the dial boilerplate in one
place and gives a natural home for a second transport's dial (TCP+TLS,
iroh) without each protocol client growing its own per-transport dial
helper. `AlknetClient` is the single dial home; the protocol crates
(`CallClient`, `ChannelClient`) shed their TLS/transport deps entirely
and take over the `Connection` `AlknetClient` produces (see
"Relationship to `CallClient` / `ChannelClient`" below).
`alknet-client` extracts the dial the same way ADR-083 extracted the
accept loop on the server side: one type that takes pre-built transport
@@ -186,12 +187,13 @@ impl AlknetClient {
/// Iroh dial. Dials on `alpn` via the iroh endpoint. The iroh path
/// does NOT use `TlsClientConfig` — iroh has its own TLS (shares the
/// `Ed25519SecretKey`, not the rustls config — ADR-087 §3, ADR-089
/// §3). The local key is extracted from `creds.local_identity`; the
/// remote `NodeId` is derived from `creds.remote_identity.fingerprint`
/// (`ed25519:<hex>` → `NodeId::from_bytes`). The verifier is iroh's
/// `NodeId` match (fingerprint pin by another name — ADR-034 §3).
/// An unknown iroh remote fails closed (no CA). Feature-gated on
/// `iroh`.
/// §3). The local key is on the pre-built iroh endpoint (set when
/// `with_iroh` configured it); the remote `NodeId` is derived from
/// `creds.remote_identity.fingerprint` (`ed25519:<hex>` →
/// `NodeId::from_bytes`). The verifier is iroh's `NodeId` match
/// (fingerprint pin by another name — ADR-034 §3). An unknown iroh
/// remote fails closed (no CA — `remote_identity` must be `Some`).
/// Feature-gated on `iroh`.
#[cfg(feature = "iroh")]
pub async fn dial_iroh(
&self,
@@ -206,10 +208,10 @@ The two rustls dials (`dial_quic`, `dial_tcp_tls`) share
pin for a known peer, CA-verify for an unknown X.509 remote, fail-closed
for an unknown raw-key remote) and the ADR-084 crypto provider
(`aws_lc_rs`). The iroh dial is the exception: iroh has its own TLS and
takes the `Ed25519SecretKey` directly (extracted from
`creds.local_identity`), not a `rustls::ClientConfig`. The consistency
is in the rule (ADR-034), not in the type — the same exception as the
server side (ADR-082, ADR-087 §3). All three dials take
takes the `Ed25519SecretKey` directly (on the pre-built iroh endpoint,
not extracted from `creds` at dial time), not a `rustls::ClientConfig`.
The consistency is in the rule (ADR-034), not in the type — the same
exception as the server side (ADR-082, ADR-087 §3). All three dials take
`&ConnectionCredentials` — the unified transport-level credential
bundle (ADR-091).
@@ -405,15 +407,12 @@ let conn = client.dial_tcp_tls("hub.example", addr, b"alknet/call", &creds).awai
let call = CallClient::new(registry, idp).spawn_dispatch(conn);
```
The per-protocol QUIC convenience constructors that previously lived on
`CallClient` / `ChannelClient` (`connect` / `connect_quic`) are
**removed**. They welded the dial into the protocol crate — every
`CallClient` user transitively pulled `quinn` + `rustls` + the TLS
verifier machinery, and the convenience constructor's existence made
`alknet-call` / `alknet-channels-call` depend on `alknet-client` (or
duplicate the dial), contradicting the dep graph below. The dial is a
distinct concern from the protocol take-over; `AlknetClient` is the
single home for it. A caller that wants the old one-liner shape composes
The dial is a distinct concern from the protocol take-over;
`AlknetClient` is the single home for it. Keeping the dial off
`CallClient` / `ChannelClient` means every `CallClient` user doesn't
transitively pull `quinn` + `rustls` + the TLS verifier machinery, and
`alknet-call` / `alknet-channels-call` don't depend on `alknet-client`
(or duplicate the dial) — see the dep graph below. A caller composes
two lines: `client.dial_quic(...).await?` then
`CallClient::new(...).spawn_dispatch(conn)` (or
`ChannelClient::from_connection(conn).await?`). See
@@ -422,25 +421,24 @@ two lines: `client.dial_quic(...).await?` then
### Iroh — shares the key, not the config (client side too)
The iroh client dial, like the iroh server side (ADR-082, ADR-087 §3),
does not consume a `rustls::ClientConfig`. It takes the
`Ed25519SecretKey` directly and feeds it to
`iroh::SecretKey::from_bytes`. Iroh handles TLS internally. The
verifier is iroh's `NodeId` match — the remote's `NodeId` (Ed25519
does not consume a `rustls::ClientConfig`. The `Ed25519SecretKey` is set
on the pre-built iroh endpoint at `with_iroh` time (the assembly layer
reads it from `StaticConfig` and feeds it to
`iroh::Endpoint::builder().secret_key()`). The `dial_iroh` method
consumes only `creds.remote_identity` (deriving the remote `NodeId`);
the local key is not in `ConnectionCredentials` for the iroh path — it
is on the endpoint. The dial signature is unified — all three dials
take `&ConnectionCredentials` (ADR-091) — and the iroh dial simply
ignores the `local_identity` field (the key is already on the endpoint).
The verifier is iroh's `NodeId` match — the remote's `NodeId` (Ed25519
public key) is verified against the expected `NodeId`, which is
fingerprint-pinning by another name. An unknown iroh remote fails
closed (no CA to fall back to — ADR-034 §3, Assumption 1).
The `dial_iroh` method extracts the key from
`creds.local_identity` (`ConnectionCredentials`) rather than taking a
separate `Ed25519SecretKey` parameter because the dial signature is
unified — all three dials take `&ConnectionCredentials` (ADR-091). The
assembly layer reads the key from `StaticConfig` (in core) and passes
it via `ConnectionCredentials`, same as the server side's iroh endpoint
construction.
### Non-Rust native clients (out of scope)
The wire protocols (channels 9-byte chunk format — ADR-071; call
The wire protocols (channels 8-byte chunk format — ADR-071, as amended
by ADR-093; call
`EventEnvelope` — ADR-012/064) are language-agnostic. When the endpoint
uses X.509 (the web endpoint type, or a native endpoint with X.509
instead of raw keys), non-Rust native clients (Node/Deno/Bun, Python,
@@ -675,7 +673,7 @@ All design decisions are documented as ADRs in
|-----|----------|---------|
| [089](../../decisions/089-alknetclient-native-dial-seam.md) | AlknetClient — native client dial seam | New crate `alknet-client`; client-side analogue of `AlknetEndpoint`; three dials (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key); resolves OQ-55; `alknet/register` named, wire protocol deferred (§3/§5 amended by ADR-091 — dial takes `ConnectionCredentials`, not `CallCredentials`) |
| [090](../../decisions/090-client-dial-socks5-proxy-seam.md) | Client-Dial SOCKS5 Proxy Seam | `AlknetClient` gains `with_socks5_proxy`; `dial_quic` routes via UDP ASSOCIATE, `dial_tcp_tls` via CONNECT, `dial_iroh` forces relay-only via an HTTP-to-SOCKS5 bridge; OQ-67 resolved; grounded in the quinn-proxy + iroh-proxy PoCs |
| [091](../../decisions/091-connectioncredentials-decouple-dial-from-call.md) | `ConnectionCredentials` — decouple dial from call protocol | The dial credential bundle is `ConnectionCredentials` (transport-level: `local_identity` + `remote_identity`), not `CallCredentials` (call-protocol-level); all three dial signatures unify on `&ConnectionCredentials`; `dial_iroh`'s `node_id` derived from `remote_identity`; `auth_token` is a per-request payload field; `CallCredentials` removed per Am. 2026-07-17 |
| [091](../../decisions/091-connectioncredentials-decouple-dial-from-call.md) | `ConnectionCredentials` — decouple dial from call protocol | The dial credential bundle is `ConnectionCredentials` (transport-level: `local_identity` + `remote_identity`); all three dial signatures unify on `&ConnectionCredentials`; `dial_iroh`'s `node_id` derived from `remote_identity`; `auth_token` is a per-request payload field |
## Open Questions
+11 -12
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-15
last_updated: 2026-07-17
---
# alknet-core
@@ -8,19 +8,18 @@ last_updated: 2026-07-15
Shared types, auth, config, and identity for ALPN-based protocol
dispatch. Every handler crate depends on `alknet-core` for
`ProtocolHandler`, `Connection`, `AuthContext`, `IdentityProvider`, and
config types. The endpoint (`AlknetEndpoint`, `HandlerRegistry`) has
been extracted to [`alknet-endpoint`](../endpoint/README.md) (ADR-083
Amendment 2026-07-15; `EndpointError` is removed — both variants were
vestigial); core no longer carries the accept-loop runner or its
config types. The endpoint (`AlknetEndpoint`, `HandlerRegistry`) lives
in [`alknet-endpoint`](../endpoint/README.md) (ADR-083 Amendment
2026-07-15); core does not carry the accept-loop runner or its
transport deps (quinn, iroh, rcgen, rustls-acme).
`Connection::from_quinn` / `from_iroh` stay in core's `types.rs` as
`Connection::from_quinn` / `from_iroh` are in core's `types.rs` as
shared constructors (gated on core's `quinn` / `iroh` features).
`ConnectionCredentials` and `RemoteIdentity` move to `alknet-core`
(from `alknet-call`, per ADR-091) — the transport-level credential
bundle consumed by the dial (`alknet-client`) and by server-side
transport construction. `ConnectionCredentials` (the transport-level credential
bundle, including `auth_token`) stays in `alknet-call` — the dial does
not carry call-protocol dimensions.
`ConnectionCredentials` and `RemoteIdentity` live in `alknet-core` (per
ADR-091) — the transport-level credential bundle consumed by the dial
(`alknet-client`) and by server-side transport construction. There is no
call-protocol credential bundle; `auth_token` is a per-request payload
field on `call.requested`, not a transport credential.
## Documents
+1 -1
View File
@@ -374,7 +374,7 @@ registration bundle.
|----------|-----|---------|
| ProtocolHandler receives Connection, not BiStream | [ADR-007](../../decisions/007-bistream-type-definition.md) | Handlers that need multiple streams (SSH, call) have direct access to the Connection |
| BiStream is a trait | [ADR-007](../../decisions/007-bistream-type-definition.md) | WASM door preserved, test mocks possible |
| `Connection::from_stream` — generic single-stream connections | [ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `from_stream`/`from_bidi` accept any `AsyncRead + AsyncWrite`; yield-once `accept_bi` contract; unblocks TCP+TLS, SSH channels, WebTransport, wasm; QUIC variants feature-gated, `Stream` variant always available; `MockConnection`/`ConnectionKind::Mock` removed (tests use `from_stream` with `sink`/`empty`) |
| `Connection::from_stream` — generic single-stream connections | [ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `from_stream`/`from_bidi` accept any `AsyncRead + AsyncWrite`; yield-once `accept_bi` contract; unblocks TCP+TLS, SSH channels, WebTransport, wasm; QUIC variants feature-gated, `Stream` variant always available; tests use `from_stream` with `sink`/`empty` |
| `BidiStreamSource` — open `Connection` for extension | [ADR-070](../../decisions/070-bidistreamsource-trait.md) | `Connection` holds `Box<dyn BidiStreamSource>`; QUIC/iroh/stream wrap crate-private impls; `from_source` is the public constructor for downstream crates that implement the trait (channels, future transports); `from_quinn`/`from_iroh`/`from_stream`/`from_bidi` preserved; `close(code, reason)` kept on the trait (non-QUIC impls ignore the args — fixes the ADR-065 leftover clippy warning under `--no-default-features`) |
| HandlerError is non-fatal | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Handler errors close the connection, not the endpoint |
| SendStream/RecvStream wrap quinn + iroh + generic streams | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md), [ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md) | Internal enum dispatch for QUIC sources and the generic `Stream` variant |
+9 -29
View File
@@ -1,45 +1,25 @@
---
status: deprecated
last_updated: 2026-07-15
last_updated: 2026-07-17
---
# Endpoint (moved to `alknet-endpoint`)
# Endpoint (in `alknet-endpoint`)
> **This document is deprecated.** The `AlknetEndpoint` and
> `HandlerRegistry` types have been extracted from `alknet-core` into a
> new crate `alknet-endpoint` (ADR-083 Amendment 2026-07-15).
> `EndpointError` is removed (both variants were vestigial). The
> canonical spec is now
> `HandlerRegistry` types live in a separate crate, `alknet-endpoint`
> (ADR-083 Amendment 2026-07-15). The canonical spec is
> [`crates/endpoint/README.md`](../endpoint/README.md).
>
> The shared types the endpoint imports (`ProtocolHandler`,
> `Connection`, `AuthContext`, `IdentityProvider`, `DynamicConfig`) stay
> `Connection`, `AuthContext`, `IdentityProvider`, `DynamicConfig`) are
> in `alknet-core` — see [`core-types.md`](core-types.md),
> [`auth.md`](auth.md), [`config.md`](config.md).
## Historical summary
## What is in `alknet-core`
The endpoint was originally in `alknet-core/endpoint.rs` as the central
runtime type — a multi-transport accept-loop runner that dispatches
incoming connections by ALPN (ADR-010, ADR-083). ADR-082 extracted the
TLS setup code to `alknet-tls`; ADR-083 restructured the endpoint to
take pre-built transports via `with_quinn` / `with_iroh` /
`with_tcp_tls` (no TLS config); ADR-083 Amendment 2026-07-15 extracted
the endpoint itself into `alknet-endpoint` so that handler crates no
longer transitively link quinn/iroh/rcgen via core.
The endpoint's semantics — ALPN dispatch, `HandlerRegistry`, accept
loops, public `dispatch` for SSH/WT, graceful shutdown — are unchanged
by the extraction. See
[`crates/endpoint/README.md`](../endpoint/README.md) for the current
spec and [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
for the full decision.
## What stayed in `alknet-core`
`Connection::from_quinn` / `from_iroh` stay in core's `types.rs` — they
`Connection::from_quinn` / `from_iroh` are in core's `types.rs` — they
are shared-type constructors used by both the endpoint's accept loop
(server) and `alknet-client`'s dial (client, ADR-089), gated on core's
`quinn` / `iroh` features. See
(server, in `alknet-endpoint`) and `alknet-client`'s dial (client,
ADR-089), gated on core's `quinn` / `iroh` features. See
[ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) §"The
`quinn` feature split".
+25 -48
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-15
last_updated: 2026-07-17
---
# alknet-endpoint
@@ -22,24 +22,22 @@ It does not build transports and does not build TLS configs — the
assembly layer does both (transports from `alknet-tls`'s
`TlsServerConfig`, per ADR-082).
`alknet-endpoint` is extracted from `alknet-core` (ADR-083 Amendment
2026-07-15). The extraction is structural pruning, not a refactor: the
endpoint is a leaf consumer of core's shared types (it imports `auth`,
`config`, `types`; nothing in core imports from it), depended on by a
different audience (the assembly layer) than the shared types (every
handler crate). No handler crate imports `AlknetEndpoint` or
`HandlerRegistry` — they depend on `alknet-core` for
`ProtocolHandler`, `Connection`, `AuthContext`, and types only.
(`EndpointError` is removed — see below.)
`alknet-endpoint` is a leaf consumer of `alknet-core`'s shared types
(it imports `auth`, `config`, `types`; nothing in core imports from
it), depended on by the assembly layer — a different audience than the
shared types (every handler crate). No handler crate imports
`AlknetEndpoint` or `HandlerRegistry` — they depend on `alknet-core`
for `ProtocolHandler`, `Connection`, `AuthContext`, and types only.
This keeps the heavy transport deps (quinn, iroh, tokio-rustls) out of
the handler crates' dep closure.
## Why
`alknet-core` was two things welded: shared types (depended on by every
handler crate) + the endpoint (depended on by zero handler crates).
Extracting the endpoint into `alknet-endpoint` lets core shed the heavy
transport deps (quinn, iroh, rcgen, rustls-acme) and become the
lightweight types+auth+config crate the handler crates actually want.
See [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
Separating the endpoint from the shared-types crate lets `alknet-core`
be the lightweight types+auth+config crate that every handler crate
wants, while the accept-loop runner (which only the assembly layer
depends on) carries the heavy transport deps. See
[ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
§"Amendment 2026-07-15 — crate extraction" for the full rationale,
including the dependency data and the symmetry with `alknet-client`.
@@ -141,26 +139,6 @@ Registration is static at startup (ADR-010, OQ-04). The assembly layer
builds a `HandlerRegistry`, inserts all handlers, and passes it to
`AlknetEndpoint::new()`.
### `EndpointError` — removed
The endpoint previously had an `EndpointError { BindFailed(io::Error),
HandlerNotFound(Vec<u8>) }` enum. Both variants are vestigial after
ADR-083:
- `BindFailed` — the endpoint takes pre-built, pre-bound transports
(the assembly layer does the binding); the endpoint performs no bind,
so it cannot produce a bind error.
- `HandlerNotFound` — `dispatch` swallows no-handler matches (close +
log per ADR-083), so this variant is never returned.
The enum is removed. `shutdown()` is infallible (`async fn shutdown(&self)`,
no `Result`). If a future requirement adds a real failure path to
shutdown or dispatch, a fresh error type is cleaner than retrofitting
this one. The `EndpointError` type, its `TlsConfig` variant (already
removed by ADR-083), and the `BindFailed`/`HandlerNotFound` variants all
move out of the codebase with the endpoint extraction — none survives
into `alknet-endpoint`.
### `TcpTlsListener`
The type held by the endpoint's `tcp_tls` field — a tuple of the TCP
@@ -265,10 +243,8 @@ alknet-endpoint
`alknet-endpoint` depends on `alknet-core` (for `Connection`,
`ProtocolHandler`, `AuthContext`, `IdentityProvider`, `DynamicConfig`).
`HandlerRegistry` lives in `alknet-endpoint` (it moves with the
endpoint from core). `EndpointError` is removed (both variants were
vestigial — see "`EndpointError` — removed" above). The endpoint does
**not** depend on `alknet-tls` — it takes pre-built transports, so TLS
config
endpoint from core). The endpoint does **not** depend on `alknet-tls` —
it takes pre-built transports, so TLS config
construction stays at the assembly layer.
### Crate dependencies (in the dep graph)
@@ -332,15 +308,16 @@ The endpoint takes the pre-built transports; the assembly layer built
them from `alknet-tls`'s `TlsServerConfig`s. The endpoint does not see
`alknet-tls` — it sees `quinn::Endpoint` and `TlsAcceptor`.
## What `alknet-core` looks like after the extraction
## What `alknet-core` looks like
Core loses the endpoint module (~1600 LOC) and 5 heavy deps (`quinn`,
`iroh`, `rcgen`, `rustls-pemfile`, `rustls-acme`). The remaining surface
is the lightweight types+auth+config+ownership+store+fingerprint crate.
See [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
§"Amendment 2026-07-15 — crate extraction" §"What `alknet-core` looks
like after" for the module-level table and the `quinn` feature split
(`Connection::from_quinn` stays in core; the accept loop moves here).
Core is the lightweight types+auth+config+ownership+store+fingerprint
crate (~3200 LOC, no `quinn`/`iroh`/`rcgen`/`rustls-pemfile`/
`rustls-acme` deps). The endpoint module is not in core; the accept
loops are here. See [ADR-083](../../decisions/083-endpoint-as-accept-
loop-runner.md) §"Amendment 2026-07-15 — crate extraction" §"What
`alknet-core` looks like after" for the module-level table and the
`quinn` feature split (`Connection::from_quinn` stays in core; the
accept loop is here).
## Design Decisions
@@ -60,12 +60,11 @@ enabled. It serves two things on a single `h3` connection:
1. **HTTP/3 requests** — the standard HTTP/3 over QUIC framing. An
HTTP/3 request is dispatched through the same axum `Router` as `h2`/
`http/1.1` requests (ADR-042 + ADR-047 — the gateway endpoints are
the sole invoke path; the direct-call `POST /{service}/{op}` surface
was removed). From the axum router's perspective, an HTTP/3 request
is just another HTTP request; the framing difference is handled
below the router. The HTTP/3 request path is the **one-directional
projection** (client→server calls only — HTTP is request/response;
see [http-server.md](http-server.md) §"One-directional projection").
the sole invoke path). From the axum router's perspective, an HTTP/3
request is just another HTTP request; the framing difference is
handled below the router. The HTTP/3 request path is the
**one-directional projection** (client→server calls only — HTTP is
request/response; see [http-server.md](http-server.md) §"One-directional projection").
2. **WebTransport sessions** — the **bidirectional** path. WebTransport
is a transport substrate that carries ALPN protocols as
bidirectional streams (ADR-043), not a browser→hub one-way path. A
+122 -15
View File
@@ -117,7 +117,21 @@ welded to a dial. See "Transport" below.
Lets a browser reach a spoke's channels through the hub without the
hub parsing any protocol-specific framing.
6. **Worker registration** (in scope of the hub) — the HTTP endpoint
6. **Per-identity channel cap** — the hub constructs one
`ChannelLifecyclePolicy` (ADR-094) and shares it across every
channels connection it accepts. This is the cap the hub enforces on
its **inbound** peers (workers and browsers connecting to the hub).
The cap is per-identity, not per-connection — a peer with N
transport connections to the hub is bounded by the cap once, not
N times. The default is 256 per `PeerId`; per-peer-role overrides
(e.g., a lower cap for browser peers) are set via
`with_channel_policy`. The hub-as-caller case (hub dialing a
downstream spoke) is the **spoke's** policy — the spoke constructs
its own policy with a high cap for the hub peer (ADR-094 §5). The
cap is symmetric — both sides of a channels connection enforce
their cap. See "Per-identity channel cap" below.
7. **Worker registration** (in scope of the hub) — the HTTP endpoint
that lets a freshly-provisioned worker enroll its key with a
one-time registration token. The registration flow is what makes
worker provisioning over TCP+TLS a hard requirement, not an
@@ -181,7 +195,8 @@ The hub's `CallClient`-direct dial path is replaced by
### Hub struct
The `Hub` owns the aggregated `PeerCompositeEnv`, the
`OperationRegistry`, and the `Dispatcher`:
`OperationRegistry`, the `Dispatcher`, and the per-identity channel
cap policy:
```rust
pub struct Hub {
@@ -189,6 +204,15 @@ pub struct Hub {
aggregated_env: Arc<RwLock<PeerCompositeEnv>>,
dispatcher: Dispatcher,
identity_provider: Arc<dyn IdentityProvider>,
/// The per-identity channel cap policy (ADR-094). Shared across
/// every channels connection the hub accepts — that is what makes
/// the cap per-identity, not per-connection. Constructed once at
/// Hub::new and passed to ChannelOperations::new for each
/// connection. The hub's browser-leg caps and worker-leg caps are
/// enforced by the same policy (the cap is symmetric — both
/// sides of a channels connection enforce their cap on the other's
/// channels).
channel_policy: Arc<dyn ChannelLifecyclePolicy>,
}
```
@@ -211,15 +235,43 @@ impl Hub {
aggregated_env,
dispatcher,
identity_provider,
channel_policy: Arc::new(PerIdentityChannelPolicy::new(256)),
}
}
/// The shared aggregated PeerCompositeEnv. The assembly layer wires
/// this into CallAdapter::with_aggregated_env so every call's
/// The shared aggregated PeerCompositeEnv. The deployment binary
/// wires this into CallAdapter::with_aggregated_env so every call's
/// compose_root_env sees all connected workers.
pub fn aggregated_env(&self) -> &Arc<RwLock<PeerCompositeEnv>> {
&self.aggregated_env
}
/// The shared per-identity channel cap policy (ADR-094). Wired into
/// `ChannelOperations::new` for every channels connection the hub
/// accepts — this is the cap the hub enforces on its **inbound**
/// peers (workers and browsers connecting to the hub). The policy
/// `Arc` is shared across all the hub's accepted connections, which
/// is what makes the cap per-identity (a peer with N transport
/// connections to the hub is bounded by the cap once, not N times).
/// The hub-as-caller case (hub dialing a downstream spoke) is
/// governed by the **spoke's** policy, not this one — the spoke
/// constructs its own `ChannelLifecyclePolicy` with a high cap for
/// the hub peer (ADR-094 §5). See "Per-identity channel cap" below.
pub fn channel_policy(&self) -> &Arc<dyn ChannelLifecyclePolicy> {
&self.channel_policy
}
/// Override the default per-identity channel cap policy. Builder
/// method for the deployment binary to set per-peer-role caps on
/// the hub's inbound peers (e.g., a worker peer gets 256, a
/// browser peer gets a lower cap). The hub-as-caller case on a
/// downstream spoke is the spoke's own policy, not set here.
pub fn with_channel_policy(mut self, policy: Arc<dyn ChannelLifecyclePolicy>)
-> Self
{
self.channel_policy = policy;
self
}
}
```
@@ -287,8 +339,8 @@ another hub (A) is a client from A's perspective. The dial needs a
client-side TLS config (`TlsClientConfig`, ADR-087) for the outbound
connection's `rustls::ClientConfig` (verifier selection per ADR-034:
fingerprint pin for the worker's known key). The dial path mirrors the
`from_connection` primary (ADR-080; `ChannelClient::connect_quic` is
removed per ADR-089 §5 — the dial lives in `AlknetClient`):
`from_connection` primary (ADR-080; the dial lives in `AlknetClient`,
ADR-089):
```rust
impl Hub {
@@ -296,9 +348,8 @@ impl Hub {
/// connection. Transport-agnostic — the caller (or a transport
/// helper) produces the `Connection`. This is the primary path;
/// `connect_quic_worker` (a hub-level convenience, distinct from
/// the removed `ChannelClient::connect_quic` per-protocol
/// constructor — ADR-089 §5) and future `connect_tcp_tls_worker`
/// are conveniences over it.
/// the per-protocol dial in `AlknetClient`) and future
/// `connect_tcp_tls_worker` are conveniences over it.
pub async fn dial_worker_connection(
&self,
connection: Connection,
@@ -411,12 +462,16 @@ via a builder method. The `ChannelsAdapter::handle` flow becomes:
aggregated env.
The assembly layer constructs the callback and passes it to
`ChannelsAdapter`:
`ChannelsAdapter`, wiring the hub's per-identity channel cap policy
(ADR-094) into `ChannelOperations::new` so every channels connection
the hub accepts shares the same policy (the cap is per-identity, not
per-connection, because the policy `Arc` is shared):
```rust
let callback = WorkerConnectedCallback::new(Arc::clone(&hub), FromCallConfig::new());
let channels_adapter = ChannelsAdapter::new(Arc::clone(&registry), /* ... */)
.with_worker_connected_callback(callback);
.with_worker_connected_callback(callback)
.with_channel_policy(hub.channel_policy().clone());
// Register channels_adapter on alknet/channels in the HandlerRegistry.
// The endpoint dispatches alknet/channels connections to it — whether
// they arrived over quinn, iroh, or TCP+TLS (all owned by the endpoint).
@@ -595,6 +650,46 @@ handlers (`alknet/tty`, `alknet/ssh`, `alknet/tunnel`) — it runs
translation). The full relay contract is in ADR-079; the relay
implementation lives in `alknet-hub`.
### Per-identity channel cap (ADR-094)
A channel slot is a resource. The cap on how many channels a peer may
hold open against the hub is a quota check on that resource — parallel
to `OwnershipProvider::owns` (ADR-050) for spawned resources. The hub
constructs one `ChannelLifecyclePolicy` and shares it across every
channels connection it accepts (the policy `Arc` is shared, so the
cap is per-identity, not per-connection). This is the cap the hub
enforces on its **inbound** peers — workers and browsers connecting
to the hub. The default is `PerIdentityChannelPolicy::new(256)` — 256
per `PeerId` across all the peer's connections to the hub. The cap is
symmetric — both sides of a channels connection enforce their cap on
the other's channels.
The cap lives in `channels-call`, not `channels-core`, because the
channels layer is auth-blind by design (ADR-075 — that is what makes
it WASM-compatible, transport-agnostic, and ALPN-blind). The identity
is on `OperationContext`; the `channel/open` handler consults the
policy after `AccessControl::check` and before allocation; the
`channel/close` handler decrements after the drain completes.
**Relay consequence (ADR-094 §5):** when the hub relays a browser's
channel to a spoke, the spoke sees the hub as the direct caller
(ADR-032 — `forwarded_for` is metadata, not authority, for the cap as
for `AccessControl::check`). The spoke's cap applies to the hub, not
the browser. A spoke that serves a hub relaying for many browsers
must set the hub peer's cap higher than a worker peer's cap on the
**spoke's own** `ChannelLifecyclePolicy`, or the spoke denies
legitimate relayed channels when the hub's aggregate count exceeds a
worker-sized cap. This is a per-peer-role policy on the spoke, not on
the hub — the hub's `channel_policy` governs the hub's inbound peers,
not the hub-as-caller case. The hub enforces per-browser caps on the
browser leg (the hub's own policy); the spoke enforces per-hub caps
on the spoke leg (the spoke's own policy). Same shape as any per-peer
ACL.
See [ADR-094](../../decisions/094-per-identity-channel-cap.md) for
the full decision, the trait, the default/opt-out variants, and the
recursive-channels edge case.
### Service discovery
The hub registers the built-in service discovery operations
@@ -791,7 +886,7 @@ into `CallAdapter::with_aggregated_env`.
| Peer-graph routing model | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays, `PeerRef` routing, `AccessControl`-based peer auth |
| PeerEntry and Identity.id | [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | `PeerId` = `Identity.id` = `PeerEntry.peer_id` (stable) |
| Three peer roles | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Hub = role-3 `PeerEntry` (mixed fingerprints); browsers not peers; bearer-token identity over TCP/WebTransport |
| ChannelClient — transport-agnostic | [ADR-080](../../decisions/080-channelclient.md) | `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); the dial path the hub uses |
| ChannelClient — transport-agnostic | [ADR-080](../../decisions/080-channelclient.md) | `from_connection` primary; dial in `AlknetClient` (ADR-089) — the dial path the hub uses |
| Channels transport-agnostic | [ADR-071](../../decisions/071-channels-wire-format.md) | Substrate modes; `Connection::from_stream`/`from_bidi` (ADR-065) — the substrate the hub relays |
| TCP+TLS as first-class owned transport | [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) | `with_tcp_tls(listener, acceptor)` — TCP+TLS is owned by the endpoint, not a sibling loop; supersedes ADR-010 Am. 1 |
| Channel 0 pre-negotiated | [ADR-072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 = `alknet/call`; the `CallAdapter` runs here |
@@ -799,6 +894,7 @@ into `CallAdapter::with_aggregated_env`.
| Endpoint types and entry points | [ADR-086](../../decisions/086-endpoint-types-and-entry-points.md) | Three endpoint types (web/native/iroh); entry-point vs. endpoint ALPN distinction; split ALPN lists per endpoint type |
| `TlsClientConfig` for outbound dials | [ADR-087](../../decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `alknet-tls` provides client-side TLS config; hub-as-client is a first-class use case; not blocked on the dial-seam extraction (OQ-55) |
| `AlknetClient` native dial seam | [ADR-089](../../decisions/089-alknetclient-native-dial-seam.md) | New crate `alknet-client`; the hub's outbound worker dials use `AlknetClient` (via the `supervise_worker` closure or the `connect_quic_worker` convenience); resolves OQ-55 |
| Per-identity channel cap | [ADR-094](../../decisions/094-per-identity-channel-cap.md) | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; the hub's policy governs its inbound peers and is shared across all their connections; the hub-as-caller case on a downstream spoke is the spoke's own policy with a high cap for the hub peer (ADR-094 §5) |
## Open Questions
@@ -814,11 +910,16 @@ See [open-questions.md](../../open-questions.md) for full details.
ALPN would serve the same role over QUIC/TCP without HTTP.
- **OQ-65** (open): WebSocket carrying channels — whether the browser
path extends from call-protocol-only (ADR-048) to full channels
(the 9-byte chunk format over WebSocket binary frames). If chosen,
(the 8-byte chunk format over WebSocket binary frames). If chosen,
the browser is a first-class channels participant and the hub relay
works unchanged for browser legs. The web endpoint advertises
`alknet/channels` by default (ADR-086 §3 — the advertisement is
settled; OQ-65 governs whether the browser path uses it).
- **OQ-68** (open): Channels 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* is decided (ADR-093);
the *function surface* is not. Does not block the hub (the hub uses
the `ChannelManager` interface either way).
- **OQ-52** (open): `CallConnection::wait_for_close()` — the
supervision loop needs a way to await connection close. The
committed interim is polling `connection().accept_bi()` until
@@ -836,8 +937,7 @@ See [open-questions.md](../../open-questions.md) for full details.
## References
- [channel-client.md](../channels/channel-client.md) — `ChannelClient`
(`from_connection` — the take-over; `connect_quic` removed per
ADR-089 §5, dial now via `AlknetClient`)
(`from_connection` — the take-over; dial via `AlknetClient` per ADR-089)
- [channels-adapter.md](../channels/channels-adapter.md) —
`ChannelsAdapter`, `ChannelManager`, the accept path
- [channel-operations.md](../channels/channel-operations.md) —
@@ -854,16 +954,23 @@ See [open-questions.md](../../open-questions.md) for full details.
`resolve_from_fingerprint` (the identity paths over transports)
- ADR-029: Peer-Graph Routing Model
- ADR-034: Three Peer Roles (hub = role-3, bearer-token identity)
- ADR-050: Dynamic Resource Ownership (the parallel for the channel cap —
a channel slot is a resource, the cap is a quota check)
- ADR-065: `Connection::from_stream`/`from_bidi` (TCP+TLS path)
- ADR-067: Aggregated Peer-Environment Wiring
- ADR-068: PeerCompositeEnv::peer_operations Override
- ADR-069: from_call Is a Manual Free Function
- ADR-075: ChannelsAdapter and ChannelManager (the auth-blindness that
forces the per-identity cap into `channels-call`, not `channels-core`)
- ADR-079: Hub Relay — Translate, Not Transparently Forward
- ADR-080: ChannelClient (transport-agnostic `from_connection`)
- ADR-082: alknet-tls extraction (`TlsServerConfig` — shared across quinn + TCP+TLS)
- ADR-083: Endpoint as multi-transport accept-loop runner (`with_tcp_tls` — TCP+TLS owned by the endpoint; the hub composes transports and handlers)
- ADR-086: Endpoint types and entry points (web/native/iroh; entry-point vs. endpoint; split ALPN lists per endpoint type)
- ADR-087: `TlsClientConfig` not blocked on dial seam (client-side TLS config; hub-as-client requirement)
- ADR-094: Per-Identity Channel Cap (the `ChannelLifecyclePolicy` the hub
constructs and shares across all its channels connections; the relay
consequence for hub-as-caller on downstream spokes)
- alkapi [hub.md](/workspace/@alkdev/alkapi/docs/architecture/hub.md) —
the first hub consumer, the concrete use case that informed this
crate
+204 -246
View File
@@ -1,6 +1,6 @@
---
status: reviewed
last_updated: 2026-07-15
last_updated: 2026-07-17
---
# alknet-tls
@@ -17,16 +17,9 @@ one verifier rule, N clients.
## What
`alknet-tls` extracts the TLS setup that was welded to the quinn endpoint
in `alknet-core`. The existing code (`endpoint.rs`) builds a
`rustls::ServerConfig` from a `TlsIdentity`, then **consumes** it into a
`quinn::ServerConfig` — making it impossible to reuse the same cert for a
TCP+TLS listener. ACME is worse: the `AcmeState` task is spawned inside
the quinn endpoint, so a TCP+TLS listener would need its own ACME state
machine (two orders for the same domain, two cert caches, potential
Let's Encrypt rate-limiting).
`alknet-tls` fixes this by making the TLS config **shareable**:
`alknet-tls` provides `TlsServerConfig` and `TlsClientConfig` —
shareable TLS setup types that a deployment builds once and hands to
whichever transports it runs:
```rust
pub struct TlsServerConfig {
@@ -64,15 +57,17 @@ transports.
## Why
`alknet-core` builds the `rustls::ServerConfig` once, then consumes it
into a `quinn::ServerConfig` — making the cert unreusable for a TCP+TLS
listener. For ACME the problem is worse: the `AcmeState` task is spawned
inside the quinn endpoint, so a TCP+TLS listener would need a second ACME
state machine for the same domain (duplicate orders, divergent cert
caches, Let's Encrypt rate-limit risk). The full rationale, including
the cert-reuse problem, the ACME worst case, and the three reasons a
separate crate is the right shape (dependency isolation, ACME weight,
quinn/iroh having their own TLS), is in
Without a shareable TLS config, a `rustls::ServerConfig` built for one
transport gets consumed into that transport's wrapper (e.g.
`quinn::ServerConfig`), making the cert unreusable for a TCP+TLS
listener. For ACME the problem is worse: the `AcmeState` task spawned
inside the quinn endpoint means a TCP+TLS listener would need a second
ACME state machine for the same domain (duplicate orders, divergent cert
caches, Let's Encrypt rate-limit risk). `alknet-tls` isolates TLS setup
from the transport so one config serves all transports. The full
rationale, including the cert-reuse problem, the ACME worst case, and
the three reasons a separate crate is the right shape (dependency
isolation, ACME weight, quinn/iroh having their own TLS), is in
[ADR-082](../../decisions/082-alknet-tls-extraction.md).
### The three endpoint types (ADR-086)
@@ -107,64 +102,73 @@ transports the deployment runs.
## Architecture
### What moves from `alknet-core` to `alknet-tls` (server side)
### Server-side contents
| Component | Current location | New location |
|-----------|-----------------|-------------|
| `TlsIdentity` enum | `alknet-core/config.rs` | **stays in core** (it's a config type) |
| `Ed25519SecretKey` | `alknet-core/config.rs` | **stays in core** (config type) |
| `build_rustls_server_config()` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` (unconditional) |
| `build_quinn_server_config_from_rustls()` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` (`for_quinn()` — wraps rustls config in `QuicServerConfig`) |
| `TlsSetup` (ACME state machine) | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` (the `TlsServerConfig::new` ACME path) |
| `RawKeyCertResolver` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` |
| `Ed25519SigningKey` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` (consolidates with the `alknet-call` duplicate — see client table) |
| `AcceptAnyCertVerifier` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` |
| `SelfSignedCert` / `generate_self_signed_cert()` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` |
| `load_cert_chain()` / `load_private_key()` | `alknet-core/endpoint.rs` | `alknet-tls` (consolidates with the `alknet-call` duplicate — see client table) |
| `fingerprint.rs` | `alknet-core/fingerprint.rs` | **stays in core** (shared by server + client; the client-side `FingerprintPinVerifier` is now in `alknet-tls` per ADR-089 §5, so both consumers are co-located; production code uses `sha2` + manual DER only — `rustls` is test-only. See OQ-59 — the original dep-edge concern that motivated keeping `fingerprint.rs` in core is dissolved by ADR-089 §5.) |
The server-side TLS setup — `rustls::ServerConfig` construction, cert
resolvers, the ACME state machine — is in `alknet-tls/src/server.rs`.
These components were originally part of `alknet-core`'s endpoint
module (quinn-gated); ADR-082 moved them into `alknet-tls` so a
`TlsServerConfig` is shareable across transports rather than consumed
into a single transport's wrapper.
### What moves from `alknet-call` to `alknet-tls` (client side)
| Component | Notes |
|-----------|-------|
| `TlsServerConfig` | The central type — wraps `rustls::ServerConfig` + the optional ACME task handle |
| `build_rustls_server_config()` | Unconditional; called by `TlsServerConfig::new` |
| `for_quinn()` | Wraps the rustls config in a `QuicServerConfig` (feature-gated on `quinn`) |
| `TlsSetup` / ACME path | The `TlsServerConfig::new` ACME branch spawns the state-machine task |
| `RawKeyCertResolver` | Presents an Ed25519 key as an RFC 7250 raw public key server cert |
| `Ed25519SigningKey` | One copy in `alknet-tls`, shared by server + client (see below) |
| `AcceptAnyCertVerifier` | Accepts any client cert and extracts the fingerprint (raw-key servers don't pin client certs) |
| `SelfSignedCert` / `generate_self_signed_cert()` | The dev `SelfSigned` identity path |
| `load_cert_chain()` / `load_private_key()` | In `pem.rs`; one copy, shared by server + client |
`TlsClientConfig::new` (ADR-087) centralizes the client-side verifier
selection + provider wiring + client-auth cert presentation that
currently lives in `alknet-call/src/client/call_client.rs`. The
extraction is the client-side analogue of the server-side
`endpoint.rs` extraction above.
| Component | Current location | New location |
|-----------|-----------------|-------------|
| `build_quinn_client_config()` | `alknet-call/client/call_client.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` (`TlsClientConfig::new` + `for_quinn()`) |
| `build_client_auth()` | `alknet-call/client/call_client.rs` | `alknet-tls` (client-auth cert resolver construction inside `TlsClientConfig::new`) |
| `select_server_verifier()` | `alknet-call/client/call_client.rs` | `alknet-tls` (ADR-034 verifier selection inside `TlsClientConfig::new`) |
| `load_platform_root_cert_store()` | `alknet-call/client/call_client.rs` | `alknet-tls` (the unknown-X.509-remote CA path inside `TlsClientConfig::new`) |
| `FingerprintPinVerifier` | `alknet-call/client/call_client.rs` | `alknet-tls` (moved — it is a TLS concern; `TlsClientConfig::new` constructs it; moving it lets `alknet-call` shed its direct `rustls` dep entirely per ADR-089 §5) |
| `Ed25519SigningKey` (client-side copy) | `alknet-call/client/call_client.rs` | `alknet-tls` (consolidates with the `endpoint.rs` duplicate — one copy in `alknet-tls`) |
| `RawKeyClientCertResolver` | `alknet-call/client/call_client.rs` | `alknet-tls` |
| `NoClientCertResolver` | `alknet-call/client/call_client.rs` | `alknet-tls` |
| `load_cert_chain()` / `load_private_key()` (client-side copies) | `alknet-call/client/call_client.rs` | `alknet-tls` (consolidates with the `endpoint.rs` duplicate — one copy in `alknet-tls`) |
| `CallClient::connect` | `alknet-call/client/call_client.rs` | **removed** (ADR-089 §5 — the dial is extracted to `AlknetClient`; `CallClient` keeps only `spawn_dispatch`, shedding its TLS/transport deps) |
**Consolidation note.** `Ed25519SigningKey` and
`load_cert_chain`/`load_private_key` are currently **duplicated** across
`endpoint.rs` (server) and `call_client.rs` (client). After extraction
there is one copy of each in `alknet-tls`, used by both
`TlsServerConfig::new` and `TlsClientConfig::new`. Both call sites
(`endpoint.rs`'s server path, `call_client.rs`'s client path) are
updated to import from `alknet-tls`.
`TlsIdentity` and `Ed25519SecretKey` stay in core because they're config
types — `StaticConfig` holds a `TlsIdentity`, and config types belong in
core. `alknet-tls` re-exports them for convenience. `fingerprint.rs` stays
in core because it's shared by both the server path (endpoint extracts
fingerprint from the client cert) and the client path
(`FingerprintPinVerifier` — now in `alknet-tls` per ADR-089 §5 —
matches the server's cert against a pinned fingerprint).
The production code in `fingerprint.rs` uses only `sha2` and manual DER
parsing — the `rustls::sign` usage is in the test helper only. See OQ-59
(the original dep-edge concern that motivated keeping `fingerprint.rs`
in core is dissolved by ADR-089 §5 — `FingerprintPinVerifier` moved to
The config types `TlsIdentity` and `Ed25519SecretKey` live in
`alknet-core` (`config.rs`) — `StaticConfig` holds a `TlsIdentity`, and
config types belong in core. `alknet-tls` imports them. `fingerprint.rs`
lives in core because it is shared by both the server path (the
endpoint extracts the fingerprint from the client cert) and the client
path (`FingerprintPinVerifier`, in `alknet-tls`, matches the server's
cert against a pinned fingerprint). The production code in
`fingerprint.rs` uses only `sha2` and manual DER parsing; the
`rustls::sign` usage is in the test helper only. See OQ-59 — the
original dep-edge concern that motivated keeping `fingerprint.rs` in
core is dissolved by ADR-089 §5 (`FingerprintPinVerifier` is in
`alknet-tls`, so its consumers are co-located).
### Client-side contents
The client-side TLS setup — verifier selection, client-auth cert
presentation, provider wiring — is in `alknet-tls/src/client.rs`.
These components were originally part of `alknet-call`'s client
module (quinn-gated); ADR-087 / ADR-089 §5 moved them into `alknet-tls`
so `alknet-call` has no direct `rustls` dep and the verifier selection
is shared across all outbound dials.
| Component | Notes |
|-----------|-------|
| `TlsClientConfig::new` | Builds a `rustls::ClientConfig` from `ConnectionCredentials` + ALPN; runs ADR-034 verifier selection + ADR-084 provider wiring + client-auth cert presentation |
| `for_quinn()` | Wraps the rustls config in a `quinn::ClientConfig` (feature-gated on `quinn`) |
| `into_rustls_config()` | Returns the inner `rustls::ClientConfig` for consumers that build their own transport wrapper (e.g. `dial_tcp_tls` wraps it in a `TlsConnector`) |
| `build_client_auth()` | Constructs the client-auth cert resolver inside `TlsClientConfig::new` |
| `select_server_verifier()` | ADR-034 verifier selection (fingerprint pin / CA / fail-closed) inside `TlsClientConfig::new` |
| `load_platform_root_cert_store()` | The unknown-X.509-remote CA path inside `TlsClientConfig::new` |
| `FingerprintPinVerifier` | A TLS concern; `TlsClientConfig::new` constructs it. Locating it in `alknet-tls` lets `alknet-call` have no direct `rustls` dep (ADR-089 §5) |
| `RawKeyClientCertResolver` | Presents the local key as an RFC 7250 raw public key client cert |
| `NoClientCertResolver` | The no-client-cert path |
| `Ed25519SigningKey` | One copy in `alknet-tls` (`signing.rs`), shared by server + client |
| `load_cert_chain()` / `load_private_key()` | In `pem.rs`; one copy, shared by server + client |
`Ed25519SigningKey` and `load_cert_chain`/`load_private_key` are single
copies in `alknet-tls`, used by both `TlsServerConfig::new` and
`TlsClientConfig::new`. Before the extraction these were duplicated
across the server (in core's endpoint module) and the client (in call's
client module); the extraction consolidated them.
The dial is in `AlknetClient` (`alknet-client`, ADR-089);
`CallClient` keeps only `spawn_dispatch`, and `alknet-call` has no
TLS/transport deps.
### `TlsServerConfig`
The central type. Built once from a `TlsIdentity` + ALPN list, shared
@@ -230,12 +234,12 @@ architecture decision.
### Behavior-preservation invariants
The extraction must preserve these load-bearing TLS behaviors. They
originate from [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md),
These load-bearing TLS behaviors must be preserved. They originate from
[ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md),
which established the `TlsIdentity` model, the `Acme` variant, and the
`acme-tls/1` ALPN challenge handling. An implementer who omits any of
these produces a crate that compiles and passes type-checks but silently
changes TLS behavior:
`acme-tls/1` ALPN challenge handling. Omitting any of them produces a
crate that compiles and passes type-checks but silently changes TLS
behavior:
- **`max_early_data_size = u32::MAX`** on all server config paths (X509,
RawKey, SelfSigned, ACME). Enables 0-RTT / early data. Omitting it
@@ -278,8 +282,8 @@ impl TlsServerConfig {
/// build their own transport-specific wrapper not covered by
/// `for_quinn` / `for_tcp_tls`. No current consumer (iroh reads the
/// `Ed25519SecretKey` directly, not the rustls config — see "Iroh:
/// shares the key, not the rustls config" below); kept as a
/// forward-looking accessor for future transport wrappers.
/// shares the key, not the rustls config" below); retained for
/// transport wrappers that do not fit `for_quinn` / `for_tcp_tls`.
pub fn rustls_config(&self) -> &rustls::ServerConfig;
}
```
@@ -291,7 +295,7 @@ the `Endpoint`, using RFC 7250 raw keys. It does not consume a
`rustls::ServerConfig` — it takes an `iroh::SecretKey` and handles TLS
internally. So `alknet-tls` does not have a `for_iroh()` method. Instead,
the assembly layer reads the `Ed25519SecretKey` from `StaticConfig`
(stays in core) and passes it to iroh's `Endpoint::builder().secret_key()`
(lives in core) and passes it to iroh's `Endpoint::builder().secret_key()`
directly. `alknet-tls` is involved only when iroh is not the sole
transport — in that case, the same `Ed25519SecretKey` feeds both
`TlsServerConfig::new(TlsIdentity::RawKey(key), ...)` (for quinn/TCP) and
@@ -346,22 +350,20 @@ alknet-tls
`rustls-native-certs` and `webpki-roots` are always-present deps (not
feature-gated) because the unknown-X.509-remote CA-verification path in
`TlsClientConfig::new` is needed by any client dialing a public X.509
endpoint, regardless of transport (QUIC or TCP+TLS). In the
pre-extraction code these lived in `alknet-call` behind the `quinn`
feature; the extraction (ADR-089 §5) moves them to `alknet-tls` ungated,
and `alknet-call` sheds the deps entirely.
endpoint, regardless of transport (QUIC or TCP+TLS). They are not gated
under `quinn`/`tcp` — a TCP+TLS-only or QUIC-only deployment both need
the CA path. `alknet-call` does not depend on them (the dial's TLS
deps are in `alknet-tls`/`alknet-client` now).
`alknet-core` loses `rustls-pemfile`, `rcgen`, and `rustls-acme` from
its dependencies — the cert-loading, self-signed generation, and ACME
machinery move to `alknet-tls`. Core's `acme` feature
(`acme = ["dep:rustls-acme"]` in `Cargo.toml` and the
`#[cfg(feature = "acme")]` gates on `acme_state_handle` in `endpoint.rs`)
becomes vestigial after the extraction and is removed — the ACME state
machine now lives on `TlsServerConfig` in `alknet-tls`, not on
`AlknetEndpoint`. Core keeps `quinn` and `iroh` (the endpoint struct and
accept loops remain in core), `ed25519-dalek` (`Ed25519SecretKey` stays
in `config.rs`), and `rustls` / `rustls-pki-types` (`fingerprint.rs` uses
`rustls::pki_types` in production and `rustls::sign` in the test helper
`alknet-core` does not depend on `rustls-pemfile`, `rcgen`, or
`rustls-acme` — cert-loading, self-signed generation, and the ACME
state machine are in `alknet-tls` (on `TlsServerConfig`, not on
`AlknetEndpoint`). Core has no `acme` feature. Core does keep `quinn`
and `iroh` (for `Connection::from_quinn` / `from_iroh` — the shared
constructors the endpoint and the dial both use),
`ed25519-dalek` (`Ed25519SecretKey` in `config.rs`), and `rustls` /
`rustls-pki-types` (`fingerprint.rs` uses `rustls::pki_types` in
production and `rustls::sign` in the test helper
`build_ed25519_spki_der` — see OQ-59).
> **Terminology — hub, worker, hub-worker.** A *hub* is a node that
@@ -374,47 +376,11 @@ in `config.rs`), and `rustls` / `rustls-pki-types` (`fingerprint.rs` uses
> "assembly layer" (ADR-014) is the deployment binary that wires crates
> — in practice, today, usually a hub or hub-worker.
### Implementation ordering
### What `AlknetEndpoint` (in `alknet-endpoint`) does
`alknet-tls` is greenfield — `crates/alknet-tls` does not exist yet. The
endpoint section below ("What `AlknetEndpoint` does after the refactor")
describes the **post-refactor target**, not the current source. The
current `crates/alknet-core/src/endpoint.rs` is the **extraction
source** — `AlknetEndpoint::new(static_config, ...)` builds TLS
internally, the shape ADR-083 replaces. The endpoint is extracted into
a new crate `alknet-endpoint` (ADR-083 Amendment 2026-07-15) as part of
this work. The extraction and refactor are **sequenced**, not
simultaneous:
1. **`alknet-tls` first** — build the crate in isolation. `TlsServerConfig`
and `TlsClientConfig` are unit-testable against `TlsIdentity` without
touching the endpoint. This is the greenfield step.
2. **`alknet-endpoint` second** — build the new endpoint crate fresh
against the ADR-083 shape (`new(handlers, dynamic,
identity_provider, drain_timeout)` + `with_quinn` / `with_iroh` /
`with_tcp_tls`), importing `Connection`/`ProtocolHandler`/`AuthContext`
from `alknet-core` and taking pre-built transports (no TLS config —
the assembly layer builds those via `alknet-tls`). The old
`crates/alknet-core/src/endpoint.rs` is deleted.
3. **Assembly layer last** — the deployment binary (hub/worker) builds
the `TlsServerConfig`(s) and `TlsClientConfig`(s), the transports, and
hands them to `AlknetEndpoint` (in `alknet-endpoint`) via the builder
methods.
A compilable intermediate state exists after step 1: `alknet-tls` built
and tested standalone, with `endpoint.rs` still in its old shape. The
call sites for `TlsServerConfig` / `TlsClientConfig` do not exist until
step 2/3 — an implementer testing step 1 writes tests against the TLS
types directly, not against a wired-up endpoint.
### What `AlknetEndpoint` (in `alknet-endpoint`) does after the refactor
`AlknetEndpoint::new()` currently builds `TlsSetup` internally. After
the refactor (see [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)),
the endpoint (extracted into `alknet-endpoint` per ADR-083 Amendment
2026-07-15) takes **no TLS config at all** — it is a multi-transport
accept-loop runner. TCP+TLS is an owned transport (via `with_tcp_tls`),
not an external loop:
`AlknetEndpoint` takes **no TLS config at all** — it is a
multi-transport accept-loop runner. TCP+TLS is an owned transport (via
`with_tcp_tls`), not an external loop:
```rust
impl AlknetEndpoint {
@@ -466,7 +432,9 @@ handle lives on the `TlsServerConfig`, not the endpoint.
This resolves the single-`Arc<TlsServerConfig>` problem: the endpoint
has no "the TLS config" to take because a hub has two. It also means
shutdown is single-owner — the endpoint owns all its accept loops
(quinn, iroh, TCP+TLS); one `shutdown()` stops them all.
(quinn, iroh, TCP+TLS); one `shutdown()` stops them all. See
[`crates/endpoint/README.md`](../endpoint/README.md) and
[ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md).
### The TCP+TLS accept loop (out of scope for this crate)
@@ -485,26 +453,25 @@ sharing, not transport accept logic.
A hub dials out to workers it supervises and to other hubs
(hub-as-client); `alknet-worker` dials a hub. Both need a
`rustls::ClientConfig` with ADR-034's verifier selection and ADR-084's
crypto provider. `TlsClientConfig` centralizes this — it is a
present prerequisite for the first hub deployment, consumed by
crypto provider. `TlsClientConfig` centralizes this, and is consumed by
`AlknetClient`'s QUIC and TCP+TLS dials (ADR-089).
There are exactly two clients in the alknet client surface as far as
`TlsClientConfig` and `AlknetClient` are concerned — **call**
(`CallClient`) and **channels** (`ChannelClient`, which is a proxy over
many ALPNs via channel 0). Both must support all three transport
accessors below; the TLS config is shared across them, the dial is
per-transport per-client.
many ALPNs via channel 0). Both share `TlsClientConfig` via the dial;
the TLS config is shared across them, the dial is per-transport
per-client.
```rust
pub struct TlsClientConfig {
config: rustls::ClientConfig,
rustls_config: rustls::ClientConfig,
}
impl TlsClientConfig {
/// Build a client TLS config. Takes two inputs, both derived from
/// `Capabilities` (ADR-014) / `ConnectionCredentials`-shaped values
/// (ADR-091):
/// Build a client TLS config from `ConnectionCredentials` and the
/// dial's ALPN. `ConnectionCredentials` (ADR-091, in `alknet-core`)
/// carries the two dimensions the dial consumes:
///
/// 1. `local_identity` — the local node's `TlsIdentity` (RFC 7250
/// raw key or X.509), presented as the client cert. `None` →
@@ -512,59 +479,48 @@ impl TlsClientConfig {
/// `SelfSigned` → no client cert (dev-only). `Acme` →
/// `TlsError::AcmeConfig` (server-only identity).
///
/// 2. `verifier_context` — the inputs to ADR-034's server-cert
/// 2. `remote_identity` — the inputs to ADR-034's server-cert
/// verifier selection:
/// - known peer (PeerEntry present) → fingerprint pin
/// (FingerprintPinVerifier)
/// - unknown remote + X.509 → CA verification
/// (WebPkiServerVerifier)
/// - unknown remote + raw key → fail closed at handshake (not
/// a `new`-time error; see ADR-088 §6)
/// - `Some(fingerprint)` (known peer, `PeerEntry` present) →
/// fingerprint pin (`FingerprintPinVerifier`)
/// - `None` + X.509 transport → CA verification
/// (`WebPkiServerVerifier`)
/// - `None` + raw key → fail closed at handshake (not a `new`-
/// time error; see ADR-088 §6)
///
/// Applies ADR-084 crypto provider (aws_lc_rs::default_provider()).
pub fn new(
local_identity: &Option<TlsIdentity>,
verifier_context: &ClientVerifierContext,
credentials: &ConnectionCredentials,
alpn: &[u8],
) -> Result<Self, TlsError>;
/// Produce a `quinn::ClientConfig` for a QUIC dial. Clones the
/// inner rustls config, wraps it in `QuicClientConfig`. Returns
/// `Result` because `QuicClientConfig::try_from(rustls::ClientConfig)`
/// can fail with `NoInitialCipherSuite` — the same failure the
/// server-side `for_quinn()` surfaces as `TlsError::QuinnWrap`.
/// Feature-gated on `quinn`.
/// Consume the config and produce a `quinn::ClientConfig` for a
/// QUIC dial. Returns `Result` because
/// `QuicClientConfig::try_from(rustls::ClientConfig)` can fail with
/// `NoInitialCipherSuite` — the same failure the server-side
/// `for_quinn()` surfaces as `TlsError::QuinnWrap`. Feature-gated
/// on `quinn`.
#[cfg(feature = "quinn")]
pub fn for_quinn(&self) -> Result<quinn::ClientConfig, TlsError>;
pub fn for_quinn(self) -> Result<quinn::ClientConfig, TlsError>;
/// Produce a `tokio_rustls::TlsConnector` for a TCP+TLS dial.
/// Clones the inner rustls config. Infallible —
/// `TlsConnector::new(rustls::ClientConfig)` cannot fail.
/// Feature-gated on `tcp` (pulls `tokio-rustls`).
#[cfg(feature = "tcp")]
pub fn for_tcp_tls(&self) -> tokio_rustls::TlsConnector;
/// Borrow the underlying rustls config, for consumers that need to
/// build their own transport-specific wrapper not covered by
/// `for_quinn` / `for_tcp_tls` (e.g. a future transport). Not
/// feature-gated — returns the raw rustls config, not a
/// transport-specific wrapper.
pub fn rustls_config(&self) -> &rustls::ClientConfig;
/// Consume the config and return the inner `rustls::ClientConfig`,
/// for consumers that build their own transport-specific wrapper —
/// e.g. `dial_tcp_tls` wraps it in a
/// `tokio_rustls::TlsConnector::from(Arc::new(rustls_config))`. Not
/// feature-gated; the raw rustls config is transport-agnostic.
pub fn into_rustls_config(self) -> rustls::ClientConfig;
}
```
The `ClientVerifierContext` carries the inputs to ADR-034's verifier
selection (whether a `PeerEntry` exists for the remote, the expected
fingerprint). The exact struct shape is an implementation detail; the
decisions are in ADR-034. `ClientVerifierContext` is derived from
`ConnectionCredentials` (in `alknet-core`, per ADR-091) at the dial
site — `AlknetClient` extracts the TLS-relevant fields
(`local_identity` → client cert, `remote_identity` → fingerprint-pin
input) and builds a `ClientVerifierContext` from the latter. The
call-protocol `auth_token` is not in `ConnectionCredentials` — it is a
per-request field on `call.requested` payloads (a call-protocol / hub
concept), not a transport credential; it never reaches `TlsClientConfig`
or `ClientVerifierContext`. The `TlsError` variant granularity (covering
both server and client errors) is decided — see
`TlsClientConfig::new` runs ADR-034's verifier selection directly off
`ConnectionCredentials.remote_identity` — there is no separate
`ClientVerifierContext` type; the credential bundle carries the
fingerprint (or its absence), which is all the verifier selection needs.
The call-protocol `auth_token` is not in `ConnectionCredentials` — it
is a per-request field on `call.requested` payloads (a call-protocol /
hub concept), not a transport credential; it never reaches
`TlsClientConfig`. The `TlsError` variant granularity (covering both
server and client errors) is decided — see
[ADR-088](../../decisions/088-tlserror-shape.md) and the
[`TlsError`](#tlserror) section below.
@@ -576,25 +532,26 @@ containerized deployment with no system CA bundle), the built-in
the `NoRootAnchors` failure mode unreachable in practice — a
containerized worker dialing a public X.509 hub succeeds without
requiring the operator to mount a CA bundle. Native-certs *load* errors
are logged, not returned (preserved behavior); the fallback guarantees
the store is non-empty regardless. See ADR-088 §5.
are logged, not returned; the fallback guarantees the store is
non-empty regardless. See ADR-088 §5.
`TlsClientConfig` produces a `rustls::ClientConfig`; the caller (the
transport-specific dial helper — `AlknetClient::dial_quic` /
`dial_tcp_tls`, ADR-089) passes it to the transport's connector. The
config is transport-agnostic; the dial is not. This is the client-side
analogue of ADR-065's server-side separation: the take-over
(`spawn_dispatch` / `from_connection`, transport-agnostic) is built
now; the dial (transport-specific) is per-transport. The
transport-polymorphic dial is now extracted as `alknet-client`
(ADR-089, resolves OQ-55) — `AlknetClient` builds the `TlsClientConfig`
per-dial and calls the transport's connector.
(`spawn_dispatch` / `from_connection`, transport-agnostic) is
transport-agnostic; the dial (transport-specific) is per-transport.
The transport-polymorphic dial is `alknet-client` (ADR-089, resolves
OQ-55) — `AlknetClient` builds the `TlsClientConfig` per-dial and
calls the transport's connector.
The client-side accessor API mirrors the server side: `for_quinn()`
/ `for_tcp_tls()` / `rustls_config()` — three transports, same
pattern. Iroh is the exception (see below). `AlknetClient` (ADR-089)
consumes `TlsClientConfig` via these accessors for the QUIC and TCP+TLS
dials; the iroh dial is the key-not-config exception.
The client-side accessor API: `for_quinn()` (QUIC) and
`into_rustls_config()` (any other transport — `dial_tcp_tls` wraps the
rustls config in a `TlsConnector`). Iroh is the exception (see below).
`AlknetClient` (ADR-089) consumes `TlsClientConfig` via these accessors
for the QUIC and TCP+TLS dials; the iroh dial is the key-not-config
exception.
### Iroh — shares the key, not the config (client side too)
@@ -617,7 +574,16 @@ the `for_quinn()` accessors on both (`for_tcp_tls` is infallible —
`alknet-tls`. The shape, the rationale for single-enum-over-thin-wrapper,
and the "what is NOT a variant" list are in
[ADR-088](../../decisions/088-tlserror-shape.md); this section is
the sketch.
the target shape per that ADR.
> **Implementation note.** The current `alknet-tls/src/lib.rs`
> `TlsError` is a simplified 3-variant enum (`Config(String)`,
> `Io(io::Error)`, `Cert(String)`) without `#[non_exhaustive]`. The
> full ADR-088 shape below (six typed variants, `#[non_exhaustive]`,
> `#[from` sources) is the target; the present code folds the
> finer-grained categories into `Config`/`Cert` strings. An implementer
> refining `TlsError` to match ADR-088 is a two-way-door change (the
> enum is crate-local, no external match arms).
```rust
/// Errors produced by `TlsServerConfig::new`, `TlsClientConfig::new`,
@@ -679,9 +645,8 @@ arrive asynchronously and are logged (ADR-082 §"Behavior-preservation
invariants").
**Ownership.** `TlsError` lives in `alknet-tls`, owned by the crate
that produces it. It is not re-exported from `alknet-core`; `EndpointError`
is removed entirely after ADR-083 (both variants were vestigial), so
core has no endpoint error type and does not need to know about
that produces it. It is not re-exported from `alknet-core`; core has
no endpoint error type, so core does not need to know about
`TlsError`. The assembly layer (hub/worker) depends on `alknet-tls`
directly and gets `TlsError` from that dependency.
@@ -689,15 +654,14 @@ directly and gets `TlsError` from that dependency.
```
alknet-tls
├── alknet-core (TlsIdentity, Ed25519SecretKey, fingerprint)
└── alknet-core (TlsIdentity, Ed25519SecretKey, fingerprint)
alknet-core (loses TLS setup code + endpoint)
├── (rustls — only for fingerprint.rs types, if kept)
alknet-core (lightweight — types + auth + config + fingerprint + credentials)
└── (rustls / rustls-pki-types — only for fingerprint.rs types)
alknet-call (pure protocol crate — no TLS/transport deps per ADR-089 §5)
└── alknet-core (ProtocolHandler, Connection, types; ConnectionCredentials/
RemoteIdentity moved to core per ADR-091; CallCredentials removed per ADR-091 Am. 2026-07-17
alknet-call)
RemoteIdentity from core per ADR-091)
alknet-hub (multi-transport endpoint)
├── alknet-tls (TlsServerConfig — shared across quinn + TCP)
@@ -706,7 +670,7 @@ alknet-hub (multi-transport endpoint)
├── alknet-channels-call (ChannelClient)
├── alknet-call (CallAdapter, Dispatcher)
├── alknet-http (HttpAdapter)
├── alknet-core (Connection, ProtocolHandler, AuthContext, IdentityProvider)
└── alknet-core (Connection, ProtocolHandler, AuthContext, IdentityProvider)
```
`alknet-tls` depends on `alknet-core` only. No handler crate depends on
@@ -725,7 +689,7 @@ All design decisions are documented as ADRs in
| ADR | Decision | Summary |
|-----|----------|---------|
| [082](../../decisions/082-alknet-tls-extraction.md) | alknet-tls crate extraction | Extract TLS setup from alknet-core/endpoint.rs; `TlsServerConfig` shareable across quinn + TCP+TLS + iroh; one ACME state machine |
| [083](../../decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as multi-transport accept-loop runner | `AlknetEndpoint` takes no TLS config; TCP+TLS is an owned transport (`with_tcp_tls`); `dispatch` public for SSH/WT; `acme-tls/1` guard moves to shared `dispatch` |
| [083](../../decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as multi-transport accept-loop runner | `AlknetEndpoint` takes no TLS config; TCP+TLS is an owned transport (`with_tcp_tls`); `dispatch` public for SSH/WT; `acme-tls/1` guard is in shared `dispatch` |
| [084](../../decisions/084-aws-lc-rs-crypto-provider.md) | aws-lc-rs crypto provider | `rustls::crypto::aws_lc_rs::default_provider()` on all server + client config paths; matches iroh; FIPS-capable; do not switch to `ring` or process-default without a new ADR |
| [086](../../decisions/086-endpoint-types-and-entry-points.md) | Endpoint types and entry points | Three endpoint types (web/native/iroh); split ALPN lists per endpoint type (resolves OQ-62); entry-point vs. endpoint ALPN distinction |
| [087](../../decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `TlsClientConfig` not blocked on dial seam | `alknet-tls` provides `TlsClientConfig` (client-side); not deferred behind OQ-55; breaks the circular hedge; hub-as-client is a first-class use case |
@@ -773,38 +737,33 @@ See [open-questions.md](../../open-questions.md) for full details.
- **OQ-64** (resolved): `alknet-tls` provides `TlsClientConfig`
(ADR-087). Not blocked on the dial-seam extraction — the TLS
config is a prerequisite for the dial, not a consequence of it.
Centralizes ADR-034 verifier selection + ADR-084 provider; the
hub-as-client requirement makes it a prerequisite for the first hub
deployment. The dial seam is now extracted as `alknet-client`
(ADR-089, OQ-55 resolved); `TlsClientConfig` is consumed by
`AlknetClient`'s QUIC and TCP+TLS dials.
Centralizes ADR-034 verifier selection + ADR-084 provider. The dial
seam is `alknet-client` (ADR-089, OQ-55 resolved); `TlsClientConfig`
is consumed by `AlknetClient`'s QUIC and TCP+TLS dials.
- **OQ-55** (resolved by ADR-089): `AlknetClient::dial()` — the
transport-polymorphic dial seam. Extracted as a new crate
`alknet-client` with three dial methods (`dial_quic` /
`dial_tcp_tls` / `dial_iroh`). `TlsClientConfig` (OQ-64, resolved)
is the prerequisite the dial consumes. See
[`crates/client/README.md`](../client/README.md) and
transport-polymorphic dial seam. `alknet-client` has three dial
methods (`dial_quic` / `dial_tcp_tls` / `dial_iroh`).
`TlsClientConfig` (OQ-64, resolved) is the prerequisite the dial
consumes. See [`crates/client/README.md`](../client/README.md) and
[ADR-089](../../decisions/089-alknetclient-native-dial-seam.md).
### Next session — client shape
### Client shape (in `alknet-client`)
The client is now specced. [`crates/client/README.md`](../client/README.md)
defines `AlknetClient` — the native client dial seam (ADR-089, resolves
OQ-55). There are exactly two clients in the alknet client surface as
far as `TlsClientConfig` and `AlknetClient` are concerned: **call**
[`crates/client/README.md`](../client/README.md) defines `AlknetClient`
— the native client dial seam (ADR-089, resolves OQ-55). There are
exactly two clients in the alknet client surface as far as
`TlsClientConfig` and `AlknetClient` are concerned: **call**
(`CallClient`) and **channels** (`ChannelClient`, a proxy over many
ALPNs via channel 0). Both consume `TlsClientConfig` via the same three
accessors (`for_quinn`, `for_tcp_tls`, `rustls_config`); iroh is the
exception (shares the key, not the config). `AlknetClient` is the dial
that feeds them — it produces a `Connection` and the protocol
take-overs (`spawn_dispatch`, `from_connection`) consume it. The
per-protocol QUIC convenience constructors (`CallClient::connect` /
`ChannelClient::connect_quic`) are **removed** per ADR-089 §5 — the
dial is centralized in `AlknetClient`, and the protocol crates shed
their TLS/transport deps. The `alknet/register` ALPN (native
registration entry point, parallel to HTTP registration in OQ-58) is
named by ADR-089; its wire protocol is deferred (OQ-66).
ALPNs via channel 0). Both consume `TlsClientConfig` through the dial
(`for_quinn` for QUIC, `into_rustls_config` wrapped in a `TlsConnector`
for TCP+TLS); iroh is the exception (shares the key, not the config).
`AlknetClient` is the dial that feeds them — it produces a `Connection`
and the protocol take-overs (`spawn_dispatch`, `from_connection`)
consume it. The dial is centralized in `AlknetClient` (ADR-089); the
protocol crates have no TLS/transport deps. The `alknet/register` ALPN
(named by ADR-089; wire protocol deferred, OQ-66) is the native
registration entry point, parallel to HTTP registration in OQ-58.
## References
@@ -829,15 +788,14 @@ named by ADR-089; its wire protocol is deferred (OQ-66).
(the endpoint spec; TLS config is built by `alknet-tls`, not the
endpoint — per ADR-083)
- `docs/architecture/crates/core/config.md` — `TlsIdentity`, `StaticConfig`
- `crates/alknet-core/src/endpoint.rs` — the server-side code being
extracted (`build_rustls_server_config`, `TlsSetup`, `RawKeyCertResolver`,
`Ed25519SigningKey`, `AcceptAnyCertVerifier`, `generate_self_signed_cert`,
`load_cert_chain`, `load_private_key`)
- `crates/alknet-call/src/client/call_client.rs` — the client-side code
being extracted (`build_quinn_client_config`, `build_client_auth`,
`select_server_verifier`, `load_platform_root_cert_store`,
- `crates/alknet-tls/src/server.rs` — `TlsServerConfig`,
`RawKeyCertResolver`, `AcceptAnyCertVerifier`,
`generate_self_signed_cert`, `build_rustls_server_config`
- `crates/alknet-tls/src/client.rs` — `TlsClientConfig`,
`FingerprintPinVerifier`, `RawKeyClientCertResolver`,
`NoClientCertResolver`, `Ed25519SigningKey` (duplicate),
`load_cert_chain`/`load_private_key` (duplicates))
`NoClientCertResolver`, `select_server_verifier`, `build_client_auth`,
`load_platform_root_cert_store`
- `crates/alknet-tls/src/pem.rs` — `load_cert_chain`, `load_private_key`
- `crates/alknet-tls/src/signing.rs` — `Ed25519SigningKey`
- `crates/alknet-core/src/fingerprint.rs` — fingerprint extraction
(shared by server endpoint and client verifier)
+52 -17
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-07
last_updated: 2026-07-18
---
# alknet-tty — TtyAdapter and Session Lifecycle
@@ -100,14 +100,18 @@ A `alknet/tty` session on one bidi stream proceeds in three phases:
`Some`, a concurrent stderr pump emits stderr chunks (stream_type 2).
On backend stdout EOF, emit a zero-length stdout sentinel.
- **B. client → backend**: client chunks → backend. stdin chunks
(stream_type 0) → `TtyHandle.stdin` (via `AsyncWrite`). Control
chunks (stream_type 3) → `ControlMessage` dispatch: `Resize` →
`TtyControl::resize`, `Signal` → `TtyControl::signal`, `Eof` →
close stdin. `Exit` from the client is ignored (server→client only).
(stream_type 0) → `TtyHandle.stdin` (via `AsyncWrite`).
Client→server control chunks (`STREAM_CTRL_IN`, stream_type 3) →
`ControlMessage` dispatch: `Resize` → `TtyControl::resize`, `Signal`
→ `TtyControl::signal`, `Eof` → close stdin. `Exit` on
`STREAM_CTRL_IN` is a protocol violation (it's server→client only)
and is ignored. `STREAM_CTRL_OUT` (stream_type 4) from the client is
a protocol violation (it's the server→client half) and is ignored.
On client read-half close or a zero-length stdin chunk, signal EOF
to the backend's stdin.
- **C. exit → exit chunk**: await `TtyHandle.exit_code`; on resolve,
enqueue `{"type":"exit","code":N}` as a control chunk (stream_type 3).
enqueue `{"type":"exit","code":N}` as a server→client control
chunk (`STREAM_CTRL_OUT`, stream_type 4).
A drainer task writes chunks to the client in arrival order. After the
exit chunk is written (task C resolves and the exit chunk drains),
@@ -118,6 +122,36 @@ hardcoded the local PTY backend; the adapter dispatches to any
`TtyBackend`. See `/workspace/alknet-tty-poc/src/session.rs` for the
reference implementation of the three-pump driver.
### Bidirectional Control Channel (Phase 7)
The control channel is split into two halves so it is genuinely
bidirectional on the wire:
- **`STREAM_CTRL_IN = 3`** — client→server control (`Resize`, `Signal`,
`Eof`).
- **`STREAM_CTRL_OUT = 4`** — server→client control (`Exit`).
The adapter enforces the direction:
- An `Exit` arriving on `STREAM_CTRL_IN` is a protocol violation
(server→client message on the client→server half) — the adapter
ignores it (the previous single `STREAM_CONTROL = 3` could not
distinguish the two directions, so `Exit` from the client was always
ignored; the split makes the rejection explicit).
- A `Resize`/`Signal`/`Eof` arriving on `STREAM_CTRL_OUT` is a protocol
violation (client→server message on the server→client half) — the
adapter ignores it (the server never dispatches control messages it
receives on the server→client half).
- `STREAM_CTRL_OUT` (stream_type 4) chunks written by the client are a
protocol violation (the client should not write on the server→client
half) — the adapter ignores them.
The exit chunk (`Exit`) is emitted on `STREAM_CTRL_OUT` (stream_type
4), not on the previous `STREAM_CONTROL = 3`. A client distinguishing
the two halves can route exit vs. control without parsing the JSON
`type` tag first. See `docs/research/alknet-crate-extraction/findings.md`
Phase 7 and `tty-wire.md` §"Control Channel".
### Negotiation Errors
If the server cannot allocate the session, it sends a JSON error response
@@ -146,17 +180,18 @@ The disambiguation is by the first byte: a JSON error frame's 4-byte
big-endian length prefix always starts with `0x00` (error frames MUST
be under 16 MiB — `MAX_CHUNK_LEN` — so the high byte is zero; this is
a wire-format invariant, not an assumption), while a raw chunk's first
byte is a `stream_type` in `{0, 1, 2, 3}`. A stream_type of `0` (stdin
from server) is invalid — the server never sends stdin chunks — so the
client distinguishes: read the first byte; if it is `0x00`, interpret
the next 4 bytes as a big-endian length prefix and read that many bytes
as a JSON error frame; otherwise interpret it as a `stream_type` byte
and continue reading the raw chunk header. This is a one-way-door
wire-format invariant (ADR-052): error frames use the negotiation
framing (length prefix) and MUST be under 16 MiB; success uses the raw
chunk framing (stream_type byte first); the `0x00`-as-length-prefix vs
`0x00`-as-invalid-stream_type disambiguation is what makes the two
distinguishable on the wire.
byte is a `stream_type`. The server never sends `0` (stdin —
client→server only) or `3` (`STREAM_CTRL_IN` — client→server only), so
the server-sent set is `{1, 2, 4}` (stdout, stderr, `STREAM_CTRL_OUT`);
`0x00` is unambiguous. The client distinguishes: read the first byte; if
it is `0x00`, interpret the next 4 bytes as a big-endian length prefix
and read that many bytes as a JSON error frame; otherwise interpret it
as a `stream_type` byte and continue reading the raw chunk header. This
is a one-way-door wire-format invariant (ADR-052): error frames use the
negotiation framing (length prefix) and MUST be under 16 MiB; success
uses the raw chunk framing (stream_type byte first); the
`0x00`-as-length-prefix vs `0x00`-as-invalid-stream_type disambiguation
is what makes the two distinguishable on the wire.
### Exit-Chunk Ordering (ADR-055)
+78 -35
View File
@@ -1,13 +1,14 @@
---
status: draft
last_updated: 2026-07-07
last_updated: 2026-07-18
---
# alknet-tty — Wire Format
The wire protocol for `alknet/tty`: the negotiation frame (JSON
carriage), the raw chunk codec, the control channel, and the sentinels.
The two-carriage model is decided in
carriage), the raw chunk codec, the control channel (split into
`STREAM_CTRL_IN` / `STREAM_CTRL_OUT` halves — Phase 7), and the
sentinels. The two-carriage model is decided in
[ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md);
this document specifies what an implementer builds.
@@ -164,19 +165,34 @@ no `call.responded`/`call.completed` — this is not the call protocol.
- **`stream_type`** (1 byte) — the channel:
| stream_type | channel | direction | payload |
|---|---|---|---|
| 0 | data-in (stdin) | client→server | raw bytes |
| 1 | data-out (stdout) | server→client | raw bytes |
| 2 | data-err (stderr) | server→client | raw bytes |
| 3 | control | bidirectional | JSON control message |
| stream_type | channel | direction | payload |
|-------------|-------------|----------------|---------------------|
| 0 | data-in (stdin) | client→server | raw bytes |
| 1 | data-out (stdout) | server→client | raw bytes |
| 2 | data-err (stderr) | server→client | raw bytes |
| 3 | ctrl-in | client→server | JSON control message (`Resize`, `Signal`, `Eof`) |
| 4 | ctrl-out | server→client | JSON control message (`Exit`) |
`stream_type > 3` is a protocol error (`InvalidStreamType`). There is
no extension escape hatch in the byte — a 5th channel is a wire-format
`stream_type > 4` is a protocol error (`InvalidStreamType`). There is
no extension escape hatch in the byte — a 6th channel is a wire-format
change requiring a new ALPN (`alknet/tty/v2` per ADR-006), not a
negotiated addition to this format. See ADR-052 §"Fixed channel set,
not extensible."
**Bidirectional control channel (Phase 7).** The control channel is
split into two halves so it is genuinely bidirectional on the wire:
`STREAM_CTRL_IN = 3` carries client→server control (`Resize`,
`Signal`, `Eof`); `STREAM_CTRL_OUT = 4` carries server→client control
(`Exit`). The previous single `STREAM_CONTROL = 3` was documented as
"bidirectional" but the adapter ignored `Exit` from the client
because the two directions were indistinguishable on the same
stream_type — see `docs/research/alknet-crate-extraction/findings.md`
Phase 7. The split makes the bidirectionality explicit: each
direction has its own stream_type, and the adapter enforces the
direction (an `Exit` arriving on `STREAM_CTRL_IN` is a protocol
violation and is ignored; a `Resize` arriving on `STREAM_CTRL_OUT` is
likewise a protocol violation and is ignored).
- **`length`** (4 bytes, big-endian) — payload length in bytes. Max
16 MiB (`MAX_CHUNK_LEN = 16 * 1024 * 1024`). A chunk larger than 16 MiB
is a protocol error (`ChunkTooLarge`).
@@ -208,9 +224,14 @@ Zero-length data chunks are sentinels:
Control chunks are never zero-length (the JSON payload is at least
`{}`).
### Control Channel (stream_type 3)
### Control Channel
Control chunks carry a JSON payload tagged by `type`. The schema is the
The control channel is split into two halves (Phase 7):
- **`STREAM_CTRL_IN` (stream_type 3)** — client→server control.
- **`STREAM_CTRL_OUT` (stream_type 4)** — server→client control.
Each half carries JSON payloads tagged by `type`. The schema is the
POC's `ControlMessage` (`/workspace/alknet-tty-poc/src/control.rs`):
```rust
@@ -231,12 +252,22 @@ pub enum ControlMessage {
}
```
| Direction | Message | Shape | Maps to |
|---|---|---|---|
| client→server | resize | `{"type":"resize","cols":80,"rows":24,"pixel_width":0,"pixel_height":0}` | SSH `window-change`, docker exec resize, `ioctl(TIOCSWINSZ)` |
| client→server | signal | `{"type":"signal","name":"INT"}` | SSH `signal`, docker exec signal, `kill(-pgid, sig)` (REQ-TTY-02) |
| client→server | eof | `{"type":"eof"}` | SSH channel EOF, docker stdin close, `ChildStdin::drop` |
| server→client | exit | `{"type":"exit","code":0}` | the terminal/completion signal (ADR-055) |
| stream_type | direction | Message | Shape | Maps to |
|-------------|----------------|---------|-------|---------|
| 3 (ctrl_in) | client→server | resize | `{"type":"resize","cols":80,"rows":24,"pixel_width":0,"pixel_height":0}` | SSH `window-change`, docker exec resize, `ioctl(TIOCSWINSZ)` |
| 3 (ctrl_in) | client→server | signal | `{"type":"signal","name":"INT"}` | SSH `signal`, docker exec signal, `kill(-pgid, sig)` (REQ-TTY-02) |
| 3 (ctrl_in) | client→server | eof | `{"type":"eof"}` | SSH channel EOF, docker stdin close, `ChildStdin::drop` |
| 4 (ctrl_out) | server→client | exit | `{"type":"exit","code":0}` | the terminal/completion signal (ADR-055) |
The adapter enforces the direction: an `Exit` arriving on
`STREAM_CTRL_IN` is a protocol violation (the adapter ignores it); a
`Resize`/`Signal`/`Eof` arriving on `STREAM_CTRL_OUT` is likewise a
protocol violation (the adapter ignores it). The split makes the
control channel genuinely bidirectional on the wire — the previous
single `STREAM_CONTROL = 3` was documented as "bidirectional" but the
adapter had to ignore `Exit` from the client because the two directions
were indistinguishable on the same stream_type. See
`docs/research/alknet-crate-extraction/findings.md` Phase 7.
**Signal names.** `name` is an uppercase string. The supported set (per
the POC's `signal_from_name`): `HUP`, `INT`, `QUIT`, `TERM`, `KILL`,
@@ -261,12 +292,12 @@ type is not.
Two signals both close the client's stdin:
1. **`{"type":"eof"}` control chunk** (stream_type 3) — explicit,
recommended. Tells the server to close the backend's stdin
(`ChildStdin::drop` / PTY writer close). The client may still want to
receive remaining stdout + the exit code, so the server does not tear
down the session on eof — it just closes stdin and keeps pumping
output.
1. **`{"type":"eof"}` control chunk** (stream_type 3, `STREAM_CTRL_IN`)
— explicit, recommended. Tells the server to close the backend's
stdin (`ChildStdin::drop` / PTY writer close). The client may still
want to receive remaining stdout + the exit code, so the server does
not tear down the session on eof — it just closes stdin and keeps
pumping output.
2. **Zero-length stdin chunk** (stream_type 0, length 0) — the docker
POC's sentinel. Accepted for compatibility with that pattern.
@@ -288,17 +319,26 @@ a session — see [tty-adapter.md](tty-adapter.md).
## Constraints
- **The wire format is one-way (ADR-052).** The 5-byte header, the fixed
stream_type set (0-3), and the two-carriage sequence are bytes clients
and servers parse. A 5th channel type requires a new ALPN
stream_type set (0-4), and the two-carriage sequence are bytes clients
and servers parse. A 6th channel type requires a new ALPN
(`alknet/tty/v2` per ADR-006), not a negotiated addition.
- **The control channel is split into two halves (Phase 7).**
`STREAM_CTRL_IN = 3` is client→server (`Resize`, `Signal`, `Eof`);
`STREAM_CTRL_OUT = 4` is server→client (`Exit`). The adapter enforces
the direction: an `Exit` on `STREAM_CTRL_IN` is ignored; a `Resize` on
`STREAM_CTRL_OUT` is ignored. The split is what makes the control
channel genuinely bidirectional on the wire — the previous single
`STREAM_CONTROL = 3` was documented as "bidirectional" but the adapter
had to ignore `Exit` from the client because the two directions were
indistinguishable on the same stream_type.
- **No windowing.** The chunk format has no flow-control window; QUIC's
per-stream flow control is the backpressure mechanism (OQ-45 resolved:
the backpressure chain is complete by construction — QUIC flow control
→ bounded drainer channel → bounded stdout channel → OS pipe/PTY
buffer → process `write()` blocks; no unbounded buffer breaks the
chain). The reversal path, if ever needed, is an additive
`ControlMessage` variant on stream_type 3, not a wire-format header
change.
`ControlMessage` variant on `STREAM_CTRL_IN`/`STREAM_CTRL_OUT`, not a
wire-format header change.
- **No negotiation round-trip.** The client writes the negotiation frame
and starts sending chunks; the server reads the frame and starts
pumping. There is no "the server acknowledges the negotiation before
@@ -312,18 +352,21 @@ a session — see [tty-adapter.md](tty-adapter.md).
without entering raw mode. The error response MUST be under 16 MiB
(`MAX_CHUNK_LEN`) so the 4-byte big-endian length prefix's high byte
is `0x00` — this is what makes the framing-disambiguation trick
(first byte `0x00` = error frame, first byte `1`/`2`/`3` = raw chunk)
sound; it is a wire-format invariant, not an empirical observation.
See [tty-adapter.md](tty-adapter.md) §"Negotiation errors".
(first byte `0x00` = error frame, first byte `1`/`2`/`4` = raw chunk;
the server never sends `0` (stdin, client→server) or `3`
(`STREAM_CTRL_IN`, client→server), so `0x00` is unambiguous) sound; it
is a wire-format invariant, not an empirical observation. See
[tty-adapter.md](tty-adapter.md) §"Negotiation errors".
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Wire format and two-carriage model | [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | `alknet/tty` ALPN; JSON negotiation frame then raw chunks; fixed channel set 0-3; control as JSON |
| Wire format and two-carriage model | [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | `alknet/tty` ALPN; JSON negotiation frame then raw chunks; fixed channel set 0-4; control as JSON |
| Bidirectional control channel split | Phase 7 (this doc, amended) | `STREAM_CTRL_IN = 3` (client→server) and `STREAM_CTRL_OUT = 4` (server→client) replace the single `STREAM_CONTROL = 3`; the adapter enforces the direction |
| No alknet-call dependency (self-contained framing) | [ADR-057](../../decisions/057-alknet-tty-no-alknet-call-dep.md) | alknet-tty implements its own length-prefixed framing; format coincides with alknet-call's by convention, not by code reuse |
| Exit code on a control chunk | [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) | `{"type":"exit","code":N}` on stream_type 3; "exit chunk is last" invariant |
| Stdin closure canonical signal | OQ-47 | Either `eof` control chunk or zero-length stdin chunk; `eof` recommended |
| Exit code on a control chunk | [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) | `{"type":"exit","code":N}` on `STREAM_CTRL_OUT` (stream_type 4); "exit chunk is last" invariant |
| Stdin closure canonical signal | OQ-47 | Either `eof` control chunk (`STREAM_CTRL_IN`) or zero-length stdin chunk; `eof` recommended |
## Open Questions
+110
View File
@@ -0,0 +1,110 @@
---
status: draft
last_updated: 2026-07-22
---
# alknet-typedef
The binary struct engine: a small Rust crate that takes a JSON Schema
with `TypeDef:*` custom keywords and produces an offset map, read/write
functions, and validation — all driven by the schema. The schema is the
format definition; the engine is generic.
## Documents
| Document | Status | Description |
|----------|--------|-------------|
| [overview.md](overview.md) | draft | Crate purpose, "schema is the format" principle, dependencies, consumers, scope boundaries |
| [schema-layer.md](schema-layer.md) | draft | The 19 `TypeDef:*` kinds, jsonschema custom keyword integration, TypeBox interop, schema annotations |
| [layout-engine.md](layout-engine.md) | draft | Offset computation, the two layout modes (packed sequential vs aligned static), alignment, endianness, variable-length handling |
| [data-access.md](data-access.md) | draft | Read/write functions, TUnion dispatch, field paths, zero-copy access, length-prefix reading |
| [validation.md](validation.md) | draft | Custom keyword validators for all 19 `TypeDef:*` kinds, `TypedefError`, load-time vs access-time validation, `TypedefEngine` |
## Applicable ADRs
| ADR | Title | Relevance |
|-----|-------|-----------|
| [095](../../decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md) | Purpose, Scope, and the jsonschema Engine | What the crate is/isn't; why jsonschema not a custom engine; "schema is the format" principle; scope boundaries |
| [096](../../decisions/096-two-layout-modes-packed-vs-aligned.md) | Two Layout Modes — Packed Sequential vs Aligned Static | The most important architectural finding; when to use each mode; `LayoutBuilder`/`SequentialReader` vs `OffsetMap` |
| [097](../../decisions/097-schema-annotations.md) | Schema Annotations — Endianness, Alignment, Encoding, TUnion Discriminators | Concrete JSON shapes for all schema-level annotations |
| [098](../../decisions/098-error-handling-validation-strategy.md) | Error Handling and Validation Strategy | `TypedefError` enum; load-time build, access-time check; field-path-carrying errors |
| [099](../../decisions/099-int64-uint64-first-class-kinds.md) | Int64/Uint64 as First-Class Kinds | 64-bit integers (SFTP offsets, metatensor data_offsets); JSON precision caveat |
| [100](../../decisions/100-reject-non-final-inline-length-prefixed-in-aligned-mode.md) | Reject Non-Final Inline Length-Prefixed Variable Fields in Aligned Mode | Prevents silent data corruption (inline variable data clobbering subsequent fields) |
| [101](../../decisions/101-packed-mode-read-factory.md) | Packed-Mode Read API — Engine as SequentialReader Factory | `engine.sequential_reader()` returns an owned reader, not a reference |
| [102](../../decisions/102-reject-tunion-in-aligned-mode.md) | Reject TUnion in Aligned Mode for v1 | Unions are the protocol pattern; aligned-mode union semantics were broken |
## Relevant Open Questions
| OQ | Title | Status | Relevance |
|----|-------|--------|-----------|
| OQ-069 | Arrays of variable-length-element structs | deferred(scope) | Requires lazy walking logic; blocked on a concrete consumer that needs it |
| OQ-070 | `no_std` + `alloc` support | deferred(scope) | Target `std` for v1; blocked on an embedded use case |
| OQ-071 | Builder API for schema construction | deferred(scope) | Schemas are authored in TypeBox or hand-written JSON for v1; blocked on a concrete need |
## Key Design Principles
1. **The schema is the format.** A JSON Schema with `TypeDef:*` custom
keywords is both the validation spec and the layout spec. No separate
format definition, no separate parser, no separate validator. One
schema, three uses: validate, compute offsets, access data. See
[overview.md](overview.md) and [ADR-095](../../decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md).
2. **jsonschema is the validation engine, not a custom engine.** The
`jsonschema` crate (v0.46.5, Draft 2020-12) handles validation with
custom keyword support. The novel code is the offset computation, not
the validation. This eliminates ~14,000 lines of hand-rolled schema
engines (typebox-rs, alktype). See [schema-layer.md](schema-layer.md)
and [ADR-095](../../decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md).
3. **Two layout modes for two use cases.** Packed sequential
(`LayoutBuilder`/`SequentialReader`) for protocol wire formats (SFTP,
channels, TTY). Aligned static (`OffsetMap`) for mmap-friendly formats
(metatensor). The consumer selects the mode; the schema is the same.
See [layout-engine.md](layout-engine.md) and
[ADR-096](../../decisions/096-two-layout-modes-packed-vs-aligned.md).
4. **Variable-length types default to inline length-prefixing.**
`[length: u32][data]` is the universal pattern used by channels, SFTP,
TTY, and most binary protocols. Offset indirection (the metatensor
blob tensor pattern) is opt-in via the `encoding` annotation. See
[layout-engine.md](layout-engine.md) and
[ADR-097](../../decisions/097-schema-annotations.md).
5. **TUnion supports both byte-offset and field-name discriminators.**
Byte-offset for protocol dispatch (SFTP type bytes, call protocol
event types). Field-name for the typedef.ts string pattern. See
[data-access.md](data-access.md) and
[ADR-097](../../decisions/097-schema-annotations.md).
6. **Endianness is per-schema, default little-endian.** The engine reads
the `"endian"` annotation and byte-swaps accordingly. SFTP consumers
specify `"endian": "big"`. See [layout-engine.md](layout-engine.md)
and [ADR-097](../../decisions/097-schema-annotations.md).
7. **Validation is opt-in, built once at load time.** The jsonschema
validator is compiled once at schema load time. Access-time validation
is a fast `is_valid()` check. High-throughput paths can skip
validation; security-sensitive paths can validate every frame. See
[validation.md](validation.md) and
[ADR-098](../../decisions/098-error-handling-validation-strategy.md).
8. **Not a serialization framework.** The typedef engine is not a
general-purpose serde replacement. It operates on raw byte buffers at
computed offsets — no intermediate `Value` tree, no reflection, no
dynamic dispatch per field. For JSON data, use serde. For binary data
with a known schema, use typedef. See [overview.md](overview.md) and
[ADR-095](../../decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md).
## References
- `docs/research/alknet-typedef/findings.md` — POC results (26 tests
passing, two layout modes, TUnion dispatch, endianness)
- `docs/research/call-channels-unification/findings.md` §"alknet-typedef:
JSON Schema as the binary struct engine" — the origin of this research
thread
- `/workspace/@alkdev/typebox/example/typedef/typedef.ts` — the TypeBox
schema kinds (619 lines)
- `/workspace/jsonschema/` — the jsonschema crate (v0.46.5, Draft 2020-12)
- `/workspace/alknet-typedef-poc/` — the POC code (disposable)
- `/workspace/@alkimiadev/typebox-rs/` — prior attempt, replaced by typedef
- `/workspace/@alkimiadev/alktype/` — prior attempt, replaced by typedef
@@ -0,0 +1,385 @@
---
status: draft
last_updated: 2026-07-22
---
# alknet-typedef — Data Access
The data access layer: read/write functions, TUnion dispatch, field paths,
zero-copy access for fixed-size types, and length-prefix reading for
variable-length types. This is the consumer-facing API — given a compiled
`TypedefEngine` and a byte buffer, read and write fields at
schema-computed offsets.
This document covers two layers:
- **Primitive read/write functions** in the `data_access` module —
typed reads/writes at a caller-provided offset. These are the building
blocks used by the layout types (`OffsetMap`, `LayoutBuilder`,
`SequentialReader`) and the `TypedefEngine`. Each operates on a raw
byte buffer at a known offset and returns a `TypedefError::Access`
carrying the field path on bounds or encoding failures.
- **The `FieldValue` enum and the higher-level APIs** —
`TypedefEngine::read_field`/`write_field` (aligned mode) and
`SequentialReader::read_next`/`read_field` (packed mode) — which look
up a field's offset via the layout and dispatch to the primitive
functions, returning a unified `FieldValue<'a>`.
## The `FieldValue` enum
The higher-level read APIs return a single unified type — `FieldValue<'a>`
— so one method can read any field kind without the caller dispatching on
schema kind first. The variant carries the typed value; the lifetime
borrows from the input buffer for variable-length kinds (zero-copy).
```rust
pub enum FieldValue<'a> {
I8(i8), I16(i16), I32(i32), I64(i64),
U8(u8), U16(u16), U32(u32), U64(u64),
F32(f32), F64(f64),
Bool(bool),
Enum(u32), // u32 index into the schema's "enum" array
String(&'a str), // borrows from the buffer
Bytes(&'a [u8]), // borrows from the buffer
Struct { start: usize, end: usize }, // consumer recurses with a fresh reader
Union { discriminator: String, variant_start: usize },
Array { count: u32, element_start: usize, element_stride: usize },
}
```
For composite kinds (`Struct`, `Union`, `Array`), `FieldValue` returns a
layout descriptor, not the decoded contents — the consumer recurses with
a fresh `SequentialReader` (or a sub-range read) scoped to the reported
byte range. `Array`'s `element_stride` is `0` for variable-length element
types, signalling the consumer must walk each element sequentially.
## Read/Write Model
The typedef engine operates on raw byte buffers (`&[u8]` for reading,
`&mut [u8]` for writing). There is no intermediate `Value` tree, no
reflection, no dynamic dispatch per field. The engine uses the offset map
(or `LayoutBuilder`/`SequentialReader`) to locate fields, then performs
typed access at the computed positions.
### Higher-level read/write
The `TypedefEngine` and `SequentialReader` provide the primary
consumer-facing read/write APIs. They look up a field's offset via the
layout and dispatch to the primitive `data_access` functions, returning
`FieldValue` (read) or accepting `&FieldValue` (write).
```rust
impl TypedefEngine {
// Aligned mode: looks up the field's ByteRange in the OffsetMap,
// dispatches to the right data_access function by TypeDefKind.
// Returns TypedefError::Access if compiled in packed mode
// (use sequential_reader() for packed mode).
pub fn read_field<'a>(&self, buffer: &'a [u8], field_path: &str)
-> Result<FieldValue<'a>, TypedefError>;
pub fn write_field(&self, buffer: &mut [u8], field_path: &str,
value: &FieldValue<'_>) -> Result<(), TypedefError>;
// Packed mode: returns an owned fresh SequentialReader (ADR-101).
// Each call returns a new reader with the cursor at position 0.
// The consumer owns the reader and drives read_next/read_field/reset.
pub fn sequential_reader(&self) -> Option<SequentialReader>;
}
impl SequentialReader {
// Packed mode: walks the buffer field-by-field, reading length
// prefixes to find each field's position. read_field walks all
// preceding fields to reach the target.
pub fn read_next<'a>(&mut self, buffer: &'a [u8])
-> Result<Option<(String, FieldValue<'a>)>, TypedefError>;
pub fn read_field<'a>(&mut self, buffer: &'a [u8], field_path: &str)
-> Result<FieldValue<'a>, TypedefError>;
pub fn reset(&mut self);
pub fn position(&self) -> usize;
pub fn endian(&self) -> Endian;
}
```
`read_field`/`write_field` on `TypedefEngine` work for the fixed-size
primitive kinds and the length-prefixed `String`/`Bytes`/`Timestamp`
fields. Composite kinds (`Struct`, `Union`, `Array`, `Record`) return a
`FieldValue` carrying a layout descriptor (byte range, variant start,
or array stride) for the consumer to recurse on — see §"FieldValue" above.
For writing in packed mode, the consumer uses `LayoutBuilder::build` to
compute positions, then calls the primitive `data_access::write_*`
functions at the computed offsets. There is no packed-mode
`engine.write_field` — the layout depends on the actual data sizes,
which the builder consumes at `build` time.
### Primitive read/write functions
The `data_access` module exposes typed read/write functions for each
primitive kind. Each takes `field_path: &str` for error attribution
(produces a `TypedefError::Access` carrying the path on bounds or
encoding failures) and, for multi-byte types, an `Endian` parameter.
### Fixed-size types
Fixed-size types (`TFloat32`, `TInt32`, `TUint8`, `TEnum`, etc.) are
accessed via zero-copy reads of N bytes at the offset:
```rust
// Read a u32 at a known offset, applying endianness. Bounds-checked.
fn read_u32(buffer: &[u8], offset: usize, field_path: &str, endian: Endian)
-> Result<u32, TypedefError> {
let bytes: [u8; 4] = read_array(buffer, offset, field_path)?;
Ok(match endian {
Endian::Little => u32::from_le_bytes(bytes),
Endian::Big => u32::from_be_bytes(bytes),
})
}
// Write a u32 at a known offset, applying endianness. Bounds-checked.
fn write_u32(buffer: &mut [u8], offset: usize, value: u32,
field_path: &str, endian: Endian) -> Result<(), TypedefError> {
let bytes = match endian {
Endian::Little => value.to_le_bytes(),
Endian::Big => value.to_be_bytes(),
};
write_array(buffer, offset, bytes, field_path)
}
```
The engine applies endianness at access time based on the schema's
`"endian"` annotation (ADR-097). The offset computation is
endian-agnostic. The `read_array`/`write_array` helpers perform the
bounds check and produce `TypedefError::Access` with the field path on
failure.
### TEnum access
`TEnum` is a fixed-size type (4 bytes, `u32` index). Read/write delegates
to the `u32` primitives, applying the schema's endianness:
```rust
pub fn read_enum(buffer: &[u8], offset: usize, field_path: &str, endian: Endian)
-> Result<u32, TypedefError> {
read_u32(buffer, offset, field_path, endian)
}
```
The consumer maps the `u32` index back to the enum's string values using
the schema's `"enum"` array (index 0 → first value, index 1 → second
value, etc.). The engine does not perform this mapping — it operates on
the raw `u32` index. The jsonschema validator checks that the index
corresponds to a valid enum value at the JSON level.
### Variable-length types (inline length-prefixing)
For variable-length types with inline length-prefixing (the default),
the `data_access` module provides `read_string`/`write_string`/
`read_bytes`/`write_bytes`. Each takes `field_path: &str` for error
attribution and `endian` for the length prefix:
```rust
// Read a length-prefixed string, borrowing from the buffer.
fn read_string<'a>(buffer: &'a [u8], offset: usize,
field_path: &str, endian: Endian) -> Result<&'a str, TypedefError>;
// Write a length-prefixed string. Returns total bytes written (4 + data.len()).
fn write_string(buffer: &mut [u8], offset: usize, value: &str,
field_path: &str, endian: Endian) -> Result<usize, TypedefError>;
// read_bytes / write_bytes have the same shape — raw bytes, no UTF-8 check.
```
The engine reads the 4-byte length prefix at the field's offset, then
slices the data that follows. For writing, the engine writes the length
prefix + data. `read_string` validates UTF-8 and returns a `&str`
borrowing from the input buffer (zero-copy); `read_bytes` returns a
`&[u8]` slice with no encoding check.
In packed sequential mode, the `SequentialReader` uses the length prefix
to determine the position of the next field. In aligned static mode, the
`OffsetMap` records the position of the length prefix; the variable data
is accessed separately.
### Variable-length types (offset indirection)
For variable-length types with offset indirection (opt-in), the
`data_access` module provides `read_string_indirect`/`read_bytes_indirect`.
The 8-byte struct at `buffer[offset..offset+8]` is
`{ data_offset: u32, data_length: u32 }` (endian-aware); the actual
bytes live in a separate `data_region`:
```rust
fn read_string_indirect<'a>(buffer: &'a [u8], offset: usize,
data_region: &'a [u8], field_path: &str,
endian: Endian) -> Result<&'a str, TypedefError>;
fn read_bytes_indirect<'a>(buffer: &'a [u8], offset: usize,
data_region: &'a [u8], field_path: &str,
endian: Endian) -> Result<&'a [u8], TypedefError>;
```
The field is a struct `{offset: u32, length: u32}` at a known position
in the `OffsetMap`. The consumer provides the data region separately; the
engine reads the offset and length, then slices the data region.
## TUnion Dispatch
The `tunion` module provides TUnion discriminator dispatch — reading the
discriminator value from a byte buffer, looking up the variant schema in
the union's `mapping`, and reporting the offset where the variant struct
begins. All reads go through the `data_access` primitives so bounds checks
and endianness handling are uniform with the rest of the engine.
The result of dispatch is a `UnionDispatch` struct:
```rust
pub struct UnionDispatch {
pub key: String, // mapping key (stringified disc value)
pub variant_offset: usize, // byte offset where the variant struct starts
pub discriminator_size: usize, // discriminator's byte size
}
```
After dispatch, the consumer calls `tunion::resolve_variant(union_schema, &dispatch.key)`
to get the variant schema, then reads the variant's fields at
`dispatch.variant_offset` using the normal `data_access` functions (or a
fresh `SequentialReader` scoped to the variant).
### Byte-offset discriminator
```rust
/// Read the discriminator value from a byte-offset TUnion. The discriminator
/// is a fixed-size integer (TypeDef:Uint8/Uint16/Uint32) at a known byte
/// offset. Returns the mapping key (stringified integer) and the variant
/// struct offset.
pub fn read_byte_discriminator(
buffer: &[u8],
union_schema: &Value,
endian: Endian,
) -> Result<UnionDispatch, TypedefError>;
```
This is the SFTP `Packet` enum pattern — byte 0 is the type byte, bytes
1..N are the variant struct. The call protocol's 5 event types
(`call.requested` → 0x01, etc.) use the same pattern. The variant struct
starts at `offset + discriminator_size`.
### Field-name discriminator
```rust
/// Read the discriminator value from a field-name TUnion. The
/// discriminator is a named field within the struct — the consumer
/// provides the field's computed offset (from the OffsetMap or
/// LayoutBuilder). Supports TypeDef:String, Uint8, and Enum discriminator
/// fields.
pub fn read_field_discriminator(
buffer: &[u8],
union_schema: &Value,
disc_field_offset: usize,
endian: Endian,
) -> Result<UnionDispatch, TypedefError>;
```
The discriminator is a named field within the struct. Its offset is
computed like any other field (the consumer passes it in as
`disc_field_offset`). The mapping keys are string values. After reading
the discriminator, the consumer looks up the variant schema and reads
the variant's fields starting at the end of the discriminator field.
### Variant resolution
```rust
/// Look up a variant schema from the union's mapping. Inline schemas
/// are returned directly. $ref pointers of the form "#/$defs/<name>"
/// are resolved against the union schema's own $defs block.
pub fn resolve_variant<'a>(union_schema: &'a Value, key: &str)
-> Result<&'a Value, TypedefError>;
/// Get the discriminator's byte size (1/2/4 for Uint8/16/32) for a
/// byte-offset TUnion. Field-name discriminators have no fixed size
/// and produce a TypedefError::Schema.
pub fn discriminator_size(union_schema: &Value) -> Result<usize, TypedefError>;
```
### TUnion in the layout engines
The `LayoutBuilder` and `SequentialReader` also handle TUnion fields
inline during traversal (the consumer does not need to call the `tunion`
functions for a union field reached during a sequential walk). For
`LayoutBuilder`, the consumer supplies the discriminator value (byte-offset)
or variant index (field-name) in `var_sizes` under the synthetic key
`"<union_path>.__discriminator"` or `"<union_path>.__variant"`. For
`SequentialReader`, a union field yields
`FieldValue::Union { discriminator, variant_start }`. The standalone
`tunion` functions are for dispatch outside the layout walk — e.g., a
consumer that receives a bare union buffer and needs to identify the
variant before recursing.
## Field Paths
Fields are addressed by dotted paths: `"header.version"`, `"payload.data"`.
Both `OffsetMap` and `PackedLayout` store fully-qualified paths (nested
struct fields appear under their parent's path prefix). The higher-level
APIs (`TypedefEngine::read_field`/`write_field`, `SequentialReader::read_field`)
accept a field path, look up the byte range/position in the layout, and
dispatch to the primitive `data_access` function for the field's kind.
For aligned-mode access, `TypedefEngine::read_field(&buffer, "header.version")`
returns `FieldValue` — it looks up the `ByteRange` in the `OffsetMap`, finds
the field's `TypeDef:*` kind in the schema, and calls the matching
`data_access::read_*` function. `write_field` is the mirror. Composite
kinds (`Struct`, `Union`, `Array`, `Record`) return a `FieldValue`
carrying a layout descriptor; the consumer recurses with a fresh reader
or sub-range read.
For packed-mode access, `SequentialReader::read_field(&buffer, "c")` walks
all preceding fields to reach the target (sequential access is inherent
to packed layouts). `read_next` walks fields in declaration order.
Nested structs produce nested field paths. The offset computation
propagates the field path prefix during recursion, so the `OffsetMap`
and `PackedLayout` contain entries like `"header.version"` and
`"header.magic"`.
## Zero-Copy Access
For fixed-size types, the engine provides zero-copy access — the consumer
gets a reference to the bytes in the buffer, not a copy. This is
important for performance-sensitive paths (metatensor tensor access,
high-throughput protocol parsing).
For variable-length types with inline length-prefixing, the engine
returns a slice of the buffer — the string or byte array data is not
copied. The consumer gets a `&str` or `&[u8]` that borrows from the
input buffer.
For offset-indirect types, the consumer provides the data region; the
engine returns a slice of that region.
## Error Handling
Read/write errors carry the field path for debugging. See
[ADR-098](../../decisions/098-error-handling-validation-strategy.md) and
[validation.md](validation.md) for the full error model.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Two layout modes | [ADR-096](../../decisions/096-two-layout-modes-packed-vs-aligned.md) | Determines whether offsets are fixed (OffsetMap) or sequential (SequentialReader) |
| Schema annotations | [ADR-097](../../decisions/097-schema-annotations.md) | Endianness, encoding, and TUnion discriminator shapes that control data access |
| Error handling | [ADR-098](../../decisions/098-error-handling-validation-strategy.md) | Field-path-carrying errors for read/write operations |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-069** (deferred(scope)): Arrays of variable-length-element structs
— affects the sequential walking logic for array access.
## References
- `docs/research/alknet-typedef/findings.md` §"POC Results" — POC 1
(read/write round-trip) and POC 2 (SFTP byte-identical round-trip)
- [layout-engine.md](layout-engine.md) — offset computation that produces
the positions this layer reads/writes at
- [validation.md](validation.md) — validation that runs on the same
buffers
@@ -0,0 +1,360 @@
---
status: draft
last_updated: 2026-07-22
---
# alknet-typedef — Layout Engine
The layout engine: offset computation, the two layout modes (packed
sequential vs aligned static), alignment, endianness, and variable-length
field handling. This is the novel code — the recursive walk of the schema
JSON that computes byte positions for each field.
## The Two Layout Modes
The POCs surfaced that protocols and mmap-friendly formats need different
layout strategies. This is the most important architectural finding —
decided in [ADR-096](../../decisions/096-two-layout-modes-packed-vs-aligned.md).
### Mode 1: Packed sequential (protocol wire formats)
Fields are packed with no alignment padding. Variable-length fields shift
all subsequent fields. Used by SFTP, channels, TTY, and most binary
protocols.
**Components:**
- **`LayoutBuilder`** — constructed via `LayoutBuilder::new(schema)` (requires `TypeDef:Struct` at the top level), then `builder.build(&var_sizes) -> Result<PackedLayout, TypedefError>` where `var_sizes: &HashMap<String, usize>` maps variable-length field paths (and TUnion discriminator/variant keys) to their actual byte sizes. Used at write time when the consumer knows the data sizes upfront. The builder computes positions only; the consumer writes data via the [`data_access`](data-access.md) functions at the computed positions.
- **`SequentialReader`** — constructed via `SequentialReader::new(schema)`, then driven by `reader.read_next(&buffer) -> Result<Option<(String, FieldValue)>, TypedefError>` until `Ok(None)`, or `reader.read_field(&buffer, path)` to seek a single field (which walks all preceding fields to reach the target). `reader.reset()` rewinds to the start. Used at read time when the consumer is parsing an incoming frame.
**How it works:**
For a struct with fields `[u8, u32, string]` where the string is 10 bytes:
```
LayoutBuilder::build(var_sizes: {"payload": 10}):
field[0] u8: offset 0, size 1
field[1] u32: offset 1, size 4
field[2] string: offset 5, size 4 (length prefix) + 10 (data)
total: 19
SequentialReader::read_next (read):
read u8 at offset 0
read u32 at offset 1
read u32 length prefix at offset 5 → data_len
read string data at offset 9, length data_len
next field at offset 9 + data_len
```
There is no alignment padding. The `u32` at offset 1 is unaligned — this
is correct for protocol wire formats, which pack fields tightly.
**Variable-length fields in packed mode:**
The `LayoutBuilder` takes actual data sizes for variable-length fields
to compute correct positions for subsequent fields. The consumer must
know the data sizes before writing — this is inherent to packed layouts.
The `SequentialReader` reads each field's length prefix to determine the
data extent and the position of the next field. The reader walks the
buffer sequentially; it cannot jump to field N without reading fields
0..N-1 first.
### Mode 2: Aligned static (mmap-friendly formats)
Fields have fixed positions with natural alignment padding.
Variable-length fields get a 4-byte length prefix at a known offset; the
variable data is not included in the static layout. Used by metatensor
and safetensors.
**Component:**
- **`OffsetMap`** — constructed via `OffsetMap::compute(schema) -> Result<Self, TypedefError>` (requires `TypeDef:Struct` at the top level). Walks the schema once, computes fixed byte positions for each field based on type sizes and alignment. The output is a flat table of `(field_path, byte_range)` pairs (see [Public Types](#public-types)). Used for both read and write at known offsets.
**How it works:**
For a struct with fields `[u8, u32, f32]` and natural alignment:
```
OffsetMap:
field[0] u8: offset 0, size 1
field[1] u32: offset 4, size 4 (3 bytes padding after u8)
field[2] f32: offset 8, size 4
total: 12 (struct aligned to 4)
```
The `u32` is aligned to offset 4 (its natural alignment). The consumer
can read `field[1]` at offset 4 without reading `field[0]` first — random
access by field path.
**Variable-length fields in aligned mode:**
Variable-length fields get a 4-byte length prefix at a known offset. The
variable data lives outside the static layout — either immediately after
the fixed fields (inline length-prefixing) or in a separate data region
(offset indirection). The `OffsetMap` records the position of the length
prefix (or the `{offset, length}` pair for offset-indirect fields).
For inline length-prefixing, the variable data follows the fixed fields
but is not included in the `OffsetMap`'s field ranges. The consumer reads
the length prefix from the `OffsetMap`'s known offset, then slices the
data region.
For offset indirection, the field is a struct `{offset: u32, length: u32}`
at a known position in the `OffsetMap`. The consumer reads the offset and
length, then slices the separate data region.
### Inline length-prefixing in aligned mode — non-final field restriction
Inline length-prefixed variable fields in aligned mode are only allowed
as the **last field** in their struct. A non-final inline
length-prefixed variable field is rejected at `OffsetMap::compute` time
with a `TypedefError::Offset` — the `OffsetMap` reserves only 4 bytes
(the length prefix), but `data_access::write_string` writes prefix +
data inline, which would clobber subsequent fields. Non-final variable
fields must use `maxLength` (fixed-size reservation) or
`"encoding": "offset-indirect"`. See
[ADR-100](../../decisions/100-reject-non-final-inline-length-prefixed-in-aligned-mode.md).
## Offset Computation Algorithm
The offset computation is a recursive walk of the schema JSON. The
algorithm is the same for both modes; the difference is whether alignment
padding is inserted between fields.
### Fixed-size types
For each fixed-size type, the algorithm:
1. Determines the type's byte size from the `TypeDef:*` kind.
2. In aligned mode: inserts padding to satisfy the type's alignment
(or the field's `align` annotation, or the struct's `align` default).
3. Records the field's `(start, end)` range.
4. Advances the current offset by the type's size.
### Composite types
**`TStruct`:** Recurse into the struct's `properties`. The inner fields
are computed relative to the struct's start offset. The struct's total
size is the sum of its fields' sizes (plus alignment padding in aligned
mode). The struct itself may have an `align` annotation that rounds up
its total size.
**`TUnion`:** TUnion is supported in packed sequential mode only. In
aligned static mode, `OffsetMap::compute` rejects `TUnion` fields with
`TypedefError::Offset` — see
[ADR-102](../../decisions/102-reject-tunion-in-aligned-mode.md). Unions
are the protocol dispatch pattern (SFTP type bytes, call protocol event
types); mmap-friendly formats use structs and arrays, not tagged unions.
In packed sequential mode, the discriminator occupies
`offset..offset + discriminator_size` bytes. For byte-offset
discriminators, the variant struct starts at `offset + discriminator_size`.
For field-name discriminators, the discriminator is just another field —
its offset is computed like any other field, and the variant struct
follows at the end of the discriminator field.
Variant sizes depend on the actual sizes of variable-length fields within
each variant, which aren't known at schema time. The `LayoutBuilder`
takes the actual variant discriminator value and data sizes at write time,
computes the size of the selected variant, and uses that for the union's
total size. The `SequentialReader` reads the discriminator first, looks
up the variant schema, then reads the variant struct sequentially — it
doesn't need to know the union's total size upfront.
**`TArray` of fixed-size elements:** Element stride = element size (plus
alignment padding in aligned mode). Element `i` starts at
`array_offset + i × stride`. The array's total size is `count × stride`.
**`TArray` of variable-length-element structs:** Deferred for v1
(OQ-069).
### Variable-length types
The typedef engine supports three strategies for variable-length types
(see [schema-layer.md](schema-layer.md) §Variable-length types and
[ADR-097](../../decisions/097-schema-annotations.md) §3 for the full
annotation shapes).
**Strategy 1: Inline length-prefixing (default).**
1. Records the position of the 4-byte length prefix.
2. In aligned mode: the length prefix is aligned; the variable data is
not included in the static layout.
3. In packed mode: the `LayoutBuilder` takes the actual data size to
compute the length prefix value and the position of subsequent fields.
The `SequentialReader` reads the length prefix to determine the data
extent and the position of the next field.
**Strategy 2: Fixed-size reservation (`maxLength`).**
1. In aligned static mode: reserves `maxLength` bytes at a fixed offset.
Data shorter than `maxLength` is zero-padded. Subsequent fields have
known, unchanging offsets — the field is fixed-size from the layout
perspective. This is the database `VARCHAR(N)` pattern.
2. In packed sequential mode: `maxLength` is a validation constraint
only. The engine uses strategy 1 (inline length-prefixing) because
protocols don't benefit from fixed-size reservation.
**Strategy 3: Offset indirection (`"encoding": "offset-indirect"`).**
1. The field is a struct `{offset: u32, length: u32}`.
2. The `OffsetMap` records the position of this struct.
3. The consumer provides the data region separately. This is the
metatensor blob tensor pattern — the index struct lives in one region,
the blob data lives in another.
### Nested structs and field paths
Nested structs produce dotted field paths: `header.version`,
`header.magic`. The offset computation propagates the field path prefix
during recursion. Both `OffsetMap` and `PackedLayout` store fully-qualified
paths; the `iter()` method of each yields fields in schema `properties`
order, with nested struct fields appearing inline under their parent's
path prefix.
### Endianness
Endianness is per-schema (ADR-097). The offset computation is
endian-agnostic — it computes byte positions, not byte values. The
read/write functions apply endianness when converting between bytes and
typed values. The engine reads the `"endian"` annotation from the schema
and byte-swaps accordingly. All fixed-size types — including `TEnum`
(u32 index) — follow the schema's endianness.
## Mode Selection
The consumer selects the mode at engine construction time via the
`LayoutMode` enum, passed to `TypedefEngine::compile`:
```rust
pub enum LayoutMode {
/// Packed sequential — for protocol wire formats (SFTP, channels, TTY).
Packed,
/// Aligned static — for mmap-friendly formats (metatensor, safetensors).
Aligned,
}
```
The choice is determined by the use case, not by the schema:
- **Protocol consumer** (SFTP, binary call frames, TTY negotiation):
`LayoutMode::Packed` → uses `LayoutBuilder` for writing and
`SequentialReader` for reading.
- **mmap consumer** (metatensor): `LayoutMode::Aligned` → uses `OffsetMap`
for both reading and writing at known offsets.
The same schema can be used in either mode. A schema describing an SFTP
packet can be consumed by a `SequentialReader` (for parsing incoming
frames) and a `LayoutBuilder` (for constructing outgoing frames). A schema
describing a metatensor layout can be consumed by an `OffsetMap` (for
mmap access).
`TypedefEngine` exposes mode-appropriate accessors: `engine.offset_map()`
returns `Some(&OffsetMap)` in aligned mode and `None` in packed mode;
`engine.layout_builder()` returns `Some(&LayoutBuilder)` in packed mode
and `None` in aligned mode. `engine.sequential_reader()` returns
`Option<SequentialReader>` (an owned fresh reader, not a reference — the
reader has mutable cursor state that the consumer owns; see
[ADR-101](../../decisions/101-packed-mode-read-factory.md)) in packed
mode and `None` in aligned mode. See [validation.md](validation.md)
§"The TypedefEngine struct" for the engine API.
## Public Types
The layout engine produces three public types, one per layout component.
All are re-exported from the crate root.
### `ByteRange` (aligned mode)
```rust
pub struct ByteRange {
pub start: usize, // inclusive
pub end: usize, // exclusive
}
```
A half-open byte range produced by `OffsetMap::compute` for each field.
`end - start` is the field's byte size in the static layout (for
variable-length fields: the length prefix, the `{offset, length}` pair,
or the `maxLength` reservation — not the variable data). `ByteRange`
provides `len()` and `is_empty()`.
### `FieldPosition` (packed mode)
```rust
pub struct FieldPosition {
pub offset: usize,
pub size: usize,
pub kind: TypeDefKind,
}
```
A field's computed position in a packed layout, produced by
`LayoutBuilder::build`. For variable-length fields, `size` is `4` (the
length prefix); for fixed-size fields, `size` is the type's byte size.
`kind` records the field's `TypeDef:*` kind so the consumer can dispatch
to the correct `data_access` read/write function.
### `PackedLayout` (packed mode)
The result of `LayoutBuilder::build`: a map of `field_path → FieldPosition`
plus the total buffer size needed.
```rust
impl PackedLayout {
pub fn get(&self, field_path: &str) -> Option<&FieldPosition>;
pub fn total_size(&self) -> usize;
pub fn iter(&self) -> impl Iterator<Item = &(String, FieldPosition)>;
}
```
`get` looks up a field by dotted path. For TUnion byte-offset
discriminators, the discriminator is recorded under the synthetic path
`"<union_path>.__discriminator"`. `iter` yields fields in layout order
(schema `properties` order, with nested struct fields appearing inline
under their parent's path prefix).
### `OffsetMap` (aligned mode)
A flat table of `(field_path, byte_range)` pairs computed from a schema.
```rust
impl OffsetMap {
pub fn compute(schema: &Value) -> Result<Self, TypedefError>;
pub fn get(&self, field_path: &str) -> Option<&ByteRange>;
pub fn total_size(&self) -> usize;
pub fn iter(&self) -> impl Iterator<Item = &(String, ByteRange)>;
}
```
`compute` requires a `TypeDef:Struct` at the top level. `total_size`
includes trailing alignment padding. `iter` yields fields in insertion
order (schema `properties` order, nested struct fields appearing inline).
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Two layout modes | [ADR-096](../../decisions/096-two-layout-modes-packed-vs-aligned.md) | Packed sequential for protocols; aligned static for mmap formats |
| Schema annotations | [ADR-097](../../decisions/097-schema-annotations.md) | Endianness, alignment, encoding annotations that control layout behavior |
| Non-final inline variable fields | [ADR-100](../../decisions/100-reject-non-final-inline-length-prefixed-in-aligned-mode.md) | Rejected in aligned mode (would clobber subsequent fields); use `maxLength` or `offset-indirect` |
| Packed-mode read factory | [ADR-101](../../decisions/101-packed-mode-read-factory.md) | `engine.sequential_reader()` returns an owned fresh reader, not a reference |
| TUnion in aligned mode | [ADR-102](../../decisions/102-reject-tunion-in-aligned-mode.md) | Rejected for v1 (broken semantics; no current consumer needs it) |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-069** (deferred(scope)): Arrays of variable-length-element structs
— requires lazy walking logic; blocked on a concrete consumer that
needs it.
## References
- `docs/research/alknet-typedef/findings.md` §"POC Results" — POC 1
(aligned OffsetMap) and POC 2 (packed LayoutBuilder/SequentialReader)
- [ADR-096](../../decisions/096-two-layout-modes-packed-vs-aligned.md) —
the two layout modes decision
- [ADR-097](../../decisions/097-schema-annotations.md) — schema
annotations
- [schema-layer.md](schema-layer.md) — the 17 TypeDef kinds and their
byte sizes
- [data-access.md](data-access.md) — read/write functions that use the
computed offsets
@@ -0,0 +1,203 @@
---
status: draft
last_updated: 2026-07-22
---
# alknet-typedef — Overview
The binary struct engine: a small Rust crate that takes a JSON Schema
with `TypeDef:*` custom keywords and produces an offset map, read/write
functions, and validation — all driven by the schema. The schema is the
format definition; the engine is generic.
This document covers the crate's purpose, the "schema is the format"
principle, its dependency edges, consumers, and scope boundaries.
Component details are in the sibling documents.
## What
`alknet-typedef` is a library crate that consumes JSON Schemas annotated
with `TypeDef:*` custom keywords (the same kinds defined in TypeBox's
`typedef.ts`, plus `TypeDef:Bytes`, `TypeDef:Int64`, and `TypeDef:Uint64`
as alknet-typedef additions) and produces three capabilities:
1. **An offset map** — walks the schema, computes byte offsets for each
field based on type sizes, field order, and alignment.
2. **Read/write functions** — given a `&[u8]` buffer and a field path,
read the field's bytes at its offset (zero-copy for fixed-size types).
Given a `&mut [u8]` buffer, write a value at its offset.
3. **Validation** — via `jsonschema` custom keywords, validates that a
buffer's bytes match the schema's type constraints.
The heavy lifting is done by the `jsonschema` crate (validation) and
`serde_json` (schema parsing). The novel code is the offset computation
— a recursive walk of the schema JSON that computes byte positions for
each field. The custom keyword implementations are small (a few lines
each, generated from shared macros — see [validation.md](validation.md)).
The crate replaces two prior attempts that built their own jsonschema
engines — typebox-rs (~8,400 lines) and alktype (~5,600 lines) — with
`jsonschema` + an offset map + small custom keyword implementations. See
[ADR-095](../../decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md).
## Why
The crate's purpose is to be the binary struct engine for every alknet
component that reads or writes binary data at computed offsets. Instead
of per-protocol serde structs (russh-sftp's 29 packet types), per-handler
wire format code (TTY's 5-byte format parser), or per-format offset
computation (metatensor's tensor access), all of these become instances
of the same engine with different schemas.
The guiding insight:
> **The schema is the format.** A JSON Schema with `TypeDef:Float32`,
> `TypeDef:Struct`, `TypeDef:Union` etc. is both the validation spec and
> the layout spec. No separate format definition, no separate parser, no
> separate validator. One schema, three uses: validate, compute offsets,
> access data.
This is the convergence of three threads identified in the
call-channels-unification research: the `typedef.ts` schema kinds from
TypeBox, the russh-sftp protocol packets, and the metatensor format. The
common pattern: a JSON Schema describes the shape of binary data, and
the binary data is the struct's bytes at computed offsets.
The crate was bumped up in the timeline when the call-channels-unification
research surfaced that channels, TTY, and the binary call protocol are
all variations on the same wire-format family — `[discriminant][length][payload]`.
The typedef engine makes the "channels is call with a binary data plane"
unification concrete: the binary data plane's wire format is the call
protocol's own schema system, just binary-encoded. The `channel_open`
marker says "use binary framing"; the typedef engine says "here's how to
read/write the binary payload."
## The "Schema Is the Format" Principle
A JSON Schema with `TypeDef:*` custom keywords serves three roles
simultaneously:
| Role | Mechanism | When |
|------|-----------|------|
| **Validation spec** | `jsonschema` custom keywords | Load time (build validator), access time (validate buffer) |
| **Layout spec** | Offset computation from type sizes + field order | Load time (build offset map) |
| **Data access** | Read/write at computed offsets | Access time (read field, write field) |
No separate format definition, no separate parser, no separate validator.
The schema is the single source of truth for the binary format. Adding a
new field to a protocol is adding a property to the schema JSON — the
engine computes the new offsets automatically.
This is the same principle as `#[repr(C)]` struct field access, but at
runtime from a portable JSON Schema instead of at compile-time from
language-specific annotations. The schema is the ABI contract.
## Dependencies
```
alknet-typedef
├── jsonschema (v0.46.5, Draft 2020-12) — validation engine, custom keyword support
├── serde_json (with preserve_order) — schema parsing; field order is load-bearing
└── (no tokio, no platform deps) — WASM-clean by construction
```
`alknet-typedef` is dependency-light: `jsonschema` + `serde_json` only.
No tokio, no platform deps. Compiles to `wasm32-unknown-unknown` for
browser use. The `jsonschema` crate is already in the workspace at
`/workspace/jsonschema/` but not yet used by any alknet crate — typedef
is the first consumer.
`serde_json` requires the `preserve_order` feature because field order
is load-bearing for binary layouts. The order of properties in the
schema JSON determines the order of fields in the binary struct.
## Consumers
| Consumer | Schema describes | Engine provides |
|----------|-----------------|-----------------|
| russh-sftp | 29 packet structs + Packet union (byte discriminator) | Read/write SFTP frames from bytes |
| metatensor | Model layout (ConvNet struct, tensor refs) | Offset map for mmap'd tensor access |
| binary call frames | `call.requested` / `call.responded` / etc. structs | Read/write binary call frames |
| TTY negotiation | `NegotiateRequest` / `NegotiateResponse` structs | Read/write TTY control frames |
| channels wire | `ChunkHeader { channel_id, length }` | Already trivial (8 bytes, no schema needed) |
The russh-sftp case is the most instructive and the highest-value POC
target. The `Packet` enum's `TryFrom<&mut Bytes>` impl is a hand-written
dispatch on a type byte followed by serde deserialization. Under typedef,
the dispatch is `TUnion` with a byte-offset discriminator — the schema
says "byte 0 is the discriminator, bytes 1..N are the variant struct."
The engine reads the discriminator, looks up the variant schema, computes
offsets, reads fields. Same result, no per-packet-type code.
## Scope Boundaries (What This Is Not)
These boundaries are decided in [ADR-095](../../decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md).
- **Not metatensor.** typedef is the binary struct *engine*. Metatensor
is a *format* (8-byte header + JSON header + binary data) that uses the
typedef engine for its offset computation and tensor access.
- **Not a Value system.** TypeBox's `Value.Diff`, `Value.Migrate`,
`Value.Convert` — schema evolution — is out of scope for v1. The engine
should not do anything that explicitly blocks adding a Value system
later.
- **Not a code generator.** typebox-rs's `codegen/` module is a separate
concern. The typedef engine consumes schemas; it does not generate them.
- **Not a schema builder.** The typedef engine does not provide a fluent
API for constructing schemas. Schemas are plain JSON — authored in
TypeBox, generated by ujsx components, or hand-written. A builder API
is deferred (OQ-071).
- **Not a serialization framework.** The typedef engine is not a
general-purpose serde replacement. It operates on raw byte buffers at
computed offsets — no intermediate `Value` tree, no reflection, no
dynamic dispatch per field. For JSON data, use serde. For binary data
with a known schema, use typedef.
## Architecture (component pointers)
- **[schema-layer.md](schema-layer.md)** — the 19 `TypeDef:*` kinds,
jsonschema custom keyword integration, TypeBox interop, schema
annotations (endianness, alignment, encoding, TUnion discriminators).
- **[layout-engine.md](layout-engine.md)** — offset computation, the two
layout modes (packed sequential vs aligned static), alignment,
endianness, variable-length field handling.
- **[data-access.md](data-access.md)** — read/write functions, TUnion
dispatch, field paths, zero-copy access for fixed-size types,
length-prefix reading for variable-length types.
- **[validation.md](validation.md)** — custom keyword validators for all
19 `TypeDef:*` kinds, `TypedefError`, load-time vs access-time
validation, `TypedefEngine` as the compiled form of a schema.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Purpose, scope, and the jsonschema engine | [ADR-095](../../decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md) | What the crate is/isn't; why jsonschema not a custom engine; "schema is the format" principle; scope boundaries |
| Two layout modes | [ADR-096](../../decisions/096-two-layout-modes-packed-vs-aligned.md) | Packed sequential (`LayoutBuilder`/`SequentialReader`) for protocols; aligned static (`OffsetMap`) for mmap formats |
| Schema annotations | [ADR-097](../../decisions/097-schema-annotations.md) | Endianness (schema-level, default LE), alignment (struct + field-level), encoding (length-prefixed vs offset-indirect), TUnion discriminators (byte-offset vs field-name) |
| Error handling and validation | [ADR-098](../../decisions/098-error-handling-validation-strategy.md) | `TypedefError` enum; load-time build, access-time check; field-path-carrying errors; jsonschema `ValidationError` wrapping |
| Int64/Uint64 kinds | [ADR-099](../../decisions/099-int64-uint64-first-class-kinds.md) | 64-bit integers as first-class kinds (SFTP offsets, metatensor data_offsets) |
| Non-final inline variable fields | [ADR-100](../../decisions/100-reject-non-final-inline-length-prefixed-in-aligned-mode.md) | Rejected in aligned mode (would clobber subsequent fields) |
| Packed-mode read factory | [ADR-101](../../decisions/101-packed-mode-read-factory.md) | `engine.sequential_reader()` returns an owned fresh reader |
| TUnion in aligned mode | [ADR-102](../../decisions/102-reject-tunion-in-aligned-mode.md) | Rejected for v1 (broken semantics; no current consumer needs it) |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-069** (deferred(scope)): Arrays of variable-length-element structs.
- **OQ-070** (deferred(scope)): `no_std` + `alloc` support.
- **OQ-071** (deferred(scope)): Builder API for schema construction.
## References
- `docs/research/alknet-typedef/findings.md` — POC results (26 tests
passing, two layout modes, TUnion dispatch, endianness)
- `docs/research/call-channels-unification/findings.md` §"alknet-typedef:
JSON Schema as the binary struct engine" — the origin of this research
thread
- `/workspace/@alkdev/typebox/example/typedef/typedef.ts` — the TypeBox
schema kinds (619 lines)
- `/workspace/jsonschema/` — the jsonschema crate (v0.46.5, Draft 2020-12)
- `/workspace/alknet-typedef-poc/` — the POC code (disposable)
- `/workspace/@alkimiadev/typebox-rs/` — prior attempt, replaced by typedef
- `/workspace/@alkimiadev/alktype/` — prior attempt, replaced by typedef
@@ -0,0 +1,506 @@
---
status: draft
last_updated: 2026-07-22
---
# alknet-typedef — Schema Layer
The schema layer: the 19 `TypeDef:*` custom type kinds, their mapping to
Rust types and byte sizes, the `jsonschema` custom keyword integration,
TypeBox interop, and the concrete JSON shapes for schema-level
annotations.
## The 19 TypeDef Kinds
These are the custom schema kinds defined in TypeBox's `typedef.ts`
(`/workspace/@alkdev/typebox/example/typedef/typedef.ts`, 619 lines) and
ported to Rust via `jsonschema` custom keywords. Each kind carries binary
layout semantics — a known byte size (for fixed-size types) or a known
encoding strategy (for variable-length types).
| Kind | TypeBox key | Rust type | Size | Category |
|------|-------------|-----------|------|----------|
| `TFloat32` | `TypeDef:Float32` | `f32` | 4 | fixed |
| `TFloat64` | `TypeDef:Float64` | `f64` | 8 | fixed |
| `TInt8` | `TypeDef:Int8` | `i8` | 1 | fixed |
| `TInt16` | `TypeDef:Int16` | `i16` | 2 | fixed |
| `TInt32` | `TypeDef:Int32` | `i32` | 4 | fixed |
| `TInt64` | `TypeDef:Int64` | `i64` | 8 | fixed |
| `TUint8` | `TypeDef:Uint8` | `u8` | 1 | fixed |
| `TUint16` | `TypeDef:Uint16` | `u16` | 2 | fixed |
| `TUint32` | `TypeDef:Uint32` | `u32` | 4 | fixed |
| `TUint64` | `TypeDef:Uint64` | `u64` | 8 | fixed |
| `TBoolean` | `TypeDef:Boolean` | `bool` (0x00=false, 0x01=true) | 1 | fixed |
| `TString` | `TypeDef:String` | length-prefixed UTF-8 | variable | variable |
| `TBytes` | `TypeDef:Bytes` | length-prefixed raw bytes | variable | variable |
| `TStruct` | `TypeDef:Struct` | record of fields | sum of field sizes | composite |
| `TUnion` | `TypeDef:Union` | tagged union | discriminator + variant | composite |
| `TArray` | `TypeDef:Array` | repeated element | count × element size | composite |
| `TEnum` | `TypeDef:Enum` | u32 index into enum values | 4 (fixed) | fixed |
| `TRecord` | `TypeDef:Record` | count-prefixed sequence of (key, value) pairs | variable | variable |
| `TTimestamp` | `TypeDef:Timestamp` | length-prefixed RFC 3339 string | variable | variable |
`TypeDef:Int64` and `TypeDef:Uint64` are alknet-typedef additions —
TypeBox's `typedef.ts` tops out at 32-bit integers. They are required by
the primary POC targets: SFTP `Read`/`Write` packets have `offset: u64`,
and metatensor `data_offsets` are `u64`. See
[ADR-099](../../decisions/099-int64-uint64-first-class-kinds.md).
### The `TypeDefKind` enum
The engine represents the 19 kinds as a Rust enum — `TypeDefKind` — with
one variant per kind (`TypeDefKind::Float32`, `TypeDefKind::Struct`, etc.).
The enum provides compile-time exhaustiveness checking and integer
discriminant dispatch (a jump table) instead of string comparison at
every field access. It is `pub` and re-exported from the crate root.
```rust
pub enum TypeDefKind {
Int8, Int16, Int32, Int64,
Uint8, Uint16, Uint32, Uint64,
Float32, Float64,
Boolean, Enum,
String, Bytes, Timestamp,
Struct, Union, Array, Record,
}
```
The enum carries the kind's binary-layout metadata as inherent methods:
| Method | Returns | Notes |
|--------|---------|-------|
| `as_str(self)` | `&'static str` | The JSON Schema keyword, e.g. `"TypeDef:Uint8"` |
| `type_size(self)` | `Option<usize>` | `Some(N)` for fixed-size kinds; `None` for variable/composite |
| `natural_alignment(self)` | `usize` | 1 for u8/i8/bool, 2 for u16/i16, 4 for u32/i32/f32/enum, 8 for u64/i64/f64, 4 for variable-length (the u32 length prefix), 1 for struct/union/array |
| `is_fixed_size(self)` | `bool` | True for the 12 fixed-size primitive kinds |
| `is_composite(self)` | `bool` | True for Struct, Union, Array, Record |
| `is_variable_length(self)` | `bool` | True for String, Bytes, Timestamp, Record |
| `needs_endian(self)` | `bool` | True for kinds whose read/write takes an `Endian` parameter |
`TypeDefKind` implements `Display` (renders the keyword string) and
`FromStr` (parses the keyword string back into the variant, returning
`TypedefError::Schema` for unknown kinds). The layout engines and the
validator dispatch on the enum, not on strings.
### Fixed-size types
`TFloat32`, `TFloat64`, `TInt8`, `TInt16`, `TInt32`, `TUint8`, `TUint16`,
`TUint32`, `TBoolean`, and `TEnum` have known byte sizes. The offset
computation uses these sizes directly. Read/write is zero-copy pointer
cast for these types.
**`TBoolean` byte representation:** `0x00` = false, `0x01` = true. Other
values are invalid and produce a `TypedefError::Access` on read.
**`TEnum` binary representation:** A `u32` index into the enum's declared
values, in declaration order. The first declared value is index 0, the
second is index 1, etc. The enum's values are declared via the standard
JSON Schema `"enum"` keyword (e.g., `"enum": ["read", "write", "execute"]`).
The `TypeDef:Enum` custom keyword signals that the type is an enum for
layout purposes; the built-in `enum` keyword provides the value list.
**Design note:** TypeBox's `TEnum` is a string enum (variable-length). The
typedef engine uses a `u32` index instead — a deliberate deviation from
TypeBox fidelity in favor of binary efficiency. Most enums have a small
number of variants (e.g., the call protocol's 5 event types); a `u32`
index is compact, fixed-size, and sufficient for any realistic enum. The
JSON representation (for validation) remains a string; the binary
representation is the `u32` index.
The `u32` index follows the schema's endianness annotation (ADR-097), like
all other fixed-size types. In little-endian mode the index is
`u32::from_le_bytes`; in big-endian mode it is `u32::from_be_bytes`.
### Variable-length types
`TString`, `TBytes`, `TRecord`, and `TTimestamp` have variable byte sizes.
The typedef engine supports three strategies for handling variable-length
types in binary layouts, selected by the `encoding` annotation and the
standard JSON Schema `maxLength` keyword:
| Strategy | Encoding annotation | Layout behavior | Use case |
|----------|-------------------|-----------------|----------|
| **Inline length-prefixed** | `"length-prefixed"` (default) | `[length: u32][data]`; shifts subsequent fields in packed mode | Protocol wire formats (SFTP, channels, TTY) |
| **Fixed-size reservation** | (none — uses `maxLength`) | `[data: maxLength bytes]`, zero-padded; fixed offset in aligned mode | mmap-friendly formats where max size is known (database `VARCHAR(N)` pattern) |
| **Offset indirection** | `"offset-indirect"` | `{offset: u32, length: u32}` pointing into a separate data region | Blob tensors, metatensor variable-length data (the blob tensor pattern) |
**Strategy 1: Inline length-prefixing (default).** The field's fixed
portion is a 4-byte length prefix at a computed offset. The variable data
follows immediately after. In packed sequential mode, the length prefix
determines the position of subsequent fields. In aligned static mode, the
length prefix is at a known offset; the variable data is not included in
the static layout. This is the universal pattern used by channels, SFTP,
TTY, and most binary protocols.
**Strategy 2: Fixed-size reservation.** When a variable-length field
declares `maxLength` (a standard JSON Schema keyword), the engine reserves
`maxLength` bytes at a fixed offset in aligned static mode. Data shorter
than `maxLength` is zero-padded; data longer than `maxLength` is a
validation error. This makes the field fixed-size from the layout
perspective — subsequent fields have known, unchanging offsets. This is
the database `VARCHAR(N)` pattern and the metatensor struct-tensor
pattern for fields with known maximum sizes.
In packed sequential mode, `maxLength` is a validation constraint only —
the engine still uses inline length-prefixing (strategy 1) because
protocols don't benefit from fixed-size reservation.
**Strategy 3: Offset indirection.** The field is a struct
`{offset: u32, length: u32}` at a known position. The consumer provides
the data region separately; the engine reads the offset and length, then
slices the data region. This is the metatensor blob tensor pattern — the
index struct lives in one region, the blob data lives in another. Enables
mmap-friendly random access to variable-length data without parsing
length prefixes and without reserving worst-case space.
**Default strategy selection:**
- In packed sequential mode: always strategy 1 (inline length-prefixing).
`maxLength` is a validation constraint only.
- In aligned static mode: strategy 2 (fixed-size reservation) if
`maxLength` is declared; strategy 3 (offset indirection) if
`"encoding": "offset-indirect"` is declared; strategy 1 (inline
length-prefixing) otherwise.
**Length prefix endianness:** The 4-byte length prefix (strategies 1 and 3)
respects the schema's `"endian"` annotation (ADR-097). In little-endian
mode, the length is `u32::from_le_bytes`. In big-endian mode, the length
is `u32::from_be_bytes`. This ensures SFTP consumers (big-endian) have
consistent byte order for both field values and length prefixes.
**`TBytes`:** Raw bytes — no UTF-8 constraint. The payload is `&[u8]`.
Otherwise identical to `TString` in layout (same three strategies).
**Design note:** `TypeDef:Bytes` is an alknet-typedef addition — it does
not exist in TypeBox's `typedef.ts` (which defines 16 kinds). It is
included because raw byte arrays are a common binary protocol primitive
(SFTP data payloads, channels payloads, tensor data) and are semantically
distinct from UTF-8 strings. In the binary representation, TBytes is raw
bytes with no encoding (not base64, not hex). In the JSON representation
(for validation), TBytes is a string (JSON has no native byte type).
**`TRecord`:** A string-keyed map. The value type is declared via the
schema's `"values"` property (e.g., `"values": { "TypeDef:Float32": true }`).
Binary layout is a count-prefixed sequence of `(key, value)` pairs:
`[count: u32][key_len: u32][key_bytes][value]...` repeated `count` times.
The count is the number of entries. Each key is a length-prefixed UTF-8
string. Each value is encoded according to its declared `TypeDef:*` kind
— a `Record<Uint32>` value is 4 raw bytes; a `Record<String>` value is
itself a length-prefixed string; a `Record<Struct>` value is the struct's
fields laid out inline. There is **no separate `value_len` prefix** —
the value's size is determined by its kind (fixed-size kinds have a
known size; variable-length kinds carry their own length prefix). The
count and key-length prefixes respect the schema's endianness. In
aligned static mode with `maxLength`, the entire record is reserved at
`maxLength` bytes (zero-padded).
**`TTimestamp`:** An RFC 3339 timestamp string (the internet profile of
ISO 8601). Stored as a length-prefixed UTF-8 string (strategy 1) or
fixed-size reservation (strategy 2 with `maxLength`). The data-access
layer treats timestamps as opaque length-prefixed strings — it does not
parse or validate the timestamp format. The jsonschema custom keyword
validator checks RFC 3339 conformance at the JSON level (see
[validation.md](validation.md)).
`TArray` is variable-length when the element type is variable-length or
when the count is not known at schema time. For fixed-size element arrays
with a known count, the size is `element_size × count`.
**`TArray` count declaration:** The array count is declared via the
standard JSON Schema `"minItems"` and `"maxItems"` keywords. When
`minItems == maxItems`, the array has a fixed count known at schema time.
When they differ or are absent, the count is variable and the array uses
a length-prefixed encoding: `[count: u32][element_0]...[element_N]`.
The count prefix respects the schema's endianness.
### Composite types
`TStruct` and `TUnion` are composite — their size is the sum of their
fields' sizes (plus alignment padding in aligned static mode). The offset
computation recurses into their properties.
## Schema-Layer Public API
The `schema` module exposes the foundational types and functions every
other module depends on. These are re-exported from the crate root.
### `get_typedef_kind` vs `get_typedef_kind_loose`
The engine recognizes a `TypeDef:*` kind on a schema node two ways,
because the keyword value may be either a boolean (`true`) or an
annotation object (`{ "encoding": "..." }`):
| Function | Recognizes | Returns |
|----------|------------|---------|
| `get_typedef_kind(node) -> Option<&str>` | Boolean form only (`{ "TypeDef:String": true }`) | The keyword string, e.g. `"TypeDef:String"` |
| `get_typedef_kind_loose(node) -> Option<&str>` | Boolean form **and** object form | The keyword string |
| `get_typedef_kind_enum(node) -> Option<TypeDefKind>` | Boolean form only | The parsed enum variant |
| `get_typedef_kind_loose_enum(node) -> Option<TypeDefKind>` | Boolean form **and** object form | The parsed enum variant |
The boolean-form-only functions are used by the validator factories
(which reject the object form as a schema error) and the top-level
kind-check in `OffsetMap::compute` / `LayoutBuilder::new` / `SequentialReader::new`
(which require `TypeDef:Struct` at the root). The "loose" variants are
used by the layout engines during field traversal, so that a variable-
length field with an `encoding` annotation (`{ "TypeDef:String":
{ "encoding": "offset-indirect" } }`) is still recognized as a `String`.
### Annotation parsers
Each schema-level annotation has a dedicated parser that reads it from a
`serde_json::Value` node and returns a sensible default when absent:
| Function | Annotation | Default |
|----------|------------|---------|
| `parse_endian(node) -> Endian` | `"endian"` | `Endian::Little` |
| `parse_align(node) -> Option<usize>` | `"align"` | `None` |
| `parse_max_length(node) -> Option<usize>` | `"maxLength"` | `None` |
| `parse_encoding(keyword_value) -> VariableEncoding` | `"encoding"` (within the keyword's value object) | `VariableEncoding::LengthPrefixed` |
| `parse_discriminator(node) -> Result<DiscriminatorKind, TypedefError>` | `"discriminator"` | (required — returns `TypedefError::Schema` if absent) |
### Public enums
```rust
pub enum Endian { Little, Big }
pub enum VariableEncoding { LengthPrefixed, OffsetIndirect }
pub enum DiscriminatorKind {
Byte { offset: usize, disc_type: TypeDefKind },
Field { name: String },
}
```
`DiscriminatorKind::Byte` carries the byte position (`offset`) and the
discriminator's `TypeDef:*` kind (`disc_type`, restricted to `Uint8`/
`Uint16`/`Uint32`). `DiscriminatorKind::Field` carries the discriminator
field's name. See [data-access.md](data-access.md) §"TUnion Dispatch" for
how these drive dispatch.
### `$ref` resolution and normalization
| Function | Purpose |
|----------|---------|
| `normalize_refs(schema: &mut Value)` | Walks the schema; rewrites every `"$ref"` whose value is a bare name (no `#` prefix) to `"#/$defs/<name>"`. Idempotent. Runs once at `TypedefEngine::compile` time. |
| `resolve_ref(root, ref_path) -> Option<&Value>` | Resolves a JSON Pointer `$ref` (e.g. `"#/$defs/Read"`) against the root schema. |
| `resolve_ref_or_inline(node, root) -> Option<&Value>` | If `node` has a `"$ref"`, resolves it against `root`; otherwise returns `node` itself (it's an inline schema). |
`normalize_refs` bridges TypeBox's bare-name ref output and `jsonschema`'s
JSON Pointer requirement. The layout engines call `resolve_ref_or_inline`
on every `$ref`-bearing node they encounter during traversal.
## jsonschema Custom Keyword Integration
The `jsonschema` crate (v0.46.5, Draft 2020-12) supports custom keywords
via the `with_keyword` API. Each `TypeDef:*` kind is registered as a
custom keyword:
```rust
let validator = jsonschema::options()
.with_keyword("TypeDef:Float32", factory)
.with_keyword("TypeDef:Int32", factory)
.with_keyword("TypeDef:Struct", factory)
// ... all 17 kinds
.build(&schema)?;
```
The factory closure receives the parent schema object, the keyword's
value, and the schema path — enabling cross-keyword awareness. The
`TypeDef:Struct` validator, for example, inspects the parent's
`properties` to validate each field against its declared `TypeDef:*` kind.
Each custom keyword implementation is ~10 lines. The `jsonschema` crate
handles all structural validation (object properties, required fields,
array items, enum values) — the custom keywords only need to validate
the leaf type constraints. See [validation.md](validation.md) for the
validator implementations.
This is the same pattern as TypeBox's `TypeRegistry.Set` on the JS side.
Same semantics, different language, same JSON Schema wire format. A
TypeBox schema serialized to JSON feeds into the typedef engine after a
single pre-processing step: normalizing `$ref` values (see below).
## TypeBox Interop
TypeBox modules render to standard JSON Schema under `$defs`. A TypeBox
schema like:
```typescript
const TensorRef = Type.Object({
dtype: Type.Union([Type.Literal("F32"), Type.Literal("I16")]),
shape: Type.Array(Type.Number()),
data_offsets: Type.Tuple([Type.Number(), Type.Number()])
});
```
serialized to JSON is a standard JSON Schema with `type: "object"`,
`properties`, and `required`. That JSON feeds into the typedef engine
after `$ref` normalization. The `TypeDef:*` custom keywords are added by
TypeBox's `TypeRegistry.Set` — they appear in the serialized JSON as
additional properties on the schema object.
### `$ref` normalization
TypeBox generates bare-name `$ref` values (e.g., `"$ref": "Read"`),
referencing sibling definitions within the same `$defs` block. The
`jsonschema` crate requires full JSON Pointer paths (e.g.,
`"$ref": "#/$defs/Read"`). The typedef engine normalizes TypeBox-style
refs at schema load time via [`normalize_refs`](#ref-resolution-and-normalization)
— a ~20-line recursive walk that rewrites every bare-name `"$ref"` to
`"#/$defs/<name>"`. The normalization is idempotent — full JSON Pointer
refs pass through unchanged. It runs once at `TypedefEngine::compile`
time, before the schema is passed to `jsonschema` or the offset
computation.
**Verification:** The jsonschema crate (v0.46.5) rejects bare-name refs
with `Resource 'Read' is not present in a registry`. Full JSON Pointer
refs (`#/$defs/Read`) resolve correctly. The normalization step bridges
the gap between TypeBox's output and jsonschema's input.
The typedef engine does not depend on TypeBox or any JS toolchain. It
consumes JSON — whether that JSON was authored in TypeBox, generated by
a ujsx component, or hand-written. The schema is the interface.
## Schema Annotations
Schema-level annotations control binary layout behavior. These are
decided in [ADR-097](../../decisions/097-schema-annotations.md).
### Endianness
Schema-level annotation with a default of little-endian:
```json
{ "TypeDef:Struct": true, "endian": "big", "properties": { ... } }
```
- `"endian": "little"` (default) — read/write in little-endian byte order.
- `"endian": "big"` — read/write in big-endian byte order.
- Applies to the entire schema and all nested types.
### Alignment
Both struct-level and field-level, with field-level overriding:
```json
{
"TypeDef:Struct": true,
"align": 256,
"properties": {
"weight": { "TypeDef:Float32": true, "align": 16 }
}
}
```
- Struct-level `"align"` sets the default for all fields.
- Field-level `"align"` overrides the struct default.
- Default alignment: 1 for u8/i8/bool, 2 for u16/i16, 4 for u32/i32/f32/
enum, 8 for u64/i64/f64, 4 for variable-length (the u32 length prefix),
1 for struct/union/array.
- Only meaningful in aligned static mode (ADR-096). Ignored in packed
sequential mode.
### Variable-length encoding
The typedef engine supports three strategies for variable-length types
(see §Variable-length types above for full details). The strategy is
selected by the `encoding` annotation and the standard JSON Schema
`maxLength` keyword:
```json
// Strategy 1: Inline length-prefixing (default, shorthand)
{ "TypeDef:String": true }
// Strategy 1: Explicit inline length-prefixing
{ "TypeDef:String": { "encoding": "length-prefixed" } }
// Strategy 2: Fixed-size reservation (uses standard maxLength)
{ "TypeDef:String": true, "maxLength": 256 }
// Strategy 3: Offset indirection (opt-in)
{ "TypeDef:String": { "encoding": "offset-indirect" } }
```
- `"encoding": "length-prefixed"` (default) — 4-byte length prefix at
computed offset, variable data follows immediately. Used by protocol
wire formats.
- `maxLength` (standard JSON Schema keyword) — in aligned static mode,
reserves `maxLength` bytes at a fixed offset (zero-padded). Makes the
field fixed-size from the layout perspective. In packed sequential
mode, `maxLength` is a validation constraint only.
- `"encoding": "offset-indirect"` — field is a struct
`{offset: u32, length: u32}` pointing into a separate data region.
The consumer provides the data region separately. Used by metatensor
blob tensors.
- Applies to all variable-length types: `TypeDef:String`, `TypeDef:Bytes`,
`TypeDef:Array`, `TypeDef:Record`, `TypeDef:Timestamp`.
### TUnion discriminators
Two discriminator kinds: byte-offset (protocol dispatch) and field-name
(typedef.ts pattern).
**Byte-offset discriminator** (SFTP type bytes, call protocol event types):
```json
{
"TypeDef:Union": true,
"discriminator": {
"kind": "byte",
"offset": 0,
"type": "TypeDef:Uint8"
},
"mapping": {
"5": { "$ref": "#/$defs/Read" },
"6": { "$ref": "#/$defs/Write" },
"101": { "$ref": "#/$defs/Status" }
}
}
```
- `"offset"` — byte position of the discriminator.
- `"type"` — the `TypeDef:*` kind of the discriminator (typically
`TypeDef:Uint8`).
- Mapping keys are stringified integers. The variant struct starts at
`offset + discriminator_size`.
**Field-name discriminator** (typedef.ts pattern):
```json
{
"TypeDef:Union": true,
"discriminator": {
"kind": "field",
"name": "type"
},
"mapping": {
"read": { "$ref": "#/$defs/Read" },
"write": { "$ref": "#/$defs/Write" }
}
}
```
- `"name"` — the field name holding the discriminator value.
- Mapping keys are string values matching the discriminator field's value.
- The discriminator field is just another field in the struct.
Mapping values may be either inline schemas or `$ref` pointers. Both work.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Schema annotations | [ADR-097](../../decisions/097-schema-annotations.md) | Concrete JSON shapes for endianness, alignment, encoding, and TUnion discriminators |
| Int64/Uint64 kinds | [ADR-099](../../decisions/099-int64-uint64-first-class-kinds.md) | 64-bit integers as first-class kinds (required by SFTP offsets and metatensor data_offsets) |
| Purpose and scope | [ADR-095](../../decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md) | Why jsonschema not a custom engine; "schema is the format" principle |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-071** (deferred(scope)): Builder API for schema construction.
## References
- `/workspace/@alkdev/typebox/example/typedef/typedef.ts` — the TypeBox
schema kinds (619 lines)
- `/workspace/jsonschema/` — the jsonschema crate (v0.46.5, Draft 2020-12)
- [ADR-097](../../decisions/097-schema-annotations.md) — schema
annotation shapes
- [validation.md](validation.md) — custom keyword validator implementations
@@ -0,0 +1,335 @@
---
status: draft
last_updated: 2026-07-22
---
# alknet-typedef — Validation
The validation layer: custom keyword validators for all 19 `TypeDef:*`
kinds, the `TypedefError` enum, load-time vs access-time validation
strategy, and the `TypedefEngine` as the compiled form of a schema.
## Validation Strategy
Validation is delegated to the `jsonschema` crate (v0.46.5, Draft
2020-12). The typedef engine does not implement its own validation —
it registers custom keyword validators for each `TypeDef:*` kind and
lets `jsonschema` handle the structural validation (object properties,
required fields, array items, enum values).
The strategy is decided in [ADR-098](../../decisions/098-error-handling-validation-strategy.md):
1. **Load time:** Parse the schema JSON, build the layout engine, build the
jsonschema validator. This is the `TypedefEngine::compile(schema)` constructor.
2. **Access time:** Use the compiled engine for repeated read/write
operations. Validation is opt-in per operation.
### What validation validates
The jsonschema validator operates on `serde_json::Value` instances — it
validates JSON representations of data, not raw byte buffers. This is
the correct separation of concerns:
- **JSON validation** (jsonschema): validates that a JSON document
conforms to the schema. Used for validating hand-written schemas,
TypeBox output, JSON payloads, or the JSON representation of a binary
struct after deserialization.
- **Binary access validation** (data access layer): the read/write
functions perform type-level validation at access time — range checks
for integers, UTF-8 validity for strings, buffer bounds checking.
These return `TypedefError::Access` with field paths.
The "schema is the format" principle means the same schema describes
both the JSON shape and the binary layout. The jsonschema validator
checks the JSON shape; the data access layer checks the binary layout.
A consumer that wants to validate a binary buffer end-to-end reads the
buffer into a `Value` tree via the data access layer, then validates
that `Value` against the jsonschema validator. This is a two-step
process, not a single `validate(buffer)` call.
### The `TypedefEngine` struct
The `TypedefEngine` is the compiled form of a schema. It supports both
layout modes (ADR-096) via an internal `Layout` enum:
```rust
pub struct TypedefEngine {
layout: Layout, // packed or aligned (private enum)
validator: jsonschema::Validator, // compiled once at load time
endian: Endian, // parsed from the schema's "endian" annotation
schema: Value, // the normalized schema (refs resolved)
}
// Private — the consumer selects via LayoutMode at compile time.
enum Layout {
Packed { builder: LayoutBuilder },
Aligned { offset_map: OffsetMap },
}
```
The consumer selects the mode at construction time via `LayoutMode`
(see [layout-engine.md](layout-engine.md) §"Mode Selection"). The `Layout`
enum is private — the engine exposes mode-appropriate accessors instead:
```rust
impl TypedefEngine {
pub fn compile(schema: &mut Value, mode: LayoutMode) -> Result<Self, TypedefError>;
pub fn mode(&self) -> LayoutMode;
pub fn endian(&self) -> Endian;
pub fn offset_map(&self) -> Option<&OffsetMap>; // Some in aligned mode
pub fn layout_builder(&self) -> Option<&LayoutBuilder>; // Some in packed mode
pub fn sequential_reader(&self) -> Option<SequentialReader>; // owned fresh reader (ADR-101)
}
```
`compile` takes `&mut Value` because it normalizes `$ref` values in place
(via [`normalize_refs`](schema-layer.md#ref-resolution-and-normalization))
before computing the layout and building the validator. The `schema`
field retains the normalized schema for `read_field`'s kind lookup and
for `sequential_reader()`'s factory construction. The validator is
mode-agnostic (it operates on `Value`, not raw bytes).
The `Layout::Packed` variant stores only the `LayoutBuilder` (write-side).
The `SequentialReader` (read-side) is not stored — it has mutable cursor
state that the consumer owns, so `sequential_reader()` constructs a fresh
reader on each call (ADR-101).
The `read_field`/`write_field` methods on `TypedefEngine` are the
aligned-mode data-access API — see [data-access.md](data-access.md)
§"Higher-level read/write".
## Custom Keyword Validators
Each `TypeDef:*` kind gets a `Keyword` implementation registered via
`jsonschema::options().with_keyword(...)`. The validators check leaf
type constraints; `jsonschema` handles all structural validation.
### Numeric type validators
**`TypeDef:Float32` / `TypeDef:Float64`:**
- Value must be a finite number.
- For `Float32`: value must be representable as `f32` (no precision loss
beyond `f32`'s mantissa).
**`TypeDef:Int8` / `TypeDef:Int16` / `TypeDef:Int32`:**
- Value must be an integer within the type's range.
- Int8: -128..127, Int16: -32768..32767, Int32: -2147483648..2147483647.
**`TypeDef:Uint8` / `TypeDef:Uint16` / `TypeDef:Uint32`:**
- Value must be a non-negative integer within the type's range.
- Uint8: 0..255, Uint16: 0..65535, Uint32: 0..4294967295.
### String and binary validators
**`TypeDef:String`:**
- Value must be a valid UTF-8 string.
- If `maxLength` is specified in the schema, the string's byte length
must not exceed it.
**`TypeDef:Bytes`:**
- Value must be a string (JSON represents binary data as a string — JSON
has no native byte type).
- If `maxLength` is specified, the byte length must not exceed it.
- **Binary representation:** In the binary layout, `TBytes` is raw bytes
with no encoding (not base64, not hex). The JSON representation (for
validation) uses a string; the binary representation (for data access)
uses `&[u8]` directly.
**`TypeDef:Enum`:**
- The `TypeDef:Enum` custom keyword signals that the type is an enum for
*layout* purposes (the engine needs to know it's a fixed-size u32 index,
not a variable-length string). The built-in `enum` keyword provides the
value list and handles value-membership validation. The custom keyword
validator is a no-op beyond the built-in check — it exists solely for
the layout engine to recognize the type.
**`TypeDef:Timestamp`:**
- Value must be a valid RFC 3339 timestamp string (the internet profile
of ISO 8601, e.g., `"2026-07-20T15:30:00Z"`).
### Composite type validators
**`TypeDef:Struct`:**
- Value must be an object.
- Each property must match its declared `TypeDef:*` kind.
- Required fields must be present.
- The `jsonschema` crate's built-in `properties` and `required` keywords
handle the structural checks — the custom keyword only needs to
validate that each field's value matches its `TypeDef:*` kind.
**`TypeDef:Union`:**
- The discriminator value must be one of the mapping keys.
- The variant struct must match the declared schema for that discriminator
value.
**`TypeDef:Array`:**
- Value must be an array.
- Each element must match the array's declared element type.
- If `minItems`/`maxItems` is specified, the array length must be within
bounds.
### Other validators
**`TypeDef:Boolean`:**
- Value must be `true` or `false`.
**`TypeDef:Record`:**
- Value must be an object.
- All values must match the record's declared value type (specified via
the `"values"` property in the schema, e.g.,
`"values": { "TypeDef:Float32": true }`).
### Validator implementation pattern
Each custom keyword implementation is ~10 lines. Example for
`TypeDef:Float32`:
```rust
struct Float32Validator;
impl Keyword for Float32Validator {
fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> {
match instance {
Value::Number(n) if n.as_f64().map_or(false, |f| f.is_finite()) => Ok(()),
_ => Err(ValidationError::custom("expected finite f32-compatible number")),
}
}
fn is_valid(&self, instance: &Value) -> bool {
instance.as_f64().map_or(false, |f| f.is_finite())
}
}
```
Registration:
```rust
let validator = jsonschema::options()
.with_keyword("TypeDef:Float32", |parent, value, path| {
Ok(Box::new(Float32Validator))
})
.build(&schema)?;
```
The factory closure receives the parent schema object, the keyword's
value, and the schema path. This enables cross-keyword awareness — for
example, a `TypeDef:Struct` validator can inspect the parent's
`properties` to validate each field against its declared `TypeDef:*` kind.
## TypedefError
A single `TypedefError` enum covers all error conditions across the
engine's three phases (schema parsing, offset computation, read/write)
plus validation. Decided in [ADR-098](../../decisions/098-error-handling-validation-strategy.md).
```rust
pub enum TypedefError {
/// Schema parsing errors (invalid JSON, missing keywords, unknown TypeDef kinds).
Schema(String),
/// Offset computation errors (field not found, unsupported type).
Offset { field_path: String, reason: String },
/// Read/write errors (buffer too short, invalid UTF-8, value out of range).
Access { field_path: String, reason: String },
/// Validation errors (delegated to jsonschema).
Validation(ValidationError<'static>),
}
```
- **`Schema`** — for errors during `TypedefEngine::compile()`. Invalid
JSON, missing required keywords, unknown `TypeDef:*` kinds.
- **`Offset`** — for errors during offset computation. Field not found
in the schema, type not supported for offset computation, recursive
depth exceeded. Carries the field path.
- **`Access`** — for errors during read/write. Buffer too short, invalid
UTF-8 in a string field, value out of range for the target type.
Carries the field path.
- **`Validation`** — wraps `jsonschema`'s `ValidationError`. The
`'static` lifetime is correct — the validator owns its schema reference
and lives for the lifetime of the `TypedefEngine`.
### Field-path-carrying errors
Read/write and offset errors include the field path for debugging:
```rust
Err(TypedefError::Access {
field_path: "header.version".to_string(),
reason: "buffer too short: need 4 bytes at offset 12, have 2".to_string(),
})
```
This makes debugging binary format issues tractable — the error tells
you exactly which field failed and why.
## Validation Timing
### Load time: `TypedefEngine::compile()`
The expensive work happens once at schema load time:
1. Normalize `$ref` values in the schema (`normalize_refs`).
2. Parse the schema's `"endian"` annotation.
3. Compute the layout (`LayoutBuilder`/`SequentialReader` for packed, `OffsetMap` for aligned).
4. Build the jsonschema validator (`jsonschema::options().with_keyword(...).build(&schema)?`).
The result is a `TypedefEngine` that can be used for repeated operations.
### Access time: `engine.validate_json(&Value)` / `engine.is_valid_json(&Value)`
Validation is opt-in per operation. The consumer calls
`engine.validate_json(instance)` when validation is desired, or
`engine.is_valid_json(instance)` for a boolean check. The jsonschema
validator is already compiled — these are fast checks against the
compiled validator.
```rust
pub fn validate_json(&self, instance: &Value) -> Result<(), TypedefError>;
pub fn is_valid_json(&self, instance: &Value) -> bool;
```
The argument is a `serde_json::Value` (the JSON representation of the
data), not a raw byte buffer — see §"What validation validates" above.
To validate a binary buffer end-to-end, the consumer reads it into a
`Value` tree via the data access layer, then validates that `Value`.
High-throughput paths can skip validation. Security-sensitive paths
(parsing incoming frames from untrusted peers) can validate every frame.
The choice is the consumer's.
## Relationship to Read/Write
Validation and data access are independent operations on the same data.
The consumer can:
1. Validate the JSON representation of a buffer to ensure it conforms to
the schema.
2. Read fields from the binary buffer at computed offsets.
3. Both — validate the JSON representation first, then read the binary
buffer (defense in depth).
The engine does not couple validation and access. A consumer that trusts
its data source can skip validation and go straight to read/write. A
consumer that parses untrusted input can validate the JSON
representation first, then access the binary buffer.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Error handling and validation | [ADR-098](../../decisions/098-error-handling-validation-strategy.md) | `TypedefError` enum; load-time build, access-time check; field-path-carrying errors; jsonschema `ValidationError` wrapping |
| Purpose and scope | [ADR-095](../../decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md) | Why jsonschema not a custom engine |
## Open Questions
None specific to validation. The three typedef OQs (OQ-069, OQ-070,
OQ-071) are about layout, platform support, and schema construction —
not validation.
## References
- `docs/research/alknet-typedef/findings.md` §"Validation" — the POC's
custom keyword validators for all 17 kinds
- [ADR-098](../../decisions/098-error-handling-validation-strategy.md) —
error handling and validation strategy
- [schema-layer.md](schema-layer.md) — the 17 TypeDef kinds that the
validators check
- [data-access.md](data-access.md) — read/write functions that operate
on the same buffers
@@ -2,7 +2,9 @@
## Status
Accepted
Accepted (amended 2026-07-18 — Phase 7: the control channel is split
into `STREAM_CTRL_IN = 3` (client→server) and `STREAM_CTRL_OUT = 4`
(server→client) halves; see §"Control channel split" below)
## Context
@@ -123,12 +125,15 @@ The bidi stream has two phases:
| 0 | data-in (stdin) | client→server | raw bytes |
| 1 | data-out (stdout) | server→client | raw bytes |
| 2 | data-err (stderr) | server→client | raw bytes |
| 3 | control | bidirectional | JSON control message |
| 3 | ctrl-in | client→server | JSON control message (`Resize`, `Signal`, `Eof` — see §4a) |
| 4 | ctrl-out | server→client | JSON control message (`Exit` — see §4a) |
`stream_type > 3` is a protocol error (`InvalidStreamType`). There is
no extension escape hatch in the byte — a 5th channel is a wire-format
change requiring a new ALPN (`alknet/tty/v2` per ADR-006), not a
negotiated addition to this format.
`stream_type > 4` is a protocol error (`InvalidStreamType`). (Phase 7
amendment, §4a: the original `3 = control (bidirectional)` is split
into `3 = ctrl-in` and `4 = ctrl-out`; the original bound was
`> 3`.) There is no extension escape hatch in the byte — a 6th
channel is a wire-format change requiring a new ALPN (`alknet/tty/v2`
per ADR-006), not a negotiated addition to this format.
- `length` — payload length in bytes, u32 big-endian, max 16 MiB. A
chunk larger than 16 MiB is a protocol error (`ChunkTooLarge`). The
@@ -176,6 +181,53 @@ tearing down the session. This is a two-way-door extension point within
the one-way-door wire format — adding a control message type is
additive; changing the chunk header is not.
### 4a. Control channel split (Phase 7 amendment, 2026-07-18)
The single `stream_type 3 = control (bidirectional)` in §3 and §4 above
is **split into two halves** so the control channel is genuinely
bidirectional on the wire:
| stream_type | channel | direction | payload |
|-------------|-------------|----------------|------------------------------------------------------|
| 3 | `STREAM_CTRL_IN` | client→server | JSON control message (`Resize`, `Signal`, `Eof`) |
| 4 | `STREAM_CTRL_OUT` | server→client | JSON control message (`Exit`) |
The `stream_type > 3` protocol-error bound becomes `stream_type > 4`.
The chunk header is otherwise unchanged (5 bytes: 1 type + 4 length).
**Why the split.** The original §4 documented stream_type 3 as
"bidirectional" and listed the four control messages with their
directions. But the adapter had no way to distinguish the two
directions on the same stream_type — `Exit` from the client was always
ignored (the adapter's `pump_client_to_backend` matched `Exit` and
logged "ignoring Exit control from client (server→client only)"). The
spec said "bidirectional"; the code was half-duplex. The split makes
the bidirectionality literal: each direction has its own stream_type,
the adapter enforces the direction (an `Exit` arriving on
`STREAM_CTRL_IN` is a protocol violation; a `Resize` arriving on
`STREAM_CTRL_OUT` is a protocol violation), and a client can route
exit vs. control without parsing the JSON `type` tag first.
**Door type.** One-way, same as the original §3 / §4. The stream_type
set is bytes clients and servers parse. A client written against the
old single-`STREAM_CONTROL` shape will misread `STREAM_CTRL_OUT = 4` as
`InvalidStreamType (> 3)` and tear down the session — the split is a
wire-format change, not an additive extension. The reversal path is
the same as the original: a new ALPN (`alknet/tty/v2`), which coexists
rather than replaces. The trade is one new stream_type byte now vs.
the half-duplex-in-disguise flaw forever.
**What changes in the spec.** §3's stream_type table gains a 5th row
(`4 = ctrl_out, server→client`); the bound becomes `> 4`. §4's
direction table is unchanged in content (the four messages keep their
directions and shapes) but the direction is now encoded in the
stream_type, not just in the adapter's behavior. ADR-055's "exit chunk
is last" invariant is unchanged — the exit chunk still rides the
control channel, just on `STREAM_CTRL_OUT` (stream_type 4) instead of
the old single `STREAM_CONTROL` (stream_type 3). See
`docs/research/alknet-crate-extraction/findings.md` Phase 7 for the
full migration notes.
### 5. Negotiation errors use the JSON framing, not the raw chunk format
If the server cannot allocate the session (unknown backend, PTY
@@ -280,7 +280,11 @@ the args are optional for their transport.
- ADR-002: ProtocolHandler trait (unchanged by this ADR)
- ADR-007: BiStream type definition (amended by ADR-065; this ADR does not
touch `BiStream`)
touch `BiStream`; amended by ADR-092 — `BiStream` is the concrete handler
leaf, not a bare trait)
- ADR-092: `BiStream` as the handler leaf (amends this ADR's `accept_bi`
return type — `(SendStream, RecvStream)` → `BiStream`; the trait shape
and the `from_source` extension point are preserved)
- ADR-009: One-way door decision framework (why `ProtocolHandler` is not
changed — this ADR is additive to `Connection`, not a trait revision)
- ADR-010: ALPN router and endpoint (the endpoint constructs `Connection`s
@@ -1,9 +1,39 @@
# ADR-071: alknet-channels Wire Format — 9-Byte Chunk Header
# ADR-071: alknet-channels Wire Format — 8-Byte Chunk Header
## Status
Accepted (revised 2026-07-12: substrate simplification + stream_type
decomposition)
decomposition; **amended 2026-07-18 by ADR-093: wire format is 8 bytes,
not 9; `stream_type` removed from the channels header — see "Amendment
(ADR-093, 2026-07-18)" below**)
## Amendment (ADR-093, 2026-07-18)
The 9-byte chunk header is **amended to 8 bytes**:
`[channel_id:u32 BE][length:u32 BE][payload]`. The `stream_type` byte is
**removed** from the channels header — the channels layer has no
`stream_type` concept, not in its header, not in its code, not in its
mental model. The handler owns its sub-stream multiplexing on the
`BiStream` the channels layer gives it (per ADR-093, the channels-layer
consequence of ADR-092's `BiStream` handler leaf). What was the channels
header's `stream_type` byte is now the first byte of the payload, owned
by the handler's framing (TTY's 5-byte format, call's length-prefixed
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
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
(`ch_len = tty_len + 5`). This is the documented cost of clean
separation of concerns — see ADR-093 §"Consequences" for the full
cost/benefit.
The body below describes the **current** (9-byte) shape; the amendment
above is the operative decision. The 9-byte description is kept as the
historical context for the amendment. See ADR-093 for the resolution
rationale and the cross-ADR impacts.
## Context
@@ -239,17 +269,29 @@ tokio-dependent shell.
## Door type
**One-way.** The chunk header layout (`channel_id:u32 + stream_type:u8 +
length:u32`) and the stream_type group assignments (0/1/2 = data, 3/4/5 =
length:u32`, 9 bytes) and the stream_type group assignments (0/1/2 = data, 3/4/5 =
control, `% 3` formula) are wire-format commitments. Changing them after
deployments exist requires a version migration.
**Amended by ADR-093 (2026-07-18):** the header layout is now
`channel_id:u32 + length:u32` (8 bytes); the `stream_type` byte and its
decomposition are removed from the channels layer. The one-way door is
re-cast (the channels crate is not yet implemented, so this is the right
time to cast it). See ADR-093 for the amended door-type discussion.
The `MAX_CHUNK_LEN` value (16 MiB) is a two-way-door implementation detail
within the one-way format.
## References
- **ADR-093**: channels pure channel multiplexing (amends this ADR —
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;
amended by ADR-077 — scoped to direct TTY)
amended by ADR-077 — scoped to direct TTY; **re-amended by ADR-093 —
TTY always uses its 5-byte format, carried transparently in the
channels payload**)
- ADR-065: `Connection::from_stream` (the transport-agnostic Connection)
- ADR-070: `BidiStreamSource` trait (the extension point the channels
connection implements; its docstring already anticipated per-channel
@@ -2,7 +2,27 @@
## Status
Accepted
Accepted (amended 2026-07-18 by ADR-093 — channel 0's `stream_types`
field is removed; the channels layer has no `stream_type` concept; the
call protocol's `EventEnvelope` framing is the channels payload, carried
transparently — see "Amendment (ADR-093, 2026-07-18)" below)
## Amendment (ADR-093, 2026-07-18)
Channel 0's `stream_types` field (the `[0, 1]` active set) is **removed**.
The channels layer has no `stream_type` concept (ADR-093) — it carries the
call protocol's `EventEnvelope` framing (ADR-064) transparently in the
8-byte header's payload. The call protocol's bidirectionality (client
writes requests, server writes responses) is a call-protocol concern,
not a channels-layer concern; the channels layer routes by `channel_id`
only and yields a `BiStream` to the `CallAdapter`. The `CallAdapter`'s
`accept_bi()` returns one `BiStream` (per ADR-092); the call protocol
reads/writes `EventEnvelope` frames on it, exactly as on a top-level
`alknet/call` connection.
The body below describes the **original** (with `stream_types`) shape;
the amendment above is the operative decision. See ADR-093 for the
resolution rationale and the cross-ADR impacts.
## Context
@@ -121,7 +141,11 @@ unused; assigning them is additive).
## References
- ADR-071: channels wire format (the 9-byte chunk header channel 0 uses)
- ADR-071: channels wire format (the 8-byte chunk header channel 0 uses,
as amended by ADR-093)
- ADR-093: channels pure channel multiplexing (amends this ADR —
channel 0's `stream_types` field removed; the call protocol's framing
is the channels payload, carried transparently)
- ADR-073: channel lifecycle operations (registered on channel 0's
`OperationRegistry`)
- ADR-064: irpc never integrated — hand-rolled EventEnvelope framing (the
@@ -2,7 +2,26 @@
## Status
Accepted
Accepted (amended 2026-07-18 by ADR-093 — `stream_types` field removed
from `channel/open`; `stream_type` field removed from `channel/control`;
`channel:stream_type_unavailable` error code removed; the channels layer
has no `stream_type` concept — see "Amendment (ADR-093, 2026-07-18)"
below)
## Amendment (ADR-093, 2026-07-18)
The `stream_types` field is **removed** from `channel/open`'s input and
output. The `stream_type` field is **removed** from `channel/control`'s
input. The `channel:stream_type_unavailable` error code is **removed**.
The channels layer has no `stream_type` concept (ADR-093) — the handler
owns its sub-stream multiplexing on the `BiStream` it receives. The
handler's sub-stream set is implicit in its ALPN's wire format (e.g.,
TTY's 5-byte format declares its own `stream_type` set internally; the
channels layer carries the bytes transparently).
The body below describes the **original** (with `stream_types`) shape;
the amendment above is the operative decision. See ADR-093 for the
resolution rationale and the cross-ADR impacts.
## Context
@@ -292,7 +311,11 @@ the underlying one-way commitment.
## References
- ADR-071: channels wire format
- ADR-071: channels wire format (amended by ADR-093 — 8-byte header, no
`stream_type`)
- ADR-093: channels pure channel multiplexing (amends this ADR —
`stream_types` field removed from `channel/open`; `stream_type` field
removed from `channel/control`; handler owns sub-stream multiplexing)
- ADR-072: channel 0 is pre-negotiated `alknet/call`
- ADR-049: StreamingHandler for subscriptions (the machinery
`channel/resources/subscribe` uses — implemented and tested)
@@ -2,7 +2,29 @@
## Status
Accepted
Accepted (**amended 2026-07-18 by ADR-093: `into_sub_streams()` removed;
`accept_bi` is the only accessor, yields one `BiStream` per channel —
see "Amendment (ADR-093, 2026-07-18)" below**)
## Amendment (ADR-093, 2026-07-18)
`into_sub_streams()`, `ChannelSubStreams`, and `SubStreamHandle` are
**removed**. The channels layer exposes one accessor: `accept_bi()`,
which yields one `BiStream` per channel (per ADR-092, already landed).
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
design (`accept_bi` for generic handlers, `into_sub_streams` for typed
handlers) collapses to one accessor.
The body below describes the **original** (two-accessor) shape; the
amendment above is the operative decision. The two-accessor description
is kept as the historical context for the amendment. See ADR-093 for
the resolution rationale (the channels layer has no `stream_type`
concept; the handler owns its sub-stream multiplexing) and the
cross-ADR impacts.
## Context
@@ -194,6 +216,11 @@ rewrite of those handlers' integration code. The trait impl is in the
channels crate (not core), so the one-way door is the channels crate's API,
not a core type.
**Amended by ADR-093 (2026-07-18):** `into_sub_streams()` is removed;
`accept_bi` is the only accessor. The one-way door is re-cast (the
channels crate is not yet implemented, so this is the right time). See
ADR-093 for the amended door-type discussion.
The choice of `into_sub_streams()` returning `Vec<(u8, SendStream,
RecvStream)>` (vs a typed struct, vs a map) is a two-way-door implementation
detail — the return type can change without breaking the contract as long
@@ -201,6 +228,12 @@ as the handler crate's destructure code updates.
## References
- **ADR-093**: channels pure channel multiplexing (amends this ADR —
`into_sub_streams()` removed; `accept_bi` is the only accessor, yields
one `BiStream` per channel; the handler owns its sub-stream
multiplexing)
- ADR-092: `BiStream` as the handler leaf (the transport-leaf decision
this ADR's amendment builds on — `accept_bi` returns `BiStream`)
- ADR-070: BidiStreamSource trait (the extension point this implements)
- ADR-065: Connection::from_stream (the yield-once path this generalizes for
channels)
@@ -2,7 +2,26 @@
## Status
Accepted
Accepted (amended 2026-07-18 by ADR-093 — demux reads 8-byte headers, not
9-byte; one reassembly buffer per `channel_id` (not per
`(channel_id, stream_type)`); `ChannelState.stream_types` removed; the
channels layer has no `stream_type` concept — see "Amendment (ADR-093,
2026-07-18)" below)
## Amendment (ADR-093, 2026-07-18)
The demux loop reads **8-byte headers** (not 9-byte). `ChannelState` has
**one reassembly buffer per `channel_id`** (not per
`(channel_id, stream_type)`), yielding a `BiStream` to the handler. The
`stream_types: Vec<u8>` field on `ChannelState` is **removed**. The
`ChannelManager` has no `stream_type` concept — it routes by `channel_id`
only, and the handler owns its sub-stream multiplexing on the `BiStream`
it receives (per ADR-093, the channels-layer consequence of ADR-092's
`BiStream` handler leaf).
The body below describes the **original** (9-byte, per-stream_type) shape;
the amendment above is the operative decision. See ADR-093 for the
resolution rationale and the cross-ADR impacts.
## Context
@@ -224,11 +243,14 @@ contract.
## References
- ADR-071: channels wire format (the chunks the demux reads)
- ADR-071: channels wire format (the chunks the demux reads, as amended
by ADR-093 — 8-byte header)
- ADR-093: channels pure channel multiplexing (amends this ADR — 8-byte
header, one reassembly buffer per channel, no `stream_type` concept)
- ADR-072: channel 0 pre-negotiated (the `preinstall_channel_0` step)
- ADR-073: channel lifecycle operations (the ops registered on `call_ops`)
- ADR-074: ChannelBidiStreamSource (the per-channel source the manager
constructs)
constructs, as amended by ADR-093 — `accept_bi` yields a `BiStream`)
- ADR-076: backpressure, channel limits, ID reuse (the `buffer_cap` /
`max_channels` / reuse invariants)
- `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #4-#6
@@ -2,7 +2,73 @@
## Status
Accepted
Accepted (amended 2026-07-18 by ADR-093 — backpressure is per-`channel_id`,
not per-`(channel_id, stream_type)`; the channels layer has one reassembly
buffer per channel, yielding a `BiStream` — see "Amendment (ADR-093,
2026-07-18)" below; **amended 2026-07-19 by ADR-094 — the per-connection
`max_channels = 256` is reframed as a per-connection memory bound, not a
DoS defense; the per-identity DoS defense lives in `channels-call` via
`ChannelLifecyclePolicy` — see "Amendment (ADR-094, 2026-07-19)" below**)
## Amendment (ADR-094, 2026-07-19)
The per-connection `max_channels = 256` cap is **reframed as a
per-connection memory bound**, not a DoS defense. A single peer can
open an unbounded number of transport connections, so a per-connection
cap is not a per-peer DoS defense — it is a bound on one connection's
reassembly-buffer cost. The per-identity DoS defense (256 per
`PeerId`, enforced in `channels-call` via `ChannelLifecyclePolicy`)
is documented in [ADR-094](094-per-identity-channel-cap.md).
What changes in this ADR:
1. **§"Maximum channels per connection: 256 default"** — the cap stays
at 256, but its role is reframed. It is a per-connection memory
bound (limits one connection's reassembly-buffer cost regardless of
policy), not the DoS defense against an authenticated peer. The
per-identity DoS defense is the `ChannelLifecyclePolicy`
consultation in the `channel/open` handler (ADR-094).
2. **§"DoS defense summary"** — the table is **removed**. It framed
the per-connection cap as the DoS defense, which it is not. ADR-094
§2 contains the corrected per-identity DoS defense summary.
3. **The "per-connection, not per-peer — a peer can open more channels
on a second connection" line** — this was the channels layer
confessing a hole and hoping the layer above it would fill it. The
line is **corrected** to state that the per-connection cap is a
memory bound, and that the per-identity cap is the DoS defense
(ADR-094). A peer that opens a second connection gets a second
per-connection memory bound; it does **not** get a second
per-identity quota — the `ChannelLifecyclePolicy` is shared across
connections.
What stays:
- The 256 default and the `max_channels` field on `ChannelManager`
(still returns `channel:too_many_channels` when hit — the
per-identity policy returns the same error code, so an over-cap
peer sees the same error either way).
- The bounded-buffer backpressure decision (DP-5) — unchanged.
- The channel-ID reuse decision (monotonic `next_id` with
wrap-around) — unchanged.
- The drain-before-reuse invariant — unchanged, and the
`channel/close` handler now also calls
`ChannelLifecyclePolicy::on_close` at this point (ADR-094 §3).
## Amendment (ADR-093, 2026-07-18)
The bounded-buffer backpressure is per-`channel_id` (not per
`(channel_id, stream_type)`). The channels layer has one reassembly
buffer per channel (yielding a `BiStream`), not one per
`(channel_id, stream_type)`. The 1 MiB default and the 256-channel cap are
unchanged; the per-channel memory ceiling is 1 MiB (was up to 5 MiB for a
TTY channel with 5 active stream_types under the per-stream_type model).
This is a net improvement (lower memory ceiling per channel), not a
regression. The bounded-buffer *approach* is unchanged; only the
buffer granularity changes (per-channel, not per-stream_type).
The body below describes the **original** (per-stream_type) shape; the
amendment above is the operative decision. See ADR-093 for the resolution
rationale.
## Context
@@ -69,36 +135,47 @@ default `max_channels` of 256, the `u32` space is effectively unlimited
which time old channels are long drained. **The "reuse" in OQ-CH-04 is
satisfied by the wrap-around, not by a free-list.**
### Maximum channels per connection: 256 default (OQ-CH-05/06)
### Maximum channels per connection: 256 default (OQ-CH-05/06 — memory bound)
The `channel_id` is `u32` — the wire format supports ~4 billion channels.
The practical limit is memory (reassembly buffers per channel) and the
transport's flow control.
**Default per-connection channel limit: 256** (`max_channels` field on
`ChannelManager`, configurable). This is the DoS defense (OQ-CH-06): an
authenticated peer that opens many channels and never reads from them is
bounded by `max_channels × buffer_cap` = 256 × 1 MiB = 256 MiB worst case.
Bounded buffers (DP-5) limit the damage per channel; the connection cap
limits the number of channels. Defense in depth.
`ChannelManager`, configurable). This is a **per-connection memory
bound**: it limits one connection's reassembly-buffer cost (256 × 1 MiB
= 256 MiB worst case per connection) regardless of policy. It composes
with the per-identity DoS defense (ADR-094) but is not itself a DoS
defense — a peer can open an unbounded number of transport connections,
so a per-connection cap cannot bound a peer's total channels. The
per-identity DoS defense (256 per `PeerId`, enforced in `channels-call`
via `ChannelLifecyclePolicy`) is documented in
[ADR-094](094-per-identity-channel-cap.md).
Exceeding the limit returns `channel:too_many_channels` (ADR-073 error
codes). The limit is per-connection, not per-peer — a peer can open more
channels on a second connection.
Exceeding the per-connection limit returns `channel:too_many_channels`
(ADR-073 error codes) — the same error code the per-identity policy
returns when the per-identity cap is hit. An over-cap peer sees the
same error either way; which cap fired first is an implementation
detail. The limit is per-connection as a memory bound; the per-identity
cap (ADR-094) is what bounds a peer's total channels across all its
connections.
### DoS defense summary (OQ-CH-06)
| Layer | Mechanism | Default |
|-------|-----------|---------|
| Per-channel | Bounded reassembly buffer (stop reading when full) | 1 MiB per `(channel_id, stream_type)` |
| Per-connection | Channel count cap | 256 channels |
| Per-peer | Auth (`AccessControl::check` on `channel/open`) | Assembly-layer policy |
The DoS defense against an authenticated peer opening many channels is
the **per-identity cap** enforced in `channels-call` via
`ChannelLifecyclePolicy` — documented in
[ADR-094](094-per-identity-channel-cap.md). A per-connection cap
cannot be the DoS defense because a peer can open an unbounded number
of transport connections; the unit that must be bounded is the
identity, not the connection.
An authenticated peer that opens 256 channels and never reads from them
consumes at most 256 MiB of reassembly buffers — bounded, not unbounded.
The assembly layer's `AccessControl` policy can further restrict
`channel/open` (e.g., `required_scopes: ["channel:open:alknet/tty"]`) to
limit who can open channels at all.
The per-connection `max_channels = 256` (this ADR) is a **memory
bound** that limits one connection's reassembly-buffer cost. It
composes with the per-identity cap as defense-in-depth (the
`NoCap` policy path still has the per-connection memory bound), but
it is not the security boundary. See ADR-094 §2 for the corrected
DoS defense summary.
## Consequences
@@ -106,18 +183,21 @@ limit who can open channels at all.
- Bounded-buffer backpressure is validated by the POC (1 MiB test, no
deadlock, no cross-channel blocking). The decision is made, not hedged.
- The 256-channel default cap with 1 MiB buffers gives a bounded 256 MiB
worst-case memory per connection — a clear DoS ceiling, not an open-ended
one.
worst-case memory per connection — a clear per-connection memory
ceiling, not an open-ended one. The per-identity DoS ceiling (256 per
`PeerId` across all the peer's connections) is documented in ADR-094.
- Monotonic `next_id` with wrap-around avoids free-list drain-tracking
complexity while still satisfying ID reuse (on wrap, after ~16.7M
channels).
**Negative:**
- The 256-channel default may be too low for a hub with many concurrent
browser sessions each opening multiple channels. The cap is configurable
per `ChannelManager`; the hub assembly layer may set it higher for
deployments with many concurrent sessions. This is a deployment-time
decision, not an architecture decision.
- The 256-channel per-connection cap may be too low for a hub with many
concurrent browser sessions each opening multiple channels. The cap
is configurable per `ChannelManager`; the hub deployment may set it
higher for deployments with many concurrent sessions. This is a
deployment-time decision, not an architecture decision. (The
per-identity cap in ADR-094 is the DoS-relevant bound; the
per-connection cap is a memory backstop.)
- 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
@@ -134,9 +214,23 @@ doesn't change the wire format, so even that reversal is feasible.
## References
- ADR-071: channels wire format (the chunks the buffers hold)
- ADR-073: channel lifecycle operations (`channel:too_many_channels` error)
- ADR-075: ChannelManager (`buffer_cap`, `max_channels`, `next_id` fields)
- ADR-071: channels wire format (the chunks the buffers hold, as amended
by ADR-093)
- ADR-093: channels pure channel multiplexing (amends this ADR —
per-channel reassembly buffer, not per-`(channel_id, stream_type)`)
- ADR-094: per-identity channel cap as DoS defense (amends this ADR —
the per-connection `max_channels = 256` is reframed as a per-connection
memory bound, not a DoS defense; the per-identity DoS defense lives in
`channels-call` via `ChannelLifecyclePolicy`)
- ADR-073: channel lifecycle operations (`channel:too_many_channels`
error; the `channel/open` and `channel/close` handlers that gain the
`ChannelLifecyclePolicy` consultation)
- ADR-075: ChannelManager (`buffer_cap`, `max_channels`, `next_id`
fields; the auth-blindness that forces the per-identity cap into
`channels-call`, not `channels-core`)
- ADR-032: forwarded-for identity (why the spoke caps the hub, not the
browser — `forwarded_for` is metadata, not authority, for the cap as
for `AccessControl::check`)
- `docs/research/alknet-channels/poc-summary.md` §POC Target 1 (backpressure
validation), §POC Target 3 (1 MiB tunnel test)
- `docs/research/alknet-channels/phase-0-findings.md` §DP-5, §OQ-CH-03/04/
@@ -2,7 +2,40 @@
## Status
Accepted
Accepted (**reversed 2026-07-18 by ADR-093: TTY always uses its 5-byte
format; the channels layer carries it transparently in the payload —
see "Reversal (ADR-093, 2026-07-18)" below**)
## Reversal (ADR-093, 2026-07-18)
The two-mode TTY design (direct vs inside-channels, with different
sub-stream access paths) is **reversed**. TTY's 5-byte format
(`[stream_type:u8][length:u32][payload]`, ADR-052) is TTY's internal
format, used in **both** direct mode and inside-channels mode. The two
modes differ only in *where the `BiStream` comes from* (a top-level
`alknet/tty` connection vs a `channel/open` with ALPN `alknet/tty`), not
in *how TTY parses it*. The same `wire.rs` code runs in both modes.
When TTY is inside channels, the channels layer strips its 8-byte header
(ADR-093) and hands TTY the payload bytes. TTY parses its 5-byte header
from the payload. The channels layer carries TTY's 5-byte chunks
transparently in its payload — no shared fields, no leaked abstraction,
no double-chunking concern (the 13-byte total header is 8 channels + 5
TTY, not 8 + 9; the channels `length` is always `tty_len + 5`).
The `channels` feature on `alknet-tty` becomes "run TTY's sub-demux on a
channels-backed `BiStream`" — the same code as direct mode, different
`BiStream` source. The control channel split (`STREAM_CTRL_IN` /
`STREAM_CTRL_OUT`, Phase 7) is TTY-internal; the channels layer doesn't
know about it. ADR-074's `into_sub_streams()` (the accessor this ADR's
two-mode design relied on) is removed by ADR-093; TTY sub-demuxes its
`BiStream` via its own 5-byte format instead.
The body below describes the **original** (two-mode) shape; the reversal
above is the operative decision. The two-mode description is kept as
the historical context for the reversal. See ADR-093 for the resolution
rationale (the channels layer has no `stream_type` concept; the handler
owns its sub-stream multiplexing) and the cross-ADR impacts.
## Context
@@ -178,16 +211,32 @@ re-merging the formats would require unifying 5-byte and 9-byte chunk
handling, which is a rewrite. The `channels` feature gate is two-way — it
can be removed if channels integration is no longer needed.
**Reversed by ADR-093 (2026-07-18):** the two-mode design is reversed —
TTY always uses its 5-byte format, carried transparently in the channels
payload. The one-way door is re-cast (the channels crate is not yet
implemented, so this is the right time). See ADR-093 for the amended
door-type discussion.
## References
- ADR-052: alknet-tty wire format (amended — scoped to direct connections)
- **ADR-093**: channels pure channel multiplexing (reverses this ADR —
TTY always uses its 5-byte format; the channels layer carries it
transparently; the two-mode design is preserved but differs only in
`BiStream` source, not in parsing)
- ADR-052: alknet-tty wire format (amended — scoped to direct connections
by this ADR; **re-amended by ADR-093 — TTY always uses its 5-byte
format, in both direct and inside-channels modes**)
- ADR-053: TtyBackend trait and TtyHandle (unchanged by this ADR)
- ADR-055: exit-chunk-is-last (generalized by this ADR + ADR-073)
- ADR-057: alknet-tty does not depend on alknet-call (preserved — the
channels feature is on alknet-channels, not alknet-call)
- ADR-071: channels wire format (the 9-byte format the channels path uses)
- ADR-071: channels wire format (the 9-byte format the channels path
uses; **amended by ADR-093 — 8-byte format, no `stream_type`**)
- ADR-074: ChannelBidiStreamSource / `into_sub_streams` (the accessor the
channels path uses)
channels path uses; **amended by ADR-093 — `into_sub_streams()`
removed**)
- ADR-092: `BiStream` as the handler leaf (the transport-leaf decision
that enables the reversal — `accept_bi` returns `BiStream`)
- ADR-061: DockerTtyBackend in alknet-docker (the feature-gated dependency
pattern this ADR mirrors)
- `docs/research/alknet-channels/phase-0-findings.md` §DP-3, §OQ-CH-02,
@@ -128,8 +128,13 @@ existing, so shape convergence is observable).
## References
- ADR-074: ChannelBidiStreamSource (the `accept_bi` that yields the stream
pair the pumps operate on)
- ADR-074: ChannelBidiStreamSource (the `accept_bi` that yields the
`BiStream` the pumps operate on, as amended by ADR-093)
- ADR-093: channels pure channel multiplexing (the `BiStream`-only
accessor decision this ADR's two-pump pattern builds on)
- ADR-092: `BiStream` as the handler leaf (the transport-leaf decision —
`accept_bi` returns `BiStream`; the two-pump pattern calls
`tokio::io::split(bidi)` for its halves)
- ADR-055: exit-chunk-is-last (the three-pump TTY invariant — the pattern
this ADR does NOT touch)
- `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #7 (the
@@ -113,11 +113,13 @@ do the protocol work.
This ADR defines the relay *contract* (translate channel 0, byte-forward
data channels with ID rewrite) so the channels crate's `ChannelManager`
exposes the interface the relay needs (`open_channel_stream(channel_id,
stream_type) -> (SendStream, RecvStream)` for the byte-forward pumps). The
relay *implementation* lives in `alknet-hub` (or a downstream hub like
alkapi), not in `alknet-channels`. The channels crate is ALPN-blind and
does not know it is being relayed.
exposes the interface the relay needs (`open_channel_stream(channel_id)
-> BiStream` for the byte-forward pumps). The relay *implementation*
lives in `alknet-hub` (or a downstream hub like alkapi), not in
`alknet-channels`. The channels crate is ALPN-blind and does not know it
is being relayed. The `channel_id` rewrite is a 4-byte field rewrite
within the 8-byte header (per ADR-093); the relay does not parse the
payload.
## Consequences
@@ -170,6 +172,9 @@ auth path. The `channel_id` mapping strategy (`HashMap` per pair) is two-way
terminates on each leg)
- ADR-073: channel lifecycle operations (what the hub translates)
- ADR-075: ChannelsAdapter and ChannelManager (the interface the relay uses)
- ADR-093: channels pure channel multiplexing (the 8-byte header the relay
reads/writes; the 4-byte `channel_id` rewrite; the `BiStream`-yielding
`open_channel_stream` interface)
- `docs/research/alknet-channels/phase-0-findings.md` §Hub Motivation,
§The hub relay, §OQ-CH-11
- `docs/architecture/crates/hub/README.md` — the hub crate (the relay
@@ -4,7 +4,28 @@
Accepted (amended 2026-07-12 — see "Amendment: transport-agnostic API"
below; amended 2026-07-16 — `connect_quic` removed per ADR-089 §5, see
"Amendment: `connect_quic` removed" below)
"Amendment: `connect_quic` removed" below; **amended 2026-07-18 by
ADR-093 — `stream_types` field removed from `open_channel` and `Channel`;
the channels layer has no `stream_type` concept, see "Amendment
(ADR-093, 2026-07-18)" below**)
## Amendment (ADR-093, 2026-07-18)
The `stream_types: &[u8]` field is **removed** from `open_channel`'s
signature, and `pub stream_types: Vec<u8>` is **removed** from the
`Channel` struct. The channels layer has no `stream_type` concept
(ADR-093) — the handler owns its sub-stream multiplexing on the
`BiStream` it receives via `Channel.source` (a `ChannelBidiStreamSource`
whose `accept_bi` yields a `BiStream` per ADR-092). The handler's
sub-stream set is implicit in its ALPN's wire format (e.g., TTY's 5-byte
format declares its own `stream_type` set internally; the channels
layer carries the bytes transparently). The `into_sub_streams()` reference
in the `Channel.source` doc comment is moot — `into_sub_streams()` is
removed by ADR-093 (amending ADR-074).
The body below describes the **original** (with `stream_types`) shape;
the amendment above is the operative decision. See ADR-093 for the
resolution rationale and the cross-ADR impacts.
## Amendment: `connect_quic` removed (2026-07-16, per ADR-089 §5)
@@ -265,7 +286,11 @@ decided now.
## References
- ADR-073: channel lifecycle operations (`open_channel` sends `channel/open`)
- ADR-074: ChannelBidiStreamSource (what `Channel.source` wraps)
- ADR-074: ChannelBidiStreamSource (what `Channel.source` wraps, as
amended by ADR-093 — `accept_bi` yields a `BiStream`)
- ADR-093: channels pure channel multiplexing (`stream_types` field
removed from `open_channel` and `Channel`; handler owns sub-stream
multiplexing)
- ADR-075: ChannelManager (the shared state `ChannelClient` holds)
- OQ-55: AlknetClient / client establishment extraction (the deferred core
concern this ADR does NOT block on)
@@ -2,7 +2,25 @@
## Status
Accepted
Accepted (amended 2026-07-18 by ADR-093 — the 9-byte wire format is now
8-byte; `ChannelSubStreams` / `SubStreamHandle` removed; the channels
layer has no `stream_type` concept — see "Amendment (ADR-093, 2026-07-18)"
below)
## Amendment (ADR-093, 2026-07-18)
The wire format in `channels-core` is now **8-byte** (not 9-byte); the
`ChannelSubStreams` / `SubStreamHandle` typed destructure accessor is
**removed** (the channels layer has no `stream_type` concept —
`accept_bi` yields a `BiStream`, and the handler owns its sub-stream
multiplexing). The channel 0 pre-negotiation in `channels-call` no
longer constructs "reassembly buffers with `stream_types` [0, 1]" — it
constructs one reassembly buffer for `channel_id = 0`, yielding a
`BiStream` to the `CallAdapter`. The "What moves where" table's "9-byte
wire format" row is now "8-byte wire format"; the `ChannelSubStreams`
row is removed. The two-crate split (`channels-core` /
`channels-call`), the dep graph, and the "hub and worker are consumers"
principle are unchanged. See ADR-093 for the resolution rationale.
## Context
@@ -212,10 +230,15 @@ the dependency direction (hub/worker → channels, not channels → hub/worker).
- ADR-003: crate decomposition (no-handler-depends-on-another-handler —
preserved; the channels sub-crates depend on core/call, not on handlers)
- ADR-071: channels wire format (revised — substrate simplification; the
wire format is in `channels-core`)
wire format is in `channels-core`; **amended by ADR-093 — 8-byte header,
no `stream_type`**)
- ADR-093: channels pure channel multiplexing (amends this ADR — 8-byte
wire format; `ChannelSubStreams` / `SubStreamHandle` removed; the
channels layer has no `stream_type` concept)
- ADR-072: channel 0 pre-negotiated (moves to `channels-call`)
- ADR-073: channel lifecycle operations (move to `channels-call`)
- ADR-074: ChannelBidiStreamSource (in `channels-core`)
- ADR-074: ChannelBidiStreamSource (in `channels-core`; **amended by
ADR-093 — `into_sub_streams` removed, `accept_bi` yields `BiStream`**)
- ADR-075: ChannelsAdapter and ChannelManager (split: core demux in
`channels-core`, call coupling in `channels-call`)
- ADR-079: hub relay (in `alknet-hub` — the hub crate consumes
@@ -151,7 +151,7 @@ alknet mono-repo (the core networking toolkit)
substrate, not part of it.
- Foundational handlers depend on `alknet-core` (for
`ProtocolHandler`, `Connection`) and/or `alknet-channels` (for
`ChannelBidiStreamSource`, `into_sub_streams`). No handler depends
`ChannelBidiStreamSource`). No handler depends
on another handler — cross-handler communication goes through
`alknet/call` on channel 0.
- `alknet-vault` is standalone (zero alknet crate dependencies — ADR-018).
@@ -0,0 +1,626 @@
# ADR-092: `BiStream` as the Handler Leaf — Unify the Split-Pair `accept_bi`
## Status
Proposed (amends ADR-070's `BidiStreamSource::accept_bi` return type;
amends ADR-065's `from_stream` / `from_bidi` constructors; amends
ADR-074's `ChannelBidiStreamSource::accept_bi` return type;
resurrects ADR-007's `BiStream` trait as the handler-facing leaf type;
supersedes the "two Phase 6 issues" framing in
`docs/research/alknet-crate-extraction/findings.md`; **`into_sub_streams()`
preservation subsequently reversed by ADR-093 (2026-07-18) — see the
note at the bottom of this ADR**)
> **Note on `into_sub_streams()` (added 2026-07-18, ADR-093):** This ADR's
> body states `into_sub_streams()` (ADR-074) is "preserved" as the
> second accessor alongside `accept_bi`, because TTY's named
> unidirectional sub-streams are the case that justifies keeping
> `SendStream` / `RecvStream`. ADR-093 reverses that preservation: the
> channels layer has no `stream_type` concept, `into_sub_streams()` is
> removed, and TTY sub-demuxes its `BiStream` via its own 5-byte format
> (the same code TTY runs in direct mode). `SendStream` / `RecvStream`
> collapse to thin newtypes over `Box<dyn Async* + Send + Unpin>` as
> this ADR specifies, but their only consumer is the channels
> reassembly path's internal join (constructing a `BiStream` from split
> halves), not `into_sub_streams()`. See ADR-093 for the resolution
> rationale (the channels layer is pure channel multiplexing; the
> handler owns its sub-stream multiplexing on the `BiStream`).
## Context
The crate-extraction findings doc
(`docs/research/alknet-crate-extraction/findings.md` Phase 6) deferred
the `alknet-http` rework on the grounds that the `QuicStream` wrapper
(44 lines, `crates/alknet-http/src/server/adapter.rs:271-314`) is a
*necessary* adapter — `accept_bi()` returns a split
`(SendStream, RecvStream)` pair, `SendStream` implements only
`AsyncWrite`, `RecvStream` implements only `AsyncRead`, and
`HttpAdapter::serve_io` needs a single `AsyncRead + AsyncWrite`. The
finding was correct about the symptom and wrong about the cause. This
ADR untangles the cause.
### The tangle: five abstractions for "a bidirectional byte stream"
Today the codebase has five abstractions for the same concept, and every
handler picks a joining strategy per-handler:
| # | Abstraction | Where | Notes |
|---|-------------|-------|-------|
| 1 | `BiStream` trait (`AsyncRead + AsyncWrite + Send + Unpin`) | `crates/alknet-core/src/types.rs:226` | **Vestigial in code.** Declared per ADR-007, named in ADR-070 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. |
| 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
split, so every consumer either re-joins it (HTTP's `QuicStream`,
`QuicStreamDuplex` test helper) or bypasses `Connection` entirely
(WS's `WsStream` + bespoke dispatch loop).**
### What ADR-070 left half-finished
ADR-070 extracted `BidiStreamSource` as the connection-level extension
point and kept `accept_bi` returning `(SendStream, RecvStream)`:
```rust
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
```
This preserved the existing `Connection` API verbatim (the right call
for ADR-070's scope — the trait extraction was the one-way door; the
return shape was a known leftover). But it left the join *per-handler*:
every handler that wants a single duplex stream re-implements the same
`AsyncRead + AsyncWrite` wrapper. The wrapper is small (44 lines) and
correct, but it is duplicated per-handler, and the duplication is what
forces the WS path into a bespoke `WsStream` trait instead of running
through `Connection::from_bidi` like every other transport.
### What ADR-007 already specified
ADR-007 defined `BiStream: AsyncRead + AsyncWrite + Send + Unpin` as
the leaf, and the ADR's "Why BiStream is still defined as a trait"
section (lines 86-94) lists three uses: WASM door, testing,
portability. The trait was placed in `alknet-core` and then not used
as the handler leaf — ADR-002's `handle` signature takes `Connection`
(correctly, for multi-stream handlers like TTY that loop `accept_bi`),
and `Connection::accept_bi` returns the split pair. `BiStream` became
"the trait that would have been the leaf if handlers received a single
stream." This ADR makes it the actual leaf — not by changing the
handler signature (still `Connection`), but by changing what
`accept_bi` yields.
### The ecosystem convention
`AsyncRead + AsyncWrite + Send + Unpin` (or close variants) is the
Rust ecosystem's standard "bidirectional byte stream" shape:
- `tokio::net::TcpStream`, `tokio::net::UdpSocket`
- `tokio_rustls::server::TlsStream<TcpStream>`
- `russh::Channel::into_stream()` — "Consume the Channel to produce a
bidirectional stream, sending and receiving `ChannelMsg::Data` as
`AsyncRead + AsyncWrite`"
- `tokio::io::DuplexStream`
- A WS-message adapter (the one place real adapter work is required)
All satisfy `BiStream` natively. Making `BiStream` the handler leaf
aligns alknet with the convention: `Connection::from_bidi(stream)`
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
(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
shutdown-on-completion contract applies to the `ReadHalf`/`WriteHalf`
unchanged.
### The TTY named-sub-streams case (ADR-074, ADR-077)
ADR-074 specifies `into_sub_streams()` returning
`Vec<(u8, SubStreamHandle)>` where `SubStreamHandle` is
`Send(SendStream) | Recv(RecvStream)`. ADR-077's TTY-inside-channels
mode destructures into five named handles (`stdin`, `stdout`,
`stderr`, `ctrl_in`, `ctrl_out`). **Every stream_type is
unidirectional** (ADR-071) — the typed-sub-stream leaves are
unidirectional by design, and the join is wrong for them.
This means `SendStream` and `RecvStream` cannot fully go away. They
remain as the typed-sub-stream leaves for the channels-inside-TTY
case (and any future handler that destructures a `ChannelSubStreams`).
What goes away is the *quinn-welding* in them: today `SendStreamKind`
/ `RecvStreamKind` are enums with `Quinn` / `Iroh` / `Stream` variants
that dispatch per-call. Once `accept_bi` returns a joined `BiStream`,
the quinn/iroh `accept_bi` impls do the join *once* (via
`tokio::io::join`) and yield a `BiStream`. The `SendStream` /
`RecvStream` types collapse to thin newtypes over
`Box<dyn AsyncWrite + Send + Unpin>` / `Box<dyn AsyncRead + Send +
Unpin>` — used only by `into_sub_streams()` and the channels reassembly
path, never by a top-level handler's `accept_bi` call.
## Decision
### `accept_bi` returns `BiStream`
`BidiStreamSource::accept_bi` returns a single `BiStream`, not a split
pair:
```rust
#[async_trait]
pub trait BidiStreamSource: Send + Sync + 'static {
async fn accept_bi(&self) -> Result<BidiStream, StreamError>;
async fn open_bi(&self) -> Result<BidiStream, StreamError>;
fn remote_addr(&self) -> Option<SocketAddr>;
fn close(&self, code: u32, reason: &str);
}
```
`Connection::accept_bi` / `open_bi` delegate verbatim. The public
`Connection` API is preserved except for the return type — which is a
type change every caller sees, addressed below.
### `BiStream` is a concrete newtype, not a bare trait
A bare `dyn BiStream` won't work: `AsyncRead` / `AsyncWrite` methods
take `Pin<&mut Self>`, and trait objects need `Pin<Box<dyn ...>>` or a
newtype that owns the inner stream and re-projects. The clean shape is
a concrete struct that boxes the inner joined stream:
```rust
pub struct BiStream {
inner: Box<dyn AsyncReadWrite + Send + Unpin>,
}
// Internal helper trait — the union of AsyncRead + AsyncWrite + Send +
// Unpin. Not public; exists only to give BiStream a single boxed field.
trait AsyncReadWrite: AsyncRead + AsyncWrite {}
impl<T: AsyncRead + AsyncWrite> AsyncReadWrite for T {}
impl AsyncRead for BiStream { /* delegate to self.inner */ }
impl AsyncWrite for BiStream { /* delegate to self.inner */ }
```
`BiStream: AsyncRead + AsyncWrite + Send + Unpin` by construction. The
old `pub trait BiStream: AsyncRead + AsyncWrite + Send + Unpin {}`
(ADR-007, `types.rs:226`) is removed — the trait was never consumed,
and the concrete struct carries the same trait bounds forward as
implied bounds, not a marker trait. This is the ADR-007 resurrection:
the name and the bounds survive, the shape becomes a concrete leaf.
### The join moves into core's quinn/iroh impls (once)
```rust
#[cfg(feature = "quinn")]
async fn accept_bi(&self) -> Result<BidiStream, StreamError> {
let (send, recv) = self.conn.accept_bi().await
.map_err(map_quinn_connection_error)?;
Ok(BiStream::from_joined(send, recv)) // tokio::io::join internally
}
```
The `QuicStream` wrapper (`adapter.rs:271-314`, 44 lines) becomes
`BiStream::from_joined(send, recv)` — one line, in core, invisible to
handlers. The same applies to iroh. The join is no longer per-handler.
### `Connection::from_bidi` is the only public stream constructor;
`from_stream` is removed
Today `from_bidi` is a convenience wrapper that calls
`tokio::io::split(stream)` then `from_stream(send, recv)`, and
`from_stream` bakes the split into the constructor API — the same
split-leaf shape pushed one step earlier. With `BiStream` as the leaf,
`from_bidi` is the only public constructor that takes a joined stream.
`Connection::from_stream(send, recv, ...)` is **removed**.
The rule this normalizes: **the split never crosses a crate boundary
as part of a constructor.** A crate that produces split halves
naturally (the channels reassembly path, which produces
`MpscSendStream` / `MpscRecvStream` as distinct async types) joins
them *itself* via `tokio::io::join(send, recv)` (one line) and calls
`from_bidi`. A crate that has a joined stream (`TcpStream`,
`TlsStream<TcpStream>`, `russh::Channel::into_stream()`,
`WsBidiStream`, even a test `DuplexStream`) calls `from_bidi` directly.
`Connection` only ever holds a `BiStream`. The split is a crate-internal
concern of wherever it naturally arises.
The existing `from_stream` call sites update mechanically:
- `crates/alknet-client/src/dial/tcp_tls.rs` already uses `from_bidi`
(no change).
- `crates/alknet-endpoint/src/accept/tcp_tls.rs` already uses
`from_bidi` (no change).
- The call crate's test stubs
(`call_client.rs:91`, `protocol/connection.rs:465`,
`protocol/dispatch.rs:465`, `protocol/adapter.rs:294`,
`client/from_call.rs:428`) today do
`tokio::io::split(x)` then `from_stream(send, recv, ...)` — they
become `from_bidi(x, ...)` directly, one call, no split.
- The channels reassembly path (per ADR-074, the future
`ChannelBidiStreamSource::accept_bi` impl) joins its
`MpscSendStream` / `MpscRecvStream` via `tokio::io::join` and calls
`from_bidi` — the join is in the channels crate (where the split
exists), not in the core constructor API.
- The core test at `types.rs:768` and the `from_source_tests` helper
become `from_bidi` calls (or construct `BiStream` directly via
`BiStream::from_joined`).
`SendStream::from_stream` / `RecvStream::from_stream` (the per-half
constructors, `types.rs:267` / `types.rs:289`) are **retained** — they
are the per-half boxing for `into_sub_streams()` (ADR-074) and the
channels reassembly path's `SubStreamHandle` leaves, not constructors
that feed `Connection`. The split lives where it is natural (channels
reassembly → `SubStreamHandle`), doesn't leak into `Connection`'s API.
### `SendStream` / `RecvStream` collapse to thin newtypes
```rust
pub struct SendStream { inner: Box<dyn AsyncWrite + Send + Unpin> }
pub struct RecvStream { inner: Box<dyn AsyncRead + Send + Unpin> }
```
Used by `into_sub_streams()` (ADR-074) and the channels reassembly
path. No `SendStreamKind` / `RecvStreamKind` enum — the quinn/iroh
dispatch is gone, the join happens once in the `BidiStreamSource` impl.
`SendStream::from_quinn` / `from_iroh` (crate-private) become the
thin-boxing constructors used only by the channels reassembly path
when it needs to expose unidirectional sub-streams. The
`from_stream(impl AsyncWrite + Send + Unpin)` / `from_stream(impl
AsyncRead + Send + Unpin)` public constructors are retained.
### `HttpAdapter` drops `QuicStream`
```rust
async fn handle(&self, connection: Connection, auth: &AuthContext)
-> Result<(), HandlerError>
{
if let Some(identity) = auth.identity.clone() {
let _ = connection.set_identity(identity);
}
let stream = connection.accept_bi().await
.map_err(stream_error_to_handler)?;
self.serve_io(stream).await // BiStream: AsyncRead + AsyncWrite + Unpin
}
```
`QuicStream` (44 lines) and `QuicStreamDuplex` (test helper, 38 lines)
are removed. `serve_io<I: AsyncRead + AsyncWrite + Send + Unpin>` is
unchanged — `BiStream` satisfies the bounds by construction.
### WebSocket runs through `Connection::from_bidi`
`WsBidiStream` (new, ~50-80 lines) implements `AsyncRead` / `AsyncWrite`
over `axum::extract::ws::WebSocket` binary messages: `AsyncRead`
consumes `Message::Binary` payloads (text messages close with a
protocol error, matching the current `drive_ws_session` behavior);
`AsyncWrite` frames each write as a `Message::Binary`; `poll_shutdown`
emits `Message::Close`. The WS session then runs through
`Connection::from_bidi(WsBidiStream::new(socket), alpn, addr)` +
`CallAdapter::handle` (or whatever the call-protocol's
`ProtocolHandler` is at the assembly layer) — the same path as any
other transport.
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)
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
changes is that the WS session feeds it through a `Connection` rather
than a parallel `WsStream` trait.
### Channels spec updates
ADR-074's `ChannelBidiStreamSource::accept_bi` returns `BiStream`:
```rust
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
// Yields the joined (stream_type 0, stream_type 1) pair on first
// call, ConnectionClosed on subsequent calls.
}
```
`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-071, ADR-077). 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-074.
The channels POC's `MpscSendStream` / `MpscRecvStream` feed
`BiStream::from_joined(send, recv)` (or `from_stream` if the channels
crate prefers to construct the joined leaf directly from the mux
handle) — the `ChannelBidiStreamSource::accept_bi` impl does the join
once, and the per-channel `Connection::accept_bi` yields a `BiStream`.
The POC's `TunnelHandler` calls `tokio::io::split(bidi)` to get its two
pump halves, the same idiom it would use over `TcpStream`.
### `BiStream` over WebSocket enables "VPN-like without being a VPN" in v1
The `webtransport.md` spec describes the "VPN-like without being a VPN"
path: a browser opens a WebTransport session to `/alknet/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.
With `BiStream` as the leaf, the same path exists over WebSocket in
v1: a browser opens a WS connection, `WsBidiStream` presents it as a
`BiStream`, `Connection::from_bidi` wraps it, `ChannelsAdapter::handle`
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
"WS message stream is another `BiStream`-satisfying transport" becomes
literal — the code does what the spec said.
The channels POC's sync core already compiles under
`wasm32-unknown-unknown`; a `BiStream`-over-WS adapter would too. The
WASM-clean property is preserved by the unification, not blocked by
it.
### WebTransport is a channels concern, not an alknet-http concern
The `h3` handler as specified in `webtransport.md` does exactly the
channels shape: one connection, N bidi streams inside, each routed to
an ALPN by the CONNECT path. That's `ChannelsAdapter::handle` with a
different wire format (HTTP/3 extended CONNECT vs the 9-byte chunk
header). When WebTransport revives, the h3 multi-stream demux leaves
`alknet-http` and becomes a channels-variant ALPN — the `alknet-http`
h3 path becomes "register an ALPN handler that gets one `BiStream` and
serves it as HTTP/3," same as `h2`/`http/1.1`. The
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
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
`ChannelsAdapter::handle`, and the extraction is a move, not a
redesign.
## What does NOT change
- **`ProtocolHandler` trait shape** — `handle(&self, connection:
Connection, auth: &AuthContext)` stays. This is an internal
`Connection` refactor; the handler trait is the ADR-009 one-way door.
- **`HandlerRegistry`** — unchanged.
- **`Connection::remote_alpn` / `set_identity` / `identity` / `close`**
— unchanged. These are `Connection`-level, not transport-level.
- **`BidiStreamSource` trait** (ADR-070) — preserved. Three signatures
change return type (`accept_bi`, `open_bi`, and the implied
`Connection::accept_bi` / `open_bi`); the trait shape and the
extension-point model are preserved.
- **`from_source` constructor** (ADR-070) — preserved. Downstream
crates implement `BidiStreamSource` and construct via `from_source`;
their `accept_bi` impls return `BiStream`.
- **`into_sub_streams()`** (ADR-074) — preserved. TTY's named
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
call `tokio::io::split(bidi)` for their two pump halves; the
shutdown-on-completion contract applies to the `ReadHalf` /
`WriteHalf` unchanged.
- **Yield-once contract** (ADR-065) — preserved.
`StreamBidiStreamSource::accept_bi` yields the `BiStream` once then
returns `ConnectionClosed`. The contract is about *how many times*
`accept_bi` yields, not *what shape* it yields.
- **`Connection::from_quinn` / `from_iroh`** — preserved as
convenience wrappers; internally wrap the `QuinnBidiStreamSource` /
`IrohBidiStreamSource` whose `accept_bi` does the join.
- **`Connection::from_bidi`** — promoted to the only public stream
constructor. `Connection::from_stream` is removed (the split no
longer crosses a crate boundary as part of a constructor).
- **`SendStream::from_stream` / `RecvStream::from_stream`** (per-half
constructors) — retained, but only as the boxing for
`into_sub_streams()` and the channels reassembly path's
`SubStreamHandle` leaves. Not constructors that feed `Connection`.
- **The endpoint's accept loops** (quinn/iroh) — unchanged.
## Consequences
**Positive:**
- The `QuicStream` wrapper (44 lines) and `QuicStreamDuplex` test
helper (38 lines) are removed from `alknet-http`. `HttpAdapter::handle`
becomes 4 lines. `serve_io`'s signature is unchanged.
- The `WsStream` trait, the bespoke `drive_ws_session` loop, and ~150
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"
becomes literal.
- One abstraction (`BiStream`) replaces five. The leaf type matches the
ecosystem convention (`russh::Channel::into_stream()`, `TcpStream`,
`TlsStream<TcpStream>`, `DuplexStream`).
- "VPN-like without being a VPN" over WS in v1 becomes real: the same
path `webtransport.md` specified, over WS, now. The browser's WASM
parser implements `BiStream` over a WS-message adapter; the server
wraps it via `Connection::from_bidi`; `ChannelsAdapter::handle` runs
the demux; each channel's `BiStream` reaches `SshAdapter::handle`
unchanged.
- The quinn-welding in `SendStream` / `RecvStream` (the
`SendStreamKind` / `RecvStreamKind` enums and their per-call
dispatch) is gone. `SendStream` / `RecvStream` become thin newtypes
used only by the channels reassembly path and `into_sub_streams()`.
- The future WebTransport extraction is a move (h3 demux → a
channels-variant ALPN), not a redesign. The unification is what
makes it clean.
- ADR-007's `BiStream` is resurrected as the actual leaf, matching the
original intent the code never delivered.
**Negative:**
- Every `accept_bi().await` caller sees a return-type change from
`(SendStream, RecvStream)` to `BiStream`. Callers that want the
split pair call `tokio::io::split(bidi)`. The call-site change is
mechanical (`let (send, recv) = ...` → `let bidi = ...; let (recv,
send) = tokio::io::split(bidi)`), but it touches every handler. This
is the one-time cost of the unification; the alternative is
per-handler wrappers forever.
- Every `Connection::from_stream(send, recv, ...)` call site is
removed. The call crate's test stubs (5 sites) become `from_bidi`
calls. The channels reassembly path gains a one-line
`tokio::io::join` before `from_bidi`. No caller outside core and
the channels reassembly path was ever doing anything other than
`tokio::io::split` then `from_stream` — the split was always
gratuitous at the call site.
- ADR-070's `accept_bi` return shape is amended. ADR-070 explicitly
preserved the split-pair shape to keep the `Connection` API verbatim;
this ADR reverses that preservation. The trade is: one type change
across the codebase now, vs. one wrapper per handler forever.
ADR-070's trait-extraction (the one-way door) is preserved; the
return-shape is the amended part.
- ADR-074's `ChannelBidiStreamSource::accept_bi` return shape is
amended (same change, same rationale). `into_sub_streams()` is
unchanged.
- `BiStream` becomes a concrete struct (with an internal boxed
`dyn AsyncReadWrite`), not a bare trait object. This is the
`Pin<&mut Self>` projection requirement — a bare `dyn BiStream` is
not ergonomic for `AsyncRead` / `AsyncWrite` impls. The ADR-007
trait is removed; the bounds survive as implied bounds on the
concrete struct. The name and the convention are preserved; the
shape becomes a concrete leaf.
- `WsBidiStream` is real new code (~50-80 lines). The WS-message ↔
byte-stream adapter is the one place the unification requires
non-trivial work — WS messages are framed, not a byte stream, so
the adapter owns the framing. This is the same work the current
`drive_ws_session` loop does, just relocated from a bespoke loop
into the `AsyncRead` / `AsyncWrite` impls.
- The `alknet-http` crate gains a dependency on whatever crate
owns `WsBidiStream` (likely `alknet-http` itself, or a small
`alknet-ws` crate if WASM-targetability is a goal — the browser side
needs the same adapter). This is a packaging decision, not a
design one — recorded as an open question below.
## Door type
**One-way.** The `accept_bi` return shape is the handler-facing API
surface. Once handlers are written against `BiStream`, reversing to
the split-pair shape is a rewrite of every handler's call site. The
trade is one type change across the codebase now vs. one wrapper per
handler forever — this ADR takes the one-time cost.
The `BiStream` concrete-struct shape (internal `Box<dyn
AsyncReadWrite>`, `Pin` projection) is a two-way-door implementation
detail — the internal representation can change without breaking the
public `AsyncRead + AsyncWrite + Send + Unpin` bounds.
## Migration
The migration is mechanical and can be ordered to keep the workspace
compilable:
1. **Core: introduce `BiStream` as the concrete leaf.** Add the
struct, the `AsyncRead` / `AsyncWrite` impls, the `from_joined`
constructor. Change `BidiStreamSource::accept_bi` / `open_bi` return
types to `BiStream`. Update `QuinnBidiStreamSource` /
`IrohBidiStreamSource` / `StreamBidiStreamSource` impls to do the
join. `Connection::accept_bi` / `open_bi` delegate verbatim. Remove
`Connection::from_stream` (the split-pair constructor); promote
`Connection::from_bidi` to the only public stream constructor.
This is a single-crate change; every `accept_bi` and `from_stream`
caller breaks mechanically.
2. **Update every handler's `accept_bi` call sites and every
`from_stream` call site.** `HttpAdapter::handle` becomes 4 lines
(drop `QuicStream`). `TtyAdapter::handle` calls
`tokio::io::split(bidi)` for its pump halves (or uses
`into_sub_streams()` in channels mode — unchanged). The channels
POC's `TunnelHandler` and `EchoHandler` get the same
`tokio::io::split` treatment. `CallAdapter::handle` (wherever it
consumes `accept_bi`) gets the same. The call crate's test stubs
(5 `from_stream` sites) become `from_bidi` calls (drop the
`tokio::io::split` they were doing immediately before). The channels
reassembly path gains a one-line `tokio::io::join` before `from_bidi`.
3. **Collapse `SendStream` / `RecvStream` to thin newtypes.** Remove
`SendStreamKind` / `RecvStreamKind` enums; the quinn/iroh
constructors become thin-boxing. Used only by the channels
reassembly path and `into_sub_streams()`.
4. **`alknet-http`: rewrite WS through `Connection::from_bidi`.** Add
`WsBidiStream`; remove `WsStream` trait, `drive_ws_session` loop,
and the dispatch glue. The WS session runs through
`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-065, ADR-070, ADR-074, ADR-077** to reflect the
`BiStream` return shape. ADR-065'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-070's `accept_bi` return type is amended. ADR-074's
`ChannelBidiStreamSource::accept_bi` return type is amended;
`into_sub_streams()` is unchanged. ADR-077's 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
replaced: the `QuicStream` wrapper is removed (not because
`accept_bi` returns streams that are already duplex, but because
`accept_bi` now returns a `BiStream`); the WS path is unified; the
h3/WebTransport extraction is recorded as a future channels-variant
move enabled by this ADR.
The Phase 6 deferral in `findings.md` is resolved by this ADR — not by
the original plan (drop the wrapper as redundant) but by the actual
fix (unify the leaf so the wrapper moves into core).
## Open questions
- **Where does `WsBidiStream` live?** If WASM-targetability is a goal
(the browser side needs the same adapter), it may want to live
somewhere a WASM client can reach — `alknet-core` (no, HTTP deps
don't belong in core), a small `alknet-ws` crate, or
`alknet-http` with the browser-side adapter extracted separately.
Default: `alknet-http` owns the server-side `WsBidiStream`; the
browser-side adapter is a separate concern (the WASM SDK, not
alknet-http). Resolved at implementation time.
- **`SendStream` / `RecvStream` long-term home.** With the quinn
enums gone, these are thin newtypes over
`Box<dyn Async* + Send + Unpin>`. They could move out of
`alknet-core` into `alknet-channels-core` (their only consumer is
`into_sub_streams()`). Default: stay in `alknet-core` for now (the
channels crate is not yet extracted); revisit at the channels
extraction.
## References
- ADR-007: `BiStream` type definition (resurrected by this ADR — the
trait is removed, the bounds survive as implied bounds on the
concrete struct)
- ADR-065: `Connection::from_stream` / `from_bidi` (amended —
`from_stream` is removed; `from_bidi` is the only public stream
constructor; the split never crosses a crate boundary as part of a
constructor)
- ADR-070: `BidiStreamSource` trait (amended — `accept_bi` / `open_bi`
return `BiStream`, not the split pair; the trait shape and the
`from_source` extension point are preserved)
- ADR-074: `ChannelBidiStreamSource` (amended — `accept_bi` returns
`BiStream`; `into_sub_streams()` is unchanged)
- ADR-077: 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
applies to `tokio::io::split(bidi)` halves)
- ADR-044, ADR-048: 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
deferred `alknet-http` rework; this ADR resolves the deferral by
unifying the leaf rather than by dropping the wrapper as redundant
- `docs/architecture/crates/http/webtransport.md` — the deferred h3
handler; this ADR records the future extraction as a
channels-variant move, enabled by the unification
- `crates/alknet-core/src/types.rs:226` — the vestigial `BiStream`
trait this ADR resurrects as the concrete leaf
- `crates/alknet-http/src/server/adapter.rs:271-314` — the `QuicStream`
wrapper this ADR removes
- `crates/alknet-http/src/websocket/upgrade.rs:44-49` — the `WsStream`
trait this ADR removes
- `russh::Channel::into_stream()` — the ecosystem convention this ADR
aligns with
@@ -0,0 +1,485 @@
# ADR-093: alknet-channels — Pure Channel Multiplexing (8-Byte Header, No `stream_type`)
## Status
Accepted (amends ADR-071 — wire format is 8 bytes, not 9, and the channels
layer has no `stream_type` concept; amends ADR-074 — `into_sub_streams()`
removed, `accept_bi` is the only accessor and yields one `BiStream` per
channel; reverses ADR-077 — TTY always uses its 5-byte format, the channels
layer carries it transparently in the payload)
## Context
ADR-071 committed the channels wire format as a 9-byte chunk header
(`[channel_id:u32][stream_type:u8][length:u32]`) — a 4-byte extension of
TTY's 5-byte format, with `stream_type` carried in the channels header
and decomposed into unidirectional halves (0/1/2 = data write/read/err,
3/4/5 = control write/read/err, `% 3` formula). ADR-074 added a second
accessor (`into_sub_streams()`) alongside `accept_bi` for handlers that
need typed sub-streams (TTY's stdin/stdout/stderr/ctrl-in/ctrl-out).
ADR-077 split TTY's wire format into two modes — direct (5-byte) and
inside-channels (the channels layer de-chunks and the adapter destructures
via `into_sub_streams()`).
The stream-unification research
(`docs/research/stream-unification/findings.md`, 2026-07-18) surfaced that
these three decisions share one root: the channels layer carries a
concept (`stream_type`) it doesn't own. The 9-byte header bakes TTY's
sub-stream multiplexing into the channels wire format. The
`into_sub_streams()` accessor exists because the channels layer reassembles
per-`stream_type` and needs to expose the result. The two-mode TTY design
exists because the channels layer's `stream_type` overlaps with TTY's own
`stream_type`. The mod 2/mod 3/mod 4 numbering question (settled as mod 3
in ADR-071 revised) was a symptom of this overlap — a numbering convention
for a concept the channels layer shouldn't carry.
### The structural question
The channels layer has two objectives in tension:
1. **"Pass a stream to/from any ALPN"** — every channel is a `BiStream`;
any handler gets `accept_bi()` and treats the channel as a duplex
stream. Uniform, transport-agnostic, recursive-composition-friendly.
2. **"Channels carry N sub-streams"** — a TTY channel carries
stdin/stdout/stderr/control; the handler destructures via
`into_sub_streams()`. Carries what the source produces.
The tension is real when a sub-stream is *unidirectional* (stderr). You
can't represent stderr as a `BiStream` without wasting the write half;
you can't make it a "third half" (mod 3) without breaking pair symmetry;
you can't make the channel a single `BiStream` without losing the
stdout/stderr distinction.
ADR-074's two-accessor design resolves this by making the "pass a stream
to/from any ALPN" objective *qualified* — it applies to single-stream
channels (tunnel, SSH, call), not multi-stream channels (TTY). The mod
2/mod 3/mod 4 numbering was a symptom of that qualified design.
### The resolution: channels layer is pure channel multiplexing
The channels layer's job is "one connection carries N channels, routed
by `channel_id`." It does not know about TTY's sub-streams, SSH's channel
protocol, or how call frames its JSON. Handlers own their sub-multiplexing
on the `BiStream` the channels layer gives them.
- **Every channel is a `BiStream`.** `accept_bi()` yields one `BiStream`
per channel (per ADR-092, already landed). No `into_sub_streams()`, no
second-class accessor.
- **Handlers sub-multiplex their `BiStream` however they want.** TTY
sub-demuxes `stream_type` from its `BiStream` (its 5-byte format). Tunnel
uses the `BiStream` as raw bytes. Call length-prefixes JSON. SSH runs
its own channel protocol. The channels layer carries the bytes
transparently.
- **The mod 2/mod 3/mod 4 question dissolves at the channels layer.** The
channels layer has no `stream_type` concept — not in its header, not in
its code, not in its mental model. `stream_type` is the inner layer's
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
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
`alknet/channels` runs another channels demux on its `BiStream`. The
outer layer strips its 8-byte header; the inner layer parses its own
8-byte header from the payload. Each level is the same shape —
`BiStream → accept_bi → N BiStreams`.
### The wire format decision: 8 bytes
The channels wire format is **8 bytes**: `[channel_id:u32 BE][length:u32
BE]` followed by an opaque payload. The channels layer owns `channel_id`
and `length`; the payload is the handler's framing, carried transparently.
The 9-byte alternative (`[channel_id:u32][stream_type:u8][length:u32]`)
was considered and rejected. The 9-byte format puts `stream_type` in the
channels header, which means the channels layer carries a concept it
doesn't own. For TTY this composes cleanly (the 9-byte header is TTY's
5-byte header with `channel_id` prepended), but for non-TTY handlers
(tunnel, call, SSH) the `stream_type` byte is dead weight — the channels
layer carries a byte it doesn't understand, and the handler ignores a
byte in a header it doesn't control.
The 8-byte format is uniform across all handlers: the channels layer
carries `channel_id` + `length` + opaque payload. Every handler parses
its own framing from the payload. The cost is that TTY's `wire.rs` is
called from a payload buffer rather than directly from the wire, and the
total header for a TTY chunk is 13 bytes (8 channels + 5 TTY) instead of
9. The two length fields are close but not identical (`ch_len = tty_len +
5`); for typical TTY chunks (4 KiB+), the 5-byte overhead is ~0.1%, and
the trade is clean separation of concerns. See "Consequences" for the
full cost/benefit.
### The add/strip composition
Each layer has its own add/strip pair. The channels layer:
`add_channel_id(channel_id, payload_bytes) -> chunk` on write (prepends
the 8-byte header); `strip_channel_id(chunk) -> (channel_id,
payload_bytes)` on read (strips the 8-byte header, returns the payload).
The handler layer (e.g. TTY) parses its own framing from the payload
bytes per its existing `wire.rs`. The handler doesn't know or care that
a `channel_id` was stripped before it saw the bytes.
The composition is uniform — the same shape at every level. This is SSH's
model (layered headers, each layer strips its own at its boundary),
applied to channels. A `alknet/channels`-inside-`alknet/channels`
recursive composition is the outer layer stripping its 8-byte header, the
inner layer parsing its own 8-byte header from the payload — same code,
same shape, each level.
### Why this can land now
Three things changed since ADR-071/074/077 were accepted:
1. **ADR-092 landed `BiStream` as the handler leaf.** `accept_bi()`
returns a `BiStream` (a concrete `AsyncRead + AsyncWrite` newtype), not
a split `(SendStream, RecvStream)` pair. The join moves into core's
quinn/iroh/stream impls (once per source, invisible to handlers). This
ADR's "every channel is a `BiStream`" is the channels-layer
consequence of ADR-092's handler-leaf decision — the research-then-sync
pattern applied: ADR-092 settled the transport leaf, this ADR settles
the multiplexing layer above it.
2. **Phase 7 fixed the TTY control channel at the TTY layer.** The
`STREAM_CONTROL = 3` "bidirectional" flaw is fixed by splitting it into
`STREAM_CTRL_IN = 3` / `STREAM_CTRL_OUT = 4` — *inside TTY's 5-byte
format*, not at the channels layer. This removed the load-bearing
reason for the channels layer to carry `stream_type`: the control
bidirectionality fix is a TTY-internal concern, not a channels-layer
concern. ADR-077's two-mode TTY design was motivated by the channels
layer carrying control; with control moved inside TTY, the motivation
dissolves.
3. **No production constraint.** The develop branch is a rewrite of main
(pre-alpha). The channels crate doesn't exist yet (per ADR-081, it's
planned as `alknet-channels-core` + `alknet-channels-call`). The
decision is purely "what's cleanest," not "what's least disruptive."
The 9-byte POC validated the per-`channel_id`/`stream_type` routing
mechanism; the 8-byte spec update changes the header before
implementation begins.
### What this ADR does NOT decide
- **The add/strip API shape** (built into read/write vs. a separate
utility): the stream-unification research proposed `add_channel_id` /
`strip_channel_id` as standalone functions. Ideally the header is
built into the read/write path so the utility isn't needed at the
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
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
an `AsyncRead`. Adapting it to read from a payload buffer (`&[u8]` or
`Cursor<Bytes>`) is a small, well-scoped change (the framing logic —
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
bounded-buffer backpressure (ADR-076) is the v1 mechanism; full
windowing is an additive extension that doesn't change the wire
format. OQ-56 stays deferred(scope).
## Decision
### 1. The channels wire format is 8 bytes
```
[channel_id: u32 BE][length: u32 BE][payload bytes]
```
8 bytes of header, followed by `length` bytes of opaque payload. The
channels layer owns `channel_id` and `length`; the payload is the
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 `alknet/call` (ADR-072). Channels 1..N are opened dynamically via `channel/open` (ADR-073). |
| `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). |
The `stream_type` byte is **removed** from the channels header. The
channels layer has no `stream_type` concept — not in its header, not in
its code, not in its mental model. What was the channels header's
`stream_type` byte is now the first byte of the payload, owned by the
handler's framing (TTY's 5-byte format, call's length-prefixed JSON,
tunnel's raw bytes, SSH's channel protocol).
This amends ADR-071: 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
layer carries transparently.
### 2. `into_sub_streams()` is removed; `accept_bi` is the only accessor
ADR-074's `into_sub_streams()` / `ChannelSubStreams` / `SubStreamHandle`
are removed. The channels layer exposes one accessor: `accept_bi()`,
which yields one `BiStream` per channel (per ADR-092). Every handler —
TTY, tunnel, SSH, call — receives a `Connection`, calls `accept_bi()`
once, gets a `BiStream`, and sub-multiplexes it however it wants.
This amends ADR-074: the two-accessor design (`accept_bi` for generic
handlers, `into_sub_streams` for typed handlers) collapses to one
accessor. The "typed handler path" (ADR-074's motivating case for TTY) is
replaced by TTY sub-demuxing its `BiStream` via its own 5-byte format —
the same code TTY runs in direct mode. ADR-074's yield-once `accept_bi`
contract is preserved; the `into_sub_streams()` accessor is the amended
part.
### 3. TTY always uses its 5-byte format; the channels layer carries it transparently
ADR-077's two-mode TTY design (direct vs inside-channels) is reversed.
TTY's 5-byte format (`[stream_type:u8][length:u32][payload]`, ADR-052) is
TTY's internal format, used in *both* direct mode and inside-channels
mode. The two modes differ only in *where the `BiStream` comes from*
(a top-level `alknet/tty` connection vs a `channel/open` with ALPN
`alknet/tty`), not in *how TTY parses it*. The same `wire.rs` code runs
in both modes.
When TTY is inside channels, the channels layer strips its 8-byte header
and hands TTY the payload bytes. TTY parses its 5-byte header from the
payload. The channels layer carries TTY's 5-byte chunks transparently
in its payload — no shared fields, no leaked abstraction, no
double-chunking concern (the 13-byte total header is 8 channels + 5
TTY, not 8 + 9; the channels `length` is always `tty_len + 5`).
This reverses ADR-077: the 5-byte format is NOT scoped to direct — it's
TTY's internal format, carried transparently in the channels payload.
The `channels` feature on `alknet-tty` becomes "run TTY's sub-demux on a
channels-backed `BiStream`" — the same code as direct mode, different
`BiStream` source. The control channel split (`STREAM_CTRL_IN` /
`STREAM_CTRL_OUT`, Phase 7) is TTY-internal; the channels layer doesn't
know about it.
### 4. The add/strip composition
The channels layer's read path strips the 8-byte header and hands the
payload to the handler. The write path prepends the 8-byte header
(`add_channel_id`) onto the handler's output. The handler never sees
the `channel_id`; it sees only its own framing (the payload bytes).
```
channels: [channel_id:u32 BE][length:u32 BE][payload]
= 8-byte header + opaque payload
8 bytes
TTY inside channels:
[channel_id:u32][ch_len:u32][stream_type:u8][tty_len:u32][payload]
4 bytes 4 bytes 1 byte 4 bytes N bytes
\_________ __________/ \_________ _____________/
| |
channels header TTY chunk (5+N bytes)
(8 bytes) carried as channels payload
```
The composition is uniform — the same shape at every level. A
`alknet/channels`-inside-`alknet/channels` recursive composition is the
outer layer stripping its 8-byte header, the inner layer parsing its own
8-byte header from the payload — same code, same shape, each level.
### 5. What does NOT change
- **ADR-092's `BiStream` leaf** — unchanged. This ADR is the
channels-layer consequence of ADR-092: `accept_bi` yields a `BiStream`,
handlers sub-multiplex it. The two ADRs compose (ADR-092 settles the
transport leaf; this ADR settles the multiplexing layer above it).
- **`ProtocolHandler` trait shape** (ADR-002) — unchanged. Handlers
receive a `Connection` and call `accept_bi()`.
- **Channel 0 pre-negotiated as `alknet/call`** (ADR-072) — unchanged.
Channel 0's chunks have `channel_id = 0` in the 8-byte header. The call
protocol's `EventEnvelope` framing is the payload; the channels layer
carries it transparently.
- **Channel lifecycle operations** (ADR-073) — unchanged. The four
operations (`channel/open`/`close`/`control`/`resources/subscribe`) and
their `direction` semantics are call-protocol operations on channel 0,
not channels-wire-format concerns.
- **`ChannelsAdapter` / `ChannelManager` split** (ADR-075) —
structurally unchanged. The demux loop reads 8-byte headers (not
9-byte); the `ChannelManager` is ALPN-blind, auth-blind,
transport-blind. The `stream_types` field on `channel/open` and
`ChannelState` is removed (the channels layer doesn't track
per-stream-type reassembly buffers; it tracks one reassembly buffer
per `channel_id`, yielding a `BiStream`).
- **Backpressure, channel limits, ID reuse** (ADR-076) — unchanged. The
bounded-buffer backpressure is per-`channel_id` (was per-
`(channel_id, stream_type)`; now per-`channel_id` since there's one
reassembly buffer per channel). The 256-channel cap, 1 MiB default,
and monotonic-ID-with-wrap strategy are unchanged.
- **Two-pump shutdown-on-completion** (ADR-078) — unchanged. Tunnel/SSH
handlers call `tokio::io::split(bidi)` for their two pump halves; the
shutdown-on-completion contract applies to the `ReadHalf` /
`WriteHalf` unchanged.
- **Hub relay** (ADR-079) — unchanged in contract. The hub translates
`channel/open` on channel 0 and byte-forwards data channels with
`channel_id` rewrite. The relay reads 8-byte headers (not 9-byte) and
rewrites the `channel_id` field (a 4-byte rewrite within the 8-byte
header, not a 9-byte header). The relay does not parse the payload.
- **`ChannelClient`** (ADR-080) — unchanged in API. `from_connection`
primary, `open_channel` returns a `Channel`. The `stream_types` field
on `open_channel` and `Channel` is removed (the channels layer doesn't
negotiate per-stream-type sets; the handler owns its sub-stream
multiplexing). The `channel:stream_type_unavailable` error code is
removed (the channels layer can't refuse a `stream_type` it doesn't
know about).
- **Sub-crate decomposition** (ADR-081) — unchanged. `channels-core`
(pure multiplexer, depends on `alknet-core` only) / `channels-call`
(channel 0 pre-negotiation + lifecycle op registrations, depends on
`channels-core` + `alknet-call`). The 8-byte wire format, demux/mux,
and `ChannelBidiStreamSource` are in `channels-core`; the call-protocol
coupling is in `channels-call`.
- **`BidiStreamSource` trait** (ADR-070) — unchanged in shape.
`ChannelBidiStreamSource` implements it; `accept_bi` yields a
`BiStream` (per ADR-092, already landed).
## Consequences
**Positive:**
- **Clean separation of concerns.** The channels layer has no
`stream_type` concept — not in its header, not in its code, not in its
mental model. The handler owns its framing entirely. This dissolves
the mod 2/mod 3/mod 4 question at the channels layer (there's nothing
to decompose) and fixes the "control isn't actually bidirectional" TTY
flaw at the TTY layer (where it lives, not the channels layer).
- **Uniform across all handlers.** Tunnel, call, SSH, and TTY all
receive the same shape: a `BiStream`. No handler gets a `stream_type`
byte it doesn't use; no handler needs a second accessor
(`into_sub_streams`) to reach its sub-streams. The channels layer's
API surface is `accept_bi -> BiStream`, period.
- **Recursive composition is literal.** A `alknet/channels` channel runs
another channels demux on its `BiStream`. The outer layer strips its
8-byte header; the inner layer parses its own 8-byte header from the
payload. Same code, same shape, each level. This is a property, not a
feature — the primary use case is one level of multiplexing, but the
add/strip composition makes the recursion cleaner than ADR-071's
group framing did.
- **The `into_sub_streams()` accessor and its consuming handler code are
removed.** This is a net simplification: one accessor, one handler
path, no downcast / extension trait / "two paths" ergonomics question
(which ADR-074 left as an implementation detail). The handler crate
destructures its `BiStream` via its own framing (TTY's 5-byte format),
not via a channels-crate-provided typed accessor.
- **TTY's `wire.rs` runs unchanged in both modes.** Direct mode and
inside-channels mode use the same code; only the `BiStream` source
differs. ADR-077's `drive_session_direct` / `drive_session_channels`
split collapses to one `drive_session` function. The `channels` feature
on `alknet-tty` becomes a thin wrapper that gets the `BiStream` from a
channels-backed `Connection` instead of a top-level one.
- **The channels layer is WASM-compatible by construction.** The 8-byte
header's core is pure byte manipulation (the sync core compiles under
`wasm32-unknown-unknown`, validated by the POC). The 8-byte format is
simpler than the 9-byte (one fewer field to parse), strengthening the
WASM-clean property.
**Negative:**
- **5 extra bytes per TTY chunk.** 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 (`ch_len = tty_len + 5`). For
typical TTY chunks (4 KiB+), this is ~0.1% overhead. For extreme
multiplexing scenarios, the clean separation is worth the trade-off;
for high-throughput bulk transfer, the escape hatch is multi-connection
(one channels connection per leg), not stripping the header. This is
the documented cost of the clean separation; the alternative (9-byte
header with `stream_type` in the channels layer) carries a concept the
channels layer doesn't own, which is the root cause this ADR addresses.
- **TTY's `wire.rs` needs a small adaptation.** `ChunkReader` currently
reads from an `AsyncRead` (the transport stream). Inside channels, it
reads from a payload buffer (`&[u8]` or `Cursor<Bytes>`) — the bytes
the channels layer handed it after stripping its 8-byte header. The
framing logic (stream_type constants, length validation, control
message parsing) is unchanged. This is a bounded, well-scoped
implementation change, not an architecture change. The same adaptation
applies to any handler that parses its own framing from a payload
buffer (call's `EventEnvelope` framing already reads from a buffer;
tunnel and SSH don't parse the payload, so no adaptation).
- **`channel/open` loses the `stream_types` field.** ADR-073's
`channel/open` input included `stream_types: [u8]` (the active sub-stream
set) and the response echoed the negotiated set. Under this ADR, the
channels layer doesn't negotiate sub-stream sets — the handler owns
its sub-stream multiplexing. The `stream_types` field is removed from
`channel/open` (and from the `channel:stream_type_unavailable` error
code). The `alpn` and `params` fields remain; the handler's sub-stream
set is implicit in its ALPN's wire format. This is a small wire-format
change to `channel/open` (one field removed); since the channels crate
isn't implemented yet, there's no migration cost.
- **`ChannelState.streams: HashMap<u8, ReassemblyBuffer>` becomes
`ChannelState.reassembly: ReassemblyBuffer` (one per channel, not per
`(channel_id, stream_type)`).** This is an internal simplification
(fewer reassembly buffers, simpler drain logic) but is an
implementation change, not an architecture one. The bounded-buffer
backpressure (ADR-076) is per-`channel_id` now, not per-
`(channel_id, stream_type)` — the 1 MiB default and the 256-channel cap
are unchanged; the per-channel memory ceiling is 1 MiB (was up to 5 MiB
for a TTY channel with 5 active stream_types). This is a net
improvement (lower memory ceiling per channel), not a regression.
## Door type
**One-way (wire format, accessor removal, two-mode reversal).** The 8-byte
chunk header layout (`channel_id:u32 + length:u32`), the removal of
`stream_type` from the channels header, and the removal of
`into_sub_streams()` are wire-format and API commitments. Changing them
after the channels crate is implemented and handlers are written against
them requires a version migration. Since the channels crate doesn't exist
yet, the one-way door is being cast now, before implementation — the
right time to cast a one-way door.
The reversal of ADR-077 (TTY always uses its 5-byte format) is one-way in
the same sense: once TTY's `wire.rs` runs in both modes (direct and
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
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.
## References
- ADR-071: channels wire format (amended — wire format is 8 bytes, not
9; `stream_type` removed from the channels header; the stream_type
decomposition is removed from the channels layer)
- ADR-074: ChannelBidiStreamSource (amended — `into_sub_streams()`
removed; `accept_bi` is the only accessor, yields one `BiStream` per
channel)
- ADR-077: TTY inside channels (reversed — TTY always uses its 5-byte
format; the channels layer carries it transparently in the payload;
the two-mode design is preserved but differs only in `BiStream`
source, not in parsing)
- ADR-092: `BiStream` as the handler leaf (the transport-leaf layer this
ADR builds on — `accept_bi` returns `BiStream`; `from_bidi` is the only
public stream constructor)
- ADR-070: `BidiStreamSource` trait (the extension point
`ChannelBidiStreamSource` implements; `accept_bi` yields `BiStream`)
- ADR-072: channel 0 pre-negotiated `alknet/call` (unchanged — channel 0's
chunks have `channel_id = 0` in the 8-byte header; the call protocol's
framing is the payload)
- ADR-073: channel lifecycle operations (amended — `stream_types` field
removed from `channel/open`; `channel:stream_type_unavailable` error
code removed)
- ADR-075: `ChannelsAdapter` and `ChannelManager` (structurally
unchanged — demux reads 8-byte headers; one reassembly buffer per
channel)
- ADR-076: backpressure, channel limits, ID reuse (unchanged —
bounded-buffer is per-`channel_id`; 256-channel cap, 1 MiB default,
monotonic IDs)
- ADR-078: two-pump shutdown-on-completion (unchanged — the contract
applies to `tokio::io::split(bidi)` halves)
- ADR-079: hub relay (unchanged in contract — 8-byte header, 4-byte
`channel_id` rewrite, payload byte-forwarded)
- ADR-080: `ChannelClient` (amended — `stream_types` field removed from
`open_channel` and `Channel`)
- ADR-081: 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
transparently in the channels payload; the control channel split
from Phase 7 is TTY-internal)
- `docs/research/stream-unification/findings.md` — the research that
surfaced the structural question and the resolution this ADR commits
- `docs/research/alknet-crate-extraction/findings.md` Phase 8 — the
spec-cleanup phase this ADR is the substance of
- `/workspace/alknet-channels-poc/` — the POC that validated the
per-`channel_id`/`stream_type` routing mechanism (the mechanism
supports any convention; this ADR says the channels layer doesn't have
a convention, the handler does)
@@ -0,0 +1,324 @@
# ADR-094: Per-Identity Channel Cap as DoS Defense
## Status
Accepted (amends ADR-076's DoS-defense framing — the per-connection
`max_channels = 256` is reframed as a per-connection memory bound, not a
DoS defense)
## Context
ADR-076 set the channels-layer channel limit at 256 **per connection**
and framed that cap as the DoS defense against an authenticated peer
opening many channels and never reading from them ("DoS defense
summary" table, "Per-connection channel count cap → 256 channels"). On
review, the per-connection cap is not a DoS defense at all. A single
peer can open an unbounded number of transport connections, and across
those connections, across substrates, the peer gets 256 × N × (substrate
multiplier) channels:
| Substrate a peer can use | Channels per connection |
|--------------------------|--------------------------|
| In-line (TCP+TLS, WebTransport, SSH `direct-tcpip`) | 256 (one stream, header-demuxed) |
| Native (QUIC substreams) | 256 (per-connection demux; each substream carries one channel) |
| Multi-connection (N transport connections) | 256 × N |
A peer that opens 10 transport connections to the same accepting peer
gets 2,560 channels. A peer that opens 100 gets 25,600. There is no
bound on the number of transport connections a peer can open. The
"per-connection, not per-peer — a peer can open more channels on a
second connection" line in ADR-076 was, in retrospect, the channels
layer confessing a hole and hoping the layer above it would fill it.
That is not a DoS defense; it is a per-connection memory bound
(reassembly-buffer cost per connection) labeled as a DoS defense.
The only coherent unit for a channel DoS defense is the **identity**.
The peer, not the connection, is what an authenticated-DoS defense
must bound. This is the same primitive as any other resource ACL:
`OwnershipProvider` (ADR-050) checks "does identity X own resource
Y?"; the channel cap checks "has identity X exceeded their channel
quota?" Same shape, different resource.
### Why the channels layer cannot hold the cap
`ChannelManager` (ADR-075) is auth-blind by design: "No auth state.
Auth lives in the `OperationContext` that the call protocol passes to
`channel/open`." That decision is load-bearing — it is what makes the
channels layer WASM-compatible, transport-agnostic, and ALPN-blind
(ADR-075, ADR-093). Putting per-identity tracking in the channels
layer would reverse ADR-075.
So the per-identity cap lives **one layer up**, in `channels-call`,
where the identity is already on `OperationContext` (the same place
`AccessControl::check` runs). The `channel/open` and `channel/close`
handlers (ADR-073) are in `channels-call` already; they gain a policy
consultation. The channels layer (`channels-core`) is unchanged —
still auth-blind, still WASM-clean.
### This is not a hub-specific concern
The cap is a **channels-accepting-peer concern**. A worker accepting a
direct channels connection from a peer needs the cap just as much as a
hub does. The call protocol does not need a hub to enforce "does this
peer have access to this resource?" (ADR-073: `AccessControl::check` on
`channel/open`), and neither should channels. Framing the cap as
hub-specific would be the "assembly layer" hedging pattern — putting
the hard question off on a fictional "later" that, when it arrives,
turns out to be exactly the same problem. The cap is a peer concern;
the hub is one peer that happens to aggregate others.
The cap is also **symmetric**, like the call protocol. Peer A accepts
a channels connection from Peer B; A enforces its cap on B's channels;
B enforces its cap on A's channels. Both sides have the cap, both
sides check it, same as `AccessControl::check` on any operation.
## Decision
### 1. A `ChannelLifecyclePolicy` trait in `channels-call`
```rust
/// Per-identity channel lifecycle policy. Consulted by the
/// `channel/open` handler (after `AccessControl::check`, before
/// allocation) and the `channel/close` handler (after deallocation).
/// Both handlers have the identity via `OperationContext`.
///
/// A channel slot is a resource; the cap is a quota check on that
/// resource — parallel to `OwnershipProvider::owns` (ADR-050) for
/// spawned resources. Same primitive, different resource.
pub trait ChannelLifecyclePolicy: Send + Sync + 'static {
/// Before channel allocation. Deny with `channel:too_many_channels`
/// (ADR-073) when the identity is over its cap. The identity is
/// the direct caller (the peer that opened this channels
/// connection); `forwarded_for` is metadata and is NOT consulted
/// (ADR-032).
fn check_open(&self, identity: &Identity) -> Result<(), ChannelError>;
/// After channel deallocation. Decrement the per-identity count.
/// Called by the `channel/close` handler after the drain completes
/// (ADR-076 §channel-id-reuse).
fn on_close(&self, identity: &Identity);
}
```
### 2. Default: `PerIdentityChannelPolicy::new(256)`
The default constructor enforces 256 per identity out of the box — no
hedging, no "NoOp default + wire it in the assembly layer." A channels
accepting peer that constructs `ChannelOperations::new(manager)` with
no policy argument gets `PerIdentityChannelPolicy::new(256)`. The
default is secure; opt-outs are explicit:
- `PerIdentityChannelPolicy::new(cap)` — shared per-identity state
(`HashMap<PeerId, usize>` + cap), constructed **once per accepting
peer** and shared (via `Arc`) across every channels connection that
peer accepts. For a hub, that's one `Arc<PerIdentityChannelPolicy>`
on the `Hub`, shared across all worker and browser legs. For a
worker accepting direct channels, that's one `Arc` on the worker's
own state, shared across whatever connections it accepts. For tests
and POCs, the default constructor.
- `PerIdentityChannelPolicy::with_per_identity_caps(mapping)` — a
per-peer-role variant: `HashMap<PeerId, usize>` overrides the
default cap for specific peers. Used by a spoke that serves a
high-fan-out hub (the hub peer's cap is set higher than a worker
peer's cap — see "Relay consequence" below).
- `NoCap` — no cap (for tests, POCs, and trusted single-peer
deployments). Explicit opt-out, not the default.
The policy is constructed once and passed to `ChannelOperations` at
registration time:
```rust
let policy = Arc::new(PerIdentityChannelPolicy::new(256));
let channel_ops = ChannelOperations::new(manager, policy);
channel_ops.register_on(&mut call_registry)?;
```
The same `Arc<PerIdentityChannelPolicy>` is shared across every
channels connection that peer accepts — that is what makes the cap
per-identity, not per-connection.
### 3. Enforcement point: between `AccessControl::check` and allocation
The `channel/open` handler (ADR-073) gains the policy check after
ACL and before `next_id.fetch_add`:
1. ACL is already checked by `OperationRegistry::invoke` (the existing
`AccessControl::check` path — unchanged).
2. **NEW:** `policy.check_open(&op_ctx.identity)?` — deny with
`channel:too_many_channels` if over cap.
3. Allocate the `channel_id` via `next_id.fetch_add(1, Relaxed)` (DP-1:
server-assigned — unchanged).
4. Construct the `ChannelBidiStreamSource`, spawn the handler, record
the `ChannelState` (unchanged).
5. Return the `channel_id`.
The `channel/close` handler (ADR-073) gains the decrement after the
drain completes (the same point ADR-076 marks the `channel_id` as
eligible for reuse):
1. Drain the reassembly buffer for `channel_id` (existing — ADR-076
§channel-id-reuse).
2. **NEW:** `policy.on_close(&op_ctx.identity)` — decrement the
per-identity count.
3. Return `{ "closed": true }` (unchanged).
### 4. `ChannelManager.max_channels = 256` stays as a per-connection memory bound
The per-connection cap (ADR-076) stays, but is reframed. It is no
longer the DoS defense — it is a per-connection **memory bound** that
limits one connection's reassembly-buffer cost regardless of policy.
It composes with the per-identity cap but is not the security
boundary. It still returns `channel:too_many_channels` when hit; the
per-identity policy returns the same error when the per-identity cap
is hit. An over-cap peer sees the same error either way; which cap
fired first is an implementation detail.
Keeping the per-connection bound as a backstop covers deployments that
use `NoCap` (tests, trusted single-peer) and bounds the damage if a
custom policy is buggy. Removing it would leave the channels layer
unbounded in the no-policy case. The cost of keeping it is zero (the
cap is already implemented in the POC); the cost of removing it is a
real hole in the `NoCap` path.
### 5. Relay consequence: the spoke caps the hub, not the browser
When the hub relays a browser's channel to a spoke (ADR-079), the
spoke sees the hub as the direct caller. `forwarded_for` carries the
browser's identity as metadata (ADR-032 — `forwarded_for` is not
authority; `AccessControl::check` never reads it). The channel cap
follows the same shape: the spoke's `ChannelLifecyclePolicy` is
consulted with the **hub's** identity, not the browser's. The spoke
asks "does the hub have access to open another channel?" and the
hub's quota on the spoke reflects the aggregate of all relayed
channels. The hub's per-browser caps are the hub's own concern
(enforced on the browser leg by the hub's own policy), not the
spoke's.
This is correct and consistent — the spoke authorizes the hub for
container access the same way it authorizes any peer, and the hub's
browser-relay ACL is the hub's own layer. The channel cap follows the
same pattern as any other resource ACL.
**Deployment consequence:** a spoke that serves a hub relaying for
many browsers must set the hub peer's cap higher than a worker peer's
cap, or the spoke denies legitimate relayed channels when the hub's
aggregate count exceeds a worker-sized cap. This is a per-peer-role
policy, set by the spoke via `with_per_identity_caps`. The
architecture provides the mechanism (`PerIdentityChannelPolicy::with_per_identity_caps`);
the deployment sets the numbers. This is not a flaw — it is the same
shape as any per-peer ACL (a spoke may authorize one peer for 1000
containers and another for 10; the channel cap is the same kind of
per-peer policy).
### 6. Recursive channels do not bypass the cap
A recursive `alknet/channels`-inside-`alknet/channels` channel runs a
new `ChannelsAdapter` with a new `ChannelManager`. If the same
`ChannelLifecyclePolicy` is wired into the inner `ChannelOperations`,
the inner channels are counted against the same identity. If a
different policy is wired, the inner channels are counted against
that policy's identity (which may be a different identity, if the
inner channels connection is authenticated separately). Either way,
the cap applies; recursion is not a bypass. The 13-byte-per-chunk
overhead of recursion is the documented cost (ADR-093); the cap
behavior is unchanged. Recursive channels are an edge case for edge
cases and not specced further.
## Consequences
**Positive:**
- A real per-identity DoS defense. A peer with N transport connections
to the same accepting peer is bounded by 256 (or the configured
per-identity cap), not 256 × N × (substrate multiplier). The cap
composes correctly across substrates because the unit is the
identity, not the connection.
- The cap is symmetric, like the call protocol. Both sides of a
channels connection enforce their cap; the cap is a peer concern,
not a hub-specific concern.
- The cap lives in `channels-call`, where the identity is already on
`OperationContext`. The channels layer (`channels-core`) is
unchanged — still auth-blind, still WASM-clean, still
transport-agnostic. ADR-075's auth-blindness is preserved.
- The default is secure. `PerIdentityChannelPolicy::new(256)` is the
out-of-the-box behavior; opt-outs (`NoCap`) are explicit. A
deployment that forgets to wire a policy still gets a per-identity
cap.
- The cap is the same primitive as any other resource ACL
(`OwnershipProvider` for spawned resources, `AccessControl::check`
for operations). The mental model is uniform: a channel slot is a
resource, the cap is a quota check on that resource.
**Negative:**
- One new trait (`ChannelLifecyclePolicy`) and one new constructor
argument on `ChannelOperations`. The `channel/open` and
`channel/close` handlers gain a policy call. Small implementation
cost; the policy is a single trait method per direction.
- Per-identity state is shared across connections
(`HashMap<PeerId, usize>` on the policy, guarded by a `Mutex`). The
state is touched on `channel/open` and `channel/close` only — not
on every chunk. The contention is per-identity, not per-chunk;
acceptable for the intended use cases.
- A spoke serving a high-fan-out hub must set the hub peer's cap
higher than the default, or legitimate relayed channels are denied.
This is a deployment-time policy decision, surfaced explicitly by
`with_per_identity_caps`. Not a flaw; the same shape as any
per-peer ACL.
- The cap is per direct-caller identity (ADR-032), not per
`forwarded_for` originator. A hub relaying for 100 browsers
consumes one channel slot per relayed channel against the hub's
quota on the spoke, not 100 slots against 100 browser quotas. A
spoke that wants per-browser capping would need to read
`forwarded_for` for authority, which ADR-032 explicitly forbids.
This is the correct trade-off: capping against `forwarded_for`
would reverse ADR-032's "forwarded_for is metadata, not authority"
and is a much bigger change. The hub enforces per-browser caps on
the browser leg; the spoke enforces per-hub caps on the spoke leg.
## Door type
**One-way.** The `ChannelLifecyclePolicy` trait surface
(`check_open(&Identity) -> Result<(), ChannelError>` and
`on_close(&Identity)`) is a one-way-door API commitment — the
`channels-call` `channel/open` and `channel/close` handlers depend on
it, and consumers (`Hub`, worker crates) construct implementations.
Removing the trait or changing the signatures after deployments exist
is a breaking change.
The **default cap value (256)** is a two-way-door implementation
detail within the one-way trait surface — changing the default is
additive (a new constructor or a default-override), not a wire-format
change.
The **reframing of ADR-076's per-connection cap** (from DoS defense
to memory bound) is two-way — it's a documentation change, not a
behavior change. The per-connection cap still exists, still returns
`channel:too_many_channels`, and still bounds one connection's
reassembly-buffer cost.
## References
- **ADR-076**: Backpressure, Channel Limits, and ID Reuse (amended by
this ADR — the per-connection `max_channels = 256` is reframed as a
per-connection memory bound, not a DoS defense; the "DoS defense
summary" table is removed; the "per-connection, not per-peer" line
is corrected)
- **ADR-075**: ChannelsAdapter and ChannelManager (the auth-blindness
this ADR preserves — the cap lives in `channels-call`, not
`channels-core`)
- **ADR-073**: Channel Lifecycle Operations (the `channel/open` and
`channel/close` handlers that gain the policy check; the
`channel:too_many_channels` error code)
- **ADR-093**: channels Pure Channel Multiplexing (the umbrella
decision; the channels layer has no `stream_type` concept, and no
identity concept either — both are above it)
- **ADR-032**: Forwarded-For Identity (Metadata, Not Authority) (why
the spoke caps the hub, not the browser — `forwarded_for` is
metadata; the direct caller's identity is the authority for the cap
just as it is for `AccessControl::check`)
- **ADR-079**: Hub Relay — Translate, Not Transparently Forward (the
relay path where the spoke sees the hub as the direct caller)
- **ADR-050**: Dynamic Resource Ownership for Runtime-Spawned
Resources (the parallel — a channel slot is a resource, the cap is
a quota check, same primitive as `OwnershipProvider::owns`)
- **ADR-030**: PeerEntry and Identity.id Decoupling (`PeerId` =
`Identity.id` — the stable key the per-identity cap counts against)
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff. Show more