open-standards/wai/extensions/live-events.md
Transaction Science ab23c8ab6b live rooms: AI participants are marked in their grant, and chat and reactions state their origin
live-events §4.4 and §4.5: a chat and a reaction may state an origin after their last
field (1 human, 2 machine, 3 machine-reviewed, with the model's digest for 2 and 3), as a
caption does. live-rooms §4.4: the grant attribute `ai` names the model that acts for an AI
participant; §9.2 step 6 refuses its chat, reaction or caption that does not state origin 2
or 3 with that model (`ai_unmarked`), and a relay does not forward one. Both corpora gain
vectors (additions only); both second implementations read the new fields.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 05:17:43 -04:00

693 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# WAI Extension: Live Events
> Status: Draft. Registers the record `wai.live.events`: a signed, timeline-bound event
> track. Timed metadata, ad and chapter cues, chat, reactions, polls, markers,
> remote-control commands, captions, moderation decisions and room lifecycle are all
> *events*, and an event is one record, `WEV1`: signed by its principal, hash-chained to
> that principal's previous event, stamped with the room clock, and optionally anchored to
> one media object. The same bytes ride an MSF event timeline over MoQ, an
> `EXT-X-DATERANGE` tag, a CMAF `emsg` box, a WebRTC data channel and a recording. No new
> transport: WAI defines none by charter, and this extension does not change that. Field
> semantics and rules over them only. The reference implementation is the `live_events`
> module of `wai-rs` (feature `live_events`); the corpus is
> [`live-conformance/events/`](../live-conformance/events/), and its second implementation
> is `live-conformance/live_events_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
>
> Drafts read for this revision: `draft-ietf-moq-msf-01` (MSF),
> `draft-wilaw-moq-scte35-event-timeline-00`, `draft-wilaw-moq-webvtt-msf-00`,
> `draft-jennings-moq-mocha-chat-00` and `draft-jennings-moq-mocha-reactions-00`, all
> Internet-Drafts, none past Working Group Last Call; with RFC 8216 §4.3.2.7 and AOM
> id3-emsg v1.0.0. A section number of one of those drafts in this text is that
> revision's.
## 1. Scope
In scope: the event record and its digest and signature (§3); the kinds and their
payloads (§4); the event policy a verifier holds each principal to (§5); verification of
one record and of each principal's chain, with a delivery gap told apart from corruption
(§6); poll tallies as `wai.feed.state` cells (§7); and the field mappings onto MoQ, LL-HLS,
CMAF and DASH, WebRTC and a recording, with what a subscriber joining mid-stream receives
(§8).
Out of scope: a transport; the room descriptor, which fixes a room's id and its clock, and
the room grant, which carries a principal's policy (both belong to a later extension);
the contents of a `take_commit` (§4.12), which [capture binding](capture-binding.md)
§5 defines; moderation
workflow, which [live-moderation](live-moderation.md) defines; the ad breaks a room's cues
open, and their interstitials and asset lists, which [live-ads](live-ads.md) defines; and how a
sink renders any event.
Relationship to the core spec: `wai.live.events` 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.
## 2. Terms
- **Room**: a multi-party live session. Its id is 32 bytes. The room extension defines it
as the digest of the signed room descriptor; here it is opaque, and a verifier is given
it.
- **Room clock**: microseconds since the room's `t0`, a UTC instant the room fixes. A
binding that needs a wallclock is given `t0` as microseconds since 1970-01-01T00:00:00Z;
one that needs a media time is given the presentation time of `t0`.
- **Principal**: an Ed25519 public key (RFC 8032), 32 bytes. Its `did:key` form names the
same key.
- **Event**, **record**: one `WEV1` (§3).
- **Chain**: one principal's events in one room, ordered by `seq`.
- **Head**: the event of a chain a verifier accepted last.
- **Stream order**: the order in which a verifier is offered one principal's records. Each
binding says what it is (§8).
## 3. The event record
### 3.1 Layout
Integers are little-endian.
| offset | size | field | meaning |
|---|---|---|---|
| 0 | 4 | `magic` | `WEV1` (ASCII) |
| 4 | 1 | `version` | 1 in this revision |
| 5 | 32 | `room` | the room's id |
| 37 | 32 | `principal` | the author's Ed25519 public key |
| 69 | 8 | `seq` | u64: the event's position in its chain, from 0 |
| 77 | 32 | `prev` | the digest (§3.2) of the chain's event `seq − 1`; 32 zero bytes when `seq` is 0 |
| 109 | 8 | `t` | u64: the room clock at the event |
| 117 | 1 | `anchor_flag` | 0: no anchor; 1: an anchor follows |
| 118 | 48 | `anchor` | only when `anchor_flag` is 1: `track` (32 bytes), `group` u64, `object` u64 |
| *a* | 1 | `kind` | §4 |
| *a* + 1 | 2 | `payload_len` | u16 |
| *a* + 3 | `payload_len` | `payload` | §4 |
| *a* + 3 + `payload_len` | 64 | `sig` | §3.2 |
*a* is 118 without an anchor and 166 with one.
An **anchor** binds an event to one MoQ object, so that a marker, a cue or a caption lands
on an exact frame. `track` is SHA-256 over the track's namespace as its MSF catalog entry
has it (its own, or the catalog track's that it inherits, MSF §5.2.2), one zero byte, and
the track's name; `group` and `object` are the object's MOQT Group ID and Object ID. An
anchor names an object on MoQ; the other bindings carry it unchanged.
`payload_len` lets a payload be up to 65535 bytes, the format's limit; the bound a
verifier applies is the policy's `max_payload` (§5).
### 3.2 Digest and signature
The **digest** of an event is SHA-256 (FIPS 180-4) over its record from `magic` through
`payload`: every byte but `sig`. It is written as 64 lowercase hexadecimal digits. An event
is named by its digest wherever another event refers to it: `prev`, a vote's poll, a
reaction's or a decision's target.
The **signature** is Ed25519 (RFC 8032, pure Ed25519) by `principal`, over the 15 bytes
`wai:live-event` (ASCII) and 0x01, followed by the bytes the digest covers. It is verified
strictly, as [SPEC](../SPEC.md) §7 *Signatures* requires: a key that fails step 1 or 2 is
`bad_key`, and a signature that fails a later step is `bad_signature`.
### 3.3 Reading a record
A reader reads exactly the bytes it is given as one record, in the order of §3.1, and
refuses at the first of these that holds:
1. Fewer than 4 bytes: `truncated`. A magic other than `WEV1`: `malformed`.
2. No byte after the magic: `truncated`. A version other than 1: `unknown_version`. A
reader reads no further field, since a later version's layout is its own.
3. The bytes end before a field the layout requires: `truncated`. An `anchor_flag` other
than 0 or 1, when it is read: `malformed`.
4. Bytes after `sig`: `malformed`.
## 4. Kinds and payloads
| code | kind | payload |
|---|---|---|
| `0x01` | `meta` | §4.2 |
| `0x02` | `cue` | §4.3 |
| `0x03` | `chat` | §4.4 |
| `0x04` | `reaction` | §4.5 |
| `0x05` | `poll_open` | §4.6 |
| `0x06`, `0x07` | `vote`, `poll_close` | §4.7 |
| `0x08` | `marker` | §4.8 |
| `0x09` | `control` | §4.9 |
| `0x0A` | `caption` | §4.10 |
| `0x0B` | `decision` | §4.11 |
| `0x0C` | `take_commit` | §4.12 |
| `0x10`–`0x16` | `join`, `leave`, `publish`, `unpublish`, `grant_exchange`, `takeover`, `failover` | §4.13 |
No other code is defined in this revision; a record of another kind is refused
`unknown_kind` (§6.1). Codes `0x17` to `0x1F` are reserved for lifecycle kinds.
A payload is read in the order its fields are written, and each field's rule applies when
it is read. A payload that ends before a field, that holds bytes after its last field, or
whose field holds a value this section does not allow, is `malformed_payload`; a text
field's own rules are §4.1's. Integers are little-endian. A digest field is 32 bytes.
### 4.1 Text
A text field is a u16 length followed by that many bytes. Its rules, in order:
1. The bytes are UTF-8 (RFC 3629): no overlong form, no surrogate, nothing above U+10FFFF.
Otherwise `bad_utf8`.
2. They hold at most the policy's `max_text_cp` code points (§5; 500 by default).
Otherwise `text_too_long`.
Each text field is bounded on its own. Empty text is allowed.
### 4.2 `meta`
Opaque application metadata: the whole payload, read for its length alone. It is the timed
metadata a player hands to its application.
### 4.3 `cue`
| field | size | meaning |
|---|---|---|
| `type` | u8 | 1 out (leave the program for a break), 2 in (return to it), 3 chapter, 4 break (out and back, its duration known) |
| `cue_id` | u32 | ties an out to its in; an SCTE-35 `splice_event_id` where the message has one |
| `duration_ms` | u32 | out and break: the planned duration; in: the actual duration since its out; chapter: its length; 0 when not known |
| `form` | u8 | 0: no SCTE-35 message; 1: an SCTE 35-1 `splice_info_section()`; 2: an SCTE 35-2 event-based signaling message |
| `scte_len` | u16 | only when `form` is not 0 |
| `scte` | `scte_len` | the message, verbatim |
Form 1 is read only as far as this: `table_id` is 0xFC, and `section_length` (the low 12
bits of bytes 1 and 2) plus 3 is `scte_len`. WAI reads no command, descriptor or CRC of
it. Form 2 is at least one byte, carried opaque. A `type` or `form` not in the table is
`malformed_payload`. The summary (`type`, `cue_id`, `duration_ms`) is what a sink acts on
without parsing SCTE-35. Where a message is carried too, both describe one splice, and a
producer **MUST NOT** write a summary its message contradicts.
### 4.4 `chat`
| field | size | meaning |
|---|---|---|
| `text` | text | the message |
| `origin` | u8 | optional: 1 human, 2 machine, 3 machine-reviewed |
| `model` | 32 | only when `origin` is 2 or 3: a digest naming the model that produced the message |
`origin` is present exactly when a byte follows `text`; a chat without it states no origin.
`origin` and `model` mean what a caption's mean (§4.10): they are the message's
machine-generation disclosure, and a sink that labels machine-generated messages takes the
label from them. A room may require them of a principal: [live-rooms](live-rooms.md) §4.4
marks an AI participant, whose every chat states origin 2 or 3.
### 4.5 `reaction`
| field | size | meaning |
|---|---|---|
| `type` | u8 | 1 emoji, 2 vote, 3 raise, 4 applause, 5 custom: the reaction types of `draft-jennings-moq-mocha-reactions-00` §4.4 |
| `value` | u16 | not 0: for emoji and raise, that draft's §4.5 integer encodings; for the others, the application's |
| `flags` | u8 | bit 0: a target follows; bit 1: this retracts the reaction it repeats; every other bit 0 |
| `target` | 32 | only when bit 0 is set: the digest of the event reacted to |
| `origin` | u8 | optional, as a chat's (§4.4): present exactly when a byte follows the field before it |
| `model` | 32 | only when `origin` is 2 or 3 |
A reaction without a target is **standalone**. Standalone reactions are held to the
policy's reaction rate (§6.3), as that draft holds them to one per second per device
(§5.1 there). A room's polls use `poll_open` and `vote`, not reaction type 2.
### 4.6 `poll_open`
`question` (text), `n` (u8, at least 2), then `n` option texts. A poll is named by the
digest of its `poll_open`.
### 4.7 `vote` and `poll_close`
- `vote`: `poll` (a poll's digest), `option` (u8).
- `poll_close`: `poll`, `n` (u8, at least 2), then `n` counts (u32 each): the author's
statement of the final tally.
§7 says which votes count, which `poll_close` closes a poll, and when a stated tally is
checked.
### 4.8 `marker`
| field | size | meaning |
|---|---|---|
| `taxonomy` | u32 | what the marker observes, in the application's taxonomy |
| `polarity` | u8 | 0 neutral, 1 positive, 2 negative |
| `text` | text | a note |
| `has_attachment` | u8 | 0 or 1 |
| `attachment` | 32 | only when `has_attachment` is 1: a digest naming an attached object, such as a voice note sealed as a WAI envelope ([jwp-receipts](jwp-receipts.md) §2.1 content hash) |
### 4.9 `control`
`target` (the principal the command is for), `command` (u8), then the command's argument:
| `command` | name | argument |
|---|---|---|
| 1 | `record_start` | `count_in_ms` u32: recording starts this long after `t` |
| 2 | `record_stop` | none |
| 3 | `zoom` | `q16` u32: the zoom factor in Q16.16, not 0 |
| 4 | `mute` | `on` u8: 1 mute, 0 unmute |
| 5 | `layer_request` | `layer` u8: the layer asked for |
A control event takes effect at its `t` on the room clock, so a count-in lines up for every
participant. Whether the target acts on it is the target's; the event records that it was
asked, by whom and when.
### 4.10 `caption`
| field | size | meaning |
|---|---|---|
| `duration_ms` | u32 | how long the caption shows; 0 when not known |
| `origin` | u8 | 1 human, 2 machine, 3 machine-reviewed |
| `model` | 32 | only when `origin` is 2 or 3: a digest naming the model that produced the text |
| `lang_len` | u8 | |
| `lang` | `lang_len` | a BCP 47 language tag (RFC 5646), in ASCII |
| `text` | text | the caption |
`lang` is read here only as far as this: 2 to 35 characters; a first subtag of 2 to 8
letters; then subtags of 1 to 8 letters or digits, each after one hyphen. A tag that fails
is `malformed_payload`. `origin` is the event's machine-generation disclosure: a sink that
labels machine-generated content, in its player, in a playlist characteristic or in a
provenance assertion, takes the label from it.
### 4.11 `decision`
`verdict` (u8: 1 allow, 2 deny, 3 redact), `target` (the digest of the event decided on),
`reason` (u16: a reason code the room defines). A decision is an event of the principal
that decided, and the target stays in its chain unchanged. A sink **SHOULD NOT** show
content a decision of a principal the room authorises to moderate denies or redacts.
[Live-moderation](live-moderation.md) defines the statement that follows a decision in its
author's chain, what else a target may name, and how one decision supersedes another.
### 4.12 `take_commit`
Read here for its length alone, which `max_payload` bounds: a room's verifier carries it
opaque. [Capture binding](capture-binding.md) §5 defines its contents, a device's rolling
commitment to the master it is capturing, and a take verifier reads them.
### 4.13 Lifecycle
| code | kind |
|---|---|
| `0x10` | `join` |
| `0x11` | `leave` |
| `0x12` | `publish` |
| `0x13` | `unpublish` |
| `0x14` | `grant_exchange` |
| `0x15` | `takeover` |
| `0x16` | `failover` |
Payload: `subject` (the principal the event is about), then `detail`, the rest of the
payload, opaque in this revision: a track name, or the digest of a grant. The room
extension defines `detail` for each kind. A room authority, or a relay acting for it,
authors these; the default policy permits them to no one (§5).
## 5. The event policy
An event policy states what one principal may send. A verifier holds every principal to a
policy: a default one, and one of the principal's own where it has one. In this revision
the verifier's caller gives it the policies. The room grant of a later extension carries
these same fields, per principal.
The policy record, `WEP1`, is 65 bytes; integers are little-endian.
| offset | size | field | meaning | default |
|---|---|---|---|---|
| 0 | 4 | `magic` | `WEP1` (ASCII) | |
| 4 | 1 | `version` | 1 in this revision | |
| 5 | 32 | `kinds` | kind *k* is permitted when bit *k* mod 8 of byte *k* div 8 is set | every kind of §4 but the lifecycle kinds |
| 37 | 2 | `max_payload` | the longest payload, in bytes | 1024 |
| 39 | 2 | `max_text_cp` | the most code points in one text field | 500 |
| 41 | 4 | `max_events` | the most events in one window (§6.3) | 50 |
| 45 | 8 | `window_us` | the window, in microseconds of room clock | 1 000 000 |
| 53 | 4 | `max_reactions` | the most standalone reactions in one reaction window | 1 |
| 57 | 8 | `reaction_window_us` | the reaction window | 1 000 000 |
A reader refuses a policy record: with fewer than 4 bytes, `truncated`; with another magic,
`policy_malformed`; with no version byte, `truncated`; with another version,
`unknown_version`; shorter than 65 bytes, `truncated`; longer, `policy_malformed`; that
permits a kind §4 does not define, or in which any bound is 0, `policy_malformed`.
Every bound this extension gives a number for is one of these defaults, and a room
**MAY** set any other. A room authority's policy is the default with the lifecycle kinds
also permitted.
## 6. Verification
A verifier verifies one room's records, against the room id it is given. It is offered
records one at a time, each principal's in stream order. A record it refuses changes
nothing it holds.
### 6.1 One record
In this order; the first check that fails names the refusal:
1. The record reads (§3.3): `truncated`, `malformed` or `unknown_version`.
2. Its `kind` is one §4 defines: otherwise `unknown_kind`.
3. Its `room` is the verifier's room: otherwise `wrong_room`.
4. Its signature verifies (§3.2): otherwise `bad_key` or `bad_signature`.
5. Its principal's policy permits its kind: otherwise `kind_not_permitted`.
6. `payload_len` is at most the policy's `max_payload`: otherwise `payload_too_large`.
7. Its payload reads (§4), with the policy's `max_text_cp`: otherwise `malformed_payload`,
`bad_utf8` or `text_too_long`.
### 6.2 A principal's chain
For each principal the verifier keeps the digests of the events it holds, by `seq`, and
the chain's head. For a record that passed §6.1:
1. If the verifier holds an event with this `seq` and this digest, the record is a
**duplicate**: it is ignored, counted nowhere, and checked no further. The records a
group repeats at its start (§8.2) are duplicates to a subscriber that holds them.
2. With a head:
- `seq` at or below the head's: `seq_replay` when an event with this `seq` is held (a
second, different event claims a `seq` already used), and `seq_not_increasing`
otherwise (it arrives behind the head);
- `seq` one above the head's: `prev` **MUST** be the head's digest, otherwise
`chain_broken`;
- `seq` more than one above the head's: the seqs between are a gap;
- in each of the last two cases, `t` **MUST NOT** be below the head's `t`, otherwise
`time_reversed`.
3. Without a head, the first record of the principal the verifier accepts: when `seq` is
0, `prev` **MUST** be 32 zero bytes, otherwise `chain_broken`; when `seq` is above 0,
seqs 0 to `seq − 1` are a gap.
Then §6.3. An event accepted becomes the head, and a gap it opens is recorded.
A gap is a **detectable delivery gap**, reported with the code `delivery_gap`. The
verifier knows exactly which seqs it does not hold, and that the events it does hold are
the principal's, in order. A gap is not a refusal, and a verifier **MUST NOT** report it as
corruption. A receiver **MAY** fill a gap by fetching the events it lacks (MOQT FETCH of
earlier groups, or a recording) and verifying the chain again from its start. It **MUST
NOT** splice an event behind its head into a chain it has moved past: that record is
refused `seq_not_increasing`, by step 2.
### 6.3 Rates
Over the events of the principal the verifier has accepted (a duplicate or a refused
record is not counted):
1. Counting this event, more than `max_events` events whose `t` is less than `window_us`
before this event's `t`: `rate_exceeded`.
2. For a standalone reaction (§4.5), counting it, more than `max_reactions` standalone
reactions whose `t` is less than `reaction_window_us` before its `t`:
`rate_exceeded`.
An earlier event at `t_f` is within a window `w` of an event at `t` when `t − t_f < w`:
two events exactly one window apart do not share one. A verifier with a gap counts only
what it holds; one that holds the whole chain checks the rate exactly.
### 6.4 Outcomes
Verifying a sequence of records gives one outcome:
- **refuse**: the reason and the index of the first record refused. The verifier stops
there.
- **gap**: no record was refused, and the verifier does not hold every seq of every chain
it saw. It states each gap as the principal and the first and last seq it lacks, in the
order found, under the code `delivery_gap`.
- **accept**: no record was refused, there is no gap, and every stated tally equals the
replay (§7). A stated tally that does not is **refuse**, `tally_mismatch`, at the index
of its `poll_close`.
## 7. Polls replay as feed state
A poll's tally is a record of `wai.feed.state` cells ([state-feeds](state-feeds.md)): the
state an op log of `wai.world.sim.feed0` replays to. A tally is therefore
replay-equivalent, and a tally an author states is checkable.
Over the events a verifier accepted:
- A **poll** is a `poll_open`. Its **close** is the `poll_close` naming it with the lowest
`seq` among those its author wrote. A `poll_close` by any other principal closes
nothing.
- A `vote` **counts** when the verifier holds its poll, its `option` is below the poll's
`n`, its `t` is at or after the `poll_open`'s `t`, and the poll has no close or the
vote's `t` is before the close's `t`.
- A principal's vote in a poll is its last vote that counts, by `seq`; an earlier one is
replaced.
The feed: each poll is the node `mint_node_id(author, seq)` of its `poll_open`
([interactive-worlds](interactive-worlds.md) §5.4), whose record is the `Int` cells
`state, count_0, …, count_{n−1}`. Its ops are:
- `CREATE` with `Int(1)` and `n` cells `Int(0)`, the actor the poll's author and the
Lamport counter the `poll_open`'s `seq`;
- `ADD_INT(1 + option, 1)` for each principal's vote;
- `SET_CELL(0, Int(0))` when the poll has a close.
Every verifier derives these ops from signed events; they are not sent and not signed. A
vote one principal changes is, equally, `ADD_INT −1` on the old option and `+1` on the new,
and the replay reaches the same state. The tally's state hash is feed0's,
`BLAKE3("wai:world-state\x01" ‖ canonical state bytes)` ([state-feeds](state-feeds.md)
§4), over every poll the verifier holds.
A `poll_close`'s counts are its author's statement. A verifier that holds every chain
whole (no gap) checks each close: its `n` equals the poll's, and each count equals the
votes for that option. Otherwise it refuses with `tally_mismatch`, at the lowest index of
such a close. With a gap a verifier does not make this check: a tally it computes is
unchecked.
## 8. Bindings
Field semantics only. Each binding says how the record is carried, what it derives from
the record, its stream order (§6), and how a reader refuses what it cannot read
(`binding_malformed`) or what disagrees with the record it carries (`binding_mismatch`).
The record a reader takes from a binding is then verified by §6.
### 8.1 MoQ: an MSF event timeline
- Each principal publishes its events on a track of its own, as
`draft-jennings-moq-mocha-chat-00` §3.3 has each device publish on its own track. A
chain and a track are then one unit, and a relay fans tracks out without merging them.
The room extension names the tracks.
- The track is an MSF event timeline (MSF §8). Its catalog entry has `packaging`
`"eventtimeline"` (MSF §5.2.4), `eventType` `"urn:wai:event:1"` (MSF §5.2.5), `mimetype`
`"application/json"`, and `depends` naming the tracks its anchors name (MSF §8.2).
- A **record** of the timeline (MSF §8.1) has one index member and `data`:
- an anchored event whose `group` and `object` are each at most 2^53 − 1 has
`"l": [group, object]`, the MOQT Location of the anchored object;
- any other event has `"t"`: `⌊(t0 + t) / 1000⌋`, the wallclock in milliseconds since
1970 (MSF §7.1.1), which **MUST** be at most 2^53 − 1;
- `data` is `{"wev1": "<base64url>"}`, the record in base64url without padding
(RFC 4648 §5).
It is written `{"t":<t>,"data":{"wev1":"<b64>"}}` or
`{"l":[<group>,<object>],"data":{"wev1":"<b64>"}}`, with no whitespace. This binding
never writes `m`: an event has no media timestamp of its own, and an anchor names its
object exactly. An events track uses `t` and `l` record by record, as
`draft-wilaw-moq-scte35-event-timeline-00` §4.2 also does; MSF §8.1's preference for
one index type is a **SHOULD**.
- A reader parses the record as JSON and ignores members this binding does not define.
It refuses as `binding_malformed`: anything but a JSON object; no string `data.wev1`;
one that is not the canonical base64url of its bytes (the RFC 4648 §5 alphabet, no `=`,
and the encoding of what it decodes to); bytes that §3.3 does not read as a record of a
kind §4 defines; and a record with other than exactly one of `t`, `l` and `m`. It refuses
as `binding_mismatch` an index other than the one this binding writes for the event:
`t` a JSON integer equal to the event's, or `l` an array of exactly two integers equal
to its anchor's.
- **Groups.** An event's group is `⌊t / 60 000 000⌋`, the minute of room clock it falls
in, as `draft-jennings-moq-mocha-chat-00` §3.4 groups messages by minute. Group IDs
increase with `t`, since `t` never decreases along a chain. Object 0 of a group is the
independent object of MSF §8.3: a JSON array of the join set (§8.2) at the group's
start. Each event of the group is then the next object, an array of its one record, an
incremental update (MSF §8.3). MSF §8.3 has the independent object hold every record
"accumulated and accessible" so far. For an events track the records accessible at a
group boundary are its join set; earlier records stay reachable by FETCH of earlier
groups.
- **Stream order** is Location order: by group, then by object, and the records of one
object in the order written.
- **Cues.** A publisher **MAY** also publish each cue of form 1 on a timeline of
`draft-wilaw-moq-scte35-event-timeline-00`, its `scte35_payload` the base64 (RFC 4648
§4) of the cue's section and its index chosen by that draft's §4.2, so that a consumer
of SCTE-35 alone reads it unchanged. That draft registers the event type
`urn:scte:scte35:2022:bin`, and MSF §14.2's initial table spells it
`urn:scte:scte35:2013:bin`; this revision takes no position between the two.
- **Captions.** A publisher **MAY** also publish each caption as a cue of
`draft-wilaw-moq-webvtt-msf-00` on a timeline of its own. This revision defines no
mapping between the two.
- **Registration.** WAI requests `urn:wai:event:1` in the "MSF Event Timeline Types"
registry (MSF §14.2, Expert Review). The URN is the form of that registry's entries,
and it is also this extension's `emsg` scheme (§8.4). Until it is registered it is a
custom type in MSF §10.5's sense.
### 8.2 Joining mid-stream
The **join set** of a principal's track at the start of group *g* (room clock *g* × 60 s)
is drawn from the principal's events whose `t` is before that instant. In `seq` order it
holds:
- every `poll_open` whose poll has no `poll_close` of its author before the instant
(**MUST**);
- in each of those polls, the principal's last vote (**MUST**);
- every `caption` with a `duration_ms` above 0 whose interval, `t` to
`t + duration_ms`, contains the instant (**MUST**);
- every `cue` of type out or break with no later `in` of the same `cue_id` (**MUST**);
- any other event (**MAY**).
The records are repeated byte for byte, so each is a duplicate (§6.2 step 1) to a
subscriber that holds it and the state a joining subscriber starts from. This is how
`draft-wilaw-moq-webvtt-msf-00` §5 has the first object of each group carry the cues
accumulated so far. A subscriber that joins verifies from the first record it holds and
reports what it lacks as gaps (§6.2), so it knows a tally it computes is unchecked. It
learns a final tally from the poll author's `poll_close`, or checks one by fetching the
voters' earlier groups.
### 8.3 LL-HLS: `EXT-X-DATERANGE`
An event in a Media Playlist is one `EXT-X-DATERANGE` tag (RFC 8216 §4.3.2.7, which
low-latency HLS keeps). Its attributes are written in this order, separated by commas with
no spaces:
| attribute | when | value |
|---|---|---|
| `ID` | always | quoted: the event's digest, 64 lowercase hexadecimal digits |
| `CLASS` | always | quoted: `science.transaction.wai.event` |
| `START-DATE` | always | quoted: `t0 + t` as `YYYY-MM-DDTHH:MM:SS.mmmZ`, in UTC, the milliseconds floored, always three digits after the point |
| `DURATION` | a cue of type in or chapter, or a caption, with `duration_ms` above 0 | `duration_ms` as decimal seconds with three digits after the point (`30.000`) |
| `PLANNED-DURATION` | a cue of type out or break with `duration_ms` above 0 | as `DURATION` |
| `SCTE35-OUT`, `SCTE35-IN`, `SCTE35-CMD` | a cue of form 1 | `0x` and the section in uppercase hexadecimal: `SCTE35-OUT` for out and break, `SCTE35-IN` for in, `SCTE35-CMD` for chapter (RFC 8216 §4.3.2.7.1) |
| `X-WAI-EVENT` | always | quoted: the record in base64url without padding |
- RFC 8216 §4.3.2.7.1 represents a splice out/in pair with tags that share one `ID`. Here
each tag carries an event of its own under its own `ID`, because two tags of a playlist
that share an `ID` must agree on every attribute both carry, and their `X-WAI-EVENT`
values differ. `cue_id` ties the pair.
- RFC 8216 asks a client attribute's name to follow reverse-DNS form. `X-WAI-EVENT`
appears only under its `CLASS`, whose semantics every date range of that class follows
(RFC 8216 §4.3.2.7).
- A playlist that carries one of these tags **MUST** carry `EXT-X-PROGRAM-DATE-TIME`
(RFC 8216 §4.3.2.7), on the same UTC timeline as `t0`.
- **Stream order** is the order of the tags in the playlist, across its updates.
- A reader takes the tag as `#EXT-X-DATERANGE:` and an attribute list (RFC 8216 §4.2):
attribute names of `A`–`Z`, `0`–`9` and `-`, each at most once, and a quoted value that
runs to the next quote. It refuses as `binding_malformed`: another tag; an attribute list
that does not read; no `X-WAI-EVENT`, one not quoted, or one that is not canonical
base64url (§8.1); and bytes that §3.3 does not read as a record of a kind §4 defines. It
then renders the tag for that record and refuses as `binding_mismatch` unless each
attribute of the table above is, in the tag and in the rendering, either absent from
both or present in both with the same value as written. Attributes the table does not
name are ignored, and their order is not compared.
### 8.4 CMAF and DASH: `emsg` version 1
An event is one Event Message box, version 1 (ISO/IEC 23009-1, the syntax AOM id3-emsg
v1.0.0 §2.2.2 reproduces). Integers are big-endian:
| field | value |
|---|---|
| `size` | the box's length in bytes |
| `type` | `emsg` |
| `version`, `flags` | 1, 0 |
| `timescale` | 1000 |
| `presentation_time` | u64: `pt0 + ⌊t / 1000⌋`, where `pt0` is the presentation time of `t0` in milliseconds on the track's timeline |
| `event_duration` | a cue or a caption: `duration_ms`, or 0xFFFFFFFF (not known) when it is 0; any other kind: 0 |
| `id` | the first four bytes of the event's digest, as a u32 |
| `scheme_id_uri` | `urn:wai:event:1`, then a zero byte |
| `value` | the kind's name (§4), then a zero byte |
| `message_data` | the record |
- `id` lets a player drop a box it has seen. A receiver identifies an event by its digest
(§6.2), since four bytes of a digest can repeat.
- **Stream order** is presentation order: the order of the boxes in the segments, by
`presentation_time`, and within one time in the order the segment holds them.
- A reader refuses as `binding_malformed`: a box shorter than 32 bytes, whose `size` is
not its length, whose type is not `emsg` or whose version is not 1; a box without two
strings each ended by a zero byte; and `message_data` that §3.3 does not read as a
record of a kind §4 defines. It then renders the box for that record and refuses as
`binding_mismatch` unless the two are equal byte for byte.
- A publisher whose players surface only ID3 timed metadata **MAY** also carry the record
in ID3 as AOM id3-emsg v1.0.0 has it (scheme `https://aomedia.org/emsg/ID3`). This
revision defines no ID3 frame for it.
### 8.5 WebRTC
A data channel (RFC 8831), reliable and ordered, with binary messages. One message is one
record, whole. **Stream order** is message order. A sink that needs an event's media time
takes it from the anchor or from the room clock; the RTP absolute capture time header
extension, where a sender adds it, is experimental.
### 8.6 Recording
A recording holds each principal's chain whole: its records concatenated in `seq` order,
each record's length following from its own fields (§3.1). **Stream order** is that order.
In a sealed package the events are one component, which
[live-recording](live-recording.md) §3.3 defines. A recording verifies with the outcome accept, or states its gaps.
## 9. Reasons
| reason | raised at |
|---|---|
| `truncated` | §3.3, §5 |
| `malformed` | §3.3 |
| `unknown_version` | §3.3, §5 |
| `unknown_kind` | §6.1 step 2 |
| `wrong_room` | §6.1 step 3 |
| `bad_key` | §3.2, §6.1 step 4 |
| `bad_signature` | §3.2, §6.1 step 4 |
| `kind_not_permitted` | §6.1 step 5 |
| `payload_too_large` | §6.1 step 6 |
| `malformed_payload` | §4, §6.1 step 7 |
| `bad_utf8` | §4.1 |
| `text_too_long` | §4.1 |
| `seq_replay` | §6.2 step 2 |
| `seq_not_increasing` | §6.2 step 2 |
| `chain_broken` | §6.2 steps 2 and 3 |
| `time_reversed` | §6.2 step 2 |
| `delivery_gap` | §6.2, §6.4: a gap, not a refusal |
| `rate_exceeded` | §6.3 |
| `tally_mismatch` | §7 |
| `policy_malformed` | §5 |
| `binding_malformed` | §8.1, §8.3, §8.4 |
| `binding_mismatch` | §8.1, §8.3, §8.4 |
## 10. Conformance
[`live-conformance/events/`](../live-conformance/events/) holds the corpus, in one file,
`vectors.json`, written from fixed seeds by `wai_live_events_vectors gen`. It has:
- **vectors**: a room, the policies its principals are held to, and records in stream
order, with the outcome §6.4 gives them. An accepted vector also states each record's
digest, the tally (§7) and each record's MSF record, `EXT-X-DATERANGE` tag and `emsg`
box (§8); a vector with a gap states its gaps. Every kind has an accepted vector, and
chains of several principals are interleaved in one. Every refusal of §6 has a vector
refused with it, each a control of one defect: a flipped signature bit and three
forgeries that a lenient Ed25519 verifier accepts, a broken chain, a reorder, a replayed
`seq`, `t` going back, an oversize payload, a kind not permitted, both rates exceeded,
invalid UTF-8 and overlong text, truncated and malformed records, an unknown version and
kind, the wrong room, malformed payloads (a chat stating a machine origin without its
model among them) and a stated tally the replay does not reach. Chats and a reaction
stating each origin have an accepted vector.
- **policy cases**: policy records, read (§5) or refused with their reason.
- **binding cases**: tags, boxes and MSF records, read back to their record (§8) or
refused with their reason.
A conforming verifier gives every vector, policy case and binding case the outcome, the
reason, the index, the gaps, the digests, the tally and the renderings the corpus states.
`wai_live_events_vectors verify` and the test `tests/live_events.rs` check that the file
regenerates byte for byte and that `wai-rs` gives every case its outcome.
`live-conformance/live_events_verify.py` is the second implementation, written from this
text: it shares no code with `wai-rs`, verifies signatures with WAI's strict rule written
from RFC 8032 (`claim-conformance/_ed25519.py`) and computes BLAKE3 from its specification
(`claim-conformance/_blake3.py`). It gives every case the same outcome.
## 11. Security and privacy considerations
- Every field of an event is signed. A relay can drop an event but cannot alter one, and
a drop is a gap the receiver detects (§6.2).
- `t` is its author's claim. A room clock authority and per-hop receipts, both of later
extensions, witness when an event was seen; this revision checks only that a chain's
`t` never goes back.
- Two different events with one `seq` are refused `seq_replay`. A receiver that holds
both holds two signatures by one key over one position: evidence the author
equivocated.
- Payloads are untrusted data: an SCTE-35 message, `meta`, a lifecycle `detail` and a
`take_commit` are carried, not interpreted, as
`draft-wilaw-moq-scte35-event-timeline-00` §6.2 has SCTE-35 payloads treated.
- The policy's payload, text and rate bounds limit how much one principal can make every
receiver hold and check.
- A principal's events are linkable to one another, since one key signs them all. A room
that needs participation that cannot be linked across rooms gives principals a key per
room. Events persist in recordings, and a `decision` that redacts an event does not
remove it from the chains that hold it (§4.11).
- Confidentiality is the carrying transport's, and end-to-end encryption of tracks is
[confidentiality](confidentiality.md)'s; this extension signs events and does not
encrypt them.
## Appendix A — Registration request
For the "MSF Event Timeline Types" registry (MSF §14.2):
| Event Type | Description | Specification |
|---|---|---|
| `urn:wai:event:1` | WAI live event records (`WEV1`), one per record, in `data.wev1` | this extension |