Skip to main content

WAI Extension: Stream Audit

Mirrored from the canonical text at commit 260cbd09 ().

Status: Draft. Registers the record wai.delivery.audit: evidence a content owner gathers from the players of a stream it already distributes over HTTP, with no change to the service that packages and delivers it. For each playback session the player makes a fresh key, the session key, and signs with it: settlement acknowledgements, WDA1, of every object it fetched, each named by plain SHA-256 under settlement §14, one chain per origin; a presentation claim, WPC1 of ad-presentation, for each ad the stream marks; and a session record, WAS1, which binds them: the channel, the owner’s collector, the spans of time it presented and whether each was in view and audible, its start time and stalls (recomputed from its delivery-telemetry log), its sink energy per role with each figure’s acquisition class, how the session ended, and the digests of every record it names. The player sends the records to the owner’s collector, which verifies them, stores them, and states a channel report per period: time presented, in view and audible; ads presented and the quartile each reached; start time and stalls once enough sessions hold them; acknowledged bytes per origin and object class; and joules per role and acquisition class, never summed across classes. A party holding the stored records recomputes the report byte for byte. No new transport, and no price, tariff or amount. The reference implementation is the stream_audit module of wai-rs (feature stream_audit); the collector is wai/wai-audit, whose web/ directory holds the player integration; the corpus is live-conformance/audit/, and its second implementation is live-conformance/stream_audit_verify.py. Keywords MUST, MUST NOT, SHOULD, MAY are RFC 2119/8174.

Texts this revision relies on: the WAI texts it cites; RFC 9110 §8.4 (content codings) and §14 (range requests); RFC 9530 §2 and §3 (Content-Digest, Repr-Digest); RFC 6454 §6.2 (the ASCII serialization of an origin); RFC 8032; FIPS 180-4; the WHATWG Fetch Standard’s CORS-safelisted response-header names and Access-Control-Expose-Headers; and draft-pantos-hls-rfc8216bis-22 §4.4.5.1 (EXT-X-DATERANGE) as live-ads reads it, with its EXTINF, EXT-X-PROGRAM-DATE-TIME and EXT-X-BYTERANGE tags.

1. Scope

In scope: the channel and the session key (§3); what a player acknowledges and how (§4); the session record (§5); the ads it presented (§6); its telemetry (§7); its energy and how the session ended (§8); the bundle a player sends (§9); verification (§10); the channel report and its recomputation (§11); and what a collector does (§12).

Out of scope: a transport; how a player reaches the collector, beyond §12; the service that delivers the stream, which this extension asks for nothing; the authenticity of what was delivered, since nothing the delivering service signs is read here (§15); the markers of a DASH EventStream and of an in-band event, which this revision does not read; and any price, tariff, amount or invoice: a report states what was presented and delivered, never what it is worth.

Relationship to the core spec: wai.delivery.audit is a record (SPEC §5): a signed record a WAI tool emits and verifies. It names no payload format and is not dispatchable.

2. Terms

3. The channel and the session key

3.1 The channel

A channel’s name is 1 to 255 bytes of printable ASCII (0x21 to 0x7E), which the owner chooses and configures its players with. Its id is SHA-256 over wai:audit-channel (ASCII), 0x01 and the name. A name is the owner’s label, never a URL: a URL can carry a token, and a channel outlives the URLs its stream is served from.

3.2 The session key and the session

A player MUST make a fresh Ed25519 key for each session, from its platform’s random generator, and MUST NOT use it for another session or derive it from an identifier of the viewer, the device or an account: ad-presentation §3’s rule, for a channel in place of a room.

The session is the first 16 bytes of SHA-256 over wai:ad-presentation-session (ASCII), 0x01, the channel id and the session key: ad-presentation §3’s derivation with the channel id in the room id’s place, so that the claims of §6 name it exactly as a room’s claims name theirs.

4. Acknowledgements

4.1 What a player acknowledges

A player acknowledges every object of the presentation it receives, under settlement §14: each named by the SHA-256 of its bytes as received, from the upstream named by its origin. It does not acknowledge a key or a licence response: their bytes are secrets, and their digest would let a party holding a candidate key confirm it.

Each acknowledgement (settlement §3) has mode 1, role 2 (a sink), upstream the origin’s upstream (settlement §14.2), downstream the session key, which signs it, and zero grant and proof in every entry. Its entries are by object class (settlement §3.3):

