MoQT Draft-18: What Changed Since Draft-17
By moqtap team
MoQT Draft-18: What Changed Since Draft-17
Draft-ietf-moq-transport-18 was published this week, two months after draft-17. The pace has not slowed as the protocol approaches its RFC milestone — draft-18 is one of the larger interim drafts of recent memory, with breaking wire-format changes spread across the control plane and the data plane.
This post is a focused diff: what changed, what broke, and what implementers need to update. We assume familiarity with draft-17 (see our protocol evolution post for the longer arc).
At a glance
The change log groups draft-18’s changes into three buckets. The headline items:
Session and control plane
- Unified
moqt://URI scheme covering both QUIC and WebTransport SUBSCRIBE_NAMESPACEsplit intoSUBSCRIBE_NAMESPACEand a newSUBSCRIBE_TRACKSmessageRequired Request ID Deltafield removed from every request message- New
REDIRECTerror response with an explicit Redirect structure GOAWAYis now allowed on individual request streams and gains a Request ID on the control streamPUBLISH_OKremoved as a distinct message type — it is now aREQUEST_OKalias- Generalized stream-reset error codes; new error codes added
REQUEST_OKgains a trailing Track Properties block- Mandatory-to-understand track extensions, with a corresponding
UNSUPPORTED_EXTENSIONerror code
Data plane
- FETCH responses use delta-encoded Group ID and Object ID instead of absolute values
- New
FIRST_OBJECTbit on the SUBGROUP_HEADER type DELIVERY_TIMEOUTsplit into two timeouts; newFILL_TIMEOUTparameter- 7-byte varints and non-minimal varint encodings are now permitted
- Padding streams and padding datagrams added
Editorial / process
- Mandatory-to-understand semantics for unknown Track Properties
- New IANA registries for Setup Options and LOC properties
- Numerous clarifications around joining-fetch behavior and request cancellation
The rest of this post walks the changes most likely to trip implementers.
1. Required Request ID Delta is gone
Draft-17 added a Required Request ID Delta field to every request message — a 1+ byte varint that expressed dependencies between requests on different streams. Draft-18 removes it entirely. The change log entry is terse: “Remove Required Request ID (#1615).”
That sounds small. On the wire it is the single most disruptive change in the draft, because every request message is now one byte shorter and the field at the same offset is no longer the dependency delta:
Draft-17 SUBSCRIBE payload: Request ID (vi64) | Required Request ID Delta (vi64) | Track Namespace ...
Draft-18 SUBSCRIBE payload: Request ID (vi64) | Track Namespace ...Affected messages: SUBSCRIBE, PUBLISH, FETCH, REQUEST_UPDATE, TRACK_STATUS, PUBLISH_NAMESPACE, SUBSCRIBE_NAMESPACE, SUBSCRIBE_TRACKS. An implementation that sends draft-17 bytes to a draft-18 peer will misparse the namespace count as the dependency delta and almost certainly close the session with PROTOCOL_VIOLATION.
For senders, the corresponding receive-side cleanup is straightforward: drop the field, recompute message lengths.
2. SUBSCRIBE_NAMESPACE split into two messages
Draft-17’s SUBSCRIBE_NAMESPACE (type 0x11) carried a Subscribe Options byte with three legal values: 0 (publish-only), 1 (namespace-only), or 2 (both). Draft-18 splits this into two distinct request types:
| Message | Type | What you get back |
|---|---|---|
SUBSCRIBE_NAMESPACE | 0x50 | NAMESPACE / NAMESPACE_DONE advertisements |
SUBSCRIBE_TRACKS | 0x51 | PUBLISH messages on new bidi streams for matching tracks |
The renumbering matters: the old 0x11 is no longer assigned. The new types are also two-byte varints rather than one-byte (0x50 encodes as 0x40 0x50, 0x51 as 0x40 0x51), so even the parser’s first byte read gets a different shape.
Three operational consequences:
- The
Subscribe Optionsfield is gone from the wire format; senders no longer choose at runtime, they pick a message type. - Prefix-overlap detection is now per-type. The spec is explicit: “SUBSCRIBE_NAMESPACE and SUBSCRIBE_TRACKS have independent overlap spaces, so a SUBSCRIBE_NAMESPACE and a SUBSCRIBE_TRACKS may share the same prefix.” Two requests of the same type with overlapping prefixes still get
REQUEST_ERRORwithPREFIX_OVERLAP. - The
FORWARDparameter, which previously appeared inSUBSCRIBE_NAMESPACE, now belongs toSUBSCRIBE_TRACKS(since only the track-publication side is affected by forwarding state).
3. REDIRECT as a first-class error response
Draft-18 introduces a REDIRECT error code (0x34) and a corresponding Redirect structure carried in the REQUEST_ERROR body. The structure tells the requester where to retry:
Redirect { Connect URI Length (vi64), Connect URI (..), Track Namespace (..), Track Name Length (vi64), Track Name (..),}Both fields are optional in effect: a zero-length Connect URI means “use the current session,” and zero-length namespace + name means “retry with the same Full Track Name.” So an upstream relay can redirect a client to a different relay, to the same relay under a different track name, or both at once.
Three behaviors worth knowing:
- Namespace-scoped requests (
SUBSCRIBE_NAMESPACE,PUBLISH_NAMESPACE) MUST receive an emptyTrack Namein the Redirect; non-empty is a session-levelPROTOCOL_VIOLATION. - Server-to-server redirects are forbidden in one direction: a server receiving a REDIRECT with non-zero
Connect URI LengthMUST close the session. - Relays MAY cache a REDIRECT response for up to
Retry Intervalmilliseconds and respond locally to subsequent matching requests.
REDIRECT also extends to established subscriptions — a relay can redirect a subscriber away from itself mid-stream, which is the building block for graceful relay switchover.
4. GOAWAY on request streams (and a Request ID on the control stream)
Draft-17’s GOAWAY was always sent on the control stream and applied to the whole session. Draft-18 broadens it in two ways:
GOAWAY Message { Type (vi64) = 0x10, Length (16), New Session URI Length (vi64), New Session URI (..), Timeout (vi64), [Request ID (vi64)],}The Request ID field is present only on the control stream. It identifies the smallest peer Request ID that was not (or might not have been) processed before the GOAWAY. Anything at or above that ID gets REQUEST_ERROR with GOING_AWAY; anything below was processed and the response stream tells the rest of the story.
Sending GOAWAY on a request stream migrates just that request. The receiver reissues the request on a new session at the supplied URI (or the current session if URI is empty) and closes the old request stream. This is much lighter weight than tearing down the whole session for a single migration.
The endpoint MUST close the session with PROTOCOL_VIOLATION if it sees more than one GOAWAY on the control stream — or more than one GOAWAY on any single request stream.
5. PUBLISH_OK is now a REQUEST_OK alias
Draft-17 had a distinct PUBLISH_OK message at type 0x1E with its own format. Draft-18 collapses it: the document defines PUBLISH_OK, REQUEST_UPDATE_OK, TRACK_STATUS_OK, SUBSCRIBE_NAMESPACE_OK, and PUBLISH_NAMESPACE_OK as shorthand for REQUEST_OK (0x07) sent in response to the corresponding request type.
The wire-level effect is that responses to PUBLISH now use the REQUEST_OK (0x07) format:
REQUEST_OK Message { Type (vi64) = 0x7, Length (16), Number of Parameters (vi64), Parameters (..) ..., Track Properties (..),}REQUEST_OK itself gained a trailing Track Properties block. For everything except TRACK_STATUS_OK, that block is empty (the message length tells the parser there are zero remaining bytes). For TRACK_STATUS_OK, the block carries the same Track Properties that a SUBSCRIBE_OK would have carried — letting subscribers query a track’s properties without actually subscribing.
If you receive a non-empty Track Properties block in PUBLISH_OK, REQUEST_UPDATE_OK, SUBSCRIBE_NAMESPACE_OK, or PUBLISH_NAMESPACE_OK, the spec says you MUST close the session with PROTOCOL_VIOLATION.
6. Delivery timeouts split
Draft-17 had a single DELIVERY_TIMEOUT parameter (type 0x02) and a corresponding Track Property. Draft-18 splits the concept along the lines that implementations had been operating along anyway:
| Name | Type | Granularity |
|---|---|---|
OBJECT_DELIVERY_TIMEOUT | 0x02 | Per-object — reset the stream once a single object exceeds it |
SUBGROUP_DELIVERY_TIMEOUT | 0x06 | Per-subgroup — reset the whole stream once the subgroup exceeds it |
Both are varints and both are valid as parameters (in PUBLISH_OK, SUBSCRIBE, REQUEST_UPDATE) and as Track Properties. The wire encoding for the old DELIVERY_TIMEOUT (type 0x02) is preserved — only the name changed for that code point — but receivers should treat the property as the per-object timeout and watch for the new 0x06 for the per-subgroup one.
If both are non-zero on a subgroup, the smaller value applies. If both are zero, no delivery timeout is enforced.
A new sibling parameter is FILL_TIMEOUT (type 0x0A), which appears in FETCH only. It bounds how long a relay will wait for upstream sources to fill in objects in the requested range before reporting them as Unknown gaps. A value of zero means “don’t wait at all — just report what’s cached.”
7. FETCH responses use delta-encoded Group and Object IDs
Draft-17’s FETCH stream carried each object with absolute Group ID and Object ID fields. Draft-18 changes both to deltas:
{ Serialization Flags (vi64), [Group ID Delta (vi64),] [Subgroup ID (vi64),] [Object ID Delta (vi64),] ...}The decoding rules:
- The first object MUST include both deltas; their values are the absolute Group ID and Object ID.
- For subsequent objects: if Group ID Delta is present, the Group ID is computed from the delta and the prior object’s Group ID. In Ascending order,
new = prior + delta + 1; in Descending order,new = prior - (delta + 1). A computed value outside[0, 2^64-1]isPROTOCOL_VIOLATION. - When Group ID Delta is present, Object ID is the value of Object ID Delta if present (i.e., absolute within the new group). When Group ID Delta is absent, Object ID is
prior + deltaif Delta is present, otherwiseprior + 1.
The flag bits also shift slightly: 0x04 and 0x08 in the Serialization Flags now signal “Object ID Delta is present” and “Group ID Delta is present” respectively (rather than absolute IDs). For sequential objects within the same group, the most efficient encoding is now Serialization Flags = 0x00 with no Group/Object fields at all.
8. SUBGROUP_HEADER gains a FIRST_OBJECT bit
Draft-17 used 4 low-order bits + bit 5 of the SUBGROUP_HEADER type to indicate which optional fields were present, giving valid type ranges of 0x10..0x1F and 0x30..0x3F. Draft-18 adds bit 6 — the FIRST_OBJECT bit (0x40) — and the valid type ranges expand to 0x10..0x1F, 0x30..0x3F, 0x50..0x5F, and 0x70..0x7F.
When FIRST_OBJECT is set, the publisher is signaling that the first object in this subgroup stream is the original publisher’s first object in that subgroup. Combined with the existing END_OF_GROUP bit (0x08), a single stream carrying FIRST_OBJECT | END_OF_GROUP (e.g., type 0x58) describes “the entire subgroup is on this one stream from start to finish.” That is a useful signal for relays trying to decide whether they can forward a stream end-to-end without needing to merge across multiple subscriber streams.
9. Padding streams and padding datagrams
Draft-18 introduces explicit padding for both unidirectional streams and datagrams:
- Padding stream: stream type
0x132B3E28, followed by zero or more bytes that MUST all be0x00. The receiver discards everything to prevent flow-control exhaustion. - Padding datagram: a corresponding datagram type with similar semantics.
Senders are expected to send padding streams at lower priority than control or object data so that probing for additional bandwidth does not interfere with delivery. Either side can cancel a padding stream at any time without affecting MOQT application state.
10. The smaller items worth knowing
A grab bag of changes that don’t need their own section but do need awareness:
PUBLISH_DONEstatus codes 0x5 and 0x6 swapped. In draft-17,0x5 = EXPIREDand0x6 = TOO_FAR_BEHIND. In draft-18,0x5 = TOO_FAR_BEHINDand0x6 = EXPIRED. The change log doesn’t call this out specifically (it’s part of “align with PUBLISH_DONE”), but it is a quiet semantic flip that a status-code translation layer must handle.- New
UNSUPPORTED_EXTENSIONerror (0x33). Used when a track carries a Mandatory Track Property (type range0x4000–0x7FFF) that the receiver does not understand. Mandatory Track Properties have strict propagation rules — relays cannot forward a track they do not understand. TRACK_NAMESPACE_PREFIXparameter (0x34). Appears inREQUEST_UPDATEforSUBSCRIBE_NAMESPACE/SUBSCRIBE_TRACKSrequests, allowing the prefix to be updated on an established subscription.Track Propertiesis added toREQUEST_OKbut its length is implicit from the message length. There is no separate length field. Parsers that strictly validated message length against parsed-field lengths in draft-17 will need to consume any trailing bytes as Track Properties.- 7-byte varints and non-minimal encodings are now allowed. Draft-18 explicitly permits encoding a value in more bytes than strictly required. Receivers MUST accept these; senders MAY produce them. The 7-byte form is useful when a value’s required byte count is not yet known but a length field needs to be reserved.
- Joining FETCH error model changed. Joining FETCH against a subscription with
Forward State 0now returnsREQUEST_ERRORwithINVALID_RANGE, rather than closing the session withPROTOCOL_VIOLATIONas in draft-17. Less catastrophic for the rest of the session. - REQUEST_UPDATE failure no longer always tears down the subscription. The behavior is clarified across request types.
What this means for implementers
If you maintain a draft-17 implementation, the upgrade work for draft-18 falls into roughly three buckets, in order of urgency:
- Wire-incompatible changes that will cause immediate protocol violations: drop
Required Request ID Delta, switchSUBSCRIBE_NAMESPACEto type0x50, addSUBSCRIBE_TRACKS(0x51), useREQUEST_OK(0x07) forPUBLISH_OKresponses, add the trailing Track Properties block toREQUEST_OK. - New features that endpoints must understand without necessarily implementing fully: parse the optional
Redirectstructure inREQUEST_ERROR, acceptGOAWAYon request streams with the new optionalRequest ID, handle the newUNSUPPORTED_EXTENSIONandREDIRECTerror codes without panicking, recognize the new SUBGROUP_HEADER type ranges. - Semantic shifts that won’t crash anything but will subtly change behavior: the renamed
OBJECT_DELIVERY_TIMEOUTand newSUBGROUP_DELIVERY_TIMEOUT, the swappedPUBLISH_DONEcodes, FETCH delta encoding, theFILL_TIMEOUTparameter on FETCH.
The moqtap test vectors repo now ships a complete draft18/ directory — every control message, data stream header, datagram form, and a representative set of error responses, with hex strings that are wire-faithful to the spec. If you are porting an implementation, those vectors are the fastest way to verify your decoder against the new format.
Looking ahead
Draft-18 is the largest interim change since draft-17 itself. With each draft now landing breaking wire changes, multi-version support is no longer optional for any tool that claims to span the protocol’s history. moqtap’s codec libraries already handle drafts 07 through 18, and the protocol-evolution work continues as the working group moves toward an RFC.
If you are debugging a session and not sure which draft your endpoints actually speak, moqtap auto-detects the version on the wire and shows you exactly where two implementations disagree on the bytes.