wai: a channel's breaks marked in its HLS Media Playlist, and why a delivery took the route it took #636

Merged
dcharlot merged 1 commit from wai/hls-cues-delivery-decision into main 2026-11-05 07:52:53 -05:00
41 changed files with 7837 additions and 102 deletions

View file

@ -558,6 +558,7 @@ and a role are fixed once registered.
| `wai-robustness-claim` | document-kind | defined | robustness-evidence §8 |
| `wai-robustness-corpus` | document-kind | defined | robustness-evidence §3 |
| `wai-robustness-harness:1` | scheme | defined | robustness-evidence §6 |
| `wai-route-delivery-decision` | document-kind | defined | route-record §6.3 |
| `wai-route-policy` | document-kind | defined | route-record §3.3 |
| `wai-route-pricebook` | document-kind | defined | route-record §3.2 |
| `wai-route-record` | document-kind | defined | route-record §5.1 |

View file

@ -1338,7 +1338,11 @@ its reason, a gap, an overlap, a programme with no stop, a time zone named by le
outside its programme and a window outside the schedule. The guide, an XMLTV document or a
now/next document for a player's guide row, is rendered from the receipt and the lineup and names
both, so a verifier recomputes it and compares, and states of each of its programmes whether the
receipt's program-as-run says it aired:
receipt's program-as-run says it aired. A Media Playlist of the channel marks each break of the
receipt's schedule where it places it, with `EXT-X-CUE-OUT` and its duration, `EXT-X-CUE-OUT-CONT`
and `EXT-X-CUE-IN`, and with RFC 8216's date ranges carrying its SCTE-35 section, so the playlist
of the channel's segments, MPEG-2 transport stream segments among them, carries the schedule's ad
markers; a verifier given the receipt, the cues and the playlist recomputes every tag and compares:
[`extensions/channel-lineup.md`](extensions/channel-lineup.md).
And for **watching together** in a room — `wai.live.cowatch`: people in different places
@ -1619,7 +1623,11 @@ and of every attempt, each with its hop's OER/2 job receipt and whether it was b
receipt of the hop that served naming the outputs it delivered. The attempts follow the order
and only the last may serve; a failed hop, and a hop that served after the deadline the job
declared, is not billed. A verifier recomputes the record from the three documents, holds each attempt to its
receipt, and refuses a record whose choice they do not give. No transport and no payment:
receipt, and refuses a record whose choice they do not give. A delivery of the job's output is
routed the same way, from its own request, price book and policy, and its signed decision
record states every route considered, the route taken and the key each other route was passed
over by, names the job's record and the delivery decided before it, and is refused when its
route is not the rule's or its chain is broken. No transport and no payment:
[`extensions/route-record.md`](extensions/route-record.md).
And for **auditing a stream another service delivers** — `wai.delivery.audit`: a content owner's

View file

@ -0,0 +1,79 @@
### A channel's breaks marked in its HLS Media Playlist, and why a delivery took the route it took
- **The cue tags** ([extensions/channel-lineup.md](extensions/channel-lineup.md) §7.4): a packager that writes a
channel's segments, MPEG-2 transport stream segments among them, marks each break of the `wai.video.channel`
receipt's schedule, in two forms. `EXT-X-CUE-OUT` with the break's duration goes on its first segment,
`EXT-X-CUE-OUT-CONT` with the time elapsed and the duration on each later one, and `EXT-X-CUE-IN` on its return.
For a break whose cue carries its SCTE-35 section, the `EXT-X-DATERANGE` pair of RFC 8216 §4.3.2.7.1 is written
as well: `SCTE35-OUT` and `PLANNED-DURATION` with the last segment before the break, kept while the window holds
a segment of it (RFC 8216 §6.2.1), then `DURATION` on its return. A date range's `ID` is the break's id and its
start, so a break id a schedule repeats on another day names another range. A segment carries the same
`EXT-X-CUE` lines in every window that holds it. A break whose start or end falls inside a segment is refused
`cue_unaligned`, and one whose id holds a double quote, a line feed or a carriage return, which no quoted-string
can hold, `cue_unwritable`. A window's last segment ends before 10000-01-01T00:00:00Z: a break in the window
starts at the latest where the window ends, so every date the playlist writes has four digits of year.
- **Its verification** (§8.4): a receipt, the cues its import wrote (`cues.json`) and a playlist. Each cue is held
to its break's id, start and duration, and through its cue id to a `splice_insert()`'s `splice_event_id`
(`cues_malformed`, `cues_mismatch`). The playlist's window is read back (`playlist_malformed`), and its tags are
rendered again and compared byte for byte (`playlist_mismatch`). The receipt signs each break's id, start and
duration, which every tag is written from. It does not sign the section a date range carries, and §12 says so.
- **The reference**: `channel_lineup::cue_tags` (`render`, `read_playlist`, `verify_playlist`), and
`channel_lineup::import::break_cue`, the cue §4.8 yields for a break.
- **The corpus** [`live-conformance/channels/`](live-conformance/channels/) gains the sample's cues and five more
channels' receipts or cues. One is the JSON form with brk-1's section left out, whose receipt is the sample's;
three name brk-1 with a double quote, a line feed and a carriage return, the last a receipt the operator signs
itself, since an import refuses the id; and one is a channel scheduled at the end of the year 9999. It also
gains 26 playlist windows and 28 playlist verifications. Eleven windows render playlists, among them one that
begins inside a break, one at a return, one with no section, and segments of 6.006 seconds, whose spans state
milliseconds and whose `TARGETDURATION` is 7, 6.006 rounded up. Four more sit at a bound of §7.4: a URI of 2 048
bytes, a segment of 2^32 − 1 milliseconds, a media sequence number of 2^53 − 1, and a window that ends at
9999-12-31T23:59:59.999Z where a break starts. Fifteen are refused: a control of each refusal of §7.4, and one
past each bound. Eight verifications are verified, and each of the others is a control of one step.
`channel_lineup_verify.py` renders and reads the playlists itself. It catches each of its 18 new checks dropped,
among them one for each bound and one for each character no quoted-string holds, and 13 new mutated copies.
- **Breaking:** `api:rust:wai::channel_lineup::Reason::CuesMalformed`,
`api:rust:wai::channel_lineup::Reason::CuesMismatch`, `api:rust:wai::channel_lineup::Reason::PlaylistMalformed`,
`api:rust:wai::channel_lineup::Reason::CueUnaligned`, `api:rust:wai::channel_lineup::Reason::CueUnwritable` and
`api:rust:wai::channel_lineup::Reason::PlaylistMismatch`: six reasons, which an exhaustive match over `Reason`
needs.
- **The delivery-decision record** ([extensions/route-record.md](extensions/route-record.md) §6.3, §7.2 step 10),
`wai-route-delivery-decision`: why a delivery of a job's output took the route it took. A delivery is routed as a
job is, from its own request, price book of version 2 and policy, so its candidates are the routes that could
carry it (an origin, a cache, a bearer), with every limit that removed one. The record restates every route as
its price book stated it, with its estimates and expected figures. It states the order, the decision and the
route taken, and in `passed` the key by which each other surviving route was passed over. It names its inputs by
digest: the delivery's three documents and, as its `parent`, the job's route record, whose served hop's output
it carries by count and root. Signed by the job's router under its own domain, it is chained: each decision after
a job's first names the one before it by `prev`, so a decision left out before a later one, moved or out of
order is refused. The list proves nothing of decisions after its last, since a verifier is not told how many
there were.
- **Its verification.** A verification document may have `deliveries`, each a delivery's three documents and its
decision, the job's decisions from the first. Each is read and held to its documents (`request_mismatch`,
`policy_mismatch`, `pricebook_mismatch`), and its routing is recomputed (`candidate_mismatch`, `removal_mismatch`,
`rank_mismatch`, `decision_mismatch`). Its route and its passed routes are checked (`delivery_route_mismatch`),
then its parent (`delivery_parent_mismatch`; a record of version 1, or one no hop served, has nothing to
deliver). Last comes its chain (`delivery_chain_broken`: its place, its `prev`, a decision before the output
existed or before the one before it, one delivery decided twice). A form that breaks §6.3 is `delivery_invalid`.
No route record changes, and a document without `deliveries` reads exactly as before.
- **The reference**: `route_record::delivery` (`seal`, `passed`, `delivered_by`, `form`); the route record's
candidate form and step 6 are shared with it, unchanged.
- **The corpus.** Routes gain 70 vectors: six accepted and 64 controls, each a control of one defect, refused at its
own place. Each member that binds a decision to its documents has a control of its own: the request's id,
operation, capability, fallback, quantities and digest, the policy's name, version and digest, and the price
book's digest, currency and `issued`. The accepted ones are a delivery to Europe that one cache wins on time to
first byte over another of one price, two decisions chained, a delivery no route survives, one under a record of
version 2, one verified without receipts, and an empty `deliveries`. `route_record_verify.py` verifies each from
the text. It catches each of its 31 new checks dropped and every mutated copy.
- **The registry** lists `wai-route-delivery-decision` as a document kind of route-record §6.3 (REGISTRY.md and
`codecs/registry.rs`).
- **Breaking:** `api:rust:wai::route_record::Accepted::deliveries`: what the decisions state, `None` when the
document has none. A struct literal of `Accepted` needs it.
- **Breaking:** `api:rust:wai::route_record::Document::deliveries`: the deliveries' texts, `None` when the document
has none. A struct literal of `Document` needs it.
- **Breaking:** `api:rust:wai::route_record::Reason::DeliveryInvalid`,
`api:rust:wai::route_record::Reason::DeliveryRouteMismatch`,
`api:rust:wai::route_record::Reason::DeliveryParentMismatch` and
`api:rust:wai::route_record::Reason::DeliveryChainBroken`: four reasons, which an exhaustive match over `Reason`
needs.
- **Breaking:** `api:rust:wai::route_record::Reason::ALL`: the list of §8's codes holds those four, an array of 29.
A use that names its length needs the new one.

View file

@ -15,6 +15,14 @@
> and its second implementation is `live-conformance/channel_lineup_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
>
> This revision adds the **cue tags** of a Media Playlist of the channel (§7.4): a packager that
> writes the channel's segments, MPEG-2 transport stream segments among them, marks each break of
> the receipt's schedule with `EXT-X-CUE-OUT` and the break's duration, `EXT-X-CUE-OUT-CONT` on each
> later segment of the break and `EXT-X-CUE-IN` on its return, and, for a break whose cue carries
> its SCTE-35 section, with the `EXT-X-DATERANGE` tags of RFC 8216 §4.3.2.7.1; a verifier given
> the receipt, its cues and the playlist recomputes the tags and compares (§8.4). An import, a
> lineup and a guide are unchanged.
>
> Texts read for this revision, each at its source:
>
> - the XMLTV DTD, `xmltv.dtd`: its date and time format, and the elements `tv`, `channel`,
@ -30,6 +38,11 @@
> date range);
> - ANSI/SCTE 35-1 2023r2: §9.6 (Table 5, `splice_info_section()`) and §9.7.3 (Table 10,
> `splice_insert()`);
> - RFC 8216 (HTTP Live Streaming): §4.2 (attribute lists), §4.3.2.1 (`EXTINF`), §4.3.2.6
> (`EXT-X-PROGRAM-DATE-TIME`), §4.3.2.7 (`EXT-X-DATERANGE`), §4.3.2.7.1 (SCTE-35 in a date
> range), §4.3.3.1, §4.3.3.2 and §4.3.3.4 (`EXT-X-TARGETDURATION`, `EXT-X-MEDIA-SEQUENCE`,
> `EXT-X-ENDLIST`), §6.2.1 and §6.3.1 (the server's and the client's general rules), §7 (protocol
> versions) and §8.10 (its example of a date range carrying SCTE-35);
> - RFC 8259 (JSON), RFC 8785 (the JSON Canonicalization Scheme), and XML 1.0 for the subset of
> §3.3.
>
@ -40,13 +53,15 @@
In scope: the bounds and value forms an import reads (§3); the import of a channel's schedule,
catalogue and ad breaks into a schedule, its assets and its cues (§4); the JSON carriage a
`wai.video.channel` receipt travels in, which is revenue-share's (§5); the lineup, its rules, its signature and how it binds its
schedule (§6); the guide, in XMLTV and as a now/next document (§7); verification of a receipt, a
lineup and a guide together (§8); the operations that do each, with a worked example (§9).
schedule (§6); the guide, in XMLTV and as a now/next document (§7), and the cue tags of a Media
Playlist of the channel (§7.4); verification of a receipt, a
lineup and a guide together (§8), and of a playlist's cue tags (§8.4); the operations that do
each, with a worked example (§9).
Out of scope: a transport; how feeds travel between operators; playout, which turns a schedule
into segments; the ad decision of a break, which [live-ads](live-ads.md) and `wai.video.ssai`
make; the revenue an asset's rights holder is owed; the HLS cue tags that the HLS text does not
define, and interstitial date ranges, which [stitched-presentation](stitched-presentation.md)
make; the revenue an asset's rights holder is owed; how a packager cuts and encodes segments,
which §7.4 takes as given; interstitial date ranges, which [stitched-presentation](stitched-presentation.md)
reads; SCTE 224 audience and blackout rules (a lineup states where an asset may be shown, and
says nothing about when); and the elements of the XMLTV DTD and of Media RSS that §4 does not
name, which an import does not read.
@ -587,6 +602,136 @@ A programme is an object with the members `start_ms`, `stop_ms` and `asset_id`;
`{"system", "value"}`; `artwork`, an array of `{"sha256", "uri"}`; `geo`, `null` for everywhere,
`{"allow": [...]}` or `{"deny": [...]}`; `rights_holder`; and `duration_ms`.
### 7.4 The Media Playlist's cue tags
A receipt also gives the ad markers of a Media Playlist of the channel (RFC 8216), so that a
packager that writes the channel's segments, MPEG-2 transport stream segments among them, marks
each break where the signed schedule places it, and anyone holding
the receipt and its cues recomputes the markers and compares (§8.4). Each break is marked in two
forms:
- the tags `EXT-X-CUE-OUT`, `EXT-X-CUE-OUT-CONT` and `EXT-X-CUE-IN`, in a form in common use, which
no published standard defines and which this section fixes; and
- for a break whose cue carries its section, the `EXT-X-DATERANGE` tags of RFC 8216 §4.3.2.7.1,
which carry the SCTE-35 section (ANSI/SCTE 35-1 2023r2 §9.6) in `SCTE35-OUT`.
A client ignores a tag it does not recognise (RFC 8216 §6.3.1), so a client that reads one form
plays a playlist that carries both.
**The breaks** are the receipt's `ad` entries of a duration above 0, in schedule order. A
break's **id** is its entry's asset id, its **start** the entry's start, its **duration** the
entry's duration, and its **end** its start plus its duration. Each break has its cue, the one an
import wrote for it (§4.8, the `cues.json` of §9); its **section** is the cue's message when the
cue is of form 1, and it has none when the cue is of form 0. The receipt signs each break's id,
start and duration, which are all the cue tags are written from. It does not sign the section a
date range carries: that is the cue's, which §8.4 holds to its break's id and duration and no
further.
**The window** is what a Media Playlist lists: its media sequence number (RFC 8216 §4.3.3.2); the
instant its first segment begins; its segments in order, each a URI and a duration, each beginning
where the one before it ends; and whether it is **ended** (`EXT-X-ENDLIST`, RFC 8216 §4.3.3.4). A
segment's URI is 1 to 2 048 bytes of ASCII from `!` to `~`, the first of which is not `#`, and its
duration 1 to 2^32 − 1 milliseconds; the media sequence number is at most 2^53 − 1, and the last
segment ends before 253 402 300 800 000, the first instant of the year 10000. A break in the window
starts at the latest where the window ends, so every date below has four digits of year. A window
that is not so is refused `playlist_malformed`.
A segment **starts in** a break when it starts at or after the break's start and before its end.
A break's **last segment before it** is the segment that ends at its start, and its **return** the
segment that starts at its end. A break **is in** the window when the window holds its last
segment before it, a segment that starts in it, or its return. The tags are refused:
- `cue_unaligned`, at the first segment of the window, in order, inside which a break's start or
end lies (after the segment's start and before its end): a marker applies from a segment's
start, and the splice the schedule places would fall inside the segment;
- then `cue_unwritable`, at the first break, among the breaks, that is in the window, has a
section and has an id holding a double quote, a line feed or a carriage return, which no
quoted-string can hold (RFC 8216 §4.2).
Otherwise the **playlist** of the window is these lines, each ended by a line feed:
```
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:TARGET
#EXT-X-MEDIA-SEQUENCE:SEQUENCE
#EXT-X-PROGRAM-DATE-TIME:FIRST
```
then, for each segment of the window in order, its cue lines (below), `#EXTINF:DURATION,` and its
URI; and then `#EXT-X-ENDLIST` when the window is ended.
- `TARGET` is the longest segment's duration in seconds, rounded up, so that each `EXTINF` rounded
to the nearest integer is at most it (RFC 8216 §4.3.3.1); `SEQUENCE` the media sequence number in
decimal, with no leading zero; and `FIRST` the first segment's start as a date.
- A **date** is an instant written `YYYY-MM-DDTHH:MM:SS.mmmZ`, in UTC, always three digits after the
point (RFC 8216 §4.3.2.6); a **span** is a count of milliseconds written as seconds with three
digits after the point (`90.000`). A segment's `DURATION` is its duration as a span: a decimal
duration, which needs `EXT-X-VERSION` 3 (RFC 8216 §4.3.2.1, §7).
A segment's **cue lines** are, in this order:
1. The date range tags written at it, break by break in schedule order, of each break that has a
section: its **out tag** at the first segment of the window that puts it in the window, and its
**in tag** at its return, when the window holds it:
```
#EXT-X-DATERANGE:ID="ID",START-DATE="START",PLANNED-DURATION=SPAN,SCTE35-OUT=0xSECTION
#EXT-X-DATERANGE:ID="ID",START-DATE="START",DURATION=SPAN
```
`ID` is the break's id, `@` and its start in decimal; `START` its start as a date; `SPAN` its
duration as a span; and `SECTION` its section in uppercase hexadecimal, a hexadecimal-sequence
(RFC 8216 §4.2).
2. `#EXT-X-CUE-IN`, when a break ends at the segment's start.
3. `#EXT-X-CUE-OUT:SPAN`, `SPAN` the break's duration, when a break starts at the segment's start;
otherwise `#EXT-X-CUE-OUT-CONT:ElapsedTime=ELAPSED,Duration=SPAN`, when the segment starts in a
break, `ELAPSED` the time from the break's start to the segment's as a span.
So:
- **A date range announces its splice and is kept while it maps a segment.** RFC 8216 §4.3.2.7.1
places an out section in `SCTE35-OUT`, with a `START-DATE` that is the splice's and a
`PLANNED-DURATION`, and has a tag announcing a splice added with the last segment before it; once
the end is established, another tag of the same `ID` signals it. RFC 8216 §6.2.1 keeps a date range
while any of its dates maps to a segment the playlist holds. Two tags of one `ID` agree on every
attribute both carry (RFC 8216 §4.3.2.7), here `ID` and `START-DATE`, which that section requires
of every date range. The in tag carries no `SCTE35-IN`: an import keeps the out section alone
(§4.5 step 3).
- **An `ID` names one break.** A channel's schedule may repeat a break's id on another day; its start
makes the `ID` unique in any playlist, as RFC 8216 §4.3.2.7.1 requires of an id that may recur.
- **The cue form is a function of each segment.** A segment's `EXT-X-CUE` lines depend on its own
start and the schedule alone, so it carries the same ones in every window that holds it, and a
player that joins inside a break reads, on the first segment it is given, how far into the break
it is and how long the break is. The date range tags depend on the window, as RFC 8216 places them.
- **A break with no section has no date range**: RFC 8216 §4.3.2.7.1 maps a section, and there is
none to map. Its cue form is written all the same.
- **Every playlist states its program date-time** (`FIRST`), which RFC 8216 §4.3.2.7 requires of a
playlist that carries a date range, on the receipt's UTC timeline.
- **Nothing is spliced.** The segments are the channel's own, the program and the ad entries
alike; the tags say where a downstream inserter replaces the ad entries' segments. No
`EXT-X-DISCONTINUITY` is written, since the window's segments run on one timeline.
For brk-1 of the corpus's sample (§11), 90 seconds from 18:12:00 UTC, in ten-second segments, the
last segment before it, its first and its return carry:
```
#EXT-X-DATERANGE:ID="brk-1@1793297520000",START-DATE="2026-10-29T18:12:00.000Z",PLANNED-DURATION=90.000,SCTE35-OUT=0xFC30…
#EXTINF:10.000,
seg-71.ts
#EXT-X-CUE-OUT:90.000
#EXTINF:10.000,
seg-72.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=10.000,Duration=90.000
#EXTINF:10.000,
seg-73.ts
…
#EXT-X-DATERANGE:ID="brk-1@1793297520000",START-DATE="2026-10-29T18:12:00.000Z",DURATION=90.000
#EXT-X-CUE-IN
#EXTINF:10.000,
seg-81.ts
```
## 8. Verification
### 8.1 Inputs
@ -639,6 +784,46 @@ difference: a title, a rating or a picture the lineup does not state, a programm
receipt states which of it was on air in its window;
- for `guide_entry_unscheduled`, the index of step 10.
### 8.4 A playlist's cue tags
A verifier given a receipt in its JSON carriage (§5), a cues document and a Media Playlist's bytes
gets one verdict, **verified** or the reason of the first step it fails:
1. The receipt reads, as §8.2 step 1: `channel_receipt_malformed`.
2. Its signature verifies, as §8.2 step 2: `bad_key`, `bad_signature`.
3. Its rules hold, as §8.2 step 3: `channel_receipt_invalid`.
4. **The cues** read: one JSON text (RFC 8259) in UTF-8, nested at most 64 deep, whose objects each
name a member once, an array of objects of exactly `break` (a string), `start_ms` (an integer of
at most 2^53 − 1, written as §4.4 writes one) and `payload` (an even count of lowercase
hexadecimal digits), as `channel.import` writes `cues.json` (§9). Otherwise `cues_malformed`.
5. They are the breaks' cues (§7.4): there are as many as there are breaks, and the cue at each index
is its break's: its `break` the break's id and its `start_ms` the break's start, and its payload
the one §4.8 yields for a break of that id and duration and of the section the payload carries,
when it carries a message of form 1 that reads as §4.5 step 3 reads a section, or of none, when it
is of form 0. Otherwise `cues_mismatch`, at the first index that is not, a cue missing or added at
the first index past the shorter list. A break whose duration is above 2^32 − 1 milliseconds has no
cue of §4.8, and its index fails.
6. **The playlist** reads: UTF-8 text whose lines, split at each line feed, the last ended by one,
begin with `#EXTM3U`; with exactly one line `#EXT-X-MEDIA-SEQUENCE:` and a decimal integer with no
leading zero, and a line `#EXT-X-PROGRAM-DATE-TIME:` and an HLS date-time (§3.4) before its first
segment; whose segments are each a line `#EXTINF:`, a duration (§3.4) and `,`, followed by a line,
the segment's URI, at least one of them. It is ended when one of its lines is `#EXT-X-ENDLIST`. Its
**window** is that media sequence number, the first such date-time's instant as its first
segment's start, its segments in order and whether it is ended, which **MUST** hold to §7.4's
bounds. Otherwise `playlist_malformed`. No other line is read here: step 8 compares every line.
7. Its window's tags can be written (§7.4): `cue_unaligned`, at the segment's index; `cue_unwritable`,
at the break's index among the breaks.
8. Its bytes are the playlist of §7.4 of its window: `playlist_mismatch`.
Step 8 catches a marker the schedule does not place, at a segment or in a date range: a break
another duration, another start or another section than its cue's, a tag left out, added or moved.
A verification states its verdict and the **part** it is about (`receipt` for steps 1 to 3, `cues`
for steps 4 and 5, `playlist` for steps 6 to 8, none when verified); the receipt's identity, when it
reads; for a playlist that reads, its window (its media sequence number, its first segment's start,
how many segments it holds and whether it is ended) and the breaks in it, each with its id, its
start, its duration and whether it has a date range; and the index of step 5 or step 7.
## 9. Operations and a worked example
Three operations of the WAI command line carry this text (the operation catalogue,
@ -705,8 +890,15 @@ The codes this extension adds, each with where it is raised:
| `guide_source_mismatch` | §8.2 step 9 |
| `guide_entry_unscheduled` | §8.2 step 10 |
| `guide_mismatch` | §8.2 step 11 |
| `cues_malformed` | §8.4 step 4 |
| `cues_mismatch` | §8.4 step 5 |
| `playlist_malformed` | §7.4, §8.4 step 6 |
| `cue_unaligned` | §7.4, §8.4 step 7 |
| `cue_unwritable` | §7.4, §8.4 step 7 |
| `playlist_mismatch` | §8.4 step 8 |
A verification also gives live-events' `bad_key` and `bad_signature` (§8.2 steps 2 and 5).
A verification also gives live-events' `bad_key` and `bad_signature` (§8.2 steps 2 and 5, §8.4
step 2).
## 11. Conformance
@ -728,10 +920,38 @@ programmes, owners and pictures made for it. The vectors:
order, a program-as-run the window does not give, a lineup that does not read, one another key
signed, one for each rule of §6.5 that it alone breaks, a guide that does not read, one naming
another schedule, one that places a programme the schedule does not, and one whose title the
lineup does not state).
lineup does not state);
- **playlists**: windows of the sample's Media Playlist and the playlist each renders (§7.4): brk-1
from before its last segment before it to after its return; a live window that begins inside
brk-2, whose section is a `time_signal()`; brk-3, which begins half a second past a whole second,
in six-second segments, the window ended; a window that begins at a return; one no break is in;
and brk-1 from the JSON form with no section, which yields the same receipt and other cues, and
no date range. And a control of each refusal of §7.4: segments cut off a break's start, a segment
across a return, a break named with a double quote, and a segment of no duration;
- **playlist windows at their bounds**: brk-1 in segments of 6.006 seconds, whose `EXTINF` and
`ElapsedTime` state milliseconds and whose `TARGETDURATION` is 7, 6.006 rounded up; a segment URI
of 2 048 bytes; a segment of 2^32 − 1 milliseconds; a media sequence number of 2^53 − 1; and, of
a channel scheduled at the end of the year 9999, a window that ends at 9999-12-31T23:59:59.999Z,
where a break starts, its date range dated in the year 9999. And a control of each bound: brk-1 named with a line feed, and
with a carriage return in a receipt the operator signed (`cue_unwritable`); that window with a
segment of a millisecond after it, which ends at 10000-01-01T00:00:00Z; a window of no segment; a
segment URI of no byte, of 2 049 bytes, holding a space, holding a letter outside ASCII and
beginning with `#`; a segment of 2^32 milliseconds; and a media sequence number of 2^53
(`playlist_malformed`);
- **playlist verifications**: the playlists of the first six windows, the one of 6.006-second
segments and the one of the year 9999, each verified against its receipt and its cues (§8.4); and a
control of each step: a receipt that does not read, a flipped signature, a
program-as-run its window does not give, cues that do not read, a cue a millisecond longer than its
break, one whose section names another `splice_event_id`, one a second late, one left out, another
receipt's, a playlist with no media sequence number, one that ends at 10000-01-01T00:00:00Z, one
whose segment URI begins with `#`, one whose segments move off a break's start,
a break named with a double quote, and playlists that state another duration, leave out an
`EXT-X-CUE-OUT-CONT`, return a segment early, carry another section, start a date range a second
late, and leave out a date range's end.
A conforming implementation gives every import its outcome (the receipt, the lineup and the cues,
or the refusal) and every verification its verdict and facts, as the corpus states.
or the refusal), every verification its verdict and facts, every playlist window its playlist or
its refusal, and every playlist verification its verdict and facts, as the corpus states.
`wai_channel_lineup_vectors verify` and the test `tests/channel_lineup.rs` check that every file
regenerates byte for byte, that `wai-rs` gives every vector its outcome, that §10 lists exactly
the codes the reference adds, and that the corpus gives every reason of §10 and both of
@ -739,7 +959,8 @@ live-events'. `live-conformance/channel_lineup_verify.py` is the second implemen
from this text: it shares no code with `wai-rs`; it verifies Ed25519 with WAI's strict rule
(`claim-conformance/_ed25519.py`), BLAKE3 with `claim-conformance/_blake3.py`, and reads a channel
receipt's carriage, its signed bytes, its identity and its rules as the revenue-share corpus's
second implementation reads them (§5); it reads XML, JSON and playlists itself; and it mutates copies of the corpus and requires
second implementation reads them (§5); it reads XML, JSON and playlists itself, and renders and
reads a Media Playlist's cue tags itself (§7.4, §8.4); and it mutates copies of the corpus and requires
every copy to fail, and drops each of its own checks in turn and requires the corpus to catch the
weakened verifier.
@ -759,6 +980,13 @@ weakened verifier.
- **Bounded reading.** An import reads bounded documents (§3.1), XML to a bounded depth with no
document type declaration that defines anything (§3.3), so no entity is ever expanded, and
integers it reads are checked against their bounds before any arithmetic.
- **What the cue tags prove.** A playlist that verifies marks each break where the receipt's
signed schedule places it, with its signed duration, and every segment of it; one that moves a
break, shortens it or leaves a segment of it unmarked is refused. The section a date range carries
is the import's (§4.8): the receipt does not sign it, and a verifier holds it only to its break's
id and duration, and through the cue id to a `splice_insert()`'s `splice_event_id`. A party that
splices on a section's own fields holds the playlist it was imported from, or the signed `cue`
events of a room that carried it (live-events §3.2, §4.3), which sign the section itself.
- **Availability.** Where an asset may be shown is the operator's statement of the catalogue's
restriction; a player enforces it, or a delivery that a `wai.video.manifest` describes does.
A lineup holds no viewer's data.

