# joule-moq MoqT (Media over QUIC Transport) for CommunicationOS broadcast media, with a JWP receipt extension: every object carries verifiable provenance and its energy figure. ## The drafts it implements `draft-ietf-moq-transport-22` (1 October 2026), the default, and two earlier drafts for interoperation with deployed relays and other implementations: `draft-ietf-moq-transport-18` (12 May 2026), the working group's interoperability target, and `draft-ietf-moq-transport-16` (13 January 2026). A draft is chosen before the first MOQT byte, by the ALPN `moqt-NN` on native QUIC or the `WT-Available-Protocols` value `moqt-NN` on WebTransport; this crate prefers -22, then -18, then -16 (`draft::Draft`). Drafts do not interoperate with each other, so a session is one draft from its first byte to its last. `MOQT_DRAFT_VERSION` is -22's conventional code, `0xff000016`. Each draft is a codec module of its own: `draft22`, `draft18`, `draft16`. The rest of the crate (tracks, objects, receipts, the session's state) is written once, in -22's model; `draft18::bridge` and `draft16::bridge` map every message and object of the earlier drafts to and from it, and name what one draft can say and the other cannot. `joule-moq-relay`'s `transport::bridge` runs a whole session that way, so the relay and its client speak all three drafts with one implementation. ### Where -18 differs from -22 -18 has -22's integer, Key-Value-Pairs, names, Token structure, message frame, a pair of unidirectional control streams and a bidirectional stream per request, and -22's objects on the wire; `draft18` uses -22's code for those and has its own for the rest: | | -18 | -22 | the bridge | |---|---|---|---| | filter parameter `0x21` | SUBSCRIPTION_FILTER, length-prefixed: Next Group Start, Largest Object, AbsoluteStart, AbsoluteRange (End Group Delta) | LOCATION_FILTER, six forms | Relative Start 0, Next Object, Absolute Start, Absolute Start Group End; -22's other forms are refused | | FILL_PARAMETERS, Range Filters, INCLUDE_PROPERTIES | none | defined | refused (NOT_SUPPORTED) | | PUBLISH_OK's parameters | the subscription's (filter, forward, priority, group order, timeouts) | EXPIRES only; the rest by REQUEST_UPDATE | left out, by name | | GOAWAY | a Request ID, on the control stream | none | supplied from the peer's Request IDs seen | | FETCH | Fetch Type: Standalone (End exclusive, `{g, 0}` the whole group) or Joining | namespace, name, LOCATION_FILTER (inclusive); joining by fill | exclusive to inclusive; Joining refused | | FETCH_OK End Location | exclusive | inclusive | `{g, o}` to `{g, o-1}`, `{g, 0}` to `{g, 2^64-1}` | | PUBLISH_BLOCKED / PUBLISH_SKIPPED | 0x0F | 0x0F | renamed | | PUBLISH_STATE_NOTIFY | none | 0x22 | left out | | PUBLISH_DONE Stream Count, unknown | 2^62-1 | 2^64-1 | mapped | | End of Timed-Out Range (0x20C) | none | defined | sent as End of Unknown Range | | Setup Options 0x06, 0x08 | unknown | MAX_FILTER_RANGES, MAX_REQUEST_UPDATES | left out both ways | | codes | VERSION_NEGOTIATION_FAILED, DUPLICATE_SUBSCRIPTION, INVALID_JOINING_REQUEST_ID, SUBSCRIPTION_ENDED | TOO_MANY_REQUEST_UPDATES, CONFLICTING_FILTERS, INVALID_FILTER | by number; an unknown code reads as INTERNAL_ERROR | | a requester's FIN on PUBLISH_NAMESPACE, SUBSCRIBE_NAMESPACE, SUBSCRIBE_TRACKS | cancels it (§6.1: "closing the stream with either a FIN or RESET_STREAM"; §6.2: withdrawn by cancelling) | not a cancellation (§6.4.2.2) | an -18 FIN reaches -22 as a cancellation; a -22 requester's FIN on these is not sent | -18 itself leaves two things open, read here as follows: Table 5 lists `0x1E PUBLISH_OK` pointing to REQUEST_OK, while §10.5 and the change log make PUBLISH_OK the name of the REQUEST_OK that answers a PUBLISH; this crate sends 0x07 and reads either. And §2.5 names `0x38`-`0x3F` as the one-byte application property range where its §15.8 (and -22) give `0x78`-`0x7F`; this crate's own properties are in `0x3800`-`0x3FFF`, which both give. ### Where -16 differs from -22 -16 writes every integer as QUIC's (RFC 9000 §16: 1, 2, 4 or 8 bytes, at most 2^62-1), and has another session shape, so `draft16` is its own throughout: | | -16 | -22 | |---|---|---| | control | one bidirectional stream the client opens: CLIENT_SETUP (0x20), SERVER_SETUP (0x21), then every request and response by Request ID | SETUP (0x2F00) on each end's unidirectional stream; a stream per request | | Request IDs | the next in sequence (§9.1), within MAX_REQUEST_ID (setup parameter 0x02, message 0x15), REQUESTS_BLOCKED (0x1A) | parity and uniqueness | | cancelling | UNSUBSCRIBE, FETCH_CANCEL, PUBLISH_NAMESPACE_DONE, PUBLISH_NAMESPACE_CANCEL | resetting the request's stream | | Message Parameters | Key-Value-Pairs: priority, group order and forward as integers, LARGEST_OBJECT length-prefixed; one DELIVERY_TIMEOUT (> 0) | each in its own form; two delivery timeouts | | SUBSCRIPTION_FILTER AbsoluteRange | End Group absolute | (-18) a delta | | SUBSCRIBE_NAMESPACE (0x11) | Subscribe Options: PUBLISH, NAMESPACE or both | SUBSCRIBE_NAMESPACE and SUBSCRIBE_TRACKS | | SUBGROUP_HEADER | no FIRST_OBJECT bit | FIRST_OBJECT 0x40 | | fetch objects | Group and Object IDs absolute | delta-coded in the Group Order | | padding | none | streams and datagrams | | a Track Namespace | 1 to 32 fields | 0 to 32 | | PUBLISH_DONE codes | EXPIRED 0x5, TOO_FAR_BEHIND 0x6 | the reverse | | stream reset UNKNOWN_OBJECT_STATUS | 0x4 | 0x6 (0x4 is GOING_AWAY) | `joule-moq-relay`'s bridge renumbers this end's Request IDs into -16's sequence, grants the peer requests ahead of it, routes each request and its responses to a stream of its own, turns a cancelled stream into -16's cancel message, and re-encodes every object (the pairs inside Immutable Extensions included). A request that finds no Request ID left under the peer's MAX_REQUEST_ID for ten seconds is refused locally, with EXCESSIVE_LOAD. -16's SUBSCRIBE_NAMESPACE rides a bidirectional stream of its own (§9.25) while every other request rides the control stream, and QUIC does not order one stream against another, so a peer's request can arrive ahead of an earlier one still in flight: the bridge admits a new ID of the peer's parity within the grant, leaving the gap, and closes the session with INVALID_REQUEST_ID for an ID seen before, where §9.1 would close it for any ID out of sequence. ## What is implemented The codec (`draft22`), every decoder bounded (it reads no byte that is not there and allocates nothing from a length before the bytes behind it exist) and every refusal a typed `WireError` carrying the draft's consequence: close the session with a code, reject the request, or a malformed track. | -22 | module | |---|---| | §8.1 variable-length integer: 1 to 9 bytes, leading ones, longer forms accepted | `draft22::varint` | | §8.2 Location, §8.7 Track Namespace (at most 32 fields, none empty, 4,096 bytes for a Full Track Name), §8.8 rendering | `draft22::names` | | §8.3 Key-Value-Pairs (delta types, values up to 2^16-1 bytes), §8.4 and §10 Properties with Immutable Properties, Prior Group and Object ID Gaps and Mandatory Track Properties, §9.1 Setup Options | `draft22::kvp` | | §8.9 Authorization Token structure | `draft22::token` | | §9 every control message of Table 5, framed by a 16-bit Length | `draft22::control` | | §9.20 every Message Parameter, the list each message allows, the rules on repetition; §8.6 Range Filters; §9.20.9 Location Filters; FILL_PARAMETERS | `draft22::params` | | §6.3 and §6.4.2 control and request streams, Request IDs by end, §9.1.7 MAX_REQUEST_UPDATES credit, the order of messages on each request stream | `draft22::request` | | §11 subgroup streams, object datagrams, fetch streams with End of Range, padding; a whole-stream decoder and an incremental one | `draft22::data` | | §12 and §16.11 session, request, PUBLISH_DONE and stream reset codes; §13 grease | `draft22::codes` | On it: - `session`: the draft by ALPN, SETUP options, Request IDs, the receipt extension declared by a Setup Option. - `transport_quic` (feature `quic`): native QUIC on quinn. A pair of unidirectional control streams, each opened by SETUP; a bidirectional stream per request; subgroup, fetch and padding streams; object datagrams. - `mock`: an in-memory fabric and relay whose objects, receipts and requests cross the -22 codec. - `carriage`: how this crate's objects and receipts ride -22 objects (below). ### Where -22 leaves an encoding open The codec takes one reading of each, and the vector checker takes the same: - FILL_TIMEOUT (0x0A) and RENDEZVOUS_TIMEOUT (0x04) state no encoding; both are durations in milliseconds and are read as varints, as the draft encodes its other durations. - FILL_PARAMETERS is length-prefixed and holds "a sequence of Parameters": read up to its length, with no count before them, type deltas starting from 0. - End of Range (§11.4.1.2) says "the Group ID and Object ID fields are present"; the only such fields of a fetch object are the Group ID Delta and Object ID Delta, which bits 0x08 and 0x04 of 0x8C, 0x10C and 0x20C mark. They are read as an object's are: absolute on the first entry, and after it a Group ID that moves one or more groups in the request's Group Order, with an absolute Object ID. - An unknown message, stream or datagram type closes the session; -22 names no code, and this codec gives PROTOCOL_VIOLATION. ## Carriage of this crate's objects and receipts Object Properties in the range -22 sets aside for an application's own use (§8.4), inside the object's Immutable Properties, since a relay must not change them: | type | name | value | |---|---|---| | `0x3A40` | `MRL_LAYER` | varint: the object's layer, when the header states layers | | `0x3A41` | `WAI_RECEIPT` | bytes: WAI `extensions/moq-streaming-format.md` §5's property, laid out below | | `0x3A42` | `MRL_TOTAL_LAYERS` | varint, when non-zero | | `0x3A43` | `JWP_OBJECT_RECEIPT` | bytes: the object receipt standing alone, in the binary form of WAI `extensions/jwp-receipts.md` §2.7.2 | | `0x3A44` | `WAI_ENVELOPE` | varint `1`, when the payload is a WAI envelope | A layer cap on a SUBSCRIBE is an OBJECT_PROPERTY_FILTER on `MRL_LAYER` (`messages::mrl_cap_filter`), which needs the peer's MAX_FILTER_RANGES above 0. An object's delivery timeout rides as its OBJECT_DELIVERY_TIMEOUT property (§10.2). The `WAI_RECEIPT` value: `content_hash` (32 bytes), `joules_micro` (a -22 varint), `acquisition` (a varint length, then UTF-8), `acquisition_evidence` (a varint length, then bytes), `group_receipt` (32 bytes), and nothing after. A group's receipts ride its receipts track (moq-streaming-format §4.1): group *g* carries one object, the group receipt and the object receipts of group *g* of the media track in the binary form of WAI jwp-receipts §2.7.3: `WJR1`, the group receipt's canonical bytes (its signing payload in the form signed, then its signature, and for a legacy group its parent flag and parent), a u32 count, and each object receipt's body. `ReceiptBundle::read` gives the group's link hash computed over the bytes received; `read_receipts`, `GroupReceipt::from_binary` and `ObjectReceipt::from_binary` refuse what does not read with the §3 step jwp-receipts §2.7.4 names. `tests/wai_jwp_corpus.rs` reads and writes every binary vector of WAI's corpus. **Room grants** (WAI `extensions/live-rooms.md` §10.5, `grant_proof`): a grant is public, so it is presented with a `WRP1` proof of its principal's key over a challenge the verifier chose. Over MOQT the relay's SETUP carries the challenge (`GRANT_CHALLENGE`, `0x3A47`) and the client's the grant as an AUTHORIZATION TOKEN beside the proof (`GRANT_PROOF`, `0x3A49`), with the context `SETUP //`. `check_proof` is §10.5.4's checks, given the grant's digest and principal by whatever read the grant; over HTTP the same record rides the `WAI-Room-Proof` header and the challenge `WAI-Room-Challenge`. The receipt extension is declared by the Setup Option `0x3A46`, a varint holding the extension's version. -22 has no unregistered range for Setup Options and asks for a provisional registration; until one is made the code point is this crate's choice, and a peer that does not know it ignores it (§9.1). ## Not yet implemented - `draft-ietf-moq-transport-14` (ALPN `moq-00`, the version negotiated inside the setup messages) and the drafts between -16 and -22 other than -18. A relay that offers only -14 is not reached. - WebTransport (MOQT over HTTP/3, §6.2.1), a relay with priority fan-out and per-object receipts, and the authorization token cache (§8.9) are in `joule-moq-relay`, built on this codec; this crate's own session keeps no token cache. The browser client is `joule-moq-web`, over this crate built without its default feature `runtime` (the codec, the carriage and the receipts, for `wasm32-unknown-unknown`). - Evaluating filters at a publisher (§3.3), and holding a peer to its MAX_FILTER_RANGES: the codec reads and writes every filter and counts their ranges. - GOAWAY, delivery-timeout enforcement on real streams and §5.1's scheduling are `joule-moq-relay`'s; this crate's QUIC transport has none of them, and its mock relay applies delivery timeouts on a caller-driven clock. - `draft-ietf-moq-loc` and `draft-ietf-moq-msf` semantics (catalogs, LOC properties): their properties ride as any property does. ## The vector corpus `tests/vectors/draft-22/vectors.json` holds -22 vectors: varints, every control message type, control, subgroup, fetch and padding streams, datagrams, and the `WAI_RECEIPT` value; and negative vectors, each with the refusal it must meet. `tests/draft22_vectors.rs` generates the file deterministically from typed values and holds the crate to it; `JOULE_MOQ_WRITE_VECTORS=1` rewrites it. `tests/vectors/draft-22/moqt22_check.py` decodes the corpus independently, from the draft's text, standard library only: ```sh python3 -I tests/vectors/draft-22/moqt22_check.py ``` Each vector has a `name`, a `kind`, its bytes as `hex`, `canonical` (whether this crate's encoder makes exactly those bytes) and `expect`; a `uni-stream` vector also has `context.group_order` (1 Ascending, 2 Descending), the Group Order of the request a fetch stream answers. | kind | bytes | reading | |---|---|---| | `varint` | one varint | `{"value", "length"}` | | `control` | one framed message | the message (below) | | `uni-stream` | a whole stream to its FIN | a list of events (below) | | `datagram` | one datagram | `{"datagram": {…}}` or `{"padding": n}` | | `wai-receipt` | the `WAI_RECEIPT` value | its five fields | The JSON view: byte strings are lowercase hex; a Track Namespace is a list of its fields in hex; a Location is `[group, object]`; a Key-Value-Pair is `{"type", "value"}` for an even type and `{"type", "bytes"}` for an odd one, with Immutable Properties as raw bytes. A message is an object with `"message"` (its name in Table 5) and its fields by their names in the draft, lowercased with underscores. A parameter is `{"type": NAME}` with `"value"` (a varint or a uint8), `"token"`, `"location"`, `"filter"` (a Location Filter's `filter_type` and the fields it has; a Range Filter's `set_id`, `property_type` for the property filters, and `ranges` as `[start, end or null]`; `null` for Length 0), `"parameters"` (FILL_PARAMETERS) or `"namespace"`. Stream events are `{"header": "subgroup" | "fetch" | "control" | "padding", …}`, `{"control": message}`, `{"object": …}` (subgroup), `{"fetch_object": …}`, `{"end_of_range": {"kind", "location"}}` and `{"padding": n}`. A refusal is `{"error": kind, "consequence": c}`, with `c` one of `session:`, `request:` or `malformed-track` (and `property_invalid` for the `WAI_RECEIPT` value, which a sink discards). The kinds are `WireError::kind`: `truncated`, `trailing-bytes`, `unknown-message-type`, `unknown-stream-type`, `invalid-flags`, `unknown-parameter`, `duplicate`, `parameter-not-allowed`, `duplicate-filter`, `invalid-filter`, `type-overflow`, `length-exceeds-limit`, `too-many-namespace-fields`, `empty-namespace-field`, `invalid-value`, `overflow`, `properties-on-non-normal`, `unknown-object-status`, `no-prior-object`, `empty-datagram-properties`, `malformed-token`, `unexpected-message`, `malformed-track`, and `session-rule` (a session error with a code of its own, such as KEY_VALUE_FORMATTING_ERROR). ### The -18 and -16 corpora `tests/vectors/draft-18/vectors.json` and `tests/vectors/draft-16/vectors.json` hold each earlier draft's vectors in the same form, generated by `tests/draft18_vectors.rs` and `tests/draft16_vectors.rs` (`JOULE_MOQ_WRITE_VECTORS=1` rewrites them) and decoded independently by `tests/vectors/draft-18/moqt18_check.py` and `tests/vectors/draft-16/moqt16_check.py`, each written from its draft's text: - every control message of the draft, every parameter it may carry, each subscription filter, each FETCH type, and the setup messages; - the drafts' own examples: -18 §1.4.1 Table 2's integers; RFC 9000 Appendix A.1's for -16's integer; and the first subgroup of -18 §11.6 and -16 §10.5. Their second example cannot be reproduced: it gives a Properties Length of 33 and a Length of 21 for a 14-byte value. 33 is what -16's integer would take for a 21-byte value, not -18's. The working group publishes no vector corpus of its own; - control, subgroup (Immutable Properties, End of Group), fetch (both orders at -18; absolute IDs at -16) and padding streams, and datagrams; - negative vectors for what each draft refuses that a later one reads (a -22 form in an -18 message, End of Timed-Out Range, -22's SETUP in -16, LARGEST_OBJECT without its length, a DELIVERY_TIMEOUT of 0, FIRST_OBJECT at -16, and more), each with the draft's consequence. The -18 view differs from -22's in its filter (`{"filter_type", "start", "end_group_delta"}`), FETCH (`fetch_type` and the Standalone or Joining fields) and GOAWAY (`request_id`, null when absent); the -16 view adds each response's `request_id`, calls Properties `extensions`, and writes an AbsoluteRange's `end_group`. ## Tests - `cargo test -p joule-moq --features quic --all-targets` runs the codecs' unit tests (each draft's, and each bridge's mappings both ways), the three corpora (`tests/draft22_vectors.rs`, `tests/draft18_vectors.rs`, `tests/draft16_vectors.rs`), generated round trips and hostile input (`tests/draft22_roundtrip.rs`), the QUIC transport over loopback, the mock and relay, the staged MoQ trace and WAI's JWP corpus. - The independent decoders above run over their corpora in CI (`.github/workflows/joule-code-conformance.yml`). - `joule-moq-relay`'s `tests/drafts.rs` runs sessions at -18 and -16 through the relay (see that crate), and `joule-moq-interop` runs this crate against other implementations' relays (below). ## Interoperability Runs of 2026-10-21, from the build host, with `joule-moq-interop` (in this workspace) and `joule-moq-relay` at this revision. Relays and clients are named by what they offer; the interoperability runner's registry (its `implementations.json` at commit `e8d544e9`, of 2026-10-20) lists their endpoints, and `scripts/public.py` reads it. Two scenarios per relay, draft and transport: - **the runner's test cases**: `setup-only`, `announce-only`, `publish-namespace-done`, `subscribe-namespace-lifecycle`, `subscribe-error`, `rendezvous-timeout` (none at -16, which has no RENDEZVOUS_TIMEOUT), `announce-subscribe`, `subscribe-before-announce`; - **WAI receipts end to end** (`wai-receipts`): one session publishes a catalog, a WAI track (ten groups of three objects, each a WAI envelope carrying its receipt in Immutable Properties) and its receipts track through the relay; a second session subscribes and verifies every object against its receipt: inclusion, the signed bundle, the receipts chain, and the receipt and envelope the object carries. Where it does not pass, it runs again by PUBLISH instead of the namespace route, and with no per-object properties, to find which part the relay does not carry. **Pass** is every case, both scenarios, on every transport tried; **partial** names what did not pass and why; **fail** is a scenario the relay does not carry. | Relay | Draft | QUIC | WebTransport | Result | Cause of what did not pass | |---|---|---|---|---|---| | A public MOQT relay network's draft-18 interop endpoint | -18 | yes | yes | pass | | | The same network's production endpoint | -16 | | | not covered | it needs an operator's token | | The same network's draft-14 endpoint | -14 | | | not covered | this crate has no -14 | | An open interop relay, drafts -14 to -22 on one port | -22, -18, -16 | yes | yes | pass | | | A research relay at -18 | -18 | yes | yes | pass | | | The same research relay at -22 | -22 | yes | yes | pass | | | A relay at -22 | -22 | yes | yes | pass | | | The same project's relay at -18 | -18 | yes | yes | fail | PUBLISH_NAMESPACE is accepted, but a SUBSCRIBE for a track in it gets no answer, nor does one after PUBLISH: `announce-subscribe` and receipts fail; no NAMESPACE for a subscribed prefix; `rendezvous-timeout`: DOES_NOT_EXIST. | | A relay at -16 and -18 | -18, -16 | yes | yes | pass | | | A relay at -14 to -22 | -22, -18, -16 | yes | yes | pass | | | A media server's relay at -22 | -22 | yes | yes | partial | `rendezvous-timeout`: DOES_NOT_EXIST at once, not TIMEOUT after the 500 ms asked. Receipts pass. | | A relay library's relay, -16 to -22 | -22, -18, -16 | yes | yes | partial | Receipts pass at every draft. A namespace a closed session published stays registered, so the next session's PUBLISH_NAMESPACE of it is refused ("Namespace already published"): `announce-only`, `publish-namespace-done`, `announce-subscribe`, `subscribe-before-announce` over QUIC at -22 and -18, `publish-namespace-done` over WebTransport. | | An open relay network's anonymous endpoint, -14 to -20 | -18, -16 | yes | yes | partial | Receipts: every object delivered and verified against the receipts track, but the relay drops Object Properties, so no object carries its own receipt or envelope; it refuses PUBLISH (NOT_SUPPORTED). `rendezvous-timeout` at -18: DOES_NOT_EXIST ("unroutable"). Once, over WebTransport, an internal transport error answered a SUBSCRIBE. Every runner case passes at -16. | | A relay at -18 to -22 | -18 | yes | yes | partial | As the row above: Object Properties dropped, PUBLISH refused, `rendezvous-timeout` DOES_NOT_EXIST. | | The same relay at -22 | -22 | yes | yes | fail | It refuses the publisher's SUBSCRIBE_OK with MALFORMED_TRACK ("short buffer"), so no track crosses it: `announce-subscribe` and receipts fail. The same exchange passes there at -18, and at -22 with the relays above. | | A relay at -18 with a self-signed certificate | -18 | yes | yes | partial | Objects that carry Object Properties are not forwarded: no media object arrives, while the receipts track (no properties) does and verifies; with no per-object properties every object verifies. SUBSCRIBE_OK for a track no one publishes (`subscribe-error`, `rendezvous-timeout`). | | A relay at -18 over WebTransport only | -18 | | yes | fail | Not reached from the build host: no answer to the QUIC handshake. | Other implementations' interoperability test clients against `joule-moq-relay` on loopback (`scripts/clients.sh`), three rounds each, over QUIC and WebTransport: | Client | Draft | Result | Cause | |---|---|---|---| | The public relay network's -18 test client (the runner's cases and its data-plane cases) | -18 | pass | | | An open relay network's test client, its draft pinned | -18, -16 | pass | | | The same client at its default draft | -22 | partial | Its SUBSCRIBE carries Message Parameter 0x1, which -22 does not define, so the relay closes the session (-22 §9.20: PROTOCOL_VIOLATION): `announce-subscribe`. It withdraws a namespace by a FIN, which at -22 is not a cancellation (§6.4.2.2): `subscribe-namespace-lifecycle`. | These runs found five faults in `joule-moq-relay`, fixed here (its README states the rules now): a PUBLISH_DONE that arrives before the streams it counts; a track published again after TRACK_ENDED; a publisher that closes its control stream before its last objects; an -18 requester's FIN on a namespace request; a -16 Request ID that arrives ahead of one in flight on another stream. They also found one in the client: it stopped reading after a count of streams, not of objects. `scripts/local.sh` runs the client against the relay at each draft and across drafts, and checks that `--tamper` (a payload, a receipt, or a byte of an object in flight) fails. None of these runs is in CI: they reach the public internet or run other implementations' code.