Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions doc/concept/standard.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ maps everything else to "not supported" or a harmless equivalent. The
subset drops. What a peer actually observes against this implementation:

- **Pull.** Subscribers ask. Single-track `PUBLISH` offers are declined; announce a namespace and serve the resulting subscriptions. Announcements go out unsolicited, and we also ask for every prefix we may discover. The solicit `SETUP` option makes us wait to be asked. Without it, a draft 16+ peer also gets each match as a `NAMESPACE` on its `SUBSCRIBE_NAMESPACE` stream, so it hears the namespace twice. We never send `PUBLISH`: on drafts 16 and 17 a `SUBSCRIBE_NAMESPACE` asking only for `PUBLISH` is refused, one asking for both gets only `NAMESPACE`, and any other Subscribe Options value closes the session.
- **History is one group.** A `FETCH` returns one group from the cache, or on drafts 14 through 19 the saved prefix of the group a new subscription just joined. A range of groups is refused, as is a draft 20+ `LOCATION_FILTER` that ends at Largest Object rather than an absolute End Location. On draft 20 and later, `FETCH_OK`'s End Location is the requested end, capped at Largest Object: a range holding no objects gets an empty fetch stream, and only a start past Largest Object is refused, with `INVALID_RANGE`. A relay's copy with no upstream subscription can't know Largest Object short of the track's end, so it echoes the requested end instead. JavaScript publishing refuses every `FETCH`. Datagrams are never fetchable. A Rust reader that only fetches learns the track from `TRACK_STATUS` instead of subscribing, so a finished track stays fetchable; draft 17 still subscribes, since its `TRACK_STATUS` answer cannot say whether the track is timed, and so does a reader whose publisher refuses `TRACK_STATUS` as `NOT_SUPPORTED`.
- **History is one group.** In Rust, Object ID gaps must be marked by Prior Object ID Gap properties for a group to be fetchable. A `FETCH` returns one group from the cache, or on drafts 14 through 19 the saved prefix of the group a new subscription just joined. A range of groups is refused, as is a draft 20+ `LOCATION_FILTER` that ends at Largest Object rather than an absolute End Location. On draft 20 and later, `FETCH_OK`'s End Location is the requested end, capped at Largest Object: a range holding no objects gets an empty fetch stream, and only a start past Largest Object is refused, with `INVALID_RANGE`. A relay's copy with no upstream subscription can't know Largest Object short of the track's end, so it echoes the requested end instead. JavaScript publishing refuses every `FETCH`. Datagrams are never fetchable. A Rust reader that only fetches learns the track from `TRACK_STATUS` instead of subscribing, so a finished track stays fetchable; draft 17 still subscribes, since its `TRACK_STATUS` answer cannot say whether the track is timed, and so does a reader whose publisher refuses `TRACK_STATUS` as `NOT_SUPPORTED`.
- **Timing.** A track whose `SUBSCRIBE_OK` declares no `TIMESCALE` is untimed, as is every track on drafts 14 through 16. A Rust reader that never subscribed takes the units from `TRACK_STATUS` on draft 18 and later.
- **Strict SETUP and parameters.** Repeated unknown `SETUP` options, GREASE included, are accepted if well-formed; a repeated known option is rejected. On draft 17 and later, a `GROUP_ORDER` other than Ascending (1) or Descending (2) closes the session. Message parameters follow the negotiated draft's lists: drafts 14 and 15 ignore an unknown or misplaced parameter, draft 16 ignores one defined only for another message but closes the session on an unknown one, and draft 17 and later close it on both.
- **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`. A token attached to an individual request is otherwise decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. `SUBSCRIBE_TRACKS` is refused unread, so its token is not decoded. On draft 17 and later, the negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band.
- **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `FORWARD=0`, range filters, `SUBSCRIBE_TRACKS`, and the fetch forms above. On draft 19 and later, and in Rust on drafts 14 through 16, a subscription update may change only priority; any other update ends that subscription.
- **Datagrams** are a single normal object at object 0, forwarded without renumbering. Anything else is dropped, and a malformed one closes the session. Rust and JavaScript both carry them on every draft.
- **Objects and datagrams.** Rust relays preserve Object IDs, gaps, object properties, FIRST\_OBJECT, and END\_OF\_GROUP status objects between moq-transport peers for subgroup 0. 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 (drafts 14 through 16), as for a plain frame; an immutable one that would change or vanish is refused. Property 0x06 is a draft-ietf-moq-loc-03 Timestamp only before draft 19, which reassigns it to SUBGROUP\_DELIVERY\_TIMEOUT, so it is dropped across that boundary, or refused when immutable. From draft 15, a status object carrying properties is refused, and one relayed from draft 14 leaves without them. A `FETCH` carries the END\_OF\_GROUP object on drafts 14 and 15; later drafts have no FETCH status, so their 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.
- **Priority.** Higher is served first. The IETF default of 128 is this stack's 127, and a track that never sets one is 127.
- **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 Expand Up @@ -61,12 +61,14 @@ product's model, not bugs, and the relay does not change them.
requires the relay to send that `SUBSCRIBE` to all matching publishers. See
[publisher epochs](/concept/moq-lite#publisher-epochs) for how that one route
is chosen.
- **Unknown object properties are dropped.**
- **Plain readers leave object properties out.**
[Draft 18 §2.5](https://www.ietf.org/archive/id/draft-ietf-moq-transport-18.html#section-2.5)
says a relay that does not understand a property still forwards and caches
it. The model keeps a payload and a timestamp, and
[leaves other per-object metadata out](/concept/moq-lite#what-moq-lite-leaves-out),
so a property it does not understand stops at the session that delivered it.
it. Rust moq-transport relay paths do so; the public API, plain moq-lite, and
JavaScript [leave other per-object metadata out](/concept/moq-lite#what-moq-lite-leaves-out).
Immutable property blocks retain their bytes. Forwarding is refused when the
negotiated drafts use incompatible key or integer encodings, or when the block
holds a Timestamp in units the response does not declare.
- **`SUBSCRIBE_OK` before an old source answers.** moq-lite 01 through 04 have
no track stream, so the relay cannot learn from that source whether the track
exists before answering. A moq-transport subscriber gets `SUBSCRIBE_OK`
Expand Down
21 changes: 21 additions & 0 deletions js/net/src/ietf/ietf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1857,6 +1857,27 @@ test("Frame rejects a property that runs past its block", async () => {
);
});

// Draft-19 reassigns property 0x06 from the draft-ietf-moq-loc-03 Timestamp to
// SUBGROUP_DELIVERY_TIMEOUT, which must not be read as a time.
test("Frame reads property 0x06 as a Timestamp only before draft-19", async () => {
const flags: GroupFlags = {
hasExtensions: true,
hasSubgroup: false,
hasSubgroupObject: false,
hasEnd: true,
hasPriority: true,
firstObject: true,
};
for (const [version, expected] of [
[Version.DRAFT_18, 42],
[Version.DRAFT_19, undefined],
] as const) {
const reader = new Reader(undefined, new Uint8Array([0, 2, 0x06, 42, 1, 0xaa]), version);
const frame = await reader.decode((c) => Frame.decode(c, flags, Timescale.MILLI));
expect(frame.timestamp?.value).toBe(expected);
}
});

// A fetch stream's first object is the only one carrying absolute ids, so a wrong flag byte
// there silently renumbers every object after it. This codec is the sole serialization path
// for a served fill.
Expand Down
17 changes: 16 additions & 1 deletion js/net/src/ietf/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ const PROP_TIMESTAMP = 0x10n;
// so it is not accepted here.
const PROP_TIMESTAMP_DRAFT03 = 0x06n;

/** Whether 0x06 is the draft-03 Timestamp: draft-19 reassigns it to SUBGROUP_DELIVERY_TIMEOUT. */
function hasDraft03Timestamp(version: IetfVersion | undefined): boolean {
switch (version) {
case Version.DRAFT_14:
case Version.DRAFT_15:
case Version.DRAFT_16:
case Version.DRAFT_17:
case Version.DRAFT_18:
return true;
default:
return false;
}
}

// draft-18 adds bit 0x40 (FIRST_OBJECT) to the subgroup header type per spec
// 11.4.2. Set on emit unless a filter trimmed the group's head. On parse a
// cleared bit is the peer's claim that the stream starts partway through;
Expand Down Expand Up @@ -110,6 +124,7 @@ export function decodeObjectTime(c: Cursor, timescale: Timescale): Timestamp | u
let timestamp: bigint | undefined;
let prevType = 0n;
let first = true;
const draft03 = hasDraft03Timestamp(asIetf(c.version));

while (c.remaining > 0) {
const step = c.u62();
Expand All @@ -119,7 +134,7 @@ export function decodeObjectTime(c: Cursor, timescale: Timescale): Timestamp | u

if (id % 2n === 0n) {
const value = c.u62();
if (id === PROP_TIMESTAMP || id === PROP_TIMESTAMP_DRAFT03) {
if (id === PROP_TIMESTAMP || (draft03 && id === PROP_TIMESTAMP_DRAFT03)) {
timestamp = value;
}
} else {
Expand Down
15 changes: 8 additions & 7 deletions quest/m1/ietf-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ Boundaries:
Decided with the maintainer on 2026-10-13. This revisits the
[#4926](https://github.com/moq-dev/moq/issues/4926) decline for relays only.
That decline's cache-fragmentation concern does not apply here, because
an explicit `skip` tells the cache a gap is intentional, and a FETCH it
private Object IDs distinguish gaps from frame ordinals, and a FETCH it
cannot answer unambiguously is refused.

- **Carried:** subgroups with their publisher priority, object ID gaps as a
crate-private `skip: u64`, object properties as a decoded KVP list,
- **Carried:** subgroups with their publisher priority, object ID gaps as
crate-private absolute Object IDs, object properties as a decoded KVP list,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
END_OF_GROUP status objects at their object ID, datagrams past object 0,
and unknown track properties.
- **Plain readers** see subgroup 0 only. A track with no subgroup 0 reads as
Expand All @@ -52,9 +52,11 @@ cannot answer unambiguously is refused.
moq-lite does not advertise this capability.
- **Immutable properties** keep their serialized inner bytes and ingress
encoding family, including across relay hops. Refuse forwarding between
absolute-key drafts 14/15 and delta-key drafts 16+ when immutable
properties are present; rewriting them would violate immutability and
forwarding them unchanged would change their meaning.
the absolute-key/QUIC-varint drafts 14/15, delta-key/QUIC-varint draft 16,
and delta-key/leading-ones-varint drafts 17+ when immutable properties
are present. Draft 17 is a separate family because it forbids 7-byte varints.
Rewriting them would violate immutability; forwarding unchanged
across those families would change their meaning.
- **Public model carve-out.** These fields stay crate-private on purpose,
an exception to [a public model](/quest/m1/model-public-surface.md). A
third-party relay built on the public model drops them, as plain lite
Expand All @@ -81,7 +83,6 @@ Work owned by this README, after the children land:

## Required

- [Object fields](/quest/m1/ietf-extension/objects.md) - object ID gaps, object properties, END_OF_GROUP status objects, and datagrams past object 0 survive moq-transport -> moq-relay -> moq-transport
- [Subgroups](/quest/m1/ietf-extension/subgroups.md) - a group holds several subgroups, each forwarded on its own stream with its ID and exact publisher priority; plain readers see subgroup 0
- [Dynamic groups](/quest/m1/ietf-extension/dynamic-groups.md) - NEW_GROUP_REQUEST reaches the original publisher, coalesced across downstream subscribers
- [Track properties](/quest/m1/ietf-extension/track-properties.md) - unknown track properties are forwarded unchanged in SUBSCRIBE_OK, FETCH_OK, and TRACK_STATUS_OK
Expand Down
1 change: 0 additions & 1 deletion quest/m1/ietf-extension/lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ and the framing above, only when negotiated.

## Required

- [Object fields](/quest/m1/ietf-extension/objects.md) - the per-object fields it frames
- [Subgroups](/quest/m1/ietf-extension/subgroups.md) - the subgroup streams it frames
- [Track properties](/quest/m1/ietf-extension/track-properties.md) - the track properties it frames
- [Dynamic groups](/quest/m1/ietf-extension/dynamic-groups.md) - the upstream request signal and capability it carries
Expand Down
76 changes: 0 additions & 76 deletions quest/m1/ietf-extension/objects.md

This file was deleted.

6 changes: 1 addition & 5 deletions quest/m1/ietf-extension/subgroups.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Decided 2026-10-13 (see the [epic](/quest/m1/ietf-extension/README.md)).

- `group::Producer` and `group::Consumer` own crate-private subgroups. Subgroup
0 is the public frame list. Every other subgroup keeps its own frames with
their `skip`, properties, end status, and priority.
their Object IDs, properties, end status, and priority.
- Priority is stored as the wire's `Option<u8>`. `None` means the header
omitted it and inherits the track default. It round-trips exactly, so 200
stays 200 and never goes through the 128-to-127 track mapping.
Expand All @@ -37,7 +37,3 @@ benchmark swept over subgroups per group and subscribers, since subgroups fan
out.

Public API: none. Wire: none new.

## Required

- [Object fields](/quest/m1/ietf-extension/objects.md) - subgroups store the per-object skip, properties, and end status it adds
6 changes: 2 additions & 4 deletions quest/m1/ietf-extension/track-properties.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,8 @@ handled as today.
Decided 2026-10-13 (see the [epic](/quest/m1/ietf-extension/README.md)).

- Store the unknown properties crate-private on `track::Info`, which is
`#[non_exhaustive]`, using the `Property` list from
[object fields](/quest/m1/ietf-extension/objects.md). Re-encode them for each
egress draft. Immutable Properties keep their inner bytes verbatim and
`#[non_exhaustive]`, using the private `model::object::Property` list.
Re-encode them for each egress draft. Immutable Properties keep their inner bytes verbatim and
their ingress encoding family; the cross-family refusal from object
fields applies to track properties too.
- Unknown properties include those the draft defines but moq-net does not
Expand All @@ -35,5 +34,4 @@ Public API: none. Wire: none new.

## Required

- [Object fields](/quest/m1/ietf-extension/objects.md) - introduces the decoded `Property` list
- [Dynamic groups](/quest/m1/ietf-extension/dynamic-groups.md) - makes DYNAMIC_GROUPS advertising truthful by carrying NEW_GROUP_REQUEST upstream
Loading
Loading