Skip to content

Latest commit

 

History

History
560 lines (393 loc) · 32.1 KB

File metadata and controls

560 lines (393 loc) · 32.1 KB

Kyneta Wire Protocol Specification

Wire protocol for @kyneta/transport message transport. Defines the universal Frame<T> abstraction, two encoding pipelines (binary and text), framing, fragmentation, and reassembly for the exchange's seven-message protocol.

Overview

Every message sent over a transport is wrapped in a frame. The frame is the universal delivery unit — there is no unframed path. A frame carries a protocol version, a per-direction sequence number (seq), an optional content hash, and content that is either complete (the full payload) or a fragment (one piece of a larger payload). The seq stamps every frame so each exchanged message is referenceable at the protocol level (debugging/tracing); for fragments it also serves as the reassembly group key.

Two encoding pipelines share this frame abstraction:

Pipeline Payload type T Wire format Encoding Use case
Binary Uint8Array Binary bytes encodeWireMessage (CBOR) WebSocket, WebRTC, Unix socket
Text string JSON string encodeTextWireMessage (JSON) SSE, HTTP

Batching is orthogonal to framing. The frame layer does not distinguish single messages from batches. The payload's own structure (CBOR array vs map, JSON array vs object) determines singular vs plural. The WireMessage layer handles encode/decode; the frame just carries the payload.

Message Types

Seven message types form the exchange protocol:

Discriminator (CBOR) Type Direction Purpose
0x01 establish Bidirectional Announce peer identity
0x02 depart Bidirectional Signal peer departure
0x10 present Bidirectional Announce document IDs and metadata
0x11 interest Bidirectional Request a specific document's state
0x12 offer Bidirectional Deliver document state (snapshot or delta)
0x13 dismiss Bidirectional Retract interest in a document
0x14 vacant Point-to-point Negative ack to interest: "I don't have this doc and won't serve it"

Discriminator ranges:

  • 0x01–0x0F — Lifecycle messages (establish, depart)
  • 0x10–0x1F — Sync messages (present, interest, offer, dismiss, vacant)

Discriminators are allocated sequentially from the next free value and classified by exact-value set-membership — numbering carries no semantics (there is no range/mask dispatch anywhere; classification is Set membership in validate-wire-message.ts and an exact-value decode switch). A future datagram-only message type takes the next free value (0x15+). Introduce a reserved range only alongside range dispatch.

The text pipeline uses human-readable type strings ("establish", "present", etc.) instead of integer discriminators.

Frame — Universal Frame Abstraction

type Frame<T> = {
  version: number
  seq: number               // per-direction monotonic message id (uint32, wraps)
  hash: string | null       // null today; hex SHA-256 digest in the future
  content: Complete<T> | Fragment<T>
}

type Complete<T> = {
  kind: "complete"
  payload: T
}

type Fragment<T> = {
  kind: "fragment"
  index: number             // 0-based position
  total: number             // total fragment count
  totalSize: number         // total payload size (bytes or characters)
  payload: T                // this fragment's chunk
}

Binary pipeline: Frame<Uint8Array>. Text pipeline: Frame<string>.

seq is advanced once per logical message (one Pipeline.send), shared by all fragments of that message, and stamped on every frame — complete or fragment. Two peers can name the same exchange by seq (the sender's send-seq equals the receiver's receive-seq for that message).

Fragments are fully self-describing. Every fragment carries index, total, and totalSize, and is grouped for reassembly by the enclosing frame's seq. There is no separate "fragment header" message — the receiver auto-creates collection state on first contact with a new seq.

Codec Interfaces

encodeWireMessage/decodeWireMessage (binary) and encodeTextWireMessage/decodeTextWireMessage (text) handle the ChannelMsg ⇄ WireMessage conversion. The alias transformer (applyOutboundAliasing / applyInboundAliasing) applied before encode and after decode is the single ChannelMsg ⇄ WireMessage step.

Binary (Uint8Array in/out)

encodeWireMessage(msg: ChannelMsg): WireMessage and decodeWireMessage(wire: WireMessage): ChannelMsg. CBOR-encoded Uint8Array in/out at the frame layer.