Every item is a whole object: start 0 and end its length. A byte-range segment is an object of its own (settlement §14.1).

4.2 Windows and chains

A player cuts its session into windows of W microseconds from the session’s start: the k-th from start + kW to start + (k + 1)W, the last ending at the session’s end. W is the player’s choice, 10 s by default and at least 1 s. For each origin and each window in which it received an object from that origin, it signs one acknowledgement of the objects received in that window, in the order received; an object’s time is when its body was complete. An origin’s acknowledgements form one chain (settlement §3.7), each naming in prev the digest of the one before it, the first naming zero.

A window’s item list names an object once. An object the window already lists for that origin, received again with the same bytes (a retried request, a playlist refreshed unchanged) is a repeat: the session record counts repeats, their objects and bytes (§5.1), and lists none.

4.3 What an acknowledgement states

That the holder of the session key received those bytes from that origin in that window. Not that the bytes are what the owner published, nor that they were presented: presentation is the session record’s spans (§5.2) and the claims (§6).

5. The session record

5.1 Layout

When a session ends, the player signs one record:

fieldsizemeaning
magic4WAS1 (ASCII)
version11 in this revision
channel32the channel id (§3.1)
owner32the owner’s key: the collector the session reports to
player32the session key, which signs the record
session16the session (§3.2)
presentation11 live, 2 on demand
start, end8 eachu64: the session’s span
ended1how the session ended (§8.2)
origins1 + …a u8 count n, at least 1, then each origin: a u16 length, 1 to 2048, and its ASCII serialization (RFC 6454 §6.2)
acks2 + 32 × na u16 count, at least 1, then the digests of the session’s acknowledgements
claims2 + 32 × na u16 count, then the digests of its presentation claims (§6)
spans4 + 11 × na u32 count, then each span (§5.2)
startup_ms4u32: the start time, delivery-telemetry’s vst_ms (§7.1); 4 294 967 295 when nothing was presented
stalls4u32: the stalls begun after the first presentation
stall_ms8u64: their total duration
repeat_objects4u32: the repeats (§4.2)
repeat_bytes8u64: their bytes
log32the digest of the session’s observation log (§7.1)
notes32the digest of its notes document (§7.3), or zero for none
energy1 + …a u8 count, then each energy entry (§5.3)
attachments2 + 32 × na u16 count, then the digests of the records it attaches (§8.3)
sig64§5.4

Each list of digests is in strictly ascending order, and so are the origins, bytewise.

5.2 Spans

A span is a stretch of the session’s time during which the player presented the stream at normal speed; 11 bytes:

fieldsizemeaning
start_ms4u32: where it starts, in milliseconds after the session’s start
length_ms4u32: its length, at least 1
visible_bp2u16: the least fraction of the player’s pixels in view it observed over the span, in basis points (0 to 10 000)
flags1bit 0 audible: audible throughout; bit 1 ad: within a marked ad (§6)

A player observes at least every 200 ms and begins a new span whenever playback stops or resumes, its quantised visible fraction or audible state changes, or a marked ad begins or ends, by ad-presentation §10’s rules: a fraction of 0 while the page or application is hidden, out of focus or locked; audible only when unmuted at a volume above 0. The spans are in order and do not overlap.

5.3 Energy entries

An entry states the energy of one role of the session:

fieldsizemeaning
role11 origin, 2 delivery (the network between origin and player), 3 sink decode, 4 display
joules8u64: microjoules over the session; 0 is the unmetered marker
class1 + …its acquisition class: energy-measurement §2.7’s tag byte, then that tag’s evidence

A role with a figure states it with its class; a role the player knows took part and did not measure states the unmetered marker (tag 0 and 0 J, live-metering §2); a role it knows nothing of has no entry (§8.1).

5.4 Digest and signature

The digest of a record is SHA-256 over its bytes before sig. Its signature is Ed25519 by player over the 18 bytes wai:audit-session (ASCII) and 0x01, followed by the bytes the digest covers.

5.5 Reading and rules

