wai.media.synthesis: origin claims for every kind of media, two marking layers, AI participants, reviews and labels #387

Merged
dcharlot merged 11 commits from wai/synthesis into main 2026-10-21 10:44:43 -04:00
37 changed files with 6911 additions and 48 deletions

View file

@ -114,6 +114,10 @@ on:
# reports and its §4 the derivations, edge_verify.py reads both tables, and the
# registry's wire identifiers test reads its sections.
- 'wai/extensions/deriving-edge.md'
# tests/synthesis.rs requires this extension's §12 to list the codes the reference
# reports and its §10.2 table to pin the reference's icons, synthesis_verify.py reads
# both, and the registry's wire identifiers test reads its sections.
- 'wai/extensions/synthesis.md'
- 'wai/wai-web/**'
# The audio provenance check below reads the corpora above with this tool.
- 'wai/tools/wai_audio_provenance_check.py'
@ -785,7 +789,8 @@ jobs:
# energy-steering corpus), and live_moderation_verify.py (the live-moderation corpus), and
# e2ee_verify.py (the end-to-end encrypted rooms corpus), live_watermark_verify.py (the
# forensic-watermark corpus), live_ads_verify.py (the live-ads corpus), c2pa_live_verify.py
# (the live-provenance corpus) and edge_verify.py (the deriving-edge corpus).
# (the live-provenance corpus), edge_verify.py (the deriving-edge corpus) and
# synthesis_verify.py (the synthesis 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:
@ -1121,6 +1126,26 @@ jobs:
(cd wai-rs && cargo test --no-default-features --features live_provenance --lib live_provenance::)
(cd wai-rs && cargo test --no-default-features --features live_provenance --test live_provenance)
python3 -I live-conformance/c2pa_live_verify.py live-conformance/c2pa-live
# wai.media.synthesis (extensions/synthesis.md): the reference module's unit tests (the
# pinned derivations by their rules, the class lattice, a generator's envelope) and
# tests/synthesis.rs (the committed corpus regenerates byte for byte from its fixed seeds
# and no other file is there, wai-rs gives every case its stated outcome and statements,
# §12 lists exactly the reference's codes, each of which the corpus gives, and §10.2's
# table pins exactly the reference's icons); then the corpus's second implementation,
# synthesis_verify.py, written from the extension's text and sharing no code with wai-rs,
# which runs the pinned derivations and writes each digital source type and C2PA
# statement itself, verifies each room with live_rooms_verify.py and every record with
# WAI's strict Ed25519 rule and BLAKE3 from claim-conformance, reads the extension's icon
# and reason tables, and requires every mutated copy of the corpus to fail; standard
# library only, run isolated (-I).
- name: synthesis provenance · wai-rs and synthesis_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features synthesis --lib synthesis::)
(cd wai-rs && cargo test --no-default-features --features synthesis --test synthesis)
python3 -I live-conformance/synthesis_verify.py live-conformance/synthesis
# wai.live.e2ee (extensions/confidentiality.md): the corpus's second implementation,
# e2ee_verify.py, written from the extension's text and the documents it reads and
# sharing no code with the reference (joule-moq's secure_objects and joule-moq-mls,

View file

@ -1021,6 +1021,11 @@ impl Grant {
return bad;
}
let Some(attrs) = read_attrs(&attrs) else { return bad };
// live-rooms §4.4: `ai`, marking an AI participant, is a model's digest as 64 lowercase
// hexadecimal digits.
if attrs.iter().any(|(k, v)| k == "ai" && !(v.len() == 64 && v.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)))) {
return bad;
}
// Step 6.
if exp - nbf > MAX_GRANT_S {
return Err(Reason::GrantTooLong);

View file

@ -6,6 +6,59 @@ wire format may change and such changes are recorded here.
## v0.4.0 — current
### `wai.media.synthesis`: what any piece of media is, in two marking layers, with reviews and labels
- **The extension** ([synthesis](extensions/synthesis.md)) registers the record
`wai.media.synthesis`. An **origin claim** (`wai-origin`) says of one object, group or track
that it is captured, reconstructed (a codec, neural or not, rebuilding what was captured),
assistive (an edit that does not substantially alter its input) or synthesized (generated).
It names its step (`capture`, `convert`, `assist`, `generate`), the models the step ran, the
oversight of a generation, and each input by its content hash and the link hash of its own
claim. Its class is the greatest of its step's and its inputs', so it rises along a chain of
derivations and never falls; a transcode of a generated video is synthesized. A codec is a
conversion: it names no model and keeps its input's class and source, as energy-binding §6.1
has a decode.
- **IPTC and C2PA as functions of the claim.** The digital source type (`digitalCapture`,
`algorithmicallyEnhanced`, `trainedAlgorithmicMedia`, `compositeWithTrainedAlgorithmicMedia`,
`compositeSynthetic` and the rest) follows from the step, the inputs, the models and the
oversight; the C2PA statement is the actions (`c2pa.created` with the source, or
`c2pa.opened` then `c2pa.converted` or `c2pa.edited`) and one `c2pa.ai-disclosure` per trained
model, written in JCS. The mapping keeps timed-text's for captions.
- **Assistive claims checked, not trusted.** Two integer derivations are pinned: a 2× upscale
with `wai.video.int_motion`'s half-sample rounding, and a 3 × 3 binomial denoise. Evidence
names the input's and the output's sample digests; a verifier holding the input's decoded
samples recomputes the output and refuses a mismatch. A float or unpinned step states
`attest` and is reported as asserted; a policy can require proof.
- **Two marking layers.** The signed claim is the first; the second is a watermark whose 8-byte
payload is the claim's class and the head of its link hash. A marking record names the scheme
and the payload embedded, a detection record what a detector read, and a verifier refuses a
detection that disagrees with the claim or finds no mark in a marked object, and, under a
policy that asks for both layers, a synthesized object with no marking. No watermark scheme is
registered: the scheme is a slot a later revision fills.
- **AI participants in rooms.** live-rooms §4.4's grant attribute `ai` names the model that acts
for a participant; its chat, reactions and captions state origin 2 or 3 with that model, and
the room verifier refuses one that does not (`ai_unmarked`, live-rooms §9.2 step 6). Chat and
reaction events of live-events gain an optional `origin` and `model` after their last field.
Every track an AI participant publishes needs a synthesized track claim naming its model.
- **Certified synthesis.** A review (`wai-review`) is a signed verdict over one exact
reconstruction at `decode-equivalence` or `generation-equivalence`. A player presents it only
where its latest presentation of the object holds that tier on its decode path and output the
reconstruction reviewed, so one review holds for every viewer of exact output.
- **Labels.** A label policy (`wai-label-policy`) says which classes are labelled, with which
visual and audible assets, at what interval and grace; a player's signed log (`wai-player`) of
presentations, labels shown, resumptions after a break and reviews presented is held to it:
a label at first exposure, after each interruption and within the interval. The EU icons the
Code of Practice's Annex 1 publishes are pinned by SHA-256; no audible EU label is published.
- **Reference and corpus.** `wai-rs` feature `synthesis` (`src/synthesis.rs`).
`live-conformance/synthesis/`, written from fixed seeds by `wai_synthesis_vectors`: a
capture's chain through a neural codec and both pinned derivations, a float super-resolution
asserted, generated media in both layers, a room with an AI host, a certified and labelled
session, and one refused case per reason. Checked by `tests/synthesis.rs` and by
`live-conformance/synthesis_verify.py`, a second implementation that shares no code with
`wai-rs`, verifies each room with `live_rooms_verify.py`, runs the derivations and writes each
statement itself, and requires every mutated copy of the corpus to fail. The registry lists
the record and its document kinds.
### `wai.edge.derivation`: an edge that derives, under its own receipts, chained to its source
- **The extension** ([deriving-edge](extensions/deriving-edge.md)) registers the record

View file

@ -43,8 +43,8 @@ Every registered string has one kind.
`wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording`,
`wai.live.metering`, `wai.live.steering`, `wai.live.moderation`, `wai.live.e2ee`,
`wai.live.watermark`, `wai.live.ads` and `wai.live.provenance`; timed text,
`wai.text.timed`; the deriving edge's record, `wai.edge.derivation`). It names no payload
format.
`wai.text.timed`; the deriving edge's record, `wai.edge.derivation`; the origin claims of
media, `wai.media.synthesis`). It names no payload format.
A derivation or record string is not a payload capability, so it is not
dispatchable (SPEC §4): dispatch never takes an envelope's capability that names
@ -209,6 +209,7 @@ and a role are fixed once registered.
| `wai.live.room` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.steering` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.watermark` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.media.synthesis` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.mesh.draco` | payload | registered | volumetric | standalone | royalty-free | standard-unclassified | — | §5 Immersive audio, cinema, volumetric, haptics, HDR |
| `wai.mesh.glb` | payload | registered | volumetric | standalone | royalty-free | standard-unclassified | — | §5 Interactive, temporal & computed content |
| `wai.meta.dovi` | companion | registered | metadata | companion | proprietary | standard-unclassified | — | §5 Immersive audio, cinema, volumetric, haptics, HDR |
@ -361,11 +362,17 @@ and a role are fixed once registered.
| `wai` | catalog | defined | moq-streaming-format §3 |
| `wai-c2pa` | catalog | defined | live-provenance §10.2 |
| `wai-derivation` | document-kind | defined | deriving-edge §5.1 |
| `wai-detection` | document-kind | defined | synthesis §7.3 |
| `wai-key-status` | document-kind | defined | jwp-receipts §7.9 |
| `wai-label-policy` | document-kind | defined | synthesis §10.1 |
| `wai-marking` | document-kind | defined | synthesis §7.2 |
| `wai-module-grant` | document-kind | defined | jwp-receipts §7.9 |
| `wai-module-grants` | document-kind | defined | http-delivery §8.4 |
| `wai-origin` | document-kind | defined | synthesis §3.1 |
| `wai-player` | document-kind | defined | synthesis §10.3 |
| `wai-receipt-index` | document-kind | defined | http-delivery §8.2 |
| `wai-receipts` | catalog | defined | moq-streaming-format §4.1 |
| `wai-review` | document-kind | defined | synthesis §9.1 |
| `wai1` | codecs-prefix | defined | http-delivery §4.1 |
| `wai1` | fourcc | provisional | http-delivery Appendix B |
| `wai2` | codecs-prefix | defined | http-delivery §4.1 |

View file

@ -595,8 +595,8 @@ kind:
`wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording`,
`wai.live.metering`, `wai.live.steering`, `wai.live.moderation`, `wai.live.e2ee`,
`wai.live.watermark`, `wai.live.ads` and `wai.live.provenance`, and timed text,
`wai.text.timed`; and an edge's derivation record, `wai.edge.derivation`), which names no
payload format.
`wai.text.timed`; an edge's derivation record, `wai.edge.derivation`; and the origin claims of
media, `wai.media.synthesis`), 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
@ -1118,6 +1118,29 @@ whose proof is an RFC 9942 receipt of inclusion in the repository's RFC 9162 log
boxes of each segment ride a provenance track beside the media track:
[`extensions/live-provenance.md`](extensions/live-provenance.md).
And for the **origin** of any media — `wai.media.synthesis`: an origin claim says of one
object, one group or one track that it is captured, reconstructed (a codec, neural or not,
rebuilding what was captured), assistive (an edit that does not substantially alter its input)
or synthesized (generated). It names each input by the link hash of the input's own claim, and
its class is the greatest of its step's and its inputs', so the class rises along every chain
of derivations and never falls; a generator's own envelope (a `GenerativeDeterministic` or
`Behavioral` capability, or `intent` `create`) cannot be claimed as anything else. The claim's
IPTC digital source type and its C2PA statement (the actions, and one `c2pa.ai-disclosure`
assertion per trained model) are functions of it; a codec's step is a conversion, which asserts
no source of its own, as a decode does under energy-binding §6.1. An assistive claim over one
of two pinned integer derivations (a 2× upscale and a 3 × 3 denoise) is proved by recomputing
the output from the input's decoded samples; any other assistive step can only assert it. The
signed claim is the first of two marking layers; the second is a watermark whose payload is the
claim's class and the head of its link hash, and a detection that disagrees with the claim is
refused (the watermark algorithm is a slot a later revision fills). A room's AI participant,
marked in its grant, states its machine origin in its chat, reactions and captions, and labels
every track it publishes. A review signed over one exact reconstruction is presented only by a
player whose decode path holds the review's tier and whose output is that reconstruction, so one
review holds for every viewer of exact output. A label policy says when a label is due (first
exposure, after each interruption, at an interval), with the EU icons pinned by digest, and a
player's signed log shows whether it was shown:
[`extensions/synthesis.md`](extensions/synthesis.md).
### Payloads of the WAI-native integer codecs
This revision pins a payload format to each WAI-native integer codec whose row named

View file

@ -176,7 +176,17 @@ producer **MUST NOT** write a summary its message contradicts.
### 4.4 `chat`
One text field: the message.
| field | size | meaning |
|---|---|---|
| `text` | text | the message |
| `origin` | u8 | optional: 1 human, 2 machine, 3 machine-reviewed |
| `model` | 32 | only when `origin` is 2 or 3: a digest naming the model that produced the message |
`origin` is present exactly when a byte follows `text`; a chat without it states no origin.
`origin` and `model` mean what a caption's mean (§4.10): they are the message's
machine-generation disclosure, and a sink that labels machine-generated messages takes the
label from them. A room may require them of a principal: [live-rooms](live-rooms.md) §4.4
marks an AI participant, whose every chat states origin 2 or 3.
### 4.5 `reaction`
@ -186,6 +196,8 @@ One text field: the message.
| `value` | u16 | not 0: for emoji and raise, that draft's §4.5 integer encodings; for the others, the application's |
| `flags` | u8 | bit 0: a target follows; bit 1: this retracts the reaction it repeats; every other bit 0 |
| `target` | 32 | only when bit 0 is set: the digest of the event reacted to |
| `origin` | u8 | optional, as a chat's (§4.4): present exactly when a byte follows the field before it |
| `model` | 32 | only when `origin` is 2 or 3 |
A reaction without a target is **standalone**. Standalone reactions are held to the
policy's reaction rate (§6.3), as that draft holds them to one per second per device
@ -633,7 +645,9 @@ In a sealed package the events are one component, which
forgeries that a lenient Ed25519 verifier accepts, a broken chain, a reorder, a replayed
`seq`, `t` going back, an oversize payload, a kind not permitted, both rates exceeded,
invalid UTF-8 and overlong text, truncated and malformed records, an unknown version and
kind, the wrong room, malformed payloads and a stated tally the replay does not reach.
kind, the wrong room, malformed payloads (a chat stating a machine origin without its
model among them) and a stated tally the replay does not reach. Chats and a reaction
stating each origin have an accepted vector.
- **policy cases**: policy records, read (§5) or refused with their reason.
- **binding cases**: tags, boxes and MSF records, read back to their record (§8) or
refused with their reason.

View file

