joule-moq gains mpeg2ts: the catalog entry of an "mpeg2ts" track with the draft's field rules, a publisher's side in its four modes (a program or a multiplex forwarded, one program derived with its PAT rewritten, one PID's packets, 192-octet source packets), groups at random access points, and a subscriber's reader that refuses each fault with a reason of its own: an object cut inside a packet, a packet out of step, a lost sync byte, a broken continuity counter, PSI that does not read or whose CRC fails, a PAT or PMT that disagrees with the catalog, an unsignalled change of time base. The transport stream syntax is read from Rec. ITU-T H.222.0 (08/2023). A multiplexer for tests builds the corpus's streams from a wai-pack floor's CMAF segments; no stream comes from outside. moq-streaming-format 4.11 states the track's relation to WAI: receipted like any other, and neither a package's floor nor a carriage of envelopes. catalog_check.py --strict reads the packaging, with 26 planted faults. Corpus wai/moqt-conformance/mpeg2ts with a second implementation in Python. decisions D20. Co-Authored-By: Claude Fable 5.2 <[email protected]>
42 KiB
joule-moq
MoqT (Media over QUIC Transport) for WAI broadcast media, with a JWP receipt extension: every object carries verifiable provenance and its energy figure.
The drafts it implements
draft-ietf-moq-transport-23 (2 November 2026), the current draft, and three earlier drafts
for interoperation with deployed relays and other implementations:
draft-ietf-moq-transport-22 (1 October 2026), the model this crate is written in,
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 -23, then -22, -18 and -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, the model's.
Each draft is a codec module of its own: draft23, draft22, draft18, draft16. The rest
of the crate (tracks, objects, receipts, the session's state) is written once, in -22's model;
draft23::bridge, draft18::bridge and draft16::bridge map every message and object of the
other 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 four drafts with one implementation.
Where -23 differs from -22
-23 has -22's session shape, integer, Location, Key-Value-Pairs, names, Token, subgroup
streams, datagrams and padding; draft23 uses -22's code for those and has its own for the
rest. Its change log (§A.1) lists each change; each is implemented as follows:
| -23 (issue) | draft23 |
the bridge to -22 |
|---|---|---|
control message Length a varint (#2017); MAX_CONTROL_MESSAGE_SIZE, Setup Option 0x09, at least and by default 65535 |
control: framed with a varint, a Length past the limit refused before its body is waited for (FIELD_LENGTH_EXCEEDED); the option read and checked |
the frame rewritten; the option kept, as an unknown one is at -22 |
FETCH_OK gone, 0x18 reserved; FETCH answered by REQUEST_OK with EXPIRES and LARGEST_OBJECT (#2009) |
control: 0x18 an unknown message (UNEXPECTED_MESSAGE); RequestOk::check_for(Fetch) |
-23 to -22: End Location from LARGEST_OBJECT inside the requested range, else the requested end; -22 to -23: LARGEST_OBJECT where the End Location falls short; End Of Track becomes End of Track (0x81) after the last object on the fetch stream |
| a relative Location filter in FETCH is INVALID_RANGE (#1960) | read; the publisher's to refuse | refused with INVALID_RANGE by the bridge, both ways |
FORWARD (0x10, 1 forward) renamed PAUSED (0x11, 1 paused) (#2002) |
params::Parameter::Paused; 0x10 unknown (INVALID_PARAMETER) |
inverted, both ways |
| REQUEST_UPDATEs processed and answered in order; FILL_PARAMETERS and TRACK_NAMESPACE_PREFIX updates not coalesced (#1938, #2042) | RequestUpdate::may_coalesce |
the relay answers updates in order, one by one |
| LARGEST_OBJECT a relay computes (#1978) | read and written | the relay's: the largest of what its upstream publisher stated (SUBSCRIBE_OK, PUBLISH, PUBLISH_STATE_NOTIFY) and the objects it received on its upstream subscriptions, all unpaused and unfiltered; an object counts once whole, not from its first byte (joule-moq-relay's README) |
overlapping SUBSCRIBE_NAMESPACE and SUBSCRIBE_TRACKS allowed; PREFIX_OVERLAP gone; SUBSCRIBE_TRACKS_ID (0x36) (#2018) |
codes, params |
the relay refuses an overlap only at -22 and earlier; SUBSCRIBE_TRACKS_ID dropped (the relay refuses SUBSCRIBE_TRACKS) |
| updating a namespace subscription's prefix (#2019) | read | the relay's |
| SUBSCRIBE_TRACKS lists its parameters (#1995); FILL_PARAMETERS also in SUBSCRIBE_TRACKS and PUBLISH (#2007) | params::ctx |
FILL_PARAMETERS in PUBLISH refused (NOT_SUPPORTED): -22 has no fill for a PUBLISH |
| duplicate PUBLISH_NAMESPACE allowed (#2021) | the relay keeps each, the namespace published until the last is withdrawn | |
| NAMESPACE carries Parameters (#1858) | control::Namespace; none is defined |
dropped by name if any |
| FILL_PARAMETERS a count of parameters, not a length (#2015) | params |
the inner parameters mapped |
| EXPIRES in every OK (#1994) | params::ctx |
dropped where -22's message has no place for it |
| specific session codes in place of PROTOCOL_VIOLATION (#1773) | error: the code each fault gets, by plane |
a -23 session is closed with -23's code |
REQUEST_ERROR's Error Details: REDIRECT (0x01), UNSUPPORTED_PROPERTY (0x02) (#2010, #2011) |
control::ErrorDetail; REDIRECT without its detail is MALFORMED_MESSAGE |
the Redirect both ways; other details dropped by name |
| unknown Mandatory Track Properties in TRACK_STATUS_OK (#2016) | data::unknown_mandatory |
the relay's |
| EXPIRED_AUTH_TOKEN for PUBLISH_DONE (#1970), UNAVAILABLE for REQUEST_ERROR (#2041) | codes |
numbers travel; on a -23 session the relay ends a subscription with PUBLISH_DONE EXPIRED_AUTH_TOKEN when the grant it was held by expires, and on the older drafts' sessions, whose PUBLISH_DONE has no such status, the session |
| GOAWAY on a request stream from its receiver only (#1966) | request::RequestStream |
a -23 requester's GOAWAY closes the session (PROTOCOL_VIOLATION); a -22 requester's is not sent |
| a receiver need not detect every duplicate Request ID (#1993) | -22's tracker, which detects every one | |
| Track Names not empty (#1954) | refused (PROTOCOL_VIOLATION), except a Redirect's for a namespace request | an empty -22 name refused (NOT_SUPPORTED) |
| NEW_GROUP_REQUEST before DYNAMIC_GROUPS is known (#2008) | read | |
a Gone object state, End of Gone Range (0x40C) (#2022) |
data::RangeEnd::Gone |
sent to -22 as End of Unknown Range |
End of Range delta-coded (#2031); Object ID Delta + 2 within a group (#2006); End of Group (0x80) and End of Track (0x81) (#2013) |
data::FetchCodec |
every fetch stream re-encoded, both ways; End of Group dropped at -22, and an End of Range inside the prior entry's group, which -22 (as read here) cannot say, left as a gap |
| UNKNOWN_OBJECT_STATUS reset code gone (#2004) | codes |
|
| a supported property outside its scope, or repeated, makes the track malformed (#2039, #2044) | data::check_properties |
a subgroup stream's bytes are -22's and pass the bridge as they are: the relay's reader applies the rule to a -23 session's (draft22::data::UniStreamDecoder::objects_at_23) |
| SUBGROUP_HEADER PROPERTIES bit for later objects (#1915) | the sender's | |
| grease values are not Mandatory Track Properties (#2037) | data::is_mandatory, Properties::check_object_as |
the relay's reader takes -23's reading on a -23 session's streams and keeps -22's on a -22 session's |
Where -23 leaves an encoding open
- MAX_CONTROL_MESSAGE_SIZE's type,
0x09, is odd, so as a Key-Value-Pair its value is length-prefixed bytes, while §9.1.8 encodes the size as a variable-length integer: the bytes hold exactly one varint. A value below 65535, or bytes that are not one varint, do not match the option's definition (MALFORMED_KEY_VALUE). - §9.1 forbids repeating a Setup Option but names no code: PROTOCOL_VIOLATION.
- End of Group and End of Track close the group of the prior entry, which may be an End of Range; the encoder writes them only right after an object.
- A REDIRECT Error Detail whose value does not read is a Key-Value-Pair that does not match its type (§8.3, MALFORMED_KEY_VALUE); a REDIRECT beside a code other than REDIRECT, like UNSUPPORTED_PROPERTY beside one other than UNSUPPORTED_EXTENSION, is kept as an unknown detail and ignored (§9.4.2).
- A fault whose rule names no code takes, on a control or request stream, MALFORMED_MESSAGE when it is cut short or has bytes left over and INVALID_FIELD for a value out of range; on a data stream TRUNCATED_OBJECT for a FIN inside an object; in a datagram MALFORMED_OBJECT.
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.
Mappings of other MoQ drafts
Two modules map a draft onto this crate's objects; neither is on a session's path.
file:draft-jennings-moq-file-05. A track's objects as a.moqfile (one JSON member an object) beside a.datfile of their payloads, read back byte for byte, properties and all, so a track and its receipts track verify from the files alone. The draft is written against -14; the module writes -23's objects, and -22's, -18's and -16's, which carry the same. WAIextensions/live-recording.md§8.7; corpuswai/moqt-conformance/moq-file/.v3c:draft-yuyou-moq-v3c-00. Awai.pc.vpccpayload split into the draft's component tracks and joined back. WAIextensions/moq-streaming-format.md§4.5; corpuswai/moqt-conformance/v3c/. ISO/IEC 23090-5 was not read, and the corpus holds no bitstream an encoder wrote.
cargo run -p joule-moq --example oneway_vectors -- gen writes both corpora, and
tests/oneway_vectors.rs holds the crate to them.
Track switching and probe tracks
Two modules are what an adaptive subscriber and the relay that serves it compute; neither opens a stream, and neither is on a session's path until a caller puts it there.
switching:draft-ugur-moq-fast-switching-00, read withdraft-gurel-moq-track-switching-01. From an MSF catalog'saltGroupandkeyframeSpacingit reads a Switch Set, its GoP Ladders and its Alignment Unit, and refuses a set in which a spacing is not a multiple of that unit. It gives a Location's Frame Number, a target's nearest Switch Point and its Distance, the track to switch to under the draft's full policy or its simpler one, and the End Group expected of the current track. ASwitchPlanstates the switch as the two requestsdraft-ietf-moq-transport-23has: a SUBSCRIBE of the target with an absolute-start Location Filter at the Switch Point, and a REQUEST_UPDATE that ends the current subscription at the End Group.SWITCH_FROM,altTrackGroupandSwitch Track Aliasare not in -23 and are not written; the module's text says what they would add. AReleaseBuffertakes both tracks' objects through the overlap and presents each Frame Number once, in order. A switch that would start inside a Group, or at a Group the target never publishes, is refused, each with its own code, and so is a plan whose End Group would leave frames unsent. Corpuswai/moqt-conformance/switching/.probe:draft-gurel-moq-probing-00. Dynamic Probe Names parsed by the draft's grammar with every invalid case of its §7.1 under its own reason; preset names; the resolution of its Figure 4 in exact integer arithmetic; a probe's objects, each with its Location, its payload size and its release time, at the lowest publisher priority, with LOC's Timestamp property and a payload of zeros; the per-probe, session and relay-wide limits of its §7.2 with Table 5's defaults as a configuration, nothing clamped; the error code of each outcome in each direction and its Retry Interval; the probe catalog, which has no member a limit could be carried in; the session-scoped namespaces and the empty-namespace form; and a receiver's measurement of throughput and of its arrival gaps against the send gaps. The relay that serves probes calls this module. Corpuswai/moqt-conformance/probe/.
cargo run -p joule-moq --example adaptation_vectors -- gen writes both corpora, and
tests/adaptation_vectors.rs holds the crate to them.
MPEG-2 transport streams
mpeg2ts (draft-gregoire-moq-msfts-02; WAI extensions/moq-streaming-format.md
§4.11): a track whose objects are whole transport stream packets. Track is its catalog
entry; group_starts, package, derive_program, derive_es, to_m2ts and init_data
are a publisher's side in the draft's four modes; Reader is a subscriber's, refusing each
fault with a reason of its own and reporting what the draft leaves to a receiver. The
packet, section, counter and clock rules are read from Rec. ITU-T H.222.0 (08/2023). mux
is a multiplexer for tests alone. Such a track carries receipts as any other does, and is
neither a WAI package's floor nor a carriage of envelopes. Corpus
wai/moqt-conformance/mpeg2ts/: cargo run -p joule-moq --example mpeg2ts_vectors -- gen
writes it, and tests/mpeg2ts_vectors.rs holds the crate to it.
Test tracks
moq_test(draft-afrind-moq-test-01, the latest revision, expired): a Track Namespace of sixteen tuple fields is the parameters of a track of synthetic objects.Paramsreads and writes the fields (every one written out: no draft spoken here carries the blank field the draft uses for a default),Limitsis what a publisher produces at most,Params::schedulelists every object with its Location, subgroup, status and length, andCheckerholds what a subscriber received to that schedule, with a reason of its own for each fault.joule-moq-interop --test moq-testruns it through a relay. Corpuswai/moqt-conformance/moq-test/.test_tracks(draft-marzot-moq-test-tracks-00): the namespace of a set of media test tracks: its grammar and refusals, its one shortest form, its tracks with their ladders, labels, altGroups and bitrates, and each track's timing. It produces no media: the draft's content is encoded video and audio this repository does not compute. Corpuswai/moqt-conformance/test-tracks/.
cargo run -p joule-moq --example test_track_vectors -- gen writes both corpora, and
tests/test_track_vectors.rs holds the crate to them.
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).
An extension parameter: RELAY_PATH
-22 §9.20 has every Message Parameter "defined in the negotiated version of MOQT or negotiated
via Setup Options". joule-moq-relay's cascading negotiates one: each end of a session between
two relays declares the Setup Option RELAY_ID (0x3A4F, odd, so its value is bytes: the
relay's 32-byte id), and with both declared every request one relay sends the other may carry
RELAY_PATH (0x3A51): length-prefixed, 1 to 16 relay ids of 32 bytes each, concatenated, the
sender last. The codec reads it in SUBSCRIBE, FETCH, TRACK_STATUS, PUBLISH_NAMESPACE and
SUBSCRIBE_NAMESPACE (Parameter::RelayPath, Parameters::relay_path) and in no other message;
a length that is not a multiple of 32, or past 16 ids, does not read. Whether it was negotiated
is the session's to know: a receiver that did not negotiate it holds it unknown and closes the
session with PROTOCOL_VIOLATION, as §9.20 requires. At -23 it is negotiated the same way and has
the same encoding and the same messages (draft23::params::Parameter::RelayPath; -23 §9.19
admits a parameter "negotiated via Setup Options" as -22 §9.20 does), draft23::bridge carries
it both ways, and an unnegotiated one, one in another message, or one that is not 1 to 16 ids
is INVALID_PARAMETER there; the -23 corpus holds it (two messages that carry it, four refused).
The -18 and -16 bridges have no form for it and refuse it by name.
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 reads the client's SETUP before it sends its own
(draft-ietf-moq-privacy-pass-auth-04 §3.5.1): every SETUP of a relay carries a challenge
(GRANT_CHALLENGE, 0x3A47) good for one later SETUP there, and the client's carries the
grant as an AUTHORIZATION TOKEN beside the proof (GRANT_PROOF, 0x3A49) over a challenge
an earlier SETUP gave it, with the context SETUP //<authority><path>. A SETUP refused says
so (GRANT_REFUSED, 0x3A53, the reason's code; add_refusal, refusal_of), and
refusal_asks_for_proof says whether a proof over the challenge beside it answers it. 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.
LOC objects (WAI extensions/loc-carriage.md, loc): a WAI envelope whose payload is
one WebCodecs chunk (an Opus packet, an AVC or HEVC access unit in Annex B, an ADTS frame)
rides a track of packaging loc (draft-ietf-moq-loc-05) as one object: the payload is the
LOC Payload, and the Immutable Properties hold the LOC properties (Timestamp 0x10,
Timescale 0x08, Video Frame Marking 0x09, Audio Level 0x0C, Video Config 0x0D,
Audio Config 0x0F, and -05's Codec String 0x11 where the publisher states one), the envelope's manifest as WAI_MANIFEST (0x3A61, bytes) and
WAI_RECEIPT. loc::to_loc writes one and loc::from_loc reads it back into the envelope,
byte for byte, its properties and its receipt property, refusing with the codes of the
extension's §6. tests/loc_vectors.rs regenerates WAI's corpus live-conformance/loc/
(examples/loc_vectors.rs gen) and checks every vector in it.
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 -23 corpus
tests/vectors/draft-23/vectors.json holds -23's vectors in the same form, generated by
tests/draft23_vectors.rs and decoded independently by tests/vectors/draft-23/moqt23_check.py,
written from -23's text: every control message and every parameter each may carry, the
forms -23 changed (a varint Length, MAX_CONTROL_MESSAGE_SIZE, REQUEST_OK answering FETCH, Error
Details, NAMESPACE's parameters, PAUSED, SUBSCRIBE_TRACKS_ID, counted FILL_PARAMETERS, fetch
streams with each indicator in both Group Orders), and negative vectors, the planted faults,
each with the session code -23 gives it. Among them each -22 form a -23 endpoint meets: FETCH_OK
(0x18), FORWARD, a 16-bit Length, a length-prefixed FILL_PARAMETERS, NAMESPACE without its
parameter count. The -23 view of a refusal names -23's code (session:UNEXPECTED_MESSAGE,
session:INVALID_PARAMETER, ...); NAMESPACE has parameters; REQUEST_ERROR has details; a
fetch stream has {"end_of_range": {"kind": "gone", ...}}, {"end_of_group": true} and
{"end_of_track": true}.
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 four corpora (tests/draft23_vectors.rs,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/draft23.rsandtests/drafts.rsrun sessions at -23, -18 and -16 through the relay (see that crate), andjoule-moq-interopruns this crate against relays (below).
Interoperability
Our own implementations, at each draft
Runs of 2026-11-10, on loopback (joule-moq-interop's scripts/local.sh, the relay on a port
the system chose): joule-moq-interop (this crate's client) against joule-moq-relay; the
relay's own sessions (joule-moq-relay's tests/draft23.rs, tests/drafts.rs); the ingest
gateway's sessions to the relay (joule-moq-ingest's tests/ertmp.rs); and the browser client
(joule-moq-web's e2e/room.e2e.mjs, scenarios webtransport23 and websocket23).
| implementation | transport | -23 | -22 | -18 | -16 |
|---|---|---|---|---|---|
joule-moq-interop: the runner's eight cases |
QUIC | pass | pass | pass | pass (no RENDEZVOUS_TIMEOUT at -16) |
joule-moq-interop: wai-receipts |
QUIC | pass | pass | pass | pass |
joule-moq-interop: wai-receipts |
WebTransport | pass | pass | pass | pass |
joule-moq-interop: wai-receipts, publisher and subscriber at different drafts |
QUIC | 23 to 22, 22 to 23, 23 to 18, 16 to 23: pass | 16 to 22, 22 to 16: pass | 18 to 16, 16 to 18: pass | |
joule-moq-interop: --tamper payload, receipt, in flight (must fail) |
QUIC | fails, as it must | fails, as it must | ||
joule-moq-relay: two publishers, three subscribers, every object verified |
QUIC, WebTransport, WebSocket | pass | pass | pass | pass |
joule-moq-relay: -23 and -22 clients through one relay, both directions |
QUIC, WebTransport, WebSocket | pass | pass | ||
joule-moq-relay: FETCH from the cache |
QUIC, WebTransport | pass | pass | pass | pass |
joule-moq-ingest: Enhanced RTMP into the room, the gateway's link at the draft, a -22 watcher |
QUIC | pass | pass | pass | pass |
joule-moq-web: the room page in a browser (three publishing pages and a late joiner by FETCH, every object verified, the relay's summaries naming the draft) |
WebTransport, WebSocket | pass | pass |
Other implementations
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.