--- title: "Media over QUIC - Lite" abbrev: "moql" category: info docname: draft-lcurley-moq-lite-latest submissiontype: IETF # also: "independent", "editorial", "IAB", or "IRTF" number: date: v: 3 area: wit workgroup: moq author: - fullname: Luke Curley email: kixelated@gmail.com normative: moqt: I-D.ietf-moq-transport qmux: I-D.ietf-quic-qmux qmuxws: title: "QMux over WebSocket" target: https://datatracker.ietf.org/doc/draft-lcurley-qmux-websocket/ author: - ins: L. Curley name: Luke Curley date: false RFC3986: RFC6455: RFC8831: RFC9000: RFC9002: RFC9562: informative: I-D.lcurley-moq-cluster: moqflate: title: "DEFLATE Compressed Tracks for MoQ" target: https://datatracker.ietf.org/doc/draft-lcurley-moq-flate/ author: - ins: L. Curley name: Luke Curley date: false --- abstract moq-lite is designed to fanout live content 1->N across the internet. It leverages QUIC to prioritize important content, avoiding head-of-line blocking while respecting encoding dependencies. While primarily designed for media, the transport is payload agnostic and can be proxied by relays/CDNs without knowledge of codecs, containers, or encryption keys. --- note_Note_to_Readers This document was generated by an AI model from the implementation at [github.com/moq-dev/moq](https://github.com/moq-dev/moq) and is maintained alongside it. Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.com/moq-dev/moq/pulls) if this spec sucks and you want to fix anything. --- middle # Conventions and Definitions {::boilerplate bcp14-tagged} # Rationale This draft is based on MoqTransport [moqt]. The concepts, motivations, and terminology are very similar and when in doubt, refer to existing MoqTransport literature. A few things have been renamed (ex. object -> frame) to better align with media terminology. I absolutely believe in the motivation and potential of Media over QUIC. The layering is phenomenal and addresses many of the problems with current live media protocols. I fully support the goals of the working group and the IETF process. But it's been difficult to design such an experimental protocol via committee. MoqTransport has become too complicated. There are too many messages, optional modes, and half-baked features. Too many hypotheses, too many potential use-cases, too many diametrically opposed opinions. This is expected (and even desired) as compromise gives birth to a standard. But I believe the standardization process is hindering practical experimentation. The ideas behind MoQ can be proven now before being cemented as an RFC. We should spend more time building an *actual* application and less time arguing about a hypothetical one. moq-lite is the bare minimum needed for a real-time application aiming to replace WebRTC. Every feature from MoqTransport that is not necessary (or has not been implemented yet) has been removed for simplicity. This includes many great ideas (ex. subgroups) that may be added as they are needed. This draft is the current state, not the end state. # Concepts moq-lite consists of: - **Session**: An established QUIC connection between a client and server. - **Broadcast**: A collection of Tracks from a single publisher. - **Track**: A series of Groups, each of which can be delivered and decoded *out-of-order*. - **Group**: A series of Frames, each of which must be delivered and decoded *in-order*. - **Frame**: A sized payload of bytes within a Group. The application determines how to split data into broadcast, tracks, groups, and frames. The moq-lite layer provides fanout, prioritization, and caching even for latency sensitive applications. ## Session A Session consists of a connection between a client and a server. The moq-lite version identifier is `moq-lite-xx` where `xx` is the two-digit draft version. The identifier for this draft is `moq-lite-07-wip` while it is a work in progress, and becomes `moq-lite-07` once finalized. For bare QUIC, this is negotiated as an ALPN token during the QUIC handshake. For WebTransport over HTTP/3, the QUIC ALPN remains `h3` and the moq-lite version is advertised via the `WT-Available-Protocols` and `WT-Protocol` CONNECT headers. A session that negotiates no version this way, such as WebTransport with an endpoint that cannot offer subprotocols, negotiates it in SETUP as in [moqt] draft 14. The client opens a bidirectional stream and sends CLIENT_SETUP (0x20) listing the versions it supports, each numbered 0xff0dad00 plus the draft number, and the server replies with SERVER_SETUP (0x21) selecting one or closes the session with VERSION_NEGOTIATION_FAILED. For this draft, the server then ends the stream, neither endpoint sends anything further on it, both ignore its parameters, and the session proceeds as if the version had been negotiated by subprotocol. When UDP is unavailable, moq-lite MAY also run over reliable byte-stream transports via Qmux [qmux]; see [Transports](#transports) for the specific bindings. The session is active immediately after the connection is established. Both endpoints SHOULD begin sending and receiving streams right away to avoid an extra round-trip. Optional capabilities and extensions are negotiated by the SETUP message each endpoint sends on its [Setup Stream](#setup-stream). Neither endpoint waits for the peer's SETUP before opening other streams. An endpoint MUST buffer a stream whose behavior or encoding depends on a negotiated extension until the peer's SETUP arrives; everything else proceeds immediately. As a fallback, an endpoint that opens an extension stream the peer does not support simply sees that stream reset (see [STREAM_TYPE](#stream_type)). A negotiated capability applies only to this hop; each session is negotiated independently and relays MUST NOT forward SETUP. While moq-lite is a point-to-point protocol, it's intended to work end-to-end via relays. Each client establishes a session with a CDN edge server, ideally the closest one. Any broadcasts and subscriptions are transparently proxied by the CDN behind the scenes. ## Broadcast A Broadcast is a collection of Tracks named by a path. This corresponds to a MoqTransport's "track namespace". A path is a sequence of non-empty segments joined by `/` (0x2F), so it never starts or ends with `/` or contains two in a row; the empty path has no segments and prefixes every path. A publisher instance is identified by an Epoch, carried on its routes (see [ANNOUNCE_START](#announce-start)): a path and an Epoch name one Broadcast, and a publisher MUST NOT serve different content under the same pair. A restarted publisher mints a new Epoch rather than reusing the path's old one, while replicas of the same content MAY share one. A route without an Epoch names no instance: nothing says another route serves the same bytes. A publisher advertises what it can serve via ANNOUNCE_START messages, each carrying a path prefix: a route covering every broadcast path beneath it. A route is the only shape an advertisement takes: a publisher that serves only some of the paths beneath a prefix, such as a transcoder for any broadcast's derivative, advertises the covering prefix and refuses the requests it will not serve (see [Resolution](#resolution)); no message narrows a route. The subscriber uses the ANNOUNCE_REQUEST message to discover these routes, then subscribes to specific paths under them; which paths name broadcasts is an application convention. The common convention is that a publisher announces each broadcast's exact path as its own route, so subscribers can enumerate broadcasts, while a service announces one short prefix and serves whatever is requested beneath it. Announcements are live and can change over time, allowing for dynamic origin discovery. A broadcast consists of any number of Tracks. The contents, relationships, and encoding of tracks are determined by the application. ## Track A Track is a series of Groups identified by a unique name within a Broadcast. A track consists of a single active Group at any moment, called the "latest group". When a new Group is started, the previous Group is closed and may be dropped for any reason. The duration before an incomplete group is dropped is determined by the application and the publisher/subscriber's latency target. Every subscription is scoped to a single Track. A subscription starts at a configurable Group (defaulting to the latest) and continues until a configurable end Group or until either the publisher or subscriber cancels the subscription. Both bounds may be refined to a Frame within their Group, so a subscription can start or stop partway through a Group rather than only on a Group boundary (see [Positions](#positions)). The subscriber and publisher both indicate their delivery preference: - `Priority` indicates if Track A should be transmitted instead of Track B. - `Group Order` indicates if the newest or oldest pending Group of a Track should be transmitted first. - `Subscriber Max Age` indicates how far a non-latest Group may fall behind the latest Group before it is dropped from live delivery; `Publisher Max Age` indicates the maximum age before a non-latest Group is dropped from the publisher's cache. The combination of these preferences enables the most important content to arrive during network degradation while still respecting encoding dependencies. ## Group A Group is an ordered stream of Frames within a Track. Each group consists of an append-only list of Frames. A Group is normally served by a dedicated QUIC stream which is closed on completion, reset by the publisher, or cancelled by the subscriber. This ensures that all Frames within a Group arrive reliably and in order. In contrast, Groups may arrive out of order due to network congestion and prioritization. The application SHOULD process or buffer groups out of order to avoid blocking on flow control. A small single-frame Group MAY instead be transmitted as a QUIC datagram when reliability is not required (see [Datagrams](#datagrams)). ## Frame A Frame is a payload of bytes within a Group. A frame is used to represent a chunk of data with an upfront size. The contents are opaque to the moq-lite layer. Frames within a Group are numbered from 0 in the order they were produced. This index is not transmitted per frame; it is implied by position within the Group, anchored by the [GROUP](#group) message's `Frame Start`. A Group Stream normally starts at frame 0, but MAY start later when the publisher only holds (or was only asked for) part of the Group. Each frame of a timed Track carries a presentation timestamp expressed in the Track's `Timescale` (see [TRACK_INFO](#track-info)), used by the moq-lite layer for [expiration](#expiration) decisions. A Track without a `Timescale` is untimed: its frames and datagrams carry no timestamp. A timestamp MUST be less than 2^61, so every version can carry it; a receiver treats a larger one as a malformed Track. Unless a negotiated extension specifies otherwise, a publisher MUST NOT start a timed Group's frame 0 earlier than that of any lower-sequence Group it publishes. A receiver MAY check a received Group against this rule, refusing a violating Group's stream (STOP_SENDING) with TIMESTAMP_MISMATCH. A relay that cannot vouch for a Track's timestamps, such as ones received over another protocol, MUST serve the Track untimed unless a negotiated extension identifies their unchecked provenance. ## Positions {#positions} A Position is a (Group Sequence, Frame Index) pair identifying one frame within a Track. Positions order lexicographically: by group first, then by frame within the group. SUBSCRIBE and FETCH bound their delivery by Position rather than by Group alone. A SUBSCRIBE Range's Absolute anchors name Positions (see [SUBSCRIBE](#subscribe)), and FETCH carries a `Frame Start` and `Frame End` within its group; a frame index of 0 at the start, or an absent end frame, is the whole group. The bounds are chosen so that two subscriptions abut exactly. A Range starts at its start Position and stops just before its end Position, so a subscriber that has received frames 0 through `N-1` of group `G` and wants the remainder starts a Range at (`G`, `N`). Ending the first subscription's Range at (`G`, `N`) covers exactly the complement with no gap and no overlap: the two requests carry the *same* numbers on the wire. This is what lets a subscriber move a Track to a different publisher partway through a Group instead of waiting for the next Group to start, which matters for Tracks whose current Group may stay open indefinitely. A Group can therefore be assembled from more than one publisher: each contributes a disjoint run of frames, and the subscriber concatenates them in index order. A partial Group is only ever delivered to a subscriber that asked for one. A publisher that cannot serve a Group from the frame the subscription names MUST skip that Group entirely and resolve the subscription to a later one, rather than deliver the part it holds. The resolved start is therefore always either exactly the requested Position or the beginning of a later Group, never some third Position the subscriber did not choose. A Group is the unit of decodability: its earlier Frames may carry a keyframe or compression state the later Frames need. A subscriber MAY start a Range at any frame index, whether or not it retains the earlier Frames. # Flow This section outlines the flow of messages within a moq-lite session. See the Messages section for the specific encoding. ## Connection moq-lite runs on top of any transport that provides ordered, multiplexed, bidirectional streams: bare QUIC, WebTransport over HTTP/3 (required for web support), or Qmux [qmux] when UDP is unavailable. See [Transports](#transports) for the bindings. How the underlying connection is authenticated is out-of-scope for this draft. ## Transports {#transports} moq-lite defines five transport bindings. All five carry the same control and data streams defined elsewhere in this document; they differ only in how QUIC streams are multiplexed onto the underlying connection. |----|---------------------|------------------|----------------------| | | Transport | ALPN / Identifier | Record framing | |---:|:--------------------|:------------------|:--------------------| | 1 | QUIC | `moq-lite-xx` | Native QUIC streams | | 2 | WebTransport / H3 | `moq-lite-xx` (CONNECT header) | Native WebTransport streams | | 3 | Qmux over TCP/TLS | `moq-lite-xx` (ALPN over TLS) | Qmux Record [qmux] | | 4 | Qmux over WebSocket | `moq-lite-xx` (Sec-WebSocket-Protocol) | WebSocket message [qmuxws] | | 5 | Qmux over RTCDataChannel | `moq-lite-xx` (signaling and DataChannel protocol) | DataChannel message | For bindings 1 and 2, moq-lite uses the underlying QUIC/WebTransport stream APIs directly. QUIC datagrams (see [Datagrams](#datagrams)) are supported by bindings 1 and 2 only; a publisher MUST NOT emit datagrams on bindings 3, 4, and 5. For binding 3, a client opens a TCP connection, performs a TLS handshake, and negotiates the moq-lite version as the ALPN token. Each direction of the TLS byte stream then carries Qmux Records as defined in [qmux]. For binding 4, a client opens a WebSocket connection [RFC6455] offering the moq-lite version as the subprotocol, per [qmuxws]: each WebSocket binary message carries one Qmux Record's `Frames` payload, with the message boundary replacing the Record `Size` field. For binding 5, WebRTC Data Channels [RFC8831] carry Qmux. Signaling agrees the moq-lite version before establishing the peer connection. The client creates one reliable, ordered RTCDataChannel with that version as its protocol; the server MUST refuse a channel whose protocol differs or whose delivery is unordered or partially reliable. The binding uses Qmux draft-01: each binary DataChannel message carries one Record's `Frames` payload, with the message boundary replacing the Record `Size` field. The record limit defaults to 16 KiB and MUST NOT exceed the negotiated SCTP `maxMessageSize`; an endpoint MUST refuse a message limit below Qmux's minimum record size. A sender splits stream payloads at frame boundaries to fit this limit, never within a frame. Signaling, peer admission, and ICE server selection are application concerns. All other moq-lite semantics (stream types, message encoding, flow control, etc.) are identical across bindings. ## Termination QUIC bidirectional streams have an independent send and receive direction. Rather than deal with half-open states, moq-lite combines both sides. If an endpoint closes the send direction of a stream, the peer MUST also close their send direction. moq-lite contains many long-lived transactions, such as subscriptions and announcements. These are terminated when the underlying QUIC stream is terminated. To terminate a stream, an endpoint may: - close the send direction (STREAM with FIN) to gracefully terminate (all messages are flushed). - reset the send direction (RESET_STREAM) to immediately terminate. After resetting the send direction, an endpoint MAY close the recv direction (STOP_SENDING). However, it is ultimately the other peer's responsibility to close their send direction. ## Error Codes {#error-codes} There are two independent error code spaces, one for terminating the session and one for resetting a stream. The same numeric value means different things in each, so an endpoint MUST select the code from the space matching what it is terminating. The shared codes match moq-transport draft-18 and later. The codes moq-lite uses are listed in full below; an endpoint MUST NOT assign a moq-lite specific meaning to any code below 32. Codes 64 and above are the application's, opaque to moq-lite. An endpoint MUST ignore a code it does not recognize, treating it as an unspecified error. An endpoint MUST NOT infer a meaning for an unregistered code; in particular, it MUST NOT assume a code is an authorization failure unless it is UNAUTHORIZED, MALFORMED_AUTH_TOKEN, or EXPIRED_AUTH_TOKEN. Codes 32 through 47 are reserved for a future revision: nothing is assigned there and a value in it MUST NOT be interpreted. Codes 48 through 63 are moq-lite's own, for conditions moq-transport has no code for, and are assigned by the tables below. ### Session Error Codes Sent when terminating the session, via the transport's session close. | Code | Name | Description | | ------- | ------------- | ----------- | | 0x0 | NO_ERROR | The session was terminated without error. | | ------- | ------------- | ----------- | | 0x1 | INTERNAL_ERROR | An implementation-specific error. | | ------- | ------------- | ----------- | | 0x2 | UNAUTHORIZED | The endpoint is not authorized to establish the session, or to perform an operation on it. | | ------- | ------------- | ----------- | | 0x3 | PROTOCOL_VIOLATION | The peer violated this specification. | | ------- | ------------- | ----------- | | 0x7 | TOO_MANY_REQUESTS | The peer held more announcements or subscriptions than the endpoint allows. | | ------- | ------------- | ----------- | | 0x10 | GOAWAY_TIMEOUT | The peer did not close within the GOAWAY drain deadline. | | ------- | ------------- | ----------- | | 0x11 | CONTROL_MESSAGE_TIMEOUT | The peer took too long to respond to a control message. | | ------- | ------------- | ----------- | | 0x15 | VERSION_NEGOTIATION_FAILED | No version could be negotiated. | | ------- | ------------- | ----------- | | 0x16 | MALFORMED_AUTH_TOKEN | The endpoint's token could not be parsed. | | ------- | ------------- | ----------- | | 0x18 | EXPIRED_AUTH_TOKEN | The endpoint's token expired. Unlike UNAUTHORIZED, refreshed credentials may succeed. | | ------- | ------------- | ----------- | | 0x30 | NOT_SUPPORTED | The requested feature, token, or grant is unsupported. | | ------- | ------------- | ----------- | ### Stream Error Codes Sent when resetting a stream (RESET_STREAM), or when refusing to receive one (STOP_SENDING). | Code | Name | Description | | ------- | ------------- | ----------- | | 0x0 | INTERNAL_ERROR | An implementation-specific error. | | ------- | ------------- | ----------- | | 0x1 | CANCELLED | The stream was cancelled by either endpoint. A routine unsubscribe. | | ------- | ------------- | ----------- | | 0x2 | DELIVERY_TIMEOUT | The content missed its delivery deadline. | | ------- | ------------- | ----------- | | 0x3 | SESSION_CLOSED | The session is closing, taking this stream with it. | | ------- | ------------- | ----------- | | 0x4 | GOING_AWAY | A GOAWAY was sent or received. | | ------- | ------------- | ----------- | | 0x5 | TOO_FAR_BEHIND | The reader fell too far behind and content was dropped to catch up. | | ------- | ------------- | ----------- | | 0x12 | MALFORMED_TRACK | The track's content could not be parsed. | | ------- | ------------- | ----------- | | 0x31 | CONTROL_TIMEOUT | The peer took too long to answer a control request. Distinct from DELIVERY_TIMEOUT, which is content that missed its deadline. | | ------- | ------------- | ----------- | | 0x32 | GROUP_TOO_LARGE | The group grew past the publisher's cache budget and was aborted. | | ------- | ------------- | ----------- | | 0x33 | NOT_FOUND | The requested group, track, or broadcast is not here. | | ------- | ------------- | ----------- | | 0x34 | OLD | The group was superseded by a newer group and dropped. | | ------- | ------------- | ----------- | | 0x35 | EVICTED | The group was dropped under memory pressure. Unlike OLD it was still current, so it can be re-fetched. | | ------- | ------------- | ----------- | | 0x36 | UNROUTABLE | The broadcast is neither announced nor served, so there is no route to it, or the route serving it was lost, such as when a relay's upstream session ends. | | ------- | ------------- | ----------- | | 0x37 | WRONG_SIZE | A frame's payload length disagreed with its declared size. | | ------- | ------------- | ----------- | | 0x38 | FRAME_TOO_LARGE | A frame declared a payload larger than the receiver accepts. | | ------- | ------------- | ----------- | | 0x39 | TIMESTAMP_MISMATCH | A frame's timestamp does not fit its track: it is present on an untimed track, absent on a timed one, or starts a Group before a lower-sequence one. | | ------- | ------------- | ----------- | | 0x3A | NOT_FETCHABLE | The FETCH named a group delivered only as a datagram, which is never cached (see [Datagrams](#datagrams)). | | ------- | ------------- | ----------- | | 0x3B | UNAUTHORIZED | The endpoint does not authorize this request, or no longer does, such as when the [scope](#auth-stream) does not cover it. The session stays up. | | ------- | ------------- | ----------- | Note that CANCELLED is 0x1, not 0x0: a stream reset with 0x0 is an INTERNAL_ERROR, not a routine cancellation. An endpoint terminating a stream because the session is ending SHOULD use SESSION_CLOSED rather than the session's own code, since the two spaces are disjoint. A relay terminating a stream because the upstream session serving it ended SHOULD use UNROUTABLE, since the downstream session is not ending. # Streams moq-lite uses a bidirectional stream for each transaction. If the stream is closed, potentially with an error, the transaction is terminated. ## Bidirectional Streams Bidirectional streams are used for control streams. There's a 1-byte STREAM_TYPE at the beginning of each stream. |---------|--------------|-------------| | ID | Stream | Creator | |--------:|:-------------|:------------| | 0x1 | Announce | Subscriber | | ------- | ------------ | ----------- | | 0x2 | Subscribe | Subscriber | | ------- | ------------- | ---------- | | 0x3 | Fetch | Subscriber | | ------- | ------------- | ---------- | | 0x4 | Probe | Subscriber | | ------- | ------------- | ----------- | | 0x5 | Goaway | Either | | ------- | ------------- | ----------- | | 0x6 | Track | Subscriber | | ------- | ------------- | ----------- | | 0x7 | Auth | Either | | ------- | ------------- | ----------- | | 0x8 | Tracks | Subscriber | | ------- | ------------- | ----------- | ### Announce A subscriber can open an Announce Stream to discover routes matching a prefix. A route is an advertisement that paths under its prefix can be served; it claims capability, not inventory, so it never asserts that any specific broadcast exists. The subscriber creates the stream with an ANNOUNCE_REQUEST message. The publisher replies with a single ANNOUNCE_OK message followed by announcements for any matching routes and any future changes: - ANNOUNCE_START: a matching route over a path prefix is available. - ANNOUNCE_END: a previously started route is no longer advertised. - ANNOUNCE_UPDATE: a previously started advertisement was atomically updated (new hops or cost). - ANNOUNCE_RESTART: another publisher instance now serves a previously started advertisement. - SYNCED: the initial set is complete. ANNOUNCE_OK carries metadata that applies to every announcement on the stream: the publisher's own `Hop ID` (the implicit trailing entry of every announcement's path) (see [ANNOUNCE_OK](#announce-ok)). The publisher MUST send [SYNCED](#synced) exactly once, after the initial set. The subscriber MAY batch announcements until SYNCED; subsequent announcements are live updates. The initial set is relative to this hop: a relay answers with what it holds without waiting for upstreams. The publisher MAY coalesce changes to a route it has not yet sent, and sends no more events before SYNCED than the initial set holds routes. A retraction of a route already sent MAY precede SYNCED. Each ANNOUNCE_START implicitly assigns the next Announce ID on the stream: a counter starting at 0 that increments by 1 per advertisement. The id never appears on the wire; both endpoints derive it from the message order on the (reliable, ordered) stream. ANNOUNCE_END, ANNOUNCE_UPDATE, and ANNOUNCE_RESTART reference the Announce ID instead of repeating the route's prefix. Each route has at most one current advertisement per stream. A second ANNOUNCE_START for an already-advertised route is a protocol violation; an ANNOUNCE_UPDATE or ANNOUNCE_RESTART atomically replaces the current advertisement while keeping its id live. The subscriber MUST close the session with a PROTOCOL_VIOLATION if it receives an ANNOUNCE_END, ANNOUNCE_UPDATE, or ANNOUNCE_RESTART referencing an Announce ID that was never assigned or already retired, an ANNOUNCE_START for a route that is already advertised, or any announcement before ANNOUNCE_OK. When the stream is closed, the subscriber MUST assume that all routes are now unavailable. A broadcast path has at most 32 segments and at most 4,096 bytes; a receiver MUST close the session with a PROTOCOL_VIOLATION on a longer one, including one resolved by [Compression](#announce-compression). A route covers a path when its prefix is a leading run of the path's segments; matching is per path segment, so a prefix never matches half a segment, and equality is byte-by-byte within each segment. A publisher answering a request stream presents each of its routes clamped to the intersection with the requested prefix: a route above the request's prefix appears as the request prefix itself (an empty suffix), which is exactly the covered set the subscriber may see. There MAY be multiple Announce Streams, potentially containing overlapping prefixes, that get their own ANNOUNCE_OK + announcements. #### Compression {#announce-compression} An ANNOUNCE_START, ANNOUNCE_UPDATE, or ANNOUNCE_RESTART MAY copy the head of a path or the tail of a Hop ID list from a live advertisement on the same stream, its base. A `Base` field names it by distance: the base's Announce ID is the next unassigned Announce ID minus `Base`, so 1 names the latest ANNOUNCE_START, and 0 names no base. The receiver resolves every base against the advertisements live when the message arrives, and the result is identical to the literal encoding: a base ending later changes nothing. A publisher MAY always send a `Base` of 0. The subscriber MUST close the session with a PROTOCOL_VIOLATION if a non-zero `Base` names an Announce ID that was never assigned or already retired, a `Keep` is non-zero with a `Base` of 0 or exceeds what the base has, or the resolved path or Hop ID list breaks its own rules. #### Hidden Paths {#hidden} A route is hidden from a request when a segment of its path below the requested prefix starts with `.` (0x2E). A segment inside the prefix never hides anything, so a request that names the hidden segment itself (`.stats`) lists what is under it, and a route at or above the prefix has no segment below it. Only the first byte counts: `catalog.v2` is not hidden. A publisher SHOULD NOT announce a hidden route unless the ANNOUNCE_REQUEST set `Hidden`. Hiding is a convenience for discovery, not access control: a publisher MAY treat a subscriber it trusts, such as another relay in its own cluster, as opted in, and MUST authorize hidden paths like any other. SUBSCRIBE, FETCH, and TRACK resolve a hidden path exactly as any other. #### Routing {#routing} Each advertisement carries the path of Hop IDs it traversed and an accumulated Route Cost (see [ANNOUNCE_START](#announce-start)), which relays use to build a loop-free mesh. A receiver MUST discard an announcement whose reconstructed path contains its own Hop ID: it has looped back, so forwarding it would extend the loop and subscribing through it would route the receiver back to itself. This is the only loop defense moq-lite requires, and it catches loops of any length. A conforming sender never sends one (see below), so a receiver MAY instead close the session with a protocol violation; discarding is what keeps a mesh working when one member does not conform. A Hop ID of 0 means unknown and never matches anything; withholding an ID trades loop detection for privacy. A Hop ID spans 64 bits. A receiver MAY assign an identity of its own to a peer that declared 0, as local selection state for filtering that session; it MUST NOT forward that identity as a Hop ID. A publisher MUST NOT advertise a path whose entries contain the Hop ID the subscriber declared in its SETUP (see [Hop Parameter](#hop-parameter)). The receiver can only discard it, and acting on it would form a loop, so sending one is never useful. Of the paths that remain a publisher SHOULD advertise the best, and nothing when every known path contains that Hop ID. Selection is per subscriber, so a subscriber that the serving path flows through still receives the best standby path, which is what lets it fail over if its own copy dies. The per-subscriber winner changing travels as an ANNOUNCE_UPDATE when the new winner serves the same Broadcast, and as an ANNOUNCE_RESTART when it does not: another Epoch, or, between routes without one, another route; the last qualifying path appearing or disappearing travels as an ANNOUNCE_START or ANNOUNCE_END. A relay whose resolution of a path beneath a prefix moves to another route without an Epoch while the prefix's own winner stays (see the Spread Hash below) sends an ANNOUNCE_RESTART of that prefix. When serving a subscription, a publisher MUST select the source by that same exclusion; if only excluded sources remain, the subscription is unroutable. Applying one rule to both advertisement and dispatch keeps advertised paths truthful, which is what prevents subscription cycles of any length. When resolving a path covered by several routes (across any number of streams), the subscriber SHOULD prefer the most specific covering route (see [Resolution](#resolution)), then the newest Epoch, ranking a route without one last, then the higher preference the subscriber locally assigns to the arriving link, neutral unless assigned and either promoted above or demoted below that, then a path that contains no 0 Hop ID over one that does, then the lowest Route Cost after adding each arriving link's cost (see [Cost Parameter](#cost-parameter)), breaking ties toward the shortest path, then toward the lowest Spread Hash, and then toward the most recently received, so a reconnecting publisher is not outranked by the stale session it replaced. The Spread Hash is the 64-bit FNV-1a hash, with offset basis `0x420C0DECB00B` and the standard FNV-64 prime, of the requested path's UTF-8 bytes followed by each Hop ID of the route's path, oldest first, as 8 little-endian bytes. It is keyed on the requested path rather than the route's prefix, so equal-cost advertisers of one prefix share its paths instead of the first one taking them all, while one path resolves to the same advertiser on every relay that holds the same routes. When choosing which route to advertise for a prefix, the requested path is the prefix itself. A relay skips the Spread Hash for a route a publisher announced to it directly at the requested path, which no other relay ranks, so a publisher reconnecting under a new Hop ID wins at once. Routes with the same Epoch serve the same Broadcast, so a relay MAY move a live subscription between them, continuing from the first frame the subscriber lacks, and a route change (a reconnect, a cheaper path, a draining session) is invisible to the subscriber. A relay MUST NOT move a subscription to a route with a different Epoch, or between routes without one: it stays on its route and ends with it. When another Broadcast wins the path, subscriptions already open stay on theirs until the subscriber drops them or the route goes; new requests resolve the winner, and the ANNOUNCE_RESTART tells subscribers to request again. A receiver of an ANNOUNCE_RESTART (or of the ANNOUNCE_END and ANNOUNCE_START standing in for one) MUST NOT serve a new request under its prefix from anything resolved through the replaced advertisement. Epochs compare as their 16 bytes, which for a UUIDv7 orders them by creation time. Ordering Epochs minted on different hosts therefore trusts their clocks: a restart on a host whose clock runs behind loses to the older instance until that one is retracted. #### Resolution {#resolution} A SUBSCRIBE, FETCH, or TRACK request names a path, and the receiver resolves it against the routes covering that path, after the per-subscriber exclusion above. Only the most specific covering routes are consulted: those with the longest prefix. A route strictly inside another's paths always ranks above it, and a broadcast's exact path is the most specific route there is, so a concrete announcement shadows every broader route covering it at any cost; routes over the same prefix form one tier. The winning tier is the whole answer: a refusal from it never falls through to a less specific route, so a service refusing a path does not leak the request to a catch-all, and one unserved path costs one round trip rather than a walk down the candidates. Nor is a refusal retried at another route of the same prefix. Within the tier, cost and the tie-breaks of [Routing](#routing) order the routes. A route resolved this way serves the request like any other, and the route is not consumed by it. A relay MUST NOT announce a path merely because it resolved it: the covering route stays the only advertisement until the advertiser announces the concrete path, which it SHOULD do once it is producing, so a later request finds the running broadcast by its exact path instead of resolving a second producer. An advertiser that will not serve a resolved request resets the request stream with a typed code (see [Error Codes](#error-codes)). Every refusal is terminal and propagates without re-resolution, so probing unserved paths costs one round trip per path. An advertiser signals capacity through its route alone, withdrawing or re-pricing it before it runs out. ### Subscribe A subscriber opens Subscribe Streams to request a Track. The subscriber MUST start a Subscribe Stream with a SUBSCRIBE message followed by any number of SUBSCRIBE_UPDATE messages. The publisher replies with a SUBSCRIBE_OK message once the start group is resolved, followed by a SUBSCRIBE_END message once the subscription ends. For a live track the publisher MAY withhold SUBSCRIBE_OK until the first matching group resolves the start; if the track has already ended with no matching groups, it sends SUBSCRIBE_END with no preceding SUBSCRIBE_OK. A requested start past the publisher's largest position is the exception: the publisher MUST send SUBSCRIBE_OK without waiting, with `Group` set to the requested start group, so a subscriber resuming just past what it holds learns where the live feed is from the answer alone (a quiet track may not reach the start for a long time). A rejection is a stream reset: a publisher that cannot serve the subscription (no such track, an ended broadcast, or any other refusal) MUST promptly reset the stream rather than leave it pending, so a subscriber distinguishes "pending" from "refused" by the reset, not by a timeout. A route claims capability rather than inventory, so a subscription for a covered path that names nothing is refused this way too. The track's immutable publisher properties are not carried here; they are fetched once via a [Track Stream](#track-stream). The subscriber needs the track's TRACK_INFO (notably its timescale) to interpret FRAME messages, and MAY open the Track and Subscribe streams concurrently, buffering frames until it arrives. The publisher sends SUBSCRIBE_OK once the absolute start position is resolved, and SUBSCRIBE_END as soon as the exclusive final group is known (see [SUBSCRIBE_OK](#subscribe-ok) and [SUBSCRIBE_END](#subscribe-end)). The publisher closes its side of the stream (FIN) after SUBSCRIBE_END, once every Group Stream has finished or been reset. A subscription whose every Range has an `End` other than None is covered once every Group in its Ranges has been sent as a Group Stream or a datagram, or named by SUBSCRIBE_DROP. The publisher MAY then close its side (FIN) without SUBSCRIBE_END, once every Group Stream has finished or been reset, rather than wait for a SUBSCRIBE_UPDATE to widen it. That FIN ends the subscription, not the Track: the subscriber MUST NOT treat it as the Track's end, and opens a new Subscribe Stream to receive more. It can arrive after the subscriber widened the Ranges in a SUBSCRIBE_UPDATE that the publisher never read. After reading the publisher's FIN, the subscriber MUST close its side (FIN) once its tail accounting has settled: every received Group Stream has been read to its FIN, reset, or dropped, and every sequence in range has arrived, been named by SUBSCRIBE_DROP, or exhausted the subscriber's grace period. A publisher closing its session gracefully MUST wait for the subscriber's FIN or reset on every Subscribe Stream before closing the connection; a transport acknowledgement alone does not confirm the subscriber read the tail. A subscriber MAY cancel by closing its side (FIN); the publisher then closes its side (FIN) without owing SUBSCRIBE_END. Any other subscription stays open until SUBSCRIBE_END, and either endpoint MAY reset the stream at any time. ### Fetch A subscriber opens a Fetch Stream (0x3) to request a single Group from a Track. The subscriber sends a FETCH message containing the broadcast path, track name, priority, group sequence, and the frame range within that group. Unlike SUBSCRIBE, FETCH works on both live and ended broadcasts; it is the only way to read an ended one. The publisher responds with FRAME messages directly on the same bidirectional stream — there is no response header. The Subscribe ID, Group Sequence, and index of the first returned frame are implicit, taken from the original FETCH request. Because there is no response header, a publisher that cannot serve the requested frame range in full MUST reset the stream rather than return a shorter run; the subscriber has no way to learn where a truncated response actually started. As with a subscription, the subscriber MUST already have the track's [TRACK_INFO](#track-info) to parse the returned frames; because the properties are immutable, a single Track Stream lookup is reused across every FETCH of that track (group-by-group fetches do not re-fetch it). The publisher FINs the stream after the last frame, or resets the stream on error. Fetch behaves like HTTP: a single request/response per stream. ### Track {#track-stream} A subscriber opens a Track Stream (0x6) to learn a Track's immutable publisher properties without subscribing or fetching. The subscriber sends a TRACK message containing the broadcast path and track name. The publisher replies with a single TRACK_INFO message and then FINs the stream, or resets the stream on error (e.g. the track does not exist). The open stream is interest in the Track, which the publisher holds until the subscriber closes its side, and a subscriber that goes on to subscribe keeps it open until its Subscribe Stream's first response or reset, so demand never lapses between the two streams at any hop. The properties are fixed for one track, not for the path it arrived on: a different broadcast reaching the same path brings its own, so TRACK_INFO learned through a retracted announcement no longer applies. Because a subscriber cannot parse buffered group frames until TRACK_INFO arrives, the publisher SHOULD prioritize TRACK_INFO ahead of group data on the connection. ### Tracks {#tracks-stream} A subscriber opens a Tracks Stream (0x8) to list the Tracks of one Broadcast. A listing is discovery, not delivery: the subscriber SUBSCRIBEs to each Track it wants as usual, and a Track's groups reach a late SUBSCRIBE from the publisher's cache while they are retained. The subscriber sends a [TRACKS](#tracks) message naming the broadcast path and Epoch. The publisher replies with listing events, then FINs the stream once the Broadcast ends, or resets it on error: - [TRACKS_ADD](#tracks-add): a Track is listed, with its TRACK_INFO. - [TRACKS_REMOVE](#tracks-remove): a listed Track no longer is, without having finished. - [TRACKS_FINISH](#tracks-finish): a listed Track no longer is because it finished. - [SYNCED](#synced): every Track this hop listed when the stream opened, and still lists, has been sent. A Broadcast lists the Tracks its publisher produces. A Track the publisher serves only on request, such as any name a transcoder answers for, is never listed, and listing never creates one. A removal, either TRACKS_REMOVE or TRACKS_FINISH, promises only that the Track is no longer listed; a SUBSCRIBE for its name MAY still succeed. TRACKS_FINISH says the Track's publisher declared its end, so its Groups are all the Track will hold; TRACKS_REMOVE says it left unfinished, such as a publisher that went away or a route that was withdrawn, so it may go on elsewhere. A relay forwards each removal as its upstream reported it, never as its own cache would suggest. A Track listed again under a removed name is the same Track: within one Broadcast a name always means the same content. The listing is relative to this hop: a relay answers with the Tracks it currently holds and never waits on its upstream, so a relay without a listing of its own sends SYNCED at once and its upstream's Tracks follow as live TRACKS_ADD. A subscriber SHOULD hold at most one Tracks Stream per Broadcast, since each costs the publisher the whole listed set. A publisher MAY reset an older Tracks Stream for the same Broadcast with CANCELLED once a newer one opens. The publisher MAY coalesce changes to a name it has not yet sent, but never omits the removal of a name it has sent. It sends SYNCED exactly once, after every Track it listed when the stream opened and still lists, and sends no more events before SYNCED than it listed then. A removal of a Track already sent MAY precede SYNCED. The subscriber MUST close the session with a PROTOCOL_VIOLATION on a TRACKS_ADD for a name already listed on the stream, a TRACKS_REMOVE or TRACKS_FINISH for a name not listed, a second SYNCED, or an unknown event type. A FIN before SYNCED means the Broadcast ended. A publisher MUST reset a Tracks Stream with NOT_FOUND when the route it would serve the path from covers it by a shorter prefix: a route claims capability, not inventory, so it names no Broadcast to list. A subscriber MAY skip the Track Stream for a Track it learned from TRACKS_ADD, since the properties are the same immutable ones. ### Probe A subscriber opens a Probe Stream (0x4) to measure, and optionally increase, the available bitrate of the connection. The publisher advertises its Probe level in SETUP (see [Probe Parameter](#probe-parameter)): None, Report (measure only), or Increase (measure and actively probe). The subscriber sends a PROBE message with a target bitrate on the bidirectional stream. The subscriber MAY send additional PROBE messages on the same stream to update the target bitrate. If the publisher advertised the Increase capability, it SHOULD pad the connection (or send redundant data) to achieve the most recent target bitrate, without exceeding the congestion window. A publisher that advertised Report but not Increase reads and ignores the target and only reports; it MUST NOT pad above its current sending rate or reset the stream over a target. In either case the publisher periodically replies with PROBE messages on the same bidirectional stream containing the current estimated bitrate and smoothed RTT. If the publisher advertised no Probe capability (e.g., the congestion controller is not exposed), it MUST reset the stream. ### Auth {#auth-stream} Either endpoint can open an Auth Stream (0x7) to present a token and learn what it grants. The opener sends a single AUTH message and keeps its side open for as long as the token applies. Each endpoint SHOULD open one Auth Stream with an empty token right after the session starts, so both learn what the credential the connection already presented (the [Token](#token-parameter) parameter, the request URI, a client certificate, or nothing) allows. The acceptor replies with an AUTH_OK carrying the grant, or an AUTH_ERROR refusing the token. The grant MUST join the acceptor's scope before it sends AUTH_OK. It MAY send further AUTH_OK messages to replace the grant, such as with a lowered expiry, and MAY send an AUTH_ERROR after an AUTH_OK to revoke it; the latest AUTH_OK is the token's grant. After an AUTH_ERROR the acceptor closes its side of the stream. The opener withdraws a token by closing or resetting its side of the stream, and the acceptor then closes its own. A grant names the paths the opener may publish to the acceptor and subscribe to from it, relative to the session. An endpoint's scope is the union of the grants of its open Auth Streams, and a stream that ends removes its grant from the union. When the union shrinks, an endpoint SHOULD withdraw its announcements and cancel its subscriptions that the union no longer covers, keeping the session and everything still covered. An endpoint that cancels a subscription, or refuses or stops serving a request, because the union does not cover it resets the stream with UNAUTHORIZED. The acceptor judges each request against the union when it reads the request. A presenter MUST NOT send a request outside its known union while any non-empty token it presented remains unanswered; an unknown union covers no paths for this rule. Once those tokens are answered, it sends the request if the union covers it, or refuses it locally. An empty token does not hold requests: the connection credential is judged before the session serves requests. The same wait applies before closing the session for announcing a broadcast outside the union. An endpoint that announces a broadcast outside the settled union, and still announces it once the wait ends, SHOULD close the session with UNAUTHORIZED, rather than wait for a subscription that will never come. To renew a token without interrupting requests the new grant still covers, the presenter waits for the new token's AUTH_OK before withdrawing the old token. An acceptor that does not verify a token in band, or cannot express its grant in AUTH_OK, MUST send AUTH_ERROR with NOT_SUPPORTED and close its side of the stream. This applies before or after an AUTH_OK, including when a grant exceeds the message size limit. A presenter receiving a stream reset before any reply treats it as unsupported, for compatibility with peers that do not support the Auth Stream (see [STREAM_TYPE](#stream_type)). ### Goaway Either endpoint can open a Goaway Stream (0x5) to initiate a graceful session shutdown. The sender sends a GOAWAY message containing an optional new session URI. If the URI is non-empty, the peer SHOULD establish a new session at the provided URI and migrate any active subscriptions. After receiving a GOAWAY, the peer MAY keep opening requests on the current session until it has moved to a replacement session. The sender SHOULD keep answering them until the session ends, and MAY reset one with GOING_AWAY. The recipient SHOULD treat that reset as a route failure and retry the request on the replacement session or another route. The sender closes the stream (FIN) when it is ready to terminate the session. The peer SHOULD close all streams and the session after migrating or when it no longer needs the session. # Delivery The most important concept in moq-lite is how to deliver a subscription. QUIC can only improve the user experience if data is delivered out-of-order during congestion. This is the sole reason why data is divided into Broadcasts, Tracks, Groups, and Frames. moq-lite consists of multiple groups being transmitted in parallel across separate streams. How these streams get transmitted over the network is very important, and yet has been distilled down into a few simple properties: ## Prioritization The Publisher and Subscriber both exchange a `Priority` value, which determines which Track should be transmitted next. The subscriber's `Group Order` determines which Group within the Track is transmitted next. A publisher SHOULD attempt to transmit streams based on these rules. This depends on the QUIC implementation and it may not be possible to get fine-grained control. A publisher SHOULD order a [datagram](#datagrams) among the subscription's streams as if it were its Group's stream, ahead of that stream when both exist. ### Priority The `Subscriber Priority` is scoped to the connection and MAY change over the life of the subscription via SUBSCRIBE_UPDATE. The `Publisher Priority` is fixed for the lifetime of the Track (see [TRACK_INFO](#track-info)) and SHOULD be used only to resolve conflicts or ties. Both are a byte where higher is more urgent, and 128 is the midpoint an endpoint SHOULD use for a priority it was never given. Publisher priority matters most where a relay serves several downstream subscriptions from one upstream subscription, which has no single subscriber priority. Rather than try to explain everything, here's an example: **Example:** There are two people in a conference call, Ali and Bob. We subscribe to both of their audio tracks with subscriber priority 2 and video tracks with subscriber priority 1. Each publisher advertises a fixed publisher priority (here audio at 2 and video at 1) used only to break ties. This results in equal priority for `Ali` and `Bob` while prioritizing audio. ```text ali/audio + bob/audio: subscriber_priority=2 publisher_priority=2 ali/video + bob/video: subscriber_priority=1 publisher_priority=1 ``` Because publisher priority cannot change, dynamic adaptation is the subscriber's job. If the subscriber detects that Bob is actively speaking, it raises the subscriber priority of Bob's tracks via SUBSCRIBE_UPDATE: ```text bob/audio: subscriber_priority=4 publisher_priority=2 bob/video: subscriber_priority=3 publisher_priority=1 ali/audio: subscriber_priority=2 publisher_priority=2 ali/video: subscriber_priority=1 publisher_priority=1 ``` The subscriber priority takes precedence, so the subscriber can likewise full-screen Ali's window by raising the subscriber priority of Ali's tracks above Bob's. ### Group Order Within a subscription, a publisher SHOULD transmit pending Groups in the subscriber's `Group Order`: newest first when descending (the default), oldest first when ascending. Descending sheds the backlog rather than the live edge under congestion, which is what a Group boundary exists to make possible; ascending drains a backlog in sequence order, for catch-up. `Group Order` only orders transmission; it never decides whether a Group is delivered, which is [Expiration](#expiration)'s job. An ascending subscriber SHOULD raise its `Subscriber Max Age` so the backlog it intends to drain is not expired first. A subscriber MUST support gaps and out-of-order delivery regardless. ## Expiration Expiration governs when an older group is dropped. The publisher SHOULD reset Group Streams for non-latest groups whose age reaches `Subscriber Max Age` (see [SUBSCRIBE](#subscribe), and the age definition below); the subscriber MAY also locally drop such groups. Expiration only removes the group from live delivery; the publisher MAY still retain it for FETCH or new subscriptions until its age exceeds `Publisher Max Age` (see [TRACK_INFO](#track-info)). A FETCH can additionally carry `Subscriber Max Age`; it can tighten, but never relax, the publisher bound. It is not crucial to aggressively expire groups thanks to [prioritization](#prioritization), but a lower priority group still consumes RAM, bandwidth, and potentially flow control. It is RECOMMENDED that an application set conservative limits and only resort to expiration when data is absolutely no longer needed. A group is never expired until its **successor** has arrived: the next group by sequence number that has arrived at this hop and was not reset. The latest group has no successor, so it is never expired. A group is expired once either of two ages meets the relevant `Max Age`: - Its **timestamp age** is the difference between the *newest* frame timestamp of the latest group that has at least one frame, and this group's **reach**, defined below. - Its **arrival age** is the time elapsed since its successor arrived at this hop. A group of an untimed Track has no timestamps, so arrival age alone expires it. By timestamp age, a group is expired only once it **provably cannot overlap the Max Age window**: every frame it could still present is older than the budget allows. Arrival age needs no such proof: it bounds how long a superseded group may still wait to be delivered. Being behind is not itself a reason to expire anything. [Prioritization](#prioritization) already transmits newer groups first, so an older group consumes only whatever capacity is left over; it therefore closes the gap faster than the live edge advances, and a receiver that is behind can converge without losing content. What cannot be recovered is a group with nothing left worth delivering, and that is what expiration removes. A group's **reach** is the first frame timestamp of the next group by sequence number, or unbounded when no later group has presented a frame. A group cannot present past where its successor begins, so this is the furthest it could still reach. Its own frames do not bound it: frame durations are not carried on the wire, so a group's last frame timestamp is where that frame *starts* presenting, not where the group ends. A group whose successor has not presented a frame therefore has no timestamp age, because nothing yet proves where it stops. The group itself needs no timestamp: a zero-frame group (a keep-alive or gap marker) is bounded by its successor's first frame the same way. Reach is an exclusive bound, so a timestamp age **equal** to Max Age already expires the group: the freshest frame it could still hold sits strictly below its reach, and is therefore strictly older than the budget. A Max Age of zero follows from the same rule without a special case. Timestamp age means a burst of groups delivered together still reads as its true age, so catching up never resets the clock. Arrival age bounds what timestamps cannot: a publisher whose timestamps stop advancing, a successor that never presents a frame, an untimed Track, and a run of empty groups. An expired group SHOULD be reset at the QUIC level to avoid consuming flow control. A publisher MUST NOT finish (FIN) the Group Stream of an expired group that has not finished at this hop, even after sending every frame it holds; it resets with OLD instead, since a FIN claims the group is whole. ## Unidirectional Streams Unidirectional streams are used for data transmission. |--------|----------|-------------| | ID | Stream | Creator | |-------:|:---------|-------------| | 0x0 | Group | Publisher | | ------ | -------- | ----------- | | 0x1 | Setup | Either | | ------ | -------- | ----------- | | 0x2 | Padding | Either | | ------ | -------- | ----------- | ### Setup {#setup-stream} Each endpoint MUST open a Setup Stream (0x1) at the start of the session to advertise the optional capabilities and extensions it supports. The opener sends a single SETUP message and immediately closes the stream (FIN). There is exactly one Setup Stream per direction; an endpoint that receives a second Setup Stream MUST close the session with a PROTOCOL_VIOLATION. An endpoint with no optional capabilities sends a SETUP with an empty parameter list rather than omitting the stream, giving the peer a deterministic signal that no capabilities are forthcoming. ### Padding {#padding-stream} Either endpoint MAY open a Padding Stream (0x2), followed by arbitrary bytes until FIN. The receiver MUST read and discard those bytes incrementally, returning stream and connection flow-control credit as they arrive without retaining the body. Receiving padding does not change session or subscription state. This allows bandwidth probes to measure delivery without requiring a media subscription. ### Group A publisher creates Group Streams in response to a Subscribe Stream. A Group Stream MUST start with a GROUP message and MAY be followed by any number of FRAME messages. A Group MAY contain zero FRAME messages, potentially indicating a gap in the track. A frame MAY contain an empty payload, potentially indicating a gap in the group. The GROUP message's `Frame Start` gives the index of the first FRAME on the stream, so a stream carrying only part of a Group is self-describing. It is redundant with the subscription's own start Position in the steady state, and carried anyway because a SUBSCRIBE_UPDATE that moves the bound races the Group Streams already in flight: the two travel on different streams with no ordering between them, so the receiver would otherwise have to guess which bound a given Group Stream was opened under. A publisher MUST NOT send two Group Streams for the same Group Sequence on one subscription. A subscriber assembling a Group from more than one publisher does so across separate subscriptions, and is responsible for concatenating the runs in index order and for ignoring any frame it has already received. Both the publisher and subscriber MAY reset the stream at any time. This is not a fatal error and the session remains active. The subscriber MAY cache the error and potentially retry later. ## Datagrams QUIC datagrams provide unreliable, unordered delivery for latency-sensitive content that does not need retransmission. A publisher MAY transmit a Group consisting of exactly one Frame as a single QUIC datagram, in addition to (or instead of) opening a Group Stream, based on application hints, group size, and network conditions; a multi-frame Group is delivered via a Group Stream only. A publisher sends a datagram to a subscription only when its Group Sequence is inside one of the subscription's Ranges, where a start Position past frame 0 excludes its group (see [Positions](#positions)). A datagram is never cached or retransmitted, and FETCH never returns one. A publisher MAY send a new subscription the datagrams still in a short send buffer, so a subscriber cannot assume a datagram was sent after it subscribed. A publisher that knows a FETCH names a group delivered only as a datagram resets the stream with NOT_FETCHABLE; otherwise it answers as for a group that does not exist, with NOT_FOUND. A publisher SHOULD drop a datagram rather than block when the transport has no room for it. There is no separate subscription for datagram delivery: datagrams are routed to existing subscriptions via the Subscribe ID, and a subscriber receiving the same group via both a stream and a datagram MUST deduplicate by group sequence. Each datagram body has the following encoding (note: there is no message length prefix; the QUIC datagram boundary delimits the payload): ~~~ DATAGRAM Body { Subscribe ID (i) Group Sequence (i) [Timestamp (i)] [Inflated Size (i)] Payload (b) } ~~~ **Subscribe ID**: The Subscribe ID of an active subscription on the same session. A subscriber receiving a datagram with an unknown Subscribe ID MUST silently drop it. **Group Sequence**: The absolute sequence number of the group carried by this datagram. **Timestamp**: The absolute timestamp of the single frame in the group, expressed in the Track's `Timescale`. Present only on a timed Track, which the receiver learns from the subscription named by `Subscribe ID`. **Inflated Size**: The size of the payload once inflated. Present only when the Track's Compression is not none; the datagram payload is then compressed alone, as a stream of its own. **Payload**: The frame payload, extending to the end of the datagram. The total datagram body MUST NOT exceed 1200 bytes, ensuring it fits within the minimum QUIC path MTU without IP-layer fragmentation; a publisher MUST NOT send a larger one and a receiver MUST silently drop it. A group whose frame does not fit is simply not eligible for datagram delivery. # Encoding This section covers the encoding of each message. ## Variable-Length Integers {#varint} A field marked `(i)` is a variable-length integer. moq-lite-07 uses the leading-ones encoding of [moqt]: the number of leading 1 bits in the first byte gives the length, from 1 byte carrying 7 bits to 9 bytes carrying 64, and every length is valid. ## Message Length Most messages are prefixed with a variable-length integer indicating the number of bytes in the message payload that follows. This length field does not include the length of the varint length itself. A message on the Setup Stream or any bidirectional stream that fails to decode, such as one whose fields disagree with its Message Length or carry a value this specification forbids, MUST close the session with a PROTOCOL_VIOLATION. Resetting only the stream would let the peer repeat the violation on the next one. A stream that ends or is reset before its message completes only terminates that stream, except on the Setup and Auth Streams. A GROUP or FRAME that fails to decode resets only its Group or Fetch Stream, and a STREAM_TYPE the negotiated version does not define resets only its stream (see [STREAM_TYPE](#stream_type)). Except in FRAME, a Message Length over 65,535 bytes is unexpected: a sender MUST NOT exceed it, and a receiver MAY reject it based on the length prefix alone. The version and extensions should be used to support new fields, not the message length. ## STREAM_TYPE {#stream_type} All streams start with a short header indicating the stream type. ~~~ STREAM_TYPE { Stream Type (i) } ~~~ The stream ID depends on if it's a bidirectional or unidirectional stream, as indicated in the Streams section. A receiver MUST reset the stream if it receives an unknown stream type, including one the negotiated version predates. Unknown stream types MUST NOT be treated as fatal; this is the fallback when an extension stream is opened against a peer that did not negotiate it. ## SETUP {#setup} A SETUP message advertises the optional capabilities and extensions the sender supports for this session. It is sent exactly once, as the only message on a [Setup Stream](#setup-stream). ~~~ SETUP Message { Message Length (i) Parameter Count (i) Setup Parameter (..) ... } Setup Parameter { Parameter ID (i) Parameter Length (i) Parameter Value (..) } ~~~ SETUP follows the [Message Length](#message-length) cap; a receiver MUST treat a longer SETUP as a protocol violation. **Parameter Count**: The number of Setup Parameters that follow. **Parameter ID**: Identifies the capability or extension. A receiver MUST ignore unknown Parameter IDs, allowing new capabilities to be added without breaking older implementations. A Parameter ID MUST NOT appear more than once; a receiver MUST close the session with a PROTOCOL_VIOLATION if it does. **Parameter Length**: The length of Parameter Value in bytes. **Parameter Value**: The parameter-specific value, interpreted according to Parameter ID. A capability is available for the session only if the relevant endpoint advertises it; an absent parameter means the sender does not support that capability. The following Setup Parameters are defined: |------|----------------|-------------| | ID | Name | Value | |-----:|:---------------|:------------| | 0x1 | Probe | Level (i) | |------|----------------|-------------| | 0x2 | Path | Path (s) | |------|----------------|-------------| | 0x3 | Role | Role (i) | |------|----------------|-------------| | 0x4 | Cost | Cost (i) | |------|----------------|-------------| | 0x5 | Hop | Hop ID (i) | |------|----------------|-------------| | 0x6 | Implementation | Tokens (s) | |------|----------------|-------------| | 0x7 | Token | Token (b) | |------|----------------|-------------| ### Probe Parameter {#probe-parameter} The Probe Parameter advertises the sender's capability level when acting as a publisher on a [Probe Stream](#probe). The Parameter Value is a variable-length integer level, where each level includes the one below it: - `0` **None**: The publisher does not support probing. Equivalent to omitting the parameter. - `1` **Report**: The publisher can measure and periodically report its estimated bitrate. - `2` **Increase**: The publisher can additionally pad the connection (or send redundant data) to probe for bandwidth above its current sending rate, up to the subscriber's target. The [Probe Stream](#probe) specifies the publisher's behavior at each level. A subscriber SHOULD NOT open a Probe Stream to a publisher at `None`, and below `Increase` probes for more bandwidth some other way, such as speculatively switching to a higher rendition. ### Path Parameter {#path-parameter} The Path Parameter carries the request target the client wishes to reach, equivalent to the path and query components of a moq-lite URI. A server uses it to route the session before any broadcasts are exchanged; its interpretation is otherwise application-defined and opaque to moq-lite. The Parameter Value is a UTF-8 string holding the `path-abempty` component of the URI [RFC3986]; when the URI carries a query, the client MUST append `?` followed by the `query` component, since a deployment commonly puts the session credential there. The value MAY be empty, which is equivalent to omitting the parameter: both request the server's default path. A server that receives an invalid value MUST close the session with a PROTOCOL_VIOLATION, and one that does not recognize the requested path MUST close the session. This parameter exists for the bindings that negotiate only an ALPN token and have no request URI of their own (bindings 1 and 3 in [Transports](#transports)); a client using one of them SHOULD send it. It MUST NOT be sent on a binding whose handshake carries a request URI (bindings 2 and 4), and only the client sends it; a receiver MUST close the session with a PROTOCOL_VIOLATION on either violation. A relay MUST NOT forward it; like other per-hop setup metadata it applies only to this hop. ### Role Parameter {#role-parameter} The Role Parameter advertises the direction the client intends to use the session for. A moq-lite session is bidirectional, but a client's authorization (e.g. the credential in the [Path](#path-parameter)) may grant only one direction; the hint lets a server reject a mismatched session during SETUP instead of accepting it and silently carrying no data. The Parameter Value is a variable-length integer: - `0` **Both**: The client may publish and/or subscribe. The default, and equivalent to omitting the parameter. - `1` **Publisher**: The client intends to publish (and not subscribe). - `2` **Subscriber**: The client intends to subscribe (and not publish). A receiver that does not recognize the value MUST treat it as `Both`, so a newer client cannot break an older server. The role is a hint that only ever narrows the session: a server MUST still enforce the client's authorization on every publish and subscribe, and MAY close a session whose advertised role requires a direction the authorization does not grant. Only the client sends it; a client that receives one MUST close the session with a PROTOCOL_VIOLATION. A relay MUST NOT forward it. ### Cost Parameter {#cost-parameter} The Cost Parameter declares what subscribing from this endpoint costs: a receiver adds the value the sender declared to the Route Cost of every announcement that sender forwards (see [Routing](#routing)). The Parameter Value is a variable-length integer in deployment-chosen units, the same units as the Route Cost. An absent parameter means the default cost of 1, under which the accumulated Route Cost equals the hop count and routing degenerates to shortest-path. A value of 0 is meaningful and distinct from absent: it makes that direction free, e.g. between two relays in the same datacenter. Both endpoints send it and the two values need not match: the parameter prices the sender's own egress. A relay MUST NOT forward it. A declared cost is an assertion, not an instruction: a receiver MAY charge a locally configured value instead, so a peer cannot reprice its neighbours by declaring itself cheap. ### Hop Parameter {#hop-parameter} The Hop Parameter declares the sender's Hop ID: the identity that names it in the paths of announcements it forwards. The Parameter Value is a variable-length integer; a value of 0 carries no identity and is equivalent to omitting the parameter. Declaring it at setup gives the receiver the peer's identity before any other stream arrives, so route selection applies the same exclusion to the peer's subscriptions as to its announcements (see [Routing](#routing)), even on a session that never opens an Announce Stream. Either endpoint MAY send it; a subscriber-only endpoint with no identity MAY omit it, but a publisher SHOULD have a Hop ID regardless (see [ANNOUNCE_OK](#announce-ok)). A relay MUST NOT forward it. ### Implementation Parameter {#implementation-parameter} The Implementation Parameter names the sender's software, so a peer can attribute a session to its build. The Parameter Value is a UTF-8 string of space-separated `name/version` product tokens, outermost first, such as `moq-ffi/0.4.12 moq-net-rs/0.3.10`; it is otherwise opaque to moq-lite. A receiver that receives an invalid UTF-8 value MUST close the session with a PROTOCOL_VIOLATION. Either endpoint MAY send it, and MAY omit it to avoid disclosing its software. A receiver MUST NOT authorize on it or change its protocol behavior because of it. A relay MUST NOT forward it. ### Token Parameter {#token-parameter} The Token Parameter carries the client's connection credential, such as a JWT, so a server can authorize the session from the SETUP it already reads before serving. The Parameter Value is the token's bytes, in a format the endpoints agree on out of band; it MUST NOT be empty, and a receiver MUST close the session with a PROTOCOL_VIOLATION on an empty value. It is the credential the empty-token [Auth Stream](#auth-stream) refers to, so the client presents any other token on its own Auth Stream rather than here. A client whose request URI already carries a credential SHOULD NOT also send a different one here, and a server MAY refuse a session that presents two different credentials. The URI is fixed before the version is negotiated, so a client that may negotiate a version without this parameter carries the same credential in the query as well. Only the client sends it; a client that receives one MUST close the session with a PROTOCOL_VIOLATION. A relay MUST NOT forward it. ## ANNOUNCE_REQUEST {#announce-request} A subscriber sends an ANNOUNCE_REQUEST message to indicate it wants to receive announcements for any broadcasts with a path that starts with the requested prefix. ~~~ ANNOUNCE_REQUEST Message { Message Length (i) Broadcast Path Prefix (s), Hidden (8), } ~~~ **Broadcast Path Prefix**: Indicate interest for any broadcasts with a path that starts with this prefix. **Hidden**: 1 to also receive hidden routes (see [Hidden Paths](#hidden)), 0 otherwise. Any other value is a PROTOCOL_VIOLATION. The publisher MUST respond with an ANNOUNCE_OK message followed by ANNOUNCE_START messages for any matching routes, followed by ANNOUNCE_START, ANNOUNCE_END, ANNOUNCE_UPDATE, and ANNOUNCE_RESTART messages for any future updates, subject to [Routing](#routing). Implementations SHOULD consider reasonable limits on the number of matching broadcasts to prevent resource exhaustion. ## ANNOUNCE_OK {#announce-ok} A publisher sends an ANNOUNCE_OK message exactly once, as the first message on the response side of an Announce Stream. It carries metadata that is constant for the lifetime of the stream and applies to every announcement that follows. ~~~ ANNOUNCE_OK Message { Message Length (i) Hop ID (i) } ~~~ **Hop ID**: The publisher's own Hop ID. This is treated as the implicit trailing entry of every ANNOUNCE_START, ANNOUNCE_UPDATE, and ANNOUNCE_RESTART Hop ID list on this stream; those messages MUST NOT repeat this value as the last entry of their `Hop ID` list. The value 0 is reserved to mean "unknown": either no Hop ID was assigned (e.g. when bridging from an older protocol version) or the endpoint deliberately withholds it to obscure the underlying routing. A publisher that assigns a Hop ID MUST choose a non-zero value, and SHOULD assign itself one (a fresh random value per session suffices), so downstream receivers can detect loops through it. Receivers reconstruct the full path as `Hop IDs ++ [ANNOUNCE_OK.Hop ID]`. ## SYNCED {#synced} The initial set on a discovery stream has been delivered. ~~~ SYNCED Message { Type (i) = 0x4 Message Length (i) = 0 } ~~~ SYNCED has no body and assigns no Announce ID. A second SYNCED on the same stream is a PROTOCOL_VIOLATION. An Announce Stream ending before SYNCED is a PROTOCOL_VIOLATION; a Tracks Stream FIN before SYNCED means the Broadcast ended (see [Tracks](#tracks-stream)). ## ANNOUNCE_START {#announce-start} A publisher sends an ANNOUNCE_START message to advertise a route: a claim that paths under a prefix can be served. Each ANNOUNCE_START implicitly assigns the next Announce ID on the stream, later referenced by ANNOUNCE_END, ANNOUNCE_UPDATE, and ANNOUNCE_RESTART (see [Announce](#announce)). Only the suffix is encoded on the wire, as the full route prefix can be constructed by prepending the requested prefix. ~~~ ANNOUNCE_START Message { Type (i) = 0x0 Message Length (i) Path Base (i), Path Keep (i), Route Prefix Suffix (s), Epoch (b), Hops (..), Route Cost (i), } Hops { Hop Base (i), Hop Count (i), Hop ID (i) ..., Hop Keep (i), } ~~~ **Type**: Set to 0x0 to indicate an ANNOUNCE_START message. **Path Base** and **Path Keep**: The suffix begins with the first `Path Keep` segments of the suffix advertised by the base that `Path Base` names (see [Compression](#announce-compression)). **Route Prefix Suffix**: The remaining segments of the suffix, which is combined with the requested prefix to form the route's full prefix. An empty suffix advertises the requested prefix itself, which is how a route covering more than the request presents (see [Announce](#announce)). **Epoch**: The publisher instance the route serves: 16 bytes holding a UUIDv7 [RFC9562], or empty when unknown. Any other length, or a UUID of another version or variant, is a PROTOCOL_VIOLATION. A relay forwards the Epoch unchanged and MUST NOT invent one for a route that arrived without one. Only an ANNOUNCE_RESTART changes it; ANNOUNCE_UPDATE never does. **Hop Base** and **Hop Keep**: The Hop ID list ends with the last `Hop Keep` entries of the list advertised by the base that `Hop Base` names (see [Compression](#announce-compression)), following the literal `Hop ID` entries. The two bases are independent. **Hop Count**: The number of literal Hop ID entries that follow. The resolved list does NOT include the publisher's own `Hop ID` from ANNOUNCE_OK. An empty list indicates either that the announcement originated locally on the publisher (the publisher itself is the origin) or that the upstream peer does not support hop tracking. A receiver MUST close the session with a PROTOCOL_VIOLATION if the Hop Count does not match the number of subsequent Hop ID entries. **Hop ID**: A unique identifier for each relay in the path from the origin publisher, ordered from origin to the upstream of the responding publisher. The responding publisher's own Hop ID is NOT included in the resolved list; it is carried once in ANNOUNCE_OK, so the total path length is the resolved list's length plus 1 (`Hop Count + Hop Keep + 1`). When forwarding an announcement received from an upstream peer, a relay MUST append the upstream peer's ANNOUNCE_OK `Hop ID` to the resolved list, since that ID is no longer implicit downstream. A Hop ID value of 0 means the hop is unknown: either it was never assigned or a relay deliberately withholds it (see [Routing](#routing)). A received 0 is forwarded unchanged. When bridging an announcement from an upstream that sent no hop list, a relay writes 0 for that hop. An identity a receiver assigned that upstream is local selection state and MUST NOT be forwarded as a Hop ID. A receiver MUST close the session with a PROTOCOL_VIOLATION if a non-zero Hop ID appears twice in the resolved list. Duplicate values of 0 are not a violation, since 0 identifies nothing and any number of hops may be unknown. **Route Cost**: The static production and link price, in units chosen by the deployment. The publisher seeds its production cost (0 for content it already produces, larger for a standby that starts work on demand). Each forwarding relay adds the upstream peer's declared link cost (see [Cost Parameter](#cost-parameter)). Each sum MUST saturate at 2^62-1. ## ANNOUNCE_END {#announce-end} A publisher sends an ANNOUNCE_END message to retract a previously started route, referencing its Announce ID. The id is retired and MUST NOT be referenced again. Retraction claims nothing about the content: a broadcast whose publisher stops advertising may remain readable by exact path, serving its stored groups over FETCH. Retraction does not disturb subscriptions already in flight, which conclude normally with SUBSCRIBE_END. How a subscriber learns the path of stored content is out of band, e.g. an application catalog. ~~~ ANNOUNCE_END Message { Type (i) = 0x1 Message Length (i) Announce ID (i) } ~~~ **Type**: Set to 0x1 to indicate an ANNOUNCE_END message. **Announce ID**: The ordinal implicitly assigned by a prior ANNOUNCE_START on this stream. Referencing an id that was never assigned, or one already retired, is a protocol violation. Announce IDs are never reused within a stream; a route that is announced again after an ANNOUNCE_END gets a fresh id from its next ANNOUNCE_START. ## ANNOUNCE_UPDATE {#announce-update} A publisher sends an ANNOUNCE_UPDATE message to atomically update a previously started advertisement's metadata, referencing its Announce ID. The route's prefix is unchanged and the id stays live; the Hop ID list MAY differ from the original (e.g. after a relay failover or upstream restart). An update carries no content claim: in-flight subscriptions under the route are undisturbed. ~~~ ANNOUNCE_UPDATE Message { Type (i) = 0x2 Message Length (i) Announce ID (i), Hops (..), Route Cost (i), } ~~~ **Type**: Set to 0x2 to indicate an ANNOUNCE_UPDATE message. **Announce ID**: The ordinal implicitly assigned by a prior ANNOUNCE_START on this stream. Referencing an id that was never assigned, or one already retired by an ANNOUNCE_END, is a protocol violation. **Hops** and **Route Cost**: As defined for [ANNOUNCE_START](#announce-start). `Hop Base` may name this advertisement itself, which resolves against the list being replaced. An update whose only change is a Route Cost is valid: it is how a relay re-prices a route without disturbing it. ## ANNOUNCE_RESTART {#announce-restart} A publisher sends an ANNOUNCE_RESTART message when another publisher instance replaces a previously started advertisement (see [Routing](#routing)), referencing its Announce ID. The route's prefix is unchanged and the id stays live. Whatever the subscriber resolved under the prefix through the advertisement is the old instance: in-flight subscriptions on it are undisturbed, and a new request resolves afresh. ~~~ ANNOUNCE_RESTART Message { Type (i) = 0x3 Message Length (i) Announce ID (i), Epoch (b), Hops (..), Route Cost (i), } ~~~ **Type**: Set to 0x3 to indicate an ANNOUNCE_RESTART message. **Announce ID**: As defined for [ANNOUNCE_UPDATE](#announce-update). **Epoch**, **Hops**, and **Route Cost**: As defined for [ANNOUNCE_START](#announce-start), replacing the advertisement's. `Hop Base` may name this advertisement itself, which resolves against the list being replaced. ## SUBSCRIBE SUBSCRIBE is sent by a subscriber to start a subscription. ~~~ SUBSCRIBE Message { Message Length (i) Subscribe ID (i) Broadcast Path (s) Epoch (b) Track Name (s) Subscriber Priority (8) Group Order (8) Subscriber Max Age (i) Range Count (i) Range (..) ... } Range { Start (Anchor) End (Anchor) } Anchor { Anchor Type (i) [Group Sequence (i)] [Frame Index (i)] [Groups Back (i)] [Timestamp (i)] } ~~~ **Subscribe ID**: A unique identifier chosen by the subscriber. A Subscribe ID MUST NOT be reused within the same session, even if the prior subscription has been closed. **Subscriber Priority**: The priority of the subscription within the session, represented as a u8 with 128 as the midpoint. The publisher SHOULD transmit *higher* values first during congestion. See the [Prioritization](#prioritization) section for more information. **Group Order**: Which pending Group the publisher SHOULD transmit first: descending (0x0), newest first, or ascending (0x1), oldest first. Any other value is a PROTOCOL_VIOLATION. See the [Group Order](#group-order) section for more information. **Subscriber Max Age**: The subscriber's preference, in milliseconds, for how long a non-latest group may remain in flight before being considered stale and dropped from live delivery. The publisher SHOULD reset (at the QUIC level) Group Streams for groups whose age, as defined in [Expiration](#expiration), reaches this duration. Applies only to non-latest groups; the latest group is never dropped on staleness grounds. A value of `0` means the subscriber wants only the latest group in live delivery (older groups are immediately stale once a newer group arrives). This is a delivery-time preference, not a retention rule: the publisher MAY still hold these groups for FETCH or future subscriptions (see `Publisher Max Age` in [TRACK_INFO](#track-info)). See the [Expiration](#expiration) section for more information. **Range Count**: The number of Ranges that follow. A value of 0 is a PROTOCOL_VIOLATION, since a subscription that asks for nothing has no use. **Range**: A span of the Track to deliver, from its `Start` up to its `End`. The subscription is the union of its Ranges: the publisher delivers each Group inside any of them once, in `Group Order` across all of them, and accounts with SUBSCRIBE_DROP for every Group it will not deliver. **Anchor Type**: Which fields follow, and what the Anchor names: - None (0x0): no fields. As a `Start`, the oldest Group (no floor); as an `End`, the live edge (unbounded). - Absolute (0x1): `Group Sequence` and `Frame Index`, a Position. A `Start` delivers from it and an `End` stops just before it. - Relative (0x2): `Groups Back`, the Group that many before the latest, 0 being the latest. Included as an `End`. - Time (0x3): `Timestamp`, the Group containing that presentation time in the Track's `Timescale` (see [TRACK_INFO](#track-info)). Included as an `End`. On an untimed Track it is refused with TIMESTAMP_MISMATCH. Any other value is a PROTOCOL_VIOLATION. The publisher resolves a Relative or Time Anchor against the Track as it holds it. A `Start` is a floor, not a request; `Subscriber Max Age` is the only thing that asks for data. The publisher SHOULD start at the oldest group at or above the floor that [Expiration](#expiration) has not expired, and MUST NOT deliver an older one. A `Subscriber Max Age` of 0 therefore starts at the latest group, since every older group is already stale. A subscriber that buffers is then handed the head of what it can still play instead of only the live edge, and is never sent history it would discard on arrival: the same bound decides what to start at and what to expire, so the two cannot disagree. A floor above the latest group simply waits there: that is a resumed subscription naming where it left off. Reaching back is best-effort, not a guarantee that the groups still exist; see `Publisher Max Age` in [TRACK_INFO](#track-info). An Absolute `Start` past frame 0 delivers its group from that frame; a start resolved at a later group is always delivered from frame 0. A subscriber MAY name any start frame, as described in [Positions](#positions). An Absolute `End` past frame 0 delivers its group up to that frame. ## SUBSCRIBE_UPDATE A subscriber can modify a subscription with a SUBSCRIBE_UPDATE message. A subscriber MAY send multiple SUBSCRIBE_UPDATE messages to update the subscription. Its Ranges replace the previous ones, growing or shrinking the subscription. Lowering a `Start` covers the groups below the old one that the publisher still holds, not only those that arrive later; a group already sent is not sent again. ~~~ SUBSCRIBE_UPDATE Message { Message Length (i) Subscriber Priority (8) Group Order (8) Subscriber Max Age (i) Range Count (i) Range (..) ... Wanted Group (i) } ~~~ See [SUBSCRIBE](#subscribe) for information about each field. Moving a `Start` forward within a group that is already being delivered does not retract frames the publisher has already sent; it only bounds what the publisher sends from here on. **Wanted Group**: The newest group the subscriber's live-edge joins want, encoded as the sequence plus one; `0` means none. Like the other fields, it is subscription state: every SUBSCRIBE_UPDATE restates it, and the latest one applies. A publisher MUST ignore a value at or below its largest group, and otherwise evaluate it as a new SUBSCRIBE under the Track's Refresh ratio. A new SUBSCRIBE itself counts as a join and needs no Wanted Group field. ## TRACK TRACK is sent by a subscriber to request a Track's immutable publisher properties. It is the first message on a Track Stream (0x6). ~~~ TRACK Message { Message Length (i) Broadcast Path (s) Epoch (b) Track Name (s) } ~~~ **Broadcast Path**: The broadcast path of the track. **Epoch**: The publisher instance the subscriber expects, encoded as in [ANNOUNCE_START](#announce-start), or empty for whichever wins the path. A publisher MUST refuse a request with UNROUTABLE when the route it would serve from has another Epoch, so a request is never answered by a different instance than the one it named. A relay fills in the Epoch of the route it resolved when forwarding a request upstream; SUBSCRIBE and FETCH carry it the same way. **Track Name**: The name of the track. ## TRACK_INFO {#track-info} TRACK_INFO is sent by the publisher in response to a TRACK message. It is the sole message on the Track Stream; the publisher FINs immediately afterward, or resets the stream on error (e.g. the track does not exist). ~~~ TRACK_INFO Message { Message Length (i) Publisher Priority (8) Publisher Max Age (i) Timescale (i) Refresh (i) Compression (i) } ~~~ Every field is **fixed for the lifetime of the Track** and MUST NOT change; a change requires a new Track. This is what lets the properties live on their own stream, fetched once and cached, instead of being echoed on every SUBSCRIBE and FETCH response. Publisher properties fan *out* at a relay (one upstream subscription serving many downstreams), so a change would have to propagate everywhere; subscriber properties fan *in*, which the relay already merges, so they MAY change freely via SUBSCRIBE_UPDATE. **Publisher Priority**: The publisher's priority for this Track, represented as a u8 with 128 as the midpoint, used only to resolve ties between subscriptions of equal subscriber priority. See the [Prioritization](#prioritization) section for more information. **Publisher Max Age**: The maximum age, in milliseconds, that the publisher caches a non-latest group, with age as defined in [Expiration](#expiration). Applies only to non-latest groups; the latest group is always retained. It is an upper bound on retention, the inverse of an HTTP `Cache-Control: max-age` guarantee: - A subscriber MAY issue a SUBSCRIBE or FETCH for an older group, but the publisher MAY have already dropped any group whose age exceeds `Publisher Max Age`. - The publisher MAY drop groups sooner than `Publisher Max Age` under resource pressure; subscribers MUST NOT assume older groups within the bound are still available. Encoded as the duration in milliseconds plus one; `0` means no publisher limit. An encoded value of `1` declares a duration of zero, so the publisher caches only the latest group (older groups MAY be dropped as soon as a newer group arrives). The unit is milliseconds, matching `Subscriber Max Age`. See the [Expiration](#expiration) section for more information. **Timescale**: The number of timestamp units per second for frame timestamps on this Track, or `0` for an untimed Track, whose FRAME messages and datagrams carry no timestamp. Common values include `1000` (milliseconds), `1000000` (microseconds), `48000` (audio sample rate), and `90000` (RTP video clock). **Refresh**: An optional unsigned 32-bit byte ratio, encoded as the ratio plus one; `0` disables refresh. On a live-edge join, a publisher asks for a group beyond every sequence already produced when the newest group's bytes after its first frame exceed the ratio times that first frame's bytes. Joins coalesce until the requested group arrives. The request does not hold back delivery: the joiner still receives the newest cached group while it is pending. A relay forwards its joins' demand in SUBSCRIBE_UPDATE's `Wanted Group`. **Compression**: How each Group of this Track is compressed: none (0x0), or DEFLATE (0x1), each Group one DEFLATE stream as specified by {{moqflate}}. Any other value is a PROTOCOL_VIOLATION. When Compression is not none, FRAME messages and datagrams carry an `Inflated Size`. ## TRACKS {#tracks} TRACKS is sent by a subscriber to list a Broadcast's Tracks. It is the first message on a Tracks Stream (0x8). ~~~ TRACKS Message { Message Length (i) Broadcast Path (s) Epoch (b) } ~~~ **Broadcast Path**: The path of the Broadcast to list. **Epoch**: The publisher instance the subscriber expects, as in [TRACK](#track). The listing is pinned to the instance that answers it and ends with that instance. ## TRACKS_ADD {#tracks-add} A publisher sends TRACKS_ADD on a Tracks Stream when a Track is listed. ~~~ TRACKS_ADD Message { Type (i) = 0x0 Message Length (i) Track Name (s) Track Info (..) } ~~~ **Track Name**: The name of the Track, unique among the Tracks listed on the stream. **Track Info**: A [TRACK_INFO](#track-info) message, with its own Message Length, as a Track Stream would answer for the Track. ## TRACKS_REMOVE {#tracks-remove} A publisher sends TRACKS_REMOVE on a Tracks Stream when a listed Track is no longer listed. ~~~ TRACKS_REMOVE Message { Type (i) = 0x1 Message Length (i) Track Name (s) } ~~~ **Track Name**: The name a TRACKS_ADD on the stream listed. ## TRACKS_FINISH {#tracks-finish} A publisher sends TRACKS_FINISH on a Tracks Stream when a listed Track is no longer listed because it finished: its publisher declared its end. ~~~ TRACKS_FINISH Message { Type (i) = 0x2 Message Length (i) Track Name (s) } ~~~ **Track Name**: The name a TRACKS_ADD on the stream listed. ## SUBSCRIBE_OK {#subscribe-ok} A SUBSCRIBE_OK message confirms a subscription and resolves its absolute start position. It is the first message the publisher sends on the Subscribe Stream, once the start position is known. This is the trimmed-down counterpart of MoqTransport's SUBSCRIBE_OK: it retains the name and the role of the publisher's positive response, but carries only the resolved start position and the publisher's largest position (all other per-track properties live in [TRACK_INFO](#track-info)). ~~~ SUBSCRIBE_OK Message { Type (i) = 0x0 Message Length (i) Group (i) Largest Group (i) [Largest Frame (i)] } ~~~ **Type**: Set to 0x0 to indicate a SUBSCRIBE_OK message. **Group**: The absolute sequence number where delivery starts when the subscription resolves. Group streams can arrive out of order, so it need not be the first group sent. It MUST be greater than or equal to the lowest requested `Start`, as resolved for a Relative or Time Anchor. This group is not a new floor. A subscriber SHOULD NOT wait for a group between the requested floor and this group. The publisher still delivers such a group if it is created or becomes available later, while it is within `Subscriber Max Age`. A subscriber whose `Subscriber Max Age` resolves the start to the latest group learns that sequence here. There is no matching frame field, because the start frame is never in doubt: a partial group is only delivered when it was asked for, so the subscription starts either exactly where it asked or at the beginning of a later group (see [Positions](#positions)). The subscriber derives the start frame from `Group` and its own request: - `Group` equals the requested start group: delivery begins at the requested start frame. - `Group` is greater: delivery begins at frame 0. The second case is easy to get wrong, so to be explicit: a subscriber that requested group 5 frame 15 and receives `Group` = 6 starts at **frame 0** of group 6, not frame 15. The frame offset belonged to group 5 and is gone along with the rest of it; it does not carry forward to whichever group the publisher resolved to. **Largest Group** and **Largest Frame**: The largest position the publisher knows of for the track when it answers: the last frame that exists, like moq-transport's Largest Location, including one a relay's source reported that has yet to arrive. `Largest Group` is the group sequence plus one, and 0 means the track has nothing yet, in which case `Largest Frame` is absent. A subscriber takes it as where the live feed is: a relay holding groups from an earlier subscription serves them from cache only once this says they are current, which a publisher answering promptly lets it do without waiting for a frame. ## SUBSCRIBE_END {#subscribe-end} A SUBSCRIBE_END message is sent by the publisher to signal that no group at or after a given sequence will be produced. ~~~ SUBSCRIBE_END Message { Type (i) = 0x1 Message Length (i) Group (i) } ~~~ **Type**: Set to 0x1 to indicate a SUBSCRIBE_END message. **Group**: The exclusive end of the range: the absolute sequence number of the first group that will never be delivered. A value of 0 means the track ended before producing any groups. The subscriber MUST NOT wait for any group at or after this sequence. The publisher SHOULD send SUBSCRIBE_END as soon as the exclusive final group is known, even while lower groups are still in flight. ## SUBSCRIBE_DROP {#subscribe-drop} A publisher names groups it will not deliver with SUBSCRIBE_DROP on the Subscribe Stream. ~~~ SUBSCRIBE_DROP Message { Type (i) = 0x2 Message Length (i) Group Start (i) Group End (i) Error Code (i) } ~~~ **Group Start** and **Group End** are the inclusive range of group sequences the publisher gave up. **Error Code** is a [stream error code](#error-codes) saying why the groups were given up, as a reset retaining the Group Stream header would: for example NOT_FOUND for a sequence the publisher never had, or OLD for one past its budget. A subscriber MUST end the readers of a dropped group it holds with that error, including a group whose stream is still arriving, rather than end it cleanly. For every sequence in the subscription's range, the publisher MUST send a Group Stream, send a datagram, or name it in SUBSCRIBE_DROP. This includes sequences it skipped and streams reset before their headers were delivered. A reliably delivered reset retaining the Group Stream header accounts for that group instead. The publisher SHOULD send SUBSCRIBE_DROP as soon as it gives a sequence up, without waiting for SUBSCRIBE_END. A datagram counts as sent even if it is lost; a subscriber SHOULD bound its wait for unaccounted sequences, for example by Subscriber Max Age. ## FETCH FETCH is sent by a subscriber to request a single group from a track. ~~~ FETCH Message { Message Length (i) Broadcast Path (s) Epoch (b) Track Name (s) Subscriber Priority (8) Subscriber Max Age (i) Group Sequence (i) Frame Start (i) Frame End (i) } ~~~ **Broadcast Path**: The broadcast path of the track to fetch from. **Track Name**: The name of the track to fetch from. **Subscriber Priority**: The priority of the fetch within the session, represented as a u8. See the [Prioritization](#prioritization) section for more information. **Subscriber Max Age**: The optional maximum age in milliseconds that the subscriber accepts, encoded as the value plus one; 0 omits the reader bound. The publisher still applies `Publisher Max Age`, and MUST use the tighter bound while serving and while the returned group remains open. **Group Sequence**: The sequence number of the group to fetch. **Frame Start**: The index of the first frame to return within the group (see [Positions](#positions)). A plain absolute index; 0 means the start of the group (default). **Frame End**: The last frame to return (inclusive), encoded as the absolute frame index + 1. A value of 0 means through the end of the group (default). A `Frame End` below `Frame Start` once decoded is a protocol violation; equal bounds are a legal single-frame range. The publisher responds with FRAME messages directly on the same stream — there is no response header. The subscriber parses them using the track's [TRACK_INFO](#track-info), which it MUST already have (see the [Track Stream](#track-stream)); the group sequence and the index of the first frame are implicit from the FETCH request. The publisher FINs the stream after the last frame, or resets on error. There is no FETCH_ERROR message — the publisher signals failure by resetting the stream. A publisher holding fewer frames than requested MUST reset rather than truncate, since a short response is indistinguishable from one that started elsewhere. A group that ends before `Frame End` is not a truncation: the publisher FINs after the last frame it has, provided the group is complete and it served everything from `Frame Start` onward. ## PROBE PROBE is used to measure the available bitrate of the connection. ~~~ PROBE Message { Message Length (i) Bitrate (i) RTT (i) } ~~~ **Bitrate**: When sent by the subscriber (stream opener): the target bitrate in bits per second that the publisher should pad up to (see [Probe](#probe)). When sent by the publisher (responder): the current estimated bitrate in bits per second. A value of 0 means unknown. **RTT**: The smoothed round-trip time in milliseconds, as defined in [RFC9002]. A value of 0 means unknown. > NOTE: RTT is included in the PROBE message because not all QUIC implementations and browser WebTransport APIs expose RTT statistics directly. This field may be deprecated once RTT is universally available via the underlying transport API. ## GOAWAY {#goaway-message} A GOAWAY message is sent to initiate a graceful session shutdown with an optional redirect. ~~~ GOAWAY Message { Message Length (i) New Session URI (s) } ~~~ **New Session URI**: A URI for the peer to reconnect to. An empty string indicates no redirect; the peer should simply close the session. The URI MUST NOT exceed 8,192 bytes; a receiver MUST treat a longer URI as a protocol violation and MAY reject it based on the length prefix alone. A recipient MUST validate the URI against local policy before reconnecting, including verifying the scheme, authority, and port are permitted. If validation fails, the recipient MUST close the session without reconnecting. A client MUST send an empty New Session URI, as it cannot instruct a server to establish connections. A server that receives a non-empty New Session URI MUST close the session with a protocol violation. The new session URI SHOULD use the same scheme as the current session's URI. An endpoint MUST close the session with a protocol violation if it receives more than one GOAWAY. A peer that reconnects to a provided URI SHOULD keep using that URI for subsequent reconnects rather than reverting to the original. Routes that arrived on a session that received a GOAWAY stay usable until the session ends. ## AUTH {#auth-message} AUTH is the first message on an Auth Stream, presenting a token. ~~~ AUTH Message { Message Length (i) Token (b) } ~~~ **Token**: The credential to verify, opaque to moq-lite. An empty token presents the credential the connection already carried, or nothing. ## AUTH_OK {#auth-ok} AUTH_OK grants a token, and replaces any grant sent before it on the same stream. ~~~ AUTH_OK Message { Type (i) = 0x0 Message Length (i) Publish Count (i) Publish Pattern (s) ... Subscribe Count (i) Subscribe Pattern (s) ... Expires (i) } ~~~ **Publish Pattern**: A [pattern](#path-pattern) matching broadcast paths the opener may announce and serve. A count of zero grants none. **Subscribe Pattern**: A [pattern](#path-pattern) matching broadcast paths the opener may subscribe to. **Expires**: The number of milliseconds until the grant lapses, or 0 for never. A value greater than 2^53 - 1 is invalid and MUST close the session with PROTOCOL_VIOLATION. The acceptor revokes a lapsed grant with AUTH_ERROR; the opener uses Expires to present a replacement token in time. ### Path Pattern {#path-pattern} A pattern is a set of broadcast paths, written as `/`-separated segments. Each segment is one of: - a literal, matching that segment exactly; - `*`, matching any one segment; - a literal with one `*` inside it, such as `cam-*.hang`, matching any segment that starts with the bytes before the `*` and ends with the bytes after it, without overlapping them; - `**`, matching zero or more segments. A pattern matches a path when its segments match the path's segments in order, covering the whole path. The empty pattern matches only the empty path, `room/**` matches `room` and every path beneath it, and `**` matches every path. A pattern has at most 32 segments and at most one `**`. It has no leading, trailing, or repeated `/`, no segment with more than one `*`, and no `**` combined with other bytes in a segment. It is canonical: a `**` is never immediately preceded by a `*` segment, since `*/**` and `**/*` match the same paths and only `**/*` is valid. A pattern that breaks any of these rules is a PROTOCOL_VIOLATION. A grant covers a request when one of its patterns matches the path. An endpoint MUST close the session with PROTOCOL_VIOLATION when an AUTH stream message fails to decode, has trailing body bytes, or has an unexpected type for its direction. The opener sends exactly one AUTH; any additional message on its side is a PROTOCOL_VIOLATION. ## AUTH_ERROR {#auth-error} AUTH_ERROR refuses a token, or revokes it after an AUTH_OK. ~~~ AUTH_ERROR Message { Type (i) = 0x1 Message Length (i) Error Code (i) Reason Phrase (s) } ~~~ **Error Code**: A code from the [session error registry](#session-error-codes), such as UNAUTHORIZED. A code that does not fit in 32 bits is a PROTOCOL_VIOLATION that closes the session. **Reason Phrase**: A human-readable reason, at most 8,192 bytes. Invalid UTF-8 or a reason longer than 8,192 bytes is a PROTOCOL_VIOLATION that closes the session. ## GROUP The GROUP message contains information about a Group, as well as a reference to the subscription being served. ~~~ GROUP Message { Message Length (i) Subscribe ID (i) Group Sequence (i) Frame Start (i) } ~~~ **Subscribe ID**: The corresponding Subscribe ID. This ID is used to distinguish between multiple subscriptions for the same track. **Group Sequence**: The sequence number of the group. This SHOULD increase by 1 for each new group. A subscriber MUST handle gaps, potentially caused by congestion. **Frame Start**: The index of the first FRAME message on this stream within the group (see [Positions](#positions)). A plain absolute index; 0 means the stream carries the group from its beginning, which is the common case. A non-zero value means the leading frames are not on this stream, either because the subscription started partway into this group or because the publisher only holds part of it. The subscriber MUST NOT assume it will receive the missing frames on another stream; they are a gap unless a separate subscription or FETCH covers them. ## FRAME The FRAME message is a payload within a group. ~~~ FRAME Message { [Timestamp Delta (i)] [Inflated Size (i)] Message Length (i) Payload (b) } ~~~ **Timestamp Delta**: A signed delta from the previous frame's timestamp, in the Track's negotiated `Timescale`. Present only on a timed Track. Encoded as a zigzag-mapped variable-length integer: - Encode: `unsigned = (signed << 1) ^ (signed >> 63)` (arithmetic right shift). - Decode: `signed = (unsigned >> 1) ^ -(unsigned & 1)`. Zigzag interleaves non-negative and negative values so small magnitudes of either sign fit in a 1-byte varint. The first frame of a group is delta-encoded from `0`, so its `Timestamp Delta` is the zigzag encoding of the absolute timestamp. **Inflated Size**: The size of the payload once inflated. Present only when the Track's Compression is not none; the payload is then the next slice of the Group's compressed stream. A receiver MUST check it against its limits before inflating, and MUST treat a payload that inflates to any other size as malformed. **Payload**: An application-specific payload. The `Message Length` describes the payload size on the wire. # Appendix A: Changelog ## moq-lite-07 - Limited a broadcast path to 32 segments and 4,096 bytes. - A session without a negotiated subprotocol negotiates the version in a draft-14 CLIENT_SETUP and SERVER_SETUP exchange. - A path never has an empty segment, so a leading, trailing, or doubled `/` is a PROTOCOL_VIOLATION rather than a second spelling of the same path. - Added the SETUP `Implementation` parameter (0x6): the sender's software as `name/version` product tokens. - Hop IDs span 64 bits. - Allow a negotiated extension to preserve timestamps with explicitly unchecked provenance. - Added Padding Stream (0x2), read and discarded incrementally by either endpoint. - Named 128 the midpoint priority for an unset Subscriber or Publisher Priority. - Added the Tracks Stream (0x8) with TRACKS, TRACKS_ADD, TRACKS_REMOVE, TRACKS_FINISH, and SYNCED: a subscriber lists one Broadcast's Tracks, each with its TRACK_INFO, then SUBSCRIBEs to those it wants, and learns whether each removed Track finished. A relay answers at once with what it holds. - Added TRACK_INFO `Refresh` and SUBSCRIBE_UPDATE `Wanted Group`, carrying ratio-based refresh demand through relays. - Added `Compression` to TRACK_INFO: none (0x0) or DEFLATE (0x1), whose Groups are DEFLATE streams per draft-lcurley-moq-flate. FRAME and datagrams carry an `Inflated Size` when it is not none. - SUBSCRIBE may start at any frame, including when the subscriber no longer holds the prefix. - Replaced ANNOUNCE_OK Active Count with an ordered, empty SYNCED event (0x4), marking the initial set without retaining routes that end before delivery. - A publisher must not start a timed Group's frame 0 earlier than a lower-sequence Group's, and a receiver may refuse a Group that does. A relay serves a Track whose timestamps it cannot vouch for untimed unless a negotiated extension identifies their unchecked provenance. - Bounded every timestamp below 2^61. A larger one is a malformed Track. - Added the reliable ordered RTCDataChannel binding, with one Qmux Record per binary message and no datagrams. - Added `Group Order` to SUBSCRIBE and SUBSCRIBE_UPDATE: descending (0x0, the previous fixed order) or ascending (0x1). - Listed the shared EXPIRED_AUTH_TOKEN (0x18) session code. A request that expires on its own resets with the UNAUTHORIZED stream code. - SUBSCRIBE_OK `Group` names where delivery starts when the subscription resolves, which need not be the first group sent. It is not a new floor. A subscriber does not wait for a group between the requested floor and it, but a publisher still delivers one that arrives later within Subscriber Max Age. - Replaced `Group Start`, `Group End`, `Frame Start`, and `Frame End` in SUBSCRIBE and SUBSCRIBE_UPDATE with a non-empty list of Ranges. Each end is an Anchor: none, an absolute Position (exclusive as an end), a count of Groups back from the latest, or a presentation time. - A subscription's range bounds datagrams like groups, and FETCH never returns a datagram. - A SUBSCRIBE_UPDATE that lowers a `Start` covers the groups below the old start that the publisher still holds, not only those that arrive later. A group already sent is not sent again. - A publisher SHOULD order a datagram among the subscription's streams as if it were its Group's stream, ahead of that stream when both exist. It SHOULD drop a datagram rather than block when the transport has no room for it, where it previously sent one only if the congestion controller could transmit it immediately. - Assigned 0x3A NOT_FETCHABLE in the stream error table: a FETCH for a group delivered only as a datagram. - The subscriber FINs its Subscribe Stream after settling its tail; graceful session close waits for that FIN or reset. - A subscriber MAY cancel by FIN on its Subscribe Stream, and the publisher then FINs without SUBSCRIBE_END. - A publisher MAY FIN a subscription whose every Range has an End, once each Group in them is sent or dropped, without SUBSCRIBE_END. The FIN ends the subscription, not the Track. - Removed the NO_CAPACITY stream error and its one re-resolution. Every refusal is terminal, including at another route of the same prefix. - Assigned 0x3B UNAUTHORIZED in the stream error table: a request reset because the endpoint does not authorize it, or no longer does, distinct from SESSION_CLOSED. - A relay resets with UNROUTABLE, rather than INTERNAL_ERROR, a stream served through an upstream session that ended. - A GOAWAY recipient MAY keep opening requests on the current session until it has moved to a replacement session; previously it MUST NOT open new streams. The sender SHOULD keep answering them and MAY reset one with GOING_AWAY, which the recipient SHOULD retry on another route. - Replaced Warm and Cold Route Cost with one static Route Cost. - Made TRACK_INFO Publisher Max Age optional, encoded as milliseconds plus one with zero meaning no limit. - Added an optional `Subscriber Max Age` to FETCH, encoded as milliseconds plus one with zero meaning absent. It can tighten, but never relax, `Publisher Max Age`. - Added `Largest Group` and `Largest Frame` to SUBSCRIBE_OK: the publisher's largest position when it answers, which a subscriber takes as where the live feed is. A publisher MUST answer at once when the requested start is past it. - Added `Epoch` to ANNOUNCE_START, SUBSCRIBE, TRACK, and FETCH: a UUIDv7 naming the publisher instance, or empty. A path and an Epoch name one Broadcast. A relay MAY move a subscription between routes with the same Epoch, continuing from the first frame the subscriber lacks instead of at a group boundary, and never between routes with different Epochs or none. The newest Epoch wins a path for new requests, while subscriptions to the older one stay until their route goes. Replaces the first-hop identity. - Added ANNOUNCE_RESTART (0x3): another publisher instance replaces a live advertisement, under a new Epoch or, without one, through another route, including a path beneath a prefix moving to another route. - An untimed Track declares a `Timescale` of 0 in TRACK_INFO, and its FRAME messages and datagrams omit the timestamp. - Expiration also measures a group's arrival age, the time since its successor arrived at this hop, so a group of an untimed Track expires by arrival age alone. - A publisher resets the Group Stream of an expired, unfinished group with OLD rather than finishing it, even after sending every frame it holds. - An open Track Stream is interest in the Track until the subscriber closes it, which a subscriber does once its Subscribe Stream has a response. - Assigned `moq-lite-07-wip` as this draft's protocol identifier until it is finalized as `moq-lite-07`. - Added the client-only Token Setup Parameter (0x7), carrying the connection credential in SETUP instead of only the request URI. - Switched every variable-length integer, including SETUP parameter values, from QUIC's two-bit length prefix to moq-transport's leading-ones encoding, widening the range to 64 bits. - Hid routes with a `.`-prefixed segment below the requested prefix from announce discovery, and added the ANNOUNCE_REQUEST `Hidden` field to opt in. - Restored SUBSCRIBE_DROP (type 0x2), replacing Stream Count on SUBSCRIBE_END. Every sequence is accounted for by a Group Stream, a datagram, or a drop; a publisher SHOULD send a drop as soon as it gives a sequence up. - SUBSCRIBE_DROP's Error Code is a stream error code, which ends the dropped groups' readers, including one still arriving. - SUBSCRIBE_END declares the final boundary while lower groups can still be in flight; the Subscribe Stream FIN follows once every Group Stream has finished or been reset. - Added the Spread Hash tie-break after the shortest path: a hash of the requested path and the route's Hop IDs, so equal-cost advertisers of one prefix share its paths. - Skipped the Spread Hash for a route a publisher announced directly at the requested path. - Added a local link preference to route selection, after the newest Epoch and before the 0 Hop ID check: the higher preference a subscriber assigns to the arriving link wins, and an unassigned link is neutral. Not carried on the wire. - Added announce compression: ANNOUNCE_START gains `Path Base` and `Path Keep` to copy the head of a live advertisement's suffix, and ANNOUNCE_START and ANNOUNCE_UPDATE gain `Hop Base` and `Hop Keep` to copy the tail of a live advertisement's Hop ID list. - Capped the Message Length of every message except FRAME at 65,535 bytes. - Added the TOO_MANY_REQUESTS (0x7) session code, closing a session whose peer goes past the endpoint's bound on subscriptions or announcements. - Added the MALFORMED_AUTH_TOKEN (0x16) session code, with moq-transport's meaning. - Removed KEY_VALUE_FORMATTING_ERROR (0x6) from the session codes; a repeated SETUP parameter is a PROTOCOL_VIOLATION. - Removed the standby-ordering deployment requirements on Route Cost seeds and bounds. - Removed rules for mapping onto earlier versions and moq-transport, and guidance on relay internals that no peer observes, such as announce and listing queues, TRACK_INFO caching, upstream requests, and GOAWAY route costs. Coalescing announce and listing changes is now a MAY, bounded only by when SYNCED arrives, and a publisher MAY, rather than MUST, reset an older Tracks Stream for the same Broadcast. - Limited AUTH_OK Expires to 2^53 - 1 milliseconds, with an out-of-range value closing the session with PROTOCOL_VIOLATION. - Invalid UTF-8 or an oversized AUTH_ERROR reason closes the session with PROTOCOL_VIOLATION. - An Auth Stream message that fails to decode, has trailing body bytes, or has an unexpected type, or a second AUTH from the opener, closes the session with PROTOCOL_VIOLATION. - Any Setup Stream or bidirectional stream message that fails to decode closes the session with PROTOCOL_VIOLATION; previously only an unexpected Message Length did, as a SHOULD. This includes a frame bound without its group bound, and a Hop Count that disagrees with its Hop ID entries, each of which previously reset only the stream. A stream ending mid-message outside the Setup and Auth Streams, a malformed GROUP or FRAME on a Group or Fetch Stream, and an unknown stream type still affect only that stream. - A Report-level publisher reads and ignores PROBE targets instead of resetting the Probe Stream. - Added NOT_SUPPORTED (0x30) to session errors; unsupported Auth Stream tokens and grants use AUTH_ERROR before or after a grant. - Added the Auth Stream (0x7) with AUTH, AUTH_OK, and AUTH_ERROR: either endpoint presents a token on its own stream and learns the [path patterns](#path-pattern) it may publish and subscribe to. The union of a session's open grants is its scope. A shrink withdraws what it no longer covers, and an announcement outside the scope closes the session with UNAUTHORIZED. A peer without the stream resets it. - Required scope widening before AUTH_OK and presenter-side waiting for unanswered non-empty tokens before sending an uncovered request or rejecting a publication; renewal waits for AUTH_OK before withdrawing the old token. ## moq-lite-06 - Assigned `moq-lite-06` as this draft's protocol identifier. - Require error-code translation when bridging protocols and draft versions. - Made a repeated non-zero Hop ID in one announcement's Hop ID list a PROTOCOL_VIOLATION, matching draft-lcurley-moq-cluster. Repeated 0 entries stay legal. - Moved the Qmux-over-WebSocket binding details to draft-lcurley-qmux-websocket; the binding itself is unchanged. - Extended the SETUP `Path` parameter to carry the URI query: a client appends `?` and the query component after the path, matching moq-transport's PATH option. The credential a deployment puts in the query was previously unrepresentable on a binding with no request URI. - Allowed an empty SETUP `Path` parameter, equivalent to omitting it; both request the server's default path. Previously an empty value was a protocol violation, which made the two ways of asking for the default disagree. - Corrected SUBSCRIBE_END `Group` to an exclusive bound: the first sequence that will never be delivered, with 0 meaning no groups were produced. It was previously specified as the inclusive last group, which could not distinguish an empty track from one whose only group was 0. - Split ANNOUNCE_BROADCAST into three typed messages: ANNOUNCE_START (0x0), ANNOUNCE_END (0x1), and ANNOUNCE_UPDATE (0x2), each prefixed with a Type discriminator like the subscribe stream's responses. - Specified resolution of a request against the routes covering its path: only the most specific tier, the longest prefix, is consulted, so a concrete path shadows every broader route; a refusal never falls through, and cost and the existing tie-breaks order the tier. A route is always a prefix, and no message narrows one: an advertiser that serves only some of the paths beneath its prefix refuses the rest. A relay never announces a path because it resolved it; the advertiser announces the concrete path once producing. Standby ordering requires enforced deployment bounds on charged link count and cost; 2^32 is a recommended seed only when it exceeds their product. - Split the reserved stream error range: 32 through 47 stays reserved, and 48 through 63 is moq-lite's own, assigned by the tables and mapped rather than forwarded across a bridge. Assigned 0x30 NO_CAPACITY there: it permits one re-resolution within the tier excluding the refusing advertiser, and a receiver that has spent or lacks that retry resets downstream with another code. Assigned 0x32 GROUP_TOO_LARGE: a group that grew past the publisher's cache budget is aborted. Every other code is terminal. - Assigned 0x33 NOT_FOUND, 0x34 OLD, and 0x35 EVICTED in the stream error table: a group the publisher cannot serve because it was never here, has been superseded, or was dropped under memory pressure. - Assigned 0x36 UNROUTABLE, 0x37 WRONG_SIZE, 0x38 FRAME_TOO_LARGE, and 0x39 TIMESTAMP_MISMATCH in the stream error table, moving them out of the reserved 32 through 47 range, which no longer carries provisional placeholders. - Assigned 0x31 CONTROL_TIMEOUT in the stream error table: a request stream torn down because the peer never answered, which DELIVERY_TIMEOUT described as late content. It has no moq-transport value and bridges to INTERNAL_ERROR. - A disallowed stream type, a role mismatch, or a missing extension is a PROTOCOL_VIOLATION; the session table gains no code for them, so nothing is sent from the reserved 32 through 47 range in either registry. - Added implicit Announce IDs: each ANNOUNCE_START assigns the next per-stream ordinal. - ANNOUNCE_END and ANNOUNCE_UPDATE reference the Announce ID instead of repeating the broadcast path. - Replaced the duplicate-`active` restart idiom with ANNOUNCE_UPDATE; a second ANNOUNCE_START for an already-advertised prefix is now a protocol violation. - Made the Announce Stream violations close the session rather than reset the stream: an unknown or retired Announce ID, an ANNOUNCE_START for an already-advertised prefix, and any announcement before ANNOUNCE_OK. - Redefined an announcement as a route: the advertised path is a prefix claiming that paths beneath it can be served, matching moq-transport's namespace semantics, rather than the exact path of one available broadcast. A route claims capability, not inventory; which covered paths name broadcasts is an application convention (announcing each broadcast's exact path keeps enumeration working). ANNOUNCE_UPDATE updates a route's metadata in place and carries no content claim. Because routes carry no content identity, a relay never splices a live subscription across sources reached through different routes: a serving session ending resets its subscriptions and the subscriber re-requests through the best remaining route. - Stated the ended-broadcast lifecycle without a flag: a broadcast whose route is retracted with ANNOUNCE_END may remain readable by exact path, its stored groups read over FETCH, discovered out of band; retraction never disturbs in-flight subscriptions. - Specified route matching as per path segment (a prefix never matches half a segment) and specified how a route broader than the requested prefix presents: clamped to the request, as an empty suffix. - Added `Warm Route Cost` and `Cold Route Cost` fields to ANNOUNCE_START and ANNOUNCE_UPDATE: the same path priced against two cache states. Warm is the accumulated cost of the transfers a subscription via this advertisement would newly cause, and is what route selection minimizes; Cold prices the path as if nothing along it were carrying, and breaks a Warm tie before path length, with the most recently received advertisement below that. A wire that cannot express Cold is read as the saturation ceiling, not as 0. - Added a SETUP `Cost` parameter (0x4) declaring what subscribing from the sender costs, added by the receiver to every announcement that sender forwards. Both endpoints send their own, so the two directions are priced independently, and a receiver MAY charge a locally configured value instead. Unpriced directions default to 1, degrading to shortest-path routing. - Removed `Exclude Hop` from ANNOUNCE_REQUEST. The receiver's hop-based loop check already discards a looped announcement, so the field only saved the wasted send. - Stated the receiver's loop check normatively in ANNOUNCE_START: an announcement whose reconstructed path contains the receiver's own Hop ID is neither forwarded nor selected as a route. - Added a SETUP `Hop` parameter (0x5): each endpoint declares its Hop ID at session setup, carrying session-wide the identity `Exclude Hop` carried per announce stream, and filtering subscriptions as well as announcements (including sessions that never open an Announce Stream). - Made advertisement selection per subscriber: a publisher MUST NOT advertise a path containing the subscriber's declared Hop ID and otherwise advertises the best remaining one (a subscriber the serving path flows through receives the best standby instead of nothing), MUST serve subscriptions by the same exclusion, and the actively-carrying cost discount applies only to the serving path. This is how redundant publishers fail over across a mesh. - Added `Frame Start` and `Frame End` to SUBSCRIBE and SUBSCRIBE_UPDATE, qualifying the start and end group with a frame index so a subscription can begin or end partway through a group. `Frame Start` is a plain index qualifying the `Group Start` group; `Frame End` is the index + 1, matching `Group End`, and MUST be 0 when `Group End` is absent. - Added `Frame Start` and `Frame End` to FETCH, bounding the returned frames within the group. A publisher that cannot serve the full range resets the stream. - Added `Frame Start` to GROUP, giving the index of the first FRAME on the stream so a partial group is self-describing. - Added the Positions section defining a (group, frame) position, its lexicographic ordering, and the rule that a partial group is only ever delivered to a subscriber that asked for one. A publisher that cannot serve a group from the requested frame skips it and resolves to a later group, so SUBSCRIBE_OK needs no frame field: the start frame follows from `Group` and the subscriber's own request. - Capped the GOAWAY New Session URI at 8,192 bytes, matching moq-transport. - Restricted the GOAWAY New Session URI to servers, specified a duplicate GOAWAY as a protocol violation, and recommended scheme continuity and sticky redirects. - Exempted a ceiling-cost serving path from the actively-carrying cost discount: a relay whose serving path costs the saturation ceiling (primarily a session that received a GOAWAY) advertises the ceiling instead of 0, so the drain propagates downstream instead of being re-masked by each carrying hop. Keyed on the value, not the reason, which does not travel on the wire. - Added the Error Codes section, defining separate session and stream code spaces and listing the codes moq-lite uses, reused unchanged from moq-transport. Codes 64+ are the application's; 32-63 are reserved and MUST NOT be interpreted, pending a future revision. Previously the codes were unspecified, so an endpoint could neither send one a peer would understand nor safely interpret one it received. Note this renumbers every code an existing implementation sent, and that a stream reset of 0x0 is now INTERNAL_ERROR rather than a cancellation (CANCELLED is 0x1). - Renamed `Subscriber Max Latency` to `Subscriber Max Age` and `Publisher Max Latency` to `Publisher Max Age`. - Redefined timestamp age so a group is expired only once it provably cannot overlap the Max Age window. Age is now measured from a group's *reach* (the first frame timestamp of its successor, which is the furthest it could still present) to the newest frame of the latest group, and an age equal to Max Age expires it, since reach is an exclusive bound. Previously age ran from the group's own first frame, which expired a group whose later frames were still well inside the budget. A group whose successor has not yet presented a frame is never expired, because frame durations are not on the wire and nothing else proves where it ends. - Removed the wall-clock age measure from expiration: timestamps are the only input, and wall-clock reclamation of idle content is the retention cache's own policy (`Publisher Max Age`) rather than part of the subscription rule. A zero-frame group needs no measure of its own, since its reach is bounded by its successor's first frame like any other group. - Removed `Subscriber Ordered` from SUBSCRIBE and SUBSCRIBE_UPDATE, and `Publisher Ordered` from TRACK_INFO and SUBSCRIBE_OK. Group order within a Track is now normatively newest-first, with no field to invert it: a subscriber that wants sequence order reads in sequence order, which costs the network nothing and does not let one subscriber's preference reach a Track a relay is fanning out to many. Note this removes a byte from the middle of each message, so a lite-06 peer cannot parse a lite-05 one's SUBSCRIBE (the earlier drafts keep the byte, and an implementation serving them SHOULD write 0 and ignore what it reads). - Made `Group Start` an absolute floor (the raw minimum group sequence, default 0) rather than the sequence + 1 with 0 meaning the latest group. The start resolves from `Subscriber Max Age` instead: the oldest group at or above the floor within the budget, so a subscriber that buffers is handed the head of what it can still play. A zero budget still resolves to the latest group, which was the only start the old encoding could ask for by default. - Removed the actively-carrying Warm discount, its ceiling exemption, and the `(Cold cost, hash)` adoption rank with its re-parenting delay: costs are forwarded accumulated only. The `Warm` and `Cold` fields and the selection order are unchanged. - Replaced the no-splice rule with a first-hop identity: two routes covering one path with the same non-zero first hop are the same origin reached another way, and a relay MAY move a live subscription between them at a group boundary. Splicing across differing or unknown (0) first hops remains prohibited. - Ranked a path that contains a 0 Hop ID below every fully identified path before comparing Route Cost. A received 0 is forwarded unchanged, and bridging an upstream that sent no hop list writes 0 for that hop. An assigned identity is local selection state and MUST NOT be forwarded. ## moq-lite-05 - Renamed ANNOUNCE_INTEREST to ANNOUNCE_REQUEST and ANNOUNCE to ANNOUNCE_BROADCAST. - Added a SETUP message and Setup Stream (0x1). - Added a SETUP `Probe` parameter. - Added a SETUP `Path` parameter to convey the request path on bindings that have no request URI (native QUIC and Qmux-over-TCP/TLS). - Added a SETUP `Role` parameter so a client can advertise its intended direction (Publisher/Subscriber/Both) and be rejected during SETUP when its authorization lacks that direction. - Added Track Stream (0x6) and TRACK_INFO. - Removed FETCH_OK. - Trimmed SUBSCRIBE_OK to a single resolved start group. - Split end-of-subscription signaling into SUBSCRIBE_END. - Renamed `Start Group`/`End Group` to `Group Start`/`Group End` in SUBSCRIBE, SUBSCRIBE_UPDATE, and SUBSCRIBE_DROP. - Allowed duplicate `active` ANNOUNCE_BROADCAST messages to atomically replace the prior advertisement. - Added ANNOUNCE_OK with `Hop ID` and `Active Count`. - Added mandatory `Timescale` to TRACK_INFO. - Added `Timestamp Delta` to FRAME. - Added `Timestamp` to the QUIC datagram body. - Moved `Publisher Max Latency` to TRACK_INFO and redefined it as a maximum retention bound: the longest the publisher caches a non-latest group (the inverse of an HTTP `Cache-Control: max-age` guarantee). `Subscriber Max Latency` keeps its name and remains the subscriber's delivery-time expiration preference. - Expire a group once **either** its timestamp age or its wall-clock arrival age exceeds Max Latency (the shorter lifetime wins), bounding both manipulated timestamps and delivery bursts. - Added QUIC datagram delivery for groups. Datagrams and Group Streams are independent delivery modes with no conversion between them: an oversized (>1200 byte) datagram MUST NOT be sent and is dropped on receipt, and bindings without a datagram channel do not fall back from datagrams to streams. - Added Qmux [qmux] transport bindings for TCP/TLS and WebSocket. ## moq-lite-04 - Renamed ANNOUNCE_PLEASE to ANNOUNCE_SUBSCRIBE. - ANNOUNCE_BROADCAST `Hops` count replaced with explicit `Hop ID` list for loop detection. - Added `Exclude Hop` to ANNOUNCE_REQUEST for relay loop avoidance. - Added GOAWAY stream for graceful session shutdown and migration. - Added RTT to PROBE message. Bitrate and RTT use 0 for unknown. ## moq-lite-03 - Version negotiated via ALPN (`moq-lite-xx`) instead of SETUP messages. - Removed Session, SessionCompat streams and SESSION_CLIENT/SESSION_SERVER/SESSION_UPDATE messages. - Unknown stream types reset instead of fatal; enables extension negotiation via stream probing. - Added FETCH stream for single group download. - Added Start Group and End Group to SUBSCRIBE, SUBSCRIBE_UPDATE, and SUBSCRIBE_OK. - Added SUBSCRIBE_DROP on Subscribe stream. - Subscribe stream closed (FIN) when all groups accounted for. - Added PROBE stream replacing SESSION_UPDATE bitrate. - Removed ANNOUNCE_INIT message. - Added `Hops` to ANNOUNCE_BROADCAST. - Added `Subscriber Max Latency` and `Subscriber Ordered` to SUBSCRIBE and SUBSCRIBE_UPDATE. - Added `Publisher Priority`, `Publisher Max Latency`, and `Publisher Ordered` to SUBSCRIBE_OK. - SUBSCRIBE_OK may be sent multiple times. ## moq-lite-02 - Added SessionCompat stream. - Editorial stuff. ## moq-lite-01 - Added Message Length (i) to all messages. # Appendix B: Upstream Differences A quick comparison of moq-lite and moq-transport-14: - Streams instead of request IDs. - Pull only: No unsolicited publishing. - FETCH is HTTP-like (single request/response) vs MoqTransport FETCH (multiple groups). - Capabilities negotiated via a SETUP message on a unidirectional stream that does not block other streams, instead of MoqTransport's blocking CLIENT_SETUP/SERVER_SETUP handshake on the control stream. - Both moq-lite and MoqTransport use ALPN for version identification. - Names use utf-8 strings instead of byte arrays. - Track Namespace is a string, not an array of any array of bytes. - Subscriptions default to the latest group, not the latest object. - No subgroups - No Object ID gaps - No object properties - No paused subscriptions (forward=0) ## Deleted Messages - MAX_SUBSCRIBE_ID - REQUESTS_BLOCKED - SUBSCRIBE_ERROR - UNSUBSCRIBE - PUBLISH_DONE - PUBLISH - PUBLISH_OK - PUBLISH_ERROR - FETCH_OK - FETCH_ERROR - FETCH_CANCEL - FETCH_HEADER - TRACK_STATUS - TRACK_STATUS_OK - TRACK_STATUS_ERROR - PUBLISH_NAMESPACE - PUBLISH_NAMESPACE_OK - PUBLISH_NAMESPACE_ERROR - PUBLISH_NAMESPACE_CANCEL - SUBSCRIBE_NAMESPACE_OK - SUBSCRIBE_NAMESPACE_ERROR - UNSUBSCRIBE_NAMESPACE - OBJECT_DATAGRAM ## Renamed Messages - SUBSCRIBE_NAMESPACE -> ANNOUNCE_REQUEST - SUBGROUP_HEADER -> GROUP ## Deleted Fields Some of these fields occur in multiple messages. - Request ID - Track Alias - Filter Type - StartObject - Expires - ContentExists - Parameters - Subgroup ID - Object ID - Object Status - Extension Headers # Security Considerations moq-lite inherits the transport security of the underlying connection: QUIC and WebTransport provide confidentiality and integrity via TLS 1.3, the Qmux TCP and WebSocket bindings run over TLS, and the RTCDataChannel binding inherits DTLS security. How that connection is authenticated is out of scope (see [Connection](#connection)). The considerations below are specific to moq-lite. ## Bandwidth Probing The `Increase` Probe level (see [Probe Parameter](#probe-parameter)) lets a subscriber ask the publisher to pad the connection up to a target bitrate. A publisher MUST NOT treat the target as authorization to send beyond what congestion control allows: padding is bounded by the congestion window, so probing cannot be used to amplify traffic toward the subscriber or a spoofed address. Because all data flows on an established, congestion-controlled session to the connecting peer, moq-lite offers no off-path amplification vector. ## Authorization An AUTH_OK tells the opener what the acceptor will allow; it does not replace the acceptor's own enforcement, which MUST still refuse what the grant does not cover. A token presented on an Auth Stream is as sensitive as one in the request URI, and relies on the same transport confidentiality. ## Session Redirection GOAWAY carries an optional New Session URI that asks the peer to reconnect elsewhere. A malicious or compromised peer could use this to redirect a client to an attacker-controlled server. A recipient MUST validate the URI against local policy (scheme, authority, and port) before reconnecting, and MUST NOT reconnect if validation fails (see [GOAWAY](#goaway)). Migrated subscriptions carry no implicit trust from the prior session; the new session is authenticated independently. ## Routing Metadata and Privacy Hop IDs (see [ANNOUNCE_OK](#announce-ok) and [ANNOUNCE_START](#announce-start)) expose the relay path of a broadcast, which may reveal internal topology. A relay that does not wish to disclose its position MAY use the reserved value 0 ("unknown") instead of a stable identifier, at the cost of losing loop detection through itself (see [Routing](#routing)). The Hop ID announcement filter (see [Hop Parameter](#hop-parameter)) exists for loop avoidance, not access control: a subscriber cannot verify that a publisher honored it, so it MUST NOT be relied upon to hide a broadcast from a peer that declared its Hop ID. ## Resource Exhaustion A peer can open many streams (subscriptions, announcements, fetches), request large announce prefixes, or advertise broad routes. Implementations SHOULD bound the number of concurrent subscriptions, announce matches, and cached groups; an endpoint whose peer goes past its bound on subscriptions or announcements SHOULD close the session with TOO_MANY_REQUESTS, and SHOULD rely on QUIC flow control and stream limits to backpressure a misbehaving peer (see [ANNOUNCE_REQUEST](#announce-request)). Expiration (see [Expiration](#expiration)) bounds how long stale groups consume memory and flow control. A broad route invites a request for any covered path, each of which may start work: an advertiser SHOULD bound the work it starts, withdrawing its route before it runs out, and every refusal is terminal, so a flood of requests costs the mesh one round trip each rather than a search (see [Resolution](#resolution)). ## Datagram Injection Datagrams are routed to a subscription solely by Subscribe ID and carry no per-group authentication beyond that of the QUIC connection. On an unmodified QUIC/WebTransport connection this is sufficient, since datagrams are protected by the transport. A subscriber MUST silently drop any datagram with an unknown Subscribe ID and MUST deduplicate against groups received on streams (see [Datagrams](#datagrams)). ## Opaque Payloads The moq-lite layer treats Frame payloads as opaque and performs no validation of their contents. Confidentiality or integrity of the media itself (e.g. end-to-end encryption transparent to relays) is an application concern and out of scope for this draft. # IANA Considerations This document has no IANA actions. --- back # Acknowledgments {:numbered="false"} TODO acknowledge.