Skip to content

SDK behavior contract

Every Relay SDK, in any language, implements the same protocol pin and the same observable semantics. This document states those semantics without reference to any one language. The language READMEs describe how each API spells them. This file says what must be true, and vocabulary.md fixes the public names every SDK uses for the concepts named here.

protocol.json holds the machine-readable pins. Each SDK has a test that checks its constants against that file.

  • The MOQT version is the draft pinned in protocol.json, currently draft-ietf-moq-transport-22 with wire identifier moqt-22, and draft-ietf-moq-transport-20 (moqt-20) as a fallback. An SDK offers moqt-22, then moqt-20, and no other protocol identifier, and speaks whichever the relay selects. The two drafts differ on the wire only in the LOCATION_FILTER parameter, which the core encodes for the negotiated one. moq-relay selects moqt-22 from 0.15.0, so every relay version the SDKs support speaks it, but before 0.17.3 it frames LOCATION_FILTER the draft-20 way on moqt-22 too (draft-22 LOCATION_FILTER framing).
  • Authentication is a JWT supplied by the application. The SDK sends it as the jwt query parameter on the endpoint URL. An explicit jwt parameter already present on the URL takes precedence over a token callback, and the callback is not called. An empty jwt parameter (?jwt= or a bare ?jwt), including one among several jwt parameters, is an argument error (see “Arguments”) rather than a way to connect with no credential. The SDK does not treat it as absent. A whitespace-only value is not empty and is sent as given, as a token callback’s would be.
  • The endpoint is optional. A connect call given none dials the hosted Relay rooted at its token’s root, the token-rooted endpoint. A connection given an endpoint is unchanged.
  • SDK-generated logs and error text do not include the token, and SDK tooling does not persist it to disk. Tooling redacts the supplied token from browser diagnostics it forwards. The browser itself is outside that guarantee: Chromium can log the full authenticated WebTransport URL on handshake failure. Applications must sanitize captured diagnostics before storing or forwarding them. See ts/GUIDE.md, “Browser token exposure”.
  • Connecting has one deadline that covers token resolution and both handshakes, transport and MOQT setup.
  • The browser transport may end a silent connection after about 5 s while data is outstanding. The SDK reports it as TRANSPORT_ERROR in phase connection and cannot tell it apart from other losses. See browser transport limits.
  • The connection’s transport is the application’s choice, through the transport factory. The default stays the browser’s WebTransport (TypeScript) or native QUIC (Python, and @caprid/relay/node): the SDK never falls back to another transport by itself.
  • The TypeScript SDK’s opt-in @caprid/relay-websocket package carries MOQT on QMux over a WebSocket, TCP to the same host and port as the endpoint (https: becomes wss:), for networks that block UDP and browsers without a usable WebTransport. createWebSocketTransport() always dials it.
  • withWebSocketFallback() dials WebTransport first. If that handshake has not completed within webSocketDelayMs (default 250 ms, the Connection Attempt Delay of Happy Eyeballs, RFC 8305), it dials QMux over WebSocket alongside it, and a WebTransport handshake that fails sooner starts the WebSocket dial at once. The first dial to be ready carries the session and the other is closed. The connection fails only once both dials have failed, with the WebSocket dial’s failure. Where the runtime has no WebTransport that can negotiate protocols, it dials WebSocket at once. Each connection, a supervisor’s reconnects and rotations included, decides afresh. Nothing is remembered between connections. A WebTransport session that comes up and is then refused (for example over its credentials) is not retried over WebSocket.
  • Both transports run the same session. The differences come from the relay and from TCP (QMux over WebSocket). Streams share one TCP connection, so a lost packet holds up every stream (head-of-line blocking). Datagrams are unavailable. A credentials refusal arrives as a failed WebSocket handshake, reported as TRANSPORT_ERROR in phase connecting, not UNAUTHORIZED. A send stream’s close resolves once its FIN reaches the socket, not the relay (transport adapter).
  • The application can give the SDK a cancellation primitive, such as an abort signal or a cancellation token, that governs the connection’s entire lifetime rather than only setup.
  • Every connection exposes a way to observe its closure and the error that caused it, if any.
  • A GOAWAY from the relay ends the connection with an error. The application opens a replacement. Subscriptions still awaiting their first response end as terminated by the closure (see “Subscription lifecycle”). Accepted or cancelled requests do not receive a second initial response. The core’s own track source still fails pending requests through their error callback. Opt-in supervised connections reconnect. The primitives never do.
  • A session lasts until its credential expires, and no longer. moq-relay ends a session at its grant’s deadline: the JWT’s exp with the reference auth server (moq auth serve), and no later with Mux’s. A live session cannot take a new token: the relay ignores AUTHORIZATION_TOKEN on later requests, and revalidation re-checks the credential the session connected with (token expiry). Plan to replace a connection before its credential expires.
  • The token provider runs once per connection, while connecting. It returns a token, or a RelayCredential: the token together with expiresAt / expires_at, the time the relay stops honoring it. An empty token sends none, as before. A credential of the wrong shape is a TypeError. An invalid Date (TypeScript) or a naive datetime (Python) is a RangeError / ValueError.
  • Every connection exposes expiresAt / expires_at: the credential’s expiry when the provider gave one, otherwise the exp claim of the JWT it connected with (a bare token from the provider, or a jwt already on the URL), read without verifying it. A token that is not a JWT with a numeric exp leaves it unknown (undefined / None). The primitives only report it. They never replace a connection.
  • At that time the relay closes the session with MOQT session termination code UNAUTHORIZED (0x2), reason unauthorized. Both SDKs report it as UNAUTHORIZED in phase connection on the connection’s closure, and operations still pending fail with it. On moq-relay 0.17.0 the close arrives about 80 ms after exp. One exception: a TypeScript publisher in Chromium sees the relay abort its control stream before WebTransport reports the close code, and reports TRANSPORT_ERROR in phase connection instead (issue #279). A Chromium subscriber reports UNAUTHORIZED.
  • Supervised connections replace their connection make-before-break before the credential expires. See rotation.

Branch on code and phase, not message wording. SDK lifecycle errors use these stable codes:

Code Application response
CONNECTION_TIMEOUT Connecting exceeded its deadline; consider a new connection with backoff.
WRITE_TIMEOUT Inspect the phase below, then choose the next object or group explicitly.
DELIVERY_TIMEOUT An object outlived its delivery timeout before it reached the transport. The data is stale: do not retry it as it is; choose fresh data or the next random-access point.
CLOSED The handle is closed; use an open connection, track, or group.
WRITE_PENDING Await the outstanding operation or finish/abort the open group before starting another.
NAMESPACE_NOT_SOLICITED Received namespace solicitations do not cover this publisher; check the namespace and token configuration. A later matching solicitation can restore demand.
NAMESPACE_ENDED The relay ended the namespace announcement; open a replacement publisher if appropriate.
SUBSCRIPTION_REQUEST_FAILED An async subscription request failed; inspect its preserved MOQT request error and the connection state.
SUBSCRIPTION_OVERFLOW The event buffer filled; the subscription was cancelled. Choose a suitable capacity or faster consumption before subscribing again.
PREFIX_OVERLAP namespaceEvents / namespace_events was given a prefix that equals, contains or falls under one this connection is already discovering (draft-22 §9.15). Nothing was sent. Widen one iterator’s prefix and filter, end the other iterator first, or use another connection.
UPDATE_QUEUE_FULL More than eight updates were issued before the subscription was accepted; await acceptance (onSubscribed / on_subscribed) before updating again. Nothing was sent for the refused update.
SUBSCRIPTION_CHANGED Upstream demand ended or changed during an operation; choose fresh data or a new random-access point. It can be group-level: the relay may stop one group’s stream while its subscription continues, so check hasDemand / has_demand rather than assume demand ended.
RECONNECTING A supervised publisher had no live generation for the operation, or the connection under it ended (phase connection). Nothing is replayed: write the next object once the publisher is live again.
GOAWAY The relay requested migration; open a replacement connection.
TRANSPORT_ERROR Inspect the original cause and connection state before deciding whether to reconnect or publish again. In TypeScript, a connecting-phase TRANSPORT_ERROR usually means the browser abandoned the handshake, so a retry policy should treat it like CONNECTION_TIMEOUT (see browser transport limits).
TRANSPORT_UNAVAILABLE Supply a transport implementation in a runtime without browser WebTransport; in Node, import from @caprid/relay/node with @caprid/relay-quic installed (TypeScript).
UNAUTHORIZED The relay refused the connection’s credentials: no token, a bad, malformed or expired one, or an endpoint path the token does not grant. Supply a valid token before connecting again. In phase connection, the relay ended a live session because its credential expired or its grant was revoked (credential lifetime); connect again with a fresh token. TypeScript reports it in browsers and over native QUIC (@caprid/relay/node); against relays before moq-relay 0.17.0 a browser does not see the close, so there it arrives as TRANSPORT_ERROR (#14).

connecting covers token resolution and connection setup. connection marks a connection-wide failure. It makes no claim about individual writes that were in flight. subscription marks an async subscription request failure or local event-buffer overflow. Publishing records waiting-for-demand, opening-stream, writing, or closing before entering each step. A WRITE_TIMEOUT or DELIVERY_TIMEOUT in waiting-for-demand means this attempt sent no object bytes. For timeouts or transport/subscription failures in a transport phase, conservatively allow for partial delivery. closing can fail after all object bytes were sent. Opening a group can start sending its header before the first object write. A phase records the failure’s origin, and a later call on the failed handle can report that same error.

For WRITE_PENDING and calls rejected because a handle is already closed, phase describes the refused operation. That call sends nothing. A write or group refused with CLOSED because its track or group is already closed reports phase waiting-for-demand, the phase that means nothing was sent. CLOSED can also interrupt an operation already in flight. It then carries that operation’s phase, so the code alone does not rule out partial delivery.

No code or phase promises safe replay. Account for previous attempts, data freshness, and your application’s deduplication policy. Successful writes mean transport acceptance, not viewer acknowledgment. The SDK does not automatically retry or reconnect, outside the opt-in supervised connections. UNAUTHORIZED is the one code that says the relay refused the credentials: the relay closed the session with MOQT session termination code UNAUTHORIZED (0x2; MALFORMED_AUTH_TOKEN 0x16 and EXPIRED_AUTH_TOKEN 0x18 map to it too). Draft-22 §12.2 defines it as refusing to establish a session, but moq-relay also closes a live session with it when the session’s grant expires or is revoked, so both SDKs map it in any phase: connecting before the relay’s SETUP, connection after it (#227, #261). A close in the turn SETUP arrives stays TRANSPORT_ERROR. From moq-relay 0.17.0 a browser sees the relay’s close (CLOSE_WEBTRANSPORT_SESSION with code 2 and reason unauthorized), so TypeScript reports it in browsers as well as over native QUIC. Against older relays the browser lost the close, and a refusal arrived as TRANSPORT_ERROR (#14). Neither another connection failure nor an uncovered namespace proves authentication was denied. A subscription outside the token’s grant is refused per request, through onError / on_error (MOQT UNAUTHORIZED, 0x1), and leaves the connection open.

Clean connection shutdown still resolves closed without an error. Subsequent operations fail with CLOSED. After a failed connection, closed and subsequent connection operations expose the same failure. Caller cancellation reasons and token-provider exceptions retain their identity. Argument and serialization errors keep their idiomatic language types. Subscription onError / on_error callbacks retain the separate MOQT RequestError contract and numeric protocol codes.

Both SDKs export one Relay error type, RelayError, with a stable code discriminant and a phase field, plus the three subscription errors that carry payload (SubscriptionRequestError, SubscriptionOverflowError, SubscriptionConnectionClosedError). There are no other subclasses. Callers branch on code. Human-readable messages are not a compatibility contract. Wrapped transport and session failures retain the original cause: TypeScript in RelayError.cause, Python chained as __cause__. Transport-layer exceptions are not Relay errors. A Relay operation that fails on the transport raises TRANSPORT_ERROR with the transport exception as its cause.

Local argument errors remain TypeError/RangeError in TypeScript and TypeError/ValueError in Python: a value of the wrong type or shape is a TypeError in both languages. A value of the right type outside its bound is a RangeError in TypeScript and a ValueError in Python. The Arguments table lists the rules. A refused concurrent operation uses WRITE_PENDING and does not cancel the active operation. Caller-provided cancellation reasons bypass wrapping, including non-Error JavaScript values and Python task cancellation. Connection failures are shared with pending publishing operations. Their connection phase does not establish whether those writes sent bytes.

The values an application hands the SDK that end up on the wire are checked in their original container, copied once, and only then used. Both roles apply the same rules, so a namespace the publisher accepts is one the subscriber accepts. Arguments are checked before connection or slot state at every entry point: a bad argument is reported the same way on a closed connection or a busy track, and it never reaches the token provider, the transport factory, a request id or a stream. The token root is the exception: the provider supplies it, so it is checked after the provider runs, and still before the transport factory. The portable cases live in vectors/arguments.json and run in every SDK’s suite. The table also lists the local checks that stay where they are consumed (deadlines, track registration, object status, event capacity). They follow the same class rule, and only track registration is checked after connection state, because it depends on the connection’s registry.

Argument Rule TypeError in both languages RangeError (TypeScript) / ValueError (Python) Checked at
Namespace 1 to 32 non-empty string fields in an array (TypeScript) or a list or tuple (Python); sent verbatim, never split or joined on /. The 32-field limit is limits.maxNamespaceFields in protocol.json. A discovery prefix follows the same rule. any other container, including a single string; no fields; a field that is not a non-empty string more than 32 fields connectRelayPublisher / connect_relay_publisher; subscribe, subscribeEvents / subscribe_events, trackSource.subscribe / track_source.subscribe; the prefix of namespaceEvents / namespace_events
Track name A non-empty string, in both roles. anything else registerTrack / register_track; the three subscriber entry points
Properties A list of {type, value} pairs. type is a non-negative integer; an even type carries a non-negative integer, an odd type carries bytes, which are copied. Integers are bounded by what the language’s core varint carries: 2^53-1 in TypeScript, 2^64-1 in Python. Each byte value is at most limits.maxPropertyValueBytes (its 16-bit wire length). A track’s properties encode to at most limits.maxTrackPropertiesBytes (65,525 bytes, the room SUBSCRIBE_OK leaves them), hold at most relay.limits.maxTrackProperties (64) pairs, and keep Default Publisher Priority (0x0E) at most 255 and Default Publisher Group Order (0x22) at 1 or 2 (trackProperties, draft-22 §10.4 and §10.5), because otherwise the track registers but every subscribe to it fails while the publisher’s writes time out. An object’s properties on write encode to at most relay.limits.maxObjectPropertiesBytes (64 KiB), because the relay drops a larger object without an error to either side. The SDK measures the encoding with the core’s own encoder before any slot or stream is taken. not a list; a pair without the shape; a type or integer value that is not a non-negative integer the varint carries; bytes where an integer belongs or an integer where bytes belong a byte value longer than its wire length; a list whose encoding exceeds its bound; track properties with more than 64 pairs, or a 0x0E or 0x22 value draft-22 rules out registerTrack / register_track; write on a track or group writer
Endpoint A string holding an absolute https URL with a host and no fragment or userinfo. A jwt query parameter, when present, is not empty; this holds for every jwt parameter when there are several. Or no endpoint at all (undefined in TypeScript, None in Python), when the token roots it (Token root). not a string, including null in TypeScript; a string that does not parse as an absolute URL, including an https URL with no host another scheme; a fragment or userinfo; an empty jwt parameter connectRelaySubscriber / connect_relay_subscriber, connectRelayPublisher / connect_relay_publisher
Token root With no endpoint, a token provider whose token is a JWT: three .-separated parts whose second is a JSON object in unpadded base64url, read without verifying the token. Its root claim is a string. The root without its leading and trailing /, split on /, follows the Room rule; an empty root roots nothing (token-rooted endpoint). Checked after the provider runs, before the transport factory. no token provider; no token or an empty one; a token that is not such a JWT; a root claim missing or not a string; as Room, such as an empty field (acme//rooms) as Room: a field with a character the Room rule refuses, . or .., or more than 32 fields the four connect calls with no endpoint
Room The endpoint follows the Endpoint rule and the room the Namespace rule. Each room field is also made only of the characters a URL path carries unchanged and the relay reads back as the same field, A–Z, a–z, 0–9 and -._~!$&'()+,;=:@, and is not . or .. (room-rooted endpoints). Checked endpoint first, then room. as Endpoint and Namespace as Endpoint and Namespace; a room field with any other character, such as a space, %, /, *, ?, #, \ or a non-ASCII character, or a field that is . or .. roomEndpoint / room_endpoint
Deadline A number in (0, 2147483647] milliseconds in TypeScript, (0, 2147483.647] seconds in Python: the largest delay a JavaScript timer holds, and the same bound in both SDKs. The connection, request and write deadlines share one check. not a number, including a boolean zero, negative, NaN, infinite, or above the bound connectTimeoutMs / connect_timeout on both connect calls; requestTimeoutMs / request_timeout; writeTimeoutMs / write_timeout on registerTrack / register_track
Track registration A track name not yet registered on this publisher connection, including after its writer closed. Checked after the track name, properties and deadline, and after connection state. a name already registered registerTrack / register_track
Media track timing A media kind, 'audio' or 'video'; every duration a positive integer number of microseconds (a safe integer in TypeScript, an int other than a boolean in Python) of at most 2147483647000, the deadline bound in whole milliseconds. Video needs a keyframe interval, and a target latency is at least the keyframe interval. A publisher priority, when given, is an integer from 0 to 255. a kind that is not a string; video without a keyframe interval; a duration or publisher priority that is not a number (TypeScript) or not an int, including a boolean and any float (Python) another kind; a target latency below the keyframe interval; a duration that is zero, negative or above the bound, and in TypeScript also fractional, NaN or infinite; a publisher priority below 0 or above 255, and in TypeScript also fractional, NaN or infinite mediaTrackProperties / media_track_properties
Media subscribe parameters A media kind, 'audio' or 'video'; overrides that are message parameters (an object in TypeScript, MessageParameters in Python); a subscriber priority in them, when given, is an integer from 0 to 255. a kind that is not a string; overrides that are not an object (TypeScript) or not MessageParameters (Python); a subscriber priority that is not a number (TypeScript) or not an int, including a boolean and any float (Python) another kind; a subscriber priority below 0 or above 255, and in TypeScript also fractional, NaN or infinite mediaSubscribeParameters / media_subscribe_parameters
Object status normal, end-of-group or end-of-track; defaults to normal. not a string any other string write, writeText, writeJSON on a group writer
Event capacity A positive integer (a safe integer in TypeScript); defaults to 64. not a number, including a boolean zero, negative, fractional, NaN, or infinite subscribeEvents / subscribe_events, namespaceEvents / namespace_events
Payload A byte sequence: any ArrayBufferView in TypeScript; any object supporting the buffer protocol in Python (bytes, bytearray, memoryview, …). Copied once. At most limits.maxObjectPayloadBytes. a string, an ArrayBuffer, a list of integers, anything else longer than the object payload limit write, writeText, writeJSON on a track or group writer

Messages are not a compatibility contract. The class and the point of rejection are.

  • Namespaces are ordered tuples of fields sent verbatim. The SDK adds no prefix and performs no normalization. The relay resolves a namespace relative to the endpoint URL’s path. With no path (https://relay.mux.global), fields are absolute: the token’s root prefix is part of the namespace the application supplies. With a path the token grants, such as a room-rooted endpoint whose path is the token’s root (https://relay.mux.global/acme/rooms/42), the path is the prefix and the application supplies only the fields below it: ['p', 'a'] there addresses what ['acme', 'rooms', '42', 'p', 'a'] addresses on the bare host. The room-rooted endpoint is the recommended form: namespaces leave the token’s root out, and a connection is confined to the path it dialed. The bare host suits a client that spans several rooms under one grant, such as a lobby that discovers every room. Both forms are supported, and they meet on the relay: a subscriber on either form receives a track published on the other, and discovery on a room-rooted connection names namespaces relative to its path, as its prefix is. A connection to a room-rooted endpoint cannot address anything outside that room: an absolute namespace there names one below the room (['acme', 'rooms', '42', 'acme', ...]), so a subscribe reaches only a publisher that announced that longer namespace, and is refused as unroutable when there is none. A .. field is an ordinary field. An endpoint path the token does not grant fails the connection with UNAUTHORIZED, where on the bare host a namespace outside the grant fails only its own subscribe, and its publish never announces (issue #119). The token remains the access boundary: the path confines a connection, not its token, which can dial any path it grants. Measured on *.relay.mux.global (moq-relay 0.17.0) on one relay and across regions, on 2026-10-05 and again on 2026-10-20 in both SDKs over every transport, which the live smoke test now repeats (issue #266). Namespaces and track names follow the Arguments table in both roles: an invalid one is rejected locally, before connecting a publisher or sending a subscription.
  • roomEndpoint(endpoint, room) / room_endpoint(endpoint, room) builds the room-rooted endpoint, so an application does not assemble the URL itself. Given only the room, it roots DEFAULT_ENDPOINT (room-rooted endpoints). A connect call given no endpoint dials the room-rooted endpoint of its token’s root on DEFAULT_ENDPOINT (token-rooted endpoint), so the shortest call is the recommended form.
  • A known track can be subscribed directly. The SDK does not interpret payloads and does not assume a track carries audio, video, or timestamps.
  • Object callbacks run as the stream layer delivers objects. The callback path adds no application queue, no ordering across streams, no automatic payload decoding, no clock synchronization, and no media scheduling.
  • Each new subscription is independent. Re-subscribing does not resume the preceding request. Relay caching and location filters can deliver the same object again across subscriptions, including after a publisher restart. Applications own retry backoff and any cross-subscription continuity policy. See publisher restarts and resubscription.
  • A subscription without a location filter, whether it sends no LOCATION_FILTER or an explicit zero-field one, carries the draft’s meaning: it passes all Objects published or received via upstream subscriptions (draft-22 Section 3.3.1). Whether that reaches back into what a relay cached before the subscription is the relay’s choice. The Mux relay (moq-relay 0.17.0) starts it at the beginning of its latest group, and starts a Next Object filter after its largest object, skipping the rest of that group (late subscribers). A subscriber that needs earlier groups asks for them with a relative-group or absolute filter. An absolute start is served from the requested group while the relay still caches it. The SDK sends the filter it is given and adds none.
  • The SDK sends every subscription parameter the application sets. Which ones take effect is the relay’s choice: moq-relay 0.17.0 ignores GROUP_ORDER, NEW_GROUP_REQUEST and the delivery timeouts on a SUBSCRIBE, and refuses FORWARD = 0 and range filters (subscription parameters). The SDK does not warn about an ignored parameter.
  • One parameter is the exception: MAX_AGE (0x915C6), the MoQ Timestamp extension’s subscriber max age, which maxAge / max_age carries in milliseconds (0 to 2^53-1). The extension forbids sending it to a peer whose SETUP did not declare its MAX_AGE option (0x915C4), and on draft 17 and later a relay that does not know the parameter fails the SUBSCRIBE. So both SDKs send it on SUBSCRIBE and REQUEST_UPDATE only when the relay’s SETUP declared the option (any value: the value is reserved), leave it off otherwise and on a request issued before the relay’s SETUP arrived, and never send it on FETCH, TRACK_STATUS or SUBSCRIBE_NAMESPACE, for which it is not defined. The caller’s parameters are not changed. maxAgeSupported / max_age_supported on the subscriber connection says whether the relay declared it; it is known before connectRelaySubscriber / connect_relay_subscriber returns and does not change. Neither SDK declares the option itself: it would invite a relay to send MAX_AGE to an SDK publisher, which does not act on it. moq-dev main declares it and enforces the budget per subscription; moq-relay 0.17.x does neither (subscriber max age). Implemented in both SDKs.
  • The request timeout bounds only the subscription’s initial response. An accepted subscription has no application inactivity deadline. The SDK schedules no application-level keepalive: keeping the transport connected is left to peer traffic and the transport’s own keepalive, which draft-22 Section 15.6.1 names as one way to keep an idle session open. In a browser that is the browser’s own QUIC PING, and @caprid/relay-quic has quinn send a keep-alive every 4 seconds. Python’s native QUIC transport sends a QUIC PING once it has sent nothing for 5 seconds. That PING also lets a receive-only connection survive a NAT rebinding: the relay learns the new address from it. The Python transport uses aioquic’s default 60-second local idle timeout, subject to peer negotiation.
  • update() sends REQUEST_UPDATE, which the draft allows only on an accepted subscription (draft-22 Section 3.1). An update issued before acceptance is queued in order and sent when SUBSCRIBE_OK arrives. At most eight wait, and a further one raises UPDATE_QUEUE_FULL without sending anything. A subscription that ends before acceptance discards its queue, and its ending is what the application observes. An update on an ended subscription raises CLOSED. Each update consumes its own Request ID. A relay that acknowledges an update with REQUEST_OK reaches onUpdateOk / on_update_ok. One that refuses it with REQUEST_ERROR ends the subscription (issue #94). Implemented in both SDKs. moq-relay, however, never answers a REQUEST_UPDATE: any byte on the request stream after SUBSCRIBE_OK ends the subscription with PUBLISH_DONE TRACK_ENDED, so against it an update ends delivery (relay interop deviation 4). REQUEST_UPDATE carries no track either, so it cannot change tracks against any relay. A switchable subscription changes the track, the filter or the priority with a new subscription, make-before-break.
  • A subscribe for a namespace the relay has not seen announced is rejected, not parked: moq-relay 0.15.8 and 0.17.0 answer request error 16 (0x10, DOES_NOT_EXIST) with reason unroutable, and moq-relay through 0.15.1 answered its literal 404. The same code 16 with reason not found means the namespace is announced but the track is not, which is not the race. The relay routes subscribes to a namespace only once it has processed the publisher’s announce, about one round trip after the publisher’s announced settles, and later on a subscriber’s relay that learns the route from a cluster peer (announce propagation), so a subscribe that races the announce is rejected the same way. The SDK reports that rejection like any other, through subscribed and on_error, and does not retry a subscribe. An application avoids the race by subscribing once its discovery reports the namespace announced, or else retries the rejection within its own budget rather than treating it as fatal. A supervised subscriber’s want retries it: 16 unroutable and the older 404 without reporting them, never treating 16 not found as the race (issue #136).
  • The SDK surfaces every subgroup reset it receives, and connection closure, explicitly. It surfaces a reset when the relay forwards it, which may be well after the publisher’s abort: moq-relay 0.15.8 forwarded each one only when the publisher’s next group began, about 0.5 s later, or 3 s after a 3 s publisher pause. It cannot surface a reset the relay does not forward: a relay may drop a subgroup that was reset upstream before it began forwarding it (seen before 0.15.8, unverified on it), and then the subscriber sees neither objects nor a reset for that group, only a gap in group IDs that cannot be told apart from a group never published (subgroup resets). There is no exactly-once or gap-free delivery promise.
  • The publisher’s done notification is a control message and may arrive before trailing objects still in transit. It does not end the subscription: the request stays open until the application cancels it or the connection ends, and the SDK does not resubscribe on its own. It is not a prompt publisher-death detector. The SDK reports it when the relay sends it. On moq-relay 0.17.0 a publisher’s clean close, or its connection closing, reaches existing subscriptions within 0.1 s, as status 2 after close() and as 0 when the connection drops without MoQ cleanup. A crashed publisher’s arrives as status 0 only after the relay’s QUIC idle timeout, about 11 s (it was about 30-35 s on 0.15.8, whose idle timeout was 30 s).
  • Replacing a publisher on the same namespace does not hand off existing subscriptions: moq-relay 0.15.8 and 0.17.0 never move them to the replacement. After a clean end, new subscriptions fail with request error 16 unroutable until the replacement announces, and are served by it at once after that. After a crash, the relay still holds the dead publisher’s announcement until its QUIC idle timeout (about 11 s on 0.17.0), so new subscriptions are accepted and served only the old publisher’s cached last object. On 0.17.0 that can continue after the replacement announces, until the timeout, because the relay does not prefer the newer announcement (duplicate announce). Re-subscribing to a track that finished while its namespace stays announced replays the cached last group and another done, one round trip later each time, because the relay keeps a finished track cached by design. Once nobody has read it for about 30 s, moq-relay 0.17.0 forgets it and a subscribe gets request error 16 not found. Do not re-subscribe to a track the publisher ended. A done that followed the subscription’s own update() is different: the track is live, and re-subscribing is how to apply the change against moq-relay (publisher restarts). Applications own freshness checks and bounded recovery. See publisher recovery.
  • Subscriptions cancel independently of each other. The application owns connection shutdown.
  • subscribe() returns a RelaySubscription: the request handle plus two awaitables. subscribed settles when the relay answers, and ended settles once when the subscription can no longer deliver. Neither implies exactly-once or gap-free delivery, and publisher completion settles neither. The table below is the shared contract for both.

The application’s subscription (see CONTEXT.md) is pending until the relay answers, accepted after SUBSCRIBE_OK, and ended once it can no longer deliver. Every ending is recorded as a SubscriptionEnd with a kind (rejected, terminated, cancelled), the protocol cause (request-error, connection-closed, cancelled), whether the subscription had been accepted, and the error. Implemented in both SDKs.

State Event subscribed ended Callbacks
pending SUBSCRIBE_OK resolves with the acceptance info stays pending onSubscribed
pending REQUEST_ERROR: rejection, request timeout, or the request stream failing before an answer rejects SubscriptionRequestError, preserving the request error resolves kind: rejected, cause: request-error onError
pending connection ends rejects SubscriptionConnectionClosedError, preserving the connection error as its cause when known resolves kind: terminated, cause: connection-closed, accepted: false nothing on the subscription; the connection’s closed carries the reason
pending cancel(reason) rejects with reason, or a CLOSED Relay error when none was given resolves kind: cancelled, with the reason as error nothing
accepted objects, subgroup end or reset, state notify, update acknowledged (moq-relay never acknowledges one, see deviation 4), GOAWAY on the request stream settled stays pending the matching callback
accepted PUBLISH_DONE settled stays pending: publisher completion is not an ending, trailing objects can still arrive onPublisherDone
accepted REQUEST_ERROR, which is how a relay refuses an update() (issue #94); moq-relay sends none and ends the subscription with PUBLISH_DONE instead (deviation 4) settled resolves kind: terminated, cause: request-error, accepted: true onError
accepted connection ends settled resolves kind: terminated, cause: connection-closed, accepted: true nothing on the subscription; the connection’s closed carries the reason
accepted cancel(reason) settled resolves kind: cancelled nothing; trailing objects stop
ended anything, including a second cancel() settled settled, unchanged nothing; update() raises CLOSED; the events iterator has already raised or reached EOF

The core fails a pending request before it reports the closure that killed it: in the same turn when the session notices the closure, or turns earlier when the transport fails its streams before the session learns of it. A pre-acceptance request failure is therefore never recorded before the turn ends, and one that arrives after the transport has already ended is left to the closure, so the closure is the ending the subscription describes, with the connection error preserved. A genuine rejection reaches subscribed, ended and onError one turn after the core delivers it. A REQUEST_ERROR after acceptance is recorded at once.

Python asks the transport whether it has already ended. A browser’s WebTransport reports its end only through the closed promise, and Chromium settles it a task or more after it aborts the request stream of a connection lost while that stream opens (issue #102). So TypeScript holds a pre-acceptance failure the core synthesized from a stream event (FIN before a response, a stream reset or a failed stream open, but not the response timeout) until the transport’s closed settles, and records it as a rejection only if the connection is still up 250 ms later. A REQUEST_ERROR message and the response timeout keep the one-turn hold.

A readiness awaitable nobody awaits cannot surface as an unhandled rejection: the SDK pre-handles it. Awaiting it later still observes the same result.

roomEndpoint(endpoint, room) / room_endpoint(endpoint, room) returns the room-rooted endpoint for a room, the namespace prefix its path names (issue #348). Given only the room, roomEndpoint(room) / room_endpoint(room) roots DEFAULT_ENDPOINT, the relay.defaultEndpoint pinned in protocol.json. It is a pure function: it reads no token, opens no connection and changes nothing about connect*. The cases live in vectors/room-endpoint.json and run in every SDK’s suite.

  • Validation. The endpoint follows the Endpoint rule and the room the Namespace rule of the Arguments table, with the same error classes, endpoint first. A room field must also be one or more of A–Z, a–z, 0–9 and -._~!$&'()+,;=:@, and not . or ... Anything else is a RangeError (TypeScript) or ValueError (Python) that names the field.
  • No encoding. The helper percent-encodes nothing. moq-relay takes each segment of the path as a namespace field without percent-decoding it, so a%20b, a%25b, a%2Fb and %C3%A9 root a connection at fields that still hold the % sequence, not at a b, a%b, a/b or é (endpoint paths). An encoded / is therefore neither a separator nor a refusal: it is part of a literal field. A browser’s URL parser always encodes a space or a non-ASCII character and folds . and .. segments into the path, and moq-relay refuses a path containing * with UNAUTHORIZED. No URL can root a connection at a field like these, so the helper refuses them. A namespace with such a field is still valid, and the application reaches it from the bare host with absolute fields.
  • Path. The result keeps the endpoint’s scheme, host and port, as the language’s URL parser writes them. It takes the endpoint’s path without its trailing slashes and appends / and each room field, so https://relay.example and https://relay.example/ give the same URL. An existing path stays in front of the room: with https://relay.example/acme and ['rooms', '42'] the result is https://relay.example/acme/rooms/42.
  • Query. The endpoint’s query is kept unchanged after the new path, including a present but empty ?. A jwt parameter stays in the query, never in the path, and the connect calls read it there as usual. The endpoint has no fragment to keep, because the Endpoint rule refuses one.

A connect call given no endpoint (url left out in TypeScript, None in Python) dials the hosted Relay rooted at its token’s root (issue #374). This holds for connectRelaySubscriber, connectRelayPublisher, connectSupervisedSubscriber and connectSupervisedPublisher, and their Python spellings. The cases live in vectors/token-rooted-endpoint.json and run in every SDK’s suite.

  • Endpoint. The connection runs the token provider as usual, then reads the token’s root claim without verifying the token. The relay verifies it, so a root the token was not signed with fails at connect with UNAUTHORIZED, as a wrong path does. The root is a /-separated path: without its leading and trailing /, its fields are the room, and the connection dials what roomEndpoint(DEFAULT_ENDPOINT, fields) / room_endpoint(DEFAULT_ENDPOINT, fields) returns, with the token in jwt. Namespaces are then relative to the root, as on any room-rooted endpoint. An empty root, a grant of the whole relay, dials the bare DEFAULT_ENDPOINT: namespaces relative to an empty root are absolute.
  • Credential. A RelayCredential roots the endpoint at its token’s root. The token’s expiry is read as on any connection (credential lifetime).
  • Failures. A missing token provider, no token, a token that is not a JWT with a JSON object payload, a root claim that is missing or not a string, and a root the Room rule refuses all fail locally, before the transport factory runs, by the Token root row of the Arguments table. The message says to pass the endpoint instead.
  • Supervisors. A supervised connection given no endpoint keeps the endpoint its first connection dialed once that connection is up, and every reconnect and rotation dials it with a fresh token. A later token whose grant does not cover that path fails UNAUTHORIZED instead of moving the connection to another room. Until the first connection is up, nothing has joined a room, so each attempt roots the endpoint at its own token.
  • Scope. root is moq-token’s claim, which the hosted Relay’s tokens carry. Any other relay, or a room other than the token’s root, takes an explicit endpoint, which the connection uses as given.

The Relay product API provides subscriber.subscribeEvents(options, { capacity, signal }) in TS and subscriber.subscribe_events(namespace, track, capacity=..., cancellation=...) in Python. These create and own a new subscription immediately. They yield typed data and lifecycle events, using the same payload objects as callbacks. The generic MoqTrackSource and existing subscribe callbacks remain available and retain their synchronous delivery model. The handle’s subscribed and ended awaitables (see the subscription lifecycle table above) are the readiness and termination signals. The iterator’s raise or EOF is its own. It follows the product vocabulary from #63. Its explicit publisherDone / publisher_done event distinguishes publisher completion from final delivery.

  • capacity defaults to 64 events and must be a positive integer (a safe integer in TS). Every queued object and lifecycle event consumes one slot. This is an event-count limit, not a byte budget: an object may contain up to 16 MiB of payload and 1 MiB of encoded properties, plus decoded metadata overhead. Choose a smaller capacity for large objects. Protocol decoding limits also bound individual control events. The queue owns at most capacity event references. Objects retained by application code and transport buffers are outside this limit. An event handed directly to a waiting reader is delivered and no longer counts against queue capacity, even before that reader resumes. Later teardown cannot retract that handoff.
  • Overflow fails immediately with SubscriptionOverflowError, discards the whole queue, cancels only the owned request and its active subgroup readers, and detaches observers. It does not silently drop objects and continue. Buffered data is also discarded on any other termination. These limits provide local buffering, not transport-wide backpressure.
  • Events preserve callback arrival order. They do not sort groups or invent an order across streams. No exactly-once, gap-free, or fully drained delivery guarantee is implied.
Event / condition Iterator result
Subscription accepted subscribed, with SubscribeOk
Object (including protocol object status) object, with MoqtObject
Publisher control completion publisherDone / publisher_done, with PublishDone, including status, stream count and reason; iteration stays open for trailing objects
Subgroup FIN subgroupEnd / subgroup_end; only that subgroup ended
Subgroup reset subgroupReset / subgroup_reset; a gap may have occurred; iteration continues
Publisher state change stateNotify / state_notify, with changed parameters
Update acknowledged updateOk / update_ok
Request GOAWAY goaway, with relocation details; informational, no automatic retry
Request rejection, initial-response timeout, or initial request failure Raises SubscriptionRequestError, preserving requestError / request_error
Connection ends at any point, including a clean local close Raises SubscriptionConnectionClosedError, preserving the connection error as cause / __cause__ if available; never a claim of drained EOF
Iterator close() / TS return() / Python aclose() Discards buffered events, cancels the owned subscription, pending reads settle as clean EOF
Per-iterator abort signal / cancellation token Discards buffered events and raises the supplied reason (or the platform’s default cancellation reason)

Before acceptance, a connection failure reaches the core’s request-error callback before the connection observer. The subscription holds that failure until the turn ends (see “Subscription lifecycle”), so the iterator raises SubscriptionConnectionClosedError with the connection exception preserved, the same as after acceptance, rather than a SubscriptionRequestError carrying the core’s reason string.

The iterator errors are RelayError subclasses with stable codes: SubscriptionRequestError uses SUBSCRIPTION_REQUEST_FAILED, and SubscriptionOverflowError uses SUBSCRIPTION_OVERFLOW, both in the subscription phase. SubscriptionConnectionClosedError uses CLOSED in the connection phase and retains the underlying connection failure as its cause when present. The callback API still receives unwrapped MOQT RequestError values.

A failure remains observable on subsequent reads. The first termination wins. Do not close the shared connection merely to stop this consumer. Publisher completion never auto-cancels or resubscribes. Choose a timeout or explicit application policy if no more data is expected.

Only one pending iterator read is supported. A concurrent read fails without disrupting the first. It is a state error, not an argument error: a plain Error in TypeScript and a RuntimeError in Python, and not a RelayError. There is no background consumer task. TS for await calls return() on break or a consumer-body exception. Use a finally { events.close(); } if cancelling while the body awaits other work. A per-iterator signal can also interrupt a pending read. Python requires async with events: (or try/finally: events.close()) around async for for deterministic cleanup on early break, consumer exception, or task cancellation in the loop body: Python’s loop alone does not call aclose(). Cancelling a Python task blocked in anext() also closes the iterator. Consequently, asyncio.timeout() or asyncio.wait_for(anext(events), ...) expiring cancels the subscription. The caller receives TimeoutError and subsequent reads return EOF. Use those deadlines to end consumption, not to skip an idle interval and resume the same subscription. In TS, Promise.race does not cancel the losing read: use the iterator’s abort signal to end consumption, or retain and await that same pending read before requesting another. Consumer exceptions propagate to the caller. The SDK never retries or swallows them. Stop using abandoned iterators explicitly. Garbage collection is not the ownership policy. Connection observation and cancellation listeners are removed on teardown, and pending reads settle.

Subscription callbacks must be synchronous in both APIs, including the generic core. A declared async callback is rejected with TypeError during subscribe, before allocating a request ID. For a synchronous function that returns an awaitable dynamically, Python emits RuntimeWarning, closes a returned coroutine, or cancels a returned Future/Task and observes its eventual exception. Other custom awaitables are diagnosed and discarded without running them. TS reports a TypeError to console.error once per handler per subscription and observes every returned promise’s rejection. JS promises cannot be cancelled, so already-started work may continue. Delivery never awaits these results, and such callbacks are unsupported. Ordinary synchronous return values remain ignored, and ordinary synchronous callback exceptions retain the existing callback error behavior. Use the iterator for awaited work. If starting independent work deliberately, use a synchronous callback that returns nothing and arrange its task lifetime and error handling explicitly.

Handlers are validated and snapshotted when subscribe is called. Set them all before subscribing. Later assignments to the original handler object do not change an existing subscription. Request rejection and local/session teardown now cancel the request stream and active subgroup readers. Teardown suppresses subsequent end/reset callbacks and protocol errors from those cancelled readers. It does not report cancellation as a network gap. An actual reset while the subscription is active still invokes the gap callback. Publisher completion leaves readers active for trailing objects.

Changing what a viewer receives (a simulcast or ABR layer, a location filter, a priority) takes a new SUBSCRIBE: draft-22 REQUEST_UPDATE names no track, and moq-relay ends a subscription on any update. The draft recommends subscribing to the alternate track before cancelling the old one (draft-22 Section 3.7.2). subscriber.subscribeSwitchable(options, handlers) / subscriber.subscribe_switchable(namespace, track, handlers, parameters=...) does that for the application (issue #264). Implemented in both SDKs, on the primitive subscriber. A supervised want moves the same way when it follows a newer generation or is wanted again with another track or parameters (moves).

  • One subscription at a time is active, and only its events reach the handlers. Each handler gets the active track’s name as its last argument. Every subscription underneath is an ordinary subscription with the lifecycle above. The handle has no update().

  • switchTo(track, { parameters, until, drainMs, signal }) / switch_to(track, parameters=..., until=..., drain=..., cancellation=...) subscribes to track in the same namespace. The track may be the active one, which is how to change parameters. parameters are not inherited: omitted means none. The new subscription is pending, and the active one keeps delivering. The arguments, the connection and the switchable’s own state are checked at once. A failed check leaves any pending switch alone.

  • Cutover. Each object of the pending subscription is offered to until. 'first-object' (the default) accepts the first object. A synchronous predicate accepts each object it returns true for. Objects it refuses, and the pending subscription’s other events, are discarded, with one exception. Its objects, subgroup ends and resets are held (the latest 256), and those that come after the accepted object in its own subscription follow it. Those are its later groups, and its group’s later objects, subgroup ends and resets. Across relays a later group can arrive before the object that cuts over, and the predicate refuses it as too far ahead (issue #282). On the first accepted object:

    1. The old subscription is cancelled.
    2. onSwitched / on_switched runs with the old track, the new track and the new subscription’s SUBSCRIBE_OK.
    3. The accepted object is delivered, then the held events that come after it, in arrival order.

    switchTo resolves with the same record.

  • Avoiding a gap needs a timeline. The SDK knows no media time, and group IDs are not one. Each track numbers its own groups, and the SDK’s publisher counts them from the track’s first sent group, so layers that gained demand at different times disagree. An application that must not skip media supplies a predicate. It compares the object’s own timeline (a timestamp property, a frame number) with what the active subscription delivered, and accepts an object within a few frames of it. The same check refuses a stale first object. The relay starts a subscription at its cached latest group, and that group is old if the publisher skipped the track while nobody watched it. A subscriber on another relay can also lose the first object altogether (issue #243).

  • Drain. Groups arrive out of order across streams, especially through another relay, so an older group of the old track can still be in flight at the cutover. drainMs / drain (default 0) keeps the old subscription open for that long. It takes 0 up to the bound every SDK deadline has (2147483647 ms, 2147483.647 s), the longest a JavaScript timer holds. Beyond it, or negative, is a RangeError / ValueError, and a non-number a TypeError. While it drains, its objects, subgroup ends and resets still reach the handlers, with the old track’s name, if they belong to a group no later than the largest it had delivered. Its later groups and other events are discarded. When the time is up, it is cancelled. An old subscription that had delivered nothing is cancelled at once.

  • A failed switch leaves the active subscription delivering. A switch fails, reaching no handler, when:

    • The pending subscription ends before its cutover. The switch fails as subscribed would.
    • A newer switchTo supersedes it. The switch fails with CLOSED.
    • The signal or token cancels it. The switch fails with the signal’s or token’s reason.
    • The predicate throws. The switch fails with that error.
  • Endings.

    • subscribed settles with the SUBSCRIBE_OK of the first subscription to become active. If the switchable ends before that, it fails as the first subscription’s subscribed does.
    • ended settles with that subscription’s SubscriptionEnd when the active subscription ends other than by a cutover, or on cancel(). A REQUEST_ERROR ending also reaches onError with the track.
    • Any pending switch then fails, with CLOSED or with the connection error if the connection ended. Any drain stops.
    • Publisher completion is not an ending, as for any subscription.
    • switchTo on an ended switchable raises CLOSED.
  • Measured on moq-relay 0.17.0 at *.relay.mux.global (2026-10-06). The test switched between two layers written frame for frame every 50 ms, with a frame-window predicate and a 1 s drain. The cutover came about 140 ms after switchTo with publisher and subscriber on one relay (sjc), and about 1 to 1.6 s with the subscriber on another (fra). The live smoke test asserts that no frame is missing across the cutover, in both runtimes of each SDK. Without the predicate and drain, a 'first-object' switch skipped a frame in most cross-relay runs.

A subscriber can learn which namespaces under a prefix are announced, without an application channel of its own. subscriber.namespaceEvents({ prefix, capacity, signal }) in TS and subscriber.namespace_events(prefix, capacity=..., cancellation=...) in Python send one SUBSCRIBE_NAMESPACE for the prefix. They return a bounded iterator with the same ownership, capacity, overflow and cancellation rules as async subscription consumption. The iterator owns the request, never the connection.

  • Each event is announced or unannounced, with the full namespace: the prefix’s fields followed by the rest, sent verbatim. The relay names each namespace relative to the prefix, and the SDK joins the two. A namespace equal to the prefix is reported as the prefix itself.

  • The events are edge-triggered per namespace. Draft-22 lets a relay name a namespace again, and moq-relay does when its route changes. Those repeats are dropped, so an application that tracks the announced set holds the relay’s current view. A NAMESPACE_DONE for a namespace the relay never named closes the connection with PROTOCOL_VIOLATION, as draft-22 §9.15 requires.

  • The prefix follows the namespace argument rules (1 to 32 non-empty fields), checked before connection state. Draft-22 also allows an empty prefix, meaning every namespace. The SDK does not. The prefix matches whole fields: ['room', 'a'] does not cover ['room', 'ab'].

  • One connection discovers each prefix tree once. Draft-22 §9.15 has a relay answer a SUBSCRIBE_NAMESPACE whose prefix overlaps one already open on the session (equal, shorter or longer, field by field) with PREFIX_OVERLAP. moq-relay 0.17.0 does not enforce this, so the SDK refuses such a prefix itself, with PREFIX_OVERLAP in phase subscription, before anything is sent, and behaves the same against every relay. The prefix is free again once its iterator has ended. To watch both ['room'] and ['room', 'a'], discover ['room'] and filter.

  • The subscriber connection’s SETUP carries the SOLICIT option (0x40B5A = 1, draft-lcurley-moq-solicit-00), so the relay advertises namespaces only on these requests. Without it, moq-relay 0.17.0 pushes an unsolicited PUBLISH_NAMESPACE for everything in scope right after SETUP, and a SUBSCRIBE_NAMESPACE stream then carries nothing. The publisher connection does not send SOLICIT: it never asks for namespaces.

  • The relay sends no marker between the initial snapshot and later changes. Measured on moq-relay 0.17.0 at *.relay.mux.global (2026-10-06):

    What Same relay (sjc) Subscriber on another relay (fra)
    Already-announced namespaces arrive after the request ~50 ms ~110 ms
    A new announce arrives after the publisher’s announced settles ~50 ms ~140 ms
    unannounced after the publisher’s clean close() ~50 to 300 ms ~150 to 400 ms
    unannounced after the publisher process is killed ~12 s (QUIC idle timeout) ~12 s

    An announce learned from a cluster peer looks the same as a local one. Nothing in the event says which relay holds the publisher.

  • A prefix the token does not cover is accepted and stays silent. The relay does not reject it. An event is about the relay’s routing table, not a publisher’s health: announced does not mean any track has content, and a namespace two publishers announce (duplicate announce) is reported once.

  • Discovery is how a subscriber avoids the announce race. A subscribe sent on the same connection once announced arrives was never rejected unroutable on moq-relay 0.17.0 (0 of 160 trials on 2026-10-06, both SDKs, same relay and another relay), while one sent as soon as the publisher’s announced settled was rejected 6 to 14 of 40 times on the same relay and 40 of 40 on another (announce propagation). This is an observation of that relay, not a protocol guarantee. A subscriber that waits for announced needs no timed retry for the race. A later unroutable means the namespace has gone since. unannounced follows, and the next announced is the cue to subscribe again. not found still means the track is not there.

  • Endings: a REQUEST_ERROR, the request timeout, or the request’s stream failing raises SubscriptionRequestError. A stream that fails without a REQUEST_ERROR first waits up to the same closure grace as a subscription. The connection ending raises SubscriptionConnectionClosedError, a full queue SubscriptionOverflowError, and the signal or cancellation its reason. A relay that finishes the request cleanly ends the iterator without an error, after unannounced for every namespace still announced (draft-22 §9.15 treats the end of the response stream as a withdrawal of each), and after any events still queued, which a clean end does not discard. After an error ending, treat every namespace as withdrawn. A GOAWAY on the request stream is not surfaced. The connection’s GOAWAY ends the connection.

The primitives above never reconnect or retry. Applications can opt into a supervision layer built only on them: connectSupervisedPublisher / connect_supervised_publisher and connectSupervisedSubscriber / connect_supervised_subscriber. It owns the recovery policy in publisher recovery, so live applications don’t each write it again (issue #260). Neither supervisor changes what the primitives do. Python gives the backoff bounds and stale_after in seconds, and its want handlers take the namespace as a second argument.

Both supervisors:

  • Resolve once their first connection is up. A first attempt that fails on its arguments, or because the relay refused the credentials (UNAUTHORIZED), rejects with that failure. Any other failure is retried with backoff, bounded only by the cancellation primitive, whose abort rejects with its reason. After the first connection, every ending is retried, UNAUTHORIZED included, since the token provider runs again on each attempt.
  • Reconnect whenever the connection ends without the application closing it: error, GOAWAY, or a clean close by the relay. The delay starts at 500 ms and doubles up to 10 s. Each delay is drawn from the upper half of its ceiling (jitter), and the backoff starts over after a connection that stayed up longer than the longest delay. Both bounds are options.
  • Report connecting, live, reconnecting and closed through a status callback, with the error that ended the previous connection or failed the last attempt. closed settles once the supervisor stops: close() (no error), the cancellation primitive (its reason), or a first attempt that is not retried (that failure). It never rejects. A rotation reports nothing: the status stays live.
  • Replace their connection before its credential expires, make-before-break (rotation).
  • Given no endpoint, dial the token-rooted endpoint their first connection dialed, once it is up, for every later connection, so a later token whose grant does not cover it fails UNAUTHORIZED instead of moving to another room.

A rotation replaces a supervisor’s live connection make-before-break, so that no connection reaches its credential’s expiry (credential lifetime) and viewers see a switch instead of a drop. It is not a reconnect: the old connection keeps serving until the new one has taken over.

  • When. rotateBeforeMs / rotate_before (default 60 s) before the live connection’s expiresAt / expires_at, but never earlier than halfway through the time left when it connected, so a credential shorter than the margin still serves half its life. A connection whose expiry is unknown never rotates on its own. rotate() rotates now, for an application that revokes credentials or wants a fresh token. It is a no-op while no connection is up.
  • The replacement connects with the same options, so the token provider runs again. A failed attempt is retried with the reconnection backoff while the old connection lives. If the old one ends first, the supervisor reconnects as after any other ending. When a rotation the supervisor started on its own gets a credential that expires no later than the live one, it discards the replacement and lets the live connection run to its end: two connections the relay ends together replace nothing. rotate() always takes the replacement.
  • Overlap. The old connection closes once everything has moved to the new one, after 10 s at most, or sooner if it ends on its own.
  • Publisher. The replacement is the next generation. Every registered track is registered on it, and onGeneration / on_generation runs for it once it is up. generation names it from then on. Each track’s writer keeps writing to the old generation until the track has demand on the new one or has none on the old one, and then moves at the start of its next write or group, never under one: a group opened on the old generation finishes there. Subscribers following generations move to the new one when it is announced, which creates its demand. hasDemand and demandChanges() follow the generation the writer is on, and report no false when demand carries over. They report true again on the move, as they do when the relay replaces its upstream subscription, so state an application writes on demand (a catalog) reaches the new generation. When the overlap runs out, every track moves regardless of demand, and the old generation closes as close() closes it.
  • Subscriber. The replacement is a new connection, and every want moves its subscription to it make-before-break, as a move does: the replacement opens pending on the new connection while the old subscription keeps delivering. A want following generations discovers the root on the new connection first. Then:
    • Same target (namespace, track and parameters): the same publisher’s stream continues. The replacement starts at the latest group, so its objects at or before the last location the old subscription delivered are dropped, and the first one after it cuts over without until. No callback marks it, no onSwitched, onRestarted or onSubscribed, and nothing is lost or repeated, so decoders keep their state. The old subscription is cancelled, not drained. Once any of the want’s subscriptions has heard PUBLISH_DONE, nothing is dropped: a restarted publisher numbers its groups from 0 again.
    • Another target, a newer generation announced on the new connection or a re-want: an ordinary move, with until, the drain and onSwitched.
    • The old connection closing under the old subscription leaves the replacement to carry on, and a replacement refused or ended before its cutover is retried after the retry backoff, as a move’s is. The new connection ending restarts the want with connection. A want with no accepted subscription starts over on the new connection at once. A rotation to a connection that is itself rotated away before the cutover moves on to the newest one.
  • Each connection is a generation. It serves the application’s root namespace followed by one generation field the SDK chooses: the start time in fixed-width base 36, then four random characters. Generations therefore sort by age as strings. The token must grant the root. The root plus one field must satisfy the namespace rules, checked when the supervisor starts. generation exposes the live generation’s field and namespace, and a callback runs once each generation is up and every track is bound to it.
  • A fresh namespace per connection is what publisher-recovery.md recommends for predictable handoff on moq-relay 0.17.0: a reconnect on the same namespace is a new publisher to the relay, and duplicate announces are ranked arbitrarily (duplicate announce). If the relay later supports taking over a route, the same API can keep one namespace across reconnects without applications changing.
  • registerTrack checks its options at once, under the same rules as the primitive’s, and returns a writer that outlives the generations. The supervisor registers the track again on every generation. Track names are unique per supervised publisher, including after their writer closes. A track closed by the application is never registered again.
  • Writes are never replayed. While no generation is up, write, writeText, writeJSON and openGroup fail at once with RECONNECTING in phase connection, and hasDemand is false. An operation that the connection’s end interrupts also fails with RECONNECTING, never CLOSED. A frame writer therefore treats it as an interruption, not a retirement, and resumes at the next keyframe on the next generation. The writer’s closed is true only once the application closed the track or the supervisor stopped.
  • demandChanges() spans generations. It reports the current state first, false when a generation that had demand is lost, and the next generation’s changes after it. It ends when the track closes.
  • close() stops reconnecting and closes the live generation, as the primitive’s close() does.
  • want(key, options, handlers) keeps a subscription to one track open until unwant(key). Arguments are checked at once. Wanting a key again with the same namespace and generation-following updates the want in place. The handlers, the retry backoff, the stale threshold, until and the drain are swapped. Another track or other parameters move the subscription (below). Another namespace or generation-following replaces the want.
  • Each want runs one subscription at a time on the live connection, two while a move is pending:
    • Retried at once, without backoff: the connection ended (the want moves to the replacement connection), the followed generation was withdrawn with none newer, or a move came before the subscription was accepted.
    • Retried after backoff (from 250 ms, doubling up to 8 s, with jitter): the announce race (16 unroutable, or the older 404), every other rejection not listed below, PUBLISH_DONE, a REQUEST_ERROR after acceptance, and a subscription that went stale. Only an object from a later group than the subscription’s first resets the backoff. Cached objects followed by a done are not progress, so a finished track still announced is replayed no faster than the backoff allows (issue #21).
    • Surfaced, after which the want stops and is forgotten: a rejection that no wait fixes. These are REQUEST_ERROR 0x1 UNAUTHORIZED, 0x3 NOT_SUPPORTED, 0x4 MALFORMED_AUTH_TOKEN, 0x5 EXPIRED_AUTH_TOKEN, 0x11 INVALID_RANGE and 0x12 MALFORMED_TRACK, reported as SubscriptionRequestError. Discovery refusing the root is surfaced the same way, and so is PREFIX_OVERLAP between two followed roots on one connection.
  • staleAfterMs / stale_after resubscribes when no object arrived for that long, measured from acceptance or the last object. It is for tracks that are never silent while live: after a publisher crash, moq-relay 0.17.0 can hold an accepted subscription on the dead publisher’s cached object for about 11 s. Without it, silence is not a failure.
  • Following generations. With generations: true, the want’s namespace is a supervised publisher’s root. The supervisor discovers the root (discovery) and subscribes on the newest generation announced one field below it. Deeper namespaces are ignored. One discovery per root per connection is shared by every want on it. The want moves as soon as a newer generation is announced, even while the old one is still announced, as a crashed publisher’s is until the relay’s idle timeout. It also moves when the current generation is withdrawn and an older one is still announced. With none announced, it waits. No application channel is needed.
  • Moves are made before the break (issue #282), as on a switchable subscription. A move is a newer generation, or a re-want with another track or parameters. Once the current subscription has been accepted, the want opens the replacement as pending while the current one keeps delivering:
    • Each object of the pending subscription is offered to until (WantOptions.until / until=). 'first-object', the default, accepts the first. A synchronous predicate accepts each object it returns true for. The objects it refuses are discarded, except those that come after the accepted object, which follow it as on a switchable. A new generation’s namespace is fresh, so its first group is its publisher’s first. The first-object default is never worse than a break, and an application with a timeline passes a predicate that compares it. Group IDs are not a shared timeline: each generation and track numbers its own groups. A predicate that throws is logged and accepts the object, so application code never stalls a move.
    • Cutover. On the first accepted object, the old subscription is cancelled, or drains for drainMs / drain (default 0) as a switchable’s does. Then onSwitched / on_switched runs with the old and new namespace and track and the new SUBSCRIBE_OK, and then the accepted object is delivered. A move runs neither onRestarted nor onSubscribed.
    • The old subscription ends first (PUBLISH_DONE, as when a supervised publisher replaces its connection, a REQUEST_ERROR, or the stale threshold). The pending subscription carries on and cuts over on its next object, whatever until says, since there is nothing left to compare. onPublisherDone still runs for the old one. The stale threshold counts objects from either subscription. When it passes during a move, only the old subscription ends, and the replacement gets a full threshold of its own before the want restarts with stale.
    • The pending subscription fails before its cutover. A rejection no wait fixes is surfaced, and the want stops, unless the key was wanted again with another target first. Then the want reports onRestarted for the subscription it cancelled (its own ending’s reason, else terminated), and tries the new target at once. Any other failure leaves the current subscription delivering, and the move is retried after the retry backoff, sooner if the want asks for something else. If the old subscription had already ended, onRestarted reports its reason instead, and the want resubscribes as after any restart.
    • A newer move supersedes a pending one. A re-want back to the current track and parameters cancels the pending one. A move before the current subscription is accepted replaces it at once, since there is nothing to overlap. The connection’s end takes both subscriptions, and the want restarts with connection.
  • Catch-up (TypeScript only). catchUp(key) replaces the want’s accepted subscription at once, break-before-make: it cancels it, which makes the relay drop what it still holds queued for it, and subscribes again with the want’s options, so a want with no location filter starts at the newest group. onRestarted reports catch-up. The replacement opens without delay when the cancelled subscription had progressed (delivered an object from a later group than its first), and otherwise after the retry backoff, so a link too slow for the track cannot make it spin. It is ignored for an unknown key and while no subscription is accepted. During a move it ends both subscriptions, and the replacement is made for whatever the want asks for then. It exists because moq-relay 0.17.5 (measured) and 0.17.7 (read in source) drop nothing for a late subscriber, whatever delivery timeout the subscriber sends (relay-interop.md). It is meant to work with a relay that honours that timeout too (moq-dev/moq#5881): such a relay drops the late groups itself, and a catch-up then clears only what still arrives late. A frame reader’s onCatchUp is its usual caller (media subscribing). Measured on 2026-10-17 against a local moq-relay 0.17.5, with a 3 s outage on a 600 kbit/s link carrying a 400 kbit/s audio track (ts/scripts/probe-catch-up.mjs, two runs each): without catch-up, the listener got 401 and 407 of 1,200 frames: once delivery resumed (about 5 s after the link came back, as QUIC recovered), it got a few frames a second, about 800 ms late, to the end of the run 8 s later. With catchUpAfterUs 300 ms and a late-drop limit of 100 ms, one catch-up followed the resumption, the listener got 796 and 801 of 1,200 frames, and latency was back at 21 ms within a second.
  • Handlers receive the current subscription’s objects, subgroup endings and resets, and its PUBLISH_DONE. onSubscribed receives the namespace the subscription was made on: the generation’s, when following one. onRestarted({ reason, namespace }) runs when an accepted subscription ends and will be replaced, before any object of the replacement. Its reason is publisher-done, stale, generation, connection or terminated, or in TypeScript catch-up. generation remains for a withdrawal with none newer announced. Decoders drop state that assumed continuity there, and at onSwitched. Rejections before acceptance, the announce race included, never reach a handler unless they are surfaced.
  • Nothing is fetched to fill a gap. A replacement subscription after a restart starts live with the want’s parameters, so objects published between two subscriptions are not delivered. A move leaves no gap only as far as its until and drain allow. Trailing objects after a PUBLISH_DONE are discarded when the subscription is cancelled for its replacement.
  • close() cancels every want and closes the connection.
  • Publishing uses a dedicated connection with a token authorized to publish the namespace. One publisher connection serves one namespace and any number of tracks.
  • Publishing follows the relay’s announce-and-serve flow. Connecting a publisher announces its namespace. Registering a track makes it servable and sends nothing. The relay learns of the track when a subscriber asks for it. A write waits for the relay to subscribe upstream before sending bytes. A subscriber creates that demand. If namespace solicitations have arrived but none currently covers the publisher namespace when the demand deadline expires, the write reports a namespace coverage error instead of an ordinary demand timeout. Check the token and namespace before retrying. This does not prove authorization was denied: solicitations can arrive independently, and a later matching prefix permits writes.
  • publisher.announced settles once the namespace has been handed to the transport on a solicitation that covers it, no earlier than the first registered track. It is the strongest fact the publisher has: the relay acknowledges an announce with nothing and routes subscribes about a round trip later. It stays pending while no solicitation covers the namespace (the first write reports NAMESPACE_NOT_SOLICITED), and the connection’s closure terminates it if that comes first, with the closure’s error or CLOSED after a clean shutdown.
  • Two publishers may announce the same namespace, and nothing warns either one or any subscriber. On moq-relay 0.17.0 the relay picks one of the two announcing connections for new subscribers, effectively at random (a hash of the namespace and each connection’s random hop id ranks the routes before their age does). The choice is stable while both stay connected, and the other publisher gets no demand at all. Existing viewers stay on the publisher that served them, and when it retracts they get done status 2 (or, if it crashed, done status 0 about 11 s later) and do not move. New subscribers then go to the remaining one. After a crash, new subscribers may be routed to the dead publisher and served its cached last object until the relay’s idle timeout. moq-relay 0.15.8 instead gave every new subscriber to the newest announcer. Neither SDK can detect which publisher serves a viewer (issue #37). Keep one publisher per namespace.
  • The default write deadline is 10 seconds and covers both waiting for demand and the transport write.
  • A timeout, cancellation, transport write failure, or relay subscription change resets the affected write’s stream and leaves the track open while the publisher connection remains open. A stream that opens after cancellation is reset without sending payload bytes. The next explicit write uses a new group ID. Failed groups are never reused. After demand disappears, later writes wait for a new relay subscription. Applications choose fresh data or the next random-access point after a failure. Already delivered bytes cannot be withdrawn. There is no automatic retry.
  • A write’s payload must be a byte sequence in the language’s own terms, and its properties must follow the Arguments table. Anything else is rejected before the write starts, which is a local argument error and leaves the track usable. An oversized property is such an error too, not a transport failure once demand arrives.
  • Each write copies its bytes and sends one independent group containing one object. Only one write may be pending per track. A concurrent write is rejected rather than queued.
  • Write completion means the transport accepted the complete group, not that any viewer received it. The transport accepted the group once its FIN was handed to the transport, not once the relay acknowledged it, so a track is not limited to one group per round trip. Until that acknowledgment the group is a settling group (see CONTEXT.md). Closing a track does not wait for its settling groups. A settling group is never reset when the relay subscription changes or ends. Only its own delivery timeout resets it. One that fails later (STOP_SENDING, a reset, its delivery timeout, the session ending) is dropped without an error on the track, and frees its place under the limit below: the cause surfaces as demand, a new relay subscription, or the connection’s closure. While a publisher connection has 64 settling groups, the next write or group waits in opening-stream under its write deadline before opening its stream, so a stall is a WRITE_TIMEOUT, not a transport failure. Open groups do not count toward that limit. This also bounds how much a Python publisher accepts into a dead uplink, since its writes complete once the transport has sent the FIN. publisher.close() waits for the connection’s settling groups for up to the largest write deadline among its tracks before it ends the session. In Python the same wait also covers each ended track’s PUBLISH_DONE (see deviation 2). Delivery timeouts end some settling groups sooner and leave this bound unchanged. Groups still unacknowledged by then may be lost, and the close still succeeds. A write that completed just before the relay ended its subscription may reach no viewer, and no error follows.
  • There is no publisher-side retention or replay. Late-subscriber behavior depends on relay caching and subscription filters.
  • A publisher connection answers TRACK_STATUS (draft-22 Section 9.13) for its tracks, on moqt-22 and moqt-20 alike, as the SUBSCRIBE for the same track would be answered, without creating a subscription or sending objects. A registered track that is not closed gets TRACK_STATUS_OK (a REQUEST_OK) with the track’s properties, as SUBSCRIBE_OK carries them, and LARGEST_OBJECT once the track has written an object: the largest group and object ID whose write the transport took, across every relay subscription the track has had on this connection. Any other track gets REQUEST_ERROR DOES_NOT_EXIST (unknown track), as a SUBSCRIBE does. An answer that does not fit its control message (track properties at the size limit together with a long LARGEST_OBJECT) gets INTERNAL_ERROR. The publisher then FINs the request stream and reads nothing more from it. Demand, the write slot and every track writer are unaffected, and the application is not told. SUBSCRIBE_OK still carries no LARGEST_OBJECT (see Draft-22 protocol boundaries). moq-relay main from moq-dev/moq#5991 asks the publisher for a track’s status before it subscribes on behalf of another relay, and fails that relay’s subscribe when the publisher refuses, so a track crosses two relays there only because the publisher answers (a viewer on another relay). Implemented in both SDKs.
  • A failed write may have delivered partial data. There is no automatic retry.
  • The publisher encodes supplied track and object properties as protocol metadata. The raw transport API adds no default timescale, timestamp, or LOC media packaging. Subscribers expose the properties received from the relay, which may differ from those sent by the publisher. End-to-end preservation of arbitrary properties is not guaranteed. See Relay property handling.
  • Object payloads, encoded object properties and individual property values are limited to the sizes in protocol.json.
  • Closing a track writer cancels an outstanding write and ends that track. Other tracks continue. Closing the publisher ends all tracks, retracts the namespace, and waits for transport shutdown. A track’s subscription ends with PUBLISH_DONE (TRACK_ENDED) after its data streams close, then a FIN, which needs moq-relay 0.15.4 or later. Neither connection sends GOAWAY (relay interop deviations).
  • Track names cannot be reused within a publisher connection, including after their writer closes.
  • A track writer may open a multi-object group: objects are appended with object IDs 0, 1, 2, … on one stream, and closing the group FINs the stream. Object 0 is the group’s random-access point, which is how LOC/MSF media (one GOP per group) is published. A group that fails part-way is reset rather than completed, and the track stays open for the next group. Whether a subscriber learns of the reset depends on the relay (see Subscribing). Only one write or group may be pending per track. The write deadline applies separately to opening a group (including demand), each object write, and closing the group. Idle time between operations is unrestricted. Aborting a group after it closed cleanly has no effect: the FIN already ended the stream, and a reset after complete delivery is ignorable (draft-22 Section 11.3.2). Implemented in both SDKs.
  • A track writer exposes whether the relay currently demands the track and lets the application observe every change, so a state-shaped track such as a catalog can re-send its document on each relay subscription. Demand observers emit the current state first, include replacement subscriptions (a repeated “demanded” value), and finish when the track ends. A closed track reports no demand. Demand is the relay’s single upstream subscription for the track: the relay shares it across every downstream viewer, so additional viewers produce no further notification, and when it ends is the relay’s choice (moq-relay 0.15 and later cancel it about one round trip after the last viewer leaves, and earlier relays held it for about 30 seconds). Demand is therefore neither a viewer count nor a liveness signal. A document re-sent on each demanded value reaches the viewers who join during one relay subscription from the relay’s cache, not as a re-send, so a state-shaped track writes only state that is safe to serve from cache and to receive more than once (relay demand). When the first viewer is on another relay, or is a moq-lite viewer on any relay, the publisher’s relay first subscribes briefly to answer a track-info query and then subscribes again, so objects written during the first subscription reach no viewer, with or without a SUBSCRIPTION_CHANGED error. A live publisher cannot rely on its first objects after demand begins being delivered (a viewer on another relay). That is moq-relay 0.17. main asks with TRACK_STATUS instead, which the publisher answers (see above), and subscribes once (TRACK_STATUS on moq-relay main). Implemented in both SDKs.

Draft-22 Section 5.2 gives each group two delivery timeouts, in milliseconds: SUBGROUP_DELIVERY_TIMEOUT (0x06) bounds how long a group’s FIN may wait for acknowledgment, and OBJECT_DELIVERY_TIMEOUT (0x02) bounds how old an object may be when it reaches the transport. There are no options for them: an application sets the publisher’s values with the track and object properties it already passes.

  • The value. Each group’s value is fixed just before its stream opens, after any wait for settling room. For a group opened without its first object, the publisher’s side is fixed when that object is written. The publisher’s is the property on the group’s first object when present, and the track property otherwise. On any later object the property is ignored. The subscriber’s is the parameter on the relay subscription’s SUBSCRIBE, as updated by any REQUEST_UPDATE so far. When both are non-zero the smaller applies, and otherwise the non-zero one. A value of 0 means no timeout. A value above 2147483647 ms is clamped to that bound, and registration refuses neither 0 nor a clamped value. Both types are even, so their values are integers: the Arguments table already refuses bytes there. A REQUEST_UPDATE carrying either parameter is acknowledged with REQUEST_OK, and the new value applies to groups whose stream opens after it. Groups already open or settling keep theirs.
  • The subgroup timeout starts when the group’s FIN is handed to the transport, the moment its operation completes and it becomes a settling group. If the relay has not acknowledged the FIN when it expires, the stream is reset with DELIVERY_TIMEOUT (0x2), as Section 5.2 requires before the stream reaches “all data committed”. Nothing is reported to the track: the group leaves the settling set like any other late failure and frees its place under the 64-group limit. The timer belongs to the group, so it keeps running through a relay subscription change or end and through the track’s PUBLISH_DONE. Only the connection’s end stops it.
  • The object timeout counts from the call that hands the object over (write(), openGroup() / open_group() given the group’s first object, or the group’s write()) to the moment the transport takes the object, so waiting for demand, for settling room, for the stream and behind the group’s earlier bytes all count. Section 5.2 starts the clock when the application provides the object, so a bare openGroup() has none to time: only the write deadline bounds its wait for demand, settling room and the stream. Opening a group with its first object (payload, with optional properties) puts that wait on the object’s clock, and its 0x02 property is known before the stream opens. The call returns once the transport takes the object, with objectCount / object_count at 1, or fails like a write() and returns no group (#306). Properties without a payload are a TypeError. The age is checked again just before the object is handed over, as Section 5.2 requires. Until the group’s value is fixed only the publisher’s value times the wait. An object already older than the fixed value fails then, before any stream opens. When it expires the operation fails with DELIVERY_TIMEOUT in its current phase, and a stream already open is reset with code 0x2. In a group the whole group is reset and its later writes fail with the same error. No stream is opened again for it (Section 5.2: SHOULD NOT). The track stays open and the next write uses a new group ID, as after any interruption. The object timeout runs alongside the write deadline: whichever expires first fails the write, with DELIVERY_TIMEOUT or WRITE_TIMEOUT. Once the transport has taken an object, its bytes cannot be timed out. In TypeScript the transport has taken an object when its stream write resolves, so an object queued behind stream flow control is still timed. In Python, when the stream write returns, once aioquic has transmitted the bytes.
  • On moq-relay 0.15.8, 0.17.0 and 0.17.5 (and 0.17.7, read in source) neither value has any effect beyond the publisher. A subscriber’s values are sent on SUBSCRIBE as parameters.objectDeliveryTimeout / subgroupDeliveryTimeout (object_delivery_timeout / subgroup_delivery_timeout) and the relay drops nothing for them: measured on 0.17.5, a listener with a 100 ms timeout behind a link too slow for the track received every group, up to 11 s late (relay-interop.md). A late listener’s remedies are the sequencer’s late drop and catch-up (media subscribing). The relay never sends either parameter upstream: its SUBSCRIBE carries only 0x10, 0x20, 0x21, 0x22 and 0x23, plus 0x35 only to opt out of properties, which the relay never does (subscribe.rs), its REQUEST_UPDATE only 0x10, 0x20 and 0x21, and it states that it never negotiates the Section 5.2 parameter (error.rs). It drops both track properties and the 0x02 object property, and reads an object’s 0x06 as a Timestamp (Relay property handling), so a first-object 0x06 reaches viewers as a Timestamp. In production the publisher’s own values are therefore the only ones that apply, and a track property is the safer way to set the subgroup timeout. The reverse also holds: a publisher that still sends timestamps as 0x06 (the draft-ietf-moq-loc-03 id) now gives each group a subgroup timeout equal to its first object’s timestamp. Applications must not send a timestamp as 0x06. LOC Timestamp is 0x10. Evidence: issue #167.
  • Defaults belong to the media tier, not the write slot: registerTrack sets none, and media publishing gives media tracks a subgroup timeout.

Implemented in both SDKs.

The TypeScript publisher opens every data stream of a track that declares a Default Publisher Priority (0x0E) with a send order of -1 - p, for the first integer p among its track properties: from -1 for priority 0 down to -256 for 255 (#336). A transport adapter takes it as createUnidirectionalStream({ sendOrder }) (TA-28), where a higher value goes first and every other stream has 0.

  • Under a congested uplink the publisher’s own transport then sends a more urgent track’s bytes first: with the media defaults, audio (-17) ahead of video (-97). Without it, the audio streams share the uplink with the video streams that have bytes queued, and wait behind them before the relay sees them.

  • Draft-22’s lower-is-more-urgent priority (§5.1) is turned around into WebTransport’s higher-first order, and kept below 0, because the control and request streams have 0: a backlogged track never holds up SUBSCRIBE_OK, PUBLISH_DONE or a request.

  • A track without the property gets no send order, so its streams share 0 with the control streams and go ahead of every track that declares a priority. A catalog or a media timeline registered without one reaches a joining viewer ahead of the media it describes.

  • Every stream of a track has the same send order, so the mapping prefers no group of a track over another. The transport decides how equal streams share the uplink, and a later group’s stream can still finish first.

  • Three transports apply it (TA-28): Firefox’s WebTransport from 155, @caprid/relay-websocket, whose QMux scheduler sends highest first and round-robin among equals, and @caprid/relay-quic, as quinn’s stream priority. Chromium’s does not: Chromium 153 has no WebTransportSendStream and drops the option, so a Chromium publisher sends as before. The Python SDK sets none: aioquic has no stream priorities.

  • It orders only what the publisher still holds. A backlog already in the network, in a deep bottleneck queue, delays audio and video alike. What the relay sends each viewer is ordered by the viewer’s subscriber priority (#154).

  • Measured on 2026-10-20 with ts/scripts/probe-send-order.mjs: a Firefox 155 page published 50 fps audio (160-byte frames, one group per frame) and about 1 Mbit/s of 30 fps video (a 40 kB keyframe each second) to sjc through an uplink capped at 800 kbit/s, and a subscriber on a clean path timed the audio from capture to arrival. “Off” is the publisher before #336, with the send order stripped from every stream:

    uplink queue send order runs audio p50 p95 p99 max
    none (no cap) on 1 51 ms 52 ms 53 ms 54 ms
    50 ms on 3 89 ms 105–106 ms 108–109 ms 110–115 ms
    50 ms off 4 120–125 ms 207–229 ms 264–302 ms 326–444 ms
    300 ms on 1 328 ms 354 ms 359 ms 361 ms
    300 ms off 1 352 ms 463 ms 655 ms 923 ms

    A fifth run without it, on the 50 ms queue, delivered no audio at all: every audio group waited out its 500 ms subgroup timeout behind video. With the order turned around (video first), no audio arrived either, which shows Firefox schedules by it. In Chromium 153 on, off and turned around measured the same (audio p50 119–121 ms on the 50 ms queue). Video arrived alike either way.

Every write and every open group is one operation occupying the track’s write slot (see CONTEXT.md). The operation records its phase before each step, and this table is the shared test list both SDKs implement: what the caller sees, what reaches the wire, and what state the track is left in. The Source column separates what the protocol rules from SDK policy. Section numbers refer to draft-ietf-moq-transport-22 unless stated otherwise.

Phase Interruption Reported to the caller Wire effect Track afterwards Source
waiting-for-demand write deadline expires WRITE_TIMEOUT, or NAMESPACE_NOT_SOLICITED when solicitations arrived but none covers the namespace; phase waiting-for-demand nothing sent open; the next write waits for demand again SDK policy: the protocol has no timer for awaiting a subscription (§15.6 only warns of idle timeout)
waiting-for-demand object timeout expires: the publisher’s value, the only one that applies before the group’s value is fixed DELIVERY_TIMEOUT, phase waiting-for-demand nothing sent open; the next write waits for demand again §5.2: an object’s age counts from when the application provided it, and is checked before it reaches the transport
waiting-for-demand relay subscription ends while waiting the wait continues until the deadline nothing sent open SDK policy
waiting-for-demand caller cancels the caller’s reason, unwrapped; phase waiting-for-demand nothing sent open SDK policy
waiting-for-demand track closed CLOSED, phase waiting-for-demand nothing sent ended SDK policy
waiting-for-demand connection ends the connection’s own error (phase connection), or CLOSED after a clean shutdown nothing sent ended §6.6: streams and subscriptions end with the session
opening-stream write deadline expires or caller cancels WRITE_TIMEOUT or the caller’s reason; phase opening-stream a stream that opens after the failure is reset before any object is written, code DELIVERY_TIMEOUT (0x2) or CANCELLED (0x1); its header may precede the reset (RESET_STREAM_AT) open; the next write uses a new group ID §11.3.2: a stream closed before all objects are delivered MUST be reset, and the reset SHOULD include the header; the deadline itself is SDK policy
opening-stream object timeout expires, while waiting for settling room or for the stream, or the object is already older than the value fixed just before the stream opens DELIVERY_TIMEOUT, phase opening-stream no stream opens, or a stream that opens after the failure is reset before any object is written, code DELIVERY_TIMEOUT (0x2) open; the next write uses a new group ID §5.2 MUST reset with DELIVERY_TIMEOUT; §11.3.2
writing object timeout expires before the transport takes the object DELIVERY_TIMEOUT, phase writing; a group’s earlier objects may have been delivered. Never closing: the object is with the transport by then stream reset, code DELIVERY_TIMEOUT (0x2); the group’s later writes and close() fail with the same error, and no stream is opened again for it open; the next write uses a new group ID §5.2: MUST reset with DELIVERY_TIMEOUT, SHOULD NOT open a new stream for the subgroup
writing, closing write deadline expires WRITE_TIMEOUT, phase writing or closing; object bytes may have been delivered, and closing can fail after all of them were. In TypeScript closing can only fail before the FIN is handed to the transport; Python’s closing can still wait on transport backpressure stream reset, code DELIVERY_TIMEOUT (0x2) open; the next write uses a new group ID §11.3.2 MUST reset; §5.2 and §16.11.4 for the code; the deadline is SDK policy
opening-stream, writing, closing caller cancels (AbortSignal, task cancellation, or group.abort()) the caller’s reason unwrapped, or CLOSED when abort() gave none; the current phase stream reset, code CANCELLED (0x1) open §11.3.2 MUST reset; code choice is SDK policy (§12.5 SHOULD be relevant)
opening-stream, writing, closing the transport refuses the stream or fails a write, other than the relay stopping the operation’s own stream TRANSPORT_ERROR with the cause preserved; the current phase stream reset, code INTERNAL_ERROR (0x0), when a stream exists open SDK policy
opening-stream, writing, closing relay subscription replaced or ended, or the relay stopped the operation’s stream (STOP_SENDING, §11.3.2) SUBSCRIPTION_CHANGED; the current phase; after a stop the transport error is kept as the cause. The same code whichever of the stop and the subscription change arrives first, and nothing further is reported for the operation stream reset, code CANCELLED (0x1); after a stop the QUIC stack has already answered with its own RESET_STREAM (RFC 9000 §3.5) open; the next write waits for new demand only if the relay subscription ended, and proceeds at once while it is still live §3.1.2: the publisher MUST reset open streams of a cancelled subscription; §11.3.2: a subscriber MAY stop a subgroup stream whose group is no longer of interest, which does not by itself end the subscription; treating a new SUBSCRIBE as a replacement rather than a concurrent subscription is SDK policy (§3.1, ADR-0001)
opening-stream, writing, closing track closed CLOSED; the current phase stream reset, code CANCELLED (0x1) ended §9.9: every stream is closed or reset before the track ends
opening-stream, writing, closing connection ends the connection’s own error (phase connection), or CLOSED streams end with the session; a reset that is still sent carries SESSION_CLOSED (0x3), or GOING_AWAY (0x4) after GOAWAY ended §12.2; §16.11.4 for the codes
none: refused before an operation starts a write or group on a track that already ended, or a write on a group already closed with its FIN CLOSED, phase waiting-for-demand nothing sent unchanged SDK policy: the phase that means nothing was sent
any a second write or group is started WRITE_PENDING, phase writing; the active operation is unaffected nothing sent unchanged SDK policy: one pending operation per track
after the FIN is handed over (settling group) subgroup timeout expires before the relay acknowledges the FIN nothing; the operation had completed stream reset, code DELIVERY_TIMEOUT (0x2); the group leaves the settling set open, whether or not the subscription or the track has ended since §5.2: MUST reset the stream when the timer expires before all data is acknowledged
after a clean group close abort() nothing; closed stays true and a repeated close() returns nothing sent open §11.3.2: a reset after all data and the FIN is ignorable

A failed group’s ID is never reused, which is stricter than §2.2 (a subgroup may resume on a new stream after a premature reset). Group IDs increase monotonically and object IDs start at 0 within a group (draft-ietf-moq-msf-01 §6). A group may end without an END_OF_GROUP marker (§12.1). There is no automatic retry, and the application chooses the next random-access point after a failure. MSF and LOC say nothing about an abandoned group. Implemented in both SDKs.

These are deployment observations, separate from the SDK guarantees above. Tests against production moq-relay 0.15.8 on 2026-09-28 (3 of 3 runs identical) found the following, and the same matrix was identical on production 0.17.0 on 2026-10-05. moq-relay 0.17.5 and earlier strip object properties: the relay stores each object as its size and a timestamp only, so no other object property can survive it (a cluster interop run through 0.17.5 also delivered only 0x10). This is fixed upstream in moq-dev/moq#5494, which keeps object properties through the relay, but no release has it yet (issue #262).

  • Of the object properties, only Timestamp (0x10) reaches a subscriber. The relay also reads 0x06, the Timestamp id of draft-ietf-moq-loc-03, as a Timestamp and forwards it as 0x10. Every other object property tested was dropped, even- and odd-typed alike: Timescale (0x08), Video Config (0x0D), 0x02, 0x04, 0x05, 0x0B, and 0x40 to 0x43. Object payloads and IDs were preserved.
  • Timestamps are rescaled to microseconds using the declared Timescale: with track Timescale 90000, a Timestamp of 123456 arrives as 1371733. An object-level Timescale takes precedence over the track’s. With Timescale 1,000,000 a supplied Timestamp passes through unchanged.
  • A track without a declared Timescale gets relay-clock timestamps on every object, replacing any Timestamp the publisher supplied. So does an object without a Timestamp on a declared track. This departs from LOC, which defines a Timestamp without a Timescale as wall-clock microseconds since the Unix epoch. Such a stamp from a publisher does not survive this relay.
  • SUBSCRIBE_OK carries the relay’s own track properties for every track: Timescale (0x08) = 1,000,000, Default Publisher Priority (0x0E) = 128, and Default Publisher Group Order (0x22, decimal 34) = 2 (descending). A publisher’s own 0x0E passes through, its 0x22 is replaced with 2, and its other track properties (for example 0x0D, 0x40, 0x41) are dropped.
  • A track registered with a Default Publisher Priority (0x0E) above 255 or a Default Publisher Group Order (0x22) other than 1 or 2 makes the relay refuse every subscribe to it with REQUEST_ERROR 18 invalid value. The publisher gets no error. Its writes time out waiting for demand. Draft-22 rules both values out (§10.4, §10.5), so the SDKs refuse them at registration (issue #252).
  • On 0.17.0, a track registered with more than 64 track properties makes the relay refuse every subscribe to it with REQUEST_ERROR 18 too many (MAX_PROPERTIES, an implementation limit), again with no error to the publisher. The relay accepts 64 pairs, or one byte value filling the rest of a maximal SUBSCRIBE_OK. The SDKs refuse such a track at registration (relay.limits.maxTrackProperties, issue #252).
  • From 0.17.0, an object whose property block is larger than 64 KiB (65,536 bytes) is dropped by the relay: it stops that stream, and the subscriber sees a gap with no reset, while the publisher’s write succeeds. The SDKs refuse such a write (relay.limits.maxObjectPropertiesBytes, issue #247; upstream 434ab81f5, an implementation limit rather than a draft one).
  • On 0.17.0, a subscription with INCLUDE_PROPERTIES = 0 gets an empty track property list in SUBSCRIBE_OK and objects with no properties at all, Timestamp included (publisher.rs).

This is not an exhaustive registry compatibility test or a promise about future relay versions. Compared with the 0.14.17 and 0.15.1 observations, the 0x0E in SUBSCRIBE_OK and the invalid-value refusal are new.

For publisher-defined media timing, declare a track timescale and put timestamps in that unit on objects. To sync a broadcast’s tracks, stamp them all from one clock. That convention, and the player’s playout clock, are in media-layer.md, Shared timeline and playout clock. Interpret received timestamps using the received track timescale. A timestamp’s presence alone does not establish that the publisher supplied it. Do not treat a relay-generated timestamp as the application’s event time or a synchronized wall clock. Put application metadata that must arrive intact in the payload (or a separate metadata track’s payload). For the Video.js media example, codec configuration is also carried in the catalog’s initDataList, so decoding does not depend on Video Config object-property forwarding.

Evidence and protocol naming: issue #23 and, at moq-relay 0.15.8, the relay’s track property implementation, SUBSCRIBE_OK track properties and frame model.

  • Both product SDKs provide opt-in text and JSON writes on track and group writers and standalone readers for received objects. The protocol core remains payload-agnostic.
  • Each helper encodes or decodes one complete UTF-8 object. Text readers preserve a leading U+FEFF and reject malformed UTF-8. JSON readers additionally reject invalid JSON, including non-standard NaN and Infinity tokens. Empty text is valid, but an empty payload is not JSON.
  • Writes delegate to the byte writer, retaining its options, metadata, grouping, demand, concurrency, cancellation, deadlines, and size limit (measured in encoded bytes). Serialization failures occur before starting a write and leave the writer usable.
  • JSON uses the language’s standard serializer and parser, with no schema validation or automatic content-type properties. Unsupported values fail locally. JavaScript also rejects top-level values for which JSON.stringify returns undefined. Runtime-specific number, Unicode, and serialization conversion rules are documented in each SDK’s README. This is not canonical JSON.
  • Readers do not mutate payloads or metadata. Decode failures propagate to the caller. Applications catch them inside object callbacks when they want to skip malformed objects.

The optional @caprid/moq-media / moq-media packages implement pure format helpers. Raw Relay writers keep their existing byte/group behavior. No automatic media packaging is added to them. The media pins in protocol.json and exact bytes and parsed values in vectors/media.json apply to both languages.

  • LOC track properties declare a timescale (default 1,000,000). Object properties carry Timestamp (0x10), Timescale (0x08), and explicitly supplied Video Config (0x0D) / Audio Config (0x0F). Callers supply configs only on keyframes. Timestamps arrive in microseconds. Other timescales round to the nearest integer tick, ties upward. Inputs and the resulting tick count must be nonnegative safe integers (at most 2^53−1). The timescale must be positive.
  • The LOC timestamp reader (locTimestampUs / loc_timestamp_us, issue #290) returns an object’s Timestamp (0x10) in microseconds, or nothing when the object has no Timestamp that is a nonnegative safe integer. It scales by the first Timescale that is a positive safe integer, looking at the object’s Timescale (0x08), then the track properties’ (as SUBSCRIBE_OK carries them), and skipping any other value. It never reads the catalog’s timescale: msf-01 §5.2.21 does not tie that field to the LOC Timestamp. The result is round(value × 1,000,000 / timescale), ties upward. With no Timescale it is the value itself, LOC’s wall-clock microseconds. A result past 2^53−1 reads as nothing. Pinned by vectors/media.json locTimestamps.
  • The independent MSF catalog is compact UTF-8 JSON with version: "draft-01" and explicit per-track namespace, LOC packaging, and live status. It omits isComplete: msf-01 §5.1.3 forbids the field while false and forbids removing it once added. Absent optional fields are omitted, as is empty init data. Catalog strings must be valid Unicode, with no lone surrogates. Entries are ordered video, audio, the media list in caller order, the media timeline, then data in caller order. Init-data entries have <track-name>-init IDs and inline base64 data. Numbers must be finite. Integral values must be safe integers and have no decimal suffix. Fractional values use ECMAScript JSON number spelling, including its exponent threshold.
  • The end-of-broadcast catalog (buildMsfEndCatalog / build_msf_end_catalog) is msf-01 §11.3’s final document: version, the optional generatedAt, isComplete: true, and an empty tracks array, encoded by the same rules. A publisher sends it only when a broadcast ends on purpose, after every media track has ended (closing a track sends PUBLISH_DONE Track Ended), and never on a crash or a lost connection.
  • avc1 and avc3 video entries are withheld without non-empty init data. With avcC, the codec suffix comes from the record’s profile/constraints/level. The avc1/avc3 prefix is retained. Header inspection rejects truncated/wrong-version headers. It does not decode SPS semantics.
  • Opus always declares 48,000 Hz and defaults to channel configuration "2". Video and audio entries carry renderGroup (msf-01 §5.2.11), 1 unless the caller names another, and altGroup (§5.2.12) when the caller names one. Both must be non-negative safe integers, and the members of an alternate group must share a role and a render group (media-layer.md, issue #351). No target latency or buffer is set. Data entries have only name and optional role beyond the common fields, and reject the media roles video, signlanguage, audio, audiodescription, caption, and subtitle. Track names must be non-empty and unique, including tracks temporarily withheld pending init data.
  • Namespace inputs are sequences of fields, never bare strings. Catalog namespace fields must be non-empty and cannot contain /: the supported Video.js catalog dialect joins/splits namespace fields using that separator. Source URL components support arbitrary valid UTF-8 and escape every byte outside [A-Za-z0-9_] as lowercase .xx. Source composition accepts explicit https:// or moqt:// URLs without a fragment or credentials, preserves authority/path/query, supplies / for an absent path, defaults to the catalog track, and percent-escapes an optional c4m token using RFC 3986 unreserved characters.
  • Python H.264 helpers accept three- or four-byte Annex B start codes, omit Annex B padding zeros, extract SPS/PPS, construct a four-byte-length avcC, and convert access units to four-byte NAL lengths. SPS/PPS/AUD are dropped by default. Retaining them is an explicit option. avcC creation rejects missing/wrong-kind parameter sets, more than 31 SPS or 255 PPS, and NALs over 65,535 bytes. Construction supports Baseline, Main, and Extended profiles (66, 77, 88). Other profiles are rejected until their SPS-derived chroma/bit-depth extension fields are implemented. Encoder-generated avcC records of other profiles can still be passed to the catalog builder. The extension requirement is reflected in FFmpeg’s avcC writer.
  • TypeScript consumes encoder/demuxer-provided avcC and length-prefixed packets, and exposes only avcCodecString for header inspection. It does not expose Annex B parsing or construction APIs. @caprid/moq-media maps standard WebCodecs decoder configs for four-byte-length AVC and Opus, copying the exact description buffer/view range. chunkToLocFrame accepts native encoded video or audio chunks, copies their payload bytes unchanged, preserves microsecond presentation timestamps, and attaches supplied configs only to key chunks. Callers preserve encoder output order. These helpers require no browser globals at import time. The optional @caprid/moq-mediabunny file-input adapter borrows public packet payloads and rejects metadata-only packets. Both chunk and packet helpers accept a shared integer timestamp offset to normalize negative timestamps. Input timestamps must be safe integers and the final timestamp follows the LOC nonnegative safe-integer contract. The adapter retains compatibility re-exports of the config mappers and LocFrame type from the media package.
  • The catalog reader (parseMsfCatalog / parse_msf_catalog) takes a catalog object’s payload and the catalog track’s namespace, and returns the version, generation time, completeness, and track descriptions in document order. It reads the independent form only. It rejects a payload that is not a UTF-8 JSON object (no byte-order mark, no NaN/Infinity), a missing or non-string version, a version other than draft-01 or the deployed alias 1 (msf-01 §5.1.1: a subscriber MUST NOT parse a version it does not understand), a missing tracks array, a deltaUpdate, and init data that is not base64 (decoded as WHATWG atob does). Everything else is ignored when not understood, as msf-01 requires: unknown fields, fields of the wrong JSON type, non-integral widths, heights, sample rates, bitrates, render and alternate groups, and generation times, track entries that are not objects or have no string name, and init entries whose type is not inline. A track’s slash-joined namespace is split back into fields, dropping empty ones (the builder’s dialect). A track without one inherits the catalog track’s namespace (§5.2.2). Packaging is returned as declared, loc or otherwise, and payloads are not interpreted. Absent packaging reads as the empty string and absent isLive as false. initRef resolves through initDataList to bytes. An unresolved reference leaves the track without init data. Every builder document must read back as its vector’s parsed value. groupMsfTracks / group_msf_tracks groups parsed tracks into the sets a viewer picks one track from: tracks sharing a render group and an alternate group form one set, every other track is a set of its own, and sets are ordered by their first track.
  • One timeline, and the playout clock (media-layer.md, issues #285 and #290): every LOC media track one catalog lists is stamped in microseconds since the Unix epoch, its capture time on the publisher’s system clock, with Timescale 1,000,000 declared on the track. The broadcast clock (createBroadcastClock / BroadcastClock) pins an integer-microsecond origin on its monotonic source (originUs) to an integer Unix-epoch time (originEpochUs). It maps times on its own source as originEpochUs + source − originUs, rounded to whole microseconds with ties upward. It maps a capture clock with another origin and rate (an AudioContext, Chromium’s MediaStreamTrackProcessor) through a source timeline: anchor + round(s − anchor s) − captureLatencyUs. A calibration pair (s, at) re-anchors when there is no anchor, or when the anchor (without the capture latency) is off by more than toleranceUs (5 ms). Uncalibrated, the first timestamp anchors at now(). The capture latency defaults to 0, must be a nonnegative safe integer, and can be changed between timestamps. No helper knows a device’s latency. It maps sample index n as first + floor(n × 1,000,000 / rate). The playout clock (createPlayoutClock / PlayoutClock) is master-audio: its position is the latest audio report plus elapsed local time. It paces by wall clock while idle, after stopAudio / stop_audio, and after audioTimeoutUs (500 ms) without a report arriving (measured from arrival, not from the report’s atUs). due answers wait (early by at most maxVideoDelayUs, 1 s), show (late by at most lateDropUs, 40 ms), or drop. A frame further ahead is shown at once. Wall pacing re-anchors on a frame more than maxVideoDelayUs early or more than the target latency late. The target latency (targetLatencyUs / target_latency_us) is the option when given, a positive number. Otherwise it is twice the longest of the last four keyframe intervals reported (reportKeyframeInterval / report_keyframe_interval, each positive), and 2 s before any is. reset() keeps the reported intervals and counts itself in resets. Both clocks read an injected monotonic microsecond source and need no DOM or transport. The decisions are pinned by vectors/clock.json. Implemented in both SDKs.
  • The Media Timeline track (msf-01 §7; media-layer.md, issue #291). The broadcast clock’s originEpochUs / origin_epoch_us is the origin as integer Unix-epoch microseconds: as given, or else read once at creation as the system clock (Date.now(), time.time_ns()) less the source time elapsed since the origin. An entry for a timeline timestamp t, location (g, o) (object defaults to 0) and wall-clock time w (defaults to t, the epoch-anchored timeline, and 0 means unknown) is [floor(t / 1000), [g, o], floor(w / 1000)]. Every number must be a nonnegative safe integer. The document builder encodes entries in the order given as compact JSON. The reader rejects a payload that is not a UTF-8 JSON array (no byte-order mark, no NaN/Infinity). It reads a record whose first three items are a number, an array whose first two items are numbers, and a number, all nonnegative safe integers, and ignores extra items. It skips every other record. It does not decompress. The catalog builder’s optional timeline description emits one entry after video and audio and before data: packaging and role mediatimeline, mimetype application/json, isLive, and depends. depends defaults to the video track’s name followed by the audio track’s, whichever exist. Each name must be a video or audio track of the same catalog, and the list must be non-empty with no duplicates. Names listed but withheld pending init data are left out, and the entry is withheld when none remains. The timeline’s name defaults to timeline and joins the uniqueness check. The catalog reader returns depends (string items only) and mimetype as declared. The timeline accumulator (createMediaTimeline / MediaTimeline) holds one broadcast’s entries, each built as the entry helper builds it (wall-clock time defaulting to the timestamp), and keeps at most maxEntries / max_entries (a positive safe integer, default 512), dropping the oldest. Each add returns the independent document of the retained entries. Before the first it is []. A rejected entry leaves the timeline unchanged. Retention is pinned by vectors/media.json. Cadence (one entry per video group that was sent) and the publishing rules are in media-layer.md. Implemented in both SDKs.
  • The Opus packet duration (opusPacketDurationUs / opus_packet_duration_us) reads RFC 6716 §3.1’s TOC byte: the frame length from the configuration (SILK 10, 20, 40 or 60 ms, hybrid 10 or 20 ms, CELT 2.5, 5, 10 or 20 ms), times one frame for code 0, two for codes 1 and 2, and for code 3 the low six bits of the next byte. An empty packet, or a code 3 packet without that byte, reads as nothing. Pinned by vectors/media.json opusPacketDurations.
  • The interrupt track (INTERRUPT_TRACK, interrupt; media-layer.md, issue #320) carries one interrupt per group as compact UTF-8 JSON: track (default audio), the optional fromUs, then untilUs, with no fromUs key when it is absent. The builder (buildInterrupt / build_interrupt) and the reader (parseInterrupt / parse_interrupt) refuse an empty or non-string track, a track that is not valid Unicode, a timestamp that is not a nonnegative safe integer, and a fromUs that is not before untilUs. The reader refuses a payload that is not a UTF-8 JSON object, reads an integral number such as 2e3 as an integer, and ignores unknown fields. A listener applies an interrupt for an audio track it plays as a cut of that range. Pinned by vectors/media.json interrupts, interruptErrors, interruptReads and interruptReadErrors. The track name by protocol.json.
  • The transcript track (TRANSCRIPT_TRACK, transcript; media-layer.md, issue #320) carries one version of one segment per group as compact UTF-8 JSON: seq, track (default audio), fromUs, the optional untilUs, text and final, in that order, with no untilUs key when it is absent. The builder and the reader (buildTranscriptSegment / build_transcript_segment, parseTranscriptSegment / parse_transcript_segment) refuse a seq or timestamp that is not a nonnegative safe integer, an empty or non-string track, a track or text that is not valid Unicode, a non-string text, a non-boolean final, an untilUs not after fromUs, and a final segment without untilUs. The reader refuses a payload that is not a UTF-8 JSON object, reads an integral number as an integer, and ignores unknown fields. A cut starts at the later of the range’s fromUs (or 0) and the floored playout position, and changes nothing when that is not before untilUs. Otherwise a Transcript remembers it, drops the segments starting in it, and ends any segment running past its start there, final and marked cut. apply keeps a segment’s latest version, clipped by the remembered cuts in order of start: refused if it starts inside one (a final version refused this way also drops the line held for its segment), ended at the start of the first it runs into. It refuses any version of a segment the talker sent final, even one a cut dropped, and an interim version of a cut segment. A final version of a cut segment replaces it, clipped. It returns whether the held line changed. Lines are ordered by fromUs, then seq. At a position, the lines are those with fromUs at or before it, and there are none while idle. Pinned by vectors/media.json transcriptSegments, transcriptSegmentErrors, transcriptSegmentReads, transcriptSegmentReadErrors, transcripts and transcriptCutErrors. The track name by protocol.json.
  • Python’s Opus encoder and decoder (moq_media.opus, the opus extra; issue #320, ADR 0006) are PyAV’s libopus. The encoder takes signed 16-bit PCM, or a PyAV frame, in runs: the first call of a run gives the timeline timestamp of its first sample and later calls refuse one, packet k of a run is stamped at that timestamp plus k × 20 ms, and ending the run pads its partial frame with silence and drains the encoder. Defaults are voice: mono, 32 kb/s, libopus’s voip application. The decoder returns s16 PCM at the caller’s rate and channel count, and resets on request, at a discontinuity. TypeScript exports no counterpart: applications encode with WebCodecs, and the audio player decodes with an internal libopus WebAssembly build.
  • Encoding, pacing, and time-based frame dropping remain application responsibilities. Catalog serving, GOP grouping and recovery are the Media publishing policy below, and reading frames in decode order within a latency budget is Media subscribing, both implemented in both SDKs. A register_media publishing handle is not implemented.

The media publishing policy lives in the product package (@caprid/relay/media, caprid_relay.media) over registered tracks: one tier above the write slot, one below the proposed register_media handle. It knows no format. A frame carries its own object properties and a catalog is bytes. Implemented in both SDKs.

  • A catalog is a state-shaped track. The catalog service sends its document on the current relay demand and again on every later relay subscription, including a replacement subscription (the repeated “demanded” value), so a viewer joining after a relay reconnect receives it. A viewer joining while one relay subscription lasts triggers no re-send and is served the relay’s cached copy. Its document is fixed. Not adopted: MSF’s publish-on-change with a Joining FETCH from the relay cache. A plain SUBSCRIBE against the tested relay is live-edge only (#26), re-sending is what the Video.js player relies on, and MSF-01 §6’s one-object-per-stream sentence is treated as a draft defect because it contradicts LOC-04 §4.2 and MSF-01 §5.
  • A catalog write that fails while the track stays open is retried after a configurable delay (default one second) for as long as demand remains, keeping only the current document. Losing demand ends the retries until the next relay subscription. The service runs until cancelled or until the track ends, and raises the failure that ended the track when a write exposed it.
  • A document service serves a state-shaped track whose document the application replaces, such as the media timeline. It sends the latest document, each time as a new group, on every relay subscription as the catalog service does, and again on every update while the relay is subscribed. It sends nothing before the first document. An update without relay demand only replaces the document. An update made during a write is sent after it. Retries follow the catalog’s rule but always send the latest document. A retirement during a write ends the service with that failure. The track ending otherwise ends it quietly.
  • A frame writer maps encoded frames onto one track’s groups. Under keyframe grouping, the LOC mapping for video, a keyframe FINs the open group and opens a new one, and deltas append to it. Under frame grouping, for audio and any other independently decodable frames, every frame is its own single-object group. Object properties are sent as the frame supplies them.
  • Writes on one writer are served one at a time, in the order they were called, so an encoder whose output callback cannot await may call write without waiting for the previous one. Frames waiting behind the one being written are the writer’s backlog, bounded by maxBacklog / max_backlog (a positive integer, default 8 under keyframe grouping and 25 under frame grouping). A caller that awaits every write never has a backlog. A waiting write that is cancelled (in Python only, because a TypeScript promise cannot be cancelled) leaves the backlog, and under keyframe grouping leaves a gap: the frame after it is treated as following a discontinuity, and a keyframe is needed.
  • A frame that arrives at a full backlog means the transport has fallen behind, and the writer drops rather than queues. Under keyframe grouping every waiting frame is dropped and the open group is reset at once. The frame being written into it, if any, is dropped too. The arriving frame then opens the next group if it is a keyframe, and is dropped otherwise, as is every delta after it until the next keyframe. Under frame grouping the oldest waiting frame is dropped and the arriving frame takes its place at the back.
  • A frame the relay has no subscription for when its turn comes is skipped at once. The writer never waits on the write deadline, because an encoder loop cannot block. A relay demand change (a new or replacement subscription, or its end) ends the open group in the write slot at once. The writer finds it ended at the next frame, even one written before it has seen the change, and skips deltas until the next keyframe. The open group is not replayed.
  • Under keyframe grouping a writer says when it needs a keyframe (keyframeNeeded / keyframe_needed): from its start until a keyframe arrives, and again from each demand change, interruption, stale or discontinuous frame, or drop until the next keyframe arrives. A keyframe arrives when write is called with it, whether or not it is later sent. The optional onKeyframeNeeded / on_keyframe_needed callback runs synchronously on every entry into that state after the start, so an encoder can force a keyframe instead of waiting out its interval. An exception from it is reported (to console.error, or to the asyncio loop’s exception handler) and does not touch the writer. The writer watches the track’s demand from its creation until it closes or the track ends. In Python it is therefore created with an event loop running. Under frame grouping no keyframe is ever needed.
  • A frame that starts a group (a keyframe, or under frame grouping every frame) opens it as the group’s first object, so a delivery timeout counts the wait for settling room and for the stream. When that open fails, no group came back, so the outcome names none.
  • Lateness is the application’s. An optional stale predicate is consulted before a group is opened, again once a frame has opened its group (the frame went out with the open, and a stale one has its group reset), and before each later write, since time passes inside those steps. A stale frame is dropped, an open group that depended on it is reset, and deltas are dropped until the next keyframe. A frame flagged as following a discontinuity resets the open group the same way.
  • A failure is one of two kinds. It ends the writer (a retirement, see CONTEXT.md) exactly when the error code is CLOSED or the track reports closed afterwards, and likewise when the track is already closed as a frame’s turn comes. That frame and every later one are reported as ended, with the error, and nothing further touches the track. Any other failure (an interruption) resets the group, leaves the track open, and reports the frame as interrupted with its error. The next keyframe, or the next independent frame, opens a new group. The FIN of the previous group that a keyframe triggers is part of that keyframe’s write: a retirement during it ends the writer, while an interruption during it is absorbed, because the previous group’s delivered objects stand and the new group opens regardless. A group the writer resets for a drop is not a failure. Demand alone never decides: no demand is also what a track nobody subscribes to looks like.
  • A media track gets a default delivery timeout from mediaTrackProperties(kind, options) / media_track_properties(kind, ...), whose track properties go to registerTrack / register_track beside the format’s own (for LOC, locTrackProperties()): a stall then costs stale frames, not lasting latency. They hold one SUBGROUP_DELIVERY_TIMEOUT, in whole milliseconds rounded up from microseconds. Audio’s is 500 ms. Video’s is the target latency less the keyframe interval, and never less than the interval. The target latency defaults to twice the interval, so video’s default is one interval. An explicit subgroup timeout replaces either default. It is a track property because moq-relay reads an object’s 0x06 as a Timestamp, and because the relay ignores both sides’ values only the publisher’s own enforcement applies.
    • No object timeout is set. The frame writer opens each group with its first frame, so an object timeout would count the wait for settling room (#306), but once the group’s stream is open Chromium’s WebTransport takes each object at once even during a stall (every write resolved in under 1 ms through a 2 s blackout; #309). Whether the defaults should add one is #310.
    • Audio’s value is measured, not derived from the target latency, which would let a whole stall’s audio through late. Through a 2 s uplink blackout to sjc, with the subscriber on a clean path, it brought the player’s audio lag after the stall from 2.44 s to 0.43 s (0.27 s before it), and under a 4 s 80 kbit/s uplink cap from 4.6 s to 1.6–1.7 s. Without it, audio’s 50 groups a second fill the 64-group settling limit during a stall, so both tracks’ groups wait up to about 1 s to open (#302). The timer starts when a frame’s FIN is handed over, so the audio from a stall’s last 500 ms survives and arrives late: after an uplink stall the player’s lag can rise by up to about the audio timeout. With the WebCodecs example’s defaults, three 2 s uplink blackouts raised it by 214, 168 and 511 ms (2170 ms without), so the live bar is a rise below the audio timeout plus 100 ms, not a fixed jitter allowance.
    • A subgroup timeout resets a settling group the writer has moved past. It reports nothing to the writer and raises no keyframe-needed, since the open group decodes without it. Under keyframe grouping the open group has no timer until its FIN, at the next keyframe, so its objects can wait out a stall in the transport. Dropping late video is then the reader’s.
    • A downlink stall is outside the publisher’s reach: the relay keeps a stalled viewer’s backlog whatever these values are.
  • The same properties put audio ahead of video with a Default Publisher Priority (0x0E), lower being more urgent (draft-22 §5.1): 16 for audio and 96 for video, the values Huddle set by hand, pinned in spec/protocol.json as mediaTrackProperties.publisherPriority. An explicit publisher priority replaces either default. Group headers leave the priority out, so it is the track’s value for every group.
    • The TypeScript publisher’s transport sends the more urgent track first when the uplink is congested, in Firefox, in Node and over WebSocket (send order). Chromium’s WebTransport and the Python publisher do not.
    • On moq-relay 0.17 the default changes no delivery from the relay. The relay passes a publisher’s 0x0E to viewers in SUBSCRIBE_OK, but orders what it sends a viewer by subscriber priority alone and ignores the publisher’s when subscriber priorities tie (#154, relay interop). A viewer that wants audio first under congestion asks for it with its own SUBSCRIBER_PRIORITY, which mediaSubscribeParameters / media_subscribe_parameters gives it with the same values (media subscribing).
  • Every frame gets exactly one outcome: sent, skipped, stale, dropped, interrupted or ended. The writer returns it rather than raising, so a caller keeps counters without catching per frame, and the group the frame was written to or that was reset on its behalf travels with it. Closing the writer FINs the open group, reports every waiting frame as dropped, and refuses further frames with CLOSED. The registered track stays the application’s to close.
  • A replaceable track (createReplaceableTrack / ReplaceableTrack) publishes a track the application can replace mid-stream, for interruptions (track replacement). It registers its first track under the given name and lists it in the catalog (catalog.service.update(catalog.build(name)) / catalog_service.update(build_catalog(name))) at creation. Each replacement registers a new track, <name>-<n + 1> for replacement n from 1 unless the application names it, updates the catalog with that name, drops what the old track has not sent (frames still waiting resolve dropped, the old frame writer closes) and closes the old track. The new track takes every frame written from the call on. Registration or a catalog that fails leaves the current track in place, and a new track registered before the catalog failed is closed; the failed attempt uses its name up, and the next asks for the next. Closing retires the current track at once, so nothing waiting on it goes out later, and finishes once every old track still closing has closed. Frames written to a replacement while the relay has not subscribed to it wait, in order, until its first relay subscription or until the demand wait (default 2 s, 0 for none) runs out, then go to the frame writer one at a time, so a listener that subscribes once the new catalog reaches it misses none of the new reply. The first track never waits. Closing drops what has not been sent and closes the current track; writes and replacements are refused after it. Every frame still gets exactly one outcome.

A track’s objects arrive in any order across group streams: after a stall, an older group’s stream can finish after newer groups have started (issue #303). The raw subscription delivers them as they arrive. A frame sequencer (createFrameSequencer / FrameSequencer, in the format packages) turns one track’s objects into frames in decode order and drops what is too late against the broadcast’s playout clock. A frame reader (createFrameReader / FrameReader, in @caprid/relay/media and caprid_relay.media) is a subscription’s or a want’s handlers that drive a sequencer and poll it when a wait ends. It knows no format. Implemented in both SDKs.

  • A viewer subscribes to a media track with the parameters from mediaSubscribeParameters(kind, overrides) / media_subscribe_parameters(kind, overrides), in @caprid/relay/media and caprid_relay.media (#337). They hold a SUBSCRIBER_PRIORITY that puts audio ahead of video, lower being more urgent: 16 for audio and 96 for video, the same values as the publisher priorities, pinned in spec/protocol.json as mediaSubscribeParameters.subscriberPriority. The overrides are copied onto a new result, and an explicit subscriber priority, an integer from 0 to 255, replaces the default. A kind other than 'audio' or 'video', or a priority outside that range, is refused like the media track timing arguments.

    • This is the priority that decides delivery on moq-relay 0.17. The relay orders what it sends a viewer by subscriber priority alone and ignores the publisher’s 0x0E even when subscriber priorities tie (#154: 2.1 s against 26.9 s of lag with different subscriber priorities on a congested link, no difference with publisher priorities alone; relay interop). Without it, a viewer whose downlink is congested gets no audio preference, whatever the publisher declared.

    • Measured on 2026-10-20 against ord (moq-relay 0.17.5) with ts/scripts/probe-subscriber-priority.mjs: a Node publisher on a clean path sent 50 fps audio (160-byte frames, one group per frame) and about 1 Mbit/s of 30 fps video (a 40 kB keyframe each second), both registered with mediaTrackProperties, and a Node subscriber behind a downlink capped at 800 kbit/s (100 ms queue) timed the audio from capture to arrival, after a 5 s warm-up of a 25 s run. “Off” subscribes with no parameters, as before #337. “Inverted” sends audio 96 and video 16. The probe read each SUBSCRIBE off the subscriber’s request streams: SUBSCRIBER_PRIORITY 16 and 96 with the helper, none without. On a clean path every mode delivered audio in 1 to 2 ms.

      subscriber priority runs audio p50 p95 p99 max audio received after the warm-up
      on (audio 16, video 96) 3 43–45 ms 70–72 ms 74–76 ms 76–93 ms 995 of about 1,000 published
      off 3 176–181 ms 243–245 ms 253–259 ms 308–441 ms 996 of about 1,000 published
      inverted (audio 96, video 16) 2 11.9 s, 2 frames; none 2 and 0 of about 1,000

      Video lagged 7.7 to 8.3 s at p50 either way: the cap is below its rate, and the relay keeps a stalled viewer’s backlog. The relay stamped each group stream header with the track’s 0x0E in every mode, so a group header does not show what the viewer asked for. A track without 0x0E gets 128 in its headers, and delivery still follows SUBSCRIBER_PRIORITY: the same runs without mediaTrackProperties gave the same latencies, and inverted audio starved again (#344, relay interop).

    • It has to be chosen before SUBSCRIBE: on that relay any update() ends the subscription (deviation 4), so a viewer that wants another priority switches or subscribes again. SUBSCRIBE_OK carries the publisher’s 0x0E only after the choice is made.

    • The relay serves each viewer separately, so the priority orders only that viewer’s own tracks against each other. A track subscribed without it gets the relay’s default.

  • Lateness is measured against the playout clock’s position, which advances in real time through a stall. While the clock is idle nothing is late. The budget is the clock’s target latency, one setting for every track of the broadcast. The sequencer reports the interval between the keyframes of consecutive groups to the clock, so the default follows the stream. Only a keyframe that can still be delivered reports one: an old duplicate does not move the budget.

  • A frame’s timestamp is its LOC Timestamp, scaled by the object’s Timescale, then the track properties from SUBSCRIBE_OK. An object without one is counted (untimed) and ignored. An end-of-group status object ends its group. A repeated object is ignored.

  • Keyframe grouping (video): frames go out in order, group by group and object by object, as soon as they are next. In-order delivery adds no delay. A frame more than the target latency behind the position is past its deadline: its group is abandoned, and so is any group whose keyframe is.

  • When the next frame is missing (a gap inside the group being delivered, a still-open group whose next frame has not arrived, or the next group’s object 0), later frames wait only while a newer group’s keyframe is waiting, and only until the position passes the last delivered frame’s timestamp plus the target latency (at once if nothing was delivered yet), and while the clock is idle until the group ends or is reset. The group is then passed over. A group whose stream ended with a hole, or was reset, is passed over at once. A group that ended after its last object was delivered is followed by the next group with no discontinuity. Any other group that ends or is reset without its keyframe is discarded, since it can never be delivered, and the end or reset of a group that has not been seen is kept only for the next group.

  • When the next group ID has not been seen, the lowest group holding its keyframe waits while that keyframe is not yet due (its timestamp ahead of the position), since the missing group may still arrive or may never have been sent (a failed group’s ID is not reused). Groups and objects behind what was delivered or passed over, or below the group being delivered, are dropped as late.

  • Frame grouping (audio): every frame stands alone and goes out at once in group and object order. A group may hold several frames, one per object, as a group opened with openGroup() / open_group() writes them. The position is then a group and an object, so a group’s later objects are still ahead of its first. A frame whose group ID skips past the last delivered one, whose object ID skips within the last delivered group, or that opens a newer group past its object 0, carries gap. A frame more than the clock’s lateDropUs behind the position is dropped, so a burst after a stall cannot build a lasting backlog in the player. One behind what was delivered (an earlier group, or an earlier object of the same group) is dropped too. Nothing waits unless a reorder window is set (below).

  • Per-track delivery policy is TypeScript only. Python keeps the defaults. Three sequencer options tune one track without touching the clock its other tracks share. Each default leaves the behaviour above unchanged:

    • lateDropUs, frame grouping only: the late-drop limit for this track instead of the clock’s. Infinity drops nothing as late, for a reader that wants every frame (speech-to-text).
    • reorderWindowUs, frame grouping only (default 0): a frame whose group ID is more than one past the last group delivered, dropped or known to be over waits, with every newer one, for the missing group (a later object of the last group taken is not held), until the window has passed since that frame’s group arrived (on the clock’s local time). A missing group that arrives in time goes out in order with no gap. After the window the waiting frames go out with gap. A group that ended or was reset with nothing in it releases the wait at once, whenever its end arrived, before the first delivery or after a restart included. A frame that turns late while it waits is dropped as late. For a reader that wants every frame of a talker, the window should be at least the publisher’s delivery timeout (audio’s default is 500 ms): a group the publisher’s transport is still resending can arrive that long after the group that follows it, and moq-relay 0.17.5 delivers it however late. With a relay that honours a subscriber’s delivery timeout, the agent’s subscription sends none, or one at least as long as the window.
    • catchUpAfterUs: when frames keep arriving late with none delivered, from the first one dropped as late to the latest one at least this long apart (on the clock’s local time), the next poll returns catchUp: true, once, until a frame is delivered again. A restart does not re-arm it, so a replacement that is late from its first frame resyncs rather than catches up again. The TypeScript reader calls onCatchUp, whose owner replaces the subscription at the live edge (a want’s catch-up). Set it below the target latency, or a resync re-anchors on the late frames first.
  • A delivered frame whose group ID is more than one past the previous delivered frame’s carries missingGroups (TypeScript only): the inclusive range of group IDs of which nothing was delivered, whether never received, reset, or dropped as late or behind. It is the gap report an application acts on. Nothing in the SDK fetches the missing groups. A trailing loss is reported only with the next delivered frame, and never across a restart, where group IDs start over.

  • The first frame after anything was skipped carries the discontinuity gap. A decoder resets there, as at a keyframe it can start from. No gap is marked before the first delivery.

  • Resync: once frames have arrived but none within the budget for one whole target latency of the position, with their lateness steady, the sequencer delivers the newest late group that still has its keyframe (or the newest late frame), marks it resync, and resets the clock, which re-anchors on the next frame shown. Lateness is steady while it stays within a quarter of the target latency of where it stood when the wait began, judged on every late object, held or arriving. A stall’s growing lateness and a draining backlog’s shrinking one restart the wait. That group keeps collecting its later objects and its end while it waits. The wait is measured on the clock’s local time (localNow() / local_now_us()). If another track resets or re-anchors the shared clock meanwhile (a reset, or a position that no longer moves with local time, by more than a quarter of the target latency), a candidate back within the budget (or facing an idle clock) is delivered normally instead, and otherwise the wait starts over on the new timeline.

  • Resync forward: a keyframe that would open the next delivered group (or, under frame grouping, a frame) more than the target latency ahead of the position means the clock was anchored on a backlog, as after a resync during a long stall. The sequencer resets the clock and marks that frame resync, so the backlog does not become lasting latency. It does so in a poll of its own: frames before it in the same poll are returned first, with a wait of 0. A lasting increase in delay therefore costs one target latency, not a black or silent player.

  • Timestamp playout (TypeScript only, frame grouping only, off by default): timestampPlayout holds a frame that arrives ahead of its timestamp, for a talker that sends faster than real time (a text-to-speech agent). A frame, and every one after it, waits until its timestamp is within the lead (leadUs, default 100 ms; a function is read at every poll, and a value it returns that is not a finite number from 0 up counts as the default) of the position; the poll’s wait says when. While the clock has no position, the first frame considered anchors one of the sequencer’s own, that frame’s timestamp at the clock’s local time, until the clock has a position or is reset. Only a frame more than horizonUs (default 30 s) ahead of the position (or, before the clock has one, of the sequencer’s own) resyncs forward; one within it is held instead. Late drop, resync and catch-up are unchanged. The held bound grows to cover the horizon at 2.5 ms frames. A frame held is counted once (early). The audio player input’s playoutLeadUs is the lead that keeps its jitter buffer at its target. The sequencer keeps the least delay seen from the clock’s position to its local time since the clock was last reset, with the lead in force then; when the delay now exceeds it, less any growth of the lead since, by more than 40 ms (underruns left the clock behind), held frames are released early by the excess beyond those 40 ms, so the player’s jitter buffer holds it and trims it back. A lead that changes while the delay does not releases nothing early.

  • A want’s Restart or Move restarts the sequencer: everything held is forgotten, group IDs start over, the clock is reset so the new timeline is not judged against the old anchor, and the next frame carries restart, even when nothing was delivered before and even when that frame comes by a resync. A Move also brings the new subscription’s track properties.

  • poll() returns the frames ready now, at most maxFrames / max_frames (a positive integer, default all), and how long until a wait can end: 0 when more is ready now, nothing when only a new object can change it. Frames not taken stay held and are judged again at the next poll. While the clock is idle and frames are held, it asks to be polled again after 50 ms, since the clock may anchor without any new object. At most about 1,024 frames are held: past that, as when nothing polls, everything held is dropped as late and the next frame carries gap. The TypeScript reader polls after every event, the Python reader while a read is pending. Both poll again when that time comes, or after 50 ms if sooner: another track may reset or re-anchor a shared clock meanwhile, which no event of this track’s reports. The TypeScript reader hands each frame to onFrame. An event fed from within onFrame is applied after the frame in hand, and a Restart or Move fed that way drops the rest of the batch, which belongs to the replaced subscription. The Python reader is iterated and takes one frame per read, so a slow reader gets fresh frames, not a backlog. Both treat each group as one subgroup, as the frame writer sends it. A want read through a reader keeps drainMs / drain at 0, the default: a drained old subscription’s groups would arrive after the Move, indistinguishable from the new subscription’s. Counters: delivered, late, skipped groups, resyncs, untimed, and in TypeScript early. The decisions are pinned by vectors/frame-sequencer.json.

  • A listener follows a track replacement by the catalog: findReplacementTrack / find_replacement_track returns nothing while a newer catalog still lists the track playing (its namespace and name), and otherwise the first track listed with that track’s namespace, role and render group, or nothing when there is none (a removal, not a replacement).

  • The optional TypeScript Mediabunny adapter depends on the media format package and declares Mediabunny as a peer. Neither the core, format package, nor product SDK depends on the adapter.

  • The generic media format package depends only on the core, imports no product or transport library, and owns no encoder outside a module behind an optional extra that its root does not import: Python’s moq_media.opus, on PyAV (ADR 0006). The core imports nothing from the media package.

  • The product package does not depend on the media format package either. Its media policy pairs with it structurally: a frame carries its own properties, and the frame reader drives a sequencer it is given.

  • Each language has a product package that owns the Relay API and the authentication convention, and a generic MOQT core package that is protocol-only.

  • The core has no runtime dependencies, no knowledge of Relay, and no player, UI, or media engine imports. It works over an abstract transport that the product package supplies.

  • Transport adapters are separate packages the application passes in, and the product package depends on none of them. The TypeScript WebSocket adapter is the only package with a third-party runtime dependency, @moq/qmux at an exact version (ADR 0005).

  • Each language enforces this mechanically in its check task.

Live tests and examples in every language read the same environment:

Variable Meaning
RELAY_TOKEN Relay JWT. When absent, tooling prompts with input hidden. Never written to disk or printed.
RELAY_URL Overrides the default endpoint in protocol.json. Must be https and must not carry the token.
RELAY_SUB_URL Live tests only: the subscribers’ relay, for a cross-relay run. Defaults to RELAY_URL; same rules.
RELAY_NAMESPACE_PREFIX Namespace prefix the token authorizes. Live tests append a random field so runs never share relay state.

Each SDK ships an opt-in test against a real relay, excluded from the default check. It publishes and subscribes on the same random namespace, then asserts:

  1. A publisher connection and a subscriber connection both open with the supplied token.
  2. Once the publisher’s announced settles, subscriptions to two tracks on the namespace are accepted, retrying the fresh-namespace rejection while the announce propagates. A supervised subscriber’s wants make them and never resubscribe during the run.
  3. Binary payloads, an empty payload, and a text payload all arrive intact. Each is written once, after a warm-up marker the subscriber drops has arrived on that track: a SUBSCRIBE_OK does not mean the path to the publisher is ready (a viewer on another relay). The report records the warm-up writes.
  4. Closing one track writer does not affect delivery on another track.
  5. A late subscriber decodes LARGEST_OBJECT from a populated track’s SUBSCRIBE_OK and receives the next live object.
  6. The subscriber’s discovery on the namespace’s parent prefix reports the namespace announced once the publisher’s announced settles, then unannounced after the publisher’s clean close(), and nothing else for that namespace. The report records how long the withdrawal took.
  7. A want following a supervised publisher’s root receives the first generation’s objects. When a second supervised publisher starts on the same root while the first is still up, the want moves to the newer generation make-before-break. Both generations beat one shared counter every 50 ms, and the want cuts over once a beat of the new one is within a few of the latest delivered, with a 1 s drain. It reports one onSwitched and no onRestarted, and no beat number is missing across the move, on one relay and across two. The report records how long the move took.
  8. A switchable subscription moves from one layer to another make-before-break. The layers carry the same frame numbers, one group per frame, and no frame number is missing across the cutover. The report records how long the cutover took.
  9. A room-rooted endpoint, whose path is the run’s namespace (built with roomEndpoint / room_endpoint, so the namespace prefix must hold only room fields), takes namespaces relative to that path (subscribing). A publisher on each form and a subscriber on each form receive each other’s objects, discovery on each form names both publishers in its own form, and the relay refuses an absolute namespace on the rooted connection without delivering any of the room’s objects. The report records how long discovery took and the refusal.
  10. All connections close cleanly once the test closes them.

Adding an assertion here means adding it to every SDK’s smoke test. The TypeScript test runs the same scenario over each transport it ships: native QUIC in Node, WebTransport in Chromium, and QMux over WebSocket in Node and Chromium, with WebTransport in Firefox, WebKit and Firefox over WebSocket, and the WebTransport-to-WebSocket fallback on request (RELAY_RUNTIME). See relay interoperability for a local moq-relay test.

Datagram reception, discovery of tracks within a namespace (namespace discovery is specified), and player adapters are not implemented in any SDK and have no contract yet. Add them here when the first SDK gains them.

  • MAX_REQUEST_ID and REQUESTS_BLOCKED were removed after draft-16 (draft-22 Appendix A.6). They are not draft-22 request-budget messages. Unknown control messages terminate the session, as required by Section 9.
  • An unrecognized wire Object Status terminates the session with PROTOCOL_VIOLATION, following Section 11.1.1. The UNKNOWN_OBJECT_STATUS stream-reset code has a different meaning: a FETCH publisher cannot determine whether an object exists (Section 12.5). Ordinary subgroup transport resets remain per-stream delivery events.
  • A publisher retains no objects, so it serves no fills (Section 3.4). A SUBSCRIBE or REQUEST_UPDATE with FILL_PARAMETERS is answered as if the parameter were absent (SUBSCRIBE_OK, or REQUEST_OK for an update), and nothing about the subscription changes. Neither SDK publisher reports a Largest Object there (only in TRACK_STATUS_OK, below), so every fill range is empty and no fill fetch stream opens. The TypeScript core’s publish session can report one (getLargestObject on a registered track, for embedders such as Video.js). It then sends LARGEST_OBJECT in SUBSCRIBE_OK and REQUEST_OK, and fails a nonempty fill the way Section 3.4.1 requires: it opens a fill fetch stream, writes the FETCH_HEADER (committed where the transport has commit()), resets it, and counts it in PUBLISH_DONE’s Stream Count.
  • A publisher connection serves SUBSCRIBE, SUBSCRIBE_NAMESPACE (the announce carrier) and TRACK_STATUS. It answers PUBLISH with REQUEST_ERROR UNINTERESTED and every other request (FETCH, PUBLISH_NAMESPACE, SUBSCRIBE_TRACKS) with NOT_SUPPORTED: a non-relay endpoint may implement a subset and should answer the rest that way (Section 1.6).
  • TRACK_STATUS_OK from either SDK publisher carries LARGEST_OBJECT once the track has written an object, as Section 9.20.17 requires (“If Objects have been published on this Track the Publisher MUST include this parameter”). The publisher’s SUBSCRIBE_OK still does not, so the two answers differ there, where Section 9.13 says they carry the same parameters. Adding it to SUBSCRIBE_OK changes what every relay’s upstream subscription sees and turns a fill request into a failed fill (below), and has not been tested against moq-relay (publisher history). A TypeScript core track with getLargestObject answers TRACK_STATUS with that Largest Object, the same one its SUBSCRIBE_OK carries.

Four choices in the generic cores depend on how moq-relay treats the draft’s teardown messages, track aliases and request updates. All four conform to draft-22 (and draft-20). Each is recorded because the relay’s behavior, and for the first one its version, decides whether it works. The relay behavior, the evidence and how to re-test are in relay interoperability. Re-test against the relay before changing one: on a relay older than 0.15.4 the first one aborts every viewer’s track. Both SDKs agree on all four, and the core module headers (ts/packages/moq-core/src/protocol/publish-session.ts, python/packages/moq-core/src/moq_core/publish_session.py) state the same constraints.

  1. A subscription ends with PUBLISH_DONE, then FIN, which needs moq-relay 0.15.4 or later. Draft-22 Section 6.4.2.2: “the publisher of an Established subscription MUST send PUBLISH_DONE, before sending a FIN”, and Section 9.9: “A sender MUST NOT send PUBLISH_DONE until it has closed all streams it will ever open”. This conforms. Every end the publisher chooses resets the subscription’s data streams that are still open, writes PUBLISH_DONE with Stream Count set to the data streams opened for that subscription, and FINs the request stream:

    end PUBLISH_DONE status reason
    closing a track writer, or the publisher TRACK_ENDED (0x2) track ended
    a replacement subscription supersedes an older one TRACK_ENDED (0x2) superseded by a newer subscription
    an unsupported REQUEST_UPDATE, after its REQUEST_ERROR (Section 9.5.1) UPDATE_FAILED (0x8) unsupported subscription update
    the subscriber FINs its direction while the subscription is live INTERNAL_ERROR (0x0) subscriber closed its request stream

    No status names a replacement, and the relay finishes a track only on TRACK_ENDED. A subscription the subscriber cancels (a reset) gets no PUBLISH_DONE, and its open data streams are reset (Section 3.1.2). Once a subscription is ending no data stream opens on its alias, and one that finishes opening afterwards is reset and not counted. moq-relay 0.15.4 and later read the PUBLISH_DONE, wait for Stream Count data streams or a grace period (1 s), finish the track, and only then FIN their side of the request stream: a viewer receives an end-of-track object and PUBLISH_DONE status 2 track ended. A session that closes before the relay has done so aborts the track, which a viewer sees as status 0 internal error, so closing the publisher waits for that FIN (the one Section 6.4.2.2 says a requester SHOULD send once the responder’s FIN completes the request) on every subscription it ended before closing the transport (bounded, see deviation 2). The SDK subscriber sends that FIN too, once the publisher’s FIN follows PUBLISH_DONE, so an SDK publisher facing an SDK subscriber directly closes without waiting out the bound. moq-relay 0.15.4 and later treat a FIN without PUBLISH_DONE as a failed request and abort the track. moq-relay 0.15.1 to 0.15.3 do the reverse: any byte after SUBSCRIBE_OK aborts the track, so every end these SDKs send reaches viewers as status 0 internal error, where a bare FIN finished the track. No behavior ends cleanly on both, and the SDKs cannot tell the versions apart (the relay’s SETUP names only moq-lite-rs), so they require 0.15.4 or later.

  2. Neither connection sends GOAWAY. Draft-22 Section 9.2: “An endpoint sends a GOAWAY message on its control stream to inform the peer it intends to close the session soon”, and “A client MUST send a zero-length New Session URI in any GOAWAY”. GOAWAY is optional, so omitting it conforms. The SDK sends none because it has no drain behavior: a GOAWAY written as the transport closes gives the peer nothing to act on. How the relay treats a client GOAWAY depends on its version: moq-relay 0.14.17 closes the whole session, and 0.15.1 and later record it and keep the session. Closing the publisher ends every subscription with PUBLISH_DONE and a FIN (deviation 1). It then waits, for up to the largest write deadline among its tracks, for the relay to acknowledge the connection’s settling groups. Python also waits for the PUBLISH_DONEs and FINs to be acknowledged, so a short uplink outage at close does not send the session close while they are still in flight. After that it retracts the namespace with NAMESPACE_DONE, gives those writes a bounded window (250 ms), then waits up to 2 s for the relay to FIN its side of each ended subscription’s request stream, and ends the session. None of these bounds reports an error when it expires. The 2 s covers the relay’s 1 s grace plus a round trip. Against moq-relay the wait ends as soon as the relay has finished every track, typically within a round trip. Closing a subscriber connection closes the transport and writes nothing more on the control stream. The application observes nothing: the relay learns of the shutdown from the transport close, and from a publisher’s PUBLISH_DONEs and retraction before it. A GOAWAY the relay sends is still received and handled on both connections. Sending GOAWAY becomes useful once the SDK can drain on it, which means giving it time to arrive before the transport closes. On relays older than 0.15.1 it would still close the session.

  3. A track alias is the peer’s own request ID. Draft-22 Section 3.1: “A publisher MAY assign the same or different Track Aliases to these subscriptions”, and Section 3.1.3: “The same Track Alias MUST NOT be used by a publisher to refer to two different Tracks simultaneously in the same session.” This conforms, and narrows a MAY: every SUBSCRIBE_OK carries the SUBSCRIBE’s request ID as the alias, which is unique for the session by construction, so an alias is never shared between subscriptions, of one track or of two. A request ID the peer reuses, or one with a client’s (even) parity, closes the session with INVALID_REQUEST_ID before it can become an alias, as Section 6.4.2.1 requires. The subscriber connection applies the same check to every request the relay opens, including the ones it rejects, before answering. moq-relay allocates server request IDs odd, from 1, so the parity check never fires against it. moq-relay 0.15.1 and 0.15.8 close the session with PROTOCOL_VIOLATION when one alias names two different tracks, and cancel the newer subscription when two live subscriptions to one track share an alias. Unique aliases avoid both. The application never sees an alias on the Relay surface. Nothing needs to change unless the SDK serves concurrent subscriptions to one track, which ADR-0001 rules out today.

  4. update() sends REQUEST_UPDATE, which moq-relay does not implement. Draft-22 Section 9.5: “The receiver of a REQUEST_UPDATE MUST respond with exactly one REQUEST_UPDATE_OK or REQUEST_UPDATE_ERROR message indicating if the update was successful”, per-request names for REQUEST_OK and REQUEST_ERROR (Section 1.5). This conforms: each update() on an accepted subscription writes one REQUEST_UPDATE, with its own Request ID, on that subscription’s request stream. A REQUEST_OK reaches onUpdateOk / on_update_ok, and a REQUEST_ERROR reaches onError / on_error and ends the subscription. moq-relay answers neither, from 0.14.17 through 0.17.0: after SUBSCRIBE_OK it only polls the request stream for closure, any inbound byte counts as a close there, and it then takes its normal end path, dropping its upstream demand and writing PUBLISH_DONE status TRACK_ENDED (0x2) track ended. A viewer that calls update() therefore gets onPublisherDone / on_publisher_done within one round trip and no further objects, whatever the update carried (a subscriber priority, forward 0, or nothing), and onUpdateOk / on_update_ok never fires. As after any done, the subscription’s ended stays pending until it is cancelled or the connection closes. The session stays open, and a new subscribe on the same connection works. So against moq-relay a pause is a cancel followed by a new subscribe later, and a changed priority or filter is a new subscription. Subscribing paused is not an alternative: moq-relay 0.17.0 refuses a SUBSCRIBE with FORWARD = 0 with request error 3 (NOT_SUPPORTED) FORWARD=0 not supported and keeps the session open, where 0.15.8 closed the session. The SDK sends the update anyway and does not warn, as decided in issue #94. REQUEST_UPDATE support would be an upstream relay feature.

Both cores pin the second choice for each role: after an orderly close, the control stream of a publish session and of a subscribe session carries SETUP and nothing else.