From 16b0aa7e062c9decf252bc2ae19a662e05eb2338 Mon Sep 17 00:00:00 2001
From: Luke Curley <kixelated@gmail.com>
Date: Thu, 18 Jun 2026 20:26:14 -0700
Subject: [PATCH] Add a telescoping SUBSCRIBE_DEMAND to moq-lite and a
 moq-transport extension

Report the downstream demand for a subscription back up the relay
fan-out tree, so an origin learns its true audience and what those
subscribers need across any number of hops.

moq-lite: a new SUBSCRIBE_DEMAND message on the Subscribe Stream
carrying Subscriptions Created and Subscriptions Closed (cumulative
counts whose difference is the current subscriber count; a relay sums
each across its downstreams, so demand telescopes for free) and a
Group Request (the minimum group the subscriber wants produced,
encoded like Group Start). The group request is a level, not a
one-shot "new group now" trigger -- already satisfied if a group at
or beyond it exists -- so it is idempotent and aggregates as the
maximum of downstream requests; a publisher MAY also treat a rising
Subscriptions Created as an implicit new-group request. A Type tag is
added to the subscriber's post-SUBSCRIBE messages (0x0
SUBSCRIBE_UPDATE, 0x1 SUBSCRIBE_DEMAND).

moq-transport: draft-lcurley-moq-demand expresses the same in
moq-transport's idiom -- a fire-and-forget SUBSCRIBE_DEMAND control
message on the request stream (no Request ID, no response),
negotiated per hop via a SUBSCRIBE_DEMAND Setup Option. Replaces the
earlier subscribe-stats framing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 draft-lcurley-moq-demand.md | 195 ++++++++++++++++++++++++++++++++++++
 draft-lcurley-moq-lite.md   |  54 +++++++++-
 2 files changed, 247 insertions(+), 2 deletions(-)
 create mode 100644 draft-lcurley-moq-demand.md

