MoQT Draft-20: What Changed Since Draft-19
By moqtap team
MoQT Draft-20: What Changed Since Draft-19
Draft-ietf-moq-transport-20 was published on 31 August 2026, eight weeks after draft-19, running to 158 pages. It is the largest single-revision change in months: the pull request queue that had gated the document all summer emptied in a twenty-eight-hour window, and everything in it landed at once.
This post is a focused diff: what changed, what breaks, and what to update. It is written against the published .txt of both revisions rather than the editors’ release notes, because on three counts those turn out to undersell what happened — and two of them are silent wire incompatibilities. One of those two, the FETCH range flipping from exclusive to inclusive, appears in no change log at all; we found it by diffing the documents field by field while building draft-20 support.
Draft-19 was gentle on the data plane; its header layouts were byte-for-byte identical to draft-18. Draft-20 is not. If you have a working draft-19 implementation, the FETCH path and the property registry both need attention before you talk to a draft-20 peer.
At a glance
Session and control plane
FETCHis a different message. TheFetch Typefield is gone, and Track Namespace and Track Name are now inline fields. Not a tweak — a rewrite- Joining FETCH is deleted, replaced by fill fetch streams driven by a new
FILL_PARAMETERSparameter (0x23) - New
PUBLISH_STATE_NOTIFYcontrol message (0x22) - New
INCLUDE_PROPERTIESparameter (0x35) LOCATION_FILTERgets an explicit length-determines-shape wire format, and now carries the FETCH range- The FETCH range flipped from exclusive to inclusive — the
+1is gone, and this appears in no change log - Three codes removed:
SUBSCRIPTION_ENDED(0x3),VERSION_NEGOTIATION_FAILED(0x15),INVALID_JOINING_REQUEST_ID(0x32) — plus two whole enums, Fetch Type and Location Filter Type PUBLISH_DONE’sStream Count“unknown” sentinel moves2^62 - 1→2^64 - 1PUBLISHcan carry Subscription Parameters;AUTHORIZATION TOKENis never copied fromSUBSCRIBE_TRACKS; six parameters dropPUBLISH_OKentirely- Publishers are no longer required to retain Largest Location
Data plane
TypebecomesType FlagsonOBJECT_DATAGRAMandSUBGROUP_HEADER— a genuine bitfield with a reserved bit and explicitPROTOCOL_VIOLATIONrulesOBJECT_DELIVERY_TIMEOUTnow starts at the last header byte, not the first payload byte- New
End of Timed-Out Rangemarker for objects abandoned when the Fill Timeout expires - Scheduling between fill-delivered and subscription-delivered objects is defined
Registry — read this one even if you skip the rest
TIMESTAMPmoves0x06→0x10VIDEO_FRAME_MARKINGmoves0x0A→0x09, and0x0Ais reassigned toENCRYPTED_LISTfrom a different specification- New
AUDIO_CONFIG(0x0F) and Secure ObjectsPADDING(0x32)
Security — three new sections: Mutual TLS, Preventing Impersonation, and Logging of Untrusted String Fields.
1. FETCH is a different message
This is the largest wire change in draft-20, and the release notes describe it in nine words: “Remove the Joining variant of FETCH and the ‘standalone’ moniker.”
Here is draft-19:
FETCH Message { Type (vi64) = 0x16, Length (16), Request ID (vi64), Fetch Type (vi64), [Standalone (Standalone Fetch),] [Joining (Joining Fetch),] Number of Parameters (vi64), Parameters (..) ...}And draft-20:
FETCH Message { Type (vi64) = 0x16, Length (16), Request ID (vi64), Track Namespace (..), Track Name Length (vi64), Track Name (..), Number of Parameters (vi64), Parameters (..) ...}The Fetch Type discriminator is gone, and with it the three-value table (0x1 Standalone, 0x2 Relative Joining, 0x3 Absolute Joining) and both variant structures. What used to be nested inside the Standalone Fetch structure — namespace and name — is now inline. The range that used to travel in message fields now travels in the LOCATION_FILTER parameter, following the filter rewrite that landed in the same revision.
The message type codepoint is unchanged at 0x16. That is the trap. A draft-19 parser will happily accept a draft-20 FETCH, read the Track Namespace’s first varint as a Fetch Type, and diverge from there. There is no version mismatch to catch it, because the two peers agreed on a draft in the ALPN and then disagreed about what 0x16 contains.
Three sections went with it: Standalone Fetch, Joining Fetches, and Joining Fetch Range Calculation.
The range flipped from exclusive to inclusive, and nothing tells you
This is the one to read twice. It is not in the editors’ change log, and it is not signalled anywhere in the FETCH section itself — we found it by diffing the two documents field by field.
Draft-19 defined FETCH_OK’s End Location as:
the last Object, plus 1; or 0 to indicate the entire Group
and resolved an over-long request to {Largest.Group, Largest.Object + 1}.
Draft-20 says:
The end of the range covered by the FETCH response. This is the End Location from the FETCH request Location Filter parameter unless the requested range extends beyond Largest Object…
The +1, the Object == 0 means-whole-group convention, and the Largest.Object + 1 computation are all simply gone. The range now inherits Location Filter semantics, and §5.1.2 states those plainly: “A Location filter specifies an inclusive range of Locations.” §10.13 repeats it for FETCH.
So a draft-19 fetch encoder ported forward asks for one object too many, and a draft-19 decoder reading a draft-20 FETCH_OK believes the response is one object shorter than it is. Both directions fail quietly — there is no malformed byte, no error code, nothing to catch it but a test that counts objects. If you carry a + 1 anywhere near an end location, that is the line.
Losing the Object == 0 convention has a second consequence worth noting: there is no longer any encoding for “the whole of this group” in a FETCH_OK, and the draft does not say what the OK should report when the request’s filter omitted EndObject. Reporting the resolved absolute end location is the only reading that round-trips.
2. Fill fetch streams
The replacement for Joining FETCH is not a message. It is a parameter on an ordinary subscription.
FILL_PARAMETERS (Parameter Type 0x23, length-prefixed) may appear in a SUBSCRIBE or REQUEST_UPDATE. Its presence asks the publisher to open a unidirectional fill fetch stream beginning with a FETCH_HEADER, delivered as a FETCH response. It carries a nested sequence of parameters, and an empty FILL_PARAMETERS is the common case — that is the whole recipe for what used to be a joining fetch.
The details that will cost you an afternoon if you skip them:
The fill range is independent of the subscription’s filter. It comes from the Location filter inside FILL_PARAMETERS, or the subscription’s own Location filter if that is omitted. Either way it is evaluated with Fetch rules, so it never extends beyond Largest Object. A zero-length inner LOCATION_FILTER — or no subscription filter at all — means the entire track up to Largest Object. So a subscriber can pull a back-range while the live subscription starts at the next group, which is the point.
An object can be delivered twice. The draft says so plainly: where the fill range overlaps the subscription’s Location filter, an object can be both fill-delivered and subscription-delivered. If you want exactly-once, the specified recipe is a Next Object subscription filter with an open-ended fill range, which the publisher terminates at Largest Object. This is new surface area for duplicate-suppression bugs, and it is not something draft-19’s joining fetch could produce.
A subscription can have several fill fetch streams open at once. Each REQUEST_UPDATE carrying FILL_PARAMETERS opens another one, identified by the Request ID in its FETCH_HEADER — the SUBSCRIBE Request ID for the initial fill, the REQUEST_UPDATE Request ID afterwards. Opening a new one does not cancel the previous ones. If your state model assumed one fetch stream per subscription, it is wrong now.
Forward State gates it. A publisher opens a fill fetch stream only when it processes a SUBSCRIBE or REQUEST_UPDATE carrying FILL_PARAMETERS while Forward State is 1. Sending FILL_PARAMETERS while Forward State is 0 opens nothing, and later flipping Forward State to 1 without re-sending them opens nothing either.
Fill fetch streams inherit the subscription’s parameters — subscriber priority, range filters, authorization — with anything inside FILL_PARAMETERS overriding for the fill only. FILL_TIMEOUT applies as it does to a FETCH.
3. The property registry reshuffle
This is filed in the release notes under Notable Editorial Changes, as “Fix LOC and Secure Objects entries in the provisional Property registry.” It is not editorial. It is a codepoint reassignment across two specifications.
The provisional Property Type registry in draft-19:
| Type | Name | Specification |
|---|---|---|
0x06 | TIMESTAMP | draft-ietf-moq-loc |
0x08 | TIMESCALE | draft-ietf-moq-loc |
0x0A | VIDEO_FRAME_MARKING | draft-ietf-moq-loc |
0x0C | AUDIO_LEVEL | draft-ietf-moq-loc |
0x0D | VIDEO_CONFIG | draft-ietf-moq-loc |
And in draft-20:
| Type | Name | Specification |
|---|---|---|
0x10 | TIMESTAMP (was 0x06) | draft-ietf-moq-loc |
0x08 | TIMESCALE | draft-ietf-moq-loc |
0x09 | VIDEO_FRAME_MARKING (was 0x0A) | draft-ietf-moq-loc |
0x0A | ENCRYPTED_LIST (new — different spec) | draft-ietf-moq-secure-objects |
0x0C | AUDIO_LEVEL | draft-ietf-moq-loc |
0x0D | VIDEO_CONFIG | draft-ietf-moq-loc |
0x0F | AUDIO_CONFIG (new) | draft-ietf-moq-loc |
0x32 | PADDING (new) | draft-ietf-moq-secure-objects |
0x0A did not become unassigned. It changed owner. A draft-19 LOC publisher emitting VIDEO_FRAME_MARKING at 0x0A is, to a draft-20 receiver, emitting a Secure Objects ENCRYPTED_LIST. Both are even-numbered types, so both are skipped by parsing a varint value — the shapes are compatible enough that nothing will throw. You get a frame marking silently interpreted as an encryption artifact, or the reverse.
This resolves the codepoint collision between LOC and Secure Objects that was raised as an open problem at IETF 126 and noted again in MoQ News #1. Resolving it was correct. Calling the resolution editorial is what makes it dangerous, because “editorial” is precisely the label implementers use to decide what to skip.
AUDIO_CONFIG at 0x0F is a decoder-configuration property owned by LOC. One row also widens in scope: VIDEO_CONFIG (0x0D) goes from Object to Track, Object. TIMESCALE was already Track, Object in draft-19, and AUDIO_CONFIG is new, so neither of those widened.
The registration policy ranges — 0x00–0x77 Standards Action or IESG Approval (one-byte encoding), 0x78–0x7F reserved for application-specific use, 0x80–0x37FF Specification Required (two-byte), 0x3800–0x3FFF application-specific, and 0x4000–0x7FFF reserved for Mandatory Track Properties — are unchanged; draft-19 §15.8 carries the identical list.
A naming collision to be aware of. There are now two unrelated things called PADDING. The data-stream type
PADDINGis0x132B3E28and is unchanged from draft-19. The new Secure Objects propertyPADDINGis0x32. Different registries, no wire conflict — but they will collide in your identifiers and your logs.
4. Type Flags: same bytes, stricter rules
Draft-19 defined the datagram type as an enumeration:
Type (vi64) = 0x00..0x0F / 0x20..0x21 / 0x24..0x25 / 0x28..0x29 / 0x2C..0x2D,Draft-20 renames the field to Type Flags and stops pretending it is a list:
Type Flags (vi64),The bit meanings are unchanged — PROPERTIES (0x01), END_OF_GROUP (0x02), ZERO_OBJECT_ID (0x04), DEFAULT_PRIORITY (0x08), STATUS (0x20). What changes is validation, which is now explicit and mandatory. An endpoint MUST close the session with PROTOCOL_VIOLATION on:
- any value with bit 4 (
0x10) set — newly reserved, MUST be zero - any value with a bit set whose meaning is not specified
- both
STATUS(0x20) andEND_OF_GROUP(0x02) set together
So the good news is better than it first looks. We computed both rule sets against draft-19’s enumeration, and the valid value sets are byte-for-byte identical — for OBJECT_DATAGRAM and for SUBGROUP_HEADER. If you validated by checking membership in the draft-19 range list, you are already correct and have nothing to change. What breaks is a loose validator: mask-and-ignore, or “anything I can parse”, now has to reject.
SUBGROUP_HEADER gets the same treatment but not the same rules, and this is the easy mistake to make. Draft-20 lists exactly three conditions for it — SUBGROUP_ID_MODE == 0b11, bit 4 not set, and values of 128 or greater. Note bit 4 is the opposite requirement from the datagram case: on a subgroup header it MUST be set, not clear. And there is no “unspecified bit” clause, because bits 0–6 are all specified. Do not port the datagram rules across.
5. Three codes removed — and two whole enums
| Code | Name | Why |
|---|---|---|
0x3 | SUBSCRIPTION_ENDED | A subscription no longer ends merely because Largest Object passes the end of the Location Filter |
0x15 | VERSION_NEGOTIATION_FAILED | Session error removed outright |
0x32 | INVALID_JOINING_REQUEST_ID | Nothing left to be invalid — Joining FETCH is gone |
If you emit any of these, stop. If you switch on them, the branches are dead. Note that 0x32 as a Property type is now Secure Objects PADDING, per §3 — different registry, but worth not confusing when you grep.
Nothing was renumbered in any code registry — we checked all of them, in both directions. The only identity change anywhere in draft-20 is the provisional Property table in §3.
Two entire enumerations also vanish, which is easy to miss because neither is a “code” in the registry sense:
- The Fetch Type registry (
0x1Standalone,0x2Relative Joining,0x3Absolute Joining), gone with the FETCH rewrite in §1. - The Location Filter Type enum (
0x1Next Group Start,0x2Largest Object,0x3AbsoluteStart,0x4AbsoluteRange).LOCATION_FILTERno longer has a type discriminator at all — the field count carries the meaning. The two named filters still have exact equivalents: draft-19’s Next Group Start is the 1-field form withStartGroup = 0, and Largest Object is the 2-field{0, 0}.
And one removal that is not a code at all: draft-19’s rule that an unsatisfiable subscription filter must be rejected with INVALID_RANGE is deleted. Draft-20 §5.1.2 now says a Location Filter on a subscription is always valid.
6. PUBLISH_STATE_NOTIFY and INCLUDE_PROPERTIES
PUBLISH_STATE_NOTIFY (0x22) is sent by a publisher on a subscription’s bidirectional stream to tell the subscriber that subscription state changed for a reason other than a subscriber-sent REQUEST_UPDATE. Unlike REQUEST_UPDATE, it is explicitly unilateral: the receiver does not answer with REQUEST_OK or REQUEST_ERROR, and it is not subject to the MAX_REQUEST_UPDATES limit. It is publisher-to-subscriber and subscriptions-only — receiving one from a subscriber, or for any other request type, is a PROTOCOL_VIOLATION.
It carries only the parameters whose values changed; a parameter that is absent is unchanged. Two constraints are worth noting. The publisher MUST NOT use it to change a subscriber-controlled parameter unless the subscriber asked for the change. And it MUST include the LARGEST_OBJECT parameter when known, so the subscriber can work out the point in the track at which the change took effect — without that anchor a unilateral notification would be unactionable on a live stream. The draft closes by calling the message informative, requiring no action from the recipient.
The absence of a round trip is why it matters beyond its own definition: Mo Zanaty plans to rework the Top Tracks filter onto it, expecting to halve control-channel traffic during track switches by eliminating the REQUEST_UPDATE/OK exchange. Expect more of the protocol to migrate onto it for the same reason.
INCLUDE_PROPERTIES (0x35) is narrower than its name suggests. It is a uint8 boolean, not a property selector: 0 means do not send Track Properties, 1 means send them, and the default is 1. It may appear in SUBSCRIBE, TRACK_STATUS, FETCH, or SUBSCRIBE_TRACKS, and it governs whether the responding OK message — or the resulting PUBLISH messages, in the SUBSCRIBE_TRACKS case — carries Track Properties. Set to 0, the Track Properties field is still structurally present but SHOULD be empty, so this is a bandwidth opt-out rather than a schema change. Any value other than 0 or 1 is a PROTOCOL_VIOLATION.
7. Smaller items worth knowing
LOCATION_FILTERhas a real wire format now. Parameter Type0x21, length-prefixed, withStartGroup,StartObject,EndGroupDelta, andEndObjectall optional. The draft says the byte length determines which fields are present — but taken literally that is not implementable, because MoQT varints are 1–9 bytes and non-minimal encodings are legal, soLength = 2is ambiguous between two one-byte fields and one two-byte field. The rule that actually works: decode varints untilLengthis consumed, then switch on the count. This is the length-determines-shape design from the filter rewrite, now written into the wire-format section rather than described prose-style.- Range filter encodings got proper figures —
SUBGROUP_FILTER(0x25),OBJECTID_FILTER(0x26),PRIORITY_FILTER(0x27),OBJECT_PROPERTY_FILTER(0x28),TRACK_PROPERTY_FILTER(0x29). They did not move sections (§5.1.3 → §5.1.4 is renumbering); what changed is that one-line shorthand became a real definition. Same codepoints. PUBLISH_DONE’sStream Count“unknown” sentinel changed from2^62 - 1to2^64 - 1. A live wire value, and encodable because MoQT’s varint is a leading-ones-prefix encoding that reaches a full 64 bits in nine bytes — it is not the QUIC varint. One consequence: the sentinel is now indistinguishable from a well-formed exact count.Stream Countalso now includes fill fetch streams, which matters if you use it to decide when subscription state can be dropped.OBJECT_DELIVERY_TIMEOUTstarts at the last header byte, not the first payload byte. Small text change, real effect on anyone measuring against the delivery timeouts.- A relay MUST send an upstream FETCH to at least one publisher, and the draft now discusses the tradeoffs of aggregating downstream filters onto one upstream subscription — the relay behaviour getting normative treatment.
- Publishers need not retain Largest Location, and re-using a Track Alias across concurrent subscriptions now carries an explicit warning.
- The REDIRECT empty-namespace rule inverted. In draft-19, an empty Track Namespace and Track Name in a Redirect meant “reuse the original request’s values”. Draft-20 deletes that and treats them as the literal redirect target.
- Parameters moved off
PUBLISH_OKwholesale, which the one-line summary undersells. Six parameter definitions droppedPUBLISH_OKfrom their “MAY appear in” lists —OBJECT_DELIVERY_TIMEOUT(0x02),SUBGROUP_DELIVERY_TIMEOUT(0x06),FORWARD(0x10),SUBSCRIBER_PRIORITY(0x20),LOCATION_FILTER(0x21),NEW_GROUP_REQUEST(0x32) — as did the whole range-filter group (0x25–0x29). OnlyEXPIRES(0x08) still names it. Sending any of the others on aPUBLISH_OKis now aPROTOCOL_VIOLATION. Separately,FORWARDis allowed on aREQUEST_UPDATEforSUBSCRIBE_TRACKS. SUBSCRIBE_TRACKSecho rules were replaced, not tweaked. Draft-19’s explicitFORWARD/GROUP_ORDERecho rules are deleted; the publisher must now communicate all initial subscription parameters in the resultingPUBLISH, with per-parameter defaults applying when absent.FILL_TIMEOUT’s outcome reclassified. What used to produce “Unknown gaps” (0x10C) now produces “Timed-Out gaps” — the newEnd of Timed-Out Rangemarker (0x20C) replacesEnd of Unknown Rangeas the fill-timeout result.- Three new security sections: Mutual TLS (13.3.1), Preventing Impersonation (13.3.3) — a relay MUST ensure a client cannot publish to namespaces or tracks belonging to another — and Logging of Untrusted String Fields (13.9). The draft also now recommends end-to-end object encryption for confidentiality from relays, which is transport formally pointing at Secure Objects as the answer to “the relay can read my media.”
What this means for implementers
In rough order of how badly it will bite:
- Rewrite your FETCH encoder and decoder. Same codepoint, different message. This fails silently against a draft-19 peer, not loudly.
- Delete every
+ 1on an end location. The FETCH range is inclusive now. It is in no change log, it produces no malformed byte, and the only thing that catches it is a test that counts objects. - Audit your property codepoints.
TIMESTAMP0x06→0x10,VIDEO_FRAME_MARKING0x0A→0x09, and0x0Anow belongs to Secure Objects. This is the one that will produce a bug report about “corrupted frame markings” six weeks from now. - Replace Joining FETCH with
FILL_PARAMETERS, and revisit your assumption that a subscription has at most one fetch stream. Decide explicitly how you handle an object arriving twice. - Tighten datagram and subgroup header validation — but check first: if you validate by membership in the draft-19 value list you are already correct. It is mask-and-ignore validators that break. Remember the subgroup rules are not the datagram rules.
- Audit which parameters you put on a
PUBLISH_OK. Six of them plus the range filters are aPROTOCOL_VIOLATIONthere now. - Delete three dead status codes, and the Fetch Type and Location Filter Type enums with them.
- Rename
TypetoType Flagsin your logs and traces while you are there. It is cosmetic, but the term appears in every conversation about this revision from here on.
Looking ahead
Draft-20 is not the revision to interoperate against. The interop event on 2 September targeted draft-18, and the interop runner’s registry still names draft-18 as its current target — of 23 registered implementation entries, exactly one advertises draft-20.
The editors’ plan, stated on the list the day draft-20 shipped, is that draft-21 captures the in-person editorial meeting’s output and is a purely editorial diff from -20, with draft-22 around 15 September as the interop target four weeks before the Seattle interim on 14–15 October.
So the normative content you are reading here is, as far as anyone currently plans, what draft-22 will contain plus whatever lands in the next two weeks. Draft-21 will move a great deal of text around — the restructuring merged on 1 September consolidates error codes, extracts common sections, and renames “Forwarding Preference” to “Delivery Mode” — but it should not change bytes. That makes draft-20 the right revision to build against now, and draft-22 the right one to test against next month.
We track each revision as it lands in MoQ News.