View file

@ -37,6 +37,15 @@
> one hop, what came back and what the provider bills, signed by the router and bound to the
> hop's attempt and receipt. A verification document without one is read exactly as before.
>
> This revision adds the **delivery-decision record** (§6.3): why a delivery of a job's output
> took the route it took. A delivery is routed as a job is, from its own request, price book and
> policy (§3, §4); its record states every route considered with the figures its price book stated
> and the limits that removed it, the rule that chose and by which key each other route was passed
> over, and every input it read by digest. It is signed by the job's router, names the job's
> route record as its parent and the delivery decided before it, and holds what it delivers to
> what the job's served hop delivered. A verification document without one is read exactly as
> before.
>
> Texts read for this revision: the WAI texts this one cites; RFC 8259; RFC 8785; RFC 8032;
> ISO 4217 (the alphabetic currency code); FIPS 180-4; the BLAKE3 specification.
@ -46,8 +55,9 @@ In scope: the three documents a routing decision rests on, the request, the pric
the policy (§3); how a router computes its candidates, their removals, their ranking and
its decision from them (§4); the record, its attempts and its signature (§5); how an
attempt is held to its hop's OER/2 receipt, and in versions 2 and 3 to the outputs it
delivered (§6); an output's inclusion proof (§6.1); verification (§7); and the reference
implementation and corpus (§10).
delivered (§6); an output's inclusion proof (§6.1); a third party's hop (§6.2); a delivery's
decision, routed as a job is and chained to its job's record (§6.3); verification (§7); and the
reference implementation and corpus (§10).
Out of scope: a transport; how a router learns its prices and measures its endpoints'
failures, which the price book states and this extension takes as stated; accounts, credit
@ -601,6 +611,88 @@ record's usage (receipt-ledger §4.9). The record states no energy figure: the
hop's receipt states it, or the unmetered marker where the provider gave none
(oer-receipts §3.1).
### 6.3 A delivery's decision
A job's output is then delivered: sent to who asked for it, by one of the routes that could carry
it, an origin, a cache or a bearer among them. A router that routes a delivery reads the
delivery's own documents, of §3's forms: its **request**, whose `job` is the delivery's id and
whose `quantities` are what the delivery carries, unit by unit (`bytes`, for one); its **price
book**, of version 2, whose entries are the routes, each priced for the operation it performs;
and its **policy**. From them §4 computes the delivery's candidates, every limit that removes one,
the ranking, the order and the decision, as it computes a job's. The router states why the
delivery took the route it took in a **delivery-decision record**, a document (§2) of exactly these
members:
| member | value |
|---|---|
| `kind` | `"wai-route-delivery-decision"` |
| `version` | `1` |
| `job` | the job's id, as its route record states it |
| `parent` | the job's route record's digest (§5.3) |
| `seq` | an integer at least 1: its place among the job's delivery decisions |
| `prev` | `null`, or 64 lowercase hexadecimal digits: the digest of the job's decision of `seq` − 1, and `null` for its first (*its chain*, below) |
| `objects` | an object of exactly `count`, an integer at least 1, and `root`, 64 lowercase hexadecimal digits: what the delivery carries, the objects the job's served hop delivered, by their count and their commitment (§6) |
| `delivery` | a token: the delivery's request's `job`, the delivery's id |
| `operation`, `capability`, `fallback`, `quantities` | the delivery's request's, of the forms §3.1 gives them |
| `request` | 64 lowercase hexadecimal digits: the delivery's request's digest |
| `policy` | an object of exactly `name`, `version` and `digest`: the delivery's policy's, of the forms §5.1 gives them |
| `pricebook` | an object of exactly `digest`, `currency` and `issued`: the delivery's price book's, of the forms §5.1 gives them |
| `candidates` | the delivery's candidates (§4.1), in their order, 0 to 1024 objects each of the members and forms of a candidate of a record of version 2 (§5.1) |
| `order` | the order (§4.4): 0 to 1024 tokens, the surviving routes' endpoints |
| `decision` | an object of exactly `decided_by` and `runner_up`, of the forms §5.1 gives them (§4.5) |
| `route` | a token, the route the delivery took: the first endpoint of the order; `null` when no route survives |
| `passed` | 0 to 1023 objects of exactly `endpoint`, a token, and `key`, a key of the key list (§4.4): for each survivor after the first, in the order, the first key on which it and the first differ, by which it was passed over |
| `decided` | a time: when the router decided |
| `signer`, `sig` | the router's key and its signature |
A record that is not of these members, each of its form, is `delivery_invalid`; the relations
between its members, and between it and its documents, its parent and the decisions before it, are
those below.
**Why, in full.** The record restates each route as its price book stated it, with its estimates,
its expected figures and every limit that removed it; it names the rule that chose, the policy by
its name, version and digest, and for every survivor the key that ranked the route the delivery
took above it, the runner-up's being the decision's `decided_by`; and it names by digest every
input the rule read: the delivery's three documents and, by its parent, the job and the objects it
delivers. A verifier recomputes all of it (§7.2 step 10), so a router cannot state a route the
rule did not choose, nor a reason it did not have.
**Signed and chained.** The record is signed by the job's router, over the domain
`wai:route-delivery-decision\x01` and the canonical form of the record without `sig`, under the
strict rule of SPEC §7, and named by its digest (§2), over its canonical form with `sig`. It is
chained to its job: `parent` names the job's route record, and each decision after the first
names the one before it by `prev`, so a job's decisions are one sequence from its record, in which
a decision left out before a later one, moved or given out of order is refused. The sequence proves
nothing of decisions after its last: a verifier is not told how many deliveries were decided, and a
list that ends early verifies.
Given the job's route record and its decisions, from the first and in order (§7.2 step 10), a
decision is held to:
- **its documents**, as step 5 holds a record to its job's: its `delivery`, `operation`,
`capability`, `fallback`, `quantities` and `request` are the delivery's request's `job`,
`operation`, `capability`, `fallback`, `quantities` and digest (`request_mismatch`); its `policy`
the delivery's policy's (`policy_mismatch`); and its `pricebook` the delivery's price book's
digest, currency and `issued`, the book of version 2 (`pricebook_mismatch`);
- **§4 over them**, as step 6 holds a record's candidates, order and decision (`candidate_mismatch`,
`removal_mismatch`, `rank_mismatch`, `decision_mismatch`); and its `route` the first of the order,
or `null` for none, and its `passed` each later survivor's endpoint with its key, in the order
(`delivery_route_mismatch`);
- **its parent**: its `signer` and `job` are the route record's, its `parent` the route record's
digest, and its `objects` what the route record's served attempt delivered: in a record of
version 3, its `delivered`; in one of version 2, the count of its `outputs` and their commitment
(§6). A record of version 1 states no output, and one no hop served delivered nothing, so neither
has a delivery. Otherwise `delivery_parent_mismatch`;
- **its chain**: its `seq` is its place in the list, from 1; its `prev` is `null` for the first and
the digest of the decision before it for every other; its `decided` is at least the served
attempt's `end`, when the objects first existed, and at least the `decided` of the decision before
it; and its `delivery` is not that of a decision before it. Otherwise `delivery_chain_broken`.
The route a delivery took is a decision, not a delivery: whether the route carried the objects is
for the delivery's own receipts to state ([bearer-receipts](bearer-receipts.md)), and what it costs
for the tariff that bills it (receipt-ledger §4). The record states no energy figure and no amount
of its own, only the figures its price book stated and the estimates §4 derives from them.
## 7. Verification
### 7.1 Inputs
@ -610,9 +702,13 @@ A verifier is given a **verification document**: a JSON object of exactly `reque
`null` or a list of strings each holding an OER/2 receipt's text, and `keys`, `null` or an
object of a receipt's `chain.signer` names to keys, as oer-receipts §5 publishes them. It may
also have `third_party`, a list of strings each holding a third-party hop record's text (§6.2).
It may also have `deliveries`, a list of objects of exactly `request`, `pricebook`, `policy` and
`decision`, each a string holding that document's text: a delivery's three documents and its
delivery-decision record (§6.3), the job's decisions from the first, in order.
The texts are carried as strings so that each is read as written. A verification document that
is not such an object, whose `receipts` is a list while its `keys` is `null`, or that has
`third_party` while its `receipts` is `null`, is `malformed`.
`third_party` while its `receipts` is `null`, is `malformed`, and so is one whose `deliveries` is
not such a list.
### 7.2 Steps
@ -667,6 +763,15 @@ and where (`at`, a list: the member, then indexes or members within it):
is `third_party_invalid`; then its signature, `bad_key`, `bad_signature`, at `sig` within
it; then its binding, `third_party_mismatch`, at the member; then what its hop delivered,
`third_party_output_mismatch`, at `outputs` within it.
10. **The delivery decisions** (§6.3), when the document has `deliveries`, each in turn, at
`deliveries`, its index and the member: its `request`, `pricebook` and `policy`, as steps 1 to 3
read the job's (`malformed`, `request_invalid`, `pricebook_invalid`, `policy_invalid`); then its
`decision`: a text that is not a document, or whose `kind` is not
`wai-route-delivery-decision`, is `malformed`; a `version` that is an integer other than `1` is
`unknown_version`, at `version` within it; one that is not of §6.3's members and forms is
`delivery_invalid`; then its signature, `bad_key`, `bad_signature`, at `sig` within it; then, at
the member within it, its documents, §4 over them (a candidate at `candidates` and its index),
its route, its parent and its chain, in that order and each as §6.3 holds it.
### 7.3 Outcome
@ -680,7 +785,9 @@ and where (`at`, a list: the member, then indexes or members within it):
limits that removed it; whether the hop receipts were `checked` or `not_checked`; and, when
checked, the hops whose receipts state the unmetered marker (oer-receipts §3.1), which a
reader **MUST NOT** present as hops of zero joules. When the document has `third_party`, each
record's hop, digest, `account`, `usage` and `charge`, in the order of their hops.
record's hop, digest, `account`, `usage` and `charge`, in the order of their hops. When it has
`deliveries`, each decision's `seq`, `delivery`, digest, `route`, `decision` and `passed`, in
their order.
## 8. Reasons
@ -713,6 +820,10 @@ The codes this extension adds, each with where it is raised:
| `third_party_invalid` | §6.2, §7.2 step 9 |
| `third_party_mismatch` | §6.2, §7.2 step 9 |
| `third_party_output_mismatch` | §6.2, §7.2 step 9 |
| `delivery_invalid` | §6.3, §7.2 step 10 |
| `delivery_route_mismatch` | §6.3, §7.2 step 10 |
| `delivery_parent_mismatch` | §6.3, §7.2 step 10 |
| `delivery_chain_broken` | §6.3, §7.2 step 10 |
A verifier of this extension also raises `malformed` for a text that is not a document (§2)
or a verification document that is not §7.1's, `unknown_version` for a record of another
@ -785,6 +896,30 @@ verification document with the outcome §7.3 gives it:
another unit, times outside the attempt's and a second record of a hop
(`third_party_mismatch`); and outputs that are not what the receipt names, the same in another
order, and another number than the attempt's count (`third_party_output_mismatch`).
- **Accepted, delivery decisions**, under records of versions 2 and 3: a job's output delivered to
Europe by the faster of two caches of one price, the dearer cache and the origin passed over by
price, the second cache by time to first byte; a second delivery of it to another region by the
one route that keeps no data there, chained to the first; a delivery no route survives; a record
verified without its receipts; and a document whose `deliveries` is empty.
- **Refused, delivery decisions**: a `deliveries` that is not a list, an entry without its policy
and a decision not as text (`malformed`); the delivery's request, price book and policy each
invalid; a decision that is not a document, of another kind and of version 2; a member missing and
one added, a `seq` of 0, a `prev` in capitals, no object, `objects` with a member besides, a route
passed by no key, a route as a number, a candidate of version 1's members, `decided` as text and
more routes passed than a list holds (`delivery_invalid`); a decision changed after it was signed,
a signer of small order and one signed under the route record's domain; another delivery's id,
operation, capability, quantities and request digest, and a fallback the request does not name
(`request_mismatch`); another policy version, name and digest (`policy_mismatch`); another price
book's `issued`, digest and currency, and one of version 1 (`pricebook_mismatch`); a route's price
restated, a route left out, a removal left out, two ranks swapped, the order reversed and another
`decided_by`; another route, none while one survives, a route passed by another key, a passed
route left out, a removed route passed, and a route where none survives
(`delivery_route_mismatch`); another router's decision, another job's, another parent, another
count and root, and a decision of a job no hop served and of a record of version 1
(`delivery_parent_mismatch`); and a first decision at `seq` 2 and one naming a `prev`, a second
with no `prev` and one naming another, decisions out of order, the first left out before the
second, one decided before the output existed and one before the decision before it, and one
delivery decided twice (`delivery_chain_broken`).
`wai_route_record_vectors verify` and the test `tests/route_record.rs` check that the file
regenerates byte for byte, that `wai-rs` gives every vector its outcome, that every reason of
@ -795,7 +930,8 @@ written from RFC 8032 (`claim-conformance/_ed25519.py`), verifies the hop receip
OER/2 reference verifier (`oer-conformance/oer2_verify.py`), computes the outputs'
commitment with BLAKE3 written from its specification (`claim-conformance/_blake3.py`),
recomputes every candidate, removal, expected figure, rank, order, decision and attempt
itself, gives every vector the same outcome, requires every mutated copy of the corpus to
itself, and every delivery's routing, route, passed routes, parent and chain, gives every
vector the same outcome, requires every mutated copy of the corpus to
fail, and requires the corpus to catch the verifier with each of its checks dropped.
## 11. Security and privacy considerations
@ -839,8 +975,22 @@ fail, and requires the corpus to catch the verifier with each of its checks drop
answers: the provider signs nothing, so the record is the router's statement of what passed
between them, bound by digest to the bytes, which a holder of them checks. A provider's
`charge` is what the provider reported, as the router read it, and is never a price.
- **A delivery's route is the rule's.** A delivery-decision record whose route, or whose reason for
passing a route over, is not what §4 gives over the delivery's own documents is refused
(`delivery_route_mismatch`), so a router that sends a delivery to a route its rule did not choose
cannot sign a record that verifies. Each decision names the job's route record and the decision
before it, so one left out before a later one, moved or put in another job's deliveries is refused
(`delivery_chain_broken`, `delivery_parent_mismatch`). The decisions after the last one given are
not proved absent: a verifier is not told how many there were.
- **What a delivery decision proves.** That the router, holding the delivery's three documents,
chose its route by their rules, for the output the job's served hop delivered. It does not prove
that a route's terms are as its price book states, which a holder of the published price book of
that digest compares, nor that the route carried the objects, which the delivery's own receipts
state.
- **What a record reveals.** Its job id, its operation and its quantities, every endpoint and
provider that could have served the job with their prices, and when each hop ran; in
version 2, every candidate's measured failures and the content hashes of the job's
outputs. A third-party hop record reveals, besides, what the provider answered about the job.
outputs. A third-party hop record reveals, besides, what the provider answered about the job,
and a delivery decision every route that could have carried the job's output, with its terms,
and where the delivery was to go, by the regions and residencies its request names.
They travel between the router, the job's owner and an auditor they choose.

View file

@ -14,10 +14,14 @@ stitched_presentation_verify.py. Everything else is here, from the standard libr
- the import of an XMLTV schedule, Media RSS catalogues, the JSON form and a Media Playlist's
breaks into a schedule, its assets and its cues (§4);
- the lineup, its rules, digest and signature, and whether it describes a schedule (§6);
- the XMLTV guide and the now/next document (§7), and verification (§8).
- the XMLTV guide and the now/next document (§7), and verification (§8);
- the cue tags of a Media Playlist (§7.4): the breaks and their cues, a playlist's window, its
alignment and its lines; and a playlist's verification (§8.4).
For every import it requires the outcome the corpus states (the refusal, or a receipt, lineup and
cues it recomputes and whose signatures it verifies); for every verification, the facts. Of the
cues it recomputes and whose signatures it verifies); for every verification, the facts; for every
playlist window, the playlist byte for byte or the refusal; and for every playlist verification,
the facts. Of the
corpus, that every reason of §10 and both of live-events' appear, and that no other file is
there. Then it mutates copies of the corpus, one defect each, and requires every copy to fail;
and it drops each of its own checks in turn and requires the corpus to catch the weakened
@ -1196,6 +1200,256 @@ def verify(receipt, lineup, guide):
# ---------------------------------------------------------------- the corpus
# ---------------------------------------------------------------- §7.4 the cue tags, §8.4
PL_VERSION = 3 # RFC 8216 §7
MAX_URI, MAX_SEGMENT, WINDOW_ENDS_BEFORE = 2048, U32, 253402300800000 # §7.4
def read_cues(b):
"""§8.4 step 4: the cues as `cues.json` holds them, or None."""
v = strict_json(b)
if not isinstance(v, list):
return None
out = []
for c in v:
if not isinstance(c, dict) or set(c) != {"break", "start_ms", "payload"}:
return None
if not isinstance(c["break"], str) or not jint(c["start_ms"]):
return None
p = c["payload"]
if not isinstance(p, str) or len(p) % 2 or any(ch not in "0123456789abcdef" for ch in p):
return None
out.append({"break": c["break"], "start_ms": c["start_ms"], "payload": bytes.fromhex(p)})
return out
def schedule_breaks(r):
"""§7.4: the receipt's ad entries of a duration above 0, in schedule order."""
return [(e["asset_id"], e["start_ms"], e["duration_ms"]) for e in r["schedule"] if e["kind"] == "ad" and e["duration_ms"] > 0]
def payload_section(p):
"""The section a cue's payload carries: (True, section) of form 1, (True, None) of form 0,
(False, None) when neither."""
if len(p) < 10:
return False, None
if p[9] == 0:
return True, None
if p[9] == 1 and len(p) >= 12:
n = p[10] | (p[11] << 8)
sec = p[12:12 + n]
if len(sec) == n and section_reads(sec):
return True, sec
return False, None
def bind_cues(r, cues):
"""§8.4 step 5: the breaks, each (id, start, duration, section); or Refuse with the index."""
breaks = schedule_breaks(r)
out = []
for i in range(max(len(breaks), len(cues))):
if i >= len(breaks) or i >= len(cues):
check("cues_count", False, ("cues_mismatch", i))
break
bid, start, dur = breaks[i]
c = cues[i]
check("cues_break", c["break"] == bid and c["start_ms"] == start, ("cues_mismatch", i))
ok, sec = payload_section(c["payload"])
check("cues_payload", ok and dur <= U32 and cue_payload({"id": bid, "duration": dur, "section": sec}) == c["payload"], ("cues_mismatch", i))
out.append({"id": bid, "start": start, "duration": dur, "end": start + dur, "section": sec})
return out
def window_holds(w):
"""§7.4's bounds, each a check of its own."""
segs = w["segments"]
end = w["start"] + sum(d for _, d in segs)
bounds = {
"pl_segments": len(segs) > 0,
"pl_uri_empty": all(len(u) > 0 for u, _ in segs),
"pl_uri_long": all(len(u.encode()) <= MAX_URI for u, _ in segs),
"pl_uri_chars": all(0x21 <= ord(c) <= 0x7E for u, _ in segs for c in u),
"pl_uri_hash": not any(u.startswith("#") for u, _ in segs),
"pl_duration_zero": all(d >= 1 for _, d in segs),
"pl_duration_long": all(d <= MAX_SEGMENT for _, d in segs),
"pl_sequence": w["sequence"] <= MAX_INSTANT,
"pl_end": end < WINDOW_ENDS_BEFORE,
}
return all(ok for name, ok in bounds.items() if name not in SKIP)
def seg_spans(w):
out, at = [], w["start"]
for _, d in w["segments"]:
out.append((at, at + d))
at += d
return out
def first_in(b, sp):
"""The first segment that puts a break in the window (§7.4), or None."""
for k, (s, e) in enumerate(sp):
if e == b["start"] or b["start"] <= s < b["end"] or s == b["end"]:
return k
return None
def pl_span(ms):
return "%d.%03d" % (ms // 1000, ms % 1000)
def pl_date(ms):
d = datetime.datetime(1970, 1, 1) + datetime.timedelta(milliseconds=ms)
return d.strftime("%Y-%m-%dT%H:%M:%S") + ".%03dZ" % (ms % 1000)
def render(breaks, w):
"""§7.4: the playlist of a window, or Refuse((reason, index))."""
check("pl_window", window_holds(w), ("playlist_malformed", None))
sp = seg_spans(w)
for k, (s, e) in enumerate(sp):
check("aligned", not any(s < x < e for b in breaks for x in (b["start"], b["end"])), ("cue_unaligned", k))
for i, b in enumerate(breaks):
if first_in(b, sp) is not None and b["section"] is not None:
for name, c in (("writable_quote", '"'), ("writable_line_feed", "\n"), ("writable_carriage_return", "\r")):
check(name, c not in b["id"], ("cue_unwritable", i))
target = max((d + 999) // 1000 for _, d in w["segments"])
out = ["#EXTM3U", "#EXT-X-VERSION:%d" % PL_VERSION, "#EXT-X-TARGETDURATION:%d" % target,
"#EXT-X-MEDIA-SEQUENCE:%d" % w["sequence"], "#EXT-X-PROGRAM-DATE-TIME:" + pl_date(w["start"])]
for k, ((uri, d), (s, _)) in enumerate(zip(w["segments"], sp)):
for b in breaks:
if b["section"] is None:
continue
rid = "%s@%d" % (b["id"], b["start"])
if first_in(b, sp) == k:
out.append('#EXT-X-DATERANGE:ID="%s",START-DATE="%s",PLANNED-DURATION=%s,SCTE35-OUT=0x%s'
% (rid, pl_date(b["start"]), pl_span(b["duration"]), b["section"].hex().upper()))
if s == b["end"]:
out.append('#EXT-X-DATERANGE:ID="%s",START-DATE="%s",DURATION=%s' % (rid, pl_date(b["start"]), pl_span(b["duration"])))
if any(b["end"] == s for b in breaks):
out.append("#EXT-X-CUE-IN")
starting = [b for b in breaks if b["start"] == s]
inside = [b for b in breaks if b["start"] < s < b["end"]]
if starting:
out.append("#EXT-X-CUE-OUT:" + pl_span(starting[0]["duration"]))
elif inside:
b = inside[0]
out.append("#EXT-X-CUE-OUT-CONT:ElapsedTime=%s,Duration=%s" % (pl_span(s - b["start"]), pl_span(b["duration"])))
out += ["#EXTINF:%s," % pl_span(d), uri]
if w["ended"]:
out.append("#EXT-X-ENDLIST")
return "".join(ln + "\n" for ln in out)
def read_cue_playlist(b):
"""§8.4 step 6: a playlist's window, or None."""
try:
t = b.decode("utf-8")
except UnicodeDecodeError:
return None
if not t.endswith("\n"):
return None
lines = t[:-1].split("\n")
if lines[0] != "#EXTM3U":
return None
seq, first, ended, segs, i = None, None, False, [], 1
try:
while i < len(lines):
ln = lines[i]
if ln.startswith("#EXT-X-MEDIA-SEQUENCE:"):
v = ln[len("#EXT-X-MEDIA-SEQUENCE:"):]
if seq is not None or not plain_uint(v) or int(v) > MAX_INSTANT:
return None
seq = int(v)
elif ln.startswith("#EXT-X-PROGRAM-DATE-TIME:"):
if first is None and not segs:
first = hls_date_time(ln[len("#EXT-X-PROGRAM-DATE-TIME:"):])
elif ln == "#EXT-X-ENDLIST":
ended = True
elif ln.startswith("#EXTINF:"):
v = ln[len("#EXTINF:"):]
if not v.endswith(",") or i + 1 >= len(lines):
return None
segs.append((lines[i + 1], duration(v[:-1])))
i += 1
i += 1
except Refuse:
return None
if seq is None or first is None:
return None
w = {"sequence": seq, "start": first, "segments": segs, "ended": ended}
return w if window_holds(w) else None
def verify_playlist(receipt, cues, playlist):
"""§8.4: the facts."""
f = {"verdict": "verified", "part": None, "receipt": None, "window": None, "index": None}
def fail(part, reason, index=None):
f["verdict"], f["part"], f["index"] = reason, part, index
return f
r = read_receipt(receipt)
if r is None:
return fail("receipt", "channel_receipt_malformed")
f["receipt"] = receipt_identity(r).hex()
bad = ED.verify(r["signer_pubkey"], receipt_payload(r), r["sig"]) if "receipt_sig" not in SKIP else None
if bad:
return fail("receipt", bad)
if not receipt_rules(r):
return fail("receipt", "channel_receipt_invalid")
c = read_cues(cues)
if c is None:
return fail("cues", "cues_malformed")
try:
breaks = bind_cues(r, c)
except Refuse as e:
return fail("cues", e.reason[0], e.reason[1])
w = read_cue_playlist(playlist)
if w is None:
return fail("playlist", "playlist_malformed")
sp = seg_spans(w)
f["window"] = {"media_sequence": w["sequence"], "start_ms": w["start"], "segments": len(w["segments"]), "ended": w["ended"],
"breaks": [{"break": b["id"], "start_ms": b["start"], "duration_ms": b["duration"], "scte35": b["section"] is not None}
for b in breaks if first_in(b, sp) is not None]}
try:
text = render(breaks, w)
except Refuse as e:
return fail("playlist", e.reason[0], e.reason[1])
if "pl_bytes" not in SKIP and text.encode("utf-8") != playlist:
return fail("playlist", "playlist_mismatch")
return f
def window_of(v):
return {"sequence": v["media_sequence"], "start": v["start_ms"], "segments": [(s["uri"], s["duration_ms"]) for s in v["segments"]],
"ended": v["ended"]}
def check_playlist(v, files):
"""None when a window renders what the corpus states, else why not."""
r = read_receipt(files[v["receipt"]])
c = read_cues(files[v["cues"]])
if r is None or c is None:
return "the receipt or the cues do not read"
o = v["outcome"]
try:
text = render(bind_cues(r, c), window_of(v["window"]))
except Refuse as e:
got = {"refused": e.reason[0], "index": e.reason[1]}
return None if o == got else "refused %s, the corpus states %s" % (got, o)
if "playlist" not in o or files.get(o["playlist"]) != text.encode("utf-8"):
return "renders another playlist than the corpus's %s" % o
return None
def check_playlist_verification(v, files):
files = apply_edits(files, v["edits"])
facts = verify_playlist(files[v["receipt"]], files[v["cues"]], files[v["playlist"]])
return None if facts == v["facts"] else "states %s, the corpus %s" % (facts, v["facts"])
def apply_edits(files, edits):
out = dict(files)
for e in edits:
@ -1269,6 +1523,10 @@ def corpus_files(doc):
names.add(v["outcome"]["receipt"])
for v in doc["verifications"]:
names |= {x for x in (v["receipt"], v["guide"]) if x}
for v in doc["playlists"]:
names |= {v["receipt"], v["cues"]} | ({v["outcome"]["playlist"]} if "playlist" in v["outcome"] else set())
for v in doc["playlist_verifications"]:
names |= {v["receipt"], v["cues"], v["playlist"]}
return names
@ -1290,9 +1548,25 @@ def check_all(doc, files, listing, quiet=False):
why = "broken: %r" % (e,)
if why:
fails.append("verification %s: %s" % (v["name"], why))
for v in doc["playlists"]:
try:
why = check_playlist(v, files)
except (KeyError, ValueError, TypeError) as e:
why = "broken: %r" % (e,)
if why:
fails.append("playlist %s: %s" % (v["name"], why))
for v in doc["playlist_verifications"]:
try:
why = check_playlist_verification(v, files)
except (KeyError, ValueError, TypeError) as e:
why = "broken: %r" % (e,)
if why:
fails.append("playlist verification %s: %s" % (v["name"], why))
added, reused = reasons()
seen = {v["outcome"]["refused"] for v in doc["imports"] if "refused" in v["outcome"]}
seen |= {v["facts"]["verdict"] for v in doc["verifications"]}
seen |= {v["outcome"]["refused"] for v in doc["playlists"] if "refused" in v["outcome"]}
seen |= {v["facts"]["verdict"] for v in doc["playlist_verifications"]}
for r in added + reused:
if r not in seen:
fails.append("the corpus gives no %s" % r)
@ -1349,6 +1623,21 @@ def mutations(doc, files, listing):
m("the receipt's window", lambda d, fs, ls: fs.__setitem__("sample.channel.json", fs["sample.channel.json"].replace(b'"window_end_ms": 1793300400000', b'"window_end_ms": 1793300401000')))
m("a file added", lambda d, fs, ls: (fs.__setitem__("extra.txt", b"x"), ls.add("extra.txt")))
m("a vector of a reason dropped", lambda d, fs, ls: d.__setitem__("imports", [v for v in d["imports"] if v["name"] != "window_outside"]))
pl = lambda d, n: next(v for v in d["playlists"] if v["name"] == n) # noqa: E731
plv = lambda d, n: next(v for v in d["playlist_verifications"] if v["name"] == n) # noqa: E731
m("a playlist's EXT-X-CUE-OUT duration", lambda d, fs, ls: fs.__setitem__("sample.out-in.m3u8", fs["sample.out-in.m3u8"].replace(b"CUE-OUT:90.000", b"CUE-OUT:90.001")))
m("a playlist's elapsed time", lambda d, fs, ls: fs.__setitem__("sample.inside.m3u8", fs["sample.inside.m3u8"].replace(b"ElapsedTime=40.000", b"ElapsedTime=41.000")))
m("a date range's section", lambda d, fs, ls: fs.__setitem__("sample.offset.m3u8", fs["sample.offset.m3u8"].replace(b"SCTE35-OUT=0xFC30", b"SCTE35-OUT=0xFC31")))
m("the ended window's end", lambda d, fs, ls: fs.__setitem__("sample.offset.m3u8", fs["sample.offset.m3u8"].replace(b"#EXT-X-ENDLIST\n", b"")))
m("a window's first segment", lambda d, fs, ls: pl(d, "out_in")["window"].__setitem__("start_ms", pl(d, "out_in")["window"]["start_ms"] + 10000))
m("a refusal's index", lambda d, fs, ls: pl(d, "unaligned_end")["outcome"].__setitem__("index", 9))
m("a cues file's payload", lambda d, fs, ls: fs.__setitem__("no-section.cues.json", fs["no-section.cues.json"].replace(b'"payload": "04', b'"payload": "03', 1)))
m("a playlist verification's break", lambda d, fs, ls: plv(d, "playlist_inside")["facts"]["window"]["breaks"][0].__setitem__("scte35", False))
m("a playlist verification's index", lambda d, fs, ls: plv(d, "cues_missing")["facts"].__setitem__("index", 2))
m("a target duration rounded down", lambda d, fs, ls: fs.__setitem__("sample.fractional.m3u8", fs["sample.fractional.m3u8"].replace(b"TARGETDURATION:7\n", b"TARGETDURATION:6\n")))
m("a span's milliseconds dropped", lambda d, fs, ls: fs.__setitem__("sample.fractional.m3u8", fs["sample.fractional.m3u8"].replace(b"ElapsedTime=6.006,", b"ElapsedTime=6.000,")))
m("the last instant's window a millisecond later", lambda d, fs, ls: pl(d, "last_instant")["window"].__setitem__("start_ms", pl(d, "last_instant")["window"]["start_ms"] + 1))
m("a refused URI's bound", lambda d, fs, ls: pl(d, "uri_too_long")["window"]["segments"][0].__setitem__("uri", pl(d, "uri_too_long")["window"]["segments"][0]["uri"][1:]))
return out
@ -1357,7 +1646,8 @@ def main(corpus_dir):
doc, files, listing = load(corpus_dir)
if check_all(doc, files, listing):
return 1
print("channel lineup corpus: %d imports and %d verifications hold" % (len(doc["imports"]), len(doc["verifications"])))
print("channel lineup corpus: %d imports, %d verifications, %d playlists and %d playlist verifications hold"
% (len(doc["imports"]), len(doc["verifications"]), len(doc["playlists"]), len(doc["playlist_verifications"])))
bad = 0
for name, d, fs, ls in mutations(doc, files, listing):
if not check_all(d, fs, ls, quiet=True):
@ -1367,7 +1657,10 @@ def main(corpus_dir):
checks = ["inputs", "size", "fields", "timezone", "no_stop", "asset", "duplicate", "counts", "doctype", "conflict",
"scte35", "break_duration", "duration_unknown", "gap", "overlap", "whole_seconds", "owner", "artwork",
"break_overlap", "break_outside", "window", "receipt_sig", "receipt_rules", "lineup_sig", "signer",
"describes_root", "describes_starts", "describes_assets", "describes_set", "source", "entries", "bytes"]
"describes_root", "describes_starts", "describes_assets", "describes_set", "source", "entries", "bytes",
"cues_count", "cues_break", "cues_payload", "pl_window", "pl_segments", "pl_uri_empty", "pl_uri_long",
"pl_uri_chars", "pl_uri_hash", "pl_duration_zero", "pl_duration_long", "pl_sequence", "pl_end", "aligned",
"writable_quote", "writable_line_feed", "writable_carriage_return", "pl_bytes"]
weak = 0
for c in checks:
SKIP = {c}

View file

@ -2,7 +2,8 @@
The corpus of [`extensions/channel-lineup.md`](../../extensions/channel-lineup.md) §11: a sample
linear channel as operators exchange it, imported into its `wai.video.channel` receipt and its
`WLU1` lineup, its guides, and what a verifier states of each. Every file but this one is written
`WLU1` lineup, its guides, the cue tags of windows of its Media Playlist, and what a verifier
states of each. Every file but this one is written
from fixed seeds by `wai_channel_lineup_vectors gen` (feature `channel_lineup`), and
`wai_channel_lineup_vectors verify <dir>` and `tests/channel_lineup.rs` require them byte for byte.
The second implementation is [`../channel_lineup_verify.py`](../channel_lineup_verify.py), run as
@ -23,6 +24,13 @@ three content owners, with four ad breaks:
| `sample.now-next.json`, `sample.before.now-next.json`, `sample.after.now-next.json` | now/next documents inside a break, before the schedule and after it |
| `media_rss_loop.channel.json` | the receipt of one owner's items alone, back to back |
| `aired-misreported.channel.json` | a receipt, signed, whose program-as-run its window does not give |
| `sample.cues.json` | the cues the sample's import writes (`cues.json`, §9) |
| `no-section.cues.json` | the cues of the same channel from the JSON form with brk-1's section left out: its receipt is the sample's |
| `quoted.channel.json`, `quoted.cues.json` | the receipt and cues of the same channel from the JSON form with brk-1 named `brk"1` |
| `line-feed.channel.json`, `line-feed.cues.json` | the receipt and cues of the same channel from the JSON form with brk-1 named with a line feed |
| `carriage-return.channel.json`, `carriage-return.cues.json` | the same with brk-1 named with a carriage return, which an import refuses: the operator signs that receipt itself |
| `far.channel.json`, `far.cues.json` | a channel scheduled at the end of the year 9999: a programme to 9999-12-31T23:59:59.999Z, a break of a millisecond from then, and one from 10000-01-01T00:00:00Z |
| `sample.out-in.m3u8`, `sample.inside.m3u8`, `sample.offset.m3u8`, `sample.return.m3u8`, `sample.none.m3u8`, `sample.no-section.m3u8`, `sample.fractional.m3u8`, `sample.longest-uri.m3u8`, `sample.longest-segment.m3u8`, `sample.last-sequence.m3u8`, `far.m3u8` | the Media Playlists the windows of `playlists` render, their breaks' cue tags written (§7.4) |
| `vectors.json` | the operator's key, and every vector |
`vectors.json` holds:
@ -37,7 +45,16 @@ three content owners, with four ad breaks:
- `verifications`: each with its `receipt` file, its `lineup` bytes in hexadecimal, its `guide`
file or `null`, its `edits`, and its `facts`, what §8.3 states: `verdict`, `part`, `receipt`,
`lineup`, `guide` (`format`, `from_ms`, `to_ms`, `at_ms`, and its `programmes`, each
`start_ms`, `stop_ms`, `asset_id` and `aired`), and `entry`.
`start_ms`, `stop_ms`, `asset_id` and `aired`), and `entry`;
- `playlists`: each with its `receipt` and `cues` files, its `window` (`media_sequence`,
`start_ms`, its `segments`, each `uri` and `duration_ms`, and `ended`), and its `outcome`:
`{"playlist": file}`, the playlist the window renders byte for byte, or `{"refused": reason,
"index": n}` (§7.4: the segment's index for `cue_unaligned`, the break's for `cue_unwritable`,
`null` for `playlist_malformed`);
- `playlist_verifications`: each with its `receipt`, `cues` and `playlist` files, its `edits`, and
its `facts`, what §8.4 states: `verdict`, `part`, `receipt`, `window` (`media_sequence`,
`start_ms`, `segments`, the count, `ended`, and its `breaks`, each `break`, `start_ms`,
`duration_ms` and `scte35`, whether it has a date range), and `index`.
An edit is applied to a copy of the file it names before the vector is run: `{"file", "find",
"replace"}` replaces the one occurrence of `find` (an edit whose `find` does not occur exactly

View file

@ -0,0 +1,96 @@
{
"channel_id": "sample-1",
"schedule_root": "2a0d787631e0382342f428d60f5d8c3da1733dbfdeaf2373953ca1fd5bf5e5d3",
"schedule": [
{
"asset_id": "hl-101",
"start_ms": 1793296800000,
"duration_ms": 720000,
"kind": "program"
},
{
"asset_id": "brk\r1",
"start_ms": 1793297520000,
"duration_ms": 90000,
"kind": "ad"
},
{
"asset_id": "hl-101",
"start_ms": 1793297610000,
"duration_ms": 870000,
"kind": "program"
},
{
"asset_id": "brk-2",
"start_ms": 1793298480000,
"duration_ms": 120000,
"kind": "ad"
},
{
"asset_id": "tk-7",
"start_ms": 1793298600000,
"duration_ms": 930500,
"kind": "program"
},
{
"asset_id": "brk-3",
"start_ms": 1793299530500,
"duration_ms": 60000,
"kind": "ad"
},
{
"asset_id": "tk-7",
"start_ms": 1793299590500,
"duration_ms": 809500,
"kind": "program"
},
{
"asset_id": "hl-102",
"start_ms": 1793300400000,
"duration_ms": 840000,
"kind": "program"
},
{
"asset_id": "brk-4",
"start_ms": 1793301240000,
"duration_ms": 90000,
"kind": "ad"
},
{
"asset_id": "hl-102",
"start_ms": 1793301330000,
"duration_ms": 870000,
"kind": "program"
},
{
"asset_id": "nf-1",
"start_ms": 1793302200000,
"duration_ms": 1800000,
"kind": "program"
}
],
"window_start_ms": 1793296800000,
"window_end_ms": 1793300400000,
"aired": [
0,
1,
2,
3,
4,
5,
6
],
"work_schedule_ops": 11,
"joules_micro": 0,
"energy_provenance": null,
"grant": {
"grant_hash": "0000000000000000000000000000000000000000000000000000000000000000",
"capability": "video.channel",
"joule_ceiling_micro": 18446744073709551615,
"funds_ceiling": null
},
"parent_receipt_hash": null,
"signer_pubkey": "da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a",
"signer_id": "sample-operator",
"sig": "0e34af1c40ea15e2662ec7ee0317831637a7ce81e1951dbb29519379a44ce893d272d05b84b58cbf4dbe4d574f44e3c05bc3b0ccb89eb4b9413982ef56c7a701"
}

View file

@ -0,0 +1,22 @@
[
{
"break": "brk\r1",
"start_ms": 1793297520000,
"payload": "04e9030000905f0100012800fc3025000000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000008f389a15"
},
{
"break": "brk-2",
"start_ms": 1793298480000,
"payload": "041733d0f9c0d40100011900fc3016000000000000fffff00506fe090321000000f83beb5f"
},
{
"break": "brk-3",
"start_ms": 1793299530500,
"payload": "04eb03000060ea0000012800fc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a"
},
{
"break": "brk-4",
"start_ms": 1793301240000,
"payload": "04ec030000905f0100012800fc3025000000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000959f6b5f"
}
]

View file

@ -0,0 +1,44 @@
{
"channel_id": "far",
"schedule_root": "0022cbb66de5c21d9bb6c6eebda6f5f653dfaf600e6f3c0482b620b853759138",
"schedule": [
{
"asset_id": "far-prog",
"start_ms": 253402300739999,
"duration_ms": 60000,
"kind": "program"
},
{
"asset_id": "far-1",
"start_ms": 253402300799999,
"duration_ms": 1,
"kind": "ad"
},
{
"asset_id": "far-2",
"start_ms": 253402300800000,
"duration_ms": 30001,
"kind": "ad"
}
],
"window_start_ms": 253402300739999,
"window_end_ms": 253402300830001,
"aired": [
0,
1,
2
],
"work_schedule_ops": 3,
"joules_micro": 0,
"energy_provenance": null,
"grant": {
"grant_hash": "0000000000000000000000000000000000000000000000000000000000000000",
"capability": "video.channel",
"joule_ceiling_micro": 18446744073709551615,
"funds_ceiling": null
},
"parent_receipt_hash": null,
"signer_pubkey": "da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a",
"signer_id": "sample-operator",
"sig": "26c0fb2a6cb471d6add0e3dcb359e5e37e1e9fd544255af095d2fa5b846420ab021b7ac48454d5a4f0cc58635618b024a4393b51a4a459b03b7e6bd04c3f910a"
}

View file

@ -0,0 +1,12 @@
[
{
"break": "far-1",
"start_ms": 253402300799999,
"payload": "04a0e4f10101000000011900fc3016000000000000fffff00506fe090321000000f83beb5f"
},
{
"break": "far-2",
"start_ms": 253402300800000,
"payload": "048ea34d3031750000011900fc3016000000000000fffff00506fe090321000000f83beb5f"
}
]

View file

@ -0,0 +1,8 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:1000
#EXT-X-PROGRAM-DATE-TIME:9999-12-31T23:59:49.999Z
#EXT-X-DATERANGE:ID="far-1@253402300799999",START-DATE="9999-12-31T23:59:59.999Z",PLANNED-DURATION=0.001,SCTE35-OUT=0xFC3016000000000000FFFFF00506FE090321000000F83BEB5F
#EXTINF:10.000,
seg-1000.ts

View file

@ -0,0 +1,96 @@
{
"channel_id": "sample-1",
"schedule_root": "fb34463ff9b7c9abf29716dd3aec6ca2d099f1b60cd69ca166d82f8d69f45cfa",
"schedule": [
{
"asset_id": "hl-101",
"start_ms": 1793296800000,
"duration_ms": 720000,
"kind": "program"
},
{
"asset_id": "brk\n1",
"start_ms": 1793297520000,
"duration_ms": 90000,
"kind": "ad"
},
{
"asset_id": "hl-101",
"start_ms": 1793297610000,
"duration_ms": 870000,
"kind": "program"
},
{
"asset_id": "brk-2",
"start_ms": 1793298480000,
"duration_ms": 120000,
"kind": "ad"
},
{
"asset_id": "tk-7",
"start_ms": 1793298600000,
"duration_ms": 930500,
"kind": "program"
},
{
"asset_id": "brk-3",
"start_ms": 1793299530500,
"duration_ms": 60000,
"kind": "ad"
},
{
"asset_id": "tk-7",
"start_ms": 1793299590500,
"duration_ms": 809500,
"kind": "program"
},
{
"asset_id": "hl-102",
"start_ms": 1793300400000,
"duration_ms": 840000,
"kind": "program"
},
{
"asset_id": "brk-4",
"start_ms": 1793301240000,
"duration_ms": 90000,
"kind": "ad"
},
{
"asset_id": "hl-102",
"start_ms": 1793301330000,
"duration_ms": 870000,
"kind": "program"
},
{
"asset_id": "nf-1",
"start_ms": 1793302200000,
"duration_ms": 1800000,
"kind": "program"
}
],
"window_start_ms": 1793296800000,
"window_end_ms": 1793300400000,
"aired": [
0,
1,
2,
3,
4,
5,
6
],
"work_schedule_ops": 11,
"joules_micro": 0,
"energy_provenance": null,
"grant": {
"grant_hash": "0000000000000000000000000000000000000000000000000000000000000000",
"capability": "video.channel",
"joule_ceiling_micro": 18446744073709551615,
"funds_ceiling": null
},
"parent_receipt_hash": null,
"signer_pubkey": "da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a",
"signer_id": "sample-operator",
"sig": "364c62c19d4d6d1b4b585b677603c2f3751f1aebacd7af81a271b482144bccc162de1e14af3c157fe8d5a6321bb38573d9cc0fbe70a9b87027a75d40705eee0e"
}

View file

@ -0,0 +1,22 @@
[
{
"break": "brk\n1",
"start_ms": 1793297520000,
"payload": "04e9030000905f0100012800fc3025000000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000008f389a15"
},
{
"break": "brk-2",
"start_ms": 1793298480000,
"payload": "041733d0f9c0d40100011900fc3016000000000000fffff00506fe090321000000f83beb5f"
},
{
"break": "brk-3",
"start_ms": 1793299530500,
"payload": "04eb03000060ea0000012800fc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a"
},
{
"break": "brk-4",
"start_ms": 1793301240000,
"payload": "04ec030000905f0100012800fc3025000000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000959f6b5f"
}
]

View file

@ -0,0 +1,22 @@
[
{
"break": "brk-1",
"start_ms": 1793297520000,
"payload": "04cf47598c905f010000"
},
{
"break": "brk-2",
"start_ms": 1793298480000,
"payload": "041733d0f9c0d40100011900fc3016000000000000fffff00506fe090321000000f83beb5f"
},
{
"break": "brk-3",
"start_ms": 1793299530500,
"payload": "04eb03000060ea0000012800fc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a"
},
{
"break": "brk-4",
"start_ms": 1793301240000,
"payload": "04ec030000905f0100012800fc3025000000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000959f6b5f"
}
]

View file

@ -0,0 +1,96 @@
{
"channel_id": "sample-1",
"schedule_root": "170673c8908c8a3699ff6c72a0999fbddd0d4d5106863fbfd391c84fe0822270",
"schedule": [
{
"asset_id": "hl-101",
"start_ms": 1793296800000,
"duration_ms": 720000,
"kind": "program"
},
{
"asset_id": "brk\"1",
"start_ms": 1793297520000,
"duration_ms": 90000,
"kind": "ad"
},
{
"asset_id": "hl-101",
"start_ms": 1793297610000,
"duration_ms": 870000,
"kind": "program"
},
{
"asset_id": "brk-2",
"start_ms": 1793298480000,
"duration_ms": 120000,
"kind": "ad"
},
{
"asset_id": "tk-7",
"start_ms": 1793298600000,
"duration_ms": 930500,
"kind": "program"
},
{
"asset_id": "brk-3",
"start_ms": 1793299530500,
"duration_ms": 60000,
"kind": "ad"
},
{
"asset_id": "tk-7",
"start_ms": 1793299590500,
"duration_ms": 809500,
"kind": "program"
},
{
"asset_id": "hl-102",
"start_ms": 1793300400000,
"duration_ms": 840000,
"kind": "program"
},
{
"asset_id": "brk-4",
"start_ms": 1793301240000,
"duration_ms": 90000,
"kind": "ad"
},
{
"asset_id": "hl-102",
"start_ms": 1793301330000,
"duration_ms": 870000,
"kind": "program"
},
{
"asset_id": "nf-1",
"start_ms": 1793302200000,
"duration_ms": 1800000,
"kind": "program"
}
],
"window_start_ms": 1793296800000,
"window_end_ms": 1793300400000,
"aired": [
0,
1,
2,
3,
4,
5,
6
],
"work_schedule_ops": 11,
"joules_micro": 0,
"energy_provenance": null,
"grant": {
"grant_hash": "0000000000000000000000000000000000000000000000000000000000000000",
"capability": "video.channel",
"joule_ceiling_micro": 18446744073709551615,
"funds_ceiling": null
},
"parent_receipt_hash": null,
"signer_pubkey": "da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a",
"signer_id": "sample-operator",
"sig": "e46eb54cfb9875b8ac81ad19bf0902021468b44589d522bc5e168e7337c0332b371c71a197ef2ebbf8bdbf8d7f66c851b29172f2169812bbfdcba12956d0c900"
}

View file

@ -0,0 +1,22 @@
[
{
"break": "brk\"1",
"start_ms": 1793297520000,
"payload": "04e9030000905f0100012800fc3025000000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000008f389a15"
},
{
"break": "brk-2",
"start_ms": 1793298480000,
"payload": "041733d0f9c0d40100011900fc3016000000000000fffff00506fe090321000000f83beb5f"
},
{
"break": "brk-3",
"start_ms": 1793299530500,
"payload": "04eb03000060ea0000012800fc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a"
},
{
"break": "brk-4",
"start_ms": 1793301240000,
"payload": "04ec030000905f0100012800fc3025000000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000959f6b5f"
}
]

View file

@ -0,0 +1,22 @@
[
{
"break": "brk-1",
"start_ms": 1793297520000,
"payload": "04e9030000905f0100012800fc3025000000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000008f389a15"
},
{
"break": "brk-2",
"start_ms": 1793298480000,
"payload": "041733d0f9c0d40100011900fc3016000000000000fffff00506fe090321000000f83beb5f"
},
{
"break": "brk-3",
"start_ms": 1793299530500,
"payload": "04eb03000060ea0000012800fc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a"
},
{
"break": "brk-4",
"start_ms": 1793301240000,
"payload": "04ec030000905f0100012800fc3025000000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000959f6b5f"
}
]

View file

@ -0,0 +1,57 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:7
#EXT-X-MEDIA-SEQUENCE:700
#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:11:53.994Z
#EXT-X-DATERANGE:ID="brk-1@1793297520000",START-DATE="2026-10-29T18:12:00.000Z",PLANNED-DURATION=90.000,SCTE35-OUT=0xFC3025000000000000FFFFF01405000003E97FEFFE03DCC500FE007B98A00001000000008F389A15
#EXTINF:6.006,
seg-700.ts
#EXT-X-CUE-OUT:90.000
#EXTINF:6.006,
seg-701.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=6.006,Duration=90.000
#EXTINF:6.006,
seg-702.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=12.012,Duration=90.000
#EXTINF:6.006,
seg-703.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=18.018,Duration=90.000
#EXTINF:6.006,
seg-704.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=24.024,Duration=90.000
#EXTINF:6.006,
seg-705.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=30.030,Duration=90.000
#EXTINF:6.006,
seg-706.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=36.036,Duration=90.000
#EXTINF:6.006,
seg-707.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=42.042,Duration=90.000
#EXTINF:6.006,
seg-708.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=48.048,Duration=90.000
#EXTINF:6.006,
seg-709.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=54.054,Duration=90.000
#EXTINF:6.006,
seg-710.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=60.060,Duration=90.000
#EXTINF:6.006,
seg-711.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=66.066,Duration=90.000
#EXTINF:6.006,
seg-712.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=72.072,Duration=90.000
#EXTINF:6.006,
seg-713.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=78.078,Duration=90.000
#EXTINF:6.006,
seg-714.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=84.084,Duration=90.000
#EXTINF:5.916,
seg-715.ts
#EXT-X-DATERANGE:ID="brk-1@1793297520000",START-DATE="2026-10-29T18:12:00.000Z",DURATION=90.000
#EXT-X-CUE-IN
#EXTINF:6.006,
seg-716.ts

View file

@ -0,0 +1,39 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:171
#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:28:30.000Z
#EXT-X-DATERANGE:ID="brk-2@1793298480000",START-DATE="2026-10-29T18:28:00.000Z",PLANNED-DURATION=120.000,SCTE35-OUT=0xFC3016000000000000FFFFF00506FE090321000000F83BEB5F
#EXT-X-CUE-OUT-CONT:ElapsedTime=30.000,Duration=120.000
#EXTINF:10.000,
seg-171.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=40.000,Duration=120.000
#EXTINF:10.000,
seg-172.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=50.000,Duration=120.000
#EXTINF:10.000,
seg-173.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=60.000,Duration=120.000
#EXTINF:10.000,
seg-174.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=70.000,Duration=120.000
#EXTINF:10.000,
seg-175.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=80.000,Duration=120.000
#EXTINF:10.000,
seg-176.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=90.000,Duration=120.000
#EXTINF:10.000,
seg-177.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=100.000,Duration=120.000
#EXTINF:10.000,
seg-178.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=110.000,Duration=120.000
#EXTINF:10.000,
seg-179.ts
#EXT-X-DATERANGE:ID="brk-2@1793298480000",START-DATE="2026-10-29T18:28:00.000Z",DURATION=120.000
#EXT-X-CUE-IN
#EXTINF:10.000,
seg-180.ts
#EXTINF:10.000,
seg-181.ts

View file

@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:9007199254740991
#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:20:00.000Z
#EXTINF:10.000,
seg-9007199254740991.ts

View file

@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:4294968
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PROGRAM-DATE-TIME:2026-10-30T18:00:00.000Z
#EXTINF:4294967.295,
seg-0.ts

View file

@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:120
#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:20:00.000Z
#EXTINF:10.000,
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.ts

View file

@ -0,0 +1,41 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:70
#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:11:40.000Z
#EXTINF:10.000,
seg-70.ts
#EXTINF:10.000,
seg-71.ts
#EXT-X-CUE-OUT:90.000
#EXTINF:10.000,
seg-72.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=10.000,Duration=90.000
#EXTINF:10.000,
seg-73.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=20.000,Duration=90.000
#EXTINF:10.000,
seg-74.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=30.000,Duration=90.000
#EXTINF:10.000,
seg-75.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=40.000,Duration=90.000
#EXTINF:10.000,
seg-76.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=50.000,Duration=90.000
#EXTINF:10.000,
seg-77.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=60.000,Duration=90.000
#EXTINF:10.000,
seg-78.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=70.000,Duration=90.000
#EXTINF:10.000,
seg-79.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=80.000,Duration=90.000
#EXTINF:10.000,
seg-80.ts
#EXT-X-CUE-IN
#EXTINF:10.000,
seg-81.ts
#EXTINF:10.000,
seg-82.ts

View file

@ -0,0 +1,11 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:120
#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:20:00.000Z
#EXTINF:10.000,
seg-120.ts
#EXTINF:10.000,
seg-121.ts
#EXTINF:10.000,
seg-122.ts

View file

@ -0,0 +1,45 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:452
#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:45:24.500Z
#EXT-X-DATERANGE:ID="brk-3@1793299530500",START-DATE="2026-10-29T18:45:30.500Z",PLANNED-DURATION=60.000,SCTE35-OUT=0xFC3025000000000000FFFFF01405000003EB7FEFFE0EA5C568FE005265C000010000000045B18E8A
#EXTINF:6.000,
seg-452.ts
#EXT-X-CUE-OUT:60.000
#EXTINF:6.000,
seg-453.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=6.000,Duration=60.000
#EXTINF:6.000,
seg-454.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=12.000,Duration=60.000
#EXTINF:6.000,
seg-455.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=18.000,Duration=60.000
#EXTINF:6.000,
seg-456.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=24.000,Duration=60.000
#EXTINF:6.000,
seg-457.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=30.000,Duration=60.000
#EXTINF:6.000,
seg-458.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=36.000,Duration=60.000
#EXTINF:6.000,
seg-459.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=42.000,Duration=60.000
#EXTINF:6.000,
seg-460.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=48.000,Duration=60.000
#EXTINF:6.000,
seg-461.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=54.000,Duration=60.000
#EXTINF:6.000,
seg-462.ts
#EXT-X-DATERANGE:ID="brk-3@1793299530500",START-DATE="2026-10-29T18:45:30.500Z",DURATION=60.000
#EXT-X-CUE-IN
#EXTINF:6.000,
seg-463.ts
#EXTINF:6.000,
seg-464.ts
#EXT-X-ENDLIST

View file

@ -0,0 +1,43 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:70
#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:11:40.000Z
#EXTINF:10.000,
seg-70.ts
#EXT-X-DATERANGE:ID="brk-1@1793297520000",START-DATE="2026-10-29T18:12:00.000Z",PLANNED-DURATION=90.000,SCTE35-OUT=0xFC3025000000000000FFFFF01405000003E97FEFFE03DCC500FE007B98A00001000000008F389A15
#EXTINF:10.000,
seg-71.ts
#EXT-X-CUE-OUT:90.000
#EXTINF:10.000,
seg-72.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=10.000,Duration=90.000
#EXTINF:10.000,
seg-73.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=20.000,Duration=90.000
#EXTINF:10.000,
seg-74.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=30.000,Duration=90.000
#EXTINF:10.000,
seg-75.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=40.000,Duration=90.000
#EXTINF:10.000,
seg-76.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=50.000,Duration=90.000
#EXTINF:10.000,
seg-77.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=60.000,Duration=90.000
#EXTINF:10.000,
seg-78.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=70.000,Duration=90.000
#EXTINF:10.000,
seg-79.ts
#EXT-X-CUE-OUT-CONT:ElapsedTime=80.000,Duration=90.000
#EXTINF:10.000,
seg-80.ts
#EXT-X-DATERANGE:ID="brk-1@1793297520000",START-DATE="2026-10-29T18:12:00.000Z",DURATION=90.000
#EXT-X-CUE-IN
#EXTINF:10.000,
seg-81.ts
#EXTINF:10.000,
seg-82.ts

View file

@ -0,0 +1,12 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:81
#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:13:30.000Z
#EXT-X-DATERANGE:ID="brk-1@1793297520000",START-DATE="2026-10-29T18:12:00.000Z",PLANNED-DURATION=90.000,SCTE35-OUT=0xFC3025000000000000FFFFF01405000003E97FEFFE03DCC500FE007B98A00001000000008F389A15
#EXT-X-DATERANGE:ID="brk-1@1793297520000",START-DATE="2026-10-29T18:12:00.000Z",DURATION=90.000
#EXT-X-CUE-IN
#EXTINF:10.000,
seg-81.ts
#EXTINF:10.000,
seg-82.ts

File diff suppressed because it is too large Load diff

View file

@ -27,7 +27,10 @@ else is here:
inclusion proof (§6.1, `inclusion_proof`), which the receipt ledger's verifier checks a job
source's outputs with; and each third-party hop record (§6.2): its members and forms, no key,
header or URL query in it, its signature under its own domain, and its binding to its attempt,
its candidate and what its hop's receipt names.
its candidate and what its hop's receipt names;
- each delivery-decision record (§6.3): its members and forms, its signature under its own
domain, its binding to its delivery's documents, the routing of §4 over them recomputed, the
route and the key each other route was passed over by, its parent and its chain.
For every vector it requires the outcome the corpus states; of the corpus, that every reason of
§8 and every code it reuses appears in it, that §8 names every reason it raises, and that no
@ -650,8 +653,13 @@ def verify(doc):
def verify_steps(doc):
has_third = isinstance(doc, dict) and "third_party" in doc
members = ["request", "pricebook", "policy", "record", "receipts", "keys"] + (["third_party"] if has_third else [])
has_deliveries = isinstance(doc, dict) and "deliveries" in doc
members = ["request", "pricebook", "policy", "record", "receipts", "keys"] + (["third_party"] if has_third else []) \
+ (["deliveries"] if has_deliveries else [])
if not exactly(doc, members) \
or (has_deliveries and not (isinstance(doc["deliveries"], list) and all(
exactly(x, ["request", "pricebook", "policy", "decision"]) and all(isinstance(x[k], str) for k in x)
for x in doc["deliveries"]))) \
or (has_third and not (isinstance(doc["third_party"], list) and all(isinstance(x, str) for x in doc["third_party"]))) \
or (has_third and doc["receipts"] is None) \
or not all(isinstance(doc[k], str) for k in ("request", "pricebook", "policy", "record")) \
@ -736,6 +744,8 @@ def verify_steps(doc):
unmetered = hop_receipts(rec, doc["receipts"], doc["keys"])
# 9. The third-party hop records.
third_party = third_party_records(rec, doc["third_party"], doc["receipts"]) if has_third else None
# 10. The delivery decisions.
deliveries = delivery_decisions(rec, doc["deliveries"]) if has_deliveries else None
served_attempt = atts[-1] if last_served else None
billed_hop = next((a["hop"] for a in atts if a["billed"]), None)
if deadline is None or served_attempt is None:
@ -761,6 +771,8 @@ def verify_steps(doc):
}
if third_party is not None:
accepted["third_party"] = third_party
if deliveries is not None:
accepted["deliveries"] = deliveries
return accepted
@ -1059,6 +1071,155 @@ def third_party_records(rec, texts, receipt_texts):
return out
# ---------------------------------------------------------------- §6.3 a delivery's decision
DELIVERY_DOMAIN = b"wai:route-delivery-decision\x01"
DELIVERY = ["kind", "version", "job", "parent", "seq", "prev", "objects", "delivery", "operation", "capability", "fallback",
"quantities", "request", "policy", "pricebook", "candidates", "order", "decision", "route", "passed", "decided",
"signer", "sig"]
PASSED_KEYS = ["targets", "price", "ttfb", "energy", "quality", "endpoint"] # §4.4
def delivery_form(d):
"""§6.3: the members, each of its own form. Relations between them are step 10's."""
if not exactly(d, DELIVERY):
return False
o = d["objects"]
return (d["version"] == 1 and is_token(d["job"]) and is_hex(d["parent"], 64) and is_int(d["seq"], 1)
and null_or(d["prev"], lambda x: is_hex(x, 64))
and exactly(o, ["count", "root"]) and is_int(o["count"], 1) and is_hex(o["root"], 64)
and is_token(d["delivery"]) and isinstance(d["operation"], str) and OPERATION.match(d["operation"]) is not None
and is_cap(d["capability"]) and null_or(d["fallback"], is_cap) and quantities_ok(d["quantities"])
and is_hex(d["request"], 64)
and exactly(d["policy"], ["name", "version", "digest"]) and is_token(d["policy"]["name"])
and is_int(d["policy"]["version"], 1) and is_hex(d["policy"]["digest"], 64)
and exactly(d["pricebook"], ["digest", "currency", "issued"]) and is_hex(d["pricebook"]["digest"], 64)
and isinstance(d["pricebook"]["currency"], str) and CURRENCY.match(d["pricebook"]["currency"]) is not None
and is_int(d["pricebook"]["issued"])
and isinstance(d["candidates"], list) and len(d["candidates"]) <= 1024 and all(candidate_form(c, 2) for c in d["candidates"])
and isinstance(d["order"], list) and len(d["order"]) <= 1024 and all(is_token(x) for x in d["order"])
and exactly(d["decision"], ["decided_by", "runner_up"]) and d["decision"]["decided_by"] in DECIDED_BY
and null_or(d["decision"]["runner_up"], is_token)
and null_or(d["route"], is_token)
and isinstance(d["passed"], list) and len(d["passed"]) <= 1023
and all(exactly(x, ["endpoint", "key"]) and is_token(x["endpoint"]) and x["key"] in PASSED_KEYS for x in d["passed"])
and is_int(d["decided"]) and is_hex(d["signer"], 64) and is_hex(d["sig"], 128))
def passed_over(req, pol, cands):
"""§6.3 `passed`: each survivor after the first, in the order, with the first key of the key
list on which it and the first differ."""
survivors = sorted((c for c in cands if c["rank"] is not None), key=lambda c: c["rank"])
if not survivors:
return []
out = []
for c in survivors[1:]:
key = next(k for k in key_list(req, pol) if compare(req, k, survivors[0], c))
out.append({"endpoint": c["endpoint"], "key": key})
return out
def delivered_by(rec):
"""What the record's served attempt delivered: (count, root, end), or None for a record of
version 1 and one no hop served."""
atts = rec["attempts"]
if not atts or atts[-1]["outcome"] != "served":
return None
a = atts[-1]
if "delivered" in a:
return a["delivered"]["count"], a["delivered"]["root"], a["end"]
if "outputs" in a:
return len(a["outputs"]), commitment(a["outputs"]), a["end"]
return None
def delivery_decisions(rec, entries):
"""§7.2 step 10: a job's decisions, from the first and in order."""
parent = delivered_by(rec)
out, before, seen = [], None, set()
for i, t in enumerate(entries):
place = ["deliveries", i]
req = read(t["request"], request_ok, "request_invalid", place + ["request"])
book = read(t["pricebook"], pricebook_ok, "pricebook_invalid", place + ["pricebook"])
pol = read(t["policy"], policy_ok, "policy_invalid", place + ["policy"])
d = read_document(t["decision"])
at = lambda m: place + ["decision", m] # noqa: E731
if not isinstance(d, dict) or d.get("kind") != "wai-route-delivery-decision":
raise Refuse("malformed", place + ["decision"])
if on("dd-version") and is_int(d.get("version")) and d["version"] != 1:
raise Refuse("unknown_version", at("version"))
if on("dd-form") and not delivery_form(d):
raise Refuse("delivery_invalid", place + ["decision"])
if on("dd-signature"):
body = {k: v for k, v in d.items() if k != "sig"}
bad = ed_verify(bytes.fromhex(d["signer"]), DELIVERY_DOMAIN + canonical(body).encode("utf-8"), bytes.fromhex(d["sig"]))
if bad:
raise Refuse(bad, at("sig"))
# Its documents.
for m, want in (("delivery", req["job"]), ("operation", req["operation"]), ("capability", req["capability"]),
("fallback", req["fallback"]), ("quantities", req["quantities"]), ("request", digest(req))):
if on("dd-request-" + m) and not same(d[m], want):
raise Refuse("request_mismatch", at(m))
for m, want in (("name", pol["name"]), ("version", pol["version"]), ("digest", digest(pol))):
if on("dd-policy-" + m) and not same(d["policy"][m], want):
raise Refuse("policy_mismatch", at("policy"))
if on("dd-pricebook-version") and book_version(book) != 2:
raise Refuse("pricebook_mismatch", at("pricebook"))
for m, want in (("digest", digest(book)), ("currency", book["currency"]), ("issued", book["issued"])):
if on("dd-pricebook-" + m) and not same(d["pricebook"][m], want):
raise Refuse("pricebook_mismatch", at("pricebook"))
# §4 over them.
cands, order, decision, _ = route(req, book, pol)
stated = d["candidates"]
for j in range(max(len(stated), len(cands))):
here = place + ["decision", "candidates", j]
if j >= len(stated) or j >= len(cands):
raise Refuse("candidate_mismatch", here)
s_, c = stated[j], cands[j]
if on("dd-candidates") and not same({k: v for k, v in s_.items() if k not in ("removed", "rank")},
{k: v for k, v in c.items() if k not in ("removed", "rank")}):
raise Refuse("candidate_mismatch", here)
if on("dd-removals") and not same(s_["removed"], c["removed"]):
raise Refuse("removal_mismatch", here)
if on("dd-ranks") and not same(s_["rank"], c["rank"]):
raise Refuse("rank_mismatch", here)
if on("dd-order") and not same(d["order"], order):
raise Refuse("rank_mismatch", at("order"))
if on("dd-decision") and not same(d["decision"], decision):
raise Refuse("decision_mismatch", at("decision"))
# Its route.
route_ = order[0] if order else None
if on("dd-route") and not same(d["route"], route_):
raise Refuse("delivery_route_mismatch", at("route"))
passed = passed_over(req, pol, cands)
if on("dd-passed") and not same(d["passed"], passed):
raise Refuse("delivery_route_mismatch", at("passed"))
# Its parent.
for m in ("signer", "job"):
if on("dd-parent-" + m) and d[m] != rec[m]:
raise Refuse("delivery_parent_mismatch", at(m))
if on("dd-parent") and d["parent"] != digest(rec):
raise Refuse("delivery_parent_mismatch", at("parent"))
if parent is None:
raise Refuse("delivery_parent_mismatch", at("objects"))
count, root, served_end = parent
if on("dd-objects") and not same(d["objects"], {"count": count, "root": root}):
raise Refuse("delivery_parent_mismatch", at("objects"))
# Its chain.
if on("dd-seq") and d["seq"] != i + 1:
raise Refuse("delivery_chain_broken", at("seq"))
if on("dd-prev") and d["prev"] != (before[0] if before else None):
raise Refuse("delivery_chain_broken", at("prev"))
if on("dd-decided") and (d["decided"] < served_end or (before is not None and d["decided"] < before[1])):
raise Refuse("delivery_chain_broken", at("decided"))
if on("dd-once") and req["job"] in seen:
raise Refuse("delivery_chain_broken", at("delivery"))
seen.add(req["job"])
before = (digest(d), d["decided"])
out.append({"seq": i + 1, "delivery": req["job"], "digest": digest(d), "route": route_, "decision": decision, "passed": passed})
return out
# ---------------------------------------------------------------- the corpus
def check(doc, quiet=False):
@ -1144,6 +1305,16 @@ def mutations(doc):
copy_with("its stated third-party digest changed", k, lambda x: x["outcome"]["third_party"][0].__setitem__("digest", "00" * 32))
copy_with("a record's body named by another digest", k, lambda x: x["document"]["third_party"].__setitem__(
0, x["document"]["third_party"][0].replace('"bytes":', '"bytes":1', 1)))
if "deliveries" in o and o["deliveries"]:
copy_with("its stated route changed", k, lambda x: x["outcome"]["deliveries"][0].__setitem__("route", "relay.us" if o["deliveries"][0]["route"] != "relay.us" else None))
copy_with("its stated passed routes dropped", k, lambda x: x["outcome"]["deliveries"][0].__setitem__("passed", [] if o["deliveries"][0]["passed"] else [{"endpoint": "x", "key": "price"}]))
copy_with("its stated decision's digest changed", k, lambda x: x["outcome"]["deliveries"][0].__setitem__("digest", "00" * 32))
copy_with("its stated delivery's seq changed", k, lambda x: x["outcome"]["deliveries"][-1].__setitem__("seq", 7))
copy_with("its last decision dropped", k, lambda x: x["document"]["deliveries"].pop())
copy_with("a decision's text another", k, lambda x: x["document"]["deliveries"][0].__setitem__(
"decision", x["document"]["deliveries"][0]["decision"].replace('"decided":', '"decided":1', 1)))
if "deliveries" in o and not o["deliveries"]:
copy_with("its stated deliveries dropped", k, lambda x: x["outcome"].pop("deliveries"))
copy_with("its stated deadline changed", k, lambda x: x["outcome"].__setitem__("deadline", "met" if o["deadline"] != "met" else "missed"))
copy_with("its stated order reversed", k, lambda x: x["outcome"].__setitem__("order", list(reversed(o["order"])) + ["x"]))
copy_with("its stated decision changed", k, lambda x: x["outcome"]["decision"].__setitem__("decided_by", "price" if o["decision"]["decided_by"] != "price" else "ttfb"))
@ -1169,7 +1340,13 @@ CHECKS = ["request_invalid", "pricebook_invalid", "policy_invalid", "version", "
"tp-most-exchanges", "tp-signer", "tp-job", "tp-hop", "tp-one-a-hop", "tp-endpoint",
"tp-receipt", "tp-outcome", "tp-error", "tp-provider", "tp-unit", "tp-start", "tp-end", "tp-root", "tp-count",
"limit-policy", "limit-unit", "limit-region", "limit-residency", "limit-training", "limit-retention", "limit-ga",
"limit-consent", "limit-unit-price", "limit-spend"]
"limit-consent", "limit-unit-price", "limit-spend",
"dd-version", "dd-form", "dd-signature", "dd-request-delivery", "dd-request-operation", "dd-request-capability",
"dd-request-fallback", "dd-request-quantities", "dd-request-request", "dd-policy-name", "dd-policy-version",
"dd-policy-digest", "dd-pricebook-version", "dd-pricebook-digest", "dd-pricebook-currency", "dd-pricebook-issued",
"dd-candidates", "dd-removals",
"dd-ranks", "dd-order", "dd-decision", "dd-route", "dd-passed", "dd-parent-signer", "dd-parent-job", "dd-parent",
"dd-objects", "dd-seq", "dd-prev", "dd-decided", "dd-once"]
def main(corpus_dir):

