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.
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).
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.
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
- 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)
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.
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.
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.
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).
- 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.
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.
- 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.
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.
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.
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.
- 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
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.
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.
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.
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).
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.
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.
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).
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).
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).
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).
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.