diff --git a/draft-lcurley-moq-demand.md b/draft-lcurley-moq-demand.md
new file mode 100644
index 0000000..55d5f9c
--- /dev/null
+++ b/draft-lcurley-moq-demand.md
@@ -0,0 +1,195 @@
+---
+title: "MoQ Demand Extension"
+abbrev: "moq-demand"
+category: info
+
+docname: draft-lcurley-moq-demand-latest
+submissiontype: IETF  # also: "independent", "editorial", "IAB", or "IRTF"
+number:
+date:
+v: 3
+area: wit
+workgroup: moq
+
+author:
+ -
+    fullname: Luke Curley
+    email: kixelated@gmail.com
+
+normative:
+  moqt: I-D.ietf-moq-transport
+
+informative:
+
+--- abstract
+
+This document defines a SUBSCRIBE_DEMAND message for MoQ Transport {{moqt}}: fire-and-forget feedback that a subscriber reports about a subscription describing the downstream demand for it.
+It carries how many subscribers a subscription represents — as a pair of cumulative counts whose difference telescopes up the relay fan-out tree, letting a publisher learn its total audience across any number of hops — and an optional Group Request asking the publisher to produce a new group once the subscriber has fallen behind.
+
+--- middle
+
+# Conventions and Definitions
+{::boilerplate bcp14-tagged}
+
+
+# Introduction
+A publisher in {{moqt}} often wants to know the demand for a Track: how many subscribers are receiving it, and whether any of them needs a fresh group to make progress.
+Both are straightforward when a subscriber connects directly, but {{moqt}} is designed around relays: a relay aggregates many downstream subscriptions for the same Track into a single upstream subscription toward the origin (its "fan-out" tree).
+The origin sees one upstream subscription per relay, not the individual subscribers behind it, so it can neither count its true audience nor learn what those subscribers need without out-of-band coordination.
+
+This document defines a SUBSCRIBE_DEMAND message that reports this demand back up the subscription path.
+It carries two kinds of information, each chosen so that it aggregates cheaply up the fan-out tree:
+
+- **Audience size**, as a pair of cumulative counts, `Subscriptions Created` and `Subscriptions Closed`. A relay reports the **sum** of each across the downstream subscriptions it serves, so the difference — the current number of subscribers — telescopes for free. At the origin, the demand on each upstream subscription is the total number of subscribers reachable through that relay, transitively, across any number of hops.
+- A **Group Request**, the minimum group a subscriber wants the publisher to produce. A relay reports the **maximum** across its downstreams (less any it can already satisfy from cache). It is expressed as a *level* — "I want a group at least this new" — not a one-shot trigger, so it is idempotent and deduplicates naturally as it aggregates.
+
+Demand changes as subscribers join, leave, and fall behind, so SUBSCRIBE_DEMAND is sent repeatedly over the life of a subscription.
+It is a dedicated, fire-and-forget message rather than a parameter on REQUEST_UPDATE ({{moqt}} Section 10.9).
+A REQUEST_UPDATE consumes a Request ID ({{moqt}} Section 10.1) and obliges the receiver to answer with a REQUEST_OK or REQUEST_ERROR ({{moqt}} Section 10.9) — a request/response transaction whose purpose is to *modify* the subscription's delivery range or priority, which is a poor fit for feedback pushed repeatedly that changes neither.
+SUBSCRIBE_DEMAND instead rides the subscription's existing request stream, consumes no Request ID, and elicits no response.
+
+
+# Setup Negotiation
+The Demand extension is negotiated during the SETUP exchange as defined in {{moqt}} Section 9.4.
+
+Both endpoints indicate support by including the following Setup Option:
+
+~~~
+SUBSCRIBE_DEMAND Setup Option {
+  Option Key (vi64) = 0xC0117
+  Option Value Length (vi64) = 0
+}
+~~~
+
+The extension is available on a hop only if both endpoints on that hop included this option.
+The extension is negotiated independently on each hop: a relay MAY support it upstream but not downstream, or vice versa.
+
+Negotiation is mandatory before the message is sent.
+{{moqt}} (Section 10) requires an endpoint that receives an unknown control message type to close the session, so — unlike an optional parameter, which can be ignored — a SUBSCRIBE_DEMAND message cannot be sent speculatively.
+An endpoint MUST NOT send SUBSCRIBE_DEMAND on a hop that did not negotiate this extension.
+
+
+# SUBSCRIBE_DEMAND Message
+This document defines a new control message, sent on a subscription's request stream ({{moqt}} Section 3.3) by the endpoint that opened it (the subscriber, which for an upstream subscription is the relay).
+
+~~~
+SUBSCRIBE_DEMAND Message {
+  Type (vi64) = 0xC0117
+  Length (16)
+  Subscriptions Created (vi64)
+  Subscriptions Closed (vi64)
+  Group Request (vi64)
+}
+~~~
+
+The message MUST NOT be the first message on the request stream; it follows the SUBSCRIBE ({{moqt}} Section 10.7) that opened the stream.
+It consumes no Request ID ({{moqt}} Section 10.1), and the receiver MUST NOT respond to it.
+A subscriber MAY send it any number of times over the life of the subscription to refresh the values.
+
+**Subscriptions Created** and **Subscriptions Closed**:
+Cumulative counts, over the life of this subscription, of the downstream subscriptions it represents that have been created and closed respectively.
+The current demand — the number of subscribers presently receiving the Track through this subscription — is `Subscriptions Created - Subscriptions Closed`.
+A leaf subscriber represents only itself: `Subscriptions Created` is `1` and `Subscriptions Closed` is `0`.
+These are the defaults until a SUBSCRIBE_DEMAND is received, so a subscriber that represents only itself need not send the message.
+
+**Group Request**:
+The minimum group the subscriber wants the publisher to produce.
+A value of `0` means no request: the publisher produces groups at its own cadence.
+A non-zero value `N` requests that the publisher produce a group with Group ID at least `N - 1`; the offset by one keeps `0` available as "no request" while leaving Group ID `0` requestable.
+See [Group Requests](#group-requests) for the semantics.
+
+
+# Semantics
+
+## Audience Size
+The audience size is a reduction up the subscription tree.
+
+A **leaf subscriber** (one that is not a relay) represents itself: `Subscriptions Created` of `1` and `Subscriptions Closed` of `0`, a demand of `1`.
+It need not report this default.
+
+A **relay** that aggregates one or more downstream subscriptions for a Track into a single upstream subscription reports, on that upstream subscription, the **sum** of the `Subscriptions Created` of its downstreams and the **sum** of their `Subscriptions Closed`, treating a downstream that has not reported as `1` created and `0` closed.
+It increments `Subscriptions Created` as downstream subscriptions are created and `Subscriptions Closed` as they are closed, and SHOULD keep both counts non-decreasing over the upstream subscription's life — accounting a fully-departed downstream's outstanding demand (its last-reported `Created - Closed`) as newly closed — so that neither count moves backward when a downstream detaches.
+When either sum changes, the relay sends a SUBSCRIBE_DEMAND message upstream with the new totals.
+
+Because each relay reports the sum of its subtree, the difference telescopes: at the origin, `Created - Closed` on a given upstream subscription is the total number of leaf subscribers reachable through that subscription, across any number of relay hops.
+A publisher reads its total audience for a Track as the sum of `Created - Closed` over the subscriptions it is serving.
+
+Reporting the two counts separately, rather than a single current-demand gauge, lets a publisher distinguish *churn* from a *new arrival*: a rising `Subscriptions Created` means at least one new subscriber has joined, which a publisher MAY treat as an implicit [Group Request](#group-requests), since a newly-joined subscriber generally needs a fresh group (e.g. a keyframe) to begin decoding.
+
+## Group Requests {#group-requests}
+A subscriber raises `Group Request` to ask the publisher to start a new group once it has fallen too far behind the live edge to catch up — for example, after missing the group with ID `5` it requests `6` to jump to the next group rather than wait for it to be produced naturally.
+
+The request is a **level**, not an edge: it names the minimum group the subscriber needs, and is satisfied the moment a group at or beyond it exists.
+A publisher that has already produced a group with Group ID at or above the request takes no action — the request is already met.
+This is the key difference from a one-shot "produce a new group now" signal: because the request is idempotent, it can be retransmitted, coalesced, and aggregated without a publisher producing one redundant group per copy it receives.
+
+`Group Request` fans *in* at a relay as the **maximum** of its downstreams' requests, minus any the relay can already satisfy itself: a relay that holds a group at or beyond a downstream's request serves it from cache and does not propagate it; it forwards a request upstream only when it lacks a group at or beyond the highest value its downstreams want.
+Once the publisher produces a group satisfying the highest request, every lower request is satisfied at once.
+
+A publisher SHOULD honor a `Group Request` by producing a new group as soon as it can (subject to its own encoding constraints, such as a keyframe boundary), but MAY decline or defer it; the request does not override the publisher's control of its own Track.
+`Group Request` is the only field of this message that affects delivery. The audience-size counts MUST NOT influence prioritization, caching, congestion response, or any other distribution decision beyond the optional new-group hint above.
+
+
+# Rate Limiting
+Subscriber churn can change the audience size rapidly, and at a busy relay each change would otherwise produce an upstream SUBSCRIBE_DEMAND message.
+
+A relay SHOULD rate-limit SUBSCRIBE_DEMAND messages per subscription, coalescing audience-size changes that occur within a short window (on the order of a second) and then sending the latest values.
+Because each message carries current values rather than deltas, a change that reverts within the window — a subscriber that joins and leaves, or leaves and returns — requires no upstream message at all.
+
+A `Group Request` increase is latency-sensitive — the subscriber is stalled waiting for a group it can decode — and SHOULD be forwarded promptly rather than held for the audience-size window.
+Because the message is independent of REQUEST_UPDATE, neither kind of update delays a genuine subscription change: delivery-affecting updates are forwarded according to {{moqt}} without regard to the demand window.
+
+
+# Security Considerations
+**Audience disclosure.**
+`Subscriptions Created` and `Subscriptions Closed` disclose aggregate viewership to the publisher and to every relay on the path toward it.
+For some applications the size of an audience is sensitive (for example, it can reveal the popularity or reach of content, or that an audience has dropped to zero).
+Because the extension is negotiated per hop, an endpoint that considers this sensitive simply does not advertise the SUBSCRIBE_DEMAND Setup Option, and no demand is exchanged on that hop.
+
+**Untrusted values.**
+The values are supplied by the subscriber side and aggregated by intermediaries, none of which the publisher can fully trust.
+A malicious or buggy subscriber can report inflated or deflated counts, and a malicious relay can report any sums regardless of its actual downstream subscriptions.
+The audience-size counts are therefore advisory: an endpoint MUST NOT use one for any security-sensitive purpose — such as billing, admission control, rate limiting, or capacity planning that affects other subscribers — without independent verification.
+A `Group Request` can at most ask the publisher to produce a group it could already have produced for any subscriber, and a publisher MAY decline it; honoring requests at an unbounded rate would let a subscriber drive group production, so a publisher SHOULD bound the rate at which it acts on requests.
+
+**Churn amplification.**
+A subscriber that rapidly joins and leaves, or repeatedly raises its `Group Request`, could attempt to amplify control traffic toward the origin.
+The rate-limiting in [Rate Limiting](#rate-limiting) bounds the audience-size case, and the idempotent, level-based `Group Request` collapses repeated identical requests into a single upstream value and a single produced group.
+Because the message consumes no Request ID and elicits no response, this churn cannot exhaust an identifier space or force the origin into matching replies.
+
+This extension introduces no other security considerations beyond those described in {{moqt}}.
+
+
+# IANA Considerations
+
+This document requests the following registrations.
+
+## MOQT Setup Options
+
+This document requests a registration in the "MOQT Setup Options" registry ({{moqt}} Section 15.4), whose policy is Specification Required.
+moq-transport defines no private-use range for Setup Options; extensions request a (provisional) codepoint.
+A high, distinctive value is chosen to avoid the low ranges reserved by {{moqt}} and to minimize collisions with provisional registrations by other extensions; it also avoids the greasing pattern (`0x7f * N + 0x9D`).
+
+| Value | Name | Reference |
+|:------|:-----|:----------|
+| 0xC0117 | SUBSCRIBE_DEMAND | This Document |
+
+## MOQT Message Types
+
+This document registers a control message type.
+{{moqt}} does not yet establish an IANA registry for message types, so this is a provisional codepoint pending such a registry; the value is chosen to be high and distinctive to avoid the low ranges {{moqt}} assigns and to minimize collisions with provisional registrations by other extensions, and it avoids the greasing pattern (`0x7f * N + 0x9D`).
+This is the same value as the SUBSCRIBE_DEMAND Setup Option above; Setup Options and message types are independent namespaces, so the shared value is unambiguous.
+
+The Stream column has the meaning defined by {{moqt}} Section 10: "Request" indicates the message is carried on a bidirectional request stream. The message is not marked "First": it never opens a request stream.
+
+| Value | Name | Stream | Reference |
+|:------|:-----|:-------|:----------|
+| 0xC0117 | SUBSCRIBE_DEMAND | Request | This Document |
+
+
+--- back
+
+# Acknowledgments
+{:numbered="false"}
+
+This document was drafted with the assistance of Claude, an AI assistant by Anthropic.
diff --git a/draft-lcurley-moq-lite.md b/draft-lcurley-moq-lite.md
index feef157..0ef8d6b 100644
--- a/draft-lcurley-moq-lite.md
+++ b/draft-lcurley-moq-lite.md
@@ -299,7 +299,8 @@ There MAY be multiple Announce Streams, potentially containing overlapping prefi
 ### Subscribe
 A subscriber opens Subscribe Streams to request a Track.
 
-The subscriber MUST start a Subscribe Stream with a SUBSCRIBE message followed by any number of SUBSCRIBE_UPDATE messages.
+The subscriber MUST start a Subscribe Stream with a SUBSCRIBE message followed by any number of SUBSCRIBE_UPDATE and SUBSCRIBE_DEMAND messages.
+The opening SUBSCRIBE is identified by the stream type (0x2) and carries no message Type; every subsequent message from the subscriber begins with a Type that distinguishes SUBSCRIBE_UPDATE (0x0) from SUBSCRIBE_DEMAND (0x1), mirroring how the publisher's messages on this stream are typed.
 When a start group can be resolved, the publisher replies with a SUBSCRIBE_OK message (confirming the subscription and resolving its start group), followed by any number of SUBSCRIBE_END and SUBSCRIBE_DROP messages.
 When the accepted track has already ended with no matching groups there is no start group to resolve, so the publisher sends SUBSCRIBE_END with no preceding SUBSCRIBE_OK.
 A rejection is a stream reset: if the publisher cannot serve the subscription — the track does not exist, or it otherwise refuses — it MUST reset the stream rather than leave it pending, and SHOULD do so promptly (within roughly a round trip) so the subscriber is not left waiting.
@@ -770,6 +771,7 @@ The start and end group can be changed in either direction (growing or shrinking
 
 ~~~
 SUBSCRIBE_UPDATE Message {
+  Type (i) = 0x0
   Message Length (i)
   Subscriber Priority (8)
   Subscriber Ordered (8)
@@ -779,7 +781,54 @@ SUBSCRIBE_UPDATE Message {
 }
 ~~~
 
-See [SUBSCRIBE](#subscribe) for information about each field.
+**Type**:
+Set to 0x0 to indicate a SUBSCRIBE_UPDATE message.
+
+See [SUBSCRIBE](#subscribe) for information about each remaining field.
+
+
+## SUBSCRIBE_DEMAND
+A subscriber sends a SUBSCRIBE_DEMAND message to report the downstream demand for a subscription: how many subscribers it represents and, optionally, the next group they need.
+Unlike SUBSCRIBE_UPDATE it does not change the subscription's delivery range or priority; it is kept separate so that refreshing demand does not re-echo the subscription's delivery parameters on every change.
+A subscriber MAY send multiple SUBSCRIBE_DEMAND messages over the life of the subscription to refresh the values.
+
+~~~
+SUBSCRIBE_DEMAND Message {
+  Type (i) = 0x1
+  Message Length (i)
+  Subscriptions Created (i)
+  Subscriptions Closed (i)
+  Group Request (i)
+}
+~~~
+
+**Type**:
+Set to 0x1 to indicate a SUBSCRIBE_DEMAND message.
+
+**Subscriptions Created** and **Subscriptions Closed**:
+Cumulative counts, over the life of this subscription, of the downstream subscriptions it represents that have been created and closed respectively.
+The current demand — the number of subscribers presently receiving the Track through this subscription — is `Subscriptions Created - Subscriptions Closed`.
+These are subscriber-side values that fan *in* at a relay: a relay merging multiple downstream subscriptions into one upstream subscription reports the **sum** of their `Subscriptions Created` and the **sum** of their `Subscriptions Closed`, so both counts (and therefore the demand) telescope up the fan-out tree.
+A publisher thus learns its total number of downstream subscribers across any number of relay hops by reading `Created - Closed` on its single upstream subscription, without any per-hop coordination.
+
+A **leaf subscriber** represents only itself: `Subscriptions Created` is `1` and `Subscriptions Closed` is `0` (a demand of `1`).
+These are the defaults until a SUBSCRIBE_DEMAND is received, so a leaf subscriber need not send the message at all.
+A **relay** increments `Subscriptions Created` each time a downstream subscription is created and `Subscriptions Closed` each time one is closed; it SHOULD keep both counts non-decreasing over the upstream subscription's life, accounting a fully-departed downstream's outstanding demand as closed.
+Because they are independent counts rather than a single gauge, a publisher can also treat a rising `Subscriptions Created` as an implicit request to start a new group: a newly-joined subscriber generally needs a fresh group (e.g. a keyframe) to begin decoding, so the publisher MAY start one when the count increases, without waiting for an explicit `Group Request`.
+
+The counts are advisory: a subscriber MAY misreport them, and a relay MUST NOT use them for delivery decisions other than the optional new-group hint above.
+
+**Group Request**:
+The minimum group the subscriber wants the publisher to produce, encoded like `Group Start` (see [SUBSCRIBE](#subscribe)): `0` means no request (the default — the publisher produces groups at its own cadence), and a non-zero value is the requested absolute group sequence + 1.
+A subscriber raises this to ask the publisher to start a new group once it has fallen too far behind the live edge to catch up — for example, after missing group `5` it requests `6` to jump to the next group rather than wait.
+Unlike a one-shot "new group now" signal, this is a *level* a publisher compares against: if it has already produced a group at or beyond the request the request is already satisfied and no new group is needed, which makes the request idempotent and safe to retransmit or aggregate.
+This value fans *in* at a relay as the **maximum** of its downstream requests, minus any the relay can already satisfy from its own cache: a relay forwards a request upstream only when it lacks a group at or beyond the highest value its downstreams want.
+Once the publisher produces a group satisfying the highest request, every lower request is satisfied at once.
+
+A relay SHOULD rate-limit SUBSCRIBE_DEMAND messages it sends upstream, coalescing demand changes within a short window (roughly a second) so that rapid subscriber churn does not flood the upstream with control messages; because each message carries the latest values rather than deltas, a change that reverts within the window requires no message at all.
+A `Group Request` increase is latency-sensitive, however, and SHOULD be forwarded promptly rather than held for the demand window.
+
+Future revisions MAY append additional fields to this message; the `Message Length` bounds the message so a receiver can stop after the fields it understands.
 
 
 ## TRACK
@@ -1066,6 +1115,7 @@ A generic library or relay MUST NOT inspect or modify the decompressed contents
 # Appendix A: Changelog
 
 ## moq-lite-05
+- Added a SUBSCRIBE_DEMAND message reporting the downstream demand for a subscription. It carries `Subscriptions Created` and `Subscriptions Closed` (cumulative counts whose difference is the current subscriber count; both sum up the relay fan-out tree, so a publisher reads its total audience across any number of hops from its single upstream subscription) and a `Group Request` (the minimum group the subscriber wants produced, encoded like `Group Start`; `0` means no request). The group request is a level rather than a one-shot "new group now" signal — already satisfied if a group at or beyond it exists — making it idempotent and aggregatable as the maximum of downstream requests; a relay also MAY treat a rising `Subscriptions Created` as an implicit new-group request. It is kept separate from SUBSCRIBE_UPDATE so refreshing demand does not re-echo the subscription's delivery parameters; relays SHOULD rate-limit it to absorb subscriber churn. This also introduced a `Type` tag on the subscriber's post-SUBSCRIBE messages (`0x0` SUBSCRIBE_UPDATE, `0x1` SUBSCRIBE_DEMAND) to distinguish them, mirroring the publisher's typed responses.
 - Added a SETUP message, sent once on a unidirectional Setup Stream (0x1) at the start of the session and FIN'd immediately. It carries a list of Setup Parameters for negotiating optional capabilities and extensions per-hop, replacing the prior stream-probing approach (version is still negotiated via ALPN, not SETUP). Endpoints keep exchanging non-Setup streams without waiting for SETUP, buffering only a stream whose encoding a negotiated extension would change; unknown stream types are still reset as a fallback.
 - Added a SETUP `Probe` parameter advertising the publisher's capability level: `None`, `Report` (measure and report the estimated bitrate), or `Increase` (additionally pad to probe for bandwidth above the current sending rate). The levels are nested since probing without measuring is meaningless. A subscriber must not rely on a level the publisher did not advertise.
 - Added `Frame Start` to FETCH so a subscriber can begin partway through a group instead of always at frame `0`, allowing resumption of a partially-received group.