View file

@ -22,7 +22,11 @@ for the control that pairs one with a price book of version 1: the served hop's
by their count and root, to which its receipt is held, 5,000 of them in one record the size of
the others. Then those of the third-party hop record (§6.2), whose names begin `third_party_`:
each document carries the records beside the receipts, under route records of versions 2 and
3, with each rule of §6.2 broken once.
3, with each rule of §6.2 broken once. Then those of the delivery-decision record (§6.3), whose
names begin `delivery_`: each document carries, in `deliveries`, a job's decisions with each
delivery's own request, price book of version 2 and policy, under route records of versions 2
and 3. The routes are priced per gigabyte for the operation `http.export`, and each rule of §6.3
is broken once.
```sh
# wai-rs: regenerates byte for byte, and every vector gets its outcome

File diff suppressed because one or more lines are too long

View file

@ -4039,6 +4039,13 @@
"section": "§6",
"status": "defined"
},
{
"name": "wai-route-delivery-decision",
"kind": "document-kind",
"document": "extensions/route-record.md",
"section": "§6.3",
"status": "defined"
},
{
"name": "wai-route-policy",
"kind": "document-kind",

View file

@ -7,12 +7,15 @@
//! description, artwork by content hash, where it may be shown and its rights holder, and where
//! each programme of the guide begins, bound to the schedule by its root. The **guide** — an
//! XMLTV document ([`xmltv_guide`]) or a now/next document ([`now_next`]) — is rendered from the
//! receipt and the lineup and names both, so [`verify`] recomputes it and compares.
//! receipt and the lineup and names both, so [`verify`] recomputes it and compares. The cue
//! tags of a Media Playlist of the channel ([`cue_tags`]) are rendered from the receipt and the
//! import's cues, so [`cue_tags::verify_playlist`] recomputes them and compares too.
//!
//! Every function here is a pure function of the bytes it is given: no I/O, no clock and no
//! floating point. The corpus of §11 is [`corpus`].
pub mod corpus;
pub mod cue_tags;
pub mod import;
use ed25519_dalek::{Signer, SigningKey};
@ -86,6 +89,12 @@ pub enum Reason {
GuideSourceMismatch,
GuideEntryUnscheduled,
GuideMismatch,
CuesMalformed,
CuesMismatch,
PlaylistMalformed,
CueUnaligned,
CueUnwritable,
PlaylistMismatch,
BadKey,
BadSignature,
}
@ -123,6 +132,12 @@ impl Reason {
Reason::GuideSourceMismatch,
Reason::GuideEntryUnscheduled,
Reason::GuideMismatch,
Reason::CuesMalformed,
Reason::CuesMismatch,
Reason::PlaylistMalformed,
Reason::CueUnaligned,
Reason::CueUnwritable,
Reason::PlaylistMismatch,
];
/// live-events' codes a verification also gives (§10).
pub const REUSED: &'static [Reason] = &[Reason::BadKey, Reason::BadSignature];
@ -160,6 +175,12 @@ impl Reason {
Reason::GuideSourceMismatch => "guide_source_mismatch",
Reason::GuideEntryUnscheduled => "guide_entry_unscheduled",
Reason::GuideMismatch => "guide_mismatch",
Reason::CuesMalformed => "cues_malformed",
Reason::CuesMismatch => "cues_mismatch",
Reason::PlaylistMalformed => "playlist_malformed",
Reason::CueUnaligned => "cue_unaligned",
Reason::CueUnwritable => "cue_unwritable",
Reason::PlaylistMismatch => "playlist_mismatch",
Reason::BadKey => "bad_key",
Reason::BadSignature => "bad_signature",
}

View file

@ -15,7 +15,8 @@ use ed25519_dalek::{Signer, SigningKey};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use super::import::{import, Feeds, Imported};
use super::cue_tags::{self, Segment, Window, WINDOW_ENDS_BEFORE};
use super::import::{break_cue, import, Cue, Feeds, Imported};
use super::{hex, now_next, receipt_text, verification_json, verify as verify_all, xmltv_guide, Lineup, Reason};
/// 2026-10-29T18:00:00Z, the sample's first programme.
@ -543,6 +544,192 @@ pub fn run_verification(v: &Value, files: &BTreeMap<String, Vec<u8>>) -> Result<
Ok(verification_json(&verify_all(receipt, &lineup, guide)))
}
// ---------------------------------------------------------------------------------------
// The Media Playlist's cue tags (§7.4, §8.4)
// ---------------------------------------------------------------------------------------
/// A window of segments `seg-<n>.ts`, numbered from its media sequence number.
fn window(seq: u64, start: u64, durations: &[u64], ended: bool) -> Window {
let segments = durations.iter().enumerate().map(|(k, &d)| Segment { uri: format!("seg-{}.ts", seq + k as u64), duration_ms: d }).collect();
Window { media_sequence: seq, start_ms: start, segments, ended }
}
/// A window of one ten-second segment whose URI is `uri`.
fn one_uri(seq: u64, start: u64, uri: &str) -> Window {
Window { media_sequence: seq, start_ms: start, segments: vec![Segment { uri: uri.into(), duration_ms: 10_000 }], ended: false }
}
/// brk-1 in segments of 6.006 seconds: the last segment before it, fourteen in it and one of
/// 5.916 seconds that ends at its return, then the return.
fn fractional() -> Vec<u64> {
let mut d = vec![6_006u64; 15];
d.push(5_916);
d.push(6_006);
d
}
/// The far channel's receipt and cues: a programme that ends at the last instant a date of four
/// digits of year writes (9999-12-31T23:59:59.999Z), far-1, a break of one millisecond from that
/// instant, and far-2, of 30.001 seconds from 10000-01-01T00:00:00Z; each break with brk-2's
/// section.
fn far(grant: &crate::quantum_ops::GrantRef) -> (crate::wai_video_channel::ChannelReceipt, Vec<Cue>) {
use crate::wai_video_channel::{ChannelReceipt, EntryKind, ScheduleEntry};
let e = WINDOW_ENDS_BEFORE;
let section = breaks()[1].3.clone();
let entry = |id: &str, start_ms: u64, duration_ms: u64, kind: EntryKind| ScheduleEntry { asset_id: id.into(), start_ms, duration_ms, kind };
let schedule = vec![entry("far-prog", e - 60_001, 60_000, EntryKind::Program), entry("far-1", e - 1, 1, EntryKind::Ad), entry("far-2", e, 30_001, EntryKind::Ad)];
let r = ChannelReceipt::seal(&operator(), "sample-operator", "far", schedule, e - 60_001, e + 30_001, 0, None, grant.clone(), None);
let cues = [("far-1", e - 1, 1), ("far-2", e, 30_001)].iter().map(|&(id, start_ms, d)| Cue { break_id: id.into(), start_ms, payload: break_cue(id, d, Some(&section)) }).collect();
(r, cues)
}
/// The same receipt and cues with brk-1's id `from` named `to`, signed again.
fn renamed(r: &crate::wai_video_channel::ChannelReceipt, cues: &[Cue], from: &str, to: &str) -> (crate::wai_video_channel::ChannelReceipt, Vec<Cue>) {
let mut schedule = r.schedule.clone();
let e = schedule.iter_mut().find(|e| e.asset_id == from).expect("the break");
e.asset_id = to.into();
let (duration, section) = (e.duration_ms, breaks()[0].3.clone());
let sealed = crate::wai_video_channel::ChannelReceipt::seal(
&operator(),
r.signer_id.clone(),
r.channel_id.clone(),
schedule,
r.window_start_ms,
r.window_end_ms,
r.joules_micro,
r.energy_provenance.clone(),
r.grant.clone(),
r.parent_receipt_hash,
);
let cues = cues.iter().map(|c| if c.break_id == from { Cue { break_id: to.into(), payload: break_cue(to, duration, Some(&section)), ..c.clone() } } else { c.clone() }).collect();
(sealed, cues)
}
/// A playlist vector: its name, what it shows, the receipt and cues files, the window, and the
/// playlist file it renders or the refusal it was written for, with its index.
type PlaylistVector = (&'static str, &'static str, &'static str, &'static str, Window, Result<&'static str, (Reason, Option<usize>)>);
/// Each playlist vector (§7.4).
fn playlist_vectors() -> Vec<PlaylistVector> {
use Reason::*;
let (rc, cues) = ("sample.channel.json", "sample.cues.json");
let ten = |n: usize| vec![10_000u64; n];
let mut late_end = ten(10);
late_end.push(15_000);
let brk_1 = T0 + 12 * MIN;
let day_after = T0 + 24 * 60 * MIN;
let last = WINDOW_ENDS_BEFORE - 1;
let (fr, fc) = ("far.channel.json", "far.cues.json");
let longest = "a".repeat(2_045) + ".ts";
vec![
("out_in", "brk-1 (a splice_insert() section) from two segments before it to one after its return: its date range announced on the last segment before it, EXT-X-CUE-OUT on its first, EXT-X-CUE-OUT-CONT on each later one, EXT-X-CUE-IN and the range's end on its return", rc, cues, window(70, T0 + 11 * MIN + 40_000, &ten(13), false), Ok("sample.out-in.m3u8")),
("inside", "a live window that begins 30 seconds into brk-2 (a time_signal() section): the date range on its first segment, which carries EXT-X-CUE-OUT-CONT", rc, cues, window(171, T0 + 28 * MIN + 30_000, &ten(11), false), Ok("sample.inside.m3u8")),
("offset", "brk-3, which begins half a second past a whole second, in six-second segments cut at its start and its end, the window ended", rc, cues, window(452, T0 + 45 * MIN + 24_500, &[6_000; 13], true), Ok("sample.offset.m3u8")),
("return", "a window that begins at brk-1's return: EXT-X-CUE-IN on its first segment, and the whole date range before it", rc, cues, window(81, T0 + 13 * MIN + 30_000, &ten(2), false), Ok("sample.return.m3u8")),
("none", "a window no break is in: no cue tag and no date range", rc, cues, window(120, T0 + 20 * MIN, &ten(3), false), Ok("sample.none.m3u8")),
("no_section", "brk-1 from the JSON form with no section: the same receipt, and the cue tags with no date range", rc, "no-section.cues.json", window(70, T0 + 11 * MIN + 40_000, &ten(13), false), Ok("sample.no-section.m3u8")),
("unaligned_start", "segments cut five seconds off brk-1's start", rc, cues, window(70, T0 + 11 * MIN + 45_000, &ten(13), false), Err((CueUnaligned, Some(1)))),
("unaligned_end", "a segment that runs across brk-1's return", rc, cues, window(70, T0 + 11 * MIN + 40_000, &late_end, false), Err((CueUnaligned, Some(10)))),
("unwritable", "brk-1 named with a double quote, which no date range's ID can hold", "quoted.channel.json", "quoted.cues.json", window(70, T0 + 11 * MIN + 40_000, &ten(13), false), Err((CueUnwritable, Some(0)))),
("window_invalid", "a segment of no duration", rc, cues, window(70, T0 + 11 * MIN + 40_000, &[10_000, 0, 10_000], false), Err((PlaylistMalformed, None))),
("fractional", "brk-1 in segments of 6.006 seconds and one of 5.916 that ends at its return: EXTINF and ElapsedTime to the millisecond, and a TARGETDURATION of 7, 6.006 rounded up", rc, cues, window(700, brk_1 - 6_006, &fractional(), false), Ok("sample.fractional.m3u8")),
("longest_uri", "a segment URI of 2 048 bytes", rc, cues, one_uri(120, T0 + 20 * MIN, &longest), Ok("sample.longest-uri.m3u8")),
("longest_segment", "a segment of 2^32 − 1 milliseconds, the day after the schedule: a TARGETDURATION of 4294968, its duration rounded up", rc, cues, window(0, day_after, &[u64::from(u32::MAX)], false), Ok("sample.longest-segment.m3u8")),
("last_sequence", "a media sequence number of 2^53 − 1", rc, cues, window(9_007_199_254_740_991, T0 + 20 * MIN, &ten(1), false), Ok("sample.last-sequence.m3u8")),
("last_instant", "a window that ends at 9999-12-31T23:59:59.999Z, where far-1 starts: far-1's date range on its last segment, dated in the year 9999, and far-2, a millisecond later, not in it", fr, fc, window(1_000, last - 10_000, &ten(1), false), Ok("far.m3u8")),
("unwritable_line_feed", "brk-1 named with a line feed, which no quoted-string can hold", "line-feed.channel.json", "line-feed.cues.json", window(70, T0 + 11 * MIN + 40_000, &ten(13), false), Err((CueUnwritable, Some(0)))),
("unwritable_carriage_return", "brk-1 named with a carriage return, which no quoted-string can hold, in a receipt the operator signed (an import refuses the id)", "carriage-return.channel.json", "carriage-return.cues.json", window(70, T0 + 11 * MIN + 40_000, &ten(13), false), Err((CueUnwritable, Some(0)))),
("past_the_last_instant", "the same window and a segment of a millisecond after it, which ends at 10000-01-01T00:00:00Z and would hold far-2's last segment before it, dated in the year 10000", fr, fc, window(1_000, last - 10_000, &[10_000, 1], false), Err((PlaylistMalformed, None))),
("no_segment", "a window of no segment", rc, cues, window(120, T0 + 20 * MIN, &[], false), Err((PlaylistMalformed, None))),
("uri_empty", "a segment URI of no byte", rc, cues, one_uri(120, T0 + 20 * MIN, ""), Err((PlaylistMalformed, None))),
("uri_too_long", "a segment URI of 2 049 bytes", rc, cues, one_uri(120, T0 + 20 * MIN, &format!("a{longest}")), Err((PlaylistMalformed, None))),
("uri_with_a_space", "a segment URI holding a space, below !", rc, cues, one_uri(120, T0 + 20 * MIN, "seg 120.ts"), Err((PlaylistMalformed, None))),
("uri_not_ascii", "a segment URI holding a letter outside ASCII", rc, cues, one_uri(120, T0 + 20 * MIN, "seg-120-\u{e9}.ts"), Err((PlaylistMalformed, None))),
("uri_as_a_tag", "a segment URI that begins with #, as a tag does", rc, cues, one_uri(120, T0 + 20 * MIN, "#seg-120.ts"), Err((PlaylistMalformed, None))),
("segment_too_long", "a segment of 2^32 milliseconds, the day after the schedule", rc, cues, window(0, day_after, &[u64::from(u32::MAX) + 1], false), Err((PlaylistMalformed, None))),
("sequence_too_large", "a media sequence number of 2^53", rc, cues, window(9_007_199_254_740_992, T0 + 20 * MIN, &ten(1), false), Err((PlaylistMalformed, None))),
]
}
/// The breaks a receipt file and a cues file give, bound (§8.4 step 5).
fn bound(files: &BTreeMap<String, Vec<u8>>, receipt: &str, cues: &str) -> Result<Vec<cue_tags::Break>, String> {
let r = super::read_receipt(files.get(receipt).ok_or("the receipt file")?).ok_or("the receipt reads")?;
let c = cue_tags::read_cues(files.get(cues).ok_or("the cues file")?).ok_or("the cues read")?;
cue_tags::bind_cues(&r, &c).map_err(|i| format!("cue {i} is not its break's"))
}
/// Render a playlist vector over the files given.
pub fn run_playlist(v: &Value, files: &BTreeMap<String, Vec<u8>>) -> Result<Result<String, cue_tags::Unwritten>, String> {
let breaks = bound(files, v["receipt"].as_str().ok_or("receipt")?, v["cues"].as_str().ok_or("cues")?)?;
let w = cue_tags::window_of(&v["window"]).ok_or("the window")?;
Ok(cue_tags::render(&breaks, &w))
}
/// A playlist verification vector: its name, what it shows, the receipt, cues and playlist
/// files, its edits.
type PlaylistCheckVector = (&'static str, &'static str, &'static str, &'static str, &'static str, Value);
/// Each playlist verification vector (§8.4).
fn playlist_check_vectors(sample: &Imported, cues_text: &str) -> Vec<PlaylistCheckVector> {
let (rc, cues, pl) = ("sample.channel.json", "sample.cues.json", "sample.out-in.m3u8");
let sig = hex(&sample.receipt.sig);
let flipped = format!("{}{}", if sig.starts_with('0') { "1" } else { "0" }, &sig[1..]);
let first = hex(&sample.cues[0].payload);
// The first cue's duration (bytes 5 to 8 of its payload), one millisecond more; and its
// section's splice_event_id (bytes 14 to 17 of the section, 26 to 29 of the payload), changed.
let mut longer = sample.cues[0].payload.clone();
longer[5] = longer[5].wrapping_add(1);
let mut other_event = sample.cues[0].payload.clone();
other_event[29] ^= 0x01;
let last = cues_text.rfind(",\n {").expect("more than one cue");
let tail = &cues_text[last..cues_text.len() - 2];
vec![
("playlist_out_in", "brk-1's playlist, verified", rc, cues, pl, json!([])),
("playlist_inside", "a live window inside brk-2, verified", rc, cues, "sample.inside.m3u8", json!([])),
("playlist_offset", "brk-3's ended window, verified", rc, cues, "sample.offset.m3u8", json!([])),
("playlist_return", "a window from brk-1's return, verified", rc, cues, "sample.return.m3u8", json!([])),
("playlist_none", "a window no break is in, verified", rc, cues, "sample.none.m3u8", json!([])),
("playlist_no_section", "brk-1 with no section, verified against the same receipt and its own cues", rc, "no-section.cues.json", "sample.no-section.m3u8", json!([])),
("playlist_fractional", "brk-1 in segments of 6.006 seconds, verified", rc, cues, "sample.fractional.m3u8", json!([])),
("playlist_last_instant", "the far channel's window that ends at 9999-12-31T23:59:59.999Z, verified", "far.channel.json", "far.cues.json", "far.m3u8", json!([])),
("pl_receipt_malformed", "a receipt missing a member", rc, cues, pl, json!([edit(rc, "\"signer_id\": \"sample-operator\"", "\"signer\": \"sample-operator\"")])),
("pl_receipt_bad_signature", "a receipt whose signature was altered", rc, cues, pl, json!([edit(rc, &sig, &flipped)])),
("pl_receipt_invalid", "a receipt, signed, whose program-as-run is not what its window gives", "aired-misreported.channel.json", cues, pl, json!([])),
("cues_malformed", "a cue whose start is named start", rc, cues, pl, json!([edit(cues, "\"start_ms\": 1793297520000", "\"start\": 1793297520000")])),
("cues_other_duration", "brk-1's cue a millisecond longer than its break", rc, cues, pl, json!([edit(cues, &first, &hex(&longer))])),
("cues_other_event", "brk-1's section naming another splice_event_id than its cue", rc, cues, pl, json!([edit(cues, &first, &hex(&other_event))])),
("cues_other_start", "brk-2's cue a second after its break's start", rc, cues, pl, json!([edit(cues, "\"start_ms\": 1793298480000", "\"start_ms\": 1793298481000")])),
("cues_missing", "the last break's cue left out", rc, cues, pl, json!([edit(cues, tail, "")])),
("cues_for_another_receipt", "the cues of brk-1 named with a double quote, against the sample's receipt", rc, "quoted.cues.json", pl, json!([])),
("playlist_malformed", "a playlist with no media sequence number", rc, cues, pl, json!([edit(pl, "#EXT-X-MEDIA-SEQUENCE:70\n", "")])),
("playlist_past_the_year_9999", "the far channel's playlist with its segment a millisecond longer, so that it ends at 10000-01-01T00:00:00Z", "far.channel.json", "far.cues.json", "far.m3u8", json!([edit("far.m3u8", "#EXTINF:10.000,", "#EXTINF:10.001,")])),
("playlist_uri_as_a_tag", "a playlist whose segment URI begins with #", rc, cues, "sample.none.m3u8", json!([edit("sample.none.m3u8", "\nseg-120.ts\n", "\n#seg-120.ts\n")])),
("playlist_unaligned", "a first segment five seconds longer, which moves every later one off brk-1's start", rc, cues, pl, json!([edit(pl, "#EXTINF:10.000,\nseg-70.ts", "#EXTINF:15.000,\nseg-70.ts")])),
("playlist_unwritable", "brk-1's playlist against the receipt and cues that name it with a double quote", "quoted.channel.json", "quoted.cues.json", pl, json!([])),
("playlist_other_duration", "EXT-X-CUE-OUT stating another duration than the break's", rc, cues, pl, json!([edit(pl, "#EXT-X-CUE-OUT:90.000", "#EXT-X-CUE-OUT:60.000")])),
("playlist_cont_dropped", "an EXT-X-CUE-OUT-CONT left out", rc, cues, pl, json!([edit(pl, "#EXT-X-CUE-OUT-CONT:ElapsedTime=40.000,Duration=90.000\n", "")])),
("playlist_cue_in_early", "EXT-X-CUE-IN a segment before the break's return", rc, cues, pl, json!([edit(pl, "#EXT-X-CUE-OUT-CONT:ElapsedTime=80.000,Duration=90.000", "#EXT-X-CUE-IN"), edit(pl, "#EXT-X-CUE-IN\n#EXTINF:10.000,\nseg-81.ts", "#EXTINF:10.000,\nseg-81.ts")])),
("playlist_other_section", "a date range carrying another section than the cue's", rc, cues, pl, json!([edit(pl, "SCTE35-OUT=0xFC30", "SCTE35-OUT=0xFC31")])),
("playlist_other_start", "a date range starting a second after the break", rc, cues, pl, json!([edit(pl, "START-DATE=\"2026-10-29T18:12:00.000Z\",PLANNED-DURATION", "START-DATE=\"2026-10-29T18:12:01.000Z\",PLANNED-DURATION")])),
("playlist_no_range_end", "the date range's end left out", rc, cues, pl, json!([edit(pl, "#EXT-X-DATERANGE:ID=\"brk-1@1793297520000\",START-DATE=\"2026-10-29T18:12:00.000Z\",DURATION=90.000\n", "")])),
]
}
/// The files of a playlist verification vector, with its edits applied, and its facts.
pub fn run_playlist_check(v: &Value, files: &BTreeMap<String, Vec<u8>>) -> Result<Value, String> {
let files = apply_edits(files, &v["edits"])?;
let file = |k: &str| -> Result<&Vec<u8>, String> { files.get(v[k].as_str().ok_or(k)?).ok_or_else(|| format!("the {k} file")) };
Ok(cue_tags::playlist_check_json(&cue_tags::verify_playlist(file("receipt")?, file("cues")?, file("playlist")?)))
}
fn playlist_outcome(got: &Result<String, cue_tags::Unwritten>, file: Option<&str>) -> Value {
match (got, file) {
(Ok(_), Some(f)) => json!({"playlist": f}),
(Err(u), _) => json!({"refused": u.reason.code(), "index": u.index}),
(Ok(_), None) => json!({"playlist": null}),
}
}
// ---------------------------------------------------------------------------------------
// generate, verify
// ---------------------------------------------------------------------------------------
@ -624,6 +811,71 @@ pub fn generate() -> Vec<(String, String)> {
v["facts"] = facts;
verifications.push(v);
}
// The cues the sample's import wrote; the same channel from the JSON form with brk-1's
// section left out, which yields the same receipt and other cues; and with brk-1 named with a
// double quote, which yields another receipt.
let cues_text = serde_json::to_string_pretty(&cues_json(&sample)).expect("JSON") + "\n";
files.push(("sample.cues.json".into(), cues_text.clone()));
let json_inputs = &ivs[1].2;
let section_1 = hex(&breaks()[0].3);
let held = bytes_of(&files);
let bare = run_import(&json!({"inputs": json_inputs, "edits": [edit("sample.json", &format!("\"scte35\": \"{section_1}\""), "\"scte35\": null")]}), &held)
.expect("the JSON form")
.expect("it imports");
assert_eq!(receipt_text(&bare.receipt), receipt_text(&sample.receipt), "a section is not in the receipt");
files.push(("no-section.cues.json".into(), serde_json::to_string_pretty(&cues_json(&bare)).expect("JSON") + "\n"));
let quoted = run_import(&json!({"inputs": json_inputs, "edits": [edit("sample.json", "\"id\": \"brk-1\"", "\"id\": \"brk\\\"1\"")]}), &held)
.expect("the JSON form")
.expect("it imports");
files.push(("quoted.channel.json".into(), receipt_text(&quoted.receipt)));
files.push(("quoted.cues.json".into(), serde_json::to_string_pretty(&cues_json(&quoted)).expect("JSON") + "\n"));
// brk-1 named with a line feed, which an import reads (§3.2); and with a carriage return,
// which it does not, so the operator signs that receipt itself.
let lf = run_import(&json!({"inputs": json_inputs, "edits": [edit("sample.json", "\"id\": \"brk-1\"", "\"id\": \"brk\\n1\"")]}), &held)
.expect("the JSON form")
.expect("it imports");
let cues_file = |c: &[Cue]| serde_json::to_string_pretty(&super::import::cues_json(c)).expect("JSON") + "\n";
files.push(("line-feed.channel.json".into(), receipt_text(&lf.receipt)));
files.push(("line-feed.cues.json".into(), cues_file(&lf.cues)));
let (cr, cr_cues) = renamed(&lf.receipt, &lf.cues, "brk\n1", "brk\r1");
files.push(("carriage-return.channel.json".into(), receipt_text(&cr)));
files.push(("carriage-return.cues.json".into(), cues_file(&cr_cues)));
let (far_receipt, far_cues) = far(&sample.receipt.grant);
files.push(("far.channel.json".into(), receipt_text(&far_receipt)));
files.push(("far.cues.json".into(), cues_file(&far_cues)));
let held = bytes_of(&files);
let mut playlists = Vec::new();
for (name, about, receipt, cues, w, want) in playlist_vectors() {
let mut v = json!({"name": name, "about": about, "receipt": receipt, "cues": cues, "window": cue_tags::window_json(&w)});
let got = run_playlist(&v, &held).unwrap_or_else(|e| panic!("{name}: {e}"));
match (&got, &want) {
(Ok(text), Ok(file)) => files.push((file.to_string(), text.clone())),
(Err(u), Err((r, i))) => {
assert_eq!((u.reason, u.index), (*r, *i), "{name}: refused {} at {:?}", u.reason, u.index);
seen.insert(r.code());
}
(Err(u), Ok(_)) => panic!("{name}: refused {}", u.reason),
(Ok(_), Err((r, _))) => panic!("{name}: rendered, written for {r}"),
}
v["outcome"] = playlist_outcome(&got, want.ok());
playlists.push(v);
}
let held = bytes_of(&files);
let mut playlist_verifications = Vec::new();
for (name, about, receipt, cues, playlist, edits) in playlist_check_vectors(&sample, &cues_text) {
let mut v = json!({"name": name, "about": about, "receipt": receipt, "cues": cues, "playlist": playlist, "edits": edits});
let facts = run_playlist_check(&v, &held).unwrap_or_else(|e| panic!("{name}: {e}"));
let verdict = facts["verdict"].as_str().expect("a verdict").to_string();
let expect_verified =
["playlist_out_in", "playlist_inside", "playlist_offset", "playlist_return", "playlist_none", "playlist_no_section", "playlist_fractional", "playlist_last_instant"].contains(&name);
assert_eq!(verdict == "verified", expect_verified, "{name}: {verdict}");
if let Some(r) = Reason::from_code(&verdict) {
seen.insert(r.code());
}
v["facts"] = facts;
playlist_verifications.push(v);
}
for r in Reason::ADDED.iter().chain(Reason::REUSED) {
assert!(seen.contains(r.code()), "the corpus gives no {r}");
}
@ -633,6 +885,8 @@ pub fn generate() -> Vec<(String, String)> {
"operator": {"key": hex(&operator().verifying_key().to_bytes()), "seed": hex(&operator().to_bytes())},
"imports": imports,
"verifications": verifications,
"playlists": playlists,
"playlist_verifications": playlist_verifications,
});
files.push(("vectors.json".into(), serde_json::to_string_pretty(&doc).expect("JSON") + "\n"));
files.sort();
@ -685,5 +939,25 @@ pub fn verify(files: &BTreeMap<String, String>) -> Vec<String> {
Err(e) => fails.push(format!("{name}: {e}")),
}
}
for v in doc["playlists"].as_array().into_iter().flatten() {
let name = v["name"].as_str().unwrap_or("?");
match run_playlist(v, &held) {
Ok(got) => {
let file = v["outcome"]["playlist"].as_str();
if playlist_outcome(&got, file) != v["outcome"] || got.as_ref().is_ok_and(|t| file.and_then(|f| files.get(f)) != Some(t)) {
fails.push(format!("{name}: renders {:?}, the corpus states {}", got.as_ref().map(|_| "a playlist").map_err(|u| u.reason), v["outcome"]));
}
}
Err(e) => fails.push(format!("{name}: {e}")),
}
}
for v in doc["playlist_verifications"].as_array().into_iter().flatten() {
let name = v["name"].as_str().unwrap_or("?");
match run_playlist_check(v, &held) {
Ok(f) if f == v["facts"] => {}
Ok(f) => fails.push(format!("{name}: states {f}, the corpus {}", v["facts"])),
Err(e) => fails.push(format!("{name}: {e}")),
}
}
fails
}

View file

@ -0,0 +1,471 @@
//! The cue tags of an HLS Media Playlist (extensions/channel-lineup.md §7.4, §8.4).
//!
//! A packager that writes a channel's segments, MPEG-2 transport stream segments among them,
//! marks each break of the receipt's schedule in the playlist:
//! `EXT-X-CUE-OUT` with the break's duration, `EXT-X-CUE-OUT-CONT` on each later segment of the
//! break and `EXT-X-CUE-IN` on its return; and, for a break whose cue carries its SCTE-35
//! section, the `EXT-X-DATERANGE` tags of RFC 8216 §4.3.2.7.1. [`render`] writes a window's
//! playlist from the breaks [`bind_cues`] reads from a receipt and its cues; [`verify_playlist`]
//! reads a playlist's window back, renders it again and compares.
//!
//! Pure functions of the bytes they are given: no I/O, no clock and no floating point.
use serde_json::{json, Value};
use super::import::{break_cue, section_reads, Cue};
use super::{duration_ms, hex, hex_bytes, hls_date_time, read_receipt, receipt_verdict, xmltv_format, Reason, MAX_INSTANT};
use crate::strict_json::{self, Json};
use crate::wai_video_channel::{ChannelReceipt, EntryKind};
/// `EXT-X-VERSION`: a decimal `EXTINF` duration needs version 3 (RFC 8216 §7).
pub const VERSION: u8 = 3;
/// The most bytes of a segment's URI (§7.4).
pub const MAX_URI: usize = 2048;
/// The longest segment, in milliseconds (§7.4).
pub const MAX_SEGMENT_MS: u64 = u32::MAX as u64;
/// The instant a window ends before: 10000-01-01T00:00:00Z. A break in the window starts at the
/// latest where the window ends, so every date it writes has four digits of year (§7.4).
pub const WINDOW_ENDS_BEFORE: u64 = 253_402_300_800_000;
/// A break of the receipt's schedule (§7.4): an `ad` entry of a duration above 0, with the
/// section its cue carries, if any.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Break {
pub id: String,
pub start_ms: u64,
pub duration_ms: u64,
pub section: Option<Vec<u8>>,
}
impl Break {
pub fn end_ms(&self) -> u64 {
self.start_ms + self.duration_ms
}
/// The `ID` of its date range: its id, `@` and its start in decimal (§7.4).
pub fn date_range_id(&self) -> String {
format!("{}@{}", self.id, self.start_ms)
}
/// Whether a quoted-string can hold its date range's `ID` (RFC 8216 §4.2).
fn writable(&self) -> bool {
!self.id.contains(['"', '\n', '\r'])
}
}
/// A segment of a window: its URI and its duration in milliseconds.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Segment {
pub uri: String,
pub duration_ms: u64,
}
/// What a Media Playlist lists (§7.4): its media sequence number, the instant its first segment
/// begins, its segments in order, and whether it is ended.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Window {
pub media_sequence: u64,
pub start_ms: u64,
pub segments: Vec<Segment>,
pub ended: bool,
}
impl Window {
/// Each segment's start and end.
fn spans(&self) -> Vec<(u64, u64)> {
let mut at = self.start_ms;
self.segments
.iter()
.map(|s| {
let span = (at, at.saturating_add(s.duration_ms));
at = span.1;
span
})
.collect()
}
/// Whether it holds to §7.4's bounds.
pub fn holds(&self) -> bool {
let uri = |u: &str| (1..=MAX_URI).contains(&u.len()) && u.bytes().all(|c| (0x21..=0x7e).contains(&c)) && !u.starts_with('#');
self.media_sequence <= MAX_INSTANT
&& !self.segments.is_empty()
&& self.segments.iter().all(|s| uri(&s.uri) && (1..=MAX_SEGMENT_MS).contains(&s.duration_ms))
&& self.segments.iter().try_fold(self.start_ms, |at, s| at.checked_add(s.duration_ms)).is_some_and(|end| end < WINDOW_ENDS_BEFORE)
}
}
/// The receipt's breaks (§7.4): its `ad` entries of a duration above 0, in schedule order, each
/// its id, its start and its duration.
pub fn schedule_breaks(r: &ChannelReceipt) -> Vec<(String, u64, u64)> {
r.schedule.iter().filter(|e| e.kind == EntryKind::Ad && e.duration_ms > 0).map(|e| (e.asset_id.clone(), e.start_ms, e.duration_ms)).collect()
}
/// Read a cues document (§8.4 step 4): one JSON text, an array of objects of exactly `break` (a
/// string), `start_ms` (an integer of at most 2^53 − 1) and `payload` (an even count of lowercase
/// hexadecimal digits), as `channel.import` writes `cues.json`.
pub fn read_cues(b: &[u8]) -> Option<Vec<Cue>> {
let j = strict_json::read(b).ok()?;
let mut out = Vec::new();
for c in j.as_arr()? {
let m = c.as_obj()?;
if m.len() != 3 || !["break", "start_ms", "payload"].iter().all(|k| m.iter().any(|(n, _)| n == k)) {
return None;
}
let start_ms = c.get("start_ms").and_then(Json::as_u64).filter(|&t| t <= MAX_INSTANT)?;
out.push(Cue { break_id: c.get("break")?.as_str()?.to_string(), start_ms, payload: hex_bytes(c.get("payload")?.as_str()?)? });
}
Some(out)
}
/// The section a cue's payload carries: `Some(Some(_))` for one of form 1 that reads as §4.5 step
/// 3 reads a section, `Some(None)` for form 0, `None` otherwise.
fn payload_section(p: &[u8]) -> Option<Option<&[u8]>> {
match p.get(9)? {
0 => Some(None),
1 => {
let n = u16::from_le_bytes([*p.get(10)?, *p.get(11)?]) as usize;
let s = p.get(12..12 + n)?;
section_reads(s).then_some(Some(s))
}
_ => None,
}
}
/// §8.4 step 5: the breaks of the receipt, each with the section its cue carries; or the first
/// index whose cue is not its break's, a missing or an extra cue at the first index past the
/// shorter list.
pub fn bind_cues(r: &ChannelReceipt, cues: &[Cue]) -> Result<Vec<Break>, usize> {
let breaks = schedule_breaks(r);
let mut out = Vec::with_capacity(breaks.len());
for i in 0..breaks.len().max(cues.len()) {
let (Some((id, start, duration)), Some(c)) = (breaks.get(i), cues.get(i)) else { return Err(i) };
if c.break_id != *id || c.start_ms != *start || *duration > MAX_SEGMENT_MS {
return Err(i);
}
let Some(section) = payload_section(&c.payload) else { return Err(i) };
if break_cue(id, *duration, section) != c.payload {
return Err(i);
}
out.push(Break { id: id.clone(), start_ms: *start, duration_ms: *duration, section: section.map(<[u8]>::to_vec) });
}
Ok(out)
}
/// A duration as a span (§7.4): seconds with three digits after the point.
pub fn span(ms: u64) -> String {
format!("{}.{:03}", ms / 1000, ms % 1000)
}
/// An instant as a date (§7.4): `YYYY-MM-DDTHH:MM:SS.mmmZ`, in UTC.
pub fn date(ms: u64) -> String {
let s = xmltv_format(ms);
format!("{}-{}-{}T{}:{}:{}.{:03}Z", &s[0..4], &s[4..6], &s[6..8], &s[8..10], &s[10..12], &s[12..14], ms % 1000)
}
/// Why a window's tags cannot be written (§7.4), with the index §8.4 states: the segment's for
/// `cue_unaligned`, the break's for `cue_unwritable`, none for `playlist_malformed`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Unwritten {
pub reason: Reason,
pub index: Option<usize>,
}
/// The first segment of the window that puts a break in it (§7.4): its last segment before the
/// break, a segment that starts in it, or its return.
fn first_in(b: &Break, spans: &[(u64, u64)]) -> Option<usize> {
spans.iter().position(|&(s, e)| e == b.start_ms || (b.start_ms <= s && s < b.end_ms()) || s == b.end_ms())
}
/// The breaks that are in the window, as `(index among the breaks, break)`.
pub fn in_window<'a>(breaks: &'a [Break], w: &Window) -> Vec<(usize, &'a Break)> {
let spans = w.spans();
breaks.iter().enumerate().filter(|(_, b)| first_in(b, &spans).is_some()).collect()
}
/// The Media Playlist of a window, its breaks marked (§7.4).
pub fn render(breaks: &[Break], w: &Window) -> Result<String, Unwritten> {
if !w.holds() {
return Err(Unwritten { reason: Reason::PlaylistMalformed, index: None });
}
let spans = w.spans();
let inside = |x: u64, (s, e): (u64, u64)| s < x && x < e;
if let Some(k) = spans.iter().position(|&sp| breaks.iter().any(|b| inside(b.start_ms, sp) || inside(b.end_ms(), sp))) {
return Err(Unwritten { reason: Reason::CueUnaligned, index: Some(k) });
}
if let Some((i, _)) = in_window(breaks, w).into_iter().find(|(_, b)| b.section.is_some() && !b.writable()) {
return Err(Unwritten { reason: Reason::CueUnwritable, index: Some(i) });
}
let target = w.segments.iter().map(|s| s.duration_ms.div_ceil(1000)).max().expect("a segment");
let mut o = format!(
"#EXTM3U\n#EXT-X-VERSION:{VERSION}\n#EXT-X-TARGETDURATION:{target}\n#EXT-X-MEDIA-SEQUENCE:{}\n#EXT-X-PROGRAM-DATE-TIME:{}\n",
w.media_sequence,
date(w.start_ms)
);
for (k, (seg, &(s, _))) in w.segments.iter().zip(&spans).enumerate() {
for b in breaks {
let Some(section) = &b.section else { continue };
let id = b.date_range_id();
if first_in(b, &spans) == Some(k) {
o.push_str(&format!(
"#EXT-X-DATERANGE:ID=\"{id}\",START-DATE=\"{}\",PLANNED-DURATION={},SCTE35-OUT=0x{}\n",
date(b.start_ms),
span(b.duration_ms),
hex(section).to_uppercase()
));
}
if s == b.end_ms() {
o.push_str(&format!("#EXT-X-DATERANGE:ID=\"{id}\",START-DATE=\"{}\",DURATION={}\n", date(b.start_ms), span(b.duration_ms)));
}
}
if breaks.iter().any(|b| b.end_ms() == s) {
o.push_str("#EXT-X-CUE-IN\n");
}
if let Some(b) = breaks.iter().find(|b| b.start_ms == s) {
o.push_str(&format!("#EXT-X-CUE-OUT:{}\n", span(b.duration_ms)));
} else if let Some(b) = breaks.iter().find(|b| b.start_ms < s && s < b.end_ms()) {
o.push_str(&format!("#EXT-X-CUE-OUT-CONT:ElapsedTime={},Duration={}\n", span(s - b.start_ms), span(b.duration_ms)));
}
o.push_str(&format!("#EXTINF:{},\n{}\n", span(seg.duration_ms), seg.uri));
}
if w.ended {
o.push_str("#EXT-X-ENDLIST\n");
}
Ok(o)
}
/// A decimal integer with no leading zero, at most 2^53 − 1.
fn plain(s: &str) -> Option<u64> {
(!s.is_empty() && s.bytes().all(|c| c.is_ascii_digit()) && (s == "0" || !s.starts_with('0'))).then(|| s.parse().ok()).flatten().filter(|&n| n <= MAX_INSTANT)
}
/// Read a Media Playlist's window (§8.4 step 6), or `None`: `playlist_malformed`.
pub fn read_playlist(b: &[u8]) -> Option<Window> {
let lines: Vec<&str> = std::str::from_utf8(b).ok()?.strip_suffix('\n')?.split('\n').collect();
if lines.first() != Some(&"#EXTM3U") {
return None;
}
let (mut sequence, mut first, mut ended, mut segments) = (None, None, false, Vec::new());
let mut i = 1;
while i < lines.len() {
let l = lines[i];
if let Some(v) = l.strip_prefix("#EXT-X-MEDIA-SEQUENCE:") {
if sequence.replace(plain(v)?).is_some() {
return None;
}
} else if let Some(v) = l.strip_prefix("#EXT-X-PROGRAM-DATE-TIME:") {
if first.is_none() && segments.is_empty() {
first = Some(hls_date_time(v).ok()?);
}
} else if l == "#EXT-X-ENDLIST" {
ended = true;
} else if let Some(v) = l.strip_prefix("#EXTINF:") {
let d = duration_ms(v.strip_suffix(',')?).ok()?;
let uri = lines.get(i + 1)?;
segments.push(Segment { uri: uri.to_string(), duration_ms: d });
i += 1;
}
i += 1;
}
let w = Window { media_sequence: sequence?, start_ms: first?, segments, ended };
w.holds().then_some(w)
}
/// A break a window holds, as a playlist's verification states it (§8.4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Marked {
pub id: String,
pub start_ms: u64,
pub duration_ms: u64,
/// Whether it has a date range: whether its cue carries a section.
pub date_range: bool,
}
/// What a playlist's verification states (§8.4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PlaylistCheck {
/// `Ok` when verified, otherwise the reason.
pub verdict: Result<(), Reason>,
/// `receipt`, `cues` or `playlist`.
pub part: Option<&'static str>,
/// The receipt's identity, when it reads.
pub receipt: Option<[u8; 32]>,
/// The window, when the playlist reads, and the breaks in it.
pub window: Option<(Window, Vec<Marked>)>,
/// For `cues_mismatch` the cue's index, for `cue_unaligned` the segment's, for
/// `cue_unwritable` the break's.
pub index: Option<usize>,
}
/// Verify a playlist's cue tags against a receipt and its cues (§8.4).
pub fn verify_playlist(receipt: &[u8], cues: &[u8], playlist: &[u8]) -> PlaylistCheck {
let mut v = PlaylistCheck { verdict: Ok(()), part: None, receipt: None, window: None, index: None };
let fail = |mut v: PlaylistCheck, part: &'static str, r: Reason, index: Option<usize>| {
v.verdict = Err(r);
v.part = Some(part);
v.index = index;
v
};
let Some(r) = read_receipt(receipt) else { return fail(v, "receipt", Reason::ChannelReceiptMalformed, None) };
v.receipt = Some(r.receipt_hash());
if let Err(e) = receipt_verdict(&r) {
return fail(v, "receipt", e, None);
}
let Some(cues) = read_cues(cues) else { return fail(v, "cues", Reason::CuesMalformed, None) };
let breaks = match bind_cues(&r, &cues) {
Ok(b) => b,
Err(i) => return fail(v, "cues", Reason::CuesMismatch, Some(i)),
};
let Some(w) = read_playlist(playlist) else { return fail(v, "playlist", Reason::PlaylistMalformed, None) };
let marked = in_window(&breaks, &w).into_iter().map(|(_, b)| Marked { id: b.id.clone(), start_ms: b.start_ms, duration_ms: b.duration_ms, date_range: b.section.is_some() }).collect();
v.window = Some((w.clone(), marked));
match render(&breaks, &w) {
Err(u) => fail(v, "playlist", u.reason, u.index),
Ok(text) if text.as_bytes() != playlist => fail(v, "playlist", Reason::PlaylistMismatch, None),
Ok(_) => v,
}
}
/// A window as the corpus states it: `media_sequence`, `start_ms`, `segments` (each `uri` and
/// `duration_ms`) and `ended`.
pub fn window_json(w: &Window) -> Value {
json!({
"media_sequence": w.media_sequence,
"start_ms": w.start_ms,
"segments": w.segments.iter().map(|s| json!({"uri": s.uri, "duration_ms": s.duration_ms})).collect::<Vec<_>>(),
"ended": w.ended,
})
}
/// A window from [`window_json`]'s form, or `None`.
pub fn window_of(v: &Value) -> Option<Window> {
let segments = v["segments"].as_array()?.iter().map(|s| Some(Segment { uri: s["uri"].as_str()?.to_string(), duration_ms: s["duration_ms"].as_u64()? })).collect::<Option<Vec<_>>>()?;
Some(Window { media_sequence: v["media_sequence"].as_u64()?, start_ms: v["start_ms"].as_u64()?, segments, ended: v["ended"].as_bool()? })
}
/// A playlist's verification as JSON (§8.4; the corpus's `facts`).
pub fn playlist_check_json(v: &PlaylistCheck) -> Value {
json!({
"verdict": v.verdict.err().map_or("verified", Reason::code),
"part": v.part,
"receipt": v.receipt.map(|h| hex(&h)),
"window": v.window.as_ref().map(|(w, marked)| json!({
"media_sequence": w.media_sequence,
"start_ms": w.start_ms,
"segments": w.segments.len(),
"ended": w.ended,
"breaks": marked.iter().map(|m| json!({"break": m.id, "start_ms": m.start_ms, "duration_ms": m.duration_ms, "scte35": m.date_range})).collect::<Vec<_>>(),
})),
"index": v.index,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn brk(id: &str, start_ms: u64, duration_ms: u64, section: bool) -> Break {
Break { id: id.into(), start_ms, duration_ms, section: section.then(|| vec![0xFC, 0x30, 0x00]) }
}
fn win(seq: u64, start_ms: u64, n: usize) -> Window {
Window { media_sequence: seq, start_ms, segments: (0..n).map(|k| Segment { uri: format!("s{}.ts", seq + k as u64), duration_ms: 10_000 }).collect(), ended: false }
}
/// The lines before each segment's `EXTINF`, by the segment's URI.
fn cue_lines(text: &str) -> Vec<(String, Vec<String>, Vec<String>)> {
let mut out = Vec::new();
let (mut daterange, mut cue) = (Vec::new(), Vec::new());
let mut lines = text.lines().skip(5);
while let Some(l) = lines.next() {
if l.starts_with("#EXTINF:") {
out.push((lines.next().expect("a URI").to_string(), std::mem::take(&mut daterange), std::mem::take(&mut cue)));
} else if l.starts_with("#EXT-X-DATERANGE:") {
daterange.push(l.to_string());
} else if l.starts_with("#EXT-X-CUE") {
cue.push(l.to_string());
}
}
out
}
#[test]
fn back_to_back_breaks_close_the_first_then_open_the_second() {
let breaks = [brk("a", 20_000, 20_000, true), brk("b", 40_000, 10_000, true)];
let text = render(&breaks, &win(0, 0, 6)).expect("aligned");
let at_40 = &cue_lines(&text)[4];
assert_eq!(at_40.0, "s4.ts");
assert_eq!(at_40.2, ["#EXT-X-CUE-IN", "#EXT-X-CUE-OUT:10.000"]);
assert!(at_40.1[0].contains("ID=\"a@20000\"") && at_40.1[0].contains("DURATION=20.000"), "{:?}", at_40.1);
// b's range was announced with the last segment before it, s3, which is in a.
let at_30 = &cue_lines(&text)[3];
assert_eq!(at_30.2, ["#EXT-X-CUE-OUT-CONT:ElapsedTime=10.000,Duration=20.000"]);
assert!(at_30.1[0].contains("ID=\"b@40000\"") && at_30.1[0].contains("PLANNED-DURATION=10.000"));
}
#[test]
fn a_segment_carries_the_same_cue_lines_in_every_window_that_holds_it() {
let breaks = [brk("a", 50_000, 40_000, true)];
let all = cue_lines(&render(&breaks, &win(0, 0, 12)).expect("aligned"));
for first in 0..12 {
let part = cue_lines(&render(&breaks, &win(first as u64, first as u64 * 10_000, 12 - first)).expect("aligned"));
for (k, seg) in part.iter().enumerate() {
assert_eq!(seg.2, all[first + k].2, "window from {first}, segment {k}");
}
// The range is in every window holding s4 (its last segment before it) to s9 (its
// return), and its out tag is on the first of those the window holds.
let outs: Vec<usize> = part.iter().enumerate().filter(|(_, s)| s.1.iter().any(|l| l.contains("SCTE35-OUT"))).map(|(k, _)| k).collect();
let want: Vec<usize> = if first <= 4 { vec![4 - first] } else if first <= 9 { vec![0] } else { vec![] };
assert_eq!(outs, want, "window from {first}");
}
}
#[test]
fn a_break_with_no_section_has_no_date_range_and_its_id_is_never_written() {
let breaks = [brk("quote\"d", 10_000, 10_000, false)];
let text = render(&breaks, &win(0, 0, 3)).expect("no ID is written");
assert!(!text.contains("DATERANGE"));
assert_eq!(cue_lines(&text)[1].2, ["#EXT-X-CUE-OUT:10.000"]);
assert_eq!(cue_lines(&text)[2].2, ["#EXT-X-CUE-IN"]);
let with = [brk("quote\"d", 10_000, 10_000, true)];
assert_eq!(render(&with, &win(0, 0, 3)), Err(Unwritten { reason: Reason::CueUnwritable, index: Some(0) }));
// Outside the window, the same break is not written, so it does not refuse.
assert!(render(&with, &win(5, 50_000, 2)).is_ok());
}
#[test]
fn a_rendering_reads_back_to_its_window() {
let breaks = [brk("a", 20_000, 20_000, true)];
let mut w = win(7, 1_793_296_800_000, 6);
w.ended = true;
let text = render(&[Break { start_ms: w.start_ms + 20_000, ..breaks[0].clone() }], &w).expect("aligned");
assert_eq!(read_playlist(text.as_bytes()), Some(w));
}
#[test]
fn a_window_past_its_bounds_is_malformed() {
let mut w = win(0, WINDOW_ENDS_BEFORE - 10_001, 1);
assert!(w.holds());
w.segments[0].duration_ms += 1;
assert!(!w.holds());
let mut w = win(0, 0, 1);
w.segments[0].uri = "#seg.ts".into();
assert_eq!(render(&[], &w).map_err(|u| u.reason), Err(Reason::PlaylistMalformed));
}
#[test]
fn a_break_at_the_last_instant_is_dated_in_the_year_9999() {
let last = WINDOW_ENDS_BEFORE - 1;
let breaks = [brk("a", last, 1, true), brk("b", WINDOW_ENDS_BEFORE, 30_000, true)];
// The window ends where a starts, so a is in it and b is not.
let text = render(&breaks, &win(0, last - 10_000, 1)).expect("in its bounds");
assert!(text.contains("#EXT-X-PROGRAM-DATE-TIME:9999-12-31T23:59:49.999Z\n"), "{text}");
assert!(text.contains("ID=\"a@253402300799999\",START-DATE=\"9999-12-31T23:59:59.999Z\",PLANNED-DURATION=0.001,"), "{text}");
assert!(!text.contains("b@"), "{text}");
// With a segment of a millisecond after it, the window would hold b's last segment before
// it, and b's date has five digits of year: the window is past its bounds.
let mut w = win(0, last - 10_000, 2);
w.segments[1].duration_ms = 1;
assert_eq!(render(&breaks, &w), Err(Unwritten { reason: Reason::PlaylistMalformed, index: None }));
assert_eq!(read_playlist(text.as_bytes()).map(|w| w.start_ms), Some(last - 10_000));
}
}

View file

@ -418,7 +418,7 @@ fn hex_any(s: &str) -> Option<Vec<u8>> {
}
/// A `splice_info_section()` as live-events §4.3 reads form 1 (§4.5 step 3).
fn section_reads(b: &[u8]) -> bool {
pub(crate) fn section_reads(b: &[u8]) -> bool {
b.len() >= 3 && b[0] == 0xFC && (u16::from_be_bytes([b[1], b[2]]) & 0x0FFF) as usize + 3 == b.len()
}
@ -596,10 +596,16 @@ pub fn cue_id(break_id: &str, section: Option<&[u8]>) -> u32 {
/// A break's `cue` payload of live-events §4.3 (§4.8).
fn cue_payload(k: &Break) -> Vec<u8> {
break_cue(&k.id, k.duration, k.section.as_deref())
}
/// The `cue` payload of live-events §4.3 that §4.8 yields for a break of this id and duration,
/// with this section or none. A duration is at most 2^32 − 1 milliseconds (§4.5 step 4).
pub fn break_cue(id: &str, duration_ms: u64, section: Option<&[u8]>) -> Vec<u8> {
let mut o = vec![4u8];
o.extend_from_slice(&cue_id(&k.id, k.section.as_deref()).to_le_bytes());
o.extend_from_slice(&(k.duration as u32).to_le_bytes());
match &k.section {
o.extend_from_slice(&cue_id(id, section).to_le_bytes());
o.extend_from_slice(&(duration_ms as u32).to_le_bytes());
match section {
None => o.push(0),
Some(s) => {
o.push(1);

View file

@ -865,6 +865,7 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
wire("wai-route-policy", WireKind::DocumentKind, ROUTE_RECORD, "§3.3", WireStatus::Defined),
wire("wai-route-record", WireKind::DocumentKind, ROUTE_RECORD, "§5.1", WireStatus::Defined),
wire("wai-route-third-party-hop", WireKind::DocumentKind, ROUTE_RECORD, "§6.2", WireStatus::Defined),
wire("wai-route-delivery-decision", WireKind::DocumentKind, ROUTE_RECORD, "§6.3", WireStatus::Defined),
wire("WRC1", WireKind::Magic, REVENUE_SHARE, "§3.1", WireStatus::Defined),
wire("WRS1", WireKind::Magic, REVENUE_SHARE, "§7.1", WireStatus::Defined),
wire("WOR1", WireKind::Magic, OPPORTUNITY, "§7.1", WireStatus::Defined),

View file

@ -36,6 +36,7 @@ use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
pub mod corpus;
pub mod delivery;
pub mod third_party;
/// The record this extension registers.
@ -103,6 +104,10 @@ pub enum Reason {
ThirdPartyInvalid,
ThirdPartyMismatch,
ThirdPartyOutputMismatch,
DeliveryInvalid,
DeliveryRouteMismatch,
DeliveryParentMismatch,
DeliveryChainBroken,
// Reused (§8).
Malformed,
UnknownVersion,
@ -112,7 +117,7 @@ pub enum Reason {
impl Reason {
/// The codes this extension adds, in §8's order.
pub const ALL: [Reason; 25] = [
pub const ALL: [Reason; 29] = [
Reason::RequestInvalid,
Reason::PricebookInvalid,
Reason::PolicyInvalid,
@ -138,6 +143,10 @@ impl Reason {
Reason::ThirdPartyInvalid,
Reason::ThirdPartyMismatch,
Reason::ThirdPartyOutputMismatch,
Reason::DeliveryInvalid,
Reason::DeliveryRouteMismatch,
Reason::DeliveryParentMismatch,
Reason::DeliveryChainBroken,
];
/// The codes it reuses (§8).
@ -170,6 +179,10 @@ impl Reason {
Reason::ThirdPartyInvalid => "third_party_invalid",
Reason::ThirdPartyMismatch => "third_party_mismatch",
Reason::ThirdPartyOutputMismatch => "third_party_output_mismatch",
Reason::DeliveryInvalid => "delivery_invalid",
Reason::DeliveryRouteMismatch => "delivery_route_mismatch",
Reason::DeliveryParentMismatch => "delivery_parent_mismatch",
Reason::DeliveryChainBroken => "delivery_chain_broken",
Reason::Malformed => "malformed",
Reason::UnknownVersion => "unknown_version",
Reason::BadKey => "bad_key",
@ -1142,6 +1155,45 @@ pub fn verify_output_proof(output: &[u8; 32], index: u64, siblings: &[[u8; 32]],
&node == root
}
/// A candidate of a record of `version` (§5.1): each member of its own form. Relations between
/// members are §7.2 step 6's.
fn candidate_form(c: &Value, version: u64) -> bool {
let int_or_null = |v: &Value| v.is_null() || int(v).is_some();
let bool_or_null = |v: &Value| v.is_null() || v.is_boolean();
let tok_or_null = |v: &Value| v.is_null() || tok(v).is_some();
let scalar = |v: &Value| !(v.is_array() || v.is_object());
let Some(c) = exactly(c, &candidate_members(version)) else { return false };
let v2 = book_version(version) != VERSION_2
|| (c["operation"].as_str().is_some_and(operation)
&& (c["failures"].is_null() || Failures::form(&c["failures"]).is_some())
&& int_or_null(&c["expected_cost"])
&& int_or_null(&c["expected_ttfb_ms"]));
v2 && tok(&c["endpoint"]).is_some()
&& tok(&c["provider"]).is_some()
&& c["capability"].as_str().is_some_and(capability)
&& tok_or_null(&c["region"])
&& tok_or_null(&c["residency"])
&& bool_or_null(&c["trains_on_inputs"])
&& int_or_null(&c["retention_days"])
&& bool_or_null(&c["ga"])
&& bool_or_null(&c["needs_consent"])
&& c["unit"].as_str().is_some_and(unit)
&& int(&c["divisor"]).is_some_and(|d| d >= 1)
&& int(&c["price"]).is_some()
&& int_or_null(&c["ttfb_ms"])
&& int_or_null(&c["energy_uj"])
&& (c["energy_class"].is_null() || c["energy_class"].as_str().is_some_and(|x| CLASSES.contains(&x)))
&& (c["quality"].is_null() || int(&c["quality"]).is_some_and(|q| q <= 1000))
&& c["via_fallback"].is_boolean()
&& int_or_null(&c["cost"])
&& int_or_null(&c["energy"])
&& c["removed"].as_array().is_some_and(|r| {
r.len() <= LIMITS.len()
&& r.iter().all(|x| exactly(x, &["limit", "value"]).is_some_and(|x| x["limit"].as_str().is_some_and(|l| LIMITS.contains(&l)) && scalar(&x["value"])))
})
&& (c["rank"].is_null() || int(&c["rank"]).is_some_and(|n| n >= 1))
}
fn record_form(v: &Value) -> bool {
let version = v.get("version").and_then(int).unwrap_or(0);
let mut attempt_members = ATTEMPT_MEMBERS.to_vec();
@ -1155,41 +1207,7 @@ fn record_form(v: &Value) -> bool {
let hexn = |v: &Value, n: usize| v.as_str().is_some_and(|s| s.len() == n && s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)));
let cap_or_null = |v: &Value| v.is_null() || v.as_str().is_some_and(capability);
let int_or_null = |v: &Value| v.is_null() || int(v).is_some();
let bool_or_null = |v: &Value| v.is_null() || v.is_boolean();
let tok_or_null = |v: &Value| v.is_null() || tok(v).is_some();
let scalar = |v: &Value| !(v.is_array() || v.is_object());
let candidate = |c: &Value| -> bool {
let Some(c) = exactly(c, &candidate_members(version)) else { return false };
let v2 = book_version(version) != VERSION_2
|| (c["operation"].as_str().is_some_and(operation)
&& (c["failures"].is_null() || Failures::form(&c["failures"]).is_some())
&& int_or_null(&c["expected_cost"])
&& int_or_null(&c["expected_ttfb_ms"]));
v2 && tok(&c["endpoint"]).is_some()
&& tok(&c["provider"]).is_some()
&& c["capability"].as_str().is_some_and(capability)
&& tok_or_null(&c["region"])
&& tok_or_null(&c["residency"])
&& bool_or_null(&c["trains_on_inputs"])
&& int_or_null(&c["retention_days"])
&& bool_or_null(&c["ga"])
&& bool_or_null(&c["needs_consent"])
&& c["unit"].as_str().is_some_and(unit)
&& int(&c["divisor"]).is_some_and(|d| d >= 1)
&& int(&c["price"]).is_some()
&& int_or_null(&c["ttfb_ms"])
&& int_or_null(&c["energy_uj"])
&& (c["energy_class"].is_null() || c["energy_class"].as_str().is_some_and(|x| CLASSES.contains(&x)))
&& (c["quality"].is_null() || int(&c["quality"]).is_some_and(|q| q <= 1000))
&& c["via_fallback"].is_boolean()
&& int_or_null(&c["cost"])
&& int_or_null(&c["energy"])
&& c["removed"].as_array().is_some_and(|r| {
r.len() <= LIMITS.len()
&& r.iter().all(|x| exactly(x, &["limit", "value"]).is_some_and(|x| x["limit"].as_str().is_some_and(|l| LIMITS.contains(&l)) && scalar(&x["value"])))
})
&& (c["rank"].is_null() || int(&c["rank"]).is_some_and(|n| n >= 1))
};
let attempt = |i: usize, a: &Value| -> bool {
let Some(a) = exactly(a, &attempt_members) else { return false };
let served = match a["outcome"].as_str() {
@ -1234,7 +1252,7 @@ fn record_form(v: &Value) -> bool {
.is_some_and(|p| tok(&p["name"]).is_some() && int(&p["version"]).is_some_and(|v| v >= 1) && hexn(&p["digest"], 64))
&& exactly(&o["pricebook"], &["digest", "currency", "issued"])
.is_some_and(|p| hexn(&p["digest"], 64) && p["currency"].as_str().is_some_and(currency) && int(&p["issued"]).is_some())
&& o["candidates"].as_array().is_some_and(|c| c.len() <= 1024 && c.iter().all(candidate))
&& o["candidates"].as_array().is_some_and(|c| c.len() <= 1024 && c.iter().all(|c| candidate_form(c, version)))
&& o["order"].as_array().is_some_and(|x| x.len() <= 1024 && x.iter().all(|e| tok(e).is_some()))
&& exactly(&o["decision"], &["decided_by", "runner_up"])
.is_some_and(|d| d["decided_by"].as_str().is_some_and(|x| DECIDED_BY.contains(&x)) && tok_or_null(&d["runner_up"]))
@ -1274,6 +1292,9 @@ pub struct Accepted {
/// `None` when the document has no `third_party`; otherwise what each third-party hop record
/// states, in the order of their hops (§6.2).
pub third_party: Option<Vec<third_party::Read>>,
/// `None` when the document has no `deliveries`; otherwise what each delivery decision states,
/// in their order (§6.3).
pub deliveries: Option<Vec<delivery::Read>>,
}
/// A verification document's texts (§7.1).
@ -1286,6 +1307,9 @@ pub struct Document<'a> {
pub keys: Option<&'a Map<String, Value>>,
/// The third-party hop records' texts (§6.2), when the document has them.
pub third_party: Option<Vec<&'a str>>,
/// The delivery decisions' texts (§6.3), each with its delivery's three documents, when the
/// document has them.
pub deliveries: Option<Vec<delivery::Texts<'a>>>,
}
impl<'a> Document<'a> {
@ -1296,6 +1320,9 @@ impl<'a> Document<'a> {
if v.get("third_party").is_some() {
members.push("third_party");
}
if v.get("deliveries").is_some() {
members.push("deliveries");
}
let o = exactly(v, &members).ok_or_else(bad)?;
let s = |k: &str| o[k].as_str().ok_or_else(bad);
let receipts = match &o["receipts"] {
@ -1316,7 +1343,12 @@ impl<'a> Document<'a> {
Some(Value::Array(a)) if receipts.is_some() => Some(a.iter().map(|x| x.as_str().ok_or_else(bad)).collect::<Result<Vec<_>, _>>()?),
Some(_) => return Err(bad()),
};
Ok(Document { request: s("request")?, pricebook: s("pricebook")?, policy: s("policy")?, record: s("record")?, receipts, keys, third_party })
let deliveries = match o.get("deliveries") {
None => None,
Some(Value::Array(a)) => Some(a.iter().map(|x| delivery::Texts::from_value(x).ok_or_else(bad)).collect::<Result<Vec<_>, _>>()?),
Some(_) => return Err(bad()),
};
Ok(Document { request: s("request")?, pricebook: s("pricebook")?, policy: s("policy")?, record: s("record")?, receipts, keys, third_party, deliveries })
}
}
@ -1386,38 +1418,7 @@ pub fn verify_document(d: &Document<'_>) -> Result<Accepted, Refusal> {
}
// 6. The candidates, the order and the decision.
let routed = route(&r, &b, &p);
let stated = rec["candidates"].as_array().expect("a list");
let cat = |i: usize| at(&[json!("record"), json!("candidates"), json!(i)]);
for i in 0..stated.len().max(routed.candidates.len()) {
let (Some(s), Some(c)) = (stated.get(i), routed.candidates.get(i)) else {
return Err(Refusal::new(Reason::CandidateMismatch, cat(i)));
};
let want = c.to_value();
let differs_besides = |s: &Value| {
let strip = |v: &Value| {
let mut o = v.as_object().expect("an object").clone();
o.remove("removed");
o.remove("rank");
o
};
strip(s) != strip(&want)
};
if differs_besides(s) {
return Err(Refusal::new(Reason::CandidateMismatch, cat(i)));
}
if s["removed"] != want["removed"] {
return Err(Refusal::new(Reason::RemovalMismatch, cat(i)));
}
if s["rank"] != want["rank"] {
return Err(Refusal::new(Reason::RankMismatch, cat(i)));
}
}
if rec["order"] != json!(routed.order_endpoints()) {
return Err(Refusal::new(Reason::RankMismatch, field("order")));
}
if rec["decision"] != routed.decision() {
return Err(Refusal::new(Reason::DecisionMismatch, field("decision")));
}
check_routed(&rec, &routed, &[json!("record")])?;
// 7. The attempts.
let attempts = rec["attempts"].as_array().expect("a list");
let aat = |i: usize| at(&[json!("record"), json!("attempts"), json!(i)]);
@ -1464,6 +1465,11 @@ pub fn verify_document(d: &Document<'_>) -> Result<Accepted, Refusal> {
}
_ => None,
};
// 10. The delivery decisions.
let deliveries = match &d.deliveries {
Some(ds) => Some(delivery::check(&rec, ds)?),
None => None,
};
let served = served_hop.map(|a| (a["endpoint"].as_str().expect("a token").to_string(), a["hop"].as_u64().expect("a hop")));
let outputs = served_hop
.and_then(|a| a.get("outputs"))
@ -1488,9 +1494,54 @@ pub fn verify_document(d: &Document<'_>) -> Result<Accepted, Refusal> {
removed: routed.candidates.iter().filter(|c| !c.removed.is_empty()).map(|c| (c.entry.endpoint.clone(), c.removed.iter().map(|(l, _)| *l).collect())).collect(),
unmetered,
third_party,
deliveries,
})
}
/// §7.2 step 6 over a routed document, a record or a delivery's decision (§6.3), at `place` and
/// the member: its candidates, each in turn, then its order and its decision, as `routed` gives
/// them.
fn check_routed(rec: &Value, routed: &Routed, place: &[Value]) -> Result<(), Refusal> {
let field = |m: &[Value]| {
let mut a = place.to_vec();
a.extend_from_slice(m);
a
};
let stated = rec["candidates"].as_array().expect("a list");
let cat = |i: usize| field(&[json!("candidates"), json!(i)]);
for i in 0..stated.len().max(routed.candidates.len()) {
let (Some(s), Some(c)) = (stated.get(i), routed.candidates.get(i)) else {
return Err(Refusal::new(Reason::CandidateMismatch, cat(i)));
};
let want = c.to_value();
let differs_besides = |s: &Value| {
let strip = |v: &Value| {
let mut o = v.as_object().expect("an object").clone();
o.remove("removed");
o.remove("rank");
o
};
strip(s) != strip(&want)
};
if differs_besides(s) {
return Err(Refusal::new(Reason::CandidateMismatch, cat(i)));
}
if s["removed"] != want["removed"] {
return Err(Refusal::new(Reason::RemovalMismatch, cat(i)));
}
if s["rank"] != want["rank"] {
return Err(Refusal::new(Reason::RankMismatch, cat(i)));
}
}
if rec["order"] != json!(routed.order_endpoints()) {
return Err(Refusal::new(Reason::RankMismatch, field(&[json!("order")])));
}
if rec["decision"] != routed.decision() {
return Err(Refusal::new(Reason::DecisionMismatch, field(&[json!("decision")])));
}
Ok(())
}
/// §6: each attempt held to its hop's OER/2 receipt. The hops whose receipts state the
/// unmetered marker.
fn check_receipts(rec: &Value, texts: &[&str], keys: &Map<String, Value>) -> Result<Vec<u64>, Refusal> {
@ -1564,6 +1615,9 @@ pub fn outcome_json(r: &Result<Accepted, Refusal>) -> Value {
if let Some(t) = &a.third_party {
outcome["third_party"] = t.iter().map(third_party::read_json).collect();
}
if let Some(ds) = &a.deliveries {
outcome["deliveries"] = ds.iter().map(delivery::read_json).collect();
}
outcome
}
}

View file

@ -764,7 +764,7 @@ fn routed(request: Value, pricebook: Value, accepted: u64, attempts: &[Hop], out
/// The corpus, as `vectors.json` holds it.
pub fn generate() -> Result<String, String> {
let mut out = Vec::new();
for vec in vectors().into_iter().chain(vectors_2()).chain(vectors_3()).chain(vectors_third_party()) {
for vec in vectors().into_iter().chain(vectors_2()).chain(vectors_3()).chain(vectors_third_party()).chain(vectors_delivery()) {
let got = outcome_json(&verify(&vec.document));
if let Some((reason, at)) = &vec.expect
&& (got["outcome"] != "refuse" || got["reason"] != *reason || got["at"] != *at)
@ -1458,3 +1458,292 @@ fn vectors_third_party() -> Vec<Vector> {
v.push(output_mismatch("third_party_another_number_than_the_attempts_count", with_third_party(&counted, std::slice::from_ref(&three_record))));
v
}
// ───────────────────────────── delivery decisions (§6.3) ─────────────────────────────
/// What a delivery's routes perform: envelopes named by their content hash, ready for an origin.
const EXPORT: &str = "http.export";
/// A route's price is per gigabyte.
const GB: u64 = 1_000_000_000;
/// A route of a delivery: one way of carrying a job's output, of entry form of version 2.
#[allow(clippy::too_many_arguments)]
fn route_entry(endpoint: &str, provider: &str, place: (Option<&str>, Option<&str>), retention: u64, ga: bool, price: u64, ttfb_ms: Option<u64>, failures: Value) -> Value {
let mut e = entry(endpoint, provider, IM, place, (Some(false), Some(retention), Some(ga), Some(false)), ("bytes", GB, price), ttfb_ms, None, None);
e["operation"] = json!(EXPORT);
e["failures"] = failures;
e
}
/// The routes. The two `eu-central` caches cost the same and never failed, the second slower;
/// `cache.eu-west` is cheaper by its list price and fails a tenth of the time, each failure billed,
/// which makes it dearer per delivery served; `origin.eu` is the dearest; `mcast.eu` is a
/// preview; and `relay.us` runs in another region, its data resting there.
fn routes() -> Vec<Value> {
vec![
route_entry("cache.eu-central", "prov-cache-a", EU_C, 30, true, 20_000, Some(40), failures(1_000, 0, 0, 0)),
route_entry("cache.eu-central-2", "prov-cache-b", EU_C, 30, true, 20_000, Some(80), failures(1_000, 0, 0, 0)),
route_entry("cache.eu-west", "prov-cache-c", EU_W, 30, true, 19_000, Some(60), failures(1_000, 100, 19_000, 3_000)),
route_entry("mcast.eu", "prov-bearer", EU_W, 0, false, 5_000, None, Value::Null),
route_entry("origin.eu", "prov-origin", EU_C, 0, true, 60_000, Some(120), failures(1_000, 0, 0, 0)),
route_entry("relay.us", "prov-relay", (Some("us-east"), Some("us")), 0, true, 15_000, Some(90), failures(1_000, 0, 0, 0)),
]
}
/// A delivery's request: three gigabytes of a job's output, to `regions` with its data resting in
/// `residency`, sorted by price.
fn delivery_request(id: &str, regions: &[&str], residency: &[&str], zero_retention: bool) -> Value {
json!({
"kind": "wai-route-request", "job": id, "operation": EXPORT, "capability": IM, "fallback": null,
"quantities": [{"unit": "bytes", "quantity": 3 * GB}],
"limits": {
"regions": regions, "residency": residency, "no_training": true, "zero_retention": zero_retention, "ga_only": true,
"consent_on_file": true, "max_price": [], "spend_ceiling": null,
},
"prefer": {"sort": "price", "ttfb_ms": null, "quality": null},
"deadline_us": null,
})
}
fn to_europe(id: &str) -> Value {
delivery_request(id, &["eu-central", "eu-west"], &["eu"], false)
}
/// A delivery's documents and its sealed decision.
#[derive(Clone)]
struct Delivery {
request: Value,
pricebook: Value,
policy: Value,
decision: Value,
}
impl Delivery {
/// The `seq`-th decision of `parent`'s deliveries, after `prev`, routed over [`routes`] and
/// decided `decided` after the job was accepted.
fn new(parent: &Job, seq: u64, prev: Option<&Delivery>, request: Value, decided: u64) -> Delivery {
Delivery::over(parent, seq, prev, request, pricebook_2_of(routes()), decided)
}
fn over(parent: &Job, seq: u64, prev: Option<&Delivery>, request: Value, pricebook: Value, decided: u64) -> Delivery {
let r = Request::read(&canonical(&request)).expect("the request reads");
let b = PriceBook::read(&canonical(&pricebook)).expect("the price book reads");
let p = Policy::read(&canonical(&policy(3))).expect("the policy reads");
let decision = super::delivery::seal(&router(), &parent.record, seq, prev.map(|d| super::digest(&d.decision)), &r, &b, &p, ACCEPTED + decided);
Delivery { request, pricebook, policy: policy(3), decision }
}
fn texts(&self) -> Value {
json!({"request": canonical(&self.request), "pricebook": canonical(&self.pricebook), "policy": canonical(&self.policy), "decision": canonical(&self.decision)})
}
/// The same delivery with its decision edited and signed again.
fn edited(&self, f: impl FnOnce(&mut Value)) -> Delivery {
let mut d = self.clone();
f(&mut d.decision);
d.decision = super::delivery::resign(d.decision, &router());
d
}
}
fn with_deliveries(j: &Job, ds: &[&Delivery]) -> Value {
let mut d = j.document();
d["deliveries"] = json!(ds.iter().map(|x| x.texts()).collect::<Vec<_>>());
d
}
fn vectors_delivery() -> Vec<Vector> {
let mut v = Vec::new();
let at0 = |p: &[Value]| {
let mut a = vec![json!("deliveries"), json!(0)];
a.extend_from_slice(p);
Value::Array(a)
};
let dec0 = |m: &str| at0(&[json!("decision"), json!(m)]);
let alpha = || ("im.alpha", None, 1_000, 4_000_000, metered("im.alpha"));
let accept_delivery = |name: &'static str, document: Value| Vector { name, document, expect: None };
// ── accepted ──
// The job served three objects at 4 s; its output goes to Europe at 5 s by the faster of two
// caches of one price, and to the United States at 6 s by the one route that keeps no data
// there.
let job = Job::new_3(request_2("job-5001", ENC, no_preference()), &[alpha()], &delivered("job-5001", 3), true);
let eu = Delivery::new(&job, 1, None, to_europe("dlv-5001-eu"), 5_000_000);
let us = Delivery::new(&job, 2, Some(&eu), delivery_request("dlv-5001-us", &["us-east"], &["us"], true), 6_000_000);
v.push(accept_delivery("delivery_one_route_of_four", with_deliveries(&job, &[&eu])));
v.push(accept_delivery("delivery_two_decisions_chained", with_deliveries(&job, &[&eu, &us])));
let none = Delivery::new(&job, 1, None, delivery_request("dlv-5001-ap", &["ap-south"], &["ap"], false), 5_000_000);
v.push(accept_delivery("delivery_no_route_survives", with_deliveries(&job, &[&none])));
let listed = Job::new_2(request_2("job-5002", ENC, no_preference()), pricebook_2(), policy(3), &[alpha()], &delivered("job-5002", 2), true);
let listed_eu = Delivery::new(&listed, 1, None, to_europe("dlv-5002-eu"), 5_000_000);
v.push(accept_delivery("delivery_under_a_record_of_version_2", with_deliveries(&listed, &[&listed_eu])));
let unreceipted = Job::new_3(request_2("job-5001", ENC, no_preference()), &[alpha()], &delivered("job-5001", 3), false);
v.push(accept_delivery("delivery_receipts_not_checked", with_deliveries(&unreceipted, &[&eu])));
v.push(accept_delivery("delivery_none_given", with_deliveries(&job, &[])));
// ── refused: the document ──
let mut not_a_list = job.document();
not_a_list["deliveries"] = Value::Null;
v.push(refuse("delivery_not_a_list", not_a_list, "malformed", json!([])));
let mut no_policy = with_deliveries(&job, &[&eu]);
no_policy["deliveries"][0].as_object_mut().unwrap().remove("policy");
v.push(refuse("delivery_an_entry_without_its_policy", no_policy, "malformed", json!([])));
let mut not_text = with_deliveries(&job, &[&eu]);
not_text["deliveries"][0]["decision"] = eu.decision.clone();
v.push(refuse("delivery_a_decision_not_as_text", not_text, "malformed", json!([])));
// ── refused: the delivery's documents ──
let texts = |d: &Delivery, member: &str, f: &dyn Fn(&mut Value)| {
let mut doc = with_deliveries(&job, &[d]);
let mut x: Value = serde_json::from_str(doc["deliveries"][0][member].as_str().unwrap()).unwrap();
f(&mut x);
doc["deliveries"][0][member] = json!(canonical(&x));
doc
};
v.push(refuse("delivery_request_invalid", texts(&eu, "request", &|r| r["quantities"] = json!([])), "request_invalid", at0(&[json!("request")])));
v.push(refuse(
"delivery_pricebook_invalid",
texts(&eu, "pricebook", &|b| b["entries"].as_array_mut().unwrap().swap(0, 1)),
"pricebook_invalid",
at0(&[json!("pricebook")]),
));
v.push(refuse("delivery_policy_invalid", texts(&eu, "policy", &|p| p["max_attempts"] = json!(0)), "policy_invalid", at0(&[json!("policy")])));
// ── refused: the decision's form ──
let mut not_a_document = with_deliveries(&job, &[&eu]);
not_a_document["deliveries"][0]["decision"] = json!("{");
v.push(refuse("delivery_decision_not_a_document", not_a_document, "malformed", at0(&[json!("decision")])));
let kind = eu.edited(|d| d["kind"] = json!("wai-route-record"));
v.push(refuse("delivery_decision_of_another_kind", with_deliveries(&job, &[&kind]), "malformed", at0(&[json!("decision")])));
let v2 = eu.edited(|d| d["version"] = json!(2));
v.push(refuse("delivery_decision_of_version_2", with_deliveries(&job, &[&v2]), "unknown_version", dec0("version")));
let invalid = |name: &'static str, f: &dyn Fn(&mut Value)| refuse(name, with_deliveries(&job, &[&eu.edited(|d| f(d))]), "delivery_invalid", at0(&[json!("decision")]));
v.push(invalid("delivery_a_member_missing", &|d| {
d.as_object_mut().unwrap().remove("decided");
}));
v.push(invalid("delivery_a_member_added", &|d| d["attempts"] = json!([])));
v.push(invalid("delivery_at_seq_0", &|d| d["seq"] = json!(0)));
v.push(invalid("delivery_a_prev_in_capitals", &|d| d["prev"] = json!("AB".repeat(32))));
v.push(invalid("delivery_no_object", &|d| d["objects"]["count"] = json!(0)));
v.push(invalid("delivery_objects_with_a_member_besides", &|d| d["objects"]["bytes"] = json!(3 * GB)));
v.push(invalid("delivery_passed_by_no_key", &|d| d["passed"][0]["key"] = json!("cost")));
v.push(invalid("delivery_a_route_as_a_number", &|d| d["route"] = json!(1)));
v.push(invalid("delivery_a_candidate_of_version_1", &|d| {
let c = d["candidates"][0].as_object_mut().unwrap();
for m in ["operation", "failures", "expected_cost", "expected_ttfb_ms"] {
c.remove(m);
}
}));
v.push(invalid("delivery_decided_as_text", &|d| d["decided"] = json!("5")));
v.push(invalid("delivery_more_passed_than_a_list_holds", &|d| d["passed"] = json!(vec![json!({"endpoint": "origin.eu", "key": "price"}); 1024])));
// ── refused: its signature ──
let mut changed = eu.decision.clone();
changed["decided"] = json!(ACCEPTED + 5_000_001);
v.push(refuse("delivery_changed_after_it_was_signed", with_deliveries(&job, &[&Delivery { decision: changed, ..eu.clone() }]), "bad_signature", dec0("sig")));
let mut small = eu.decision.clone();
small["signer"] = json!(hex(&crate::sig::IDENTITY));
v.push(refuse("delivery_a_signer_of_small_order", with_deliveries(&job, &[&Delivery { decision: small, ..eu.clone() }]), "bad_key", dec0("sig")));
let mut other_domain = eu.decision.clone();
let under_the_records = router().sign(&super::signing_bytes(&other_domain));
other_domain["sig"] = json!(hex(&under_the_records.to_bytes()));
v.push(refuse("delivery_signed_under_the_route_records_domain", with_deliveries(&job, &[&Delivery { decision: other_domain, ..eu.clone() }]), "bad_signature", dec0("sig")));
// ── refused: its documents ──
let edited = |name: &'static str, reason: &'static str, m: &str, f: &dyn Fn(&mut Value)| refuse(name, with_deliveries(&job, &[&eu.edited(|d| f(d))]), reason, dec0(m));
v.push(edited("delivery_another_deliverys_id", "request_mismatch", "delivery", &|d| d["delivery"] = json!("dlv-5001-other")));
v.push(edited("delivery_another_operation", "request_mismatch", "operation", &|d| d["operation"] = json!("http.load")));
v.push(edited("delivery_another_capability", "request_mismatch", "capability", &|d| d["capability"] = json!("wai.video.channel")));
v.push(edited("delivery_a_fallback_the_request_does_not_name", "request_mismatch", "fallback", &|d| d["fallback"] = json!("wai.video.channel")));
v.push(edited("delivery_its_quantities_restated", "request_mismatch", "quantities", &|d| d["quantities"][0]["quantity"] = json!(2 * GB)));
v.push(edited("delivery_another_requests_digest", "request_mismatch", "request", &|d| d["request"] = json!("ab".repeat(32))));
v.push(edited("delivery_another_policy", "policy_mismatch", "policy", &|d| d["policy"]["version"] = json!(4)));
v.push(edited("delivery_another_policys_name", "policy_mismatch", "policy", &|d| d["policy"]["name"] = json!("fastest")));
v.push(edited("delivery_another_policys_digest", "policy_mismatch", "policy", &|d| d["policy"]["digest"] = json!("ab".repeat(32))));
v.push(edited("delivery_another_price_book", "pricebook_mismatch", "pricebook", &|d| d["pricebook"]["issued"] = json!(ISSUED + 1)));
v.push(edited("delivery_another_price_books_digest", "pricebook_mismatch", "pricebook", &|d| d["pricebook"]["digest"] = json!("ab".repeat(32))));
v.push(edited("delivery_another_currency", "pricebook_mismatch", "pricebook", &|d| d["pricebook"]["currency"] = json!("USD")));
let book_1 = pricebook();
let on_book_1 = Delivery { pricebook: book_1.clone(), ..eu.clone() }.edited(|d| d["pricebook"] = json!({"digest": hex(&super::digest(&book_1)), "currency": "EUR", "issued": book_1["issued"]}));
v.push(refuse("delivery_on_a_price_book_of_version_1", with_deliveries(&job, &[&on_book_1]), "pricebook_mismatch", dec0("pricebook")));
// ── refused: §4 over them ──
let cand = |i: usize| at0(&[json!("decision"), json!("candidates"), json!(i)]);
v.push(refuse("delivery_a_routes_price_restated", with_deliveries(&job, &[&eu.edited(|d| d["candidates"][0]["price"] = json!(10_000))]), "candidate_mismatch", cand(0)));
v.push(refuse("delivery_a_route_left_out", with_deliveries(&job, &[&eu.edited(|d| { d["candidates"].as_array_mut().unwrap().pop(); })]), "candidate_mismatch", cand(5)));
v.push(refuse(
"delivery_a_removal_left_out",
with_deliveries(&job, &[&eu.edited(|d| { d["candidates"][5]["removed"].as_array_mut().unwrap().pop(); })]),
"removal_mismatch",
cand(5),
));
v.push(refuse(
"delivery_two_ranks_swapped",
with_deliveries(&job, &[&eu.edited(|d| {
d["candidates"][0]["rank"] = json!(2);
d["candidates"][1]["rank"] = json!(1);
})]),
"rank_mismatch",
cand(0),
));
v.push(edited("delivery_the_order_reversed", "rank_mismatch", "order", &|d| d["order"].as_array_mut().unwrap().reverse()));
v.push(edited("delivery_decided_by_another_key", "decision_mismatch", "decision", &|d| d["decision"]["decided_by"] = json!("price")));
// ── refused: its route ──
v.push(edited("delivery_another_route", "delivery_route_mismatch", "route", &|d| d["route"] = json!("cache.eu-west")));
v.push(edited("delivery_no_route_while_one_survives", "delivery_route_mismatch", "route", &|d| d["route"] = Value::Null));
v.push(edited("delivery_a_route_passed_by_another_key", "delivery_route_mismatch", "passed", &|d| d["passed"][0]["key"] = json!("price")));
v.push(edited("delivery_a_passed_route_left_out", "delivery_route_mismatch", "passed", &|d| { d["passed"].as_array_mut().unwrap().pop(); }));
v.push(edited("delivery_a_removed_route_passed", "delivery_route_mismatch", "passed", &|d| d["passed"].as_array_mut().unwrap().push(json!({"endpoint": "relay.us", "key": "price"}))));
v.push(refuse(
"delivery_a_route_where_none_survives",
with_deliveries(&job, &[&none.edited(|d| d["route"] = json!("relay.us"))]),
"delivery_route_mismatch",
dec0("route"),
));
// ── refused: its parent ──
let mut unsigned = eu.decision.clone();
let o = unsigned.as_object_mut().unwrap();
o.remove("signer");
o.remove("sig");
let other_router = super::delivery::sign(unsigned, &key("another router"));
v.push(refuse("delivery_another_routers", with_deliveries(&job, &[&Delivery { decision: other_router, ..eu.clone() }]), "delivery_parent_mismatch", dec0("signer")));
v.push(edited("delivery_another_jobs", "delivery_parent_mismatch", "job", &|d| d["job"] = json!("job-5009")));
v.push(edited("delivery_another_parent", "delivery_parent_mismatch", "parent", &|d| d["parent"] = json!(hex(&super::digest(&listed.record)))));
v.push(edited("delivery_another_count", "delivery_parent_mismatch", "objects", &|d| d["objects"]["count"] = json!(2)));
v.push(edited("delivery_another_root", "delivery_parent_mismatch", "objects", &|d| d["objects"]["root"] = json!(hex(&super::output_commitment(&delivered("job-5001", 2))))));
// A parent whose served hop delivered nothing: the decision names it, and its objects are none
// the record states.
let unserved = Job::new_3(
request_2("job-5003", ENC, no_preference()),
&[("im.alpha", Some("timeout"), 1_000, 3_000_000, failed("im.alpha")), ("im.eta", Some("unavailable"), 3_000_000, 3_500_000, failed("im.eta")), ("im.iota", Some("refused"), 3_500_000, 3_600_000, failed("im.iota"))],
&[],
true,
);
let naming = |parent: &Job| {
eu.edited(|d| {
d["job"] = parent.record["job"].clone();
d["parent"] = json!(hex(&super::digest(&parent.record)));
})
};
v.push(refuse("delivery_of_a_job_no_hop_served", with_deliveries(&unserved, &[&naming(&unserved)]), "delivery_parent_mismatch", dec0("objects")));
let first = base();
v.push(refuse("delivery_of_a_record_of_version_1", with_deliveries(&first, &[&naming(&first)]), "delivery_parent_mismatch", dec0("objects")));
// ── refused: its chain ──
let at1 = |m: &str| json!(["deliveries", 1, "decision", m]);
v.push(refuse("delivery_first_at_seq_2", with_deliveries(&job, &[&eu.edited(|d| d["seq"] = json!(2))]), "delivery_chain_broken", dec0("seq")));
v.push(refuse("delivery_first_naming_a_prev", with_deliveries(&job, &[&eu.edited(|d| d["prev"] = json!(hex(&super::digest(&us.decision))))]), "delivery_chain_broken", dec0("prev")));
v.push(refuse("delivery_second_with_no_prev", with_deliveries(&job, &[&eu, &us.edited(|d| d["prev"] = Value::Null)]), "delivery_chain_broken", at1("prev")));
v.push(refuse("delivery_second_naming_another", with_deliveries(&job, &[&eu, &us.edited(|d| d["prev"] = json!(hex(&super::digest(&none.decision))))]), "delivery_chain_broken", at1("prev")));
v.push(refuse("delivery_given_out_of_order", with_deliveries(&job, &[&us, &eu]), "delivery_chain_broken", dec0("seq")));
v.push(refuse("delivery_one_left_out", with_deliveries(&job, &[&us]), "delivery_chain_broken", dec0("seq")));
let early = Delivery::new(&job, 1, None, to_europe("dlv-5001-eu"), 3_999_999);
v.push(refuse("delivery_decided_before_the_output", with_deliveries(&job, &[&early]), "delivery_chain_broken", dec0("decided")));
let before = Delivery::new(&job, 2, Some(&eu), delivery_request("dlv-5001-us", &["us-east"], &["us"], true), 4_999_999);
v.push(refuse("delivery_decided_before_the_one_before", with_deliveries(&job, &[&eu, &before]), "delivery_chain_broken", at1("decided")));
let again = Delivery::new(&job, 2, Some(&eu), to_europe("dlv-5001-eu"), 6_000_000);
v.push(refuse("delivery_one_delivery_decided_twice", with_deliveries(&job, &[&eu, &again]), "delivery_chain_broken", at1("delivery")));
v
}

View file

@ -0,0 +1,304 @@
//! The delivery-decision record (extensions/route-record.md §6.3): why a delivery of a job's output
//! took the route it took.
//!
//! A delivery is routed as a job is, from its own request, price book (of version 2) and policy,
//! by [`super::route`]: the candidates are the routes that could carry it. Its record restates
//! every route with the figures its price book stated, every limit that removed one, the order and
//! the decision, the route taken, and for every other survivor the key that ranked the route taken
//! above it ([`passed`]); and it names its inputs by digest: the delivery's three documents and, as
//! its `parent`, the job's route record. Signed by the job's router, it is chained: each decision
//! after a job's first names the one before it by `prev`. [`check`] holds a job's decisions, in
//! order, to their documents, to §4 over them, to their parent and to their chain (§7.2 step 10).
use std::cmp::Ordering;
use std::collections::BTreeSet;
use ed25519_dalek::{Signer, SigningKey};
use serde_json::{json, Value};
use super::{
at, by_key, candidate_form, canonical, capability, check_routed, currency, digest, exactly, hex, int, operation, output_commitment, quantities, read_document, route, tok, unhex, Candidate,
PriceBook, Policy, Reason, Refusal, Request, Routed, DECIDED_BY, VERSION_2,
};
pub const KIND: &str = "wai-route-delivery-decision";
pub const VERSION: u64 = 1;
/// The domain a record is signed under (§6.3).
pub const SIGN_DOMAIN: &[u8] = b"wai:route-delivery-decision\x01";
/// The members of a record (§6.3).
pub const MEMBERS: [&str; 23] = [
"kind", "version", "job", "parent", "seq", "prev", "objects", "delivery", "operation", "capability", "fallback", "quantities", "request", "policy", "pricebook", "candidates",
"order", "decision", "route", "passed", "decided", "signer", "sig",
];
/// The keys a survivor may be passed over by: those of a key list (§4.4).
pub const PASSED_KEYS: [&str; 6] = ["targets", "price", "ttfb", "energy", "quality", "endpoint"];
/// A delivery's texts in a verification document (§7.1): its three documents and its decision.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Texts<'a> {
pub request: &'a str,
pub pricebook: &'a str,
pub policy: &'a str,
pub decision: &'a str,
}
impl<'a> Texts<'a> {
/// An object of exactly `request`, `pricebook`, `policy` and `decision`, each a string.
pub fn from_value(v: &'a Value) -> Option<Texts<'a>> {
let o = exactly(v, &["request", "pricebook", "policy", "decision"])?;
Some(Texts { request: o["request"].as_str()?, pricebook: o["pricebook"].as_str()?, policy: o["policy"].as_str()?, decision: o["decision"].as_str()? })
}
}
/// What a verification reads of a decision that holds (§7.3).
#[derive(Clone, Debug, PartialEq)]
pub struct Read {
pub seq: u64,
pub delivery: String,
pub digest: [u8; 32],
/// The route the delivery took, or `None` when no route survived.
pub route: Option<String>,
pub decision: Value,
/// Each later survivor's endpoint, with the key it was passed over by.
pub passed: Vec<(String, &'static str)>,
}
/// For each survivor after the first, in the order, its endpoint and the first key of the key list
/// on which it and the first differ (§6.3 `passed`).
pub fn passed(r: &Request, routed: &Routed) -> Vec<(String, &'static str)> {
let Some((&first, rest)) = routed.order.split_first() else { return Vec::new() };
let a: &Candidate = &routed.candidates[first];
rest.iter()
.map(|&i| {
let b = &routed.candidates[i];
let k = routed.key_list.iter().find(|k| by_key(r, k, a, b) != Ordering::Equal).expect("endpoints differ");
(b.entry.endpoint.clone(), PASSED_KEYS.iter().copied().find(|x| x == k).expect("a key of the key list"))
})
.collect()
}
/// What a route record's served attempt delivered (§6.3 *its parent*): the count and the
/// commitment of its outputs, and its `end`. `None` for a record of version 1, which states no
/// output, and for one no hop served.
pub fn delivered_by(record: &Value) -> Option<(u64, String, u64)> {
let a = record["attempts"].as_array()?.last().filter(|a| a["outcome"] == "served")?;
let end = a["end"].as_u64()?;
if let Some(d) = a.get("delivered") {
return Some((d["count"].as_u64()?, d["root"].as_str()?.to_string(), end));
}
let hashes = a.get("outputs")?.as_array()?.iter().map(|x| unhex::<32>(x.as_str()?)).collect::<Option<Vec<_>>>()?;
Some((hashes.len() as u64, hex(&output_commitment(&hashes)), end))
}
/// The bytes a record is signed over (§6.3): the domain, then the canonical form of the record
/// without `sig`.
pub fn signing_bytes(record: &Value) -> Vec<u8> {
let mut r = record.clone();
if let Some(o) = r.as_object_mut() {
o.remove("sig");
}
let mut m = SIGN_DOMAIN.to_vec();
m.extend_from_slice(canonical(&r).as_bytes());
m
}
/// Sign a record body with the router's `key`: sets `signer` and `sig`.
pub fn sign(mut body: Value, key: &SigningKey) -> Value {
let o = body.as_object_mut().expect("a record is an object");
o.insert("signer".into(), json!(hex(&key.verifying_key().to_bytes())));
o.insert("sig".into(), json!(""));
let sig = key.sign(&signing_bytes(&body));
body["sig"] = json!(hex(&sig.to_bytes()));
body
}
/// Sign a record again over whatever it states, keeping its `signer`: for the corpus's controls.
#[doc(hidden)]
pub fn resign(mut body: Value, key: &SigningKey) -> Value {
body["sig"] = json!("");
let sig = key.sign(&signing_bytes(&body));
body["sig"] = json!(hex(&sig.to_bytes()));
body
}
/// The decision of a delivery of the output of the job whose route record is `parent` (§6.3):
/// routed from the delivery's three documents, the `seq`-th of the job's, after the decision whose
/// digest is `prev`, decided at `decided`, signed by `key`. `parent` is a record whose served hop
/// delivered (version 2 or 3); [`check`] holds the rest.
#[allow(clippy::too_many_arguments)]
pub fn seal(key: &SigningKey, parent: &Value, seq: u64, prev: Option<[u8; 32]>, r: &Request, b: &PriceBook, p: &Policy, decided: u64) -> Value {
let routed = route(r, b, p);
let order = routed.order_endpoints();
let (count, root, _) = delivered_by(parent).expect("a record whose served hop delivered");
let body = json!({
"kind": KIND,
"version": VERSION,
"job": parent["job"],
"parent": hex(&digest(parent)),
"seq": seq,
"prev": prev.map(|d| hex(&d)),
"objects": {"count": count, "root": root},
"delivery": r.job,
"operation": r.operation,
"capability": r.capability,
"fallback": r.fallback,
"quantities": r.doc["quantities"],
"request": hex(&r.digest),
"policy": {"name": p.name, "version": p.version, "digest": hex(&p.digest)},
"pricebook": {"digest": hex(&b.digest), "currency": b.currency, "issued": b.issued},
"candidates": routed.candidates.iter().map(Candidate::to_value).collect::<Vec<_>>(),
"order": order,
"decision": routed.decision(),
"route": order.first(),
"passed": passed_json(&passed(r, &routed)),
"decided": decided,
});
sign(body, key)
}
fn passed_json(p: &[(String, &'static str)]) -> Value {
Value::Array(p.iter().map(|(e, k)| json!({"endpoint": e, "key": k})).collect())
}
fn hexn(v: &Value, n: usize) -> bool {
v.as_str().is_some_and(|s| s.len() == n && s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)))
}
/// A record of §6.3's members, each of its own form. Relations between members are §7.2 step 10's.
pub fn form(d: &Value) -> bool {
let Some(o) = exactly(d, &MEMBERS) else { return false };
let tok_or_null = |v: &Value| v.is_null() || tok(v).is_some();
o["version"] == json!(VERSION)
&& tok(&o["job"]).is_some()
&& hexn(&o["parent"], 64)
&& int(&o["seq"]).is_some_and(|n| n >= 1)
&& (o["prev"].is_null() || hexn(&o["prev"], 64))
&& exactly(&o["objects"], &["count", "root"]).is_some_and(|x| int(&x["count"]).is_some_and(|n| n >= 1) && hexn(&x["root"], 64))
&& tok(&o["delivery"]).is_some()
&& o["operation"].as_str().is_some_and(operation)
&& o["capability"].as_str().is_some_and(capability)
&& (o["fallback"].is_null() || o["fallback"].as_str().is_some_and(capability))
&& quantities(&o["quantities"]).is_some()
&& hexn(&o["request"], 64)
&& exactly(&o["policy"], &["name", "version", "digest"]).is_some_and(|p| tok(&p["name"]).is_some() && int(&p["version"]).is_some_and(|v| v >= 1) && hexn(&p["digest"], 64))
&& exactly(&o["pricebook"], &["digest", "currency", "issued"])
.is_some_and(|p| hexn(&p["digest"], 64) && p["currency"].as_str().is_some_and(currency) && int(&p["issued"]).is_some())
&& o["candidates"].as_array().is_some_and(|c| c.len() <= 1024 && c.iter().all(|c| candidate_form(c, VERSION_2)))
&& o["order"].as_array().is_some_and(|x| x.len() <= 1024 && x.iter().all(|e| tok(e).is_some()))
&& exactly(&o["decision"], &["decided_by", "runner_up"]).is_some_and(|d| d["decided_by"].as_str().is_some_and(|x| DECIDED_BY.contains(&x)) && tok_or_null(&d["runner_up"]))
&& tok_or_null(&o["route"])
&& o["passed"].as_array().is_some_and(|ps| {
ps.len() <= 1023 && ps.iter().all(|x| exactly(x, &["endpoint", "key"]).is_some_and(|x| tok(&x["endpoint"]).is_some() && x["key"].as_str().is_some_and(|k| PASSED_KEYS.contains(&k))))
})
&& int(&o["decided"]).is_some()
&& hexn(&o["signer"], 64)
&& hexn(&o["sig"], 128)
}
/// §7.2 step 10: a job's decisions, from the first and in order, each read, its signature checked
/// and held to its documents, to §4 over them, to its parent `rec` and to its chain.
pub(super) fn check(rec: &Value, deliveries: &[Texts<'_>]) -> Result<Vec<Read>, Refusal> {
let parent = delivered_by(rec);
let parent_digest = hex(&digest(rec));
let mut read: Vec<Read> = Vec::new();
let mut before: Option<(String, u64)> = None;
let mut seen = BTreeSet::new();
for (i, t) in deliveries.iter().enumerate() {
let place = |m: &[Value]| {
let mut a = vec![json!("deliveries"), json!(i)];
a.extend_from_slice(m);
at(&a)
};
let member = |m: &str| place(&[json!("decision"), json!(m)]);
let r = Request::read(t.request).map_err(|e| Refusal::new(e, place(&[json!("request")])))?;
let b = PriceBook::read(t.pricebook).map_err(|e| Refusal::new(e, place(&[json!("pricebook")])))?;
let p = Policy::read(t.policy).map_err(|e| Refusal::new(e, place(&[json!("policy")])))?;
let d = read_document(t.decision).ok_or_else(|| Refusal::new(Reason::Malformed, place(&[json!("decision")])))?;
if d.get("kind").and_then(Value::as_str) != Some(KIND) {
return Err(Refusal::new(Reason::Malformed, place(&[json!("decision")])));
}
if d.get("version").and_then(int).is_some_and(|v| v != VERSION) {
return Err(Refusal::new(Reason::UnknownVersion, member("version")));
}
if !form(&d) {
return Err(Refusal::new(Reason::DeliveryInvalid, place(&[json!("decision")])));
}
let signer: [u8; 32] = unhex(d["signer"].as_str().expect("a key")).expect("a key");
let sig: [u8; 64] = unhex(d["sig"].as_str().expect("a signature")).expect("a signature");
crate::sig::verify_strict(&signer, &signing_bytes(&d), &sig).map_err(|e| {
let reason = match e {
crate::sig::SigError::BadKey => Reason::BadKey,
crate::sig::SigError::BadSignature => Reason::BadSignature,
};
Refusal::new(reason, member("sig"))
})?;
// Its documents.
for (m, want) in [
("delivery", json!(r.job)),
("operation", json!(r.operation)),
("capability", json!(r.capability)),
("fallback", json!(r.fallback)),
("quantities", r.doc["quantities"].clone()),
("request", json!(hex(&r.digest))),
] {
if d[m] != want {
return Err(Refusal::new(Reason::RequestMismatch, member(m)));
}
}
if d["policy"] != json!({"name": p.name, "version": p.version, "digest": hex(&p.digest)}) {
return Err(Refusal::new(Reason::PolicyMismatch, member("policy")));
}
if b.version != VERSION_2 || d["pricebook"] != json!({"digest": hex(&b.digest), "currency": b.currency, "issued": b.issued}) {
return Err(Refusal::new(Reason::PricebookMismatch, member("pricebook")));
}
// §4 over them, then the route.
let routed = route(&r, &b, &p);
check_routed(&d, &routed, &place(&[json!("decision")]))?;
let order = routed.order_endpoints();
if d["route"] != json!(order.first()) {
return Err(Refusal::new(Reason::DeliveryRouteMismatch, member("route")));
}
let why = passed(&r, &routed);
if d["passed"] != passed_json(&why) {
return Err(Refusal::new(Reason::DeliveryRouteMismatch, member("passed")));
}
// Its parent.
for m in ["signer", "job"] {
if d[m] != rec[m] {
return Err(Refusal::new(Reason::DeliveryParentMismatch, member(m)));
}
}
if d["parent"] != json!(parent_digest) {
return Err(Refusal::new(Reason::DeliveryParentMismatch, member("parent")));
}
let Some((count, root, served_end)) = &parent else { return Err(Refusal::new(Reason::DeliveryParentMismatch, member("objects"))) };
if d["objects"] != json!({"count": count, "root": root}) {
return Err(Refusal::new(Reason::DeliveryParentMismatch, member("objects")));
}
// Its chain.
if d["seq"] != json!(i + 1) {
return Err(Refusal::new(Reason::DeliveryChainBroken, member("seq")));
}
if d["prev"] != json!(before.as_ref().map(|(h, _)| h)) {
return Err(Refusal::new(Reason::DeliveryChainBroken, member("prev")));
}
let decided = d["decided"].as_u64().expect("a time");
if decided < *served_end || before.as_ref().is_some_and(|(_, t)| decided < *t) {
return Err(Refusal::new(Reason::DeliveryChainBroken, member("decided")));
}
if !seen.insert(r.job.clone()) {
return Err(Refusal::new(Reason::DeliveryChainBroken, member("delivery")));
}
let dg = digest(&d);
before = Some((hex(&dg), decided));
read.push(Read { seq: i as u64 + 1, delivery: r.job.clone(), digest: dg, route: order.first().cloned(), decision: routed.decision(), passed: why });
}
Ok(read)
}
/// A read decision's outcome (§7.3).
pub fn read_json(r: &Read) -> Value {
json!({"seq": r.seq, "delivery": r.delivery, "digest": hex(&r.digest), "route": r.route, "decision": r.decision, "passed": passed_json(&r.passed)})
}