| Filename | Latest commit message | Latest commit date |
|---|---|---|
By meaning: the relay keeps PP's rating functions (take_ratings, gate, group_age) and T1's track_restarts side by side. A track restarted after TRACK_ENDED drops its cache, owners and Largest Location as T1 has it; the rating claims held for it stay, since a claim only raises an age and the publisher carries its claims again on the new PUBLISH. CHANGELOG kept both. Co-Authored-By: Claude Opus 5.5 <[email protected]> |
||
| .. | ||
| examples | ||
| src | ||
| tests | ||
| Cargo.toml | ||
| README.md | ||
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(featurequic): 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 //<authority><path>. 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(ALPNmoq-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 isjoule-moq-web, over this crate built without its default featureruntime(the codec, the carriage and the receipts, forwasm32-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-locanddraft-ietf-moq-msfsemantics (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:
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:<code>, request:<code> 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-targetsruns 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'stests/drafts.rsruns sessions at -18 and -16 through the relay (see that crate), andjoule-moq-interopruns 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.