@ -276,6 +276,20 @@ device captures for, as 64 lowercase hexadecimal digits of its key; and `minor`
or `false`), whether the authority marks that principal a minor. Attributes are signed with the grant,
so a layout computed from them is computed from what the authority stated.
One more attribute marks an **AI participant**: `ai`, the digest naming the model that acts
for the principal (a prior's digest, SPEC §3.1), as 64 lowercase hexadecimal digits. A grant
whose `ai` is not that form breaks this section's rule (§4.3 step 5, `grant_invalid`). The
authority states it, as it states `minor`; a principal is an AI participant from the instant
a grant carrying it is its grant in effect until one without it is. An AI participant's
`chat`, `reaction` and `caption` events state that a machine produced them, with that model
(§9.2 step 6), and the tracks it publishes are synthesized media
([synthesis](synthesis.md) §8). A relay that forwards a room's events **MUST NOT** forward
one that §9.2 step 6 refuses. A sink **MUST** show, with every presence, voice, avatar and
message of an AI participant, that it is an AI system: the information Regulation (EU)
2024/1689 Article 50(1) asks a system that interacts with people to give. The sink's showing
it is an engineering requirement of this extension, and is not a statement about the
Regulation.
### 4.5 How a receipt names a grant
A receipt that names the grant it was carried under does so with the grant reference
@ -598,6 +612,10 @@ record, in the merged order:
5. A `control` event of such a principal needs a power of its grant in effect (§3.3):
`record_start` and `record_stop` the record power, any other command the direct power.
Otherwise `power_not_granted`.
6. A `chat`, `reaction` or `caption` event of a principal whose grant in effect carries
`ai` (§4.4) states origin 2 (machine) or 3 (machine-reviewed), with the model `ai`
names (live-events §4.4, §4.5, §4.10). One that states no origin, origin 1, or another
model is `ai_unmarked`.
Each principal's effective policy at `t` is so the policy of its grant in effect at `t`,
and the room keys' is the authority's. Each record is reported with the grant it was
@ -904,6 +922,7 @@ The codes this extension adds, each with where it is raised:
| `participant_limit` | §3.5, §7.2 `join` |
| `outside_grant_window` | §4.3, §7.2, §9.2 step 4 |
| `power_not_granted` | §9.2 step 5 |
| `ai_unmarked` | §4.4, §9.2 step 6 |
| `publish_not_permitted` | §7.2 `publish`, `grant_exchange`, `takeover` |
| `profile_not_granted` | §7.2 `publish`, `grant_exchange`, `takeover` |
| `already_published` | §7.2 `publish` |
@ -953,8 +972,11 @@ key-log head or none, and chains, with the outcome §9.4 gives them.
- **Gap**: a chain with a record not delivered.
- **Refused**: each reason of §11 at least once, each vector a control of one defect, and
the codes of live-events §9 that a descriptor, a grant or a record of a room raises;
among them a grant with the seek power whose role lacks it, and a grant and a role with
a power above bit 4.
among them a grant with the seek power whose role lacks it, a grant and a role with
a power above bit 4, a grant whose `ai` attribute does not read, and an AI participant's
chat that states no origin, one that states origin 1 and one that names another model.
- **AI participant**: a host whose grant names the model that acts for it, its chat and
its caption stating that model, beside a person whose chat and reaction state no origin.
- **Proofs** (§10.5), in the file's `proofs`: a grant of a room presented with a proof over
MOQT (verb `SETUP`) and over HTTP (a WHIP `POST` and a `DELETE`, in their header forms),
each accepted; and, each refused with its reason, a grant presented with no proof, a

760
wai/extensions/synthesis.md Normal file
View file

@ -0,0 +1,760 @@
# WAI Extension: Synthesis Provenance
> Status: Draft. Registers the record `wai.media.synthesis`: what a piece of media is, for every
> kind of media WAI carries. An **origin claim** says of one object, one group or one track that
> it is **captured**, **reconstructed** (a codec, neural or not, rebuilding what was captured),
> **assistive** (an edit that does not substantially alter its input, such as an upscale or a
> denoise) or **synthesized** (generated). The claim names each input by the link hash of the
> input's own claim, and its class is the greater of the class its own step gives and its
> inputs' classes, so the class rises along every chain of derivations and never falls. The
> claim restates the content's IPTC digital source type, which is a function of the claim, and
> so is its C2PA statement: the actions and the `c2pa.ai-disclosure` assertions a manifest
> carries. An assistive claim carries **evidence**: where its step is one of the two integer
> derivations this extension pins, a verifier holding the input's decoded samples recomputes the
> output and compares it, so the claim that the edit is assistive is checked, not trusted. The
> signed claim is the first of two **marking layers**; the second is a watermark whose payload is
> a function of the claim, and a verifier refuses a detection that disagrees with it. The
> watermark algorithm itself is a slot this revision leaves to a later one. An **AI participant**
> of a room is marked in its grant, and its messages, captions and tracks carry its machine
> origin. A **review** is a signed verdict over one exact reconstruction, which a player presents
> only for a presentation whose decode path holds the review's tier, so one review holds for
> every viewer of that output. A **label policy** says when a label must be shown, and a player's
> signed **log** shows whether it was. No new transport. The reference implementation is the
> `synthesis` module of `wai-rs` (feature `synthesis`); the corpus is
> [`live-conformance/synthesis/`](../live-conformance/synthesis/), and its second
> implementation is `live-conformance/synthesis_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
>
> Texts read for this revision, each pinned to the version named: the Code of Practice on
> Transparency of AI-Generated Content, final text of 10 June 2026, Section 1 Measures 1.1 to 1.3
> and Section 2 Measures 1.1 and 1.2 and its Annex 1; the EU icons the Commission publishes for
> that Annex, as fetched on 21 October 2026 from the page it last updated on 13 October 2026
> (§10.2); Regulation (EU) 2024/1689, Article 50, paragraphs 2, 4 and 5 as that Code reproduces
> them, and the last sentence of paragraph 2 in a reproduction of the Official Journal text (the
> Official Journal itself could not be fetched for this revision); the C2PA Technical
> Specification, version 2.4, §6.4, §18.15.2, §18.15.4.5 and §18.28; and the IPTC Digital Source
> Type vocabulary, whose most recent concept change is dated 2024-10-23. A section number of one
> of those texts in this extension is that version's.
## 1. Scope
In scope: the records and their encodings (§2); the classes, the steps and the origin claim
(§3); the two pinned integer derivations and the evidence that makes an assistive claim
checkable (§4); the digital source type (§5) and the C2PA statement (§6) a claim gives; the
two marking layers, their records and their agreement (§7); AI participants of rooms (§8); the
review and when a player presents it (§9); when a label is shown, the EU icons pinned by digest,
and a player's log (§10); verification (§11).
Out of scope: a transport; how a generator, a codec or an editor works; the watermark algorithm
of the second marking layer and its detector, which a later revision registers in the slot of
§7.4; how a manifest is assembled, embedded and signed, which is C2PA's and
[provenance](provenance.md)'s; key discovery for the signers of these records, which a verifier
takes from [jwp-receipts](jwp-receipts.md) §7 as it does for any other key; and the rendering of a
label, beyond the asset it shows and the instants it shows it at.
Relationship to the core spec: `wai.media.synthesis` 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 [timed-text](timed-text.md) and [live-events](live-events.md): a caption's
`origin` (live-events §4.10), and a chat's or a reaction's (§4.4, §4.5 there), is this
extension's class for a text event, with no record of its own: origin 1 is a person's text,
class `captured` with source `digitalCreation`; origin 2 a machine's, class `synthesized`
generated with oversight `fully_autonomous`; origin 3 a machine's reviewed by a person, class
`synthesized` with oversight `human_validated`. Timed-text §10.3's export is then §5's and §6's
for such a step.
Relationship to [energy-binding](energy-binding.md) §6.1: a decode, and any codec, reconstructs;
it does not create, and a neural codec does not make its output AI-generated. This extension
keeps that: a codec is a `convert` step (§3.2), which names no model, asserts no source of its
own and keeps its input's class, and its C2PA statement is `c2pa.opened` and `c2pa.converted`
with no digital source type (§6).
Relationship to [determinism-tiers](determinism-tiers.md): a pinned derivation of §4 is integer
throughout, so its output is a pure function of its input's samples, and a claim over it is
checked by recomputation, at the tier `replicate` promises. A review (§9) is presented only where
the presentation holds the review's tier, under the rule of determinism-tiers §2 (*What a tier
covers*): a tier covers what the sink emits.
Duties this extension supports: the two machine-readable marking layers of the Code of Practice
(Section 1, Measure 1.1: Sub-measure 1.1.1, signed and time-stamped metadata; Sub-measure 1.1.2,
an imperceptible watermark) and its richer provenance information (Measure 1.3: the model, the
timestamp, the type of operations); the disclosure of AI systems that interact with people
(Article 50(1)); and the placement of a label at first exposure, after interruptions and at
regular intervals (Section 2, Sub-measures 1.2.1 to 1.2.3). WAI states what its records carry and
what a verifier checks. It makes no statement about any regime, and a deployment confirms what a
regime asks of it against that regime's text.
## 2. Records
Every record of this extension is a JSON object (RFC 8259) under the encodings of
[jwp-receipts](jwp-receipts.md) §2.4.3: byte strings in lowercase hexadecimal; integers unsigned,
at most 2^53 − 1, in plain decimal; no other number; no boolean; a **term** and a **date-time**
as defined there. A record is read as jwp-receipts §3.1 step 1 reads a claim: RFC 8259's grammar,
no member named twice in one object, at most 32 levels of nesting. Each record has exactly the
members its section lists, no member missing and none added, at any level, and a `kind` naming
it; one that does not is `record_malformed`.
Every record carries `signer_pubkey` (32 bytes, the Ed25519 key of RFC 8032 that signs it) and
`sig` (64 bytes):
```
sig = Ed25519(signer_key, "wai:synthesis\x01" || JCS(record without "sig"))
link_hash = BLAKE3("wai:synthesis-link\x01" || JCS(record))
```
JCS is RFC 8785, which on these values reduces to what jwp-receipts §2.4.3 states. The signature
is verified strictly ([SPEC](../SPEC.md) §7 *Signatures*): a key that fails step 1 or 2 is
`bad_key`, and a signature that fails a later step `bad_signature`. A record is named by its
link hash wherever another names it. A record whose members carry `issued_at` holds a date-time
there, the instant its signer signed it: the time stamp the Code of Practice's Sub-measure 1.1.1
asks for, self-asserted.
A digest of samples is SHA-256 (FIPS 180-4) over a canonical buffer: for an image, the `RGB8`
buffer of [reconstruction-binding](reconstruction-binding.md) §2 (row-major, three bytes a
pixel, no row padding); for another medium, the canonical buffer its capability's
`*-equivalence` names ([determinism-tiers](determinism-tiers.md) §2). An object's **content
hash** is jwp-receipts §2.1's.
## 3. Classes and the origin claim
### 3.1 The origin claim
An origin claim, `kind` `"wai-origin"`, has these members:
| member | type | meaning |
|---|---|---|
| `kind` | string | `"wai-origin"` |
| `subject` | object | what it is about: `{"type": "object", "content_hash"}`, an object by its content hash; `{"type": "group", "receipt"}`, a group by its group receipt's link hash (jwp-receipts §3 step 3); or `{"type": "track", "track", "from_group"}`, a track by its digest (live-rooms §6.2: SHA-256 over the rendering of its namespace, one zero byte and its name) from the group `from_group` on |
| `media` | string | `audio`, `video`, `image` or `text` |
| `class` | string | `captured`, `reconstructed`, `assistive` or `synthesized` (§3.2) |
| `step` | string | `capture`, `convert`, `assist` or `generate` (§3.2) |
| `operation` | term | what ran: a capability or derivation string, or a term naming the type of operation (`upscale`, `inpainting`, `speech-synthesis`) |
| `record` | 32 B hex or `null` | the identity of a record that derived the subject, where its deriver signs one; `null` otherwise |
| `source` | string | the subject's digital source type (§5) |
| `models` | array | the models the step ran, at most 16, each `{"digest", "type"}`: a prior's digest (SPEC §3.1) and `trained` (a model derived from sampled content) or `algorithmic` (one that is not); no digest twice |
| `oversight` | string or `null` | for `generate`: `fully_autonomous`, `prompt_guided` or `human_validated` (C2PA 2.4 §18.28.4); otherwise `null` |
| `inputs` | array | the objects the step read, at most 16, each `{"content_hash", "origin", "class", "source"}`: its content hash, the link hash of its own origin claim or `null` where it has none, and that claim's class and source, restated; no content hash twice |
| `evidence` | object or `null` | for `assist`: §4.3; otherwise `null` |
| `issued_at` | date-time | when the signer signed it |
| `signer_pubkey`, `sig` | | §2 |
`media`, `class`, `step`, `oversight`, a model's `type` and an input's `class` hold only the
values the table names, and `source` and an input's `source` only the codes of §5. Otherwise
`record_malformed`.
Who signs: the party that performed the step: the capturing device or its operator, the
encoder, the editor, the generator's provider. A claim carries its signer's authority and no
more; which signers a verifier trusts is its policy.
A verifier is given at most one origin claim for one subject; a second for a subject an earlier
claim names is `claim_conflict`.
### 3.2 Steps and the class lattice
The classes are ordered: `captured` < `reconstructed` < `assistive` < `synthesized`. Each step
gives a class, and constrains the claim:
| step | class it gives | inputs | models | `oversight` | `evidence` |
|---|---|---|---|---|---|
| `capture` | `captured` | none | none | `null` | `null` |
| `convert` | `reconstructed` | at least one | none | `null` | `null` |
| `assist` | `assistive` | exactly one | any | `null` | an object |
| `generate` | `synthesized` | any | at least one | not `null` | `null` |
A `capture` states as its `source` one of `digitalCapture`, `computationalCapture`,
`screenCapture` or `digitalCreation`: a recording of the world, or a person's work made without
a generative model. A claim that breaks the table is `record_malformed`.
- **capture** records the world, or a person's authorship: a camera, a microphone, a screen
recording, a person typing or drawing.
- **convert** rebuilds or rearranges its inputs without generating or enhancing anything: an
encode, a decode, a transcode, a re-package, a decoder's reconstruction (neural or not), and a
derivation that copies samples or composes them (crop, rotation, grid or overlay composition,
[derived-tracks](derived-tracks.md)). A codec names no model: its parameter set is pinned in
its envelope, and it reconstructs what its input holds.
- **assist** edits one input without substantially altering it: an upscale, a denoise, a colour
correction. It is the step whose claim needs evidence (§4).
- **generate** produces content by a model: a generated picture, voice or video, an inpainting,
a voice clone, a talking head, a frame generated between two others, an avatar a model drives.
The **class of a claim** is the greatest of its step's class and its inputs' stated classes. A
claim whose `class` is another is `class_mismatch`. So a conversion of a synthesized input is
synthesized, and a transcode, a clip or a re-package can never lower what its input was.
### 3.3 Checks against what a verifier holds
- **Inputs.** For each input whose `origin` names a claim the verifier holds: that claim's
subject is the object of the input's `content_hash`, and its `class` and `source` are the
input's. Otherwise `input_mismatch`. An input whose claim is not held is taken as stated.
- **The subject's own envelope.** Where the subject is an object and the verifier holds its
envelope (SPEC §2): an envelope whose capability's registry class is `GenerativeDeterministic`
or `Behavioral` (SPEC §7), or whose manifest's `intent` is `create`, is generated by every sink
that presents it, and its claim's `step` is `generate`. A claim of another step over such an
envelope is `class_understated`.
### 3.4 Through every derivation
A party that derives an object from others and publishes it signs an origin claim for it whose
inputs name the inputs' claims; where it signs a record of the derivation too, the claim's
`record` names that record. A relay forwards claims as jwp-receipts §4.1 has it forward any
receipt, byte for byte, and alters none. So each object a sink receives has a claim, and the
chain of claims behind it reaches the capture or generation it came from:
| derivation | the output's claim |
|---|---|
| a relay forwards an object | none: the object is the same, and its claim holds for it |
| an object re-packaged, or a `WAI2` component exploded (SPEC §7.1) | the same object for every rule that names objects by content hash; its claim holds |
| an encode, a transcode, a rung of a ladder, a decoder's reconstruction | `convert` of the input |
| a crop, a rotation, a composition of several inputs | `convert` of the inputs |
| an upscale, a denoise, a sink-side enhancement | `assist` of the input, with evidence (§4) |
| a frame generated between two others, a talking head from a picture and a voice, an inpainting | `generate` over the inputs |
| a clip of a recording, a segment of a track | the claim of each group or object it holds, unchanged |
A track or group claim covers each object of the track or group; an object claim, where one is
given, is the object's.
## 4. Assistive evidence
### 4.1 `wai-synthesis:upscale2`
The derivation `wai-synthesis:upscale2`, with no parameters (`{}`), maps a *w* × *h* `RGB8` buffer
`in` to a 2*w* × 2*h* buffer `out`. For each pixel (*x*, *y*) of `in` and each of its three bytes,
with *x*₁ = min(*x* + 1, *w* − 1), *y*₁ = min(*y* + 1, *h* − 1), *a* = in(*x*, *y*),
*b* = in(*x*₁, *y*), *c* = in(*x*, *y*₁) and *d* = in(*x*₁, *y*₁):
- out(2*x*, 2*y*) = *a*;
- out(2*x* + 1, 2*y*) = (*a* + *b* + 1) >> 1;
- out(2*x*, 2*y* + 1) = (*a* + *c* + 1) >> 1;
- out(2*x* + 1, 2*y* + 1) = (*a* + *b* + *c* + *d* + 2) >> 2.
This is the half-sample interpolation `wai.video.int_motion` predicts with (SPEC §5), in the same
integer arithmetic.
### 4.2 `wai-synthesis:denoise3`
The derivation `wai-synthesis:denoise3`, with no parameters (`{}`), maps a *w* × *h* `RGB8`
buffer `in` to a *w* × *h* buffer `out`: for each pixel (*x*, *y*) and each of its three bytes,
out(*x*, *y*) = (8 + Σ *k*(*i*, *j*) · in(clamp(*x* + *i*), clamp(*y* + *j*))) >> 4,
the sum over *i*, *j* ∈ {−1, 0, 1}, with *k* = 4 at (0, 0), 2 where exactly one of *i*, *j* is 0,
and 1 at the four corners; clamp keeps a coordinate within 0 to *w* − 1 (or *h* − 1).
### 4.3 The evidence
An assist claim's `evidence` is an object of exactly these members:
| member | type | meaning |
|---|---|---|
| `tier` | string | `replicate`: the step is a pinned derivation, checkable by recomputation; `attest`: it is not, and the claim asserts that the edit is assistive |
| `derivation` | term | the derivation run |
| `params` | object | its parameters |
| `width`, `height` | integer | the input's size, each at least 1 |
| `input_samples` | 32 B hex | the digest of the input's samples (§2) |
| `output_samples` | 32 B hex | the digest of the output's samples |
`tier` and the sizes outside these values are `record_malformed`. `replicate` evidence names
`wai-synthesis:upscale2` or `wai-synthesis:denoise3`, with `params` `{}`, and an output of at most
2^26 bytes (4 · `width` · `height` · 3 for the upscale, `width` · `height` · 3 for the denoise);
otherwise `evidence_invalid`. A derivation evaluated in float, or by any operation this
revision does not pin, states `attest`: the edit may well be assistive, but its claim can only
assert it.
A model-driven derivation joins the pinned set when a capability registers it in integer
arithmetic at `decode-equivalence`, with its parameter set pinned (SPEC §3.1); its evidence then
names it and its pin, and is checked the same way. This revision registers none.
### 4.4 Checking the evidence
A verifier is given the decoded samples of the objects it holds (§11.1). For an assist claim, in
this order:
1. `replicate` evidence that §4.3 does not admit: `evidence_invalid`.
2. With `replicate` evidence and the input's decoded samples held: they are `width` · `height` ·
3 bytes and their digest is `input_samples` (`input_samples_mismatch`); the derivation is run
over them, and the digest of its output is `output_samples`
(`assistive_recompute_mismatch`). The claim is then **proved**. Without the input's samples it
is **not recomputed**; `attest` evidence is **asserted**.
3. With the subject's decoded samples held (an object subject): their digest is
`output_samples` (`samples_mismatch`).
4. Under a policy that requires proof (§11.1), a claim that is not proved:
`assistive_not_proved`.
A proved claim says that this output is that pinned derivation of that input, and nothing else:
a fact any verifier holding the input's samples checks, at the tier `replicate` promises.
## 5. The digital source type
A claim's `source` is a code of the IPTC Digital Source Type vocabulary, whose terms are
`http://cv.iptc.org/newscodes/digitalsourcetype/` followed by the code. It is a function of the
claim:
| step | inputs | `source` |
|---|---|---|
| `capture` | none | as stated: `digitalCapture`, `computationalCapture`, `screenCapture` or `digitalCreation` |
| `convert` | one | the input's `source` |
| `convert` | several | `compositeSynthetic` when an input's class is `synthesized`; else `compositeCapture` when every input's source is `digitalCapture` or `computationalCapture`; else `composite` |
| `assist` | one | the input's `source` when its class is `synthesized`; else `algorithmicallyEnhanced` |
| `generate` | any | the **generated source**: `algorithmicMedia` when no model is `trained`; else `compositeWithTrainedAlgorithmicMedia` when `oversight` is `human_validated` or an input's class is not `synthesized`; else `trainedAlgorithmicMedia` |
A claim whose `source` is another is `source_mismatch`. The codes are the vocabulary's own
definitions: `algorithmicallyEnhanced` is an algorithmic modification that leaves the main
content unchanged; `trainedAlgorithmicMedia` media created by a model trained on captured
content; `compositeWithTrainedAlgorithmicMedia` an edit made with such a model, and, by C2PA 2.4
§18.28.3's pairing, generated content a person reviewed and approved; `algorithmicMedia` media
created purely by an algorithm with no sampled training data. A `reconstructed` claim keeps its
input's code: a codec makes no claim about what it reconstructed.
## 6. The C2PA statement
A claim gives one statement, the JSON object `{"actions": A, "assertions": D}` written in JCS
(RFC 8785):
- `A`, the actions of the manifest's first actions assertion (C2PA 2.4 §18.15.2):
- a claim with no input or with several: `[{"action": "c2pa.created", "digitalSourceType": U}]`,
`U` the IPTC term of the claim's `source`;
- a `convert` of one input: `[{"action": "c2pa.opened"}, {"action": "c2pa.converted"}]`, with
no digital source type, as [energy-binding](energy-binding.md) §6.1 has it for a decode: the
input is the parent ingredient, and its own manifest states its source;
- an `assist` of one input: `[{"action": "c2pa.opened"}, {"action": "c2pa.edited",
"digitalSourceType": U}]`, `U` the term of `algorithmicallyEnhanced`;
- a `generate` of one input: the same, `U` the term of the generated source (§5).
- `D`, one `c2pa.ai-disclosure` assertion (C2PA 2.4 §18.28) for each model whose `type` is
`trained`, in the claim's order, labelled `c2pa.ai-disclosure`, `c2pa.ai-disclosure__1`, …
(C2PA 2.4 §6.4): `{"label": L, "data": {"modelType": "c2pa.types.model", "modelIdentifier":
"tag:transaction.science,2026:wai/model/" ‖ the digest in lowercase hexadecimal}}`, and, for a
`generate`, `"contentProfile": {"humanOversightLevel": O}` in `data`, `O` the claim's
`oversight`. A model of type `algorithmic` has none: C2PA 2.4 §18.28.3 attaches the assertion
only where a trained model was invoked. The model is named by digest, as timed-text §10.3 names
a caption's.
Embedding the statement in a manifest, and signing that, is C2PA's and provenance's. A rendition
of a playlist whose objects include one of class `synthesized` SHOULD carry the
`public.machine-generated` characteristic (`draft-pantos-hls-rfc8216bis-22` §4.4.6.1), as
timed-text §10.2 has it for captions.
## 7. Two marking layers
### 7.1 The layers
The **first layer** is the origin claim: signed, time-stamped metadata (the Code of Practice's
Sub-measure 1.1.1). The **second** is an imperceptible watermark embedded in the content
(Sub-measure 1.1.2), whose payload is a function of the claim (§7.2). The Code asks for both on
the outputs of generative systems and treats fingerprinting or logging as a supplement that is
never sufficient alone (Sub-measure 1.1.3); a content-bound commitment, such as a
[reconstruction binding](reconstruction-binding.md) over decoded pixels, is a supplement of that
kind here and not the second layer.
Under a policy that requires both layers (§11.1), an object claim of class `synthesized` with no
marking record naming it (§7.2) is `layer_missing`.
### 7.2 The payload and the marking record
A watermark carrying a claim holds an 8-byte **payload**: the code of the claim's class (0
`captured`, 1 `reconstructed`, 2 `assistive`, 3 `synthesized`), then the first seven bytes of
the claim's link hash. Read from the content alone, with no metadata, it says the class and points
at the claim. Seven bytes find a claim; they do not prove one, and the claim's signature does
that. The marking record names the scheme that reads it (§7.4); a signpost in the content naming
the detection solution, as the Code's Measure 3.4(c)(ii) describes one, is not part of this
revision.
The party that embeds the mark signs a **marking** record, `kind` `"wai-marking"`:
| member | type | meaning |
|---|---|---|
| `kind` | string | `"wai-marking"` |
| `subject` | 32 B hex | the content hash of the marked object |
| `origin` | 32 B hex | the link hash of its origin claim |
| `scheme` | term | the watermark scheme (§7.4) |
| `scheme_digest` | 32 B hex | the SHA-256 of the scheme's definition |
| `payload` | 8 B hex | the payload embedded |
| `issued_at` | date-time | when it signed it |
| `signer_pubkey`, `sig` | | §2 |
A verifier holds the claim the marking names (`claim_unknown`); that claim's subject is the
marking's object, and the marking's payload is the claim's (§7.2) (`marking_mismatch`).
### 7.3 Detection and agreement
A detector that reads a watermark signs a **detection** record, `kind` `"wai-detection"`: the
members of a marking but `origin`, with `payload` the 8 bytes read, or `null` where it found no
mark. For each detection, in order, with the origin claim whose subject is the detection's
object (if the verifier holds one) and the first marking whose subject it is (if it holds one):
1. With a marking: the detection's `scheme` and `scheme_digest` are the marking's
(`detection_mismatch`).
2. With no payload read: where a marking says the object was marked, `watermark_absent`, the
second layer having been removed; otherwise the object is **unmarked**.
3. With a payload read and a claim held: the payload is the claim's (§7.2); otherwise
`layers_disagree`. A content that carries the mark of a synthesized claim while its metadata
says captured, or the mark of another claim, is refused here. With no claim held, the
detection is **not checked**.
### 7.4 The scheme slot
This revision registers no watermark scheme. A scheme is named by a term `wai-mark:<name>` and
the digest of its definition, which a later revision pins with its embedding as an integer
derivation appended to a generative one, so that a sink that generates content embeds the mark
as part of the reconstruction it is held to, and with its key-free detector. Until then a
detection is a signed statement by its detector and nothing more. The conformance corpus names
`wai-mark:conformance`, whose definition is the text `wai-mark:conformance names no watermark
algorithm (synthesis.md section 7.4)` and which names no algorithm: it exercises the records and
their agreement, not a watermark.
## 8. Live rooms
### 8.1 AI participants and their events
[live-rooms](live-rooms.md) §4.4 marks an **AI participant**: its grant's `ai` attribute names the
model that acts for it. Its `chat`, `reaction` and `caption` events state origin 2 or 3 with that
model ([live-events](live-events.md) §4.4, §4.5, §4.10); a room verifier refuses one that does
not, `ai_unmarked` (live-rooms §9.2 step 6), and a relay does not forward it.
### 8.2 Its tracks
Every track an AI participant publishes is synthesized media. Its publisher **MUST** sign, for
each layer track it publishes, a track claim (§3.1, `subject` of type `track`) of class
`synthesized`, step `generate`, whose `models` include the model the grant's `ai` names; the
claim is signed by the participant's key or a room key. A relay **MUST NOT** forward the objects
of such a track while it holds no such claim.
A verifier given a room (§11.1) replays its lifecycle events in the merged order (live-rooms
§9.2): each `join` and `grant_exchange` sets its subject's grant, each `leave` clears it, and for
each `publish` and `takeover` whose subject's grant names a model in `ai`, each layer track
*k* from 0 to `layers` − 1, owned by the subject (its key in lowercase hexadecimal) or by the slot
(`_` and its name), of the published media kind, is named by its digest (live-rooms §6.2). A track
with no claim as above is `ai_track_unlabelled`, at that lifecycle event.
## 9. Certified synthesis
### 9.1 The review
A person or an organisation that reviews a synthesized output signs a **review**, `kind`
`"wai-review"`:
| member | type | meaning |
|---|---|---|
| `kind` | string | `"wai-review"` |
| `subject` | 32 B hex | the content hash of the object reviewed |
| `origin` | 32 B hex | the link hash of its origin claim, of class `synthesized` |
| `tier` | string | the tier at which the reconstruction reviewed is portable: `decode-equivalence` or `generation-equivalence` ([determinism-tiers](determinism-tiers.md) §2; SPEC §7 `GenerativeDeterministic`) |
| `reconstruction` | 32 B hex | the digest of the canonical output the reviewer saw (§2) |
| `decoder` | term | the decoder the reviewer's output came from |
| `variant` | term or `null` | its decode path, where it has more than one |
| `scope` | string | what was reviewed: `accuracy` (words, a script), `rendering` (pixels, a voice, signing), `translation` |
| `verdict` | string | `approved`, `approved-with-notes` or `rejected` |
| `notes` | 32 B hex or `null` | the digest of the notes, `null` exactly for `approved` |
| `role` | term or `null` | a role the reviewer holds for this scope, as the reviewer states it |
| `issued_at` | date-time | when it signed it |
| `signer_pubkey`, `sig` | | §2 |
A verifier holds the claim a review names (`claim_unknown`), whose subject is the review's object
and whose class is `synthesized` (`review_subject_mismatch`).
### 9.2 Presenting a review
A player **proves its tier** by stating its decode path and the digest of what it presented, in a
`present` entry of its log (§10.3): the decoder, its variant, the tier the path holds, and the
output's digest. The tier a path holds is the weakest along it: the decoder's tier at its pin
(SPEC §3.1), and `none` where any float stage, or any processing after the decode that alters the
medium, lies on the path ([determinism-tiers](determinism-tiers.md) §2 *What a tier covers*: a
tier covers what the sink emits). A verifier that holds the object's decoded samples (the output
of a decode at the stated tier) checks the stated digest against them:
`present_output_irreproducible` where it differs.
A player **MAY** present a review, in a `review` entry of its log, only for an object it has
presented, and only where its latest presentation of the object before that entry:
1. presented the review's object, which the verifier holds the review of
(`claim_unknown`, `review_subject_mismatch`);
2. under a review whose verdict is not `rejected` (`review_rejected`);
3. on a path of the review's tier (`review_path_not_exact`): a path of tier `none` or
`entropy-consistency` never presents a review made at `decode-equivalence`;
4. with the review's `reconstruction` as its output's digest (`review_reconstruction_mismatch`).
Where these hold, the viewer received the output the reviewer saw, byte for byte: one review
holds for every viewer whose presentation is exact at that tier, and for no other.
## 10. Label display
### 10.1 When a label is shown
A **label policy**, `kind` `"wai-label-policy"`, signed by the party that presents content to
viewers, says which content is labelled and how:
| member | type | meaning |
|---|---|---|
| `kind` | string | `"wai-label-policy"` |
| `classes` | array | the classes labelled, 1 to 4 of §3.2's, in its order, none twice |
| `visual` | 32 B hex | the SHA-256 of the visual label shown with visual media |
| `audio` | 32 B hex or `null` | the SHA-256 of the audible label played with audio-only media; `null` for none |
| `interval_us` | integer or `null` | the longest a labelled presentation goes without a label, in microseconds of the log's clock; at least 1 when not `null` |
| `grace_us` | integer | how long after the instant it is due a label may come |
| `issued_at` | date-time | when it signed it |
| `signer_pubkey`, `sig` | | §2 |
A label is due, for each kind (visual for `video`, `image` and `text`, audible for `audio`):
- at **first exposure**: the first presentation of labelled content of that kind in the session
(the Code of Practice's Sub-measure 1.2.1(d));
- after each **interruption**: the first presentation of labelled content of that kind after the
presentation resumed from an ad break or another interruption (Sub-measure 1.2.2(b); live-ads'
breaks end on a `cue` event, live-events §4.3);
- at **intervals**: where the policy has one, every presentation of labelled content of that kind
that is neither, no longer than the interval after the last label (Sub-measures 1.2.2(b) and
1.2.3).
### 10.2 The EU icons, pinned
The Code of Practice's Annex 1 publishes three EU icons, for content fully generated by AI
(`AI GENERATED`), for content partially modified by AI (`AI MODIFIED`), and a basic one (`AI`),
each in four variations. The Commission publishes them as two archives: SVG
(`https://ec.europa.eu/newsroom/dae/redirection/document/129546`, SHA-256
`f76707b69b546677b173975c018528e5e549e12afb05550f32c2f46500ffbefd` as fetched) and PNG
(`https://ec.europa.eu/newsroom/dae/redirection/document/129547`, SHA-256
`aedc51656497a2e8f3e42798e5749a929952f5b262bad368ee3057f58c0f52c7` as fetched). Each file is
pinned here by the SHA-256 of its bytes:
| file | SHA-256 |
|---|---|
| `LABEL_AI GENERATED_black transparent.svg` | `63d28ab55916b4548edff21d5fbcac065a13e5fb936e9b304ff0bada45f2fac1` |
| `LABEL_AI GENERATED_black.svg` | `503af176b05fd725e68b0aa526977d31bcd657d74b15c6103a57ace81384940f` |
| `LABEL_AI GENERATED_white transparent.svg` | `d19736f6da9d38af3cffce8ba08f3f7658b573e1794c173fcd8b55a5c200ed44` |
| `LABEL_AI GENERATED_white.svg` | `10125cdef3fc60a351df72fe1266bb00ea046086921bd1bb20135f0f07b5ed36` |
| `LABEL_AI MODIFIED_black transparent.svg` | `9ab09f54c1ccef01799af43794cd93a8af6607ba9ac6b9526fb90a02be8ddf3d` |
| `LABEL_AI MODIFIED_black.svg` | `2e7349e5eca4ee78eeef160dfef4545c31850932245373a07c5401e73e6599c0` |
| `LABEL_AI MODIFIED_white transparent.svg` | `7ccbce41f9f821a574007b32806ddb6d635ee8bbc5d753ccd05f66f5a3165845` |
| `LABEL_AI MODIFIED_white.svg` | `0c72a7d569a1447e2982a843e156bd5f9cb2ab6f201943c6c68f988dc2f1bb6e` |
| `LABEL_AI_black transparent.svg` | `f8e20f1dde8c7d95940da08960b1063620812acc9b4ea96eb5ad051f80f67c21` |
| `LABEL_AI_black.svg` | `58ffe859a4d74829d397f534a988081bcefb716849c7d3b84fa7a026dd1d257f` |
| `LABEL_AI_white transparent.svg` | `52d87cb3f6a191dba24796f745e1eeb80d342883acd1a2e6d856342ad853766c` |
| `LABEL_AI_white.svg` | `5b3b94ae67fea55c4f2d013d0b8ed5b876c1533555ed0ed60e688f2f3ccf1a50` |
| `LABEL_AI GENERATED_black transparent.png` | `74c8c4ee720c4a3286c3c1b9f216ac9b918ebf449e8c421dde5a40cd216b27e6` |
| `LABEL_AI GENERATED_black.png` | `8d0af57bc93ba3797042a4b56db757d85f924d9b0200b75b8842da7be5e402a2` |
| `LABEL_AI GENERATED_white transparent.png` | `06c4c7c00738e0e44c35f5927e72842ff7a9dd757e0374335b9cc1ba6bb52199` |
| `LABEL_AI GENERATED_white.png` | `f139d8de4b5173f47a777431e7e03d9258d8ea7ee1ee1a062febc7e9fff067fc` |
| `LABEL_AI MODIFIED_black transparent.png` | `f48f6c5137834fff87c701eb9a44934ac51c6b5d826b9b5cfead1b88024f8387` |
| `LABEL_AI MODIFIED_black.png` | `0f32e4afbe1459baaf425c78373ea541421ecdb61803438892e2fc07df67da8f` |
| `LABEL_AI MODIFIED_white transparent.png` | `1f3b435b0bf775a168309c4ede49b23c7fa94b759a72623932e6ccb7bdef6203` |
| `LABEL_AI MODIFIED_white.png` | `64241cf4f87eb0837bd234cb63716c7d78f4e9dbbb8866f40464ca4e6bfbfead` |
| `LABEL_AI_black transparent.png` | `24da93847c04a5c86c714c6cd018c45331b47df32936b617b3c8476933d97fa9` |
| `LABEL_AI_black.png` | `0814d5f04533c118f81aed0d353ea75d1df4171c40b962dba1dfa044a3d84779` |
| `LABEL_AI_white transparent.png` | `5b8c743b679a62453a4bc25070b82bb4db33ad9c68f4ef1b6ef7bb562dd3a4e3` |
| `LABEL_AI_white.png` | `b9041e416fed455722a3aaa06a5ea0eb376d8a8e11385fdc8e3e8ee6998cdf26` |
A policy whose `visual` is one of these digests shows that EU icon; any other digest names an
equivalent label, which the Code allows (Section 2, Commitment 1). A player that shows a pinned
icon shows exactly those bytes. No audible EU label is published: the Annex says an audio-only
icon will be developed, and the Code's audio work begins with a task force. A policy's `audio` is
then the digest of the disclaimer or earcon its signer chooses (Sub-measure 1.2.3), until a
revision of this table pins a published one.
### 10.3 The player's log
A player signs a **log** of a viewing session, one record per entry, `kind` `"wai-player"`, under a
key of that session (no identity of the viewer or the device):
| member | type | meaning |
|---|---|---|
| `kind` | string | `"wai-player"` |
| `session` | 32 B hex | fresh random bytes of the session, the same in every entry |
| `seq` | integer | the entry's position, from 0 |
| `prev` | 32 B hex or `null` | the link hash of the entry before it; `null` for entry 0 |
| `at` | integer | the session clock at the entry, in microseconds; never below the entry before it's |
| `entry` | object | one of the four below |
| `signer_pubkey`, `sig` | | §2, the same key in every entry |
The entries:
- `{"type": "present", "subject", "origin", "class", "media", "tier", "decoder", "variant",
"output"}`: the player presented an object from `at` on: its content hash; the link hash of the
origin claim it applied, or `null`; that claim's class as it read it, or `null`; the `media` of
§3.1; the tier its path holds (`decode-equivalence`, `generation-equivalence`,
`entropy-consistency` or `none`, §9.2); its decoder (a term) and variant (a term or `null`), as a
`reconstruct-session` claim names them (jwp-receipts §2.4.12); and its output's digest, or `null`
where it states none.
- `{"type": "shown", "label", "asset", "trigger"}`: the player showed a label: `visual` or `audio`;
the SHA-256 of the asset shown; and why, as the player states it: `first-exposure`, `resume` or
`interval`.
- `{"type": "resume", "after"}`: presentation resumed after an interruption, `ad-break` or
`other`.
- `{"type": "review", "subject", "review"}`: the player presented the review whose link hash is
`review` for the object `subject` (§9.2).
A log whose `seq` is not each entry's position, whose `prev` is not the link hash of the entry
before, whose `at` goes back, or whose `session` or key changes, is `player_chain_broken`, at the
first entry that breaks it.
### 10.4 The obligations
Each `present` entry whose `origin` names a claim the verifier holds states that claim's class,
for that claim's object (`present_mismatch`). Under a label policy, in this order:
1. Every `shown` entry's asset is the policy's `visual` for a visual label and its `audio` for an
audible one (`label_asset_mismatch`).
2. Each `present` entry whose `class` is one the policy labels is a **labelled presentation** at
its `at`, *p*, of the label kind its `media` asks for (§10.1). Taking them in the log's order,
each kind on its own:
- the first of its kind is a **first exposure**: a `shown` entry of that kind is in the log
with `at` at most *p* + `grace_us`;
- a later one, when a `resume` entry lies between the labelled presentation of its kind
before it and it, is a **resume**: a `shown` entry of that kind has `at` from the latest such
`resume`'s `at` to *p* + `grace_us`;
- any other, under a policy with an interval, is an **interval**: a `shown` entry of that kind
has `at` from *p* − `interval_us` to *p* + `grace_us`.
One with no such `shown` entry is `label_not_shown`, at its `present` entry. A label not shown
at first exposure is caught by the player's own signed log: the log states the presentation and
when the label came.
## 11. Verification
### 11.1 Inputs
A verifier is given, each in a list: origin claims, marking records, detection records and
reviews; a label policy or none; a player's log or none; the envelopes of the objects it holds
(each named by its content hash); the decoded samples it holds, each with the content hash of
the object decoded; a room or none, as live-rooms §9.1 gives a room verifier its inputs; and a
**policy** of two members:
| member | values | default | meaning |
|---|---|---|---|
| `dual_layer` | `when-given`, `required` | `when-given` | `required`: §7.1's rule applies (`layer_missing`) |
| `assistive` | `accept-asserted`, `require-proof` | `accept-asserted` | `require-proof`: §4.4 step 4 applies (`assistive_not_proved`) |
### 11.2 Steps
The verifier runs these steps and stops at the first refusal, which it reports with the reason
and where: a record by its list and index, the label policy, or the room as live-rooms §9.1 states
a place in it.
1. **Reading.** Each record, list by list (origin claims, markings, detections, reviews, the label
policy, the log's entries), each in order, reads (§2, §3.1, §3.2, §4.3, §7.2, §7.3, §9.1, §10.1,
§10.3) and its signature verifies: `record_malformed`, `bad_key`, `bad_signature`.
2. **Origin claims**, in order, each by these checks in order: a subject named before
(`claim_conflict`); its class (§3.2, `class_mismatch`); its source (§5, `source_mismatch`); its
inputs and its envelope (§3.3: `input_mismatch`, `class_understated`); its evidence (§4.4:
`evidence_invalid`, `input_samples_mismatch`, `assistive_recompute_mismatch`,
`samples_mismatch`, `assistive_not_proved`).
3. **Marking.** The markings in order (§7.2: `claim_unknown`, `marking_mismatch`); the detections
in order (§7.3: `detection_mismatch`, `watermark_absent`, `layers_disagree`); then, under
`dual_layer` `required`, the origin claims in order (§7.1, `layer_missing`).
4. **Reviews**, in order (§9.1: `claim_unknown`, `review_subject_mismatch`).
5. **The room.** Live-rooms §9: a refusal there is this verification's, at the room. A gap is not
a refusal. Then its AI participants' tracks (§8.2, `ai_track_unlabelled`).
6. **The log.** Its chain (§10.3, `player_chain_broken`); then each entry in order: a `present`
entry (§10.4, `present_mismatch`; §9.2, `present_output_irreproducible`) and a `review` entry
(§9.2: `claim_unknown`, `review_subject_mismatch`, `review_rejected`, `review_path_not_exact`,
`review_reconstruction_mismatch`); then, under a label policy, §10.4's steps 1 and 2
(`label_asset_mismatch`, `label_not_shown`).
### 11.3 What an accepted case states
- for each origin claim, in order: its link hash, class and source, its C2PA statement (§6) as
written, and for an assist claim whether it is `proved`, `not-recomputed` or `asserted` (§4.4);
- for each detection, in order: its object and its result, `agree`, `unmarked` or `not-checked`
(§7.3);
- for each AI participant's track, in the order §8.2 reaches them: its digest and the claim that
labels it;
- for each `review` entry of the log: its index and the review's link hash;
- for each labelled presentation that had a label due: its index, the trigger (`first-exposure`,
`resume` or `interval`) and the index of the first `shown` entry that met it;
- the file name of the EU icon the label policy's `visual` is, where it is one (§10.2);
- the room's own outcome, `accept` or `gap`, where a room was given.
## 12. Reasons
The codes this extension adds, each with where it is raised:
| reason | raised at |
|---|---|
| `record_malformed` | §2, §3.1, §3.2, §4.3, §11.2 step 1 |
| `bad_key` | §2 |
| `bad_signature` | §2 |
| `claim_conflict` | §3.1 |
| `class_mismatch` | §3.2 |
| `source_mismatch` | §5 |
| `input_mismatch` | §3.3 |
| `class_understated` | §3.3 |
| `evidence_invalid` | §4.3, §4.4 step 1 |
| `input_samples_mismatch` | §4.4 step 2 |
| `assistive_recompute_mismatch` | §4.4 step 2 |
| `samples_mismatch` | §4.4 step 3 |
| `assistive_not_proved` | §4.4 step 4 |
| `claim_unknown` | §7.2, §9.1, §9.2 |
| `marking_mismatch` | §7.2 |
| `detection_mismatch` | §7.3 |
| `watermark_absent` | §7.3 |
| `layers_disagree` | §7.3 |
| `layer_missing` | §7.1 |
| `ai_track_unlabelled` | §8.2 |
| `player_chain_broken` | §10.3 |
| `present_mismatch` | §10.4 |
| `present_output_irreproducible` | §9.2 |
| `review_subject_mismatch` | §9.1, §9.2 |
| `review_rejected` | §9.2 |
| `review_path_not_exact` | §9.2 |
| `review_reconstruction_mismatch` | §9.2 |
| `label_asset_mismatch` | §10.4 |
| `label_not_shown` | §10.4 |
A verifier given a room also raises every code a room verifier raises (live-rooms §11, and the
live-events codes it lists), at the room: `ai_unmarked` among them, for an AI participant's
event that does not state its machine origin (live-rooms §9.2 step 6).
## 13. Conformance
[`live-conformance/synthesis/`](../live-conformance/synthesis/) holds the corpus, in one file,
`vectors.json`, written from fixed seeds by `wai_synthesis_vectors gen`. It lists the EU icons of
§10.2 and the **vectors**: each a case of §11.1, its outcome, and, accepted, everything §11.3
states; refused, its reason and its place. It has:
- **Accepted**: a capture, a neural codec's reconstruction of it, and its two pinned derivations,
each proved by recomputation from the reconstruction's decoded samples; a float
super-resolution step whose attest evidence is asserted; a generated video, an algorithmic
audio generation from a `wai.generate.spectral` envelope, an inpainting a person reviewed, a
composite of a capture and the generated video, and a transcode of the video, each synthesized
object marked in both layers, a detection that agrees and one that finds the capture unmarked;
a room whose AI host states its model in its chat and caption and labels its speech track; and a
review of the generated video presented on an exact path, with labels shown at first exposure,
after an ad break and within an interval.
- **Refused**: each reason of §12 at least once, each a control of one defect: among them a
synthesized object labelled captured (`class_understated`), an assistive claim whose
recomputation differs (`assistive_recompute_mismatch`), the two marking layers disagreeing
(`layers_disagree`), a review presented on a non-exact path (`review_path_not_exact`), an AI
participant's unmarked chat (`ai_unmarked`, raised by the room) and a track it publishes with no
claim (`ai_track_unlabelled`), and a label not shown at first exposure, after a break, and within
the interval (`label_not_shown`).
A conforming verifier gives every vector the outcome, the reason, the place and the statements
the corpus states. `wai_synthesis_vectors verify` and the test `tests/synthesis.rs` check that the
file regenerates byte for byte, that `wai-rs` gives every vector its outcome, that §12 lists
exactly the reference's codes, and that §10.2's table pins exactly the reference's icons.
`live-conformance/synthesis_verify.py` is the second implementation, written from this text: it
shares no code with `wai-rs`; it reads §10.2's pins and §12's codes from this text,
verifies each room with the live-rooms corpus's second implementation (`live_rooms_verify.py`),
every signature with WAI's strict rule (`claim-conformance/_ed25519.py`) and every link hash with
BLAKE3 written from its specification (`claim-conformance/_blake3.py`); and it mutates copies of
the corpus and requires every copy to be refused.
## 14. Security and privacy considerations
- A claim carries its signer's authority. A class is a statement; what this extension adds is
that a class can only rise along a chain of claims (§3.2), that an assistive claim over a pinned
derivation is checked by recomputation (§4), that a generator's own envelope cannot be claimed
as anything but generated (§3.3), and that the second layer is checked against the first (§7).
A synthesized object whose producer signs no claim at all is caught only by the second layer,
and only once a watermark scheme is registered (§7.4).
- A watermark payload's seven bytes of link hash locate a claim; they are not a commitment, and a
mark can be copied into other content. A detection that agrees with a claim says the content
carries that claim's mark, not that the content is the claim's subject; the claim's subject is
bound by content hash.
- Evidence states digests of samples, never samples. A verifier that recomputes holds the input's
decoded samples, which it decoded itself.
- A player's log is signed under a key of the session and names objects, claims, labels and
instants of the session's clock: no viewer, no device and no account. It shows what that key
reports, and is not evidence that a person watched.
- A review names its reviewer's key and, where given, a role the reviewer states; a role is the
reviewer's statement until an authority attests it.
- A relay can withhold a claim but cannot alter one. A sink that receives an object without the
claim its chain should hold sees the gap in the chain of inputs (§3.4).

View file

@ -18,6 +18,7 @@ and the independent verifier of each, beside it.
| [`e2ee/`](e2ee/) | [confidentiality](../extensions/confidentiality.md) (`wai.live.e2ee`) | `e2ee_verify.py` |
| [`watermark/`](watermark/) | [forensic-watermark](../extensions/forensic-watermark.md) (`wai.live.watermark`) | `live_watermark_verify.py` |
| [`c2pa-live/`](c2pa-live/) | [live-provenance](../extensions/live-provenance.md) (`wai.live.provenance`) | `c2pa_live_verify.py` |
| [`synthesis/`](synthesis/) | [synthesis](../extensions/synthesis.md) (`wai.media.synthesis`) | `synthesis_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
@ -26,12 +27,12 @@ writes `recordings/`, `wai_timed_text_vectors gen` writes `captions/` and
`wai_live_metering_vectors gen` writes `metering/`, `wai_energy_steering_vectors gen` writes
`steering/`, `wai_live_moderation_vectors gen` writes `moderation/`,
`wai_live_watermark_vectors gen` writes `watermark/`, `wai_live_ads_vectors gen` writes
`ads/` and `wai_live_provenance_vectors gen` writes `c2pa-live/`, each from records authored
in code, from fixed seeds (`events/README.md`,
`ads/`, `wai_live_provenance_vectors gen` writes `c2pa-live/` and `wai_synthesis_vectors gen` writes
`synthesis/`, 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`,
`watermark/README.md`, `ads/README.md`, `c2pa-live/README.md`); `c2pa-live/` cuts its media
from the packaging corpus's AV1 floor. `joule_moq_e2ee_vectors gen` (in
`watermark/README.md`, `ads/README.md`, `c2pa-live/README.md`, `synthesis/README.md`);
`c2pa-live/` cuts its media from the packaging corpus's AV1 floor. `joule_moq_e2ee_vectors gen` (in
`joule-code/joule-code-rs/crates/joule-moq-mls`) writes `e2ee/vectors.json` from fixed seeds,
and `e2ee/` also holds the test vectors RFC 9605 and `draft-ietf-moq-secure-objects-01`
publish, as printed (`e2ee/README.md`). One recorded record: `metering/relay-record.wmh` and
@ -55,4 +56,5 @@ python3 live_ads_verify.py ads
python3 e2ee_verify.py e2ee
python3 live_watermark_verify.py watermark
python3 c2pa_live_verify.py c2pa-live
python3 synthesis_verify.py synthesis
```

View file

@ -432,6 +432,52 @@
}
]
},
{
"name": "kind_chat_origin",
"about": "chat and reaction stating their origin (§4.4, §4.5): a machine message with its model's digest, a machine-reviewed one, a human one stating origin 1, and a machine reaction targeting the first",
"room": "main",
"default_policy": "default",
"policies": {},
"events": [
"5745563101994ce44cd8c3fa2781c7976335330a5e718019657440cb46a77db3210900ce31c2a1e8c1326f0811f77d5ef3628991d76af48f4a089e7ed08541846e351fce930000000000000000000000000000000000000000000000000000000000000000000000000000000040420f00000000000003480025004865726520697320612073756d6d617279206f66207468652074616c6b20736f206661722e02b16dcccea3b663fa41a0b4c59228cb2dddbbf2510df387ed330dc4a11aeeae1cd1c8363bda75e23afcaba414d396c3dcdeaf82c1f2954261ccb7d20aa5a527d65cf9a59fb50c9992e1afe2b51a87df6093dc255bddd3ca137bdef58caecf160c",
"5745563101994ce44cd8c3fa2781c7976335330a5e718019657440cb46a77db3210900ce31c2a1e8c1326f0811f77d5ef3628991d76af48f4a089e7ed08541846e351fce9301000000000000002ff65886fa1f94c8cd7c18a84d052b6f8e1d32c36f975bbc8f23048fef770f3580841e000000000000034b00280052657669657765643a207468652073756d6d6172792061626f76652069732061636375726174652e03b16dcccea3b663fa41a0b4c59228cb2dddbbf2510df387ed330dc4a11aeeae1c74553836741ceae7553cc599849aa68d35290a1e831a027c82a9a6def82b8f28dd6024eed3368c80a22f37559328e881733901207ba09ea7a2a9a1f55acb5607",
"5745563101994ce44cd8c3fa2781c7976335330a5e718019657440cb46a77db3210900ce31c2a1e8c1326f0811f77d5ef3628991d76af48f4a089e7ed08541846e351fce9302000000000000007bcf54fed4a5769d4c7987599718e6f2359e9f6fb17cf2eb754b6b79f034c116c0c62d00000000000003150012005479706564206279206120706572736f6e2e012df40b7fb2b654619a6d2b19133102bcbdb6b7bb5bc936a9bde2a6924842041a561080468342519243994d6e34b466c8b380ac4da5a5454739460eb48e6a8d06",
"5745563101994ce44cd8c3fa2781c7976335330a5e718019657440cb46a77db3210900ce31c2a1e8c1326f0811f77d5ef3628991d76af48f4a089e7ed08541846e351fce930300000000000000a806a8ec963a5a2bea734fd5fbdaeb68eb8890a7b1ac0e67ed8106decbe90bf800093d000000000000044500010100012ff65886fa1f94c8cd7c18a84d052b6f8e1d32c36f975bbc8f23048fef770f3502b16dcccea3b663fa41a0b4c59228cb2dddbbf2510df387ed330dc4a11aeeae1c06d90f8c5c3615fed522ab121c32ad98b740c874c0ebd1b391f55162d1a0ecec81fa90563807f382142d37ee1868d4107291c9456fa8bdd8630d0ca8f9b69f07"
],
"outcome": "accept",
"digests": [
"2ff65886fa1f94c8cd7c18a84d052b6f8e1d32c36f975bbc8f23048fef770f35",
"7bcf54fed4a5769d4c7987599718e6f2359e9f6fb17cf2eb754b6b79f034c116",
"a806a8ec963a5a2bea734fd5fbdaeb68eb8890a7b1ac0e67ed8106decbe90bf8",
"e262cac60f3b23c92d7e3f64aa7af8725c7f8199f5fc3588051b46a32e997a9c"
],
"tally": {
"state_hash": "771b30268d4b5a93046d9e87dbbc10bae6fc76c4c2d5dde9071f801bfdb9a68d",
"polls": []
},
"bindings": [
{
"msf": "{\"t\":1792108801000,\"data\":{\"wev1\":\"V0VWMQGZTORM2MP6J4HHl2M1MwpecYAZZXRAy0anfbMhCQDOMcKh6MEybwgR931e82KJkddq9I9KCJ5-0IVBhG41H86TAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEBCDwAAAAAAAANIACUASGVyZSBpcyBhIHN1bW1hcnkgb2YgdGhlIHRhbGsgc28gZmFyLgKxbczOo7Zj-kGgtMWSKMst3bvyUQ3zh-0zDcShGu6uHNHINjvadeI6_KukFNOWw9zer4LB8pVCYcy30gqlpSfWXPmln7UMmZLhr-K1GoffYJPcJVvd08oTe971jK7PFgw\"}}",
"daterange": "#EXT-X-DATERANGE:ID=\"2ff65886fa1f94c8cd7c18a84d052b6f8e1d32c36f975bbc8f23048fef770f35\",CLASS=\"science.transaction.wai.event\",START-DATE=\"2026-10-16T00:00:01.000Z\",X-WAI-EVENT=\"V0VWMQGZTORM2MP6J4HHl2M1MwpecYAZZXRAy0anfbMhCQDOMcKh6MEybwgR931e82KJkddq9I9KCJ5-0IVBhG41H86TAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEBCDwAAAAAAAANIACUASGVyZSBpcyBhIHN1bW1hcnkgb2YgdGhlIHRhbGsgc28gZmFyLgKxbczOo7Zj-kGgtMWSKMst3bvyUQ3zh-0zDcShGu6uHNHINjvadeI6_KukFNOWw9zer4LB8pVCYcy30gqlpSfWXPmln7UMmZLhr-K1GoffYJPcJVvd08oTe971jK7PFgw\"",
"emsg": "00000136656d736701000000000003e80000000000002af8000000002ff6588675726e3a7761693a6576656e743a310063686174005745563101994ce44cd8c3fa2781c7976335330a5e718019657440cb46a77db3210900ce31c2a1e8c1326f0811f77d5ef3628991d76af48f4a089e7ed08541846e351fce930000000000000000000000000000000000000000000000000000000000000000000000000000000040420f00000000000003480025004865726520697320612073756d6d617279206f66207468652074616c6b20736f206661722e02b16dcccea3b663fa41a0b4c59228cb2dddbbf2510df387ed330dc4a11aeeae1cd1c8363bda75e23afcaba414d396c3dcdeaf82c1f2954261ccb7d20aa5a527d65cf9a59fb50c9992e1afe2b51a87df6093dc255bddd3ca137bdef58caecf160c"
},
{
"msf": "{\"t\":1792108802000,\"data\":{\"wev1\":\"V0VWMQGZTORM2MP6J4HHl2M1MwpecYAZZXRAy0anfbMhCQDOMcKh6MEybwgR931e82KJkddq9I9KCJ5-0IVBhG41H86TAQAAAAAAAAAv9liG-h-UyM18GKhNBStvjh0yw2-XW7yPIwSP73cPNYCEHgAAAAAAAANLACgAUmV2aWV3ZWQ6IHRoZSBzdW1tYXJ5IGFib3ZlIGlzIGFjY3VyYXRlLgOxbczOo7Zj-kGgtMWSKMst3bvyUQ3zh-0zDcShGu6uHHRVODZ0HOrnVTzFmYSapo01KQoegxoCfIKppt74K48o3WAk7tM2jICiLzdVkyjogXM5ASB7oJ6noqmh9VrLVgc\"}}",
"daterange": "#EXT-X-DATERANGE:ID=\"7bcf54fed4a5769d4c7987599718e6f2359e9f6fb17cf2eb754b6b79f034c116\",CLASS=\"science.transaction.wai.event\",START-DATE=\"2026-10-16T00:00:02.000Z\",X-WAI-EVENT=\"V0VWMQGZTORM2MP6J4HHl2M1MwpecYAZZXRAy0anfbMhCQDOMcKh6MEybwgR931e82KJkddq9I9KCJ5-0IVBhG41H86TAQAAAAAAAAAv9liG-h-UyM18GKhNBStvjh0yw2-XW7yPIwSP73cPNYCEHgAAAAAAAANLACgAUmV2aWV3ZWQ6IHRoZSBzdW1tYXJ5IGFib3ZlIGlzIGFjY3VyYXRlLgOxbczOo7Zj-kGgtMWSKMst3bvyUQ3zh-0zDcShGu6uHHRVODZ0HOrnVTzFmYSapo01KQoegxoCfIKppt74K48o3WAk7tM2jICiLzdVkyjogXM5ASB7oJ6noqmh9VrLVgc\"",
"emsg": "00000139656d736701000000000003e80000000000002ee0000000007bcf54fe75726e3a7761693a6576656e743a310063686174005745563101994ce44cd8c3fa2781c7976335330a5e718019657440cb46a77db3210900ce31c2a1e8c1326f0811f77d5ef3628991d76af48f4a089e7ed08541846e351fce9301000000000000002ff65886fa1f94c8cd7c18a84d052b6f8e1d32c36f975bbc8f23048fef770f3580841e000000000000034b00280052657669657765643a207468652073756d6d6172792061626f76652069732061636375726174652e03b16dcccea3b663fa41a0b4c59228cb2dddbbf2510df387ed330dc4a11aeeae1c74553836741ceae7553cc599849aa68d35290a1e831a027c82a9a6def82b8f28dd6024eed3368c80a22f37559328e881733901207ba09ea7a2a9a1f55acb5607"
},
{
"msf": "{\"t\":1792108803000,\"data\":{\"wev1\":\"V0VWMQGZTORM2MP6J4HHl2M1MwpecYAZZXRAy0anfbMhCQDOMcKh6MEybwgR931e82KJkddq9I9KCJ5-0IVBhG41H86TAgAAAAAAAAB7z1T-1KV2nUx5h1mXGObyNZ6fb7F88ut1S2t58DTBFsDGLQAAAAAAAAMVABIAVHlwZWQgYnkgYSBwZXJzb24uAS30C3-ytlRhmm0rGRMxAry9tre7W8k2qb3ippJIQgQaVhCARoNCUZJDmU1uNLRmyLOArE2lpUVHOUYOtI5qjQY\"}}",
"daterange": "#EXT-X-DATERANGE:ID=\"a806a8ec963a5a2bea734fd5fbdaeb68eb8890a7b1ac0e67ed8106decbe90bf8\",CLASS=\"science.transaction.wai.event\",START-DATE=\"2026-10-16T00:00:03.000Z\",X-WAI-EVENT=\"V0VWMQGZTORM2MP6J4HHl2M1MwpecYAZZXRAy0anfbMhCQDOMcKh6MEybwgR931e82KJkddq9I9KCJ5-0IVBhG41H86TAgAAAAAAAAB7z1T-1KV2nUx5h1mXGObyNZ6fb7F88ut1S2t58DTBFsDGLQAAAAAAAAMVABIAVHlwZWQgYnkgYSBwZXJzb24uAS30C3-ytlRhmm0rGRMxAry9tre7W8k2qb3ippJIQgQaVhCARoNCUZJDmU1uNLRmyLOArE2lpUVHOUYOtI5qjQY\"",
"emsg": "00000103656d736701000000000003e800000000000032c800000000a806a8ec75726e3a7761693a6576656e743a310063686174005745563101994ce44cd8c3fa2781c7976335330a5e718019657440cb46a77db3210900ce31c2a1e8c1326f0811f77d5ef3628991d76af48f4a089e7ed08541846e351fce9302000000000000007bcf54fed4a5769d4c7987599718e6f2359e9f6fb17cf2eb754b6b79f034c116c0c62d00000000000003150012005479706564206279206120706572736f6e2e012df40b7fb2b654619a6d2b19133102bcbdb6b7bb5bc936a9bde2a6924842041a561080468342519243994d6e34b466c8b380ac4da5a5454739460eb48e6a8d06"
},
{
"msf": "{\"t\":1792108804000,\"data\":{\"wev1\":\"V0VWMQGZTORM2MP6J4HHl2M1MwpecYAZZXRAy0anfbMhCQDOMcKh6MEybwgR931e82KJkddq9I9KCJ5-0IVBhG41H86TAwAAAAAAAACoBqjsljpaK-pzT9X72uto64iQp7GsDmftgQbey-kL-AAJPQAAAAAAAARFAAEBAAEv9liG-h-UyM18GKhNBStvjh0yw2-XW7yPIwSP73cPNQKxbczOo7Zj-kGgtMWSKMst3bvyUQ3zh-0zDcShGu6uHAbZD4xcNhX-1SKrEhwyrZi3QMh0wOvRs5H1UWLRoOzsgfqQVjgH84IULTfuGGjUEHKRyUVvqL3YYw0MqPm2nwc\"}}",
"daterange": "#EXT-X-DATERANGE:ID=\"e262cac60f3b23c92d7e3f64aa7af8725c7f8199f5fc3588051b46a32e997a9c\",CLASS=\"science.transaction.wai.event\",START-DATE=\"2026-10-16T00:00:04.000Z\",X-WAI-EVENT=\"V0VWMQGZTORM2MP6J4HHl2M1MwpecYAZZXRAy0anfbMhCQDOMcKh6MEybwgR931e82KJkddq9I9KCJ5-0IVBhG41H86TAwAAAAAAAACoBqjsljpaK-pzT9X72uto64iQp7GsDmftgQbey-kL-AAJPQAAAAAAAARFAAEBAAEv9liG-h-UyM18GKhNBStvjh0yw2-XW7yPIwSP73cPNQKxbczOo7Zj-kGgtMWSKMst3bvyUQ3zh-0zDcShGu6uHAbZD4xcNhX-1SKrEhwyrZi3QMh0wOvRs5H1UWLRoOzsgfqQVjgH84IULTfuGGjUEHKRyUVvqL3YYw0MqPm2nwc\"",
"emsg": "00000137656d736701000000000003e800000000000036b000000000e262cac675726e3a7761693a6576656e743a31007265616374696f6e005745563101994ce44cd8c3fa2781c7976335330a5e718019657440cb46a77db3210900ce31c2a1e8c1326f0811f77d5ef3628991d76af48f4a089e7ed08541846e351fce930300000000000000a806a8ec963a5a2bea734fd5fbdaeb68eb8890a7b1ac0e67ed8106decbe90bf800093d000000000000044500010100012ff65886fa1f94c8cd7c18a84d052b6f8e1d32c36f975bbc8f23048fef770f3502b16dcccea3b663fa41a0b4c59228cb2dddbbf2510df387ed330dc4a11aeeae1c06d90f8c5c3615fed522ab121c32ad98b740c874c0ebd1b391f55162d1a0ecec81fa90563807f382142d37ee1868d4107291c9456fa8bdd8630d0ca8f9b69f07"
}
]
},
{
"name": "kind_caption",
"about": "caption (§4.10): human, machine and machine-reviewed origins, the latter two with a model digest, three language tags, and a caption with no stated duration",
@ -1420,6 +1466,19 @@
"reason": "malformed_payload",
"index": 0
},
{
"name": "malformed_payload_origin",
"about": "a chat stating origin 2 with no model digest after it (§4.4)",
"room": "main",
"default_policy": "default",
"policies": {},
"events": [
"5745563101994ce44cd8c3fa2781c7976335330a5e718019657440cb46a77db3210900ce31c2a1e8c1326f0811f77d5ef3628991d76af48f4a089e7ed08541846e351fce930000000000000000000000000000000000000000000000000000000000000000000000000000000040420f000000000000030f000c007768696368206d6f64656c3f02b8bbfca6c533438f2f05fdd56a1354fe50609951f58fef3cd0087f8bb25afe82d87dc1114046480dc6a6a18df663a91b25172f1c6c356084519d478b6107f207"
],
"outcome": "refuse",
"reason": "malformed_payload",
"index": 0
},
{
"name": "malformed_payload_poll",
"about": "a poll_open with one option (§4.6)",

View file

@ -164,6 +164,19 @@ def splice_info_section_ok(b):
return len(b) >= 3 and b[0] == 0xFC and 3 + (((b[1] & 0x0F) << 8) | b[2]) == len(b)
def stated_origin(r):
"""§4.4, §4.5: the origin a chat or a reaction may state after its last field, as
(origin, model), or (None, None) when no byte follows."""
if r.done():
return None, None
o = r.u8()
if o == 1:
return 1, None
if o in (2, 3):
return o, r.take(32)
raise Refuse("malformed_payload")
def read_payload(kind, b, max_cp):
"""§4: the payload as a dict, or Refuse."""
m = "malformed_payload"
@ -188,6 +201,7 @@ def read_payload(kind, b, max_cp):
raise Refuse(m)
elif kind == 0x03:
p["text"] = text(r, max_cp)
p["origin"], p["model"] = stated_origin(r)
elif kind == 0x04:
p["type"] = r.u8()
if p["type"] not in (1, 2, 3, 4, 5):
@ -200,6 +214,7 @@ def read_payload(kind, b, max_cp):
raise Refuse(m)
p["target"] = r.take(32) if flags & 1 else None
p["remove"] = bool(flags & 2)
p["origin"], p["model"] = stated_origin(r)
elif kind == 0x05:
p["question"] = text(r, max_cp)
n = r.u8()

View file

@ -73,7 +73,7 @@ REASONS = [
"grant_exceeds_room", "exchange_unlinked", "exchange_mismatch", "lifecycle_malformed",
"grant_unknown", "grant_wrong_principal", "grant_revoked", "grant_reused",
"already_present", "not_present", "participant_limit", "outside_grant_window",
"power_not_granted", "publish_not_permitted", "profile_not_granted", "already_published",
"power_not_granted", "ai_unmarked", "publish_not_permitted", "profile_not_granted", "already_published",
"not_published", "slot_unknown", "slot_full", "publisher_limit", "exchange_not_current",
"exchange_off_tick", "takeover_not_higher", "failover_not_standby", "failover_early",
"pop_missing", "pop_malformed", "pop_wrong_grant", "pop_wrong_key", "pop_bad_signature",
@ -395,11 +395,22 @@ def grant_fields_ok(g, d, held):
media.add(m)
if len(g["origins"]) > 8 or not all(is_token(o, 255) for o in g["origins"]):
return False
if read_attrs(g["attrs"]) is None:
attrs = read_attrs(g["attrs"])
if attrs is None:
return False
if any(k == "ai" and not re.fullmatch(r"[0-9a-f]{64}", v) for k, v in attrs):
return False
return all(h["digest"] != g["digest"] for h in held)
def ai_model(g):
"""§4.4: the model an AI participant's grant names, as bytes, or None."""
for k, v in read_attrs(g["attrs"]) or []:
if k == "ai":
return bytes.fromhex(v)
return None
def publishes(g, m):
return m in MEDIA and g["publish"] >> (m - 1) & 1 == 1
@ -1035,6 +1046,10 @@ def step(d, room, ver, seen, auth, b):
need = RECORD if payload["command"] in (1, 2) else DIRECT
if g["powers"] & need == 0:
raise Refuse("power_not_granted")
model = ai_model(g)
if model is not None and payload["kind"] in (0x03, 0x04, 0x0A):
if payload["origin"] not in (2, 3) or payload["model"] != model:
raise Refuse("ai_unmarked")
return g["digest"]
@ -1124,7 +1139,7 @@ def main(corpus_dir):
missing = [r for r in REASONS if r not in reasons_seen]
if missing:
fails.append("no vector refused with %s" % ", ".join(missing))
rooms = ["lesson", "broadcast", "takeover", "redundant_failover", "promote_viewer", "dvr"]
rooms = ["lesson", "broadcast", "takeover", "redundant_failover", "promote_viewer", "dvr", "ai_participant"]
absent = [r for r in rooms if outcomes.get(r) != "accept"]
if absent:
fails.append("rooms not accepted: %s" % ", ".join(absent))

View file

@ -3529,6 +3529,23 @@
1
]
},
{
"name": "grant_ai_malformed",
"about": "a grant whose ai attribute is not 64 lowercase hexadecimal digits (§4.4)",
"descriptor": "5752443101988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c28589285c73c9d1dc51b9a83c180ec0bd17f1c8520c0ab70c4874225109cadb2d493ec1b00406cd8e95d060040420f0000000000150068747470733a2f2f726f6f6d732e6578616d706c650800000003003c0000005745503101fe1f0000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f00000000000504686f737407010102ae06000000000000000000000000000000000000000000000000000000000000096d6f64657261746f720001000108080000000000000000000000000000000000000000000000000000000000000861756469656e636500010100580000000000000000000000000000000000000000000000000000000000000005677565737403010100580100000000000000000000000000000000000000000000000000000000000007656e636f64657203000000000000000000000000000000000000000000000000000000000000000000000002057374616765020100000000046d61696e0202d007000002067370656563680101046f707573000001000000000000100000004000000080bb0000010f0a63616d6572612d37323002020d617630312e302e30354d2e30380b617663312e363430303146010002800268011e0096000000f40100000005d0021e00f4010000dc0500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000086c97e1a4b2609cb3438fd6c165e0bdc6f016bae7d16c4d37a387f4be3891b46f97c63fbf4814de11aadb3ee409d246a182017b43cacb6a6c64905bceb31c30b",
"grants": [
"575247310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a2036872df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa100000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a0000000000070101020000000000000000000000000000000000000000000000000000000000000000005745503101ae060000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011568747470733a2f2f72656c61792e6578616d706c65000008067f81349126761a6b7fe0cf3bd66d94800e6a65dc2f9d1d07de1a362f6d95bd8122dad96eed33117981aa9923f907d8a1a236fcf8c14579e586cbe500470b",
"575247310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c00000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a000000000200010100000000000000000000000000000000000000000000000000000000000000000000574550310158000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011568747470733a2f2f72656c61792e6578616d706c6545000261694000414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141416e3520d47ed02cd4fa26c68cb2a874282c9d370a49429fd985bb64ecbca67ceaf7563a898af9d9091047cd32ad068802707bec6f1820c82b6f0ec86d65ca820b"
],
"key_log_head": "{\"grant_max_s\":86400,\"issued_at\":\"2026-10-15T00:00:00Z\",\"keys\":[{\"compromised_since\":null,\"id\":\"room-authority\",\"key\":\"988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c28589285\",\"may_sign\":[\"live/room\"],\"status\":\"active\",\"until\":null}],\"kind\":\"wai-key-set\",\"next_root\":\"3328656f3df5d9617e108d46080e23610712c9979d4437e706240f53d6cadf7b\",\"origin\":\"https://rooms.example\",\"prev\":null,\"revoked_modules\":[],\"root\":\"687972706312d9675734bf13b4bcf695ecb1b374de7809242aa91f130c6a1c0c\",\"seq\":0,\"sig\":\"ba55b157ebe88fc97f2b5a5e3556415563552ed88b9931060b9e99ae0a7decefdc63e2cb916b9a5d03c80a0c1823de14e7d8d3fe0b726c2464d823611780290a\",\"status_max_age_s\":3600}",
"chains": [],
"outcome": "refuse",
"reason": "grant_invalid",
"at": [
"grant",
1
]
},
{
"name": "grant_attrs_too_long",
"about": "a grant whose attributes take more than 1024 bytes (§4.4)",
@ -4489,6 +4506,471 @@
1
]
},
{
"name": "ai_participant",
"about": "a host whose grant's ai attribute names a model: its chat states origin 2 and its caption origin 3, each with that model; a person's chat and reaction state none (§4.4, §9.2 step 6)",
"descriptor": "5752443101988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c28589285c73c9d1dc51b9a83c180ec0bd17f1c8520c0ab70c4874225109cadb2d493ec1b00406cd8e95d060040420f0000000000150068747470733a2f2f726f6f6d732e6578616d706c650800000003003c0000005745503101fe1f0000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f00000000000504686f737407010102ae06000000000000000000000000000000000000000000000000000000000000096d6f64657261746f720001000108080000000000000000000000000000000000000000000000000000000000000861756469656e636500010100580000000000000000000000000000000000000000000000000000000000000005677565737403010100580100000000000000000000000000000000000000000000000000000000000007656e636f64657203000000000000000000000000000000000000000000000000000000000000000000000002057374616765020100000000046d61696e0202d007000002067370656563680101046f707573000001000000000000100000004000000080bb0000010f0a63616d6572612d37323002020d617630312e302e30354d2e30380b617663312e363430303146010002800268011e0096000000f40100000005d0021e00f4010000dc0500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000086c97e1a4b2609cb3438fd6c165e0bdc6f016bae7d16c4d37a387f4be3891b46f97c63fbf4814de11aadb3ee409d246a182017b43cacb6a6c64905bceb31c30b",
"grants": [
"575247310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a2036872df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa100000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a0000000000070101020000000000000000000000000000000000000000000000000000000000000000005745503101ae060000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011568747470733a2f2f72656c61792e6578616d706c6545000261694000383763386330653335666337666136613964346231393232336634303238326663333565396665633265353933383061633562336466616561653664353930385c245e4d368eea2fa46a37c069226e63a3489e51338486b574f405737169de74e0e230e5f27b523eb35cdc5e5a63fe4f104c48556ba46d8433b3db9a265eaa01",
"575247310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c00000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a000000000200010100000000000000000000000000000000000000000000000000000000000000000000574550310158000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011568747470733a2f2f72656c61792e6578616d706c650000da52cb8a9a33184a5da85c37affe7d9dfc6f4e7a3019ecc07c090358ded448066ac88859718562bb805f34d7da43880addb6098e654e8e51481afb9ee22ee40e"
],
"key_log_head": "{\"grant_max_s\":86400,\"issued_at\":\"2026-10-15T00:00:00Z\",\"keys\":[{\"compromised_since\":null,\"id\":\"room-authority\",\"key\":\"988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c28589285\",\"may_sign\":[\"live/room\"],\"status\":\"active\",\"until\":null}],\"kind\":\"wai-key-set\",\"next_root\":\"3328656f3df5d9617e108d46080e23610712c9979d4437e706240f53d6cadf7b\",\"origin\":\"https://rooms.example\",\"prev\":null,\"revoked_modules\":[],\"root\":\"687972706312d9675734bf13b4bcf695ecb1b374de7809242aa91f130c6a1c0c\",\"seq\":0,\"sig\":\"ba55b157ebe88fc97f2b5a5e3556415563552ed88b9931060b9e99ae0a7decefdc63e2cb916b9a5d03c80a0c1823de14e7d8d3fe0b726c2464d823611780290a\",\"status_max_age_s\":3600}",
"chains": [
[
"574556310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687c73c9d1dc51b9a83c180ec0bd17f1c8520c0ab70c4874225109cadb2d493ec1b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001040002df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa13427f23d6605d4ed9be070c149aa749edd535e46fb4a813e47bdd750d546f9654022ff7403111b077255cc79fffeb37146f44c32b5f5f811ee202b6a1811fc80d874544e4e887d493655b3d5f1e26a16664ec23594e8e685b4201ff3d8c9e105",
"574556310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687c73c9d1dc51b9a83c180ec0bd17f1c8520c0ab70c4874225109cadb2d493ec1b010000000000000066e0e16b1f2817739e3b6f2c9c87086b31f86249123017fc159142eca323af28000000000000000000104000fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c37d406a857ee63ae9fecd4f293211c3e92c6847beb4871066ecfbca21f4a38607dad484d1fc66d48a45d08c1cec17d18889212fe90f204ee16684d701e5ebf4cb837bdd6dd0af53318a5465ac6f44a98f3769e7481e99169975af532a5614809"
],
[
"574556310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c0000000000000000000000000000000000000000000000000000000000000000000000000000000040420f0000000000000312001000497320616e796f6e652074686572653f882e98ec64ee02bb24da475a9dc9c491bb077b9fbd4cd32c90c73a253d1848e72c394c8a3fe09a0c620fcb5ad17a38d15b96620fd504be6c7dd57f25a889e20a",
"574556310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c010000000000000021509022cbf78272fd37df1a2ac940fddcacf6b7c394169b54e256abac734c8200093d000000000000040400040100001557491f18f4601eba695fd96f943ad21fe5e87575fe87a3824c41e81a2e99fd47231c68b03c133f8dd6a33001fe1fa77671adaf576efa3dad5ae35488ac6508"
],
[
"574556310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a2036872df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa10000000000000000000000000000000000000000000000000000000000000000000000000000000080841e000000000000034300200057656c636f6d652e204920616d20616e206175746f6d6174656420686f73742e0287c8c0e35fc7fa6a9d4b19223f40282fc35e9fec2e59380ac5b3dfaeae6d590818cbffb02f3b3c5c74d381e071119923078b88c258f5dc2ed86b012166e80546d4a2111fe5e0e26a4e24428b73194386e0b4f1f731c50e47737760846cb1ea0f",
"574556310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a2036872df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1010000000000000081cef7e8b20d0491664ba49ee6fbd5f26e16ec4decbcf820a3f7dedb10ac0403c0c62d0000000000000a3200d00700000387c8c0e35fc7fa6a9d4b19223f40282fc35e9fec2e59380ac5b3dfaeae6d590802656e080057656c636f6d652ebeb6999762bad005e0998b47d5b193f5e32326601e9524604b5b39d2eb25fb631cd22f64c01ab4db57ccd4a104e810387806e200748bdce6cdeb0720a7c3280f"
]
],
"outcome": "accept",
"room": "91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687",
"grant_digests": [
"3427f23d6605d4ed9be070c149aa749edd535e46fb4a813e47bdd750d546f965",
"37d406a857ee63ae9fecd4f293211c3e92c6847beb4871066ecfbca21f4a3860"
],
"order": [
[
0,
0,
null
],
[
0,
1,
null
],
[
1,
0,
"37d406a857ee63ae9fecd4f293211c3e92c6847beb4871066ecfbca21f4a3860"
],
[
2,
0,
"3427f23d6605d4ed9be070c149aa749edd535e46fb4a813e47bdd750d546f965"
],
[
2,
1,
"3427f23d6605d4ed9be070c149aa749edd535e46fb4a813e47bdd750d546f965"
],
[
1,
1,
"37d406a857ee63ae9fecd4f293211c3e92c6847beb4871066ecfbca21f4a3860"
]
],
"state": {
"present": [
{
"principal": "2df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1",
"grant": "3427f23d6605d4ed9be070c149aa749edd535e46fb4a813e47bdd750d546f965"
},
{
"principal": "fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c",
"grant": "37d406a857ee63ae9fecd4f293211c3e92c6847beb4871066ecfbca21f4a3860"
}
],
"published": [],
"slots": [
{
"slot": "stage",
"media": "video",
"live": null,
"standby": null,
"precedence": null
},
{
"slot": "main",
"media": "video",
"live": null,
"standby": null,
"precedence": null
}
],
"tracks": [],
"timeline": [
{
"principal": "2df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1",
"grant": "3427f23d6605d4ed9be070c149aa749edd535e46fb4a813e47bdd750d546f965",
"t": 0
},
{
"principal": "fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c",
"grant": "37d406a857ee63ae9fecd4f293211c3e92c6847beb4871066ecfbca21f4a3860",
"t": 0
}
]
},
"tally": {
"state_hash": "771b30268d4b5a93046d9e87dbbc10bae6fc76c4c2d5dde9071f801bfdb9a68d",
"polls": []
},
"moqt": [
{
"grant": "3427f23d6605d4ed9be070c149aa749edd535e46fb4a813e47bdd750d546f965",
"reval": 60,
"seek": false,
"scopes": [
[
[
0
]
],
[
[
2,
6
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687",
"2df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1",
"audio",
null
]
],
[
[
2,
6
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687",
"2df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1",
"video",
null
]
],
[
[
6
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687",
"_stage",
"video",
null
]
],
[
[
6
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687",
"_stage",
null
],
"catalog"
],
[
[
6
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687",
"_main",
"video",
null
]
],
[
[
6
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687",
"_main",
null
],
"catalog"
],
[
[
2,
6
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687",
"2df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1",
"screen",
null
]
],
[
[
2,
6
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687",
"2df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1",
null
],
"catalog"
],
[
[
2,
6
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687",
"2df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1",
"events",
null
],
"0"
],
[
[
3
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687"
]
],
[
[
4,
7,
8
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687"
],
"catalog"
],
[
[
4,
7,
8
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687"
],
"0"
],
[
[
4,
7,
8
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687"
],
"0_receipts"
],
[
[
4,
7,
8
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687"
],
"1"
],
[
[
4,
7,
8
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687"
],
"1_receipts"
]
]
},
{
"grant": "37d406a857ee63ae9fecd4f293211c3e92c6847beb4871066ecfbca21f4a3860",
"reval": 60,
"seek": false,
"scopes": [
[
[
0
]
],
[
[
2,
6
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687",
"fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c",
"events",
null
],
"0"
],
[
[
3
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687"
]
],
[
[
4,
7,
8
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687"
],
"catalog"
],
[
[
4,
7,
8
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687"
],
"0"
],
[
[
4,
7,
8
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687"
],
"0_receipts"
],
[
[
4,
7,
8
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687"
],
"1"
],
[
[
4,
7,
8
],
[
"91ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687"
],
"1_receipts"
]
]
}
]
},
{
"name": "ai_unmarked",
"about": "an AI participant's chat that states no origin (§9.2 step 6)",
"descriptor": "5752443101988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c28589285c73c9d1dc51b9a83c180ec0bd17f1c8520c0ab70c4874225109cadb2d493ec1b00406cd8e95d060040420f0000000000150068747470733a2f2f726f6f6d732e6578616d706c650800000003003c0000005745503101fe1f0000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f00000000000504686f737407010102ae06000000000000000000000000000000000000000000000000000000000000096d6f64657261746f720001000108080000000000000000000000000000000000000000000000000000000000000861756469656e636500010100580000000000000000000000000000000000000000000000000000000000000005677565737403010100580100000000000000000000000000000000000000000000000000000000000007656e636f64657203000000000000000000000000000000000000000000000000000000000000000000000002057374616765020100000000046d61696e0202d007000002067370656563680101046f707573000001000000000000100000004000000080bb0000010f0a63616d6572612d37323002020d617630312e302e30354d2e30380b617663312e363430303146010002800268011e0096000000f40100000005d0021e00f4010000dc0500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000086c97e1a4b2609cb3438fd6c165e0bdc6f016bae7d16c4d37a387f4be3891b46f97c63fbf4814de11aadb3ee409d246a182017b43cacb6a6c64905bceb31c30b",
"grants": [
"575247310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a2036872df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa100000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a0000000000070101020000000000000000000000000000000000000000000000000000000000000000005745503101ae060000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011568747470733a2f2f72656c61792e6578616d706c6545000261694000383763386330653335666337666136613964346231393232336634303238326663333565396665633265353933383061633562336466616561653664353930385c245e4d368eea2fa46a37c069226e63a3489e51338486b574f405737169de74e0e230e5f27b523eb35cdc5e5a63fe4f104c48556ba46d8433b3db9a265eaa01",
"575247310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c00000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a000000000200010100000000000000000000000000000000000000000000000000000000000000000000574550310158000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011568747470733a2f2f72656c61792e6578616d706c650000da52cb8a9a33184a5da85c37affe7d9dfc6f4e7a3019ecc07c090358ded448066ac88859718562bb805f34d7da43880addb6098e654e8e51481afb9ee22ee40e"
],
"key_log_head": "{\"grant_max_s\":86400,\"issued_at\":\"2026-10-15T00:00:00Z\",\"keys\":[{\"compromised_since\":null,\"id\":\"room-authority\",\"key\":\"988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c28589285\",\"may_sign\":[\"live/room\"],\"status\":\"active\",\"until\":null}],\"kind\":\"wai-key-set\",\"next_root\":\"3328656f3df5d9617e108d46080e23610712c9979d4437e706240f53d6cadf7b\",\"origin\":\"https://rooms.example\",\"prev\":null,\"revoked_modules\":[],\"root\":\"687972706312d9675734bf13b4bcf695ecb1b374de7809242aa91f130c6a1c0c\",\"seq\":0,\"sig\":\"ba55b157ebe88fc97f2b5a5e3556415563552ed88b9931060b9e99ae0a7decefdc63e2cb916b9a5d03c80a0c1823de14e7d8d3fe0b726c2464d823611780290a\",\"status_max_age_s\":3600}",
"chains": [
[
"574556310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687c73c9d1dc51b9a83c180ec0bd17f1c8520c0ab70c4874225109cadb2d493ec1b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001040002df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa13427f23d6605d4ed9be070c149aa749edd535e46fb4a813e47bdd750d546f9654022ff7403111b077255cc79fffeb37146f44c32b5f5f811ee202b6a1811fc80d874544e4e887d493655b3d5f1e26a16664ec23594e8e685b4201ff3d8c9e105",
"574556310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687c73c9d1dc51b9a83c180ec0bd17f1c8520c0ab70c4874225109cadb2d493ec1b010000000000000066e0e16b1f2817739e3b6f2c9c87086b31f86249123017fc159142eca323af28000000000000000000104000fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c37d406a857ee63ae9fecd4f293211c3e92c6847beb4871066ecfbca21f4a38607dad484d1fc66d48a45d08c1cec17d18889212fe90f204ee16684d701e5ebf4cb837bdd6dd0af53318a5465ac6f44a98f3769e7481e99169975af532a5614809"
],
[
"574556310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c0000000000000000000000000000000000000000000000000000000000000000000000000000000040420f0000000000000312001000497320616e796f6e652074686572653f882e98ec64ee02bb24da475a9dc9c491bb077b9fbd4cd32c90c73a253d1848e72c394c8a3fe09a0c620fcb5ad17a38d15b96620fd504be6c7dd57f25a889e20a"
],
[
"574556310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a2036872df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa10000000000000000000000000000000000000000000000000000000000000000000000000000000080841e000000000000032200200057656c636f6d652e204920616d20616e206175746f6d6174656420686f73742ed3025193682bf2a1635fed1f8919f6b62399702536a5bbb76d1658c9f00056b7d858882840895670a8c9ea39ecd2b96d2845d5e37481852523eec2e6a1bc4b0b"
]
],
"outcome": "refuse",
"reason": "ai_unmarked",
"at": [
"record",
2,
0
]
},
{
"name": "ai_unmarked_human",
"about": "an AI participant's chat that states origin 1, human (§9.2 step 6)",
"descriptor": "5752443101988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c28589285c73c9d1dc51b9a83c180ec0bd17f1c8520c0ab70c4874225109cadb2d493ec1b00406cd8e95d060040420f0000000000150068747470733a2f2f726f6f6d732e6578616d706c650800000003003c0000005745503101fe1f0000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f00000000000504686f737407010102ae06000000000000000000000000000000000000000000000000000000000000096d6f64657261746f720001000108080000000000000000000000000000000000000000000000000000000000000861756469656e636500010100580000000000000000000000000000000000000000000000000000000000000005677565737403010100580100000000000000000000000000000000000000000000000000000000000007656e636f64657203000000000000000000000000000000000000000000000000000000000000000000000002057374616765020100000000046d61696e0202d007000002067370656563680101046f707573000001000000000000100000004000000080bb0000010f0a63616d6572612d37323002020d617630312e302e30354d2e30380b617663312e363430303146010002800268011e0096000000f40100000005d0021e00f4010000dc0500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000086c97e1a4b2609cb3438fd6c165e0bdc6f016bae7d16c4d37a387f4be3891b46f97c63fbf4814de11aadb3ee409d246a182017b43cacb6a6c64905bceb31c30b",
"grants": [
"575247310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a2036872df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa100000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a0000000000070101020000000000000000000000000000000000000000000000000000000000000000005745503101ae060000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011568747470733a2f2f72656c61792e6578616d706c6545000261694000383763386330653335666337666136613964346231393232336634303238326663333565396665633265353933383061633562336466616561653664353930385c245e4d368eea2fa46a37c069226e63a3489e51338486b574f405737169de74e0e230e5f27b523eb35cdc5e5a63fe4f104c48556ba46d8433b3db9a265eaa01",
"575247310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c00000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a000000000200010100000000000000000000000000000000000000000000000000000000000000000000574550310158000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011568747470733a2f2f72656c61792e6578616d706c650000da52cb8a9a33184a5da85c37affe7d9dfc6f4e7a3019ecc07c090358ded448066ac88859718562bb805f34d7da43880addb6098e654e8e51481afb9ee22ee40e"
],
"key_log_head": "{\"grant_max_s\":86400,\"issued_at\":\"2026-10-15T00:00:00Z\",\"keys\":[{\"compromised_since\":null,\"id\":\"room-authority\",\"key\":\"988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c28589285\",\"may_sign\":[\"live/room\"],\"status\":\"active\",\"until\":null}],\"kind\":\"wai-key-set\",\"next_root\":\"3328656f3df5d9617e108d46080e23610712c9979d4437e706240f53d6cadf7b\",\"origin\":\"https://rooms.example\",\"prev\":null,\"revoked_modules\":[],\"root\":\"687972706312d9675734bf13b4bcf695ecb1b374de7809242aa91f130c6a1c0c\",\"seq\":0,\"sig\":\"ba55b157ebe88fc97f2b5a5e3556415563552ed88b9931060b9e99ae0a7decefdc63e2cb916b9a5d03c80a0c1823de14e7d8d3fe0b726c2464d823611780290a\",\"status_max_age_s\":3600}",
"chains": [
[
"574556310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687c73c9d1dc51b9a83c180ec0bd17f1c8520c0ab70c4874225109cadb2d493ec1b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001040002df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa13427f23d6605d4ed9be070c149aa749edd535e46fb4a813e47bdd750d546f9654022ff7403111b077255cc79fffeb37146f44c32b5f5f811ee202b6a1811fc80d874544e4e887d493655b3d5f1e26a16664ec23594e8e685b4201ff3d8c9e105",
"574556310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687c73c9d1dc51b9a83c180ec0bd17f1c8520c0ab70c4874225109cadb2d493ec1b010000000000000066e0e16b1f2817739e3b6f2c9c87086b31f86249123017fc159142eca323af28000000000000000000104000fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c37d406a857ee63ae9fecd4f293211c3e92c6847beb4871066ecfbca21f4a38607dad484d1fc66d48a45d08c1cec17d18889212fe90f204ee16684d701e5ebf4cb837bdd6dd0af53318a5465ac6f44a98f3769e7481e99169975af532a5614809"
],
[
"574556310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c0000000000000000000000000000000000000000000000000000000000000000000000000000000040420f0000000000000312001000497320616e796f6e652074686572653f882e98ec64ee02bb24da475a9dc9c491bb077b9fbd4cd32c90c73a253d1848e72c394c8a3fe09a0c620fcb5ad17a38d15b96620fd504be6c7dd57f25a889e20a"
],
[
"574556310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a2036872df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa10000000000000000000000000000000000000000000000000000000000000000000000000000000080841e000000000000032300200057656c636f6d652e204920616d20616e206175746f6d6174656420686f73742e0127f048da20a70e3abec01ff3cdf9c9563b4585ccd45c192e7d28a8ae98773910f796c7ffea6a735359c3d05699e25365dc6cad4d3d12f531758f088ada112c00"
]
],
"outcome": "refuse",
"reason": "ai_unmarked",
"at": [
"record",
2,
0
]
},
{
"name": "ai_unmarked_model",
"about": "an AI participant's chat that states origin 2 with a model other than its grant's (§9.2 step 6)",
"descriptor": "5752443101988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c28589285c73c9d1dc51b9a83c180ec0bd17f1c8520c0ab70c4874225109cadb2d493ec1b00406cd8e95d060040420f0000000000150068747470733a2f2f726f6f6d732e6578616d706c650800000003003c0000005745503101fe1f0000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f00000000000504686f737407010102ae06000000000000000000000000000000000000000000000000000000000000096d6f64657261746f720001000108080000000000000000000000000000000000000000000000000000000000000861756469656e636500010100580000000000000000000000000000000000000000000000000000000000000005677565737403010100580100000000000000000000000000000000000000000000000000000000000007656e636f64657203000000000000000000000000000000000000000000000000000000000000000000000002057374616765020100000000046d61696e0202d007000002067370656563680101046f707573000001000000000000100000004000000080bb0000010f0a63616d6572612d37323002020d617630312e302e30354d2e30380b617663312e363430303146010002800268011e0096000000f40100000005d0021e00f4010000dc0500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000086c97e1a4b2609cb3438fd6c165e0bdc6f016bae7d16c4d37a387f4be3891b46f97c63fbf4814de11aadb3ee409d246a182017b43cacb6a6c64905bceb31c30b",
"grants": [
"575247310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a2036872df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa100000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a0000000000070101020000000000000000000000000000000000000000000000000000000000000000005745503101ae060000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011568747470733a2f2f72656c61792e6578616d706c6545000261694000383763386330653335666337666136613964346231393232336634303238326663333565396665633265353933383061633562336466616561653664353930385c245e4d368eea2fa46a37c069226e63a3489e51338486b574f405737169de74e0e230e5f27b523eb35cdc5e5a63fe4f104c48556ba46d8433b3db9a265eaa01",
"575247310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c00000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a000000000200010100000000000000000000000000000000000000000000000000000000000000000000574550310158000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011568747470733a2f2f72656c61792e6578616d706c650000da52cb8a9a33184a5da85c37affe7d9dfc6f4e7a3019ecc07c090358ded448066ac88859718562bb805f34d7da43880addb6098e654e8e51481afb9ee22ee40e"
],
"key_log_head": "{\"grant_max_s\":86400,\"issued_at\":\"2026-10-15T00:00:00Z\",\"keys\":[{\"compromised_since\":null,\"id\":\"room-authority\",\"key\":\"988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c28589285\",\"may_sign\":[\"live/room\"],\"status\":\"active\",\"until\":null}],\"kind\":\"wai-key-set\",\"next_root\":\"3328656f3df5d9617e108d46080e23610712c9979d4437e706240f53d6cadf7b\",\"origin\":\"https://rooms.example\",\"prev\":null,\"revoked_modules\":[],\"root\":\"687972706312d9675734bf13b4bcf695ecb1b374de7809242aa91f130c6a1c0c\",\"seq\":0,\"sig\":\"ba55b157ebe88fc97f2b5a5e3556415563552ed88b9931060b9e99ae0a7decefdc63e2cb916b9a5d03c80a0c1823de14e7d8d3fe0b726c2464d823611780290a\",\"status_max_age_s\":3600}",
"chains": [
[
"574556310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687c73c9d1dc51b9a83c180ec0bd17f1c8520c0ab70c4874225109cadb2d493ec1b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001040002df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa13427f23d6605d4ed9be070c149aa749edd535e46fb4a813e47bdd750d546f9654022ff7403111b077255cc79fffeb37146f44c32b5f5f811ee202b6a1811fc80d874544e4e887d493655b3d5f1e26a16664ec23594e8e685b4201ff3d8c9e105",
"574556310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687c73c9d1dc51b9a83c180ec0bd17f1c8520c0ab70c4874225109cadb2d493ec1b010000000000000066e0e16b1f2817739e3b6f2c9c87086b31f86249123017fc159142eca323af28000000000000000000104000fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c37d406a857ee63ae9fecd4f293211c3e92c6847beb4871066ecfbca21f4a38607dad484d1fc66d48a45d08c1cec17d18889212fe90f204ee16684d701e5ebf4cb837bdd6dd0af53318a5465ac6f44a98f3769e7481e99169975af532a5614809"
],
[
"574556310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c0000000000000000000000000000000000000000000000000000000000000000000000000000000040420f0000000000000312001000497320616e796f6e652074686572653f882e98ec64ee02bb24da475a9dc9c491bb077b9fbd4cd32c90c73a253d1848e72c394c8a3fe09a0c620fcb5ad17a38d15b96620fd504be6c7dd57f25a889e20a"
],
[
"574556310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a2036872df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa10000000000000000000000000000000000000000000000000000000000000000000000000000000080841e000000000000034300200057656c636f6d652e204920616d20616e206175746f6d6174656420686f73742e0267fa6ab069abc245aeccbc6afa47448ff1510522c9f9b7a79b7da263115ac1813dad0dd58243a1930f8b515255419da8042fb3bd634162e4d49a0eaa3c634dcc81144fe2cc83c3d0852e7a516af18e7f9deaa4883b4faf13af1ae9f150590302"
]
],
"outcome": "refuse",
"reason": "ai_unmarked",
"at": [
"record",
2,
0
]
},
{
"name": "rate_exceeded",
"about": "a third event in one second under a grant of two per second (§4.1; live-events §6.3)",

View file

@ -0,0 +1,38 @@
# live-conformance/synthesis — synthesis provenance
The corpus of [`extensions/synthesis.md`](../../extensions/synthesis.md) §13, in one file,
`vectors.json`: the EU icons §10.2 pins (`eu_icons`, each its `file` and `sha256`), and the
**vectors**, each a case of §11.1 and what a verifier gives it.
A case holds its `policy` (`dual_layer`, `assistive`); its records as JSON texts, list by list:
`claims` (origin claims), `markings`, `detections`, `reviews`, `label_policy` (a text or
`null`) and `player` (the log's entries); the `objects` it holds, each a `WAI1` envelope in
hexadecimal; the `decoded` samples it holds, each the content hash of the `object` decoded and
its canonical `samples` in hexadecimal; and a `room` or `null`: its `descriptor`, `grants`,
`key_log` and `chains` as live-rooms' corpus gives them.
**Synthesis equivalence.** For each vector, a conforming verifier reaches the outcome it states:
- `refuse`: the `reason` and where, `at`: `["claim", i]`, `["marking", i]`, `["detection", i]`,
`["review", i]`, `["label_policy"]`, `["player", i]`, or `["room", …]` with the room verifier's
own place (`["room", "record", c, i]` for record `i` of chain `c`);
- `accept`: the `statements` of §11.3: `claims`, each its `link`, `class`, `source`, `c2pa` (the
statement of §6, exactly as written) and `assistive` (`proved`, `not-recomputed`, `asserted`, or
`null` for a claim that is not an assist); `detections`, each its `subject` and `result`;
`ai_tracks`, each its `track` and `claim`; `reviews_presented`, each its log `entry` and
`review`; `labels`, each its log `entry`, `trigger` and the `shown` entry that met it;
`visual_icon`, the file of §10.2 the label policy's visual is, or `null`; and `room`, the room's
own outcome or `null`.
A verifier that reaches another outcome, reason, place or statement does not conform.
**Provenance.** `wai_synthesis_vectors gen` writes `vectors.json` from records authored in code:
every key is SHA-256 of the corpus's label and an index, every sample buffer and envelope payload
is SHA-256 output of a label, every time is a constant, and Ed25519 signing is deterministic, so
the file is the same bytes on every machine. The watermark scheme the markings and detections
name, `wai-mark:conformance`, names no algorithm (§7.4): the corpus exercises the records and the
agreement of the two layers, not a watermark.
```
python3 -I ../synthesis_verify.py .
```

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,864 @@
#!/usr/bin/env python3
"""Second implementation of extensions/synthesis.md (`wai.media.synthesis`), over the corpus
`live-conformance/synthesis/`.
Written from the extension's text. It shares no code with wai-rs: it reads every record as §2
has it read (strict JSON, the encodings of jwp-receipts §2.4.3), verifies every signature with
WAI's strict Ed25519 rule (claim-conformance/_ed25519.py) and every link hash with BLAKE3
written from its specification (claim-conformance/_blake3.py), runs the two pinned derivations
of §4 in its own arithmetic, writes the digital source type of §5 and the C2PA statement of §6
itself, and verifies each room with the live-rooms corpus's second implementation
(live_rooms_verify.py). It reads §10.2's pinned icons and §12's codes from the extension.
python3 -I synthesis_verify.py <live-conformance/synthesis>
It gives every vector of the corpus the outcome, the reason, the place and the statements the
corpus states, checks that every reason of §12 refuses a vector, and then mutates copies of the
corpus and requires every copy to fail. Exit status 0 only if all of that holds. Python 3.8 or
later; standard library only.
"""
import copy
import hashlib
import json
import os
import re
import sys
import unicodedata
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 _ed25519 as ED # noqa: E402
from _blake3 import blake3 # noqa: E402
EXTENSION = os.path.join(HERE, "..", "extensions", "synthesis.md")
REGISTRY = os.path.join(HERE, "..", "registry", "capabilities.json")
SIGN_DOMAIN = b"wai:synthesis\x01" # §2
LINK_DOMAIN = b"wai:synthesis-link\x01" # §2
OBJECT_DOMAIN = b"moq-jwp:object\x01" # jwp-receipts §2.1
IPTC = "http://cv.iptc.org/newscodes/digitalsourcetype/"
MODEL_ID = "tag:transaction.science,2026:wai/model/" # §6
UPSCALE2, DENOISE3 = "wai-synthesis:upscale2", "wai-synthesis:denoise3"
MAX_INT = (1 << 53) - 1
CLASSES = ["captured", "reconstructed", "assistive", "synthesized"] # §3.2, in order
STEP_CLASS = {"capture": "captured", "convert": "reconstructed", "assist": "assistive", "generate": "synthesized"}
CAPTURE_SOURCES = ["digitalCapture", "computationalCapture", "screenCapture", "digitalCreation"]
SOURCES = CAPTURE_SOURCES + ["algorithmicallyEnhanced", "trainedAlgorithmicMedia", "compositeWithTrainedAlgorithmicMedia",
"algorithmicMedia", "composite", "compositeCapture", "compositeSynthetic"] # §5
OVERSIGHT = ["fully_autonomous", "prompt_guided", "human_validated"]
MEDIA = ["audio", "video", "image", "text"]
PATH_TIERS = ["decode-equivalence", "generation-equivalence", "entropy-consistency", "none"]
REVIEW_TIERS = ["decode-equivalence", "generation-equivalence"]
# Unicode's White_Space property (a term holds none of these, jwp-receipts §2.4.3).
WHITE_SPACE = set("\t\n\x0b\x0c\r \x85\xa0         "
"   

   ")
class Refuse(Exception):
def __init__(self, reason, at=None):
super().__init__(reason)
self.reason, self.at = reason, at
class Bad(Exception):
"""A record that does not read: `record_malformed`."""
# ---------------------------------------------------------------- §2 records
def strict_json(text):
"""RFC 8259, no member named twice, no number but an unsigned integer, no unpaired
surrogate, at most 32 levels."""
def pairs(ps):
d = {}
for k, v in ps:
if k in d:
raise Bad("a member named twice")
d[k] = v
return d
def no_float(s):
raise Bad("a number that is not an unsigned integer")
def integer(s):
if s.startswith("-"):
raise Bad("a negative number")
return int(s)
try:
v = json.loads(text, object_pairs_hook=pairs, parse_float=no_float, parse_int=integer, parse_constant=no_float)
except (ValueError, RecursionError):
raise Bad("not JSON")
def walk(x, depth):
if isinstance(x, (dict, list)):
if depth > 32:
raise Bad("too deep")
for y in (x.values() if isinstance(x, dict) else x):
walk(y, depth + 1)
if isinstance(x, dict):
for k in x:
walk(k, depth)
elif isinstance(x, str):
if any(0xD800 <= ord(c) <= 0xDFFF for c in x):
raise Bad("an unpaired surrogate")
elif isinstance(x, bool):
raise Bad("a boolean")
elif isinstance(x, int):
if x > MAX_INT:
raise Bad("an integer past 2^53 - 1")
walk(v, 1)
return v
def jstr(s):
out = ['"']
for c in s:
o = ord(c)
if c == '"':
out.append('\\"')
elif c == "\\":
out.append("\\\\")
elif c == "\b":
out.append("\\b")
elif c == "\f":
out.append("\\f")
elif c == "\n":
out.append("\\n")
elif c == "\r":
out.append("\\r")
elif c == "\t":
out.append("\\t")
elif o < 0x20:
out.append("\\u%04x" % o)
else:
out.append(c)
out.append('"')
return "".join(out)
def jcs(v):
"""RFC 8785 on the values a record carries."""
if v is None:
return "null"
if isinstance(v, bool):
raise Bad("a boolean")
if isinstance(v, int):
return str(v)
if isinstance(v, str):
return jstr(v)
if isinstance(v, list):
return "[" + ",".join(jcs(x) for x in v) + "]"
keys = sorted(v, key=lambda k: k.encode("utf-16-be"))
return "{" + ",".join(jstr(k) + ":" + jcs(v[k]) for k in keys) + "}"
def link_hash(v):
return blake3(LINK_DOMAIN + jcs(v).encode("utf-8"))
def signing_bytes(v):
return SIGN_DOMAIN + jcs({k: x for k, x in v.items() if k != "sig"}).encode("utf-8")
def need(cond):
if not cond:
raise Bad("a member breaks its rule")
def hexb(v, n):
need(isinstance(v, str) and len(v) == 2 * n and re.fullmatch(r"[0-9a-f]*", v) is not None)
return bytes.fromhex(v)
def opt_hex(v, n):
return None if v is None else hexb(v, n)
def integer(v):
need(isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= MAX_INT)
return v
def term(v):
need(isinstance(v, str) and 0 < len(v.encode("utf-8")) <= 2048
and not any(c in WHITE_SPACE or unicodedata.category(c) == "Cc" for c in v))
return v
def opt_term(v):
return None if v is None else term(v)
def one_of(v, values):
need(isinstance(v, str) and v in values)
return v
def members(v, names):
need(isinstance(v, dict) and set(v) == set(names))
return v
def date_time(s):
"""RFC 3339 §5.6 date-time, with §5.7's days (jwp-receipts §2.4.3)."""
m = re.fullmatch(r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-]([0-9]{2}):([0-9]{2}))", s or "")
need(m is not None)
y, mo, d, h, mi, sec = (int(m.group(i)) for i in range(1, 7))
leap = y % 4 == 0 and (y % 100 != 0 or y % 400 == 0)
days = [31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
need(1 <= mo <= 12 and 1 <= d <= days[mo - 1] and h <= 23 and mi <= 59 and sec <= 60)
if m.group(9) is not None:
need(int(m.group(9)) <= 23 and int(m.group(10)) <= 59)
def read(text, kind, names, fields):
"""§2: read one record, then its signature. `fields` reads the kind's own members."""
try:
v = strict_json(text)
members(v, names)
need(v["kind"] == kind)
signer = hexb(v["signer_pubkey"], 32)
sig = hexb(v["sig"], 64)
if "issued_at" in v:
date_time(v["issued_at"])
r = fields(v)
except (Bad, KeyError, TypeError, AttributeError):
raise Refuse("record_malformed")
bad = ED.verify(signer, signing_bytes(v), sig)
if bad:
raise Refuse(bad)
r.update(value=v, signer=signer, link=link_hash(v))
return r
# ---------------------------------------------------------------- §3 the origin claim
ORIGIN = ["kind", "subject", "media", "class", "step", "operation", "record", "source", "models", "oversight", "inputs",
"evidence", "issued_at", "signer_pubkey", "sig"]
def subject_of(v):
t = v.get("type")
if t == "object":
members(v, ["type", "content_hash"])
return ("object", hexb(v["content_hash"], 32))
if t == "group":
members(v, ["type", "receipt"])
return ("group", hexb(v["receipt"], 32))
if t == "track":
members(v, ["type", "track", "from_group"])
integer(v["from_group"])
return ("track", hexb(v["track"], 32))
raise Bad("a subject of no type")
def origin_fields(v):
c = {"subject": subject_of(v["subject"]), "media": one_of(v["media"], MEDIA), "class": one_of(v["class"], CLASSES),
"step": one_of(v["step"], list(STEP_CLASS)), "operation": term(v["operation"]), "record": opt_hex(v["record"], 32),
"source": one_of(v["source"], SOURCES),
"oversight": None if v["oversight"] is None else one_of(v["oversight"], OVERSIGHT)}
need(isinstance(v["models"], list) and len(v["models"]) <= 16)
c["models"] = []
for m in v["models"]:
members(m, ["digest", "type"])
c["models"].append({"digest": hexb(m["digest"], 32), "trained": one_of(m["type"], ["trained", "algorithmic"]) == "trained"})
need(len({m["digest"] for m in c["models"]}) == len(c["models"]))
need(isinstance(v["inputs"], list) and len(v["inputs"]) <= 16)
c["inputs"] = []
for i in v["inputs"]:
members(i, ["content_hash", "origin", "class", "source"])
c["inputs"].append({"content_hash": hexb(i["content_hash"], 32), "origin": opt_hex(i["origin"], 32),
"class": one_of(i["class"], CLASSES), "source": one_of(i["source"], SOURCES)})
need(len({i["content_hash"] for i in c["inputs"]}) == len(c["inputs"]))
e = v["evidence"]
if e is None:
c["evidence"] = None
else:
members(e, ["tier", "derivation", "params", "width", "height", "input_samples", "output_samples"])
need(isinstance(e["params"], dict))
c["evidence"] = {"tier": one_of(e["tier"], ["replicate", "attest"]), "derivation": term(e["derivation"]),
"params": e["params"], "width": integer(e["width"]), "height": integer(e["height"]),
"input_samples": hexb(e["input_samples"], 32), "output_samples": hexb(e["output_samples"], 32)}
need(c["evidence"]["width"] >= 1 and c["evidence"]["height"] >= 1)
n, m, s = len(c["inputs"]), len(c["models"]), c["step"]
plain = c["oversight"] is None and c["evidence"] is None
if s == "capture":
need(n == 0 and m == 0 and plain and c["source"] in CAPTURE_SOURCES)
elif s == "convert":
need(n >= 1 and m == 0 and plain)
elif s == "assist":
need(n == 1 and c["oversight"] is None and c["evidence"] is not None)
else:
need(m >= 1 and c["oversight"] is not None and c["evidence"] is None)
return c
def class_of(c):
"""§3.2: the greatest of the step's class and the inputs'."""
return max([STEP_CLASS[c["step"]]] + [i["class"] for i in c["inputs"]], key=CLASSES.index)
def generated_source(c):
"""§5."""
if not any(m["trained"] for m in c["models"]):
return "algorithmicMedia"
if c["oversight"] == "human_validated" or any(i["class"] != "synthesized" for i in c["inputs"]):
return "compositeWithTrainedAlgorithmicMedia"
return "trainedAlgorithmicMedia"
def source_of(c):
"""§5's table."""
ins = c["inputs"]
if c["step"] == "capture":
return c["source"]
if c["step"] == "convert":
if len(ins) == 1:
return ins[0]["source"]
if any(i["class"] == "synthesized" for i in ins):
return "compositeSynthetic"
if all(i["source"] in ("digitalCapture", "computationalCapture") for i in ins):
return "compositeCapture"
return "composite"
if c["step"] == "assist":
return ins[0]["source"] if ins[0]["class"] == "synthesized" else "algorithmicallyEnhanced"
return generated_source(c)
def statement(c):
"""§6, as its JCS text."""
if len(c["inputs"]) != 1:
actions = [{"action": "c2pa.created", "digitalSourceType": IPTC + c["source"]}]
elif c["step"] == "convert":
actions = [{"action": "c2pa.opened"}, {"action": "c2pa.converted"}]
elif c["step"] == "assist":
actions = [{"action": "c2pa.opened"}, {"action": "c2pa.edited", "digitalSourceType": IPTC + "algorithmicallyEnhanced"}]
else:
actions = [{"action": "c2pa.opened"}, {"action": "c2pa.edited", "digitalSourceType": IPTC + generated_source(c)}]
disclosures = []
for m in [m for m in c["models"] if m["trained"]]:
data = {"modelType": "c2pa.types.model", "modelIdentifier": MODEL_ID + m["digest"].hex()}
if c["step"] == "generate":
data["contentProfile"] = {"humanOversightLevel": c["oversight"]}
k = len(disclosures)
disclosures.append({"label": "c2pa.ai-disclosure" if k == 0 else "c2pa.ai-disclosure__%d" % k, "data": data})
return jcs({"actions": actions, "assertions": disclosures})
def payload_of(c):
"""§7.2: the class's code, then seven bytes of the claim's link hash."""
return bytes([CLASSES.index(c["class"])]) + c["link"][:7]
# ---------------------------------------------------------------- §4 the derivations
def upscale2(px, w, h):
out = bytearray(4 * w * h * 3)
ow = 2 * w
def at(x, y, k):
return px[(y * w + x) * 3 + k]
for y in range(h):
for x in range(w):
x1, y1 = min(x + 1, w - 1), min(y + 1, h - 1)
for k in range(3):
a, b, c, d = at(x, y, k), at(x1, y, k), at(x, y1, k), at(x1, y1, k)
out[((2 * y) * ow + 2 * x) * 3 + k] = a
out[((2 * y) * ow + 2 * x + 1) * 3 + k] = (a + b + 1) >> 1
out[((2 * y + 1) * ow + 2 * x) * 3 + k] = (a + c + 1) >> 1
out[((2 * y + 1) * ow + 2 * x + 1) * 3 + k] = (a + b + c + d + 2) >> 2
return bytes(out)
def denoise3(px, w, h):
out = bytearray(w * h * 3)
for y in range(h):
for x in range(w):
for k in range(3):
s = 8
for j in (-1, 0, 1):
for i in (-1, 0, 1):
weight = (2 if i == 0 else 1) * (2 if j == 0 else 1)
xx, yy = min(max(x + i, 0), w - 1), min(max(y + j, 0), h - 1)
s += weight * px[(yy * w + xx) * 3 + k]
out[(y * w + x) * 3 + k] = s >> 4
return bytes(out)
DERIVATIONS = {UPSCALE2: (upscale2, 4), DENOISE3: (denoise3, 1)}
# ---------------------------------------------------------------- §7, §9, §10 the other records
def marking_fields(v):
return {"subject": hexb(v["subject"], 32), "origin": hexb(v["origin"], 32), "scheme": term(v["scheme"]),
"scheme_digest": hexb(v["scheme_digest"], 32), "payload": hexb(v["payload"], 8)}
def detection_fields(v):
return {"subject": hexb(v["subject"], 32), "scheme": term(v["scheme"]), "scheme_digest": hexb(v["scheme_digest"], 32),
"payload": opt_hex(v["payload"], 8)}
def review_fields(v):
verdict = one_of(v["verdict"], ["approved", "approved-with-notes", "rejected"])
notes = opt_hex(v["notes"], 32)
need((notes is None) == (verdict == "approved"))
term(v["decoder"])
opt_term(v["variant"])
opt_term(v["role"])
one_of(v["scope"], ["accuracy", "rendering", "translation"])
return {"subject": hexb(v["subject"], 32), "origin": hexb(v["origin"], 32), "tier": one_of(v["tier"], REVIEW_TIERS),
"reconstruction": hexb(v["reconstruction"], 32), "verdict": verdict}
def policy_fields(v):
need(isinstance(v["classes"], list) and len(v["classes"]) >= 1)
idx = [CLASSES.index(one_of(c, CLASSES)) for c in v["classes"]]
need(all(a < b for a, b in zip(idx, idx[1:])))
interval = None if v["interval_us"] is None else integer(v["interval_us"])
need(interval != 0)
return {"classes": list(v["classes"]), "visual": hexb(v["visual"], 32), "audio": opt_hex(v["audio"], 32),
"interval_us": interval, "grace_us": integer(v["grace_us"])}
def entry_of(e):
t = e.get("type")
if t == "present":
members(e, ["type", "subject", "origin", "class", "media", "tier", "decoder", "variant", "output"])
term(e["decoder"])
opt_term(e["variant"])
return {"type": t, "subject": hexb(e["subject"], 32), "origin": opt_hex(e["origin"], 32),
"class": None if e["class"] is None else one_of(e["class"], CLASSES), "media": one_of(e["media"], MEDIA),
"tier": one_of(e["tier"], PATH_TIERS), "output": opt_hex(e["output"], 32)}
if t == "shown":
members(e, ["type", "label", "asset", "trigger"])
return {"type": t, "audio": one_of(e["label"], ["visual", "audio"]) == "audio", "asset": hexb(e["asset"], 32),
"trigger": one_of(e["trigger"], ["first-exposure", "resume", "interval"])}
if t == "resume":
members(e, ["type", "after"])
one_of(e["after"], ["ad-break", "other"])
return {"type": t}
if t == "review":
members(e, ["type", "subject", "review"])
return {"type": t, "subject": hexb(e["subject"], 32), "review": hexb(e["review"], 32)}
raise Bad("an entry of no type")
def player_fields(v):
return {"session": hexb(v["session"], 32), "seq": integer(v["seq"]), "prev": opt_hex(v["prev"], 32), "at": integer(v["at"]),
"entry": entry_of(v["entry"])}
# ---------------------------------------------------------------- §11 verification
def generates(env, classes):
"""§3.3: an envelope whose capability's registry class is generative-exact or behavioral, or
whose intent is create."""
if len(env) < 8 or env[:4] != b"WAI1":
return False
n = int.from_bytes(env[4:8], "little")
try:
m = json.loads(env[8:8 + n])
except ValueError:
return False
cap = m.get("model_requirement", {}).get("capability", "")
return classes.get(cap) in ("generative-exact", "behavioral") or m.get("intent") == "create"
def track_digest(room, owner, media, layer):
"""live-rooms §6.2."""
return hashlib.sha256(("-".join([room.hex(), owner, media]) + "\x00" + str(layer)).encode()).digest()
def verify(case, classes):
"""§11.2: the statements of §11.3, or Refuse with its reason and place."""
st = {"claims": [], "detections": [], "ai_tracks": [], "reviews_presented": [], "labels": [], "visual_icon": None, "room": None}
def each(key, kind, names, fields, place):
out = []
for i, t in enumerate(case[key]):
try:
out.append(read(t, kind, names, fields))
except Refuse as e:
raise Refuse(e.reason, [place, i])
return out
# Step 1.
claims = each("claims", "wai-origin", ORIGIN, origin_fields, "claim")
markings = each("markings", "wai-marking", ["kind", "subject", "origin", "scheme", "scheme_digest", "payload", "issued_at", "signer_pubkey", "sig"],
marking_fields, "marking")
detections = each("detections", "wai-detection", ["kind", "subject", "scheme", "scheme_digest", "payload", "issued_at", "signer_pubkey", "sig"],
detection_fields, "detection")
reviews = each("reviews", "wai-review", ["kind", "subject", "origin", "tier", "reconstruction", "decoder", "variant", "scope", "verdict", "notes",
"role", "issued_at", "signer_pubkey", "sig"], review_fields, "review")
policy = None
if case["label_policy"] is not None:
try:
policy = read(case["label_policy"], "wai-label-policy", ["kind", "classes", "visual", "audio", "interval_us", "grace_us", "issued_at",
"signer_pubkey", "sig"], policy_fields)
except Refuse as e:
raise Refuse(e.reason, ["label_policy"])
log = each("player", "wai-player", ["kind", "session", "seq", "prev", "at", "entry", "signer_pubkey", "sig"], player_fields, "player")
by_link = {c["link"]: c for c in claims}
objects = {blake3(OBJECT_DOMAIN + bytes.fromhex(o)): bytes.fromhex(o) for o in case["objects"]}
decoded = {bytes.fromhex(d["object"]): bytes.fromhex(d["samples"]) for d in case["decoded"]}
sha = lambda b: hashlib.sha256(b).digest() # noqa: E731
# Step 2.
seen = set()
for i, c in enumerate(claims):
def no(reason):
raise Refuse(reason, ["claim", i])
key = jcs(c["value"]["subject"])
if key in seen:
no("claim_conflict")
seen.add(key)
if c["class"] != class_of(c):
no("class_mismatch")
if c["source"] != source_of(c):
no("source_mismatch")
for inp in c["inputs"]:
held = by_link.get(inp["origin"])
if held is not None and (held["subject"] != ("object", inp["content_hash"]) or held["class"] != inp["class"]
or held["source"] != inp["source"]):
no("input_mismatch")
kind, h = c["subject"]
if kind == "object" and h in objects and generates(objects[h], classes) and c["step"] != "generate":
no("class_understated")
status = None
e = c["evidence"]
if e is not None:
w, hh = e["width"], e["height"]
if e["tier"] == "replicate":
if e["derivation"] not in DERIVATIONS or e["params"] != {} or DERIVATIONS[e["derivation"]][1] * w * hh * 3 > (1 << 26):
no("evidence_invalid")
status = "not-recomputed"
px = decoded.get(c["inputs"][0]["content_hash"])
if px is not None:
if len(px) != w * hh * 3 or sha(px) != e["input_samples"]:
no("input_samples_mismatch")
if sha(DERIVATIONS[e["derivation"]][0](px, w, hh)) != e["output_samples"]:
no("assistive_recompute_mismatch")
status = "proved"
else:
status = "asserted"
if kind == "object" and h in decoded and sha(decoded[h]) != e["output_samples"]:
no("samples_mismatch")
if case["policy"]["assistive"] == "require-proof" and status != "proved":
no("assistive_not_proved")
st["claims"].append({"link": c["link"].hex(), "class": c["class"], "source": c["source"], "c2pa": statement(c), "assistive": status})
# Step 3.
for i, m in enumerate(markings):
c = by_link.get(m["origin"])
if c is None:
raise Refuse("claim_unknown", ["marking", i])
if c["subject"] != ("object", m["subject"]) or payload_of(c) != m["payload"]:
raise Refuse("marking_mismatch", ["marking", i])
for i, d in enumerate(detections):
claim = next((c for c in claims if c["subject"] == ("object", d["subject"])), None)
marking = next((m for m in markings if m["subject"] == d["subject"]), None)
if marking is not None and (marking["scheme"], marking["scheme_digest"]) != (d["scheme"], d["scheme_digest"]):
raise Refuse("detection_mismatch", ["detection", i])
if d["payload"] is None:
if marking is not None:
raise Refuse("watermark_absent", ["detection", i])
result = "unmarked"
elif claim is None:
result = "not-checked"
elif d["payload"] != payload_of(claim):
raise Refuse("layers_disagree", ["detection", i])
else:
result = "agree"
st["detections"].append({"subject": d["subject"].hex(), "result": result})
if case["policy"]["dual_layer"] == "required":
for i, c in enumerate(claims):
if c["class"] == "synthesized" and c["subject"][0] == "object" and not any(m["origin"] == c["link"] for m in markings):
raise Refuse("layer_missing", ["claim", i])
# Step 4.
for i, r in enumerate(reviews):
c = by_link.get(r["origin"])
if c is None:
raise Refuse("claim_unknown", ["review", i])
if c["subject"] != ("object", r["subject"]) or c["class"] != "synthesized":
raise Refuse("review_subject_mismatch", ["review", i])
# Step 5.
room = case["room"]
if room is not None:
chains = [[bytes.fromhex(e) for e in ch] for ch in room["chains"]]
grants = [bytes.fromhex(g) for g in room["grants"]]
out = LRV.verify_room(bytes.fromhex(room["descriptor"]), grants, room["key_log"], chains)
if out["outcome"] == "refuse":
raise Refuse(out["reason"], ["room"] + list(out["at"]))
st["room"] = out["outcome"]
d = LRV.read_descriptor(bytes.fromhex(room["descriptor"]))
held, _ = LRV.check_grants(d, grants)
by_digest = {g["digest"]: g for g in held}
current = {}
for c_i, r_i, under in out["order"]:
if under is not None:
continue
ev = LEV.read_record(chains[c_i][r_i])
if not 0x10 <= ev["kind"] <= 0x16:
continue
subject, x = ev["payload"][:32], LRV.read_detail(ev["kind"], ev["payload"][32:])
if ev["kind"] == 0x10:
current[subject] = x["grant"]
continue
if ev["kind"] == 0x14:
current[subject] = x["new"]
continue
if ev["kind"] == 0x11:
current.pop(subject, None)
continue
if ev["kind"] not in (0x12, 0x15):
continue
g = by_digest.get(current.get(subject))
model = None
for k, v in (LRV.read_attrs(g["attrs"]) or []) if g else []:
if k == "ai":
model = bytes.fromhex(v)
if model is None:
continue
owner = subject.hex() if not x["slot"] else "_" + x["slot"]
media = {1: "audio", 2: "video", 3: "screen"}[x["media"]]
for layer in range(x["layers"]):
t = track_digest(d["room"], owner, media, layer)
found = next((c for c in claims if c["subject"] == ("track", t) and c["class"] == "synthesized" and c["step"] == "generate"
and any(m["digest"] == model for m in c["models"])
and (c["signer"] == subject or LRV.is_room_key(d, c["signer"]))), None)
if found is None:
raise Refuse("ai_track_unlabelled", ["room", "record", c_i, r_i])
st["ai_tracks"].append({"track": t.hex(), "claim": found["link"].hex()})
# Step 6.
for j, p in enumerate(log):
ok = p["seq"] == j and p["session"] == log[0]["session"] and p["signer"] == log[0]["signer"]
ok = ok and (p["prev"] is None if j == 0 else (p["prev"] == log[j - 1]["link"] and p["at"] >= log[j - 1]["at"]))
if not ok:
raise Refuse("player_chain_broken", ["player", j])
for j, p in enumerate(log):
e = p["entry"]
if e["type"] == "present":
c = by_link.get(e["origin"]) if e["origin"] is not None else None
if c is not None and (c["subject"] != ("object", e["subject"]) or e["class"] != c["class"]):
raise Refuse("present_mismatch", ["player", j])
if e["output"] is not None and e["subject"] in decoded and sha(decoded[e["subject"]]) != e["output"]:
raise Refuse("present_output_irreproducible", ["player", j])
elif e["type"] == "review":
r = next((r for r in reviews if r["link"] == e["review"]), None)
if r is None:
raise Refuse("claim_unknown", ["player", j])
last = next((q["entry"] for q in reversed(log[:j]) if q["entry"]["type"] == "present" and q["entry"]["subject"] == e["subject"]), None)
if last is None or r["subject"] != e["subject"]:
raise Refuse("review_subject_mismatch", ["player", j])
if r["verdict"] == "rejected":
raise Refuse("review_rejected", ["player", j])
if last["tier"] != r["tier"]:
raise Refuse("review_path_not_exact", ["player", j])
if last["output"] != r["reconstruction"]:
raise Refuse("review_reconstruction_mismatch", ["player", j])
st["reviews_presented"].append({"entry": j, "review": e["review"].hex()})
if policy is not None:
st["visual_icon"] = ICONS.get(policy["visual"].hex())
for j, p in enumerate(log):
e = p["entry"]
if e["type"] == "shown" and (policy["audio"] if e["audio"] else policy["visual"]) != e["asset"]:
raise Refuse("label_asset_mismatch", ["player", j])
def shown_in(audio, lo, hi):
return next((k for k, q in enumerate(log) if q["entry"]["type"] == "shown" and q["entry"]["audio"] == audio and lo <= q["at"] <= hi), None)
last = {False: None, True: None}
for j, p in enumerate(log):
e = p["entry"]
if e["type"] != "present" or e["class"] not in policy["classes"]:
continue
audio = e["media"] == "audio"
hi = p["at"] + policy["grace_us"]
prev = last[audio]
if prev is None:
trigger, k = "first-exposure", shown_in(audio, 0, hi)
else:
resumes = [q["at"] for q in log[prev + 1:j] if q["entry"]["type"] == "resume"]
if resumes:
trigger, k = "resume", shown_in(audio, resumes[-1], hi)
elif policy["interval_us"] is not None:
trigger, k = "interval", shown_in(audio, max(p["at"] - policy["interval_us"], 0), hi)
else:
last[audio] = j
continue
if k is None:
raise Refuse("label_not_shown", ["player", j])
st["labels"].append({"entry": j, "trigger": trigger, "shown": k})
last[audio] = j
return st
# ---------------------------------------------------------------- the extension's tables
def section(text, head):
at = text.index("\n" + head)
nxt = re.search(r"\n#{2,3} ", text[at + 1:])
return text[at: at + 1 + nxt.start()] if nxt else text[at:]
with open(EXTENSION, encoding="utf-8") as _f:
_TEXT = _f.read()
ICONS = {}
for _line in section(_TEXT, "### 10.2 ").splitlines():
_m = re.fullmatch(r"\| `(LABEL_AI[^`]*)` \| `([0-9a-f]{64})` \|", _line.strip())
if _m:
ICONS[_m.group(2)] = _m.group(1)
REASONS = [l.split("`")[1] for l in section(_TEXT, "## 12. Reasons").splitlines() if l.startswith("| `")]
def outcome(v, classes):
try:
return {"outcome": "accept", "statements": verify(v, classes)}
except Refuse as e:
return {"outcome": "refuse", "reason": e.reason, "at": e.at}
def stated(v):
return {k: v[k] for k in ("outcome", "reason", "at", "statements") if k in v}
def check(doc, classes):
"""Failures of a corpus document: an outcome other than the one stated, or a table that
differs from the extension's."""
fails = []
pins = {d["sha256"]: d["file"] for d in doc["eu_icons"]}
if pins != ICONS or len(doc["eu_icons"]) != len(ICONS):
fails.append("the corpus's EU icons differ from §10.2's table")
for v in doc["vectors"]:
try:
got = outcome(v, classes)
except Exception as e: # noqa: BLE001 — a malformed copy fails, whatever it breaks
got = {"outcome": "unreadable: %r" % (e,)}
if got != stated(v):
fails.append("%s: %s" % (v["name"], json.dumps(got)[:300]))
return fails
def flip_hex_digit(s, at):
c = "0" if s[at] != "0" else "1"
return s[:at] + c + s[at + 1:]
def mutations(doc):
"""Copies of the corpus, each with one defect a conforming verifier must catch."""
vn = {v["name"]: k for k, v in enumerate(doc["vectors"])}
def mutate(label, f):
d = copy.deepcopy(doc)
f(d["vectors"])
return label, d
chain, gen, room, cert = vn["chain"], vn["generated"], vn["ai_room"], vn["certified"]
def retext(vs, i, key, k, f):
"""Rewrite record k of list `key` of vector i as JSON, applying f to its value."""
v = json.loads(vs[i][key][k])
f(v)
vs[i][key][k] = json.dumps(v)
def decoded_byte(vs):
d = vs[chain]["decoded"][0]
d["samples"] = flip_hex_digit(d["samples"], 0)
def upscale_digit(vs):
retext(vs, chain, "claims", 2, lambda v: v["evidence"].update(output_samples=flip_hex_digit(v["evidence"]["output_samples"], 5)))
def detection_class(vs):
retext(vs, gen, "detections", 0, lambda v: v.update(payload="00" + v["payload"][2:]))
return [
mutate("a digit of the upscale's signature", lambda vs: retext(vs, chain, "claims", 2, lambda v: v.update(sig=flip_hex_digit(v["sig"], 9)))),
mutate("the upscale's output digest changed", upscale_digit),
mutate("a byte of the reconstruction's decoded samples", decoded_byte),
mutate("the reconstruction's claim withheld from the chain", lambda vs: vs[chain]["claims"].pop(1)),
mutate("the generated video's detection reading class captured", detection_class),
mutate("the generated video's marking withheld", lambda vs: vs[gen]["markings"].pop(0)),
mutate("the composite's source stated compositeCapture",
lambda vs: vs[gen]["statements"]["claims"][4].update(source="compositeCapture")),
mutate("the transcode's statement stated as created",
lambda vs: vs[gen]["statements"]["claims"][5].update(c2pa=vs[gen]["statements"]["claims"][1]["c2pa"])),
mutate("the AI host's track claim withheld", lambda vs: vs[room].update(claims=[])),
mutate("the AI host's grant withheld from the room", lambda vs: vs[room]["room"]["grants"].pop(0)),
mutate("the certified log's first label withheld", lambda vs: vs[cert]["player"].pop(0)),
mutate("the label after the break stated as an interval",
lambda vs: vs[cert]["statements"]["labels"][1].update(trigger="interval")),
mutate("the review stated at generation-equivalence",
lambda vs: retext(vs, cert, "reviews", 0, lambda v: v.update(tier="generation-equivalence"))),
mutate("the presented video's decoded frame changed",
lambda vs: vs[cert]["decoded"][0].update(samples=flip_hex_digit(vs[cert]["decoded"][0]["samples"], 3))),
mutate("the policy's visual stated as another pinned icon",
lambda vs: vs[cert]["statements"].update(visual_icon="LABEL_AI_black.svg")),
mutate("the first refusal stated at another place", lambda vs: vs[next(k for k, v in enumerate(vs) if v["outcome"] == "refuse")].update(at=["claim", 1])),
("an EU icon's pin changed", dict(copy.deepcopy(doc), eu_icons=[dict(doc["eu_icons"][0], sha256="00" * 32)] + doc["eu_icons"][1:])),
]
def main(corpus_dir):
fails = []
st = ED._self_test()
print(("PASS" if not st else "FAIL") + " Ed25519 strict rule self-test " + ",".join(st))
if st:
fails.append("ed25519 self-test")
with open(REGISTRY, encoding="utf-8") as f:
classes = {c["capability"]: c["replicate"] for c in json.load(f)["capabilities"]}
with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as f:
doc = json.load(f)
if len(ICONS) != 24:
fails.append("§10.2's table does not read as 24 pinned icons")
reasons_seen = set()
for v in doc["vectors"]:
try:
got = outcome(v, classes)
except Exception as e: # noqa: BLE001
got = {"outcome": "unreadable: %r" % (e,)}
ok = got == stated(v)
if v.get("reason"):
reasons_seen.add(v["reason"])
print(("PASS" if ok else "FAIL") + " vector %s: %s" % (v["name"], got["outcome"] + (" %s at %s" % (got["reason"], got["at"]) if "reason" in got else "")))
if not ok:
fails.append("%s: %s, the corpus states %s" % (v["name"], json.dumps(got)[:400], json.dumps(stated(v))[:400]))
table = check(doc, classes)
if any("EU icons" in t for t in table):
fails.append("the corpus's EU icons differ from §10.2's table")
missing = [r for r in REASONS if r not in reasons_seen]
if missing:
fails.append("no vector refused with %s" % ", ".join(missing))
accepted = {v["name"] for v in doc["vectors"] if v["outcome"] == "accept"}
want = {"chain", "assistive_asserted", "generated", "ai_room", "certified"}
if not want <= accepted:
fails.append("vectors not accepted: %s" % ", ".join(sorted(want - accepted)))
print("%s every reason of §12 refuses a vector, and every case of §13 is accepted" % ("PASS" if not missing and want <= accepted else "FAIL"))
for label, d in mutations(doc):
caught = check(d, classes)
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") + " synthesis corpus: %d vectors" % len(doc["vectors"]))
return 1 if fails else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: synthesis_verify.py <live-conformance/synthesis>", file=sys.stderr)
sys.exit(2)
sys.exit(main(sys.argv[1]))

View file

@ -770,6 +770,20 @@
"edition": null,
"registered": "2026-10-20"
},
{
"capability": "wai.media.synthesis",
"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-21"
},
{
"capability": "wai.mesh.draco",
"kind": "payload",
@ -2319,6 +2333,13 @@
"section": "§5.1",
"status": "defined"
},
{
"name": "wai-detection",
"kind": "document-kind",
"document": "extensions/synthesis.md",
"section": "§7.3",
"status": "defined"
},
{
"name": "wai-key-status",
"kind": "document-kind",
@ -2326,6 +2347,20 @@
"section": "§7.9",
"status": "defined"
},
{
"name": "wai-label-policy",
"kind": "document-kind",
"document": "extensions/synthesis.md",
"section": "§10.1",
"status": "defined"
},
{
"name": "wai-marking",
"kind": "document-kind",
"document": "extensions/synthesis.md",
"section": "§7.2",
"status": "defined"
},
{
"name": "wai-module-grant",
"kind": "document-kind",
@ -2340,6 +2375,20 @@
"section": "§8.4",
"status": "defined"
},
{
"name": "wai-origin",
"kind": "document-kind",
"document": "extensions/synthesis.md",
"section": "§3.1",
"status": "defined"
},
{
"name": "wai-player",
"kind": "document-kind",
"document": "extensions/synthesis.md",
"section": "§10.3",
"status": "defined"
},
{
"name": "wai-receipt-index",
"kind": "document-kind",
@ -2354,6 +2403,13 @@
"section": "§4.1",
"status": "defined"
},
{
"name": "wai-review",
"kind": "document-kind",
"document": "extensions/synthesis.md",
"section": "§9.1",
"status": "defined"
},
{
"name": "wai1",
"kind": "codecs-prefix",

View file

@ -618,6 +618,13 @@ wai:wai.live.watermark a wai:Capability ;
wai:specSection "§5 Interactive, temporal & computed content" ;
wai:registeredOn "2026-10-20"^^xsd:date .
wai:wai.media.synthesis a wai:Capability ;
rdfs:label "wai.media.synthesis" ;
wai:kind wai:kind-record ;
wai:status wai:status-registered ;
wai:specSection "§5 Interactive, temporal & computed content" ;
wai:registeredOn "2026-10-21"^^xsd:date .
wai:wai.mesh.draco a wai:Capability ;
rdfs:label "wai.mesh.draco" ;
wai:kind wai:kind-payload ;

View file

@ -451,7 +451,8 @@ fn payload_json(ev: &Event) -> Value {
"lang": c.lang,
"text": c.text,
}),
Payload::Chat { text } => json!({"text": text}),
Payload::Chat { text, origin: None } => json!({"text": text}),
Payload::Chat { text, origin: Some(o) } => json!({"text": text, "origin": o.origin, "model": o.model.map(|m| hex(&m))}),
Payload::Marker(m) => json!({"marker": format!("{m:?}")}),
Payload::Reaction(r) => json!({"reaction": format!("{r:?}")}),
Payload::Lifecycle { kind: k, subject, detail } => {
@ -535,7 +536,7 @@ pub fn caption_payload(duration_ms: u32, origin: u8, model: Option<String>, lang
#[wasm_bindgen(js_name = chatPayload)]
pub fn chat_payload(text: String) -> Vec<u8> {
Payload::Chat { text }.encode()
Payload::Chat { text, origin: None }.encode()
}
/// A lifecycle payload (live-rooms §7.2) from its JSON description: `kind` (`join`,

View file

@ -289,6 +289,15 @@ png_still = []
# logs key_log's (key_discovery), its clips int_video's (video_int); jpeg-encoder, already a
# dependency of `codecs`, writes its attested JPEG outputs. No new dependency.
derive_edge = ["key_discovery", "video_int", "png_still", "dep:jpeg-encoder"]
# Synthesis provenance (extensions/synthesis): `wai.media.synthesis` — the origin claim of an
# object, a group or a track (captured, reconstructed, assistive or synthesized; the class rises
# along a chain of derivations and never falls), its IPTC digital source type and C2PA statement,
# the two pinned integer derivations whose recomputation proves an assistive claim, the marking
# and detection records of the second marking layer and their agreement with the claim, the
# tracks of a room's AI participants, reviews that a player presents only on a decode path of
# the review's tier, and the label obligations of a player's signed log. SHA-256, BLAKE3,
# Ed25519 and the key log only; no new dependency.
synthesis = ["live_room"]
# 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,
@ -743,6 +752,11 @@ name = "wai_live_moderation_vectors"
path = "src/bin/wai_live_moderation_vectors.rs"
required-features = ["live_moderation"]
[[bin]]
name = "wai_synthesis_vectors"
path = "src/bin/wai_synthesis_vectors.rs"
required-features = ["synthesis"]
[[bin]]
name = "wai_live_watermark_vectors"
path = "src/bin/wai_live_watermark_vectors.rs"
@ -1461,6 +1475,11 @@ name = "live_moderation"
path = "tests/live_moderation.rs"
required-features = ["live_moderation"]
[[test]]
name = "synthesis"
path = "tests/synthesis.rs"
required-features = ["synthesis"]
[[test]]
name = "live_watermark"
path = "tests/live_watermark.rs"

View file

@ -0,0 +1,74 @@
//! Conformance corpus for synthesis provenance (`live-conformance/synthesis/`, extensions/synthesis.md §13).
//!
//! wai_synthesis_vectors gen <corpus-dir> write vectors.json from fixed seeds
//! wai_synthesis_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/synthesis_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::synthesis::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_synthesis_vectors gen|verify <corpus-dir>");
std::process::exit(2);
}
}
}
fn verify(dir: &Path) -> i32 {
let mut files = BTreeMap::new();
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
eprintln!("read {}: {e}", dir.display());
return 1;
}
};
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if name == "README.md" {
continue;
}
match std::fs::read_to_string(e.path()) {
Ok(s) => {
files.insert(name, s);
}
Err(err) => {
eprintln!("read {name}: {err}");
return 1;
}
}
}
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
let same = files == generated;
println!("{} every file regenerates byte for byte from its seeds, and no other file is there", if same { "PASS" } else { "FAIL" });
let fails = corpus::verify(&files);
for f in &fails {
println!("FAIL {f}");
}
println!("{} every case gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
(!same || !fails.is_empty()) as i32
}

