wai.live.moderation: review policies, signed decisions, explained redactions, and DSA statements of reasons #337

Merged
dcharlot merged 8 commits from wai/moderation into main 2026-10-19 11:05:15 -04:00
35 changed files with 8261 additions and 40 deletions

View file

@ -91,6 +91,10 @@ on:
# reports, energy_steering_verify.py reads that table, and the registry's wire
# identifiers test reads its sections.
- 'wai/extensions/energy-steering.md'
# tests/live_moderation.rs requires this extension's §10 to list the codes the reference
# reports, live_moderation_verify.py reads its §9.5, §9.6 and §10 tables, and the
# registry's wire identifiers test reads its sections.
- 'wai/extensions/live-moderation.md'
- 'wai/wai-web/**'
# The audio provenance check below reads the corpora above with this tool.
- 'wai/tools/wai_audio_provenance_check.py'
@ -758,8 +762,8 @@ jobs:
# live-rooms corpus), and capture_take_verify.py (the capture-binding corpus), and
# live_compose_verify.py (the live-compose corpus), live_record_verify.py (the
# live-recording corpus), timed_text_verify.py (the timed-text corpus),
# live_metering_verify.py (the live-metering corpus), and energy_steering_verify.py (the
# energy-steering corpus).
# live_metering_verify.py (the live-metering corpus), energy_steering_verify.py (the
# energy-steering corpus), and live_moderation_verify.py (the live-moderation corpus).
# Each exits non-zero on any failure or any difference, and the step fails with it.
# One platform: what they check is the reading of the text, not the architecture.
independent-verifiers:
@ -1030,6 +1034,27 @@ jobs:
(cd wai-rs && cargo test --no-default-features --features energy_steering --lib energy_steering::)
(cd wai-rs && cargo test --no-default-features --features energy_steering --test energy_steering)
python3 -I live-conformance/energy_steering_verify.py live-conformance/steering
# wai.live.moderation (extensions/live-moderation.md): the reference module's unit tests
# (policies, statements and notices round-trip and their rules refuse, the export's
# strings and dates, the content types, the states that show an item) and
# tests/live_moderation.rs (every file of the committed corpus, vectors.json and each
# statement-of-reasons export, regenerates byte for byte from its fixed seeds and no
# other file is there, wai-rs gives every room, export and clip its stated outcome, and
# §10 lists exactly the reference's codes, each of which the corpus gives); then the
# corpus's second implementation, live_moderation_verify.py, written from the
# extension's text and sharing no code with wai-rs, which verifies each room with
# live_rooms_verify.py and each clip with live_record_verify.py, writes every export
# byte for byte, reads the extension's category, content-type and reason tables, and
# requires every mutated copy of the corpus to fail; standard library only, run
# isolated (-I).
- name: live moderation · wai-rs and live_moderation_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features live_moderation --lib live_moderation::)
(cd wai-rs && cargo test --no-default-features --features live_moderation --test live_moderation)
python3 -I live-conformance/live_moderation_verify.py live-conformance/moderation
# The quantum corpus's second implementation (quantum-sim extension), integers only:
# it derives the circuit/1 phase table by its recurrence and the circuit2 table by an
# interval-certified procedure, and requires both literal tables and their digests,

View file

@ -6,6 +6,55 @@ wire format may change and such changes are recorded here.
## v0.4.0 — current
### `wai.live.moderation`: review policies, signed decisions, and statements of reasons
- **The extension** ([live-moderation](extensions/live-moderation.md)) registers the record
`wai.live.moderation`. The room authority signs a **review policy**, `WMP1`, chained to the
one it replaces and in effect from a room-clock instant: which event kinds and media are held
for review before delivery (pre) or reviewed after it (post), the reviewers, each one's
deadline and fallback (allow, deny or hold), the trusted flaggers it recognises, the reason
codes with their ground, provision or clause and database category, the territory, and the
redress text. Every policy keeps the room descriptor's review field (live-rooms §3.6).
- **Decisions.** A moderator's verdict is a `decision` event of `wai.live.events`, unchanged,
followed in its chain by a `meta` event carrying the **statement**, `WMS1`: its target (an
event, or a media object such as a recorded chunk named by its leaf), the target's `prev`,
the decision it supersedes, its basis (own initiative, a notice, a complaint, the policy's
fallback), its use of automated means in detection and in the decision, and its ground,
explanation and facts. Decisions on one target form a line by digest; an appeal's outcome
supersedes the decision complained of. The room's clock signs each fallback the policy
applies, at or after the deadline. A decision by a principal without the moderate power is
refused by the room verifier (`kind_not_permitted`).
- **Notices and complaints**, `WMN1`, are signed by whoever submits them: a notice names an
item, its ground and the submitter's statement and contact (Article 16(2)); a complaint
names a decision (Article 20). The contact is never exported.
- **Review and redaction.** Each item's state over time (held, pending, allowed, denied,
redacted, or the fallback at its deadline), what a relay holds and delivers, and what a sink
shows. A restricted item is withheld, never replaced: a gap it leaves in its author's chain
is explained by the moderators' signed statements, walking the chain back through each
withheld event's `prev`; a clip over a redacted chunk keeps its range proof, withholds the
chunk's bytes (`redaction_not_applied` when they are given), and presents the chunk again
once a later decision restores it. A recording package under moderation is specified only.
- **The statement-of-reasons export.** Each restricting decision maps onto one record of the
DSA Transparency Database's statement-of-reasons format (the schema in force from
1 July 2025, which its documentation calls API v2), written byte for byte: every member,
its source in the WAI records, closed lists and length limits, the sixteen categories, the
content types, and every Article 17 element with WAI's field for it. It carries no
principal key, notice contact or content; `source_identity` is never written. A record that
cannot be written is refused `sor_incomplete`, naming the member.
- **Twenty refusal reasons**, from `policy_malformed` to `redaction_not_applied`.
- **Reference and corpus.** `wai-rs` feature `live_moderation` (`src/live_moderation.rs`);
live-recording's clip verifier gains `verify_clip_exempting`, which `verify_clip` calls
exempting nothing, so its corpus is unchanged. `live-conformance/moderation/`, written from
fixed seeds by `wai_live_moderation_vectors`: a pre-moderated chat room whole and as a
subscriber receives it, a notice and a trusted flagger's notice with their decisions, an
appeal that reverses a decision, live-recording's lesson session with a chunk redacted and
then restored, one refused room per reason, an export refused, and clips of the redacted
take; each export a file of its own. Checked by `tests/live_moderation.rs` and by
`live-conformance/live_moderation_verify.py`, a second implementation that shares no code
with `wai-rs`, verifies each room and clip with its corpora's second implementations, writes
every export itself, and requires every mutated copy of the corpus to fail. The registry
lists the record and the magics `WMP1`, `WMS1` and `WMN1`.
### wai-quantum-service: every job's energy, signed with its result
- **What a result carries.** `energy` holds the CPU energy of the job's worker process, from

View file

@ -41,7 +41,8 @@ Every registered string has one kind.
`wai.asset.provenance`, the quantum-operations receipts, the delivery records
`wai.video.manifest` and its siblings; the live records `wai.live.events`,
`wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording`,
`wai.live.metering` and `wai.live.steering`; timed text, `wai.text.timed`).
`wai.live.metering`, `wai.live.steering` and `wai.live.moderation`; timed text,
`wai.text.timed`).
It names no payload format.
A derivation or record string is not a payload capability, so it is not
@ -198,6 +199,7 @@ and a role are fixed once registered.
| `wai.live.compose` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.events` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.metering` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.moderation` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.recording` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.room` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.steering` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
@ -310,7 +312,10 @@ and a role are fixed once registered.
| `WHP1` | magic | defined | http-delivery §6.2 |
| `WJR1` | magic | defined | jwp-receipts §2.7.3 |
| `WMH1` | magic | defined | live-metering §3.1 |
| `WMN1` | magic | defined | live-moderation §5.1 |
| `WMP1` | magic | defined | live-moderation §3.1 |
| `WMR1` | magic | defined | live-metering §4.1 |
| `WMS1` | magic | defined | live-moderation §4.2 |
| `WRD1` | magic | defined | live-rooms §3.1 |
| `WRG1` | magic | defined | live-rooms §4.1 |
| `WRM1` | magic | defined | live-recording §4.1 |

View file