Implementation in src/cbor.ts — compact CBOR with integer discriminators and short field names. Uint8Array data in SubstratePayload is encoded natively as CBOR byte strings (no base64).

Text (JSON-safe objects in/out)

encodeTextWireMessage(msg: ChannelMsg): unknown and decodeTextWireMessage(obj: unknown): ChannelMsg. JSON-safe object in/out at the frame layer.

Implementation in src/json.ts — human-readable JSON with full type strings. Uint8Array data in SubstratePayload is transparently base64-encoded on write and base64-decoded on read. JSON SubstratePayload data passes through as-is.

Binary Wire Format

Frame Header (10 bytes)

Offset  Size   Field
──────  ─────  ──────────────────
0       1      Version (WIRE_VERSION = 3 — frame encoding axis)
1       1      Type (0x00 = complete, 0x01 = fragment)
2       4      Payload length (Uint32 big-endian)
6       4      Seq (Uint32 big-endian) — per-direction monotonic message id

Note — three distinct version axes. This header byte is the frame encoding version (WIRE_VERSION = 3). It is unrelated to the per-doc SyncMode and to the establish protocolVersion (pv, the sync wire-contract revision; see "Protocol Version Compatibility"). Do not conflate them.

payloadLength stays at offset 2; seq is appended at offset 6. A stream framer that delimits frames by reading the type byte and payload length therefore needs no offset changes when this field was added — only the header size constant.

Complete Frame

[6-byte header]
[payload: codec-encoded bytes]

The type byte is 0x00. Payload length covers the codec-encoded bytes.

Fragment Frame

