# 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; a PUBLISH the relay sends for a SUBSCRIBE_TRACKS carries SUBSCRIBE_TRACKS_ID, added by the session's bridge; one received is kept beside its -22 form | | 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` | the delivery timeouts, SUBSCRIBER_PRIORITY and LOCATION_FILTER, which -22's SUBSCRIBE_TRACKS does not list, carried beside it (`bridge::subscribe_tracks_beside`) for the PUBLISHes it asks for, and FILL_PARAMETERS there too, which each such PUBLISH carries (added by the relay's session bridge) and which opens its fill; FILL_PARAMETERS in a PUBLISH received, which -22's PUBLISH does not list (-22 §9.8), carried beside it too (`bridge::publish_fill_beside`), the relay reading the fill fetch stream that follows into its cache | | 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; an End of Range is a Location at -22 (§11.4.1.2), so one inside the prior entry's group is carried | | 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 of the -22 message, by name; the relay's session bridge keeps those of a PUBLISH_OK to its own PUBLISH beside it and the relay applies them | | 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; a Joining FETCH the relay's session bridge receives is resolved against the subscription it names (its track; from the Start its Joining Start gives to its Joining Location, inclusive, §10.12.2.1), held while that subscription is Pending, INVALID_JOINING_REQUEST_ID or INVALID_RANGE as §10.12.2 has them (`bridge::joining_fetch_to22`) | | FETCH_OK End Location | exclusive | inclusive | `{g, o}` to `{g, o-1}`, `{g, 0}` to `{g, 2^64-1}`; answering a FETCH whose End asked for the whole of group `g`, `{g, 0}` where the response covers that group's last Object (the -22 End `{g, 2^64-1}`, or the track's final Object in `g` with End Of Track), §10.13 (and -16 §9.17) | | 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_TRACKS, SUBSCRIBE_NAMESPACE, and for both SUBSCRIBE_NAMESPACE with its PUBLISH half kept beside it by the relay's session bridge | | Joining FETCH | Relative or Absolute, of a subscription whose Filter Type is Largest Object (§9.16.2) | none; joining by fill | the relay's session bridge resolves it against the subscription it names: its track, from the Start its Joining Start gives to the Subscribe Largest Location its SUBSCRIBE_OK or PUBLISH stated, inclusive (§9.16.2.1); INVALID_RANGE with no Largest Location or a start past it, INVALID_JOINING_REQUEST_ID, and a subscription of another filter closing the session (`draft16::bridge::joining_fetch_to22`) | | 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 `.moq` file (one JSON member an object) beside a `.dat` file 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. WAI `extensions/live-recording.md` §8.7; corpus `wai/moqt-conformance/moq-file/`. - `v3c`: `draft-yuyou-moq-v3c-00`. A `wai.pc.vpcc` payload split into the draft's component tracks and joined back. WAI `extensions/moq-streaming-format.md` §4.5; corpus `wai/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 with `draft-gurel-moq-track-switching-01`. From an MSF catalog's `altGroup` and `keyframeSpacing` it 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. A `SwitchPlan` states the switch as the two requests `draft-ietf-moq-transport-23` has: 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`, `altTrackGroup` and `Switch Track Alias` are not in -23 and are not written; the module's text says what they would add. A `ReleaseBuffer` takes 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. Corpus `wai/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. Corpus `wai/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. `Params` reads and writes the fields (every one written out: no draft spoken here carries the blank field the draft uses for a default), `Limits` is what a publisher produces at most, `Params::schedule` lists every object with its Location, subgroup, status and length, and `Checker` holds what a subscriber received to that schedule, with a reason of its own for each fault. `joule-moq-interop --test moq-test` runs it through a relay. Corpus `wai/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. Corpus `wai/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` (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). ### 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": they are read as the Location itself, each a varint, not as deltas from the prior entry. An End of Range may so lie in the prior entry's group (the rest of a group whose end is not known), and the entry after it takes it as its prior Location. -23 is the draft that delta-codes the indicator (its §A.1, #2031). Until 11 November 2026 this codec read the Group ID as an object's Group ID Delta; a run against other implementations showed it (*Interoperability*), and -18, whose fetch stream is -22's, was read the same wrong way. - 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 (`joule-moq-relay` declares 32 and drops the layers past the cap for that subscriber alone). 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 //`. 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` (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; `joule-moq-relay` evaluates them for its subscribers (its README, *Range Filters*). - 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 -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-targets` runs 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. - `cargo test -p joule-moq --features "serde_json/preserve_order quic qlog secure-objects" --all-targets`, with `JOULE_MOQ_EXPECT_INSERTION_ORDER=1`, runs the same in a build whose `serde_json` keeps each object's members in the order they were inserted, as it does wherever a crate built beside this one turns `preserve_order` on (wai-rs does). Every corpus and catalog text is written through `json_order` (`src/json_order.rs`), in key order, so each regenerates byte for byte in either build; the variable makes `json_order`'s test fail if the feature did not take. - The independent decoders above run over their corpora in CI (`.github/workflows/joule-code-conformance.yml`). - `joule-moq-relay`'s `tests/draft23.rs` and `tests/drafts.rs` run sessions at -23, -18 and -16 through the relay (see that crate), and `joule-moq-interop` runs 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, built from pinned commits and run on loopback Runs of 2026-11-11 on the build host (x86_64 Linux), by `joule-moq-interop`'s `scripts/peers/`: fourteen other implementations, each built from the commit WAI decision D30 pins, run inside one network namespace that has only the loopback interface. Nothing here reached another host, and nothing was reported to anyone. The record of the run is `scripts/peers/results/2026-11-11-framework.json` (its table, `2026-11-11-framework.md`); `scripts/peers/README.md` says how to repeat it. The matrix took 1 h 18 min. Run again the same evening and in the record in place of the first: the cells of one peer added later, one peer's pairs after a fault in the test's own setup (a track's namespace), and the two-relay section after it was taught to say when a link is not made. No peer relay speaks -23: -23 is exercised against the one peer that has it, a publisher. | Peer | Draft | Pairing | Passed | Failed | Not applicable | Skipped by their client | |---|---|---|---|---|---|---| | aiomoqt | -22 | our client, their relay | 70 | 8 | 0 | 0 | | aiomoqt | -22 | their clients, our relay | 38 | 2 | 0 | 0 | | aiomoqt | -22 | two relays | 11 | 2 | 1 | 0 | | aiomoqt | -18 | our client, their relay | 70 | 8 | 0 | 0 | | aiomoqt | -18 | their clients, our relay | 38 | 2 | 0 | 0 | | aiomoqt | -18 | two relays | 11 | 2 | 0 | 0 | | aiomoqt | -16 | our client, their relay | 68 | 8 | 0 | 2 | | aiomoqt | -16 | their clients, our relay | 20 | 2 | 0 | 18 | | aiomoqt | -16 | two relays | 11 | 2 | 0 | 0 | | imquic | -22 | our client, their relay | 59 | 26 | 2 | 0 | | imquic | -22 | their clients, our relay | 20 | 12 | 0 | 0 | | imquic | -22 | two relays | 0 | 0 | 1 | 0 | | imquic | -18 | our client, their relay | 45 | 39 | 1 | 0 | | imquic | -18 | their clients, our relay | 14 | 6 | 0 | 0 | | imquic | -16 | our client, their relay | 42 | 40 | 2 | 2 | | imquic | -16 | their clients, our relay | 13 | 5 | 0 | 0 | | laps | -18 | our client, their relay | 54 | 18 | 2 | 0 | | libquicr | -18 | our client, their relay | 2 | 0 | 0 | 0 | | libquicr | -18 | their clients, our relay | 3 | 3 | 0 | 0 | | moq-dev | -22 | our client, their relay | 25 | 16 | 14 | 0 | | moq-dev | -22 | their clients, our relay | 9 | 6 | 0 | 4 | | moq-dev | -22 | two relays | 0 | 0 | 1 | 0 | | moq-dev | -18 | our client, their relay | 33 | 8 | 18 | 0 | | moq-dev | -18 | their clients, our relay | 15 | 0 | 0 | 4 | | moq-go | -22 | our client, their relay | 78 | 0 | 1 | 0 | | moq-go | -22 | their clients, our relay | 18 | 0 | 1 | 0 | | moq-go | -22 | two relays | 0 | 0 | 1 | 0 | | moq-rs | -18 | our client, their relay | 64 | 10 | 4 | 0 | | moq-rs | -18 | their clients, our relay | 40 | 0 | 1 | 0 | | moq-wasm | -18 | our client, their relay | 9 | 4 | 42 | 0 | | moq-wasm | -18 | their clients, our relay | 3 | 3 | 1 | 0 | | moqlivemock | -22 | our client, their relay | 50 | 0 | 16 | 0 | | moqlivemock | -22 | their clients, our relay | 12 | 0 | 0 | 0 | | moqlivemock | -22 | two relays | 13 | 0 | 1 | 0 | | moqlivemock | -18 | our client, their relay | 50 | 0 | 16 | 0 | | moqlivemock | -18 | their clients, our relay | 12 | 0 | 0 | 0 | | moqlivemock | -18 | two relays | 11 | 1 | 0 | 0 | | moqtail | -22 | our client, their relay | 72 | 7 | 0 | 0 | | moqtail | -22 | their clients, our relay | 5 | 0 | 1 | 0 | | moqtail | -22 | two relays | 0 | 0 | 1 | 0 | | moqxr | -23 | their clients, our relay | 3 | 2 | 0 | 0 | | moqxr | -22 | their clients, our relay | 4 | 1 | 0 | 0 | | moqxr | -18 | their clients, our relay | 4 | 1 | 0 | 0 | | moqxr | -16 | their clients, our relay | 3 | 2 | 0 | 0 | | moxygen | -22 | our client, their relay | 60 | 39 | 1 | 0 | | moxygen | -22 | their clients, our relay | 24 | 18 | 0 | 0 | | moxygen | -22 | two relays | 0 | 0 | 1 | 0 | | moxygen | -18 | our client, their relay | 56 | 55 | 1 | 0 | | moxygen | -18 | their clients, our relay | 24 | 18 | 0 | 0 | | moxygen | -16 | our client, their relay | 77 | 19 | 2 | 2 | | moxygen | -16 | their clients, our relay | 24 | 18 | 0 | 0 | | quic-zig | -22 | our client, their relay | 45 | 31 | 2 | 0 | | quic-zig | -22 | their clients, our relay | 20 | 0 | 0 | 0 | | quic-zig | -22 | two relays | 0 | 0 | 1 | 0 | | quic-zig | -18 | our client, their relay | 46 | 30 | 2 | 0 | | quic-zig | -18 | their clients, our relay | 20 | 0 | 0 | 0 | | quiche | -18 | our client, their relay | 16 | 10 | 7 | 0 | | quiche | -18 | their clients, our relay | 0 | 1 | 0 | 0 | | quiche | -18 | two relays | 0 | 7 | 0 | 0 | | xquic | -18 | our client, their relay | 10 | 16 | 2 | 0 | | xquic | -18 | their clients, our relay | 6 | 0 | 1 | 0 | What each pairing ran: **our client, their relay**: the runner's eight cases; `wai-receipts` by namespace and by PUBLISH; the test tracks of `draft-afrind-moq-test-01` (one subgroup a group, one an object, two a group with End of Group markers and a cut last group, datagrams, both test extensions with a delivery timeout, datagrams with markers and an extension), each then fetched whole, by namespace and by PUBLISH, and the three namespaces a publisher refuses; their publisher tools through their relay to our subscriber, which then unsubscribes and counts what still arrives; a message, a stream and a datagram of a type no draft assigns, which the relay must close the session for; and whether a GOAWAY comes when their relay is asked to stop. **Their clients, our relay**: each peer's own test client; their publisher and subscriber tools in pairs through our relay, on streams, in datagrams and by PUBLISH, and their FETCH from our relay's cache; our relay's GOAWAY on its shutdown. **Two relays**: `wai-receipts` and the test tracks with the publisher on one relay and the subscriber on the other, where one relay dials the other. A request a peer answers NOT_SUPPORTED, or a relay refuses UNAUTHORIZED under its own authorization policy, is counted not applicable; a case its own client skips is counted skipped. **Faults these runs found in this stack**, each fixed with a test that fails without the fix: | Fault | Clause | Where | |---|---|---| | A fetch stream's End of Range was read and written with its Group ID as a delta from the prior entry. It is the Location itself, so every End of Range another implementation wrote after an object was read at the wrong group, and one in the prior entry's group could not be written at all. Found against a relay that marks the unknown rest of each group. | -22 §11.4.1.2, -18 §11.4.4.2: "the Group ID and Object ID fields are present"; -23 §A.1 is where the indicator becomes delta-coded (#2031) | `draft22::data::FetchCodec`; both corpora and both independent decoders | | To a -16 peer, a REQUEST_ERROR went out with -22's number for a code -16 does not register (EXCESSIVE_LOAD `0x9` for a load refusal, and six more). They are sent as INTERNAL_ERROR, the Retry Interval and reason kept. | -16 §13.4.2, the registry | `draft16::bridge::request_error_from22` | | A PUBLISH that begins paused (FORWARD 0) was answered REQUEST_OK and never resumed: its publisher sent nothing to any subscriber. The relay now resumes it with REQUEST_UPDATE at once. At -18 and -16, where the answer's omitted FORWARD is 1, there was no fault. | -22 §3.1.1, §7.4, §7.6: "the Relay MUST resume the upstream subscriptions with REQUEST_UPDATE" | `joule-moq-relay` `session::publish`, `tests/paused_publish.rs` | | The client dropped a request it could not read and left its stream unanswered; where the draft closes the session for the fault, it now does. | -22 §9.20: an unknown Message Parameter "MUST close the session with PROTOCOL_VIOLATION" | `joule-moq-relay` `client`, `tests/client_unreadable_request.rs` | | The interop client stopped reading once it had counted as many objects as were published, counting an End of Group a relay had stated as an object of its own, and so missed the last groups. It counts objects of Normal status. | -18 §9.1: a relay "MAY change the representation ... to a semantically equivalent one" | `joule-moq-interop` `collect` | | The test tracks' refusal cases required the publisher's code at the subscriber. No draft has a relay hand the code on; without `--strict-codes` any REQUEST_ERROR is the refusal. | -18 §10.6.2 (INTERNAL_ERROR, "an implementation specific or generic error") | `joule-moq-interop` `moq_test` | | A request the relay rejected was answered REQUEST_ERROR and FIN with a STOP_SENDING beside them (the direction it read was dropped), which one peer's client took as the end of the request and so never read the refusal. Now the direction is read to the requester's FIN. | -22 §6.4.2.3: a rejection is "a REQUEST_ERROR and FIN"; STOP_SENDING cancels | `joule-moq-relay` `wire` (`REJECTED_LINGER`), `tests/request_rejection.rs` | | A PUBLISH_NAMESPACE withdrawn by STOP_SENDING after the publisher's FIN was not seen, so NAMESPACE_DONE waited for that session's end. | -22 §6.4.2.3: "An endpoint that has already sent a FIN ... sends STOP_SENDING on the receiving direction" | `joule-moq-relay` `session::publish_namespace`, `tests/namespace_withdrawn.rs` | | An upstream SUBSCRIBE answered SUBSCRIBE_OK and at once PUBLISH_DONE (a track of one object that ended) was answered downstream REQUEST_ERROR DOES_NOT_EXIST ("the publisher left"): a race. It is answered SUBSCRIBE_OK and then PUBLISH_DONE. | -22 §7.4, §9.9 | `joule-moq-relay` `session::subscribe`, `tests/subscribe_ended_at_once.rs` | | The WebTransport server and client sent no `reset_stream_at` transport parameter and could not read RESET_STREAM_AT, and a reset WebTransport data stream could lose its header, so one peer's client would not open a session. Both ends now advertise it, read the frame, and reset a data stream with its header as the Reliable Size. | `draft-ietf-webtrans-http3-17` §3.1, §4.4; `draft-ietf-quic-reliable-stream-reset-11` §3 to §5 | `vendor/quinn-proto` and `vendor/quinn` (`VENDORED.md`), `vendor/wtransport`, `joule-moq-relay` `server::transport_config`, `tests/reset_stream_at.rs` | **Two relays.** A peer's relay that dials this one (three of them can be given an upstream) carries `wai-receipts` and the test tracks to a subscriber on it. This relay's own upstream links are to relays that declare `RELAY_ID` (*Cascading* in `joule-moq-relay`'s README), which no other implementation does, so the chain the other way round is not applicable. **Left as they are, and stated** (`joule-moq-relay`'s to take up): at -18 a Joining FETCH was refused NOT_SUPPORTED by the bridge, which one peer's own relay tests and its subscriber, which joins a track's catalog before it writes a frame, both need; the relay's session bridge now resolves it against the subscription it names (the -18 table above; `joule-moq-relay`'s `tests/joining_fetch.rs`), and at -16 one too (the -16 table above). The relay forgot an ended track's objects, so a track published again with other payloads at the same Locations was a new instance here, where -18 §2.4.2 (item 6) has a receiver that holds both treat the track as malformed, as one peer's relay does; it now keeps the ended track's Largest Location and what it cached, and a different object at a Location it holds makes the track malformed (-22 §2.1: an Object's "contents MUST NOT change over time"), up to the bound of its cache (`joule-moq-relay`'s README, PUBLISH). The relay answered SUBSCRIBE_TRACKS (at -16, SUBSCRIBE_NAMESPACE with the PUBLISH option) NOT_SUPPORTED, which the drafts allow a limited endpoint and not a relay ("Relays MUST implement all MOQT messages defined in this document", -22 §1.6, -18 §4, -16 §4); it now starts subscriptions toward its subscriber with PUBLISH at every draft it speaks, each read from its own text (`joule-moq-relay`'s README, SUBSCRIBE_TRACKS, and `tests/subscribe_tracks.rs`). The three gaps this paragraph also stated (a namespace withdrawn by STOP_SENDING after a FIN, a STOP_SENDING beside every REQUEST_ERROR, and the race of SUBSCRIBE_OK and PUBLISH_DONE) are fixed (the table above); the cells of the record that their clients and our relay ran again on the build host at `25bb230db` (`results/2026-11-11-framework-rerun-b.json`) changed from failed to passed in eight places (imquic -22's subscribe-error, rendezvous-timeout and subscribe-before-announce over QUIC and WebTransport; moq-dev -22's subscribe-namespace-lifecycle over both) and in none the other way. **Left as they are, and stated, after the relay's ninth round** (`joule-moq-relay`'s to take up): what the relay keeps of an ended publication is what its cache holds (`cache.max_bytes`), so a Location in a group at or below the newest the cache let go of is not checked. Fixed in the relay's tenth round, each with its test: a track made malformed now resets MALFORMED_TRACK the fetch streams being served of it, which it left running (-22 §12.1); a -16 Joining FETCH, resolved as the -18 one is; at -18 and -16 a FETCH_OK answering a request for whole groups (`{g, 0}`) states `{g, 0}` where the response covers the group's last object, where it stated the Location after the last object sent (-18 §10.13, -16 §9.17); at -18 and -16 a session's own namespaces are sent to its SUBSCRIBE_NAMESPACE, which those drafts echo back (-18 §6.1, -16 §6.1), where the relay left them out (-16 echoes a session's tracks too, as the relay's SUBSCRIBE_TRACKS does there); FILL_PARAMETERS in a -23 SUBSCRIBE_TRACKS, refused NOT_SUPPORTED then, is carried in each PUBLISH the request brings, which opens its fill fetch stream (-23 §3.4.1, §3.6.2; `joule-moq-relay`'s `tests/draft23.rs`); and a SUBSCRIBE_TRACKS, which crossed no upstream link then, is taken on to one, once, and the upstream relay's tracks come back as PUBLISHes the relay offers on (-22 §7.6; `joule-moq-relay`'s README, *Cascading*: one hop, since RELAY_PATH is no parameter of SUBSCRIBE_TRACKS). **Gaps of this stack's WebTransport**: its server and client sent no `reset_stream_at` transport parameter, which `draft-ietf-webtrans-http3-17` §3.1 has both ends send ("WebTransport over HTTP/3 relies on the RESET_STREAM_AT frame"), and one peer's client would not open a WebTransport session without it. Both now send it, read RESET_STREAM_AT (`draft-ietf-quic-reliable-stream-reset-11`), and reset a WebTransport data stream with RESET_STREAM_AT whose Reliable Size is its header (§4.4), in the QUIC layer vendored for it (`vendor/quinn-proto/VENDORED.md`, `joule-moq-relay`'s `tests/reset_stream_at.rs`). Another peer's client (libquicr's), whose SETTINGS name the WebTransport settings of earlier drafts, got no session from the relay's HTTP/3 layer; the fault was this stack's too: its WebTransport layer (picoquic's, `picohttp/webtransport.c` at the pin) opens no session to a server whose transport parameters lack `reset_stream_at`, as §3.1 has a client do ("MUST NOT establish WebTransport sessions if ... the server's transport parameters do not have correct values"), and it now gets one (the re-run below). The other half of that gap, the quiche relay given this one as its upstream serving this stack's subscriber no WebTransport session, is settled and was not this stack's: that relay exits as it starts whenever it is given an upstream (the differences below). **Run again after the WebTransport fix**: sections `b` and `pb` (their clients and tools against this relay) on the build host at `6a599effc`, the peers at the same pins (`scripts/peers/results/2026-11-11-framework-rerun-wt.json`, its table the `.md`): twelve cells changed from failed or not run to passed and none the other way. moqxr's publisher over WebTransport now opens a session at -23, -22, -18 and -16 and its catalog reaches this stack's subscriber at each; libquicr -18's publisher over WebTransport carries its clock both ways; and the moqxr -23 QUIC cell of the race of SUBSCRIBE_OK and PUBLISH_DONE, never confirmed before, passes. At -16 over QUIC that cell still fails, otherwise: the relay answers SUBSCRIBE_OK and then PUBLISH_DONE as it should, and the track's one object, the Largest Object before the subscription began, precedes the subscription's start, so none arrives; one object was the test's expectation, not a draft's ("Subscriptions without a filter pass all Objects published or received via upstream subscriptions", -16 §5.1.2: those after it begins). The test's `subscribe` now holds a subscription that ends with PUBLISH_DONE stating no stream opened for it (the Stream Count, -22 §9.9, -16 §9.15) to no object, and states the Largest Object its SUBSCRIBE_OK gave. **Run again after the relay's ninth round**: sections `a`, `x`, `b`, `pb` and `c` on the build host at `c00eedcc7`, the peers at the same pins (`scripts/peers/results/2026-11-12-framework-rerun-relay9.json`, its table and every changed case in the `.md`), while other sessions' builds held the host's load between 44 and 133: 24 cases changed from failed to passed and 50 the other way. To passed: aiomoqt's `namespace-discovery`, which asks SUBSCRIBE_TRACKS, at -22, -18 and -16 over both transports; the moqxr -16 QUIC catalog cell, now held to the publisher's Stream Count; ten FETCH cases of our test tracks through moxygen's -22 relay, after the test publisher's End Of Track was put right; and seven that had failed on a timeout or a lost datagram. From passed: moq-rs -18's `publish-track-subscribe` (two), whose client publishes one Full Track Name in two cases with other bytes at `{0, 0}`, which the relay now holds malformed (the differences below); aiomoqt -22's FETCH of the cut last group (five), whose relay asks upstream for group 0 alone and so learns no more than that group, and which passed before only while our publisher said End Of Track 1 for every FETCH; the same case through laps -18 and moxygen -22 by PUBLISH (six), not settled; and 37 sessions refused or not answered in time and datagrams lost, at imquic, quic-zig, laps, moqlivemock and quiche, which the earlier records, run on a quieter host, passed, not settled and not read as this stack's faults. quiche's two-relay cell now states its cause: its relay given an upstream "did not start" (it exits as it starts). **The ninth round's unsettled cells, settled by A/B** (`scripts/peers/results/2026-11-12-framework-ab-relay9.{json,md}`): the 43 cells that record moved from passed and left unsettled, and one more point of the same comparison, run with `scripts/peers/ab.py` against the relay and client built at `462222f42` (before the ninth round) and at `5e0752368` (after it), in turn, on the same host and within minutes, four rounds and more where the builds differed, at a load of 14 to 44. None is this stack's change: the one cell that differed at first (quiche -18 WebTransport datagrams, old 9 of 14, new 2 of 14) was run across every commit between the builds and no commit changed it (7 to 9 of 10 each), and the two builds then passed it 10 and 11 of 12. The causes: - the six cut-last-group FETCHes through laps -18 and moxygen -22 by PUBLISH: our test client has fetched from `{0, 0}` since `3dc07de78`, before either build (a client before it, fetching from the track's first Location, is served by both); laps's cache answers a FETCH only from a group it holds and its upstream FETCH waits out its own answer, moxygen writes LOCATION_FILTER without its type (the differences below). Every run at every build fails; - 12 datagram cells (laps, imquic, moqlivemock, quiche): in each of 56 failing runs the first datagram, `{0, 0}`, sent as soon as our publisher has written SUBSCRIBE_OK, was lost and nothing else: a relay that has not yet read the SUBSCRIBE_OK establishing its Track Alias drops it, as -18 §11.3 and -22 §3.1.3.1 allow (quiche's and libquicr's sources do), more often on a loaded host; both builds alike; - seven quic-zig WebTransport cells: a stream whose first read lacks its whole header is stopped H3_STREAM_CREATION_ERROR (the differences below); both builds alike; - 14 imquic WebTransport cells: its misparse of a stream whose prefix arrives ahead of the rest (the differences below), in every failing run's log and in no passing run's; both builds alike; - imquic -16's FETCH of the cut first group served group 0 alone, and imquic -18's FETCH of datagram objects without bit `0x40` (the differences below), every run at both builds; - moqlivemock's relay dialling ours: a SUBSCRIBE that reaches it before our relay's NAMESPACE for the same publisher is refused at once, a race of the chain; imquic's fetcher through our relay: the cell's priming subscriber can join after group 0, which the fetcher asks for, and their publisher refuses the FETCH our relay then sends it; imquic's own test client's `rendezvous-timeout` passed 16 of 16 and its round-nine failure carried no detail. Both builds alike in each. **After the relay's eleventh round, the same cells written so that those peers read them** (`scripts/peers/results/2026-11-12-framework-ab-relay11.{json,md}`): the 33 of those cells whose cause was a datagram sent before the relay could know its alias (12) or a WebTransport stream header split from what follows (quic-zig 7, imquic 14), A/B against the client and relay at `3198db1ec` and at `481c2471b`, four rounds at a load of 18 to 33: old 106 of 132 runs passed, new 132 of 132. Two changes of this stack's, neither a fix of its own fault: its WebTransport writes a data stream's header with the stream's first bytes, in one STREAM frame (`joule-moq-relay`'s README, *Transports*; `vendor/wtransport/VENDORED.md`), and the interop client's test publisher holds a track until its own subscriber has the relay's SUBSCRIBE_OK, the point from which the drafts give that the relay knows the alias ("The relay MUST have an Established upstream subscription before sending SUBSCRIBE_OK", -23 §7.5, -22 §7.4, -18 §9.4, -16 §8.4). The differences below that name a split header still stand as those peers' differences. The interop matrix's cells that pass today against the Go peers run nightly (`scripts/peers/README.md`, *Nightly*). **Where this stack is stricter than a draft's text, by choice**: more than one Immutable Properties in an object's Properties is a malformed track at every draft, where -22 and -18 (§12.7) name only a nested one and one that does not parse; a relay that forwards the container twice (below) so loses the object here. The first copy is the publisher's bytes and would verify; the object is refused all the same, because an object a relay has altered is not one to render. And `rendezvous-timeout` holds a relay to answering TIMEOUT after the time asked, which -18 §10.2.6 has as a SHOULD. A Message Parameter in a message its definition does not list (GROUP_ORDER in PUBLISH, which -18 §10.2.8 lists for SUBSCRIBE, PUBLISH_OK and FETCH) closes the session with PROTOCOL_VIOLATION, a consequence the draft does not name. **One tolerant reading, bounded**: `draft-afrind-moq-test-01` §2 has a blank tuple field stand for its default, and §3 refuses a field that is "not a positive integer". One peer writes `-1` for "no extension" and `0` for "no delivery timeout". `joule-moq-interop --peer-defaults` reads those three spellings as the defaults, and writes them when it asks that peer's publisher; nothing else does, and `joule_moq::moq_test`, which the corpus holds, refuses them. **Differences of other implementations**, each read against the draft's text. No tolerant reading was adopted for any of them: what a draft forbids is still refused here. | Peer (at D30's pin) | What it does | Clause | |---|---|---| | moq-dev relay and client, -22 | FILL_PARAMETERS written as a length, then a count, then the parameters. This codec reads -22's as a length and the parameters, so the count reads as an unknown parameter and the session is closed (PROTOCOL_VIOLATION). The draft's sentence carries both readings; -23 settles on a count and no length. | -22 §9.20.15: "uses length-prefixed encoding ... a sequence of Parameters ... encoded as if they were Parameters for a separate message" | | moq-dev relay, -18 | Only subgroup 0 and only object 0 of a datagram group are carried (it says so); an End of Group in a datagram and a datagram's Object Properties are dropped (it does not). It withdraws a namespace by a FIN, which is a cancellation at -18 and not at -22. | -18 §2.5: a Property a relay does not support "MUST NOT be modified, MUST be forwarded"; -22 §6.4.2.2 | | moq-rs relay, -18 | A FETCH of a track that arrived by PUBLISH is not sent to its publisher: the relay looks for a remote origin, finds itself, and answers INTERNAL_ERROR. A PUBLISH with Track Properties beyond two it knows is refused NOT_SUPPORTED. | -18 §9.5: an upstream FETCH goes to "the available publishers using the same matching rules as SUBSCRIBE" | | moq-go relay, -22 | None against the draft. It drops a datagram that arrives before the SUBSCRIBE_OK that establishes its alias, as §3.1.3.1 allows. | | | quic-zig relay, -22, -18 | A FETCH is not answered at all. | -22 §3.2, -18 §5.2: "MUST send exactly one FETCH_OK or REQUEST_ERROR"; "Relays MUST implement all MOQT messages" | | imquic relay, -22, -18, -16 | SUBGROUP_HEADER's Subgroup ID modes 0b00 and 0b01 read the wrong way round, so a subgroup named by its first object arrives as subgroup 0. Objects received as datagrams are served by FETCH without bit `0x40`. Below -20 a Standalone FETCH's range is not read and group 0 alone is served. A WebTransport session's close capsule is not read, so its namespaces stay published; over QUIC they stay about 80 ms after the close. A WebTransport stream whose two-byte prefix arrives ahead of the rest is misparsed. | -22 §11.3.1 (-18 §11.4.2, -16 §10.4.2); -22 §11.4.1.1 (-18 §11.4.4.1, -16 §10.4.4.1): "The publisher MUST SET bit 0x40 to '1'"; -18 §10.12.1; -22 §6.6 | | imquic client, -22 | A STOP_SENDING on a request stream is taken as the end of the request, and the REQUEST_ERROR that follows it is not read. | -22 §6.4.2.2: "each direction is closed independently" | | aiomoqt relay, -22, -18, -16 | Objects received as datagrams are served by FETCH without bit `0x40`. | as above | | laps relay (with libquicr), -18 | An object's Immutable Properties are forwarded twice, once in the container and once more. SUBSCRIBE_OK is sent before any publisher has answered, and for a track no one publishes. A datagram that carries a status is forwarded as an object whose payload is the status byte. Its cache answers a FETCH only from a group it holds (its range is looked up by its Start Group exactly), so a FETCH from `{0, 0}` of a track that starts at group 5 goes upstream; and its answer to that waits two seconds for the publisher's FETCH_OK on the one callback thread that would deliver it, so the FETCH is refused INTERNAL_ERROR "Cannot process fetch" though the publisher answered within a millisecond (its log has the fetch done the instant the wait ends). | -18 §12.7: "MUST NOT be modified or removed"; §9.4: "MUST have an Established upstream subscription before sending SUBSCRIBE_OK"; §11.3.1; §10.12.3: "If the upstream FETCH fails, the relay sends a REQUEST_ERROR", §9.5 | | xquic demo relay, -18 | SUBGROUP_HEADER types above `0x1D` close the session; SUBSCRIBE_NAMESPACE and PUBLISH are not answered; a namespace matches only when every field is equal; no object is forwarded between sessions. | -18 §11.4.2; §9.5: "the subscription's namespace or prefix thereof" | | moxygen relay, -22 | LOCATION_FILTER is read in the earlier length-inferred form, so -22's filter type byte is taken as a length: a FETCH from `{0, 0}` is refused INVALID_RANGE. It is written so too: a FETCH it sends upstream carries a length and the fields, which a -22 reader takes for the Location Filter Type, so a range whose fields come to more than five bytes (one to the track's end) reads as a type no draft assigns, and this stack's publisher closes the session PROTOCOL_VIOLATION. It caches none of a track begun by PUBLISH, so every FETCH of one goes upstream so. | -22 §9.20.9: "Any other Location Filter Type is a PROTOCOL_VIOLATION"; §A.1: "an explicit Location Filter Type ... replacing the length-inferred encoding" | | moxygen relay, -18 | A Standalone FETCH whose End Group is 2^64-1 aborts the relay's process in its cache (the group after it wraps to 0). With the cache off the same FETCH is served. | -18 §1.4 (integers to 2^64-1); §10.12.1 | | imquic relay and moqtail relay | A request stream that opens with a message type no draft assigns, a unidirectional stream of an unknown type and a datagram of an unknown type each leave the session open. | -22 §9, §6.4.1, §11 (-18 §10, §3.4, §11): "An endpoint that receives an unknown ... type MUST close the session" | | imquic client, -22, -18 | Our relay's GOAWAY is refused "Invalid Request ID": at -22, whose GOAWAY has no Request ID, it applies -18's parity check to a Request ID it did not read; at -18 it requires the parity opposite to the one the draft gives (a server's GOAWAY names the client's next Request ID). | -18 §10.4: "If no requests have been processed, this is 0 (at a server) or 1 (at a client)" | | imquic client, -18 | GROUP_ORDER (`0x22`) sent in PUBLISH, which closes the session here (above). | -18 §10.2.8: it "MAY appear in a SUBSCRIBE, PUBLISH_OK, or FETCH" | | moxygen test-track tools | Every 256th payload byte of a test object is its offset over 256, where the draft has every byte `t`; so each side refuses the other's objects. The defaults are written `-1` and `0` (above). | `draft-afrind-moq-test-01` §3: "The payload of each published object is the charater t ... repeated" | | moxygen date publisher | Through its own relay, a track it began by PUBLISH and the relay paused is not sent once the relay resumes it with REQUEST_UPDATE (its log: "Forwarder detached" each second); through this relay, which resumes at once, it is. | -22 §3.1.1 | | quiche relay and chat client, -18 | Its subscription filter is written in -22's form (a Location Filter Type of -22's numbering, no length) under ALPN `moqt-18`; this relay, reading -18's length-prefixed SUBSCRIPTION_FILTER, closes the session. | -18 §10.2.9, §5.1.2 | | moq-wasm relay, -18 | A unidirectional stream and a datagram of a type no draft assigns leave the session open (an unknown message type closes it). It admits a client without a token to namespaces under `anon` alone, so this stack's own cases are refused UNAUTHORIZED and counted not applicable; its H.264 publisher reaches this stack's subscriber through either relay, and GOAWAY passes both ways. | -18 §3.4, §11 | | moxygen test-track client | It holds a publisher to more than the draft states: a publisher priority for each group, an end-of-group signal on every subgroup where field 12 asks for none, and a FETCH_OK whose End Location is the track's last as it counts it: `{2, 6}`, six objects a group where field 6 says five (it reads field 5, the last object, as the last of every group, and §2.6 makes it the last of the track) and -18's exclusive form at -22, whose End Location is inclusive (-22 §9.12). This stack answered `{1, 4}`, and that was its relay's: having let its upstream subscription go when an earlier client left the same track at group 1, it clamped the FETCH to the Largest Location it last saw and served it from its cache (fixed: a FETCH past it goes upstream, -23 §7.5, -16 §9.17; its test publisher, which answers the range asked, also said End Of Track 1 for a range short of the track's end, now 0, -22 §9.12). `{2, 4}` at -22 is still a mismatch to this client. Not differences against the draft's text otherwise; each is why its verdict on this stack's publisher is a failure. | `draft-afrind-moq-test-01` §2, §3; -22 §9.12 | | moq-rs test client, -18 | `publish-track-only` publishes `moq-test/publish` `published-track` and ends it; `publish-track-subscribe` publishes the same Full Track Name again and sends group 0 object 0 with other bytes. This relay holds the object it holds there and ends the subscription MALFORMED_TRACK. | -22 §2.1: an Object "MUST NOT change over time"; §12.1, item 6; -18 §2.4.2, item 6 | | aiomoqt relay, -22 | A client's FETCH to the end of a track is forwarded upstream as a FETCH of group 0 alone (its log: `end_group=0, end_object=0`), so a track that starts at a later group is answered with nothing; at -18 and -16 the same FETCH is served. | -22 §9.11, §9.20.9 | | quiche relay, -18 | None against a MUST: PUBLISH is answered UNINTERESTED and Standalone FETCH NOT_SUPPORTED, RENDEZVOUS_TIMEOUT is read and not used. Its debug-log build aborts on a check when a publisher refuses the relay's SUBSCRIBE; the optimised build, which the matrix runs, logs it and goes on. Given an upstream, whatever it is, its relay exits on a segmentation fault as it starts, before it dials: its constructor sets a local event-loop pointer and hands the client it makes the member of the same name, never set (`moqt_relay.cc` at the pin; reproduced with another quiche relay as its upstream, and in a debugger). Given this relay as its upstream it so served this stack's subscriber no session, which dialled a port with no relay behind it. No protocol difference, and no gap of this stack's WebTransport; the matrix now says when a peer's relay exits after it bound its port. | -18 §10.2.6 (SHOULD); a fault in its program | ### Other implementations' public relays (earlier, not to be repeated) 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.