# 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 |
| `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 |
| `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 |
| `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 |
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
``, 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, ``,
the mid-rolls' events in schedule order, and ``;
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":".m3u8","DURATION":},…]}
```
one Asset-Description for each ad of the fill in slot order, `` its 64
lowercase hexadecimal digits and `` 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 ``, a
line feed, and:
```
.mpd…
```
one Period for each ad of the fill, its `id` the slot in decimal and `` as §7.2, with no
whitespace other than that shown and no byte after ``. 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).