[10-byte header]   (the header's seq field is the fragment group key)
[index:     2 bytes uint16 big-endian]
[total:     2 bytes uint16 big-endian]
[totalSize: 4 bytes uint32 big-endian]
[payload: chunk bytes]

The type byte is 0x01. Payload length covers the chunk data only (not the 8 bytes of fragment metadata). Total frame size = 10 (header) + 8 (metadata) + payload length.

Fragments are grouped for reassembly by the header seq — there is no separate per-fragment id. seq is a per-channel-direction monotonic uint32 advanced by the pure nextFrameSeq helper in @kyneta/wire; the counter state lives in the pipeline (PipelineState.nextSeq). It wraps at 2³², which is effectively never within a channel-direction lifetime.

No Transport Prefixes

v1 has no transport-prefix layer. The frame type byte (offset 1 of the 6-byte header) distinguishes complete vs fragment frames. The receiver decodes the header to decide whether fragment collection is needed.

CBOR Compact Encoding

encodeWireMessage encodes ChannelMsg objects as compact wire objects with short field names:

Wire field Full name Type Used by
t type integer discriminator All messages
id peerId string establish
n name string (optional) establish
y type "user" | "bot" | "service" establish
f features WireFeatures (compact map) establish (optional)
pv protocolVersion [major, minor] (two integers) establish (optional; absent ⇒ [1,0]; emitted only when non-default)
docs docs Array<{d, a?, rt, ms, sh?, sa?, shx?, shs?}> present
doc docId string (one of doc/dx required) interest, offer, dismiss, vacant
dx docId alias non-negative integer interest, offer, dismiss, vacant (one of doc/dx required)
sh schemaHash string (one of sh/shx required on present-doc) present (doc entry)
sa schemaHash alias non-negative integer (alias assignment) present (doc entry, optional)
shx schemaHash alias non-negative integer (alias reference) present (doc entry, alternative to sh)
a docId alias non-negative integer (alias assignment) present (doc entry, optional)
d docId / data string (present doc entry) or string | Uint8Array (offer) present, offer
rt replicaType [string, number, number] present (doc entry)
ms syncMode SyncModeWireValue (0x00 collaborative, 0x01 authoritative, 0x02 ephemeral) present (doc entry)
v version string interest (optional), offer
r reciprocate boolean (optional) interest, offer
pk payload kind 0x00 (entirety) or 0x01 (since) offer
pe payload encoding 0x00 (json) or 0x01 (binary) offer

Decoder invariants (Phase 3):

  • Interest, offer, dismiss, vacant: exactly one of {doc, dx} must be present. Both → doc-id-form-conflict. Neither → same code.
  • Present doc entries: exactly one of {sh, shx} must be present. Both → schema-hash-form-conflict.

Default values for optional fields

Decoders MUST tolerate absent optional fields by applying these defaults:

Field Default Notes
r (reciprocate) false Most messages don't reciprocate
pe (payload encoding) 0 (json) When omitted on offer
v (version) undefined Absent means LWW initial-sync
n (name) undefined Optional display name
f (features) undefined Treated as no features advertised — no alias/streamed/datagram
shs (supported hashes) undefined Absent means just the primary hash
pv (protocolVersion) [1, 0] Absent ⇒ peer supports only the 2.0 sync wire-contract

Binary Encoding Flow

Encode:
  ChannelMsg → applyOutboundAliasing → WireMessage
  → encodeWireMessage(wire) → Uint8Array
  → encodeBinaryFrame(complete(WIRE_VERSION, seq, payload)) → framed bytes
  → wrapCompleteMessage(framed) → transport payload

Decode:
  transport payload → parseTransportPayload → { kind: "complete", data }
  → decodeBinaryFrame(data) → Frame<Uint8Array> { content: Complete }
  → decodeWireMessage(payload) → WireMessage
  → applyInboundAliasing → ChannelMsg

Text Wire Format

Frame Prefix (2 characters)

The first element of the JSON array is a 2-character string:

Position 0: version character ('1' = version 1, '2' = version 2, ...)
Position 1: type + hash (case-encoded)
  'c' = complete, no hash
  'C' = complete, with SHA-256 hash (digest in next element)
  'f' = fragment, no hash
  'F' = fragment, with SHA-256 hash (digest in next element)

TEXT_WIRE_VERSION is currently 2, so live prefixes are "2c" / "2f".

Complete Frame

["2c", seq, <payload>]

seq (a JSON number) follows the prefix. The payload is a native JSON value — an object for a single message, an array for a batch — embedded directly (not as a string within a string).

With hash:

["2C", "hexdigest", seq, <payload>]

Fragment Frame

["2f", seq, index, total, totalSize, "chunk"]

seq is the fragment group key (it took the slot the v1 per-fragment frameId occupied). The chunk is a JSON substring of the serialized payload. The receiver concatenates chunks sharing a seq in index order and JSON.parse the result.

With hash:

["2F", "hexdigest", seq, index, total, totalSize, "chunk"]

Text Encoding Flow

Encode:
  ChannelMsg → applyOutboundAliasing → WireMessage
  → encodeTextWireMessage(wire) → JSON-safe object
  → JSON.stringify(object) → payload string
  → encodeTextFrame(complete(TEXT_WIRE_VERSION, seq, payload)) → wire string

Decode:
  wire string → decodeTextFrame → Frame<string> { content: Complete }
  → JSON.parse(payload) → JSON-safe object
  → decodeTextWireMessage(object) → WireMessage
  → applyInboundAliasing → ChannelMsg

Text Fragmentation

Large payloads are split into JSON substring chunks:

Encode:
  payload string → fragmentTextPayload(payload, maxChunkSize) → wire string[]

Each wire string is a complete, self-describing fragment frame (all sharing one seq):
  ["2f", 42, 0, 3, 1500, "{\"type\":\"offer\",\"docId\":\"doc"]
  ["2f", 42, 1, 3, 1500, "-1\",\"offerType\":\"snapshot\",\"pa"]
  ["2f", 42, 2, 3, 1500, "yload\":{\"encoding\":\"binary\"}}"]

Fragmentation Protocol

Self-Describing Fragments

Every fragment — binary or text — carries its full metadata. The group key (seq) lives in the frame header (binary offset 6 / text array element 1), not in this block:

Field Binary (v3) Text
Index 2 bytes uint16 big-endian JSON number
Total 2 bytes uint16 big-endian JSON number
Total Size 4 bytes uint32 big-endian JSON number
Chunk Raw bytes JSON string (substring)

There is no separate "fragment header" message. The FragmentCollector auto-creates tracking state when it first encounters a new seq.

FragmentCollector — Generic Reassembly

The FragmentCollector<T> is parameterized on the chunk type:

  • Binary: FragmentCollector<Uint8Array> with byte concatenation
  • Text: FragmentCollector<string> with chunks.join("")

Design: Functional Core / Imperative Shell

The pure decideFragment() function takes the current batch state and fragment metadata, returning a decision with zero side effects:

type FragmentDecision =
  | { action: "create_and_accept" }
  | { action: "accept" }
  | { action: "complete" }
  | { action: "reject_duplicate" }
  | { action: "reject_invalid_index" }
  | { action: "reject_total_mismatch" }
  | { action: "reject_size_mismatch" }

The FragmentCollector class (imperative shell) executes decisions by mutating state, managing timers, and enforcing limits.

Collector Limits

Parameter Default Purpose
Timeout 10s Abandon incomplete frames
Max concurrent frames 32 Limit tracking overhead
Max total size 50MB / 50M chars Memory cap (oldest frame evicted first)

Reassembler Wrappers

  • FragmentReassembler — binary wrapper. Parses transport prefixes and binary frame headers, delegates to FragmentCollector<Uint8Array>.
  • TextReassembler — text wrapper. Parses JSON text frames, delegates to FragmentCollector<string>.

Both are thin wrappers (~80–100 lines) that handle format-specific parsing and delegate all collection logic to the generic collector.

Binary Fragmentation

Sender:
  1. Encode message → complete binary frame (6-byte header + payload)
  2. If frame size ≤ threshold: wrapCompleteMessage(frame) → send
  3. If frame size > threshold:
     a. Use the message's seq (the per-direction counter already advanced once for this send)
     b. Split payload into chunks of maxChunkSize bytes
     c. For each chunk: build fragment frame (header carries seq; metadata + chunk)
     d. wrapFragment(fragmentFrame) → send each

Receiver:
  1. parseTransportPayload(data)
  2. If complete: decodeBinaryFrame(data) → Frame<Uint8Array> → decodeWireMessage → ChannelMsg
  3. If fragment: decodeBinaryFrame(data) → Frame<Uint8Array> with Fragment content
     → collector.addFragment(frame.seq, index, total, totalSize, chunk)
     → eventually: complete data → decodeWireMessage → ChannelMsg

Fragment Thresholds by Environment

Environment Frame limit Recommended threshold
AWS API Gateway 128KB 100KB (default)
Cloudflare Workers 1MB 500KB
Self-hosted (Bun, Node.js) Unlimited 0 (disabled)

Identifier Length Caps

DocIds and schema hashes have explicit UTF-8 byte-length caps applied uniformly across binary and text pipelines:

Identifier Cap Constant
DocId (doc / d) 512 UTF-8 bytes DOC_ID_MAX_UTF8_BYTES
Schema hash (sh) 256 UTF-8 bytes SCHEMA_HASH_MAX_UTF8_BYTES

The unit is bytes, not codepoints — multi-byte UTF-8 characters count proportionally. Receivers reject overlong values at decode time with FrameDecodeError (binary) or TextFrameDecodeError (text) using code: "doc-id-too-long" / "schema-hash-too-long".

Wire Features Negotiation

The establish message carries an optional WireFeatures map advertising what wire-format extensions a peer understands. Distinct from Capabilities in @kyneta/exchange (which describes substrate/schema bindings).

Wire shape (CBOR)

type WireFeaturesCompact = {
  a?: boolean   // alias    — peer understands a/dx/sa/shx alias fields
  s?: boolean   // streamed — reserved for future QUIC streamed mode
  d?: boolean   // datagram — reserved for future QUIC datagram mode
}

JSON shape (text pipeline)

The text pipeline uses long names: { alias?, streamed?, datagram? }.

Backward compatibility

A peer that omits features (or omits any field) is treated as not advertising that feature. Old peers ignore the unknown f field harmlessly thanks to CBOR's "ignore unknown map fields" semantics.

Default features

The current default for v1 is { alias: true } (set in SessionModel.selfFeatures). Override via Synchronizer's selfFeatures parameter to opt out.

Protocol Version Compatibility

establish carries an optional pv: [major, minor] naming the sync wire-contract revision the peer implements — the revision of the message vocabulary, handshake choreography, and negotiation rules themselves. Distinct from WIRE_VERSION (frame encoding) and SyncMode (per-doc sync policy). Absent ⇒ [1, 0] (ratified); emitted only when non-default, so a 2.0 peer's establish is byte-identical to one without the field.

Three-tier compatibility model

Wire evolution is split across three mechanisms by how peers must react to a difference:

Tier Carries On difference
WireFeatures (f) opt-in, negotiable capabilities silent — mutual-AND; absent ⇒ graceful no-op (the empty set is always a safe common subset = the base contract)
protocol minor non-negotiable backward-compatible refinements (clarifications, deprecations, behavioral tightenings) warning — purely diagnostic; minor is backward-compatible by definition
protocol major breaking change / base abandonment error — no shared contract

WireFeatures expresses any additive change (even a coordinated bundle, used iff mutual, else the base). The one thing it structurally cannot express is abandoning the base (its model is absent ⇒ false ⇒ fall back to base) — that is the sole residual job of pv's major. A single (major, minor) compared by a rule is therefore the correct shape; an array/set would only be needed for speaking two majors at once, which the discipline "graceful change is a feature; a major bump is an intentional partition" removes.

Comparison rule

Computed identically on both peers (no extra round-trip), once per channel-establish:

  • peer major !== self.major → incompatible (error).
  • same major, peer minor !== self.minor → backward-compatible refinement (warning).
  • equal (or absent) → silent.

At 2.0 a peer's self version is permanently [1, 0], so among 2.0 peers only the silent branch fires; the warning/error branches are reachable only against a future non-[1,0] peer. Detection is warn/error-only — it never gates: an incompatible peer remains observable and enters the sync graph (data simply will not converge). Actually refusing to sync is deferred to the release that ships a real major-2 peer.

Establish negotiation-core invariant

The part of establish carrying id, y, and pv is a permanent meta-contract, invariant across all protocol revisions. Future revisions may extend establish or change other messages but may never break a peer's ability to parse another peer's identity + protocolVersion. The establish validator stays tolerant of unknown fields.

DocId and Schema Hash Aliasing

Variable-length string identifiers (doc, sh) repeat heavily in steady-state sync. The QUIC connection-ID / HPACK pattern — receiver-meaningful indices replacing globally-meaningful identifiers — applies cleanly: a per-channel-direction alias table assigned via present lets later messages reference docs and schemas by short integers.

Wire fields

  • present doc entries:
    • a?: number — alias assignment for the docId. Always emitted (announcement is forward-compatible).
    • sa?: number — alias assignment for the schema hash. Emitted on first reference.
    • shx?: number — alias reference; replaces sh on subsequent references when mutualAlias is on.
  • interest / offer / dismiss:
    • dx?: number — alias reference; replaces doc when mutualAlias is on.

Aliases are non-negative integers (CBOR major type 0). They have no fixed width: CBOR encodes the smallest fitting form (1 byte for 0–23, 2 bytes for 24–255, 3 bytes for 256–65,535, 5 bytes above). Practical upper bound is Number.MAX_SAFE_INTEGER; unreachable in any realistic channel lifetime.

Announce vs use

Operation When to emit Rationale
Announce (a, sa in present) Always, regardless of peer's features.alias Old peers ignore unknown CBOR map fields; emitting unconditionally captures bytes-back-on-the-wire the moment both ends understand them.
Use (dx, or shx with sh absent) Only if both peers advertised features.alias (mutualAlias) Old peers cannot resolve aliases. Emitting dx to a non-alias peer would trigger their decoder's "exactly one of doc/dx" invariant.

Negotiation rule

A sender MAY emit dx (or shx-without-sh) only after sending a present containing the corresponding a (or sa) on the same channel and direction. On muxed transports (in-order delivery), this guarantees the receiver has seen the introduction before the use. Streamed-mode delivery (deferred) requires explicit ordering between announcement and use streams; aliases as a feature are scoped to muxed and (future) datagram modes only.

Architecture

The alias transformer lives in @kyneta/wire's alias-table.ts as two pure functions:

applyOutboundAliasing(state, msg)  { state, wire }
applyInboundAliasing(state, wire)  { state, msg | error }

Each transport's Channel holds a per-channel AliasState and calls the transformers adjacent to its codec invocation. The transformer absorbs feature capture from establish messages flowing through it — mutualAlias is derived state (per-feature AND of selfFeatures and peerFeatures), no external parameter.

Delivery Modes

The protocol reserves three delivery modes. Two are implementation-deferred in v1 but the protocol slots are reserved so future implementation can land without protocol changes.

Mode Identification of messages Aliasing applies? Implemented in v1?
Muxed docId/alias inline in each message; one ordered byte stream Yes (saves repeated bytes) Yes — current behavior
Streamed Stream identity is the docId context; offers on the stream omit doc identification No (subsumed by stream-context) Deferred
Datagram docId/alias inline (each datagram is independent) Yes (same machinery as muxed) Deferred

Muxed (v1, only formally specified)

All current transports (Bridge, WebSocket, SSE, WebRTC, Unix-socket) operate in muxed mode: a single ordered byte stream per channel-direction carries every message, with each message naming its docId (or alias) inline. Fragmentation handles MTU-bounded transports; aliasing handles the repeated-identifier compression.

Streamed (deferred)

Reserved for QUIC/WebTransport adapters. Each frame would correspond to a QUIC stream: stream identity is the docId context; offers on the stream omit doc identification. No in-stream framing; FIN ends the frame; the fragmentation layer is inert. The WireFeatures.streamed flag advertises support; aliasing is unnecessary in streamed mode (stream-as-context subsumes it).

Datagram (deferred)

Reserved for QUIC datagrams. One MTU-bounded datagram per ephemeral snapshot; aliasing required for compact self-identification. Hand-off rule: a datagram referencing an unknown alias is silently dropped. The WireFeatures.datagram flag advertises support.

Type discriminator allocation

Discriminators are allocated sequentially from the next free value and classified by exact-value set-membership; numbering carries no semantics. There is no range/mask dispatch anywhere — VALID_MESSAGE_TYPES is a Set, the decode path is an exact-value switch, and isLifecycleMsg is string equality. vacant took the next free sync value (0x14); future datagram-only message types (e.g. EphemeralSnapshot, EphemeralAck) take the next free values (0x15+). A reserved range should be introduced only alongside range dispatch (define the range and its masking together).

Future hash rule

When hash verification is added, it will be a frame trailer, not a header byte. (The v1 framing already removed the v0 hash-algorithm header byte for this reason.) Do not retrofit a header-byte solution.

Counter-Shape Parallel

The wire layer exports nextFrameSeq(prev) — a pure uint32 increment ((prev + 1) >>> 0) that advances the per-channel-direction frame seq at the transport-framing layer; the counter state is a field in PipelineState (nextSeq), advanced functionally by sendStep. The alias counter (per-channel-direction, monotonic, CBOR-major-type-0-encoded, unbounded JS number) is incremented the same way — a pure functional bump threaded through immutable AliasState. So the two counters are now the same shape and the same style (pure increments in threaded state); only the encoding differs: framing uses fixed-width uint32 with wrap; aliasing uses CBOR varint with no wrap.

Hash Support (Reserved)

v1 has no hash byte in the binary header. Hash verification, when added, will be a frame trailer — not a header byte. (See "Future hash rule" above.)

Text: Case-encoded in the prefix character. Lowercase (c, f) = no hash. Uppercase (C, F) = SHA-256 hex digest in the next array element. Reserved; not yet emitted.

Per-frame hashing enables streaming verification: the sender hashes and sends each frame independently. For fragments, this means per-chunk verification without waiting for reassembly.

Text Frame Signaling

Keepalive (WebSocket only)

The client sends a text "ping" frame at a configurable interval (default: 30s). The server responds with a text "pong". These are application-level text messages, not WebSocket protocol-level ping/pong frames.

Ready Signal (WebSocket only)

After the WebSocket connection opens, the server sends a text "ready" frame to indicate it is prepared to receive protocol messages. The client waits for this signal before creating its channel and sending establish-request.

1. Client opens WebSocket          → state: connecting
2. WebSocket open event fires      → state: connected
3. Server sends text "ready"       → state: ready
4. Client sends establish  (binary frame)
5. Server sends establish  (binary frame)
6. Protocol messages flow freely

Pipeline Architecture

Binary pipeline (WebSocket, WebRTC, Unix socket):
  applyOutboundAliasing → encodeWireMessage (CBOR) → binary frame (6B header) → binary fragmentation → FragmentReassembler
                                                                                      └→ FragmentCollector<Uint8Array>

Text pipeline (SSE, HTTP):
  applyOutboundAliasing → encodeTextWireMessage (JSON) → text frame ("Vx" prefix) → text fragmentation → TextReassembler
                                                                                        └→ FragmentCollector<string>

Shared:
  AliasState ← per-channel-direction alias table (docId/schemaHash → integer)
  applyOutboundAliasing / applyInboundAliasing ← single ChannelMsg ⇄ WireMessage transformer
  Frame<T> type ← universal frame abstraction
  FragmentCollector<T> ← generic reassembly (FC/IS design)
  CollectorOps<T> ← injected { sizeOf, concatenate }

File Map

File Purpose
src/frame-types.ts Frame<T>, Complete<T>, Fragment<T> types, constructors, type guards
src/cbor.ts encodeWireMessage/decodeWireMessageChannelMsg ↔ CBOR-encoded WireMessage bytes
src/json.ts encodeTextWireMessage/decodeTextWireMessageChannelMsg ↔ JSON-safe WireMessage objects
src/constants.ts Wire version, header size, frame types, fragment sizes, identifier length caps
src/alias-table.ts Pure ChannelMsg ⇄ WireMessage transformer with alias state and feature snapshotting
src/wire-message-helpers.ts encodeWireMessage/decodeWireMessage (binary) and encodeTextWireMessage/decodeTextWireMessage (text) — operate on pre-formed WireMessage
src/validate-identifiers.ts UTF-8 byte-length validation for DocIds and schema hashes
src/wire-types.ts CBOR integer discriminators and compact field names
src/frame.ts Binary frame encode/decode (encodeBinaryFrame, decodeBinaryFrame, convenience functions)
src/text-frame.ts Text frame encode/decode (encodeTextFrame, decodeTextFrame, fragmentTextPayload)
src/fragment-generic.ts SubstrateOps<T>/WireCodec<T> interfaces, fragmentGeneric<T>, and the pure nextFrameSeq seq helper
src/fragment-collector.ts Generic FragmentCollector<T>, pure decideFragment, CollectorOps<T>, TimerAPI
src/reassembler-generic.ts Reassembler<T> — substrate-agnostic wrapper around FragmentCollector<T>

The Version column is the binary WIRE_VERSION byte (the text pipeline carries its own TEXT_WIRE_VERSION, currently 2). All entries are pre-release; the first stable release ships at WIRE_VERSION = 3.

Version Changes
0–1 Pre-release. Unified Frame<T> architecture. 7-byte binary header. Single-byte transport prefixes. 8-byte string frameId, 4-byte index/total.
2 Compact 6-byte binary header (version, type, payloadLength); no hash-algorithm byte (deferred to frame trailer). Numeric uint16 frameId / index / total; uint32 totalSize. Removed transport-prefix layer. DocId & schemaHash aliasing with a/dx/sa/shx fields. Wire features negotiation in establish (f map; backward-compat). Identifier length caps (DocId 512 UTF-8 bytes; schemaHash 256). Delivery-mode taxonomy (muxed, streamed-deferred, datagram-deferred). vacant message (0x14) — additive negative-ack to interest; old peers reject the unknown discriminator harmlessly (set-membership), so it is wire-backward-compatible. Establish protocolVersion (pv: [major, minor]; absent ⇒ [1,0]; emitted only when non-default) — names the sync wire-contract revision, compared by the three-tier rule (features silent / minor warning / major error); additive and byte-identical for [1,0] peers.
3 Current. 10-byte binary header — appends a uint32 seq (per-direction monotonic message id) at offset 6; payloadLength stays at offset 2. Text frames gain a seq element after the prefix (TEXT_WIRE_VERSION 1→2). The per-fragment frameId metadata field is removed — the header seq is the reassembly group key (binary fragment meta 10→8 bytes; text fragment arity unchanged). seq stamps every frame so each exchanged message is referenceable for debugging/tracing, surfaced via the opt-in WireOpts.onFrame hook. Breaking vs. v2 — gated by the version byte / text prefix; landed pre-2.0 so no shipped peers are affected.