diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart
index 9b1653add91..e3532b4e379 100644
--- a/dart/moq_ffi/lib/src/moq.dart
+++ b/dart/moq_ffi/lib/src/moq.dart
@@ -22124,7 +22124,7 @@ void _checkApiChecksums() {
throw UniffiInternalError.panicked("UniFFI API checksum mismatch");
}
if (uniffi_moq_ffi_checksum_method_moqtrackconsumer_recv_datagram() !=
- 17412) {
+ 32344) {
throw UniffiInternalError.panicked("UniFFI API checksum mismatch");
}
if (uniffi_moq_ffi_checksum_method_moqtrackconsumer_recv_group() != 40781) {
@@ -22437,7 +22437,7 @@ void _checkApiChecksums() {
throw UniffiInternalError.panicked("UniFFI API checksum mismatch");
}
if (uniffi_moq_ffi_checksum_method_moqtrackproducer_append_datagram() !=
- 8934) {
+ 38326) {
throw UniffiInternalError.panicked("UniFFI API checksum mismatch");
}
if (uniffi_moq_ffi_checksum_method_moqtrackproducer_append_group() != 45225) {
@@ -22459,10 +22459,10 @@ void _checkApiChecksums() {
throw UniffiInternalError.panicked("UniFFI API checksum mismatch");
}
if (uniffi_moq_ffi_checksum_method_moqtrackproducer_insert_datagram() !=
- 29400) {
+ 30484) {
throw UniffiInternalError.panicked("UniFFI API checksum mismatch");
}
- if (uniffi_moq_ffi_checksum_method_moqtrackproducer_largest() != 3968) {
+ if (uniffi_moq_ffi_checksum_method_moqtrackproducer_largest() != 24089) {
throw UniffiInternalError.panicked("UniFFI API checksum mismatch");
}
if (uniffi_moq_ffi_checksum_method_moqtrackproducer_requests() != 60252) {
diff --git a/demo/web/src/stats.html b/demo/web/src/stats.html
index 22f06bab7ae..8e41dc6d6ab 100644
--- a/demo/web/src/stats.html
+++ b/demo/web/src/stats.html
@@ -60,7 +60,7 @@
MoQ Relay Stats
0
-
Datagrams
+
Datagrams
0
diff --git a/demo/web/src/stats.ts b/demo/web/src/stats.ts
index 95f981a762f..c5c161a3825 100644
--- a/demo/web/src/stats.ts
+++ b/demo/web/src/stats.ts
@@ -64,8 +64,8 @@ interface Snapshot {
bytes?: number;
frames?: number;
groups?: number;
- // Single-frame groups carried over unreliable QUIC datagrams: a subset of `groups`,
- // with their payload already in `frames` / `bytes`.
+ // Datagrams of datagram tracks: they belong to no group, so they count in neither
+ // `groups` nor `frames`, while their payload is in `bytes`.
datagrams?: number;
}
type BroadcastFrame = Record;
@@ -264,7 +264,7 @@ interface Sample {
ingress: number; // cumulative ingress bytes
announce: number; // cumulative announce-control bytes (both directions)
fetches: number; // cumulative group fetches requested
- datagrams: number; // cumulative single-frame groups sent as QUIC datagrams
+ datagrams: number; // cumulative datagrams of datagram tracks, apart from groups
broadcasters: number;
viewers: number;
tracks: number;
diff --git a/doc/.vitepress/drafts.test.ts b/doc/.vitepress/drafts.test.ts
index bae6a5d20d7..32f7fc3500f 100644
--- a/doc/.vitepress/drafts.test.ts
+++ b/doc/.vitepress/drafts.test.ts
@@ -128,20 +128,30 @@ describe("tables render as tables", () => {
const rendered = html(page as DraftPage);
- // The Bidirectional Streams table: one header, eight body rows, each
+ // The Bidirectional Streams table: one header, nine body rows, each
// separated by a kramdown rule in the source. Cells carry the source's
// alignment, so match around the style attribute.
const header = /
`));
}
- // One header row plus eight body rows, in a single table.
+ // One header row plus nine body rows, in a single table.
const start = rendered.lastIndexOf("
")[0];
- expect(rows.match(/
/g) ?? []).toHaveLength(9);
+ expect(rows.match(/
/g) ?? []).toHaveLength(10);
});
});
diff --git a/doc/concept/archives.md b/doc/concept/archives.md
index 5f1b9259c8d..c9743103a7b 100644
--- a/doc/concept/archives.md
+++ b/doc/concept/archives.md
@@ -45,9 +45,9 @@ cannot negotiate moq-lite 07.
Recording starts with content the subscription can still receive. A missing
group is given up after the publisher's max age capped by the producer's max
-delay, so a gap cannot stall later groups. Datagrams are uncached and can only
-be recorded from when the subscription takes effect. An archive cannot recover
-content already lost or evicted before it subscribed. The same max delay
+delay, so a gap cannot stall later groups. Only stream tracks are recorded: a
+datagram track is live-only, so an archive's playback never includes it. An
+archive cannot recover content already lost or evicted before it subscribed. The same max delay
(`producer::Config::max_delay`, 10 s by default and in `moq export archive`)
bounds the backlog: content further behind the live edge is skipped, even when
the publisher still caches it.
@@ -74,7 +74,7 @@ segment. Within one broadcast prefix:
Objects coalesce content by duration, using media time for timed tracks and
wall time for untimed ones. A flush deadline also stores idle tracks, splitting
long groups between frames when needed. Groups, frame positions, timestamps,
-payloads, and the distinction between groups and datagrams survive storage.
+and payloads survive storage.
With DVR pruning enabled, old superseded objects are deleted after the retention
window and grace; the newest object of each track stays. Pruning runs only while
the recorder runs: once a recording completes or its recorder stops, objects not
@@ -101,11 +101,10 @@ periodically listed for new tracks and objects. Once `.end` appears, refreshing
stops and tracks end as recorded; without it, including when a recorder stopped
early, the archive stays live. Empty or unrecognized prefixes are refused.
-Serving history is not paced playback from the beginning. Datagrams remain
-lossy datagrams, never fetchable groups. Time ranges currently resolve against
-the served track's cache, not the archive's stored keys, so they cannot reliably
-seek old stored content. There is no automatic switch from a live broadcast to
-its archive at the original path.
+Serving history is not paced playback from the beginning. Time ranges currently
+resolve against the served track's cache, not the archive's stored keys, so they
+cannot reliably seek old stored content. There is no automatic switch from a
+live broadcast to its archive at the original path.
The archived catalog is an ordinary track. [HLS](/bin/hls) can serve a recording
whose renditions include their `hls` record tracks, just as it serves a live
diff --git a/doc/concept/compression.md b/doc/concept/compression.md
index 697653f7a0b..306e9e29c4c 100644
--- a/doc/concept/compression.md
+++ b/doc/concept/compression.md
@@ -43,7 +43,7 @@ ends list `moq-lite-07-wip`. Sessions on the default versions, and relay links
among them, carry the inflated frames. Only Rust offers the moq-transport
extension, so a JavaScript or third-party moq-transport peer reads the
inflated copy. A received track's `compress` flag says which form arrived. A
-datagram compresses alone, with a fresh window.
+datagram track is never compressed.
## Cost on the relay
diff --git a/doc/concept/index.md b/doc/concept/index.md
index aaac09deba2..06cc676446e 100644
--- a/doc/concept/index.md
+++ b/doc/concept/index.md
@@ -20,10 +20,12 @@ what lets it forward media it can't parse.
A **broadcast** is a named, discoverable collection of **tracks** from one
publisher. A **track** is a live sequence of **groups**, and each group is a
sequence of **frames** delivered reliably and in order over its own QUIC
-stream (or, for a tiny single frame, as one unreliable datagram). Groups are independent: they can arrive out of order, be prioritized
+stream. Groups are independent: they can arrive out of order, be prioritized
against each other, and be dropped under congestion without corrupting the
rest. For video a group is a GoP starting at a keyframe; for audio it's a
second or so of samples; for data it's whatever unit can be skipped as a whole.
+A track can instead be a [datagram track](/concept/moq-lite#datagrams): small
+unreliable payloads with no groups.
Subscribers pull. Nothing is transmitted until someone subscribes, duplicate
subscriptions are merged at every relay on the way upstream, and a publisher
diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md
index 8e0703d5cdb..ff0385a16d0 100644
--- a/doc/concept/moq-lite.md
+++ b/doc/concept/moq-lite.md
@@ -27,7 +27,7 @@ carries [publisher instances](#publisher-instances).
| **Track** | A live sequence of groups, delivered out of order until closed. | Track |
| **Group** | A sequence of frames delivered reliably and in order, on its own QUIC stream. | Group |
| **Frame** | A sized chunk of bytes. | Object |
-| **Datagram** | One unreliable frame sent as a QUIC datagram instead of a group. | Datagram |
+| **Datagram** | One unreliable payload of a datagram track, sent as a QUIC datagram. | Datagram |
A broadcast is named by a path: non-empty segments joined by `/`, such as
`room/alice`. A path never starts or ends with `/` or doubles one, and a `\`
@@ -322,7 +322,7 @@ than the live and default starts. An untimed track refuses a time anchor. At the
edge, a time end
may let one group through before its first frame shows it starts past the end:
the first group that has yet to start is handed out without waiting for that
-frame, and the next one waits on it. A datagram is judged by its own timestamp.
+frame, and the next one waits on it.
Order only decides what goes first, never what is skipped, so catching up oldest
first needs a max delay that covers the backlog. When a relay serves subscribers of
@@ -430,22 +430,29 @@ keyframes do not use this mechanism.
## Datagrams
-Since moq-lite 05, a publisher can send a tiny single-frame group as a QUIC
-datagram: unreliable, unordered, under about 1200 bytes, and never
-retransmitted. It suits real-time audio and sensor data. There is no stream
-fallback, so a datagram that doesn't fit isn't delivered that way.
-
-A datagram shares its subscription's priority and group order with streams,
-so a low-priority datagram waits behind higher-priority groups instead of
-jumping the queue. From a native Rust publisher over QUIC or WebTransport,
-a full send buffer drops the lowest-priority subscription's
-oldest datagram.
-
-Nothing caches a datagram. A subscription gets the ones inside its group
-range, and a new one may get the few still in the publisher's short send
-buffer, but `FETCH` never returns one. A publisher that knows the sequence was
-a datagram refuses with `NOT_FETCHABLE` (moq-lite-07), otherwise with
-`NOT_FOUND`. Use a group for anything a late joiner needs.
+A track is either a stream track, made of groups, or a datagram track, decided
+when the publisher creates it. A datagram track is a series of QUIC datagrams:
+unreliable, unordered, under about 1200 bytes, and never retransmitted. Each
+has the datagram track's own sequence number and a timestamp, and no group. It
+suits real-time audio and sensor data. There is no stream fallback, so a
+datagram that doesn't fit isn't delivered.
+
+Datagram tracks have a namespace apart from stream tracks, so `audio` as
+datagrams and `audio` as streams are two independent tracks. Nothing on the
+wire says which kind a name is: the application knows from its catalog or by
+convention, so a broadcast's track listing never includes a datagram track. In
+Rust, `broadcast.create_datagrams(name, info)` publishes one and
+`broadcast.datagrams(name)` reads it.
+
+A datagram subscription is live from when it starts: it has no ranges, order,
+or max delay, only a priority, which a subscriber can change. A datagram still
+waits behind higher-priority traffic instead of jumping the queue. Nothing
+caches a datagram, and `FETCH` never returns one, so use a stream track for
+anything a late joiner needs.
+
+Datagram tracks need a transport with datagrams, so not WebSocket or TCP. The
+draft's [Datagrams](/draft/moq-lite#datagrams) section specifies them, and each
+library's API docs say which versions carry them.
## What moq-lite leaves out
diff --git a/doc/concept/standard.md b/doc/concept/standard.md
index ac1775442c1..805f6d2a5d9 100644
--- a/doc/concept/standard.md
+++ b/doc/concept/standard.md
@@ -28,7 +28,7 @@ subset drops. What a peer actually observes against this implementation:
- **Pull.** Subscribers ask. Single-track `PUBLISH` offers are declined, unless they answer our `SUBSCRIBE_TRACKS` (below); announce a namespace and serve the resulting subscriptions. Announcements go out unsolicited. A client asks only for the prefixes its readers watch, and a server or relay link for every prefix it may discover. We never hold two overlapping requests at a peer that did not declare solicit: a broader one replaces the narrower ones once they are closed. The solicit `SETUP` option makes us wait to be asked. Without it, a peer also gets each match as a `NAMESPACE` on its `SUBSCRIBE_NAMESPACE` stream, so it hears the namespace twice. We never send `PUBLISH`.
- **We list no tracks by `PUBLISH`.** Rust and JavaScript refuse each `SUBSCRIBE_TRACKS` with `NOT_SUPPORTED`, and the session stays up. Our own peers list tracks over moq-lite instead.
- **A Rust TRACKS listing sends `SUBSCRIBE_TRACKS` upstream, only on demand.** Listing a broadcast from a moq-transport peer sends `SUBSCRIBE_TRACKS` with `FORWARD=0` while someone lists it, never on connect, announce, or subscribe, so a peer that refuses, resets, or ignores it affects only that listing. Each listed track's paused `PUBLISH` is its upstream copy, which a reader resumes rather than subscribing again. Only a `PUBLISH_DONE` with `TRACK_ENDED` removes a listed track, and it lists as finished; any other end fails the listing, and readers list again. Our own moq-transport publishers refuse it, so a Rust listing of one fails.
-- **History is served one group at a time.** Our moq-transport publishers answer a `FETCH` only within one group, a draft-18 joining `FETCH` for the subscription's current group head, and a draft 20+ fill for the largest group's head. Whole-group fetches wait for an open group to finish. Both implementations accept and ignore `FILL_TIMEOUT`, including zero. A JavaScript subscription joins at the current group: on draft 18 it fetches the group's head with a Relative Joining `FETCH`, and from draft 20 asks for it with a fill. Datagrams are never fetchable. Subscribers still request ranges upstream, one per run of missing groups; a third-party publisher can answer those ranges and mark their gaps. A Rust reader that only fetches learns the track from `TRACK_STATUS`, so a finished track stays fetchable; a refused `TRACK_STATUS` fails that reader's request, whatever the code, rather than falling back to a `SUBSCRIBE`.
+- **History is served one group at a time.** Our moq-transport publishers answer a `FETCH` only within one group, a draft-18 joining `FETCH` for the subscription's current group head, and a draft 20+ fill for the largest group's head. Whole-group fetches wait for an open group to finish. Both implementations accept and ignore `FILL_TIMEOUT`, including zero. A JavaScript subscription joins at the current group: on draft 18 it fetches the group's head with a Relative Joining `FETCH`, and from draft 20 asks for it with a fill. Subscribers still request ranges upstream, one per run of missing groups; a third-party publisher can answer those ranges and mark their gaps. A Rust reader that only fetches learns the track from `TRACK_STATUS`, so a finished track stays fetchable; a refused `TRACK_STATUS` fails that reader's request, whatever the code, rather than falling back to a `SUBSCRIBE`.
- **A requester's FIN.** On draft 18 a requester finishing its stream cancels its request, except after `TRACK_STATUS`: nothing can follow it, so the FIN only ends the requester's input and the request is still answered. From draft 20 a FIN never cancels; a reset or `STOP_SENDING` does.
- **Compression.** Rust peers exchange compressed tracks when both offer the [Flate extension](/draft/moq-flate). Other peers receive one shared inflated copy. A publisher that seals the compression declaration makes that fallback unavailable: an incompatible peer is refused, preserving the sealed metadata. JavaScript does not offer the IETF extension.
- **Timing.** A track whose `SUBSCRIBE_OK` or `PUBLISH` declares no `TIMESCALE` is untimed. A `SUBSCRIBE` or `FETCH` with `INCLUDE_PROPERTIES=0` gets no `TIMESCALE` in its `SUBSCRIBE_OK` or `FETCH_OK`, so its objects go out unstamped. A Rust reader that never subscribed takes the units from `TRACK_STATUS`, and still asks for the properties whenever it subscribes to or fetches a timed track.
@@ -36,7 +36,7 @@ subset drops. What a peer actually observes against this implementation:
- **One `SETUP` credential**, forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A second `SETUP` token closes the session. We advertise no token cache, so an alias reference in `SETUP` closes it with `PROTOCOL_VIOLATION`, one on a request with `UNKNOWN_AUTH_TOKEN_ALIAS`, and a registration on a request with `AUTH_TOKEN_CACHE_OVERFLOW`. Where the Rust side authorizes the peer or verifies tokens, each distinct request token is verified once while any open request on that session names it, and its grant authorizes only requests naming it, even where that authorization already covers it; it never widens the session. It is refused with `NOT_SUPPORTED` when nothing verifies it, when it is empty, or when its Token Type is anything but 0 (out of band). A side that does neither, JavaScript included, ignores the token. Rust refuses a request carrying more than one distinct token with `NOT_SUPPORTED`, since it verifies one; JavaScript, which verifies none, ignores them all. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. A token-authorized `PUBLISH_NAMESPACE` needs a grant covering everything beneath the namespace, and the tracks we subscribe under it rely on that announce instead of our limit, ending with it. An update on a token-authorized request moves it to the token it carries; the old token ends with its last naming request. A request is admitted by the session or by a token for good, so an update naming a token on one the session admitted is refused with `NOT_SUPPORTED`. A token-less `PUBLISH_NAMESPACE` our limit does not cover is refused `UNAUTHORIZED`, and one the limit stops covering ends; the publisher re-announces once it widens. Expiry or revocation ends every request naming that token. The relay reports the lease end to its auth server, and a later request naming the token verifies it again. A refused renewal ends only that request. Rust and JavaScript send the token a route names (`Route::auth`) on its `PUBLISH_NAMESPACE`, and swapping it renews the request with `REQUEST_UPDATE`; the inline `NAMESPACE` that answers a solicited `SUBSCRIBE_NAMESPACE` carries no token. A token added to a reconnecting connection's auth handle rides every session it makes, so the announcement keeps it across a reconnect; one added to a single session's handle ends with that session. Rust honors a token on any supported request; discovery reveals only paths it grants, and a joining `FETCH` keeps following its subscription. A request repeating a token is refused with `MALFORMED_AUTH_TOKEN`. The negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band.
- **Pause delivery.** `FORWARD=0` on a `SUBSCRIBE` or its update drops the subscription, closing its data streams and holding nothing upstream, while the request and its slot stay. `FORWARD=1` subscribes again as a new `SUBSCRIBE` with the same filter would, so nothing carries across the pause: its `REQUEST_OK` names the live edge it joined at. A paused update may change only priority and max age.
- **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `SUBSCRIBE_TRACKS`, multi-group `FETCH`, and joining requests reaching before the joined group get `NOT_SUPPORTED`. Range Filters get `INVALID_FILTER` from draft 20 because the advertised filter limit is zero. From draft 20, namespaces whose first tuple field is exactly `.` or `.session` get `DOES_NOT_EXIST` before application lookup; other dot prefixes still reach the application. Subscription updates accept priority, negotiated subscriber max age, and delivery timeouts. Rust also carries range changes, keeps bounded subscriptions updatable, renews request tokens, and judges `NEW_GROUP_REQUEST` as a join (see dynamic groups below). From draft 20, a wider or historical fill fails on its separate fetch stream while its subscription stays open; a largest-group fill hands its head to the live subgroup tail. Other supported-but-unimplemented preferences end that subscription; a `TRACK_PROPERTY_FILTER` there closes the session with `PROTOCOL_VIOLATION`, since it belongs only to `SUBSCRIBE_TRACKS`. A namespace update may change its prefix; suffixes after the acknowledgement are relative to that new prefix.
-- **Objects and datagrams.** Only subgroup 0 is accepted: a stream for any other subgroup, whether its header gives the ID or takes it from the first Object ID, is stopped and the session stays up. Rust relays preserve Object IDs, gaps, object properties, FIRST\_OBJECT, and END\_OF\_GROUP status objects between moq-transport peers for subgroup 0, including across a moq-lite-07 hop between two relays that negotiate the [ietf](/draft/moq-ietf) extension. Datagrams retain their IDs, properties, priority, and status. Plain Rust readers and moq-lite peers without the extension see consecutive frames and only normal datagram payloads at Object ID 0; other datagram objects and statuses are dropped on those surfaces. JavaScript supports only normal datagrams at Object ID 0. A malformed datagram closes the session; a relayed one the downstream draft cannot carry is dropped. A relayed Timestamp leaves in the units the downstream response declares, and is dropped where it declares none, as for a plain frame; an immutable one that would change or vanish is refused. On a timed track the frame holds each object's time, so a mutable Timestamp that only repeats the immutable one is not restated; a status object has no frame, so it keeps its Timestamp among its properties. Only property 0x10 is read as a Timestamp. Property 0x06 stays opaque and keeps its value regardless of timestamp units; it is dropped between draft 18 and later drafts, or refused when immutable, because draft 20 assigns it to SUBGROUP\_DELIVERY\_TIMEOUT. A status object carrying properties is refused. A `FETCH` has no status objects, so its response ends at the last frame. A group cannot resume across route changes by frame ordinal once Object IDs skip ahead of the ordinals on either route; recovery refuses it instead of splicing different objects.
+- **Objects and datagrams.** Only subgroup 0 is accepted: a stream for any other subgroup, whether its header gives the ID or takes it from the first Object ID, is stopped and the session stays up. Rust relays preserve Object IDs, gaps, object properties, FIRST\_OBJECT, and END\_OF\_GROUP status objects between moq-transport peers for subgroup 0, including across a moq-lite-07 hop between two relays that negotiate the [ietf](/draft/moq-ietf) extension. Plain Rust readers and moq-lite peers without the extension see consecutive frames. Rust drops every OBJECT\_DATAGRAM, since a moq-lite track takes either streams or datagrams and Rust does not yet map a [datagram track](/concept/moq-lite#datagrams) onto moq-transport; JavaScript supports only normal datagrams at Object ID 0. A malformed datagram closes the session. A relayed Timestamp leaves in the units the downstream response declares, and is dropped where it declares none, as for a plain frame; an immutable one that would change or vanish is refused. On a timed track the frame holds each object's time, so a mutable Timestamp that only repeats the immutable one is not restated; a status object has no frame, so it keeps its Timestamp among its properties. Only property 0x10 is read as a Timestamp. Property 0x06 stays opaque and keeps its value regardless of timestamp units; it is dropped between draft 18 and later drafts, or refused when immutable, because draft 20 assigns it to SUBGROUP\_DELIVERY\_TIMEOUT. A status object carrying properties is refused. A `FETCH` has no status objects, so its response ends at the last frame. A group cannot resume across route changes by frame ordinal once Object IDs skip ahead of the ordinals on either route; recovery refuses it instead of splicing different objects.
- **Dynamic groups.** A track advertises `DYNAMIC_GROUPS` when its publisher sets a refresh ratio, and a track received with `DYNAMIC_GROUPS` gets a ratio of 0. A `NEW_GROUP_REQUEST` counts as one more subscriber joining, which a `SUBSCRIBE` already is: it asks for a new group only when the newest group's bytes after its first frame exceed the ratio times the first frame. Joins coalesce until the asked-for group arrives. A Rust relay sends `NEW_GROUP_REQUEST` upstream, on the `SUBSCRIBE` or in a `REQUEST_UPDATE`, once a join wants a group past the newest it holds; a relay holding nothing yet asks for none. On a track without a ratio, an update carrying one ends the subscription with `UPDATE_FAILED`. A moq-lite-07 hop (`moq-lite-07-wip`, opt-in) carries the same demand natively: the ratio in `TRACK_INFO` and the asked-for group in `SUBSCRIBE_UPDATE`. Earlier moq-lite versions carry neither, so a track received over one never refreshes.
- **Priority.** Higher is served first. This stack's priorities run from -128 to 127 with 0 unset, and go out on the IETF wire, which ranks lower first, as `127 - priority`: an unset priority is IETF 127. An omitted subscriber or publisher priority reads as the drafts' default of IETF 128, one step below unset, so a relay forwards it unchanged. A relay's upstream subscription carries the highest priority its subscribers ask for and updates it as they come and go; its group order stays as the `SUBSCRIBE` asked, since no draft lets an update change it.
- **Size.** An object extension block larger than 64 KiB ends that subgroup stream. The session stays up. This cap is ours, not the draft's.
diff --git a/doc/concept/stats.md b/doc/concept/stats.md
index b2fc040f48f..3c54f1d5650 100644
--- a/doc/concept/stats.md
+++ b/doc/concept/stats.md
@@ -370,7 +370,7 @@ limit: the track then ends with `FRAME_TOO_LARGE` instead of going stale.
"fetches": 0,
"bytes": 1048576, "frames": 900, "groups": 30, "datagrams": 0,
"inflated": 0,
- "stale": { "bytes": 0, "frames": 0, "groups": 0, "datagrams": 0 },
+ "stale": { "bytes": 0, "frames": 0, "groups": 0 },
"announced": 1, "announced_closed": 0,
"broadcasts": 3, "broadcasts_closed": 1,
"subscriptions": 6, "subscriptions_closed": 2
@@ -385,10 +385,10 @@ limit: the track then ends with `FRAME_TOO_LARGE` instead of going stale.
| `broadcasts_started` / `broadcasts_ended` | On egress (`publisher`), a session's first subscription to the broadcast and its last one closing. Started minus ended is the viewer count. Ingress (`subscriber`) leaves both at zero: it does not count viewers. |
| `subscriptions_started` / `subscriptions_ended` | Track subscriptions opened and closed. |
| `fetches` | One-shot group fetches requested, including ones that found nothing. Their payload counts in `bytes`, `frames`, and `groups`. |
-| `bytes` / `frames` / `groups` | Payload delivered. |
-| `datagrams` | Groups delivered as an unreliable datagram. A subset of `groups`. |
-| `inflated` | Bytes inflated from compressed groups for plain readers, counted once per group however many share the copy, plus received compressed datagrams, which inflate on arrival. Not part of `bytes`, which counts payload as stored or sent. |
-| `stale` | Payload skipped because it aged past a subscriber's latency budget, with the same four fields. Disjoint from the top-level payload counters. |
+| `bytes` / `frames` / `groups` | Payload delivered. `bytes` includes datagram payloads. |
+| `datagrams` | Datagrams of datagram tracks delivered. A datagram belongs to no group, so it counts in neither `groups` nor `frames`. |
+| `inflated` | Bytes inflated from compressed groups for plain readers, counted once per group however many share the copy. Not part of `bytes`, which counts payload as stored or sent. |
+| `stale` | Payload skipped because it aged past a subscriber's latency budget, with `bytes`, `frames`, and `groups`. Disjoint from the top-level payload counters. |
The last six fields are legacy spellings of the `*_started` and `*_ended`
counters, still written so an older consumer reads a newer relay. A reader
diff --git a/drafts/draft-lcurley-moq-e2ee.md b/drafts/draft-lcurley-moq-e2ee.md
index aacf9cf94d5..94d5edf991e 100644
--- a/drafts/draft-lcurley-moq-e2ee.md
+++ b/drafts/draft-lcurley-moq-e2ee.md
@@ -74,7 +74,7 @@ It differs where the models diverge:
- The nonce is the identity counter itself, not a salt XOR. The derived key is already unique per credential, epoch, physical name, and domain.
- The payload is ciphertext concatenated with the 16-byte tag, with no inner length prefix, encrypted properties, or padding.
- moq-lite frame indices are implied by position in the group ({{moql}} Section "Frame"), not an on-wire object ID. This profile still uses that index as the 32-bit nonce half, because it is the only canonical end-to-end frame identity on both moq-lite and MoQ Transport.
-- moq-lite datagrams share the group sequence namespace of the same track ({{moql}} Section "Datagrams"). A grouped frame 0 and a datagram with that sequence would collide under one AES-GCM key, so datagrams use a separate key domain with frame ID zero.
+- A datagram belongs to a datagram track, which numbers its datagrams with its own sequence and may share its name with a stream track ({{moql}} Section "Datagrams"). A grouped frame 0 and a datagram at that sequence would collide under one AES-GCM key, so datagrams use a separate key domain with frame ID zero.
SFrame {{sframe}} is the cryptographic ancestor of {{secure}}.
This profile is not SFrame: it has no SFrame header, no CTR field on the wire, and no per-object KID.
@@ -224,6 +224,7 @@ key = HKDF-Expand(prk, key_info, 16)
~~~
`domain` is a single byte: `0x00` for grouped frames, `0x01` for datagrams.
+A stream track and a datagram track of one semantic name share its physical name and differ only in `domain`.
A given `(generation, semantic_name, domain)` tuple has one key and one physical name.
A subscriber derives both from the semantic name it wants; a publisher derives them from the semantic name it creates or recovers from a request.
@@ -240,8 +241,8 @@ Publishers supporting both transports MUST assign contiguous Object IDs from zer
An Object ID above `2^32-1` MUST be refused as `identity` before encryption or decryption.
## Datagrams
-A datagram uses `domain = 0x01`, its 64-bit sequence as `group`, and `frame = 0`.
-On MoQ Transport, a datagram is an Object at ID 0 in an OBJECT_DATAGRAM whose Group ID is the sequence.
+A datagram uses `domain = 0x01`, its datagram track's sequence as `group`, and `frame = 0`.
+That sequence is the Sequence of a moq-lite-07 datagram, the Group Sequence of a moq-lite-05 or -06 Group Datagram, and the Group ID of a MoQ Transport OBJECT_DATAGRAM at Object ID 0.
Shared vectors cover grouped tracks on both transports and datagrams on moq-lite only.
## Nonce
@@ -264,7 +265,7 @@ AES-128-GCM encrypts the application bytes with `key`, `nonce`, and empty AAD.
The bytes placed in the MoQ frame or datagram payload are ciphertext concatenated with the 16-byte tag, in the {{RFC5116}} convention.
There is no inner header.
-Within a generation a publisher MUST allocate group and datagram sequences monotonically and frame indices in write order, so an identity is encrypted at most once.
+Within a generation a publisher MUST allocate each semantic name's group sequences, and separately its datagram sequences, monotonically, and frame indices in write order, so an identity is encrypted at most once.
Encrypting at an identity the same instance already used is `reuse` and MUST be refused.
Relays and caches forward ciphertext unchanged, so replay from a cache never re-encrypts.
@@ -273,8 +274,8 @@ Protected payload length is plaintext length plus `Nt`.
A publisher MUST refuse plaintext that would make the protected payload exceed the transport payload limit for that object (`oversize`), before it touches the network.
The interoperable grouped-frame payload cap matching current moq-net implementations is 32 MiB, so grouped plaintext MUST be at most `32 MiB - 16` bytes.
-moq-lite datagram bodies MUST remain at most 1200 bytes including Type, Subscribe ID, Group Sequence, and Timestamp ({{moql}} Section "Datagrams").
-The Type is one byte and each of those three varints at most 9 bytes, so datagram plaintext MUST be at most `1200 - 28 - 16 = 1156` bytes; a publisher cannot observe the Subscribe ID each hop will encode and MUST NOT budget for a smaller header.
+moq-lite datagram bodies MUST remain at most 1200 bytes including Type, Subscribe ID, Sequence, and Timestamp ({{moql}} Section "Datagrams").
+The moq-lite-07 Type is one byte and each of those three varints at most 9 bytes, the widest header of any moq-lite version, so datagram plaintext MUST be at most `1200 - 28 - 16 = 1156` bytes; a publisher cannot observe the Subscribe ID each hop will encode and MUST NOT budget for a smaller header.
## Catalogs
A catalog is a track like any other: published under the physical name derived from its semantic name, with each snapshot or delta protected at its own group and frame identity.
@@ -392,6 +393,7 @@ This document requests no registrations.
- Forbid applications from relying on group completeness, which is unauthenticated.
- Derive physical track names by AES-128-GCM encryption of the padded semantic name under an HMAC-SHA-256 nonce, so a publisher recovers any requested name, and bind AEAD keys to the semantic name.
- Drop a datagram that fails to open with its own `identity`, `oversize`, or `authentication` code; an `exhausted` open still fails the read.
+- A datagram belongs to a datagram track with its own sequence, independent of a stream track of the same name; the datagram domain keeps the two apart.
## draft-lcurley-moq-e2ee-00
{:numbered="false"}
diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md
index e5dd9fe4c81..027ac9ac54c 100644
--- a/drafts/draft-lcurley-moq-lite.md
+++ b/drafts/draft-lcurley-moq-lite.md
@@ -164,6 +164,10 @@ The subscriber and publisher both indicate their delivery preference:
The combination of these preferences enables the most important content to arrive during network degradation while still respecting encoding dependencies.
+A Track is instead a datagram Track when its publisher creates it as one: a series of unreliable QUIC datagrams with no Groups (see [Datagrams](#datagrams)).
+Which kind a Track is never changes, and the wire does not say; the application knows it from its catalog or by convention.
+Datagram Tracks have a namespace apart from other Tracks, so a datagram Track and a Track of Groups may share a name and are independent.
+
## Group
A Group is an ordered stream of Frames within a Track.
@@ -174,8 +178,6 @@ 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.
@@ -187,7 +189,7 @@ This index is not transmitted per frame; it is implied by position within the Gr
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 Track without a `Timescale` is untimed: its frames 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.
@@ -238,7 +240,7 @@ All five carry the same control and data streams defined elsewhere in this docum
| 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.
+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, and a subscriber SHOULD NOT open a [Datagrams Stream](#datagrams-stream) there.
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].
@@ -349,8 +351,6 @@ Sent when resetting a stream (RESET_STREAM), or when refusing to receive one (ST
| ------- | ------------- | ----------- |
| 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. |
| ------- | ------------- | ----------- |
@@ -385,6 +385,8 @@ There's a 1-byte STREAM_TYPE at the beginning of each stream.
| ------- | ------------- | ----------- |
| 0x8 | Tracks | Subscriber |
| ------- | ------------- | ----------- |
+| 0x9 | Datagrams | Subscriber |
+| ------- | ------------- | ----------- |
### Announce
A subscriber can open an Announce Stream to discover routes matching a prefix.
@@ -557,7 +559,7 @@ The publisher replies with listing events, then FINs the stream once the Broadca
- [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 Broadcast lists the Tracks its publisher produces, except datagram Tracks, which are never listed: TRACK_INFO does not say which kind a Track is, so a subscriber could not tell whether to SUBSCRIBE or send DATAGRAMS.
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.
@@ -577,6 +579,19 @@ 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.
+### Datagrams {#datagrams-stream}
+A subscriber opens a Datagrams Stream (0x9) to subscribe to a datagram Track (see [Datagrams](#datagrams)).
+
+The subscriber sends a [DATAGRAMS](#datagrams-message) message naming the broadcast path, Instance, and Track name, with its priority and a Subscribe ID.
+The publisher replies with [DATAGRAMS_INFO](#datagrams-info) once it serves the Track, then sends the Track's datagrams, each carrying that Subscribe ID.
+The subscription is live: it has no start, end, order, or age, and receives only datagrams the Track produces after the publisher answers.
+The subscriber MAY send any number of [DATAGRAMS_UPDATE](#datagrams-update) messages, each replacing its priority.
+A datagram still waits behind higher-priority traffic.
+
+A publisher with no datagram Track of that name answers as for any missing Track, resetting the stream with NOT_FOUND, and a rejection is a reset as for [SUBSCRIBE](#subscribe).
+The publisher FINs the stream once the Track ends, and the subscriber then FINs its own side; a reset ends the Track with that error.
+A subscriber MAY cancel by closing its side (FIN); the publisher then closes its side.
+
### 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).
@@ -642,7 +657,7 @@ The subscriber's `Group Order` determines which Group within the Track is transm
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.
+A [datagram](#datagrams) is ranked by its Datagrams Stream's priority like a Group Stream, the newest first among them.
### Priority
The `Subscriber Priority` is scoped to the connection and MAY change over the life of the subscription via SUBSCRIBE_UPDATE.
@@ -769,20 +784,15 @@ 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.
+They carry the datagrams of a datagram Track: each has the Track's own Sequence and a Timestamp, and belongs to no Group.
+A datagram is never cached, stored, retransmitted, or returned by FETCH, and a subscriber receives one only through a [Datagrams Stream](#datagrams-stream).
+A publisher SHOULD drop a datagram rather than block when the transport has no room for it.
-Every datagram begins with a Type (i): 0x0 for a Group Datagram, or 0x1 for a Padding Datagram.
+Every datagram begins with a Type (i): 0x0 for a Track Datagram, or 0x1 for a Padding Datagram.
A receiver MUST discard a datagram with an unknown type.
The total datagram body, including Type, MUST NOT exceed 1200 bytes; a sender MUST NOT send a larger one and a receiver MUST silently drop it.
-### Group Datagram
-
-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.
+### Track Datagram
Each datagram body has the following encoding (note: there is no message length prefix; the QUIC datagram boundary delimits the payload):
@@ -790,31 +800,27 @@ Each datagram body has the following encoding (note: there is no message length
DATAGRAM Body {
Type (i) = 0x0
Subscribe ID (i)
- Group Sequence (i)
+ 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.
+The Subscribe ID of an active Datagrams Stream on the same session.
+A subscriber MUST silently drop a datagram with an unknown Subscribe ID, or one that arrives before its stream's DATAGRAMS_INFO, since it cannot tell whether a Timestamp follows.
-**Group Sequence**:
-The absolute sequence number of the group carried by this datagram.
+**Sequence**:
+The datagram's sequence number within its datagram Track, which the publisher assigns in the order it produces them.
+It is the Track's own, not a Group Sequence.
**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.
+The absolute presentation timestamp of the datagram, in the Track's `Timescale`.
+Present only when DATAGRAMS_INFO's `Timescale` is not 0; on a Track with a `Timescale` of 0, the Sequence stands in for the datagram's time.
**Payload**:
-The frame payload, extending to the end of the datagram.
-A group whose frame does not fit is simply not eligible for datagram delivery.
+The payload, extending to the end of the datagram.
+A payload that does not fit is not sent.
### Padding Datagram
@@ -1416,7 +1422,7 @@ 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.
+The number of timestamp units per second for frame timestamps on this Track, or `0` for an untimed Track, whose FRAME messages carry no timestamp.
Common values include `1000` (milliseconds), `1000000` (microseconds), `48000` (audio sample rate), and `90000` (RTP video clock).
**Refresh**:
@@ -1429,7 +1435,7 @@ 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`.
+When Compression is not none, FRAME messages carry an `Inflated Size`.
## TRACKS {#tracks}
TRACKS is sent by a subscriber to list a Broadcast's Tracks.
@@ -1496,6 +1502,50 @@ TRACKS_FINISH Message {
**Track Name**:
The name a TRACKS_ADD on the stream listed.
+## DATAGRAMS {#datagrams-message}
+DATAGRAMS is sent by a subscriber to subscribe to a datagram Track.
+It is the first message on a [Datagrams Stream](#datagrams-stream) (0x9).
+
+~~~
+DATAGRAMS Message {
+ Message Length (i)
+ Subscribe ID (i)
+ Broadcast Path (s)
+ Instance (b)
+ Track Name (s)
+ Subscriber Priority (8)
+}
+~~~
+
+**Subscribe ID**:
+A unique identifier chosen by the subscriber, from the same space as SUBSCRIBE's: it MUST NOT reuse the Subscribe ID of any SUBSCRIBE or DATAGRAMS within the same session.
+
+See [SUBSCRIBE](#subscribe) and [TRACK](#track) for the other fields.
+
+## DATAGRAMS_UPDATE {#datagrams-update}
+A subscriber changes the priority of a Datagrams Stream with DATAGRAMS_UPDATE, each replacing the last.
+
+~~~
+DATAGRAMS_UPDATE Message {
+ Message Length (i)
+ Subscriber Priority (8)
+}
+~~~
+
+## DATAGRAMS_INFO {#datagrams-info}
+DATAGRAMS_INFO is the publisher's answer on a Datagrams Stream: the datagram Track's properties, which a subscriber needs to read its datagrams.
+
+~~~
+DATAGRAMS_INFO Message {
+ Message Length (i)
+ Publisher Priority (8)
+ Timescale (i)
+}
+~~~
+
+Both fields are fixed for the lifetime of the Track, as [TRACK_INFO](#track-info)'s are, and mean what they do there.
+A datagram Track has no compression: each payload is sent as written.
+
## SUBSCRIBE_OK {#subscribe-ok}
A SUBSCRIBE_OK message confirms a subscription and resolves where each Range starts.
It is the first message the publisher sends on the Subscribe Stream, once those starts are known, except that SUBSCRIBE_FINISH MAY precede it.
@@ -1574,11 +1624,10 @@ SUBSCRIBE_DROP Message {
**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.
+For every sequence in the subscription's range, the publisher MUST send a Group Stream 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_FINISH.
-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.
@@ -1885,8 +1934,8 @@ The `Message Length` describes the payload size on the wire.
- Named 128 the midpoint priority for an unset Subscriber or Publisher Priority.
- Made TRACK_INFO Publisher Max Age optional, encoded as milliseconds plus one with zero meaning no limit.
- 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.
-- An untimed Track declares a `Timescale` of 0 in TRACK_INFO, and its FRAME messages and datagrams omit the timestamp.
+- Added `Compression` to TRACK_INFO: none (0x0) or DEFLATE (0x1), whose Groups are DEFLATE streams per draft-lcurley-moq-flate. FRAME carries an `Inflated Size` when it is not none.
+- An untimed Track declares a `Timescale` of 0 in TRACK_INFO, and its FRAME messages omit the timestamp.
- 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 every Track's timestamps on as received.
- Bounded every timestamp below 2^61. A larger one is a malformed Track.
@@ -1901,7 +1950,7 @@ The `Message Length` describes the payload size on the wire.
- 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.
- Renamed SUBSCRIBE_END to SUBSCRIBE_FINISH, matching TRACKS_FINISH: it ends the Track, not the subscription.
- Added `Error Code` to SUBSCRIBE_FINISH: a track can end with an error while keeping the groups below its end.
-- Restored SUBSCRIBE_DROP (type 0x2), replacing Stream Count on SUBSCRIBE_FINISH. 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.
+- Restored SUBSCRIBE_DROP (type 0x2), replacing Stream Count on SUBSCRIBE_FINISH. Every sequence is accounted for by a Group Stream 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.
- Refuse a SUBSCRIBE_DROP whose Group Start is greater than Group End plus one on every version that carries it.
- SUBSCRIBE_FINISH declares the final boundary while lower groups can still be in flight, and no longer closes the Subscribe Stream: the publisher keeps reading SUBSCRIBE_UPDATE, so an update can reach an ended Track's history.
@@ -1911,12 +1960,11 @@ The `Message Length` describes the payload size on the wire.
### Delivery {#changelog-07-delivery}
-- Added a leading Type to datagrams: Group (0x0) or Padding (0x1), which either endpoint discards without changing session or subscription state.
+- Replaced group datagrams with datagram Tracks, a namespace apart from Tracks of Groups. A subscriber subscribes to one on the Datagrams Stream (0x9) with DATAGRAMS and DATAGRAMS_UPDATE, which carry only a priority, and the publisher answers with DATAGRAMS_INFO. A datagram carries the Track's own Sequence and a Timestamp, with no Group, Inflated Size, or Range. Datagrams no longer count toward SUBSCRIBE_DROP, and a subscriber no longer deduplicates them against Group Streams.
+- Added a leading Type to datagrams: Track (0x0) or Padding (0x1), which either endpoint discards without changing session or subscription state.
- 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.
-- A subscription's range bounds datagrams like groups, and FETCH never returns a datagram.
-- 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.
+- A publisher 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.
- 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`.
## moq-lite-06
@@ -2097,7 +2145,7 @@ Hop IDs (see [ANNOUNCE_OK](#announce-ok) and [ANNOUNCE_START](#announce-start))
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)).
+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 (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.
diff --git a/go/wrapper/README.md b/go/wrapper/README.md
index 4ef501f19bc..a36c8f5725a 100644
--- a/go/wrapper/README.md
+++ b/go/wrapper/README.md
@@ -163,7 +163,7 @@ sends one `Frame` and returns its sequence number (`InsertDatagram` takes the ca
own sequence instead), while `TrackConsumer.RecvDatagram`
and `TrackConsumer.Datagrams` receive them in arrival order. Payloads are capped at
1200 bytes. Datagram delivery requires a datagram-capable transport and lite-05 or
-newer moq-lite, or moq-transport; pre-lite-05, WebSocket, and TCP paths do not
+newer moq-lite; moq-transport, pre-lite-05, WebSocket, and TCP paths do not
deliver them, and there is no stream fallback.
## Versioning
diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go
index 73942d39b48..4ff1c20af46 100644
--- a/go/wrapper/moq_test.go
+++ b/go/wrapper/moq_test.go
@@ -551,10 +551,6 @@ func TestInsertDatagramAndLargest(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if largest, err := track.Largest(); err != nil || largest != nil {
- t.Fatalf("empty track: largest %v, err %v", largest, err)
- }
-
if err := track.InsertDatagram(7, moq.Frame{Payload: []byte("seven"), Timestamp: ts(0)}); err != nil {
t.Fatal(err)
}
@@ -565,16 +561,13 @@ func TestInsertDatagramAndLargest(t *testing.T) {
if datagram == nil || datagram.Sequence != 7 || string(datagram.Payload) != "seven" {
t.Fatalf("got datagram %+v", datagram)
}
- largest, err := track.Largest()
- if err != nil {
- t.Fatal(err)
- }
- if largest == nil || *largest != (moq.Position{Group: 7, Frame: 0}) {
- t.Fatalf("largest %+v", largest)
- }
if next, err := track.AppendDatagram(moq.Frame{Payload: []byte("next"), Timestamp: ts(0)}); err != nil || next != 8 {
t.Fatalf("appended at %d, err %v", next, err)
}
+ // Datagrams are numbered apart from groups, so they never move Largest.
+ if largest, err := track.Largest(); err != nil || largest != nil {
+ t.Fatalf("largest %v, err %v", largest, err)
+ }
}
func TestBroadcastConsumerClosed(t *testing.T) {
diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go
index 4ad723616a2..4a7d2fca318 100644
--- a/go/wrapper/publish.go
+++ b/go/wrapper/publish.go
@@ -222,7 +222,7 @@ func (t *TrackProducer) InsertDatagram(sequence uint64, frame Frame) error {
return t.inner.InsertDatagram(sequence, frame)
}
-// Largest reports the newest group or datagram written, at its newest frame, or nil
+// Largest reports the newest group written, at its newest frame, or nil
// before any.
func (t *TrackProducer) Largest() (*Position, error) {
return t.inner.Largest()
diff --git a/kt/README.md b/kt/README.md
index 1fb202ee284..fe9ecd6fe69 100644
--- a/kt/README.md
+++ b/kt/README.md
@@ -62,7 +62,7 @@ The `dev.moq` package is intentionally thin: Kotlin has extension functions, so
- **Fetched media**: `dev.moq.media.GroupConsumer.fetch(...).frames()` streams the decoded frames of one retained group, then completes. `dev.moq.media.GroupConsumer(group, container)` decodes a raw group instead, such as one from `recvGroup()` for arrival-order reads.
- **Timestamp conversions** (`Timestamp.kt`): media timestamps are exact `Timestamp(value, scale)` records. `Duration.toTimestamp()` builds one exactly at nanosecond scale, and `Timestamp.toDuration()` reads one back as a `kotlin.time.Duration`, rounded down to a whole nanosecond. Durations such as `stats.rtt` are already `java.time.Duration`.
- **`logLevel(...)`**: configures native Rust tracing without importing the raw bindings package.
-- **Raw datagrams**: `TrackProducer.appendDatagram(Frame(payload, timestamp))` sends one best-effort frame and returns its sequence, and `insertDatagram(sequence, frame)` takes the caller's own; `TrackConsumer.recvDatagram()` and `datagrams()` receive them. Payloads are capped at 1200 bytes, require a datagram-capable transport plus lite-05 or newer moq-lite or moq-transport, and have no stream fallback.
+- **Raw datagrams**: `TrackProducer.appendDatagram(Frame(payload, timestamp))` sends one best-effort frame and returns its sequence, and `insertDatagram(sequence, frame)` takes the caller's own; `TrackConsumer.recvDatagram()` and `datagrams()` receive them. Payloads are capped at 1200 bytes, require a datagram-capable transport plus lite-05 or newer moq-lite, and have no stream fallback.
- **`MoqException.isShutdown`** (`Errors.kt`): true for the graceful `Cancelled`/`Closed` cases.
## Versioning
diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md
index d9457e4456b..4630a685467 100644
--- a/py/moq-rs/README.md
+++ b/py/moq-rs/README.md
@@ -184,7 +184,7 @@ client = moq.Client(
- `.abort(error_code)` terminates the track or group with an application error.
- `.append_datagram(payload, timestamp=None) -> sequence` (`TrackProducer`) sends a best-effort datagram. Payloads are capped at 1200 bytes and there is no stream fallback.
- `.insert_datagram(sequence, payload, timestamp=None)` (`TrackProducer`) sends one at the caller's own sequence.
- - `.largest() -> Position | None` (`TrackProducer`) reports the newest group or datagram written, at its newest frame.
+ - `.largest() -> Position | None` (`TrackProducer`) reports the newest group written, at its newest frame. Datagrams are numbered apart from groups and never move it.
### Media
@@ -274,7 +274,7 @@ Every handle whose cleanup is `cancel()` is an async context manager, so exiting
- **`Timestamp`**. `.value: int` units at `.scale: int` units per second, exact at any scale such as 90 kHz. `Timestamp.from_timedelta(duration)` is exact at microsecond scale; `.to_timedelta()` rounds down to a whole microsecond. A write with a scale of 0 or past `2**62 - 1`, or a value past `2**61 - 1`, raises `Error.TimeOverflow`.
- **`Frame`**. `.payload: bytes`, `.timestamp: Timestamp | None`. The unit of every write and every raw read; `None` means untimed when publishing or reading.
- **`media.MediaFrame`**. `.payload: bytes`, `.timestamp: Timestamp`, `.keyframe: bool`. The frame variant of a media read. `keyframe` marks a group start or video keyframe; for audio it is true only at a group start.
-- **`Datagram`**. `.sequence: int`, `.timestamp: Timestamp | None`, `.payload: bytes`. Delivered only on datagram-capable transports with lite-05 or newer moq-lite, or moq-transport.
+- **`Datagram`**. `.sequence: int`, `.timestamp: Timestamp | None`, `.payload: bytes`. Delivered only on datagram-capable transports with lite-05 or newer moq-lite.
- **`media.Audio`**. `.codec`, `.sample_rate`, `.channel_count`, `.bitrate`, `.enabled`, `.description`.
- **`media.Video`**. `.codec`, `.coded: Dimensions`, `.display_aspect`, `.bitrate`, `.enabled`, `.framerate`, `.description`. A false `.enabled` means no frames are coming, so don't select the rendition.
- **`Subscription`**. Subscriber delivery preferences: priority, staleness, and optional group range.
diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py
index 6b1456b9c31..4b1482e1876 100644
--- a/py/moq-rs/moq/publish.py
+++ b/py/moq-rs/moq/publish.py
@@ -139,7 +139,7 @@ def insert_datagram(self, sequence: int, payload: bytes, timestamp: Timestamp |
self._inner.insert_datagram(sequence, Frame(payload, timestamp)._ffi())
def largest(self) -> Position | None:
- """The newest group or datagram written, at its newest frame; ``None`` before any."""
+ """The newest group written, at its newest frame; ``None`` before any. Datagrams never move it."""
return self._inner.largest()
def consume(self, subscription: Subscription | None = None) -> TrackConsumer:
diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py
index bcf801290ca..770d8e0ede1 100644
--- a/py/moq-rs/tests/test_local.py
+++ b/py/moq-rs/tests/test_local.py
@@ -118,18 +118,18 @@ async def reject():
await task
-async def test_insert_datagram_keeps_its_sequence_and_largest_follows():
+async def test_insert_datagram_keeps_its_sequence_apart_from_groups():
broadcast = moq.BroadcastProducer()
track = broadcast.publish_track("events")
consumer = track.consume()
- assert track.largest() is None
track.insert_datagram(7, b"seven")
datagram = await asyncio.wait_for(consumer.recv_datagram(), 5)
assert datagram is not None
assert (datagram.sequence, datagram.payload) == (7, b"seven")
- assert track.largest() == moq.Position(group=7, frame=0)
assert track.append_datagram(b"next") == 8
+ # Datagrams are numbered apart from groups, so they never move `largest`.
+ assert track.largest() is None
async def test_broadcast_consumer_closed_resolves_on_close():
diff --git a/quest/m1/datagram-tracks/README.md b/quest/m1/datagram-tracks/README.md
index f787a92fe2a..143c558c70e 100644
--- a/quest/m1/datagram-tracks/README.md
+++ b/quest/m1/datagram-tracks/README.md
@@ -61,26 +61,50 @@ Decided 2026-11-01 (maintainer, in `/quest-plan`):
- Outbound, a datagram track sends only OBJECT_DATAGRAM with object ID 0.
- **Archive.** ✅ moq-archive skips datagram tracks. A datagram track is
live-only, and an archive's playback doesn't include it.
+- **Old versions carry both kinds.** ✅ Decided 2026-11-03 (maintainer, on
+ [moq-dev/moq#7752](https://github.com/moq-dev/moq/pull/7752)): "carry both
+ on old versions, like moq-transport. Ignore duplicates because in practice,
+ only one was being used anyway."
+ - A lite-05/06 SUBSCRIBE for a name carries its stream track's groups and
+ its datagram track's datagrams, as one moq-transport subscription does. A
+ name with only a datagram track is served from it alone.
+ - A subscriber keeps the kind it asked for. One upstream SUBSCRIBE serves
+ readers of both kinds: the stream track's subscription feeds the name's
+ datagram track, so a relay between old peers carries datagrams without a
+ second subscription.
+ - No dedup: a name using both kinds can deliver a datagram to an old
+ subscriber that also holds a group of the same sequence.
+ - Reason: lite-05/06 are the default connections, and moq-ffi/moq-c publish
+ every raw track's datagrams beside it, so serving a name only one kind
+ stopped their datagrams on the default path.
- **Version.** ✅ lite-07, before it is finalized.
[Finalize moq-lite-07](/quest/m1/lite07-finalize.md) requires this epic.
lite-01..06 keep today's group datagrams.
-Not settled by the maintainer, so the first child decides it and records why:
-the datagram request's answer carries the track's timescale and
-compression, as TRACK_INFO does for a stream track.
+Decided by the Rust child ([moq-dev/moq#7752](https://github.com/moq-dev/moq/pull/7752)),
+not the maintainer, so open to revisiting:
-This README owns the work no child owns:
+- **The answer.** The lite-07 request (Datagrams Stream 0x9: DATAGRAMS, then
+ DATAGRAMS_UPDATE) is answered with DATAGRAMS_INFO, carrying the publisher
+ priority and the timescale, and no compression: a datagram track is never
+ compressed. Why: a subscriber needs the timescale to read a datagram (its
+ Timestamp is present only on a timed track), so the answer must carry it,
+ and a separate TRACK lookup would need a kind signal TRACK does not have. A
+ payload under 1200 bytes, compressed alone with a fresh window, saves little,
+ while compression would cost every datagram an Inflated Size and a decoder.
+- **Timestamp on a `Timescale` 0 track.** The datagram carries none, and its
+ Sequence stands in for its time. Why: it mirrors the
+ [model](/quest/m1/time-addressing/model.md)'s rule that a scale-less frame's
+ timestamp is its group's key, since a datagram's address is its sequence.
+ Until that model lands, Rust's datagram writes take `Option` like
+ frame writes do (`None` on an untimed track), and the model quest converts
+ both.
-- the Rust-JS interop test of a datagram track on lite-07, direct and
- through a relay;
-- rewriting the Datagrams section of `doc/concept/moq-lite.md`.
-
-The draft's own Datagrams section changes with the
-[Rust child](/quest/m1/datagram-tracks/rust.md).
+This README owns the work no child owns: the Rust-JS interop test of a
+datagram track on lite-07, direct and through a relay.
## Required
-- [Rust datagram tracks](/quest/m1/datagram-tracks/rust.md) - Rust model types, the lite-07 datagram request and body, the draft, the relay, and moq-archive skipping datagram tracks
- [JS datagram tracks](/quest/m1/datagram-tracks/js.md) - `@moq/net` datagram types and the lite-07 datagram request
- [moq-transport datagram tracks](/quest/m1/datagram-tracks/ietf.md) - Rust and JS map a datagram track to SUBSCRIBE and OBJECT_DATAGRAM with object ID 0, keeping the requested kind
- [Bindings datagram tracks](/quest/m1/datagram-tracks/bindings.md) - moq-ffi, moq-c, and every wrapper expose the separate datagram types
diff --git a/quest/m1/datagram-tracks/bindings.md b/quest/m1/datagram-tracks/bindings.md
index 4373429a18c..5c65e5cd897 100644
--- a/quest/m1/datagram-tracks/bindings.md
+++ b/quest/m1/datagram-tracks/bindings.md
@@ -13,8 +13,13 @@ UniFFI outputs, update `go/wrapper` (`publish.go`, `subscribe.go`,
`records.go`) and `dart/moq`, and record the migration in each package's
`MIGRATE.md`. Update `doc/lib/*` where they show datagrams.
-Public API: breaking for every binding's datagrams. Wire: none.
-
-## Required
+Until this lands, moq-ffi and moq-c back the old API with the new types
+([moq-dev/moq#7752](https://github.com/moq-dev/moq/pull/7752)): every raw track
+publishes a datagram track of the same name, and a reader subscribes to the
+stream track before it opens the datagram track, so a datagram reader still
+shows as the track's demand and reaches a dynamic publisher's request handler.
+Remove that coupling here, and give the separate types their own demand (the
+datagram producer's aggregate priority) and on-demand requests
+(`Requests::datagrams()`), so a native datagram-only reader is visible too.
-- [Rust datagram tracks](/quest/m1/datagram-tracks/rust.md) - the Rust types the bindings mirror
+Public API: breaking for every binding's datagrams. Wire: none.
diff --git a/quest/m1/datagram-tracks/ietf.md b/quest/m1/datagram-tracks/ietf.md
index 498568af977..c0a7c874677 100644
--- a/quest/m1/datagram-tracks/ietf.md
+++ b/quest/m1/datagram-tracks/ietf.md
@@ -23,9 +23,21 @@ Decided 2026-11-01 (maintainer), in the
Touches `rs/moq-net/src/ietf/datagram.rs` and the JS IETF adapter. Test
against moxygen in the interop lanes where it sends datagrams.
+Rust datagram tracks ([moq-dev/moq#7752](https://github.com/moq-dev/moq/pull/7752))
+left moq-transport without datagrams in Rust until this lands: the IETF
+subscriber drops every OBJECT_DATAGRAM, `datagrams()` on an IETF source is
+NOT_FOUND, and a stream track never sends one. Restore what it removed:
+
+- the Rust-JS `datagram` tail lanes in `test/interop/interop.sh`, the
+ `datagram` mode of `test/interop/clients/js-native/tail.ts`, and their
+ README bullet;
+- moq-e2ee's `datagrams_over_ietf` transport test;
+- the moq-transport rows of moq-tokio's `datagram_delivery` bench and
+ moq-net's `session_delivery_datagram`;
+- the OBJECT_DATAGRAM sentence in `doc/concept/standard.md`.
+
Public API: none beyond the model. Wire: no format change.
## Required
-- [Rust datagram tracks](/quest/m1/datagram-tracks/rust.md) - the datagram types this maps
- [JS datagram tracks](/quest/m1/datagram-tracks/js.md) - the JS datagram types this maps
diff --git a/quest/m1/datagram-tracks/js.md b/quest/m1/datagram-tracks/js.md
index 7dfb26190e4..851c6215870 100644
--- a/quest/m1/datagram-tracks/js.md
+++ b/quest/m1/datagram-tracks/js.md
@@ -10,8 +10,10 @@ the group counter. Older lite versions keep today's group datagrams.
## Plan
Follow the [epic](/quest/m1/datagram-tracks/README.md)'s decisions and the
-wire that [Rust datagram tracks](/quest/m1/datagram-tracks/rust.md) lands in
-the draft.
+lite-07 wire that `drafts/draft-lcurley-moq-lite.md` specifies (Datagrams
+Stream 0x9, DATAGRAMS, DATAGRAMS_UPDATE, DATAGRAMS_INFO, and the Track
+Datagram body), which Rust speaks since
+[moq-dev/moq#7752](https://github.com/moq-dev/moq/pull/7752).
- Remove from `js/net/src/track.ts` and the lite subscriber and publisher:
- `appendDatagram` and the shared sequence counter (`track.test.ts`
@@ -21,10 +23,8 @@ the draft.
- the datagram cases in ranges.
- Map lite-01..06 group datagrams the same way Rust does.
- Update any first-party consumer of JS datagrams.
+- Drop the sentence saying the JavaScript library still sends datagrams
+ within a stream track from the Datagrams section of `doc/concept/moq-lite.md`.
Public API: breaking for `@moq/net` datagrams. Wire: lite-07-wip, matching
Rust.
-
-## Required
-
-- [Rust datagram tracks](/quest/m1/datagram-tracks/rust.md) - lands the lite-07 wire this matches
diff --git a/quest/m1/datagram-tracks/rust.md b/quest/m1/datagram-tracks/rust.md
deleted file mode 100644
index 99e749617a9..00000000000
--- a/quest/m1/datagram-tracks/rust.md
+++ /dev/null
@@ -1,71 +0,0 @@
-# [L] Rust datagram tracks
-
-## Goal
-
-moq-net has separate datagram types: a broadcast creates a datagram
-producer/consumer pair with its own sequence counter, and stream tracks
-carry no datagram API. Lite-07 subscribes to a datagram track through its
-own request and sends datagrams addressed by that sequence. The relay
-forwards datagram tracks, and moq-archive skips them. lite-01..06 keep
-today's group datagrams.
-
-## Plan
-
-The decisions are in the [epic](/quest/m1/datagram-tracks/README.md).
-
-**Model**
-- Add the datagram producer and consumer types to `rs/moq-net/src/model/`.
- Each has its own sequence counter and a tiny send/receive buffer, with no
- cache and no ranges.
-- Datagram tracks have their own namespace in a broadcast.
-- Remove the datagram paths from stream tracks:
- - `insert_datagram` and the stored datagrams in `model/datagram.rs`;
- - `poll_recv_datagram` and its resume handling in `model/resume.rs`;
- - datagram cases in fills, misses, and range accounting.
-
-**Wire (lite-07)**
-- A datagram subscribe request carries only a priority, and an update
- changes only the priority.
-- A datagram carries its sequence, its timestamp, and the payload.
-- Decide whether the request's answer carries the track's timescale and
- compression as TRACK_INFO does, and record the reason in the epic.
-- Every datagram write takes a timestamp, as every write does
- ([the model](/quest/m1/time-addressing/model.md), maintainer 2026-11-03).
- On a scale-less track (`Timescale` 0) a stream frame's timestamp is its
- group's key, and a datagram has no group: decide what its timestamp is
- there, and record the reason in the epic.
-- Remove the lite-07 rules that mixing needed:
- - stream/datagram dedup;
- - ordering a datagram among its group's streams;
- - NOT_FETCHABLE;
- - datagrams in SUBSCRIBE_DROP coverage;
- - the datagram grace in the subscriber's end (`a_lost_datagram_delays_the_end`).
-- On lite-01..06, one subscription to a name carries both group streams and
- group datagrams. Map it the way moq-transport does, keeping the requested
- kind:
- - Inbound, a datagram-track subscription keeps only group datagrams and
- drops group streams. A stream-track subscription drops group datagrams.
- - Outbound, an old-version SUBSCRIBE for a name is served from the stream
- track of that name. It is served from the datagram track, as group
- datagrams keyed by its sequence, only when no stream track has that name.
- - This outbound rule is the plan's default. This quest may revisit it, and
- records why if it does.
-
-**Elsewhere**
-- The relay forwards each kind as it received it.
-- moq-archive's producer and consumer drop their datagram form.
-- moq-ffi's public datagram API (`append_datagram`, `recv_datagram`) is
- unchanged in this quest, and is backed internally by the new types. The
- [bindings](/quest/m1/datagram-tracks/bindings.md) quest then replaces it
- with the separate types.
-- Update `drafts/draft-lcurley-moq-lite.md`: the Datagrams section, the
- request, the error table, and the lite-07 changelog. Then run
- `just drafts check` and `just test interop --all`.
-- The PR's Impact section says how to migrate.
-
-Public API: breaking. Stream tracks lose their datagram methods, and the
-datagram types are new. Wire: lite-07-wip datagrams and a new request.
-
-## Related
-
-- [Lite-07 by start](/quest/m1/time-addressing/lite07.md) - stream groups address by start; datagrams keep a sequence
diff --git a/quest/m1/subscribe-ranges/lite.md b/quest/m1/subscribe-ranges/lite.md
index 185f31ebc87..5ed3658d37a 100644
--- a/quest/m1/subscribe-ranges/lite.md
+++ b/quest/m1/subscribe-ranges/lite.md
@@ -131,8 +131,8 @@ requirement landed. Reuse its design:
If the demand leaves while fills are out, the subscription keeps the
demand's ranges until they are answered.
- A NotFound drop names the fill's groups dropped
- (`Fill::drop_groups`), as a FETCH miss does; Old and NOT_FETCHABLE reject
- them, and any other error refuses only that request.
+ (`Fill::drop_groups`), as a FETCH miss does; Old rejects them, and any
+ other error refuses only that request.
Its integration tests (`range_fill.rs`: a miss subscribes and sends no FETCH,
a miss rides the live subscription then retires its range, a group the
@@ -157,19 +157,9 @@ whole) passed. These failed, and must pass on #7545's miss judging:
Open questions the attempt raised, now decided (2026-11-03, above), kept for
their detail:
-- **Datagram sequences.** A range fill of a sequence served only as a datagram
- never completes: datagrams resolve no SUBSCRIBE_START, so a FIN or end
- overtaking them leaves the receiver no owed range to wait on
- (`datagram::a_fetched_datagram_is_refused_per_version`). Recommended: a
- lite-07 publisher sends SUBSCRIBE_START for a bounded past range, then
- SUBSCRIBE_DROP with NOT_FETCHABLE for each sequence it holds only as a
- datagram; the subscriber already maps that drop onto the fill. This is a
- wire change. The alternative keeps FETCH for a fill the first answer shows is
- a datagram, at a round trip and a second path.
- Since settled in part: datagrams become their own track kind, which takes
- no ranges (maintainer, 2026-11-01). Today a lite-07 publisher that served a
- bounded range's only group as a datagram never answers it with SUBSCRIBE_OK
- (`TrackRun::unservable`), so such a fill parks until its readers leave.
+- **Datagram sequences.** Settled: datagrams are their own track kind, which
+ takes no ranges, and a stream track never holds one (maintainer, 2026-11-01;
+ NOT_FETCHABLE is gone).
- **Fills from partway through a group.** Route recovery (`resume::Recover`)
fills from frame N, which a whole-group range cannot say. Recommended: keep
FETCH on lite-07 for a fill whose first group starts past frame 0, as
diff --git a/quest/m1/subscribe-ranges/start-bookkeeping.md b/quest/m1/subscribe-ranges/start-bookkeeping.md
index 1d7f173b5ce..fb9dbc8792c 100644
--- a/quest/m1/subscribe-ranges/start-bookkeeping.md
+++ b/quest/m1/subscribe-ranges/start-bookkeeping.md
@@ -29,10 +29,10 @@ generations of "start".
DROP, since
[Datagram tracks](/quest/m1/datagram-tracks/README.md) takes datagrams out
of stream tracks (maintainer, 2026-11-01). The subscriber's end is "owed
- range covered and no stream active", with no datagram grace;
- `a_lost_datagram_delays_the_end` becomes a lite-05/06 case. Keep
- `Settle`'s full grace for lite-05/06 and moq-transport behind one version
- predicate.
+ range covered and no stream active", with no datagram grace. A stream
+ track's lite-05/06 subscription drops group datagrams, so a group an old
+ publisher sent only as one is a hole there. Keep `Settle`'s full grace for
+ lite-05/06 and moq-transport behind one version predicate.
- Lite-07 is unpublished, so sending a fresh START per update can remove the
`requested == start` gate (`lite/subscriber.rs`, about line 4898).
diff --git a/quest/m2/net-wrap/e2ee.md b/quest/m2/net-wrap/e2ee.md
index 12c9cc3684c..35046297d52 100644
--- a/quest/m2/net-wrap/e2ee.md
+++ b/quest/m2/net-wrap/e2ee.md
@@ -59,38 +59,14 @@ the reverse (decided 2026-10-20, maintainer):
without changing moq-net's `track(name)`; propose the encoding in the PR.
Record it in `draft-lcurley-moq-e2ee.md`, replacing the Catalogs section's
`.z` sibling rule.
-- Datagram header allowance (decided 2026-10-31, maintainer, a follow-up
- of [moq-dev/moq#7435](https://github.com/moq-dev/moq/pull/7435)):
- `MAX_DATAGRAM_HEADER` (`rs/moq-crypto/src/e2ee/limits.rs`,
- `js/crypto/src/e2ee/limits.ts`; 28 after #7435, lite-07's Type plus three 9-byte
- varints) also counts the optional lite datagram fields: the relayed fields
- the lite `ietf` extension adds, and moq-net's Inflated Size header field,
- which a compressed physical track's datagram carries outside the AEAD
- (`rs/moq-net/src/lite/datagram.rs`). Without them a maximum-size protected
- datagram can encode past the 1200-byte body and be dropped. That header
- field is not the wrapper's own inflated size above, which sits inside the
- authenticated payload and counts against the plaintext budget instead.
- Today a caller's `track::Info` reaches the physical track unchanged, so it
- can be compressed. Once the wrapper creates the physical track
- uncompressed, the header field never appears and the allowance needs only
- the `ietf` fields. Of those, the relay state, object ID, priority, and
- status have a fixed worst case, but `encode_datagram`
- (`rs/moq-net/src/lite/ietf.rs`) also writes the forwarded properties
- block, which may run to `MAX_OBJECT_EXTENSIONS` (64 KiB,
- `rs/moq-net/src/ietf/group.rs`), so no fixed allowance covers it, and the
- lite publisher drops a datagram whose body exceeds 1200 bytes
- (`rs/moq-net/src/lite/publisher.rs`). Choose during implementation between
- capping the properties a hop forwards on a protected datagram (and
- counting that cap) or documenting that relay properties are outside the
- allowance, so such a datagram may be dropped, and record the choice in the
- PR. Sealing and opening share
- `MAX_DATAGRAM_PAYLOAD` today (`rs/moq-crypto/src/e2ee/track.rs`,
- `js/crypto/src/e2ee/track.ts`), so a wider allowance would also make `open`
- refuse a datagram an older sender sealed under the narrower one. Split
- the seal and open limits, or pick the limit by wire version, so a
- receiver still opens anything that fits the body. Update the e2ee
- draft's plaintext bound and the `moq-e2ee-00` vectors with it, covering
- both limits and a datagram with non-empty relay properties.
+- Datagram header allowance: a moq-lite datagram carries only Type,
+ Subscribe ID, Sequence, and Timestamp, since datagram tracks dropped the
+ relayed `ietf` fields and the Inflated Size from it, so
+ `MAX_DATAGRAM_HEADER` (28, `rs/moq-crypto/src/e2ee/limits.rs` and
+ `js/crypto/src/e2ee/limits.ts`) covers every version. Re-check it if
+ moq-transport datagram tracks
+ ([moq-transport datagram tracks](/quest/m1/datagram-tracks/ietf.md)) add
+ header fields.
- Ranges: every moq-net range starts at a group's first frame
([moq-dev/moq#6465](https://github.com/moq-dev/moq/pull/6465)), so the wrapper reads each physical group whole
and inflates from frame 0.
diff --git a/quest/m2/resume-redesign.md b/quest/m2/resume-redesign.md
index 9674308b6c0..d7ebbd56226 100644
--- a/quest/m2/resume-redesign.md
+++ b/quest/m2/resume-redesign.md
@@ -61,8 +61,7 @@ paths diverged.
2. The upstream request is already the union of readers' resume points
(`Producer::subscription`), so per-reader copy cursors are local overhead.
3. Readers become plain cursors: delete `Inner::Resume`, `Reader`, `Copy`,
- `Retain`, `Candidate`, per-reader datagram dedupe (dedupe once on insert),
- and per-reader `learn_end`.
+ `Retain`, `Candidate`, and per-reader `learn_end`.
4. `Recover` attaches to the slot, not a handed-out group: continuing a
group whose copy failed is a
[flight](/quest/m1/unbounded-groups/flights.md) from the reader's frame on
diff --git a/rs/moq-archive/MIGRATE.md b/rs/moq-archive/MIGRATE.md
index dfb4799fc65..cea967e0a91 100644
--- a/rs/moq-archive/MIGRATE.md
+++ b/rs/moq-archive/MIGRATE.md
@@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first.
## Unreleased
+### Recording format version 4 drops the datagram form ([#7752](https://github.com/moq-dev/moq/pull/7752))
+
+An archive records stream tracks only, and its entries carry no `Form`. Versions 2 and 3 still read, but an entry of the datagram form refuses its object with `Error::Form`.
+A version 4 producer refuses to resume a prefix holding a version 2 or 3 track, failing with `Error::Version` before it writes anything: start a new recording prefix, and read the older one with a reader.
+
### Recording format version 3 replaces version 1
Release wrote version 1; the current codecs refuse it. Start a new recording prefix and retain an older reader for existing version 1 recordings.
diff --git a/rs/moq-archive/src/consumer/mod.rs b/rs/moq-archive/src/consumer/mod.rs
index 7fc6fff1ea9..92c86d92cac 100644
--- a/rs/moq-archive/src/consumer/mod.rs
+++ b/rs/moq-archive/src/consumer/mod.rs
@@ -4,8 +4,7 @@
//! stored track on a `moq_net` broadcast, the archived catalog included, with its stored
//! timescale and priority and no max age: the store's retention bounds what it holds. Each
//! track's live feed starts at its newest stored sequence, and every older group is served on
-//! demand from the store through [`track::Requests`](moq_net::track::Requests) fills, each sequence in its recorded form: a
-//! group as a group, a datagram as a datagram.
+//! demand from the store through [`track::Requests`](moq_net::track::Requests) fills.
//!
//! A reader's time start older than the newest group is looked up from the object keys, each of
//! which carries its first frame's timestamp, so the range starts at the stored group containing
@@ -348,7 +347,7 @@ async fn refresh(shared: Arc>, mut tracks: Vec Frame {
/// Group `sequence`'s frames `frames`.
fn frames(timed: bool, sequence: u64, frames: std::ops::Range) -> Entry {
- Entry::Group {
+ Entry {
sequence,
frames: frames.map(|index| frame(timed, sequence, index)).collect(),
}
}
-fn datagram(timed: bool, sequence: u64) -> Entry {
- Entry::Datagram {
- sequence,
- frame: frame(timed, sequence, 0),
- }
-}
-
/// What a reader sees of `frames(timed, sequence, indices)`.
fn expected(timed: bool, sequence: u64, indices: std::ops::Range) -> Vec {
indices.map(|index| describe(&frame(timed, sequence, index))).collect()
@@ -185,7 +178,7 @@ async fn a_recording_round_trips() {
.with_priority(3)
.with_max_age(Duration::from_secs(1));
let video = source.create_track("video", timed).unwrap();
- let mut log = source
+ let log = source
.create_track("log", track::Info::default().with_priority(9))
.unwrap();
let config = crate::producer::Config::default()
@@ -210,8 +203,6 @@ async fn a_recording_round_trips() {
group.write_frame(None, "a").unwrap();
group.finish().unwrap();
sleep(Duration::from_millis(10)).await;
- log.insert_datagram(1, None, "b").unwrap();
- sleep(Duration::from_millis(10)).await;
video.finish().unwrap();
log.finish().unwrap();
sleep(Duration::from_millis(10)).await;
@@ -239,14 +230,9 @@ async fn a_recording_round_trips() {
assert!(reader.next_group().await.unwrap().is_none());
assert_eq!(reader.finished().await.unwrap(), Position::group(6));
- // The untimed track keeps its datagram a datagram.
let info = broadcast.consume().track("log").unwrap().info().await.unwrap();
assert_eq!((info.priority, info.timescale), (9, None));
assert_eq!(fetch(&broadcast, "log", 0).await.unwrap(), ["a"]);
- assert!(matches!(
- fetch(&broadcast, "log", 1).await,
- Err(moq_net::Error::NotFetchable | moq_net::Error::NotFound)
- ));
}
/// History older than the source's max age relative to the newest group is still served.
@@ -604,55 +590,6 @@ async fn markers_end_a_followed_archive() {
assert!(next(&mut reader).await.is_none());
}
-/// Groups and datagrams sharing one sequence space replay in their recorded forms.
-#[tokio::test(start_paused = true)]
-async fn groups_and_datagrams_keep_their_forms() {
- let archive = Archive::new();
- archive.track("mixed", 0, false).await;
- archive
- .object(
- "mixed",
- 0,
- vec![frames(false, 0, 0..1), datagram(false, 1), frames(false, 2, 0..1)],
- )
- .await;
-
- let (broadcast, _run) = serve(&archive.store).await;
- let track = broadcast.consume().track("mixed").unwrap();
- let subscription = Subscription::default()
- .with_range(0..)
- .with_order(Order::Ascending)
- .with_delay(Delay::default().with_max(None));
- let mut reader = track.subscribe(subscription).await.unwrap();
-
- archive
- .object("mixed", 0, vec![datagram(false, 3), frames(false, 4, 0..1)])
- .await;
- let datagram = timeout(10 * REFRESH, reader.recv_datagram())
- .await
- .expect("the datagram arrives")
- .unwrap()
- .unwrap();
- assert_eq!((datagram.sequence, &datagram.payload[..]), (3, &b"3/0"[..]));
-
- // A stored datagram is never fetched as a group.
- assert!(fetch(&broadcast, "mixed", 1).await.is_err());
- assert_eq!(fetch(&broadcast, "mixed", 0).await.unwrap(), ["0/0"]);
- assert_eq!(fetch(&broadcast, "mixed", 2).await.unwrap(), ["2/0"]);
-
- // The newest stored group arrives live as a group, still open.
- let mut sequences = Vec::new();
- while sequences.last() != Some(&4) {
- let group = timeout(10 * REFRESH, reader.recv_group())
- .await
- .unwrap()
- .unwrap()
- .unwrap();
- sequences.push(group.sequence);
- }
- assert!(!sequences.contains(&1) && !sequences.contains(&3), "{sequences:?}");
-}
-
/// A track removed and re-added under its name replays both runs in sequence.
#[tokio::test(start_paused = true)]
async fn a_re_added_track_replays_both_runs() {
@@ -902,7 +839,7 @@ async fn a_continuation_does_not_bound_a_group_start() {
.object(
"video",
0,
- vec![Entry::Group {
+ vec![Entry {
sequence: 0,
frames: vec![frame(0, 0, 0)],
}],
@@ -913,11 +850,11 @@ async fn a_continuation_does_not_bound_a_group_start() {
"video",
1,
vec![
- Entry::Group {
+ Entry {
sequence: 0,
frames: vec![frame(0, 1, 1_000)],
},
- Entry::Group {
+ Entry {
sequence: 1,
frames: vec![frame(1, 0, 500)],
},
diff --git a/rs/moq-archive/src/consumer/track.rs b/rs/moq-archive/src/consumer/track.rs
index 5ca84109ef0..23c7ccdc160 100644
--- a/rs/moq-archive/src/consumer/track.rs
+++ b/rs/moq-archive/src/consumer/track.rs
@@ -49,66 +49,56 @@ impl Live {
pub fn push(&mut self, objects: &[Arc