diff --git a/.github/workflows/wai-catalogue.yml b/.github/workflows/wai-catalogue.yml
index c79832c9b..5113c0fc6 100644
--- a/.github/workflows/wai-catalogue.yml
+++ b/.github/workflows/wai-catalogue.yml
@@ -30,6 +30,7 @@ on:
- 'wai/live-conformance/fleet/**'
# tests/cli_ops.rs builds the revenue share's --dry-run sample from this corpus.
- 'wai/live-conformance/share/**'
+ - 'wai/live-conformance/channels/**'
- 'wai/REGISTRY.md'
- 'wai/LICENSE'
- 'wai/wai-quantum/**'
@@ -52,6 +53,7 @@ on:
- 'wai/live-conformance/fleet/**'
# tests/cli_ops.rs builds the revenue share's --dry-run sample from this corpus.
- 'wai/live-conformance/share/**'
+ - 'wai/live-conformance/channels/**'
- 'wai/REGISTRY.md'
- 'wai/LICENSE'
- 'wai/wai-quantum/**'
diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml
index 61b0b8f7c..7cf3f2623 100644
--- a/.github/workflows/wai-conformance.yml
+++ b/.github/workflows/wai-conformance.yml
@@ -142,6 +142,10 @@ on:
# reference adds, stitched_presentation_verify.py reads that table, and the registry's
# wire identifiers test reads its sections.
- 'wai/extensions/stitched-presentation.md'
+ # tests/channel_lineup.rs requires this extension's §10 to list the codes the reference
+ # adds and runs its §9's commands as the text writes them, channel_lineup_verify.py reads
+ # its §10, and the registry's wire identifiers test reads its sections.
+ - 'wai/extensions/channel-lineup.md'
# tests/live_provenance.rs requires this extension's §12 to list the codes and the C2PA
# codes the reference reports, c2pa_live_verify.py reads that table, and the registry's
# wire identifiers test reads its sections.
@@ -1164,7 +1168,7 @@ jobs:
name: independent verifiers · claims, JWP, key discovery and integer payloads (Python)
runs-on: linux-arm64
env:
- WAI_FEATURES: key_discovery,http,video_manifest,stage_measure,live_events,live_room,capture_take,live_compose,live_record,timed_text,access_services,live_metering,energy_steering,live_moderation,dred_exact,live_ads,live_provenance,synthesis,synthesis_mark,neural_int,exact_upscale,robustness,ai_usage,contribution,settlement,browser_sink,receipt_claims,takedown,influence,direction,co_performance,age_assurance,bearer_receipts,exploration,co_watch,telemetry_aggregate,exact_tonemap,splat_exact,live_watermark,derive_edge,sink_energy,key_release,stitched_presentation,prior_wire,scene_map,avatar_face,receipt_ledger,stream_audit,fleet,native_sink,platform_encode,remote_desktop,stream_termination,live_world,format_probe,live_messaging,conform_result,revenue_share,opportunity_report
+ WAI_FEATURES: key_discovery,http,video_manifest,stage_measure,live_events,live_room,capture_take,live_compose,live_record,timed_text,access_services,live_metering,energy_steering,live_moderation,dred_exact,live_ads,live_provenance,synthesis,synthesis_mark,neural_int,exact_upscale,robustness,ai_usage,contribution,settlement,browser_sink,receipt_claims,takedown,influence,direction,co_performance,age_assurance,bearer_receipts,exploration,co_watch,telemetry_aggregate,exact_tonemap,splat_exact,live_watermark,derive_edge,sink_energy,key_release,stitched_presentation,prior_wire,scene_map,avatar_face,receipt_ledger,stream_audit,fleet,native_sink,platform_encode,remote_desktop,stream_termination,live_world,format_probe,live_messaging,conform_result,revenue_share,opportunity_report,channel_lineup
WAI_PQ_FEATURES: synthesis,pq_signatures
WAI_RELEASE_FEATURES: dred_exact,synthesis_mark,robustness,avatar_face
steps:
@@ -1234,11 +1238,13 @@ jobs:
--bin wai_http_vectors --bin wai_stage_measure --bin wai_browser_sink_vectors \
--bin wai_claim_vectors --bin wai_jwp_vectors --bin wai_signer_vectors \
--bin wai_sink_energy_vectors --bin wai_stitched_presentation \
- --bin wai_stream_termination_vectors --bin wai_format_probe --bin wai_conform_result_vectors
+ --bin wai_stream_termination_vectors --bin wai_format_probe --bin wai_conform_result_vectors \
+ --bin wai_channel_lineup_vectors
for b in wai_http_vectors wai_stage_measure wai_browser_sink_vectors \
wai_claim_vectors wai_jwp_vectors wai_signer_vectors \
wai_sink_energy_vectors wai_stitched_presentation \
- wai_stream_termination_vectors wai_format_probe wai_conform_result_vectors; do
+ wai_stream_termination_vectors wai_format_probe wai_conform_result_vectors \
+ wai_channel_lineup_vectors; do
cp "${CARGO_TARGET_DIR:-target}/debug/$b" "$RUNNER_TEMP/wai-bins/union/"
done
# Copied before this build replaces target/debug's copies of the three it shares.
@@ -1259,7 +1265,7 @@ jobs:
--test stitched_presentation --test prior_wire --test scene_map --test receipt_ledger \
--test stream_audit --test fleet --test native_sink --test platform_encode --test remote_desktop \
--test stream_termination --test live_world --test format_probe --test live_messaging \
- --test conform_result --test revenue_share --test opportunity_report
+ --test conform_result --test revenue_share --test opportunity_report --test channel_lineup
cargo test --release --no-run --no-default-features --features "$WAI_RELEASE_FEATURES" \
--test dred_exact --test dred_exact_measure --test synthesis_mark --test robustness \
--test avatar_face
@@ -1720,6 +1726,25 @@ jobs:
(cd wai-rs && "$RUNNER_TEMP/wai-bins/union/wai_stitched_presentation" vectors verify ../live-conformance/stitched)
python3 -I live-conformance/stitched_presentation_verify.py live-conformance/stitched
node --test ../joule-code/joule-code-rs/crates/joule-moq-web/web/ads/stitched.test.mjs
+ # wai.video.lineup (extensions/channel-lineup.md): the reference module's unit tests (times,
+ # the document type declaration, the reasons) and tests/channel_lineup.rs (every file of the
+ # committed corpus regenerates byte for byte from its fixed seeds, and no other file is
+ # there; wai-rs gives every import and every verification its outcome; §10 lists exactly
+ # the reference's codes; §9's commands, run as the text writes them through `wai`, yield
+ # the corpus's receipt, lineup, cues and guides, and a refusal is its reason with exit 1);
+ # then the corpus's second implementation, channel_lineup_verify.py, written from the
+ # extension's text and sharing no code with wai-rs, which reimports every vector, requires
+ # every outcome and fact, and requires every mutated copy of the corpus to fail and every
+ # check it drops to be caught.
+ - name: channel lineup · wai-rs and channel_lineup_verify.py
+ working-directory: wai
+ shell: bash
+ run: |
+ set -euo pipefail
+ (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib channel_lineup::)
+ (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test channel_lineup)
+ (cd wai-rs && "$RUNNER_TEMP/wai-bins/union/wai_channel_lineup_vectors" verify ../live-conformance/channels)
+ python3 -I live-conformance/channel_lineup_verify.py live-conformance/channels
# wai.live.provenance (extensions/live-provenance.md): the reference module's unit tests
# (boxes, chunks, emsg and the BMFF hash; the JUMBF store; COSE_Sign1 and the OKP key;
# certificates written and read, their profile and chain; identifiers, track renderings,
@@ -2719,7 +2744,7 @@ jobs:
synthesis,pq_signatures sink_energy key_release stitched_presentation prior_wire \
prior_wire,neural_int scene_map avatar_face receipt_ledger stream_audit fleet native_sink \
platform_encode remote_desktop stream_termination live_world format_probe live_messaging \
- conform_result revenue_share opportunity_report; do
+ conform_result revenue_share opportunity_report channel_lineup; do
echo "::group::$f"
cargo check --no-default-features --features "$f" --tests
echo "::endgroup::"
diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md
index 09483e960..b3939a7ba 100644
--- a/wai/CHANGELOG.md
+++ b/wai/CHANGELOG.md
@@ -63,6 +63,33 @@ wire format may change and such changes are recorded here.
verifies each ledger document with `receipt_ledger_verify.py` and recomputes every attribution, line, division and
citation itself.
+### Channel lineup and guide: a linear channel's feeds, signed, and its guide checkable
+
+- **`wai.video.lineup`** (`extensions/channel-lineup.md`): a linear channel's signed lineup,
+ `WLU1`, of one `wai.video.channel` schedule, bound to it by the schedule root: each programme
+ asset's title, description, categories, rating, artwork by SHA-256, where it may be shown and its
+ rights holder, and where each programme of the guide begins.
+- **The import**: an XMLTV schedule, Media RSS catalogues (one owner's items alone play back to
+ back from a start), or a JSON form of both, with the SCTE-35 date ranges of the channel's HLS
+ Media Playlist as its breaks, into the schedule's receipt, the lineup and a live-events `cue`
+ for each break (its `cue_id` a `splice_insert()`'s `splice_event_id`). Bounded reading, an XML
+ subset with one document type declaration that defines nothing, and reasons for a gap, an
+ overlap, a programme with no stop, a time zone named by letters, a break outside its programme,
+ a window outside the schedule, an unknown asset and the rest of §10.
+- **The guide**: an XMLTV document and a now/next document (RFC 8785), rendered from the receipt
+ and the lineup and naming both; a verification recomputes it, refuses one that places a
+ programme the schedule does not or states what the lineup does not, and says of each programme
+ whether the receipt's program-as-run says it aired.
+- **The receipt's JSON carriage** is revenue-share §4.1's, `ChannelReceipt::to_json` and
+ `from_json`, the one JSON form of a channel receipt: the import writes it and a verification
+ reads it; `wai.video.channel` receipts keep their signed bytes.
+- **Operations** `channel.import`, `channel.guide` and `channel.verify` (`wai channel ...`), with
+ the agent rules; `quickstart`, the shipped `wai`'s build, holds the feature `channel_lineup`.
+- **Conformance**: `live-conformance/channels/`, a sample channel of three owners with a control
+ of each reason, written from fixed seeds by `wai_channel_lineup_vectors`; its second
+ implementation, `channel_lineup_verify.py`, from the text alone, agrees on every vector, and a
+ CI step runs both.
+
### wai-quantum for JavaScript: the engine as an npm package
- **`@transaction-science/wai-quantum` 0.1.0** (`wai/wai-quantum-js/`): the job service's engine for WebAssembly
diff --git a/wai/REGISTRY.md b/wai/REGISTRY.md
index 1b3774104..43b72515a 100644
--- a/wai/REGISTRY.md
+++ b/wai/REGISTRY.md
@@ -313,6 +313,7 @@ and a role are fixed once registered.
| `wai.video.jxs` | payload | registered | video | standalone | patent-pool | standard-defined | — | §5 Immersive audio, cinema, volumetric, haptics, HDR |
| `wai.video.keys` | record | registered | — | — | — | — | — | §5 Neural capabilities |
| `wai.video.lcevc` | companion | registered | video | enhancement | patent-pool | standard-unclassified | — | §5 Broadcast and streaming codecs |
+| `wai.video.lineup` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.video.manifest` | record | registered | — | — | — | — | — | §5 Neural capabilities |
| `wai.video.mvhevc` | payload | registered | video | standalone | patent-pool | standard-defined | — | §5 Broadcast and streaming codecs |
| `wai.video.package` | record | registered | — | — | — | — | — | §5 Neural capabilities |
@@ -406,6 +407,7 @@ and a role are fixed once registered.
| `WKT1` | magic | defined | key-release §9.3 |
| `WLI1` | magic | defined | receipt-ledger §6.1 |
| `WLT1` | magic | defined | receipt-ledger §4.1 |
+| `WLU1` | magic | defined | channel-lineup §6.1 |
| `WMA1` | magic | defined | live-messaging §8 |
| `WMC1` | magic | defined | live-messaging §3.1 |
| `WME1` | magic | defined | live-messaging §7.3 |
@@ -501,11 +503,13 @@ and a role are fixed once registered.
| `urn:wai:asset:` | scheme | defined | scene-carriage §2.2 |
| `urn:wai:event:1` | scheme | defined | live-events §8.4 |
| `urn:wai:event:1` | catalog | defined | live-events §8.1 |
+| `urn:wai:guide:` | scheme | defined | channel-lineup §7.2 |
| `wai` | catalog | defined | moq-streaming-format §3 |
| `wai-access-completeness` | document-kind | defined | access-services §7.1 |
| `wai-age-admissions` | document-kind | defined | age-assurance §7.1 |
| `wai-age-policy` | document-kind | defined | age-assurance §4.1 |
| `wai-archive` | document-kind | defined | exploration §5.3 |
+| `wai-asset-id` | scheme | defined | channel-lineup §7.2 |
| `wai-attack:` | scheme | defined | robustness-evidence §4 |
| `wai-attribution` | document-kind | defined | influence §5.3 |
| `wai-authorship-map` | document-kind | defined | contribution §7.3 |
@@ -514,6 +518,7 @@ and a role are fixed once registered.
| `wai-caption-reference` | document-kind | defined | access-services §4.3 |
| `wai-caption-sources` | document-kind | defined | access-services §4.2 |
| `wai-catalogue` | document-kind | defined | influence §3.3 |
+| `wai-channel-feed` | document-kind | defined | channel-lineup §4.4 |
| `wai-collection` | document-kind | defined | exploration §7.1 |
| `wai-conditioning` | document-kind | defined | influence §4.1 |
| `wai-contribution` | document-kind | defined | contribution §6 |
@@ -535,6 +540,7 @@ and a role are fixed once registered.
| `wai-membership` | document-kind | defined | contribution §3.2 |
| `wai-module-grant` | document-kind | defined | jwp-receipts §7.9 |
| `wai-module-grants` | document-kind | defined | http-delivery §8.4 |
+| `wai-now-next` | document-kind | defined | channel-lineup §7.3 |
| `wai-origin` | document-kind | defined | synthesis §3.1 |
| `wai-pick` | document-kind | defined | exploration §10.2 |
| `wai-player` | document-kind | defined | synthesis §10.3 |
diff --git a/wai/SPEC.md b/wai/SPEC.md
index c8eb47ca1..5beb4edc5 100644
--- a/wai/SPEC.md
+++ b/wai/SPEC.md
@@ -1302,6 +1302,21 @@ spend that counts, by currency; an ad whose segments were fetched ahead of its b
presented has no claim and counts nothing:
[`extensions/stitched-presentation.md`](extensions/stitched-presentation.md).
+And for a **linear channel's lineup and guide** — `wai.video.lineup`: the channel operator's
+signed lineup, `WLU1`, of one `wai.video.channel` schedule, bound to it by the schedule root: each
+programme asset's title, description, categories, rating, artwork by content hash, the countries
+it may be shown in and its rights holder, and where each programme of the guide begins. A channel
+as operators exchange it (an XMLTV schedule, Media RSS catalogues or a JSON form of both, with the
+SCTE-35 date ranges of its HLS Media Playlist as its ad breaks) is imported into the schedule's
+receipt, whose signed bytes are unchanged, the lineup, and a live-events `cue` for each break. The
+import reads bounded documents over an XML subset that defines no entity, and refuses, each with
+its reason, a gap, an overlap, a programme with no stop, a time zone named by letters, a break
+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:
+[`extensions/channel-lineup.md`](extensions/channel-lineup.md).
+
And for **watching together** in a room — `wai.live.cowatch`: people in different places
watching one source in sync, with the sync measured. The room authority signs a co-watch
policy, `WXP1`, naming the source (a WAI package, a sequence of on-demand objects, or another
diff --git a/wai/catalogue/ops.json b/wai/catalogue/ops.json
index 2c17f0756..ab724ed11 100644
--- a/wai/catalogue/ops.json
+++ b/wai/catalogue/ops.json
@@ -3301,6 +3301,396 @@
"sdk": "wai.report.export"
}
},
+ {
+ "id": "channel.import",
+ "summary": "A linear channel's feeds (an XMLTV schedule, Media RSS catalogues or the JSON form, and a Media Playlist's SCTE-35 date ranges) imported into its signed receipt, its signed lineup and its breaks' cues",
+ "cli": "wai channel import",
+ "aliases": [],
+ "usage": [
+ "wai channel import --channel ID (--xmltv F [--mrss F]... | --mrss F... | --feed F) [--playlist F] --key K",
+ " [--mrss-owner O]... [--owner O] [--signer-id S] [--id-system N] [--xmltv-channel X] [--start-ms T] [--artwork DIR]",
+ " [--window-start T --window-end T] [--parent HEX] [--out-dir D] [--yes] the receipt (channel.json), the lineup",
+ " (lineup.wlu) and the cues (cues.json), written to D (.); a refusal's code is its reason; replacing a file waits",
+ " for --yes (--confirm, deprecated, is the same)"
+ ],
+ "placement": "local",
+ "effect": "writes-project",
+ "dry_run": true,
+ "confirm": "when-replacing-a-file",
+ "route": null,
+ "doors": {
+ "cli": true,
+ "mcp": false,
+ "http": false
+ },
+ "toolset": "core",
+ "stability": "preview",
+ "input": {
+ "type": "object",
+ "properties": {
+ "channel": {
+ "type": "string",
+ "description": "The channel id.",
+ "x-cli": {
+ "flag": "--channel"
+ }
+ },
+ "xmltv": {
+ "type": "string",
+ "format": "path",
+ "description": "An XMLTV document: the schedule.",
+ "x-cli": {
+ "flag": "--xmltv"
+ }
+ },
+ "mrss": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "A Media RSS document: a catalogue. Repeated.",
+ "x-cli": {
+ "flag": "--mrss"
+ }
+ },
+ "mrss_owner": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "The rights holder of each --mrss document's items, in their order: none, or one each.",
+ "x-cli": {
+ "flag": "--mrss-owner"
+ }
+ },
+ "feed": {
+ "type": "string",
+ "format": "path",
+ "description": "The JSON form of a schedule, its catalogue and its breaks.",
+ "x-cli": {
+ "flag": "--feed"
+ }
+ },
+ "playlist": {
+ "type": "string",
+ "format": "path",
+ "description": "The channel's HLS Media Playlist, whose SCTE-35 date ranges are its breaks.",
+ "x-cli": {
+ "flag": "--playlist"
+ }
+ },
+ "key": {
+ "type": "string",
+ "format": "path",
+ "description": "The operator's key file: 32 bytes, or 64 hexadecimal digits.",
+ "x-cli": {
+ "flag": "--key"
+ }
+ },
+ "owner": {
+ "type": "string",
+ "description": "The rights holder of every asset no other input gives one.",
+ "x-cli": {
+ "flag": "--owner"
+ }
+ },
+ "signer_id": {
+ "type": "string",
+ "description": "The receipt's signer id (the operator key's did:key).",
+ "x-cli": {
+ "flag": "--signer-id"
+ }
+ },
+ "id_system": {
+ "type": "string",
+ "description": "The XMLTV episode-num system that names a programme's asset (wai-asset-id).",
+ "x-cli": {
+ "flag": "--id-system"
+ }
+ },
+ "xmltv_channel": {
+ "type": "string",
+ "description": "The XMLTV channel id whose programmes are read (the channel id).",
+ "x-cli": {
+ "flag": "--xmltv-channel"
+ }
+ },
+ "start_ms": {
+ "type": "integer",
+ "description": "Where a schedule of Media RSS items alone begins, milliseconds since 1970.",
+ "x-cli": {
+ "flag": "--start-ms"
+ }
+ },
+ "artwork": {
+ "type": "string",
+ "format": "path",
+ "description": "The directory holding the artwork files the feeds' icons and thumbnails name.",
+ "x-cli": {
+ "flag": "--artwork"
+ }
+ },
+ "window_start": {
+ "type": "integer",
+ "description": "The receipt's window's start (the schedule's first start).",
+ "x-cli": {
+ "flag": "--window-start"
+ }
+ },
+ "window_end": {
+ "type": "integer",
+ "description": "The receipt's window's end (the schedule's last end).",
+ "x-cli": {
+ "flag": "--window-end"
+ }
+ },
+ "parent": {
+ "type": "string",
+ "description": "The receipt hash of the window before this one, 64 hexadecimal digits.",
+ "x-cli": {
+ "flag": "--parent"
+ }
+ },
+ "out_dir": {
+ "type": "string",
+ "format": "path",
+ "description": "Where channel.json, lineup.wlu and cues.json are written (.).",
+ "x-cli": {
+ "flag": "--out-dir"
+ }
+ }
+ },
+ "required": [
+ "channel",
+ "key"
+ ],
+ "additionalProperties": false
+ },
+ "output": {
+ "type": "object",
+ "required": [
+ "channel",
+ "receipt",
+ "receipt_hash",
+ "schedule_root",
+ "lineup",
+ "lineup_digest",
+ "cues"
+ ],
+ "description": "The files written (the receipt, the lineup, the cues), the receipt's identity and schedule root, the lineup's digest, and their counts"
+ },
+ "stream": null,
+ "since": "0.3.0",
+ "deprecated": null,
+ "receipt": true,
+ "needs": {
+ "all": [
+ "channel_lineup"
+ ]
+ },
+ "names": {
+ "cli": "wai channel import",
+ "mcp": null,
+ "c": "wai_channel_import",
+ "rust": "wai::channel::import",
+ "sdk": "wai.channel.import"
+ }
+ },
+ {
+ "id": "channel.guide",
+ "summary": "A channel's guide, an XMLTV document or a now/next document, rendered from its receipt and its lineup and naming both",
+ "cli": "wai channel guide",
+ "aliases": [],
+ "usage": [
+ "wai channel guide --receipt R --lineup L [--from T --to T | --now-next --at T] [--out F] [--yes] the XMLTV guide",
+ " of a range (the whole schedule), or the now/next document of an instant, written to F (guide.xml, now-next.json);",
+ " replacing a file waits for --yes (--confirm, deprecated, is the same)"
+ ],
+ "placement": "local",
+ "effect": "writes-project",
+ "dry_run": true,
+ "confirm": "when-replacing-a-file",
+ "route": null,
+ "doors": {
+ "cli": true,
+ "mcp": false,
+ "http": false
+ },
+ "toolset": "core",
+ "stability": "preview",
+ "input": {
+ "type": "object",
+ "properties": {
+ "receipt": {
+ "type": "string",
+ "format": "path",
+ "description": "The channel receipt, in its JSON carriage.",
+ "x-cli": {
+ "flag": "--receipt"
+ }
+ },
+ "lineup": {
+ "type": "string",
+ "format": "path",
+ "description": "The lineup's file.",
+ "x-cli": {
+ "flag": "--lineup"
+ }
+ },
+ "from": {
+ "type": "integer",
+ "description": "The range's start, milliseconds since 1970 (the schedule's first start).",
+ "x-cli": {
+ "flag": "--from"
+ }
+ },
+ "to": {
+ "type": "integer",
+ "description": "The range's end (the schedule's last end).",
+ "x-cli": {
+ "flag": "--to"
+ }
+ },
+ "now_next": {
+ "type": "boolean",
+ "description": "The now/next document of --at instead.",
+ "x-cli": {
+ "flag": "--now-next"
+ }
+ },
+ "at": {
+ "type": "integer",
+ "description": "The instant of a now/next document, milliseconds since 1970.",
+ "x-cli": {
+ "flag": "--at"
+ }
+ },
+ "out": {
+ "type": "string",
+ "format": "path",
+ "description": "The guide's file (guide.xml, or now-next.json).",
+ "x-cli": {
+ "flag": "--out"
+ }
+ }
+ },
+ "required": [
+ "receipt",
+ "lineup"
+ ],
+ "additionalProperties": false
+ },
+ "output": {
+ "type": "object",
+ "required": [
+ "file",
+ "format",
+ "schedule_root",
+ "lineup"
+ ],
+ "description": "The guide's file and format (xmltv or now-next), the sources it names, and its range or instant"
+ },
+ "stream": null,
+ "since": "0.3.0",
+ "deprecated": null,
+ "receipt": false,
+ "needs": {
+ "all": [
+ "channel_lineup"
+ ]
+ },
+ "names": {
+ "cli": "wai channel guide",
+ "mcp": null,
+ "c": "wai_channel_guide",
+ "rust": "wai::channel::guide",
+ "sdk": "wai.channel.guide"
+ }
+ },
+ {
+ "id": "channel.verify",
+ "summary": "A channel's receipt, its lineup and a guide verified together: the guide recomputed from the two and compared, each programme with whether it aired",
+ "cli": "wai channel verify",
+ "aliases": [],
+ "usage": [
+ "wai channel verify --receipt R --lineup L [--guide G] the receipt, the lineup and the guide checked together;",
+ " exit 1 with the reason when one does not verify"
+ ],
+ "placement": "local",
+ "effect": "reads",
+ "dry_run": false,
+ "confirm": "never",
+ "route": null,
+ "doors": {
+ "cli": true,
+ "mcp": false,
+ "http": false
+ },
+ "toolset": "core",
+ "stability": "preview",
+ "input": {
+ "type": "object",
+ "properties": {
+ "receipt": {
+ "type": "string",
+ "format": "path",
+ "description": "The channel receipt, in its JSON carriage.",
+ "x-cli": {
+ "flag": "--receipt"
+ }
+ },
+ "lineup": {
+ "type": "string",
+ "format": "path",
+ "description": "The lineup's file.",
+ "x-cli": {
+ "flag": "--lineup"
+ }
+ },
+ "guide": {
+ "type": "string",
+ "format": "path",
+ "description": "An XMLTV guide or a now/next document.",
+ "x-cli": {
+ "flag": "--guide"
+ }
+ }
+ },
+ "required": [
+ "receipt",
+ "lineup"
+ ],
+ "additionalProperties": false
+ },
+ "output": {
+ "type": "object",
+ "required": [
+ "verdict",
+ "part",
+ "receipt",
+ "lineup",
+ "guide",
+ "entry"
+ ],
+ "description": "What extensions/channel-lineup.md §8.3 states: the verdict and the part it is about, the receipt's identity, the lineup's digest, and the guide's programmes, each with whether it aired"
+ },
+ "stream": null,
+ "since": "0.3.0",
+ "deprecated": null,
+ "receipt": false,
+ "needs": {
+ "all": [
+ "channel_lineup"
+ ]
+ },
+ "names": {
+ "cli": "wai channel verify",
+ "mcp": null,
+ "c": "wai_channel_verify",
+ "rust": "wai::channel::verify",
+ "sdk": "wai.channel.verify"
+ }
+ },
{
"id": "mcp.start",
"summary": "WAI's operations as tools for a coding agent: a Model Context Protocol server on standard input and output, in toolsets",
diff --git a/wai/extensions/channel-lineup.md b/wai/extensions/channel-lineup.md
new file mode 100644
index 000000000..b3f99731a
--- /dev/null
+++ b/wai/extensions/channel-lineup.md
@@ -0,0 +1,764 @@
+# WAI Extension: Channel Lineup and Guide
+
+> Status: Draft. Registers the record `wai.video.lineup`: a linear channel's signed **lineup**,
+> `WLU1`, which describes each programme asset of one channel schedule (its title, description,
+> categories, rating, artwork by content hash, where it may be shown, and its rights holder) and
+> where each programme of the guide begins, and which binds that schedule by its root. It
+> defines how a channel as operators exchange it (an XMLTV schedule, Media RSS catalogues, a
+> JSON form of both, and the SCTE-35 date ranges of the channel's HLS Media Playlist) is
+> **imported** into a `wai.video.channel` receipt and a lineup; and the channel's **guide**, an
+> XMLTV document or a now/next document for a player's guide row, rendered from a receipt and its
+> lineup, naming both, so that anyone holding them recomputes the guide and compares. The
+> `wai.video.channel` receipt keeps its signed bytes and travels in revenue-share's JSON carriage
+> of it. No new transport. The reference implementation is the `channel_lineup` module of `wai-rs` (feature
+> `channel_lineup`); the corpus is [`live-conformance/channels/`](../live-conformance/channels/),
+> and its second implementation is `live-conformance/channel_lineup_verify.py`.
+> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
+>
+> Texts read for this revision, each at its source:
+>
+> - the XMLTV DTD, `xmltv.dtd`: its date and time format, and the elements `tv`, `channel`,
+> `display-name`, `programme`, `title`, `desc`, `category`, `icon`, `episode-num`, `rating`
+> and `value`, with their attributes and the order the DTD gives a programme's children;
+> - *Media RSS Specification* 1.5.1, December 2009: §4.1 (`media:group`), §4.2
+> (`media:content`), §5 (where its optional elements appear), §5.2 (`media:rating`), §5.3
+> (`media:title`), §5.4 (`media:description`), §5.6 (`media:thumbnails`), §5.7
+> (`media:category`) and §5.13 (`media:restriction`); and the RSS 2.0 elements it extends
+> (`rss`, `channel`, `item`, `title`, `description`, `category`, `guid`);
+> - `draft-pantos-hls-rfc8216bis-22`: §4.2 (attribute lists, decimal-floating-point and
+> hexadecimal-sequence values), §4.4.5.1 (`EXT-X-DATERANGE`) and §4.4.5.1.1 (SCTE-35 in a
+> date range);
+> - ANSI/SCTE 35-1 2023r2: §9.6 (Table 5, `splice_info_section()`) and §9.7.3 (Table 10,
+> `splice_insert()`);
+> - RFC 8259 (JSON), RFC 8785 (the JSON Canonicalization Scheme), and XML 1.0 for the subset of
+> §3.3.
+>
+> A section number of one of these texts is that revision's.
+
+## 1. Scope
+
+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).
+
+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)
+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.
+
+Relationship to the core spec: `wai.video.lineup` is a **record** ([SPEC](../SPEC.md) §5): a
+signed record a WAI tool emits and verifies. It names no payload format and is not dispatchable.
+A guide is not a record: it is a rendering of two records that anyone recomputes, so it carries
+no signature of its own.
+
+Relationship to `wai.video.channel` (SPEC §5): an import seals a channel receipt with
+`ChannelReceipt::seal`, whose signed bytes are unchanged here, and carries it in the JSON form
+[revenue-share](revenue-share.md) §4.1 defines for it; a lineup binds the receipt's
+schedule by its `schedule_root`. A receipt's program-as-run (`aired`) is what a verification
+sets beside a guide (§8.3).
+
+Relationship to [live-events](live-events.md): each ad break an import reads becomes a `cue`
+payload of live-events §4.3, unchanged, which a room's principal signs as a `cue` event when the
+channel is delivered as a room. Relationship to [cue-injection](cue-injection.md): its injection
+record (§7 there) binds a cue to the SCTE 104 message it was translated from; a cue an import
+yields comes from a playlist's SCTE-35 date range, and an import makes no injection record.
+
+## 2. Terms
+
+- **Channel**: a linear channel, named by a **channel id**.
+- **Schedule**: the entries of a `wai.video.channel` receipt, gapless and non-overlapping, each
+ an asset id, a start, a duration and a kind: program, ad or filler.
+- **Programme**: a span of the schedule the guide lists as one item: an episode or a film, with
+ the ad breaks inside it. A programme's **asset** is the asset id of its program entries.
+- **Asset**: what an asset id names; the lineup describes each programme asset (§6.2).
+- **Catalogue**: the assets a channel's content owners describe, in Media RSS or the JSON form.
+- **Break**: a span of a programme that ads fill, from a date range or the JSON form (§4.5).
+- **Operator**: the party that runs the channel and signs its receipt and its lineup.
+- **Rights holder**: the party an asset's lineup entry names as holding its rights.
+- **Feed**: a document an import reads: an XMLTV document, a Media RSS document, the JSON form,
+ or a Media Playlist.
+- **Guide**: an XMLTV document (§7.2) or a now/next document (§7.3) rendered from a receipt and a
+ lineup.
+
+An instant is a count of milliseconds since 1970-01-01T00:00:00Z, leap seconds not counted. An
+instant or a duration an import reads, a lineup states or a guide writes is at most 2^53 − 1,
+the integers JSON carries exactly (RFC 8785 §3.2.2.3). A span from `a` to `b` holds the instants from `a`, included, to `b`,
+excluded. Integers of the lineup are little-endian. A digest is SHA-256 (FIPS 180-4), 32 bytes.
+Ed25519 is RFC 8032's, verified strictly ([SPEC](../SPEC.md) §7 *Signatures*): a key that fails
+step 1 or 2 is `bad_key`, and a signature that fails a later step is `bad_signature`. Hexadecimal
+written by this text is lowercase.
+
+## 3. Reading
+
+### 3.1 Bounds
+
+An import refuses `import_oversized` a feed or an artwork file of more than 16 777 216 bytes, and
+a feed that holds more than 10 000 programmes of the channel (XMLTV, the JSON form), more than
+10 000 items (Media RSS, all its documents together), more than 10 000 assets (the JSON form) or
+more than 10 000 breaks (the JSON form and the playlist together). The size of each feed is
+checked before it is read, and each count as soon as its document has been read as XML, JSON or
+lines, before any of its elements is read further. An XML document is read to a depth of 64
+elements at most (§3.3). An import therefore reads a bounded amount for any input.
+
+### 3.2 Text values
+
+A **text** is a sequence of Unicode scalar values in UTF-8 with no character below U+0020 other
+than U+0009 (tab) and U+000A (line feed). A **short text** is a text of 1 to 255 bytes; a
+**long text** a text of 0 to 65 535 bytes. A value an import reads that must be one of these and
+is not is refused `import_field_invalid`. Text is taken as the feed writes it; nothing is
+normalised.
+
+### 3.3 The XML subset
+
+An XMLTV or Media RSS document is read over the XML subset of
+[stitched-presentation](stitched-presentation.md) §3.5, with its local names and its text of an
+element, and with one addition: before the root element, where that subset admits a comment, the
+document **MAY** hold one document type declaration with no internal subset, which is skipped.
+Such a declaration is ``. A literal is `"`, bytes other than `"`, and `"`; or `'`,
+bytes other than `'`, and `'`. White space is one or more of space, tab, CR and LF. A
+declaration of any other form (one with an internal subset among them), or a second one, is
+outside the subset. A document outside the subset, or whose root element is not the one §4.2 or
+§4.3 names, is refused `import_xml_malformed`.
+
+An element's **first** `x` child is its first child element of local name `x`. Elements are
+named by local name only, so a Media RSS element and an RSS 2.0 element of one local name
+(`media:title` and `title`, for one) are both `title` children; §4.3 says which one an import
+takes.
+
+### 3.4 Times and durations
+
+An **XMLTV time** is 14 ASCII digits `YYYYMMDDhhmmss`, optionally followed by one space and a
+numeric zone: `+` or `-` and four digits `HHMM` with `HH` at most 23 and `MM` at most 59. With no
+zone the time is UTC, as the DTD says. The instant is the UTC instant of the date and time, less
+the zone's offset. A value is refused:
+
+- `import_timezone_unknown` when its first 14 bytes are digits and what follows them is a space
+ and anything other than a numeric zone (a zone named by letters, for one);
+- `import_time_malformed` otherwise when it is not of that form, when its year is below 1970, its
+ month not 1 to 12, its day not a day of that month in the Gregorian calendar, its hour above 23,
+ its minute above 59 or its second above 59, or when its instant is before 1970-01-01T00:00:00Z.
+
+An **HLS date-time** is `YYYY-MM-DDThh:mm:ss`, optionally `.` and one or more digits, then `Z` or
+a zone `+hh:mm` or `-hh:mm` with `hh` at most 23 and `mm` at most 59, the fields as above. Its
+fraction is read to the millisecond, and every digit after the third **MUST** be 0. A value of
+this form without a zone is refused `import_timezone_unknown`; any other value that is not of
+this form, or breaks a rule above, is refused `import_time_malformed`.
+
+A **duration** is a decimal-floating-point value (`draft-pantos-hls-rfc8216bis-22` §4.2): one or
+more digits, optionally `.` and one or more digits, a number of seconds. It is read to the
+millisecond, every fractional digit after the third **MUST** be 0, and the count of milliseconds
+**MUST** be at most 2^53 − 1; otherwise it is refused `import_time_malformed`.
+
+## 4. Import
+
+### 4.1 Inputs
+
+An import is given:
+
+- **channel**: the channel id, a short text;
+- **feeds**, one of these sets, each optionally with one Media Playlist:
+ - an XMLTV document, and zero or more Media RSS documents;
+ - one or more Media RSS documents, with no XMLTV document;
+ - one JSON form (§4.4), and no XMLTV or Media RSS document;
+- for each Media RSS document, optionally its **owner**, the rights holder of its items: a short
+ text;
+- **owner**: optionally, the rights holder of every asset that no other input gives one: a short
+ text;
+- **id_system**: the XMLTV `episode-num` system that names a programme's asset (§4.2); when not
+ given, `wai-asset-id`, the system a guide writes (§7.2);
+- **xmltv_channel**: the XMLTV channel id whose programmes are read; when not given, `channel`;
+- **start**: an instant, which a schedule made of Media RSS items alone requires (§4.6);
+- **artwork**: a set of files, each named by a file name (§4.6);
+- **window**: optionally, the receipt's window, a span from `window_start` to `window_end` (§4.9);
+- the operator's Ed25519 key and the receipt's `signer_id` (a short text), and optionally the
+ receipt hash of the window before this one, its `parent_receipt_hash`.
+
+An input set that is none of the sets above, a Media RSS owner given for some of the Media RSS
+documents and not for the others, a value above that is not of its form, or a schedule of Media
+RSS items alone with no start, is refused `import_inputs_invalid`.
+
+### 4.2 An XMLTV schedule
+
+The document's root element is `tv`. Its **channel name** is the text of the first
+`display-name` child of the first `channel` child of `tv` whose `id` attribute is
+`xmltv_channel`, when that text is not empty; it **MUST** then be a short text. The import's
+**programmes** are the `programme` children of `tv` whose `channel` attribute is
+`xmltv_channel`, in document order; when there are none, the import is refused `import_empty`.
+Each one is read, in document order, by these steps, and refused at the first that fails:
+
+1. Its `start` attribute is an XMLTV time (§3.4); an absent `start` is `import_time_malformed`.
+2. It has a `stop` attribute: otherwise `import_no_stop`. That attribute is an XMLTV time (§3.4),
+ and its instant is after the start's: otherwise `import_time_malformed`.
+3. **Its asset.** It has an `episode-num` child whose `system` attribute is `id_system` (an
+ absent `system` is `onscreen`, the DTD's default), and the text of the first such child is a
+ short text: otherwise `import_asset_unknown`. That text is the programme's asset id.
+4. **Its description**, each value as §3.2 says (`import_field_invalid`): its **title**, the text
+ of its first `title` child, a long text of 1 byte or more; its **description**, the text of
+ its first `desc` child, or empty when it has none, a long text; its **categories**, the text
+ of each `category` child, in order, each a short text, at most 16 of them; its **artwork**,
+ the `src` attribute of each `icon` child, in order, each a long text of 1 byte or more, at
+ most 8 of them (an `icon` with no `src` is refused); and its **rating**, when it has a
+ `rating` child: the first one's `system` attribute, or empty when it has none, which when
+ not empty is a short text; and the text of that child's first `value` child, a short text
+ (a `rating` with no `value`, or an empty one, is refused).
+
+Every instant an XMLTV document gives is a whole second.
+
+### 4.3 A Media RSS catalogue
+
+The document's root element is `rss`, with a `channel` child; the first `channel` child is
+read. Its **channel name** is the text of that channel's first `title` child, when not empty; it
+**MUST** then be a short text. Its **items** are that channel's `item` children, in document
+order. Each one is read, in document order, by these steps, and refused at the first that fails:
+
+1. **Its asset id** is the text of its first `guid` child (RSS 2.0's `guid`), a short text:
+ otherwise `import_asset_unknown`. An asset id that an item read before it has, in this
+ document or in an earlier one, is refused `import_asset_duplicate`.
+2. **Its content** is its first `content` child (`media:content`, §4.2 there), or, when it has
+ none, the first `content` child of its first `group` child (`media:group`, §4.1 there). Its
+ **content URI** is the content's `url` attribute, a long text, empty when absent or when it
+ has no content. Its **duration** is the content's `duration` attribute read as a duration
+ (§3.4; `import_time_malformed`), which **MUST** be at least 1 millisecond
+ (`import_time_malformed`); with no such attribute it is unknown.
+3. **Its description**, each value as §3.2 says (`import_field_invalid`): its title, the text of
+ its first `title` child, a long text of 1 byte or more; its description, the text of its
+ first `description` child, or empty, a long text; its categories, the text of each `category`
+ child, each a short text, at most 16; its artwork, the `url` attribute of each `thumbnail`
+ child (`media:thumbnail`, §5.6 there), each a long text of 1 byte or more, at most 8 (a
+ `thumbnail` with no `url` is refused); its rating, when it has a `rating` child
+ (`media:rating`, §5.2 there): the first one's `scheme` attribute, or `urn:simple` when absent
+ (§5.2's default), which when not empty is a short text; and its text, a short text.
+4. **Where it may be shown**, from its first `restriction` child whose `type` attribute is
+ `country` (`media:restriction`, §5.13 there): its `relationship` attribute is `allow` or
+ `deny`, and its text, split at white space (space, tab, CR, LF) into non-empty tokens, is
+ either the one token `all` or the one token `none`, or one or more tokens of two ASCII
+ letters each: otherwise `import_field_invalid`. Two letters name the ISO 3166-1 alpha-2 code
+ they spell in upper case, and the codes are taken in increasing order, each once. `allow` with `all`, `deny` with `none`, or no such child: shown
+ everywhere; `allow` with `none`, or `deny` with `all`: shown nowhere; `allow` with codes:
+ shown only in those countries; `deny` with codes: shown everywhere but those.
+
+The item's title is RSS 2.0's `title` or Media RSS's `media:title`, whichever the item writes
+first, and so for `description` and `category`. Elements that a `media:group` or a
+`media:content` holds, other than the content itself, are not read.
+
+### 4.4 The JSON form
+
+The JSON form carries a channel's schedule, its catalogue and its breaks in one document. It is
+one JSON text (RFC 8259) in UTF-8, nested at most 64 deep, whose objects each name a member once.
+Its value is an object with exactly these members:
+
+| member | value |
+|---|---|
+| `kind` | the string `wai-channel-feed` |
+| `channel_name` | a string |
+| `programmes` | an array of objects, each with exactly `start_ms` and `stop_ms` (integers) and `asset_id` (a string) |
+| `assets` | an array of objects, each with exactly the members of the next table |
+| `breaks` | an array of objects, each with exactly `id` (a string), `start_ms` and `duration_ms` (integers) and `scte35` (a string, or `null`) |
+
+| asset member | value |
+|---|---|
+| `asset_id`, `title`, `description`, `content_uri` | strings |
+| `categories` | an array of strings |
+| `rating` | `null`, or an object with exactly `system` and `value`, strings |
+| `artwork` | an array of objects, each with exactly `uri` and `sha256`, strings |
+| `geo` | `null` (shown everywhere), or an object with exactly one member, `allow` (shown only in) or `deny` (shown everywhere but), an array of strings |
+| `rights_holder` | a string, or `null` |
+| `duration_ms` | an integer: the asset's duration, 0 when unknown |
+
+An integer is written as decimal digits with no sign, fraction, exponent or leading zero, at most
+2^53 − 1. A document that is not so is refused `import_json_malformed`. Then, in document order,
+each value is read as §3.2 and this paragraph say (`import_field_invalid` unless said otherwise):
+`channel_name` a short text; each programme's `asset_id` a short text, with `stop_ms` after
+`start_ms` (otherwise `import_time_malformed`); each asset's `asset_id` a short text (one that an
+asset before it has is `import_asset_duplicate`), its `title` a long text of 1 byte or more, its
+`description` and `content_uri` long texts, its categories as in §4.2, its `rating`'s `system` a
+short text or empty and its `value` a short text, its artwork at most 8, each `uri` a long text
+of 1 byte or more and each `sha256` 64 lowercase hexadecimal digits, its `geo` codes each two
+upper-case ASCII letters in strictly increasing order (a `deny` with none is refused), and its
+`rights_holder` a short text when not `null`; each break as §4.5 says of a break.
+
+### 4.5 Breaks from a Media Playlist
+
+A Media Playlist is read as stitched-presentation §3.2 reads one: UTF-8, split at each line feed
+with a CR before it dropped, its first line `#EXTM3U` (otherwise `import_playlist_malformed`).
+Each line that begins `#EXT-X-DATERANGE:` is a date range tag, and the rest of the line its
+attribute list, read by the rules of stitched-presentation §3.2; a tag whose list does not read,
+or whose `ID` is not a quoted string, is refused `import_playlist_malformed`.
+
+Tags are grouped by `ID`, each group in the order its first tag appears. Two tags of one group
+that carry one attribute **MUST** carry the same value, a quoted string or not as both write it
+(`draft-pantos-hls-rfc8216bis-22` §4.4.5.1): otherwise `import_playlist_malformed`. A group is a
+**break** when one of its tags carries `SCTE35-OUT`; the other groups are not read. A break's
+attributes are those of its tags together, and it is read by these steps, each refused at the
+first that fails:
+
+1. Its **id** is its `ID`, a short text (`import_field_invalid`).
+2. Its `START-DATE` is a quoted string, an HLS date-time (§3.4): an absent one, or one that is
+ not quoted, is `import_playlist_malformed`. Its instant is the break's **start**.
+3. Its `SCTE35-OUT` (and its `SCTE35-IN`, when it has one) is an unquoted hexadecimal-sequence
+ (§4.2 there: `0x` or `0X`, then hexadecimal digits, read here in either case) of an even
+ count of digits, whose bytes are a `splice_info_section()` as live-events §4.3 reads form 1:
+ `table_id` 0xFC, and `section_length` (the low 12 bits of its bytes 1 and 2) plus 3 its
+ length. Otherwise `import_scte35_malformed`. The bytes of `SCTE35-OUT` are the break's
+ **section**.
+4. Its **duration** is its `DURATION`, unquoted, when it has one; otherwise its `END-DATE`, a
+ quoted HLS date-time, less its start, when it has one; otherwise its `PLANNED-DURATION`,
+ unquoted. A duration attribute that is quoted is `import_playlist_malformed`, and one that
+ does not read is refused as §3.4 says. A break with none of the three, or whose duration is 0
+ or above 2^32 − 1 milliseconds, or an `END-DATE` before its start, is refused
+ `import_break_duration`.
+
+A break of the JSON form is its `id` (step 1); its `start_ms`; its `scte35`, when not `null`,
+an even count of hexadecimal digits (either case, no `0x`) read as step 3 reads a section
+(`import_scte35_malformed`); and its `duration_ms`, refused as step 4 refuses one when 0 or above
+2^32 − 1. A break of the JSON form with a `null` `scte35` has no section.
+
+### 4.6 The programmes and their assets
+
+The programmes are, by the feeds:
+
+- **XMLTV**: those of §4.2;
+- **the JSON form**: its `programmes`, each the span from `start_ms` to `stop_ms`, with its `asset_id`; none is
+ `import_empty`;
+- **Media RSS alone**: the items of the documents in the order given, each document's in
+ document order, one programme an item, back to back from `start`: each programme begins where
+ the one before it ends and lasts its item's duration. An item whose duration is unknown is
+ refused `import_duration_unknown`, and an instant above 2^53 − 1 `import_time_malformed`. No
+ item is `import_empty`.
+
+The programmes are ordered by start (programmes of one start keep their order), and each next to
+the one after it: a programme that ends before the next begins is refused `import_gap`, and one
+that ends after the next begins `import_overlap`. Every programme's start, and the last one's
+end, **MUST** be a whole second, a multiple of 1000 (`import_time_malformed`): a guide's XMLTV
+times have no finer unit (§7.2).
+
+Each distinct asset id of the programmes, in the order of the first programme that names it, gets
+its lineup entry (§6.2) by these steps, refused at the first that fails:
+
+1. **Its description.** With the JSON form, its `assets` entry, which **MUST** exist
+ (`import_asset_unknown`). With XMLTV, the item of that asset id when a Media RSS document has
+ one, otherwise the first programme that names it. With Media RSS alone, its item. The
+ description is title, description, categories, artwork, rating, where it may be shown,
+ content URI and duration; a programme gives no content URI, a duration of 0 and shown
+ everywhere.
+2. **Its rights holder**: the JSON form's `rights_holder` when not `null`; otherwise the owner of
+ the Media RSS document its item came from, when given; otherwise the import's owner, when
+ given. None is refused `import_owner_unknown`.
+3. **Its artwork.** Each artwork URI of a description that came from XMLTV or Media RSS is bound
+ by its content hash: its **name** is the part of the URI after its last `/`, once the first
+ `?` or `#` and everything after it are removed. The name **MUST** be 1 to 255 bytes of ASCII
+ letters, digits, `.`, `_` and `-`, other than `.` and `..`, and the artwork set **MUST** hold a
+ file of that name; otherwise `import_artwork_missing`. A file of more than 16 777 216 bytes is
+ `import_oversized`. The artwork's `sha256` is the SHA-256 of that file's bytes. The JSON form
+ gives its artwork's `sha256` itself.
+
+Programmes and assets are read once each: an asset's later programmes give it nothing, and
+catalogue items no programme names are read (§4.3) but enter no lineup.
+
+### 4.7 The schedule
+
+The breaks are those of the JSON form, then those of the playlist, ordered by start (breaks of
+one start keep their order). A break whose end (its start and its duration) is after the next
+break's start is refused `import_overlap`; a break that does not lie within one programme, a
+programme whose start is at most its start and whose end is at least its end, is refused
+`import_break_outside`.
+
+The **schedule** is, for each programme in order: a program entry from the programme's start, or
+from the end of its previous break, to the start of its next break or to its end, of the
+programme's asset id, wherever that span is not empty; and an ad entry for each of its breaks,
+from its start to its start plus its duration, whose asset id is the break's id. The schedule is therefore gapless
+and non-overlapping, and every entry of a programme lies within it.
+
+### 4.8 The cues
+
+Each break, in the order of §4.7, yields one `cue` payload of live-events §4.3, unchanged:
+`type` 4 (break: out and back, its duration known), `duration_ms` the break's duration, and
+`form` 1 with the break's section verbatim, or `form` 0 for a break with no section. Its
+`cue_id` is the section's `splice_event_id` when the section's `encrypted_packet` bit (the high
+bit of byte 4) is 0, its `splice_command_type` (byte 13) is 0x05 (`splice_insert()`, SCTE 35-1
+§9.7.3) and it is 18 bytes or longer: the big-endian integer of bytes 14 to 17 (SCTE 35-1 §9.6,
+Table 5, and §9.7.3, Table 10). Otherwise the `cue_id` is the big-endian integer of the first 4
+bytes of SHA-256 over `wai:channel-cue-id` (ASCII), 0x01 and the break's id. A cue says, as its
+message does, that the break is out of the program and back.
+
+### 4.9 What an import yields
+
+- **The receipt**: `ChannelReceipt::seal` (SPEC §5, `wai.video.channel`) by the operator's key,
+ with the import's `signer_id`, `channel_id` the channel, the schedule of §4.7, the window
+ (when not given, from the schedule's first start to its last end), `joules_micro` 0 and no
+ class (the import meters nothing; energy-measurement §2), the grant reference with no ceilings
+ (`grant_hash` 32 zero bytes, `capability` `video.channel`, `joule_ceiling_micro` 2^64 − 1, no
+ `funds_ceiling`), and `parent_receipt_hash` as given. A window that is empty, or that does not
+ lie within the schedule (its start before the schedule's first start, or its end after its
+ last end), is refused `import_window_outside`.
+- **The lineup** (§6): the channel id, the channel name (the first of these that gives one: the
+ XMLTV document, the first Media RSS document, the JSON form; when none does, the channel id), the receipt's
+ `schedule_root`, the schedule's first start and last end, each programme's start, and the
+ assets of §4.6 in the order of their asset ids' bytes, signed by the operator's key.
+- **The cues** of §4.8, each with its break's id and start.
+
+### 4.10 The order of refusal
+
+An import is refused for the first step that fails, in this order: §4.1; then each feed, the
+XMLTV document, the Media RSS documents in the order given, the JSON form and then the playlist,
+each first by its size (§3.1), then as XML, JSON or lines, then by its counts (§3.1), then by
+its own steps (§4.2 to §4.5), element by element in document order; then §4.6, §4.7 and §4.9.
+An import that is refused yields nothing.
+
+## 5. The channel receipt's JSON carriage
+
+A `wai.video.channel` receipt keeps its signed bytes, and this text defines no form of its own for
+it: an import writes a receipt, and a verifier reads one, in the JSON carriage of
+[revenue-share](revenue-share.md) §4.1, the one JSON form of a channel receipt. A file that holds
+a receipt holds one JSON text (RFC 8259) in UTF-8 of that carriage. A receipt **reads** when it
+is such a text and reads as revenue-share §4.1 says; its entries' leaves, its schedule root, its
+signing payload, its signature and its **identity** (its receipt hash) are revenue-share §4.2's;
+its **rules** are rules 1 to 5 of revenue-share §4.3, and its signature that section's rule 6. A
+receipt **verifies** when its signature verifies and its rules hold.
+
+## 6. The lineup
+
+### 6.1 Layout
+
+| size | field | meaning |
+|---|---|---|
+| 4 | `magic` | `WLU1` (ASCII) |
+| 1 | `version` | 1 in this revision |
+| short | `channel_id` | the channel: a short text |
+| short | `channel_name` | its name, as a guide shows it: a short text |
+| 32 | `schedule_root` | the root of the schedule it describes (§5) |
+| 8 | `schedule_start_ms` | u64: the schedule's first start |
+| 8 | `schedule_end_ms` | u64: the schedule's last end |
+| 4 | `p` | u32: the programmes' count, 1 to 10 000 |
+| 8 × `p` | `programmes` | each programme's start (u64), in order |
+| 4 | `a` | u32: the assets' count, 1 to 10 000 |
+| … | `assets` | `a` assets, §6.2, in the order of their asset ids' bytes |
+| 32 | `signer` | the operator's Ed25519 key |
+| 64 | `sig` | §6.3 |
+
+A **short** field is a u8 length and that many bytes; a **long** field a u16 length and that
+many bytes. Programme *k* runs from its start to the start of programme *k* + 1, the last one to
+`schedule_end_ms`.
+
+### 6.2 An asset
+
+| size | field | meaning |
+|---|---|---|
+| short | `asset_id` | the schedule's asset id: a short text |
+| long | `title` | a long text of 1 byte or more |
+| long | `description` | a long text |
+| 1 | `c` | the categories' count, 0 to 16 |
+| short × `c` | `categories` | each a short text |
+| short | `rating_system` | the rating's system, or no bytes |
+| short | `rating_value` | the rating, or no bytes for none |
+| 1 | `k` | the artworks' count, 0 to 8 |
+| … | `artwork` | `k` artworks, each 32 bytes (the SHA-256 of the image's bytes) and a long field (its URI, a long text of 1 byte or more) |
+| 1 | `geo` | where it may be shown: 0 everywhere; 1 only in the countries listed; 2 everywhere but them |
+| 1 | `g` | the countries' count |
+| 2 × `g` | `countries` | each an ISO 3166-1 alpha-2 code, two of `A`–`Z`, in strictly increasing order |
+| short | `rights_holder` | the party that holds its rights, as the operator names it: a short text |
+| long | `content_uri` | the asset's media, or no bytes |
+| 8 | `duration_ms` | u64: the asset's duration, 0 when unknown |
+
+A rights holder is a name, not a key: a contract between the operator and the rights holder
+(a share contract of the revenue it earns, for one) names its parties itself, and a lineup states
+what the operator published.
+
+### 6.3 Digest and signature
+
+The **digest** of a lineup is SHA-256 over its bytes from `magic` through `signer`. The
+**signature** is Ed25519 by `signer` over `wai:channel-lineup` (ASCII) and 0x01, followed by the
+bytes the digest covers.
+
+### 6.4 Reading and rules
+
+A reader reads exactly the bytes it is given as one lineup, in the order of §6.1, and refuses it
+`lineup_malformed` when the bytes end before a field the layout requires or go on after `sig`,
+or when:
+
+1. its magic is not `WLU1` or its version not 1;
+2. `channel_id` or `channel_name` is not a short text;
+3. `schedule_start_ms` is not below `schedule_end_ms`, either is not a multiple of 1000, or
+ `schedule_end_ms` is above 2^53 − 1;
+4. `p` is 0 or above 10 000; or the programmes' starts are not strictly increasing, the first is
+ not `schedule_start_ms`, the last is not below `schedule_end_ms`, or one is not a multiple of
+ 1000;
+5. `a` is 0 or above 10 000, or the asset ids are not in strictly increasing order of their
+ bytes;
+6. an asset breaks §6.2: an `asset_id`, a category or `rights_holder` that is not a short text;
+ a `title` empty or not a text; a `description`, an artwork's URI or `content_uri` not a text;
+ an empty artwork URI; `c` above 16 or `k` above 8; a `rating_system` that is not empty or a
+ short text, or one that is not empty beside an empty `rating_value`; a `rating_value` that is
+ not empty or a short text; `geo` above 2, `geo` 0 with `g` above 0, `geo` 2 with `g` 0, or
+ countries not two of `A`–`Z` each in strictly increasing order; or `duration_ms` above
+ 2^53 − 1.
+
+### 6.5 A lineup and its schedule
+
+A lineup **describes** a receipt's schedule when:
+
+1. its `channel_id` is the receipt's;
+2. its `schedule_root` is the receipt's, and its `schedule_start_ms` and `schedule_end_ms` are
+ the start of the receipt's first entry and the end of its last;
+3. each programme's start is the start of an entry;
+4. each programme holds at least one program entry, and every program entry it holds (every
+ entry that starts in its span) has one asset id, the programme's asset;
+5. the asset ids of its assets are exactly the programmes' assets.
+
+A programme's ad and filler entries are its breaks; a guide lists the programme over them.
+
+## 7. The guide
+
+### 7.1 The programmes of a range
+
+A guide is rendered from a receipt and a lineup that describes its schedule. The programmes of a
+range from `from` to `to`, with `from` below `to`, are the lineup's programmes whose span meets it (a
+programme that begins before `to` and ends after `from`), in order, each with its start, its
+end, its asset's id and its asset's entry (§6.2).
+
+### 7.2 The XMLTV guide
+
+The XMLTV guide of a range is these bytes, in UTF-8, each line ending with a line feed:
+
+```
+
+
+
+ NAME
+
+
+ TITLE
+ DESCRIPTION
+ CATEGORY
+
+ ASSET
+
+ VALUE
+
+
+
+```
+
+- `ROOT` is the receipt's `schedule_root` and `LINEUP` the lineup's digest, each 64 lowercase
+ hexadecimal digits, and `FROM` and `TO` the range's ends in decimal, with no leading zero.
+ `urn:wai:guide:` names a guide's sources; a guide names them in its `source-data-url`, an
+ attribute the DTD gives `tv` for where the data came from.
+- `CHANNEL` is the lineup's `channel_id` and `NAME` its `channel_name`.
+- There is one `programme` element for each programme of the range, in order. `START` and `STOP`
+ are its start and its end as XMLTV times (§3.4) in UTC with the zone written: `YYYYMMDDhhmmss
+ +0000`.
+- Of a programme's children, in the order the DTD gives them: one `title`; one `desc` only when
+ the description is not empty; one `category` for each category, in order; one `icon` for each
+ artwork, in order, its `src` the URI; one `episode-num`, whose text is the asset id; and one
+ `rating` only when `rating_value` is not empty, with a `system` attribute only when
+ `rating_system` is not empty.
+- In text, `&`, `<` and `>` are written `&`, `<` and `>`; in an attribute value,
+ those and `"`, tab and line feed are written `&`, `<`, `>`, `"`, ` ` and
+ `
`. Every other character is written as itself.
+
+The guide's `episode-num` system is `wai-asset-id`, the import's default `id_system` (§4.1), so
+a guide imports back into the programmes it lists.
+
+### 7.3 The now/next document
+
+The now/next document of an instant `at` is the JCS serialisation (RFC 8785) of this object,
+followed by one line feed:
+
+| member | value |
+|---|---|
+| `kind` | `wai-now-next` |
+| `channel`, `channel_name` | the lineup's `channel_id` and `channel_name` |
+| `schedule_root` | the receipt's, 64 lowercase hexadecimal digits |
+| `lineup` | the lineup's digest, 64 lowercase hexadecimal digits |
+| `at_ms` | `at` |
+| `now` | the programme whose span holds `at`, or `null` |
+| `next` | when `now` is a programme, the one after it, or `null` for the last; when `at` is before the schedule's first start, the first programme; otherwise `null` |
+
+A programme is an object with the members `start_ms`, `stop_ms` and `asset_id`; `title`,
+`description` and `content_uri`; `categories`, an array of strings; `rating`, `null` or
+`{"system", "value"}`; `artwork`, an array of `{"sha256", "uri"}`; `geo`, `null` for everywhere,
+`{"allow": [...]}` or `{"deny": [...]}`; `rights_holder`; and `duration_ms`.
+
+## 8. Verification
+
+### 8.1 Inputs
+
+A verifier is given a receipt in its JSON carriage (§5), a lineup's bytes, and optionally a
+guide's bytes: an XMLTV guide when its first byte is `<`, a now/next document when it is `{`.
+
+### 8.2 Steps
+
+A verification gets one verdict, **verified** or the reason of the first step it fails:
+
+1. **The receipt** reads (§5): `channel_receipt_malformed`.
+2. Its signature verifies under `signer_pubkey` (§5): `bad_key`, `bad_signature`.
+3. Its rules hold (§5): `channel_receipt_invalid`.
+4. **The lineup** reads (§6.4): `lineup_malformed`.
+5. Its signature verifies under `signer` (§6.3): `bad_key`, `bad_signature`.
+6. Its `signer` is the receipt's `signer_pubkey`: one operator signs both. Otherwise
+ `lineup_signer_mismatch`.
+7. It describes the receipt's schedule (§6.5): `lineup_schedule_mismatch`.
+8. **The guide**, when one is given, reads: `guide_malformed`. An XMLTV guide reads when it is in
+ the subset of §3.3 with the root `tv`; its `source-data-url` is `urn:wai:guide:`, two runs of
+ 64 lowercase hexadecimal digits and two decimal integers with no leading zero, the first below
+ the second, separated by `:`; and each of its `programme` children has `start` and `stop`
+ attributes that are XMLTV times (§3.4) and an `episode-num` child of system `wai-asset-id`,
+ the text of the first of which is its asset id. A now/next document reads when it is one JSON
+ object whose members are those of §7.3, with `at_ms` an integer of at most 2^53 − 1,
+ `schedule_root` and `lineup` strings, and `now` and `next` each `null` or an object whose `start_ms` and `stop_ms` are
+ integers and whose `asset_id` is a string.
+9. It names the receipt's `schedule_root` and the lineup's digest: `guide_source_mismatch`.
+10. Each of its programmes is the lineup's: for an XMLTV guide, each programme has the start, the
+ end and the asset of one of the programmes of the guide's range (§7.1); for a now/next
+ document, `now` and `next` have those of the programmes §7.3 names for its `at_ms`, and are
+ `null` where those are. Otherwise `guide_entry_unscheduled`, at the first programme, in the
+ guide's order, that is not (its index among the guide's `programme` elements; for a now/next
+ document, `now` 0 and `next` 1).
+11. Its bytes are the guide of §7.2 for its range, or the document of §7.3 for its `at_ms`:
+ `guide_mismatch`.
+
+Step 10 catches a guide that places a programme where the schedule does not; step 11 every other
+difference: a title, a rating or a picture the lineup does not state, a programme left out.
+
+### 8.3 What a verification states
+
+- its verdict, and the **part** it is about: `receipt` for steps 1 to 3, `lineup` for steps 4 to
+ 7, `guide` for steps 8 to 11, none when verified;
+- the receipt's identity, when it reads; and the lineup's digest, when it reads;
+- for a guide that reads: its format (`xmltv` or `now-next`), its range or its `at_ms`, and its
+ programmes, each with its start, its end, its asset id and whether it **aired**: whether an
+ entry the receipt's `aired` lists meets its span. A guide shows what the schedule placed; the
+ receipt states which of it was on air in its window;
+- for `guide_entry_unscheduled`, the index of step 10.
+
+## 9. Operations and a worked example
+
+Three operations of the WAI command line carry this text (the operation catalogue,
+`wai/catalogue/ops.json`), each with the toolchain's agent rules: `--json` for the envelope,
+`--dry-run` on an operation that writes, and `--yes` before a file is replaced.
+
+- `channel.import` (`wai channel import`): feeds in; the receipt (`channel.json`, its JSON
+ carriage), the lineup (`lineup.wlu`) and the cues (`cues.json`) out, into a directory. A
+ refusal's code is the error's code, and its exit status 1.
+- `channel.guide` (`wai channel guide`): a receipt and a lineup in; an XMLTV guide of a range
+ (the whole schedule unless `--from` and `--to` say otherwise), or with `--now-next --at` a
+ now/next document, out.
+- `channel.verify` (`wai channel verify`): a receipt, a lineup and optionally a guide in; what
+ §8.3 states out, exit status 0 when verified and 1 otherwise.
+
+The corpus's sample channel (§11) is imported, guided and verified so:
+
+```
+wai channel import --channel sample-1 --xmltv sample.xmltv.xml \
+ --mrss sample-a.mrss.xml --mrss-owner owner-a --mrss sample-b.mrss.xml --mrss-owner owner-b \
+ --owner owner-c --playlist sample.m3u8 --artwork . --key operator.key --signer-id sample-operator \
+ --window-start 1793296800000 --window-end 1793300400000 --out-dir out
+wai channel guide --receipt out/channel.json --lineup out/lineup.wlu --out guide.xml
+wai channel guide --receipt out/channel.json --lineup out/lineup.wlu --now-next --at 1793299545000 --out now-next.json
+wai channel verify --receipt out/channel.json --lineup out/lineup.wlu --guide guide.xml
+```
+
+The test `tests/channel_lineup.rs` runs these commands on the corpus's files and requires the
+corpus's receipt, lineup, cues and guides from them, byte for byte.
+
+## 10. Reasons
+
+The codes this extension adds, each with where it is raised:
+
+| reason | raised at |
+|---|---|
+| `import_inputs_invalid` | §4.1 |
+| `import_oversized` | §3.1, §4.6 step 3 |
+| `import_xml_malformed` | §3.3 |
+| `import_json_malformed` | §4.4 |
+| `import_playlist_malformed` | §4.5 |
+| `import_time_malformed` | §3.4, §4.2, §4.3, §4.4, §4.6 |
+| `import_timezone_unknown` | §3.4 |
+| `import_no_stop` | §4.2 step 2 |
+| `import_empty` | §4.2, §4.6 |
+| `import_asset_unknown` | §4.2 step 3, §4.3 step 1, §4.6 step 1 |
+| `import_asset_duplicate` | §4.3 step 1, §4.4 |
+| `import_field_invalid` | §3.2, §4.2 to §4.5 |
+| `import_scte35_malformed` | §4.5 step 3 |
+| `import_break_duration` | §4.5 step 4 |
+| `import_duration_unknown` | §4.6 |
+| `import_gap` | §4.6 |
+| `import_overlap` | §4.6, §4.7 |
+| `import_owner_unknown` | §4.6 step 2 |
+| `import_artwork_missing` | §4.6 step 3 |
+| `import_break_outside` | §4.7 |
+| `import_window_outside` | §4.9 |
+| `channel_receipt_malformed` | §8.2 step 1 |
+| `channel_receipt_invalid` | §8.2 step 3 |
+| `lineup_malformed` | §6.4, §8.2 step 4 |
+| `lineup_signer_mismatch` | §8.2 step 6 |
+| `lineup_schedule_mismatch` | §8.2 step 7 |
+| `guide_malformed` | §8.2 step 8 |
+| `guide_source_mismatch` | §8.2 step 9 |
+| `guide_entry_unscheduled` | §8.2 step 10 |
+| `guide_mismatch` | §8.2 step 11 |
+
+A verification also gives live-events' `bad_key` and `bad_signature` (§8.2 steps 2 and 5).
+
+## 11. Conformance
+
+[`live-conformance/channels/`](../live-conformance/channels/) holds the corpus, written from fixed
+seeds by `wai_channel_lineup_vectors gen`: `vectors.json`, which holds the operator's key and
+each vector's inputs and outcome; the feeds of a sample channel (an XMLTV document with a second
+channel's programmes beside it, two Media RSS documents of two content owners, a Media Playlist
+with SCTE-35 date ranges, and the JSON form of the same channel); the artwork files; the receipt
+and the guides the sample yields. Every document is a fixture written for its case, of
+programmes, owners and pictures made for it. The vectors:
+
+- **imports**: the sample from XMLTV, Media RSS and the playlist; the same channel from the JSON
+ form, which yields the same receipt and lineup byte for byte; a channel made of one owner's
+ Media RSS items alone, back to back; and a control of each refusal of §4, each the sample with
+ one change (an edit the vector states, which a verifier applies to its copy);
+- **verifications**: the sample's receipt and lineup alone, with its XMLTV guide, with a guide of
+ part of the schedule, and with now/next documents before, inside and after the schedule; and a
+ control of each step of §8.2 (a receipt that does not read, a flipped signature, a key of small
+ 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).
+
+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.
+`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
+live-events'. `live-conformance/channel_lineup_verify.py` is the second implementation, written
+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
+every copy to fail, and drops each of its own checks in turn and requires the corpus to catch the
+weakened verifier.
+
+## 12. Security and privacy considerations
+
+- **What a lineup proves.** That the operator stated this description of each programme asset of
+ one schedule, and where its guide's programmes begin. It does not prove that a rights holder
+ agreed: a rights holder's name in a lineup is the operator's statement, and a party paid by it
+ checks a contract it signed, not the lineup.
+- **What a guide proves.** Nothing by itself: it is recomputed from a receipt and a lineup and
+ compared, so a guide that verifies says exactly what the two signed records say, and a guide a
+ third party altered is refused. A guide is a schedule published ahead of air; which of it was on
+ air in a window is the receipt's program-as-run (§8.3), and a guide covering a span the receipt's
+ window does not is checked against the schedule alone.
+- **Artwork.** A lineup binds each picture by its SHA-256, so a player that fetches the URI and
+ checks the hash shows the picture the operator stated, wherever it was fetched from.
+- **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.
+- **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.
diff --git a/wai/live-conformance/channel_lineup_verify.py b/wai/live-conformance/channel_lineup_verify.py
new file mode 100644
index 000000000..70c2f1511
--- /dev/null
+++ b/wai/live-conformance/channel_lineup_verify.py
@@ -0,0 +1,1390 @@
+#!/usr/bin/env python3
+"""Independent verifier of the channel lineup corpus (extensions/channel-lineup.md).
+
+Written from the extension's text. It shares no code with wai-rs, which generates the corpus,
+and never runs it. Ed25519 signatures are checked with ../claim-conformance/_ed25519.py, WAI's
+strict rule (SPEC §7 *Signatures*), and BLAKE3 with ../claim-conformance/_blake3.py. From the
+corpora's other second implementations it takes, by reference as the extension does: a channel
+receipt's JSON carriage, its leaves and schedule root, its signed bytes, its identity and its
+rules (revenue-share §4, the extension's §5) from revenue_share_verify.py; and the XML subset, the
+HLS attribute lists and the decimal reading of stitched-presentation §3.2 and §3.5 from
+stitched_presentation_verify.py. Everything else is here, from the standard library:
+
+- text values, XMLTV and HLS times, durations, and the document type declaration (§3);
+- 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).
+
+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
+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
+verifier.
+
+ python3 -I channel_lineup_verify.py
+
+Exit status 0 only if every vector verifies, every mutated copy fails and every weakened verifier
+is caught. Python 3.8 or later; standard library only.
+"""
+
+import copy
+import datetime
+import hashlib
+import json
+import os
+import struct
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, HERE)
+sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
+import _blake3 as B3 # noqa: E402
+import _ed25519 as ED # noqa: E402
+import revenue_share_verify as RSV # noqa: E402
+import stitched_presentation_verify as SPV # noqa: E402
+
+EXTENSION = os.path.join(HERE, "..", "extensions", "channel-lineup.md")
+
+MAGIC, VERSION = b"WLU1", 1 # §6.1
+LINEUP_DOMAIN = b"wai:channel-lineup\x01" # §6.3
+CUE_ID_DOMAIN = b"wai:channel-cue-id\x01" # §4.8
+ID_SYSTEM = "wai-asset-id" # §7.2
+GUIDE_URN = "urn:wai:guide:"
+NOW_NEXT_KIND, FEED_KIND = "wai-now-next", "wai-channel-feed"
+MAX_DOC, MAX_COUNT, MAX_INSTANT = 16777216, 10000, 2 ** 53 - 1 # §2, §3.1
+U32, U64 = 2 ** 32 - 1, 2 ** 64 - 1
+EVERYWHERE, ONLY, EXCEPT = 0, 1, 2 # §6.2 geo
+DIGITS = "0123456789"
+WS = " \t\r\n"
+
+# The checks a weakened verifier may drop (main(), the last part).
+SKIP = set()
+
+
+class Refuse(Exception):
+ def __init__(self, reason):
+ super().__init__(reason)
+ self.reason = reason
+
+
+def check(name, ok, reason):
+ """A rule: refuse `reason` when it fails, unless this run drops the check `name`."""
+ if not ok and name not in SKIP:
+ raise Refuse(reason)
+
+
+def sha(*parts):
+ h = hashlib.sha256()
+ for p in parts:
+ h.update(p)
+ return h.digest()
+
+
+def reasons():
+ """§10's table of the codes this extension adds, and the codes it names after it."""
+ with open(EXTENSION, encoding="utf-8") as f:
+ text = f.read()
+ sec = text[text.index("\n## 10. Reasons"):]
+ sec = sec[:sec.index("\n## ", 1)]
+ table, after = sec.split("\nA verification also", 1)
+ added = [ln.split("`")[1] for ln in table.splitlines() if ln.startswith("| `")]
+ reused = [w.strip("`") for w in after.split() if w.startswith("`") and w.rstrip(",.").endswith("`")]
+ return added, [r.rstrip("`") for r in reused]
+
+
+# ---------------------------------------------------------------- §3.2 text values
+
+def is_text(s):
+ return isinstance(s, str) and all(c >= " " or c in "\t\n" for c in s) and not any("\ud800" <= c <= "\udfff" for c in s)
+
+
+def blen(s):
+ return len(s.encode("utf-8"))
+
+
+def short_text(s):
+ return is_text(s) and 1 <= blen(s) <= 255
+
+
+def long_text(s):
+ return is_text(s) and blen(s) <= 65535
+
+
+def field(ok):
+ check("fields", ok, "import_field_invalid")
+
+
+# ---------------------------------------------------------------- §3.4 times
+
+def instant(y, mo, d, h, mi, s, ms, offset_min):
+ if y < 1970 or not 1 <= mo <= 12 or h > 23 or mi > 59 or s > 59:
+ return None
+ try:
+ days = (datetime.date(y, mo, d) - datetime.date(1970, 1, 1)).days
+ except ValueError:
+ return None
+ t = (days * 86400 + h * 3600 + mi * 60 + s - offset_min * 60) * 1000 + ms
+ return t if t >= 0 else None
+
+
+def digits(s):
+ return len(s) > 0 and all(c in DIGITS for c in s)
+
+
+def xmltv_time(v):
+ """§3.4: an XMLTV time's instant, or Refuse."""
+ if len(v) < 14 or not digits(v[:14]):
+ raise Refuse("import_time_malformed")
+ rest = v[14:]
+ if rest == "":
+ off = 0
+ elif rest.startswith(" "):
+ z = rest[1:]
+ ok = len(z) == 5 and z[0] in "+-" and digits(z[1:]) and int(z[1:3]) <= 23 and int(z[3:5]) <= 59
+ check("timezone", ok, "import_timezone_unknown")
+ if not ok:
+ off = 0
+ else:
+ off = (int(z[1:3]) * 60 + int(z[3:5])) * (-1 if z[0] == "-" else 1)
+ else:
+ raise Refuse("import_time_malformed")
+ t = instant(int(v[0:4]), int(v[4:6]), int(v[6:8]), int(v[8:10]), int(v[10:12]), int(v[12:14]), 0, off)
+ if t is None:
+ raise Refuse("import_time_malformed")
+ return t
+
+
+def xmltv_format(ms):
+ d = datetime.datetime(1970, 1, 1) + datetime.timedelta(milliseconds=ms)
+ return d.strftime("%Y%m%d%H%M%S") + " +0000"
+
+
+def hls_date_time(v):
+ """§3.4: an HLS date-time's instant, or Refuse."""
+ bad = Refuse("import_time_malformed")
+ shape = "dddd-dd-ddTdd:dd:dd"
+ if len(v) < 19 or any((c not in DIGITS) if p == "d" else (c != p) for p, c in zip(shape, v[:19])):
+ raise bad
+ p, ms = 19, 0
+ if p < len(v) and v[p] == ".":
+ q = p + 1
+ while q < len(v) and v[q] in DIGITS:
+ q += 1
+ frac = v[p + 1:q]
+ if not frac or frac[3:].strip("0"):
+ raise bad
+ ms = int((frac[:3] + "000")[:3])
+ p = q
+ z = v[p:]
+ if z == "":
+ raise Refuse("import_timezone_unknown")
+ if z == "Z":
+ off = 0
+ elif len(z) == 6 and z[0] in "+-" and z[3] == ":" and digits(z[1:3]) and digits(z[4:6]):
+ hh, mm = int(z[1:3]), int(z[4:6])
+ if hh > 23 or mm > 59:
+ raise bad
+ off = (hh * 60 + mm) * (-1 if z[0] == "-" else 1)
+ else:
+ raise bad
+ t = instant(int(v[0:4]), int(v[5:7]), int(v[8:10]), int(v[11:13]), int(v[14:16]), int(v[17:19]), ms, off)
+ if t is None or t > MAX_INSTANT:
+ raise bad
+ return t
+
+
+def duration(v):
+ """§3.4: a duration in milliseconds, or Refuse."""
+ d = SPV.decimal_scaled(v, 3)
+ if d is None or d > MAX_INSTANT:
+ raise Refuse("import_time_malformed")
+ return d
+
+
+# ---------------------------------------------------------------- §3.3 the XML subset
+
+def doctype_end(b, at):
+ """The end of a document type declaration of §3.3 at `at`, or None."""
+ def skip(p):
+ while p < len(b) and b[p] in b" \t\r\n":
+ p += 1
+ return p
+
+ def literal(p):
+ if p >= len(b) or b[p] not in b"\"'":
+ return None
+ q = b.find(b[p:p + 1], p + 1)
+ return None if q < 0 else q + 1
+
+ p = at + len(b"= len(b) or not (chr(b[p]).isascii() and (chr(b[p]).isalpha() or b[p] == ord("_"))):
+ return None
+ p += 1
+ while p < len(b) and (chr(b[p]).isascii() and (chr(b[p]).isalnum() or chr(b[p]) in "_-.:")):
+ p += 1
+ a = skip(p)
+ if a > p:
+ if b.startswith(b"SYSTEM", a):
+ q = skip(a + 6)
+ if q == a + 6:
+ return None
+ p = literal(q)
+ elif b.startswith(b"PUBLIC", a):
+ q = skip(a + 6)
+ if q == a + 6:
+ return None
+ r = literal(q)
+ if r is None:
+ return None
+ s = skip(r)
+ if s == r:
+ return None
+ p = literal(s)
+ else:
+ p = a
+ if p is None:
+ return None
+ p = skip(p)
+ return p + 1 if p < len(b) and b[p] == ord(">") else None
+
+
+def read_feed_xml(b):
+ """§3.3: the root element, or None."""
+ b = bytearray(b)
+ p = 0
+ while True:
+ while p < len(b) and b[p] in b" \t\r\n":
+ p += 1
+ if b.startswith(b"", p + 4)
+ if q < 0:
+ return None
+ p = q + 3
+ elif b.startswith(b"", p):
+ q = b.find(b"?>", p + 2)
+ if q < 0:
+ return None
+ p = q + 2
+ elif b.startswith(b" 0
+ if inp["json"] is not None:
+ sets = inp["xmltv"] is None and not inp["mrss"]
+ else:
+ sets = inp["xmltv"] is not None or bool(inp["mrss"])
+ owners = [m["owner"] for m in inp["mrss"]]
+ ok = (short_text(inp["channel"]) and sets
+ and (all(o is not None for o in owners) or all(o is None for o in owners))
+ and all(o is None or short_text(o) for o in owners)
+ and all(inp[k] is None or short_text(inp[k]) for k in ("owner", "id_system", "xmltv_channel"))
+ and short_text(inp["signer_id"])
+ and not (alone and inp["start_ms"] is None)
+ and (inp["start_ms"] is None or inp["start_ms"] <= MAX_INSTANT))
+ check("inputs", ok, "import_inputs_invalid")
+
+
+def sized(b):
+ check("size", len(b) <= MAX_DOC, "import_oversized")
+
+
+def description(e, desc_name, art, scheme_attr, scheme_default, rating_text):
+ """§4.2 step 4, §4.3 step 3."""
+ title = text_of(first(e, "title"))
+ field(title != "" and long_text(title))
+ desc = text_of(first(e, desc_name))
+ field(long_text(desc))
+ cats = [SPV.text(c) for c in SPV.kids(e, "category")]
+ field(len(cats) <= 16 and all(short_text(c) for c in cats))
+ uris = [i["attrs"].get(art[1]) for i in SPV.kids(e, art[0])]
+ field(len(uris) <= 8 and all(u is not None and u != "" and long_text(u) for u in uris))
+ rating = ("", "")
+ r = first(e, "rating")
+ if r is not None:
+ system = r["attrs"].get(scheme_attr, scheme_default)
+ field(system == "" or short_text(system))
+ value = rating_text(r)
+ field(short_text(value))
+ rating = (system, value)
+ return {"title": title, "description": desc, "categories": cats, "rating": rating, "art": ("uris", uris),
+ "geo": (EVERYWHERE, []), "content_uri": "", "duration_ms": 0}
+
+
+def read_xmltv(b, xch, id_system):
+ """§4.2: (channel name or None, [(programme, description)])."""
+ sized(b)
+ root = read_feed_xml(b)
+ if root is None or root["name"] != "tv":
+ raise Refuse("import_xml_malformed")
+ progs = [p for p in SPV.kids(root, "programme") if p["attrs"].get("channel") == xch]
+ check("counts", len(progs) <= MAX_COUNT, "import_oversized")
+ name = None
+ for c in SPV.kids(root, "channel"):
+ if c["attrs"].get("id") == xch:
+ name = text_of(first(c, "display-name")) or None
+ break
+ if name is not None:
+ field(short_text(name))
+ if not progs:
+ raise Refuse("import_empty")
+ out = []
+ for p in progs:
+ if "start" not in p["attrs"]:
+ raise Refuse("import_time_malformed")
+ start = xmltv_time(p["attrs"]["start"])
+ if "stop" not in p["attrs"]:
+ check("no_stop", False, "import_no_stop")
+ stop = start + 1000
+ else:
+ stop = xmltv_time(p["attrs"]["stop"])
+ if stop <= start:
+ raise Refuse("import_time_malformed")
+ ids = [SPV.text(e) for e in SPV.kids(p, "episode-num") if e["attrs"].get("system", "onscreen") == id_system]
+ check("asset", bool(ids) and short_text(ids[0]), "import_asset_unknown")
+ asset = ids[0] if ids else "?"
+ d = description(p, "desc", ("icon", "src"), "system", "", lambda r: text_of(first(r, "value")))
+ out.append(({"start": start, "stop": stop, "asset": asset}, d))
+ return name, out
+
+
+def restriction(item):
+ """§4.3 step 4."""
+ r = None
+ for x in SPV.kids(item, "restriction"):
+ if x["attrs"].get("type") == "country":
+ r = x
+ break
+ if r is None:
+ return (EVERYWHERE, [])
+ rel = r["attrs"].get("relationship")
+ field(rel in ("allow", "deny"))
+ allow = rel == "allow"
+ toks = [t for t in SPV.text(r).replace("\t", " ").replace("\r", " ").replace("\n", " ").split(" ") if t]
+ if toks in (["all"], ["none"]):
+ return (EVERYWHERE, []) if (toks == ["all"]) == allow else (ONLY, [])
+ ok = bool(toks) and all(len(t) == 2 and t.isascii() and t.isalpha() for t in toks)
+ field(ok)
+ codes = sorted(set(t.upper() for t in toks))
+ return (ONLY if allow else EXCEPT, codes)
+
+
+def read_mrss(b, owner, seen, count):
+ """§4.3: (channel name or None, [item])."""
+ sized(b)
+ root = read_feed_xml(b)
+ ch = first(root, "channel") if root is not None and root["name"] == "rss" else None
+ if ch is None:
+ raise Refuse("import_xml_malformed")
+ items = SPV.kids(ch, "item")
+ count[0] += len(items)
+ check("counts", count[0] <= MAX_COUNT, "import_oversized")
+ name = text_of(first(ch, "title")) or None
+ if name is not None:
+ field(short_text(name))
+ out = []
+ for it in items:
+ g = first(it, "guid")
+ asset = SPV.text(g) if g is not None else ""
+ check("asset", short_text(asset), "import_asset_unknown")
+ check("duplicate", asset not in seen, "import_asset_duplicate")
+ seen.add(asset)
+ content = first(it, "content")
+ if content is None:
+ grp = first(it, "group")
+ content = first(grp, "content") if grp is not None else None
+ uri = content["attrs"].get("url", "") if content is not None else ""
+ field(long_text(uri))
+ dur = None
+ if content is not None and "duration" in content["attrs"]:
+ dur = duration(content["attrs"]["duration"])
+ if dur == 0:
+ raise Refuse("import_time_malformed")
+ d = description(it, "description", ("thumbnail", "url"), "scheme", "urn:simple", SPV.text)
+ d["geo"] = restriction(it)
+ d["content_uri"], d["duration_ms"] = uri, dur or 0
+ out.append({"asset": asset, "desc": d, "duration": dur, "owner": owner})
+ return name, out
+
+
+def strict_json(b):
+ """§4.4's reading of JSON: a value, or None (a member named twice, a number that is not an
+ integer of the form, an unpaired surrogate, more than 64 levels)."""
+ class Bad(Exception):
+ pass
+
+ def pairs(ps):
+ names = [n for n, _ in ps]
+ if len(set(names)) != len(names):
+ raise Bad()
+ return dict(ps)
+
+ def bad_number(s):
+ raise Bad()
+
+ def depth(v, d):
+ if d > 64:
+ raise Bad()
+ if isinstance(v, dict):
+ for k, x in v.items():
+ if not is_text_or_any(k):
+ raise Bad()
+ depth(x, d + 1)
+ elif isinstance(v, list):
+ for x in v:
+ depth(x, d + 1)
+ elif isinstance(v, str) and not is_text_or_any(v):
+ raise Bad()
+
+ def is_text_or_any(s):
+ return not any("\ud800" <= c <= "\udfff" for c in s)
+
+ try:
+ s = b.decode("utf-8")
+ v = json.loads(s, object_pairs_hook=pairs, parse_float=bad_number, parse_constant=bad_number)
+ depth(v, 1)
+ return v
+ except (UnicodeDecodeError, ValueError, Bad, RecursionError):
+ return None
+
+
+def jint(v):
+ return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= MAX_INSTANT
+
+
+def exactly(o, names):
+ return isinstance(o, dict) and set(o) == set(names) and len(o) == len(names)
+
+
+def json_structure(v):
+ """§4.4's table: True when the form reads."""
+ if not exactly(v, ["kind", "channel_name", "programmes", "assets", "breaks"]) or v["kind"] != FEED_KIND:
+ return False
+ if not isinstance(v["channel_name"], str) or not all(isinstance(v[k], list) for k in ("programmes", "assets", "breaks")):
+ return False
+ for p in v["programmes"]:
+ if not (exactly(p, ["start_ms", "stop_ms", "asset_id"]) and jint(p["start_ms"]) and jint(p["stop_ms"])
+ and isinstance(p["asset_id"], str)):
+ return False
+ for a in v["assets"]:
+ if not exactly(a, ["asset_id", "title", "description", "content_uri", "categories", "rating", "artwork", "geo",
+ "rights_holder", "duration_ms"]):
+ return False
+ if not all(isinstance(a[k], str) for k in ("asset_id", "title", "description", "content_uri")):
+ return False
+ if not (isinstance(a["categories"], list) and all(isinstance(c, str) for c in a["categories"])):
+ return False
+ r = a["rating"]
+ if r is not None and not (exactly(r, ["system", "value"]) and isinstance(r["system"], str) and isinstance(r["value"], str)):
+ return False
+ if not isinstance(a["artwork"], list) or not all(exactly(w, ["uri", "sha256"]) and isinstance(w["uri"], str)
+ and isinstance(w["sha256"], str) for w in a["artwork"]):
+ return False
+ g = a["geo"]
+ if g is not None and not (isinstance(g, dict) and len(g) == 1 and list(g)[0] in ("allow", "deny")
+ and isinstance(list(g.values())[0], list)
+ and all(isinstance(c, str) for c in list(g.values())[0])):
+ return False
+ if not (a["rights_holder"] is None or isinstance(a["rights_holder"], str)) or not jint(a["duration_ms"]):
+ return False
+ for k in v["breaks"]:
+ if not (exactly(k, ["id", "start_ms", "duration_ms", "scte35"]) and isinstance(k["id"], str) and jint(k["start_ms"])
+ and jint(k["duration_ms"]) and (k["scte35"] is None or isinstance(k["scte35"], str))):
+ return False
+ return True
+
+
+def hex_any(s):
+ if len(s) % 2 or any(c not in "0123456789abcdefABCDEF" for c in s):
+ return None
+ return bytes.fromhex(s)
+
+
+def section_reads(b):
+ """live-events §4.3's reading of form 1 (§4.5 step 3)."""
+ return len(b) >= 3 and b[0] == 0xFC and (((b[1] << 8) | b[2]) & 0x0FFF) + 3 == len(b)
+
+
+def break_duration(d):
+ check("break_duration", 0 < d <= U32, "import_break_duration")
+ return d
+
+
+def scte35(b):
+ check("scte35", b is not None and section_reads(b), "import_scte35_malformed")
+ return b
+
+
+def read_json(b, nbreaks):
+ """§4.4: (channel name, programmes, {asset id: (description, rights holder)}, breaks)."""
+ sized(b)
+ v = strict_json(b)
+ if v is None or not json_structure(v):
+ raise Refuse("import_json_malformed")
+ nbreaks[0] += len(v["breaks"])
+ check("counts", len(v["programmes"]) <= MAX_COUNT and len(v["assets"]) <= MAX_COUNT and nbreaks[0] <= MAX_COUNT,
+ "import_oversized")
+ field(short_text(v["channel_name"]))
+ progs = []
+ for p in v["programmes"]:
+ field(short_text(p["asset_id"]))
+ if p["stop_ms"] <= p["start_ms"]:
+ raise Refuse("import_time_malformed")
+ progs.append({"start": p["start_ms"], "stop": p["stop_ms"], "asset": p["asset_id"]})
+ assets = {}
+ for a in v["assets"]:
+ field(short_text(a["asset_id"]))
+ check("duplicate", a["asset_id"] not in assets, "import_asset_duplicate")
+ field(a["title"] != "" and long_text(a["title"]))
+ field(long_text(a["description"]) and long_text(a["content_uri"]))
+ field(len(a["categories"]) <= 16 and all(short_text(c) for c in a["categories"]))
+ rating = ("", "")
+ if a["rating"] is not None:
+ s, val = a["rating"]["system"], a["rating"]["value"]
+ field((s == "" or short_text(s)) and short_text(val))
+ rating = (s, val)
+ field(len(a["artwork"]) <= 8 and all(w["uri"] != "" and long_text(w["uri"]) and len(w["sha256"]) == 64
+ and all(c in "0123456789abcdef" for c in w["sha256"]) for w in a["artwork"]))
+ geo = (EVERYWHERE, [])
+ if a["geo"] is not None:
+ (rel, codes), = a["geo"].items()
+ ok = (all(len(c) == 2 and c.isascii() and c.isalpha() and c.isupper() for c in codes)
+ and all(codes[i] < codes[i + 1] for i in range(len(codes) - 1)) and (rel == "allow" or codes))
+ field(ok)
+ geo = (ONLY if rel == "allow" else EXCEPT, list(codes))
+ if a["rights_holder"] is not None:
+ field(short_text(a["rights_holder"]))
+ d = {"title": a["title"], "description": a["description"], "categories": a["categories"], "rating": rating,
+ "art": ("hashed", [(bytes.fromhex(w["sha256"]), w["uri"]) for w in a["artwork"]]), "geo": geo,
+ "content_uri": a["content_uri"], "duration_ms": a["duration_ms"]}
+ assets[a["asset_id"]] = (d, a["rights_holder"])
+ breaks = []
+ for k in v["breaks"]:
+ field(short_text(k["id"]))
+ sec = scte35(hex_any(k["scte35"])) if k["scte35"] is not None else None
+ breaks.append({"id": k["id"], "start": k["start_ms"], "duration": break_duration(k["duration_ms"]), "section": sec})
+ return v["channel_name"], progs, assets, breaks
+
+
+def hex_sequence(v):
+ """§4.5 step 3: an unquoted hexadecimal-sequence's bytes, or None."""
+ value, quoted = v
+ if quoted or value[:2] not in ("0x", "0X"):
+ return None
+ return hex_any(value[2:])
+
+
+def read_playlist(b, nbreaks):
+ """§4.5: the breaks."""
+ sized(b)
+ bad = Refuse("import_playlist_malformed")
+ try:
+ s = b.decode("utf-8")
+ except UnicodeDecodeError:
+ raise bad
+ lines = [ln[:-1] if ln.endswith("\r") else ln for ln in s.split("\n")]
+ if lines[0] != "#EXTM3U":
+ raise bad
+ groups = [] # [(id, {name: (value, quoted)})]
+ for ln in lines:
+ if not ln.startswith("#EXT-X-DATERANGE:"):
+ continue
+ a = SPV.attribute_list(ln[len("#EXT-X-DATERANGE:"):])
+ if a is None:
+ raise bad
+ d = {n: (v, q) for n, v, q in a}
+ if "ID" not in d or not d["ID"][1]:
+ raise bad
+ gid = d["ID"][0]
+ g = next((x for x in groups if x[0] == gid), None)
+ if g is None:
+ g = (gid, {})
+ groups.append(g)
+ for n, v in d.items():
+ if n in g[1] and g[1][n] != v and "conflict" not in SKIP:
+ raise bad
+ g[1].setdefault(n, v)
+ brks = [g for g in groups if "SCTE35-OUT" in g[1]]
+ nbreaks[0] += len(brks)
+ check("counts", nbreaks[0] <= MAX_COUNT, "import_oversized")
+ out = []
+ for gid, a in brks:
+ field(short_text(gid))
+ sd = a.get("START-DATE")
+ if sd is None or not sd[1]:
+ raise bad
+ start = hls_date_time(sd[0])
+ sec = scte35(hex_sequence(a["SCTE35-OUT"]))
+ if "SCTE35-IN" in a:
+ scte35(hex_sequence(a["SCTE35-IN"]))
+
+ def plain(v):
+ if v[1]:
+ raise bad
+ return duration(v[0])
+ if "DURATION" in a:
+ d = plain(a["DURATION"])
+ elif "END-DATE" in a:
+ if not a["END-DATE"][1]:
+ raise bad
+ end = hls_date_time(a["END-DATE"][0])
+ check("break_duration", end >= start, "import_break_duration")
+ d = end - start
+ elif "PLANNED-DURATION" in a:
+ d = plain(a["PLANNED-DURATION"])
+ else:
+ raise Refuse("import_break_duration")
+ out.append({"id": gid, "start": start, "duration": break_duration(d), "section": sec})
+ return out
+
+
+def artwork_name(uri):
+ u = uri
+ for c in "?#":
+ u = u.split(c, 1)[0]
+ name = u.rsplit("/", 1)[-1]
+ ok = 1 <= len(name) <= 255 and name not in (".", "..") and all(c.isascii() and (c.isalnum() or c in "._-") for c in name)
+ return name if ok else None
+
+
+def cue_id(bid, sec):
+ if sec is not None and len(sec) >= 18 and sec[4] & 0x80 == 0 and sec[13] == 0x05:
+ return int.from_bytes(sec[14:18], "big")
+ return int.from_bytes(sha(CUE_ID_DOMAIN, bid.encode())[:4], "big")
+
+
+def cue_payload(k):
+ """§4.8: live-events §4.3's cue, little-endian."""
+ out = bytes([4]) + struct.pack(" MAX_INSTANT:
+ raise Refuse("import_time_malformed")
+ progs.append({"start": at, "stop": stop, "asset": it["asset"]})
+ at = stop
+ progs = sorted(progs, key=lambda p: p["start"])
+ for p, q in zip(progs, progs[1:]):
+ check("gap", p["stop"] >= q["start"], "import_gap")
+ check("overlap", p["stop"] <= q["start"], "import_overlap")
+ check("whole_seconds", all(p["start"] % 1000 == 0 for p in progs) and progs[-1]["stop"] % 1000 == 0,
+ "import_time_malformed")
+
+ # §4.6 their assets
+ item_of = {it["asset"]: it for it in items}
+ order = []
+ for p in progs:
+ if p["asset"] not in order:
+ order.append(p["asset"])
+ assets = []
+ for aid in order:
+ if jf is not None:
+ check("asset", aid in jf[1], "import_asset_unknown")
+ d, holder = jf[1].get(aid, (None, None))
+ if d is None:
+ continue
+ elif aid in item_of:
+ d, holder = item_of[aid]["desc"], item_of[aid]["owner"]
+ else:
+ d, holder = next(x for p, x in xmltv if p["asset"] == aid), None
+ holder = holder if holder is not None else inp["owner"]
+ check("owner", holder is not None, "import_owner_unknown")
+ kind, art = d["art"]
+ if kind == "uris":
+ hashed = []
+ for u in art:
+ n = artwork_name(u)
+ ok = n is not None and n in inp["artwork_files"]
+ check("artwork", ok, "import_artwork_missing")
+ body = inp["artwork_files"].get(n, b"") if n else b""
+ sized(body)
+ hashed.append((hashlib.sha256(body).digest(), u))
+ art = hashed
+ assets.append({"asset_id": aid, "title": d["title"], "description": d["description"], "categories": d["categories"],
+ "rating_system": d["rating"][0], "rating_value": d["rating"][1], "artwork": art, "geo": d["geo"][0],
+ "countries": d["geo"][1], "rights_holder": holder or "", "content_uri": d["content_uri"],
+ "duration_ms": d["duration_ms"]})
+
+ # §4.7 the schedule
+ breaks = sorted(breaks, key=lambda k: k["start"])
+ for k, n in zip(breaks, breaks[1:]):
+ check("break_overlap", k["start"] + k["duration"] <= n["start"], "import_overlap")
+ within = [[] for _ in progs]
+ for k in breaks:
+ at = next((i for i, p in enumerate(progs) if p["start"] <= k["start"] < p["stop"]), None)
+ ok = at is not None and k["start"] + k["duration"] <= progs[at]["stop"]
+ check("break_outside", ok, "import_break_outside")
+ if at is not None:
+ within[at].append(k)
+ entries = []
+ for p, ks in zip(progs, within):
+ at = p["start"]
+ for k in ks:
+ if k["start"] > at:
+ entries.append({"asset_id": p["asset"], "start_ms": at, "duration_ms": k["start"] - at, "kind": "program"})
+ entries.append({"asset_id": k["id"], "start_ms": k["start"], "duration_ms": k["duration"], "kind": "ad"})
+ at = k["start"] + k["duration"]
+ if p["stop"] > at:
+ entries.append({"asset_id": p["asset"], "start_ms": at, "duration_ms": p["stop"] - at, "kind": "program"})
+
+ # §4.9
+ first_start, last_end = progs[0]["start"], progs[-1]["stop"]
+ ws, we = inp["window"] if inp["window"] is not None else (first_start, last_end)
+ check("window", ws < we and ws >= first_start and we <= last_end, "import_window_outside")
+ name = next((n for n in names if n is not None), inp["channel"])
+ lineup = {"channel_id": inp["channel"], "channel_name": name, "schedule_root": schedule_root(entries),
+ "schedule_start_ms": first_start, "schedule_end_ms": last_end, "programmes": [p["start"] for p in progs],
+ "assets": sorted(assets, key=lambda a: a["asset_id"].encode())}
+ cues = [{"break": k["id"], "start_ms": k["start"], "payload": cue_payload(k).hex()} for k in breaks]
+ return {"entries": entries, "window": (ws, we), "lineup": lineup, "cues": cues}
+
+
+# ---------------------------------------------------------------- §5 the receipt
+
+def schedule_root(entries):
+ """revenue-share §4.2: the Merkle root of the entries' leaves."""
+ return RSV.merkle([RSV.leaf(e) for e in entries])
+
+
+def aired_of(entries, ws, we):
+ return [i for i, e in enumerate(entries) if e["start_ms"] < we and e["start_ms"] + e["duration_ms"] > ws]
+
+
+def u64(v):
+ return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= U64
+
+
+def hexn(v, n):
+ if not isinstance(v, str) or len(v) != 2 * n or any(c not in "0123456789abcdef" for c in v):
+ return None
+ return bytes.fromhex(v)
+
+
+def read_receipt(b):
+ """§5: one JSON text of revenue-share §4.1's carriage, read as revenue-share's second
+ implementation reads it; the run, or None."""
+ try:
+ r = RSV.read_run(json.loads(b.decode("utf-8")))
+ RSV.run_payload(r)
+ return r
+ except (UnicodeError, ValueError, RSV.NoRead, KeyError, TypeError):
+ return None
+
+
+def receipt_payload(r):
+ return RSV.run_payload(r)
+
+
+def receipt_identity(r):
+ return RSV.run_identity(r)
+
+
+def receipt_rules(r):
+ """§5: revenue-share §4.3's rules 1 to 5 (rule 6, the signature, is §8.2 step 2's)."""
+ return "receipt_rules" in SKIP or RSV.run_verifies(r)
+
+
+# ---------------------------------------------------------------- §6 the lineup
+
+def lshort(s):
+ b = s.encode()
+ return bytes([len(b)]) + b
+
+
+def llong(s):
+ b = s.encode()
+ return struct.pack(" len(self.b):
+ raise ValueError("short")
+ out = self.b[self.p:self.p + n]
+ self.p += n
+ return out
+
+ def u(self, fmt):
+ return struct.unpack(fmt, self.take(struct.calcsize(fmt)))[0]
+
+ def short(self):
+ return self.take(self.u("= e or any(ps[i] >= ps[i + 1] for i in range(p - 1)) or any(x % 1000 for x in ps):
+ return None
+ a = c.u(" 16:
+ return None
+ x["categories"] = [c.short() for _ in range(n)]
+ x["rating_system"], x["rating_value"] = c.short(), c.short()
+ k = c.u(" 8:
+ return None
+ x["artwork"] = [(c.take(32), c.long()) for _ in range(k)]
+ x["geo"] = c.u("= x["asset_id"].encode()):
+ return None
+ l["assets"].append(x)
+ l["signer"], l["sig"] = c.take(32), c.take(64)
+ return l if c.p == len(b) else None
+ except (ValueError, UnicodeDecodeError, struct.error):
+ return None
+
+
+def lineup_digest(l):
+ return sha(lineup_body(l, l["signer"]))
+
+
+def spans(l):
+ ps = l["programmes"]
+ return [(s, ps[k + 1] if k + 1 < len(ps) else l["schedule_end_ms"]) for k, s in enumerate(ps)]
+
+
+def programme_assets(l, sched):
+ """§6.5 rule 4: each programme's asset, or None."""
+ out = []
+ for s, e in spans(l):
+ ids = [x["asset_id"] for x in sched if x["kind"] == "program" and s <= x["start_ms"] < e]
+ if not ids or any(i != ids[0] for i in ids):
+ return None
+ out.append(ids[0])
+ return out
+
+
+def describes(l, r):
+ """§6.5, each rule a check of its own."""
+ s = r["schedule"]
+ if not s:
+ return False
+ end = s[-1]["start_ms"] + s[-1]["duration_ms"]
+ if "describes_root" not in SKIP and (l["channel_id"] != r["channel_id"] or l["schedule_root"] != r["schedule_root"]
+ or l["schedule_start_ms"] != s[0]["start_ms"] or l["schedule_end_ms"] != end):
+ return False
+ starts = {x["start_ms"] for x in s}
+ if "describes_starts" not in SKIP and any(p not in starts for p in l["programmes"]):
+ return False
+ if "describes_assets" not in SKIP and programme_assets(l, s) is None:
+ return False
+ # Rule 5 over the programmes' assets: every program entry's asset in a programme's span.
+ held = {x["asset_id"] for a, b in spans(l) for x in s if x["kind"] == "program" and a <= x["start_ms"] < b}
+ return "describes_set" in SKIP or sorted(held, key=str.encode) == [a["asset_id"] for a in l["assets"]]
+
+
+# ---------------------------------------------------------------- §7 the guide
+
+def all_programmes(r, l):
+ ids = programme_assets(l, r["schedule"])
+ by = {a["asset_id"]: a for a in l["assets"]}
+ return [(s, e, by[i]) for (s, e), i in zip(spans(l), ids)]
+
+
+def esc_text(s):
+ return s.replace("&", "&").replace("<", "<").replace(">", ">")
+
+
+def esc_attr(s):
+ return esc_text(s).replace('"', """).replace("\t", " ").replace("\n", "
")
+
+
+def xmltv_guide(r, l, frm, to):
+ """§7.2."""
+ ch = esc_attr(l["channel_id"])
+ o = ['\n',
+ '\n' % (GUIDE_URN, r["schedule_root"].hex(), lineup_digest(l).hex(), frm, to),
+ ' \n %s\n \n' % (ch, esc_text(l["channel_name"]))]
+ for s, e, a in all_programmes(r, l):
+ if not (s < to and e > frm):
+ continue
+ o.append(' \n' % (xmltv_format(s), xmltv_format(e), ch))
+ o.append(" %s\n" % esc_text(a["title"]))
+ if a["description"]:
+ o.append(" %s\n" % esc_text(a["description"]))
+ o += [" %s\n" % esc_text(c) for c in a["categories"]]
+ o += [' \n' % esc_attr(u) for _, u in a["artwork"]]
+ o.append(' %s\n' % (ID_SYSTEM, esc_text(a["asset_id"])))
+ if a["rating_value"]:
+ o.append(' \n' % esc_attr(a["rating_system"]) if a["rating_system"] else " \n")
+ o.append(" %s\n \n" % esc_text(a["rating_value"]))
+ o.append(" \n")
+ o.append("\n")
+ return "".join(o).encode("utf-8")
+
+
+def now_and_next(progs, at):
+ for k, p in enumerate(progs):
+ if p[0] <= at < p[1]:
+ return p, progs[k + 1] if k + 1 < len(progs) else None
+ if progs and at < progs[0][0]:
+ return None, progs[0]
+ return None, None
+
+
+def programme_doc(p):
+ s, e, a = p
+ geo = None
+ if a["geo"] == ONLY:
+ geo = {"allow": a["countries"]}
+ elif a["geo"] == EXCEPT:
+ geo = {"deny": a["countries"]}
+ return {"start_ms": s, "stop_ms": e, "asset_id": a["asset_id"], "title": a["title"], "description": a["description"],
+ "content_uri": a["content_uri"], "categories": a["categories"],
+ "rating": {"system": a["rating_system"], "value": a["rating_value"]} if a["rating_value"] else None,
+ "artwork": [{"sha256": h.hex(), "uri": u} for h, u in a["artwork"]], "geo": geo,
+ "rights_holder": a["rights_holder"], "duration_ms": a["duration_ms"]}
+
+
+def jcs(v):
+ """RFC 8785 over this document's values: sorted members (ASCII names), no space, strings as
+ JSON escapes them."""
+ return json.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
+
+
+def now_next_doc(r, l, at):
+ """§7.3."""
+ now, nxt = now_and_next(all_programmes(r, l), at)
+ v = {"kind": NOW_NEXT_KIND, "channel": l["channel_id"], "channel_name": l["channel_name"],
+ "schedule_root": r["schedule_root"].hex(), "lineup": lineup_digest(l).hex(), "at_ms": at,
+ "now": programme_doc(now) if now else None, "next": programme_doc(nxt) if nxt else None}
+ return (jcs(v) + "\n").encode("utf-8")
+
+
+def plain_uint(s):
+ return digits(s) and (s == "0" or s[0] != "0")
+
+
+def read_guide(b):
+ """§8.2 step 8: the guide as read, or None."""
+ if b[:1] == b"<":
+ root = read_feed_xml(b)
+ if root is None or root["name"] != "tv":
+ return None
+ url = root["attrs"].get("source-data-url", "")
+ if not url.startswith(GUIDE_URN):
+ return None
+ parts = url[len(GUIDE_URN):].split(":")
+ if len(parts) != 4 or not all(hexn(x, 32) is not None for x in parts[:2]) or not all(plain_uint(x) for x in parts[2:]):
+ return None
+ frm, to = int(parts[2]), int(parts[3])
+ if frm >= to:
+ return None
+ progs = []
+ for p in SPV.kids(root, "programme"):
+ try:
+ s, e = xmltv_time(p["attrs"].get("start", "")), xmltv_time(p["attrs"].get("stop", ""))
+ except Refuse:
+ return None
+ ids = [SPV.text(x) for x in SPV.kids(p, "episode-num") if x["attrs"].get("system", "onscreen") == ID_SYSTEM]
+ if not ids:
+ return None
+ progs.append((s, e, ids[0]))
+ return {"format": "xmltv", "root": parts[0], "lineup": parts[1], "from": frm, "to": to, "programmes": progs}
+ if b[:1] == b"{":
+ v = strict_json(b)
+ if not exactly(v, ["kind", "channel", "channel_name", "schedule_root", "lineup", "at_ms", "now", "next"]):
+ return None
+ if not jint(v["at_ms"]) or not isinstance(v["schedule_root"], str) or not isinstance(v["lineup"], str):
+ return None
+ out = []
+ for k in ("now", "next"):
+ p = v[k]
+ if p is None:
+ out.append(None)
+ elif isinstance(p, dict) and u64(p.get("start_ms")) and u64(p.get("stop_ms")) and isinstance(p.get("asset_id"), str):
+ out.append((p["start_ms"], p["stop_ms"], p["asset_id"]))
+ else:
+ return None
+ return {"format": "now-next", "root": v["schedule_root"], "lineup": v["lineup"], "at": v["at_ms"], "now": out[0],
+ "next": out[1]}
+ return None
+
+
+# ---------------------------------------------------------------- §8 verification
+
+def verify(receipt, lineup, guide):
+ """§8.2 and §8.3: the facts."""
+ f = {"verdict": "verified", "part": None, "receipt": None, "lineup": None, "guide": None, "entry": None}
+
+ def fail(part, reason):
+ f["verdict"], f["part"] = reason, part
+ 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")
+ l = read_lineup(lineup)
+ if l is None:
+ return fail("lineup", "lineup_malformed")
+ f["lineup"] = lineup_digest(l).hex()
+ bad = ED.verify(l["signer"], LINEUP_DOMAIN + lineup_body(l, l["signer"]), l["sig"]) if "lineup_sig" not in SKIP else None
+ if bad:
+ return fail("lineup", bad)
+ if l["signer"] != r["signer_pubkey"] and "signer" not in SKIP:
+ return fail("lineup", "lineup_signer_mismatch")
+ if not describes(l, r):
+ return fail("lineup", "lineup_schedule_mismatch")
+ if guide is None:
+ return f
+ g = read_guide(guide)
+ if g is None:
+ return fail("guide", "guide_malformed")
+ sched = r["schedule"]
+
+ def aired(s, e):
+ return any(sched[i]["start_ms"] < e and sched[i]["start_ms"] + sched[i]["duration_ms"] > s
+ for i in r["aired"] if i < len(sched))
+
+ progs = all_programmes(r, l)
+ if g["format"] == "xmltv":
+ shown = g["programmes"]
+ f["guide"] = {"format": "xmltv", "from_ms": g["from"], "to_ms": g["to"], "at_ms": None}
+ else:
+ shown = [p for p in (g["now"], g["next"]) if p is not None]
+ f["guide"] = {"format": "now-next", "from_ms": None, "to_ms": None, "at_ms": g["at"]}
+ f["guide"]["programmes"] = [{"start_ms": s, "stop_ms": e, "asset_id": i, "aired": aired(s, e)} for s, e, i in shown]
+ if (g["root"] != r["schedule_root"].hex() or g["lineup"] != f["lineup"]) and "source" not in SKIP:
+ return fail("guide", "guide_source_mismatch")
+ key = (lambda p: (p[0], p[1], p[2]["asset_id"]))
+ if g["format"] == "xmltv":
+ want = [key(p) for p in progs if p[0] < g["to"] and p[1] > g["from"]]
+ for i, p in enumerate(g["programmes"]):
+ if p not in want and "entries" not in SKIP:
+ f["entry"] = i
+ return fail("guide", "guide_entry_unscheduled")
+ expect = xmltv_guide(r, l, g["from"], g["to"])
+ else:
+ wn, wx = now_and_next(progs, g["at"])
+ for i, (got, w) in enumerate(((g["now"], wn), (g["next"], wx))):
+ if got != (key(w) if w else None) and "entries" not in SKIP:
+ f["entry"] = i
+ return fail("guide", "guide_entry_unscheduled")
+ expect = now_next_doc(r, l, g["at"])
+ if guide != expect and "bytes" not in SKIP:
+ return fail("guide", "guide_mismatch")
+ return f
+
+
+# ---------------------------------------------------------------- the corpus
+
+def apply_edits(files, edits):
+ out = dict(files)
+ for e in edits:
+ b = out[e["file"]]
+ if "pad" in e:
+ out[e["file"]] = b + b" " * e["pad"]
+ continue
+ s = b.decode("utf-8")
+ if s.count(e["find"]) != 1:
+ raise ValueError("%s: an edit's find does not occur once" % e["file"])
+ with_ = e["find"] + e["replace"] * e["repeat"] if "repeat" in e else e["replace"]
+ out[e["file"]] = s.replace(e["find"], with_, 1).encode("utf-8")
+ return out
+
+
+def import_inputs(v, files):
+ inp = dict(v["inputs"])
+ inp["artwork_files"] = {n: files[n] for n in inp["artwork"]}
+ if inp["window"] is not None:
+ inp["window"] = tuple(inp["window"])
+ return inp
+
+
+def check_import(doc, v, files, op_key):
+ """None when the import gets the outcome the corpus states, else why not."""
+ files = apply_edits(files, v["edits"])
+ o = v["outcome"]
+ try:
+ got = do_import(import_inputs(v, files), files)
+ except Refuse as e:
+ return None if o.get("refused") == e.reason else "refused %s, the corpus states %s" % (e.reason, o)
+ if "refused" in o:
+ return "imported, the corpus states %s" % o
+ r = read_receipt(files[o["receipt"]])
+ if r is None:
+ return "the receipt does not read"
+ inp = v["inputs"]
+ want_grant = {"grant_hash": bytes(32), "capability": "video.channel", "joule_ceiling_micro": U64, "funds_ceiling": None}
+ ws, we = got["window"]
+ same = (r["channel_id"] == inp["channel"] and r["schedule"] == got["entries"] and r["schedule_root"] == schedule_root(got["entries"])
+ and (r["window_start_ms"], r["window_end_ms"]) == (ws, we) and r["aired"] == aired_of(got["entries"], ws, we)
+ and r["work_schedule_ops"] == len(got["entries"]) and r["joules_micro"] == 0 and r["class"] is None
+ and r["grant"] == want_grant and r["parent"] == (bytes.fromhex(inp["parent_receipt_hash"]) if inp["parent_receipt_hash"] else None)
+ and r["signer_pubkey"] == op_key and r["signer_id"] == inp["signer_id"])
+ if not same:
+ return "the receipt is not the import's"
+ if ED.verify(r["signer_pubkey"], receipt_payload(r), r["sig"]) or receipt_identity(r).hex() != o["receipt_hash"]:
+ return "the receipt's signature or identity"
+ lb = bytes.fromhex(o["lineup"])
+ body = lineup_body(got["lineup"], op_key)
+ if lb[:-64] != body or ED.verify(op_key, LINEUP_DOMAIN + body, lb[-64:]) or sha(body).hex() != o["lineup_digest"]:
+ return "the lineup is not the import's, or its signature does not verify"
+ if got["cues"] != o["cues"]:
+ return "the cues differ"
+ return None
+
+
+def check_verification(v, files):
+ files = apply_edits(files, v["edits"])
+ g = files[v["guide"]] if v["guide"] else None
+ facts = verify(files[v["receipt"]], bytes.fromhex(v["lineup"]), g)
+ return None if facts == v["facts"] else "states %s, the corpus %s" % (facts, v["facts"])
+
+
+def corpus_files(doc):
+ names = {"vectors.json", "README.md"}
+ for v in doc["imports"]:
+ i = v["inputs"]
+ names |= {x for x in (i["xmltv"], i["json"], i["playlist"]) if x} | {m["file"] for m in i["mrss"]} | set(i["artwork"])
+ if "receipt" in v["outcome"]:
+ names.add(v["outcome"]["receipt"])
+ for v in doc["verifications"]:
+ names |= {x for x in (v["receipt"], v["guide"]) if x}
+ return names
+
+
+def check_all(doc, files, listing, quiet=False):
+ """The failures of every vector and of the corpus as a whole."""
+ fails = []
+ op_key = bytes.fromhex(doc["operator"]["key"])
+ for v in doc["imports"]:
+ try:
+ why = check_import(doc, v, files, op_key)
+ except (KeyError, ValueError, TypeError) as e:
+ why = "broken: %r" % (e,)
+ if why:
+ fails.append("import %s: %s" % (v["name"], why))
+ for v in doc["verifications"]:
+ try:
+ why = check_verification(v, files)
+ except (KeyError, ValueError, TypeError) as e:
+ why = "broken: %r" % (e,)
+ if why:
+ fails.append("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"]}
+ for r in added + reused:
+ if r not in seen:
+ fails.append("the corpus gives no %s" % r)
+ for r in seen - set(added) - set(reused) - {"verified"}:
+ fails.append("%s is not a reason of §10" % r)
+ if listing != corpus_files(doc):
+ fails.append("the files are not the corpus's: %s" % sorted(listing ^ corpus_files(doc)))
+ if not quiet:
+ for f in fails:
+ print("FAIL", f)
+ return fails
+
+
+def load(d):
+ listing = set(os.listdir(d))
+ files = {}
+ for n in listing:
+ with open(os.path.join(d, n), "rb") as f:
+ files[n] = f.read()
+ return json.loads(files["vectors.json"]), files, listing
+
+
+def flip(h, at):
+ c = "0" if h[at] != "0" else "1"
+ return h[:at] + c + h[at + 1:]
+
+
+def mutations(doc, files, listing):
+ """Copies of the corpus with one defect each: every one must fail."""
+ out = []
+
+ def m(name, f):
+ d, fs, ls = copy.deepcopy(doc), dict(files), set(listing)
+ f(d, fs, ls)
+ out.append((name, d, fs, ls))
+
+ imp = lambda d, n: next(v for v in d["imports"] if v["name"] == n) # noqa: E731
+ ver = lambda d, n: next(v for v in d["verifications"] if v["name"] == n) # noqa: E731
+ m("a lineup byte of the sample's outcome", lambda d, fs, ls: imp(d, "sample")["outcome"].__setitem__("lineup", flip(imp(d, "sample")["outcome"]["lineup"], 120)))
+ m("the sample's receipt hash", lambda d, fs, ls: imp(d, "sample")["outcome"].__setitem__("receipt_hash", flip(imp(d, "sample")["outcome"]["receipt_hash"], 0)))
+ m("a cue's payload", lambda d, fs, ls: imp(d, "sample")["outcome"]["cues"][0].__setitem__("payload", flip(imp(d, "sample")["outcome"]["cues"][0]["payload"], 4)))
+ m("a cue's start", lambda d, fs, ls: imp(d, "sample")["outcome"]["cues"][1].__setitem__("start_ms", 1))
+ m("a refusal's code", lambda d, fs, ls: imp(d, "gap")["outcome"].__setitem__("refused", "import_overlap"))
+ m("an edit dropped", lambda d, fs, ls: imp(d, "no_stop").__setitem__("edits", []))
+ m("a verdict", lambda d, fs, ls: ver(d, "guide_mismatch")["facts"].__setitem__("verdict", "verified"))
+ m("an aired flag", lambda d, fs, ls: ver(d, "xmltv_guide")["facts"]["guide"]["programmes"][2].__setitem__("aired", True))
+ m("the unscheduled entry's index", lambda d, fs, ls: ver(d, "guide_entry_unscheduled")["facts"].__setitem__("entry", 1))
+ m("a guide's title", lambda d, fs, ls: fs.__setitem__("sample.guide.xml", fs["sample.guide.xml"].replace(b"Tidepool Kitchen", b"Tidepool Kitchens")))
+ m("a now/next title", lambda d, fs, ls: fs.__setitem__("sample.now-next.json", fs["sample.now-next.json"].replace(b"Tidepool Kitchen", b"Tidepool Kitchens")))
+ m("an artwork file", lambda d, fs, ls: fs.__setitem__("art-tk-7.svg", fs["art-tk-7.svg"] + b" "))
+ m("a catalogue's duration", lambda d, fs, ls: fs.__setitem__("sample-a.mrss.xml", fs["sample-a.mrss.xml"].replace(b'duration="1620"/>', b'duration="1621"/>', 1)))
+ m("a break's start", lambda d, fs, ls: fs.__setitem__("sample.m3u8", fs["sample.m3u8"].replace(b"18:45:30.500Z", b"18:45:31.500Z")))
+ m("an SCTE-35 section", lambda d, fs, ls: fs.__setitem__("sample.m3u8", fs["sample.m3u8"].replace(b"03E97FEFFE", b"03EA7FEFFE", 1)))
+ 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"]))
+ return out
+
+
+def main(corpus_dir):
+ global SKIP
+ 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"])))
+ bad = 0
+ for name, d, fs, ls in mutations(doc, files, listing):
+ if not check_all(d, fs, ls, quiet=True):
+ print("FAIL mutation not caught:", name)
+ bad += 1
+ print("mutations: %d copies, %d not caught" % (len(mutations(doc, files, listing)), bad))
+ 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"]
+ weak = 0
+ for c in checks:
+ SKIP = {c}
+ try:
+ caught = bool(check_all(doc, files, listing, quiet=True))
+ except Exception:
+ caught = True
+ if not caught:
+ print("FAIL weakened verifier not caught: without", c)
+ weak += 1
+ SKIP = set()
+ print("weakened verifiers: %d checks dropped one at a time, %d not caught" % (len(checks), weak))
+ return 1 if bad or weak else 0
+
+
+if __name__ == "__main__":
+ if len(sys.argv) != 2:
+ print(__doc__)
+ sys.exit(2)
+ sys.exit(main(sys.argv[1]))
diff --git a/wai/live-conformance/channels/README.md b/wai/live-conformance/channels/README.md
new file mode 100644
index 000000000..59d200331
--- /dev/null
+++ b/wai/live-conformance/channels/README.md
@@ -0,0 +1,46 @@
+# live-conformance/channels — channel lineup and guide
+
+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
+from fixed seeds by `wai_channel_lineup_vectors gen` (feature `channel_lineup`), and
+`wai_channel_lineup_vectors verify ` and `tests/channel_lineup.rs` require them byte for byte.
+The second implementation is [`../channel_lineup_verify.py`](../channel_lineup_verify.py), run as
+`python3 -I channel_lineup_verify.py channels`.
+
+The sample channel, `sample-1`, is two hours on 2026-10-29 from 18:00 UTC, four programmes of
+three content owners, with four ad breaks:
+
+| file | what |
+|---|---|
+| `sample.xmltv.xml` | the schedule: XMLTV, with a document type declaration, a second channel's programme beside it, and times in three zone forms |
+| `sample-a.mrss.xml`, `sample-b.mrss.xml` | two owners' Media RSS catalogues: content, durations, titles, categories, thumbnails, ratings and country restrictions, one item under `media:group`, one item no programme names |
+| `sample.m3u8` | the channel's Media Playlist: SCTE-35 date ranges with real `splice_insert()` and `time_signal()` sections and their CRC, an out/in pair split across two tags of one `ID`, one duration from `END-DATE`, and a date range that is not a break |
+| `sample.json` | the same channel in the JSON form |
+| `art-*.svg` | the artwork the icons and thumbnails name |
+| `sample.channel.json` | the receipt the sample yields, its first hour the window |
+| `sample.guide.xml`, `sample.part.guide.xml` | XMLTV guides of the whole schedule and of a range |
+| `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 |
+| `vectors.json` | the operator's key, and every vector |
+
+`vectors.json` holds:
+
+- `operator`: the key that signs every receipt and lineup, and its seed (a published seed: a test
+ key that attests nothing);
+- `imports`: each with its `inputs` (§4.1: the files it reads, owners, `id_system`,
+ `xmltv_channel`, `start_ms`, the artwork set by file name, the window, `signer_id` and
+ `parent_receipt_hash`), its `edits`, and its `outcome`: `{"refused": reason}`, or the receipt's
+ file and identity, the lineup's bytes and digest in hexadecimal, and the cues (each break's id,
+ its start and its live-events `cue` payload in hexadecimal);
+- `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`.
+
+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
+once is a broken vector); with `"repeat": n` it keeps `find` and writes `replace` n times after
+it; and `{"file", "pad"}` appends that many space bytes. Each control is
+the sample with one such change, or with one input changed.
diff --git a/wai/live-conformance/channels/aired-misreported.channel.json b/wai/live-conformance/channels/aired-misreported.channel.json
new file mode 100644
index 000000000..0f42e293f
--- /dev/null
+++ b/wai/live-conformance/channels/aired-misreported.channel.json
@@ -0,0 +1,90 @@
+{
+ "channel_id": "sample-1",
+ "schedule_root": "d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83",
+ "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
+ ],
+ "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": "1e2c1bce261bbc3ff14ff2c6ca29ee2b25e8140307683e70d3041f58f504498248c17a22101b09e33cd12348c570d34bb8fea00352d6c1884647f564dda7bc0a"
+}
diff --git a/wai/live-conformance/channels/art-hl-101.svg b/wai/live-conformance/channels/art-hl-101.svg
new file mode 100644
index 000000000..4cf6d8771
--- /dev/null
+++ b/wai/live-conformance/channels/art-hl-101.svg
@@ -0,0 +1 @@
+
diff --git a/wai/live-conformance/channels/art-hl-102.svg b/wai/live-conformance/channels/art-hl-102.svg
new file mode 100644
index 000000000..4c7defb1f
--- /dev/null
+++ b/wai/live-conformance/channels/art-hl-102.svg
@@ -0,0 +1 @@
+
diff --git a/wai/live-conformance/channels/art-nf-1.svg b/wai/live-conformance/channels/art-nf-1.svg
new file mode 100644
index 000000000..6252d9f64
--- /dev/null
+++ b/wai/live-conformance/channels/art-nf-1.svg
@@ -0,0 +1 @@
+
diff --git a/wai/live-conformance/channels/art-tk-7.svg b/wai/live-conformance/channels/art-tk-7.svg
new file mode 100644
index 000000000..796361bd6
--- /dev/null
+++ b/wai/live-conformance/channels/art-tk-7.svg
@@ -0,0 +1 @@
+
diff --git a/wai/live-conformance/channels/media_rss_loop.channel.json b/wai/live-conformance/channels/media_rss_loop.channel.json
new file mode 100644
index 000000000..31f9e104c
--- /dev/null
+++ b/wai/live-conformance/channels/media_rss_loop.channel.json
@@ -0,0 +1,44 @@
+{
+ "channel_id": "sample-1",
+ "schedule_root": "2fccd317b7809fcf7439a7eefcae244d273b7430c665404d0c5d4d94b42674b0",
+ "schedule": [
+ {
+ "asset_id": "hl-101",
+ "start_ms": 1793318400000,
+ "duration_ms": 1620000,
+ "kind": "program"
+ },
+ {
+ "asset_id": "hl-102",
+ "start_ms": 1793320020000,
+ "duration_ms": 1620000,
+ "kind": "program"
+ },
+ {
+ "asset_id": "hl-103",
+ "start_ms": 1793321640000,
+ "duration_ms": 1620000,
+ "kind": "program"
+ }
+ ],
+ "window_start_ms": 1793318400000,
+ "window_end_ms": 1793323260000,
+ "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": "c9c7e26c43dc42f6939c67efd86cc1424940cc802694f6cbefdf8cee377a80cf32f350055005ef002bff4bbd544edd1069595cd58778c11ceb411816dc95880c"
+}
diff --git a/wai/live-conformance/channels/sample-a.mrss.xml b/wai/live-conformance/channels/sample-a.mrss.xml
new file mode 100644
index 000000000..6b567b3b9
--- /dev/null
+++ b/wai/live-conformance/channels/sample-a.mrss.xml
@@ -0,0 +1,37 @@
+
+
+
+ Owner A titles
+ https://owner-a.example/
+ Owner A's catalogue for linear channels.
+
+ Harbour Lights
+ hl-101
+
+ Harbour Lights
+ A lighthouse keeper's first winter.
+ Drama
+
+ tv-pg
+ us ca gb
+
+
+ hl-102
+
+
+
+
+ Harbour Lights
+ The supply boat is late.
+ Drama
+
+ tv-pg
+ de
+
+
+ hl-103
+
+ Harbour Lights
+
+
+
diff --git a/wai/live-conformance/channels/sample-b.mrss.xml b/wai/live-conformance/channels/sample-b.mrss.xml
new file mode 100644
index 000000000..4a5dd104a
--- /dev/null
+++ b/wai/live-conformance/channels/sample-b.mrss.xml
@@ -0,0 +1,16 @@
+
+
+
+ Owner B kitchen
+
+ tk-7
+
+ Tidepool Kitchen
+ Kelp, crab & lemon, on a beach stove.
+ Food
+ Family
+
+ none
+
+
+
diff --git a/wai/live-conformance/channels/sample.after.now-next.json b/wai/live-conformance/channels/sample.after.now-next.json
new file mode 100644
index 000000000..2f70d9a2c
--- /dev/null
+++ b/wai/live-conformance/channels/sample.after.now-next.json
@@ -0,0 +1 @@
+{"at_ms":1793307600000,"channel":"sample-1","channel_name":"Sample One","kind":"wai-now-next","lineup":"6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5","next":null,"now":null,"schedule_root":"d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83"}
diff --git a/wai/live-conformance/channels/sample.before.now-next.json b/wai/live-conformance/channels/sample.before.now-next.json
new file mode 100644
index 000000000..2f8005a35
--- /dev/null
+++ b/wai/live-conformance/channels/sample.before.now-next.json
@@ -0,0 +1 @@
+{"at_ms":1793293200000,"channel":"sample-1","channel_name":"Sample One","kind":"wai-now-next","lineup":"6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5","next":{"artwork":[{"sha256":"f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc","uri":"https://art.example/sample/art-hl-101.svg?w=320"}],"asset_id":"hl-101","categories":["Drama"],"content_uri":"https://media.example/a/hl-101/index.m3u8","description":"A lighthouse keeper's first winter.","duration_ms":1620000,"geo":{"allow":["CA","GB","US"]},"rating":{"system":"urn:v-chip","value":"tv-pg"},"rights_holder":"owner-a","start_ms":1793296800000,"stop_ms":1793298600000,"title":"Harbour Lights"},"now":null,"schedule_root":"d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83"}
diff --git a/wai/live-conformance/channels/sample.channel.json b/wai/live-conformance/channels/sample.channel.json
new file mode 100644
index 000000000..d902a7a34
--- /dev/null
+++ b/wai/live-conformance/channels/sample.channel.json
@@ -0,0 +1,96 @@
+{
+ "channel_id": "sample-1",
+ "schedule_root": "d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83",
+ "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": "70267928c5af2dc768215e23ca3fe4e47d106658261b40cc27a1fff1fb72906f84fe11bab8f3b95bc2763b86e95d7eaaeeb28a02b847f48f25e7107fb218d50a"
+}
diff --git a/wai/live-conformance/channels/sample.guide.xml b/wai/live-conformance/channels/sample.guide.xml
new file mode 100644
index 000000000..272f0672e
--- /dev/null
+++ b/wai/live-conformance/channels/sample.guide.xml
@@ -0,0 +1,44 @@
+
+
+
+ Sample One
+
+
+ Harbour Lights
+ A lighthouse keeper's first winter.
+ Drama
+
+ hl-101
+
+ tv-pg
+
+
+
+ Tidepool Kitchen
+ Kelp, crab & lemon, on a beach stove.
+ Food
+ Family
+
+ tk-7
+
+
+ Harbour Lights
+ The supply boat is late.
+ Drama
+
+ hl-102
+
+ tv-pg
+
+
+
+ Night Freight
+ Overnight on the coast line & the yards it feeds.
+ Documentary
+
+ nf-1
+
+ TV-14
+
+
+
diff --git a/wai/live-conformance/channels/sample.json b/wai/live-conformance/channels/sample.json
new file mode 100644
index 000000000..a1f10e837
--- /dev/null
+++ b/wai/live-conformance/channels/sample.json
@@ -0,0 +1,150 @@
+{
+ "kind": "wai-channel-feed",
+ "channel_name": "Sample One",
+ "programmes": [
+ {
+ "start_ms": 1793296800000,
+ "stop_ms": 1793298600000,
+ "asset_id": "hl-101"
+ },
+ {
+ "start_ms": 1793298600000,
+ "stop_ms": 1793300400000,
+ "asset_id": "tk-7"
+ },
+ {
+ "start_ms": 1793300400000,
+ "stop_ms": 1793302200000,
+ "asset_id": "hl-102"
+ },
+ {
+ "start_ms": 1793302200000,
+ "stop_ms": 1793304000000,
+ "asset_id": "nf-1"
+ }
+ ],
+ "assets": [
+ {
+ "asset_id": "hl-101",
+ "title": "Harbour Lights",
+ "description": "A lighthouse keeper's first winter.",
+ "content_uri": "https://media.example/a/hl-101/index.m3u8",
+ "categories": [
+ "Drama"
+ ],
+ "rating": {
+ "system": "urn:v-chip",
+ "value": "tv-pg"
+ },
+ "artwork": [
+ {
+ "uri": "https://art.example/sample/art-hl-101.svg?w=320",
+ "sha256": "f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc"
+ }
+ ],
+ "geo": {
+ "allow": [
+ "CA",
+ "GB",
+ "US"
+ ]
+ },
+ "rights_holder": "owner-a",
+ "duration_ms": 1620000
+ },
+ {
+ "asset_id": "hl-102",
+ "title": "Harbour Lights",
+ "description": "The supply boat is late.",
+ "content_uri": "https://media.example/a/hl-102/index.m3u8",
+ "categories": [
+ "Drama"
+ ],
+ "rating": {
+ "system": "urn:v-chip",
+ "value": "tv-pg"
+ },
+ "artwork": [
+ {
+ "uri": "https://art.example/sample/art-hl-102.svg?w=320",
+ "sha256": "6a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d3"
+ }
+ ],
+ "geo": {
+ "deny": [
+ "DE"
+ ]
+ },
+ "rights_holder": "owner-a",
+ "duration_ms": 1620000
+ },
+ {
+ "asset_id": "nf-1",
+ "title": "Night Freight",
+ "description": "Overnight on the coast line & the yards it feeds.",
+ "content_uri": "",
+ "categories": [
+ "Documentary"
+ ],
+ "rating": {
+ "system": "VCHIP",
+ "value": "TV-14"
+ },
+ "artwork": [
+ {
+ "uri": "https://art.example/sample/art-nf-1.svg?w=320",
+ "sha256": "2eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b1"
+ }
+ ],
+ "geo": null,
+ "rights_holder": "owner-c",
+ "duration_ms": 0
+ },
+ {
+ "asset_id": "tk-7",
+ "title": "Tidepool Kitchen",
+ "description": "Kelp, crab & lemon, on a beach stove.",
+ "content_uri": "https://media.example/b/tk-7.m3u8",
+ "categories": [
+ "Food",
+ "Family"
+ ],
+ "rating": null,
+ "artwork": [
+ {
+ "uri": "https://art.example/sample/art-tk-7.svg?w=320",
+ "sha256": "1cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b860"
+ }
+ ],
+ "geo": null,
+ "rights_holder": "owner-b",
+ "duration_ms": 1650000
+ }
+ ],
+ "breaks": [
+ {
+ "id": "brk-1",
+ "start_ms": 1793297520000,
+ "duration_ms": 90000,
+ "scte35": "fc3025000000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000008f389a15"
+ },
+ {
+ "id": "brk-2",
+ "start_ms": 1793298480000,
+ "duration_ms": 120000,
+ "scte35": "fc3016000000000000fffff00506fe090321000000f83beb5f"
+ },
+ {
+ "id": "brk-3",
+ "start_ms": 1793299530500,
+ "duration_ms": 60000,
+ "scte35": "fc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a"
+ },
+ {
+ "id": "brk-4",
+ "start_ms": 1793301240000,
+ "duration_ms": 90000,
+ "scte35": "fc3025000000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000959f6b5f"
+ }
+ ]
+}
diff --git a/wai/live-conformance/channels/sample.m3u8 b/wai/live-conformance/channels/sample.m3u8
new file mode 100644
index 000000000..1fa9836e9
--- /dev/null
+++ b/wai/live-conformance/channels/sample.m3u8
@@ -0,0 +1,17 @@
+#EXTM3U
+#EXT-X-VERSION:6
+#EXT-X-TARGETDURATION:6
+#EXT-X-MEDIA-SEQUENCE:120
+#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:11:54.000Z
+#EXTINF:6.000,
+seg-120.ts
+#EXT-X-DATERANGE:ID="brk-1",START-DATE="2026-10-29T18:12:00.000Z",PLANNED-DURATION=90.000,SCTE35-OUT=0xFC3025000000000000FFFFF01405000003E97FEFFE03DCC500FE007B98A00001000000008F389A15
+#EXTINF:6.000,
+seg-121.ts
+#EXT-X-DATERANGE:ID="brk-1",START-DATE="2026-10-29T18:12:00.000Z",DURATION=90.000,SCTE35-IN=0xFC3020000000000000FFFFF00F05000003E97F4FFE04585DA0000100000000AE39FE70
+#EXT-X-DATERANGE:ID="chapter-2",CLASS="sample.chapter",START-DATE="2026-10-29T18:20:00.000Z"
+#EXT-X-DATERANGE:ID="brk-2",START-DATE="2026-10-29T18:28:00.000Z",DURATION=120.000,SCTE35-OUT=0xFC3016000000000000FFFFF00506FE090321000000F83BEB5F
+#EXT-X-DATERANGE:ID="brk-3",START-DATE="2026-10-29T18:45:30.500Z",PLANNED-DURATION=60,SCTE35-OUT=0xfc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a
+#EXT-X-DATERANGE:ID="brk-4",START-DATE="2026-10-29T20:14:00+01:00",END-DATE="2026-10-29T19:15:30.000Z",SCTE35-OUT=0XFC3025000000000000FFFFF01405000003EC7FEFFE17D16980FE007B98A0000100000000959F6B5F
+#EXTINF:6.000,
+seg-122.ts
diff --git a/wai/live-conformance/channels/sample.now-next.json b/wai/live-conformance/channels/sample.now-next.json
new file mode 100644
index 000000000..5797e3449
--- /dev/null
+++ b/wai/live-conformance/channels/sample.now-next.json
@@ -0,0 +1 @@
+{"at_ms":1793299545000,"channel":"sample-1","channel_name":"Sample One","kind":"wai-now-next","lineup":"6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5","next":{"artwork":[{"sha256":"6a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d3","uri":"https://art.example/sample/art-hl-102.svg?w=320"}],"asset_id":"hl-102","categories":["Drama"],"content_uri":"https://media.example/a/hl-102/index.m3u8","description":"The supply boat is late.","duration_ms":1620000,"geo":{"deny":["DE"]},"rating":{"system":"urn:v-chip","value":"tv-pg"},"rights_holder":"owner-a","start_ms":1793300400000,"stop_ms":1793302200000,"title":"Harbour Lights"},"now":{"artwork":[{"sha256":"1cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b860","uri":"https://art.example/sample/art-tk-7.svg?w=320"}],"asset_id":"tk-7","categories":["Food","Family"],"content_uri":"https://media.example/b/tk-7.m3u8","description":"Kelp, crab & lemon, on a beach stove.","duration_ms":1650000,"geo":null,"rating":null,"rights_holder":"owner-b","start_ms":1793298600000,"stop_ms":1793300400000,"title":"Tidepool Kitchen"},"schedule_root":"d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83"}
diff --git a/wai/live-conformance/channels/sample.part.guide.xml b/wai/live-conformance/channels/sample.part.guide.xml
new file mode 100644
index 000000000..aaaf062f9
--- /dev/null
+++ b/wai/live-conformance/channels/sample.part.guide.xml
@@ -0,0 +1,24 @@
+
+
+
+ Sample One
+
+
+ Tidepool Kitchen
+ Kelp, crab & lemon, on a beach stove.
+ Food
+ Family
+
+ tk-7
+
+
+ Harbour Lights
+ The supply boat is late.
+ Drama
+
+ hl-102
+
+ tv-pg
+
+
+
diff --git a/wai/live-conformance/channels/sample.xmltv.xml b/wai/live-conformance/channels/sample.xmltv.xml
new file mode 100644
index 000000000..37afdef81
--- /dev/null
+++ b/wai/live-conformance/channels/sample.xmltv.xml
@@ -0,0 +1,40 @@
+
+
+
+
+ Sample One
+
+
+ Sample Two
+
+
+ Harbour Lights
+ A lighthouse keeper's first winter.
+ Drama
+
+ 0.0.
+ hl-101
+
+
+ Tidepool Kitchen
+ tk-7
+
+
+ Harbour Lights
+ hl-102
+
+
+ Night Freight
+ Overnight on the coast line & the yards it feeds.
+ Documentary
+
+ nf-1
+
+ TV-14
+
+
+
+ Another Channel's Hour
+ other-1
+
+
diff --git a/wai/live-conformance/channels/vectors.json b/wai/live-conformance/channels/vectors.json
new file mode 100644
index 000000000..13c3ab6cd
--- /dev/null
+++ b/wai/live-conformance/channels/vectors.json
@@ -0,0 +1,1787 @@
+{
+ "about": "extensions/channel-lineup.md §11: a sample channel's feeds (XMLTV, two owners' Media RSS, a Media Playlist with SCTE-35 date ranges, and the JSON form), each import's inputs, the edit that makes it a control and its outcome; each verification of a receipt, a lineup and a guide, and what it states",
+ "operator": {
+ "key": "da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a",
+ "seed": "aca23cde2418010624ea01fe74a44132163bed9ca0511ef11638290fe650ef83"
+ },
+ "imports": [
+ {
+ "name": "sample",
+ "about": "the sample channel from its XMLTV schedule (beside another channel's), two owners' Media RSS catalogues and the playlist's SCTE-35 date ranges, its first hour the receipt's window",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [],
+ "outcome": {
+ "receipt": "sample.channel.json",
+ "receipt_hash": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05",
+ "lineup_digest": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5",
+ "cues": [
+ {
+ "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"
+ }
+ ]
+ }
+ },
+ {
+ "name": "sample_json",
+ "about": "the same channel from the JSON form alone: the same receipt and lineup, byte for byte",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": null,
+ "mrss": [],
+ "json": "sample.json",
+ "playlist": null,
+ "owner": null,
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [],
+ "outcome": {
+ "receipt": "sample.channel.json",
+ "receipt_hash": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05",
+ "lineup_digest": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5",
+ "cues": [
+ {
+ "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"
+ }
+ ]
+ }
+ },
+ {
+ "name": "media_rss_loop",
+ "about": "one owner's Media RSS items alone, back to back from a start, with no break",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": null,
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ }
+ ],
+ "json": null,
+ "playlist": null,
+ "owner": null,
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": 1793318400000,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": null,
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [],
+ "outcome": {
+ "receipt": "media_rss_loop.channel.json",
+ "receipt_hash": "a2cc6ae4d6de83aa9b0c1dd3e63878a605a9ccaf813d074f37c2a1cad2ed0c99",
+ "lineup": "574c5531010873616d706c652d310e4f776e65722041207469746c65732fccd317b7809fcf7439a7eefcae244d273b7430c665404d0c5d4d94b42674b000301b8aa10100006058658aa10100000300000000301b8aa101000020e8338aa101000040a04c8aa10100000300000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b818000000000006686c2d3130330e00486172626f7572204c69676874730000000000000000076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130332f696e6465782e6d33753820b8180000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a0dc1ce7aa1b514b5f1433fd99901df6d1dd586a16482465fd2693cb484c759ec9188c13db65a5a5bce116be12c86efb5ddcbd10472fb7925dd8918632f797c0b",
+ "lineup_digest": "81a6b92a2b83c431728c15f58e68a1f03773708a8644b472c513aadc077f4eb2",
+ "cues": []
+ }
+ },
+ {
+ "name": "oversized_count",
+ "about": "the JSON form with more than 10 000 breaks",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": null,
+ "mrss": [],
+ "json": "sample.json",
+ "playlist": null,
+ "owner": null,
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [
+ {
+ "file": "sample.json",
+ "find": "\"breaks\": [",
+ "replace": "{\"id\": \"x\", \"start_ms\": 0, \"duration_ms\": 1, \"scte35\": null}, ",
+ "repeat": 10000
+ }
+ ],
+ "outcome": {
+ "refused": "import_oversized"
+ }
+ },
+ {
+ "name": "not_whole_second",
+ "about": "a programme boundary half a second past a whole second, which an XMLTV guide cannot state",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": null,
+ "mrss": [],
+ "json": "sample.json",
+ "playlist": null,
+ "owner": null,
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [
+ {
+ "file": "sample.json",
+ "find": "\"stop_ms\": 1793298600000",
+ "replace": "\"stop_ms\": 1793298600500"
+ },
+ {
+ "file": "sample.json",
+ "find": "\"start_ms\": 1793298600000",
+ "replace": "\"start_ms\": 1793298600500"
+ }
+ ],
+ "outcome": {
+ "refused": "import_time_malformed"
+ }
+ },
+ {
+ "name": "break_overlap",
+ "about": "a break that begins before the one before it ends",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [
+ {
+ "file": "sample.m3u8",
+ "find": "ID=\"brk-2\",START-DATE=\"2026-10-29T18:28:00.000Z\"",
+ "replace": "ID=\"brk-2\",START-DATE=\"2026-10-29T18:13:00.000Z\""
+ }
+ ],
+ "outcome": {
+ "refused": "import_overlap"
+ }
+ },
+ {
+ "name": "inputs_invalid",
+ "about": "an XMLTV document beside the JSON form",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": "sample.json",
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [],
+ "outcome": {
+ "refused": "import_inputs_invalid"
+ }
+ },
+ {
+ "name": "oversized",
+ "about": "an XMLTV document of more than 16 777 216 bytes",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [
+ {
+ "file": "sample.xmltv.xml",
+ "pad": 16777216
+ }
+ ],
+ "outcome": {
+ "refused": "import_oversized"
+ }
+ },
+ {
+ "name": "xml_malformed",
+ "about": "a document type declaration that defines an entity",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [
+ {
+ "file": "sample.xmltv.xml",
+ "find": "",
+ "replace": "]>"
+ }
+ ],
+ "outcome": {
+ "refused": "import_xml_malformed"
+ }
+ },
+ {
+ "name": "json_malformed",
+ "about": "the JSON form naming a member twice",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": null,
+ "mrss": [],
+ "json": "sample.json",
+ "playlist": null,
+ "owner": null,
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [
+ {
+ "file": "sample.json",
+ "find": "\"kind\": \"wai-channel-feed\",",
+ "replace": "\"kind\": \"wai-channel-feed\",\n \"kind\": \"wai-channel-feed\","
+ }
+ ],
+ "outcome": {
+ "refused": "import_json_malformed"
+ }
+ },
+ {
+ "name": "playlist_malformed",
+ "about": "two tags of one date range that give it two starts",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [
+ {
+ "file": "sample.m3u8",
+ "find": "START-DATE=\"2026-10-29T18:12:00.000Z\",DURATION",
+ "replace": "START-DATE=\"2026-10-29T18:12:01.000Z\",DURATION"
+ }
+ ],
+ "outcome": {
+ "refused": "import_playlist_malformed"
+ }
+ },
+ {
+ "name": "time_malformed",
+ "about": "a programme starting in a thirteenth month",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [
+ {
+ "file": "sample.xmltv.xml",
+ "find": "start=\"20261029183000 +0000\"",
+ "replace": "start=\"20261329183000 +0000\""
+ }
+ ],
+ "outcome": {
+ "refused": "import_time_malformed"
+ }
+ },
+ {
+ "name": "timezone_unknown",
+ "about": "a programme's time in a zone named by letters",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [
+ {
+ "file": "sample.xmltv.xml",
+ "find": "start=\"20261029183000 +0000\"",
+ "replace": "start=\"20261029193000 BST\""
+ }
+ ],
+ "outcome": {
+ "refused": "import_timezone_unknown"
+ }
+ },
+ {
+ "name": "no_stop",
+ "about": "a programme with no stop",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [
+ {
+ "file": "sample.xmltv.xml",
+ "find": " stop=\"20261029190000 +0000\" channel=\"sample-1\"",
+ "replace": " channel=\"sample-1\""
+ }
+ ],
+ "outcome": {
+ "refused": "import_no_stop"
+ }
+ },
+ {
+ "name": "empty",
+ "about": "an XMLTV channel id the document has no programme of",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": "sample-9",
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [],
+ "outcome": {
+ "refused": "import_empty"
+ }
+ },
+ {
+ "name": "asset_unknown",
+ "about": "a programme whose asset is named only in another episode-num system",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [
+ {
+ "file": "sample.xmltv.xml",
+ "find": "tk-7",
+ "replace": "tk-7"
+ }
+ ],
+ "outcome": {
+ "refused": "import_asset_unknown"
+ }
+ },
+ {
+ "name": "asset_duplicate",
+ "about": "two catalogue items with one guid",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [
+ {
+ "file": "sample-a.mrss.xml",
+ "find": "hl-103",
+ "replace": "hl-101"
+ }
+ ],
+ "outcome": {
+ "refused": "import_asset_duplicate"
+ }
+ },
+ {
+ "name": "field_invalid",
+ "about": "a restriction whose relationship is neither allow nor deny",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [
+ {
+ "file": "sample-b.mrss.xml",
+ "find": "relationship=\"deny\"",
+ "replace": "relationship=\"maybe\""
+ }
+ ],
+ "outcome": {
+ "refused": "import_field_invalid"
+ }
+ },
+ {
+ "name": "scte35_malformed",
+ "about": "a section whose section_length is not its length",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [
+ {
+ "file": "sample.m3u8",
+ "find": "DURATION=120.000,SCTE35-OUT=0x",
+ "replace": "DURATION=120.000,SCTE35-OUT=0xFC3001,X-WAS=0x"
+ }
+ ],
+ "outcome": {
+ "refused": "import_scte35_malformed"
+ }
+ },
+ {
+ "name": "break_duration",
+ "about": "a break of no duration",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [
+ {
+ "file": "sample.m3u8",
+ "find": "DURATION=120.000",
+ "replace": "DURATION=0.000"
+ }
+ ],
+ "outcome": {
+ "refused": "import_break_duration"
+ }
+ },
+ {
+ "name": "duration_unknown",
+ "about": "a Media RSS item with no duration, in a channel of Media RSS items alone",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": null,
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ }
+ ],
+ "json": null,
+ "playlist": null,
+ "owner": null,
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": 1793318400000,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": null,
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [
+ {
+ "file": "sample-a.mrss.xml",
+ "find": "",
+ "replace": ""
+ }
+ ],
+ "outcome": {
+ "refused": "import_duration_unknown"
+ }
+ },
+ {
+ "name": "gap",
+ "about": "a minute of dead air before the last programme",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [
+ {
+ "file": "sample.xmltv.xml",
+ "find": "start=\"20261029193000\"",
+ "replace": "start=\"20261029193100\""
+ }
+ ],
+ "outcome": {
+ "refused": "import_gap"
+ }
+ },
+ {
+ "name": "overlap",
+ "about": "a programme that ends after the next begins",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [
+ {
+ "file": "sample.xmltv.xml",
+ "find": "stop=\"20261029203000 +0100\"",
+ "replace": "stop=\"20261029203100 +0100\""
+ }
+ ],
+ "outcome": {
+ "refused": "import_overlap"
+ }
+ },
+ {
+ "name": "owner_unknown",
+ "about": "an asset of the XMLTV document alone, and no owner given",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": null,
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [],
+ "outcome": {
+ "refused": "import_owner_unknown"
+ }
+ },
+ {
+ "name": "artwork_missing",
+ "about": "an icon whose file the artwork set does not hold",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [],
+ "outcome": {
+ "refused": "import_artwork_missing"
+ }
+ },
+ {
+ "name": "break_outside",
+ "about": "a break that runs past the end of its programme",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793296800000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [
+ {
+ "file": "sample.m3u8",
+ "find": "ID=\"brk-2\",START-DATE=\"2026-10-29T18:28:00.000Z\"",
+ "replace": "ID=\"brk-2\",START-DATE=\"2026-10-29T18:29:00.000Z\""
+ }
+ ],
+ "outcome": {
+ "refused": "import_break_outside"
+ }
+ },
+ {
+ "name": "window_outside",
+ "about": "a window that begins before the schedule",
+ "inputs": {
+ "channel": "sample-1",
+ "xmltv": "sample.xmltv.xml",
+ "mrss": [
+ {
+ "file": "sample-a.mrss.xml",
+ "owner": "owner-a"
+ },
+ {
+ "file": "sample-b.mrss.xml",
+ "owner": "owner-b"
+ }
+ ],
+ "json": null,
+ "playlist": "sample.m3u8",
+ "owner": "owner-c",
+ "id_system": null,
+ "xmltv_channel": null,
+ "start_ms": null,
+ "artwork": [
+ "art-hl-101.svg",
+ "art-hl-102.svg",
+ "art-nf-1.svg",
+ "art-tk-7.svg"
+ ],
+ "window": [
+ 1793293200000,
+ 1793300400000
+ ],
+ "signer_id": "sample-operator",
+ "parent_receipt_hash": null
+ },
+ "edits": [],
+ "outcome": {
+ "refused": "import_window_outside"
+ }
+ }
+ ],
+ "verifications": [
+ {
+ "name": "receipt_and_lineup",
+ "about": "the sample's receipt and lineup",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05",
+ "guide": null,
+ "edits": [],
+ "facts": {
+ "verdict": "verified",
+ "part": null,
+ "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5",
+ "guide": null,
+ "entry": null
+ }
+ },
+ {
+ "name": "xmltv_guide",
+ "about": "the XMLTV guide of the whole schedule: two programmes aired in the receipt's window, two after it",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05",
+ "guide": "sample.guide.xml",
+ "edits": [],
+ "facts": {
+ "verdict": "verified",
+ "part": null,
+ "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5",
+ "guide": {
+ "format": "xmltv",
+ "from_ms": 1793296800000,
+ "to_ms": 1793304000000,
+ "at_ms": null,
+ "programmes": [
+ {
+ "start_ms": 1793296800000,
+ "stop_ms": 1793298600000,
+ "asset_id": "hl-101",
+ "aired": true
+ },
+ {
+ "start_ms": 1793298600000,
+ "stop_ms": 1793300400000,
+ "asset_id": "tk-7",
+ "aired": true
+ },
+ {
+ "start_ms": 1793300400000,
+ "stop_ms": 1793302200000,
+ "asset_id": "hl-102",
+ "aired": false
+ },
+ {
+ "start_ms": 1793302200000,
+ "stop_ms": 1793304000000,
+ "asset_id": "nf-1",
+ "aired": false
+ }
+ ]
+ },
+ "entry": null
+ }
+ },
+ {
+ "name": "part_guide",
+ "about": "the XMLTV guide of a range across the window's end",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05",
+ "guide": "sample.part.guide.xml",
+ "edits": [],
+ "facts": {
+ "verdict": "verified",
+ "part": null,
+ "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5",
+ "guide": {
+ "format": "xmltv",
+ "from_ms": 1793299500000,
+ "to_ms": 1793301300000,
+ "at_ms": null,
+ "programmes": [
+ {
+ "start_ms": 1793298600000,
+ "stop_ms": 1793300400000,
+ "asset_id": "tk-7",
+ "aired": true
+ },
+ {
+ "start_ms": 1793300400000,
+ "stop_ms": 1793302200000,
+ "asset_id": "hl-102",
+ "aired": false
+ }
+ ]
+ },
+ "entry": null
+ }
+ },
+ {
+ "name": "now_next",
+ "about": "the now/next document inside a programme's break",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05",
+ "guide": "sample.now-next.json",
+ "edits": [],
+ "facts": {
+ "verdict": "verified",
+ "part": null,
+ "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5",
+ "guide": {
+ "format": "now-next",
+ "from_ms": null,
+ "to_ms": null,
+ "at_ms": 1793299545000,
+ "programmes": [
+ {
+ "start_ms": 1793298600000,
+ "stop_ms": 1793300400000,
+ "asset_id": "tk-7",
+ "aired": true
+ },
+ {
+ "start_ms": 1793300400000,
+ "stop_ms": 1793302200000,
+ "asset_id": "hl-102",
+ "aired": false
+ }
+ ]
+ },
+ "entry": null
+ }
+ },
+ {
+ "name": "now_next_before",
+ "about": "the now/next document before the schedule begins",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05",
+ "guide": "sample.before.now-next.json",
+ "edits": [],
+ "facts": {
+ "verdict": "verified",
+ "part": null,
+ "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5",
+ "guide": {
+ "format": "now-next",
+ "from_ms": null,
+ "to_ms": null,
+ "at_ms": 1793293200000,
+ "programmes": [
+ {
+ "start_ms": 1793296800000,
+ "stop_ms": 1793298600000,
+ "asset_id": "hl-101",
+ "aired": true
+ }
+ ]
+ },
+ "entry": null
+ }
+ },
+ {
+ "name": "now_next_after",
+ "about": "the now/next document after the schedule ends",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05",
+ "guide": "sample.after.now-next.json",
+ "edits": [],
+ "facts": {
+ "verdict": "verified",
+ "part": null,
+ "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5",
+ "guide": {
+ "format": "now-next",
+ "from_ms": null,
+ "to_ms": null,
+ "at_ms": 1793307600000,
+ "programmes": []
+ },
+ "entry": null
+ }
+ },
+ {
+ "name": "receipt_malformed",
+ "about": "a receipt missing a member",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05",
+ "guide": null,
+ "edits": [
+ {
+ "file": "sample.channel.json",
+ "find": "\"signer_id\": \"sample-operator\"",
+ "replace": "\"signer\": \"sample-operator\""
+ }
+ ],
+ "facts": {
+ "verdict": "channel_receipt_malformed",
+ "part": "receipt",
+ "receipt": null,
+ "lineup": null,
+ "guide": null,
+ "entry": null
+ }
+ },
+ {
+ "name": "receipt_bad_signature",
+ "about": "a receipt whose signature was altered",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05",
+ "guide": null,
+ "edits": [
+ {
+ "file": "sample.channel.json",
+ "find": "70267928c5af2dc768215e23ca3fe4e47d106658261b40cc27a1fff1fb72906f84fe11bab8f3b95bc2763b86e95d7eaaeeb28a02b847f48f25e7107fb218d50a",
+ "replace": "00267928c5af2dc768215e23ca3fe4e47d106658261b40cc27a1fff1fb72906f84fe11bab8f3b95bc2763b86e95d7eaaeeb28a02b847f48f25e7107fb218d50a"
+ }
+ ],
+ "facts": {
+ "verdict": "bad_signature",
+ "part": "receipt",
+ "receipt": "6eacddf5e17d1aa344ee867d7b4d04df3555fb051e3118b11a3775edf24f2c6a",
+ "lineup": null,
+ "guide": null,
+ "entry": null
+ }
+ },
+ {
+ "name": "receipt_invalid",
+ "about": "a receipt, signed, whose program-as-run is not what its window gives",
+ "receipt": "aired-misreported.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05",
+ "guide": null,
+ "edits": [],
+ "facts": {
+ "verdict": "channel_receipt_invalid",
+ "part": "receipt",
+ "receipt": "edd53f42022f7846ee7aa67c7ad0dc04685e00926ba18b37acdef22b09fcf711",
+ "lineup": null,
+ "guide": null,
+ "entry": null
+ }
+ },
+ {
+ "name": "lineup_malformed",
+ "about": "a lineup with a byte after its signature",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b0500",
+ "guide": null,
+ "edits": [],
+ "facts": {
+ "verdict": "lineup_malformed",
+ "part": "lineup",
+ "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": null,
+ "guide": null,
+ "entry": null
+ }
+ },
+ {
+ "name": "lineup_bad_signature",
+ "about": "a lineup whose signature was altered",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b04",
+ "guide": null,
+ "edits": [],
+ "facts": {
+ "verdict": "bad_signature",
+ "part": "lineup",
+ "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5",
+ "guide": null,
+ "entry": null
+ }
+ },
+ {
+ "name": "lineup_bad_key",
+ "about": "the identity point as the key, with (R, S) = (B, 1): the equation holds for every message, and the key is of small order",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000010000000000000000000000000000000000000000000000000000000000000058666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000",
+ "guide": null,
+ "edits": [],
+ "facts": {
+ "verdict": "bad_key",
+ "part": "lineup",
+ "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "73865f2fe1d8342db7defeac36cca4aff3a378ca2112459fd2c8d89b2caeb0fd",
+ "guide": null,
+ "entry": null
+ }
+ },
+ {
+ "name": "lineup_signer_mismatch",
+ "about": "a lineup another key signed",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000a84e8579cbcc4c8793feab96571297a22458d3bd3d47671107083829f7ba6b1ec42835241c90d0b06b4886ee02270b40d1c10bf99619844d02149cefa2d6aacdea4fdf338c804876f42c05272da289e76a27cd86c53139b0b7ae14c1d3c88709",
+ "guide": null,
+ "edits": [],
+ "facts": {
+ "verdict": "lineup_signer_mismatch",
+ "part": "lineup",
+ "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "5e7a2c0872c91871abd3df191db1d3e77f41caa76f984366395186fd6f42c0b9",
+ "guide": null,
+ "entry": null
+ }
+ },
+ {
+ "name": "lineup_other_root",
+ "about": "a lineup, signed, naming another schedule root (§6.5 rule 2)",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e6500000000000000000000000000000000000000000000000000000000000000000099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a91f6f497fac376fd5473b61b99c97fcb262c4412aebdbc8364dd531e6dfd9096272792853ec0ffaf94bd641629a8c46846d0453eb8f3b01ab3586135eca5b20b",
+ "guide": null,
+ "edits": [],
+ "facts": {
+ "verdict": "lineup_schedule_mismatch",
+ "part": "lineup",
+ "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "07454d8e14e984a80580c774f0840f9d553b83dcbc1aa35b691d7dc2c4c0c506",
+ "guide": null,
+ "entry": null
+ }
+ },
+ {
+ "name": "lineup_programme_mid_entry",
+ "about": "a lineup, signed, with a programme that begins inside an entry (§6.5 rule 3)",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000050000000099d188a1010000e02cd688a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a43552d34a753e436ff992d91eedae90a128720e12546ea0a2f5f81fff976ac11dcd9af87800a6b79327f4947e8829203135186c85ea6d627a37cb5f4dce72e04",
+ "guide": null,
+ "edits": [],
+ "facts": {
+ "verdict": "lineup_schedule_mismatch",
+ "part": "lineup",
+ "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "fb102745262a9c9cca09a2751a333b9d8e035325d01f92955d02cd2bd131afb4",
+ "guide": null,
+ "entry": null
+ }
+ },
+ {
+ "name": "lineup_programme_two_assets",
+ "about": "a lineup, signed, whose first programme holds two assets' program entries (§6.5 rule 4)",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000030000000099d188a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a20fcdb45c1078dc80ffc7de408c62217bcafc9b8cbde1805ba9ebbeb6d43933932c663a5e6c1471c72699c0dc39032b858d51f72803dbe7806cbe9274ade9e0f",
+ "guide": null,
+ "edits": [],
+ "facts": {
+ "verdict": "lineup_schedule_mismatch",
+ "part": "lineup",
+ "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "6501ee75a48b77d14d91fa2c4224b052218ad2b8787eed9bd114ce86988f9725",
+ "guide": null,
+ "entry": null
+ }
+ },
+ {
+ "name": "lineup_asset_unused",
+ "about": "a lineup, signed, describing an asset no programme airs (§6.5 rule 5)",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000500000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000097a7a2d756e757365640e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b8180000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a0760f271a3b6302c64dc7e88682fabd2749275805b519d39f951f6ad120839983c30dbe9372a1bbe28dea63c1f976b52e5fb07e467dfb142ab853be510186a00",
+ "guide": null,
+ "edits": [],
+ "facts": {
+ "verdict": "lineup_schedule_mismatch",
+ "part": "lineup",
+ "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "485d82cbb2c278cece8ae2d4b326ab62bb13ebc90c714746a83b690febdedccd",
+ "guide": null,
+ "entry": null
+ }
+ },
+ {
+ "name": "guide_malformed",
+ "about": "a guide cut short",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05",
+ "guide": "sample.guide.xml",
+ "edits": [
+ {
+ "file": "sample.guide.xml",
+ "find": "\n",
+ "replace": ""
+ }
+ ],
+ "facts": {
+ "verdict": "guide_malformed",
+ "part": "guide",
+ "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5",
+ "guide": null,
+ "entry": null
+ }
+ },
+ {
+ "name": "guide_source_mismatch",
+ "about": "a guide naming another schedule",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05",
+ "guide": "sample.guide.xml",
+ "edits": [
+ {
+ "file": "sample.guide.xml",
+ "find": "d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83",
+ "replace": "0000000000000000000000000000000000000000000000000000000000000000"
+ }
+ ],
+ "facts": {
+ "verdict": "guide_source_mismatch",
+ "part": "guide",
+ "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5",
+ "guide": {
+ "format": "xmltv",
+ "from_ms": 1793296800000,
+ "to_ms": 1793304000000,
+ "at_ms": null,
+ "programmes": [
+ {
+ "start_ms": 1793296800000,
+ "stop_ms": 1793298600000,
+ "asset_id": "hl-101",
+ "aired": true
+ },
+ {
+ "start_ms": 1793298600000,
+ "stop_ms": 1793300400000,
+ "asset_id": "tk-7",
+ "aired": true
+ },
+ {
+ "start_ms": 1793300400000,
+ "stop_ms": 1793302200000,
+ "asset_id": "hl-102",
+ "aired": false
+ },
+ {
+ "start_ms": 1793302200000,
+ "stop_ms": 1793304000000,
+ "asset_id": "nf-1",
+ "aired": false
+ }
+ ]
+ },
+ "entry": null
+ }
+ },
+ {
+ "name": "guide_entry_unscheduled",
+ "about": "a guide that places Night Freight where the schedule places Harbour Lights",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05",
+ "guide": "sample.guide.xml",
+ "edits": [
+ {
+ "file": "sample.guide.xml",
+ "find": ">hl-102",
+ "replace": ">nf-1"
+ }
+ ],
+ "facts": {
+ "verdict": "guide_entry_unscheduled",
+ "part": "guide",
+ "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5",
+ "guide": {
+ "format": "xmltv",
+ "from_ms": 1793296800000,
+ "to_ms": 1793304000000,
+ "at_ms": null,
+ "programmes": [
+ {
+ "start_ms": 1793296800000,
+ "stop_ms": 1793298600000,
+ "asset_id": "hl-101",
+ "aired": true
+ },
+ {
+ "start_ms": 1793298600000,
+ "stop_ms": 1793300400000,
+ "asset_id": "tk-7",
+ "aired": true
+ },
+ {
+ "start_ms": 1793300400000,
+ "stop_ms": 1793302200000,
+ "asset_id": "nf-1",
+ "aired": false
+ },
+ {
+ "start_ms": 1793302200000,
+ "stop_ms": 1793304000000,
+ "asset_id": "nf-1",
+ "aired": false
+ }
+ ]
+ },
+ "entry": 2
+ }
+ },
+ {
+ "name": "guide_mismatch",
+ "about": "a guide whose title the lineup does not state",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05",
+ "guide": "sample.guide.xml",
+ "edits": [
+ {
+ "file": "sample.guide.xml",
+ "find": "Night Freight",
+ "replace": "Night Freight Live"
+ }
+ ],
+ "facts": {
+ "verdict": "guide_mismatch",
+ "part": "guide",
+ "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5",
+ "guide": {
+ "format": "xmltv",
+ "from_ms": 1793296800000,
+ "to_ms": 1793304000000,
+ "at_ms": null,
+ "programmes": [
+ {
+ "start_ms": 1793296800000,
+ "stop_ms": 1793298600000,
+ "asset_id": "hl-101",
+ "aired": true
+ },
+ {
+ "start_ms": 1793298600000,
+ "stop_ms": 1793300400000,
+ "asset_id": "tk-7",
+ "aired": true
+ },
+ {
+ "start_ms": 1793300400000,
+ "stop_ms": 1793302200000,
+ "asset_id": "hl-102",
+ "aired": false
+ },
+ {
+ "start_ms": 1793302200000,
+ "stop_ms": 1793304000000,
+ "asset_id": "nf-1",
+ "aired": false
+ }
+ ]
+ },
+ "entry": null
+ }
+ },
+ {
+ "name": "now_next_unscheduled",
+ "about": "a now/next document naming another programme on air",
+ "receipt": "sample.channel.json",
+ "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05",
+ "guide": "sample.now-next.json",
+ "edits": [
+ {
+ "file": "sample.now-next.json",
+ "find": "\"asset_id\":\"tk-7\"",
+ "replace": "\"asset_id\":\"hl-101\""
+ }
+ ],
+ "facts": {
+ "verdict": "guide_entry_unscheduled",
+ "part": "guide",
+ "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da",
+ "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5",
+ "guide": {
+ "format": "now-next",
+ "from_ms": null,
+ "to_ms": null,
+ "at_ms": 1793299545000,
+ "programmes": [
+ {
+ "start_ms": 1793298600000,
+ "stop_ms": 1793300400000,
+ "asset_id": "hl-101",
+ "aired": true
+ },
+ {
+ "start_ms": 1793300400000,
+ "stop_ms": 1793302200000,
+ "asset_id": "hl-102",
+ "aired": false
+ }
+ ]
+ },
+ "entry": 0
+ }
+ }
+ ]
+}
diff --git a/wai/registry/capabilities.json b/wai/registry/capabilities.json
index 149f97aa6..22dacb01c 100644
--- a/wai/registry/capabilities.json
+++ b/wai/registry/capabilities.json
@@ -2295,6 +2295,20 @@
"edition": null,
"registered": "2026-09-28"
},
+ {
+ "capability": "wai.video.lineup",
+ "kind": "record",
+ "status": "registered",
+ "media": null,
+ "role": null,
+ "tier": null,
+ "licence": null,
+ "replicate": null,
+ "sets": [],
+ "spec_section": "§5 Interactive, temporal & computed content",
+ "edition": null,
+ "registered": "2026-10-29"
+ },
{
"capability": "wai.video.manifest",
"kind": "record",
@@ -2947,6 +2961,13 @@
"section": "§4.1",
"status": "defined"
},
+ {
+ "name": "WLU1",
+ "kind": "magic",
+ "document": "extensions/channel-lineup.md",
+ "section": "§6.1",
+ "status": "defined"
+ },
{
"name": "WMA1",
"kind": "magic",
@@ -3612,6 +3633,13 @@
"section": "§8.1",
"status": "defined"
},
+ {
+ "name": "urn:wai:guide:",
+ "kind": "scheme",
+ "document": "extensions/channel-lineup.md",
+ "section": "§7.2",
+ "status": "defined"
+ },
{
"name": "wai",
"kind": "catalog",
@@ -3647,6 +3675,13 @@
"section": "§5.3",
"status": "defined"
},
+ {
+ "name": "wai-asset-id",
+ "kind": "scheme",
+ "document": "extensions/channel-lineup.md",
+ "section": "§7.2",
+ "status": "defined"
+ },
{
"name": "wai-attack:",
"kind": "scheme",
@@ -3703,6 +3738,13 @@
"section": "§3.3",
"status": "defined"
},
+ {
+ "name": "wai-channel-feed",
+ "kind": "document-kind",
+ "document": "extensions/channel-lineup.md",
+ "section": "§4.4",
+ "status": "defined"
+ },
{
"name": "wai-collection",
"kind": "document-kind",
@@ -3850,6 +3892,13 @@
"section": "§8.4",
"status": "defined"
},
+ {
+ "name": "wai-now-next",
+ "kind": "document-kind",
+ "document": "extensions/channel-lineup.md",
+ "section": "§7.3",
+ "status": "defined"
+ },
{
"name": "wai-origin",
"kind": "document-kind",
diff --git a/wai/vocab/v2.ttl b/wai/vocab/v2.ttl
index 734786dc6..7f7733152 100644
--- a/wai/vocab/v2.ttl
+++ b/wai/vocab/v2.ttl
@@ -1649,6 +1649,13 @@ wai:wai.video.lcevc a wai:Capability ;
wai:specSection "§5 Broadcast and streaming codecs" ;
wai:registeredOn "2026-09-28"^^xsd:date .
+wai:wai.video.lineup a wai:Capability ;
+ rdfs:label "wai.video.lineup" ;
+ wai:kind wai:kind-record ;
+ wai:status wai:status-registered ;
+ wai:specSection "§5 Interactive, temporal & computed content" ;
+ wai:registeredOn "2026-10-29"^^xsd:date .
+
wai:wai.video.manifest a wai:Capability ;
rdfs:label "wai.video.manifest" ;
wai:kind wai:kind-record ;
diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml
index a4db01b41..5687a37a2 100644
--- a/wai/wai-rs/Cargo.toml
+++ b/wai/wai-rs/Cargo.toml
@@ -159,7 +159,7 @@ floor = ["dep:image", "dep:claxon", "dep:zstd"]
# extensions/fleet.md §7 verifies it), and `wai mcp`, the server
# `wai setup-agent` registers. Pure Rust and the floor's zstd: no system library. `wai create`,
# `wai dev` and `wai setup-agent` need no feature.
-quickstart = ["floor", "http", "receipt_claims", "conform_result", "mcp", "fleet", "dep:getrandom"]
+quickstart = ["floor", "http", "receipt_claims", "conform_result", "mcp", "fleet", "dep:getrandom", "channel_lineup"]
# extensions/conformance-results.md: the record `conform-result/1`, its signature and verification.
conform_result = ["ed25519"]
# Pulls in the ONNX runtime + zstd unpack so the native sink can
@@ -366,6 +366,14 @@ live_ads = ["live_room", "video_ssai", "dep:num-bigint"]
# verify. ad-presentation's spans, viewability, quartile and attestation; live-compose's layout;
# a reader of HLS attribute lists, asset lists and an XML subset of its own. No new dependency.
stitched_presentation = ["live_ads", "live_compose"]
+# Channel lineup and guide (extensions/channel-lineup): `wai.video.lineup` — a linear channel's
+# signed lineup (WLU1) of each programme asset's description, artwork by content hash, geo
+# availability and rights holder, bound to its `wai.video.channel` schedule by the schedule root;
+# the import of an XMLTV schedule, Media RSS catalogues, a JSON form and a Media Playlist's SCTE-35
+# date ranges into that receipt, the lineup and the breaks' live-events cues; and the guide (XMLTV,
+# and a now/next document) rendered from the receipt and the lineup, checkable by recomputation.
+# Reads XML by stitched-presentation's subset and attribute lists by its reader. No new dependency.
+channel_lineup = ["video_channel", "stitched_presentation"]
# Live provenance (extensions/live-provenance): `wai.live.provenance` — the live video provenance
# of C2PA 2.4 §19 for a room's CMAF tracks and a device's CMAF take, written and verified with no
# C2PA library: ISO BMFF segments and the BMFF hash over whole-box exclusions, the JUMBF manifest
@@ -1296,6 +1304,13 @@ name = "wai_stitched_presentation"
path = "src/bin/wai_stitched_presentation.rs"
required-features = ["stitched_presentation"]
+# The corpus of extensions/channel-lineup.md §11 (`live-conformance/channels/`): `gen` writes it
+# from its fixed seeds, `verify` requires byte-identical regeneration and every vector's outcome.
+[[bin]]
+name = "wai_channel_lineup_vectors"
+path = "src/bin/wai_channel_lineup_vectors.rs"
+required-features = ["channel_lineup"]
+
[[bin]]
name = "wai_live_provenance_vectors"
path = "src/bin/wai_live_provenance_vectors.rs"
@@ -2312,6 +2327,11 @@ name = "stitched_presentation"
path = "tests/stitched_presentation.rs"
required-features = ["stitched_presentation"]
+[[test]]
+name = "channel_lineup"
+path = "tests/channel_lineup.rs"
+required-features = ["channel_lineup"]
+
[[test]]
name = "live_provenance"
path = "tests/live_provenance.rs"
diff --git a/wai/wai-rs/src/bin/wai_channel_lineup_vectors.rs b/wai/wai-rs/src/bin/wai_channel_lineup_vectors.rs
new file mode 100644
index 000000000..c097f74ef
--- /dev/null
+++ b/wai/wai-rs/src/bin/wai_channel_lineup_vectors.rs
@@ -0,0 +1,89 @@
+//! The corpus of extensions/channel-lineup.md §11 (`live-conformance/channels/`).
+//!
+//! wai_channel_lineup_vectors gen write every file from the fixed seeds
+//! wai_channel_lineup_vectors verify require byte-identical regeneration, no other
+//! file but the README, and every vector's outcome
+//!
+//! Exit status 0 when it holds, 1 with one line a finding otherwise, 2 on a usage error.
+
+use std::collections::BTreeMap;
+use std::path::Path;
+use std::process::ExitCode;
+
+use wai::channel_lineup::corpus;
+
+fn main() -> ExitCode {
+ let args: Vec = std::env::args().skip(1).collect();
+ let (Some(mode), Some(dir)) = (args.first(), args.get(1)) else {
+ eprintln!("usage: wai_channel_lineup_vectors gen|verify ");
+ return ExitCode::from(2);
+ };
+ let dir = Path::new(dir);
+ let generated = corpus::generate();
+ match mode.as_str() {
+ "gen" => {
+ if let Err(e) = std::fs::create_dir_all(dir) {
+ eprintln!("{}: {e}", dir.display());
+ return ExitCode::from(1);
+ }
+ for (name, body) in &generated {
+ if let Err(e) = std::fs::write(dir.join(name), body) {
+ eprintln!("{name}: {e}");
+ return ExitCode::from(1);
+ }
+ }
+ println!("wrote {} files to {}", generated.len(), dir.display());
+ ExitCode::SUCCESS
+ }
+ "verify" => {
+ let mut committed = BTreeMap::new();
+ let entries = match std::fs::read_dir(dir) {
+ Ok(e) => e,
+ Err(e) => {
+ eprintln!("{}: {e}", dir.display());
+ return ExitCode::from(1);
+ }
+ };
+ for e in entries.flatten() {
+ let name = e.file_name().to_string_lossy().into_owned();
+ if name == "README.md" {
+ continue;
+ }
+ match std::fs::read_to_string(e.path()) {
+ Ok(s) => {
+ committed.insert(name, s);
+ }
+ Err(err) => {
+ eprintln!("{name}: {err}");
+ return ExitCode::from(1);
+ }
+ }
+ }
+ let mut fails: Vec = Vec::new();
+ for (name, body) in &generated {
+ match committed.get(name) {
+ None => fails.push(format!("{name}: missing")),
+ Some(c) if c != body => fails.push(format!("{name}: does not regenerate byte for byte")),
+ Some(_) => {}
+ }
+ }
+ for name in committed.keys().filter(|n| !generated.iter().any(|(g, _)| g == *n)) {
+ fails.push(format!("{name}: not a file of the corpus"));
+ }
+ fails.extend(corpus::verify(&committed));
+ for f in &fails {
+ println!("{f}");
+ }
+ if fails.is_empty() {
+ println!("channel lineup corpus: {} files regenerate and every vector holds", generated.len());
+ ExitCode::SUCCESS
+ } else {
+ ExitCode::from(1)
+ }
+ }
+ _ => {
+ eprintln!("usage: wai_channel_lineup_vectors gen|verify ");
+ ExitCode::from(2)
+ }
+ }
+}
diff --git a/wai/wai-rs/src/bin/wai_cli/dx/channel.rs b/wai/wai-rs/src/bin/wai_cli/dx/channel.rs
new file mode 100644
index 000000000..1ac107454
--- /dev/null
+++ b/wai/wai-rs/src/bin/wai_cli/dx/channel.rs
@@ -0,0 +1,293 @@
+//! A linear channel's operations (extensions/channel-lineup.md §9): `wai channel import`
+//! (`channel.import`), `wai channel guide` (`channel.guide`) and `wai channel verify`
+//! (`channel.verify`).
+//!
+//! They keep the agent rules of [`super::envelope`], as every operation of the catalogue does: the
+//! exit codes, the JSON envelope with `--json`, `--dry-run` (every file through `out.rs`, nothing
+//! written), and `--yes` before a file that exists is replaced. An import's or a verification's
+//! refusal has the text's reason as its code (`import_gap`, `guide_mismatch`, ...) and exit 1.
+
+#[cfg(feature = "channel_lineup")]
+use super::envelope::EXIT_FAILED;
+use super::envelope::{self as agent, EXIT_INTERNAL, EXIT_NOT_FOUND, EXIT_PERMISSION, OpError};
+use crate::out;
+use serde_json::Value;
+#[cfg(feature = "channel_lineup")]
+use serde_json::json;
+
+type R = Result;
+
+/// Print what an operation did: its envelope with `--json`, its text otherwise; in a dry run of an
+/// operation that writes, with the plan of what it would have done.
+#[cfg_attr(not(feature = "channel_lineup"), allow(dead_code))]
+fn done(op: &str, writes: bool, data: Value, text: &str) -> R {
+ let plan = (writes && out::dry_run()).then(out::plan);
+ if agent::ctx().json {
+ agent::print(&agent::envelope_with(op, true, data, plan));
+ } else if plan.is_some() {
+ println!("{text}");
+ println!("dry run: nothing was written. Without --dry-run, wai would:");
+ for l in out::plan_lines() {
+ println!(" {l}");
+ }
+ } else {
+ println!("{text}");
+ }
+ Ok(true)
+}
+
+#[cfg_attr(not(feature = "channel_lineup"), allow(dead_code))]
+fn io_fail(path: &str, e: &std::io::Error, hint: &str) -> OpError {
+ match e.kind() {
+ std::io::ErrorKind::NotFound => OpError::new(EXIT_NOT_FOUND, "not-found", format!("{path}: {e}"), hint),
+ std::io::ErrorKind::PermissionDenied => OpError::new(EXIT_PERMISSION, "permission-denied", format!("{path}: {e}"), "the file's owner runs this"),
+ _ => OpError::new(EXIT_INTERNAL, "io-error", format!("{path}: {e}"), hint),
+ }
+}
+
+#[cfg_attr(not(feature = "channel_lineup"), allow(dead_code))]
+fn read(path: &str, what: &str) -> Result, OpError> {
+ std::fs::read(path).map_err(|e| io_fail(path, &e, &format!("give {what}")))
+}
+
+/// Every value of a flag that may be repeated, in order.
+#[cfg_attr(not(feature = "channel_lineup"), allow(dead_code))]
+fn all<'a>(args: &'a [String], flag: &str) -> Vec<&'a str> {
+ args.windows(2).filter(|w| w[0] == flag).map(|w| w[1].as_str()).collect()
+}
+
+#[cfg_attr(not(feature = "channel_lineup"), allow(dead_code))]
+fn number(args: &[String], flag: &str, usage: &str) -> Result