A reader reads exactly the bytes it is given as one record, in the order of §5.1, and refuses at the first of these that holds: fewer than 4 bytes, truncated; a magic other than WAS1, malformed; no fifth byte, truncated; a version other than 1, unknown_version; the bytes end before a field the layout requires, truncated; a class whose tag 5 reference is longer than 255 bytes, malformed; bytes after sig, malformed. It then refuses as audit_session_invalid a record that breaks any of these:

and as energy_invalid one whose energy entry breaks live-metering §3.6’s rules for a figure and its class (the tag is 0 exactly when joules is; no tag 1; the hardware classes’ evidence and resolution floor). Then the signature (§5.4): bad_key, bad_signature.

6. Ads presented

6.1 Marked ads and their markers

An ad is marked when the media playlist the player presents carries an EXT-X-DATERANGE tag (draft-pantos-hls-rfc8216bis-22 §4.4.5.1) over it with a SCTE35-OUT or SCTE35-CMD attribute, or with a CLASS the player is configured to treat as an ad. Its marker is the tag’s line as the playlist carries it, from # to the last byte before the line terminator, and its marker digest is SHA-256 over wai:audit-marker (ASCII), 0x01 and the marker. The marked span runs from the tag’s START-DATE for its DURATION (or PLANNED-DURATION), placed in the playlist by its EXT-X-PROGRAM-DATE-TIME tags.

6.2 The creative

The ad’s creative (ad-presentation §4) is the media segments of the presented playlist whose span meets the marked span, in order, each by the SHA-256 of its bytes as received (settlement §14.1) and its EXTINF duration in milliseconds, rounded half up and at least 1. Its media time starts at the first such segment’s start. A creative’s objects are among the objects the session acknowledged.

6.3 The claim

For each marked ad it presented, the player signs a claim of ad-presentation §5, with room the channel id, cue the marker digest, session the session, slot 0, creative_hash the creative digest, player the session key, and spans over the creative’s media time by ad-presentation §10’s rules. A player that plays into the ad from before it, with no pause or seek between its last observation before the ad and its first within it, starts the ad’s first span at media time 0; one that plays out of it the same way ends its last span where it left, at most the creative’s duration: playback was continuous across the boundary, which fell between two observations. A claim states what ad-presentation §13 says one states, and here no decider’s receipt stands beside it: its quartile and viewability are computed (§10.2 step 9) and reported, not compared with anyone’s count.

This revision claims every marked ad with WPC1 under the channel binding above. A record of its own for ads another party stitches into a stream, wai.video.stitched_presentation, names an ad by its marker in a manifest whose SHA-256 it states; where a deployment holds that record, a stitched ad is claimed with it, the bundle then carries the bytes of each manifest its claims name, and WPC1 stays for a marked ad that is not stitched. This revision does not read it.

7. Telemetry

7.1 The observation log

The player keeps a delivery-telemetry log of the session (delivery-telemetry §2 and its §7.1), whose sid is the session as 32 lowercase hexadecimal digits: fresh per session and derived from no identifier, as delivery-telemetry §8 requires. Its object members report url as null, since a third party’s URL can carry a token in its path, and content_hash as the object’s SHA-256. The observation log is live-metering §6.1’s logs document holding exactly this one log, as UTF-8 bytes. The record’s startup_ms, stalls and stall_ms are the log’s vst_ms, stalls and stall_ms (delivery-telemetry §7).

7.2 CMCD

The log’s Event-mode reports, CMCD version 2 (CTA-5004-B) as delivery-telemetry §3 maps them, go to the owner’s collector as application/cmcd batches (live-metering §6.3) while the session runs. Request-mode reports MAY ride on the player’s requests to an origin where the owner configures it; they are off by default, since a query added to a third party’s URL can break the signature that URL carries. A batch is advisory, as every observation is (delivery-telemetry §1); what the report reads is the log the record binds.

7.3 CMSD and the notes document

Where a response exposes CMSD-Static or CMSD-Dynamic (CTA-5006) to the player (in a page, only when the origin lists them in Access-Control-Expose-Headers; they are not CORS-safelisted names), the player records them. The notes document is a JSON array, as UTF-8 bytes, of one object per response that exposed either, in the order received, with exactly the members content_hash (the object’s SHA-256, 64 lowercase hexadecimal digits), cmsd_static and cmsd_dynamic (each the field’s value as received, a string, or null). The values are the origin’s statements, recorded, not parsed or checked. The record names the document by its digest, zero when no response exposed either field.

