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_NAMESPACE split into SUBSCRIBE_NAMESPACE and a new SUBSCRIBE_TRACKS message
  • Required Request ID Delta field removed from every request message
  • New REDIRECT error response with an explicit Redirect structure
  • GOAWAY is now allowed on individual request streams and gains a Request ID on the control stream
  • PUBLISH_OK removed as a distinct message type — it is now a REQUEST_OK alias
  • Generalized stream-reset error codes; new error codes added
  • REQUEST_OK gains a trailing Track Properties block
  • Mandatory-to-understand track extensions, with a corresponding UNSUPPORTED_EXTENSION error code

Data plane

  • FETCH responses use delta-encoded Group ID and Object ID instead of absolute values
  • New FIRST_OBJECT bit on the SUBGROUP_HEADER type
  • DELIVERY_TIMEOUT split into two timeouts; new FILL_TIMEOUT parameter
  • 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:

MessageTypeWhat you get back
SUBSCRIBE_NAMESPACE0x50NAMESPACE / NAMESPACE_DONE advertisements
SUBSCRIBE_TRACKS0x51PUBLISH 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:

  1. The Subscribe Options field is gone from the wire format; senders no longer choose at runtime, they pick a message type.
  2. 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_ERROR with PREFIX_OVERLAP.
  3. The FORWARD parameter, which previously appeared in SUBSCRIBE_NAMESPACE, now belongs to SUBSCRIBE_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 empty Track Name in the Redirect; non-empty is a session-level PROTOCOL_VIOLATION.
  • Server-to-server redirects are forbidden in one direction: a server receiving a REDIRECT with non-zero Connect URI Length MUST close the session.
  • Relays MAY cache a REDIRECT response for up to Retry Interval milliseconds 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:

NameTypeGranularity
OBJECT_DELIVERY_TIMEOUT0x02Per-object — reset the stream once a single object exceeds it
SUBGROUP_DELIVERY_TIMEOUT0x06Per-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] is PROTOCOL_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 + delta if Delta is present, otherwise prior + 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 be 0x00. 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_DONE status codes 0x5 and 0x6 swapped. In draft-17, 0x5 = EXPIRED and 0x6 = TOO_FAR_BEHIND. In draft-18, 0x5 = TOO_FAR_BEHIND and 0x6 = 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_EXTENSION error (0x33). Used when a track carries a Mandatory Track Property (type range 0x40000x7FFF) that the receiver does not understand. Mandatory Track Properties have strict propagation rules — relays cannot forward a track they do not understand.
  • TRACK_NAMESPACE_PREFIX parameter (0x34). Appears in REQUEST_UPDATE for SUBSCRIBE_NAMESPACE / SUBSCRIBE_TRACKS requests, allowing the prefix to be updated on an established subscription.
  • Track Properties is added to REQUEST_OK but 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 0 now returns REQUEST_ERROR with INVALID_RANGE, rather than closing the session with PROTOCOL_VIOLATION as 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:

  1. Wire-incompatible changes that will cause immediate protocol violations: drop Required Request ID Delta, switch SUBSCRIBE_NAMESPACE to type 0x50, add SUBSCRIBE_TRACKS (0x51), use REQUEST_OK (0x07) for PUBLISH_OK responses, add the trailing Track Properties block to REQUEST_OK.
  2. New features that endpoints must understand without necessarily implementing fully: parse the optional Redirect structure in REQUEST_ERROR, accept GOAWAY on request streams with the new optional Request ID, handle the new UNSUPPORTED_EXTENSION and REDIRECT error codes without panicking, recognize the new SUBGROUP_HEADER type ranges.
  3. Semantic shifts that won’t crash anything but will subtly change behavior: the renamed OBJECT_DELIVERY_TIMEOUT and new SUBGROUP_DELIVERY_TIMEOUT, the swapped PUBLISH_DONE codes, FETCH delta encoding, the FILL_TIMEOUT parameter 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.