Skip to content
Merged
14 changes: 9 additions & 5 deletions doc/concept/standard.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ subset drops. What a peer actually observes against this implementation:
- **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. Other malformed control input closes the session with the draft's code too, such as a server `SETUP` carrying `PATH` or `AUTHORITY`, a request cut short by FIN, or a second `GOAWAY` on one stream; a first `GOAWAY` on a request stream is legal.
- **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, except on a drafts 14 and 15 `PUBLISH_NAMESPACE`, which has no update, and a refused renewal ends only that request. Rust and JavaScript send the token a route names (`Route::auth`) on its `PUBLISH_NAMESPACE`, including the one drafts 14 and 15 answer a solicited `SUBSCRIBE_NAMESPACE` with, and swapping it renews the request with `REQUEST_UPDATE` from draft 17, or ends and re-sends it before; the inline `NAMESPACE` that answers a solicited `SUBSCRIBE_NAMESPACE` from draft 16 carries no token. The token belongs to the session that added it, so a reconnect means adding it again and re-announcing. A token on any other request is decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. `SUBSCRIBE_TRACKS` reads its namespace before refusal; 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`, `SUBSCRIBE_TRACKS`, and the fetch forms above get `NOT_SUPPORTED`. Range Filters get `INVALID_FILTER` on draft 19 and later because the advertised filter limit is zero. On draft 21 and later, 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 and delivery timeouts; Rust also renews request tokens and judges `NEW_GROUP_REQUEST` as a join (see dynamic groups below). 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`. On draft 18 and later a namespace update may change its prefix; suffixes after the acknowledgement are relative to that new prefix.
- **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `FORWARD=0`, `SUBSCRIBE_TRACKS`, and the fetch forms above get `NOT_SUPPORTED`. Range Filters get `INVALID_FILTER` on draft 19 and later because the advertised filter limit is zero. On draft 21 and later, 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 renews request tokens and judges `NEW_GROUP_REQUEST` as a join (see dynamic groups below). 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`. On draft 18 and later 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. 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. Only property 0x10 is read as a Timestamp. Property 0x06 stays opaque and keeps its value regardless of timestamp units; it is dropped across the draft-19 boundary, or refused when immutable, because draft 19 assigns it to SUBGROUP\_DELIVERY\_TIMEOUT. From draft 15, a status object carrying properties is refused, and one relayed from draft 14 leaves without them. Draft 14 allows them, but an END\_OF\_TRACK carrying properties ends the subscription as unsupported, since a track end has nowhere to keep 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.
- **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` (draft 15 and later) 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`. On drafts 15 and 16, whose `TRACK_STATUS` answer has no room for it, a relay copy first learned from `TRACK_STATUS` (a fetch-only reader) does not advertise it to later subscribers. moq-lite hops do not carry requests yet.
- **Priority.** Higher is served first. The IETF default of 128 is this stack's 127, and a track that never sets one is 127. 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.
Expand Down Expand Up @@ -101,10 +101,14 @@ choices. They follow the product model.
namespace prefixes are coalesced internally, so `DUPLICATE_SUBSCRIPTION` and
`PREFIX_OVERLAP` are never sent.

- **Delivery timeouts are advisory.** `OBJECT_DELIVERY_TIMEOUT` and
`SUBGROUP_DELIVERY_TIMEOUT` are accepted on subscriptions and their updates,
but do not drop objects or reset subgroups. Latency enforcement uses
presentation time instead of wall-clock delivery deadlines.
- **Subscriber max age is negotiated.** The [Timestamp extension](/draft/moq-timestamp)
carries a subscriber's `max_delay` in `SUBSCRIBE` and `REQUEST_UPDATE` only
when the peer advertises support. Superseded groups expire against media time
and the hop's clock; the newest group, including a quiet catalog, survives any
budget. Relays forward their most tolerant subscriber's budget. Without the
parameter, the publisher preserves its retained backlog. `OBJECT_DELIVERY_TIMEOUT`
and `SUBGROUP_DELIVERY_TIMEOUT` are accepted and ignored: their per-object and
post-FIN deadlines would also cut the newest group, so neither is a max-age budget.

- **A relay judges `NEW_GROUP_REQUEST` instead of forwarding it.**
[Draft 22 搂9.20.19](https://www.ietf.org/archive/id/draft-ietf-moq-transport-22.html#section-9.20.19)
Expand Down
58 changes: 53 additions & 5 deletions drafts/draft-lcurley-moq-timestamp.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ informative:
This document specifies the transport-level use of the TIMESTAMP and TIMESCALE properties registered by {{loc}}, independent of the LOC container itself.
A track-level Timescale property establishes the units, and an object-level Timestamp property carries the presentation time of each object.
Exposing media time to the transport lets relays make consistent age-based decisions (e.g. dropping stale objects) without parsing the media container, and it remains consistent across hops regardless of buffering or jitter.
No new code points are requested: an endpoint implementing this document is on the wire indistinguishable from a LOC endpoint that carries only these two properties.
A negotiated subscriber max age bounds delivery of superseded groups without expiring the newest group.

--- note_Note_to_Readers

Expand Down Expand Up @@ -123,16 +123,55 @@ A receiver MUST NOT substitute a time for an untimed object, such as its arrival
Arrival time differs at every hop and changes when a route fails over, so a substituted time would not be the same timeline downstream.

Age-based dropping ({{age}}) does not apply to an untimed object.
A relay MAY instead drop a group of an untimed track once a later group arrived at it longer ago than the threshold, measured on its own clock.
A relay MAY instead drop a group of an untimed track once its immediate successor arrived at it at least the threshold ago, measured on its own clock ({{max-age}}).

## Age-Based Dropping {#age}
Given two objects on the same track, both with TIMESTAMP, a relay computes their relative age as the difference of their timestamps divided by the Timescale.
A relay serving a live subscription MAY drop an object whose age relative to the most recent object on the track exceeds a locally configured or application-supplied threshold, resetting the corresponding stream per {{moqt}}.
This decision is identical at every hop because it depends only on values embedded in the objects, not on arrival time.
A relay serving a live subscription MAY drop a group once a newer group supersedes it and either of these bounds reaches the subscriber's max age ({{max-age}}):

- Its reach, bounded by the first timestamp of its immediate successor, trails the live media edge by at least the max age.
- Its immediate successor arrived at this hop at least the max age ago, measured on the hop's clock.

The newest group has no successor and MUST NOT expire under either bound, including when the max age is zero.
Media time permits dropping an old backlog immediately; the hop's clock also bounds untimed groups and a stalled media timeline.

A relay MUST NOT use timestamps to reorder delivery beyond what {{moqt}} already permits; this property informs *dropping*, not transmission order.


# Subscriber Max Age {#max-age}

An endpoint declares that it understands subscriber max age with this Setup Option ({{moqt}} Section 10.3):

~~~
MAX_AGE Setup Option {
Type (vi64) = 0x915C4
Value (vi64) = 1
}
~~~

The value is reserved and MUST be ignored on receipt.
An endpoint MUST NOT send the MAX_AGE parameter to a peer that did not declare this option.
The Timestamp and Timescale properties themselves still require no negotiation.

~~~
MAX_AGE Parameter {
Type (vi64) = 0x915C6
Value (vi64) ; milliseconds
}
~~~

MAX_AGE is a subscriber parameter on SUBSCRIBE and REQUEST_UPDATE (SUBSCRIBE_UPDATE on older drafts).
Its value MUST be at most `2^53 - 1` milliseconds; a larger value is malformed.
Zero expires superseded groups immediately, but never the newest group.
On SUBSCRIBE, absence leaves the publisher's existing delivery policy unchanged.
On an update, absence leaves the subscription's budget unchanged; presence replaces it.
A publisher receiving MAX_AGE MUST apply the budget as specified in {{age}} and MUST NOT translate it into OBJECT_DELIVERY_TIMEOUT or SUBGROUP_DELIVERY_TIMEOUT.
Those timeouts can expire the newest group and do not express this policy.

A relay combining subscribers MUST request at least the most tolerant subscriber's max age upstream, bounded by the publisher's retention policy.
One subscriber's shorter budget MUST NOT tighten another subscriber's budget.


# Security Considerations
Timestamps expose the media timeline to relays, which is the point of the extension, but a relay still treats payloads as opaque and gains no access to media content.

Expand All @@ -143,7 +182,15 @@ Because age-based dropping only affects which objects a live subscription receiv

# IANA Considerations

This document requests no registrations.
This document requests these registrations in the "MOQ Setup Options" and "MOQ Message Parameters" registries, respectively:

| Value | Name | Reference |
|:------|:-----|:----------|
| 0x915C4 | MAX_AGE | This Document |

| Value | Name | Messages | Reference |
|:------|:-----|:---------|:----------|
| 0x915C6 | MAX_AGE | SUBSCRIBE, REQUEST_UPDATE | This Document |

Both properties it uses are already registered by {{loc}} in the "MOQ Properties" registry ({{moqt}} Section 15.8), and this document changes neither their code points nor their value encodings:

Expand All @@ -165,6 +212,7 @@ An earlier version of this document requested its own code points (`0x915C0` and
## moq-timestamp-02
{:numbered="false"}

- Add a negotiated subscriber MAX_AGE parameter for SUBSCRIBE and REQUEST_UPDATE, preserving the newest group and aggregating the most tolerant budget.
- Read TIMESTAMP only from Type `0x10`, independently of the moq-transport version.
- Replaced the wall-clock arrival-time fallback with untimed tracks: a track without TIMESCALE has no media time, and a relay forwards it untimed. A relay MAY drop one of its groups once a later group arrived longer ago than the threshold.
- On a track with TIMESCALE, a publisher MUST attach TIMESTAMP to every object with a payload, and a receiver treats one without it as malformed.
Expand Down
21 changes: 7 additions & 14 deletions js/net/src/connection/accept.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,26 +118,15 @@ async function acceptAlpn(
version: Ietf.IetfVersion,
wiring: SessionProps,
): Promise<Established> {
const { control, early, solicit, hidden, activeCount, cluster, auth, epoch } = await exchangeSetup(
transport,
version,
"moq-lite-js",
wiring,
);
// Spread whole, so every negotiated flag reaches the connection.
const setup = await exchangeSetup(transport, version, "moq-lite-js", wiring);

return new Ietf.Connection({
...wiring,
client: false,
url,
quic: transport,
control,
early,
solicit,
hidden,
activeCount,
cluster,
auth,
epoch,
...setup,
// v17+ uses NativeSession which manages its own request IDs; maxRequestId is unused.
maxRequestId: 0n,
version,
Expand Down Expand Up @@ -173,6 +162,7 @@ async function acceptSetup(
params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js"));
Extensions.intoSetup(params, wiring.extensions, version);
Ietf.hiddenIntoSetup(params);
Ietf.maxAgeIntoSetup(params);
Ietf.activeCountIntoSetup(params, version);

const server = new Ietf.ServerSetup({ version, parameters: params });
Expand All @@ -190,6 +180,7 @@ async function acceptSetup(
version,
solicit: Ietf.solicitFromSetup(client.parameters),
hidden: Ietf.hiddenFromSetup(client.parameters),
maxAge: Ietf.maxAgeFromSetup(client.parameters),
activeCount: Ietf.activeCountFromSetup(client.parameters, version),
});
}
Expand Down Expand Up @@ -235,6 +226,7 @@ async function acceptNegotiated(
params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js"));
Extensions.intoSetup(params, wiring.extensions, setupVersion);
Ietf.hiddenIntoSetup(params);
Ietf.maxAgeIntoSetup(params);
Ietf.activeCountIntoSetup(params, setupVersion);

const server = new Ietf.ServerSetup({ version: selectedVersion, parameters: params });
Expand All @@ -260,6 +252,7 @@ async function acceptNegotiated(
version: selectedVersion as Ietf.IetfVersion,
solicit: Ietf.solicitFromSetup(client.parameters),
hidden: Ietf.hiddenFromSetup(client.parameters),
maxAge: Ietf.maxAgeFromSetup(client.parameters),
activeCount: Ietf.activeCountFromSetup(client.parameters, selectedVersion as Ietf.IetfVersion),
});
} else {
Expand Down
19 changes: 5 additions & 14 deletions js/net/src/connection/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ async function negotiate(url: URL, session: WebTransport, wiring: SessionProps):
params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js"));
Extensions.intoSetup(params, wiring.extensions, setupVersion);
Ietf.hiddenIntoSetup(params);
Ietf.maxAgeIntoSetup(params);
Ietf.activeCountIntoSetup(params, setupVersion);

const client = new Ietf.ClientSetup({
Expand Down Expand Up @@ -360,6 +361,7 @@ async function negotiate(url: URL, session: WebTransport, wiring: SessionProps):
version: server.version as Ietf.IetfVersion,
solicit: Ietf.solicitFromSetup(server.parameters),
hidden: Ietf.hiddenFromSetup(server.parameters),
maxAge: Ietf.maxAgeFromSetup(server.parameters),
activeCount: Ietf.activeCountFromSetup(server.parameters, server.version as Ietf.IetfVersion),
});
} else {
Expand All @@ -377,26 +379,15 @@ async function handshakeAlpn(
version: Ietf.IetfVersion,
wiring: SessionProps,
): Promise<Established> {
const { control, early, solicit, hidden, activeCount, cluster, auth, epoch } = await exchangeSetup(
session,
version,
"moq-lite-js",
wiring,
);
// Spread whole, so every negotiated flag reaches the connection.
const setup = await exchangeSetup(session, version, "moq-lite-js", wiring);

return new Ietf.Connection({
...wiring,
client: true,
url,
quic: session,
control,
early,
solicit,
hidden,
activeCount,
cluster,
auth,
epoch,
...setup,
// v17+ uses NativeSession which manages its own request IDs; maxRequestId is unused.
maxRequestId: 0n,
version,
Expand Down
Loading
Loading