wai.ai.usage: AI usage preferences bound to the bytes, and the agent's consumption claim (AG1) #386

Merged
dcharlot merged 9 commits from wai/ai-usage into main 2026-10-21 11:20:57 -04:00
28 changed files with 7370 additions and 29 deletions

View file

@ -118,6 +118,10 @@ on:
# 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'
# tests/ai_usage.rs requires this extension's §9 to list the codes the reference reports,
# ai_usage_verify.py reads that table, and the registry's wire identifiers test reads its
# sections.
- 'wai/extensions/ai-usage.md'
- 'wai/wai-web/**'
# The audio provenance check below reads the corpora above with this tool.
- 'wai/tools/wai_audio_provenance_check.py'
@ -789,8 +793,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), edge_verify.py (the deriving-edge corpus) and
# synthesis_verify.py (the synthesis corpus).
# (the live-provenance corpus), edge_verify.py (the deriving-edge corpus),
# synthesis_verify.py (the synthesis corpus) and ai_usage_verify.py (the AI usage 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:
@ -1146,6 +1150,32 @@ jobs:
(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.ai.usage (extensions/ai-usage.md): the reference module's unit tests (RFC 9651's
# Dictionary parser against the vocabulary's examples and the RFC's edges, statements
# read and refused in their steps' order, the precedence rule, HTTP fields compared by
# meaning), the consumption class's body and terms in claim_receipt, and
# tests/ai_usage.rs (vectors.json regenerates byte for byte from its fixed seeds and no
# other file is there, wai-rs gives every statement, object, room, response, catalog,
# claim and gate its stated outcome, §9 lists exactly the reference's codes, each of which
# the corpus gives, and jwp-receipts §3.1 lists the class rules); then the corpus's second
# implementation, ai_usage_verify.py, written from the extension's text and sharing no
# code with wai-rs, which parses Structured Fields, canonical forms, receipts and claims
# itself, reads rooms with live_rooms_verify.py, checks signatures with WAI's strict
# Ed25519 rule and BLAKE3 from claim-conformance, reads the extension's reason table,
# requires every mutated copy of the corpus to fail, and drops each of its own checks in
# turn and requires the corpus to catch the weakened verifier; standard library only, run
# isolated (-I). Last, claim-conformance's own verifier reads every consumption claim of
# the corpus through jwp-receipts §3.1 steps 1 to 4 and requires the stated step and reason.
- name: AI usage · wai-rs and ai_usage_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features ai_usage --lib ai_usage::)
(cd wai-rs && cargo test --no-default-features --features ai_usage --lib claim_receipt::consumption::)
(cd wai-rs && cargo test --no-default-features --features ai_usage --test ai_usage)
python3 -I live-conformance/ai_usage_verify.py live-conformance/usage
python3 -I claim-conformance/claim_verify.py consumption live-conformance/usage/vectors.json
# 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

@ -6,6 +6,57 @@ wire format may change and such changes are recorded here.
## v0.4.0 — current
### `wai.ai.usage`: AI usage preferences bound to the bytes, and the agent's consumption claim
- **The extension** ([ai-usage](extensions/ai-usage.md)) registers the record `wai.ai.usage`.
A **usage statement** carries three published vocabularies verbatim, each in its own member:
`aipref`, a statement of preference of `draft-ietf-aipref-vocab-08` in its Structured Field
form (`train-ai`, `ai-use`, `search`); `tdmrep`, the TDM Reservation Protocol's
`tdm-reservation` and `tdm-policy` (Final Community Group Report of 10 May 2024); and `cawg`,
the entries of a `cawg.training-mining` assertion (Training and Data Mining Assertion 1.1).
WAI adds no category and maps no term of one onto another. Each member names the revision it
is read at, and a revision this one does not read is refused.
- **RFC 9651's Dictionary**, parsed in full: every bare item type, parameters and inner lists,
with a repeated key keeping its last value. A value that does not parse, which the vocabulary
would read as unknown throughout, is refused rather than carried.
- **Bound to the bytes.** The manifest member `ai_usage` puts the statement inside the envelope,
so the object's receipt signs it with the content: an object whose statement is removed or
altered no longer hashes to its receipt (`usage_unbound`). A room states its preference in a
room usage record, `WAU1`, signed by the room authority and chained by the instant it takes
effect, so a change never reaches what was published before it; the descriptor's layout is
unchanged. A grant's attribute `ai_usage` carries the terms one principal is granted.
- **HTTP and MoQ.** An origin writes `draft-ietf-aipref-attach-05`'s `Content-Usage` field and
the TDMRep fields from the signed statement (http-delivery §5.6); a client compares them by
meaning, so a respelling is not a conflict and a contradiction is (`header_conflicts`). A MoQ
catalog states a track's statement as `wai_ai_usage` (moq-streaming-format §4), checked the
same way (`catalog_conflicts`).
- **One precedence rule.** A grant's terms decide the uses they name; otherwise every signed
statement that governs the object, the most restrictive governing, as the vocabulary combines
statements; unsigned statements count only where nothing is signed. CAWG's `constrained`
reads as not allowed unless a grant says otherwise.
- **The consumption claim**: the jwp-receipts claim class `consumption` (§2.4.13 there), signed
by the agent under its `did:key`. It names the objects by content hash, the term it acted
under in every vocabulary their statements carry (or `null`, a use outside it), the statements
it saw and the grant it held. A verifier checks it against the objects, their statements and
the room, and refuses a use they disallow (`usage_disallowed`); the refusal is of the use, and
the claim stays its signer's statement.
- **The gate.** A relay or gateway MAY require claims before it serves an agent: claimed before
the fetch over HTTP, where an object's content hash is known first (the proposed request field
`WAI-Usage-Claim`), or within a window of groups over MOQT; and MAY require every use to be
allowed.
- **Reference and corpus.** `wai-rs` feature `ai_usage` (`src/ai_usage.rs`, with RFC 9651's
parser in `src/ai_usage/sf.rs`), and the class's body and rules in
`src/claim_receipt/consumption.rs`. `live-conformance/usage/`, written from fixed seeds by
`wai_ai_usage_vectors`: statements, receipted envelopes (a `WAI2` among them) with a stripped
and an altered statement, a room with chained records and agent grants, responses, catalogs,
claims accepted and refused at each rule, and gates. Checked by `tests/ai_usage.rs` and by
`live-conformance/ai_usage_verify.py`, a second implementation that shares no code with
`wai-rs`, parses Structured Fields, canonical forms, receipts and claims itself, requires every
mutated copy of the corpus to fail and every weakened copy of itself to be caught;
`claim-conformance/claim_verify.py` reads the class's steps 1 to 4. The registry lists the
record, the magic `WAU1`, the catalog field `wai_ai_usage` and the proposed field
`WAI-Usage-Claim`.
### `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

View file

