715 lines
41 KiB
Markdown
715 lines
41 KiB
Markdown
# WAI Extension: Live Ads
|
||
|
||
> Status: Draft. Registers the record `wai.live.ads`: server-guided ad insertion for rooms.
|
||
> The room authority signs an **ad policy**, `WAP1`, naming the grant its ad decider runs
|
||
> under and how the room's breaks are presented. The breaks themselves are the room's signed
|
||
> `cue` events ([live-events](live-events.md) §4.3), an out and its in, or a break of known
|
||
> duration, with a post-roll marked by a **break annotation**, `WAB1`, in the cue author's
|
||
> chain. Each break maps, deterministically, onto one interstitial date range of HLS and one
|
||
> alternative-presentation event of DASH, so every edge writes the same playlist and MPD bytes
|
||
> and the manifests stay shared and cacheable. Per-viewer decisioning happens behind the one
|
||
> URI each break names: the **asset list** a player fetches when it reaches the break, filled
|
||
> by `wai.video.ssai`'s deterministic decision. Each viewer's impressions are sealed in a
|
||
> `wai.video.ssai` receipt of its **break form**, which names the break, the decision's
|
||
> inputs and the asset list served, and a verifier recomputes the decision, the asset list and
|
||
> the spend against the decider grant's funds ceiling. No new transport. The reference
|
||
> implementation is the `live_ads` module of `wai-rs` (feature `live_ads`) with the break form
|
||
> in `wai_video_ssai`; the corpus is [`live-conformance/ads/`](../live-conformance/ads/), and
|
||
> its second implementation is `live-conformance/live_ads_verify.py`.
|
||
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
|
||
>
|
||
> Texts read for this revision: `draft-pantos-hls-rfc8216bis-22` (1 May 2026), its §4.4.5.1
|
||
> (`EXT-X-DATERANGE`), §6.2.1, §6.2.4, Appendix D (Interstitials) and Appendix F (Preloading
|
||
> HLS Date Range Resources). Its Appendix H is a glossary and defines no date-range schedule,
|
||
> so nothing here maps onto it. ISO/IEC 23009-1:2026, the sixth edition, through the XML
|
||
> schema that accompanies it (its types `EventType`, `AlternativeMPDEventType`,
|
||
> `AlternativeMPDReplaceEventType`, `ImportedMpdType` and `PresentationType`) and the schema's
|
||
> example MPDs G23, G29-1 and G29-2, at the schema's revision of 2026-06-13; the clause text of
|
||
> its §5.16 was not available to this revision (§6.1). A section number of one of these texts
|
||
> is that revision's.
|
||
|
||
## 1. Scope
|
||
|
||
In scope: the ad policy, its signature and rules (§3); the room's breaks, derived from its
|
||
`cue` events and break annotations, with their identity and refusals (§4); their HLS
|
||
interstitial date ranges (§5) and their DASH alternative-presentation events (§6); the asset
|
||
list, the decision that fills it, and a viewer joining a break already running (§7); the break
|
||
form of the `wai.video.ssai` receipt (§8); and verification end to end, from the room's events
|
||
to the spend (§9).
|
||
|
||
Out of scope: a transport; how a decider assembles each viewer's candidate pod (targeting);
|
||
how beacons reach it; the SCTE-35 messages a cue may carry, which WAI carries verbatim and
|
||
never reads (live-events §4.3); the stitched form of `wai.video.ssai`, which is unchanged
|
||
(§8.1); and a pre-roll, which a room does not have: a viewer who joins a running break is
|
||
given what remains of it (§7.4), and one who joins between breaks starts with the program.
|
||
|
||
Relationship to the core spec: `wai.live.ads` is a **record** ([SPEC](../SPEC.md) §5): a
|
||
signed record a WAI tool emits and verifies. It names no payload format and is not
|
||
dispatchable.
|
||
|
||
Relationship to [live-events](live-events.md): this extension changes nothing in it. A break
|
||
is opened and closed by `cue` events as they are; the break annotation rides in the `meta`
|
||
kind (§4.2 there), bound to its cue by the chain, as [timed-text](timed-text.md)'s
|
||
annotation is bound to its caption. Live-events' own date range of each cue (§8.3 there) is
|
||
written beside the interstitial date range of this extension, under another `ID` and another
|
||
`CLASS`.
|
||
|
||
Relationship to [live-rooms](live-rooms.md): a cue's author is held to its grant: a principal
|
||
whose grant in effect does not permit `cue` is refused by the room verifier,
|
||
`kind_not_permitted`, before this extension reads its cue (live-rooms §9.2 step 4). The
|
||
decider runs under a room grant, whose funds ceiling bounds the room's ad spend (§3.4), and
|
||
names it in its receipts as live-rooms §4.5 has every receipt name a grant.
|
||
|
||
Why a record of its own, beside `wai.video.ssai`: the policy, the breaks and their renderings
|
||
are verified against a room and its events track, which the stitched form never reads; the
|
||
impressions are `wai.video.ssai`'s, in a second form of its receipt (§8), so that one decision
|
||
rule and one spend rule serve stitched and server-guided insertion alike, and every receipt of
|
||
the stitched form keeps its bytes.
|
||
|
||
## 2. Terms
|
||
|
||
- **Room**, **room clock**, **principal**, **event**, **chain**, **grant**, **room key**,
|
||
**merged order**: as in live-events §2 and live-rooms §2 and §9.2.
|
||
- **Cue**: a `cue` event (live-events §4.3). Its **type** is out (1), in (2), chapter (3) or
|
||
break (4).
|
||
- **Break**: a span of the room's program that ads replace, opened by a cue of type out or
|
||
break (§4.3). A break is **open** from an out until its in, and **closed** otherwise.
|
||
- **Post-roll**: a break played after the program ends (§4.2).
|
||
- **Avail**: the duration, in milliseconds, a break's decision fills (§4.3).
|
||
- **Decider**: the principal that decides each viewer's fill and signs the receipts, under
|
||
the grant the policy names (§3.4).
|
||
- **Pod**: the candidate ads a decision runs over for one viewer and one break, each an
|
||
`ad_id`, a `creative_hash`, a `duration_ms` and a `price_micros` (§7.1).
|
||
- **Fill**: the ads a decision selects, in slot order.
|
||
- **Asset list**: the response to the request a player makes for a break: an HLS asset list
|
||
(§7.2) or a DASH list MPD (§7.3).
|
||
- **Session**: one viewer's playback session, named in a receipt by 16 bytes the decider
|
||
assigns (§8.2).
|
||
|
||
Integers in the records of §3 and §4 are little-endian, and a digest there is SHA-256 (FIPS
|
||
180-4), 32 bytes. Integers of the receipt's signed bytes (§8) are big-endian and its hashes
|
||
BLAKE3, as the stitched form's are. Ed25519 is RFC 8032's, verified strictly ([SPEC](../SPEC.md)
|
||
§7 *Signatures*): a key that fails step 1 or 2 is `bad_key`, and a signature that fails a
|
||
later step is `bad_signature`. A *long* field is a u16 length and that many bytes.
|
||
|
||
## 3. The ad policy
|
||
|
||
### 3.1 Layout
|
||
|
||
| offset | size | field | meaning |
|
||
|---|---|---|---|
|
||
| 0 | 4 | `magic` | `WAP1` (ASCII) |
|
||
| 4 | 1 | `version` | 1 in this revision |
|
||
| 5 | 32 | `room` | the room id (live-rooms §3.2) |
|
||
| 37 | 32 | `decider_grant` | the digest of the room grant the decider runs under (§3.4) |
|
||
| 69 | 1 | `slots` | the most ads one break holds, 1 to 16 |
|
||
| 70 | 4 | `open_avail_ms` | u32: the avail of a break opened with no planned duration (§4.3); 0: such a break is refused |
|
||
| 74 | 4 | `preload_ms` | u32: how long before a break a player may fetch its asset list (§5.3, §6.2); 0: no preload |
|
||
| 78 | 1 | `restrict` | bit 0 **skip**: no seeking forward in a break; bit 1 **jump**: no seeking past a break unplayed |
|
||
| 79 | 2 | `skip_after_s` | u16: seconds of a break played before a viewer may skip the rest; 65535: no skip control |
|
||
| 81 | 1 | `snap` | bit 0 **out**: leave the program at the segment boundary nearest the break's start; bit 1 **in**: return at the one nearest its end |
|
||
| 82 | long | `asset_base` | the base of every asset-list URI (§7) |
|
||
| … | long | `creative_base` | the base of every creative's URI (§7) |
|
||
| … | 64 | `sig` | §3.2 |
|
||
|
||
A **base** is `https://` followed by 1 to 492 bytes, each of `!` to `~` (0x21–0x7E) other
|
||
than `"`, `#`, `&`, `'`, `<`, `>`, `?` and `\`, the last of them `/`. A base so holds no
|
||
character that HLS's quoted strings, JSON strings or XML attribute values would have to escape,
|
||
and a URI built on it (§7) is the base followed by a name of the break or of the creative.
|
||
|
||
### 3.2 Digest and signature
|
||
|
||
The **digest** of a policy is SHA-256 over its record from `magic` through `creative_base`.
|
||
The **signature** is Ed25519 by the room descriptor's `authority` over the 20 bytes
|
||
`wai:live-ads-policy` (ASCII) and 0x01, followed by the bytes the digest covers.
|
||
|
||
### 3.3 Reading and rules
|
||
|
||
A reader reads exactly the bytes it is given as one policy, in the order of §3.1, and refuses
|
||
at the first of these that holds:
|
||
|
||
1. Fewer than 4 bytes: `truncated`. A magic other than `WAP1`: `malformed`. No byte after the
|
||
magic: `truncated`. A version other than 1: `unknown_version`.
|
||
2. The bytes end before a field the layout requires: `truncated`.
|
||
3. Bytes after `sig`: `malformed`.
|
||
|
||
It then refuses as `ad_policy_malformed` a policy whose `slots` is not 1 to 16, whose
|
||
`restrict` or `snap` sets a bit above bit 1, or whose `asset_base` or `creative_base` is not a
|
||
base (§3.1).
|
||
|
||
### 3.4 The decider
|
||
|
||
The decider is the `principal` of the grant whose digest is `decider_grant`. That grant is one
|
||
of the room's grants, which the room verifier checks (live-rooms §4.3); it need not be used in
|
||
a `join`, since a decider sends no event. Its `funds_ceiling` and `currency` are the room's ad
|
||
budget: every price and every spend of §7 and §8 is in millionths of a minor unit of
|
||
`currency`, as [live-metering](live-metering.md)'s tariff prices are, so a spend of *s* is
|
||
within a ceiling of *c* minor units when *s* ≤ *c* × 10^6. A `funds_ceiling` of 2^64 − 1
|
||
states no ceiling. The decider's receipts name the grant as live-rooms §4.5 has a receipt name
|
||
one (§9.3).
|
||
|
||
A grant is good for a break when it is good at the break's `t` (live-rooms §4.3), and it is
|
||
not revoked through the authority's key log (live-rooms §5).
|
||
|
||
## 4. Breaks
|
||
|
||
### 4.1 Cues
|
||
|
||
A cue (live-events §4.3) carries a `type`, a `cue_id`, a `duration_ms` and, optionally, an
|
||
SCTE-35 message this extension never reads. Its `t` is the instant of the splice on the room
|
||
clock: a publisher **MAY** send a cue before that instant, and a cue anchored to a media object
|
||
(live-events §3.1) splices at that object. Every cue the room accepted, of any principal
|
||
permitted `cue`, the room keys among them, is read here, in the merged order (live-rooms §9.2),
|
||
each once.
|
||
|
||
### 4.2 The break annotation
|
||
|
||
A break is mid-roll unless its author annotates it as a post-roll. The **annotation** is the
|
||
payload of a `meta` event whose first four bytes are `WAB1`, by the same principal, at the
|
||
cue's `seq` + 1 and at the cue's `t`:
|
||
|
||
| offset | size | field | meaning |
|
||
|---|---|---|---|
|
||
| 0 | 4 | `magic` | `WAB1` (ASCII) |
|
||
| 4 | 1 | `version` | 1 in this revision |
|
||
| 5 | 32 | `cue` | the digest of the cue it annotates |
|
||
| 37 | 1 | `placement` | 1 mid-roll, 2 post-roll |
|
||
|
||
An annotation is refused `annotation_malformed` when it is not 38 bytes, its version is not 1,
|
||
or its `placement` is not 1 or 2. It is refused `annotation_misplaced` unless the event before
|
||
it in its author's chain, at `seq` − 1, is a cue of type break, at the same `t`, whose digest
|
||
is `cue`.
|
||
|
||
A **post-roll** is the break of an annotation of placement 2. It ends the program: after it,
|
||
the room has no break and no cue (§4.3, `cue_after_close`), and a room that closes ends with
|
||
it. A player plays it after the program has ended (§5.1, §6.2).
|
||
|
||
### 4.3 The schedule
|
||
|
||
The **schedule** is the room's breaks and chapters, in the order of the cues that open them.
|
||
Over the cues and annotations in the merged order, holding the open break (at most one) and
|
||
the end of the last closed break, a verifier refuses, at the record:
|
||
|
||
- any cue after a post-roll's annotation: `cue_after_close`;
|
||
- **out** (1): when a break is open, or `t` is before the end of the last closed break,
|
||
`break_overlap`. Otherwise it opens a break: its **planned** duration is `duration_ms`, and
|
||
its avail is `duration_ms`, or the policy's `open_avail_ms` when `duration_ms` is 0; with
|
||
both 0, `break_duration_unknown`;
|
||
- **in** (2): unless the open break was opened by an out of this `cue_id` and of the in's
|
||
author, `cue_in_unmatched`. The break's **duration** is ⌊(`t` − the out's `t`) / 1000⌋
|
||
milliseconds: when it is 0, `break_zero_duration`; when the in's `duration_ms` is not 0 and
|
||
is not the duration, `cue_duration_mismatch`. The break is then closed, and its end is the
|
||
in's `t`;
|
||
- **chapter** (3): a chapter of the schedule, its `t` and its `duration_ms` (0 when not known).
|
||
A chapter opens and closes nothing, and may fall inside a break;
|
||
- **break** (4): when `duration_ms` is 0, `break_zero_duration`; when a break is open, or `t`
|
||
is before the end of the last closed break, `break_overlap`. Otherwise it is a closed break
|
||
whose duration and avail are `duration_ms`, ending at `t` + `duration_ms` × 1000;
|
||
- an **annotation** (§4.2): read and placed, or refused; of placement 2, its break is the
|
||
post-roll.
|
||
|
||
A break open at the end is stated open: the room may still be live. A cue of the room that
|
||
does not read is the room's refusal, and an event with a gap before it is a delivery gap the
|
||
room states (§9.2 step 1).
|
||
|
||
### 4.4 A break's identity
|
||
|
||
A break is named by the digest of the cue that opens it. From that digest:
|
||
|
||
- its HLS **ID** is `wai-ad-` followed by the digest's 64 lowercase hexadecimal digits;
|
||
- its DASH **event id** is the digest's first eight bytes read as a big-endian u64, of which
|
||
the low 53 bits are kept, so that a player that holds an id as a double holds it exactly. A
|
||
receiver identifies a break by its digest; the id lets a player tell one event from another
|
||
in an EventStream, where a repeat of 53 bits among a room's breaks is possible but not
|
||
expected.
|
||
|
||
Every edge holding the room's events derives the same identity, and no other event of the room
|
||
has it: live-events' own date range of the cue has the digest alone as its `ID`.
|
||
|
||
## 5. HLS: interstitial date ranges
|
||
|
||
### 5.1 The interstitial tag
|
||
|
||
Each break is one Interstitial `EXT-X-DATERANGE` tag (`draft-pantos-hls-rfc8216bis-22`
|
||
§4.4.5.1, Appendix D.2), written into every Media Playlist of the room when its cue is known.
|
||
Its attributes are written in this order, separated by commas with no spaces, each only where
|
||
its row says:
|
||
|
||
| attribute | value |
|
||
|---|---|
|
||
| `ID` | quoted: the break's ID (§4.4) |
|
||
| `CLASS` | quoted: `com.apple.hls.interstitial`, the class Appendix D.2 defines <!-- allow-name: the interstitial date-range class, a protocol identifier --> |
|
||
| `START-DATE` | quoted: `t0` + `t` of the opening cue, as `YYYY-MM-DDTHH:MM:SS.mmmZ` in UTC, the milliseconds floored (live-events §8.3) |
|
||
| `CUE` | quoted: `ONCE` for a mid-roll, `POST,ONCE` for a post-roll (§4.4.5.1) |
|
||
| `DURATION` | a break of type 4: its duration as decimal seconds with three digits after the point (`30.000`) |
|
||
| `PLANNED-DURATION` | an out with a planned duration: that duration, as `DURATION` |
|
||
| `X-ASSET-LIST` | quoted: the break's HLS asset-list URI (§7.2) |
|
||
| `X-RESUME-OFFSET` | a mid-roll of type 4: its duration, as `DURATION` |
|
||
| `X-PLAYOUT-LIMIT` | a break of type 4: its duration, as `DURATION` |
|
||
| `X-SNAP` | when `snap` is not 0: quoted, `OUT`, `IN` or `OUT,IN` by its bits |
|
||
| `X-RESTRICT` | when `restrict` is not 0: quoted, `SKIP`, `JUMP` or `SKIP,JUMP` by its bits |
|
||
| `X-SKIP-CONTROL-OFFSET` | when `skip_after_s` is not 65535: `skip_after_s` in decimal (Appendix D.3) |
|
||
|
||
- `ONCE` keeps a viewer who seeks back over a break in a window of the room from being shown
|
||
it again; `POST` plays the post-roll once the program has played to its end, which for a
|
||
room is its playlist's `EXT-X-ENDLIST`.
|
||
- `X-RESUME-OFFSET` equal to the break's duration returns the viewer to the program where the
|
||
break ends on the room's timeline, whatever the fill's length, so the program a viewer
|
||
returns to is the program after the in. A post-roll has nothing to resume, and no
|
||
`X-RESUME-OFFSET`.
|
||
- An out whose end is not yet known has no `DURATION`, `X-RESUME-OFFSET` or
|
||
`X-PLAYOUT-LIMIT`: the closing tag adds them (§5.2).
|
||
|
||
### 5.2 The closing tag
|
||
|
||
When an out's in closes it, the break gains a second tag with the same `ID`, as §6.2.1 has a
|
||
server signal the actual end of a date range with a planned duration, and as §4.4.5.1 lets a
|
||
later tag add attributes to a date range:
|
||
|
||
| attribute | value |
|
||
|---|---|
|
||
| `ID` | quoted: the break's ID |
|
||
| `CLASS` | quoted: as §5.1 <!-- allow-name: the interstitial date-range class, a protocol identifier --> |
|
||
| `DURATION` | the break's duration (§4.3), as §5.1 writes durations |
|
||
| `X-RESUME-OFFSET` | the same value |
|
||
| `X-PLAYOUT-LIMIT` | the same value |
|
||
|
||
No attribute of the first tag changes: the closing tag adds three it did not have, and repeats
|
||
`CLASS` with its value. A player inside the break when the closing tag arrives ends the break
|
||
at the playout limit, and returns to the program where the in placed its end.
|
||
|
||
### 5.3 The preload tag
|
||
|
||
When the policy's `preload_ms` is not 0, each break also has a Preload `EXT-X-DATERANGE` tag
|
||
(Appendix F, Appendix D.2), written just before its interstitial tag:
|
||
|
||
| attribute | value |
|
||
|---|---|
|
||
| `ID` | quoted: the break's ID followed by `-preload` |
|
||
| `CLASS` | quoted: `com.apple.hls.preload`, the class Appendix F defines <!-- allow-name: the preload date-range class, a protocol identifier --> |
|
||
| `START-DATE` | quoted: the interstitial's `START-DATE` less `preload_ms`, in its form |
|
||
| `DURATION` | `preload_ms`, as §5.1 writes durations |
|
||
| `X-URI` | quoted: the break's HLS asset-list URI (§7.2) |
|
||
| `X-TARGET-ID` | quoted: the break's ID |
|
||
| `X-TARGET-CLASS` | quoted: the interstitial class <!-- allow-name: the interstitial date-range class, a protocol identifier --> |
|
||
|
||
A player picks a random instant in the preload range and fetches the asset list there
|
||
(Appendix D.2), so the requests of a room's audience for one break are spread over
|
||
`preload_ms` rather than arriving together at its start.
|
||
|
||
### 5.4 What stays shared
|
||
|
||
Every attribute of §5.1 to §5.3 is a function of the signed cue, its annotation, the signed
|
||
policy and the room's `t0`, and of nothing a viewer supplies. Every Media Playlist of the room
|
||
therefore carries the same date ranges with the same attributes, as §6.2.4 requires, and every
|
||
edge that holds the room's events writes the same bytes, so playlists stay cacheable. The one
|
||
URI a break names is where each viewer's decision is made (§7), and the request for it carries
|
||
what makes it a viewer's: the `_HLS_primary_id` and `_HLS_start_offset` query parameters
|
||
Appendix D.4 has a player add.
|
||
|
||
A playlist that carries these tags carries `EXT-X-PROGRAM-DATE-TIME` on the same UTC timeline
|
||
as `t0` (§4.4.5.1). The corpus states the tags of each room in the order a packager writes
|
||
them: a break's preload and interstitial tags at its cue, its closing tag at its in.
|
||
|
||
## 6. DASH: alternative presentation events
|
||
|
||
### 6.1 What this revision read
|
||
|
||
ISO/IEC 23009-1:2026 defines alternative presentation events in its §5.16, whose text this
|
||
revision could not read. It read the sixth edition's MPD schema and its example MPDs. From
|
||
them it takes: the event schemes `urn:mpeg:dash:event:alternativeMPD:replace:2025` and
|
||
`urn:mpeg:dash:event:alternativeMPD:insert:2025` (example G23 and G29-1 use the first); an
|
||
`Event`'s `presentationTime`, `duration` and `id`; its child `ReplacePresentation` or
|
||
`InsertPresentation`, with the attributes `uri` (required), `earliestResolutionTimeOffset`,
|
||
`maxDuration`, `executeOnce`, `skipAfter` and, for a replacement, `returnOffset`, with their
|
||
types; and the **list MPD**, an MPD of `type` `list` whose Periods each import one MPD
|
||
(`ImportedMPD`). The values below are written from those types and examples. Where the
|
||
schema has an attribute whose meaning this revision could not state, it does not write it:
|
||
`noJump`, `serviceDescriptionId`, `startWithOffset` and `clip`, whose defaults stand. A later
|
||
revision checks every value here against §5.16.
|
||
|
||
### 6.2 The event
|
||
|
||
A mid-roll is an `Event` of the replace scheme; a post-roll is one of the insert scheme, since
|
||
after the program there is nothing to replace. With `timescale` 1000 on its EventStream:
|
||
|
||
| attribute | value |
|
||
|---|---|
|
||
| `presentationTime` | `pt0` + ⌊`t` / 1000⌋, `t` the opening cue's, where `pt0` is the presentation time of `t0` in milliseconds on the Period's timeline (live-events §8.4) |
|
||
| `duration` | when the break's duration is known (a break of type 4, or an out its in has closed): that duration in milliseconds |
|
||
| `id` | the break's event id (§4.4) |
|
||
|
||
and its one child, `ReplacePresentation` for a mid-roll and `InsertPresentation` for a
|
||
post-roll:
|
||
|
||
| attribute | value |
|
||
|---|---|
|
||
| `uri` | the break's DASH asset-list URI (§7.3) |
|
||
| `earliestResolutionTimeOffset` | when `preload_ms` is not 0: `preload_ms` |
|
||
| `maxDuration` | when the duration is known: the duration in milliseconds |
|
||
| `executeOnce` | `true`, as HLS's `ONCE` |
|
||
| `skipAfter` | `PT`, then `skip_after_s` in decimal, then `S`; with no skip control, the avail in whole seconds rounded up (`PT30S` for 30 000 ms), so that no viewer skips before the avail has played: the schema's examples read a missing `skipAfter` as a break a viewer may skip at once |
|
||
| `returnOffset` | a `ReplacePresentation` whose duration is known: the duration in milliseconds, as HLS's `X-RESUME-OFFSET` |
|
||
|
||
Attributes are written in the order of the two tables, each as `name="value"`, the element as
|
||
`<Event …><ReplacePresentation …/></Event>`, with no whitespace other than one space before
|
||
each attribute. An out's event is written first without `duration`, `maxDuration` and
|
||
`returnOffset`, and the MPD that follows its in writes the same event with them: an MPD update
|
||
gives the event its end as HLS's closing tag does (§5.2). A planned duration has no DASH
|
||
attribute.
|
||
|
||
### 6.3 The event streams
|
||
|
||
The Period that holds the room's program carries, after every break of the schedule is
|
||
written:
|
||
|
||
1. when the schedule has a mid-roll, `<EventStream schemeIdUri="urn:mpeg:dash:event:alternativeMPD:replace:2025" timescale="1000">`,
|
||
the mid-rolls' events in schedule order, and `</EventStream>`;
|
||
2. when it has a post-roll, the same with the insert scheme and the post-roll's event.
|
||
|
||
Every value is a function of the signed records, the policy and `pt0`, so the MPD stays one
|
||
for every viewer, as the playlists do (§5.4).
|
||
|
||
## 7. The asset list
|
||
|
||
### 7.1 The decision
|
||
|
||
A player that reaches a break fetches its asset list. The decider fills it for that viewer with
|
||
the decision of `wai.video.ssai` (SPEC §5), unchanged:
|
||
|
||
- its inputs are the viewer's pod, the policy's `slots`, an **avail** and the **funds
|
||
remaining**: the break's avail (§4.3) less the viewer's start offset (§7.4), or 0 when the
|
||
offset reaches it; and the funds the decider states it had left of the decider grant's
|
||
ceiling, at most that ceiling (§3.4);
|
||
- the candidates are taken in order of `price_micros` descending, ties by `ad_id` ascending
|
||
(as bytes); each is taken when fewer than `slots` are taken, its `duration_ms` is at most the
|
||
avail not yet taken and its `price_micros` at most the funds not yet taken; the fill is the
|
||
taken candidates in that order, slot 0 first.
|
||
|
||
The fill is a pure function of those inputs, so anyone holding them recomputes it, and the
|
||
receipt binds each of them (§8).
|
||
|
||
### 7.2 HLS asset list
|
||
|
||
The break's **HLS asset-list URI** is `asset_base`, the break's ID, `.json?_HLS_interstitial_id=`
|
||
and the ID again: the query parameter Appendix D.4 has a packager add. The response is the
|
||
JSON object Appendix D.2 defines, written exactly:
|
||
|
||
```
|
||
{"ASSETS":[{"URI":"<creative_base><creative_hash>.m3u8","DURATION":<s>},…]}
|
||
```
|
||
|
||
one Asset-Description for each ad of the fill in slot order, `<creative_hash>` its 64
|
||
lowercase hexadecimal digits and `<s>` its `duration_ms` as decimal seconds with three digits
|
||
after the point, with no whitespace and no byte after the closing brace. An empty fill is
|
||
`{"ASSETS":[]}`, which has a player resume the program without playing an ad (Appendix D.5).
|
||
The asset list carries no `SKIP-CONTROL`: the tag's attributes stand.
|
||
|
||
A creative's URI names it by its hash, so the asset list a viewer is served binds the creative
|
||
bytes the receipt names.
|
||
|
||
### 7.3 DASH list MPD
|
||
|
||
The break's **DASH asset-list URI** is `asset_base`, the break's ID and `.mpd`. The response is
|
||
a list MPD, written exactly as the XML declaration `<?xml version="1.0" encoding="UTF-8"?>`, a
|
||
line feed, and:
|
||
|
||
```
|
||
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" profiles="urn:mpeg:dash:profile:list:2024" type="list" minBufferTime="PT1S"><Period id="0" duration="PT<s>S"><ImportedMPD><creative_base><creative_hash>.mpd</ImportedMPD></Period>…</MPD>
|
||
```
|
||
|
||
one Period for each ad of the fill, its `id` the slot in decimal and `<s>` as §7.2, with no
|
||
whitespace other than that shown and no byte after `</MPD>`. The profile and `minBufferTime`
|
||
are those of the schema's list-MPD example (G29-2). An MPD has at least one Period, so an empty
|
||
fill has no list MPD: the decider answers the request with no body, and the receipt binds the
|
||
empty asset list, zero bytes (§8.2).
|
||
|
||
### 7.4 Joining a break
|
||
|
||
A viewer who reaches a break after its start, by joining the room inside it, has its player
|
||
add the offset into the break at which it starts (`_HLS_start_offset`, Appendix D.4). The
|
||
decider fills the remaining avail only (§7.1), and the receipt states the offset
|
||
(`start_offset_ms`). A viewer who joins between breaks is given no break: a room has no
|
||
pre-roll. The asset list a joining viewer is served is a fill like any other, and every check
|
||
of §9.3 applies to it.
|
||
|
||
## 8. The break form of the `wai.video.ssai` receipt
|
||
|
||
### 8.1 Forms
|
||
|
||
A `wai.video.ssai` receipt has two forms:
|
||
|
||
- **form 1, stitched**: an avail of an attested manifest, named by `avail_id` and
|
||
`manifest_hash`, as SPEC §5 registers it. This revision changes nothing in it: its signed
|
||
bytes, its identity and every receipt of it stay as they were;
|
||
- **form 2, break**: a break of a room's schedule and the asset list one viewer was served for
|
||
it, defined here.
|
||
|
||
The two forms sign under different domains (§8.4), so no signature of one is a signature of
|
||
the other, and they share the impression, the pod hash, the Merkle root, the billable spend and
|
||
the identity domain, so a figure profile of energy-measurement §6.1,
|
||
`wai:video-ssai-receipt-id`, names either.
|
||
|
||
### 8.2 Fields and their JSON carriage
|
||
|
||
A form-2 receipt is carried as one JSON object (RFC 8259) with exactly these members; a byte
|
||
field is lowercase hexadecimal of its exact length, and an integer a JSON integer from 0 to
|
||
2^64 − 1:
|
||
|
||
| member | value |
|
||
|---|---|
|
||
| `form` | 2 |
|
||
| `room` | 32 bytes: the room id |
|
||
| `cue` | 32 bytes: the break's digest (§4.4) |
|
||
| `session` | 16 bytes: the session (§2), one value for every break of one playback session, and no other's |
|
||
| `start_offset_ms` | the viewer's offset into the break (§7.4); 0 for a viewer present at its start |
|
||
| `format` | from 0 to 255: 1, the HLS asset list (§7.2); 2, the DASH list MPD (§7.3) |
|
||
| `asset_list_sha256` | 32 bytes: SHA-256 of the asset list served, its exact bytes |
|
||
| `avail_ms` | the break's avail (§4.3) |
|
||
| `slots` | the policy's `slots` |
|
||
| `funds_remaining_micros` | the funds the decision ran with (§7.1) |
|
||
| `pod_hash` | 32 bytes: §8.3 |
|
||
| `impressions` | an array: each filled slot, `{"slot", "ad_id", "creative_hash", "duration_ms", "price_micros", "quartiles_reached"}`, in slot order; `ad_id` a string, `creative_hash` 32 bytes, `quartiles_reached` 0 to 255 |
|
||
| `merkle_root` | 32 bytes: §8.3 |
|
||
| `budget_spent_micros` | the billable spend (§8.3) |
|
||
| `work_decide_ops` | the candidates the decision considered |
|
||
| `joules_micro` | the decision's energy, or 0 unmetered |
|
||
| `energy_provenance` | its class's label (energy-measurement §2.7: `ModelBased`, `Estimator`, `OnChipCounter`, `CalibratedInstrument`), or `null` unmetered |
|
||
| `energy_uncertainty`, `energy_counter_filtering`, `energy_calibration_ref` | only for a class that carries them, as energy-measurement §2.7 names and writes them: `{"relative_ppm", "window_us"}`, `relative_ppm` from 0 to 2^32 − 1; `Unknown`, `Off` or `On`; a string |
|
||
| `grant` | `{"grant_hash", "capability", "joule_ceiling_micro", "funds_ceiling"}`: the grant reference (live-rooms §4.5), `funds_ceiling` `null` for none |
|
||
| `signer_pubkey` | 32 bytes: the decider's Ed25519 key |
|
||
| `signer_id` | a string: the decider's `did:key` or origin-bound id |
|
||
| `sig` | 64 bytes: §8.4 |
|
||
|
||
`quartiles_reached` is the stitched form's: 0 never rendered, 1 started (the billable
|
||
impression), 2 to 5 a quarter, a half, three quarters and all of it. A receipt that does not
|
||
read so, or that carries the legacy class label `HwShunt` (energy-measurement §2.6), is
|
||
refused `receipt_malformed`.
|
||
|
||
### 8.3 Hashes
|
||
|
||
BLAKE3, with integers big-endian, as the stitched form computes them:
|
||
|
||
- the **pod hash**: over `wai:video-ssai-pod` 0x01, the candidates' count (u64), and for each
|
||
candidate in the pod's order, `ad_id`'s length (u32) and bytes, `creative_hash`,
|
||
`duration_ms` (u64) and `price_micros` (u64);
|
||
- an impression's **leaf**: over `wai:video-ssai-imp` 0x01, `slot` (u64), `ad_id`'s length
|
||
(u32) and bytes, `creative_hash`, `duration_ms` (u64), `price_micros` (u64) and
|
||
`quartiles_reached` (u8);
|
||
- the **Merkle root** over the leaves in slot order: [jwp-receipts](jwp-receipts.md) §2.2's
|
||
tree, each leaf taken as a content hash (its first leaf form) and the final one duplicated
|
||
on an odd level; all zero for no impression;
|
||
- the **billable spend**: the sum of `price_micros` over the impressions whose
|
||
`quartiles_reached` is at least 1.
|
||
|
||
### 8.4 Signing payload and identity
|
||
|
||
The **signing payload** is the concatenation of:
|
||
|
||
1. `wai:video-ssai-break-receipt` (ASCII) and 0x01;
|
||
2. `room`, `cue`, `session`, `start_offset_ms` (u64), `format` (u8), `asset_list_sha256`,
|
||
`avail_ms` (u64), `slots` (u64), `funds_remaining_micros` (u64);
|
||
3. `pod_hash`, `merkle_root`, `budget_spent_micros` (u64), `work_decide_ops` (u64),
|
||
`joules_micro` (u64), then the class in its signed encoding (energy-measurement §2.7), the
|
||
tag byte 0 for none;
|
||
4. the grant reference: `grant_hash`, the length (u32) and bytes of `capability`,
|
||
`joule_ceiling_micro` (u64), then 1 and `funds_ceiling` (u64), or 0 for none;
|
||
5. the impressions' count (u64), then each one's leaf;
|
||
6. `signer_pubkey`, then the length (u32) and bytes of `signer_id`.
|
||
|
||
`sig` is Ed25519 by `signer_pubkey` over the signing payload. The receipt's **identity**, its
|
||
receipt hash, is BLAKE3 over `wai:video-ssai-receipt-id` 0x01, the signing payload and `sig`:
|
||
the stitched form's identity domain, over a payload whose first bytes no stitched payload has.
|
||
|
||
### 8.5 Its own rules
|
||
|
||
A form-2 receipt holds, on its own:
|
||
|
||
1. `format` is 1 or 2;
|
||
2. its signature verifies (`bad_key`, `bad_signature`);
|
||
3. `merkle_root` is the root of its impressions, `budget_spent_micros` their billable spend,
|
||
and when `grant`'s `funds_ceiling` is not `null`, the spend is within it (§3.4);
|
||
4. the impressions' slots are 0, 1, 2, … in order, each `quartiles_reached` is at most 5, and
|
||
`work_decide_ops` is at least their count;
|
||
5. the figure and its class cohere as energy-measurement §2 has them: `joules_micro` 0 with no
|
||
class, or above 0 with a class, a hardware class's declared uncertainty with
|
||
`relative_ppm` and `window_us` above 0 and at the resolution floor (`joules_micro` ×
|
||
`relative_ppm` at least 288 676), and a calibrated instrument's reference holding a
|
||
character that is not Unicode White_Space.
|
||
|
||
A rule of 1, 3, 4 or 5 that fails is `receipt_invalid`. These rules are what a buyer holding
|
||
one receipt checks alone; §9.3 adds what the room, the policy and the pod let a verifier check.
|
||
|
||
## 9. Verification
|
||
|
||
### 9.1 Inputs
|
||
|
||
An ads verifier is given what a room verifier is given (live-rooms §9.1: a descriptor, grants,
|
||
a key-log head or none, and the chains), and:
|
||
|
||
- **policy**: the room's ad policy;
|
||
- **pt0**: the presentation time of `t0`, in milliseconds on the Period's timeline (§6.2);
|
||
- **receipts**: form-2 receipts in JSON (§8.2), in a list, each with the **pod** its decision
|
||
ran over, the candidates in the order the decider gave them.
|
||
|
||
### 9.2 Steps
|
||
|
||
It runs these steps and stops at the first refusal, which it reports with the reason and
|
||
where:
|
||
|
||
1. **The room** (live-rooms §9): a refusal is the ads verifier's, at `room` with the room
|
||
verifier's own place. A gap is the outcome **gap**: a schedule read from part of the cues
|
||
is not the room's, so the verifier states the gaps and nothing more.
|
||
2. **The policy**, at `policy`: it reads (§3.3: `truncated`, `malformed`, `unknown_version`,
|
||
`ad_policy_malformed`); its `room` is the room id (`wrong_room`); its signature verifies
|
||
under the descriptor's `authority` (`bad_key`, `bad_signature`); `decider_grant` is the
|
||
digest of a grant the room verifier holds (`decider_grant_unknown`); and that grant is not
|
||
revoked through the key log the room verifier read (live-rooms §5, `grant_revoked`).
|
||
3. **The schedule** (§4.2, §4.3), over the events the room accepted in the merged order, each
|
||
once: the refusal, at the record's chain and its index in that chain.
|
||
4. **The renderings** (§5, §6): the tags, the events and the asset-list URIs of every break.
|
||
No refusal.
|
||
5. **The receipts**, in the list's order, each by §9.3: the refusal, at `receipt` and its
|
||
index.
|
||
6. **The outcome** (§9.4).
|
||
|
||
### 9.3 Each receipt
|
||
|
||
For receipt *k*, with its pod, in this order:
|
||
|
||
1. It reads as §8.2 has it: `receipt_malformed`.
|
||
2. Its `room` is the room id: `wrong_room`.
|
||
3. Its own rules (§8.5): `bad_key`, `bad_signature` or `receipt_invalid`.
|
||
4. `signer_pubkey` is the decider (§3.4): `receipt_not_decider`.
|
||
5. `grant` is the decider grant's reference: `grant_hash` its digest, `capability`
|
||
`wai.live.room`, `joule_ceiling_micro` its `joules_ceiling`, and `funds_ceiling` its
|
||
`funds_ceiling`, or `null` when that is 2^64 − 1 (live-rooms §4.5):
|
||
`receipt_grant_mismatch`.
|
||
6. `cue` names a break of the schedule: `receipt_break_unknown`.
|
||
7. The decider grant is good at the break's `t` (§3.4): `outside_grant_window`.
|
||
8. `pod_hash` is the pod hash (§8.3) of the pod given: `pod_mismatch`.
|
||
9. `avail_ms` is the break's avail, `slots` the policy's, and the impressions, less
|
||
`quartiles_reached`, are the fill §7.1 gives for the pod, the slots, the avail less
|
||
`start_offset_ms` and `funds_remaining_micros`: `decision_mismatch`.
|
||
10. `asset_list_sha256` is SHA-256 of the asset list of the fill in the receipt's `format`
|
||
(§7.2, §7.3): `asset_list_mismatch`.
|
||
11. No receipt before it in the list names this `cue` and this `session`: `receipt_duplicate`.
|
||
A viewer is filled once for a break.
|
||
12. **Spend-equivalence.** `funds_remaining_micros` is within the decider grant's ceiling, and
|
||
so is the billable spend of this receipt and every receipt before it, together (§3.4):
|
||
otherwise `funds_exceeded`.
|
||
|
||
Step 12 holds the room's ad spend to one ceiling across every viewer and break, in the order the
|
||
receipts are given. A decision's stated funds are the decider's statement; that the spend of
|
||
every receipt together stays within the ceiling is checked, whatever was stated.
|
||
|
||
### 9.4 Outcomes and what a verification states
|
||
|
||
- **refuse**: the reason and where (§9.2).
|
||
- **gap**: the room has a gap: its gaps, under live-events' code `delivery_gap`.
|
||
- **accept**: no refusal and no gap.
|
||
|
||
With accept, the verifier states:
|
||
|
||
- each break, in schedule order: its digest, ID and event id, its author, `cue_id`, `t` and
|
||
type (out or break), its placement, its planned duration and avail, and, once closed by an
|
||
in, the in's digest, its end and its duration; or that it is open;
|
||
- each chapter: its digest, `t` and `duration_ms`;
|
||
- the HLS tags, in the order of §5.4; each break's HLS and DASH asset-list URIs; the DASH event
|
||
streams (§6.3), and each out's event as it was written before its in;
|
||
- each receipt: its identity (§8.4), the asset list it binds, written by the verifier, and its
|
||
billable spend; and the spend of every receipt together.
|
||
|
||
## 10. Reasons
|
||
|
||
The codes this extension adds, each with where it is raised:
|
||
|
||
| reason | raised at |
|
||
|---|---|
|
||
| `ad_policy_malformed` | §3.3, §9.2 step 2 |
|
||
| `decider_grant_unknown` | §9.2 step 2 |
|
||
| `annotation_malformed` | §4.2 |
|
||
| `annotation_misplaced` | §4.2 |
|
||
| `cue_after_close` | §4.2, §4.3 |
|
||
| `break_overlap` | §4.3 |
|
||
| `break_duration_unknown` | §4.3 |
|
||
| `break_zero_duration` | §4.3 |
|
||
| `cue_in_unmatched` | §4.3 |
|
||
| `cue_duration_mismatch` | §4.3 |
|
||
| `receipt_malformed` | §8.2, §9.3 step 1 |
|
||
| `receipt_invalid` | §8.5, §9.3 step 3 |
|
||
| `receipt_not_decider` | §9.3 step 4 |
|
||
| `receipt_grant_mismatch` | §9.3 step 5 |
|
||
| `receipt_break_unknown` | §9.3 step 6 |
|
||
| `pod_mismatch` | §9.3 step 8 |
|
||
| `decision_mismatch` | §9.3 step 9 |
|
||
| `asset_list_mismatch` | §9.3 step 10 |
|
||
| `receipt_duplicate` | §9.3 step 11 |
|
||
| `funds_exceeded` | §9.3 step 12 |
|
||
|
||
An ads verifier also raises every code a room verifier raises (live-rooms §11 and the
|
||
live-events codes it lists), at the room: `kind_not_permitted` among them for a cue or an
|
||
annotation by a principal whose grant does not permit its kind; `truncated`, `malformed` and
|
||
`unknown_version` for a policy that does not read; `wrong_room`, `bad_key` and `bad_signature`
|
||
for a policy or a receipt of another room or whose signature fails; `grant_revoked` and
|
||
`outside_grant_window` for the decider grant (§9.2 step 2, §9.3 step 7); and `delivery_gap` for
|
||
a gap, which is not a refusal.
|
||
|
||
## 11. Conformance
|
||
|
||
[`live-conformance/ads/`](../live-conformance/ads/) holds the corpus, written from fixed seeds
|
||
by `wai_live_ads_vectors gen`: `vectors.json`, and each accepted room's HLS tags as a file of
|
||
its own, `NAME.m3u8`, one tag a line in the order of §5.4. It has:
|
||
|
||
- **Rooms**, each a room (descriptor, grants, key-log head or none, chains), its policy, `pt0`
|
||
and its receipts with their pods, and the outcome §9.4 gives them with everything it states:
|
||
- a live break, an out with no planned duration and its in, a viewer present at its start
|
||
filled from an HLS asset list, and a viewer who joins it running, filled from a DASH list
|
||
MPD with what remains of it;
|
||
- a mid-roll of known duration under a policy that restricts skipping and jumping, snaps
|
||
both ways and offers a skip control, with a viewer whose pod nothing fits;
|
||
- a program with chapters, a mid-roll, and a post-roll at its close;
|
||
- chapters, and an out with a planned duration closed by its in;
|
||
- one refused room for each reason of §10, each a control of one defect; one for each
|
||
other rule of §8.5, each refused `receipt_invalid`; a cue by a principal whose grant does
|
||
not permit `cue`, which the room refuses `kind_not_permitted`; and a decider grant revoked
|
||
through the key log, and one whose window ends before the break;
|
||
- a room with a gap.
|
||
|
||
A conforming verifier gives every room the outcome, the reason, the place and everything else
|
||
the corpus states, and every tag file byte for byte. `wai_live_ads_vectors verify` and the test
|
||
`tests/live_ads.rs` check that every file regenerates byte for byte, that `wai-rs` gives every
|
||
case its outcome, that every reason of §10 appears, and that §10 lists exactly the reference's
|
||
codes. `live-conformance/live_ads_verify.py` is the second implementation, written from this
|
||
text: it shares no code with `wai-rs`; it verifies each room with the live-rooms corpus's
|
||
second implementation (`live_rooms_verify.py`), every signature with WAI's strict rule
|
||
(`claim-conformance/_ed25519.py`) and every BLAKE3 hash with `claim-conformance/_blake3.py`; it
|
||
writes every tag, event and asset list from §5 to §7 and requires their bytes; and it mutates
|
||
copies of the corpus and requires every copy to fail.
|
||
|
||
## 12. Security and privacy considerations
|
||
|
||
- Every break is a cue its author signed, under a grant that permits `cue`; a relay or an edge
|
||
can withhold a cue, which is a gap in its author's chain, but cannot add or move a break. The
|
||
date ranges and events an edge writes are recomputable from the signed records, and a
|
||
playlist whose interstitials differ from them is one the edge altered.
|
||
- The decision is a pure function of its inputs, and every input is bound: the pod by its
|
||
hash, the avail by the break, the slots by the policy, the funds by the receipt, and the
|
||
asset list served by its digest, whose URIs name each creative by its hash. A decider that
|
||
serves an ad the decision did not select, or a creative other than the one it bills, signs a
|
||
receipt that fails §9.3.
|
||
- The funds a decision states are the decider's statement. What is checked is the bound: no
|
||
receipt spends past the ceiling, and no receipts together do, so an overspent room fails
|
||
verification at the receipt that crosses the ceiling.
|
||
- `quartiles_reached` is the decider's statement of the beacons it received; a receipt binds it
|
||
and its billing, not the beacons. The impressions a buyer and a seller reconcile are the same
|
||
signed bytes.
|
||
- A receipt names a session by 16 bytes the decider assigns. A decider **SHOULD** assign a
|
||
value with no meaning outside its own records, and **MUST NOT** derive it from a viewer's key
|
||
or another identifier a third party holds, so that receipts do not link a viewer across
|
||
rooms; within a room, one session's receipts are linkable to one another by design, so that a
|
||
viewer is filled once for a break (§9.3 step 11).
|
||
- The asset-list request carries the viewer's playback-session parameter (Appendix D.4) to the
|
||
decider, and nowhere else: the manifests every viewer shares carry nothing of any viewer.
|
||
- The policy is signed by the room authority and the receipts by the decider. A decider whose
|
||
grant is revoked or out of its window has its receipts refused (§9.2, §9.3).
|