8. Energy, the end of a session and attachments

8.1 Energy

The entries of §5.3 follow one rule: a figure with its class, the unmetered marker where a role took part unmeasured, no entry where the player knows nothing. A page has no meter: it states roles 3 and 4 as the unmetered marker and has no entry for roles 1 and 2. A native host or a sink with an energy profile states its figure with the class and evidence its counter or model gives (energy-measurement §2.7). Nothing sums figures of two classes: the report totals each role and class apart (§11.2), as live-metering §4.5 totals a session’s hops.

8.2 How the session ended

endednamewhen
1endedthe presentation reached its end
2userthe viewer stopped it, or left the page or application
3hiddenthe player ended the video because nobody could see it for its grace period
4idlethe player ended it after its idle bound passed with no interaction
5errorplayback failed

8.3 Attachments

A player may attach other signed records of the session (a termination record of wai.live.termination, a sink energy receipt of wai.sink.energy, each where the player keeps one), each named in attachments by the SHA-256 over all its bytes. This revision binds an attachment to the session and does not read it; a collector stores it with the session.

9. The bundle

A player sends a session to the collector as one bundle: a JSON object (RFC 8259) with exactly these members:

membervalue
v1
sessionthe record (§5), in lowercase hexadecimal
acksan array, one per digest of acks: { "record": hex, "items": [hex, …] }, the acknowledgement and each of its entries’ item lists in entry order
claimsan array, one per digest of claims: { "claim": hex, "marker": string, "creative": [{ "content_hash": hex, "duration_ms": integer }, …] }
logthe observation log (§7.1), a string whose UTF-8 bytes are the document
notesthe notes document (§7.3) as such a string, or null
attachmentsan array: { "name": string, "record": hex }, the record’s registered name and its bytes

A bundle that is not such an object, holds a member not listed or lacks one, a value of another type, hexadecimal that is not lowercase or not whole bytes, a creative object whose duration_ms is not 1 to 4 294 967 295 or whose content_hash is not 32 bytes, or a creative with no object, is refused audit_bundle_invalid.

10. Verification

10.1 Inputs

A verifier is given a bundle, the owner’s key, and the names of the channels the owner serves.

10.2 Steps