@ -593,8 +593,8 @@ kind:
delivery records `wai.video.manifest`, `wai.video.ssai`, `wai.video.keys`,
`wai.video.package` and `wai.video.channel`; and the live records `wai.live.events`,
`wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording`,
`wai.live.metering` and `wai.live.steering`, and timed text, `wai.text.timed`), which names
no payload format.
`wai.live.metering`, `wai.live.steering` and `wai.live.moderation`, and timed text,
`wai.text.timed`), which names no payload format.
Only payload, companion and refinement capabilities have a media class, a
fidelity tier, a replicate class and a role. A derivation or record string is not a payload
@ -979,6 +979,26 @@ report, its own inputs and the output, which a verifier recomputes. The output i
manifest HLS and DASH players already read, written byte for byte, with the decision's URI in
one member players ignore: [`extensions/energy-steering.md`](extensions/energy-steering.md).
And for the **moderation** of a room — `wai.live.moderation`: the room authority signs a review
policy, `WMP1`, that keeps the room descriptor's review field and refines it: which event kinds
and media are held for review before delivery or reviewed after it, by which reviewers, each
one's deadline, the fallback at the deadline (allow, deny or hold), the trusted flaggers it
recognises, and the reason codes in use, each with its ground (illegal content, or
incompatible with the terms), the provision or clause it rests on and its category. A
moderator's verdict is a `decision` event of `wai.live.events`, unchanged, followed by a
`WMS1` statement that names its target (an event, or a media object such as a recorded chunk
by its leaf), the decision it supersedes, its basis (own initiative, a notice, a complaint, or
the policy's fallback), its use of automated means, and its ground, explanation and facts.
Decisions on one target form a line by digest, so an appeal's outcome is a decision too; the
room's clock signs each fallback the policy applies. Anyone can sign a notice, `WMN1`, about
an item, and anyone affected a complaint about a decision. A restricted item is withheld,
never replaced: a gap it leaves in its author's chain is explained by the moderators' signed
statements, and a clip over a redacted chunk keeps its range proof and withholds the chunk's
bytes. Each restricting decision maps, byte for byte, onto one statement of reasons in the
format of the database the European Commission keeps under Regulation (EU) 2022/2065
(Article 24(5)), with no personal data:
[`extensions/live-moderation.md`](extensions/live-moderation.md).
### Payloads of the WAI-native integer codecs
This revision pins a payload format to each WAI-native integer codec whose row named

View file

@ -34,7 +34,8 @@ Out of scope: a transport; the room descriptor, which fixes a room's id and its
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; and how a sink renders any event.
workflow, which [live-moderation](live-moderation.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
@ -252,6 +253,8 @@ provenance assertion, takes the label from it.
`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`

View file

@ -0,0 +1,842 @@
# WAI Extension: Live Moderation
> Status: Draft. Registers the record `wai.live.moderation`: moderation for rooms. The room
> authority signs a **review policy**, `WMP1`: which event kinds and media are held for review
> before delivery or reviewed after it, by whom, the deadline of each, the fallback when no
> decision arrives in time (allow, deny or hold), and the reason codes in use, each with its
> ground. A moderator's verdict is a `decision` event of [live-events](live-events.md) (§4.11
> there), unchanged, followed by a `meta` event whose payload is this extension's
> **statement**, `WMS1`: what kind of thing the decision targets (an event, or a media object
> such as a recorded chunk), the decision it supersedes, its basis (own initiative, a notice,
> a complaint, or the policy's fallback), whether automated means detected the content or took
> the decision, and its ground, explanation and facts. Decisions on one target form a line by
> digest, so a later decision overrides an earlier one and the outcome of an appeal is a
> decision too. Anyone can submit a signed **notice**, `WMN1`, about an event or an object, and
> anyone affected a **complaint** about a decision. A restricted item is withheld, and where it
> is withheld from a chain, a recording or a clip, its absence is proven by the decisions
> that restrict it. Each restricting decision maps, deterministically, onto one record of the
> statement-of-reasons format of the database the European Commission keeps under Regulation
> (EU) 2022/2065. No new transport. The reference implementation is the `live_moderation`
> module of `wai-rs` (feature `live_moderation`); the corpus is
> [`live-conformance/moderation/`](../live-conformance/moderation/), and its second
> implementation is `live-conformance/live_moderation_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
>
> Texts read for this revision, each pinned to the version named:
> Regulation (EU) 2022/2065 (OJ L 277, 27.10.2022, p. 1), Articles 16, 17, 20 and 24(5), as the
> Publications Office serves its only consolidated version, 02022R2065-20221027, with no
> amending act recorded and no English corrigendum; the DSA Transparency Database's "API and
> schema" documentation and its explanation of the statement attributes
> (`transparency.dsa.ec.europa.eu`), for the submission schema in force from 1 July 2025, which
> the documentation's changelog calls API v2 and which is still submitted at `/api/v1/statement`,
> as the database's published source has it at revision `fc3b1fd5` (2026-09-23) of the
> documentation and revision `bf0afeb8` (2026-06-09) of the submission's validation rules; and
> Commission Implementing Regulation (EU) 2024/2835, Annex II, whose categories the database
> uses. An article or paragraph number in this text is the Regulation's unless it says
> otherwise.
## 1. Scope
In scope: the review policy, its signature, its rules and the policy in effect at each
instant (§3); the decision event as this extension reads it, the statement, who may decide,
lines of decisions and fallback decisions (§4); notices and complaints (§5); the items under
review, their states, and what a relay and a sink do with them (§6); verification, with the
gaps that restricted items leave explained (§7); the effect of a restriction on a stream, a
recording and a clip (§8); and the statement-of-reasons export (§9).
Out of scope: a transport; how a moderator or a classifier reaches a verdict; how a notice or
a complaint reaches the room authority; the notification of a submitter or an affected
participant (Article 16(5), Article 17(1), Article 20(5)); the submission of a record to the
database, which is an authenticated HTTP request this extension does not define; and the
restriction of accounts, payments or the provision of a service (Article 17(1)(b) to (d)),
which no WAI record expresses. This extension states what the records carry and how they map
onto the database's format. Which decisions a service takes, and which records it submits, is
not its subject.
Relationship to the core spec: `wai.live.moderation` is a **record** ([SPEC](../SPEC.md) §5):
a signed record a WAI tool emits and verifies. It names no payload format and is not
dispatchable.
Relationship to [live-events](live-events.md): this extension changes nothing in it. Its
`decision` payload (§4.11 there) holds a verdict, a target digest and a reason code and no
more, so the statement rides in the `meta` kind, which live-events leaves to applications
(§4.2 there), and is bound to its decision by the event chain (§4.2 here), as
[timed-text](timed-text.md)'s annotation is bound to its caption. A decision's target is the
digest of an event, as live-events has it, or, where its statement says so, of a media object
(§4.1).
Relationship to [live-rooms](live-rooms.md): the room descriptor's `review` field (§3.6
there) fixes the room's review at its creation; a review policy refines it and never
contradicts it (§3.5). The moderate power (§3.3 there) is the power to decide.
Relationship to [live-recording](live-recording.md): a restriction changes what a
recording package holds and what a clip presents (§8). A clip's inclusion proof does not
change: it is over the chunks' descriptors, which a restriction leaves in place.
## 2. Terms
- **Room**, **room clock**, **principal**, **event**, **chain**, **grant**, **room key**,
**merged order**: as in live-events §2 and live-rooms §2 and §9.2. A room key is the room
authority's key or the clock's.
- **Item**: an event of a decidable kind (§4.1), or a media object (§4.1), that a policy
reviews or a decision targets.
- **Decision**: a `decision` event (live-events §4.11). Its **statement** is the `WMS1`
payload of the `meta` event that follows it in its chain (§4.2).
- **Line** of a target: the decisions on it, the first superseding none and each later one
superseding the one before (§4.5). Its last decision is the **standing** decision.
- **Restricting** verdict: deny or redact. An item whose standing decision restricts it is
**restricted**.
- **Withheld**: not delivered to a subscriber session, not presented by a sink, or, for
redaction, not kept in a recording package (§8).
- **Reviewer**: a principal a policy names to decide on the items it reviews (§3.1).
- **Moderator**: a principal whose grant in effect has the moderate power (live-rooms §3.3),
or a room key.
Integers are little-endian. A digest is SHA-256 (FIPS 180-4), 32 bytes. Ed25519 is RFC 8032's,
verified strictly (SPEC §7 *Signatures*): a key that fails step 1 or 2 is `bad_key`, and a
signature that fails a later step is `bad_signature`. A *text* field is a u16 length and that
many bytes, which are UTF-8 (RFC 3629): no overlong form, no surrogate, nothing above
U+10FFFF.
## 3. The review policy
### 3.1 Layout
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WMP1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the room id (live-rooms §3.2) |
| `prev` | 32 | the digest of the policy this one replaces (§3.4); 32 zero bytes for the first |
| `effective` | 8 | u64: the room-clock instant from which this policy applies; 0 for the first |
| `reviewers` | 1 + 32 × *n* | a u8 count (0 to 16), then each reviewer's Ed25519 key |
| `flaggers` | 1 + 32 × *n* | a u8 count (0 to 16), then each key the authority recognises as a trusted flagger's (§9.3 `source_type`) |
| `entries` | 1 + 10 × *n* | a u8 count (0 to 32), then each entry: `scope` (u8), `code` (u8), `mode` (u8), `deadline_ms` (u32), `fallback` (u8), `fallback_reason` (u16) |
| `reasons` | 1 + … | a u8 count (1 to 64), then each reason code: `code` (u16), `ground` (u8), `category` (u8), `reference` (text), `url` (text) |
| `territory` | 1 + 2 × *n* | a u8 count (1 to 30), then each state's code (§9.3 `territorial_scope`), two ASCII capitals |
| `redress` | text | how an affected participant can seek redress (§9.4, Article 17(3)(f)) |
| `sig` | 64 | §3.2 |
An **entry** says how items of one kind are reviewed:
- `scope` 1: `code` is an event kind (live-events §4), and it must be a decidable kind
(§4.1); `scope` 2: `code` is a media kind (live-rooms §6.1: 1 audio, 2 video, 3 screen),
and the entry reviews every media object of that kind.
- `mode` 1 **pre**: an item is held from delivery until it is decided or its deadline passes;
`mode` 2 **post**: an item is delivered at once and reviewed after.
- `deadline_ms`: how long after the item's `t` a decision is awaited, in milliseconds of
room clock, above 0.
- `fallback`, what applies at the deadline when no decision has come: 1 **allow**, 2
**deny**, 3 **hold** (the item stays withheld until a decision comes).
- `fallback_reason`: the reason code a fallback that denies or holds applies under; 0 when
`fallback` is allow.
A **reason code** is one entry of the room's vocabulary:
- `code`: the value a decision's `reason` (live-events §4.11) carries, above 0;
- `ground`: 1 **illegal content**, 2 **incompatible with the terms** of the service;
- `category`: the database category it maps to, by its number in §9.5;
- `reference`: the legal provision, or the clause of the terms, the code rests on: 1 to 500
code points;
- `url`: empty, or an `https://` URL where the terms or the provision can be read: `https://`
then bytes of `!` to `~` (0x21–0x7E), at most 500 bytes in all.
### 3.2 Digest and signature
The **digest** of a policy is SHA-256 over its record from `magic` through `redress`. The
**signature** is Ed25519 by the room descriptor's `authority` over the 27 bytes
`wai:live-moderation-policy` (ASCII) and 0x01, followed by the bytes the digest covers.
### 3.3 Reading and rules
A reader reads exactly the bytes it is given as one policy, in the order of §3.1, and refuses
at the first of these that holds:
1. Fewer than 4 bytes: `truncated`. A magic other than `WMP1`: `malformed`. No byte after the
magic: `truncated`. A version other than 1: `unknown_version`.
2. The bytes end before a field the layout requires: `truncated`.
3. Bytes after `sig`: `malformed`.
It then holds the policy to these rules, and refuses one that breaks any of them as
`policy_malformed`:
- a count is within its range;
- the reviewers are in strictly ascending byte order, and so are the flaggers;
- the entries are in strictly ascending order of (`scope`, `code`), with `scope` 1 or 2,
`code` as `scope` requires, `mode` 1 or 2, `deadline_ms` above 0, `fallback` 1, 2 or 3,
and `fallback_reason` 0 when `fallback` is 1 and a code of `reasons` otherwise;
- the reason codes are in strictly ascending order of `code`, each above 0, with `ground` 1
or 2, `category` 1 to 16, and `reference` and `url` as §3.1 has them;
- the territory's codes are in strictly ascending order, each one of the thirty of §9.3;
- every text field is UTF-8.
Then, in the steps of §7.2: its `room` is the room id (`wrong_room`); its signature verifies
(`bad_key`, `bad_signature`); its link (§3.4, `policy_unlinked`); and the room's review field
(§3.5, `policy_conflicts_room`).
### 3.4 The policy in effect
A verifier is given a room's policies in a list. The first has `prev` zero and `effective` 0.
Each later one has as `prev` the digest of the one before it in the list, and an `effective`
above that one's. A policy that breaks this is refused `policy_unlinked`. The **policy in
effect** at room-clock instant *t* is the last of the list whose `effective` is at or below
*t*. A room given no policy has none in effect at any instant.
A policy is replaced, never amended: the authority signs a new one, chained to the old, with
the instant it takes effect. Each item is reviewed under the policy in effect at its `t`, and
each decision is checked against the policy in effect at the decision's `t`.
### 3.5 The room's review field
The room descriptor's `review` field (live-rooms §3.6) names the event kinds held for review,
one deadline and one fallback, allow or deny; the room id commits to it, so every principal
that joined the room joined under it. Every policy of the room **MUST** keep it: for each
kind that field names, the policy has an entry of `scope` 1 for that kind, with `mode` 1
(pre), the field's `deadline_ms` and the field's `fallback`. Otherwise `policy_conflicts_room`.
A policy may add entries for other kinds and for media, and name reviewers, flaggers and the
vocabulary, which the field does not hold.
## 4. Decisions
### 4.1 The decision event
A decision is a `decision` event (live-events §4.11): `verdict` (1 allow, 2 deny, 3 redact),
`target` (32 bytes) and `reason` (u16). Its statement (§4.2) says what `target` names:
- an **event**: the digest of an event of the room (live-events §3.2), of a **decidable
kind**: `meta` (other than a statement), `cue`, `chat`, `reaction`, `poll_open`, `vote`,
`poll_close`, `marker` or `caption`. A `control`, a `decision`, a `take_commit` and a
lifecycle event are not decided on: an appeal of a decision is a decision that supersedes
it (§4.5), not one that targets it;
- a **media object**: its digest. A chunk of a take is named by its leaf
([capture-binding](capture-binding.md) §4.2), which binds its index, its capture range, its
length and the digest of its bytes, so the name is the chunk's in its take; any other
object, by the digest its receipt names it with ([jwp-receipts](jwp-receipts.md) §2.1).
A decision without a statement targets an event, supersedes nothing, and is of the
moderator's own initiative.
Verdicts:
- **allow**: the item is shown;
- **deny**: access to the item is disabled: it is withheld from delivery and presentation,
and a recording keeps it, so that a later decision can restore it (§8);
- **redact**: the item is removed: it is withheld as for deny, and a recording does not keep
it (§8.2).
A restriction lasts until a later decision of its line supersedes it. A restriction for a set
time is a decision and, at the time's end, an allow that supersedes it.
### 4.2 The statement
A decision's **statement** is the payload of a `meta` event whose first four bytes are
`WMS1`, by the same principal, at the decision's `seq` + 1 and at the decision's `t`.
| offset | size | field | meaning |
|---|---|---|---|
| 0 | 4 | `magic` | `WMS1` (ASCII) |
| 4 | 1 | `version` | 1 in this revision |
| 5 | 1 | `target_type` | 1 an event, 2 a media object (§4.1) |
| 6 | 32 | `target_prev` | for an event: the target's `prev` (live-events §3.1), so that a chain withholding it stays linked (§7.3); for an object: 32 zero bytes |
| 38 | 32 | `supersedes` | the digest of the decision event this one supersedes (§4.5); 32 zero bytes for none |
| 70 | 1 | `basis` | 1 the moderator's own initiative, 2 a notice, 3 a complaint, 4 the policy's fallback (§4.6) |
| 71 | 32 | `notice` | for basis 2 and 3: the digest of the notice or complaint it answers (§5); otherwise 32 zero bytes |
| 103 | 1 | `automated` | bit 0: the content was detected or identified by automated means; bits 1 and 2: how far the decision was taken by automated means, 0 not, 1 partially, 2 fully; every other bit 0 |
| 104 | 1 | `ground` | 1 illegal content, 2 incompatible with the terms |
| 105 | text | `ground_ref` | the legal provision or the clause of the terms relied on; empty when the reason code's `reference` is the one |
| … | text | `explanation` | why the item is considered illegal, or incompatible, on that ground |
| … | text | `facts` | the facts and circumstances relied on in taking the decision |
### 4.3 Reading a statement
A statement is refused `statement_malformed` when: it ends before a field or holds bytes
after `facts`; its version is not 1; `target_type` is not 1 or 2; `target_type` is 2 and
`target_prev` is not zero; `basis` is not 1 to 4; `notice` is zero for basis 2 or 3, or not
zero for basis 1 or 4; `automated` sets a bit above bit 2, or bits 1 and 2 read 3; `basis` is 4
and `automated`'s bits 1 and 2 do not read 2, or `supersedes` is not zero; `ground` is not 1
or 2; or a text field is not UTF-8.
The `meta` payload is bounded by the principal's policy (live-events §5, `max_payload`),
1024 bytes by default, which bounds the three texts together.
### 4.4 Who decides
A decision is the event of a moderator. The room verifier already holds every decision to
it: a grant whose policy permits `decision` has the moderate power (live-rooms §3.3, §4.3 step
5), and a participant's event is verified under its grant in effect at its `t` (live-rooms
§9.2 step 4). A decision by a principal without the moderate power is therefore refused by the
room, `kind_not_permitted`, before this extension reads it. A room key decides as the
authority, without a grant.
Where the target is an item a policy reviews (§6.1) and the policy in effect at the decision's
`t` names reviewers, the decider is one of them or a room key: otherwise `not_reviewer`.
### 4.5 Lines: superseding by digest
The decisions on one target form one line. The first supersedes none; each later decision
names in `supersedes` the line's last decision before it, so that it overrides it from its own
`t`. A verifier checks each decision, in the merged order, against the line as it stands:
- `supersedes` zero while the target already has a decision: `supersede_gap`;
- `supersedes` naming a decision the verifier does not hold: `supersede_unknown`;
- naming a decision on another target: `supersede_mismatch`;
- naming a decision of this target that is not the line's last (one another decision already
supersedes): `supersede_gap`.
An **appeal** is a complaint (§5) against a decision, and its outcome is a decision of basis
3 that supersedes the decision complained of and names the complaint. It may reverse that
decision (allow after deny) or confirm it.
### 4.6 Fallback decisions
At an item's deadline with no decision, its entry's fallback applies (§6.2) whether or not
any record says so. A room key **SHOULD** sign it, as a **fallback decision** at or after the
deadline: basis 4, `automated` fully, `supersedes` zero, the entry's fallback as its verdict
(1 allow, 2 deny) and the entry's `fallback_reason` as its `reason`. A fallback decision lets a
subscriber that never received the item explain its absence (§7.3), and gives the restriction
a signed record to export (§9). A fallback of hold has no decision: the item waits.
A fallback decision is refused `fallback_not_in_policy` when: its author is not a room key;
its target is not an item reviewed under an entry whose fallback is allow or deny; or its
verdict is not that fallback, or its `reason` not the entry's `fallback_reason`. It is refused
`fallback_early` when its `t` is before the item's deadline. It supersedes nothing (§4.3), so a
target that already has a decision refuses it by its line (§4.5, `supersede_gap`): a decision
by the deadline forestalls the fallback.
## 5. Notices and complaints
### 5.1 Layout
A **notice** (Article 16) says that an item is illegal content, or incompatible with the
terms; a **complaint** (Article 20) contests a decision. Anyone can sign one with a key of
their own; neither needs a grant of the room.
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WMN1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the room id |
| `type` | 1 | 1 a notice, 2 a complaint |
| `target_type` | 1 | a notice: 1 an event, 2 a media object (§4.1); a complaint: 1, the decision event complained of |
| `target` | 32 | the target's digest: the exact location of the information (Article 16(2)(b)) |
| `t` | 8 | u64: the room-clock instant at which it was submitted |
| `ground` | 1 | a notice: 1 illegal content, 2 incompatible with the terms; a complaint: 0 |
| `good_faith` | 1 | 1: the submitter confirms its bona fide belief that what it states is accurate and complete (Article 16(2)(d)); 0: it does not |
| `ground_ref` | text | the provision or clause the submitter cites; may be empty |
| `statement` | text | the submitter's explanation of its reasons (Article 16(2)(a)), or of its complaint |
| `contact` | text | the submitter's name and email address (Article 16(2)(c)); may be empty |
| `submitter` | 32 | the Ed25519 key that signs it |
| `sig` | 64 | §5.2 |
### 5.2 Digest, signature and rules
The **digest** is SHA-256 over the record from `magic` through `submitter`. The **signature**
is Ed25519 by `submitter` over the 27 bytes `wai:live-moderation-notice` (ASCII) and 0x01,
followed by the bytes the digest covers.
A reader reads it as §3.3 steps 1 to 3 read a policy, with `WMN1` for `WMP1`, and refuses as
`notice_malformed` one in which: `type` is not 1 or 2; a notice's `target_type` is not 1 or 2,
or its `ground` not 1 or 2; a complaint's `target_type` is not 1 or its `ground` not 0;
`good_faith` is not 0 or 1; a text field is not UTF-8; or whose digest is an earlier notice's
in the list. Then its `room` is the room id (`wrong_room`), and its signature verifies
(`bad_key`, `bad_signature`).
A decision of basis 2 names a notice (`type` 1) and one of basis 3 a complaint (`type` 2) that
the verifier is given, otherwise `notice_unknown`. A notice's `target` is the decision's
target, and its `target_type` the statement's; a complaint's `target` is the decision the
answering decision supersedes; and its `t` is at or before the decision's `t`. Otherwise
`notice_mismatch`. A complaint may be submitted by anyone the decision affects, the
participant whose item it restricts or the submitter of the notice it answers among them; this
extension does not check who.
`contact` is personal data. No export carries it (§9.7).
## 6. Review
### 6.1 Items
An **item under review** is:
- an event the verifier holds, of a decidable kind (§4.1), whose kind an entry of `scope` 1
of the policy in effect at its `t` names, and whose author is neither a room key nor a
principal whose grant in effect at its `t` has the moderate power; or
- a media object the verifier is given (§7.1), whose media kind an entry of `scope` 2 of the
policy in effect at its `t` names.
Its entry gives its `mode`, its **deadline**, `t` + `deadline_ms` × 1000, and its fallback. An
item that is not under review but that a decision targets is an item too, with no entry.
### 6.2 States
An item's **state** at room-clock instant *x*, from its `t` on, is:
1. when a decision on it has its `t` at or below *x*: the verdict of the last such decision of
its line, **allowed**, **denied** or **redacted**;
2. otherwise, an item with no entry: **shown**;
3. otherwise, before its deadline: **held** in mode pre, **pending** in mode post;
4. otherwise (at or after the deadline, with no decision): **fallback_allow**,
**fallback_deny** or **fallback_hold**, by its entry's fallback.
The fallback applies at the deadline unless a decision other than a fallback decision (§4.6)
has its `t` at or below the deadline; a fallback decision records the fallback, and does not
forestall it.
An item is **visible** in the states shown, pending, allowed and fallback_allow, and withheld
in every other. A decision decides an item from its own `t` whatever came before it, so a
moderator's decision after a fallback overrides the fallback, and an appeal's outcome
overrides the decision it supersedes. A moderator's decision at the deadline itself is a
decision by the deadline, and no fallback applies.
An item's **timeline** is the sequence of its states from its `t`: one entry at its `t`, one
at its deadline when the fallback applies, and one at each decision on it in the merged order.
The entries are in the order of their instants and, at one instant, in that order (the item's,
the fallback's, the decisions'). Each entry is the instant, the state, the decision's digest
where a decision made it, and whether the state is visible. The item's state at an instant is
that of its last entry at or before it.
### 6.3 What a relay does
A relay that delivers a room's tracks under a policy applies it to each subscriber session:
- An item in state **held** is delivered at once to the reviewers (or, with none named, to
every present principal whose grant in effect has the moderate power) and to the room keys,
and is withheld from every other subscriber session. The relay keeps it until its state
becomes visible, when it delivers it to the sessions it withheld it from, or until a
decision denies or redacts it. The deadline is the longest it waits for a decision before
the fallback applies: at the deadline, under allow it delivers the item; under deny it
withholds it for good unless a later decision allows it; under hold it keeps it until a
decision comes, for as long as the session lasts.
- An item in state **pending** is delivered at once. At the deadline with no decision, under
deny or hold the relay stops delivering it, to a session that joins later or fetches it
again.
- A restricted item is withheld from every session but the reviewers' and the room keys',
and a relay **MUST NOT** deliver an event or object in a withheld state to any other
session, live, in a join set (live-events §8.2) or in answer to a FETCH.
- A relay that withholds an event goes on delivering the events after it in its chain, so a
session sees a gap (live-events §6.2). When the event is delivered later, the session fills
the gap as live-events §6.2 has a receiver fill one: by verifying the chain again from its
start. A withheld event that stays restricted leaves a gap, which the decisions that
restrict it explain (§7.3).
- An event a relay withholds from a session is not delivered to it, and its per-hop record
([live-metering](live-metering.md) §3.3) does not count it as delivered there.
A relay that signs fallback decisions (§4.6) does so as the room's clock.
### 6.4 What a sink shows
A sink shows an item at instant *x* only when its state at *x* is visible, by the decisions it
holds. Holding a decision's event and statement, it **SHOULD NOT** show what the decision
restricts (live-events §4.11). A sink that holds a decision but not its target shows nothing
of the target, and states the decision's verdict and reason in its place where it shows the
chain. A sink that labels a restriction for a viewer takes the label from the statement and
the reason code's vocabulary entry, never from the target's content.
## 7. Verification
### 7.1 Inputs
A moderation verifier is given what a room verifier is given (live-rooms §9.1: a descriptor,
grants, a key-log head or none, and the chains), and:
- **policies**: the room's review policies, in a list (§3.4);
- **notices**: notices and complaints, in a list (§5);
- **objects**: the media objects under moderation, each its digest, its media kind (1 audio, 2
video, 3 screen) and its `t`, the room-clock instant it reached the relay. For a take's
chunks, the digest is the chunk's leaf and the `t` that of the commitment that adds it
(capture-binding §5). The objects are the caller's statement, as the policies a
live-events verifier is given are; no two have one digest.
### 7.2 Steps
It runs these steps and stops at the first refusal, which it reports with the reason and
where:
1. **The room** (live-rooms §9): a refusal is the moderation verifier's, at `room` with the
room verifier's own place. A gap is not a refusal: the steps go on, and §7.3 says which
gaps decisions explain.
2. **The policies**, in the list's order (§3.3, §3.4, §3.5): the refusal, at the policy's
index.
3. **The notices**, in the list's order (§5.2): the refusal, at the notice's index.
4. **The statements.** For each `meta` event the room accepted whose payload begins with
`WMS1`, in the merged order: when its `seq` is 0, `statement_misplaced`; when the verifier
holds its principal's event at `seq` − 1, that is a decision at the same `t`, otherwise
`statement_misplaced`; when the verifier does not, the statement is an orphan of a delivery
gap, and is read no further. A statement in place reads (§4.3), otherwise
`statement_malformed`. At the statement's record.
5. **The gaps** (§7.3): which gaps restricted items explain. No refusal.
6. **The decisions**, in the merged order, each by §7.4: the refusal, at the decision's
record.
7. **The outcome** (§7.5).
A **gap remains** when the room has a gap that §7.3 does not explain. With a gap remaining, a
check of §7.4 that fails because the verifier does not hold a decision, an event or a
statement is **unresolved**, which is not a refusal; with none, it is the refusal the check
names.
### 7.3 Explained gaps
A gap of principal *P*, seqs *a* to *b* (live-events §6.2), is **explained** when the
verifier holds *P*'s event at *b* + 1, and walking back from it reaches *P*'s event at *a* − 1
(or the start of the chain, when *a* is 0) through restricted events only:
1. *D* is the `prev` of the event at *b* + 1.
2. For each *s* from *b* down to *a*: of the decisions the room accepted whose target is *D*,
the last in the merged order has a statement of `target_type` 1 and a restricting verdict,
otherwise the gap is not explained; seq *s* is withheld by that decision, and *D* becomes
its statement's `target_prev`.
3. *D* is then the digest of the event at *a* − 1, or 32 zero bytes when *a* is 0; otherwise
the gap is not explained.
Each step is checked from what the verifier holds: the link from the gap to the event after
it is the event's own `prev`, and each link inside the gap is a moderator's signed statement
about an event it decided on, so no event is passed off as withheld that its chain does not
hold at that place. An explained gap is not a delivery gap. A gap that is not explained stays
one, with live-events' code `delivery_gap`.
The events an explained gap withholds are **withheld targets**: the verifier knows their
digests and their places, not their kinds or their times.
### 7.4 Each decision
For a decision *E* of principal *P*, with target *X*, in the merged order, its statement being
*P*'s event at `seq` + 1 when that is a statement in place (step 4), **unknown** when the
verifier does not hold that seq because a gap remains there, and **none** otherwise:
1. **The reason.** A policy is in effect at *E*'s `t`, its vocabulary has *E*'s `reason`, and
a known statement's `ground` is that code's: otherwise `reason_not_in_policy`.
2. **The target.**
- Of type 2: an object the verifier is given has digest *X* and a `t` at or below *E*'s:
otherwise `target_unknown`.
- Of type 1 (or with no statement): when *X* is the digest of an event the room accepted,
that event comes before *E* in the merged order, otherwise `target_unknown`; it is of a
decidable kind, otherwise `target_not_decidable`; and a known statement's `target_prev`
is its `prev`, otherwise `target_prev_mismatch`. When *X* is a withheld target (§7.3),
these hold. Otherwise `target_unknown`, unresolved while a gap remains.
3. **The reviewer** (§4.4): `not_reviewer`. Not made for a withheld target.
4. **The line** (§4.5): `supersede_gap`, `supersede_unknown` (unresolved while a gap remains)
or `supersede_mismatch`. Not made for an unknown statement; with none, `supersedes` is
zero.
5. **The basis**, for a known statement: of basis 2 or 3, §5.2 (`notice_unknown`,
`notice_mismatch`); of basis 4, §4.6 (`fallback_not_in_policy`, `fallback_early`), its
entry's checks not made for a withheld target.
*E* then becomes the last decision of *X*'s line.
### 7.5 Outcomes and what a verification states
- **refuse**: the reason and where (§7.2).
- **gap**: no refusal, and a gap remains: the gaps that remain, under the code `delivery_gap`,
and what **accept** states, computed from what the verifier holds.
- **accept**: no refusal and no gap remaining.
With accept or gap, the verifier states:
- the room's own outcome (live-rooms §9.4), accept or gap;
- each explained gap: its principal, its seqs, and the decision withholding each seq;
- each decision, in the merged order: its digest, its decider, its `t`, its target and target
type, its verdict, reason and basis (none for an unknown statement), what it supersedes, the
notice it answers, whether its statement is known, none or unknown, and whether it stands at
the end;
- each item, events in the merged order and then objects in the order given: its digest, its
kind or media, its `t`, its entry (mode, deadline, fallback) or none, and its timeline
(§6.2);
- each decision's export (§9): the record, none for an allow, or the refusal.
## 8. Redaction in streams, recordings and clips
A restricted item is **withheld**, never replaced, and its absence is **proven**: whatever
held it shows where it was, and the decision that restricts it shows why. A WAI chain, a take
tree and a clip all bind their contents by digest, so an item cannot be swapped for a
stand-in without breaking them; withholding keeps them whole, because each of them names the
item without holding it.
### 8.1 Streams
An event withheld from a subscriber session leaves a gap in its author's chain, and the
decisions that restrict it explain the gap (§7.3): the session knows the event existed, where,
under which decision and why, and holds nothing of it. A media object withheld from a session
is an object the relay does not forward. A restricted object's receipts, which name it by its
digest, stay where they are.
### 8.2 A recording package
A recording sealed after a decision applies it to what it keeps:
- A **redacted** chunk's file is left out of its take component, and a redacted event's
record out of its principal's chain file. The take's manifest still lists the chunk's
descriptor, and the next event of the chain still names the redacted one in its `prev`.
- A **denied** item is kept, so that a later decision can allow it; every presentation of
the recording (a clip, a playlist, a sink) withholds it.
The recording verifier ([live-recording](live-recording.md) §4.4) then finds the take's chunk
missing and the chain's gap, and fails `complete` with `chunk_missing` or `delivery_gap`. A
moderation verifier explains them: a chunk the manifest lists, with no file, whose leaf a
standing redaction names, and a gap §7.3 explains with redactions alone. Where every missing
chunk and every gap is so explained, the recording is complete but for what was redacted.
What this revision implements of this section: nothing beyond §7.3 and §8.3. The reference
explains gaps in a room's chains and verifies clips; it does not verify a package under
moderation, and the corpus has no package vector.
### 8.3 Clips
A clip's chunks are proved against a commitment the device made while it captured, by a range
proof over their descriptors (live-recording §5.3). A restriction leaves the descriptors in
place, so the proof still verifies over every chunk the clip covers; what changes is which
chunks' bytes the clip presents. A chunk is **restricted** when its leaf is the target of a
standing decision that restricts it.
A **clip under moderation** is verified by live-recording §5.4 with two changes:
1. In step (k), a restricted chunk is passed over: its bytes are neither required nor checked,
and bytes given for it are not presented. The part presents the bytes of its other chunks,
in order, less `trim_in` when its first chunk is not restricted and `trim_out` when its last
is not.
2. Then, after every part: a restricted chunk whose bytes are given is
`redaction_not_applied`, at the first such chunk.
A clip that passes is accepted, and states what live-recording §5.4 states, and for each part
the indexes of its restricted chunks, each with the decision that restricts it. A clip whose
restricted chunk is restored by a later allow presents it again, and is refused
`clip_chunk_missing` when its bytes are not given. A clip's inclusion proof thus verifies in
every case, and a clip fails only with a stated reason: one of live-recording §9's, or
`redaction_not_applied`.
## 9. The statement-of-reasons export
### 9.1 The format and its version
A restricting decision maps onto one **statement of reasons** in the form the DSA Transparency
Database accepts at its single-statement endpoint (`POST /api/v1/statement`): the submission
schema in force from 1 July 2025, which the database's documentation calls API v2, read at the
revisions named at the head of this text. The export is the request body. An allow restricts
nothing, and has no export.
### 9.2 The record
The record is one JSON object (RFC 8259) whose members are those of §9.3 that apply, in the
order of §9.3, written with no whitespace. Each string is UTF-8, written with `"` as `\"`,
`\` as `\\`, U+0008, U+0009, U+000A, U+000C and U+000D as `\b`, `\t`, `\n`, `\f` and `\r`, any
other code point below U+0020 as `\u` and four lowercase hexadecimal digits, and every other
code point as itself. There is no byte after the closing brace. A date is `YYYY-MM-DD`, the UTC
date of the instant `t0` + `t` of the room clock, `t0` the descriptor's.
### 9.3 Members and their sources
"Closed" marks a member whose value is one of a list the database fixes; "free" a text the
database accepts up to the length given, in characters (code points).
| member | value | source | form |
|---|---|---|---|
| `decision_visibility` | `["DECISION_VISIBILITY_CONTENT_DISABLED"]` for deny, `["DECISION_VISIBILITY_CONTENT_REMOVED"]` for redact | the decision's `verdict` | closed, an array |
| `decision_facts` | the statement's `facts` | statement | free, 1 to 5000 |
| `decision_ground` | `DECISION_GROUND_ILLEGAL_CONTENT` (1) or `DECISION_GROUND_INCOMPATIBLE_CONTENT` (2) | statement `ground` | closed |
| `decision_ground_reference_url` | the reason code's `url`, only when it is not empty | policy | a URL, up to 500 |
| `illegal_content_legal_ground` | ground 1 only: the statement's `ground_ref`, or the reason code's `reference` when that is empty | statement, policy | free, 1 to 500 |
| `illegal_content_explanation` | ground 1 only: the statement's `explanation` | statement | free, 1 to 2000 |
| `incompatible_content_ground` | ground 2 only: as `illegal_content_legal_ground` | statement, policy | free, 1 to 500 |
| `incompatible_content_explanation` | ground 2 only: as `illegal_content_explanation` | statement | free, 1 to 2000 |
| `content_type` | a one-element array, by the target (§9.6) | the target's kind or media | closed, an array |
| `content_type_other` | only with `CONTENT_TYPE_OTHER`: §9.6's text | the target's kind | free, up to 500 |
| `category` | the reason code's `category`, by §9.5 | policy | closed |
| `territorial_scope` | the policy's `territory`, in its order | policy | closed, an array of the thirty codes `AT`, `BE`, `BG`, `CY`, `CZ`, `DE`, `DK`, `EE`, `ES`, `FI`, `FR`, `GR`, `HR`, `HU`, `IE`, `IS`, `IT`, `LI`, `LT`, `LU`, `LV`, `MT`, `NL`, `NO`, `PL`, `PT`, `RO`, `SE`, `SI`, `SK` |
| `content_date` | the date of the target's `t` (an event's, or an object's as given), from 2000-01-01 to 2038-01-01 | the target | a date |
| `application_date` | the date of the decision's `t`, from 2020-01-01 to 2038-01-01 | the decision | a date |
| `source_type` | basis 1 and 4: `SOURCE_VOLUNTARY`; basis 2: `SOURCE_TRUSTED_FLAGGER` when the notice's ground is 1 and its submitter is one of the policy's `flaggers`, `SOURCE_ARTICLE_16` when its ground is 1 and it is not, `SOURCE_TYPE_OTHER_NOTIFICATION` when its ground is 2; basis 3: the `source_type` of the decision it supersedes | statement, notice, policy | closed |
| `automated_detection` | `Yes` when `automated` bit 0 is set, `No` otherwise | statement | closed |
| `automated_decision` | `AUTOMATED_DECISION_NOT_AUTOMATED` (0), `AUTOMATED_DECISION_PARTIALLY` (1) or `AUTOMATED_DECISION_FULLY` (2), by `automated` bits 1 and 2 | statement | closed |
| `puid` | the decision's digest, 64 lowercase hexadecimal digits | the decision | `[a-zA-Z0-9-_]`, up to 500, unique |
The policy is the one in effect at the decision's `t`. A notice of ground 2 is not a notice
under Article 16, and the database's documentation names `SOURCE_TYPE_OTHER_NOTIFICATION` for
it. A decision on a complaint keeps the source of the restriction it answers. The database's
other members are not written: `decision_monetary`, `decision_provision` and
`decision_account` (no WAI decision restricts payments, the service or an account), their
`_other` texts, `decision_visibility_other`, `account_type`,
`incompatible_content_illegal`, `category_addition`, `category_specification` and
`category_specification_other`, `content_id` (a product number), `content_language`, every
`end_date_*` (a restriction lasts until a decision supersedes it, which the database reads as
indefinite when the date is left out), and `source_identity` (§9.7).
### 9.4 Article 17, element by element
| Article 17 | what it asks for | WAI's field | the member |
|---|---|---|---|
| (1)(a) | a restriction of the visibility of specific items | `verdict` deny or redact | `decision_visibility` |
| (1)(b)–(d) | payments, the service, the account | none | not written |
| (3)(a) | removal, disabling, demotion or restriction; territorial scope; duration | `verdict`; policy `territory`; the line (a restriction lasts until superseded) | `decision_visibility`; `territorial_scope`; no end date |
| (3)(b) | facts and circumstances; whether on a notice or on own initiative; the notifier's identity where strictly necessary | statement `facts`; `basis` and the notice; none | `decision_facts`; `source_type`; not written |
| (3)(c) | use of automated means, in the decision and in detection | statement `automated` | `automated_decision`, `automated_detection` |
| (3)(d) | for illegal content: the legal ground and why the item is illegal on it | statement `ground` 1, `ground_ref` or the code's `reference`, `explanation` | `decision_ground`, `illegal_content_legal_ground`, `illegal_content_explanation` |
| (3)(e) | for incompatibility: the contractual ground and why the item is incompatible with it | statement `ground` 2, `ground_ref` or the code's `reference`, `explanation`; the code's `url` | `decision_ground`, `incompatible_content_ground`, `incompatible_content_explanation`, `decision_ground_reference_url` |
| (3)(f) | the possibilities for redress | policy `redress`; a complaint (§5) and its decision (§4.5) | none: the database has no member for it; it is the statement to the affected participant's |
| the category of the decision | (the database's, from Implementing Regulation (EU) 2024/2835 Annex II) | the reason code's `category` | `category` |
| the item's kind and date | (the database's) | the target | `content_type`, `content_date` |
| the decision's date and identifier | (the database's) | the decision's `t` and digest | `application_date`, `puid` |
### 9.5 Categories
| number | `category` |
|---|---|
| 1 | `STATEMENT_CATEGORY_ANIMAL_WELFARE` |
| 2 | `STATEMENT_CATEGORY_CONSUMER_INFORMATION` |
| 3 | `STATEMENT_CATEGORY_CYBER_VIOLENCE` |
| 4 | `STATEMENT_CATEGORY_CYBER_VIOLENCE_AGAINST_WOMEN` |
| 5 | `STATEMENT_CATEGORY_DATA_PROTECTION_AND_PRIVACY_VIOLATIONS` |
| 6 | `STATEMENT_CATEGORY_ILLEGAL_OR_HARMFUL_SPEECH` |
| 7 | `STATEMENT_CATEGORY_INTELLECTUAL_PROPERTY_INFRINGEMENTS` |
| 8 | `STATEMENT_CATEGORY_NEGATIVE_EFFECTS_ON_CIVIC_DISCOURSE_OR_ELECTIONS` |
| 9 | `STATEMENT_CATEGORY_NOT_SPECIFIED_NOTICE` |
| 10 | `STATEMENT_CATEGORY_OTHER_VIOLATION_TC` |
| 11 | `STATEMENT_CATEGORY_PROTECTION_OF_MINORS` |
| 12 | `STATEMENT_CATEGORY_RISK_FOR_PUBLIC_SECURITY` |
| 13 | `STATEMENT_CATEGORY_SCAMS_AND_FRAUD` |
| 14 | `STATEMENT_CATEGORY_SELF_HARM` |
| 15 | `STATEMENT_CATEGORY_UNSAFE_AND_PROHIBITED_PRODUCTS` |
| 16 | `STATEMENT_CATEGORY_VIOLENCE` |
These are the sixteen values the database accepts in this version, in its order. Category 9
is for a notice that does not specify the kind of illegal content (Implementing Regulation
(EU) 2024/2835, Annex II).
### 9.6 Content types
| target | `content_type` | `content_type_other` |
|---|---|---|
| `chat`, `caption`, `poll_open`, `marker` | `CONTENT_TYPE_TEXT` | |
| `reaction` | `CONTENT_TYPE_OTHER` | `reaction` |
| `vote` | `CONTENT_TYPE_OTHER` | `poll vote` |
| `poll_close` | `CONTENT_TYPE_OTHER` | `poll tally` |
| `meta` | `CONTENT_TYPE_OTHER` | `timed metadata` |
| `cue` | `CONTENT_TYPE_OTHER` | `cue` |
| an object of media 1 (audio) | `CONTENT_TYPE_AUDIO` | |
| an object of media 2 (video) or 3 (screen) | `CONTENT_TYPE_VIDEO` | |
### 9.7 Personal data
Article 24(5) has the information submitted to the database contain no personal data. The
export writes nothing from a principal's key, a notice's or a complaint's `contact`,
`statement` or `submitter`, or the content of the item: its members come from the decision's
verdict, `t` and digest, the target's kind and `t`, the policy's vocabulary and territory, and
the statement's three texts. It never writes `source_identity`, which the database
documentation reserves for the notifier's identity where strictly necessary. The statement's
texts are copied as their author wrote them; whether they hold personal data is a question
about their content that no verifier can answer, and they are written knowing that they are
published.
### 9.8 When no record can be written
The record is written from the members in the order of §9.3. The first member that cannot be
written as §9.3 has it names the refusal, `sor_incomplete`, stated with that member's name:
- the decision has no statement, or an unknown one: `decision_facts` (and every member drawn
from the statement after it);
- a text that is empty, or longer than the member allows: that member;
- the target is a withheld target or an unresolved one, so its kind and `t` are not held:
`content_type`;
- a date outside the range the member allows: that member;
- a decision of basis 3 whose superseded decision's source cannot be written: `source_type`.
## 10. Reasons
The codes this extension adds, each with where it is raised:
| reason | raised at |
|---|---|
| `policy_malformed` | §3.3 |
| `policy_unlinked` | §3.4 |
| `policy_conflicts_room` | §3.5 |
| `notice_malformed` | §5.2 |
| `statement_misplaced` | §7.2 step 4 |
| `statement_malformed` | §4.3, §7.2 step 4 |
| `reason_not_in_policy` | §7.4 step 1 |
| `target_unknown` | §7.4 step 2 |
| `target_not_decidable` | §4.1, §7.4 step 2 |
| `target_prev_mismatch` | §7.4 step 2 |
| `not_reviewer` | §4.4, §7.4 step 3 |
| `supersede_gap` | §4.5, §7.4 step 4 |
| `supersede_unknown` | §4.5, §7.4 step 4 |
| `supersede_mismatch` | §4.5, §7.4 step 4 |
| `notice_unknown` | §5.2, §7.4 step 5 |
| `notice_mismatch` | §5.2, §7.4 step 5 |
| `fallback_not_in_policy` | §4.6, §7.4 step 5 |
| `fallback_early` | §4.6, §7.4 step 5 |
| `sor_incomplete` | §9.8, with the member |
| `redaction_not_applied` | §8.3 |
A moderation verifier also raises every code a room verifier raises (live-rooms §11 and the
live-events codes it lists), at the room: `kind_not_permitted` among them for a decision by a
principal without the moderate power (§4.4); `truncated`, `malformed` and `unknown_version`
for a policy or a notice that does not read; `wrong_room`, `bad_key` and `bad_signature` for a
policy or a notice of another room or whose signature fails; and `delivery_gap` for a gap that
remains, which is not a refusal. A clip under moderation raises every code a clip verifier
raises (live-recording §9), and `redaction_not_applied`.
## 11. Conformance
[`live-conformance/moderation/`](../live-conformance/moderation/) holds the corpus, written
from fixed seeds by `wai_live_moderation_vectors gen`: `vectors.json`, and each export as a
file of its own, `NAME.dK.sor.json`, *K* the decision's position among the vector's
decisions. It has:
- **Rooms**, each a room (descriptor, grants, key-log head or none, chains), its policies,
notices and objects, and the outcome §7.5 gives them with everything it states:
- a chat room under a pre-moderation policy, replaced partway by a second policy that also
reviews reactions after delivery: one message allowed before its deadline, one denied, one
past its deadline taking the fallback, which the clock signs; a reaction reviewed after
delivery whose fallback holds it;
- the same room as a subscriber that is not a reviewer receives it, the denied message and
the fallback's withheld, the gap they leave explained by the decisions;
- a notice and its decision, and a trusted flagger's notice and its redaction;
- an appeal: a complaint against a decision taken with automated means, and the decision
that reverses it;
- a room whose recorded take has a chunk redacted, and the same room after an appeal
restores it;
- one refused room for each reason of §10 a room raises, each a control of one defect, and a
decision by a principal without the moderate power, which the room refuses
`kind_not_permitted`;
- a room with a gap that no decision explains.
- **Exports**: every decision's record, byte for byte, none for an allow, and one refused
`sor_incomplete`.
- **Clips** under moderation over the redacted take: one that withholds the redacted chunk,
one cut by bytes whose first chunk is the redacted one, one that presents the redacted
chunk's bytes (`redaction_not_applied`), and, after the appeal, one that leaves the restored
chunk out (`clip_chunk_missing`) and one that presents it.
A conforming verifier gives every room, export and clip the outcome, the reason, the place and
everything else the corpus states. `wai_live_moderation_vectors verify` and the test
`tests/live_moderation.rs` check that every file regenerates byte for byte, that `wai-rs` gives
every case its outcome, that every reason of §10 appears, and that §10 lists exactly the
reference's codes. `live-conformance/live_moderation_verify.py` is the second implementation,
written from this text: it shares no code with `wai-rs`; it verifies each room with the
live-rooms corpus's second implementation (`live_rooms_verify.py`), each clip with the
live-recording corpus's (`live_record_verify.py`), and every signature with WAI's strict rule
(`claim-conformance/_ed25519.py`); it writes every export from §9 and requires its bytes; and
it mutates copies of the corpus and requires every copy to fail.
## 12. Security and privacy considerations
- Every decision is signed by its moderator, every statement rides in the moderator's chain,
every policy is signed by the room authority, and every notice by its submitter. A relay can
withhold any of them but alter none, and a withheld decision is a gap in its moderator's
chain.
- A relay holds the power to withhold. A subscriber checks it: an event withheld with no
restricting decision behind it is a delivery gap that §7.3 does not explain, which the
subscriber sees and can report. A relay that delivers a restricted item to a session is
caught by the decision the session also holds.
- Explaining a gap needs each restricted event's `prev`, which its moderator states. A
moderator that states a false one makes the gap unexplained, never a forged event accepted:
the chain still has to reach the event before the gap.
- Withholding is not erasure. A denied item stays in the recording, and a redacted one stays
wherever a copy was delivered before the decision; a redaction stops what WAI's relays,
recordings and clips deliver from then on.
- A decision names its target by digest only, and its statement holds no content. A notice
holds its submitter's contact, which is personal data: a verifier is given notices to check
decisions against them, and no export or derived record carries a notice's texts or its key.
- The statement's texts are published with the decision and with its export. Whoever writes
them writes them for a public record.
- Fallback decisions are signed by the room's clock key, which is the relay's; a policy limits
them to the fallback it names, at or after the deadline it names, so a relay cannot restrict
an item for a reason the policy does not hold.
- The `automated` field is its moderator's statement. Nothing in a record shows how a verdict
was reached; the field states it, and an export carries it.

View file

@ -475,6 +475,9 @@ take's offset *o*, its span, the window on its device's clock (§6.1), the lengt
of the bytes it presents, and its FETCH address (§8). Where the take's `single-take` holds, an
accepted clip's descriptors are its manifest's at those indexes, since rule 5 binds the
manifest's chunks to every commitment's root; the clip verifier does not need the manifest.
A clip over chunks a moderation decision restricts is verified as
[live-moderation](live-moderation.md) §8.3 has it: step (k) passes over the restricted
chunks, whose descriptors the proof still covers.
### 5.5 Cutting a clip

View file

@ -34,7 +34,8 @@ of possession every binding requires (§10).
Out of scope: a transport; how a principal obtains a grant; composition, which reads the
grant's attributes ([live-compose](live-compose.md)); the review workflow, of which this extension
fixes only the policy's fields and their meaning (§3.6); and what a recording holds,
fixes only the policy's fields and their meaning (§3.6), and which
[live-moderation](live-moderation.md) defines; and what a recording holds,
which the recording policy constrains (§3.7) and later extensions define: a device's
master recording is [capture-binding](capture-binding.md)'s take.
@ -165,8 +166,8 @@ a `decision` on it (live-events §4.11), or once `deadline_ms` milliseconds of r
have passed after its `t` with none, when the `fallback` verdict applies: 1 allow, 2 deny.
With no reviewed kind, `deadline_ms` and `fallback` are 0; with one, the kinds are within
the ceiling's, `deadline_ms` is above 0, and `fallback` is 1 or 2. This revision fixes the
policy's fields and their meaning; the review workflow and its verification are a later
extension's.
policy's fields and their meaning; the review workflow and its verification are
[live-moderation](live-moderation.md)'s, whose review policy keeps this field (§3.5 there).
### 3.7 The recording policy

View file

@ -13,15 +13,17 @@ and the independent verifier of each, beside it.
| [`captions/`](captions/) | [timed-text](../extensions/timed-text.md) (`wai.text.timed`) | `timed_text_verify.py` |
| [`metering/`](metering/) | [live-metering](../extensions/live-metering.md) (`wai.live.metering`) | `live_metering_verify.py` |
| [`steering/`](steering/) | [energy-steering](../extensions/energy-steering.md) (`wai.live.steering`) | `energy_steering_verify.py` |
| [`moderation/`](moderation/) | [live-moderation](../extensions/live-moderation.md) (`wai.live.moderation`) | `live_moderation_verify.py` |
**Provenance.** Generated here: `wai_live_events_vectors gen` writes `events/`,
`wai_live_room_vectors gen` writes `rooms/`, `wai_capture_take_vectors gen` writes
`takes/`, `wai_live_compose_vectors gen` writes `compose/`, `wai_live_record_vectors gen`
writes `recordings/`, `wai_timed_text_vectors gen` writes `captions/` and
`wai_live_metering_vectors gen` writes `metering/` and `wai_energy_steering_vectors gen` writes
`steering/`, each from records authored in code, from fixed seeds (`events/README.md`,
`rooms/README.md`, `takes/README.md`, `compose/README.md`, `recordings/README.md`,
`captions/README.md`, `metering/README.md`, `steering/README.md`). One recorded record:
`wai_live_metering_vectors gen` writes `metering/`, `wai_energy_steering_vectors gen` writes
`steering/` and `wai_live_moderation_vectors gen` writes `moderation/`, each from records
authored in code, from fixed seeds (`events/README.md`, `rooms/README.md`, `takes/README.md`,
`compose/README.md`, `recordings/README.md`, `captions/README.md`, `metering/README.md`,
`steering/README.md`, `moderation/README.md`). One recorded record:
`metering/relay-record.wmh` and its ledger, which the reference relay signed in its test.
Each verifier is written from its extension's text, shares no code with `wai-rs`, and is
@ -36,4 +38,5 @@ python3 live_record_verify.py recordings
python3 timed_text_verify.py captions
python3 live_metering_verify.py metering
python3 energy_steering_verify.py steering
python3 live_moderation_verify.py moderation
```

