wai.live.moderation: review policies, signed decisions, explained redactions, and DSA statements of reasons #337
35 changed files with 8261 additions and 40 deletions
29
.github/workflows/wai-conformance.yml
vendored
29
.github/workflows/wai-conformance.yml
vendored
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
24
wai/SPEC.md
24
wai/SPEC.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
842
wai/extensions/live-moderation.md
Normal file
842
wai/extensions/live-moderation.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
810
wai/live-conformance/live_moderation_verify.py
Normal file
810
wai/live-conformance/live_moderation_verify.py
Normal 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]))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
116
wai/live-conformance/moderation/README.md
Normal file
116
wai/live-conformance/moderation/README.md
Normal 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.
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"}
|
||||
3120
wai/live-conformance/moderation/vectors.json
Normal file
3120
wai/live-conformance/moderation/vectors.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
75
wai/wai-rs/src/bin/wai_live_moderation_vectors.rs
Normal file
75
wai/wai-rs/src/bin/wai_live_moderation_vectors.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1632
wai/wai-rs/src/live_moderation.rs
Normal file
1632
wai/wai-rs/src/live_moderation.rs
Normal file
File diff suppressed because it is too large
Load diff
1339
wai/wai-rs/src/live_moderation/corpus.rs
Normal file
1339
wai/wai-rs/src/live_moderation/corpus.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
53
wai/wai-rs/tests/live_moderation.rs
Normal file
53
wai/wai-rs/tests/live_moderation.rs
Normal 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");
|
||||
}
|
||||
Loading…
Reference in a new issue