It runs these steps and stops at the first refusal, which it reports with the reason and where:

  1. The bundle (§9), at bundle: audit_bundle_invalid.

  2. The record (§5.5), at session: truncated, malformed, unknown_version, audit_session_invalid, energy_invalid, bad_key, bad_signature.

  3. Its binding, at session: owner is the owner’s key, audit_wrong_owner; channel is the id of a channel the owner serves, audit_unknown_channel; session is the session of channel and player (§3.2), audit_session_unbound.

  4. Each acknowledgement, in the record’s order, at ack and its index:

    1. one of the bundle’s acknowledgements has the digest (settlement §8.2: SHA-256 over all but the last 64 bytes): otherwise record_missing;
    2. it reads and verifies (settlement §3.6): truncated, malformed, unknown_version, ack_invalid, self_acknowledged, bad_key, bad_signature;
    3. its mode is 1 and its downstream is player: otherwise audit_ack_unbound;
    4. its upstream is the upstream of one of the record’s origins (settlement §14.2): otherwise audit_origin_unknown;
    5. its window is within the session, the session’s start ≤ its start and its end ≤ the session’s end: otherwise ack_outside_period;
    6. its role is 2, every entry’s grant and proof are zero, and every entry’s class is 4 or the class of the record’s presentation (§4.1): otherwise audit_ack_invalid;
    7. each entry, at entry and its index: its item list is given (record_missing), reads (items_invalid) and recomputes (items_mismatch) as settlement §3.6 says, and every item’s start is 0 (audit_ack_invalid); an item list beyond its entries is audit_bundle_invalid.

    A bundle acknowledgement whose digest the record does not name, or that repeats one, is audit_bundle_invalid, at ack and its index in the bundle.

  5. The chains, at ack and an index: each origin’s acknowledgements ordered by (start, index), as settlement §8.2 step 4 orders a chain: the first with a prev other than zero, ack_chain_broken; a later one whose window overlaps the one before it, ack_overlap, or whose prev is not that one’s digest, ack_chain_broken. Of every such break, the one at the acknowledgement first in the record’s order is reported.

  6. The log, at log: the SHA-256 of the bundle’s log bytes is log, otherwise audit_document_mismatch; it reads as a logs document of exactly one log (live-metering §6.1), otherwise qoe_invalid; and its sid is the session’s and its vst_ms, stalls and stall_ms are the record’s startup_ms (null for 4 294 967 295), stalls and stall_ms, otherwise audit_qoe_mismatch.

  7. The notes, at notes: with notes zero, the bundle’s notes is null; otherwise its SHA-256 is notes: otherwise audit_document_mismatch. A notes document that does not read as §7.3 says, or that names a content_hash no item of the session’s acknowledgements has, is audit_notes_invalid.

  8. The attachments, at attachment and its index: each digest the record names is an attachment’s (record_missing), and each attachment’s digest is one the record names (audit_document_mismatch).

  9. Each claim, in the record’s order, at claim and its index: a bundle claim with the digest (SHA-256 over all but its last 64 bytes) is given, otherwise record_missing, and a bundle claim whose digest the record does not name is audit_bundle_invalid. Then each claim gets a verdict, presented or the reason of the first of these it fails:

    1. it reads (ad-presentation §5.4): claim_malformed;
    2. its signature verifies under its player (ad-presentation §5.3): bad_key, bad_signature;
    3. its room is the channel id, its session the session and its player the session key: audit_claim_unbound;
    4. its cue is the digest of the marker beside it (§6.1): audit_marker_mismatch;
    5. no claim before it that passed steps 1 to 4 has its cue and slot: claim_duplicate;
    6. its creative_hash is the digest of the creative beside it, every span ends within the creative’s duration, and its objects are exactly the content hashes of the creative’s objects its spans touch, in order (ad-presentation §9.2 step 6): claim_asset_mismatch;
    7. every one of its objects is the content hash of an item of one of the session’s acknowledgements: audit_ad_not_acknowledged.

    A claim’s verdict does not refuse the session. A presented claim’s facts are the quartile it reaches (ad-presentation §6.3, with the creative’s duration), and its run and viewable time under the video rule (ad-presentation §6.2, §6.1 viewability 1), and whether that run is at least 2 000 ms.

10.3 Outcome

11. The channel report

11.1 Its sessions

A report covers one channel of one owner over a period from from to to. Its sessions are the accepted bundles of that channel and owner whose record’s start lies in the period. Two records with one session are refused audit_session_duplicate, at report: a session reports once.

11.2 The document

The report is a JSON object with exactly these members:

membervalue
v1
channel, ownerthe channel id and the owner’s key, hexadecimal
from, tothe period
sessionsthe records’ digests, hexadecimal, in ascending order
presented{ "presented_ms", "viewable_ms", "audible_ms", "ad_ms" }: §10.3’s sums over the sessions
ended{ "ended", "user", "hidden", "idle", "error" }: the sessions by ended
ads{ "claims", "presented", "quartiles", "viewable", "refused" }: the claims, those presented, an array of five counts (the presented claims reaching quartiles 1 to 5), those whose run under the video rule is at least 2 000 ms, and an object of each refusing verdict’s count by its code
rebuffering{ "sessions", "suppressed", "presented", "startup_ms", "stalls", "stall_ms" }: the sessions; true when they are fewer than 50, and then every other member null (delivery-telemetry §8); otherwise false, the sessions with a span, the sum of their startup_ms, and the sums of stalls and stall_ms over all
deliveryan array of { "origin", "class", "objects", "bytes" }, one per origin and object class with an acknowledged object (class by settlement §3.3’s name), in ascending order of origin then class
repeats{ "objects", "bytes" }: the sums of the records’ repeats
energyan array of { "role", "class", "figures", "joules_micro" }, one per role and acquisition class among the sessions’ metered entries (role origin, delivery, sink_decode or display; class live-metering §4.5’s label), in ascending order of role number then tag
unmeteredan object with a member per role name: the sessions whose entry for the role is the unmetered marker
absentan object with a member per role name: the sessions with no entry for the role

Joules are summed within one role and one class and never across: a role’s figures of two classes are two members of energy, and a session whose entry is the unmetered marker counts in unmetered, never as zero joules.

11.3 Its bytes and signature

