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

  • FETCH is a different message. The Fetch Type field 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_PARAMETERS parameter (0x23)
  • New PUBLISH_STATE_NOTIFY control message (0x22)
  • New INCLUDE_PROPERTIES parameter (0x35)
  • LOCATION_FILTER gets an explicit length-determines-shape wire format, and now carries the FETCH range
  • The FETCH range flipped from exclusive to inclusive — the +1 is 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’s Stream Count “unknown” sentinel moves 2^62 - 12^64 - 1
  • PUBLISH can carry Subscription Parameters; AUTHORIZATION TOKEN is never copied from SUBSCRIBE_TRACKS; six parameters drop PUBLISH_OK entirely
  • Publishers are no longer required to retain Largest Location

Data plane

  • Type becomes Type Flags on OBJECT_DATAGRAM and SUBGROUP_HEADER — a genuine bitfield with a reserved bit and explicit PROTOCOL_VIOLATION rules
  • OBJECT_DELIVERY_TIMEOUT now starts at the last header byte, not the first payload byte
  • New End of Timed-Out Range marker 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

  • TIMESTAMP moves 0x060x10
  • VIDEO_FRAME_MARKING moves 0x0A0x09, and 0x0A is reassigned to ENCRYPTED_LIST from a different specification
  • New AUDIO_CONFIG (0x0F) and Secure Objects PADDING (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:

TypeNameSpecification
0x06TIMESTAMPdraft-ietf-moq-loc
0x08TIMESCALEdraft-ietf-moq-loc
0x0AVIDEO_FRAME_MARKINGdraft-ietf-moq-loc
0x0CAUDIO_LEVELdraft-ietf-moq-loc
0x0DVIDEO_CONFIGdraft-ietf-moq-loc

And in draft-20:

TypeNameSpecification
0x10TIMESTAMP (was 0x06)draft-ietf-moq-loc
0x08TIMESCALEdraft-ietf-moq-loc
0x09VIDEO_FRAME_MARKING (was 0x0A)draft-ietf-moq-loc
0x0AENCRYPTED_LIST (new — different spec)draft-ietf-moq-secure-objects
0x0CAUDIO_LEVELdraft-ietf-moq-loc
0x0DVIDEO_CONFIGdraft-ietf-moq-loc
0x0FAUDIO_CONFIG (new)draft-ietf-moq-loc
0x32PADDING (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 — 0x000x77 Standards Action or IESG Approval (one-byte encoding), 0x780x7F reserved for application-specific use, 0x800x37FF Specification Required (two-byte), 0x38000x3FFF application-specific, and 0x40000x7FFF 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 PADDING is 0x132B3E28 and is unchanged from draft-19. The new Secure Objects property PADDING is 0x32. 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) and END_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

CodeNameWhy
0x3SUBSCRIPTION_ENDEDA subscription no longer ends merely because Largest Object passes the end of the Location Filter
0x15VERSION_NEGOTIATION_FAILEDSession error removed outright
0x32INVALID_JOINING_REQUEST_IDNothing 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 (0x1 Standalone, 0x2 Relative Joining, 0x3 Absolute Joining), gone with the FETCH rewrite in §1.
  • The Location Filter Type enum (0x1 Next Group Start, 0x2 Largest Object, 0x3 AbsoluteStart, 0x4 AbsoluteRange). LOCATION_FILTER no 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 with StartGroup = 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_FILTER has a real wire format now. Parameter Type 0x21, length-prefixed, with StartGroup, StartObject, EndGroupDelta, and EndObject all 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, so Length = 2 is ambiguous between two one-byte fields and one two-byte field. The rule that actually works: decode varints until Length is 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 figuresSUBGROUP_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’s Stream Count “unknown” sentinel changed from 2^62 - 1 to 2^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 Count also now includes fill fetch streams, which matters if you use it to decide when subscription state can be dropped.
  • OBJECT_DELIVERY_TIMEOUT starts 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_OK wholesale, which the one-line summary undersells. Six parameter definitions dropped PUBLISH_OK from 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 (0x250x29). Only EXPIRES (0x08) still names it. Sending any of the others on a PUBLISH_OK is now a PROTOCOL_VIOLATION. Separately, FORWARD is allowed on a REQUEST_UPDATE for SUBSCRIBE_TRACKS.
  • SUBSCRIBE_TRACKS echo rules were replaced, not tweaked. Draft-19’s explicit FORWARD/GROUP_ORDER echo rules are deleted; the publisher must now communicate all initial subscription parameters in the resulting PUBLISH, 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 new End of Timed-Out Range marker (0x20C) replaces End of Unknown Range as 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:

  1. Rewrite your FETCH encoder and decoder. Same codepoint, different message. This fails silently against a draft-19 peer, not loudly.
  2. Delete every + 1 on 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.
  3. Audit your property codepoints. TIMESTAMP 0x060x10, VIDEO_FRAME_MARKING 0x0A0x09, and 0x0A now belongs to Secure Objects. This is the one that will produce a bug report about “corrupted frame markings” six weeks from now.
  4. 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.
  5. 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.
  6. Audit which parameters you put on a PUBLISH_OK. Six of them plus the range filters are a PROTOCOL_VIOLATION there now.
  7. Delete three dead status codes, and the Fetch Type and Location Filter Type enums with them.
  8. Rename Type to Type Flags in 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.