View file

@ -417,7 +417,7 @@ fn write(s: &Scene) -> Written {
rm.emit(DIRECTOR, capture + k * S, &Payload::Marker(Marker { taxonomy: 4, polarity, text: note.into(), attachment: None }));
}
rm.emit(DIRECTOR, s.stop_t, &Payload::Control { target: pk(DEVICE), command: Command::RecordStop });
let chat = rm.emit(VIEWER, s.start_t + 500 * MS, &Payload::Chat { text: "good luck".into() });
let chat = rm.emit(VIEWER, s.start_t + 500 * MS, &Payload::Chat { text: "good luck".into(), origin: None });
// The chunks, as captured, as the device commits them, and as the manifest lists them.
let device_time = |t: u64| (t as i128 - s.offset as i128) as u64;
let bytes: Vec<Vec<u8>> = (0..s.chunks.len()).map(|i| chunk_bytes(s.label.as_bytes(), i)).collect();

View file

@ -223,6 +223,7 @@ pub const CAP_LIVE_WATERMARK: &str = "wai.live.watermark";
pub const CAP_LIVE_ADS: &str = "wai.live.ads";
pub const CAP_LIVE_PROVENANCE: &str = "wai.live.provenance";
pub const CAP_EDGE_DERIVATION: &str = "wai.edge.derivation";
pub const CAP_MEDIA_SYNTHESIS: &str = "wai.media.synthesis";
/// One row per string SPEC §5 registers that is a payload, companion or record:
/// `(string, SPEC section, edition, registered)`.
@ -314,6 +315,7 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[
(CAP_LIVE_WATERMARK, S_INTERACTIVE, None, "2026-10-20"),
(CAP_LIVE_ADS, S_INTERACTIVE, None, "2026-10-20"),
(CAP_LIVE_PROVENANCE, S_INTERACTIVE, None, "2026-10-21"),
(CAP_MEDIA_SYNTHESIS, S_INTERACTIVE, None, "2026-10-21"),
(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"),
@ -368,6 +370,7 @@ const RECORDS: &[&str] = &[
CAP_LIVE_ADS,
CAP_LIVE_PROVENANCE,
CAP_EDGE_DERIVATION,
CAP_MEDIA_SYNTHESIS,
];
/// A named identifier SPEC uses that has the shape of a capability string but is
@ -526,6 +529,7 @@ const FORENSIC_WATERMARK: &str = "extensions/forensic-watermark.md";
const LIVE_ADS: &str = "extensions/live-ads.md";
const LIVE_PROVENANCE: &str = "extensions/live-provenance.md";
const DERIVING_EDGE: &str = "extensions/deriving-edge.md";
const SYNTHESIS: &str = "extensions/synthesis.md";
const fn wire(name: &'static str, kind: WireKind, document: &'static str, section: &'static str,
status: WireStatus) -> WireIdentifier {
@ -536,9 +540,9 @@ const fn wire(name: &'static str, kind: WireKind, document: &'static str, sectio
/// (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, of live metering, of energy steering,
/// of live moderation, of confidentiality, of forensic watermark sessions, of live ads and of
/// live provenance and of the deriving edge, in document order. The export lists them sorted by
/// name and kind.
/// of live moderation, of confidentiality, of forensic watermark sessions, of live ads, of live
/// provenance, of the deriving edge and of synthesis, 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),
@ -627,6 +631,12 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
wire("wai_c2pa_track", WireKind::Catalog, LIVE_PROVENANCE, "§10.2", WireStatus::Defined),
wire("wai-c2pa", WireKind::Catalog, LIVE_PROVENANCE, "§10.2", WireStatus::Defined),
wire("wai-derivation", WireKind::DocumentKind, DERIVING_EDGE, "§5.1", WireStatus::Defined),
wire("wai-origin", WireKind::DocumentKind, SYNTHESIS, "§3.1", WireStatus::Defined),
wire("wai-marking", WireKind::DocumentKind, SYNTHESIS, "§7.2", WireStatus::Defined),
wire("wai-detection", WireKind::DocumentKind, SYNTHESIS, "§7.3", WireStatus::Defined),
wire("wai-review", WireKind::DocumentKind, SYNTHESIS, "§9.1", WireStatus::Defined),
wire("wai-label-policy", WireKind::DocumentKind, SYNTHESIS, "§10.1", WireStatus::Defined),
wire("wai-player", WireKind::DocumentKind, SYNTHESIS, "§10.3", WireStatus::Defined),
];
/// A string with the shape of a capability that the reference implementation uses

View file

@ -460,6 +460,15 @@ pub mod energy_steering;
/// cbindgen:ignore
#[cfg(feature = "live_moderation")]
pub mod live_moderation;
// Synthesis provenance (extensions/synthesis.md): `wai.media.synthesis` — the origin claim of an
// object, a group or a track and its class (captured, reconstructed, assistive, synthesized),
// its IPTC digital source type and C2PA statement, assistive claims proved by recomputing a pinned
// integer derivation, the two marking layers and their agreement, AI participants' tracks,
// reviews presented only on a decode path of their tier, and a player's label log. Rust-only,
// like the live modules: its constants must not become macros in include/wai.h.
/// cbindgen:ignore
#[cfg(feature = "synthesis")]
pub mod synthesis;
// Forensic watermark sessions (extensions/forensic-watermark.md): `wai.live.watermark` — the edge's
// session record of the ETSI TS 104 002 A/B variants it embedded, bound to the session's grant by a
// salted commitment, the recovered sequence and the rule that matches it, and the room authority's

View file

@ -409,7 +409,7 @@ struct LiveBreak {
fn live_break(spec: Spec) -> LiveBreak {
let mut room = room(spec);
room.raw(&key(PRODUCER), 10 * S, kind::CHAT, Payload::Chat { text: "kick-off".into() }.encode());
room.raw(&key(PRODUCER), 10 * S, kind::CHAT, Payload::Chat { text: "kick-off".into(), origin: None }.encode());
let out = room.cue(PRODUCER, 60 * S, cue_type::OUT, 101, 0);
let in_ = room.cue(PRODUCER, 90 * S, cue_type::IN, 101, 30_000);
let policy = policy(&room, |_| {});

View file

@ -826,10 +826,10 @@ fn studio(v: Variant) -> Written {
c.life(S, DIRECTOR, own(media::VIDEO, 2, 2));
c.life(2 * S, MIXER, own(media::VIDEO, 3, 3));
c.life(2 * S, MIXER, own(media::AUDIO, 1, 1));
c.emit(VIEWER, 2_500 * MS, &Payload::Chat { text: "hello".into() });
c.emit(VIEWER, 2_500 * MS, &Payload::Chat { text: "hello".into(), origin: None });
c.life(3 * S, GUEST, Detail::Join { grant: gg.digest() });
c.life(3 * S, GUEST, own(media::AUDIO, 0, 1));
c.emit(VIEWER, 3_500 * MS, &Payload::Chat { text: "welcome".into() });
c.emit(VIEWER, 3_500 * MS, &Payload::Chat { text: "welcome".into(), origin: None });
c.life(4 * S, SCREEN, Detail::Join { grant: gs.digest() });
c.life(4 * S, SCREEN, own(media::SCREEN, 4, 1));
c.life(4 * S, ENCODER, Detail::Join { grant: ge.digest() });

View file

@ -446,6 +446,30 @@ pub mod scte_form {
pub const EVENT_BASED: u8 = 2;
}
/// The origin a `chat` or a `reaction` may state (§4.4, §4.5), as a `caption` states its
/// own (§4.10).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Origin {
/// 1 human, 2 machine, 3 machine-reviewed.
pub origin: u8,
/// The digest naming the model, present exactly when `origin` is 2 or 3.
pub model: Option<[u8; 32]>,
}
impl Origin {
/// Whether it states that a machine produced the content: origin 2 or 3.
pub fn is_machine(&self) -> bool {
matches!(self.origin, 2 | 3)
}
fn encode(&self, o: &mut Vec<u8>) {
o.push(self.origin);
if let Some(m) = self.model {
o.extend_from_slice(&m);
}
}
}
/// A `reaction` (§4.5).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Reaction {
@ -453,6 +477,8 @@ pub struct Reaction {
pub value: u16,
pub target: Option<[u8; 32]>,
pub remove: bool,
/// The origin it states, if it states one.
pub origin: Option<Origin>,
}
/// A `marker` (§4.8).
@ -492,7 +518,8 @@ pub struct Caption {
pub enum Payload {
Meta(Vec<u8>),
Cue(Cue),
Chat { text: String },
/// A message, and the origin it states, if it states one (§4.4).
Chat { text: String, origin: Option<Origin> },
Reaction(Reaction),
PollOpen { question: String, options: Vec<String> },
Vote { poll: [u8; 32], option: u8 },
@ -549,7 +576,12 @@ impl Payload {
}
}
}
Payload::Chat { text } => put_text(&mut o, text),
Payload::Chat { text, origin } => {
put_text(&mut o, text);
if let Some(g) = origin {
g.encode(&mut o);
}
}
Payload::Reaction(r) => {
o.push(r.rtype);
o.extend_from_slice(&r.value.to_le_bytes());
@ -557,6 +589,9 @@ impl Payload {
if let Some(t) = r.target {
o.extend_from_slice(&t);
}
if let Some(g) = &r.origin {
g.encode(&mut o);
}
}
Payload::PollOpen { question, options } => {
put_text(&mut o, question);
@ -670,6 +705,22 @@ pub fn splice_info_section_ok(b: &[u8]) -> bool {
b.len() >= 3 && b[0] == 0xFC && 3 + ((((b[1] & 0x0F) as usize) << 8) | b[2] as usize) == b.len()
}
/// §4.4, §4.5: the optional origin after a `chat`'s or a `reaction`'s last field — `origin`
/// (1, 2 or 3), then `model` for 2 and 3 — or none when no byte follows.
fn read_origin(r: &mut Rd) -> Result<Option<Origin>, Reason> {
if r.done() {
return Ok(None);
}
let m = Reason::MalformedPayload;
let origin = r.u8().ok_or(m)?;
let model = match origin {
1 => None,
2 | 3 => Some(r.d32().ok_or(m)?),
_ => return Err(m),
};
Ok(Some(Origin { origin, model }))
}
/// Read a payload of kind `k` (§4), in the order its fields are written; each field's own
/// rule applies when it is read, and bytes after the last field are `malformed_payload`.
/// `k` is a kind this revision defines.
@ -701,7 +752,10 @@ pub fn parse_payload(k: u8, b: &[u8], max_text_cp: u16) -> Result<Payload, Reaso
};
Payload::Cue(Cue { cue_type: ct, cue_id, duration_ms, scte })
}
kind::CHAT => Payload::Chat { text: read_text(&mut r, max_text_cp)? },
kind::CHAT => {
let text = read_text(&mut r, max_text_cp)?;
Payload::Chat { text, origin: read_origin(&mut r)? }
}
kind::REACTION => {
let rtype = r.u8().ok_or(m)?;
if !(1..=5).contains(&rtype) {
@ -716,7 +770,7 @@ pub fn parse_payload(k: u8, b: &[u8], max_text_cp: u16) -> Result<Payload, Reaso
return Err(m);
}
let target = if flags & 1 != 0 { Some(r.d32().ok_or(m)?) } else { None };
Payload::Reaction(Reaction { rtype, value, target, remove: flags & 2 != 0 })
Payload::Reaction(Reaction { rtype, value, target, remove: flags & 2 != 0, origin: read_origin(&mut r)? })
}
kind::POLL_OPEN => {
let question = read_text(&mut r, max_text_cp)?;
@ -1692,7 +1746,7 @@ mod tests {
}
fn chat(s: &str) -> Payload {
Payload::Chat { text: s.into() }
Payload::Chat { text: s.into(), origin: None }
}
#[test]
@ -1746,7 +1800,11 @@ mod tests {
Payload::Cue(Cue { cue_type: cue_type::BREAK, cue_id: 8, duration_ms: 0, scte: Some((2, vec![1])) }),
Payload::Cue(Cue { cue_type: cue_type::CHAPTER, cue_id: 9, duration_ms: 1, scte: None }),
chat("héllo"),
Payload::Reaction(Reaction { rtype: 1, value: 3, target: Some(d), remove: true }),
Payload::Chat { text: "hi".into(), origin: Some(Origin { origin: 1, model: None }) },
Payload::Chat { text: "hi".into(), origin: Some(Origin { origin: 2, model: Some(d) }) },
Payload::Reaction(Reaction { rtype: 1, value: 3, target: Some(d), remove: true, origin: None }),
Payload::Reaction(Reaction { rtype: 1, value: 3, target: Some(d), remove: false, origin: Some(Origin { origin: 3, model: Some(d) }) }),
Payload::Reaction(Reaction { rtype: 4, value: 1, target: None, remove: false, origin: Some(Origin { origin: 2, model: Some(d) }) }),
Payload::PollOpen { question: "q".into(), options: vec!["a".into(), "b".into()] },
Payload::Vote { poll: d, option: 1 },
Payload::PollClose { poll: d, counts: vec![1, 2] },
@ -1772,6 +1830,22 @@ mod tests {
}
}
/// §4.4, §4.5: a chat or a reaction states an origin of 1, 2 or 3, with a model exactly
/// for 2 and 3; anything else after its last field is `malformed_payload`.
#[test]
fn a_stated_origin_reads_as_a_captions_does() {
let text = chat("x").encode();
for (tail, ok) in [(vec![1], true), (vec![2], false), (vec![1, 0], false), (vec![0], false), (vec![4], false)] {
let b = [text.clone(), tail.clone()].concat();
assert_eq!(parse_payload(kind::CHAT, &b, 500).is_ok(), ok, "{tail:?}");
}
let b = [text.clone(), vec![3], vec![5; 32]].concat();
assert_eq!(parse_payload(kind::CHAT, &b, 500), Ok(Payload::Chat { text: "x".into(), origin: Some(Origin { origin: 3, model: Some([5; 32]) }) }));
let r = Payload::Reaction(Reaction { rtype: 4, value: 1, target: None, remove: false, origin: None }).encode();
assert_eq!(parse_payload(kind::REACTION, &[r.clone(), vec![2]].concat(), 500), Err(Reason::MalformedPayload));
assert!(parse_payload(kind::REACTION, &[r, vec![2], vec![6; 32]].concat(), 500).is_ok());
}
#[test]
fn text_rules() {
let mut b = vec![2, 0, 0xC0, 0xAF];
@ -1840,7 +1914,7 @@ mod tests {
let mut a = Author::new(4);
let rs: Vec<Vec<u8>> = [0, 500_000, 1_000_000].iter().map(|&t| a.emit(t, chat("x")).to_bytes()).collect();
assert_eq!(verify_chain(ROOM, p, &rs), Outcome::Accept);
let r = |target| Payload::Reaction(Reaction { rtype: 1, value: 1, target, remove: false });
let r = |target| Payload::Reaction(Reaction { rtype: 1, value: 1, target, remove: false, origin: None });
let mut a = Author::new(4);
let rs = vec![a.emit(0, r(None)).to_bytes(), a.emit(10, r(Some([1; 32]))).to_bytes(), a.emit(999_999, r(None)).to_bytes()];
assert_eq!(verify_chain(ROOM, Policy::default(), &rs), Outcome::Refuse { index: 2, reason: Reason::RateExceeded });

View file

@ -95,7 +95,7 @@ fn video(group: u64, object: u64) -> Anchor {
}
fn chat(s: &str) -> Payload {
Payload::Chat { text: s.into() }
Payload::Chat { text: s.into(), origin: None }
}
fn digest_of(record: &[u8]) -> [u8; 32] {
@ -165,7 +165,7 @@ fn caption(duration_ms: u32, origin: u8, lang: &str, text: &str) -> Payload {
}
fn reaction(rtype: u8, value: u16, target: Option<[u8; 32]>, remove: bool) -> Payload {
Payload::Reaction(Reaction { rtype, value, target, remove })
Payload::Reaction(Reaction { rtype, value, target, remove, origin: None })
}
fn lifecycle(kind: u8, subject: u8, detail: &[u8]) -> Payload {
@ -333,6 +333,22 @@ fn good_vectors() -> Vec<Vector> {
Intent::Accept,
));
let mut a = Author::new(ALICE);
let model = sha(&[b"wai:live-events/corpus/model/", b"chat"]);
let said = a.at(S, Payload::Chat { text: "Here is a summary of the talk so far.".into(), origin: Some(Origin { origin: 2, model: Some(model) }) });
let target = digest_of(&said);
out.push(v(
"kind_chat_origin",
"chat and reaction stating their origin (§4.4, §4.5): a machine message with its model's digest, a machine-reviewed one, a human one stating origin 1, and a machine reaction targeting the first",
vec![
said,
a.at(2 * S, Payload::Chat { text: "Reviewed: the summary above is accurate.".into(), origin: Some(Origin { origin: 3, model: Some(model) }) }),
a.at(3 * S, Payload::Chat { text: "Typed by a person.".into(), origin: Some(Origin { origin: 1, model: None }) }),
a.at(4 * S, Payload::Reaction(Reaction { rtype: 1, value: 1, target: Some(target), remove: false, origin: Some(Origin { origin: 2, model: Some(model) }) })),
],
Intent::Accept,
));
let mut h = Author::new(HOST);
out.push(v(
"kind_caption",
@ -653,6 +669,15 @@ fn negative_vectors() -> Vec<Vector> {
));
let mut h = Author::new(HOST);
out.push(v("malformed_payload_lang", "a caption whose language tag is one letter (§4.10)", vec![h.at(S, caption(1000, 1, "e", "x"))], refuse(0, Reason::MalformedPayload)));
let mut a = Author::new(ALICE);
let mut bytes = chat("which model?").encode();
bytes.push(2);
out.push(v(
"malformed_payload_origin",
"a chat stating origin 2 with no model digest after it (§4.4)",
vec![a.raw(S, None, kind::CHAT, bytes).to_bytes()],
refuse(0, Reason::MalformedPayload),
));
let mut h = Author::new(HOST);
out.push(v("malformed_payload_poll", "a poll_open with one option (§4.6)", vec![h.at(S, poll_open("Only one?", &["Yes"]))], refuse(0, Reason::MalformedPayload)));

View file

@ -376,8 +376,8 @@ fn lesson(s: Scene) -> WrittenRoom {
for p in &plans {
c.emit(DIRECTOR, STOP_T, &Payload::Control { target: pk(p.device), command: Command::RecordStop });
}
c.emit(VIEWER, START_T + 500 * MS, &Payload::Chat { text: "good luck".into() });
c.emit(VIEWER2, 18 * S, &Payload::Chat { text: "bravo".into() });
c.emit(VIEWER, START_T + 500 * MS, &Payload::Chat { text: "good luck".into(), origin: None });
c.emit(VIEWER2, 18 * S, &Payload::Chat { text: "bravo".into(), origin: None });
// Each take: its commitments in the device's chain, and its manifest.
let mut takes = Vec::new();
for (k, p) in plans.iter().enumerate() {

View file

@ -179,11 +179,11 @@ fn role(name: &str, powers: u8, events: &[u8]) -> Role {
}
fn chat(s: &str) -> Payload {
Payload::Chat { text: s.into() }
Payload::Chat { text: s.into(), origin: None }
}
fn emoji(target: Option<[u8; 32]>) -> Payload {
Payload::Reaction(Reaction { rtype: 1, value: 1, target, remove: false })
Payload::Reaction(Reaction { rtype: 1, value: 1, target, remove: false, origin: None })
}
/// The chat room: a host and a moderator with the moderate power, two viewers, and the

View file

@ -344,7 +344,7 @@ fn write(s: &Scene) -> Written {
for t in &s.takes {
rm.emit(DIRECTOR, s.stop_t, &Payload::Control { target: pk(t.device), command: Command::RecordStop });
}
rm.emit(VIEWER, s.start_t + 500 * MS, &Payload::Chat { text: "good luck".into() });
rm.emit(VIEWER, s.start_t + 500 * MS, &Payload::Chat { text: "good luck".into(), origin: None });
// Each take.
let mut files = BTreeMap::new();
let mut written = Vec::new();

View file

@ -148,6 +148,7 @@ pub enum Reason {
ParticipantLimit,
OutsideGrantWindow,
PowerNotGranted,
AiUnmarked,
PublishNotPermitted,
ProfileNotGranted,
AlreadyPublished,
@ -179,7 +180,7 @@ impl From<events::Reason> for Reason {
impl Reason {
/// The codes this extension adds, in §11's order.
pub const ALL: [Reason; 42] = [
pub const ALL: [Reason; 43] = [
Reason::DescriptorInvalid,
Reason::KeyLogMissing,
Reason::KeyLogNotHeld,
@ -201,6 +202,7 @@ impl Reason {
Reason::ParticipantLimit,
Reason::OutsideGrantWindow,
Reason::PowerNotGranted,
Reason::AiUnmarked,
Reason::PublishNotPermitted,
Reason::ProfileNotGranted,
Reason::AlreadyPublished,
@ -249,6 +251,7 @@ impl Reason {
Reason::ParticipantLimit => "participant_limit",
Reason::OutsideGrantWindow => "outside_grant_window",
Reason::PowerNotGranted => "power_not_granted",
Reason::AiUnmarked => "ai_unmarked",
Reason::PublishNotPermitted => "publish_not_permitted",
Reason::ProfileNotGranted => "profile_not_granted",
Reason::AlreadyPublished => "already_published",
@ -1203,8 +1206,11 @@ impl Grant {
if raw.origins.len() > 8 || !raw.origins.iter().all(|o| token_ok(o, 255)) {
return bad;
}
if read_attrs(&raw.attrs).is_none() {
return bad;
match read_attrs(&raw.attrs) {
None => return bad,
// §4.4: `ai` is a model's digest, as 64 lowercase hexadecimal digits.
Some(a) if a.iter().any(|(k, v)| k == AI_ATTR && hex32(v).is_none()) => return bad,
Some(_) => {}
}
Ok(())
}
@ -1237,6 +1243,12 @@ impl Grant {
Ok(())
}
/// The model an AI participant's grant names in its `ai` attribute (§4.4), or `None` for
/// a grant without one.
pub fn ai_model(&self) -> Option<[u8; 32]> {
self.attrs.iter().find(|(k, _)| k == AI_ATTR).and_then(|(_, v)| hex32(v))
}
/// The revocation tag of the grant's viewer (§5), when it names one.
pub fn viewer_tag(&self) -> Option<[u8; 32]> {
(self.viewer != [0; 16]).then(|| sha256(&[VIEWER_DOMAIN, &self.viewer]))
@ -1268,6 +1280,36 @@ fn read_grant(b: &[u8], d: &Descriptor) -> Result<(RawGrant, [u8; 32]), Reason>
Ok((raw, sha256(&[body])))
}
/// The attribute that marks an AI participant (§4.4).
pub const AI_ATTR: &str = "ai";
/// 64 lowercase hexadecimal digits, read as 32 bytes.
fn hex32(s: &str) -> Option<[u8; 32]> {
let b = s.as_bytes();
if b.len() != 64 || !b.iter().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(c)) {
return None;
}
let mut o = [0u8; 32];
for (i, o) in o.iter_mut().enumerate() {
*o = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
}
Some(o)
}
/// §9.2 step 6: an AI participant's `chat`, `reaction` and `caption` state that a machine
/// produced them, with the model its grant names. Any other payload, or a principal whose
/// grant names no model, is not held to it.
pub fn ai_marked(g: &Grant, p: &Payload) -> bool {
let Some(model) = g.ai_model() else { return true };
let stated = match p {
Payload::Chat { origin, .. } => origin.map(|o| (o.origin, o.model)),
Payload::Reaction(r) => r.origin.map(|o| (o.origin, o.model)),
Payload::Caption(c) => Some((c.origin, c.model)),
_ => return true,
};
matches!(stated, Some((2 | 3, Some(m))) if m == model)
}
/// A grant a room verifier holds, with its digest.
#[derive(Clone, Debug)]
pub struct HeldGrant {
@ -1988,6 +2030,12 @@ fn step(replay: &mut Replay, verifier: &mut RoomVerifier, seen: &mut Seen, b: &[
return Err(Reason::PowerNotGranted);
}
}
// f. An AI participant's chat, reaction and caption state their machine origin (§4.4).
if let Some(a) = verifier.accepted().last()
&& !ai_marked(&h.grant, &a.payload)
{
return Err(Reason::AiUnmarked);
}
Ok(Some(h.digest))
}

View file

@ -301,7 +301,7 @@ impl Build {
}
fn chat(s: &str) -> Payload {
Payload::Chat { text: s.into() }
Payload::Chat { text: s.into(), origin: None }
}
fn control(target: u8, command: Command) -> Payload {
@ -372,7 +372,7 @@ fn lesson() -> (Build, [u8; 32]) {
b.ev(PERFORMER, 8 * S, take_commit(1));
b.ev(DIRECTOR, 9 * S, Payload::Marker(Marker { taxonomy: 2, polarity: 2, text: "rushed bar 12".into(), attachment: None }));
b.ev(DIRECTOR, 10 * S, control(PERFORMER, Command::RecordStop));
b.ev(VIEWER_B, 10_500_000, Payload::Reaction(Reaction { rtype: 4, value: 1, target: None, remove: false }));
b.ev(VIEWER_B, 10_500_000, Payload::Reaction(Reaction { rtype: 4, value: 1, target: None, remove: false, origin: None }));
b.ev(DIRECTOR, 11 * S, chat("again from bar 9"));
b.life(12 * S, VIEWER_B, Detail::Leave { reason: leave::LEFT });
(b, revoked)
@ -408,7 +408,7 @@ fn broadcast() -> Build {
b.ev(VIEWER_C, 4_200_000, Payload::Vote { poll, option: 0 });
let spam = b.ev(VIEWER_C, 5 * S, chat("first!!! follow the link in my name"));
b.ev(MODERATOR, 5_500_000, Payload::Decision { verdict: 2, target: spam, reason: 3 });
b.ev(VIEWER_A, 6 * S, Payload::Reaction(Reaction { rtype: 4, value: 1, target: None, remove: false }));
b.ev(VIEWER_A, 6 * S, Payload::Reaction(Reaction { rtype: 4, value: 1, target: None, remove: false, origin: None }));
b.ev(HOST, 7 * S, Payload::PollClose { poll, counts: vec![1, 2] });
b.life(8 * S, VIEWER_C, Detail::Leave { reason: leave::REMOVED });
b
@ -483,7 +483,7 @@ fn dvr() -> Build {
b.join(S, VIEWER_A, da);
b.join(2 * S, VIEWER_B, db);
b.ev(VIEWER_A, 3 * S, chat("going back to the opening"));
b.ev(VIEWER_B, 4 * S, Payload::Reaction(Reaction { rtype: 4, value: 1, target: None, remove: false }));
b.ev(VIEWER_B, 4 * S, Payload::Reaction(Reaction { rtype: 4, value: 1, target: None, remove: false, origin: None }));
b
}
@ -527,7 +527,7 @@ fn good_vectors() -> Vec<Vector> {
b.publish(11 * S, VIEWER_A, "", media::AUDIO, 0, 1);
b.publish(11 * S, VIEWER_A, "", media::VIDEO, 1, 2);
b.ev(VIEWER_A, 12 * S, Payload::Marker(Marker { taxonomy: 1, polarity: 1, text: "question".into(), attachment: None }));
b.ev(VIEWER_B, 12 * S, Payload::Reaction(Reaction { rtype: 4, value: 1, target: None, remove: false }));
b.ev(VIEWER_B, 12 * S, Payload::Reaction(Reaction { rtype: 4, value: 1, target: None, remove: false, origin: None }));
out.push(vector(
"promote_viewer",
"a viewer's audience grant exchanged at the 10 s tick for a guest grant chained to it: before, it may chat only; after, it publishes audio and video and sends a marker (§4.6)",
@ -711,6 +711,12 @@ fn grant_vectors() -> Vec<Vector> {
&one(&|b| Grant { policy: Policy::with_kinds(&[kind::CHAT, kind::JOIN]), ..b.grant(VIEWER_A, 2) }.sign(&key(AUTHORITY)).to_bytes()),
refuse(1, Reason::GrantInvalid),
));
out.push(vector(
"grant_ai_malformed",
"a grant whose ai attribute is not 64 lowercase hexadecimal digits (§4.4)",
&one(&|b| Grant { attrs: vec![("ai".into(), "A".repeat(64))], ..b.grant(VIEWER_A, 2) }.sign(&key(AUTHORITY)).to_bytes()),
refuse(1, Reason::GrantInvalid),
));
out.push(vector(
"grant_attrs_too_long",
"a grant whose attributes take more than 1024 bytes (§4.4)",
@ -1008,6 +1014,38 @@ fn record_vectors() -> Vec<Vector> {
let r = refuse_last(&b, HOST, Reason::PowerNotGranted);
out.push(vector("power_not_granted", "a host with the direct power but not record mutes a viewer, then sends record_start (§3.3)", &b, r));
// An AI participant (§4.4): a host whose grant names the model that acts for it.
let model = sha(&[b"wai:live-rooms/corpus/model"]);
let ai_room = |says: Option<events::Origin>| {
let mut b = Build::new(base());
let g = Grant { attrs: vec![("ai".into(), hex(&model))], ..b.grant(HOST, 0) };
let gv = b.grant(VIEWER_A, 2);
let (d, dv) = (b.add(g), b.add(gv));
b.join(0, HOST, d);
b.join(0, VIEWER_A, dv);
b.ev(VIEWER_A, S, Payload::Chat { text: "Is anyone there?".into(), origin: None });
b.ev(HOST, 2 * S, Payload::Chat { text: "Welcome. I am an automated host.".into(), origin: says });
b
};
let mut b = ai_room(Some(events::Origin { origin: 2, model: Some(model) }));
b.ev(HOST, 3 * S, Payload::Caption(events::Caption { duration_ms: 2_000, origin: 3, model: Some(model), lang: "en".into(), text: "Welcome.".into() }));
b.ev(VIEWER_A, 4 * S, Payload::Reaction(events::Reaction { rtype: 4, value: 1, target: None, remove: false, origin: None }));
out.push(vector(
"ai_participant",
"a host whose grant's ai attribute names a model: its chat states origin 2 and its caption origin 3, each with that model; a person's chat and reaction state none (§4.4, §9.2 step 6)",
&b,
Intent::Accept,
));
let b = ai_room(None);
let r = refuse_last(&b, HOST, Reason::AiUnmarked);
out.push(vector("ai_unmarked", "an AI participant's chat that states no origin (§9.2 step 6)", &b, r));
let b = ai_room(Some(events::Origin { origin: 1, model: None }));
let r = refuse_last(&b, HOST, Reason::AiUnmarked);
out.push(vector("ai_unmarked_human", "an AI participant's chat that states origin 1, human (§9.2 step 6)", &b, r));
let b = ai_room(Some(events::Origin { origin: 2, model: Some(sha(&[b"wai:live-rooms/corpus/other-model"])) }));
let r = refuse_last(&b, HOST, Reason::AiUnmarked);
out.push(vector("ai_unmarked_model", "an AI participant's chat that states origin 2 with a model other than its grant's (§9.2 step 6)", &b, r));
let mut b = Build::new(base());
let g = b.grant(VIEWER_A, 2);
let g = Grant { policy: Policy { max_events: 2, ..g.policy }, ..g };

1403
wai/wai-rs/src/synthesis.rs Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -39,7 +39,7 @@ fn the_corpus_covers_every_reason_and_room() {
for r in Reason::ALL {
assert!(reasons.contains(r.code()), "nothing in the corpus is refused with {r}");
}
for name in ["lesson", "broadcast", "takeover", "redundant_failover", "promote_viewer", "dvr"] {
for name in ["lesson", "broadcast", "takeover", "redundant_failover", "promote_viewer", "dvr", "ai_participant"] {
let v = vectors.iter().find(|v| v["name"] == name).unwrap_or_else(|| panic!("no vector {name}"));
assert_eq!(v["outcome"], "accept", "{name}");
}

View file

@ -0,0 +1,71 @@
//! `wai.media.synthesis` (extensions/synthesis.md): the committed corpus
//! `live-conformance/synthesis/` 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 §12 lists
//! exactly the reasons the reference reports. The generator itself asserts that the corpus
//! gives every one of them.
#![cfg(feature = "synthesis")]
use std::collections::BTreeMap;
use std::path::PathBuf;
use wai::synthesis::{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/synthesis");
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_synthesis_vectors gen`");
}
#[test]
fn every_case_gets_its_stated_outcome() {
let fails = corpus::verify(&committed());
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
/// §12 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/synthesis.md")).expect("the extension");
let at = text.find("\n## 12. Reasons").expect("a §12");
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, "§12's table and Reason::ALL differ");
}
/// §10.2's table pins exactly the icons `EU_ICONS` holds, each by its SHA-256, in order.
#[test]
fn the_extension_pins_the_icons() {
let text = std::fs::read_to_string(wai_dir().join("extensions/synthesis.md")).expect("the extension");
let at = text.find("\n### 10.2 ").expect("a §10.2");
let end = text[at + 1..].find("\n### ").map_or(text.len(), |e| at + 1 + e);
let pinned: Vec<(String, String)> = text[at..end]
.lines()
.filter(|l| l.starts_with("| `LABEL_AI"))
.filter_map(|l| {
let cells: Vec<&str> = l.split('`').collect();
Some((cells.get(1)?.to_string(), cells.get(3)?.to_string()))
})
.collect();
let want: Vec<(String, String)> = wai::synthesis::EU_ICONS.iter().map(|(n, d)| (n.to_string(), d.to_string())).collect();
assert_eq!(pinned, want, "§10.2's table and EU_ICONS differ");
}