The report’s bytes are its JSON with every object’s members in bytewise order of their names, no white space, integers in decimal, and strings with no escape but \" and \\: the form RFC 8785 gives such a document. The collector signs them: Ed25519 by the owner’s key over the 17 bytes wai:audit-report (ASCII) and 0x01, followed by the report’s bytes. A served report is {"report": <the report>, "sig": <hex>}.

11.4 Recomputation

A party given the bundles, the channel’s name, the owner’s key and the served report recomputes the report over the report’s own period, in this order: each bundle verifies as §10 verifies it, otherwise its refusal, at bundle, its index and its place; a served report that is not an object of report and sig, or whose from, to or sessions do not read, is audit_report_mismatch; two of the sessions it reads with one session are audit_session_duplicate; a digest the report names that no given bundle has is record_missing, at report, session and its index; a report whose bytes are not the recomputed report’s bytes is audit_report_mismatch; and one whose signature does not verify under the owner’s key is bad_signature, each at report where no other place is named.

12. The collector

A collector:

The reference collector, wai-audit serve, keeps one directory per channel, a file per session, and answers on loopback; its README states its routes and limits.

13. The player integration

The reference integration is wai/wai-audit/web/audit.js, a module with no dependency, and its adapters: one that wraps the page’s fetch and XMLHttpRequest for any player that fetches over them, and one for a player whose loader interface a page can wrap. The module hashes each object with WebCrypto’s SHA-256, writes and signs the acknowledgements, the claims and the record with WebCrypto’s Ed25519, computes jwp-receipts §2.2’s tree root with a BLAKE3 of its own for inputs of one chunk, observes the media element every 200 ms as ad-presentation §10 says, writes the observation log and its Event-mode CMCD, and sends the bundle when the session ends. Its tests drive it with the corpus’s inputs and require the corpus’s records, byte for byte.

14. Reasons

The codes this extension adds, each with where it is raised:

reasonraised at
audit_bundle_invalid§9, §10.2 steps 1, 4 and 9
audit_session_invalid§5.5, §10.2 step 2
audit_wrong_owner§10.2 step 3
audit_unknown_channel§10.2 step 3
audit_session_unbound§10.2 step 3
audit_ack_unbound§10.2 step 4
audit_origin_unknown§10.2 step 4
audit_ack_invalid§10.2 step 4
audit_document_mismatch§10.2 steps 6 to 8
audit_qoe_mismatch§10.2 step 6
audit_notes_invalid§10.2 step 7
audit_claim_unbound§10.2 step 9
audit_marker_mismatch§10.2 step 9
audit_ad_not_acknowledged§10.2 step 9
audit_session_duplicate§11.1, §12
audit_report_mismatch§11.4

A verifier of this extension also raises truncated, malformed and unknown_version for a record that does not read; bad_key and bad_signature; record_missing (live-metering §9); energy_invalid and qoe_invalid (live-metering §9); settlement §11’s ack_invalid, self_acknowledged, items_invalid, items_mismatch, ack_outside_period, ack_overlap and ack_chain_broken; and ad-presentation §11’s claim_malformed, claim_duplicate and claim_asset_mismatch, as claim verdicts.

15. Conformance

live-conformance/audit/ holds the corpus, written from fixed seeds by wai_stream_audit_vectors gen as vectors.json. It holds the owner’s key, the channel names, the objects’ bytes, and:

A conforming verifier gives every session and every report the outcome, the reason, the place and every figure the corpus states. wai_stream_audit_vectors verify and the test tests/stream_audit.rs check that the file regenerates byte for byte, that wai-rs gives every vector its outcome, that every reason of §14 and every borrowed code refuses a vector, and that §14 lists exactly the reference’s codes. live-conformance/stream_audit_verify.py is the second implementation, written from this text: it shares no code with wai-rs; it reads acknowledgements and item lists with the settlement corpus’s second implementation (settlement_verify.py), claims with the ad-presentation corpus’s (ad_presentation_verify.py), and logs and energy classes with the live-metering corpus’s (live_metering_verify.py), verifies every signature with WAI’s strict rule (claim-conformance/_ed25519.py), recomputes every report itself, gives every vector the same outcome, requires every mutated copy of the corpus to fail, and requires the corpus to fail a copy of itself with each of its checks dropped.

16. Security and privacy considerations