View file

@ -0,0 +1,810 @@
#!/usr/bin/env python3
"""Independent verifier of the live-moderation corpus (extensions/live-moderation.md).
Written from the extension's text. It shares no code with wai-rs, which generates the corpus,
and never runs it. It checks Ed25519 signatures with ../claim-conformance/_ed25519.py, WAI's
strict rule (SPEC §7 *Signatures*) written from RFC 8032 with the standard library. Each room
is verified by the live-rooms corpus's own second implementation, live_rooms_verify.py
(live-rooms §9), and each clip by the live-recording corpus's, live_record_verify.py
(live-recording §5.4), each written from its text alone. Everything else is here:
- the review policy (§3): its layout, rules, signature, chain and the room's review field;
- the statement (§4.2, §4.3), lines of decisions (§4.5) and fallback decisions (§4.6);
- notices and complaints (§5);
- items under review and their timelines (§6);
- verification, with the gaps restricted events leave explained (§7);
- clips under moderation (§8.3);
- the statement-of-reasons export (§9), written byte for byte from §9.2 and §9.3, with the
categories and content types of §9.5 and §9.6 read from the extension's own tables.
For every room it requires the outcome, reason and place, and everything else the corpus
states; every export file, byte for byte; for every clip, its outcome; of the corpus, that
every reason of §10 appears in it and that no other file is there. Then it mutates copies of the
corpus, one defect each, and requires every copy to fail.
python3 live_moderation_verify.py <live-conformance/moderation>
Exit status 0 only if every case verifies and every mutated copy fails. Python 3.8 or later;
standard library only.
"""
import copy
import datetime
import hashlib
import json
import os
import re
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 live_events_verify as LEV # noqa: E402
import live_rooms_verify as LRV # noqa: E402
import live_record_verify as LREC # noqa: E402
import _ed25519 as ED # noqa: E402
Refuse = LEV.Refuse
EXTENSION = os.path.join(HERE, "..", "extensions", "live-moderation.md")
POLICY_MAGIC, STATEMENT_MAGIC, NOTICE_MAGIC = b"WMP1", b"WMS1", b"WMN1"
POLICY_DOMAIN = b"wai:live-moderation-policy\x01"
NOTICE_DOMAIN = b"wai:live-moderation-notice\x01"
ZERO = bytes(32)
META, CUE, CHAT, REACTION, POLL_OPEN, VOTE, POLL_CLOSE, MARKER, CAPTION, DECISION = 1, 2, 3, 4, 5, 6, 7, 8, 0x0A, 0x0B
DECIDABLE = {META, CUE, CHAT, REACTION, POLL_OPEN, VOTE, POLL_CLOSE, MARKER, CAPTION} # §4.1
MODERATE = 1 # live-rooms §3.3
ALLOW, DENY, REDACT = 1, 2, 3
EVENT, OBJECT = 1, 2
OWN, NOTICE, COMPLAINT, FALLBACK = 1, 2, 3, 4
ILLEGAL, INCOMPATIBLE = 1, 2
PRE, POST = 1, 2
F_ALLOW, F_DENY, F_HOLD = 1, 2, 3
VERDICTS = {1: "allow", 2: "deny", 3: "redact"}
BASES = {1: "own", 2: "notice", 3: "complaint", 4: "fallback"}
MEDIA = {1: "audio", 2: "video", 3: "screen"}
TERRITORY = ["AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GR", "HR", "HU", "IE", "IS", "IT", "LI",
"LT", "LU", "LV", "MT", "NL", "NO", "PL", "PT", "RO", "SE", "SI", "SK"] # §9.3
VISIBLE = {"shown", "pending", "allowed", "fallback_allow"} # §6.2
class Stop(Exception):
def __init__(self, reason, at):
super().__init__(reason)
self.reason, self.at = reason, at
def sha(*parts):
h = hashlib.sha256()
for p in parts:
h.update(p)
return h.digest()
# ---------------------------------------------------------------- the extension's own tables
def section(text, heading):
at = text.index("\n" + heading)
end = text.find("\n## ", at + 1)
end2 = text.find("\n### ", at + 1)
ends = [e for e in (end, end2) if e != -1]
return text[at:min(ends) if ends else len(text)]
def table_rows(sec):
return [[c.strip() for c in line.strip().strip("|").split("|")] for line in sec.splitlines()
if line.startswith("| ") and not line.startswith("|---")][1:]
def tables():
"""§9.5's categories, §9.6's content types and §10's reasons, from the text."""
with open(EXTENSION, encoding="utf-8") as f:
text = f.read()
categories = [row[1].strip("`") for row in table_rows(section(text, "### 9.5 Categories"))]
types = {}
for target, ctype, other in table_rows(section(text, "### 9.6 Content types")):
ctype, other = ctype.strip("`"), other.strip("`") or None
if target.startswith("an object of media 1"):
types[(OBJECT, 1)] = (ctype, other)
elif target.startswith("an object of media 2"):
types[(OBJECT, 2)] = types[(OBJECT, 3)] = (ctype, other)
else:
names = {v: k for k, v in LEV.KINDS.items()}
for name in re.findall(r"`([a-z_]+)`", target):
types[(EVENT, names[name])] = (ctype, other)
reasons = [row[0].strip("`") for row in table_rows(section(text, "## 10. Reasons"))]
return categories, types, reasons
CATEGORIES, CONTENT_TYPES, REASONS = tables()
# ---------------------------------------------------------------- reading
class Reader(LEV.Reader):
def text(self):
return self.take(self.u16())
def read_head(b, magic):
"""§3.3 step 1."""
if len(b) < 4:
raise Refuse("truncated")
if b[:4] != magic:
raise Refuse("malformed")
if len(b) < 5:
raise Refuse("truncated")
if b[4] != 1:
raise Refuse("unknown_version")
def utf8(b):
try:
return b.decode("utf-8")
except UnicodeDecodeError:
return None
def read_policy(b):
"""§3.1, §3.3: the policy, or Refuse."""
read_head(b, POLICY_MAGIC)
r = Reader(b, "truncated")
r.take(5)
p = {"room": r.take(32), "prev": r.take(32), "effective": r.u64()}
p["reviewers"] = [r.take(32) for _ in range(r.u8())]
p["flaggers"] = [r.take(32) for _ in range(r.u8())]
p["entries"] = []
for _ in range(r.u8()):
p["entries"].append({"scope": r.u8(), "code": r.u8(), "mode": r.u8(), "deadline_ms": r.u32(), "fallback": r.u8(),
"fallback_reason": r.u16()})
p["reasons"] = []
for _ in range(r.u8()):
p["reasons"].append({"code": r.u16(), "ground": r.u8(), "category": r.u8(), "reference": r.text(), "url": r.text()})
p["territory"] = [r.take(2) for _ in range(r.u8())]
p["redress"] = r.text()
body_len = r.p
p["sig"] = r.take(64)
if not r.done():
raise Refuse("malformed")
p["body"] = b[:body_len]
p["digest"] = sha(p["body"])
bad = Refuse("policy_malformed")
counts_ok = (len(p["reviewers"]) <= 16 and len(p["flaggers"]) <= 16 and len(p["entries"]) <= 32
and 1 <= len(p["reasons"]) <= 64 and 1 <= len(p["territory"]) <= 30)
if not counts_ok:
raise bad
asc = lambda xs: all(a < b for a, b in zip(xs, xs[1:])) # noqa: E731
if not asc(p["reviewers"]) or not asc(p["flaggers"]):
raise bad
if not asc([(e["scope"], e["code"]) for e in p["entries"]]) or not asc([c["code"] for c in p["reasons"]]):
raise bad
codes = {c["code"] for c in p["reasons"]}
for e in p["entries"]:
code_ok = (e["scope"] == 1 and e["code"] in DECIDABLE) or (e["scope"] == 2 and e["code"] in (1, 2, 3))
reason_ok = e["fallback_reason"] == 0 if e["fallback"] == F_ALLOW else e["fallback_reason"] in codes
if not (code_ok and e["mode"] in (PRE, POST) and e["deadline_ms"] > 0 and e["fallback"] in (1, 2, 3) and reason_ok):
raise bad
for c in p["reasons"]:
ref, url = utf8(c["reference"]), utf8(c["url"])
if ref is None or url is None:
raise bad
url_ok = url == "" or (len(c["url"]) <= 500 and url.startswith("https://") and all(0x21 <= x <= 0x7E for x in c["url"]))
if not (c["code"] > 0 and c["ground"] in (1, 2) and 1 <= c["category"] <= 16 and 1 <= len(ref) <= 500 and url_ok):
raise bad
c["reference"], c["url"] = ref, url
terr = [t.decode("latin-1") for t in p["territory"]]
if not asc(terr) or any(t not in TERRITORY for t in terr):
raise bad
p["territory"] = terr
if utf8(p["redress"]) is None:
raise bad
return p
def read_statement(b):
"""§4.2, §4.3: the statement, or Refuse("statement_malformed")."""
try:
r = Reader(b, "statement_malformed")
if r.take(4) != STATEMENT_MAGIC or r.u8() != 1:
raise Refuse("statement_malformed")
s = {"target_type": r.u8(), "target_prev": r.take(32), "supersedes": r.take(32), "basis": r.u8(),
"notice": r.take(32), "automated": r.u8(), "ground": r.u8()}
texts = [utf8(r.text()) for _ in range(3)]
if not r.done():
raise Refuse("statement_malformed")
except Refuse:
raise Refuse("statement_malformed")
s["ground_ref"], s["explanation"], s["facts"] = texts
decided = (s["automated"] >> 1) & 3
ok = (s["target_type"] in (EVENT, OBJECT) and (s["target_type"] == EVENT or s["target_prev"] == ZERO)
and s["basis"] in (1, 2, 3, 4) and ((s["basis"] in (NOTICE, COMPLAINT)) == (s["notice"] != ZERO))
and s["automated"] & ~0b111 == 0 and decided != 3
and (s["basis"] != FALLBACK or (decided == 2 and s["supersedes"] == ZERO))
and s["ground"] in (1, 2) and None not in texts)
if not ok:
raise Refuse("statement_malformed")
return s
def read_notice(b):
"""§5.1, §5.2 (all but the list's rule): the notice, or Refuse."""
read_head(b, NOTICE_MAGIC)
r = Reader(b, "truncated")
r.take(5)
n = {"room": r.take(32), "type": r.u8(), "target_type": r.u8(), "target": r.take(32), "t": r.u64(), "ground": r.u8(),
"good_faith": r.u8(), "ground_ref": r.text(), "statement": r.text(), "contact": r.text(), "submitter": r.take(32)}
body_len = r.p
n["sig"] = r.take(64)
if not r.done():
raise Refuse("malformed")
n["body"] = b[:body_len]
n["digest"] = sha(n["body"])
if n["type"] == 1:
ok = n["target_type"] in (1, 2) and n["ground"] in (1, 2)
elif n["type"] == 2:
ok = n["target_type"] == 1 and n["ground"] == 0
else:
ok = False
if not ok or n["good_faith"] > 1 or any(utf8(n[k]) is None for k in ("ground_ref", "statement", "contact")):
raise Refuse("notice_malformed")
return n
# ---------------------------------------------------------------- §7 verification
def policy_at(policies, t):
"""§3.4."""
for p in reversed(policies):
if p["effective"] <= t:
return p
return None
def entry_of(p, scope, code):
return next((e for e in p["entries"] if e["scope"] == scope and e["code"] == code), None) if p else None
def reason_of(p, code):
return next((c for c in p["reasons"] if c["code"] == code), None)
def has_kind(bm, k):
return bm[k // 8] >> (k % 8) & 1 == 1
def moderate(v):
"""§7: a dict of what the corpus states for room vector v."""
descriptor = bytes.fromhex(v["descriptor"])
grants = [bytes.fromhex(g) for g in v["grants"]]
chains = [[bytes.fromhex(h) for h in c] for c in v["chains"]]
room = LRV.verify_room(descriptor, grants, v["key_log"], chains) # step 1
if room["outcome"] == "refuse":
return {"outcome": "refuse", "reason": room["reason"], "at": ["room"] + room["at"]}, None
d = LRV.read_descriptor(descriptor)
held_grants, _ = LRV.check_grants(d, grants)
powers = {g["digest"]: g["powers"] for g in held_grants}
room_gaps = [(bytes.fromhex(g["principal"]), g["first"], g["last"]) for g in room.get("gaps", [])]
policies = []
for i, h in enumerate(v["policies"]): # step 2
try:
p = read_policy(bytes.fromhex(h))
if p["room"] != d["room"]:
raise Refuse("wrong_room")
bad = ED.verify(d["authority"], POLICY_DOMAIN + p["body"], p["sig"])
if bad:
raise Refuse(bad)
q = policies[-1] if policies else None
if not ((q is None and p["prev"] == ZERO and p["effective"] == 0)
or (q is not None and p["prev"] == q["digest"] and p["effective"] > q["effective"])):
raise Refuse("policy_unlinked")
kinds, deadline, fb = d["review"]
for k in range(256): # §3.5
if has_kind(kinds, k):
e = entry_of(p, 1, k)
if e is None or e["mode"] != PRE or e["deadline_ms"] != deadline or e["fallback"] != fb:
raise Refuse("policy_conflicts_room")
except Refuse as e:
return {"outcome": "refuse", "reason": e.reason, "at": ["policy", i]}, None
policies.append(p)
notices = []
for i, h in enumerate(v["notices"]): # step 3
try:
n = read_notice(bytes.fromhex(h))
if any(m["digest"] == n["digest"] for m in notices):
raise Refuse("notice_malformed")
if n["room"] != d["room"]:
raise Refuse("wrong_room")
bad = ED.verify(n["submitter"], NOTICE_DOMAIN + n["body"], n["sig"])
if bad:
raise Refuse(bad)
except Refuse as e:
return {"outcome": "refuse", "reason": e.reason, "at": ["notice", i]}, None
notices.append(n)
objects = [{"digest": bytes.fromhex(o["digest"]), "media": o["media"], "t": o["t"]} for o in v["objects"]]
# The events the room accepted, once each, in the merged order.
held, seen = [], set()
for c, i, under in room["order"]:
ev = LEV.read_record(chains[c][i])
if ev["digest"] in seen:
continue
seen.add(ev["digest"])
ev["payload_read"] = LEV.read_payload(ev["kind"], ev["payload"], 1 << 20)
ev["chain"], ev["record"], ev["under"] = c, i, bytes.fromhex(under) if under else None
held.append(ev)
by_seq = {(e["principal"], e["seq"]): k for k, e in enumerate(held)}
by_digest = {e["digest"]: k for k, e in enumerate(held)}
def room_key(p):
return p in (d["authority"], d["clock"])
def is_statement(e):
return e["kind"] == META and e["payload"][:4] == STATEMENT_MAGIC
statements = {}
for e in held: # step 4
if not is_statement(e):
continue
at = ["record", e["chain"], e["record"]]
if e["seq"] == 0:
return {"outcome": "refuse", "reason": "statement_misplaced", "at": at}, None
b = by_seq.get((e["principal"], e["seq"] - 1))
if b is None:
continue
if held[b]["kind"] != DECISION or held[b]["t"] != e["t"]:
return {"outcome": "refuse", "reason": "statement_misplaced", "at": at}, None
try:
statements[b] = read_statement(e["payload"])
except Refuse as x:
return {"outcome": "refuse", "reason": x.reason, "at": at}, None
last_on = {}
for k, e in enumerate(held):
if e["kind"] == DECISION:
last_on[e["payload_read"]["target"]] = k
explained, remaining, withheld = [], [], set() # step 5, §7.3
for g in room_gaps:
ex = explain(g, held, by_seq, statements, last_on)
if ex is None:
remaining.append(g)
else:
explained.append(ex[0])
withheld |= ex[1]
gap_remains = bool(remaining)
def event_entry(e):
"""§6.1."""
if e["kind"] not in DECIDABLE or is_statement(e) or room_key(e["principal"]) or powers.get(e["under"], 0) & MODERATE:
return None
return entry_of(policy_at(policies, e["t"]), 1, e["kind"])
def object_entry(o):
return entry_of(policy_at(policies, o["t"]), 2, o["media"])
decisions, line, decided = [], {}, {}
for k, e in enumerate(held): # step 6, §7.4
if e["kind"] != DECISION:
continue
at = ["record", e["chain"], e["record"]]
p, pl = e["principal"], e["payload_read"]
v_, x, code = pl["verdict"], pl["target"], pl["reason"]
if k in statements:
said = "known"
elif (p, e["seq"] + 1) in by_seq:
said = "none"
elif any(q == p and a <= e["seq"] + 1 <= z for q, a, z in remaining):
said = "unknown"
else:
said = "none"
st = statements.get(k)
tt = st["target_type"] if st else EVENT
try:
pol = policy_at(policies, e["t"]) # 1
rc = reason_of(pol, code) if pol else None
if rc is None or (st is not None and st["ground"] != rc["ground"]):
raise Refuse("reason_not_in_policy")
target, entry = None, None # 2
if tt == OBJECT:
o = next((o for o in objects if o["digest"] == x and o["t"] <= e["t"]), None)
if o is None:
raise Refuse("target_unknown")
target, entry = (OBJECT, o["media"], o["t"]), object_entry(o)
elif x in by_digest:
ti = by_digest[x]
te = held[ti]
if ti >= k:
raise Refuse("target_unknown")
if te["kind"] not in DECIDABLE or is_statement(te):
raise Refuse("target_not_decidable")
if st is not None and st["target_prev"] != te["prev"]:
raise Refuse("target_prev_mismatch")
target, entry = (EVENT, te["kind"], te["t"]), event_entry(te)
elif x not in withheld and not gap_remains:
raise Refuse("target_unknown")
if entry is not None and pol["reviewers"] and not room_key(p) and p not in pol["reviewers"]: # 3
raise Refuse("not_reviewer")
if said != "unknown": # 4
sup = st["supersedes"] if st else ZERO
head = line.get(x)
if sup == ZERO:
if head is not None:
raise Refuse("supersede_gap")
elif sup in decided:
if decisions[decided[sup]]["target"] != x:
raise Refuse("supersede_mismatch")
if head != sup:
raise Refuse("supersede_gap")
elif not gap_remains:
raise Refuse("supersede_unknown")
if st is not None and st["basis"] in (NOTICE, COMPLAINT): # 5
want = 1 if st["basis"] == NOTICE else 2
n = next((n for n in notices if n["digest"] == st["notice"] and n["type"] == want), None)
if n is None:
raise Refuse("notice_unknown")
aims = (n["target"] == x and n["target_type"] == tt) if st["basis"] == NOTICE else n["target"] == st["supersedes"]
if not aims or n["t"] > e["t"]:
raise Refuse("notice_mismatch")
if st is not None and st["basis"] == FALLBACK:
if not room_key(p):
raise Refuse("fallback_not_in_policy")
if target is not None:
if entry is None or entry["fallback"] == F_HOLD or v_ != entry["fallback"] or code != entry["fallback_reason"]:
raise Refuse("fallback_not_in_policy")
if e["t"] < target[2] + entry["deadline_ms"] * 1000:
raise Refuse("fallback_early")
except Refuse as r:
return {"outcome": "refuse", "reason": r.reason, "at": at}, None
line[x] = e["digest"]
decided[e["digest"]] = len(decisions)
decisions.append({"digest": e["digest"], "decider": p, "t": e["t"], "target": x, "target_type": tt, "verdict": v_,
"reason": code, "said": said, "st": st, "target_of": target})
for x in decisions:
x["standing"] = line[x["target"]] == x["digest"]
# Step 7 and what a verification states (§7.5).
out = {"outcome": "gap" if gap_remains else "accept", "room_outcome": room["outcome"],
"gaps": [{"principal": p.hex(), "first": a, "last": z} for p, a, z in remaining],
"explained": [{"principal": p.hex(), "first": a, "last": z, "decisions": [h.hex() for h in ds]} for p, a, z, ds in explained]}
out["decisions"] = [{
"digest": x["digest"].hex(), "decider": x["decider"].hex(), "t": x["t"], "target": x["target"].hex(),
"target_type": "event" if x["target_type"] == EVENT else "object", "verdict": VERDICTS[x["verdict"]], "reason": x["reason"],
"statement": x["said"], "basis": None if x["said"] == "unknown" else BASES[x["st"]["basis"] if x["st"] else OWN],
"supersedes": x["st"]["supersedes"].hex() if x["st"] and x["st"]["supersedes"] != ZERO else None,
"notice": x["st"]["notice"].hex() if x["st"] and x["st"]["notice"] != ZERO else None,
"standing": x["standing"]} for x in decisions]
items = []
for e in held:
ds = [x for x in decisions if x["target"] == e["digest"] and x["target_type"] == EVENT]
en = event_entry(e)
if en is not None or ds:
items.append(item(e["digest"], "event", LEV.KINDS[e["kind"]], e["t"], en, ds))
for o in objects:
ds = [x for x in decisions if x["target"] == o["digest"] and x["target_type"] == OBJECT]
en = object_entry(o)
if en is not None or ds:
items.append(item(o["digest"], "object", MEDIA[o["media"]], o["t"], en, ds))
out["items"] = items
ctx = {"t0": d["t0"], "policies": policies, "notices": notices, "decisions": decisions}
exports, files = [], {}
for k, x in enumerate(decisions):
rec = export(ctx, k)
if rec is None:
exports.append({"decision": x["digest"].hex(), "file": None})
elif isinstance(rec, str):
exports.append({"decision": x["digest"].hex(), "file": None, "refuse": "sor_incomplete", "member": rec})
else:
name = "%s.d%d.sor.json" % (v["name"], k)
files[name] = rec
exports.append({"decision": x["digest"].hex(), "file": name})
out["exports"] = exports
restricted = {x["target"]: x["digest"] for x in decisions
if x["standing"] and x["target_type"] == OBJECT and x["verdict"] != ALLOW}
return out, {"files": files, "restricted": restricted}
def explain(g, held, by_seq, statements, last_on):
"""§7.3: (explained gap, withheld digests), or None."""
p, a, z = g
after = by_seq.get((p, z + 1))
if after is None:
return None
dg, ds, digests = held[after]["prev"], [], set()
for _ in range(a, z + 1):
k = last_on.get(dg)
if k is None or k not in statements:
return None
s = statements[k]
if s["target_type"] != EVENT or held[k]["payload_read"]["verdict"] == ALLOW:
return None
ds.append(held[k]["digest"])
digests.add(dg)
dg = s["target_prev"]
if a == 0:
start = ZERO
else:
b = by_seq.get((p, a - 1))
if b is None:
return None
start = held[b]["digest"]
if dg != start:
return None
return (p, a, z, list(reversed(ds))), digests
def item(digest, tt, what, t, entry, ds):
"""§6.2: the item and its timeline."""
first = "shown" if entry is None else ("held" if entry["mode"] == PRE else "pending")
tl = [(t, first, None)]
if entry is not None:
due = t + entry["deadline_ms"] * 1000
if not any(x["t"] <= due and not (x["st"] and x["st"]["basis"] == FALLBACK) for x in ds):
tl.append((due, {F_ALLOW: "fallback_allow", F_DENY: "fallback_deny", F_HOLD: "fallback_hold"}[entry["fallback"]], None))
for x in ds:
tl.append((x["t"], {ALLOW: "allowed", DENY: "denied", REDACT: "redacted"}[x["verdict"]], x["digest"]))
return {"digest": digest.hex(), "target_type": tt, "kind": what, "t": t,
"entry": None if entry is None else {"mode": "pre" if entry["mode"] == PRE else "post",
"deadline_ms": entry["deadline_ms"],
"fallback": {1: "allow", 2: "deny", 3: "hold"}[entry["fallback"]]},
"timeline": [{"t": a, "state": s, "decision": h.hex() if h else None, "visible": s in VISIBLE} for a, s, h in tl]}
# ---------------------------------------------------------------- §9 the export
def js(s):
"""§9.2's string."""
out = ['"']
for c in s:
o = ord(c)
if c == '"':
out.append('\\"')
elif c == "\\":
out.append("\\\\")
elif o in (8, 9, 10, 12, 13):
out.append({8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r"}[o])
elif o < 0x20:
out.append("\\u%04x" % o)
else:
out.append(c)
out.append('"')
return "".join(out)
def utc_date(t0, t):
try:
return (datetime.datetime(1970, 1, 1) + datetime.timedelta(microseconds=t0 + t)).date().isoformat()
except OverflowError:
return None
def source(ctx, k):
"""§9.3 source_type, or None."""
x = ctx["decisions"][k]
if x["said"] == "unknown":
return None
st = x["st"]
if st is None or st["basis"] in (OWN, FALLBACK):
return "SOURCE_VOLUNTARY"
if st["basis"] == NOTICE:
n = next((n for n in ctx["notices"] if n["digest"] == st["notice"]), None)
if n is None:
return None
if n["ground"] != ILLEGAL:
return "SOURCE_TYPE_OTHER_NOTIFICATION"
pol = policy_at(ctx["policies"], x["t"])
return "SOURCE_TRUSTED_FLAGGER" if n["submitter"] in pol["flaggers"] else "SOURCE_ARTICLE_16"
y = next((j for j in range(k) if ctx["decisions"][j]["digest"] == st["supersedes"]), None)
return None if y is None else source(ctx, y)
def export(ctx, k):
"""§9: None for an allow, the member's name when §9.8 refuses, or the record's bytes."""
x = ctx["decisions"][k]
if x["verdict"] == ALLOW:
return None
st = x["st"]
if st is None or not 1 <= len(st["facts"]) <= 5000:
return "decision_facts"
pol = policy_at(ctx["policies"], x["t"])
rc = reason_of(pol, x["reason"])
illegal = st["ground"] == ILLEGAL
gname = "illegal_content_legal_ground" if illegal else "incompatible_content_ground"
ename = "illegal_content_explanation" if illegal else "incompatible_content_explanation"
gref = st["ground_ref"] or rc["reference"]
if not 1 <= len(gref) <= 500:
return gname
if not 1 <= len(st["explanation"]) <= 2000:
return ename
if x["target_of"] is None:
return "content_type"
tt, code, target_t = x["target_of"]
ctype, other = CONTENT_TYPES[(tt, code)]
cdate = utc_date(ctx["t0"], target_t)
if cdate is None or not "2000-01-01" <= cdate <= "2038-01-01":
return "content_date"
adate = utc_date(ctx["t0"], x["t"])
if adate is None or not "2020-01-01" <= adate <= "2038-01-01":
return "application_date"
src = source(ctx, k)
if src is None:
return "source_type"
m = [("decision_visibility", "[%s]" % js("DECISION_VISIBILITY_CONTENT_DISABLED" if x["verdict"] == DENY else "DECISION_VISIBILITY_CONTENT_REMOVED")),
("decision_facts", js(st["facts"])),
("decision_ground", js("DECISION_GROUND_ILLEGAL_CONTENT" if illegal else "DECISION_GROUND_INCOMPATIBLE_CONTENT"))]
if rc["url"]:
m.append(("decision_ground_reference_url", js(rc["url"])))
m += [(gname, js(gref)), (ename, js(st["explanation"])), ("content_type", "[%s]" % js(ctype))]
if other:
m.append(("content_type_other", js(other)))
m += [("category", js(CATEGORIES[rc["category"] - 1])),
("territorial_scope", "[%s]" % ",".join(js(t) for t in pol["territory"])),
("content_date", js(cdate)), ("application_date", js(adate)), ("source_type", js(src)),
("automated_detection", js("Yes" if st["automated"] & 1 else "No")),
("automated_decision", js(["AUTOMATED_DECISION_NOT_AUTOMATED", "AUTOMATED_DECISION_PARTIALLY",
"AUTOMATED_DECISION_FULLY"][(st["automated"] >> 1) & 3])),
("puid", js(x["digest"].hex()))]
return ("{" + ",".join("%s:%s" % (js(a), b) for a, b in m) + "}").encode("utf-8")
# ---------------------------------------------------------------- §8.3 clips
def clip_outcome(c, restricted):
"""§8.3: a dict of what the corpus states for clip c."""
exempt = frozenset(restricted)
got = LREC.verify_clip(c, exempt=exempt)
if got["outcome"] == "refuse":
return got
clip = LREC.read_clip(bytes.fromhex(c["record"]))
withheld = []
for k, (p, g) in enumerate(zip(clip["parts"], c["parts"])):
w = []
for j, x in enumerate(p["chunks"]):
leaf = LREC.leaf(x["desc"])
if leaf in restricted:
if j < len(g["bytes"]) and g["bytes"][j] is not None:
return {"outcome": "refuse", "reason": "redaction_not_applied", "at": ["chunk", k, j]}
w.append({"index": x["index"], "decision": restricted[leaf].hex()})
withheld.append(w)
facts = []
for f, w in zip(got["facts"], withheld):
f = {k: f[k] for k in ("take", "first", "count", "offset", "span", "device_window", "bytes", "sha256")}
f["withheld"] = w
facts.append(f)
got["facts"] = facts
return got
# ---------------------------------------------------------------- the corpus
def check(doc, files, quiet=False):
"""Every room and clip against its statement, and every export file: the failures."""
fails, runs, written = [], {}, {}
for v in doc.get("vectors", []):
try:
got, extra = moderate(v)
except Exception as e: # a crash on a mutated copy is a failure to read it
got, extra = None, None
bad = ["the vector does not verify: %s: %s" % (type(e).__name__, e)]
else:
bad = [k for k in got if v.get(k) != got[k]] + [k for k in ("outcome", "reason", "at") if k in v and k not in got]
if extra:
runs[v["name"]] = extra
for name, b in extra["files"].items():
written[name] = b
if files.get(name) != b:
bad.append("its export %s" % name)
if not quiet:
what = "?" if got is None else got["outcome"] + (" " + got["reason"] if got["outcome"] == "refuse" else "")
print(("PASS" if not bad else "FAIL") + " room %s: %s" % (v["name"], what))
fails += ["room %s: %s differs from the corpus" % (v["name"], k) for k in bad]
for c in doc.get("clips", []):
run = runs.get(c.get("room"))
try:
got = clip_outcome(c, run["restricted"] if run else {})
except Exception as e:
got, bad = None, ["the clip does not verify: %s: %s" % (type(e).__name__, e)]
else:
bad = [k for k in got if c.get(k) != got[k]] + [k for k in ("outcome", "reason", "at") if k in c and k not in got]
if not quiet:
what = "?" if got is None else got["outcome"] + (" " + got["reason"] if got["outcome"] == "refuse" else "")
print(("PASS" if not bad else "FAIL") + " clip %s: %s" % (c["name"], what))
fails += ["clip %s: %s differs from the corpus" % (c["name"], k) for k in bad]
return fails, written
def flip(h, at):
b = bytearray(bytes.fromhex(h))
b[at] ^= 0x01
return b.hex()
def mutations(doc, files):
"""Copies of the corpus, each with one defect a conforming verifier must catch."""
vn = {v["name"]: k for k, v in enumerate(doc["vectors"])}
cn = {c["name"]: k for k, c in enumerate(doc["clips"])}
def mutate(label, f):
d, fs = copy.deepcopy(doc), dict(files)
f(d["vectors"], d["clips"], fs)
return label, d, fs
pre, sub, notice, appeal, red = vn["premoderated_chat"], vn["subscriber_view"], vn["notice_and_decision"], vn["appeal_reverses"], vn["redacted_segment"]
def drop_statement(vs, cs, fs):
"""The moderator's first statement taken out of its chain."""
ch = vs[pre]["chains"]
for c in ch:
for i, h in enumerate(c):
if bytes.fromhex(h)[4 + 1 + 32 + 32 + 8 + 32 + 8 + 1 + 1 + 2:][:4] == STATEMENT_MAGIC:
del c[i]
return
def first_export(fs, name):
return next(k for k in sorted(fs) if k.startswith(name + "."))
return [
mutate("a byte of the first policy's signature", lambda vs, cs, fs: vs[pre]["policies"].__setitem__(0, flip(vs[pre]["policies"][0], -3))),
mutate("the policies given in each other's place", lambda vs, cs, fs: vs[pre]["policies"].reverse()),
mutate("the moderator's first statement dropped", drop_statement),
mutate("the denied message stated allowed", lambda vs, cs, fs: vs[pre]["decisions"][1].update(verdict="allow")),
mutate("a fallback stated at the wrong instant", lambda vs, cs, fs: vs[pre]["items"][3]["timeline"][1].update(t=34000000)),
mutate("the held reaction stated visible", lambda vs, cs, fs: vs[pre]["items"][3]["timeline"][1].update(visible=True)),
mutate("a byte of an export file", lambda vs, cs, fs: fs.update({first_export(fs, "premoderated_chat"): fs[first_export(fs, "premoderated_chat")].replace(b"Yes", b"No ")})),
mutate("the subscriber's explained gap stated with its decisions swapped",
lambda vs, cs, fs: vs[sub]["explained"][0]["decisions"].reverse()),
mutate("the subscriber's view stated without its gap explained", lambda vs, cs, fs: vs[sub].update(explained=[])),
mutate("a byte of the notice's cited provision", lambda vs, cs, fs: vs[notice]["notices"].__setitem__(0, flip(vs[notice]["notices"][0], 120))),
mutate("the notice's decision stated answering the flagger's notice", lambda vs, cs, fs: vs[notice]["decisions"][1].update(notice=vs[notice]["decisions"][3]["notice"])),
mutate("the trusted flagger's redaction stated from an Article 16 notice",
lambda vs, cs, fs: fs.update({"notice_and_decision.d3.sor.json": fs["notice_and_decision.d3.sor.json"].replace(b"SOURCE_TRUSTED_FLAGGER", b"SOURCE_ARTICLE_16")})),
mutate("the complaint withheld", lambda vs, cs, fs: vs[appeal].update(notices=[])),
mutate("the appeal's reversal stated not standing", lambda vs, cs, fs: vs[appeal]["decisions"][1].update(standing=False)),
mutate("the redacted chunk's object stated after the decision",
lambda vs, cs, fs: next(o for o in vs[red]["objects"] if o["digest"] == vs[red]["decisions"][0]["target"]).update(t=21000000)),
mutate("the clip given the redacted chunk's bytes", lambda vs, cs, fs: cs[cn["clip_withheld"]]["parts"][0]["bytes"].__setitem__(1, cs[cn["clip_restored_whole"]]["parts"][0]["bytes"][1])),
mutate("the trimmed clip's stated digest", lambda vs, cs, fs: cs[cn["clip_trimmed_withheld"]]["facts"][0].update(sha256="00" * 32)),
mutate("the withheld clip stated over the reversed room", lambda vs, cs, fs: cs[cn["clip_withheld"]].update(room="redaction_reversed")),
mutate("a stated export file removed", lambda vs, cs, fs: fs.pop(first_export(fs, "appeal_reverses"))),
]
def main(corpus_dir):
fails = []
st = ED._self_test()
print(("PASS" if not st else "FAIL") + " Ed25519 strict rule self-test " + ",".join(st))
fails += ["ed25519 self-test"] if st else []
ok_tables = len(CATEGORIES) == 16 and len(REASONS) == 20 and len(CONTENT_TYPES) == 12
print(("PASS" if ok_tables else "FAIL") + " §9.5, §9.6 and §10 read from the extension: %d categories, %d content types, %d reasons"
% (len(CATEGORIES), len(CONTENT_TYPES), len(REASONS)))
fails += [] if ok_tables else ["the extension's tables do not read"]
with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as f:
doc = json.load(f)
files = {}
for name in sorted(os.listdir(corpus_dir)):
if name.endswith(".sor.json"):
with open(os.path.join(corpus_dir, name), "rb") as f:
files[name] = f.read()
got, written = check(doc, files)
fails += got
extra = sorted(set(os.listdir(corpus_dir)) - set(written) - {"vectors.json", "README.md"})
print(("PASS" if not extra else "FAIL") + " no file but vectors.json, README.md and the exports the rooms write")
fails += ["an extra file: %s" % n for n in extra]
seen = {v.get("reason") for v in doc["vectors"] + doc["clips"]}
seen |= {e.get("refuse") for v in doc["vectors"] for e in v.get("exports", [])}
missing = [r for r in REASONS if r not in seen]
print(("PASS" if not missing else "FAIL") + " every reason of §10 appears in the corpus")
fails += ["no case gives %s" % r for r in missing]
for label, d, fs in mutations(doc, files):
caught, _ = check(d, fs, quiet=True)
print(("PASS" if caught else "FAIL") + " mutated copy fails: %s%s" % (label, " (%s)" % caught[0][:100] if caught else ""))
if not caught:
fails.append("a mutated copy verifies: %s" % label)
for f in fails:
print("FAIL " + f)
print(("PASS" if not fails else "FAIL") + " live-moderation corpus: %d rooms, %d clips, %d exports"
% (len(doc["vectors"]), len(doc["clips"]), len(files)))
return 1 if fails else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: live_moderation_verify.py <live-conformance/moderation>", file=sys.stderr)
sys.exit(2)
sys.exit(main(sys.argv[1]))

View file

@ -580,8 +580,10 @@ def read_clip(b):
return c
def verify_part(k, p, given, d, clip):
"""§5.4 step 3 for part k: its facts, or Stop."""
def verify_part(k, p, given, d, clip, exempt=frozenset()):
"""§5.4 step 3 for part k: its facts, or Stop. A chunk whose leaf is in `exempt` is passed
over in step (k), as live-moderation §8.3 has a restricted chunk passed over; none is by
default."""
I, J = d["commit_interval_ms"] * 1000, d["jitter_ms"] * 1000
at_part = ["part", k]
if bytes.fromhex(given["device"]) != p["device"]: # (a)
@ -656,7 +658,12 @@ def verify_part(k, p, given, d, clip):
if not covers or ((p["trim_in"] or p["trim_out"]) and (wi, wo) != span):
raise Stop(at_part, "clip_window_invalid")
shown = b"" # (k)
trim_in, trim_out = p["trim_in"], p["trim_out"]
for j, x in enumerate(ch):
if leaf(x["desc"]) in exempt:
trim_in = 0 if j == 0 else trim_in
trim_out = 0 if j == len(ch) - 1 else trim_out
continue
h = given["bytes"][j] if j < len(given["bytes"]) else None
if h is None:
raise Stop(["chunk", k, j], "clip_chunk_missing")
@ -667,15 +674,15 @@ def verify_part(k, p, given, d, clip):
shown += b
if len(given["bytes"]) > len(ch):
raise Stop(["chunk", k, len(ch)], "chunk_extra")
shown = shown[p["trim_in"]:len(shown) - p["trim_out"]]
shown = shown[trim_in:len(shown) - trim_out]
return {"take": p["take"].hex(), "first": p["first"], "count": len(ch), "offset": o, "span": list(span),
"device_window": [wi - o, wo - o], "bytes": len(shown), "sha256": sha(shown).hex(),
"fetch": {"namespace": [d["room"].hex(), p["device"].hex(), "take"], "name": p["take"].hex(),
"start_group": p["first"], "start_object": 0, "end_group_delta": len(ch) - 1}}
def verify_clip(v):
"""§5.4: a dict of what the corpus states."""
def verify_clip(v, exempt=frozenset()):
"""§5.4: a dict of what the corpus states. `exempt`: as verify_part's."""
try:
try:
d = LRV.read_descriptor(bytes.fromhex(v["descriptor"]))
@ -689,7 +696,7 @@ def verify_clip(v):
raise Stop(["clip"], "wrong_room")
if len(v["parts"]) != len(clip["parts"]):
raise Stop(["clip"], "clip_record_invalid")
facts = [verify_part(k, p, g, d, clip) for k, (p, g) in enumerate(zip(clip["parts"], v["parts"]))]
facts = [verify_part(k, p, g, d, clip, exempt) for k, (p, g) in enumerate(zip(clip["parts"], v["parts"]))]
except Stop as e:
return {"outcome": "refuse", "reason": e.reason, "at": e.at}
J = d["jitter_ms"] * 1000

View file

@ -0,0 +1,116 @@
# live-conformance/moderation — live moderation
The corpus of [`extensions/live-moderation.md`](../../extensions/live-moderation.md) §11:
rooms under review policies, each a room (its `WRD1` descriptor, `WRG1` grants, key-log head
or none, and each principal's `WEV1` chain), its `WMP1` review policies, its `WMN1` notices
and complaints, and the media objects under moderation; the decisions in its chains with
their `WMS1` statements; what a verifier states of them; each restricting decision's
statement-of-reasons export, byte for byte; and clips of a redacted take.
**Moderation equivalence.** For each room vector, a conforming moderation verifier given its
`descriptor`, `grants`, `key_log`, `chains`, `policies`, `notices` and `objects` (§7.1)
reaches the outcome the vector states (§7.5):
- `refuse`: the `reason` and where, `at`: `["room", …]` with the room verifier's own place
(live-rooms §9.1), `["policy", i]`, `["notice", i]`, or `["record", c, i]` for record `i`
of chain `c`;
- `accept` or `gap`: the `room_outcome` (`accept` or `gap`); the `gaps` that remain, each
`principal`, `first` and `last`; the `explained` gaps (§7.3), each with the `decisions`
withholding its seqs from `first` to `last`; the `decisions` (§7.5), each its `digest`,
`decider`, `t`, `target`, `target_type`, `verdict`, `reason`, `statement` (`known`, `none`
or `unknown`), `basis` (`own`, `notice`, `complaint`, `fallback`, or `null` for an unknown
statement), `supersedes`, `notice` and whether it is `standing`; the `items` (§6), each its
`digest`, `target_type`, `kind` (an event kind or a media kind), `t`, `entry` (`mode`,
`deadline_ms`, `fallback`) or `null`, and its `timeline`, each entry's `t`, `state`,
`decision` and whether the state is `visible`; and the `exports`: for each decision, the
`file` holding its record, `null` for an allow, or `refuse` `sor_incomplete` with the
`member` §9.8 names.
Each export file, `NAME.dK.sor.json`, is the record of decision *K* of vector `NAME`, exactly
the bytes §9.2 writes, with no trailing newline.
**Clip equivalence.** For each clip, a conforming verifier of a clip under moderation (§8.3),
given its `descriptor`, `record` and `parts` (as live-recording's corpus gives them) and the
standing restrictions of the room vector its `room` names, reaches the outcome the clip
states: `refuse` with the `reason` and `at` (live-recording's places), or `accept` with the
record's digest `clip`, its `signer`, `window`, `bound_us`, and the `facts` of each part as
live-recording states them (without the FETCH address) and the part's `withheld` chunks, each
its `index` and the `decision` that restricts it.
A verifier that reaches another outcome, reason, place, decision, state, export byte or fact
does not conform.
**Provenance.** Generated here: `wai_live_moderation_vectors gen` writes every file from rooms
authored in code, from fixed seeds. Each chat room principal's key is SHA-256 of a fixed label
and the principal's index; every time is a constant (the rooms' `t0` is 2026-10-16T00:00:00Z);
every text is written for the corpus in English; the phone number is of a reserved fictional
range; every origin, address and link is an `.example` name (RFC 2606); and Ed25519 signing is
deterministic (RFC 8032), so every file is the same bytes on every machine. The recorded take
is live-recording's lesson session, written by that corpus's own rules (`../recordings/`),
so its room, chains, commitments and chunks are that corpus's byte for byte; this corpus adds
the director's decisions to the director's chain, and the performer's complaint. The
generator asserts that each vector reaches the outcome it was written to have, and that the
corpus gives every reason of §10, before it writes. No recorded data. The legal provisions
the vocabulary cites are cited by identifier, as examples of a room's vocabulary; nothing in
the corpus states that any content is or is not lawful.
## Layout
- `vectors.json`: `categories` (§9.5, informative), `principals` and `lesson_principals` (the
keys, by name, informative), `vectors` and `clips`.
- `NAME.dK.sor.json`: the exports.
The rooms:
- `premoderated_chat`: a chat room whose descriptor holds chat for review five seconds and then
denies it; a first policy names a reviewer, a trusted flagger and the vocabulary, and a
second, from 20 s, also reviews reactions after delivery with a fallback of hold. One
message allowed before its deadline, one denied with automated detection, one past its
deadline taking the fallback, which the clock signs; a reaction held at its deadline; a
message allowed by a decision without a statement; a host's message, not reviewed.
- `subscriber_view`: the same room as a subscriber that is not a reviewer receives it: the
denied message and the fallback's are withheld, and the gap they leave is explained.
- `unexplained_gap`: the same room with the allowed message withheld: a delivery gap.
- `notice_and_decision`: an allowed message, a notice that it is illegal, and the decision that
answers it (`SOURCE_ARTICLE_16`); a message the fallback denied, a trusted flagger's notice,
and the redaction that answers it (`SOURCE_TRUSTED_FLAGGER`).
- `appeal_reverses`: a denial with automated means, its author's complaint, and the decision
on the complaint that allows the message.
- `redacted_segment` and `redaction_reversed`: live-recording's lesson session with its video
reviewed after delivery; the director redacts chunk 3 of the performer's take, and in the
second the performer complains and the director restores it.
- refused, one defect each: `not_moderator` (refused by the room, `kind_not_permitted`),
`policy_malformed`, `policy_unlinked`, `policy_conflicts_room`, `policy_bad_signature`,
`policy_wrong_room`, `notice_malformed`, `notice_bad_signature`, `statement_misplaced`,
`statement_malformed`, `reason_not_in_policy`, `target_unknown`, `target_not_decidable`,
`target_prev_mismatch`, `not_reviewer`, `supersede_gap`, `supersede_unknown`,
`supersede_mismatch`, `notice_unknown`, `notice_mismatch`, `fallback_not_in_policy` and
`fallback_early`;
- `sor_incomplete`: a denial whose statement states no facts, accepted, its export refused.
The clips, chunks 2 to 5 of the performer's take: `clip_withheld` and
`clip_trimmed_withheld` (a byte range from inside the redacted chunk) withhold chunk 3;
`clip_redaction_not_applied` gives its bytes; after the appeal, `clip_restored_missing`
leaves it out and `clip_restored_whole` presents it.
## Running
```
cd ../../wai-rs
cargo run --no-default-features --features live_moderation --bin wai_live_moderation_vectors -- verify ../live-conformance/moderation
cargo run --no-default-features --features live_moderation --bin wai_live_moderation_vectors -- gen ../live-conformance/moderation # regenerate
cargo test --no-default-features --features live_moderation --test live_moderation
cd ../live-conformance
python3 -I live_moderation_verify.py moderation
```
`wai_live_moderation_vectors verify` and `tests/live_moderation.rs` require every file to
regenerate byte for byte, no other file but this one to be there, and `wai-rs` to give every
case its outcome; the test also requires §10 to list exactly the reasons the reference
reports, and the generator that the corpus gives each of them. `live_moderation_verify.py` is
the second implementation, written from the extension's text and sharing no code with
`wai-rs`: it verifies each room with `live_rooms_verify.py` and each clip with
`live_record_verify.py`, writes every export itself, reads §9.5, §9.6 and §10 from the
extension, gives every case the same outcome, requires every reason of §10 to appear, and
then mutates copies of the corpus, one defect each, each of which must fail. Pass = an exit
status of 0 from each.

View file

@ -0,0 +1 @@
{"decision_visibility":["DECISION_VISIBILITY_CONTENT_DISABLED"],"decision_facts":"A classifier matched the message to a promotion pattern and proposed denial; a reviewer confirmed it at 10.4 s.","decision_ground":"DECISION_GROUND_INCOMPATIBLE_CONTENT","decision_ground_reference_url":"https://rooms.example/terms#4.2","incompatible_content_ground":"Room terms, clause 4.2: no unsolicited promotion","incompatible_content_explanation":"The message advertises a paid service to the room's participants, which clause 4.2 of the room's terms does not allow.","content_type":["CONTENT_TYPE_TEXT"],"category":"STATEMENT_CATEGORY_OTHER_VIOLATION_TC","territorial_scope":["AT","BE","BG","CY","CZ","DE","DK","EE","ES","FI","FR","GR","HR","HU","IE","IS","IT","LI","LT","LU","LV","MT","NL","NO","PL","PT","RO","SE","SI","SK"],"content_date":"2026-10-16","application_date":"2026-10-16","source_type":"SOURCE_VOLUNTARY","automated_detection":"Yes","automated_decision":"AUTOMATED_DECISION_PARTIALLY","puid":"a3de7474effdcfa7b047476e0094bd3bf862e96ccd8ec70a5133445d808500e1"}

View file

@ -0,0 +1 @@
{"decision_visibility":["DECISION_VISIBILITY_CONTENT_DISABLED"],"decision_facts":"A notice received at 40 s named the message; a reviewer confirmed the number is a private one and disabled access at 45 s.","decision_ground":"DECISION_GROUND_ILLEGAL_CONTENT","illegal_content_legal_ground":"Regulation (EU) 2016/679, Article 6(1)","illegal_content_explanation":"The message discloses a private phone number of a person outside the room, with no ground for processing it.","content_type":["CONTENT_TYPE_TEXT"],"category":"STATEMENT_CATEGORY_DATA_PROTECTION_AND_PRIVACY_VIOLATIONS","territorial_scope":["AT","BE","BG","CY","CZ","DE","DK","EE","ES","FI","FR","GR","HR","HU","IE","IS","IT","LI","LT","LU","LV","MT","NL","NO","PL","PT","RO","SE","SI","SK"],"content_date":"2026-10-16","application_date":"2026-10-16","source_type":"SOURCE_ARTICLE_16","automated_detection":"No","automated_decision":"AUTOMATED_DECISION_NOT_AUTOMATED","puid":"e8125d22e92e65373d1a3411c2eb841bede81a43e96795d22bf50267fead3123"}

View file

@ -0,0 +1 @@
{"decision_visibility":["DECISION_VISIBILITY_CONTENT_DISABLED"],"decision_facts":"Held for review at 50 s of the session; no decision by 55 s.","decision_ground":"DECISION_GROUND_INCOMPATIBLE_CONTENT","decision_ground_reference_url":"https://rooms.example/terms#6","incompatible_content_ground":"Room terms, clause 6: a message held for review and not reviewed by its deadline is not shown","incompatible_content_explanation":"No reviewer decided on the message within the room's five-second deadline, and clause 6 of the room's terms withholds such a message.","content_type":["CONTENT_TYPE_TEXT"],"category":"STATEMENT_CATEGORY_OTHER_VIOLATION_TC","territorial_scope":["AT","BE","BG","CY","CZ","DE","DK","EE","ES","FI","FR","GR","HR","HU","IE","IS","IT","LI","LT","LU","LV","MT","NL","NO","PL","PT","RO","SE","SI","SK"],"content_date":"2026-10-16","application_date":"2026-10-16","source_type":"SOURCE_VOLUNTARY","automated_detection":"No","automated_decision":"AUTOMATED_DECISION_FULLY","puid":"3d422ec7bf5f065daf98a6cb76808d9e72a8faf4c9b43b8e8e91667447f338f7"}

View file

@ -0,0 +1 @@
{"decision_visibility":["DECISION_VISIBILITY_CONTENT_REMOVED"],"decision_facts":"A trusted flagger's notice received at 60 s named the message; a reviewer opened the link and removed the message at 62 s.","decision_ground":"DECISION_GROUND_ILLEGAL_CONTENT","illegal_content_legal_ground":"Directive 2001/29/EC, Article 3(1)","illegal_content_explanation":"The message links a recording communicated to the public without authorisation.","content_type":["CONTENT_TYPE_TEXT"],"category":"STATEMENT_CATEGORY_INTELLECTUAL_PROPERTY_INFRINGEMENTS","territorial_scope":["AT","BE","BG","CY","CZ","DE","DK","EE","ES","FI","FR","GR","HR","HU","IE","IS","IT","LI","LT","LU","LV","MT","NL","NO","PL","PT","RO","SE","SI","SK"],"content_date":"2026-10-16","application_date":"2026-10-16","source_type":"SOURCE_TRUSTED_FLAGGER","automated_detection":"No","automated_decision":"AUTOMATED_DECISION_NOT_AUTOMATED","puid":"de3dcf9be4ccec87f5a0632cc0f9720e9d798130e43b3a8b14916db2f70f7de9"}

View file

@ -0,0 +1 @@
{"decision_visibility":["DECISION_VISIBILITY_CONTENT_DISABLED"],"decision_facts":"Held for review at 11.5 s of the session; a classifier flagged its link, and a reviewer read the message and denied it at 12.5 s.","decision_ground":"DECISION_GROUND_INCOMPATIBLE_CONTENT","decision_ground_reference_url":"https://rooms.example/terms#4.2","incompatible_content_ground":"Room terms, clause 4.2: no unsolicited promotion","incompatible_content_explanation":"The message advertises a paid service to the room's participants, which clause 4.2 of the room's terms does not allow.","content_type":["CONTENT_TYPE_TEXT"],"category":"STATEMENT_CATEGORY_OTHER_VIOLATION_TC","territorial_scope":["AT","BE","BG","CY","CZ","DE","DK","EE","ES","FI","FR","GR","HR","HU","IE","IS","IT","LI","LT","LU","LV","MT","NL","NO","PL","PT","RO","SE","SI","SK"],"content_date":"2026-10-16","application_date":"2026-10-16","source_type":"SOURCE_VOLUNTARY","automated_detection":"Yes","automated_decision":"AUTOMATED_DECISION_NOT_AUTOMATED","puid":"9566e5ae10c876ca924c518d452f230fa9c2a4c79daf1c93690af3da02df92c8"}

View file

@ -0,0 +1 @@
{"decision_visibility":["DECISION_VISIBILITY_CONTENT_DISABLED"],"decision_facts":"Held for review at 12 s of the session; no decision by 17 s.","decision_ground":"DECISION_GROUND_INCOMPATIBLE_CONTENT","decision_ground_reference_url":"https://rooms.example/terms#6","incompatible_content_ground":"Room terms, clause 6: a message held for review and not reviewed by its deadline is not shown","incompatible_content_explanation":"No reviewer decided on the message within the room's five-second deadline, and clause 6 of the room's terms withholds such a message.","content_type":["CONTENT_TYPE_TEXT"],"category":"STATEMENT_CATEGORY_OTHER_VIOLATION_TC","territorial_scope":["AT","BE","BG","CY","CZ","DE","DK","EE","ES","FI","FR","GR","HR","HU","IE","IS","IT","LI","LT","LU","LV","MT","NL","NO","PL","PT","RO","SE","SI","SK"],"content_date":"2026-10-16","application_date":"2026-10-16","source_type":"SOURCE_VOLUNTARY","automated_detection":"No","automated_decision":"AUTOMATED_DECISION_FULLY","puid":"9e7aa6a53dec448ef4c871f002e56e2255056e5c50836abfa4c02ed6df719560"}

View file

@ -0,0 +1 @@
{"decision_visibility":["DECISION_VISIBILITY_CONTENT_REMOVED"],"decision_facts":"An audio match flagged a commercial recording playing behind the performer during chunk 3 of the take; the director listened to it and removed the chunk at 20 s.","decision_ground":"DECISION_GROUND_ILLEGAL_CONTENT","illegal_content_legal_ground":"Directive 2001/29/EC, Article 3(1)","illegal_content_explanation":"The segment plays a recording protected by copyright, which the room holds no licence to communicate to the public.","content_type":["CONTENT_TYPE_VIDEO"],"category":"STATEMENT_CATEGORY_INTELLECTUAL_PROPERTY_INFRINGEMENTS","territorial_scope":["AT","DE","FR"],"content_date":"2026-10-16","application_date":"2026-10-16","source_type":"SOURCE_VOLUNTARY","automated_detection":"Yes","automated_decision":"AUTOMATED_DECISION_NOT_AUTOMATED","puid":"5d5a4a8c74ede0dbcb2015db0d168a37752f9ce23652e9eef5ae967ba46cd64f"}

View file

@ -0,0 +1 @@
{"decision_visibility":["DECISION_VISIBILITY_CONTENT_REMOVED"],"decision_facts":"An audio match flagged a commercial recording playing behind the performer during chunk 3 of the take; the director listened to it and removed the chunk at 20 s.","decision_ground":"DECISION_GROUND_ILLEGAL_CONTENT","illegal_content_legal_ground":"Directive 2001/29/EC, Article 3(1)","illegal_content_explanation":"The segment plays a recording protected by copyright, which the room holds no licence to communicate to the public.","content_type":["CONTENT_TYPE_VIDEO"],"category":"STATEMENT_CATEGORY_INTELLECTUAL_PROPERTY_INFRINGEMENTS","territorial_scope":["AT","DE","FR"],"content_date":"2026-10-16","application_date":"2026-10-16","source_type":"SOURCE_VOLUNTARY","automated_detection":"Yes","automated_decision":"AUTOMATED_DECISION_NOT_AUTOMATED","puid":"5d5a4a8c74ede0dbcb2015db0d168a37752f9ce23652e9eef5ae967ba46cd64f"}

View file

@ -0,0 +1 @@
{"decision_visibility":["DECISION_VISIBILITY_CONTENT_DISABLED"],"decision_facts":"Held for review at 11.5 s of the session; a classifier flagged its link, and a reviewer read the message and denied it at 12.5 s.","decision_ground":"DECISION_GROUND_INCOMPATIBLE_CONTENT","decision_ground_reference_url":"https://rooms.example/terms#4.2","incompatible_content_ground":"Room terms, clause 4.2: no unsolicited promotion","incompatible_content_explanation":"The message advertises a paid service to the room's participants, which clause 4.2 of the room's terms does not allow.","content_type":["CONTENT_TYPE_TEXT"],"category":"STATEMENT_CATEGORY_OTHER_VIOLATION_TC","territorial_scope":["AT","BE","BG","CY","CZ","DE","DK","EE","ES","FI","FR","GR","HR","HU","IE","IS","IT","LI","LT","LU","LV","MT","NL","NO","PL","PT","RO","SE","SI","SK"],"content_date":"2026-10-16","application_date":"2026-10-16","source_type":"SOURCE_VOLUNTARY","automated_detection":"Yes","automated_decision":"AUTOMATED_DECISION_NOT_AUTOMATED","puid":"9566e5ae10c876ca924c518d452f230fa9c2a4c79daf1c93690af3da02df92c8"}

View file

@ -0,0 +1 @@
{"decision_visibility":["DECISION_VISIBILITY_CONTENT_DISABLED"],"decision_facts":"Held for review at 12 s of the session; no decision by 17 s.","decision_ground":"DECISION_GROUND_INCOMPATIBLE_CONTENT","decision_ground_reference_url":"https://rooms.example/terms#6","incompatible_content_ground":"Room terms, clause 6: a message held for review and not reviewed by its deadline is not shown","incompatible_content_explanation":"No reviewer decided on the message within the room's five-second deadline, and clause 6 of the room's terms withholds such a message.","content_type":["CONTENT_TYPE_TEXT"],"category":"STATEMENT_CATEGORY_OTHER_VIOLATION_TC","territorial_scope":["AT","BE","BG","CY","CZ","DE","DK","EE","ES","FI","FR","GR","HR","HU","IE","IS","IT","LI","LT","LU","LV","MT","NL","NO","PL","PT","RO","SE","SI","SK"],"content_date":"2026-10-16","application_date":"2026-10-16","source_type":"SOURCE_VOLUNTARY","automated_detection":"No","automated_decision":"AUTOMATED_DECISION_FULLY","puid":"9e7aa6a53dec448ef4c871f002e56e2255056e5c50836abfa4c02ed6df719560"}

File diff suppressed because it is too large Load diff

View file

@ -644,6 +644,20 @@
"edition": null,
"registered": "2026-10-18"
},
{
"capability": "wai.live.moderation",
"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-19"
},
{
"capability": "wai.live.recording",
"kind": "record",
@ -1934,6 +1948,20 @@
"section": "§3.1",
"status": "defined"
},
{
"name": "WMN1",
"kind": "magic",
"document": "extensions/live-moderation.md",
"section": "§5.1",
"status": "defined"
},
{
"name": "WMP1",
"kind": "magic",
"document": "extensions/live-moderation.md",
"section": "§3.1",
"status": "defined"
},
{
"name": "WMR1",
"kind": "magic",
@ -1941,6 +1969,13 @@
"section": "§4.1",
"status": "defined"
},
{
"name": "WMS1",
"kind": "magic",
"document": "extensions/live-moderation.md",
"section": "§4.2",
"status": "defined"
},
{
"name": "WRD1",
"kind": "magic",

View file

@ -555,6 +555,13 @@ wai:wai.live.metering a wai:Capability ;
wai:specSection "§5 Interactive, temporal & computed content" ;
wai:registeredOn "2026-10-18"^^xsd:date .
wai:wai.live.moderation a wai:Capability ;
rdfs:label "wai.live.moderation" ;
wai:kind wai:kind-record ;
wai:status wai:status-registered ;
wai:specSection "§5 Interactive, temporal & computed content" ;
wai:registeredOn "2026-10-19"^^xsd:date .
wai:wai.live.recording a wai:Capability ;
rdfs:label "wai.live.recording" ;
wai:kind wai:kind-record ;

View file

@ -230,6 +230,15 @@ live_metering = ["live_compose", "capture_take", "telemetry"]
# steering manifest, the HLS tag and the DASH element, written exactly. SHA-256, Ed25519 and the key
# log only; no new dependency.
energy_steering = ["live_metering"]
# Live moderation (extensions/live-moderation): `wai.live.moderation` — the room authority's
# signed review policy (WMP1: what is held for review or reviewed after delivery, by whom, each
# deadline and fallback, and the reason codes with their grounds), the statement (WMS1) that
# follows a `decision` event, notices and complaints (WMN1), lines of decisions by digest, the
# review state machine, gaps that withheld events leave explained by the decisions that restrict
# them, clips of a redacted take through live-recording's clip verifier, and the export of each
# restricting decision as a statement of reasons in the DSA Transparency Database's format,
# written by hand. SHA-256, Ed25519 and the key log only; no new dependency.
live_moderation = ["live_events", "live_room", "live_record"]
# Timed text and live captions (extensions/timed-text): `wai.text.timed` — the cue model,
# live caption revisions (`caption` events of wai.live.events with the WTX1 annotation),
# and their exact mappings onto WebVTT documents and segments, the IMSC1 Text Profile,
@ -699,6 +708,11 @@ name = "wai_energy_steering_vectors"
path = "src/bin/wai_energy_steering_vectors.rs"
required-features = ["energy_steering"]
[[bin]]
name = "wai_live_moderation_vectors"
path = "src/bin/wai_live_moderation_vectors.rs"
required-features = ["live_moderation"]
[[bin]]
name = "wai_timed_text_vectors"
path = "src/bin/wai_timed_text_vectors.rs"
@ -1375,6 +1389,11 @@ name = "energy_steering"
path = "tests/energy_steering.rs"
required-features = ["energy_steering"]
[[test]]
name = "live_moderation"
path = "tests/live_moderation.rs"
required-features = ["live_moderation"]
[[test]]
name = "timed_text"
path = "tests/timed_text.rs"

View file

@ -0,0 +1,75 @@
//! Conformance corpus for live moderation (`live-conformance/moderation/`, extensions/live-moderation.md §11).
//!
//! wai_live_moderation_vectors gen <corpus-dir> write vectors.json and every statement-of-
//! reasons export (.sor.json), from fixed seeds
//! wai_live_moderation_vectors verify <corpus-dir> every file regenerates byte for byte, no other
//! file but README.md is there, and every case
//! gets the outcome it states
//!
//! The generator and this verifier are one implementation. The corpus's second
//! implementation is `live-conformance/live_moderation_verify.py`, written from the extension's
//! text and sharing no code with this binary.
use std::collections::BTreeMap;
use std::path::Path;
use wai::live_moderation::corpus;
fn main() {
let a: Vec<String> = std::env::args().collect();
match (a.get(1).map(String::as_str), a.get(2)) {
(Some("gen"), Some(d)) => {
if let Err(e) = std::fs::create_dir_all(d) {
eprintln!("create {d}: {e}");
std::process::exit(1);
}
for (name, body) in corpus::generate() {
let p = Path::new(d).join(&name);
if let Err(e) = std::fs::write(&p, body) {
eprintln!("write {}: {e}", p.display());
std::process::exit(1);
}
}
println!("wrote {d}");
}
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
_ => {
eprintln!("usage: wai_live_moderation_vectors gen|verify <corpus-dir>");
std::process::exit(2);
}
}
}
fn verify(dir: &Path) -> i32 {
let mut files = BTreeMap::new();
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
eprintln!("read {}: {e}", dir.display());
return 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) => {
files.insert(name, s);
}
Err(err) => {
eprintln!("read {name}: {err}");
return 1;
}
}
}
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
let same = files == generated;
println!("{} every file regenerates byte for byte from its seeds, and no other file is there", if same { "PASS" } else { "FAIL" });
let fails = corpus::verify(&files);
for f in &fails {
println!("FAIL {f}");
}
println!("{} every case gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
(!same || !fails.is_empty()) as i32
}

View file

@ -217,6 +217,7 @@ pub const CAP_LIVE_RECORDING: &str = "wai.live.recording";
pub const CAP_LIVE_METERING: &str = "wai.live.metering";
pub const CAP_LIVE_STEERING: &str = "wai.live.steering";
pub const CAP_TEXT_TIMED: &str = "wai.text.timed";
pub const CAP_LIVE_MODERATION: &str = "wai.live.moderation";
/// One row per string SPEC §5 registers that is a payload, companion or record:
/// `(string, SPEC section, edition, registered)`.
@ -302,6 +303,7 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[
(CAP_LIVE_METERING, S_INTERACTIVE, None, "2026-10-18"),
(CAP_LIVE_STEERING, S_INTERACTIVE, None, "2026-10-18"),
(CAP_TEXT_TIMED, S_INTERACTIVE, None, "2026-10-17"),
(CAP_LIVE_MODERATION, S_INTERACTIVE, None, "2026-10-19"),
(CAP_AUDIO_IAMF, S_IMMERSIVE, None, "2026-06-17"),
(CAP_AUDIO_AC4, S_IMMERSIVE, None, "2026-06-17"),
(CAP_AUDIO_IVAS, S_IMMERSIVE, None, "2026-06-17"),
@ -351,7 +353,7 @@ const RECORDS: &[&str] = &[
CAP_ASSET_PROVENANCE, CAP_QUANTUM_CALIBRATION, CAP_QUANTUM_JOB,
CAP_VIDEO_MANIFEST, CAP_VIDEO_SSAI, CAP_VIDEO_KEYS, CAP_VIDEO_PACKAGE, CAP_VIDEO_CHANNEL,
CAP_LIVE_EVENTS, CAP_LIVE_ROOM, CAP_CAPTURE_TAKE, CAP_LIVE_COMPOSE, CAP_LIVE_RECORDING,
CAP_LIVE_METERING, CAP_LIVE_STEERING, CAP_TEXT_TIMED,
CAP_LIVE_METERING, CAP_LIVE_STEERING, CAP_TEXT_TIMED, CAP_LIVE_MODERATION,
];
/// A named identifier SPEC uses that has the shape of a capability string but is
@ -504,6 +506,7 @@ const LIVE_RECORDING: &str = "extensions/live-recording.md";
const TIMED_TEXT: &str = "extensions/timed-text.md";
const LIVE_METERING: &str = "extensions/live-metering.md";
const ENERGY_STEERING: &str = "extensions/energy-steering.md";
const LIVE_MODERATION: &str = "extensions/live-moderation.md";
const fn wire(name: &'static str, kind: WireKind, document: &'static str, section: &'static str,
status: WireStatus) -> WireIdentifier {
@ -513,8 +516,8 @@ const fn wire(name: &'static str, kind: WireKind, document: &'static str, sectio
/// The wire identifiers of the container, of its two delivery bindings
/// (http-delivery, moq-streaming-format), of the key discovery those bindings serve
/// (jwp-receipts §7), of packaging, of live events, of live rooms, of capture binding, of
/// live composition, of live recording, of timed text and of live metering, in document
/// order. The export lists them sorted by name and kind.
/// live composition, of live recording, of timed text, of live metering, of energy steering
/// and of live moderation, in document order. The export lists them sorted by name and kind.
pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
wire("WAI1", WireKind::Magic, "SPEC.md", "§2", WireStatus::Defined),
wire("WAI2", WireKind::Magic, "SPEC.md", "§7.1", WireStatus::Defined),
@ -589,6 +592,9 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
wire("WMR1", WireKind::Magic, LIVE_METERING, "§4.1", WireStatus::Defined),
wire("WSP1", WireKind::Magic, ENERGY_STEERING, "§3.1", WireStatus::Defined),
wire("WSD1", WireKind::Magic, ENERGY_STEERING, "§7.1", WireStatus::Defined),
wire("WMP1", WireKind::Magic, LIVE_MODERATION, "§3.1", WireStatus::Defined),
wire("WMS1", WireKind::Magic, LIVE_MODERATION, "§4.2", WireStatus::Defined),
wire("WMN1", WireKind::Magic, LIVE_MODERATION, "§5.1", WireStatus::Defined),
];
/// A string with the shape of a capability that the reference implementation uses

View file

@ -451,6 +451,15 @@ pub mod live_metering;
/// cbindgen:ignore
#[cfg(feature = "energy_steering")]
pub mod energy_steering;
// Live moderation (extensions/live-moderation.md): `wai.live.moderation` — the room authority's
// review policy, the statement that follows a `decision` event, notices and complaints, lines
// of decisions by digest, the review state machine, gaps explained by the decisions that
// restrict the events they withhold, clips of a redacted take, and each restricting decision's
// statement-of-reasons export. Rust-only, like the other live modules: its constants must not
// become macros in include/wai.h.
/// cbindgen:ignore
#[cfg(feature = "live_moderation")]
pub mod live_moderation;
// Timed text and live captions (extensions/timed-text.md): `wai.text.timed` — the cue model
// both WebVTT and the IMSC1 Text Profile express, live caption revisions as `caption`
// events of `wai.live.events` with the WTX1 annotation, and the exact mappings onto WebVTT

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1173,6 +1173,9 @@ pub struct PartFacts {
pub bytes: u64,
pub sha256: [u8; 32],
pub fetch: FetchRange,
/// The indexes of the chunks step (k) passed over as exempt (see [`verify_clip_exempting`]);
/// always empty under [`verify_clip`].
pub exempt: Vec<u64>,
}
/// What a verified clip states (§5.4, §6.2).
@ -1212,7 +1215,8 @@ struct Held {
}
/// §5.4 step 3 for part `k`: the facts, or the refusal.
fn verify_part(k: usize, p: &ClipPart, i: &PartInputs, room_id: &[u8; 32], clip: &Clip, interval: i128, jitter: i128) -> Result<PartFacts, (ClipPlace, Reason)> {
#[allow(clippy::too_many_arguments)]
fn verify_part(k: usize, p: &ClipPart, i: &PartInputs, room_id: &[u8; 32], clip: &Clip, interval: i128, jitter: i128, exempt: &dyn Fn(&Chunk) -> bool) -> Result<PartFacts, (ClipPlace, Reason)> {
let at_part = |r: Reason| (ClipPlace::Part(k), r);
let at_commit = |j: usize, r: Reason| (ClipPlace::Commit { part: k, index: j }, r);
// a. The device.
@ -1305,9 +1309,22 @@ fn verify_part(k: usize, p: &ClipPart, i: &PartInputs, room_id: &[u8; 32], clip:
if !covers || (trimmed && (wi, wo) != (span_in, span_out)) {
return Err(at_part(Reason::ClipWindowInvalid));
}
// k. The bytes.
// k. The bytes. A chunk the caller exempts is passed over: its bytes are neither required
// nor checked nor presented (live-moderation §8.3); none is exempt under `verify_clip`.
let mut presented = Vec::new();
let mut passed = Vec::new();
let (mut trim_in, mut trim_out) = (p.trim_in as usize, p.trim_out as usize);
for (j, c) in ch.iter().enumerate() {
if exempt(c) {
passed.push(c.index);
if j == 0 {
trim_in = 0;
}
if j == ch.len() - 1 {
trim_out = 0;
}
continue;
}
let at = (ClipPlace::Chunk { part: k, index: j }, Reason::ClipChunkMissing);
let b = i.bytes.get(j).and_then(Option::as_ref).ok_or(at)?;
if !c.is(b) {
@ -1319,7 +1336,7 @@ fn verify_part(k: usize, p: &ClipPart, i: &PartInputs, room_id: &[u8; 32], clip:
if i.bytes.len() > ch.len() {
return Err((ClipPlace::Chunk { part: k, index: ch.len() }, take::Reason::ChunkExtra.into()));
}
let shown = &presented[p.trim_in as usize..presented.len() - p.trim_out as usize];
let shown = &presented[trim_in..presented.len() - trim_out];
Ok(PartFacts {
take: p.take,
first: p.first,
@ -1330,12 +1347,21 @@ fn verify_part(k: usize, p: &ClipPart, i: &PartInputs, room_id: &[u8; 32], clip:
bytes: shown.len() as u64,
sha256: sha256(&[shown]),
fetch: fetch_range(room_id, &p.device, &p.take, p.first, count),
exempt: passed,
})
}
/// Verify a clip (§5.4): the room descriptor, the clip record, and for each of its parts in
/// order what [`PartInputs`] holds.
pub fn verify_clip(descriptor: &[u8], record: &[u8], parts: &[PartInputs]) -> ClipOutcome {
verify_clip_exempting(descriptor, record, parts, &|_| false)
}
/// [`verify_clip`] with step (k) passing over every chunk `exempt` names: its bytes are neither
/// required nor checked nor presented, and a trim at a chunk passed over is not applied. This
/// is the first change live-moderation §8.3 makes for a clip whose chunks a decision
/// restricts; the second, refusing bytes given for such a chunk, is that extension's.
pub fn verify_clip_exempting(descriptor: &[u8], record: &[u8], parts: &[PartInputs], exempt: &dyn Fn(&Chunk) -> bool) -> ClipOutcome {
let refuse = |at, reason| ClipOutcome::Refuse { at, reason };
// 1. The descriptor.
let d = match Descriptor::parse(descriptor) {
@ -1359,7 +1385,7 @@ pub fn verify_clip(descriptor: &[u8], record: &[u8], parts: &[PartInputs]) -> Cl
let jitter = d.recording.jitter_ms as i128 * 1000;
let mut facts = Vec::with_capacity(parts.len());
for (k, (p, i)) in clip.parts.iter().zip(parts).enumerate() {
match verify_part(k, p, i, &room_id, &clip, interval, jitter) {
match verify_part(k, p, i, &room_id, &clip, interval, jitter, exempt) {
Ok(f) => facts.push(f),
Err((at, r)) => return refuse(at, r),
}

View file

@ -214,26 +214,26 @@ impl Chains {
/// A take as written: what a cutter holds of it.
#[derive(Clone)]
struct WrittenTake {
device: u8,
take: [u8; 32],
pub(crate) struct WrittenTake {
pub(crate) device: u8,
pub(crate) take: [u8; 32],
/// The chunks its manifest lists.
chunks: Vec<Chunk>,
pub(crate) chunks: Vec<Chunk>,
/// The bytes of each chunk its manifest lists.
bytes: Vec<Vec<u8>>,
pub(crate) bytes: Vec<Vec<u8>>,
/// Every commitment the device signed, in order, a dropped one included, and each
/// one's payload.
commits: Vec<Vec<u8>>,
payloads: Vec<Commit>,
offset: i64,
pub(crate) commits: Vec<Vec<u8>>,
pub(crate) payloads: Vec<Commit>,
pub(crate) offset: i64,
}
/// A session written out: the package's files before it is sealed, and its takes.
struct Written {
descriptor: Vec<u8>,
room: [u8; 32],
files: BTreeMap<String, Vec<u8>>,
takes: Vec<WrittenTake>,
pub(crate) struct Written {
pub(crate) descriptor: Vec<u8>,
pub(crate) room: [u8; 32],
pub(crate) files: BTreeMap<String, Vec<u8>>,
pub(crate) takes: Vec<WrittenTake>,
}
fn write(s: &Scene) -> Written {
@ -495,6 +495,12 @@ fn lesson_session() -> Scene {
scene(vec![performer_take("lesson"), accompanist_take()])
}
/// The lesson session as written: the room, its chains and both takes. The moderation corpus
/// (extensions/live-moderation.md §11) redacts a chunk of the performer's take and clips over it.
pub(crate) fn lesson_written() -> Written {
write(&lesson_session())
}
/// Recompute `LISTING` after a file changed.
fn relist(mut files: BTreeMap<String, Vec<u8>>) -> BTreeMap<String, Vec<u8>> {
files.remove(LISTING);

View file

@ -223,7 +223,7 @@ fn every_decoded_output_states_a_sha256() {
("pin-conformance", "parameter-set pins: every digest there is a SHA-256 of a parameter set"),
("ops-conformance", "integer operators: every operator, stage and graph vector, the lower_* decodes of the sets under sets/ among them, states its output integers in full or its refusal code, never a digest of them; certificates/ states each lowered set's SHA-256 (a parameter-set identity) and holds the lowered graphs as bytes, compared byte for byte; the output digests a capability decoded through its lowering reproduces (integer-operators §14.4) are stated by that capability's own corpus"),
("signer-conformance", "key logs, grants and statuses: signed documents, no decoded output"),
("live-conformance", "live events, rooms, takes, compositions, recordings, captions, metering and steering: signed records, commitments, packages, clips, cues and their renderings, per-hop records, ledgers and session receipts with their units and joules recomputed, steering policies and decisions with each pathway's evidence and the steering manifests recomputed byte for byte, and their verdicts, and layouts and mixes stated in full, rectangle for rectangle and sample for sample; nothing decoded against a parameter set"),
("live-conformance", "live events, rooms, takes, compositions, recordings, captions, metering, steering and moderation: signed records, commitments, packages, clips, cues and their renderings, per-hop records, ledgers and session receipts with their units and joules recomputed, steering policies and decisions with each pathway's evidence and the steering manifests recomputed byte for byte, review policies, decisions, notices and the items' timelines with each statement-of-reasons export written byte for byte, and their verdicts, and layouts and mixes stated in full, rectangle for rectangle and sample for sample; nothing decoded against a parameter set"),
("http-conformance", "objects and head proofs for transport: no case decodes a payload, and the component a fetch case picks is stated by its SHA-256"),
("cache-conformance", "cache rules: a driver, its controls and their reports, with no objects of its own"),
("byte-exact-conformance", "goldens over tests/vectors, whose sidecars state the SHA-256"),

View file

@ -0,0 +1,53 @@
//! `wai.live.moderation` (extensions/live-moderation.md): the committed corpus
//! `live-conformance/moderation/` regenerates byte for byte from its fixed seeds, holds no
//! other file, every case in it gets the outcome it states, and the extension's §10 lists
//! exactly the reasons the reference reports. The generator itself asserts that the corpus
//! gives every one of them.
#![cfg(feature = "live_moderation")]
use std::collections::BTreeMap;
use std::path::PathBuf;
use wai::live_moderation::{corpus, Reason};
fn wai_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn committed() -> BTreeMap<String, String> {
let dir = wai_dir().join("live-conformance/moderation");
std::fs::read_dir(&dir)
.expect("the committed corpus")
.flatten()
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
.filter(|(n, _)| n != "README.md")
.map(|(n, p)| (n, std::fs::read_to_string(p).expect("a text file")))
.collect()
}
#[test]
fn the_committed_corpus_regenerates_from_its_seeds() {
let files = committed();
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
let extra: Vec<&String> = files.keys().filter(|k| !generated.contains_key(*k)).collect();
let missing: Vec<&String> = generated.keys().filter(|k| !files.contains_key(*k)).collect();
let differ: Vec<&String> = generated.iter().filter(|(k, v)| files.get(*k).is_some_and(|f| f != *v)).map(|(k, _)| k).collect();
assert!(extra.is_empty() && missing.is_empty() && differ.is_empty(), "extra {extra:?}, missing {missing:?}, differ {differ:?}: run `wai_live_moderation_vectors gen`");
}
#[test]
fn every_case_gets_its_stated_outcome() {
let fails = corpus::verify(&committed());
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
/// §10 of the extension lists exactly the codes `Reason::ALL` has, each once, in order.
#[test]
fn the_extension_lists_the_reasons() {
let text = std::fs::read_to_string(wai_dir().join("extensions/live-moderation.md")).expect("the extension");
let at = text.find("\n## 10. Reasons").expect("a §10");
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
let listed: Vec<String> =
text[at..end].lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect();
let want: Vec<String> = Reason::ALL.iter().map(|r| r.code().to_string()).collect();
assert_eq!(listed, want, "§10's table and Reason::ALL differ");
}