Skip to content
2 changes: 1 addition & 1 deletion doc/concept/standard.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,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, a token on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified like an in-band session token and authorizes that one request alone, 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 renews the token, and 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. A token on any other request is decoded and grants nothing, and a request repeating one 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: the fetch forms above get `NOT_SUPPORTED`. Range Filters get `INVALID_FILTER` on draft 22 because the advertised filter limit is zero. On draft 22, 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). On draft 22, widening fills cached history on a separate fetch stream, completing earlier groups and handing the newest head to its live subgroup tail; fills follow the requested group order. 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 see consecutive frames and normal datagram payloads; 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 drafts 18 and 22, or refused when immutable, because draft 22 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. 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 drafts 18 and 22, or refused when immutable, because draft 22 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.
Expand Down
1 change: 0 additions & 1 deletion quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,6 @@ surface and zero-copy texture import, land before the cut.
- [Release audit findings](/quest/m1/release-audit/README.md) - resolve newly identified resource, parser, playback, and interoperability risks before the cut
- [Pre-cut audit findings](/quest/m1/cut-audit/README.md) - fix the crashes, hostile-input costs, and correctness bugs the 2026-10-24 audit of main found
- [Cancelled moq-transport requests stop](/quest/m1/ietf-request-cancel.md) - a cancelled request releases its state even when the peer withholds credit
- [Datagrams past object 0 never pose as object 0](/quest/m1/datagram-object-drop.md) - a hop or reader that cannot carry an Object ID delivers only object 0 of a moq-transport datagram group
- [Cluster routing](/quest/m1/cluster-routing/README.md) - prove shared routing contracts, implement per-origin routes, and connect outbound local clusters
- [Routing guide](/quest/m1/routing-guide.md) - concepts and practical outbound federation setup
- [moq-c at parity](/quest/m1/c/README.md) - the hand-written `moq-c` ships at the cut with every m1 binding feature moq-ffi gains
Expand Down
54 changes: 0 additions & 54 deletions quest/m1/datagram-object-drop.md

This file was deleted.

54 changes: 35 additions & 19 deletions rs/moq-net/src/ietf/subscriber.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6948,20 +6948,21 @@ mod tests {
);
}

/// An OBJECT_DATAGRAM at object 0 is a datagram group at its Group ID; anything the model
/// cannot carry as one is dropped, and a malformed one is the peer's violation.
/// Relays keep datagram Object IDs, while plain readers see only normal object 0.
/// An unbound alias is dropped, and a malformed datagram is the peer's violation.
#[moq_clock::test]
async fn an_object_datagram_is_a_datagram_group() {
use crate::coding::Encode as _;
use futures::FutureExt as _;

let subscriber = subscriber_with_tracks(&[(RequestId(11), "cam", "audio")]);
subscriber.register_alias(RequestId(11), 7).unwrap();
let mut consumer = {
let (mut consumer, mut relay) = {
let mut state = subscriber.state.lock();
let track = state.subscribes.get_mut(&RequestId(11)).unwrap();
track.timescale = Some(Timescale::MILLI);
track.producer.as_ref().unwrap().subscribe(None)
let producer = track.producer.as_ref().unwrap();
(producer.subscribe(None), producer.subscribe(None))
};

let timestamp = crate::Timestamp::new(96_000, Timescale::MILLI).unwrap();
Expand All @@ -6988,28 +6989,30 @@ mod tests {
};
let payload = |bytes: &'static [u8]| ietf::DatagramBody::Payload(bytes::Bytes::from_static(bytes));

// Nonzero objects are delivered; an unbound alias and non-payload statuses stay invisible to plain readers.
// Nonzero objects survive for relays; plain readers skip them, unbound aliases, and statuses.
subscriber
.recv_datagram(datagram(7, 4, Some(1), payload(b"no")))
.unwrap();
subscriber.recv_datagram(datagram(8, 4, None, payload(b"no"))).unwrap();
subscriber
.recv_datagram(datagram(7, 4, None, ietf::DatagramBody::Status(END_OF_TRACK)))
.recv_datagram(datagram(7, 9, Some(0), payload(b"yes")))
.unwrap();

subscriber
.recv_datagram(datagram(7, 9, Some(0), payload(b"yes")))
.recv_datagram(datagram(7, 10, None, ietf::DatagramBody::Status(END_OF_TRACK)))
.unwrap();
let first = consumer.recv_datagram().now_or_never().unwrap().unwrap().unwrap();
let Poll::Ready(Ok(Some(first))) = relay.poll_recv_stored_datagram(&kio::Waiter::noop()) else {
panic!("the relay did not receive the object");
};
assert_eq!(first.sequence, 4);
assert_eq!(first.wire.as_ref().unwrap().object_id, Some(1));
assert_eq!(&first.payload[..], b"no");
let received = consumer.recv_datagram().now_or_never().unwrap().unwrap().unwrap();
assert_eq!(received.sequence, 9, "the Group ID is the sequence");
assert_eq!(received.timestamp, Some(timestamp));
assert_eq!(&received.payload[..], b"yes");
assert!(
consumer.recv_datagram().now_or_never().is_none(),
"only the payload datagrams got through"
"only the normal object 0 payload got through"
Comment thread
kixelated marked this conversation as resolved.
Resolved
);

// A status datagram cannot end the group.
Expand Down Expand Up @@ -13186,29 +13189,42 @@ mod stitch_tests {
}
}

/// A datagram's object number does not change its public payload view.
/// Relays retain a datagram's Object ID; a plain reader skips it until object 0 arrives.
#[moq_clock::test]
async fn passthrough_accepts_datagrams_past_object_zero() {
let version = Version::Draft22;
let mut h = Harness::with_timescale(Fill::Done, vec![], None);
h.subscriber.version = version;
let mut consumer = h.track.subscribe(None);
let bytes = ietf::ObjectDatagram {
let mut plain = h.track.subscribe(None);
let mut datagram = ietf::ObjectDatagram {
track_alias: ALIAS,
group_id: SEQUENCE,
object_id: Some(6),
publisher_priority: Some(128),
end_of_group: false,
properties: None,
body: ietf::DatagramBody::Payload(bytes::Bytes::from_static(b"object-six")),
}
.encode_bytes(version)
.unwrap();
h.subscriber.recv_datagram(bytes).unwrap();
let received = consumer.poll_recv_datagram(&kio::Waiter::noop());
};
h.subscriber
.recv_datagram(datagram.encode_bytes(version).unwrap())
.unwrap();
let received = consumer.poll_recv_stored_datagram(&kio::Waiter::noop());
assert!(
matches!(&received, Poll::Ready(Ok(Some(data)))
if data.payload == b"object-six"[..] && data.wire.as_ref().unwrap().object_id == Some(6)),
"{received:?}"
);
assert!(plain.poll_recv_datagram(&kio::Waiter::noop()).is_pending());
datagram.object_id = Some(0);
datagram.body = ietf::DatagramBody::Payload(bytes::Bytes::from_static(b"object-zero"));
h.subscriber
.recv_datagram(datagram.encode_bytes(version).unwrap())
.unwrap();
let received = plain.poll_recv_datagram(&kio::Waiter::noop());
assert!(
matches!(&received, Poll::Ready(Ok(Some(data))) if data.payload == b"object-six"[..]),
"{version}: {received:?}"
matches!(&received, Poll::Ready(Ok(Some(data))) if data.payload == b"object-zero"[..]),
"{received:?}"
);
}

Expand Down
Loading
Loading