@ -44,7 +44,8 @@ Every registered string has one kind.
`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`; the origin claims of
media, `wai.media.synthesis`). It names no payload format.
media, `wai.media.synthesis`; AI usage preferences and consumption claims, `wai.ai.usage`).
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
@ -66,8 +67,8 @@ bindings ([http-delivery](extensions/http-delivery.md),
they serve ([jwp-receipts](extensions/jwp-receipts.md) §7),
[packaging](extensions/packaging.md), [live-events](extensions/live-events.md),
[live-rooms](extensions/live-rooms.md), [capture-binding](extensions/capture-binding.md),
[live-compose](extensions/live-compose.md), [live-recording](extensions/live-recording.md)
and [timed-text](extensions/timed-text.md) put on the wire outside a
[live-compose](extensions/live-compose.md), [live-recording](extensions/live-recording.md),
[timed-text](extensions/timed-text.md) and [ai-usage](extensions/ai-usage.md) put on the wire outside a
manifest — magic numbers, a media type, `codecs` prefixes, paths, playlist tags and
attributes, document kinds, catalog fields, MOQT Setup Options and HTTP fields — each with its kind, the section that
defines it, and whether it is defined, provisional (test material only until
@ -158,6 +159,7 @@ and a role are fixed once registered.
<!-- generated: wai registry export; do not edit by hand -->
| string | kind | status | media | role | licence | replicate | sets | registered in |
|---|---|---|---|---|---|---|---|---|
| `wai.ai.usage` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.asset.provenance` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.audio.aac` | payload | registered | audio | standalone | patent-pool | standard-unclassified | — | §5 Broadcast and streaming codecs |
| `wai.audio.ac4` | payload | registered | audio | standalone | proprietary | standard-unclassified | — | §5 Immersive audio, cinema, volumetric, haptics, HDR |
@ -309,11 +311,13 @@ and a role are fixed once registered.
| `WAB1` | magic | defined | live-ads §4.2 |
| `WAI-Room-Challenge` | http-header | proposed | live-rooms §10.5.3 |
| `WAI-Room-Proof` | http-header | proposed | live-rooms §10.5.3 |
| `WAI-Usage-Claim` | http-header | proposed | ai-usage §7.3 |
| `WAI1` | magic | defined | SPEC §2 |
| `WAI2` | magic | defined | SPEC §7.1 |
| `WAI_INNER_DIGEST` | catalog | defined | confidentiality §4.4 |
| `WAI_RECEIPT` | catalog | defined | moq-streaming-format §5 |
| `WAP1` | magic | defined | live-ads §3.1 |
| `WAU1` | magic | defined | ai-usage §4.2 |
| `WCD1` | magic | defined | live-compose §3.1 |
| `WCL1` | magic | defined | live-recording §5.1 |
| `WCN1` | magic | defined | capture-binding §8.1 |
@ -378,6 +382,7 @@ and a role are fixed once registered.
| `wai2` | codecs-prefix | defined | http-delivery §4.1 |
| `wai2` | fourcc | provisional | http-delivery Appendix B |
| `waiC` | fourcc | provisional | http-delivery Appendix B |
| `wai_ai_usage` | catalog | defined | moq-streaming-format §4 |
| `wai_c2pa_track` | catalog | defined | live-provenance §10.2 |
| `wai_capabilities` | catalog | defined | moq-streaming-format §4 |
| `wai_delivery_timeout_ms` | catalog | defined | packaging §8 |

View file

@ -595,8 +595,9 @@ 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`; an edge's derivation record, `wai.edge.derivation`; and the origin claims of
media, `wai.media.synthesis`), which names no payload format.
`wai.text.timed`; an edge's derivation record, `wai.edge.derivation`; the origin claims of
media, `wai.media.synthesis`; and AI usage preferences and consumption claims, `wai.ai.usage`),
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
@ -1141,6 +1142,25 @@ exposure, after each interruption, at an interval), with the EU icons pinned by
player's signed log shows whether it was shown:
[`extensions/synthesis.md`](extensions/synthesis.md).
And for **AI usage preferences** and the agents that consume media — `wai.ai.usage`: a usage
statement carries, verbatim and each in its own member, the AI usage preferences of
`draft-ietf-aipref-vocab-08` in its Structured Field form, the TDM Reservation Protocol's
`tdm-reservation` and `tdm-policy`, and the entries of a `cawg.training-mining` assertion
(Training and Data Mining Assertion 1.1), with no category of WAI's own. The manifest member
`ai_usage` carries it inside the envelope, where the object's receipt signs it with the content,
so an object whose statement is removed or altered fails its receipt (`usage_unbound`); a room
usage record, `WAU1`, signed by the room authority and chained by the instant it takes effect,
carries a room's; and a grant's attribute `ai_usage` carries the terms one principal is granted.
An origin writes `draft-ietf-aipref-attach-05`'s `Content-Usage` field and the TDMRep fields from
the signed statement, a MoQ catalog states it as `wai_ai_usage`, and a field or catalog that
contradicts the signed statement is refused. One rule decides a use: a grant's terms, then the
signed statements, the most restrictive governing, then unsigned statements where nothing is
signed. An agent signs a consumption claim, the jwp-receipts class `consumption`, under its
`did:key`: the objects by content hash, the term it acted under in each vocabulary, the
statements it saw and its grant; a verifier checks it against the objects, their statements and
the room, and refuses a use they disallow (`usage_disallowed`), and a relay or gateway MAY require
claims before it serves an agent: [`extensions/ai-usage.md`](extensions/ai-usage.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

@ -20,6 +20,9 @@ claim are read, verified and their figures read by the OER reference verifier
python3 claim_verify.py verdicts <file> print `name<TAB>verdict` for every vector of a
corpus-format file, ignoring any declared
outcome (the input to differential.py)
python3 claim_verify.py consumption <file>
steps 1-4 over the `consumption` claims of the
AI usage corpus (live-conformance/usage)
Exit status 0 only if every vector reports its declared outcome, and the fixture and the
bracket arithmetic this script computes equal the literals pinned in wai-rs.
@ -172,7 +175,8 @@ ENVELOPE = ["kind", "class", "subject_type", "subject", "seq", "issued_at", "bod
ADMITS = {"energy": {"object"}, "lawful-basis": {"object", "receipt"}, "log-inclusion": {"receipt"},
"soft-binding": {"object"}, "binding-recovery": {"object"},
"measurement": {"object", "receipt"}, "operating-point": {"object", "receipt"},
"stage": {"object"}, "delivery-gap": {"object"}, "reconstruct-session": {"object"}}
"stage": {"object"}, "delivery-gap": {"object"}, "reconstruct-session": {"object"},
"consumption": {"object"}}
FIGURE = ["figure_profile", "figure_index", "group", "joules_micro", "energy_provenance", "energy_uncertainty",
"energy_counter_filtering", "energy_calibration_ref"]
BODY = {
@ -186,6 +190,8 @@ BODY = {
"stage": ["vocabulary", "op_requested", "op_committed", "op_delivered", "presented", "units_by_stage", "tier",
"stage_hash", "objects", "conflicts", "deadline_misses", "bytes_envelopes"],
"delivery-gap": ["track_alias", "group_id", "object_id", "reason", "observed_at"],
# §2.4.13: ai-usage §6.1.
"consumption": ["objects", "usage", "seen", "grant"],
"reconstruct-session": ["session_nonce", "media", "host", "decoders", "units", "leaf_count", "output_root",
"primary_units", "fallback_units", "switches", "switches_total", "deadline_us", "missed",
"missed_units", "stage_sets", "framing", "counted_primary", "counted_fallback",
@ -415,6 +421,19 @@ def check_body(cls, b):
need(o["reason"] is None or isinstance(o["reason"], str), 2)
need(all(is_int(b[k]) for k in ("conflicts", "deadline_misses", "bytes_envelopes")), 2)
return
if cls == "consumption":
# ai-usage §6.1: the arrays' lengths and a term's are in the type column.
need(isinstance(b["objects"], list) and 1 <= len(b["objects"]) <= 1024
and all(is_hex(h, 32) for h in b["objects"]), 2)
need(isinstance(b["usage"], list) and 1 <= len(b["usage"]) <= 3, 2)
for e in b["usage"]:
need(isinstance(e, dict), 2)
members(e, ["vocabulary", "term"], 2)
need(e["vocabulary"] in CONSUMPTION_VOCABULARIES, 2)
need(e["term"] is None or (isinstance(e["term"], str) and 1 <= len(e["term"].encode()) <= 255), 2)
need(isinstance(b["seen"], list) and len(b["seen"]) <= 64 and all(is_hex(h, 32) for h in b["seen"]), 2)
need(b["grant"] is None or is_hex(b["grant"], 32), 2)
return
if cls == "delivery-gap":
need(all(is_int(b[k]) for k in ("track_alias", "group_id", "object_id")), 2)
need(b["reason"] in GAP_REASONS, 2)
@ -1398,6 +1417,8 @@ def class_rules(c):
stage_rules(b)
elif cls == "reconstruct-session":
session_rules(b)
elif cls == "consumption":
consumption_rules(c)
elif cls == "log-inclusion":
need(b["log_receipt"] != "", 4, "log_inclusion_no_evidence")
elif cls in ("soft-binding", "binding-recovery"):
@ -1409,6 +1430,45 @@ def class_rules(c):
need(b["observed"] != c["subject"], 4, "recovery_of_hard_bound_object")
# §2.4.13: ai-usage §3, §6.2. The vocabularies, and the terms each defines.
CONSUMPTION_VOCABULARIES = ("aipref", "cawg", "tdmrep")
AIPREF_LABELS = ("train-ai", "ai-use", "search") # draft-ietf-aipref-vocab-08 §6.1
CAWG_KEYS = ("cawg.data_mining", "cawg.ai_inference", "cawg.ai_generative_training", "cawg.ai_training")
B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
def did_key(pk):
"""§7.1's Ed25519 did:key: `did:key:z` and base58btc of 0xed 0x01 and the key."""
b = b"\xed\x01" + pk
n, out = int.from_bytes(b, "big"), ""
while n:
n, r = divmod(n, 58)
out = B58[r] + out
return "did:key:z" + "1" * (len(b) - len(b.lstrip(b"\x00"))) + out
def cawg_key(k):
"""ai-usage §3.4: components of letters, digits, `_` and `-`, separated by single dots."""
parts = k.split(".")
return 1 <= len(k.encode()) <= 255 and all(p and re.fullmatch(r"[A-Za-z0-9_-]+", p) for p in parts)
def consumption_rules(c):
"""ai-usage §6.2, in its order."""
b = c["body"]
asc = lambda xs: all(x < y for x, y in zip(xs, xs[1:])) # noqa: E731
need(asc(b["objects"]), 4, "consumption_objects_unordered")
need(c["subject"] == b["objects"][0], 4, "consumption_subject_mismatch")
need(asc([e["vocabulary"] for e in b["usage"]]), 4, "consumption_usage_unordered")
for e in b["usage"]:
t, v = e["term"], e["vocabulary"]
known = t is None or (t in AIPREF_LABELS if v == "aipref" else t == "tdm-reservation" if v == "tdmrep"
else t in CAWG_KEYS or (cawg_key(t) and not t.startswith("cawg.")))
need(known, 4, "consumption_term_unknown")
need(asc(b["seen"]), 4, "consumption_seen_unordered")
need(c["signer_id"] == did_key(bytes.fromhex(c["signer_pubkey"])), 4, "consumption_signer_not_principal")
def stage_layers(pt):
"""The layers a stage point names: exactly the member `wai-stage:layers`, 0 to 255."""
if len(pt) != 1 or "wai-stage:layers" not in pt or pt["wai-stage:layers"] > 255:
@ -1938,6 +1998,27 @@ def brackets_match_wai_rs():
return fails
def consumption_claims(path):
"""§3.1 steps 1-4 over the `consumption` claims of the AI usage corpus
(live-conformance/usage/vectors.json, ai-usage §10): a claim stated refused at one of those
steps is refused there with the stated reason, and every other claim passes them."""
with open(path, encoding="utf-8") as f:
doc = json.load(f)
fail = 0
for c in doc["claims"]:
o = c["outcome"]
want = (o["step"], o["reason"]) if o["outcome"] == "refuse" and o["step"] <= 4 else None
try:
verify_claim(c["wire"])
got = None
except Reject as r:
got = (r.step, r.reason)
fail += got != want
print(f"{'PASS' if got == want else 'FAIL'} {c['name']} expected={want} got={got}")
print(f"{len(doc['claims'])} consumption claims, {fail} failure(s)")
return 1 if fail else 0
def main(argv):
if argv[1:2] == ["brackets"]:
for name, br, _, _ in bracket_fixture():
@ -1946,6 +2027,8 @@ def main(argv):
return 0
if argv[1:2] == ["verdicts"] and len(argv) == 3:
return verdicts(argv[2])
if argv[1:2] == ["consumption"] and len(argv) == 3:
return consumption_claims(argv[2])
if argv[1:2] == ["fixture"]:
c, canon, link = fixture()
print("sig ", c["sig"])

545
wai/extensions/ai-usage.md Normal file
View file

@ -0,0 +1,545 @@
# WAI Extension: AI Usage Preferences and Consumption Claims
> Status: Draft. Registers the record `wai.ai.usage`: AI usage preferences for media, bound to
> the bytes they govern, and the signed statement of an agent that consumed media. A **usage
> statement** carries, verbatim, the preferences of three published vocabularies: the AI usage
> preference vocabulary of `draft-ietf-aipref-vocab-08`, in its exemplary Structured Field form;
> the TDM Reservation Protocol's `tdm-reservation` and `tdm-policy` (Final Community Group
> Report `CG-FINAL-tdmrep-20240510`); and the entries of the `cawg.training-mining` assertion
> (Training and Data Mining Assertion, version 1.1). WAI adds no category to any of them. The
> statement rides in an envelope's manifest, where the object's receipt signs it with the
> content, so it cannot be removed without the object failing its receipt; in a room usage
> record, `WAU1`, that the room authority signs; and in a room grant, as the terms one principal
> is granted. An HTTP origin writes the `Content-Usage` field of `draft-ietf-aipref-attach-05`
> and the TDMRep fields from the signed statement, and a MoQ catalog states it for a track
> before a sink subscribes. One precedence rule decides a use: a grant's terms, then every
> signed statement, the most restrictive governing, then unsigned statements where nothing is
> signed. An agent that consumed objects signs a **consumption claim**, the
> [jwp-receipts](jwp-receipts.md) claim class `consumption`, naming the objects by content hash,
> the term it acted under in each vocabulary, its key as a `did:key`, the statements it saw and
> the grant it held; a publisher verifies it against the objects, and a relay or gateway MAY
> require one before it serves an agent. No new transport. The reference implementation is the
> `ai_usage` module of `wai-rs` (feature `ai_usage`), with the class's body in `claim_receipt`;
> the corpus is [`live-conformance/usage/`](../live-conformance/usage/), and its second
> implementation is `live-conformance/ai_usage_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
>
> Texts read for this revision, each pinned to the version named:
> `draft-ietf-aipref-vocab-08` (14 September 2026) and `draft-ietf-aipref-attach-05`
> (19 August 2026), Internet-Drafts of the IETF AI Preferences Working Group, both Working
> Group documents intended for Proposed Standard and neither yet submitted to the IESG; the
> vocabulary marks its §3 and §4 as not yet having the group's consensus. RFC 9651
> (Structured Field Values for HTTP). The TDM Reservation Protocol, Final Community Group
> Report of 10 May 2024, the report's latest. The Creator Assertions Working Group's Training
> and Data Mining Assertion, version 1.1, ratified 16 May 2025, the current version. A section
> number of one of these texts is that version's.
## 1. Scope
In scope: the usage statement, its three vocabularies, how each is read and what each says
about a use (§3); where a statement is carried: an envelope, a room, a grant, an HTTP response
and a MoQ catalog (§4); the precedence rule and what a conflict means (§5); the consumption
claim (§6); the gate a relay or gateway applies before serving an agent (§7); and verification
(§8).
Out of scope: whether a preference is followed, which the vocabulary leaves to its recipient
(`draft-ietf-aipref-vocab-08` §3.2), and which this extension makes evidence of, not a duty;
the legal effect of a preference or of a claim; how a crawler or an agent is recognised as one
(§7.1); a profile of the agent protocols that fetch media, which this revision does not define;
and the robots.txt rule of `draft-ietf-aipref-attach-05` §3, which is the origin's and binds no
object (§4.4).
Relationship to the core spec: `wai.ai.usage` 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. The manifest member `ai_usage` (§4.1) is an OPTIONAL member under SPEC §8's rule
for MINOR revisions.
Relationship to [jwp-receipts](jwp-receipts.md): the envelope's statement is signed by the
object's receipt (§2.1 there); the consumption claim is a `claim` receipt of the class
`consumption` (§2.4.4 there), on that extension's envelope, canonical form, signature and
chain, with the body and class rules of §6 here.
Relationship to [live-rooms](live-rooms.md): the room descriptor's layout is unchanged. A
room's statement is a record of its own, chained like the review policy of
[live-moderation](live-moderation.md) §3.4, and a grant states its principal's terms in the
attribute `ai_usage` (live-rooms §4.4).
## 2. Terms
- **Vocabulary**: one of `aipref`, `tdmrep` and `cawg`, the three sources §3 carries.
- **Term**: what a use is named by in a vocabulary: an `aipref` usage category label
(`train-ai`, `ai-use` or `search`, `draft-ietf-aipref-vocab-08` §6.1); the `tdmrep` property
`tdm-reservation`, which governs text and data mining as a whole; or a key of a
`cawg.training-mining` assertion's entries.
- **Preference**: what a vocabulary says about one term: **allow**, **disallow** or
**unknown** (`draft-ietf-aipref-vocab-08` §3).
- **Statement**: a JSON object of §3 holding one or more vocabularies.
- **Signed statement**: one of an envelope whose receipts verify (§4.1), or of a room usage
record that verifies (§4.2).
- **Agent**: a party that consumes media by automated means, for any use a vocabulary names.
An **agent principal** in a room is one whose grant carries the attribute `ai_usage` (§4.3).
- **Content hash**: an object's [jwp-receipts](jwp-receipts.md) §2.1 content hash, 32 bytes,
written as 64 lowercase hexadecimal digits in JSON.
Integers are little-endian. A digest is SHA-256 (FIPS 180-4), 32 bytes. Ed25519 is RFC
8032's, verified strictly ([SPEC](../SPEC.md) §7 *Signatures*). The **canonical form** of a
JSON value is its JCS serialisation (RFC 8785) as jwp-receipts §2.4.3 restricts it: strings,
unsigned integers, `null`, arrays and objects.
## 3. The usage statement
### 3.1 Members
A statement is a JSON object with one or more of these members and no other:
| member | value | source |
|---|---|---|
| `aipref` | an object: `revision`, `value` | `draft-ietf-aipref-vocab-08` |
| `cawg` | an object: `revision`, `entries` | Training and Data Mining Assertion |
| `tdmrep` | an object: `revision`, `tdm-reservation`, `tdm-policy` | the TDM Reservation Protocol |
Each vocabulary keeps its own terms and its own preferences. A statement maps no term of one
onto a term of another, and a WAI tool **MUST NOT** write a term none of them defines.
### 3.2 `aipref`
- `revision`: the string `draft-ietf-aipref-vocab-08`.
- `value`: a statement of preference in the vocabulary's exemplary serialisation
(`draft-ietf-aipref-vocab-08` §6), as the text an HTTP `Content-Usage` field would carry
(`draft-ietf-aipref-attach-05` §2): for example `train-ai=n, search=y`. It is 1 to 1024
characters, each U+0000 to U+007F (§6.3 there: the syntax is ASCII), and it **MUST** parse
as a Dictionary (§3.3).
The preference of a term is read as `draft-ietf-aipref-vocab-08` §6.5 has it: the Dictionary's
member whose key is the term's label, its parameters disregarded, gives **allow** when its value
is the Token `y`, **disallow** when it is the Token `n`, and **unknown** otherwise; a label
absent is unknown, and a key that is not one of the three labels is ignored (§6.4 there). When
a key repeats, the Dictionary keeps its last value (RFC 9651 §4.2.2), so `train-ai=n, train-ai=y`
allows.
### 3.3 The Dictionary
`value` parses as RFC 9651 §4.2 parses a field of type Dictionary: its characters are bytes
(§4.2 step 1, which fails on a byte above 0x7F); leading spaces are discarded; the Dictionary is
parsed by §4.2.2, with its keys (§4.2.3.3), parameters (§4.2.3.2), inner lists (§4.2.1.2) and
every bare item: Integer and Decimal (§4.2.4), String (§4.2.5), Token (§4.2.6), Byte Sequence
(§4.2.7), Boolean (§4.2.8), Date (§4.2.9) and Display String (§4.2.10); leading spaces are
discarded again; and any character left fails. RFC 9651 leaves two checks of a Byte Sequence
to the parser (§4.2.7); here the sequence parses when its content, the characters between its
colons, is characters of the base64 alphabet (RFC 4648 §4) followed by at most two `=`, with
the alphabet characters numbering other than one more than a multiple of four. Neither padding
nor pad bits are checked.
`draft-ietf-aipref-vocab-08` §6.5.1 reads a value that does not parse as unknown for every
term. A statement does not carry one: a signer whose preference would be read as unknown
throughout has made a mistake that binding the statement would preserve, so it is refused.
### 3.4 `cawg`
- `revision`: the string `1.1`.
- `entries`: an object of 1 to 32 members, the assertion's `entries` map
(Training and Data Mining Assertion 1.1 §3.2), member for member. Each key is 1 to 255 bytes
of components separated by single `.`, a component being one or more of `a`–`z`, `A`–`Z`,
`0`–`9`, `_` and `-`; a key beginning `cawg.` is one of the four the assertion defines,
`cawg.data_mining`, `cawg.ai_inference`, `cawg.ai_generative_training` and `cawg.ai_training`,
since it reserves the prefix (§3.1 there). Each value is an object holding `use`, one of
`allowed`, `notAllowed` and `constrained`, and optionally `constraint_info`, 1 to 1024 bytes
of UTF-8 (the member the assertion's CDDL names; its prose spells it `constraints_info`).
A term is a key: `allowed` is **allow**; `notAllowed` is **disallow**; `constrained` is
**disallow**, since the assertion treats it as `notAllowed` in the absence of additional
information (§3.1 there), which a grant can give (§5.2); a key absent is **unknown**. The
assertion's `metadata` map is not carried.
### 3.5 `tdmrep`
- `revision`: the string `CG-FINAL-tdmrep-20240510`.
- `tdm-reservation`: the integer 1, TDM rights are reserved, or 0, they are not (the
protocol's §5.1). The protocol treats any other value as unset; a statement carries none.
- `tdm-policy`: `null`, or the URL of the rightsholder's TDM policy (the protocol's §5.2): 8 to
2048 bytes of `!` to `~` (0x21–0x7E), beginning `https://` or `http://`.
The one term is `tdm-reservation`: 1 is **disallow** and 0 is **allow**. `tdm-policy` is
carried for whoever seeks a licence and is not evaluated.
### 3.6 Reading a statement
A statement is read from JSON in which no object names a member twice. It is refused at the
first of these that holds:
1. It is not an object, it has no member, it has a member §3.1 does not list, or a
vocabulary's object has a member absent, a member its section does not list, or a member
of another type or outside its range: `usage_malformed`. A `revision` that is a string is
in range here; in `aipref`, a `value` that does not parse (§3.3) is not.
2. A vocabulary's `revision` is other than the string its section names:
`usage_revision_unknown`.
3. Its canonical form is longer than 4096 bytes: `usage_malformed`.
So a statement with a malformed `tdmrep` and an unknown `aipref` revision is refused
`usage_malformed`. The **digest** of a statement is SHA-256 over its canonical form.
## 4. Carriage
### 4.1 The envelope: the manifest member `ai_usage`
A `WAI1` or `WAI2` manifest ([SPEC](../SPEC.md) §3, §7.1) MAY carry, at its top level, the
member `ai_usage`, a statement. It applies to the object: every rendition and companion of a
`WAI2`. A reader of an earlier revision ignores it (SPEC §8); a malformed one leaves the
manifest readable, and this extension refuses the statement, not the envelope.
The member is inside the envelope's bytes, and the object's content hash covers them
([jwp-receipts](jwp-receipts.md) §2.1): the group receipt that includes the object signs the
statement with the content. An object whose statement is altered, added or removed no longer
hashes to the content hash its receipt names, and fails its receipt. A verifier given an
envelope and its receipts:
1. accepts the receipts as jwp-receipts §3 steps 1 to 5 do, the envelope hashing to the
object receipt's `content_hash` excepted; a refusal there is `receipt_invalid`;
2. requires the envelope to hash to that `content_hash`: otherwise `usage_unbound`;
3. reads the manifest's `ai_usage`, where present, by §3.6. A manifest that does not read
(SPEC §2) holds no statement that can be read: `usage_malformed`.
An object that passes all three is **held**, with its statement **signed by** the group
receipt's `signer_pubkey`, or with none. A publisher that states a preference for an object writes the
member before it receipts the object. An envelope that carries no receipt carries an unsigned
statement, which §5.3 reads as it reads an HTTP field.
### 4.2 A room: the room usage record `WAU1`
The room authority states the room's preference in a **room usage record**:
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WAU1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the room id (live-rooms §3.2) |
| `prev` | 32 | the digest of the record this one replaces; 32 zero bytes for the first |
| `effective` | 8 | u64: the room-clock instant from which it applies; 0 for the first |
| `statement` | 2 + *n* | a u16 length, then the statement's canonical form, 2 to 4096 bytes |
| `sig` | 64 | Ed25519 by the room descriptor's `authority` over the 18 bytes `wai:ai-usage-room` (ASCII) and 0x01, followed by every byte from `magic` through `statement` |
The record's **digest** is SHA-256 over the bytes from `magic` through `statement`. A reader
reads exactly the bytes it is given, in the order above, and refuses at the first of these that
holds: fewer than 4 bytes, `truncated`; a magic other than `WAU1`, `malformed`; no byte after
the magic, `truncated`; a version other than 1, `unknown_version`; the bytes end before a field,
`truncated`; bytes after `sig`, `malformed`. It then reads `statement` as JSON and by §3.6
(`usage_malformed`, `usage_revision_unknown`), and refuses bytes that are not the canonical form
of what they hold (`usage_malformed`). Then `room` is the room id (`wrong_room`), and the
signature verifies under the authority (`bad_key`, `bad_signature`).
A verifier is given a room's records in a list. The first has `prev` zero and `effective` 0;
each later one has as `prev` the digest of the one before it, and an `effective` above that
one's. A record that breaks this is refused `room_usage_unlinked`. The record **in effect** at
room-clock instant *t* is the last whose `effective` is at or below *t*. A record is replaced,
never amended: as `draft-ietf-aipref-attach-05` §3.3 has it for robots.txt, a change applies
from its instant on and never to what was published before.
An object is the room's when it is given with the room (§8). It is governed at its manifest's
`live.t` ([live-ingest](live-ingest.md) §6), an integer; an object of the room whose manifest
has none is governed by every record in the list.
### 4.3 A grant's terms: the attribute `ai_usage`
A room grant MAY carry the attribute `ai_usage` (live-rooms §4.4), whose value is the canonical
form of a statement: the **terms** the authority grants that principal. They are an
arrangement in the sense of `draft-ietf-aipref-vocab-08` §5.2, which overrides a statement of
preference for the uses it names. A grant whose attribute does not read by §3.6, or is not the
canonical form of what it holds, is refused `grant_usage_malformed`, and its principal holds no
terms. The attribute is signed with the grant (live-rooms §4.2), and the 1024 bytes the grant's
attributes take bound the terms.
### 4.4 HTTP: fields mapped from the signed statement
An origin that serves an object ([http-delivery](http-delivery.md) §5) whose envelope carries
a statement **SHOULD** send, for a `GET` or `HEAD` of it:
- for `aipref`: `Content-Usage:` and the statement's `value`, byte for byte
(`draft-ietf-aipref-attach-05` §2);
- for `tdmrep`: `tdm-reservation:` and `0` or `1`, and, where `tdm-policy` is not `null`,
`tdm-policy:` and the URL (the TDM Reservation Protocol §6.2).
`cawg` has no HTTP field. These fields are unsigned. A client reads them as the response's
statement and compares it with the object's signed statement:
- `Content-Usage` field lines are combined as RFC 9110 §5.3 combines them, with a comma and a
space, and parsed by §3.3. It **conflicts** when it does not parse, or when it gives any of
the three labels a preference other than the signed `value` gives it. Parameters, unknown keys
and spelling (`train-ai=n,search=y` against `train-ai=n, search=y`) do not conflict.
- `tdm-reservation` conflicts when more than one field line carries it, or when its value,
without leading and trailing spaces and tabs, is other than the signed one written in decimal.
`tdm-policy` conflicts when more than one field line carries it, or when its value, trimmed the
same way, is other than the signed URL, or the signed one is `null`. An absent field conflicts
with nothing.
- A field for a vocabulary the signed statement does not carry, or any field for an object with
no signed statement, conflicts with nothing: it is an unsigned statement (§5.3).
A response whose fields conflict is refused `header_conflicts`. Field names compare without
regard to case. The robots.txt `Content-Usage` rule (`draft-ietf-aipref-attach-05` §3) states a
preference for a path, not for an object, and is unsigned; a WAI origin MAY write one, and this
extension reads none.
### 4.5 MoQ: the catalog field `wai_ai_usage`
A WAI track's catalog entry MAY carry `wai_ai_usage`, a statement
([moq-streaming-format](moq-streaming-format.md) §4), so that an agent knows the preference
before it subscribes. A catalog is unsigned. Its statement **MUST** be the statement of every
object on the track that carries one; a catalog whose statement does not read by §3.6, or whose
digest differs from the digest of a held object's statement on the track, is refused
`catalog_conflicts`. A room track's catalog **SHOULD** state the room record in effect when the
catalog is published.
## 5. Precedence
### 5.1 What governs an object
An object's **governing statements** are its signed statements: the statement of its envelope,
when it is held (§4.1); and, when it is a room's, the room record in effect at its `live.t`, or
every record of the room where it has none (§4.2). A principal's **terms** are those of the grant
it acts under (§4.3).
### 5.2 The rule
The preference of a use, named by a term *T* of a vocabulary *V*, for an object and a principal:
1. **Terms.** When the principal's terms carry *V* and give *T* allow or disallow, that is the
preference: an arrangement overrides a statement of preference
(`draft-ietf-aipref-vocab-08` §5.2).
2. **Signed statements.** Otherwise, of the governing statements that carry *V*: disallow when
any gives *T* disallow; else allow when any gives it allow; else unknown. This is the
vocabulary's own rule for combining statements (§5.1 there), the most restrictive applying.
3. **Unsigned statements.** When no governing statement carries *V*, an agent MAY read an
unsigned statement for *V* (an HTTP field, a catalog, an envelope without receipts) by the
same rule. It names no declaring party (§3.1 there), and no verifier can check what it said.
A use named in several vocabularies is disallowed when any of them disallows it; allowed when
none disallows it and one allows it; unknown otherwise. A use that falls under no term of a
vocabulary is **outside** that vocabulary, which then says nothing about it. What an agent does
with an unknown preference is not this extension's: the vocabulary takes no position on a
default (§5 there), and a gate MAY (§7.1).
### 5.3 Conflicts
A signed statement governs over every unsigned one. An unsigned statement that differs from a
signed one is not the signer's: a client that finds one ignores it in step 3, reports the
response or catalog refused (`header_conflicts`, `catalog_conflicts`), and decides by the signed
statement. A stricter unsigned statement is the preference of a party nothing identifies; an
agent MAY honour it as well, and a consumption claim is checked against the signed statements
alone. Two signed statements that differ are not a conflict: rule 2 applies, and the most
restrictive governs.
## 6. The consumption claim
### 6.1 The body
A consumption claim is a [jwp-receipts](jwp-receipts.md) §2.4.3 `claim` of `class`
`consumption`, `subject_type` `object`, signed by the agent's key, whose `subject` is the first
of `objects`. Its `signer_id` is the `did:key` of `signer_pubkey` (jwp-receipts §7.1), the key
that traces the agent. Its body has exactly these members, read in this order at jwp-receipts
§3.1 step 2:
| member | type | meaning |
|---|---|---|
| `objects` | array of 1 to 1024 32 B hex | the content hashes of the objects consumed |
| `usage` | array of 1 to 3 entries | the use, in each vocabulary the agent answers |
| `seen` | array of 0 to 64 32 B hex | the digests of the statements that governed the objects (§5.1) |
| `grant` | 32 B hex or `null` | the digest of the room grant the agent consumed under (live-rooms §4.2) |
A `usage` entry has exactly `vocabulary`, one of `aipref`, `cawg` and `tdmrep`, and `term`, a
string of 1 to 255 bytes or `null`, read in that order. A term names the use as that vocabulary
names it; `null` states that the use falls under no term of the vocabulary. An array of another
length, a `vocabulary` outside the three and a `term` of another length are `field_type`.
### 6.2 Class rules
At jwp-receipts §3.1 step 4, in this order:
1. `objects` is in strictly ascending order: `consumption_objects_unordered`.
2. `subject` is the first of `objects`: `consumption_subject_mismatch`.
3. `usage` is in strictly ascending order of `vocabulary`: `consumption_usage_unordered`.
4. Each `term` that is not `null` is a term of its vocabulary: for `aipref` one of `train-ai`,
`ai-use` and `search`; for `tdmrep`, `tdm-reservation`; for `cawg`, one of the assertion's
four keys or a key of §3.4's form that does not begin `cawg.`:
`consumption_term_unknown`.
5. `seen` is in strictly ascending order: `consumption_seen_unordered`.
6. `signer_id` is the `did:key` of `signer_pubkey`: `consumption_signer_not_principal`.
### 6.3 Context
At jwp-receipts §3.1 step 6 a verifier is given the objects it holds, with their receipts
(§4.1), and, where the claim names a grant or its objects are a room's, the room: its
descriptor, its grants and its usage records. It refuses at the first of these that fails:
1. Each of `objects` is the content hash of an object it holds: `consumption_object_unknown`.
2. When `grant` is not `null`: it is the digest of one of the room's grants that live-rooms
§4.3 accepts, whose `principal` is `signer_pubkey`, and which is good at the `live.t` of
every object of the room the claim names that has one; and every object the claim names is
the room's. Otherwise `grant_mismatch`. A grant refused `grant_usage_malformed` holds no
terms and is still the principal's.
3. `seen` is exactly the digests of the objects' governing statements: `preference_mismatch`.
4. `usage` answers every vocabulary any governing statement carries: `usage_unanswered`.
5. For each object in the order of `objects`, and each entry in the order of `usage`, the
preference of §5.2, with the grant's terms where `grant` names one, is not disallow:
`usage_disallowed`.
A room whose usage records are refused (§4.2) states no preference: its objects are governed
by their own statements alone. A grant the room refuses (live-rooms §4.3) is not one of its
grants, and a claim that names it is refused at step 2.
### 6.4 What an accepted claim states
An accepted claim states, under the agent's key, that the agent consumed those objects for that
use, having seen those statements, under that grant. A verifier reports, for each object, the
preference §5.2 gives the claimed use: **allowed**, **unknown**, or **outside** when every term
is `null`. A claim refused `usage_disallowed` is still its signer's statement: its signature and
chain hold, and what it records is a use the governing statements disallow. A refusal at step 6
is of the use, never of the signature. The publisher, the agent and any party that holds the
objects and the claim reach the same verdict.
## 7. The gate
### 7.1 Policy
A relay or a gateway MAY require consumption claims before it serves an agent. Whether it does,
and how it recognises an agent outside a room, is its own policy; in a room, an agent principal
is one whose grant carries terms (§4.3). The policy has two parameters:
- `window`, 0 to 16: how many of the most recent groups the gate served the agent may lack a
claim. With 0, every object the agent requests is claimed before it is served, as over
HTTP, where the agent names each object by its content hash before it fetches it
([http-delivery](http-delivery.md) §5.2). With *n* ≥ 1, the agent claims what it received
after it received it, as over MOQT, where a live object's content hash is known only once it
is published.
- `require_allowed`: whether a use whose preference is unknown or outside is refused.
### 7.2 Decision
The gate holds the claims the agent gave it, each verified (§8) and signed by the agent's key
(in a room, its grant's principal; elsewhere, the key the gateway authenticates it by): an
accepted claim, or one refused `usage_disallowed`, is held; any other is not. It keeps the groups it served the agent,
in order, a group over HTTP being one object. For a request, a group of objects, the objects it
checks are the requested ones when `window` is 0, and otherwise those of every served group but
the last `window`. For each of those objects, in that order, it refuses at the first of these:
1. No held claim names it: `claim_required`.
2. A held claim that names it was refused `usage_disallowed`: `usage_disallowed`.
3. With `require_allowed`, a held claim that names it gives it a preference other than
allowed: `usage_not_granted`.
Otherwise it serves the request. A gate that refuses tells the agent the reason; over MOQT a
relay ends the subscription, and over HTTP a gateway answers `403`.
### 7.3 Over HTTP
An agent gives a gateway a claim with its request: the field `WAI-Usage-Claim:` and the claim's
canonical form in base64url without padding (RFC 4648 §5). The gateway verifies it, holds it as
§7.2 says, and decides. `WAI-Usage-Claim` is proposed for the HTTP Field Name Registry (RFC 9110
§16.3), and is not registered. This revision defines no MOQT carriage for a claim; a relay that
gates over MOQT holds the claims its agents gave it over HTTP.
## 8. Verification
A verifier is given what each check reads, and nothing more:
| check | inputs | outcome |
|---|---|---|
| a statement (§3.6) | its JSON | accepted with its digest and every term's preference, or refused |
| an object (§4.1) | its envelope, object receipt and group receipt | held, with its statement or none, or refused |
| a room (§4.2, §4.3) | its descriptor, grants and usage records | each record and grant accepted or refused |
| a response (§4.4) | an object and its response fields | consistent, or `header_conflicts` |
| a catalog (§4.5) | a catalog entry and the objects of its track | consistent, or refused |
| a claim (§6) | the claim, the claim before it of its signer, its objects, and its room | accepted with each object's preference, or refused at a step, with its reason |
| a gate (§7) | the policy, the served groups, the claims given, and a request | served, or refused with its reason |
A room descriptor or a grant that does not verify is refused as live-rooms §3.4 and §4.3
refuse it; a room with no verified descriptor governs nothing.
## 9. Reasons
The codes this extension adds, each with where it is raised:
| reason | raised at |
|---|---|
| `usage_malformed` | §3.6, §4.2 |
| `usage_revision_unknown` | §3.6 |
| `receipt_invalid` | §4.1 step 1 |
| `usage_unbound` | §4.1 step 2 |
| `room_usage_unlinked` | §4.2 |
| `grant_usage_malformed` | §4.3 |
| `header_conflicts` | §4.4 |
| `catalog_conflicts` | §4.5 |
| `consumption_objects_unordered` | §6.2 rule 1 |
| `consumption_subject_mismatch` | §6.2 rule 2 |
| `consumption_usage_unordered` | §6.2 rule 3 |
| `consumption_term_unknown` | §6.2 rule 4 |
| `consumption_seen_unordered` | §6.2 rule 5 |
| `consumption_signer_not_principal` | §6.2 rule 6 |
| `consumption_object_unknown` | §6.3 step 1 |
| `grant_mismatch` | §6.3 step 2 |
| `preference_mismatch` | §6.3 step 3 |
| `usage_unanswered` | §6.3 step 4 |
| `usage_disallowed` | §6.3 step 5, §7.2 |
| `claim_required` | §7.2 |
| `usage_not_granted` | §7.2 |
A room usage record also raises `truncated`, `malformed`, `unknown_version`, `wrong_room`,
`bad_key` and `bad_signature`, as live-rooms §3.4 names them, and a claim every code of
jwp-receipts §3.1 steps 1 to 5.
## 10. Conformance
[`live-conformance/usage/`](../live-conformance/usage/) holds the corpus, written from fixed
seeds by `wai_ai_usage_vectors gen` as `vectors.json`. It has:
- **Statements**: each vocabulary alone and the three together, with every term's preference;
`aipref` values that exercise the Dictionary: a repeated key, parameters, a value that is not
a Token, an inner list, a key that is not a label, and every kind of bare item; and one
statement refused for each defect of §3.6, among them a value that does not parse and an
upper-case key.
- **Objects**: a publication whose envelopes carry statements under one group receipt, among
them a `WAI2`; the same envelope with its statement removed (`usage_unbound`), and altered
(`usage_unbound`); one under a receipt whose signature fails (`receipt_invalid`); and one whose
statement does not read.
- **Rooms**: a room with two chained usage records and the grants of two agent principals, one
of whose terms lift a disallow; a record of another room, one whose signature fails, one out of
its chain, and one whose statement does not read; a grant whose terms do not read.
- **Responses** and **catalogs**: fields that agree with the signed statement in another
spelling, and fields and a catalog that contradict it.
- **Claims**: claims accepted with each object's preference — allowed, unknown, outside, and
allowed under a grant over a room's disallow — and one refused for each reason of §6.2 and
§6.3, among them a use the statement forbids, objects the verifier does not hold, and a
signature that fails.
- **Gates**: requests served and refused under each policy, for each reason of §7.2.
A conforming verifier gives every case the outcome, the reason, the step and everything else
the corpus states. `wai_ai_usage_vectors verify` and the test `tests/ai_usage.rs` check that the
file regenerates byte for byte, that `wai-rs` gives every case its outcome, that every reason of
§9 appears, and that §9 lists exactly the reference's codes.
`live-conformance/ai_usage_verify.py` is the second implementation, written from this text: it
shares no code with `wai-rs`; it reads rooms with the live-rooms corpus's second implementation
(`live_rooms_verify.py`), checks every signature with WAI's strict rule
(`claim-conformance/_ed25519.py`) and computes BLAKE3 with `claim-conformance/_blake3.py`, and
parses Structured Fields, canonical forms, receipts and claims itself; and it mutates copies of
the corpus and requires every copy to fail.
## 11. Security and privacy considerations
- A preference is not a security mechanism (`draft-ietf-aipref-vocab-08` §8). What this
extension adds is evidence: the publisher's statement bound to the bytes, and the agent's
statement of what it did, both signed. Neither makes a use happen or stops one.
- Removing an envelope's statement changes its content hash. An object served without its
receipts carries an unsigned statement at most, and an agent that requires receipts sees
the difference. A party that strips the statement and receipts the object under its own key
becomes that object's publisher, under its own key.
- An HTTP field and a catalog are written by whoever serves them. A signed statement governs
over both (§5.3), so an intermediary that rewrites a field changes nothing a claim is
checked against, and a client that compares them sees the rewrite.
- A consumption claim is signed by the agent's key and names the agent by that key alone
(jwp-receipts §7.1). A key-log confirmation, where the agent's origin publishes one, says
who holds the key; without one the claim is the key's statement.
- A claim lists the objects an agent consumed. A publisher that holds it learns what the agent
read and for what use, which is its purpose; an agent that claims under a gate discloses
that to the gate's operator too.
- A claim records the use its agent states. A false statement is a signed false statement:
a verifier checks it against the objects and the statements, and cannot check what the agent
did afterwards.
- `tdm-policy` is a URL a statement carries and a verifier never fetches.

View file

@ -248,6 +248,22 @@ refinement sits, depend on the media class.
| binary (parameter sets, circuits, ternary) | a parameter set served as an immutable resource, verified by its pin (§8.5) | — | — |
| records and derivations | carried as objects, never named in `codecs` (`codecs_not_media`) | — | — |
### 5.6 AI usage preferences
An object whose manifest carries a usage statement ([ai-usage](ai-usage.md) §4.1) carries it
signed, with the content. An origin **SHOULD** also send it in the fields
`draft-ietf-aipref-attach-05` §2 and the TDM Reservation Protocol §6.2 define, written from the
signed statement (ai-usage §4.4): `Content-Usage` with the `aipref` value, byte for byte, and
`tdm-reservation` and, where the statement has one, `tdm-policy`. It names them in
`Access-Control-Expose-Headers` beside §5.3's, so that a client in a browser can read them.
These fields are unsigned: a client compares them with the signed statement, and a response
whose fields contradict it is refused (`header_conflicts`), the signed statement governing.
A gateway that requires consumption claims of agents (ai-usage §7) reads the request field
`WAI-Usage-Claim`, names it in `Access-Control-Allow-Headers` beside §5.3's, and answers a
request it refuses with `403`. It never serves other bytes under an object's name: refusing is
not selecting (§5.3).
## 6. The head proof
### 6.1 What it proves

View file

@ -38,7 +38,8 @@ In scope:
class. `group` and `object` receipts are the **delivery** class; a
`claim` receipt (§2.4.3) carries one of the others — `energy`,
`lawful-basis`, `log-inclusion`, `measurement`, `operating-point`,
`stage`, `delivery-gap`, `reconstruct-session` (§2.4.5–§2.4.12) — on one
`stage`, `delivery-gap`, `reconstruct-session`, `consumption`
(§2.4.5–§2.4.13) — on one
envelope, one canonical form and one signature rule, so a statement other
than delivery does not need a field set of its own.
- **Objects not delivered** (§3, §4.4): an object a subscriber did not
@ -396,6 +397,7 @@ key.
| `stage` | `claim` | `object` (the stage set's base) | §2.4.10 | the party that observed the stage set |
| `delivery-gap` | `claim` | `object` (the object not forwarded) | §2.4.11 | the relay that did not forward it |
| `reconstruct-session` | `claim` | `object` (the session's entry object) | §2.4.12 | the sink that reconstructed: the key its decoder host holds |
| `consumption` | `claim` | `object` (the first object it names) | §2.4.13 | the agent that consumed the objects: its key, named by its `did:key` |
Rules:
@ -1273,6 +1275,15 @@ one of this session. The `claim` an
`end` observation carries ([delivery-telemetry](delivery-telemetry.md) §2, its §2.1)
is this claim's `link_hash`.
#### 2.4.13. `class: "consumption"`
An agent's statement that it consumed objects, for a use it names in the AI usage
vocabularies a usage statement carries, having seen the statements that governed them and
holding the room grant it names. [ai-usage](ai-usage.md) defines it: its body's members are
§6.1 there (`objects`, `usage`, `seen` and `grant`), read at §3.1 step 2; its class rules are
§6.2 there, at step 4; and its context is §6.3 there, at step 6. Its `subject` is the first of
`objects`, and its `signer_id` is the `did:key` of its `signer_pubkey`.
### 2.5. Worked example
Manifest JSON (group receipt, formatted for readability — wire form
@ -1913,7 +1924,8 @@ order listed.
is not 64 of them; a value outside an enumeration this extension
defines here (`record`, `binding_type`, a bracket's `opening`, a build's
`profile`, a stage's `tier`, a stage object's `status` and `evidence`, a
gap's `reason`, a session's `framing`, its host's `profile`, and the paths
consumption claim's usage `vocabulary`, a gap's `reason`, a session's
`framing`, its host's `profile`, and the paths
of its decoder entries and switches); a date-time that is not one of §2.4.3 (a gap's
`observed_at`); a URI that is not absolute;
a term that is not one of §2.4.3 (empty, longer than 2048 bytes, or
@ -1945,7 +1957,9 @@ order listed.
agree; and, in a `reconstruct-session` claim, how its counts agree, a
decoder entry's grammar and tier, the switches' reasons and order, the
missed presentations, the order of its stage sets, its counted bytes and
its classical rendition.
its classical rendition; and, in a `consumption` claim, the order of its
objects, usage entries and seen digests, its subject, its terms and its
`signer_id`.
3. **Signature.** Ed25519 over §2.4.3's signing input, verified by the
rule every WAI signature is held to (SPEC §7 *Signatures*): a canonical
key not of small order, `S` < L, `R` not of small order, and the
@ -1986,7 +2000,9 @@ order listed.
verified by inclusion at least (§2.4.11); a `reconstruct-session` claim
against its outputs, the objects it received, the `WAI2` envelopes it received
and the `stage` claims it names, in that order, each that the verifier holds, the
`stage` claims verified under steps 1–4 (§2.4.12). A
`stage` claims verified under steps 1–4 (§2.4.12); a `consumption` claim against
the objects it names, with their receipts, the usage statements that govern them
and its room, as [ai-usage](ai-usage.md) §6.3 orders them (§2.4.13). A
declaration is verified here under steps 1–4, and one that fails
them — or is not JSON — is `context_invalid`: its members,
`signer_pubkey` among them, are its signer's only once its signature
@ -2066,6 +2082,12 @@ and never folds it in: confirmation changes the outcome of no step here.
| 4 | `session_stage_sets_invalid` | `stage_sets` out of order, or an entry twice |
| 4 | `session_counted_invalid` | `counted_discarded` above `counted_primary` and `counted_fallback` together |
| 4 | `session_classical_invalid` | `classical_source` present without `classical_declared` or absent with it, or not `wai2-table` |
| 4 | `consumption_objects_unordered` | a `consumption` claim's `objects` are not in strictly ascending order ([ai-usage](ai-usage.md) §6.2) |
| 4 | `consumption_subject_mismatch` | its `subject` is not the first of its `objects` |
| 4 | `consumption_usage_unordered` | its `usage` entries are not in strictly ascending order of `vocabulary` |
| 4 | `consumption_term_unknown` | a `term` is not a term of its vocabulary |
| 4 | `consumption_seen_unordered` | its `seen` digests are not in strictly ascending order |
| 4 | `consumption_signer_not_principal` | its `signer_id` is not the `did:key` of its `signer_pubkey` |
| 6 | `energy_disagrees_with_delivery` | the figure differs from the `object` receipt's |
| 6 | `energy_class_disagrees_with_delivery` | the object's leaf is labelled, and the `energy` claim's class or evidence differs from the label |
| 6 | `figure_disagrees` | the receipt a `measurement` or `operating-point` claim names carries another figure at `figure_index` |
@ -2099,6 +2121,11 @@ and never folds it in: confirmation changes the outcome of no step here.
| 6 | `session_layers_mismatch` | a presentation through a decoder entry whose envelope is held is at another layer count than that envelope gives |
| 6 | `session_unit_outside_object` | a presentation through a decoder entry whose envelope is held is of a unit that envelope does not cover, from its `first_unit` |
| 6 | `session_classical_mismatch` | the envelopes' classical renditions the session did not decode give another `classical_declared` |
| 6 | `consumption_object_unknown` | a `consumption` claim names an object the verifier does not hold ([ai-usage](ai-usage.md) §6.3) |
| 6 | `grant_mismatch` | the grant it names is not the signer's, not good at an object's time, or names objects that are not the room's |
| 6 | `preference_mismatch` | its `seen` is not the digests of the statements that govern its objects |
| 6 | `usage_unanswered` | its `usage` does not answer a vocabulary a governing statement carries |
| 6 | `usage_disallowed` | a use it names is disallowed for one of its objects |
| 6 | `session_stage_disagrees` | a `stage` claim the session names is about a base, or lists an arrived layer object, the session did not receive; counts a unit the session never presented, or more units at a stage than the session presented at it or above; or no claim whose set holds a unit lists an applied layer that reaches a stage the session presented it at |
### 3.2. Recovery through a soft binding, and the ancestor rule
@ -3248,11 +3275,11 @@ A confirmation needs every one of them:
| item | authorities |
|---|---|
| a `group` receipt, and the `object` receipts under it | `delivery`; and `energy/<class>` for each acquisition class an `object` receipt under it labels, where its leaf binds a label (§2.2), and for the class a labelled group payload signs for the group's total (§2.3) |
| a `claim` | its class's authority: `energy/` followed by its `energy_provenance` for `energy`, for example `energy/OnChipCounter`; `lawful-basis/declaration` or `lawful-basis/decision` by its `record`; its class name for every other class — `log-inclusion`, `soft-binding`, `binding-recovery`, `measurement`, `operating-point`, `stage`, `delivery-gap` and `reconstruct-session`. And `energy/` followed by its `energy_provenance` whenever its body carries that member, whatever its class |
| a `claim` | its class's authority: `energy/` followed by its `energy_provenance` for `energy`, for example `energy/OnChipCounter`; `lawful-basis/declaration` or `lawful-basis/decision` by its `record`; its class name for every other class — `log-inclusion`, `soft-binding`, `binding-recovery`, `measurement`, `operating-point`, `stage`, `delivery-gap`, `reconstruct-session` and `consumption`. And `energy/` followed by its `energy_provenance` whenever its body carries that member, whatever its class |
| the energy figure of a `reconstruct-session` claim whose `energy` is not `null` | `reconstruct-session`, and `energy/` followed by `energy.energy_provenance` |
| a receipt of another WAI family that signs its own `signer_pubkey` and `signer_id` | `receipt`; and `energy/<class>` for each class its figures carry a label for |
| a module grant (§7.9) | `module/decoder` or `module/reconstruct`, by its `form` |
| a room descriptor or room grant ([live-rooms](live-rooms.md) §3, §4) | `live/room` |
| a room descriptor or room grant ([live-rooms](live-rooms.md) §3, §4), or a room usage record ([ai-usage](ai-usage.md) §4.2) | `live/room` |
| an upload receipt of a take ([capture-binding](capture-binding.md) §6) | `capture/store` |
| a per-hop session record ([live-metering](live-metering.md) §3) | `live/relay`, `live/compositor` or `live/ingest`, by its role; and `energy/<class>` for its figure's class where it is metered |
| a room-session receipt (live-metering §4) signed by a key other than the room authority | `live/meter`; and, from any signer, `energy/<class>` for each class it totals |

View file

@ -190,6 +190,17 @@ policy's fields and their meaning; the review workflow and its verification are
`mode` is at most 2, and `commit_interval_ms` and `jitter_ms` keep the rule above.
### 3.8 AI usage preferences
The descriptor's layout holds no AI usage preference. A room states one in a room usage
record, `WAU1` ([ai-usage](ai-usage.md) §4.2): a usage statement the authority signs, naming
the room id, chained so that a preference can change at an instant of the room clock and never
for what was published before it. An object of the room is governed by the record in effect at
its time, and by its own statement where its manifest carries one (ai-usage §5.1). A grant
states the terms its principal is granted in the attribute `ai_usage` (§4.4), and a principal
whose grant carries terms is an agent principal, which a relay MAY require consumption claims
of before it serves it (ai-usage §7).
## 4. The room grant
### 4.1 Layout
@ -273,8 +284,11 @@ composition descriptor names, and gives each its syntax and its meaning (live-co
§4.3); this revision checks only the form of the entries. Capture binding
([capture-binding](capture-binding.md) §3) reads two more: `subject`, the principal a
device captures for, as 64 lowercase hexadecimal digits of its key; and `minor` (`true`
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.
or `false`), whether the authority marks that principal a minor. AI usage
([ai-usage](ai-usage.md) §4.3) reads `ai_usage`: the canonical form of a usage statement, the
terms the authority grants the principal, which override the room's preference for the uses
they name. Attributes are signed with the grant, so a layout computed from them, and the terms,
are 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

View file

@ -80,6 +80,7 @@ fields, the capabilities a sink needs to dispatch what the track carries:
| `wai_receipts` | boolean | Whether objects carry receipts per §5. |
| `wai_receipt_alias` | integer | The `track_alias` the publisher signs in this track's receipts ([jwp-receipts](jwp-receipts.md) §2.4.1), from 0 to 2^53 − 1. |
| `wai_receipts_track` | string | The name, in this track's namespace, of the track that carries this track's receipts (§4.1). |
| `wai_ai_usage` | object | The usage statement of the track's objects ([ai-usage](ai-usage.md) §3), or of the room record in effect for a room's track. Absent means the catalog states none. |
- `wai_receipt_alias` names the track's receipt chain, which a verifier keeps per
publisher key and `track_alias` (jwp-receipts §3 step 3). MOQT's own Track Alias is
@ -102,6 +103,10 @@ fields, the capabilities a sink needs to dispatch what the track carries:
- `wai_determinism` is the **weakest** tier on the track, not the strongest.
Declaring the strongest would let a track advertise a guarantee most of its
objects do not meet.
- `wai_ai_usage` lets an agent know a track's AI usage preference before it
subscribes. The catalog is unsigned and the objects' own statements are
signed with them: a catalog whose statement differs from an object's is refused
(`catalog_conflicts`), and the object's governs (ai-usage §4.5, §5.3).
These are custom catalog fields under MSF's extension rule (MSF §5: custom field
names "MUST NOT collide" with MSF's, and a parser ignores a field it does not

View file

@ -19,6 +19,7 @@ and the independent verifier of each, beside it.
| [`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` |
| [`usage/`](usage/) | [ai-usage](../extensions/ai-usage.md) (`wai.ai.usage`) | `ai_usage_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
@ -27,12 +28,13 @@ 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/`, `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`, `synthesis/README.md`);
`c2pa-live/` cuts its media from the packaging corpus's AV1 floor. `joule_moq_e2ee_vectors gen` (in
`ads/`, `wai_live_provenance_vectors gen` writes `c2pa-live/`, `wai_synthesis_vectors gen`
writes `synthesis/` and `wai_ai_usage_vectors gen` writes `usage/`, 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`, `synthesis/README.md`, `usage/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
@ -57,4 +59,5 @@ python3 e2ee_verify.py e2ee
python3 live_watermark_verify.py watermark
python3 c2pa_live_verify.py c2pa-live
python3 synthesis_verify.py synthesis
python3 ai_usage_verify.py usage
```

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,44 @@
# live-conformance/usage — AI usage preferences and consumption claims
The corpus of [`extensions/ai-usage.md`](../../extensions/ai-usage.md) §10, in one file,
`vectors.json`. Its `keys` name every key by its role, as 64 lowercase hexadecimal digits; its
`revisions` are the vocabulary revisions §3 reads. Each section is a list of cases, each with
its `name`, a `description`, its inputs and its `outcome`:
- `statements` (§3): `json`, a statement's text. `accept`: the `digest` and the `preferences`
of every term it names, by vocabulary (`allow`, `disallow` or `unknown`); or `refuse` and the
`reason`.
- `publications`: each a group of objects under one key, its `group` receipt
([jwp-receipts](../../extensions/jwp-receipts.md) §2.4.1) and, for a room's media, its
`room`.
- `objects` (§4.1): `envelope` (hexadecimal) and its object `receipt`, under its publication's
group. `held`: the `content_hash`, the `statement`'s digest or `null`, and `t`, the manifest's
`live.t` or `null`; or `refuse` and the `reason`.
- `rooms` (§4.2, §4.3): `descriptor`, `grants` and usage `records`, each hexadecimal. The
outcome holds `grants` and `records`, each `accept` or the `index` and `reason` of the first
refused; `terms`, each grant the room accepts with its `principal` and its terms' digest,
`null` for none or `grant_usage_malformed`; and, where the records are accepted, `in_effect`:
the statements governing an object at each instant `t` (`null` for an object with no time).
- `responses` (§4.4): an object of a publication and its response `fields`, each a name and a
value. `accept` and the vocabularies the fields state `unsigned`, or `refuse` and the
`reason`.
- `catalogs` (§4.5): a catalog track `entry` and the `objects` of its track.
- `claims` (§6): the claim's `wire` text and its `context`: the `publications` whose held
objects the verifier holds, the `room`, and the claim before it of its signer, `previous`, by
name, or `null` when the verifier is given none. `accept` and each object's `outcome`
(`allowed`, `unknown` or `outside`), in the order of the claim's `objects`; or `refuse`, the
jwp-receipts §3.1 `step` and the `reason`.
- `gates` (§7): the `policy`, the `agent` by its key's name, the groups `served`, the `claims`
given, by name, each verified with its own context, and the group requested. `serve`, or
`refuse` and the `reason`.
A verifier that reaches another outcome, reason, step, digest, preference, terms or object
outcome does not conform. Payloads are opaque test bytes that no case decodes.
**Provenance.** Generated here: `wai_ai_usage_vectors gen` writes the file from statements,
envelopes, rooms and claims authored in code, from fixed seeds. Each key is SHA-256 of a fixed
label and an index; every time is a constant (the room's `t0` is 2026-10-21T00:00:00Z); every
origin and URL is an `.example` name (RFC 2606); and Ed25519 signing is deterministic (RFC
8032), so the file is the same bytes on every machine. The generator asserts that each case
reaches the outcome it was written to have and that the corpus gives every reason of §9.
`ai_usage_verify.py`, beside this directory, is the second implementation.

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,20 @@
{
"schema": "https://git.transaction.science/Transaction-Science/open-standards/src/branch/main/wai/registry/capabilities.schema.json",
"capabilities": [
{
"capability": "wai.ai.usage",
"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.asset.provenance",
"kind": "record",
@ -1962,6 +1976,13 @@
"section": "§10.5.3",
"status": "proposed"
},
{
"name": "WAI-Usage-Claim",
"kind": "http-header",
"document": "extensions/ai-usage.md",
"section": "§7.3",
"status": "proposed"
},
{
"name": "WAI1",
"kind": "magic",
@ -1997,6 +2018,13 @@
"section": "§3.1",
"status": "defined"
},
{
"name": "WAU1",
"kind": "magic",
"document": "extensions/ai-usage.md",
"section": "§4.2",
"status": "defined"
},
{
"name": "WCD1",
"kind": "magic",
@ -2445,6 +2473,13 @@
"section": "Appendix B",
"status": "provisional"
},
{
"name": "wai_ai_usage",
"kind": "catalog",
"document": "extensions/moq-streaming-format.md",
"section": "§4",
"status": "defined"
},
{
"name": "wai_c2pa_track",
"kind": "catalog",

View file

@ -103,6 +103,13 @@ wai:pinTier a owl:DatatypeProperty ; rdfs:domain wai:ParameterSetPin ; rdfs:rang
# ─── Registered strings ──────────────────────────────────────────────
wai:wai.ai.usage a wai:Capability ;
rdfs:label "wai.ai.usage" ;
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.asset.provenance a wai:Capability ;
rdfs:label "wai.asset.provenance" ;
wai:kind wai:kind-record ;

View file

@ -298,6 +298,14 @@ derive_edge = ["key_discovery", "video_int", "png_still", "dep:jpeg-encoder"]
# 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"]
# AI usage preferences and consumption claims (extensions/ai-usage): `wai.ai.usage` — the usage
# statement (draft-ietf-aipref-vocab-08's Structured Field form, the TDM Reservation Protocol's
# properties and the Training and Data Mining Assertion's entries, verbatim) in an envelope's
# manifest, a room's WAU1 record and a grant's terms; the HTTP and catalog fields checked
# against it; and the jwp-receipts `consumption` claim checked against its objects and room.
# RFC 9651's Dictionary parser is written here. SHA-256, Ed25519 and the room's records only;
# no new dependency.
ai_usage = ["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,
@ -785,6 +793,11 @@ name = "wai_live_provenance_vectors"
path = "src/bin/wai_live_provenance_vectors.rs"
required-features = ["live_provenance"]
[[bin]]
name = "wai_ai_usage_vectors"
path = "src/bin/wai_ai_usage_vectors.rs"
required-features = ["ai_usage"]
[[bin]]
name = "wai_timed_text_vectors"
path = "src/bin/wai_timed_text_vectors.rs"
@ -1500,6 +1513,11 @@ name = "derive_edge"
path = "tests/derive_edge.rs"
required-features = ["derive_edge"]
[[test]]
name = "ai_usage"
path = "tests/ai_usage.rs"
required-features = ["ai_usage"]
[[test]]
name = "timed_text"
path = "tests/timed_text.rs"

996
wai/wai-rs/src/ai_usage.rs Normal file
View file

@ -0,0 +1,996 @@
//! AI usage preferences and consumption claims (`extensions/ai-usage.md`): the record
//! `wai.ai.usage`.
//!
//! A **usage statement** (§3) carries, verbatim, the preferences of three vocabularies: the
//! AI usage preference vocabulary of `draft-ietf-aipref-vocab-08` in its exemplary Structured
//! Field form, the TDM Reservation Protocol's `tdm-reservation` and `tdm-policy`, and the
//! entries of a `cawg.training-mining` assertion (version 1.1). It rides in an envelope's
//! manifest member `ai_usage`, signed by the object's receipt (§4.1); in a room usage record,
//! `WAU1`, signed by the room authority (§4.2); and in a room grant's attribute `ai_usage`, as
//! the terms the authority grants its principal (§4.3). HTTP fields (§4.4) and a MoQ catalog
//! field (§4.5) restate it unsigned, and are checked against it.
//!
//! [`preference`] is §5's one rule: a grant's terms, then the signed statements, the most
//! restrictive governing. A **consumption claim** (§6) is the jwp-receipts claim class
//! `consumption`, whose body and class rules are `crate::claim_receipt::consumption`'s; this
//! module checks it against its objects, their statements and its room (§6.3), and decides a
//! gate's request (§7).
//!
//! The corpus is `live-conformance/usage/` ([`corpus`]); its second implementation is
//! `live-conformance/ai_usage_verify.py`, which shares no code with this module.
use crate::claim_receipt::{self, ClaimClass, ClaimReceipt, ConsumptionClaim, UsageEntry, Vocabulary};
use crate::jwp_delivery::{GroupReceipt, ObjectReceipt};
use crate::live_room::{self, Descriptor, Grant};
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
pub mod corpus;
pub mod sf;
/// The record this extension registers.
pub const CAPABILITY: &str = "wai.ai.usage";
/// The room usage record's magic (§4.2).
pub const ROOM_MAGIC: [u8; 4] = *b"WAU1";
/// The room usage record's signing domain (§4.2).
pub const ROOM_DOMAIN: &[u8] = b"wai:ai-usage-room\x01";
/// The record version this revision reads.
pub const RECORD_VERSION: u8 = 1;
/// The manifest member (§4.1).
pub const MANIFEST_MEMBER: &str = "ai_usage";
/// The grant attribute (§4.3).
pub const GRANT_ATTRIBUTE: &str = "ai_usage";
/// The MoQ catalog field (§4.5; moq-streaming-format §4).
pub const CATALOG_FIELD: &str = "wai_ai_usage";
/// The HTTP request field an agent gives a gateway a claim in (§7.3).
pub const CLAIM_FIELD: &str = "WAI-Usage-Claim";
/// The revisions each vocabulary is read at (§3.2, §3.4, §3.5).
pub const AIPREF_REVISION: &str = "draft-ietf-aipref-vocab-08";
pub const CAWG_REVISION: &str = "1.1";
pub const TDMREP_REVISION: &str = "CG-FINAL-tdmrep-20240510";
/// The longest canonical form of a statement (§3.6 step 3).
pub const MAX_STATEMENT: usize = 4096;
/// The longest `aipref` value (§3.2).
pub const MAX_AIPREF_VALUE: usize = 1024;
/// The most entries of a `cawg` statement (§3.4).
pub const MAX_CAWG_ENTRIES: usize = 32;
/// The longest gate window (§7.1).
pub const MAX_WINDOW: u8 = 16;
// ---------------------------------------------------------------------------------------
// Reasons (§9)
// ---------------------------------------------------------------------------------------
/// A refusal: a code of this extension's §9, a live-rooms code a room usage record or a grant
/// raises, or a jwp-receipts §3.1 code a claim raises at steps 1 to 5.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Reason {
UsageMalformed,
UsageRevisionUnknown,
ReceiptInvalid,
UsageUnbound,
RoomUsageUnlinked,
GrantUsageMalformed,
HeaderConflicts,
CatalogConflicts,
ConsumptionObjectUnknown,
GrantMismatch,
PreferenceMismatch,
UsageUnanswered,
UsageDisallowed,
ClaimRequired,
UsageNotGranted,
/// A live-rooms code: a room usage record that does not read or verify (§4.2), a
/// descriptor or a grant the room refuses.
Room(live_room::Reason),
/// A jwp-receipts §3.1 code, among them the six of ai-usage §6.2.
Claim(claim_receipt::Reason),
}
/// The codes §9 lists, in its order: this extension's own, then the class rules of §6.2 that
/// `claim_receipt` raises.
pub const CODES: [&str; 21] = [
"usage_malformed",
"usage_revision_unknown",
"receipt_invalid",
"usage_unbound",
"room_usage_unlinked",
"grant_usage_malformed",
"header_conflicts",
"catalog_conflicts",
"consumption_objects_unordered",
"consumption_subject_mismatch",
"consumption_usage_unordered",
"consumption_term_unknown",
"consumption_seen_unordered",
"consumption_signer_not_principal",
"consumption_object_unknown",
"grant_mismatch",
"preference_mismatch",
"usage_unanswered",
"usage_disallowed",
"claim_required",
"usage_not_granted",
];
impl Reason {
pub fn code(self) -> &'static str {
match self {
Reason::UsageMalformed => "usage_malformed",
Reason::UsageRevisionUnknown => "usage_revision_unknown",
Reason::ReceiptInvalid => "receipt_invalid",
Reason::UsageUnbound => "usage_unbound",
Reason::RoomUsageUnlinked => "room_usage_unlinked",
Reason::GrantUsageMalformed => "grant_usage_malformed",
Reason::HeaderConflicts => "header_conflicts",
Reason::CatalogConflicts => "catalog_conflicts",
Reason::ConsumptionObjectUnknown => "consumption_object_unknown",
Reason::GrantMismatch => "grant_mismatch",
Reason::PreferenceMismatch => "preference_mismatch",
Reason::UsageUnanswered => "usage_unanswered",
Reason::UsageDisallowed => "usage_disallowed",
Reason::ClaimRequired => "claim_required",
Reason::UsageNotGranted => "usage_not_granted",
Reason::Room(r) => r.code(),
Reason::Claim(r) => r.label(),
}
}
}
fn sha256(parts: &[&[u8]]) -> [u8; 32] {
let mut h = Sha256::new();
for p in parts {
h.update(p);
}
h.finalize().into()
}
fn hex(b: &[u8]) -> String {
claim_receipt::hex(b)
}
/// A live-events code, as a room raises it (live-rooms §11).
fn ev(r: crate::live_events::Reason) -> Reason {
Reason::Room(live_room::Reason::Events(r))
}
// ---------------------------------------------------------------------------------------
// §3 The usage statement
// ---------------------------------------------------------------------------------------
/// What a vocabulary says about one term (§2).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Preference {
Allow,
Disallow,
Unknown,
}
impl Preference {
pub fn label(self) -> &'static str {
match self {
Preference::Allow => "allow",
Preference::Disallow => "disallow",
Preference::Unknown => "unknown",
}
}
}
/// A `cawg` entry's `use` (§3.4).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CawgUse {
Allowed,
NotAllowed,
Constrained,
}
impl CawgUse {
fn from_label(s: &str) -> Option<CawgUse> {
match s {
"allowed" => Some(CawgUse::Allowed),
"notAllowed" => Some(CawgUse::NotAllowed),
"constrained" => Some(CawgUse::Constrained),
_ => None,
}
}
}
/// A statement that reads (§3.6).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Statement {
/// `aipref`: the value, and each label's preference.
pub aipref: Option<(String, [Preference; 3])>,
/// `cawg`: each entry's use.
pub cawg: Option<BTreeMap<String, CawgUse>>,
/// `tdmrep`: the reservation (0 or 1) and the policy URL.
pub tdmrep: Option<(u8, Option<String>)>,
/// The canonical form (§2).
pub canonical: String,
}
/// The preferences of `draft-ietf-aipref-vocab-08` §6.5 for the three labels, in
/// [`claim_receipt::consumption::AIPREF_LABELS`]' order; `None` when the value does not parse.
pub fn aipref_preferences(value: &str) -> Option<[Preference; 3]> {
let d = sf::parse_dictionary(value)?;
Some(claim_receipt::consumption::AIPREF_LABELS.map(|l| match d.get(l) {
Some(sf::Member::Token(t)) if t == "y" => Preference::Allow,
Some(sf::Member::Token(t)) if t == "n" => Preference::Disallow,
_ => Preference::Unknown,
}))
}
fn exact_members(m: &Map<String, Value>, allowed: &[&str], required: &[&str]) -> bool {
required.iter().all(|k| m.contains_key(*k)) && m.keys().all(|k| allowed.contains(&k.as_str()))
}
fn policy_url_ok(s: &str) -> bool {
(8..=2048).contains(&s.len())
&& s.bytes().all(|b| (0x21..=0x7e).contains(&b))
&& (s.starts_with("https://") || s.starts_with("http://"))
}
impl Statement {
/// §3.6 on a JSON value already read with no repeated member name.
pub fn read(v: &Value) -> Result<Statement, Reason> {
let bad = Reason::UsageMalformed;
let m = v.as_object().ok_or(bad)?;
if m.is_empty() || m.keys().any(|k| !["aipref", "cawg", "tdmrep"].contains(&k.as_str())) {
return Err(bad);
}
// Step 1, member by member in §3.1's order; the revisions are only typed here.
let mut revisions: Vec<(&str, &str)> = Vec::new();
let mut aipref = None;
if let Some(a) = m.get("aipref") {
let a = a.as_object().ok_or(bad)?;
if !exact_members(a, &["revision", "value"], &["revision", "value"]) {
return Err(bad);
}
let rev = a["revision"].as_str().ok_or(bad)?;
let value = a["value"].as_str().ok_or(bad)?;
if value.is_empty() || value.len() > MAX_AIPREF_VALUE || !value.is_ascii() {
return Err(bad);
}
let prefs = aipref_preferences(value).ok_or(bad)?;
revisions.push((rev, AIPREF_REVISION));
aipref = Some((value.to_owned(), prefs));
}
let mut cawg = None;
if let Some(c) = m.get("cawg") {
let c = c.as_object().ok_or(bad)?;
if !exact_members(c, &["revision", "entries"], &["revision", "entries"]) {
return Err(bad);
}
let rev = c["revision"].as_str().ok_or(bad)?;
let e = c["entries"].as_object().ok_or(bad)?;
if e.is_empty() || e.len() > MAX_CAWG_ENTRIES {
return Err(bad);
}
let mut entries = BTreeMap::new();
for (k, v) in e {
let reserved_ok = !k.starts_with("cawg.") || claim_receipt::consumption::CAWG_KEYS.contains(&k.as_str());
if !claim_receipt::consumption::cawg_key_form(k) || !reserved_ok {
return Err(bad);
}
let v = v.as_object().ok_or(bad)?;
if !exact_members(v, &["use", "constraint_info"], &["use"]) {
return Err(bad);
}
let u = CawgUse::from_label(v["use"].as_str().ok_or(bad)?).ok_or(bad)?;
if let Some(ci) = v.get("constraint_info") {
let ci = ci.as_str().ok_or(bad)?;
if ci.is_empty() || ci.len() > 1024 {
return Err(bad);
}
}
entries.insert(k.clone(), u);
}
revisions.push((rev, CAWG_REVISION));
cawg = Some(entries);
}
let mut tdmrep = None;
if let Some(t) = m.get("tdmrep") {
let t = t.as_object().ok_or(bad)?;
let names = ["revision", "tdm-reservation", "tdm-policy"];
if !exact_members(t, &names, &names) {
return Err(bad);
}
let rev = t["revision"].as_str().ok_or(bad)?;
let reservation = match t["tdm-reservation"].as_u64() {
Some(0) => 0,
Some(1) => 1,
_ => return Err(bad),
};
let policy = match &t["tdm-policy"] {
Value::Null => None,
Value::String(s) if policy_url_ok(s) => Some(s.clone()),
_ => return Err(bad),
};
revisions.push((rev, TDMREP_REVISION));
tdmrep = Some((reservation, policy));
}
// Step 2.
if revisions.iter().any(|(got, want)| got != want) {
return Err(Reason::UsageRevisionUnknown);
}
// Step 3.
let canonical = crate::jcs::jcs(v).map_err(|_| bad)?;
if canonical.len() > MAX_STATEMENT {
return Err(bad);
}
Ok(Statement { aipref, cawg, tdmrep, canonical })
}
/// §3.6 on JSON text: no member name repeated in an object, then [`Statement::read`].
pub fn read_text(text: &str) -> Result<Statement, Reason> {
let v = crate::claim_text::read(text).map_err(|_| Reason::UsageMalformed)?;
Statement::read(&v)
}
/// §4.2 and §4.3: the bytes must read and be the canonical form of what they hold.
pub fn read_canonical(bytes: &[u8]) -> Result<Statement, Reason> {
let text = std::str::from_utf8(bytes).map_err(|_| Reason::UsageMalformed)?;
let s = Statement::read_text(text)?;
if s.canonical.as_bytes() != bytes {
return Err(Reason::UsageMalformed);
}
Ok(s)
}
/// The digest (§3.6).
pub fn digest(&self) -> [u8; 32] {
sha256(&[self.canonical.as_bytes()])
}
/// The vocabularies it carries, in order.
pub fn vocabularies(&self) -> Vec<Vocabulary> {
let mut v = Vec::new();
if self.aipref.is_some() {
v.push(Vocabulary::Aipref);
}
if self.cawg.is_some() {
v.push(Vocabulary::Cawg);
}
if self.tdmrep.is_some() {
v.push(Vocabulary::Tdmrep);
}
v
}
/// The preference it gives `term` of `vocabulary` (§3.2, §3.4, §3.5); `None` when it does
/// not carry the vocabulary.
pub fn preference(&self, vocabulary: Vocabulary, term: &str) -> Option<Preference> {
match vocabulary {
Vocabulary::Aipref => self.aipref.as_ref().map(|(_, p)| {
claim_receipt::consumption::AIPREF_LABELS.iter().position(|l| *l == term).map_or(Preference::Unknown, |i| p[i])
}),
Vocabulary::Cawg => self.cawg.as_ref().map(|e| match e.get(term) {
Some(CawgUse::Allowed) => Preference::Allow,
Some(CawgUse::NotAllowed | CawgUse::Constrained) => Preference::Disallow,
None => Preference::Unknown,
}),
Vocabulary::Tdmrep => self.tdmrep.as_ref().map(|(r, _)| {
if term != claim_receipt::consumption::TDM_TERM {
Preference::Unknown
} else if *r == 1 {
Preference::Disallow
} else {
Preference::Allow
}
}),
}
}
/// Every term this statement names, with its preference: the three `aipref` labels, the
/// `cawg` keys and `tdm-reservation`. For reports.
pub fn terms(&self) -> Vec<(Vocabulary, String, Preference)> {
let mut out = Vec::new();
if let Some((_, p)) = &self.aipref {
for (l, pref) in claim_receipt::consumption::AIPREF_LABELS.iter().zip(p) {
out.push((Vocabulary::Aipref, (*l).to_owned(), *pref));
}
}
if let Some(e) = &self.cawg {
for k in e.keys() {
out.push((Vocabulary::Cawg, k.clone(), self.preference(Vocabulary::Cawg, k).expect("carried")));
}
}
if self.tdmrep.is_some() {
let t = claim_receipt::consumption::TDM_TERM;
out.push((Vocabulary::Tdmrep, t.to_owned(), self.preference(Vocabulary::Tdmrep, t).expect("carried")));
}
out
}
}
// ---------------------------------------------------------------------------------------
// §4.1 The envelope
// ---------------------------------------------------------------------------------------
/// An object a verifier holds (§4.1).
#[derive(Clone, Debug)]
pub struct HeldObject {
pub content_hash: [u8; 32],
/// Its statement, signed by `signer`, or none.
pub statement: Option<Statement>,
/// The group receipt's `signer_pubkey`.
pub signer: [u8; 32],
/// Its manifest's `live.t`, where it has an integer one.
pub t: Option<u64>,
}
/// The manifest of a `WAI1` or `WAI2` envelope as a JSON value, read as SPEC §2 reads it.
fn manifest_value(envelope: &[u8]) -> Option<Value> {
let text = crate::container::manifest_bytes(envelope).ok()?;
crate::container::check_manifest_text(text).ok()?;
serde_json::from_slice(text).ok()
}
/// §4.1: accept the receipts, require the envelope to hash to the object receipt's content
/// hash, and read the manifest's `ai_usage`.
pub fn hold_object(envelope: &[u8], object: &ObjectReceipt, group: &GroupReceipt) -> Result<HeldObject, Reason> {
group.verify().map_err(|_| Reason::ReceiptInvalid)?;
object.verify_inclusion(group).map_err(|_| Reason::ReceiptInvalid)?;
let h = crate::jwp_hash::content_hash(envelope);
if h != object.content_hash {
return Err(Reason::UsageUnbound);
}
let m = manifest_value(envelope).ok_or(Reason::UsageMalformed)?;
let statement = match m.get(MANIFEST_MEMBER) {
None => None,
Some(v) => Some(Statement::read(v)?),
};
let t = m.get("live").and_then(|l| l.get("t")).and_then(Value::as_u64);
Ok(HeldObject { content_hash: h, statement, signer: group.signer_pubkey, t })
}
// ---------------------------------------------------------------------------------------
// §4.2 The room usage record, §4.3 a grant's terms
// ---------------------------------------------------------------------------------------
/// A room usage record (§4.2).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoomRecord {
pub room: [u8; 32],
pub prev: [u8; 32],
pub effective: u64,
pub statement: Statement,
pub sig: [u8; 64],
}
impl RoomRecord {
/// The bytes from `magic` through `statement`.
pub fn body(&self) -> Vec<u8> {
let mut o = Vec::new();
o.extend_from_slice(&ROOM_MAGIC);
o.push(RECORD_VERSION);
o.extend_from_slice(&self.room);
o.extend_from_slice(&self.prev);
o.extend_from_slice(&self.effective.to_le_bytes());
let s = self.statement.canonical.as_bytes();
o.extend_from_slice(&(s.len() as u16).to_le_bytes());
o.extend_from_slice(s);
o
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut o = self.body();
o.extend_from_slice(&self.sig);
o
}
pub fn digest(&self) -> [u8; 32] {
sha256(&[&self.body()])
}
pub fn sign(mut self, authority: &ed25519_dalek::SigningKey) -> RoomRecord {
use ed25519_dalek::Signer;
let mut m = ROOM_DOMAIN.to_vec();
m.extend_from_slice(&self.body());
self.sig = authority.sign(&m).to_bytes();
self
}
/// §4.2's reading: the layout, then the statement. Not the room, the signature or the
/// chain, which need the room.
pub fn read(b: &[u8]) -> Result<RoomRecord, Reason> {
use crate::live_events::Reason as L;
if b.len() < 4 {
return Err(ev(L::Truncated));
}
if b[..4] != ROOM_MAGIC {
return Err(ev(L::Malformed));
}
if b.len() < 5 {
return Err(ev(L::Truncated));
}
if b[4] != RECORD_VERSION {
return Err(ev(L::UnknownVersion));
}
let take = |p: &mut usize, n: usize| -> Result<&[u8], Reason> {
let s = b.get(*p..*p + n).ok_or(ev(L::Truncated))?;
*p += n;
Ok(s)
};
let mut p = 5;
let room_id: [u8; 32] = take(&mut p, 32)?.try_into().expect("32");
let prev: [u8; 32] = take(&mut p, 32)?.try_into().expect("32");
let effective = u64::from_le_bytes(take(&mut p, 8)?.try_into().expect("8"));
let n = u16::from_le_bytes(take(&mut p, 2)?.try_into().expect("2")) as usize;
let statement_bytes = take(&mut p, n)?.to_vec();
let sig: [u8; 64] = take(&mut p, 64)?.try_into().expect("64");
if p != b.len() {
return Err(ev(L::Malformed));
}
let statement = Statement::read_canonical(&statement_bytes)?;
Ok(RoomRecord { room: room_id, prev, effective, statement, sig })
}
}
/// A room as §4.2 and §4.3 check it.
#[derive(Clone, Debug)]
pub struct CheckedRoom {
pub descriptor: Descriptor,
pub id: [u8; 32],
/// The grants live-rooms §4.3 accepts, each with its terms: `Some(Ok)` when it carries
/// terms that read, `Some(Err)` when they do not, `None` when it carries none.
pub grants: Vec<(live_room::HeldGrant, Option<Result<Statement, Reason>>)>,
/// The usage records, or the first refused and its index (§4.2).
pub records: Result<Vec<RoomRecord>, (usize, Reason)>,
/// The grants' refusal, when live-rooms §4.3 refuses one: its index and reason.
pub grants_refused: Option<(usize, Reason)>,
}
impl CheckedRoom {
/// The records in effect at `t`: the one in effect (§4.2), or every record where `t` is
/// `None`. None when the records were refused: such a room states no preference.
pub fn records_at(&self, t: Option<u64>) -> Vec<&RoomRecord> {
let Ok(records) = &self.records else { return Vec::new() };
match t {
None => records.iter().collect(),
Some(t) => records.iter().rev().find(|r| r.effective <= t).into_iter().collect(),
}
}
/// The grant of this digest that the room accepts.
pub fn grant(&self, digest: &[u8; 32]) -> Option<&(live_room::HeldGrant, Option<Result<Statement, Reason>>)> {
self.grants.iter().find(|(h, _)| h.digest == *digest)
}
}
/// A grant's terms (§4.3): `None` when it carries no `ai_usage` attribute.
pub fn grant_terms(g: &Grant) -> Option<Result<Statement, Reason>> {
let (_, v) = g.attrs.iter().find(|(k, _)| k == GRANT_ATTRIBUTE)?;
Some(Statement::read_canonical(v.as_bytes()).map_err(|_| Reason::GrantUsageMalformed))
}
/// Check a room (§4.2, §4.3): its descriptor, which must verify (`Err` otherwise), its grants
/// and its usage records.
pub fn check_room(descriptor: &[u8], grants: &[Vec<u8>], records: &[Vec<u8>]) -> Result<CheckedRoom, Reason> {
let d = Descriptor::parse(descriptor).map_err(Reason::Room)?;
let id = d.room_id();
let (held, grants_refused) = match live_room::check_grants(&d, &id, grants) {
Ok(h) => (h, None),
Err((i, r)) => (Vec::new(), Some((i, Reason::Room(r)))),
};
let grants = held.into_iter().map(|h| {
let terms = grant_terms(&h.grant);
(h, terms)
});
let records = check_records(&d, &id, records);
Ok(CheckedRoom { descriptor: d.clone(), id, grants: grants.collect(), records, grants_refused })
}
/// §4.2 over a room's records, in order.
pub fn check_records(d: &Descriptor, id: &[u8; 32], records: &[Vec<u8>]) -> Result<Vec<RoomRecord>, (usize, Reason)> {
let mut out: Vec<RoomRecord> = Vec::new();
for (i, b) in records.iter().enumerate() {
let r = RoomRecord::read(b).map_err(|e| (i, e))?;
if r.room != *id {
return Err((i, ev(crate::live_events::Reason::WrongRoom)));
}
let mut m = ROOM_DOMAIN.to_vec();
m.extend_from_slice(&b[..b.len() - 64]);
crate::sig::verify_strict(&d.authority, &m, &r.sig).map_err(|e| {
(
i,
ev(match e {
crate::sig::SigError::BadKey => crate::live_events::Reason::BadKey,
crate::sig::SigError::BadSignature => crate::live_events::Reason::BadSignature,
}),
)
})?;
let linked = match out.last() {
None => r.prev == [0; 32] && r.effective == 0,
Some(last) => r.prev == last.digest() && r.effective > last.effective,
};
if !linked {
return Err((i, Reason::RoomUsageUnlinked));
}
out.push(r);
}
Ok(out)
}
// ---------------------------------------------------------------------------------------
// §4.4 HTTP, §4.5 the MoQ catalog
// ---------------------------------------------------------------------------------------
/// §4.4: compare a response's fields with the object's signed statement. `Ok` holds the
/// vocabularies the fields state unsigned: those the signed statement does not carry.
pub fn check_fields(signed: Option<&Statement>, fields: &[(String, String)]) -> Result<Vec<Vocabulary>, Reason> {
let lines = |name: &str| -> Vec<&str> {
fields.iter().filter(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()).collect()
};
let trim = |s: &str| s.trim_matches(|c| c == ' ' || c == '\t').to_owned();
let mut unsigned = Vec::new();
let usage = lines("Content-Usage");
if !usage.is_empty() {
match signed.and_then(|s| s.aipref.as_ref()) {
None => unsigned.push(Vocabulary::Aipref),
Some((_, prefs)) => {
let combined = usage.join(", ");
if aipref_preferences(&combined).as_ref() != Some(prefs) {
return Err(Reason::HeaderConflicts);
}
}
}
}
let reservation = lines("tdm-reservation");
let policy = lines("tdm-policy");
if !reservation.is_empty() || !policy.is_empty() {
match signed.and_then(|s| s.tdmrep.as_ref()) {
None => unsigned.push(Vocabulary::Tdmrep),
Some((r, p)) => {
if reservation.len() > 1 || policy.len() > 1 {
return Err(Reason::HeaderConflicts);
}
if let Some(v) = reservation.first()
&& trim(v) != r.to_string()
{
return Err(Reason::HeaderConflicts);
}
if let Some(v) = policy.first()
&& Some(trim(v)) != *p
{
return Err(Reason::HeaderConflicts);
}
}
}
}
Ok(unsigned)
}
/// §4.5: a catalog track entry against the held objects of its track.
pub fn check_catalog(entry_text: &str, objects: &[&HeldObject]) -> Result<(), Reason> {
let v = crate::claim_text::read(entry_text).map_err(|_| Reason::CatalogConflicts)?;
let Some(s) = v.get(CATALOG_FIELD) else { return Ok(()) };
let s = Statement::read(s).map_err(|_| Reason::CatalogConflicts)?;
if objects.iter().filter_map(|o| o.statement.as_ref()).any(|o| o.digest() != s.digest()) {
return Err(Reason::CatalogConflicts);
}
Ok(())
}
// ---------------------------------------------------------------------------------------
// §5 Precedence
// ---------------------------------------------------------------------------------------
/// An object's governing statements (§5.1): its own, then the room's records in effect at its
/// time, when it is the room's.
pub fn governing<'a>(o: &'a HeldObject, room: Option<&'a CheckedRoom>) -> Vec<&'a Statement> {
let mut v: Vec<&Statement> = o.statement.iter().collect();
if let Some(r) = room {
v.extend(r.records_at(o.t).into_iter().map(|r| &r.statement));
}
v
}
/// §5.2 for one use: `None` when it is outside the vocabulary (`term` is `None`).
pub fn preference(entry: &UsageEntry, governing: &[&Statement], terms: Option<&Statement>) -> Option<Preference> {
let term = entry.term.as_deref()?;
if let Some(p) = terms.and_then(|t| t.preference(entry.vocabulary, term))
&& p != Preference::Unknown
{
return Some(p);
}
let ps: Vec<Preference> = governing.iter().filter_map(|s| s.preference(entry.vocabulary, term)).collect();
Some(if ps.contains(&Preference::Disallow) {
Preference::Disallow
} else if ps.contains(&Preference::Allow) {
Preference::Allow
} else {
Preference::Unknown
})
}
/// What an accepted claim states for one object (§6.4).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Outcome {
Allowed,
Unknown,
Outside,
}
impl Outcome {
pub fn label(self) -> &'static str {
match self {
Outcome::Allowed => "allowed",
Outcome::Unknown => "unknown",
Outcome::Outside => "outside",
}
}
}
/// §5.2's last paragraph over a claim's entries for one object: `Err` on a disallow.
fn combine(prefs: &[Option<Preference>]) -> Result<Outcome, ()> {
if prefs.contains(&Some(Preference::Disallow)) {
Err(())
} else if prefs.contains(&Some(Preference::Allow)) {
Ok(Outcome::Allowed)
} else if prefs.iter().all(Option::is_none) {
Ok(Outcome::Outside)
} else {
Ok(Outcome::Unknown)
}
}
// ---------------------------------------------------------------------------------------
// §6 The consumption claim
// ---------------------------------------------------------------------------------------
/// What a claim is checked against (§6.3, §8).
pub struct ClaimContext<'a> {
/// The objects the verifier holds, by content hash, and whether each is the room's.
pub objects: &'a BTreeMap<[u8; 32], (HeldObject, bool)>,
/// The room, where the claim's objects are a room's or it names a grant.
pub room: Option<&'a CheckedRoom>,
/// The claim before it of its signer, as the verifier holds it.
pub previous: Option<&'a ClaimReceipt>,
}
/// One object of a claim, by content hash, and what the claim states for it (§6.4).
pub type Judged = ([u8; 32], Outcome);
/// An accepted claim: the claim, and each object's outcome in the order of `objects`.
#[derive(Clone, Debug)]
pub struct Accepted {
pub claim: ClaimReceipt,
pub outcomes: Vec<Judged>,
}
/// A refused claim: the jwp-receipts §3.1 step, the reason, and the claim where it read.
#[derive(Clone, Debug)]
pub struct Refused {
pub step: u8,
pub reason: Reason,
pub claim: Option<Box<ClaimReceipt>>,
}
fn body(c: &ClaimReceipt) -> Option<&ConsumptionClaim> {
match &c.body {
claim_receipt::ClaimBody::Consumption(b) => Some(b),
_ => None,
}
}
/// Verify a consumption claim's wire form: jwp-receipts §3.1 steps 1 to 5, then §6.3.
pub fn verify_claim(wire: &str, ctx: &ClaimContext<'_>) -> Result<Accepted, Refused> {
let refuse =
|step: u8, reason: Reason, claim: Option<&ClaimReceipt>| Refused { step, reason, claim: claim.cloned().map(Box::new) };
let claim = claim_receipt::verify_claim_json(wire).map_err(|r| refuse(r.step, Reason::Claim(r.reason), None))?;
claim.check_parent(ctx.previous).map_err(|r| refuse(r.step, Reason::Claim(r.reason), Some(&claim)))?;
let Some(b) = body(&claim) else {
return Err(refuse(6, Reason::Claim(claim_receipt::Reason::ContextMismatch), Some(&claim)));
};
let at6 = |reason: Reason| refuse(6, reason, Some(&claim));
// Step 1.
let objects: Vec<&(HeldObject, bool)> =
b.objects.iter().map(|h| ctx.objects.get(h)).collect::<Option<_>>().ok_or_else(|| at6(Reason::ConsumptionObjectUnknown))?;
// Step 2.
let mut terms: Option<&Statement> = None;
if let Some(g) = &b.grant {
let room = ctx.room.ok_or_else(|| at6(Reason::GrantMismatch))?;
let (held, t) = room.grant(g).ok_or_else(|| at6(Reason::GrantMismatch))?;
let good = held.grant.principal == claim.signer_pubkey
&& objects.iter().all(|(o, of_room)| {
*of_room && o.t.is_none_or(|t| held.grant.covers(room.descriptor.t0_us, t))
});
if !good {
return Err(at6(Reason::GrantMismatch));
}
terms = t.as_ref().and_then(|t| t.as_ref().ok());
}
let room_of = |of_room: bool| if of_room { ctx.room } else { None };
// Step 3.
let seen: BTreeSet<[u8; 32]> =
objects.iter().flat_map(|(o, r)| governing(o, room_of(*r)).into_iter().map(Statement::digest)).collect();
if seen.iter().copied().collect::<Vec<_>>() != b.seen {
return Err(at6(Reason::PreferenceMismatch));
}
// Step 4.
let carried: BTreeSet<Vocabulary> =
objects.iter().flat_map(|(o, r)| governing(o, room_of(*r)).into_iter().flat_map(Statement::vocabularies)).collect();
let answered: BTreeSet<Vocabulary> = b.usage.iter().map(|u| u.vocabulary).collect();
if !carried.is_subset(&answered) {
return Err(at6(Reason::UsageUnanswered));
}
// Step 5.
let mut outcomes = Vec::new();
for (h, (o, r)) in b.objects.iter().zip(&objects) {
let g = governing(o, room_of(*r));
let prefs: Vec<Option<Preference>> = b.usage.iter().map(|u| preference(u, &g, terms)).collect();
let outcome = combine(&prefs).map_err(|_| at6(Reason::UsageDisallowed))?;
outcomes.push((*h, outcome));
}
Ok(Accepted { claim: claim.clone(), outcomes })
}
// ---------------------------------------------------------------------------------------
// §7 The gate
// ---------------------------------------------------------------------------------------
/// A gate's policy (§7.1).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Policy {
pub window: u8,
pub require_allowed: bool,
}
/// §7.2: decide a request. `claims` are the claims the agent gave, each as verified; `served`
/// the groups served the agent, in order; `request` the group requested.
pub fn gate(
policy: Policy,
agent: &[u8; 32],
served: &[Vec<[u8; 32]>],
claims: &[Result<Accepted, Refused>],
request: &[[u8; 32]],
) -> Result<(), Reason> {
// The held claims: the agent's, accepted or refused `usage_disallowed` at step 6.
let mut allowed: Vec<(&ClaimReceipt, &[Judged])> = Vec::new();
let mut disallowing: Vec<&ClaimReceipt> = Vec::new();
for c in claims {
match c {
Ok(a) if a.claim.signer_pubkey == *agent => allowed.push((&a.claim, &a.outcomes)),
Err(Refused { step: 6, reason: Reason::UsageDisallowed, claim: Some(c) }) if c.signer_pubkey == *agent => {
disallowing.push(c.as_ref())
}
_ => {}
}
}
let names = |c: &ClaimReceipt, h: &[u8; 32]| body(c).is_some_and(|b| b.objects.contains(h));
let checked: Vec<[u8; 32]> = if policy.window == 0 {
request.to_vec()
} else {
let keep = served.len().saturating_sub(policy.window as usize);
served[..keep].iter().flatten().copied().collect()
};
for h in &checked {
let any_allowed = allowed.iter().any(|(c, _)| names(c, h));
let any_disallowing = disallowing.iter().any(|c| names(c, h));
if !any_allowed && !any_disallowing {
return Err(Reason::ClaimRequired);
}
if any_disallowing {
return Err(Reason::UsageDisallowed);
}
if policy.require_allowed
&& allowed.iter().any(|(_, outs)| outs.iter().any(|(o, out)| o == h && *out != Outcome::Allowed))
{
return Err(Reason::UsageNotGranted);
}
}
Ok(())
}
/// The class this module verifies.
pub const CLAIM_CLASS: ClaimClass = ClaimClass::Consumption;
/// A report of a statement, as the corpus states it: the digest and every term's preference.
pub fn statement_report(s: &Statement) -> Value {
let mut m = Map::new();
m.insert("digest".into(), hex(&s.digest()).into());
let mut terms = Map::new();
for (v, t, p) in s.terms() {
let e = terms.entry(v.label()).or_insert_with(|| Value::Object(Map::new()));
e.as_object_mut().expect("object").insert(t, p.label().into());
}
m.insert("preferences".into(), Value::Object(terms));
Value::Object(m)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn st(v: Value) -> Result<Statement, Reason> {
Statement::read(&v)
}
#[test]
fn a_statement_reads_its_three_vocabularies() {
let s = st(json!({
"aipref": {"revision": AIPREF_REVISION, "value": "train-ai=n, search=y"},
"cawg": {"revision": "1.1", "entries": {"cawg.ai_inference": {"use": "allowed"}, "cawg.data_mining": {"use": "constrained", "constraint_info": "x"}}},
"tdmrep": {"revision": TDMREP_REVISION, "tdm-reservation": 1, "tdm-policy": "https://policy.example/tdm.json"}
}))
.unwrap();
assert_eq!(s.preference(Vocabulary::Aipref, "train-ai"), Some(Preference::Disallow));
assert_eq!(s.preference(Vocabulary::Aipref, "search"), Some(Preference::Allow));
assert_eq!(s.preference(Vocabulary::Aipref, "ai-use"), Some(Preference::Unknown));
assert_eq!(s.preference(Vocabulary::Cawg, "cawg.ai_inference"), Some(Preference::Allow));
assert_eq!(s.preference(Vocabulary::Cawg, "cawg.data_mining"), Some(Preference::Disallow));
assert_eq!(s.preference(Vocabulary::Cawg, "cawg.ai_training"), Some(Preference::Unknown));
assert_eq!(s.preference(Vocabulary::Tdmrep, "tdm-reservation"), Some(Preference::Disallow));
assert_eq!(s.vocabularies(), Vocabulary::ALL.to_vec());
}
#[test]
fn a_statement_is_refused_in_its_steps_order() {
let bad = |v: Value| st(v).unwrap_err();
assert_eq!(bad(json!({})), Reason::UsageMalformed);
assert_eq!(bad(json!({"aipref": {"revision": AIPREF_REVISION, "value": "Train-AI=y"}})), Reason::UsageMalformed);
assert_eq!(bad(json!({"aipref": {"revision": "draft-ietf-aipref-vocab-07", "value": "train-ai=y"}})), Reason::UsageRevisionUnknown);
// A malformed vocabulary is step 1, before an unknown revision (step 2).
assert_eq!(
bad(json!({"aipref": {"revision": "x", "value": "train-ai=y"}, "tdmrep": {"revision": TDMREP_REVISION, "tdm-reservation": 2, "tdm-policy": null}})),
Reason::UsageMalformed
);
assert_eq!(bad(json!({"tdmrep": {"revision": TDMREP_REVISION, "tdm-reservation": 1}})), Reason::UsageMalformed);
assert_eq!(bad(json!({"cawg": {"revision": "1.1", "entries": {"cawg.ai_summaries": {"use": "allowed"}}}})), Reason::UsageMalformed);
assert_eq!(bad(json!({"cawg": {"revision": "1.1", "entries": {"x": {"use": "maybe"}}}})), Reason::UsageMalformed);
assert_eq!(bad(json!({"aipref": {"revision": AIPREF_REVISION, "value": "a=1", "x": 1}})), Reason::UsageMalformed);
assert_eq!(Statement::read_text(r#"{"aipref":{"revision":"x","revision":"y","value":"a=1"}}"#).unwrap_err(), Reason::UsageMalformed);
}
#[test]
fn the_rule_is_terms_then_the_most_restrictive_signed() {
let allow = st(json!({"aipref": {"revision": AIPREF_REVISION, "value": "train-ai=y"}})).unwrap();
let deny = st(json!({"aipref": {"revision": AIPREF_REVISION, "value": "train-ai=n"}})).unwrap();
let e = UsageEntry { vocabulary: Vocabulary::Aipref, term: Some("train-ai".into()) };
assert_eq!(preference(&e, &[&allow, &deny], None), Some(Preference::Disallow));
assert_eq!(preference(&e, &[&allow], None), Some(Preference::Allow));
assert_eq!(preference(&e, &[], None), Some(Preference::Unknown));
assert_eq!(preference(&e, &[&allow, &deny], Some(&allow)), Some(Preference::Allow), "terms lift a disallow");
assert_eq!(preference(&e, &[&allow], Some(&deny)), Some(Preference::Disallow), "and narrow an allow");
let outside = UsageEntry { vocabulary: Vocabulary::Aipref, term: None };
assert_eq!(preference(&outside, &[&deny], None), None);
assert_eq!(combine(&[None, None]), Ok(Outcome::Outside));
assert_eq!(combine(&[None, Some(Preference::Unknown)]), Ok(Outcome::Unknown));
assert_eq!(combine(&[Some(Preference::Allow), Some(Preference::Unknown)]), Ok(Outcome::Allowed));
assert_eq!(combine(&[Some(Preference::Allow), Some(Preference::Disallow)]), Err(()));
}
#[test]
fn fields_conflict_by_meaning_not_spelling() {
let s = st(json!({"aipref": {"revision": AIPREF_REVISION, "value": "train-ai=n, search=y"},
"tdmrep": {"revision": TDMREP_REVISION, "tdm-reservation": 1, "tdm-policy": null}}))
.unwrap();
let f = |v: &[(&str, &str)]| v.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect::<Vec<_>>();
assert_eq!(check_fields(Some(&s), &f(&[("content-usage", "search=y;x=1,train-ai=n, foo=y")])), Ok(vec![]));
assert_eq!(check_fields(Some(&s), &f(&[("Content-Usage", "train-ai=n"), ("Content-Usage", "search=y")])), Ok(vec![]));
assert_eq!(check_fields(Some(&s), &f(&[("Content-Usage", "train-ai=y, search=y")])), Err(Reason::HeaderConflicts));
assert_eq!(check_fields(Some(&s), &f(&[("Content-Usage", "train-ai=n")])), Err(Reason::HeaderConflicts));
assert_eq!(check_fields(Some(&s), &f(&[("tdm-reservation", " 1\t")])), Ok(vec![]));
assert_eq!(check_fields(Some(&s), &f(&[("tdm-reservation", "0")])), Err(Reason::HeaderConflicts));
assert_eq!(check_fields(Some(&s), &f(&[("tdm-policy", "https://p.example/x")])), Err(Reason::HeaderConflicts));
assert_eq!(check_fields(None, &f(&[("Content-Usage", "train-ai=y")])), Ok(vec![Vocabulary::Aipref]));
}
#[test]
fn codes_are_unique_and_named() {
let set: BTreeSet<&str> = CODES.iter().copied().collect();
assert_eq!(set.len(), CODES.len());
for r in [
claim_receipt::Reason::ConsumptionObjectsUnordered,
claim_receipt::Reason::ConsumptionSubjectMismatch,
claim_receipt::Reason::ConsumptionUsageUnordered,
claim_receipt::Reason::ConsumptionTermUnknown,
claim_receipt::Reason::ConsumptionSeenUnordered,
claim_receipt::Reason::ConsumptionSignerNotPrincipal,
] {
assert!(set.contains(Reason::Claim(r).code()));
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,391 @@
//! RFC 9651 §4.2: parsing a Structured Field of type Dictionary, as ai-usage §3.3 reads an
//! `aipref` value and an HTTP `Content-Usage` field.
//!
//! Every bare item type is parsed, because whether a value parses decides whether a
//! statement is refused; only a member's being the Token `y` or `n` is kept, which is all
//! `draft-ietf-aipref-vocab-08` §6.5 reads. RFC 9651 leaves two checks of a Byte Sequence to
//! the parser (§4.2.7); ai-usage §3.3 fixes them: the content is base64 alphabet characters
//! then at most two `=`, and the alphabet characters do not number one more than a multiple
//! of four.
use std::collections::BTreeMap;
/// What a Dictionary member's value is, as far as the vocabulary reads it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Member {
/// A Token, its text.
Token(String),
/// Any other bare item, or an inner list.
Other,
}
/// A parsed Dictionary: each key's last value (§4.2.2 overwrites a repeated key).
pub type Dictionary = BTreeMap<String, Member>;
struct P<'a> {
b: &'a [u8],
i: usize,
}
type R<T> = Result<T, ()>;
fn is_lcalpha(c: u8) -> bool {
c.is_ascii_lowercase()
}
fn is_tchar(c: u8) -> bool {
c.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&c)
}
impl P<'_> {
fn peek(&self) -> Option<u8> {
self.b.get(self.i).copied()
}
fn empty(&self) -> bool {
self.i >= self.b.len()
}
fn eat(&mut self, c: u8) -> R<()> {
if self.peek() == Some(c) {
self.i += 1;
Ok(())
} else {
Err(())
}
}
fn sp(&mut self) {
while self.peek() == Some(b' ') {
self.i += 1;
}
}
fn ows(&mut self) {
while matches!(self.peek(), Some(b' ' | b'\t')) {
self.i += 1;
}
}
/// §4.2.2.
fn dictionary(&mut self) -> R<Dictionary> {
let mut d = Dictionary::new();
while !self.empty() {
let key = self.key()?;
let member = if self.peek() == Some(b'=') {
self.i += 1;
self.item_or_inner_list()?
} else {
self.parameters()?;
Member::Other
};
d.insert(key, member);
self.ows();
if self.empty() {
return Ok(d);
}
self.eat(b',')?;
self.ows();
if self.empty() {
return Err(());
}
}
Ok(d)
}
/// §4.2.1.1.
fn item_or_inner_list(&mut self) -> R<Member> {
if self.peek() == Some(b'(') {
self.inner_list()?;
Ok(Member::Other)
} else {
self.item()
}
}
/// §4.2.1.2.
fn inner_list(&mut self) -> R<()> {
self.eat(b'(')?;
while !self.empty() {
self.sp();
if self.peek() == Some(b')') {
self.i += 1;
return self.parameters();
}
self.item()?;
if !matches!(self.peek(), Some(b' ' | b')')) {
return Err(());
}
}
Err(())
}
/// §4.2.3.
fn item(&mut self) -> R<Member> {
let m = self.bare_item()?;
self.parameters()?;
Ok(m)
}
/// §4.2.3.1.
fn bare_item(&mut self) -> R<Member> {
match self.peek() {
Some(c) if c == b'-' || c.is_ascii_digit() => self.number().map(|_| Member::Other),
Some(b'"') => self.string().map(|_| Member::Other),
Some(c) if c.is_ascii_alphabetic() || c == b'*' => self.token().map(Member::Token),
Some(b':') => self.byte_sequence().map(|_| Member::Other),
Some(b'?') => self.boolean().map(|_| Member::Other),
Some(b'@') => self.date().map(|_| Member::Other),
Some(b'%') => self.display_string().map(|_| Member::Other),
_ => Err(()),
}
}
/// §4.2.3.2.
fn parameters(&mut self) -> R<()> {
while self.peek() == Some(b';') {
self.i += 1;
self.sp();
self.key()?;
if self.peek() == Some(b'=') {
self.i += 1;
self.bare_item()?;
}
}
Ok(())
}
/// §4.2.3.3.
fn key(&mut self) -> R<String> {
match self.peek() {
Some(c) if is_lcalpha(c) || c == b'*' => {}
_ => return Err(()),
}
let s = self.i;
while let Some(c) = self.peek() {
if !(is_lcalpha(c) || c.is_ascii_digit() || b"_-.*".contains(&c)) {
break;
}
self.i += 1;
}
Ok(String::from_utf8(self.b[s..self.i].to_vec()).expect("ASCII"))
}
/// §4.2.4: Integer or Decimal. Returns whether it was an Integer.
fn number(&mut self) -> R<bool> {
let mut integer = true;
let mut digits: Vec<u8> = Vec::new();
if self.peek() == Some(b'-') {
self.i += 1;
}
if self.empty() {
return Err(());
}
if !self.peek().is_some_and(|c| c.is_ascii_digit()) {
return Err(());
}
while let Some(c) = self.peek() {
self.i += 1;
if c.is_ascii_digit() {
digits.push(c);
} else if integer && c == b'.' {
if digits.len() > 12 {
return Err(());
}
digits.push(c);
integer = false;
} else {
self.i -= 1;
break;
}
if integer && digits.len() > 15 {
return Err(());
}
if !integer && digits.len() > 16 {
return Err(());
}
}
if !integer {
if digits.last() == Some(&b'.') {
return Err(());
}
let dot = digits.iter().position(|&c| c == b'.').expect("a decimal has its point");
if digits.len() - dot - 1 > 3 {
return Err(());
}
}
Ok(integer)
}
/// §4.2.5.
fn string(&mut self) -> R<()> {
self.eat(b'"')?;
while let Some(c) = self.peek() {
self.i += 1;
match c {
b'\\' => {
let n = self.peek().ok_or(())?;
self.i += 1;
if n != b'"' && n != b'\\' {
return Err(());
}
}
b'"' => return Ok(()),
0x00..=0x1f | 0x7f..=0xff => return Err(()),
_ => {}
}
}
Err(())
}
/// §4.2.6.
fn token(&mut self) -> R<String> {
match self.peek() {
Some(c) if c.is_ascii_alphabetic() || c == b'*' => {}
_ => return Err(()),
}
let s = self.i;
while let Some(c) = self.peek() {
if !(is_tchar(c) || c == b':' || c == b'/') {
break;
}
self.i += 1;
}
Ok(String::from_utf8(self.b[s..self.i].to_vec()).expect("ASCII"))
}
/// §4.2.7, with ai-usage §3.3's reading of its content.
fn byte_sequence(&mut self) -> R<()> {
self.eat(b':')?;
let rest = &self.b[self.i..];
let end = rest.iter().position(|&c| c == b':').ok_or(())?;
let content = &rest[..end];
self.i += end + 1;
let alphabet = content.iter().take_while(|&&c| c.is_ascii_alphanumeric() || c == b'+' || c == b'/').count();
let padding = &content[alphabet..];
if padding.len() > 2 || padding.iter().any(|&c| c != b'=') || alphabet % 4 == 1 {
return Err(());
}
Ok(())
}
/// §4.2.8.
fn boolean(&mut self) -> R<()> {
self.eat(b'?')?;
match self.peek() {
Some(b'0' | b'1') => {
self.i += 1;
Ok(())
}
_ => Err(()),
}
}
/// §4.2.9.
fn date(&mut self) -> R<()> {
self.eat(b'@')?;
if self.number()? { Ok(()) } else { Err(()) }
}
/// §4.2.10.
fn display_string(&mut self) -> R<()> {
self.eat(b'%')?;
self.eat(b'"')?;
let mut bytes = Vec::new();
while let Some(c) = self.peek() {
self.i += 1;
match c {
0x00..=0x1f | 0x7f..=0xff => return Err(()),
b'%' => {
let h = self.b.get(self.i..self.i + 2).ok_or(())?;
if !h.iter().all(|&x| x.is_ascii_digit() || (b'a'..=b'f').contains(&x)) {
return Err(());
}
let s = std::str::from_utf8(h).map_err(|_| ())?;
bytes.push(u8::from_str_radix(s, 16).map_err(|_| ())?);
self.i += 2;
}
b'"' => return String::from_utf8(bytes).map(|_| ()).map_err(|_| ()),
_ => bytes.push(c),
}
}
Err(())
}
}
/// Parse `text` as a Dictionary field value (§4.2), or `None` when it fails to parse.
pub fn parse_dictionary(text: &str) -> Option<Dictionary> {
if !text.is_ascii() {
return None;
}
let mut p = P { b: text.as_bytes(), i: 0 };
p.sp();
let d = p.dictionary().ok()?;
p.sp();
p.empty().then_some(d)
}
#[cfg(test)]
mod tests {
use super::*;
fn tok(s: &str) -> Member {
Member::Token(s.into())
}
#[test]
fn the_vocabulary_examples() {
// draft-ietf-aipref-vocab-08 §6.
let d = parse_dictionary("train-ai=y, search=n").unwrap();
assert_eq!(d["train-ai"], tok("y"));
assert_eq!(d["search"], tok("n"));
// §6.5.1: the last value applies; a boolean and a string are not tokens.
let d = parse_dictionary(r#"train-ai=y, train-ai, search=n, search="n""#).unwrap();
assert_eq!(d["train-ai"], Member::Other);
assert_eq!(d["search"], Member::Other);
// §6.5.2: parameters are disregarded, and only those of the selected value apply.
let d = parse_dictionary("train-ai;allow=n, train-ai=y").unwrap();
assert_eq!(d["train-ai"], tok("y"));
assert!(parse_dictionary(r#"train-ai;has;parameters="?";"#).is_none(), "a trailing ';' has no key");
assert!(parse_dictionary(r#"train-ai;has;parameters="?""#).is_some());
// §6.5.1: upper case fails the Dictionary.
assert!(parse_dictionary("Train-AI=y").is_none());
}
#[test]
fn rfc9651_edges() {
assert_eq!(parse_dictionary("").unwrap().len(), 0);
assert!(parse_dictionary("a=1,").is_none(), "a trailing comma");
assert!(parse_dictionary("a=1 ,\tb=2").is_some());
assert!(parse_dictionary("a=1 ").is_some());
assert!(parse_dictionary("\ta=1").is_none(), "only spaces lead");
assert!(parse_dictionary("a=(1 2);x, b=?0").is_some());
assert!(parse_dictionary("a=(1 2").is_none());
assert!(parse_dictionary("a=(1,2)").is_none());
assert!(parse_dictionary("a=1234567890123456").is_none(), "16 integer digits");
assert!(parse_dictionary("a=123456789012345").is_some());
assert!(parse_dictionary("a=1234567890123.1").is_none(), "13 digits before the point");
assert!(parse_dictionary("a=123456789012.123").is_some());
assert!(parse_dictionary("a=1.1234").is_none());
assert!(parse_dictionary("a=1.").is_none());
assert!(parse_dictionary("a=-").is_none());
assert!(parse_dictionary("a=@1700000000, [email protected]").is_none(), "a date is an integer");
assert!(parse_dictionary("a=@-1700000000").is_some());
assert!(parse_dictionary(r#"a=%"caf%c3%a9""#).is_some());
assert!(parse_dictionary(r#"a=%"caf%C3%A9""#).is_none(), "lower-case hex only");
assert!(parse_dictionary(r#"a=%"%c3""#).is_none(), "not UTF-8");
assert!(parse_dictionary(r#"a="x\y""#).is_none());
assert!(parse_dictionary(r#"a="x\\y\"z""#).is_some());
assert!(parse_dictionary("a=:aGVsbG8=:").is_some());
assert!(parse_dictionary("a=:aGVsbG8:").is_some(), "padding is not checked");
assert!(parse_dictionary("a=:aGVsb:").is_none(), "five characters");
assert!(parse_dictionary("a=:aGV=sbG8:").is_none());
assert!(parse_dictionary("a=:aGVsbG8===:").is_none());
assert!(parse_dictionary("a=:aGVsbG8").is_none());
assert!(parse_dictionary("a=?2").is_none());
assert!(parse_dictionary("a=*tok/en:x").unwrap()["a"] == tok("*tok/en:x"));
assert!(parse_dictionary("a=b, *c=d").is_some());
assert!(parse_dictionary("1a=b").is_none());
assert!(parse_dictionary("a=é").is_none());
}
}

View file

@ -0,0 +1,74 @@
//! Conformance corpus for AI usage preferences and consumption claims (`live-conformance/usage/`, extensions/ai-usage.md §10).
//!
//! wai_ai_usage_vectors gen <corpus-dir> write vectors.json, from fixed seeds
//! wai_ai_usage_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/ai_usage_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::ai_usage::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_ai_usage_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

@ -127,6 +127,9 @@ pub const DOMAIN_CLAIM_LINK: &[u8] = b"moq-jwp:claim-link\x01";
// names here so every existing caller of this module's API stays valid.
pub use crate::jwp_hash::{content_hash as object_content_hash, DOMAIN_OBJECT};
/// cbindgen:ignore
pub mod consumption;
pub use consumption::{ConsumptionClaim, UsageEntry, Vocabulary};
pub mod session;
pub use session::{
DecoderEntry, Missed, ObjectSeen, SessionClaim, SessionEnergy, SessionHost, SessionPath, StageSetRef, Switch,
@ -383,6 +386,19 @@ pub enum Reason {
/// A `stage` claim the session names counts units the session's outputs do not hold
/// at its stages.
SessionStageDisagrees,
/// A `consumption` claim's `objects` are not in strictly ascending order.
ConsumptionObjectsUnordered,
/// A `consumption` claim's `subject` is not the first of its `objects`.
ConsumptionSubjectMismatch,
/// A `consumption` claim's `usage` entries are not in strictly ascending order of
/// vocabulary.
ConsumptionUsageUnordered,
/// A `consumption` claim names a term its vocabulary does not define.
ConsumptionTermUnknown,
/// A `consumption` claim's `seen` digests are not in strictly ascending order.
ConsumptionSeenUnordered,
/// A `consumption` claim's `signer_id` is not the `did:key` of its `signer_pubkey`.
ConsumptionSignerNotPrincipal,
}
impl Reason {
@ -487,6 +503,12 @@ impl Reason {
Reason::SessionUnitOutsideObject => "session_unit_outside_object",
Reason::SessionClassicalMismatch => "session_classical_mismatch",
Reason::SessionStageDisagrees => "session_stage_disagrees",
Reason::ConsumptionObjectsUnordered => "consumption_objects_unordered",
Reason::ConsumptionSubjectMismatch => "consumption_subject_mismatch",
Reason::ConsumptionUsageUnordered => "consumption_usage_unordered",
Reason::ConsumptionTermUnknown => "consumption_term_unknown",
Reason::ConsumptionSeenUnordered => "consumption_seen_unordered",
Reason::ConsumptionSignerNotPrincipal => "consumption_signer_not_principal",
}
}
}
@ -557,6 +579,7 @@ pub enum ClaimClass {
Stage,
DeliveryGap,
ReconstructSession,
Consumption,
}
/// The class of `group` and `object` receipts. A claim naming it is rejected.
@ -575,6 +598,7 @@ impl ClaimClass {
ClaimClass::Stage => "stage",
ClaimClass::DeliveryGap => "delivery-gap",
ClaimClass::ReconstructSession => "reconstruct-session",
ClaimClass::Consumption => "consumption",
}
}
@ -590,6 +614,7 @@ impl ClaimClass {
"stage" => Some(ClaimClass::Stage),
"delivery-gap" => Some(ClaimClass::DeliveryGap),
"reconstruct-session" => Some(ClaimClass::ReconstructSession),
"consumption" => Some(ClaimClass::Consumption),
_ => None,
}
}
@ -610,7 +635,8 @@ impl ClaimClass {
| ClaimClass::BindingRecovery
| ClaimClass::Stage
| ClaimClass::DeliveryGap
| ClaimClass::ReconstructSession => t == SubjectType::Object,
| ClaimClass::ReconstructSession
| ClaimClass::Consumption => t == SubjectType::Object,
}
}
}
@ -1572,6 +1598,7 @@ pub enum ClaimBody {
Stage(StageClaim),
DeliveryGap(DeliveryGapClaim),
ReconstructSession(SessionClaim),
Consumption(ConsumptionClaim),
}
/// The energy body's members, in the order step 2 reads them.
@ -1665,6 +1692,7 @@ impl ClaimBody {
ClaimBody::Stage(_) => ClaimClass::Stage,
ClaimBody::DeliveryGap(_) => ClaimClass::DeliveryGap,
ClaimBody::ReconstructSession(_) => ClaimClass::ReconstructSession,
ClaimBody::Consumption(_) => ClaimClass::Consumption,
}
}
@ -1821,6 +1849,7 @@ impl ClaimBody {
m.insert("observed_at".into(), g.observed_at.clone().into());
}
ClaimBody::ReconstructSession(c) => return c.to_value(),
ClaimBody::Consumption(c) => return c.to_value(),
}
Value::Object(m)
}
@ -2044,6 +2073,7 @@ impl ClaimBody {
ClaimBody::DeliveryGap(DeliveryGapClaim { track_alias, group_id, object_id, reason, observed_at })
}
ClaimClass::ReconstructSession => ClaimBody::ReconstructSession(SessionClaim::read(m)?),
ClaimClass::Consumption => ClaimBody::Consumption(ConsumptionClaim::read(m)?),
ClaimClass::BindingRecovery => {
members(m, RECOVERY_MEMBERS)?;
ClaimBody::BindingRecovery(BindingRecoveryClaim {
@ -2177,11 +2207,18 @@ impl ClaimBody {
Ok(())
}
ClaimBody::ReconstructSession(c) => c.check_values(),
ClaimBody::Consumption(c) => c.check_values(),
}
}
/// The class rules (step 4), in the order §3.1 lists them.
fn check_class_rules(&self, subject_type: SubjectType, subject: &[u8; 32]) -> Result<(), Reason> {
fn check_class_rules(
&self,
subject_type: SubjectType,
subject: &[u8; 32],
signer_pubkey: &[u8; 32],
signer_id: &str,
) -> Result<(), Reason> {
match self {
ClaimBody::Measurement(c) => {
let class = c.figure.check_rules(subject_type)?;
@ -2275,6 +2312,7 @@ impl ClaimBody {
// against the object receipt at its coordinates at step 6.
ClaimBody::DeliveryGap(_) => Ok(()),
ClaimBody::ReconstructSession(c) => c.rules(),
ClaimBody::Consumption(c) => c.rules(subject, signer_pubkey, signer_id),
}
}
}
@ -2439,7 +2477,9 @@ impl ClaimReceipt {
sig: [0u8; 64],
};
r.check_shape()?;
r.body.check_class_rules(r.subject_type, &r.subject).map_err(|e| Rejection::at(4, e))?;
r.body
.check_class_rules(r.subject_type, &r.subject, &r.signer_pubkey, &r.signer_id)
.map_err(|e| Rejection::at(4, e))?;
r.sig = signer.sign(&r.signing_input()?).to_bytes();
Ok(r)
}
@ -2529,7 +2569,7 @@ impl ClaimReceipt {
crate::sig::verify_strict(&self.signer_pubkey, &self.signing_input()?, &self.sig)
.map_err(|e| Rejection::at(3, Reason::of_signature(e)))?;
self.body
.check_class_rules(self.subject_type, &self.subject)
.check_class_rules(self.subject_type, &self.subject, &self.signer_pubkey, &self.signer_id)
.map_err(|r| Rejection::at(4, r))
}

View file

@ -0,0 +1,236 @@
//! `consumption` (jwp-receipts §2.4.13; `extensions/ai-usage.md` §6): an agent's statement of
//! the objects it consumed, the use it made of them in each AI usage vocabulary it answers,
//! the usage statements it saw and the room grant it held.
//!
//! This file holds steps 2 and 4 of jwp-receipts §3.1: the body's members and their types,
//! and the class rules of ai-usage §6.2. Step 6, the check against the objects, the
//! statements and the room, is `crate::ai_usage`'s, which holds them.
use super::{get_opt_hash, hex, members, unhex, Reason};
use serde_json::{Map, Value};
/// The body's members, in the order step 2 reads them (ai-usage §6.1).
const CONSUMPTION_MEMBERS: &[&str] = &["objects", "usage", "seen", "grant"];
/// A `usage` entry's members.
const USAGE_MEMBERS: &[&str] = &["vocabulary", "term"];
/// The most objects one claim names.
///
/// cbindgen:ignore
pub const MAX_OBJECTS: usize = 1024;
/// The most statements one claim says it saw.
///
/// cbindgen:ignore
pub const MAX_SEEN: usize = 64;
/// The longest term, in bytes.
///
/// cbindgen:ignore
pub const MAX_TERM: usize = 255;
/// The three vocabularies a usage statement carries (ai-usage §3.1), in the order a claim
/// lists them: ascending by name.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Vocabulary {
/// `draft-ietf-aipref-vocab-08`.
Aipref,
/// The Training and Data Mining Assertion, version 1.1.
Cawg,
/// The TDM Reservation Protocol.
Tdmrep,
}
impl Vocabulary {
/// cbindgen:ignore
pub const ALL: [Vocabulary; 3] = [Vocabulary::Aipref, Vocabulary::Cawg, Vocabulary::Tdmrep];
pub fn label(self) -> &'static str {
match self {
Vocabulary::Aipref => "aipref",
Vocabulary::Cawg => "cawg",
Vocabulary::Tdmrep => "tdmrep",
}
}
pub fn from_label(s: &str) -> Option<Vocabulary> {
Vocabulary::ALL.into_iter().find(|v| v.label() == s)
}
/// Whether `term` is a term of this vocabulary (ai-usage §6.2 rule 4).
pub fn has_term(self, term: &str) -> bool {
match self {
Vocabulary::Aipref => AIPREF_LABELS.contains(&term),
Vocabulary::Tdmrep => term == TDM_TERM,
Vocabulary::Cawg => CAWG_KEYS.contains(&term) || (cawg_key_form(term) && !term.starts_with("cawg.")),
}
}
}
/// The usage category labels of `draft-ietf-aipref-vocab-08` §6.1, Table 1.
pub const AIPREF_LABELS: [&str; 3] = ["train-ai", "ai-use", "search"];
/// The one term of the TDM Reservation Protocol: the property that governs mining.
pub const TDM_TERM: &str = "tdm-reservation";
/// The four entries the Training and Data Mining Assertion 1.1 defines (§3.1 there).
pub const CAWG_KEYS: [&str; 4] = ["cawg.data_mining", "cawg.ai_inference", "cawg.ai_generative_training", "cawg.ai_training"];
/// ai-usage §3.4: 1 to 255 bytes of components separated by single dots, each one or more
/// of `a`–`z`, `A`–`Z`, `0`–`9`, `_` and `-`.
pub fn cawg_key_form(k: &str) -> bool {
!k.is_empty()
&& k.len() <= 255
&& k.split('.').all(|c| !c.is_empty() && c.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'))
}
/// One `usage` entry: the use, as a vocabulary names it, or `None` for a use outside it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UsageEntry {
pub vocabulary: Vocabulary,
pub term: Option<String>,
}
/// A `consumption` body.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConsumptionClaim {
/// The content hashes of the objects consumed.
pub objects: Vec<[u8; 32]>,
/// The use, in each vocabulary the agent answers.
pub usage: Vec<UsageEntry>,
/// The digests of the statements that governed the objects.
pub seen: Vec<[u8; 32]>,
/// The digest of the room grant the agent consumed under.
pub grant: Option<[u8; 32]>,
}
fn strictly_ascending<T: Ord>(v: &[T]) -> bool {
v.windows(2).all(|w| w[0] < w[1])
}
impl ConsumptionClaim {
pub(super) fn to_value(&self) -> Value {
let mut m = Map::new();
m.insert("objects".into(), Value::Array(self.objects.iter().map(|h| hex(h).into()).collect()));
m.insert(
"usage".into(),
Value::Array(
self.usage
.iter()
.map(|u| {
let mut e = Map::new();
e.insert("vocabulary".into(), u.vocabulary.label().into());
e.insert("term".into(), u.term.clone().map_or(Value::Null, Value::String));
Value::Object(e)
})
.collect(),
),
);
m.insert("seen".into(), Value::Array(self.seen.iter().map(|h| hex(h).into()).collect()));
m.insert("grant".into(), self.grant.map_or(Value::Null, |g| hex(&g).into()));
Value::Object(m)
}
/// Step 2: the members — missing, then unknown — then each member's type in the table's
/// order, an entry's members and types where the table reaches it.
pub(super) fn read(m: &Map<String, Value>) -> Result<ConsumptionClaim, Reason> {
members(m, CONSUMPTION_MEMBERS)?;
let ft = Reason::FieldType;
let hashes = |k: &str, least: usize, most: usize| -> Result<Vec<[u8; 32]>, Reason> {
let a = m[k].as_array().ok_or(ft)?;
if a.len() < least || a.len() > most {
return Err(ft);
}
a.iter().map(|v| v.as_str().and_then(unhex).and_then(|b| b.try_into().ok()).ok_or(ft)).collect()
};
let objects = hashes("objects", 1, MAX_OBJECTS)?;
let entries = m["usage"].as_array().ok_or(ft)?;
if entries.is_empty() || entries.len() > Vocabulary::ALL.len() {
return Err(ft);
}
let usage = entries
.iter()
.map(|v| {
let e = v.as_object().ok_or(ft)?;
members(e, USAGE_MEMBERS)?;
let vocabulary = Vocabulary::from_label(e["vocabulary"].as_str().ok_or(ft)?).ok_or(ft)?;
let term = match &e["term"] {
Value::Null => None,
Value::String(s) if !s.is_empty() && s.len() <= MAX_TERM => Some(s.clone()),
_ => return Err(ft),
};
Ok(UsageEntry { vocabulary, term })
})
.collect::<Result<Vec<_>, Reason>>()?;
let seen = hashes("seen", 0, MAX_SEEN)?;
let grant = get_opt_hash(m, "grant")?;
Ok(ConsumptionClaim { objects, usage, seen, grant })
}
/// The counts and term lengths step 2 checks, for a body built field by field.
pub(super) fn check_values(&self) -> Result<(), Reason> {
let ft = Reason::FieldType;
if self.objects.is_empty() || self.objects.len() > MAX_OBJECTS || self.seen.len() > MAX_SEEN {
return Err(ft);
}
if self.usage.is_empty() || self.usage.len() > Vocabulary::ALL.len() {
return Err(ft);
}
if self.usage.iter().any(|u| u.term.as_ref().is_some_and(|t| t.is_empty() || t.len() > MAX_TERM)) {
return Err(ft);
}
Ok(())
}
/// Step 4: ai-usage §6.2's rules, in its order.
pub(super) fn rules(&self, subject: &[u8; 32], signer_pubkey: &[u8; 32], signer_id: &str) -> Result<(), Reason> {
if !strictly_ascending(&self.objects) {
return Err(Reason::ConsumptionObjectsUnordered);
}
if self.objects.first() != Some(subject) {
return Err(Reason::ConsumptionSubjectMismatch);
}
let vocabularies: Vec<Vocabulary> = self.usage.iter().map(|u| u.vocabulary).collect();
if !strictly_ascending(&vocabularies) {
return Err(Reason::ConsumptionUsageUnordered);
}
if self.usage.iter().any(|u| u.term.as_ref().is_some_and(|t| !u.vocabulary.has_term(t))) {
return Err(Reason::ConsumptionTermUnknown);
}
if !strictly_ascending(&self.seen) {
return Err(Reason::ConsumptionSeenUnordered);
}
if crate::signer_id::did_key(signer_pubkey) != signer_id {
return Err(Reason::ConsumptionSignerNotPrincipal);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terms_are_the_vocabularies_own() {
for t in AIPREF_LABELS {
assert!(Vocabulary::Aipref.has_term(t));
}
assert!(!Vocabulary::Aipref.has_term("inference"));
assert!(!Vocabulary::Aipref.has_term("train-genai"));
assert!(Vocabulary::Tdmrep.has_term("tdm-reservation"));
assert!(!Vocabulary::Tdmrep.has_term("tdm-policy"));
for k in CAWG_KEYS {
assert!(Vocabulary::Cawg.has_term(k));
}
assert!(Vocabulary::Cawg.has_term("org.example.summaries"));
assert!(!Vocabulary::Cawg.has_term("cawg.ai_summaries"), "the prefix is reserved");
assert!(!Vocabulary::Cawg.has_term("org..example"));
assert!(!Vocabulary::Cawg.has_term("org example"));
assert!(!Vocabulary::Cawg.has_term(""));
assert!(!Vocabulary::Cawg.has_term(&"a".repeat(256)));
assert!(Vocabulary::Cawg.has_term(&"a".repeat(255)));
}
#[test]
fn vocabularies_order_by_name() {
let mut v = Vocabulary::ALL.map(|x| x.label());
v.sort();
assert_eq!(v, Vocabulary::ALL.map(|x| x.label()));
}
}

View file

@ -224,6 +224,7 @@ 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";
pub const CAP_AI_USAGE: &str = "wai.ai.usage";
/// One row per string SPEC §5 registers that is a payload, companion or record:
/// `(string, SPEC section, edition, registered)`.
@ -316,6 +317,7 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[
(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_AI_USAGE, 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"),
@ -371,6 +373,7 @@ const RECORDS: &[&str] = &[
CAP_LIVE_PROVENANCE,
CAP_EDGE_DERIVATION,
CAP_MEDIA_SYNTHESIS,
CAP_AI_USAGE,
];
/// A named identifier SPEC uses that has the shape of a capability string but is
@ -526,6 +529,7 @@ const ENERGY_STEERING: &str = "extensions/energy-steering.md";
const LIVE_MODERATION: &str = "extensions/live-moderation.md";
const CONFIDENTIALITY: &str = "extensions/confidentiality.md";
const FORENSIC_WATERMARK: &str = "extensions/forensic-watermark.md";
const AI_USAGE: &str = "extensions/ai-usage.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";
@ -541,8 +545,8 @@ const fn wire(name: &'static str, kind: WireKind, document: &'static str, sectio
/// (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, of live
/// provenance, of the deriving edge and of synthesis, in document order. The export lists them
/// sorted by name and kind.
/// provenance, of the deriving edge, of synthesis and of AI usage, 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),
@ -637,6 +641,9 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
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),
wire("WAU1", WireKind::Magic, AI_USAGE, "§4.2", WireStatus::Defined),
wire("wai_ai_usage", WireKind::Catalog, MOQ_FORMAT, "§4", WireStatus::Defined),
wire("WAI-Usage-Claim", WireKind::HttpHeader, AI_USAGE, "§7.3", WireStatus::Proposed),
];
/// A string with the shape of a capability that the reference implementation uses

View file

@ -678,6 +678,11 @@ fn step6(
.unwrap_or(passed[0].group.signer_pubkey);
claim.check_recovery_against(&d, &publisher, matchers).map_err(c)
}
// §2.4.13: its objects, the statements that govern them and its room are checked
// together (ai-usage §6.3), never one object at a time.
ClaimBody::Consumption(_) => {
Err(ReceiptRefusal::ContextNotHeld)
}
// §2.4.12: its outputs, received objects and envelopes are the sink's, not
// served beside an object.
ClaimBody::ReconstructSession(_) => Err(ReceiptRefusal::ContextNotHeld),

View file

@ -509,6 +509,15 @@ pub mod png_still;
/// cbindgen:ignore
#[cfg(feature = "derive_edge")]
pub mod derive_edge;
// AI usage preferences and consumption claims (extensions/ai-usage.md): `wai.ai.usage` — the
// usage statement carrying draft-ietf-aipref-vocab-08, the TDM Reservation Protocol and the
// Training and Data Mining Assertion verbatim, bound by an envelope's receipt, a room's WAU1
// record and a grant's terms; the HTTP fields and catalog field checked against it; the one
// precedence rule; the consumption claim's context; and the gate. Rust-only, like the live
// modules: its constants must not become macros in include/wai.h.
/// cbindgen:ignore
#[cfg(feature = "ai_usage")]
pub mod ai_usage;
// Timed text and live captions (extensions/timed-text.md): `wai.text.timed` — the cue model
// both WebVTT and the IMSC1 Text Profile express, live caption revisions as `caption`
// events of `wai.live.events` with the WTX1 annotation, and the exact mappings onto WebVTT

View file

@ -0,0 +1,61 @@
//! `wai.ai.usage` (extensions/ai-usage.md): the committed corpus `live-conformance/usage/`
//! 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 §9 lists exactly the reasons the reference
//! reports. The generator itself asserts that the corpus gives every one of them.
#![cfg(feature = "ai_usage")]
use std::collections::BTreeMap;
use std::path::PathBuf;
use wai::ai_usage::{corpus, CODES};
fn wai_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn committed() -> BTreeMap<String, String> {
let dir = wai_dir().join("live-conformance/usage");
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_ai_usage_vectors gen`");
}
#[test]
fn every_case_gets_its_stated_outcome() {
let fails = corpus::verify(&committed());
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
/// §9 of the extension lists exactly the codes `CODES` has, each once, in order.
#[test]
fn the_extension_lists_the_reasons() {
let text = std::fs::read_to_string(wai_dir().join("extensions/ai-usage.md")).expect("the extension");
let at = text.find("\n## 9. Reasons").expect("a §9");
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> = CODES.iter().map(|c| c.to_string()).collect();
assert_eq!(listed, want, "§9's table and CODES differ");
}
/// jwp-receipts §3.1's table lists the six class-rule codes of a `consumption` claim, at step 4.
#[test]
fn jwp_receipts_lists_the_class_rules() {
let text = std::fs::read_to_string(wai_dir().join("extensions/jwp-receipts.md")).expect("jwp-receipts");
for code in CODES.iter().filter(|c| c.starts_with("consumption_") && **c != "consumption_object_unknown") {
assert!(text.contains(&format!("| 4 | `{code}` |")), "jwp-receipts §3.1 does not list {code} at step 4");
}
}

View file

@ -224,7 +224,7 @@ fn every_decoded_output_states_a_sha256() {
("ops-conformance", "integer operators: every operator, stage and graph vector, the lower_* decodes of the sets under sets/ among them, states its output integers in full or its refusal code, never a digest of them; certificates/ states each lowered set's SHA-256 (a parameter-set identity) and holds the lowered graphs as bytes, compared byte for byte; the output digests a capability decoded through its lowering reproduces (integer-operators §14.4) are stated by that capability's own corpus"),
("signer-conformance", "key logs, grants and statuses: signed documents, no decoded output"),
("edge-conformance", "derivation records of a deriving edge: each replicate-tier output is recomputed and compared byte for byte by its verifier, and every output is named by its content hash in the record"),
("live-conformance", "live events, rooms, takes, compositions, recordings, captions, metering, steering, moderation, watermark sessions and ads: signed records, commitments, packages, clips, cues and their renderings, per-hop records, ledgers and session receipts with their units and joules recomputed, steering policies and decisions with each pathway's evidence and the steering manifests recomputed byte for byte, review policies, decisions, notices and the items' timelines with each statement-of-reasons export written byte for byte, watermark tokens, session records, recovered sequences and disclosures, ad policies, breaks with their interstitial tags, events and asset lists written byte for byte and their impression receipts, and their verdicts, and layouts and mixes stated in full, rectangle for rectangle and sample for sample; nothing decoded against a parameter set"),
("live-conformance", "live events, rooms, takes, compositions, recordings, captions, metering, steering, moderation, watermark sessions, ads and AI usage: signed records, commitments, packages, clips, cues and their renderings, per-hop records, ledgers and session receipts with their units and joules recomputed, steering policies and decisions with each pathway's evidence and the steering manifests recomputed byte for byte, review policies, decisions, notices and the items' timelines with each statement-of-reasons export written byte for byte, watermark tokens, session records, recovered sequences and disclosures, ad policies, breaks with their interstitial tags, events and asset lists written byte for byte and their impression receipts, usage statements with every term's preference, receipted envelopes (whose payloads are opaque test bytes no case decodes), room usage records, HTTP fields, catalogs, consumption claims and gate decisions, and their verdicts, and layouts and mixes stated in full, rectangle for rectangle and sample for sample; nothing decoded against a parameter set"),
("http-conformance", "objects and head proofs for transport: no case decodes a payload, and the component a fetch case picks is stated by its SHA-256"),
("cache-conformance", "cache rules: a driver, its controls and their reports, with no objects of its own"),
("byte-exact-conformance", "goldens over tests/vectors, whose sidecars state the SHA-256"),