wai: live metering, a room session's units and joules per hop (L9) #317

Merged
dcharlot merged 21 commits from wai/live-metering into main 2026-10-18 19:03:46 -04:00
35 changed files with 10959 additions and 28 deletions

View file

@ -84,6 +84,10 @@ on:
# tests/timed_text.rs requires this extension's §12 to list the codes the reference
# reports, and the registry's wire identifiers test reads its sections.
- 'wai/extensions/timed-text.md'
# tests/live_metering.rs requires this extension's §9 to list the codes the reference
# reports, live_metering_verify.py reads that table, and the registry's wire identifiers
# test reads its sections.
- 'wai/extensions/live-metering.md'
- 'wai/wai-web/**'
# The audio provenance check below reads the corpora above with this tool.
- 'wai/tools/wai_audio_provenance_check.py'
@ -139,6 +143,10 @@ on:
# tests/timed_text.rs requires this extension's §12 to list the codes the reference
# reports, and the registry's wire identifiers test reads its sections.
- 'wai/extensions/timed-text.md'
# tests/live_metering.rs requires this extension's §9 to list the codes the reference
# reports, live_metering_verify.py reads that table, and the registry's wire identifiers
# test reads its sections.
- 'wai/extensions/live-metering.md'
- 'wai/wai-web/**'
# The audio provenance check below reads the corpora above with this tool.
- 'wai/tools/wai_audio_provenance_check.py'
@ -803,7 +811,8 @@ jobs:
# live_events_verify.py (the live-events corpus), and live_rooms_verify.py (the
# live-rooms corpus), and capture_take_verify.py (the capture-binding corpus), and
# live_compose_verify.py (the live-compose corpus), live_record_verify.py (the
# live-recording corpus), and timed_text_verify.py (the timed-text corpus).
# live-recording corpus), timed_text_verify.py (the timed-text corpus), and
# live_metering_verify.py (the live-metering 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:
@ -1032,6 +1041,27 @@ jobs:
(cd wai-rs && cargo test --no-default-features --features timed_text --lib timed_text::)
(cd wai-rs && cargo test --no-default-features --features timed_text --test timed_text)
python3 -I live-conformance/timed_text_verify.py live-conformance/captions
# wai.live.metering (extensions/live-metering.md): the reference module's unit tests
# (records and ledgers round-trip, the energy rules, the size bound at its limit, a
# total of two classes told from a wrong one, funds rounding, the CMCD event-timeline
# record and the application/cmcd batch, a ledger entry's audit path) and
# tests/live_metering.rs (the committed corpus regenerates byte for byte from its fixed
# seeds and the reference relay's recorded record, wai-rs gives every vector its stated
# outcome, every reason of §9 refuses a vector, the large audience's relay record is the
# lesson's size, and §9 lists exactly the reference's codes); then the corpus's second
# implementation, live_metering_verify.py, written from the extension's text and sharing
# no code with wai-rs, which verifies each room with live_rooms_verify.py, each take with
# capture_take_verify.py and each composition receipt with live_compose_verify.py,
# recomputes every unit, total and grant's consumption itself, and requires every
# mutated copy of the corpus to fail; standard library only, run isolated (-I).
- name: live metering · wai-rs and live_metering_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features live_metering --lib live_metering::)
(cd wai-rs && cargo test --no-default-features --features live_metering --test live_metering)
python3 -I live-conformance/live_metering_verify.py live-conformance/metering
# The quantum corpus's second implementation (quantum-sim extension), integers only:
# it derives the circuit/1 phase table by its recurrence and the circuit2 table by an
# interval-certified procedure, and requires both literal tables and their digests,
@ -1258,6 +1288,12 @@ jobs:
working-directory: wai/wai-rs
run: cargo test --lib --no-default-features --features c2pa_emit c2pa_emit
# A room-session receipt (extensions/live-metering.md §7) in a real signed manifest:
# energy_kwh only where one class covers the session whole, the companion always.
- name: Emit a room-session receipt, sign, read back, validate
working-directory: wai/wai-rs
run: cargo test --lib --no-default-features --features c2pa_emit,live_metering c2pa_emit::tests::a_session
# The feature must stay optional. If `provenance` ever pulls c2pa in, every
# consumer that wanted only the binding types inherits a large dependency tree
# they did not ask for, and the "off by default" claim in the extension is false.

View file

@ -57,6 +57,32 @@ object framing. The crate's version moves to 0.3.0 because its public API change
- `tests/vectors/draft-22/`: a corpus of -22 vectors, good and negative, generated
by `tests/draft22_vectors.rs`, and an independent decoder that checks it in CI.
### Added — `joule-moq-relay`: the per-hop session record of a WAI room
The relay signs WAI's per-hop session record, `WMH1` (`wai/extensions/live-metering.md`
§3), of a room from its session summaries (`metering::hop_record`, `Relay::hop_record`):
for each track of the room its sessions carried, the objects and bytes in and out, the
objects dropped for their delivery timeout or still queued at the end with those whose
receipts it forwarded, the objects refused at the door and deduplicated; its subscriber
sessions' time by layer class, from the subscriptions each held; the events it delivered
(objects of Object ID above 0 on the room's events tracks); and a ledger of the room grants
its sessions ran under, by the token each presented in SETUP (`[[metering.grants]]` in the
configuration). Its own energy is the unmetered marker. The record holds nothing per object
or per viewer: media tracks are counted one by one, events tracks and every other track of
the room together, subscribers by layer class.
- The session summary carries what the record is made from, and its format is
`joule-moq-relay/session-summary/2`: per track, as a publisher, objects and bytes
received, refused at the door and deduplicated; per track, as a subscriber, fetched
bytes, objects still queued at the end and objects of Object ID above 0; the session's
layer spans per room; and the room grant its SETUP token maps to. A datagram's bytes
are now counted among a publisher's bytes received.
- `tests/room_metering.rs`: two publishers and three subscribers of one room, one
subscriber slow, over QUIC and WebTransport; the record verifies under the relay's key
and every count in it is the sum of the summaries' counts. Run with
`WAI_METERING_FIXTURE_DIR`, the test writes the record and its ledger, which WAI's
metering corpus holds as recorded.
### Added — `joule-moq-relay` 0.1.0: a `draft-ietf-moq-transport-22` relay
A new crate, library and binary, on `joule-moq`'s codec. Publishers send it tracks,

View file

@ -261,21 +261,56 @@ holds, for the session:
streams, objects dropped for their delivery timeout and how many of those were on a
track whose receipts track the session also held, subgroups reset after their FIN,
objects served by fetch or fill, objects still queued at the end, and the same per
track;
track, with each track's fetched bytes, objects still queued at the end and objects of
Object ID above 0;
- the spans of its layer class in each WAI room it subscribed to: the highest layer of the
room's media tracks it held, or 8 when it held none of them but other tracks of the room;
- as a publisher: objects and bytes received, objects refused at the door, objects
deduplicated;
deduplicated, and the same per track;
- `relay_energy`: `{"acquisition": "unmetered", "joules_micro": 0}`. No meter backs this
relay. The 0 is the unmetered marker of WAI `extensions/energy-measurement.md` §2, never
a measurement of zero joules, and must not be read or shown as one;
- `publisher_signed`: where the door verified an object's receipt, the publisher's signed
figure for producing it, summed under the acquisition class its leaf was signed with.
These are the publisher's statements about the objects, not the relay's energy.
These are the publisher's statements about the objects, not the relay's energy;
- `room_grant`, when the token the session presented in SETUP is one `[[metering.grants]]`
names: the room grant's digest and its ceilings.
The summary is relay-scoped telemetry (jwp-receipts §4.2), not a receipt of the publisher's.
Its format is `joule-moq-relay/session-summary/1`; the signature covers
Its format is `joule-moq-relay/session-summary/2`; the signature covers
`joule-moq-relay:session-summary\x01` followed by the summary's JSON bytes, and
`SignedSessionSummary::verify` checks it.
## The per-hop session record
`Relay::hop_record(room, t0_unix_us)` signs WAI's per-hop session record, `WMH1`
(`wai/extensions/live-metering.md` §3), of the room whose id is `room`, over the sessions
that ended, on the room clock of the room's `t0`. A track is the room's when its
namespace's first field is the room id in hexadecimal (WAI live-rooms §6.1); it is a media
track when its namespace ends `audio`, `video` or `screen` and its name is a layer `0` to
`7`, an events track when its namespace ends `events` and its name is `0`, and another
track of the room otherwise. The record holds:
- per media track and direction, and for the events tracks and the other tracks together:
the objects and bytes received and sent, the objects dropped for their delivery timeout,
refused at the door, deduplicated and still queued at the end, and those of the dropped
whose receipts the relay forwarded;
- its subscriber sessions, their microseconds of each layer class, and the events it
delivered to them;
- the root of its ledger, one entry for each room grant its sessions ran under (or none, for
sessions with no grant): the sessions, the bytes in and out and the subscriber time;
- its energy, the unmetered marker.
As a session closes, the relay ends its subscriptions one at a time, each when its request
stream fails; the moments between are classed by what the session still holds, so a
session whose media subscriptions end before its receipts or events ones has a span of
class 8, typically microseconds, at its end. `HopRecord::to_bytes` is the record and
`HopRecord::ledger_bytes` the ledger beside it. The
relay connects to no other hop, so every session of it that subscribes is a subscriber
session. Nothing in the record is per object or per viewer, so its size depends on the
room's published tracks and not on its audience. `tests/room_metering.rs` checks that the
record recomputes from the summaries.
## Configuration
`joule-moq-relay --config relay.toml`, or `joule-moq-relay --dev` for loopback with a

View file

@ -51,6 +51,15 @@ hold_ms = 2000
# key_path = "/etc/joule-moq-relay/summary-seed"
# out_path = "/var/log/joule-moq-relay/sessions.jsonl"
# The room grants tokens carry, for the per-hop session records of WAI rooms (an example;
# not a default): a session whose SETUP presents this token runs under this grant.
[[metering.grants]]
token_sha256 = "1df4b07d05b9b3b1115b4b6b22af319a2f99564a2fa2573a34896063c7513c10"
grant = "0000000000000000000000000000000000000000000000000000000000000001"
joules_ceiling_micro = 5000000
funds_ceiling = 500
currency = "EUR"
[auth]
required = false

View file

@ -34,6 +34,8 @@ pub struct RelayConfig {
pub summary: SummaryConfig,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub metering: MeteringConfig,
/// The New Session URI a GOAWAY names (§9.2); empty: reuse the current one.
#[serde(default)]
pub goaway_uri: String,
@ -279,6 +281,39 @@ pub struct Grant {
pub subscribe: bool,
}
/// The room grants the relay's tokens carry (WAI live-rooms §10.1), for its per-hop session
/// records (WAI live-metering §3.4): a session whose SETUP presents a token named here runs
/// under that grant. The relay holds the grant's digest and ceilings beside the token, as a
/// deployment copies them from the grant the room authority signed.
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MeteringConfig {
#[serde(default)]
pub grants: Vec<MeteredGrant>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MeteredGrant {
/// SHA-256 of the token value, hex, as an authorization grant names it.
pub token_sha256: String,
/// The room grant's digest, hex (WAI live-rooms §4.2).
pub grant: String,
/// The grant's joule ceiling, in microjoules; absent for none.
#[serde(default = "no_ceiling")]
pub joules_ceiling_micro: u64,
/// The grant's funds ceiling, in minor units of `currency`; absent for none.
#[serde(default = "no_ceiling")]
pub funds_ceiling: u64,
/// ISO 4217; empty when there is no funds ceiling.
#[serde(default)]
pub currency: String,
}
fn no_ceiling() -> u64 {
u64::MAX
}
impl RelayConfig {
pub fn from_toml(s: &str) -> Result<Self, String> {
toml::from_str(s).map_err(|e| e.to_string())
@ -296,6 +331,7 @@ impl RelayConfig {
receipts: ReceiptsConfig::default(),
summary: SummaryConfig::default(),
auth: AuthConfig::default(),
metering: MeteringConfig::default(),
goaway_uri: String::new(),
}
}

View file

@ -20,6 +20,10 @@
//! - **Session summaries** ([`summary`]): bytes and objects per subscriber, signed by the
//! relay at session end; its own energy is reported as unmetered, since no meter backs
//! it.
//! - **Per-hop session records** ([`metering`]): WAI's `WMH1` record of a room
//! (`live-metering.md` §3), signed by the relay from its summaries: per track the
//! objects and bytes in and out and the drops by reason, its subscribers' time by layer
//! class, the events it delivered, and a ledger of the room grants its sessions ran under.
//!
//! [`server::RelayServer`] binds the listeners; [`client::Client`] is a small client for
//! the tests and for tools.
@ -30,6 +34,7 @@ pub mod cert;
pub mod client;
pub mod config;
pub mod door;
pub mod metering;
pub mod relay;
pub mod sched;
pub mod server;

View file

@ -0,0 +1,457 @@
//! The relay's per-hop session record of a room: WAI's `WMH1` (`extensions/live-metering.md`
//! §3), signed by the relay from its session summaries.
//!
//! For each track of the room its sessions carried (a track whose namespace's first field is
//! the room id), the objects and bytes in and out, the objects dropped by reason with those
//! whose receipts it forwarded; its subscriber sessions' time by layer class, from the
//! subscriptions each held; the events it delivered; and a ledger, one entry for each room
//! grant its sessions ran under, by the token each presented in SETUP. Its own energy is the
//! unmetered marker, since no meter backs this relay. It connects to no other hop, so every
//! session of it that subscribes is a subscriber session (§3.3 there).
//!
//! The record is a batch: it holds no entry per object and none per viewer. Media tracks
//! are counted one by one, events tracks and every other track of the room together, and
//! subscribers by layer class, so its size does not grow with the audience.
use crate::summary::{SessionSummary, Tally};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use std::collections::BTreeMap;
/// The record's magic and version (WAI live-metering §3.1).
pub const MAGIC: &[u8; 4] = b"WMH1";
pub const VERSION: u8 = 1;
/// The domain the record's signature covers, before its bytes (§3.5).
pub const DOMAIN: &[u8] = b"wai:metering-hop\x01";
/// The domain of a ledger entry's leaf (§3.4).
pub const LEDGER_DOMAIN: &[u8] = b"wai:metering-ledger\x01";
/// The interior node of the ledger's tree (WAI staged-measurement Appendix C).
pub const NODE_DOMAIN: &[u8] = b"wai:out-node\x01";
/// The relay's role.
pub const ROLE_RELAY: u8 = 1;
/// The layer classes: layers 0 to 7, and 8 for a session on none of the room's media tracks.
pub const CLASSES: usize = 9;
pub const NO_MEDIA: u8 = 8;
/// A ceiling of none.
pub const NO_CEILING: u64 = u64::MAX;
/// A track entry's kind (§3.2).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Kind {
/// A media track: its layer and its digest.
Media { layer: u8, digest: [u8; 32] },
Events,
Other,
}
impl Kind {
fn code(&self) -> u8 {
match self {
Kind::Media { .. } => 1,
Kind::Events => 2,
Kind::Other => 3,
}
}
fn track(&self) -> [u8; 32] {
match self {
Kind::Media { digest, .. } => *digest,
_ => [0; 32],
}
}
}
fn sha256(parts: &[&[u8]]) -> [u8; 32] {
let mut c = ring::digest::Context::new(&ring::digest::SHA256);
for p in parts {
c.update(p);
}
c.finish().as_ref().try_into().expect("32 bytes")
}
/// A track rendered as `draft-ietf-moq-transport-22` §8.8 renders it, read as a track of a
/// WAI room (WAI live-rooms §6.1): its room, and whether it is a media track, an events track
/// or another track of the room. `None` for a track of no room.
pub fn classify(rendered: &str) -> Option<([u8; 32], Kind)> {
let (ns, name) = rendered.split_once("--")?;
let fields: Vec<&str> = ns.split('-').collect();
let room_hex = fields.first()?;
if room_hex.len() != 64 || !room_hex.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) {
return None;
}
let room: [u8; 32] = hex::decode(room_hex).ok()?.try_into().ok()?;
let decimal = |s: &str| (s == "0" || (!s.starts_with('0') && !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()))).then(|| s.parse::<u8>().ok()).flatten();
let kind = match (fields.len(), fields.get(2).copied(), decimal(name)) {
(3, Some("audio" | "video" | "screen"), Some(layer)) if layer <= 7 => Kind::Media { layer, digest: sha256(&[ns.as_bytes(), &[0], name.as_bytes()]) },
(3, Some("events"), Some(0)) => Kind::Events,
_ => Kind::Other,
};
Some((room, kind))
}
/// The layer class of a session holding the rendered tracks of one room (§3.3).
pub fn layer_class<'a>(tracks: impl Iterator<Item = &'a str>) -> u8 {
tracks.filter_map(|t| match classify(t)?.1 {
Kind::Media { layer, .. } => Some(layer),
_ => None,
}).max().unwrap_or(NO_MEDIA)
}
/// One track entry (§3.2).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Entry {
pub kind: u8,
pub track: [u8; 32],
pub dir: u8,
pub objects: u64,
pub bytes: u64,
pub dropped_timeout: u64,
pub dropped_refused: u64,
pub dropped_duplicate: u64,
pub dropped_closed: u64,
pub receipted: u64,
}
/// One ledger entry (§3.4).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LedgerEntry {
pub grant: [u8; 32],
pub joules_ceiling: u64,
pub funds_ceiling: u64,
pub currency: [u8; 3],
pub sessions: u32,
pub bytes_in: u64,
pub bytes_out: u64,
pub subscriber_us: [u64; CLASSES],
}
impl LedgerEntry {
fn anonymous() -> LedgerEntry {
LedgerEntry { grant: [0; 32], joules_ceiling: NO_CEILING, funds_ceiling: NO_CEILING, currency: [0; 3], sessions: 0, bytes_in: 0, bytes_out: 0, subscriber_us: [0; CLASSES] }
}
pub fn encode(&self) -> Vec<u8> {
let mut o = Vec::with_capacity(143);
o.extend_from_slice(&self.grant);
o.extend_from_slice(&self.joules_ceiling.to_le_bytes());
o.extend_from_slice(&self.funds_ceiling.to_le_bytes());
o.extend_from_slice(&self.currency);
o.extend_from_slice(&self.sessions.to_le_bytes());
o.extend_from_slice(&self.bytes_in.to_le_bytes());
o.extend_from_slice(&self.bytes_out.to_le_bytes());
for x in self.subscriber_us {
o.extend_from_slice(&x.to_le_bytes());
}
o
}
pub fn leaf(&self) -> [u8; 32] {
sha256(&[LEDGER_DOMAIN, &self.encode()])
}
}
/// RFC 6962's Merkle tree hash over leaf hashes, with the node of §2 there; the root of
/// none is 32 zero bytes (§3.4).
pub fn root(leaves: &[[u8; 32]]) -> [u8; 32] {
match leaves.len() {
0 => [0; 32],
1 => leaves[0],
n => {
let mut k = 1;
while k * 2 < n {
k *= 2;
}
sha256(&[NODE_DOMAIN, &root(&leaves[..k]), &root(&leaves[k..])])
}
}
}
/// A per-hop record, as the relay writes it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HopRecord {
pub room: [u8; 32],
pub hop: [u8; 32],
pub role: u8,
pub start: u64,
pub end: u64,
/// 0 with class tag 0: the unmetered marker.
pub joules: u64,
pub class_tag: u8,
pub entries: Vec<Entry>,
pub sessions: u32,
pub subscriber_us: [u64; CLASSES],
pub events: u64,
pub ledger: Vec<LedgerEntry>,
pub sig: [u8; 64],
}
impl HopRecord {
/// The record's bytes before its signature (§3.1).
pub fn body(&self) -> Vec<u8> {
let mut o = Vec::with_capacity(281 + 90 * self.entries.len());
o.extend_from_slice(MAGIC);
o.push(VERSION);
o.extend_from_slice(&self.room);
o.extend_from_slice(&self.hop);
o.push(self.role);
o.extend_from_slice(&self.start.to_le_bytes());
o.extend_from_slice(&self.end.to_le_bytes());
o.extend_from_slice(&self.joules.to_le_bytes());
o.push(self.class_tag);
o.extend_from_slice(&(self.entries.len() as u16).to_le_bytes());
for e in &self.entries {
o.push(e.kind);
o.extend_from_slice(&e.track);
o.push(e.dir);
for x in [e.objects, e.bytes, e.dropped_timeout, e.dropped_refused, e.dropped_duplicate, e.dropped_closed, e.receipted] {
o.extend_from_slice(&x.to_le_bytes());
}
}
o.extend_from_slice(&self.sessions.to_le_bytes());
for x in self.subscriber_us {
o.extend_from_slice(&x.to_le_bytes());
}
o.extend_from_slice(&self.events.to_le_bytes());
o.extend_from_slice(&(self.ledger.len() as u32).to_le_bytes());
o.extend_from_slice(&self.ledger_root());
o
}
pub fn ledger_root(&self) -> [u8; 32] {
root(&self.ledger.iter().map(LedgerEntry::leaf).collect::<Vec<_>>())
}
/// The record, signed.
pub fn to_bytes(&self) -> Vec<u8> {
let mut o = self.body();
o.extend_from_slice(&self.sig);
o
}
/// The ledger that travels beside it: its entries, concatenated.
pub fn ledger_bytes(&self) -> Vec<u8> {
self.ledger.iter().flat_map(LedgerEntry::encode).collect()
}
/// The record's digest: SHA-256 over its bytes before the signature (§3.5).
pub fn digest(&self) -> [u8; 32] {
sha256(&[&self.body()])
}
fn sign(mut self, key: &SigningKey) -> HopRecord {
let mut m = DOMAIN.to_vec();
m.extend_from_slice(&self.body());
self.sig = key.sign(&m).to_bytes();
self
}
/// Check the signature under the key the record names.
pub fn verify(&self) -> Result<(), String> {
let key = VerifyingKey::from_bytes(&self.hop).map_err(|e| e.to_string())?;
let mut m = DOMAIN.to_vec();
m.extend_from_slice(&self.body());
key.verify(&m, &Signature::from_bytes(&self.sig)).map_err(|e| e.to_string())
}
/// Read a record this module wrote: an unmetered one, as the relay signs.
pub fn decode(b: &[u8], ledger: &[u8]) -> Result<HopRecord, String> {
let mut p = 0usize;
let mut take = |n: usize| -> Result<&[u8], String> {
let s = b.get(p..p + n).ok_or("truncated")?;
p += n;
Ok(s)
};
let u64_of = |s: &[u8]| u64::from_le_bytes(s.try_into().expect("8"));
if take(4)? != MAGIC || take(1)? != [VERSION] {
return Err("not a WMH1 record of this version".into());
}
let room: [u8; 32] = take(32)?.try_into().expect("32");
let hop: [u8; 32] = take(32)?.try_into().expect("32");
let role = take(1)?[0];
let (start, end, joules) = (u64_of(take(8)?), u64_of(take(8)?), u64_of(take(8)?));
let class_tag = take(1)?[0];
if class_tag != 0 {
return Err("this reader reads the unmetered marker alone".into());
}
let n = u16::from_le_bytes(take(2)?.try_into().expect("2")) as usize;
let mut entries = Vec::with_capacity(n);
for _ in 0..n {
let kind = take(1)?[0];
let track: [u8; 32] = take(32)?.try_into().expect("32");
let dir = take(1)?[0];
let mut x = [0u64; 7];
for v in &mut x {
*v = u64_of(take(8)?);
}
entries.push(Entry { kind, track, dir, objects: x[0], bytes: x[1], dropped_timeout: x[2], dropped_refused: x[3], dropped_duplicate: x[4], dropped_closed: x[5], receipted: x[6] });
}
let sessions = u32::from_le_bytes(take(4)?.try_into().expect("4"));
let mut subscriber_us = [0u64; CLASSES];
for v in &mut subscriber_us {
*v = u64_of(take(8)?);
}
let events = u64_of(take(8)?);
let grants = u32::from_le_bytes(take(4)?.try_into().expect("4")) as usize;
let ledger_root: [u8; 32] = take(32)?.try_into().expect("32");
let sig: [u8; 64] = take(64)?.try_into().expect("64");
if p != b.len() {
return Err("bytes after the signature".into());
}
if ledger.len() != grants * 143 {
return Err("a ledger of another length".into());
}
let ledger: Vec<LedgerEntry> = ledger
.chunks(143)
.map(|c| {
let mut s = [0u64; CLASSES];
for (k, v) in s.iter_mut().enumerate() {
*v = u64_of(&c[71 + 8 * k..79 + 8 * k]);
}
LedgerEntry {
grant: c[..32].try_into().expect("32"),
joules_ceiling: u64_of(&c[32..40]),
funds_ceiling: u64_of(&c[40..48]),
currency: c[48..51].try_into().expect("3"),
sessions: u32::from_le_bytes(c[51..55].try_into().expect("4")),
bytes_in: u64_of(&c[55..63]),
bytes_out: u64_of(&c[63..71]),
subscriber_us: s,
}
})
.collect();
let r = HopRecord { room, hop, role, start, end, joules, class_tag, entries, sessions, subscriber_us, events, ledger, sig };
if r.ledger_root() != ledger_root {
return Err("the ledger is not the one the record signs".into());
}
Ok(r)
}
}
fn hex32(s: &str) -> Option<[u8; 32]> {
hex::decode(s).ok()?.try_into().ok()
}
/// The ledger entry a session's grant opens: the grant its SETUP token maps to, or none.
fn ledger_base(t: &Tally) -> LedgerEntry {
match &t.room_grant {
Some(g) => match hex32(&g.grant) {
Some(grant) => {
let mut currency = [0u8; 3];
if g.currency.len() == 3 {
currency.copy_from_slice(g.currency.as_bytes());
}
LedgerEntry { grant, joules_ceiling: g.joules_ceiling_micro, funds_ceiling: g.funds_ceiling, currency, ..LedgerEntry::anonymous() }
}
None => LedgerEntry::anonymous(),
},
None => LedgerEntry::anonymous(),
}
}
/// The relay's record of `room` over the sessions `summaries` describe, on the room clock
/// of `t0_unix_us` (live-rooms §3.1's `t0`), signed with `key`. Every count is the sum of the
/// summaries' counts, so the record recomputes from them.
pub fn hop_record(summaries: &[SessionSummary], room: [u8; 32], t0_unix_us: u64, key: &SigningKey) -> HopRecord {
let room_hex = hex::encode(room);
let clock = |unix_us: u64| unix_us.saturating_sub(t0_unix_us);
let mut entries: BTreeMap<(u8, [u8; 32], u8), Entry> = BTreeMap::new();
let mut ledger: BTreeMap<[u8; 32], LedgerEntry> = BTreeMap::new();
let (mut start, mut end) = (u64::MAX, 0u64);
let (mut sessions, mut subscriber_us, mut events) = (0u32, [0u64; CLASSES], 0u64);
for s in summaries {
let t = &s.tally;
let mut touched = false;
let (mut bytes_in, mut bytes_out) = (0u64, 0u64);
for (name, x) in &t.subscriber.tracks {
let Some((_, kind)) = classify(name).filter(|(r, _)| *r == room) else { continue };
touched = true;
let e = entries.entry((kind.code(), kind.track(), 2)).or_insert(Entry { kind: kind.code(), track: kind.track(), dir: 2, ..Entry::default() });
e.objects += x.objects_forwarded + x.fetch_objects;
e.bytes += x.object_bytes_forwarded + x.fetch_bytes;
e.dropped_timeout += x.dropped_delivery_timeout;
e.dropped_closed += x.undelivered_at_close;
e.receipted += x.dropped_receipts_track_subscribed;
bytes_out += x.object_bytes_forwarded + x.fetch_bytes;
}
for (name, x) in &t.publisher.tracks {
let Some((_, kind)) = classify(name).filter(|(r, _)| *r == room) else { continue };
touched = true;
let e = entries.entry((kind.code(), kind.track(), 1)).or_insert(Entry { kind: kind.code(), track: kind.track(), dir: 1, ..Entry::default() });
e.objects += x.objects_received;
e.bytes += x.object_bytes_received;
e.dropped_refused += x.refused_at_door;
e.dropped_duplicate += x.deduplicated;
bytes_in += x.object_bytes_received;
}
let spans: Vec<_> = t.subscriber.layer_spans.iter().filter(|x| x.room == room_hex).collect();
if !touched && spans.is_empty() {
continue;
}
let base = ledger_base(t);
let l = ledger.entry(base.grant).or_insert(base);
l.sessions += 1;
l.bytes_in += bytes_in;
l.bytes_out += bytes_out;
start = start.min(clock(s.opened_unix_ms * 1000));
end = end.max(clock((s.closed_unix_ms + 1) * 1000));
if !spans.is_empty() {
sessions += 1;
for x in &spans {
let us = x.to_unix_us - x.from_unix_us;
subscriber_us[x.class as usize] += us;
l.subscriber_us[x.class as usize] += us;
start = start.min(clock(x.from_unix_us));
end = end.max(clock(x.to_unix_us));
}
events += t.subscriber.tracks.iter().filter(|(n, _)| matches!(classify(n), Some((r, Kind::Events)) if r == room)).map(|(_, x)| x.incremental_objects).sum::<u64>();
}
}
if start >= end {
(start, end) = (0, 1);
}
HopRecord {
room,
hop: key.verifying_key().to_bytes(),
role: ROLE_RELAY,
start,
end,
joules: 0,
class_tag: 0,
entries: entries.into_values().collect(),
sessions,
subscriber_us,
events,
ledger: ledger.into_values().collect(),
sig: [0; 64],
}
.sign(key)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tracks_of_a_room_are_told_apart() {
let room = "ab".repeat(32);
let owner = "cd".repeat(32);
let (r, k) = classify(&format!("{room}-{owner}-video--2")).unwrap();
assert_eq!(r, [0xab; 32]);
assert!(matches!(k, Kind::Media { layer: 2, .. }));
assert_eq!(classify(&format!("{room}-{owner}-events--0")).unwrap().1, Kind::Events);
assert_eq!(classify(&format!("{room}-{owner}-video--0.2freceipts")).unwrap().1, Kind::Other);
assert_eq!(classify(&format!("{room}-{owner}-video--08")).unwrap().1, Kind::Other);
assert!(classify("live-a--cam").is_none());
assert_eq!(layer_class([format!("{room}-{owner}-audio--0"), format!("{room}-{owner}-video--1")].iter().map(String::as_str)), 1);
assert_eq!(layer_class([format!("{room}-{owner}-events--0")].iter().map(String::as_str)), NO_MEDIA);
}
/// The root is RFC 6962's: two leaves are one node.
#[test]
fn the_ledger_root() {
let (a, b) = ([1u8; 32], [2u8; 32]);
assert_eq!(root(&[]), [0; 32]);
assert_eq!(root(&[a]), a);
assert_eq!(root(&[a, b]), sha256(&[NODE_DOMAIN, &a, &b]));
assert_eq!(root(&[a, b, a]), sha256(&[NODE_DOMAIN, &root(&[a, b]), &a]));
}
}

View file

@ -9,7 +9,7 @@ use crate::cache::{self, Budget, CachedObject, Insert, TrackCache};
use crate::config::RelayConfig;
use crate::door::Door;
use crate::sched::{Item, LaneKey, LaneKind, ObjMeta, Op, Prio, SchedHandle};
use crate::summary::{class_label, SignedSessionSummary, Tally};
use crate::summary::{class_label, RoomGrantRef, SignedSessionSummary, Tally};
use crate::transport::{BoxSend, SharedConnection, TransportKind};
use crate::wire::{ReqTx, UniReader};
use bytes::Bytes;
@ -100,6 +100,8 @@ pub struct DownSub {
pub params: Mutex<SubParams>,
/// A subscription to a receipts track: no delivery timeout applies to it.
pub receipts_track: bool,
/// The tally of its session, which keeps the layer class it subscribes at.
pub tally: Arc<Mutex<Tally>>,
pub tx: ReqTx,
pub ended: watch::Sender<bool>,
}
@ -273,6 +275,10 @@ pub fn now_unix_ms() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
}
pub fn now_unix_us() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_micros() as u64).unwrap_or(0)
}
fn load_key(path: &std::path::Path) -> Result<SigningKey, String> {
let raw = std::fs::read(path).map_err(|e| format!("the summary key {}: {e}", path.display()))?;
let seed: [u8; 32] = if raw.len() == 32 {
@ -341,6 +347,27 @@ impl Relay {
self.inner.summaries.lock().unwrap().clone()
}
/// The room grant the tokens of a SETUP map to (the configuration's `metering.grants`),
/// for the session's tally.
pub fn metered_grant(&self, tokens: &[crate::auth::Resolved]) -> Option<RoomGrantRef> {
tokens.iter().filter(|(ty, _)| *ty == 0).find_map(|(_, value)| {
let h = hex::encode(crate::auth::sha256(value));
self.config().metering.grants.iter().find(|g| g.token_sha256.eq_ignore_ascii_case(&h)).map(|g| RoomGrantRef {
grant: g.grant.to_ascii_lowercase(),
joules_ceiling_micro: g.joules_ceiling_micro,
funds_ceiling: g.funds_ceiling,
currency: g.currency.clone(),
})
})
}
/// The relay's per-hop session record of `room` over the sessions that ended (WAI
/// live-metering §3), on the room clock of `t0_unix_us`, signed with the relay's key.
pub fn hop_record(&self, room: [u8; 32], t0_unix_us: u64) -> crate::metering::HopRecord {
let summaries: Vec<_> = self.summaries().into_iter().map(|s| s.summary).collect();
crate::metering::hop_record(&summaries, room, t0_unix_us, &self.inner.key)
}
pub fn stats(&self) -> RelayStats {
let s = &self.inner.stats;
let st = self.inner.state.lock().unwrap();
@ -446,7 +473,9 @@ impl Relay {
match track.owners.get(&(group, o.subgroup_id)) {
Some(owner) if *owner != o.upstream => {
if let Some(t) = session_tally {
t.lock().unwrap().publisher.deduplicated += 1;
let mut t = t.lock().unwrap();
t.publisher.deduplicated += 1;
t.published(&track.render).deduplicated += 1;
}
return false;
}
@ -498,6 +527,7 @@ impl Relay {
let meta = ObjMeta {
track: sub.render.clone(),
payload_len: o.object.payload.len() as u64,
object_id: oid,
receipts_subscribed: rsessions.contains(&sub.session),
fetched: false,
signed: o.signed,
@ -578,7 +608,7 @@ impl Relay {
let mut out = d.clone();
out.track_alias = sub.alias;
let prio = Prio { subscriber: p.subscriber_priority, publisher: priority, group_rank: Prio::group_rank(p.order, d.group_id), fill_rank: 1, mode_rank: 0, within: d.object_id };
let meta = ObjMeta { track: sub.render.clone(), payload_len: payload.len() as u64, receipts_subscribed: rsessions.contains(&sub.session), fetched: false, signed };
let meta = ObjMeta { track: sub.render.clone(), payload_len: payload.len() as u64, object_id: d.object_id, receipts_subscribed: rsessions.contains(&sub.session), fetched: false, signed };
sub.sched.push(LaneKey { sub: sub.id, kind: LaneKind::Datagram { group: d.group_id, object: d.object_id } }, Item { op: Op::Datagram { datagram: out }, prio, deadline, meta: Some(meta) });
}
self.charge_cache(&mut st, track_id, d.group_id, size);
@ -628,7 +658,11 @@ impl Relay {
match st.tracks.get_mut(&sub.track) {
None => Vec::new(),
Some(track) => {
let before = track.downs.len();
track.downs.retain(|d| d.id != sub.id);
if track.downs.len() != before {
sub.tally.lock().unwrap().unsubscribed(&sub.render, now_unix_us());
}
if track.downs.is_empty() {
let mut c = Vec::new();
track.upstreams.retain(|u| match &u.kind {
@ -712,6 +746,7 @@ impl Relay {
/// End a downstream subscription: after its queue drains, PUBLISH_DONE with the streams
/// it opened, then a FIN (§6.4.2.2, §9.9).
pub fn end_down(sub: Arc<DownSub>, status: PublishDoneCode) {
sub.tally.lock().unwrap().unsubscribed(&sub.render, now_unix_us());
let _ = sub.ended.send(true);
tokio::spawn(async move {
let n = sub.sched.drained(sub.id).await;

View file

@ -92,6 +92,8 @@ pub struct ObjMeta {
/// The track, rendered (§8.8).
pub track: Arc<str>,
pub payload_len: u64,
/// Its Object ID: above 0, on an MSF event timeline, an incremental update.
pub object_id: u64,
/// The session also subscribes to this track's receipts track.
pub receipts_subscribed: bool,
/// A fetch or fill object.
@ -613,7 +615,9 @@ impl Sched {
t.subscriber.wire_bytes_sent += wire;
if m.fetched {
t.subscriber.fetch_objects += 1;
t.track(&m.track).fetch_objects += 1;
let tr = t.track(&m.track);
tr.fetch_objects += 1;
tr.fetch_bytes += m.payload_len;
} else {
t.subscriber.objects_forwarded += 1;
t.subscriber.object_bytes_forwarded += m.payload_len;
@ -621,6 +625,9 @@ impl Sched {
tr.objects_forwarded += 1;
tr.object_bytes_forwarded += m.payload_len;
}
if m.object_id > 0 {
t.track(&m.track).incremental_objects += 1;
}
if let Some((j, class)) = m.signed {
let f = t.publisher_signed.entry(class.to_string()).or_default();
f.objects += 1;
@ -730,7 +737,13 @@ impl Sched {
fn close(&mut self) {
let left: u64 = self.lanes.values().map(|l| l.queue.iter().filter(|(_, i)| i.meta.is_some()).count() as u64).sum();
self.tally.lock().unwrap().subscriber.undelivered_at_close += left;
{
let mut t = self.tally.lock().unwrap();
t.subscriber.undelivered_at_close += left;
for m in self.lanes.values().flat_map(|l| l.queue.iter().filter_map(|(_, i)| i.meta.as_ref())) {
t.track(&m.track).undelivered_at_close += 1;
}
}
for lane in self.lanes.values_mut() {
if let Some(s) = lane.send.as_mut() {
s.reset(StreamResetCode::SESSION_CLOSED.0);

View file

@ -199,7 +199,8 @@ pub async fn serve(relay: Relay, conn: SharedConnection) {
}
Err(_) => return,
};
let tally = Arc::new(Mutex::new(Tally::default()));
let opening = Tally { room_grant: relay.metered_grant(&setup_tokens), ..Tally::default() };
let tally = Arc::new(Mutex::new(opening));
let sched = sched::spawn(conn.clone(), cfg.limits.write_concurrency, tally.clone());
let ctx = Arc::new(SessionCtx {
id,
@ -312,8 +313,16 @@ async fn datagram_loop(relay: Relay, ctx: Arc<SessionCtx>) {
None => continue,
};
{
let len = match &d.content {
joule_moq::draft22::data::DatagramContent::Payload(p) => p.len() as u64,
_ => 0,
};
let mut t = ctx.tally.lock().unwrap();
t.publisher.objects_received += 1;
t.publisher.object_bytes_received += len;
let tr = t.published(&joule_moq::draft22::names::render(&key.0, Some(&key.1)));
tr.objects_received += 1;
tr.object_bytes_received += len;
}
relay.inner.stats.objects_in.fetch_add(1, Ordering::Relaxed);
if relay.inner.door.checks(&key.0) && relay.inner.door.media_of(&key.1).is_none() {
@ -339,7 +348,11 @@ async fn datagram_loop(relay: Relay, ctx: Arc<SessionCtx>) {
fn refused(relay: &Relay, ctx: &SessionCtx, key: &TrackKey, group: u64, object: u64, why: &str) {
relay.inner.stats.refused_at_door.fetch_add(1, Ordering::Relaxed);
ctx.tally.lock().unwrap().publisher.refused_at_door += 1;
{
let mut t = ctx.tally.lock().unwrap();
t.publisher.refused_at_door += 1;
t.published(&joule_moq::draft22::names::render(&key.0, Some(&key.1))).refused_at_door += 1;
}
tracing::warn!(
target: "joule_moq_relay::door",
session = ctx.id,
@ -409,6 +422,7 @@ async fn upstream_subgroup(relay: Relay, ctx: Arc<SessionCtx>, h: joule_moq::dra
};
let door_on = relay.inner.door.checks(&key.0);
let media_of = relay.inner.door.media_of(&key.1);
let rendered = joule_moq::draft22::names::render(&key.0, Some(&key.1));
let (mut object_timeout, mut subgroup_timeout) = {
let st = relay.inner.state.lock().unwrap();
st.tracks.get(&key).map_or((0, 0), |t| (t.pub_object_timeout, t.pub_subgroup_timeout))
@ -439,6 +453,9 @@ async fn upstream_subgroup(relay: Relay, ctx: Arc<SessionCtx>, h: joule_moq::dra
let mut t = ctx.tally.lock().unwrap();
t.publisher.objects_received += 1;
t.publisher.object_bytes_received += o.payload.len() as u64;
let tr = t.published(&rendered);
tr.objects_received += 1;
tr.object_bytes_received += o.payload.len() as u64;
}
relay.inner.stats.objects_in.fetch_add(1, Ordering::Relaxed);
let mut signed = None;
@ -778,6 +795,7 @@ async fn subscribe(relay: Relay, ctx: Arc<SessionCtx>, r: TrackRequest, tx: ReqT
sched: ctx.sched.clone(),
params: Mutex::new(p),
receipts_track: is_receipts,
tally: ctx.tally.clone(),
tx: tx.clone(),
ended: watch::channel(false).0,
});
@ -794,6 +812,7 @@ async fn subscribe(relay: Relay, ctx: Arc<SessionCtx>, r: TrackRequest, tx: ReqT
let Some((sub, ok)) = made else {
return reject(&tx, RequestErrorCode::DOES_NOT_EXIST, "the publisher left").await;
};
ctx.tally.lock().unwrap().subscribed(&sub.render, crate::relay::now_unix_us());
let mut ended = sub.ended.subscribe();
if tx.send(&ControlMessage::SubscribeOk(ok)).await.is_err() {
relay.remove_down(&sub);
@ -951,7 +970,7 @@ fn queue_from_cache(relay: &Relay, sched: &SchedHandle, key: &TrackKey, start: L
payload: payload.to_vec(),
});
let p = Prio { subscriber: prio, publisher: publisher_priority, group_rank: Prio::group_rank(order, g), fill_rank: if fill { 0 } else { 1 }, mode_rank: 1, within: o };
let meta = ObjMeta { track: render.clone(), payload_len: payload.len() as u64, receipts_subscribed: false, fetched: true, signed: None };
let meta = ObjMeta { track: render.clone(), payload_len: payload.len() as u64, object_id: o, receipts_subscribed: false, fetched: true, signed: None };
sched.push(lane, Item { op: Op::FetchEntry { request_id, order, entry }, prio: p, deadline: None, meta: Some(meta) });
n += 1;
}
@ -1044,7 +1063,7 @@ async fn upstream_fetch(
FetchEntry::Object(f) => (f.publisher_priority, f.group_id, f.object_id, f.payload.len() as u64),
FetchEntry::EndOfRange { location, .. } => (u8::MAX, location.group, location.object, 0),
};
let meta = matches!(entry, FetchEntry::Object(_)).then(|| ObjMeta { track: render.clone(), payload_len: len, receipts_subscribed: false, fetched: true, signed: None });
let meta = matches!(entry, FetchEntry::Object(_)).then(|| ObjMeta { track: render.clone(), payload_len: len, object_id: o, receipts_subscribed: false, fetched: true, signed: None });
let p = Prio { subscriber: prio, publisher: pp, group_rank: Prio::group_rank(order, g), fill_rank: if fill { 0 } else { 1 }, mode_rank: 1, within: o };
sched.push(lane, Item { op: Op::FetchEntry { request_id, order, entry }, prio: p, deadline: None, meta });
}
@ -1391,7 +1410,12 @@ async fn cleanup(relay: &Relay, ctx: &Arc<SessionCtx>, why: TransportError) {
TransportError::Closed { code, by_peer: true, .. } if *code != u64::MAX => Some(*code),
_ => *ctx.close_code.lock().unwrap(),
};
let tally = ctx.tally.lock().unwrap().clone();
let closed_us = crate::relay::now_unix_us();
let tally = {
let mut t = ctx.tally.lock().unwrap();
t.close_spans(closed_us);
t.clone()
};
let summary = SessionSummary {
format: FORMAT.into(),
relay_key: hex::encode(relay.summary_key().to_bytes()),
@ -1400,7 +1424,7 @@ async fn cleanup(relay: &Relay, ctx: &Arc<SessionCtx>, why: TransportError) {
remote: ctx.remote.clone(),
draft: String::from_utf8_lossy(joule_moq::draft22::ALPN).into_owned(),
opened_unix_ms: ctx.opened_unix_ms,
closed_unix_ms: now_unix_ms(),
closed_unix_ms: closed_us / 1000,
close_code,
relay_energy: RelayEnergy::unmetered(),
tally,

View file

@ -9,13 +9,19 @@
//! `energy-measurement.md` §2). Where the door verified an object's receipt, the
//! publisher's signed figure for producing that object is summed under the class it was
//! signed with, and labelled as the publisher's: it is not the relay's energy.
//!
//! A summary also keeps what WAI's per-hop session record is made from (WAI
//! `live-metering.md` §3): per track the objects received, refused and deduplicated, per
//! track the objects of Object ID above 0 forwarded and those still queued at the end, the
//! spans of each layer class the session subscribed at, and the room grant its token maps
//! to. [`crate::metering::hop_record`] signs that record from the summaries.
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// The format name a summary carries.
pub const FORMAT: &str = "joule-moq-relay/session-summary/1";
pub const FORMAT: &str = "joule-moq-relay/session-summary/2";
/// The domain the relay's signature covers, before the summary's bytes.
pub const SIGNING_DOMAIN: &[u8] = b"joule-moq-relay:session-summary\x01";
@ -35,6 +41,37 @@ pub struct TrackTally {
pub subgroups_reset_after_fin: u64,
/// Objects served on a fetch or fill stream.
pub fetch_objects: u64,
/// Their payload bytes.
#[serde(default)]
pub fetch_bytes: u64,
/// Objects of this track still queued when the session ended.
#[serde(default)]
pub undelivered_at_close: u64,
/// Objects forwarded whose Object ID is above 0: on an MSF event timeline, its
/// incremental updates, one event each (WAI live-events §8.1).
#[serde(default)]
pub incremental_objects: u64,
}
/// One span of a subscriber session's layer class in a room (WAI live-metering §3.3): the
/// highest layer of the room's media tracks it was subscribed to, 0 to 7, or 8 when it was
/// subscribed to tracks of the room but to none of its media tracks.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LayerSpan {
/// The room id, 64 lowercase hexadecimal digits.
pub room: String,
pub class: u8,
pub from_unix_us: u64,
pub to_unix_us: u64,
}
/// The subscriptions a session holds to one room's tracks, while it holds them.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct OpenRoom {
/// Each track held, rendered, with the number of subscriptions to it.
pub tracks: BTreeMap<String, u32>,
pub class: u8,
pub since_unix_us: u64,
}
/// What a session received as a subscriber.
@ -50,6 +87,18 @@ pub struct SubscriberTally {
/// Objects still queued when the session ended.
pub undelivered_at_close: u64,
pub tracks: BTreeMap<String, TrackTally>,
/// The spans of its layer class in each room it subscribed to (WAI live-metering §3.3).
#[serde(default)]
pub layer_spans: Vec<LayerSpan>,
}
/// One track as one upstream session sent it.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PublisherTrackTally {
pub objects_received: u64,
pub object_bytes_received: u64,
pub refused_at_door: u64,
pub deduplicated: u64,
}
/// What a session sent as a publisher.
@ -61,6 +110,24 @@ pub struct PublisherTally {
pub refused_at_door: u64,
/// Objects already received from another publisher of the track (§7.3).
pub deduplicated: u64,
/// The same, per track, rendered (§8.8).
#[serde(default)]
pub tracks: BTreeMap<String, PublisherTrackTally>,
}
/// The room grant a session runs under, as the token it presented in SETUP maps it (WAI
/// live-rooms §10.1): the grant's digest and its ceilings. The relay holds these beside the
/// token; the grant itself is the room authority's record.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoomGrantRef {
/// The grant's digest, 64 lowercase hexadecimal digits.
pub grant: String,
/// In microjoules; 2^64 − 1 for none.
pub joules_ceiling_micro: u64,
/// In minor units of `currency`; 2^64 − 1 for none.
pub funds_ceiling: u64,
/// ISO 4217, or empty for none.
pub currency: String,
}
/// Publisher-signed production figures of objects delivered to the session, summed
@ -78,12 +145,82 @@ pub struct Tally {
pub publisher: PublisherTally,
/// Keyed by acquisition class label (`unmetered` for the marker).
pub publisher_signed: BTreeMap<String, SignedFigure>,
/// The room grant the session's SETUP token maps to, where the configuration maps one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub room_grant: Option<RoomGrantRef>,
/// The subscriptions the session holds to each room's tracks, by room; not part of the
/// summary.
#[serde(skip)]
pub open_rooms: BTreeMap<String, OpenRoom>,
}
impl Tally {
pub fn track(&mut self, name: &str) -> &mut TrackTally {
self.subscriber.tracks.entry(name.to_string()).or_default()
}
/// A publisher's track, rendered.
pub fn published(&mut self, name: &str) -> &mut PublisherTrackTally {
self.publisher.tracks.entry(name.to_string()).or_default()
}
/// The session subscribed to the track rendered `name` at `now`.
pub fn subscribed(&mut self, name: &str, now_unix_us: u64) {
let Some((room, _)) = crate::metering::classify(name) else { return };
let room = hex::encode(room);
let open = self.open_rooms.entry(room.clone()).or_insert_with(|| OpenRoom { since_unix_us: now_unix_us, class: u8::MAX, ..OpenRoom::default() });
*open.tracks.entry(name.to_string()).or_default() += 1;
self.reclass(&room, now_unix_us);
}
/// The session ended a subscription to the track rendered `name` at `now`.
pub fn unsubscribed(&mut self, name: &str, now_unix_us: u64) {
let Some((room, _)) = crate::metering::classify(name) else { return };
let room = hex::encode(room);
let Some(open) = self.open_rooms.get_mut(&room) else { return };
if let Some(n) = open.tracks.get_mut(name) {
*n -= 1;
if *n == 0 {
open.tracks.remove(name);
}
}
self.reclass(&room, now_unix_us);
}
/// Every span still open closes at `now`: the session ended.
pub fn close_spans(&mut self, now_unix_us: u64) {
let rooms: Vec<String> = self.open_rooms.keys().cloned().collect();
for room in rooms {
if let Some(open) = self.open_rooms.remove(&room) {
self.push_span(&room, open.class, open.since_unix_us, now_unix_us);
}
}
}
fn push_span(&mut self, room: &str, class: u8, from: u64, to: u64) {
if class != u8::MAX && to > from {
self.subscriber.layer_spans.push(LayerSpan { room: room.to_string(), class, from_unix_us: from, to_unix_us: to });
}
}
/// The room's class after a change: a span ends and another begins when it changed, and
/// the room is left when nothing of it is held.
fn reclass(&mut self, room: &str, now: u64) {
let Some(open) = self.open_rooms.get(room) else { return };
let (old, since) = (open.class, open.since_unix_us);
if open.tracks.is_empty() {
self.open_rooms.remove(room);
self.push_span(room, old, since, now);
return;
}
let new = crate::metering::layer_class(open.tracks.keys().map(String::as_str));
if new != old {
self.push_span(room, old, since, now);
let open = self.open_rooms.get_mut(room).expect("held");
open.class = new;
open.since_unix_us = now;
}
}
}
/// The relay's own energy for the session, as its acquisition class allows.

View file

@ -0,0 +1,282 @@
//! The relay's per-hop session record of a room (WAI `live-metering.md` §3, §8): two
//! publishers and three subscribers of one WAI room, one subscriber slow, and the record the
//! relay signs from its session summaries recomputes from their counts.
//!
//! The room is the corpus's `relay` room (`wai/live-conformance/metering/`): its id, the
//! principals' keys and their grants are the ones `wai_live_metering_vectors relay-room`
//! prints, every key SHA-256 of capture binding's corpus label and the principal's index,
//! and the relay signs with the room's clock key. Tracks are named as live-rooms §6.1 names
//! them: each publisher's video, layer 0, with its receipts track, and the first
//! publisher's events track. The second publisher's video has a 100 ms delivery timeout,
//! and the slow subscriber reads nothing until the load has arrived and a second and a
//! half more, so some of its objects are dropped at this hop while their receipts still
//! reach it. Every subscriber reads its streams concurrently, as slow_subscriber.rs's do
//! (README, "A subscriber that does not read"). The subscribers' sessions end before the
//! publishers'. As a session closes, the relay ends its subscriptions one at a time, and a
//! moment after its last media subscription ended and before its receipts or events one
//! did is time on no media track, class 8: the test requires such time to fall within the
//! session's close, and no time above layer 0 at all.
//!
//! With `WAI_METERING_FIXTURE_DIR` set, the test also writes the record and its ledger there
//! as `relay-record.wmh` and `relay-ledger.wml`: the corpus holds the pair one run wrote.
mod common;
use common::*;
use joule_moq::draft22::data::{SubgroupHeader, SubgroupId, SubgroupObject};
use joule_moq::draft22::{Parameter, Parameters, Token};
use joule_moq_relay::client::ClientOptions;
use joule_moq_relay::config::MeteredGrant;
use joule_moq_relay::metering::{classify, HopRecord, Kind, CLASSES};
use std::collections::BTreeMap;
use std::time::Duration;
/// The corpus room's id and `t0` (2026-10-18T00:00:00Z).
const ROOM: &str = "89a7be137d7f3c51d15b0d4ece614d60743d7542b7693c96ac7633793e9c102f";
const T0_US: u64 = 1_792_281_600_000_000;
/// Each principal's index under the corpus's key label, its grant's digest and ceilings.
const GRANTS: [(u8, &str, u64, u64, &str); 5] = [
(30, "510124baa39d99a07d9d6575698f70183759e3da8b458f8edb09f09215e14318", u64::MAX, u64::MAX, ""),
(31, "4fc2bb567c1e950b92bb2dd6796df52eb67f3e5c2e5753b3ee9bae483600f17e", u64::MAX, u64::MAX, ""),
(32, "52786b7331441935c2143544988bccb4a4db6f5c76d734292a843a0b5e20e83d", 5_000_000, 500, "EUR"),
(33, "fec5bc703c31e2600d5a51cceb45eb2e5011ec2c1890f8dc5b96fd683ef85362", u64::MAX, u64::MAX, ""),
(34, "6b4272b140a49c75d1dbedfca74343ad6c13a8a9d3a20429e7d457f30d2afd82", u64::MAX, u64::MAX, ""),
];
const RELAY: u8 = 1;
const GROUPS: u64 = 30;
const EVENT_GROUPS: u64 = 6;
const STALL: Duration = Duration::from_millis(1500);
fn sha256(parts: &[&[u8]]) -> [u8; 32] {
let mut c = ring::digest::Context::new(&ring::digest::SHA256);
for p in parts {
c.update(p);
}
c.finish().as_ref().try_into().unwrap()
}
/// A principal's key: SHA-256 of capture binding's corpus label and its index.
fn key(i: u8) -> ed25519_dalek::SigningKey {
ed25519_dalek::SigningKey::from_bytes(&sha256(&[b"wai:capture-take/corpus/key\x00", &[i]]))
}
fn pk_hex(i: u8) -> String {
hex::encode(key(i).verifying_key().to_bytes())
}
fn token(i: u8) -> Vec<u8> {
format!("room-metering-token-{i}").into_bytes()
}
fn opts(i: u8) -> ClientOptions {
ClientOptions { tokens: vec![Token::UseValue { token_type: 0, value: token(i) }], ..ClientOptions::default() }
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn two_publishers_three_subscribers_one_slow_and_the_record_recomputes() {
tokio::time::timeout(Duration::from_secs(180), run()).await.expect("the test finishes in time");
}
async fn run() {
let seed_path = std::env::temp_dir().join(format!("joule-moq-relay-room-metering-{}.seed", std::process::id()));
std::fs::write(&seed_path, hex::encode(sha256(&[b"wai:capture-take/corpus/key\x00", &[RELAY]]))).unwrap();
let server = relay(|c| {
c.limits.quic_send_window_bytes = 64 * 1024;
c.summary.key_path = Some(seed_path.clone());
c.metering.grants = GRANTS
.iter()
.map(|&(i, g, jc, fc, cur)| MeteredGrant {
token_sha256: hex::encode(sha256(&[&token(i)])),
grant: g.into(),
joules_ceiling_micro: jc,
funds_ceiling: fc,
currency: cur.into(),
})
.collect();
})
.await;
let _ = std::fs::remove_file(&seed_path);
assert_eq!(server.relay().summary_key().to_bytes(), key(RELAY).verifying_key().to_bytes(), "the relay signs with the room's clock key");
let (ka, kb) = (key(30), key(31));
let video = |i: u8, alias: u64, receipt_alias: u64, prio: u8| Tracks::new(ns(&[ROOM, &pk_hex(i), "video"]), "0", alias, receipt_alias, prio);
let a = video(30, 10, 1, 0);
let b = video(31, 20, 2, 200);
let events_ns = ns(&[ROOM, &pk_hex(30), "events"]);
let (pa, _ea) = connect(&server, Via::Quic, opts(30)).await;
let (pb, _eb) = connect(&server, Via::Quic, opts(31)).await;
let _pa = publish_tracks(&pa, &a, None).await;
let _pb = publish_tracks(&pb, &b, Some(100)).await;
let _pe = pa.publish(events_ns.clone(), name("0"), 30, Parameters::new(), track_props(64, None)).await.expect("PUBLISH the events track");
let (s1, _e1) = connect(&server, Via::Quic, opts(32)).await;
let (s2, _e2) = connect(&server, Via::WebTransport, opts(33)).await;
let mut small = quinn::TransportConfig::default();
small.stream_receive_window(quinn::VarInt::from_u32(32 * 1024));
small.receive_window(quinn::VarInt::from_u32(96 * 1024));
let (s3, _e3) = connect_with(&server, Via::Quic, ClientOptions { lazy_data: true, ..opts(34) }, Some(small)).await;
let next = || Parameters::new().with(Parameter::LocationFilter(joule_moq::draft22::LocationFilter::NextObject));
let mut views = Vec::new();
for s in [&s1, &s2, &s3] {
let va = subscribe_both(s, &a, next()).await;
let vb = subscribe_both(s, &b, next()).await;
let (ve, etx, erx) = s.subscribe(events_ns.clone(), name("0"), next()).await.expect("SUBSCRIBE the events track");
views.push((va, vb, ve.track_alias, (etx, erx)));
}
let (published_tx, published_rx) = tokio::sync::oneshot::channel::<()>();
let publish = async {
for g in 0..GROUPS {
publish_group(&pa, &ka, &a, g, &payload("a", g, 900)).await;
publish_group(&pb, &kb, &b, g, &payload("b", g, 24 * 1024)).await;
if g < EVENT_GROUPS {
// A group of an MSF event timeline: the join set, then two events.
let h = SubgroupHeader { track_alias: 30, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(64), properties: false, end_of_group: true, first_object: true };
let objects: Vec<SubgroupObject> = [b"[]".to_vec(), format!("[\"event {g}.1\"]").into_bytes(), format!("[\"event {g}.2\"]").into_bytes()]
.into_iter()
.enumerate()
.map(|(k, p)| SubgroupObject::normal(k as u64, p))
.collect();
pa.send_subgroup(h, &objects).await.expect("an events group");
}
tokio::time::sleep(Duration::from_millis(15)).await;
}
let _ = published_tx.send(());
};
let whole = |got: &[Got], v: &(Subscribed, Subscribed, u64, _)| {
let n = |al: u64| got.iter().filter(|x| x.header.track_alias == al && x.reset.is_none()).count() as u64;
n(v.0.media_alias) == GROUPS && n(v.0.receipts_alias) == GROUPS && n(v.1.receipts_alias) == GROUPS && n(v.2) == EVENT_GROUPS
};
// Each reads until A's objects, every receipt and every event group are whole, then
// until a second passes with nothing new, so every B stream is read or seen reset.
let fast = |i: usize| {
let (s, v) = (if i == 0 { &s1 } else { &s2 }, &views[i]);
async move { collect_concurrent(s, STEP * 2, Duration::from_secs(1), |got| whole(got, v)).await }
};
let slow = async {
let _ = tokio::time::timeout(STEP * 2, published_rx).await;
tokio::time::sleep(STALL).await;
collect_concurrent(&s3, STEP * 2, Duration::from_secs(1), |got| whole(got, &views[2])).await
};
let ((), g1, g2, g3) = tokio::join!(publish, fast(0), fast(1), slow);
for (g, v) in [(&g1, &views[0]), (&g2, &views[1]), (&g3, &views[2])] {
assert!(whole(g, v), "every A object, every receipt and every event group reached each subscriber");
}
let relay = server.relay().clone();
let closing = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_micros() as u64;
for s in [&s1, &s2, &s3] {
s.close(0, "done");
}
assert!(until(STEP, || relay.summaries().len() >= 3).await, "the subscribers' three summaries, before a publisher leaves");
pa.close(0, "done");
pb.close(0, "done");
assert!(until(STEP, || relay.summaries().len() >= 5).await, "five summaries");
let room: [u8; 32] = hex::decode(ROOM).unwrap().try_into().unwrap();
let rec = relay.hop_record(room, T0_US);
let back = HopRecord::decode(&rec.to_bytes(), &rec.ledger_bytes()).expect("the record reads back");
assert_eq!(back, rec);
rec.verify().expect("the record verifies under the relay's key");
assert_eq!(rec.hop, key(RELAY).verifying_key().to_bytes());
assert_eq!((rec.joules, rec.class_tag), (0, 0), "the unmetered marker: no meter backs this relay");
assert_eq!(rec.to_bytes().len(), 281 + 90 * rec.entries.len(), "no class evidence, and nothing per object or per viewer");
assert!(rec.start < rec.end);
// The record recomputes from the summaries, summed here track by track.
let summaries: Vec<_> = relay.summaries().into_iter().map(|s| s.summary).collect();
let mut want: BTreeMap<(u8, [u8; 32], u8), [u64; 7]> = BTreeMap::new();
let kind_of = |n: &str| match classify(n) {
Some((r, k)) if r == room => Some(match k {
Kind::Media { digest, .. } => (1u8, digest),
Kind::Events => (2, [0; 32]),
Kind::Other => (3, [0; 32]),
}),
_ => None,
};
let (mut events, mut sub_us) = (0u64, 0u64);
for s in &summaries {
for (n, t) in &s.tally.subscriber.tracks {
let Some((k, d)) = kind_of(n) else { continue };
let e = want.entry((k, d, 2)).or_default();
e[0] += t.objects_forwarded + t.fetch_objects;
e[1] += t.object_bytes_forwarded + t.fetch_bytes;
e[2] += t.dropped_delivery_timeout;
e[5] += t.undelivered_at_close;
e[6] += t.dropped_receipts_track_subscribed;
if k == 2 {
events += t.incremental_objects;
}
}
for (n, t) in &s.tally.publisher.tracks {
let Some((k, d)) = kind_of(n) else { continue };
let e = want.entry((k, d, 1)).or_default();
e[0] += t.objects_received;
e[1] += t.object_bytes_received;
e[3] += t.refused_at_door;
e[4] += t.deduplicated;
}
sub_us += s.tally.subscriber.layer_spans.iter().filter(|x| x.room == ROOM).map(|x| x.to_unix_us - x.from_unix_us).sum::<u64>();
}
let got: BTreeMap<(u8, [u8; 32], u8), [u64; 7]> = rec
.entries
.iter()
.map(|e| ((e.kind, e.track, e.dir), [e.objects, e.bytes, e.dropped_timeout, e.dropped_refused, e.dropped_duplicate, e.dropped_closed, e.receipted]))
.collect();
assert_eq!(got, want, "each track entry is the sum of the summaries' counts");
let digest_of = |i: u8| sha256(&[format!("{ROOM}-{}-video", pk_hex(i)).as_bytes(), &[0], b"0"]);
let (da, db) = (digest_of(30), digest_of(31));
assert_eq!(got[&(1, da, 1)][0], GROUPS, "the relay received each of A's objects once");
assert_eq!(got[&(1, db, 1)][0], GROUPS);
let b_out = got[&(1, db, 2)];
assert!(b_out[2] > 0, "the slow subscriber lost some of B's objects to their delivery timeout");
assert_eq!(b_out[6], b_out[2], "the receipts of every dropped object were forwarded");
assert_eq!(b_out[0] + b_out[2] + b_out[5], 3 * GROUPS, "each of B's objects to each subscriber sent or dropped");
assert_eq!(got[&(1, da, 2)][0], 3 * GROUPS, "A's track, with no timeout, reached all three whole");
assert_eq!(rec.events, events);
assert_eq!(rec.events, 3 * EVENT_GROUPS * 2, "two events a group, to each subscriber; the join sets are not events");
assert_eq!(rec.sessions, 3, "three subscriber sessions; a publisher's session subscribes to nothing");
assert_eq!(rec.subscriber_us.iter().sum::<u64>(), sub_us);
// Each subscriber session's layer spans, relative to the instant the test began closing
// the subscribers, for a report on failure.
let spans: Vec<(u64, u8, u64, u64)> = summaries
.iter()
.flat_map(|s| s.tally.subscriber.layer_spans.iter().filter(|x| x.room == ROOM).map(move |x| (s.session, x.class, x.from_unix_us, x.to_unix_us)))
.collect();
let report = || {
spans
.iter()
.map(|(id, c, a, z)| format!("session {id}: class {c} from {} to {} us after closing began", *a as i64 - closing as i64, *z as i64 - closing as i64))
.collect::<Vec<_>>()
.join("; ")
};
if spans.iter().any(|x| x.1 != 0) {
eprintln!("TEARDOWN-CLASS: time outside class 0: {}", report());
}
assert!(rec.subscriber_us[0] > 0, "every subscriber held layer 0 of the room's video: {}", report());
assert!(rec.subscriber_us[1..8].iter().all(|&x| x == 0), "no subscriber held a layer above 0: {}", report());
assert!(
spans.iter().filter(|x| x.1 != 0).all(|x| x.2 >= closing),
"time on no media track only while a session closed, its subscriptions ending one by one: {}",
report()
);
// The ledger: one entry for each grant, from the token each session presented.
let mut grants: Vec<&str> = GRANTS.iter().map(|g| g.1).collect();
grants.sort();
assert_eq!(rec.ledger.iter().map(|e| hex::encode(e.grant)).collect::<Vec<_>>(), grants);
let s1_entry = rec.ledger.iter().find(|e| hex::encode(e.grant) == GRANTS[2].1).unwrap();
assert_eq!((s1_entry.joules_ceiling, s1_entry.funds_ceiling, &s1_entry.currency), (5_000_000, 500, b"EUR"));
for i in 0..CLASSES {
assert_eq!(rec.ledger.iter().map(|e| e.subscriber_us[i]).sum::<u64>(), rec.subscriber_us[i]);
}
assert!(rec.ledger.iter().all(|e| e.sessions == 1));
let ina: u64 = got.iter().filter(|(k, _)| k.2 == 1).map(|(_, v)| v[1]).sum();
assert_eq!(rec.ledger.iter().map(|e| e.bytes_in).sum::<u64>(), ina, "the bytes in are the publishers'");
if let Ok(dir) = std::env::var("WAI_METERING_FIXTURE_DIR") {
let d = std::path::Path::new(&dir);
std::fs::write(d.join("relay-record.wmh"), rec.to_bytes()).unwrap();
std::fs::write(d.join("relay-ledger.wml"), rec.ledger_bytes()).unwrap();
}
server.shutdown().await;
}

View file

@ -6,6 +6,54 @@ wire format may change and such changes are recorded here.
## v0.4.0 — current
### `wai.live.metering`: a room session's units and joules per hop
- **The extension** ([live-metering](extensions/live-metering.md)) registers the record
`wai.live.metering`. Every hop that carried a room session, a relay, a compositor or an
ingest gateway, signs one **per-hop session record**, `WMH1`: per track and direction the
objects and bytes it carried and the objects it dropped by reason (delivery timeout,
refused at the door, duplicate, queued at the end) with those whose receipts it
forwarded; its subscriber sessions' time by layer class; the events it delivered; its own
energy with the acquisition class of energy-measurement, or the unmetered marker; and the
root of a **ledger**, one entry for each grant its sessions ran under, with that grant's
joule and funds ceilings. Media tracks are counted one by one, events tracks and every
other track of the room together, subscribers by layer class, so a record is at most
552 + 180 × (*M* + 2) bytes, *M* the media tracks the room published, whatever the
audience; a record past that is refused before it is read.
- **The room-session receipt**, `WMR1`, signed by the room authority or by a key the
origin's key log lists with the new authority `live/meter` (and `energy/<class>` for each
class it totals): a key-log authority rather than a grant power, since a receipt states
every principal's consumption and a grant's powers are live-rooms' closed set. It names
the per-hop records, the takes and the composition receipts it aggregates and states
publisher-seconds, subscriber-seconds by layer class, composition-seconds, events sent
and delivered and recorded-seconds (the capture each device committed while capturing),
every hop's joules with its class, one total per class and never one across classes
(refused `classes_summed`), and each grant's consumption: its units, a share of each
metered hop's figure by the bytes it moved, labelled with that figure's class, its funds
under a stated tariff, and its verdicts against its ceilings, committed by a consumption
root and every grant past a ceiling named. A verifier recomputes every number from the
records it names; eighteen refusal reasons.
- **QoE.** Clients' delivery-telemetry observations, as CMCD version 2 Event-mode reports,
ride the CMCD event timeline of `draft-wilaw-moq-cmcd-event-timeline-00` (`T` the
report's `ts`, values as strings, `sf` and the custom keys not carried), or CTA-5004-B's
`application/cmcd` batch, whose line syntax the reports already have. Only a cell of at
least fifty sessions enters the receipt, suppressed below that; the observations never
leave the session and the room's service.
- **C2PA.** `c2pa_emit` (with `live_metering`) writes a receipt into a real signed manifest:
`c2pa.environmental-sustainability` with `energy_kwh` only where one class covers the
session whole, GHG and water only when the caller supplies them with their method, and
always the companion `science.transaction.wai.live.metering` with the totals by class.
- **The reference relay** signs the per-hop record of a room from its session summaries
(joule-code's CHANGELOG), and the corpus holds a record it signed in its test.
- **Reference and corpus.** `wai-rs` feature `live_metering` (`src/live_metering.rs`;
`capture_take::find_take` exposes capture-binding §9.2 to it); `live-conformance/metering/`,
written from fixed seeds and the relay's recorded record by `wai_live_metering_vectors`, and
checked by `tests/live_metering.rs` and by `live-conformance/live_metering_verify.py`, a
second implementation that shares no code with `wai-rs`, verifies rooms, takes and
composition receipts with their corpora's own second implementations, recomputes every
unit, total and consumption entry, and requires every mutated copy of the corpus to fail.
The registry lists the record and the magics `WMH1` and `WMR1`.
### wai-quantum 0.6.4: noisy Clifford circuits by Pauli frames, the state vector on the GPU, and the density matrix by superoperators
Additive. Every pinned digest is unchanged.

View file

@ -40,8 +40,8 @@ Every registered string has one kind.
- **record** — a signed record a WAI tool emits and verifies (a receipt kind:
`wai.asset.provenance`, the quantum-operations receipts, the delivery records
`wai.video.manifest` and its siblings; the live records `wai.live.events`,
`wai.live.room`, `wai.capture.take`, `wai.live.compose` and `wai.live.recording`; timed text,
`wai.text.timed`).
`wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording` and
`wai.live.metering`; timed text, `wai.text.timed`).
It names no payload format.
A derivation or record string is not a payload capability, so it is not
@ -196,6 +196,7 @@ and a role are fixed once registered.
| `wai.image.png` | payload | registered | image | standalone | royalty-free | standard-defined | floor | §5 Mandatory floor |
| `wai.live.compose` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.events` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.metering` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.recording` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.room` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.mesh.draco` | payload | registered | volumetric | standalone | royalty-free | standard-unclassified | — | §5 Immersive audio, cinema, volumetric, haptics, HDR |
@ -300,6 +301,8 @@ and a role are fixed once registered.
| `WEP1` | magic | defined | live-events §5 |
| `WEV1` | magic | defined | live-events §3.1 |
| `WHP1` | magic | defined | http-delivery §6.2 |
| `WMH1` | magic | defined | live-metering §3.1 |
| `WMR1` | magic | defined | live-metering §4.1 |
| `WRD1` | magic | defined | live-rooms §3.1 |
| `WRG1` | magic | defined | live-rooms §4.1 |
| `WRM1` | magic | defined | live-recording §4.1 |

View file

@ -592,8 +592,8 @@ kind:
`wai.asset.provenance`, `wai.quantum.calibration`, `wai.quantum.job`, and the
delivery records `wai.video.manifest`, `wai.video.ssai`, `wai.video.keys`,
`wai.video.package` and `wai.video.channel`; and the live records `wai.live.events`,
`wai.live.room`, `wai.capture.take`, `wai.live.compose` and `wai.live.recording`, and timed
text, `wai.text.timed`), which names no payload format.
`wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording` and
`wai.live.metering`, and timed text, `wai.text.timed`), which names no payload format.
Only payload, companion and refinement capabilities have a media class, a
fidelity tier, a replicate class and a role. A derivation or record string is not a payload
@ -940,6 +940,23 @@ The event's origin and model digest are the disclosure label, carried through ev
IMSC1 attributes) or, where a target cannot hold it, in a sidecar:
[`extensions/timed-text.md`](extensions/timed-text.md).
And for what a room session **cost** — `wai.live.metering`: every hop that carried the session
(a relay, a compositor, an ingest gateway) signs one per-hop session record, `WMH1`: per track
and direction the objects and bytes it carried and the objects it dropped by reason, with those
whose receipts it forwarded; its subscriber sessions' time by layer class; the events it
delivered; its own energy with its acquisition class, or the unmetered marker; and the root of a
ledger of the grants its sessions ran under, with their ceilings. The record holds nothing per
object or per viewer, so its size is bounded by what the room published, whatever the audience.
The room authority, or a key its origin's key log lists with `live/meter`, signs the room-session
receipt, `WMR1`, naming the records, the takes and the composition receipts it aggregates and
stating publisher-seconds, subscriber-seconds by layer class, composition-seconds, events sent
and delivered and recorded-seconds, every hop's joules with its class and a total per class,
never one across classes, and each grant's consumption against its joule and funds ceilings;
a verifier recomputes every number from the records. Clients' QoE observations ride the CMCD
event timeline, and only a cell of at least fifty sessions reaches the receipt. The receipt maps
onto C2PA's `c2pa.environmental-sustainability` assertion where one class covers the session
whole: [`extensions/live-metering.md`](extensions/live-metering.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

@ -3115,6 +3115,8 @@ A confirmation needs every one of them:
| 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` |
| 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 |
A class registered in §2.4.4 registers its authority here with it, and a
family that needs a narrower authority than `receipt` registers one here.

View file

@ -0,0 +1,734 @@
# WAI Extension: Live Metering
> Status: Draft. Registers the record `wai.live.metering`: what a room session cost, in
> units and in joules, recomputable from what each hop signed. Every hop that carried the
> session (a relay, a compositor, an ingest gateway) signs one **per-hop session record**,
> `WMH1`, per room session: per track and direction the objects and bytes it carried and the
> objects it dropped by reason, its subscriber sessions' time by layer class, its own energy
> with the acquisition class of [energy-measurement](energy-measurement.md), and a ledger
> root over the grants its sessions ran under. The record is a batch: it holds no entry per
> object and none per viewer, and its size is bounded by what the room published, whatever
> the audience. The room authority, or a key its origin's key log lists for metering, signs
> the **room-session receipt**, `WMR1`, which names the per-hop records, the takes and the
> composition receipts it aggregates, and states the session's units (publisher-seconds,
> subscriber-seconds by layer class, composition-seconds, events sent and delivered,
> recorded-seconds), the joules of every hop with its class, a total per class and never
> one across classes, and each grant's consumption against its joule and funds ceilings.
> Every number in the receipt is recomputed by its verifier from the records it names.
> Clients add quality-of-experience observations, CMCD version 2 Event-mode reports carried
> on the CMCD event timeline, of which only an aggregate over enough sessions enters the
> receipt. The receipt maps onto C2PA 2.4's `c2pa.environmental-sustainability` assertion.
> No new transport. The reference implementation is the `live_metering` module of `wai-rs`
> (feature `live_metering`); the reference relay, `joule-moq-relay`, signs a per-hop record
> from its session summaries; the corpus is
> [`live-conformance/metering/`](../live-conformance/metering/), and its second
> implementation is `live-conformance/live_metering_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
>
> Texts read for this revision: CTA-5004-B (CMCD version 2: Event mode, its batch body and
> the media type `application/cmcd`); `draft-wilaw-moq-cmcd-event-timeline-00` and
> `draft-ietf-moq-msf-01`, Internet-Drafts not past Working Group Last Call; C2PA 2.4
> §18.26; RFC 6962 §2.1; RFC 8032; RFC 8941 and RFC 9651; FIPS 180-4. A section number of
> one of those drafts in this text is that revision's.
## 1. Scope
In scope: the per-hop session record, its track entries, its subscriber time by layer
class, its ledger, its size bound and its signature (§3); the room-session receipt, the
key that may sign it, its units, its joules per hop and per class, and its ceilings (§4);
verification (§5); quality-of-experience observations, how they are carried and what of
them reaches the receipt (§6); the mapping onto C2PA (§7); and what the reference relay
emits (§8).
Out of scope: a transport; how a hop measures its energy, which
[energy-measurement](energy-measurement.md) governs; how a deployment prices a unit, of
which this extension fixes only how a stated tariff is applied (§4.6); billing and payment;
and how a meter enforces a ceiling while the session runs, which [live-rooms](live-rooms.md)
§4.5 leaves to the deployment and this extension does not change (§4.6).
Relationship to the core spec: `wai.live.metering` is a **record** ([SPEC](../SPEC.md) §5):
a signed record a WAI tool emits and verifies. It names no payload format and is not
dispatchable.
## 2. Terms
- **Room**, **room clock**, **principal**, **event**, **chain**, **grant**, **grant in
effect**, **room key**, **merged order**, **slot**, **layer**: as in live-events §2 and
live-rooms §2, §6.3, §9.2.
- **Room session**: the span of room clock a receipt states, [`start`, `end`), in
microseconds.
- **Hop**: a party that carries the session's objects and signs a record of it, named by
its Ed25519 key (RFC 8032). Its **role** is 1 relay, 2 compositor ([live-compose](live-compose.md) §7)
or 3 ingest gateway.
- **Track of the room**: a track whose Track Namespace's first field is the room id as 64
lowercase hexadecimal digits (live-rooms §6.1). A **media track** is one whose namespace
is the room, an owner and `audio`, `video` or `screen`, and whose name is a layer from `0`
to `7`; an **events track** one whose namespace ends `events` and whose name is `0`
(live-events §8.1); every other track of the room is an **other** track (receipts tracks,
catalogs, CMCD tracks among them).
- **Track digest**: live-events §3.1's anchor `track`: SHA-256 (FIPS 180-4) over the
track's namespace as live-rooms §6.2 renders it, one zero byte, and its name.
- **Session** of a hop: one transport session a peer holds with it (a MOQT session, a
WebRTC peer connection). A **subscriber session** is one that subscribes to a track of the
room and that is not another hop of the session.
- **Unmetered marker**: a zero figure with class tag 0 (energy-measurement §2), never a
figure of zero joules.
- *u64*, *u32*, *u16*, *u8*: little-endian unsigned integers of that many bits. A digest is
SHA-256, 32 bytes. **Node**: staged-measurement Appendix C's `SHA-256("wai:out-node\x01" ‖
left ‖ right)`. The **root** of a list of leaf hashes is RFC 6962 §2.1's `MTH` with that
node; the root of no leaf is 32 zero bytes.
## 3. The per-hop session record
### 3.1 Layout
Each hop signs one record per room session, `WMH1`, when its part in the session ends:
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WMH1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the room id |
| `hop` | 32 | the hop's key, which signs the record |
| `role` | 1 | 1 relay, 2 compositor, 3 ingest gateway |
| `start`, `end` | 8 each | u64: the span on the room clock the record covers, [`start`, `end`) |
| `joules` | 8 | u64: the hop's own energy over the span, in microjoules; 0 is the unmetered marker |
| `class` | 1 + … | its acquisition class: energy-measurement §2.7's tag byte, then that tag's evidence |
| `tracks` | 2 + 90 × *n* | a u16 count *n*, then each track entry (§3.2) |
| `sessions` | 4 | u32: the subscriber sessions it served in the span |
| `subscriber_us` | 72 | nine u64: the subscriber-microseconds of layer classes 0 to 8 (§3.3) |
| `events` | 8 | u64: the events it delivered to subscriber sessions (§3.3) |
| `grants` | 4 | u32: the entries of its ledger (§3.4) |
| `ledger` | 32 | the ledger's root (§3.4) |
| `sig` | 64 | §3.5 |
### 3.2 Track entries
A track entry is 90 bytes:
| field | size | meaning |
|---|---|---|
| `kind` | 1 | 1 a media track; 2 every events track of the room, together; 3 every other track of the room, together |
| `track` | 32 | for kind 1, the track digest; for kinds 2 and 3, 32 zero bytes |
| `dir` | 1 | 1 in: what the hop received; 2 out: what it sent |
| `objects` | 8 | u64. In: every object it received on the track, those it dropped included. Out: every object it sent, live or fetched |
| `bytes` | 8 | u64: those objects' payload bytes |
| `dropped_timeout` | 8 | u64, out: objects not sent because their delivery timeout passed at this hop (MOQT §5.2), the rest of a subgroup reset for it included |
| `dropped_refused` | 8 | u64, in: objects refused at the door, their receipt not verifying (jwp-receipts §4.1) |
| `dropped_duplicate` | 8 | u64, in: objects another upstream of the track had already delivered |
| `dropped_closed` | 8 | u64, out: objects still queued when their session ended |
| `receipted` | 8 | u64, out: of the objects dropped, those whose receipts the hop forwarded to the same session on the track's receipts track (jwp-receipts §4.4) |
Media tracks are counted one by one, since a room publishes few of them. Events tracks are
counted together, since every principal of the room, an audience member who chats among
them, has one, and so are other tracks: a record holds no entry that grows with the
audience (§3.7).
### 3.3 Subscribers and layer classes
The **layer class** of a subscriber session at an instant is the highest layer of the
room's media tracks it is subscribed to then, 0 to 7; or 8 when it is subscribed to tracks
of the room but to none of its media tracks (an events-only viewer). `subscriber_us[c]` is
the sum, over the hop's subscriber sessions, of the room-clock microseconds within the span
during which the session was of class *c*. A session is a subscriber session from its first
subscription to a track of the room until it holds none; `sessions` counts those of the
span. A session's class follows the subscriptions it holds at each instant, including as
it closes: a hop that ends a closing session's subscriptions one at a time classes the
moments between by what is still held, class 8 once its last media subscription has ended
and while another of the room's tracks is still held.
`events` counts the objects of Object ID 1 or above the hop delivered on the room's events
tracks to subscriber sessions: on an MSF event timeline each is one event (live-events
§8.1), while object 0 of a group repeats the join set and is not counted.
A session another hop holds with this one (a relay that subscribes upstream for its own
subscribers, a compositor's subscriptions to the inputs it composes) is a **hop link**: its
objects are counted in the track entries, and it is not a subscriber session. So a
subscriber is counted at exactly one hop, the one that serves it, and a room's subscriber
time sums over its hops. A compositor is a principal of the room under its own grant, and
its subscriptions at a relay are that relay's subscriber sessions.
### 3.4 The ledger
The hop's **ledger** is the list of its entries, one for each grant its sessions in the span
ran under, in strictly ascending order of `grant`. An entry is 143 bytes:
| field | size | meaning |
|---|---|---|
| `grant` | 32 | the grant's digest (live-rooms §4.2); 32 zero bytes for sessions under no grant, such as a viewer admitted by Privacy Pass (live-rooms §10.4) |
| `joules_ceiling` | 8 | u64: the grant's `joules_ceiling`; 2^64 − 1 for none, and for the zero grant |
| `funds_ceiling` | 8 | u64: the grant's `funds_ceiling`; 2^64 − 1 for none |
| `currency` | 3 | the grant's `currency`; three zero bytes for none |
| `sessions` | 4 | u32: the hop's sessions under the grant, as publisher or subscriber |
| `bytes_in` | 8 | u64: the payload bytes the hop received from them on tracks of the room |
| `bytes_out` | 8 | u64: the payload bytes it sent to them on tracks of the room |
| `subscriber_us` | 72 | nine u64: their subscriber-microseconds by layer class (§3.3) |
A hop knows the grant a session runs under from the token it was admitted with, which maps
the grant's claims (live-rooms §10): the hop holds the grant beside the token, and the
ceilings are the grant's. Each entry's leaf is SHA-256 over the 20 bytes
`wai:metering-ledger` (ASCII) and 0x01, followed by the entry; `ledger` is the root of the
entries' leaves in order, and `grants` their number. The ledger travels beside the record,
unsigned; the record signs its root, so the ledger can only be the one its hop signed, and
one entry can be shown to the principal it concerns with its RFC 6962 audit path, without
the others.
### 3.5 Digest and signature
The **digest** of a record is SHA-256 over its bytes before `sig`. Its **signature** is
Ed25519 by `hop` over the 17 bytes `wai:metering-hop` (ASCII) and 0x01, followed by the bytes
the digest covers, verified strictly, as [SPEC](../SPEC.md) §7 *Signatures* requires: a key
that fails step 1 or 2 is `bad_key`, and a signature that fails a later step is
`bad_signature`.
### 3.6 Reading and rules
A reader reads exactly the bytes it is given as one record, in the order of §3.1, as
live-events §3.3 reads a record with `WMH1` for `WEV1` (`truncated`, `malformed`,
`unknown_version`). The class's evidence is read as energy-measurement §2.7 writes it; for
tag 5 a reference length above 255 is `malformed`. It then refuses a record that breaks any
of these rules as `energy_invalid`:
- the tag is 0 exactly when `joules` is 0;
- the tag is 0, 2 (`ModelBased`), 3 (`Estimator`), 4 (`OnChipCounter`) or 5
(`CalibratedInstrument`): the legacy tag 1 (`HwShunt`) is never emitted (energy-measurement
§2.6);
- for tags 4 and 5, `relative_ppm` and `window_us` are above 0, the figure meets the
resolution floor (`joules` × `relative_ppm` ≥ 288 676, energy-measurement §2.3), tag 4's
filtering byte is 0, 1 or 2, and tag 5's reference is UTF-8 (RFC 3629) and not only white
space;
and as `record_invalid` one that breaks any of these:
- `role` is 1, 2 or 3; `start` is below `end`;
- the track entries are in strictly ascending order of (`kind`, `track`, `dir`), each
`kind` 1 to 3 and each `dir` 1 or 2; a media entry's `track` is not zero and another
entry's is;
- an entry in has `dropped_timeout`, `dropped_closed` and `receipted` 0, and
`dropped_refused` + `dropped_duplicate` at most `objects`; an entry out has
`dropped_refused` and `dropped_duplicate` 0, and `receipted` at most `dropped_timeout` +
`dropped_closed`;
- with `sessions` 0, every `subscriber_us` and `events` are 0;
- `ledger` is 32 zero bytes exactly when `grants` is 0.
A ledger is read as entries of 143 bytes, its length a multiple of 143, in strictly
ascending order of `grant`, each `currency` three zero bytes exactly when its
`funds_ceiling` is 2^64 − 1 and three capitals `A`–`Z` otherwise, and the zero grant with
both ceilings 2^64 − 1: otherwise `ledger_invalid`. Its number of entries is checked
against the record's `grants` when the record is verified (§5.1 step 4).
### 3.7 The size bound
Let *M* be the number of distinct media tracks the room published at some instant of the
room session (§5.3). A record is then at most
B(M) = 552 + 180 × (M + 2)
bytes, and one longer is refused `record_too_large` before it is read. 552 is the record's
fixed fields, 281 bytes, with the longest class evidence, 271 bytes (tag 5 with a reference
of 255 bytes); and a record has at most two entries for each media track (in and out) and
two each for the events and other tracks together, 2 × (*M* + 2) entries of 90 bytes.
The bound does not depend on the audience. A session with three viewers and one with a
million, carried by the same publishers, give records of one size: subscribers are summed
into nine layer classes and their grants into the ledger, which the record signs by its
root. A hop's ledger grows with the grants it served; the record, which is what travels
upstream and into the receipt, does not. Receipts batch per group upstream in the same way
(jwp-receipts §2.3): a relay forwards a group receipt, not one per viewer per object.
### 3.8 What a hop counts
A hop's record covers one room session, every session it held with a track of the room in
the span. A hop that signs more than one record in a session (a long session flushed in
parts) gives them spans that do not overlap and cuts them at group boundaries: an object is
counted in the record whose span holds the arrival of its group's first object at the hop.
## 4. The room-session receipt
### 4.1 Layout
When the session is over, the room authority, or a metering key (§4.3), signs the receipt,
`WMR1`:
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WMR1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the room id |
| `signer` | 32 | the key that signs the receipt |
| `start`, `end` | 8 each | u64: the room session, [`start`, `end`) |
| `hops` | 1 + … | a u8 count, then for each per-hop record it aggregates, in strictly ascending order of `record`: `record` (32, the record's digest), `hop` (32), `role` (u8), `joules` (u64) and `class` (1 + …), restated from the record |
| `takes` | 1 + 32 × *k* | a u8 count, then the digest of each take manifest it aggregates ([capture-binding](capture-binding.md) §7.1), strictly ascending |
| `compose` | 4 + 32 | u32: the composition receipts it aggregates ([live-compose](live-compose.md) §8); then their **list digest**, SHA-256 over their digests in ascending order, concatenated (32 zero bytes when none) |
| `qoe` | 4 + 32 | u32: the session logs of QoE observations it aggregates (§6); then the SHA-256 of the logs document (32 zero bytes when none) |
| `units` | 120 | fifteen u64: `publisher_us`, `subscriber_us` for layer classes 0 to 8, `sessions`, `composition_us`, `events_sent`, `events_delivered`, `recorded_us` (§4.4) |
| `unmetered` | 1 | u8: the hop entries whose figure is the unmetered marker |
| `totals` | 1 + 13 × *m* | a u8 count *m*, then for each acquisition class among the hop entries: `tag` (u8), `figures` (u32, the hop entries of that class) and `joules` (u64, their sum), in strictly ascending order of `tag` (§4.5) |
| `tariff` | 3 + 52 | `currency` (3; zero for no tariff), then thirteen u32 prices in millionths of a minor unit: per second of `publisher_us`, of `subscriber_us` for each layer class 0 to 8, of `composition_us` and of `recorded_us`, and per event sent (§4.6) |
| `ceilings` | 4 + 32 + 4 + 33 × *p* | u32: the grants of the session (§4.6); the **consumption root** (32); then a u32 count *p* and each grant past a ceiling: `grant` (32) and `flags` (u8: bit 0 past its joule ceiling, bit 1 past its funds ceiling), in strictly ascending order of `grant` |
| `qoe_cell` | 49 | the QoE aggregate (§4.7): `suppressed` (u8), `started`, `start_failed`, `exit_before_start` (u32 each), `stalls`, `stall_ms`, `engaged_ms` (u64 each), `vst_count` (u32), `vst_ms` (u64) |
| `sig` | 64 | §4.2 |
### 4.2 Digest, signature and rules
The **digest** of a receipt is SHA-256 over its bytes before `sig`; its **signature** is
Ed25519 by `signer` over the 21 bytes `wai:metering-session` (ASCII) and 0x01, followed by
the bytes the digest covers, verified strictly (SPEC §7 *Signatures*).
A reader reads exactly the bytes it is given, as §3.6 reads a record with `WMR1` for `WMH1`
(`truncated`, `malformed`, `unknown_version`), and refuses as `classes_summed` a receipt
whose `totals` names tag 0: a total over figures of no one class, which this layout has no
other way to state. It refuses as `session_invalid` a receipt that breaks any of these:
- `start` is below `end`;
- each list is in the strictly ascending order §4.1 gives it; each hop entry's `role` is 1,
2 or 3, and its class reads and meets §3.6's `energy_invalid` rules; each total's `tag` is
2 to 5 and its `figures` above 0;
- `compose`'s digest is zero exactly when its count is, and so is `qoe`'s;
- with no tariff (`currency` zero) every price is 0; otherwise `currency` is three capitals
`A`–`Z`;
- each past grant's `flags` is 1, 2 or 3;
- `suppressed` is 0 or 1, and with 1 every other field of the cell is 0.
### 4.3 Who signs: the metering authority
The receipt is signed by the room authority (live-rooms §3.1), or by a **metering key**: a
key the room's origin's key log lists, not revoked, with the authority `live/meter` in its
`may_sign` (jwp-receipts §7.5). Where the room names an origin, the signer's key-log entry,
the room authority's included, **MUST** also hold `energy/` followed by the class label of
each class the receipt totals, as jwp-receipts §7.5 requires of every signer that restates
a figure with its class. A room that names no origin has no key log, and only its authority
signs its receipts.
The metering power is a key-log authority and not one of a grant's powers (live-rooms
§3.3), for three reasons:
1. **A receipt states every principal's consumption.** A participant's power to meter would
let one participant sign the bill of another, the authority's own ceilings included.
Metering is the operator's act, as storing a take is (`capture/store`), and the operator's
keys live in the origin's key log.
2. **Revocation and trust come with it.** A metering key is revoked through the same log as
the room authority and the hops (§5.2), and a relying party anchors it as it anchors any
origin key (jwp-receipts §7.4), with the energy authorities its figures need.
3. **A grant's powers are a closed set of four bits**, which live-rooms §3.4 holds a
descriptor and a grant to; this extension does not change them.
A signer that is neither is refused `signer_not_authorised`; a metering key whose entry is
`revoked` is refused `signer_revoked`.
### 4.4 Units
Every unit is an integer of room-clock microseconds or a count, over the room session
[`start`, `end`), and each is recomputed from the records the receipt names:
- **`publisher_us`**, publisher-microseconds: over every principal, the microseconds of the
session during which it **publishes** (live-rooms §7.3: an own track published, or a place
in a slot, live or standby), by the lifecycle replay of §5.3.
- **`subscriber_us`** for layer classes 0 to 8, and **`sessions`**: the sums over the hop
records of their `subscriber_us` and their `sessions` (§3.3).
- **`composition_us`**, composition-microseconds: over each composed track, the receipts of
one compositor, one media kind and one output track, sorted by `t`: the sum of
*t*<sub>*j*+1</sub> − *t*<sub>*j*</sub> over consecutive receipts at most 1 000 000 µs
apart. A composed track whose outputs stop for more than a second did not compose in
between, and a lone receipt adds nothing, so the figure is never more than the time the
composition ran.
- **`events_sent`**: the records the room verifier accepted (live-rooms §9.2), each once by
its digest, whose `t` is in the session: every principal's, the room keys' lifecycle
events included.
- **`events_delivered`**: the sum over the hop records of their `events` (§3.3).
- **`recorded_us`**, recorded-microseconds: over each take, the **committed capture**: with
*o* the offset of its first held commitment and *s* the `size` of its last (capture-binding
§9.2, §9.3), the sum over the manifest's chunks of index below *s* of the part of [`start`
+ *o*, `end` + *o*), each chunk's capture range on the room clock, within the session. A
take with no held commitment records nothing. What a device captured and did not commit
while capturing is not counted.
### 4.5 Joules per hop and per class
Each hop entry restates its record's `hop`, `role`, `joules` and `class`, so the receipt
states the energy of every hop with the class it was obtained under. `unmetered` counts the
entries whose figure is the unmetered marker; they are in no total, and a reader **MUST
NOT** present them as zero joules (energy-measurement §2).
The **totals** are one per class: for each tag among the metered entries, the number of
entries of that tag and the sum of their `joules`. Nothing in the receipt sums figures of
two classes. A `ModelBased` figure added to an `OnChipCounter` figure is neither a counter
reading nor a model's output, and a total of the two, labelled with either class, would
present a model as a measurement or a measurement as a model. So a receipt whose total
names tag 0 is refused `classes_summed` (§4.2), and so is one whose total for a class is the
sum of the figures of that class and of at least one other while not the sum of its own:
the total of class *X* equals Σ<sub>*c* ∈ *S*</sub> *T<sub>c</sub>* for some set *S* of the
receipt's classes that holds *X* and another, where *T<sub>c</sub>* is the sum of the
figures of class *c*, and does not equal *T<sub>X</sub>*. Any other total that does not
recompute is `unit_mismatch`.
The receipt's energy covers the session **whole** when `unmetered` is 0 and it has at least
one hop entry, and **partially** otherwise (energy-binding §4's `Whole` and `Partial`). A
partial total **MUST NOT** be presented as the session's energy.
### 4.6 Ceilings
**The grants of the session** are every grant a hop ledger names, the zero grant included,
and every grant that was in effect for a present principal at some instant of the session
(§5.3), in strictly ascending order of digest. For each, its **consumption** is:
- `publisher_us`: the publisher-microseconds of principals while it was their grant in
effect;
- `subscriber_us`, by layer class: the sums of its ledger entries' over the hop records;
- `composition_us`: the composition-microseconds of §4.4 whose earlier receipt's compositor
had it in effect at that receipt's `t`;
- `recorded_us`: the committed capture of the takes whose first held commitment was verified
under it (live-rooms §9.2);
- `events_sent`: the records of §4.4's count verified under it;
- **attributed joules**, by class: each metered hop record's figure *J* is shared among its
ledger entries by the bytes they moved: an entry with *b* = `bytes_in` + `bytes_out` of a
record whose entries move *B* bytes in all receives ⌊*J* × *b* / *B*⌋ µJ of that record's
class, nothing when *B* is 0, and the remainder is attributed to no grant. An attributed
share is an allocation of a hop's figure, not a measurement of the grant's use, and keeps
the figure's class; it is **unmetered** for the grant when an unmetered record's entry for
it moved bytes;
- **funds**, where the tariff's `currency` is the grant's: ⌈*N* / 10^12⌉ minor units of it,
with *N* = `publisher_us` × *p*<sub>pub</sub> + Σ<sub>*c*</sub> `subscriber_us`[*c*] ×
*p*<sub>sub,*c*</sub> + `composition_us` × *p*<sub>comp</sub> + `recorded_us` ×
*p*<sub>rec</sub> + `events_sent` × *p*<sub>ev</sub> × 10^6, at most 2^64 − 2; 2^64 − 1
states that no funds were computed (no tariff, or another currency).
Its verdicts, against its ceilings, each the first row of the table below that applies,
taken in the order none, past, unmetered, partial, within for the joule ceiling, and none,
no tariff, past, within for the funds ceiling:
| verdict | joule ceiling | funds ceiling |
|---|---|---|
| 0 none | `joules_ceiling` is 2^64 − 1 | `funds_ceiling` is 2^64 − 1 |
| 1 within | every hop that moved its bytes was metered, at least one did, and the attributed joules of every class together are at most the ceiling | the funds are at most the ceiling |
| 2 past | the attributed joules of every class together are above the ceiling | the funds are above the ceiling |
| 3 partial / no tariff | within on what was metered, and an unmetered hop moved its bytes | no funds were computed |
| 4 unmetered | no metered hop moved its bytes | — |
A joule ceiling is one number, and holding a use to it adds what each hop spent, whatever
its class. The verdict is that comparison; the sum it compares is stated nowhere as a
figure, and the attributed joules stay per class.
A grant's **consumption entry** is 198 bytes: `grant` (32), `joules_ceiling` (8),
`funds_ceiling` (8), `currency` (3), `publisher_us` (8), `subscriber_us` (9 × 8),
`composition_us` (8), `recorded_us` (8), `events_sent` (8), the attributed joules of tags 2,
3, 4 and 5 (4 × 8), `unmetered` (u8: 1 when its share is unmetered at some hop), `funds` (8),
and the joule and funds verdicts (u8 each). Its leaf is SHA-256 over the 25 bytes
`wai:metering-consumption` (ASCII) and 0x01, followed by the entry; the **consumption root**
is the root of the grants' leaves in order, and the receipt's `ceilings` states their number,
the root and every grant with a verdict of 2. Every principal can be shown its own entry with
its audit path against the receipt, and nobody else's.
**Past a ceiling.** live-rooms §4.5 fixes what a ceiling is: the energy and the funds a
grant's use may spend, which a meter holds the use to. How a meter holds it while the session
runs is the deployment's, and this extension changes neither the ceiling nor that. A meter
that ends a use past its ceiling does so through the room's own records: the clock removes
the principal (`leave`, reason 2) or the authority exchanges its grant (live-rooms §4.6). A
receipt records what the session did, not what it was allowed: a use past its ceiling is
counted whole, and the receipt names its grant among those past.
### 4.7 The QoE cell
The cell aggregates the session logs of §6 into one cell. With fewer than 50 logs it is
**suppressed** (delivery-telemetry §8.4: suppressed, not rounded): `suppressed` is 1 and
every other field 0. Otherwise `suppressed` is 0, and over the logs' session metrics
(delivery-telemetry §7.2): `started`, `start_failed` and `exit_before_start` count the logs
for which each holds; `stalls`, `stall_ms` and `engaged_ms` are sums; `vst_count` counts the
logs with a video start time and `vst_ms` sums those times. A cell of fewer than 50 logs that
is not suppressed is refused `qoe_not_suppressed`. The cell is the signer's aggregate of
statements nobody signed (delivery-telemetry §1.1) and **MUST NOT** be presented as evidence
of what a viewer saw; it carries no joule figure (§1.2 there).
## 5. Verification
### 5.1 Inputs and steps
A receipt verifier is given the room (a descriptor, grants, the head of the authority's key
log or none, and the chains, as live-rooms §9.1), the receipt, the per-hop records with their
ledgers, the composition descriptors and receipts, the take manifests, and the logs document
or none. It runs these steps and stops at the first refusal, which it reports with the
reason and where:
1. **The room** (live-rooms §9): its refusal, at `room`, with the room's own place. A gap in
a room key's chain is `lifecycle_incomplete` (live-compose §4.1), at `room`: the
lifecycle, and with it every unit, is unknown after it. A gap in another principal's
chain is not a refusal: the units count what the room verifier holds.
2. **The receipt** (§4.2), at `receipt`: `truncated`, `malformed`, `unknown_version`,
`classes_summed`, `session_invalid`; `wrong_room` when its `room` is not the room id;
`bad_key`, `bad_signature`.
3. **The signer** (§4.3), at `receipt`: `signer_not_authorised`, `signer_revoked`.
4. **Each hop entry**, in order, at `hop` and the entry's index:
1. a record whose digest is the entry's `record` is given, with a ledger: otherwise
`record_missing`. (The digest of given bytes is SHA-256 over all but their last 64.)
2. Its length is at most B(M) (§3.7): otherwise `record_too_large`.
3. It reads (§3.6): `truncated`, `malformed`, `unknown_version`, `energy_invalid`,
`record_invalid`; its `room` is the room id (`wrong_room`); its signature verifies
(`bad_key`, `bad_signature`).
4. The entry's `hop`, `role`, `joules` and `class` are the record's: otherwise
`unit_mismatch`.
5. The hop may sign for its role (§5.2): `hop_not_authorised`, `hop_revoked`.
6. Its span is within the session: `start` ≤ its `start` and its `end` ≤ `end`. Otherwise
`span_outside_session`.
7. Each media entry's `track` is a track the room published in the session (§5.3):
otherwise `track_unknown`.
8. Its ledger reads (§3.6, `ledger_invalid`); has `grants` entries and the root `ledger`;
and each entry names the zero grant or a grant of the room, with that grant's ceilings
and currency. Otherwise `ledger_mismatch`.
9. Its `subscriber_us` are the sums of its ledger's, class by class: otherwise
`unit_mismatch`.
5. **Each take**, in the receipt's order, at `take` and its index: a manifest with that
digest is given (`record_missing`); it reads and verifies (capture-binding §7.2, with
that section's reasons); and it is in the room as capture-binding §9.2 finds it, with that
section's reasons.
6. **The composition receipts**, at `compose`: the receipts given are as many as `compose`
states and have its list digest, otherwise `record_missing`; then each, in ascending
order of digest, at its index: it reads (live-compose §8.1, with that section's
reasons), the composition descriptor it names is given (`record_missing`), it verifies
against the room and that descriptor (live-compose §8.3, with that section's reasons),
and its `t` is in the session (`span_outside_session`).
7. **The logs**, at `qoe`: when `qoe` states logs, a logs document whose SHA-256 is its
digest is given (`record_missing`); it reads (§6.1, `qoe_invalid`); and it holds as many
logs as `qoe` states (`unit_mismatch`).
8. **The units** (§4.4), at `units`: `unit_mismatch`.
9. **The energy** (§4.5), at `energy`: `unmetered` is the number of unmetered hop entries;
then each stated total, in order, names a class among the hop entries' and states that
class's sum, or else is `classes_summed` when the sum it states is one §4.5 describes
and `unit_mismatch` otherwise, and states that class's number of figures; and the totals
name every class of the metered entries. Otherwise `unit_mismatch`.
10. **The QoE cell** (§4.7), at `qoe`: `qoe_not_suppressed`, then `unit_mismatch`.
11. **The ceilings** (§4.6), at `ceilings`: the number of grants, the consumption root and
the grants past a ceiling recompute; otherwise `ceiling_mismatch`.
A receipt that passes is **accepted**: every unit, every hop's figure, every total and every
grant's consumption is what the records it names give.
### 5.2 Who may sign a hop record
Where the room names an origin, the hop's key has an entry in the head given for the room
(live-rooms §5) whose `may_sign` holds the authority of its role, `live/relay`,
`live/compositor` or `live/ingest`, and, for a metered figure, `energy/` followed by the
label of its class (jwp-receipts §7.5): otherwise `hop_not_authorised`. An entry whose
`status` is `revoked` is `hop_revoked`. A hardware class's energy authority is confirmed only
under a pinned anchor (jwp-receipts §7.5, *Pinned-only authorities*); the verifier is a
function of the head its caller anchored, and the caller states which anchor that was.
Where the room names no origin, a hop's key is a room key (live-rooms §2): otherwise
`hop_not_authorised`.
### 5.3 The room's replay
Over the records the room verifier accepted, in the merged order (live-rooms §9.2), the room
keys' lifecycle events are applied as live-rooms §7.2 applies them, each taking effect at its
`t`: the room at an instant *x* is the state after every lifecycle event whose `t` is at most
*x* (live-compose §4.1). Between two consecutive instants at which lifecycle events fall, the
state is constant. From it:
- **who publishes**, and so `publisher_us`: a principal publishes while it has an own track
published or stands in a slot, live or standby;
- **the grant in effect** of each present principal, and so the grants of the session;
- **the media tracks published**, and so *M* (§3.7) and `track_unknown`: at each instant of
the session, the tracks of each own track's layers 0 to `layers` − 1 (owner the principal),
and of each slot's live publisher's layers (owner `_` and the slot's name), each as its
track digest. A standby's media is not delivered and adds no track.
### 5.4 Outcome
- **refuse**: the reason and where (§5.1).
- **accept**: the receipt's digest, the session's units, each hop record's digest, size and
bound, the totals and the coverage, each grant's consumption entry, the consumption root,
and the QoE cell.
## 6. Quality-of-experience observations
### 6.1 Observations and their logs
A client of the room reports its experience with the observations of
[delivery-telemetry](delivery-telemetry.md) §2, unchanged: `start`, `present`, `stall-start`,
`stall-end`, `end` and the rest, one session's in `sn` order (§7.1 there). Their Event-mode
reports are CMCD version 2 (CTA-5004-B), as delivery-telemetry §3 maps them. A **session
log** is one session's observations; the **logs document** is a JSON array of logs, each a
non-empty JSON array of observations, as UTF-8 bytes; it reads when every observation
reads (delivery-telemetry §2.4) and every log is a log (§7.1 there), and is refused
`qoe_invalid` otherwise. The receipt names the document by its SHA-256 and states the number
of logs; the cell (§4.7) is computed from their session metrics (delivery-telemetry §7.2).
### 6.2 On the CMCD event timeline
Over MoQ, a client publishes its reports on a CMCD track of
`draft-wilaw-moq-cmcd-event-timeline-00`: an MSF event timeline (MSF §8) whose catalog entry
is in the catalog's `publishTracks`, with `packaging` `eventtimeline`, `eventType`
`urn:cta:cmcd:2026` and a `cmcdConfig` (that draft's §4.2, §4.3). The room's catalog names
the track; its namespace's first field is the room id, so it is a track of the room, counted
among the other tracks (§3.2). Each record of the timeline is one Event-mode report:
- an observation that delivery-telemetry §3 reports in Request mode, a `response` (its
`e=rr`, which that draft's §3 bars) and a `present` that is not the session's first are
not carried;
- the record's wallclock index `T` is the report's `ts`, and `ts` is not in its `data` (that
draft's §4.2.1);
- `data` holds the report's members that the draft's §3 permits and the catalog's
`cmcdConfig` enables, `v` always, each value a string: an Integer's decimal digits, a
Token's characters, a String's characters without its quotes, an Inner List as RFC 9651
serializes it. `sf` and the custom keys of delivery-telemetry §4 are not among the keys
that draft permits and are not carried;
- where the catalog's track name asks for a player identifier (that draft's §5), the client
uses the observation's `sid`, fresh per session (delivery-telemetry §8.1), and never a
principal's key or any identifier that outlives the session.
The draft's own example writes Integers as JSON numbers; its normative text says that every
value is a string, and this revision follows the text. The reference writes the record with
`live_metering::cmcd_timeline_record`.
### 6.3 Over HTTP: `application/cmcd`
Over HTTP, a client sends its Event-mode reports to the room's service as CTA-5004-B's batch:
an HTTP POST whose body has the media type `application/cmcd`, one report a line, the lines
separated by one line feed and the last not followed by one, each line a report in the
`key=value` syntax with its keys in alphabetical order and no URL encoding. A report of
delivery-telemetry §3 is such a line already: an RFC 9651 Dictionary with its keys in
bytewise order, with no optional white space, every Event-mode report carrying `e`, `ts` and
`v`. CTA-5004-B cites RFC 8941, which RFC 9651 obsoletes; the reports use no type RFC 9651
added (Date, Display String), so a line is the same bytes under either. The reference writes
a batch with `live_metering::cmcd_batch_body`.
### 6.4 Privacy
- **What reaches the receipt**: the number of logs, the digest of the document, and the cell
of §4.7, suppressed below 50 sessions. Nothing per session.
- **What never leaves the session and the room's service**: the observations themselves,
their `sid`, their timestamps, the objects, URLs and timings they name, and each session's
metrics. A client sends them only to the room's own service (delivery-telemetry §8.1,
§8.3); a relay carries a CMCD track only to the subscribers the room's service names and
never fans it out to the room's participants. The digest in the receipt commits to the
document without revealing it: a party holding the document can check the cell, and one
that does not learns the cell alone.
- An observation names no principal; a client's `sid` is fresh per session and derived from
no identifier, so the logs of one viewer in two sessions do not link, and a log cannot be
matched to a grant. The cell is not per grant and not per layer class, so no cell is
smaller than the session.
## 7. C2PA
A room-session receipt maps onto C2PA 2.4's `c2pa.environmental-sustainability` assertion
(§18.26) in a manifest of an asset of the session, such as its recording
([live-recording](live-recording.md)) or its composite, under
[energy-binding](energy-binding.md)'s rules:
- **Energy.** The standard assertion holds one figure, `energy_kwh`. It is written only when
the receipt's energy is whole (§4.5) and every hop entry is of one class: then
`energy_kwh` is that class's total, derived (1 µJ is 2.8 × 10^-13 kWh, energy-binding §4),
and `measurementMethod` names this extension, the class and the hop count. With several
classes or an unmetered hop, no standard assertion is written: one figure would sum
classes or present a part as the whole.
- **GHG and water.** `carbon_kgco2e` and `water_litres` are written only when the emitter's
caller supplies them, each with the method that gave it, which the companion carries as
`carbonMethod` and `waterMethod`; the standard assertion is then written whether or not it
holds `energy_kwh`. The receipt holds neither figure, and an emitter **MUST NOT** derive
them from the energy and a grid or water factor (energy-binding §4).
- **The companion.** Beside it, never instead, the emitter writes the companion assertion
`science.transaction.wai.live.metering`: the receipt's digest, the room id, the session
span, the coverage, `unmetered`, the totals by class label, the units, and the number of
grants with the consumption root. It is written whether or not the standard assertion is,
since it is the only place the classes are stated. A reader refuses a standard assertion
with no companion, and an `energy_kwh` beside a companion that states several classes or
a partial total.
The reference implements this in `c2pa_emit` (feature `c2pa_emit` with `live_metering`):
`SessionManifest` writes the assertions into a real signed C2PA manifest of a given asset,
with the session's recording or the receipt as its parent ingredient and the action
`c2pa.converted`, every assertion created; `recover_session` reads them back, applying the
reader's rules above, and `RecoveredSession::describes` checks them against the receipt.
## 8. The reference relay
`joule-moq-relay` keeps a signed session summary of every MOQT session it serves, and signs a
per-hop record of a room from those summaries (`metering::hop_record`): for each track of the
room its sessions carried, the objects and bytes in and out, the drops by reason with the
receipts forwarded, its subscriber sessions' time by layer class from the subscriptions each
held, the events it delivered, and a ledger from the room grants its tokens map to. Its own
energy is the unmetered marker, since no meter backs it. It connects to no other hop, so
every session it serves that subscribes is a subscriber session. Its record recomputes from
its summaries, which its own test checks, and the corpus holds a record it signed in that
test (§10).
## 9. Reasons
The codes this extension adds, each with where it is raised:
| reason | raised at |
|---|---|
| `session_invalid` | §4.2, §5.1 step 2 |
| `signer_not_authorised` | §4.3, §5.1 step 3 |
| `signer_revoked` | §4.3, §5.1 step 3 |
| `record_missing` | §5.1 steps 4 to 7 |
| `record_too_large` | §3.7, §5.1 step 4 |
| `energy_invalid` | §3.6, §4.2 |
| `record_invalid` | §3.6 |
| `hop_not_authorised` | §5.2 |
| `hop_revoked` | §5.2 |
| `span_outside_session` | §5.1 steps 4 and 6 |
| `track_unknown` | §5.1 step 4 |
| `ledger_invalid` | §3.6 |
| `ledger_mismatch` | §5.1 step 4 |
| `unit_mismatch` | §4.4, §4.5, §4.7, §5.1 |
| `classes_summed` | §4.2, §4.5 |
| `ceiling_mismatch` | §4.6, §5.1 step 11 |
| `qoe_invalid` | §6.1 |
| `qoe_not_suppressed` | §4.7 |
A verifier of this extension also raises `truncated`, `malformed` and `unknown_version` for a
record or a receipt that does not read; `bad_key` and `bad_signature` for their signatures;
`wrong_room` for one of another room; every code a room verifier raises (live-rooms §11 and
the live-events codes it lists), at `room`; `lifecycle_incomplete` (live-compose §10), at
`room`; capture-binding §12's codes for a take, at `take`; and live-compose §10's for a
composition receipt, at `compose`.
## 10. Conformance
[`live-conformance/metering/`](../live-conformance/metering/) holds the corpus, in one file,
`vectors.json`, written from fixed seeds by `wai_live_metering_vectors gen`, and the record
the reference relay signed in its test, `relay-record.wmh` with its ledger
`relay-ledger.wml`, which the generator reads. It has rooms, and **vectors**, each a room, a
receipt and what it names, with the outcome §5.4 gives them:
- **Accepted**: a lesson room with a relay, a compositor and two recorded takes, its receipt
whole; mixed acquisition classes (a relay's counter reading, a compositor's model, an ingest
gateway's estimate) with one total per class; a viewer's grant past its joule ceiling and
another past its funds ceiling, named in the receipt; a large audience, ten thousand
subscriber sessions whose relay record is the size of the lesson's, with a QoE cell of more
than fifty logs; and the reference relay's own record, signed in its test, in a receipt
over the room its test publishes into.
- **Refused**: each reason of §9 at least once, each vector a control of one defect, and the
codes a receipt, a record, a take, a composition receipt and a room raise from the
extensions this one reads.
A conforming verifier gives every vector the outcome, the reason, the place and the facts
the corpus states. `wai_live_metering_vectors verify` and the test `tests/live_metering.rs`
check that the file regenerates byte for byte, that `wai-rs` gives every vector its outcome,
that every reason of §9 refuses a vector, and that §9 lists exactly the reference's codes.
`live-conformance/live_metering_verify.py` is the second implementation, written from this
text: it shares no code with `wai-rs`, verifies signatures with WAI's strict rule written
from RFC 8032 (`claim-conformance/_ed25519.py`), verifies each room, take and composition
receipt with the corpora's own second implementations (`live_rooms_verify.py`,
`capture_take_verify.py`, `live_compose_verify.py`), recomputes every unit, every total and
every grant's consumption itself, gives every vector the same outcome, and requires every
mutated copy of the corpus to fail.
## 11. Security and privacy considerations
- Every count a receipt states rests on a signature: a hop's on its record, a device's on
its take, a compositor's on its receipts, a principal's on its events. A meter that
inflates a unit is refused, since the verifier recomputes it; a hop that inflates its own
counts is caught only where another record contradicts it (an upstream relay's out against
a downstream one's in), and the receipt makes both comparable.
- A hop's figure is its own statement, and its class says how it was obtained
(energy-measurement §5): the receipt makes the figures attributable and their sums
recomputable, not true. An attributed share is an allocation by bytes and says so.
- The record's bound keeps what a hop sends upstream independent of the audience, so metering
a large audience costs a relay tree what metering a small one does.
- A ledger entry names a grant and its use; the ledger and the consumption entries are given
to the parties that verify the receipt, and each principal can be shown its own entry
alone, by its audit path. A grant names its principal (live-rooms §13), so a deployment
that publishes a receipt publishes its root and counts, not its ledgers.
- QoE observations are unsigned (delivery-telemetry §1.1); the receipt's cell is suppressed
below 50 sessions and never carries a session's data (§6.4).

View file

@ -11,13 +11,16 @@ and the independent verifier of each, beside it.
| [`compose/`](compose/) | [live-compose](../extensions/live-compose.md) (`wai.live.compose`) | `live_compose_verify.py` |
| [`recordings/`](recordings/) | [live-recording](../extensions/live-recording.md) (`wai.live.recording`) | `live_record_verify.py` |
| [`captions/`](captions/) | [timed-text](../extensions/timed-text.md) (`wai.text.timed`) | `timed_text_verify.py` |
| [`metering/`](metering/) | [live-metering](../extensions/live-metering.md) (`wai.live.metering`) | `live_metering_verify.py` |
**Provenance.** Generated here: `wai_live_events_vectors gen` writes `events/`,
`wai_live_room_vectors gen` writes `rooms/`, `wai_capture_take_vectors gen` writes
`takes/`, `wai_live_compose_vectors gen` writes `compose/`, `wai_live_record_vectors gen`
writes `recordings/` and `wai_timed_text_vectors gen` writes `captions/`, 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`). No recorded data.
writes `recordings/`, `wai_timed_text_vectors gen` writes `captions/` and
`wai_live_metering_vectors gen` writes `metering/`, 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`). One recorded record:
`metering/relay-record.wmh` and its ledger, which the reference relay signed in its test.
Each verifier is written from its extension's text, shares no code with `wai-rs`, and is
run by `.github/workflows/wai-conformance.yml` beside the reference's own check:
@ -29,4 +32,5 @@ python3 capture_take_verify.py takes
python3 live_compose_verify.py compose
python3 live_record_verify.py recordings
python3 timed_text_verify.py captions
python3 live_metering_verify.py metering
```

View file

@ -0,0 +1,986 @@
#!/usr/bin/env python3
"""Independent verifier of the live-metering corpus (extensions/live-metering.md).
Written from the extension's text. It shares no code with wai-rs, which generates the
corpus, and never runs it. It checks Ed25519 signatures with
../claim-conformance/_ed25519.py, WAI's strict rule (SPEC §7 *Signatures*) written from
RFC 8032 with the standard library. Each room is verified by the live-rooms corpus's own
second implementation, live_rooms_verify.py (live-rooms §9); each take by
capture_take_verify.py (capture-binding §7.2, §9.2); each composition receipt by
live_compose_verify.py (live-compose §8.3). Everything else is here, from the text:
- the per-hop session record (§3): read, its class, its rules, its size bound, its ledger
and the ledger's root;
- the room-session receipt (§4): read, its rules, who may sign it;
- the room's replay (§5.3): who publishes, the grants in effect, the media tracks
published;
- the units (§4.4), the joules per hop and per class (§4.5), each grant's consumption and
its verdicts (§4.6), and the QoE cell (§4.7) from the logs' session metrics
(delivery-telemetry §7), all recomputed with Python's integers.
For every vector it requires the outcome, reason and place, and the digest, units,
records, totals, coverage, consumption entries, root, grants past a ceiling and QoE cell
the corpus states; of the corpus, that every reason of §9 refuses a vector and that §9
lists exactly those reasons. Then it mutates copies of the corpus, one defect each, and
requires every copy to fail.
python3 live_metering_verify.py <live-conformance/metering>
Exit status 0 only if every vector verifies and every mutated copy fails. Python 3.8 or
later; standard library only.
"""
import copy
import hashlib
import json
import os
import re
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
import live_events_verify as LEV # noqa: E402
import live_rooms_verify as LRV # noqa: E402
import capture_take_verify as CT # noqa: E402
import live_compose_verify as LCV # noqa: E402
import _ed25519 as ED # noqa: E402
Refuse = LEV.Refuse
HOP_MAGIC = b"WMH1"
SESSION_MAGIC = b"WMR1"
HOP_DOMAIN = b"wai:metering-hop\x01"
SESSION_DOMAIN = b"wai:metering-session\x01"
LEDGER_DOMAIN = b"wai:metering-ledger\x01"
CONSUMPTION_DOMAIN = b"wai:metering-consumption\x01"
NODE_DOMAIN = b"wai:out-node\x01"
NO_CEILING = 2 ** 64 - 1
NO_FUNDS = 2 ** 64 - 1
CLASSES = 9
GAP_US = 1000000
QOE_MIN = 50
FLOOR = 288676
LABELS = {2: "ModelBased", 3: "Estimator", 4: "OnChipCounter", 5: "CalibratedInstrument"}
HOP_AUTHORITY = {1: "live/relay", 2: "live/compositor", 3: "live/ingest"}
METER_AUTHORITY = "live/meter"
ZERO = bytes(32)
JOULES_VERDICT = ["none", "within", "past", "partial", "unmetered"]
FUNDS_VERDICT = ["none", "within", "past", "no_tariff"]
LIFECYCLE = range(0x10, 0x17)
REASONS = [
"session_invalid", "signer_not_authorised", "signer_revoked", "record_missing", "record_too_large",
"energy_invalid", "record_invalid", "hop_not_authorised", "hop_revoked", "span_outside_session",
"track_unknown", "ledger_invalid", "ledger_mismatch", "unit_mismatch", "classes_summed",
"ceiling_mismatch", "qoe_invalid", "qoe_not_suppressed",
]
class Stop(Exception):
def __init__(self, reason, at):
super().__init__(reason)
self.reason, self.at = reason, at
def sha(*parts):
h = hashlib.sha256()
for p in parts:
h.update(p)
return h.digest()
def bound(m):
"""§3.7: 552 + 180 × (M + 2)."""
return 552 + 180 * (m + 2)
def given_digest(b):
"""§5.1 step 4: SHA-256 over all but the last 64 bytes."""
return sha(b[:max(len(b) - 64, 0)])
def node(a, b):
return sha(NODE_DOMAIN, a, b)
def mth(leaves):
"""RFC 6962 §2.1, with the node of staged-measurement Appendix C; zero for none."""
if not leaves:
return ZERO
if len(leaves) == 1:
return leaves[0]
k = 1
while k * 2 < len(leaves):
k *= 2
return node(mth(leaves[:k]), mth(leaves[k:]))
class R:
"""A cursor; running out is `truncated`."""
def __init__(self, b, p=0):
self.b, self.p = b, p
def take(self, n):
if self.p + n > len(self.b):
raise Refuse("truncated")
s = self.b[self.p:self.p + n]
self.p += n
return s
def u8(self):
return self.take(1)[0]
def u16(self):
return struct.unpack("<H", self.take(2))[0]
def u32(self):
return struct.unpack("<I", self.take(4))[0]
def u64(self):
return struct.unpack("<Q", self.take(8))[0]
def be32(self):
return struct.unpack(">I", self.take(4))[0]
def be64(self):
return struct.unpack(">Q", self.take(8))[0]
def done(self):
return self.p == len(self.b)
def read_head(b, magic):
if len(b) < 4:
raise Refuse("truncated")
if b[:4] != magic:
raise Refuse("malformed")
if len(b) < 5:
raise Refuse("truncated")
if b[4] != 1:
raise Refuse("unknown_version")
INVALID = "invalid"
def read_class(r):
"""energy-measurement §2.7's tag and evidence: None (tag 0), a tuple, or INVALID."""
tag = r.u8()
if tag == 0:
return None
if tag in (2, 3):
return (tag,)
if tag == 4:
ppm, window, f = r.be32(), r.be64(), r.u8()
return (4, ppm, window, f) if ppm > 0 and window > 0 and f in (0, 1, 2) else INVALID
if tag == 5:
ppm, window, n = r.be32(), r.be64(), r.be32()
if n > 255:
raise Refuse("malformed")
ref = r.take(n)
try:
text = ref.decode("utf-8")
except UnicodeDecodeError:
return INVALID
return (5, ppm, window, text) if ppm > 0 and window > 0 and text.strip() != "" else INVALID
return INVALID
def energy_ok(joules, cls):
"""§3.6's `energy_invalid` rules."""
if cls == INVALID:
return False
if cls is None:
return joules == 0
if joules == 0:
return False
return cls[0] not in (4, 5) or joules * cls[1] >= FLOOR
def check_sig(key, domain, body, sig):
bad = ED.verify(key, domain + body, sig)
if bad:
raise Refuse(bad)
# ---------------------------------------------------------------- §3 the per-hop record
def read_hop(b, room):
"""§3.6: the record, its class, its rules, its room, its signature."""
read_head(b, HOP_MAGIC)
r = R(b, 5)
x = {"room": r.take(32), "hop": r.take(32), "role": r.u8(), "start": r.u64(), "end": r.u64(), "joules": r.u64()}
x["class"] = read_class(r)
x["tracks"] = []
for _ in range(r.u16()):
e = {"kind": r.u8(), "track": r.take(32), "dir": r.u8()}
for k in ("objects", "bytes", "timeout", "refused", "duplicate", "closed", "receipted"):
e[k] = r.u64()
x["tracks"].append(e)
x["sessions"] = r.u32()
x["sub"] = [r.u64() for _ in range(CLASSES)]
x["events"], x["grants"], x["ledger"] = r.u64(), r.u32(), r.take(32)
sig = r.take(64)
if not r.done():
raise Refuse("malformed")
if not energy_ok(x["joules"], x["class"]):
raise Refuse("energy_invalid")
order = [(e["kind"], e["track"], e["dir"]) for e in x["tracks"]]
def entry_ok(e):
if e["kind"] == 1:
kind_ok = e["track"] != ZERO
elif e["kind"] in (2, 3):
kind_ok = e["track"] == ZERO
else:
return False
if e["dir"] == 1:
return kind_ok and e["timeout"] == 0 and e["closed"] == 0 and e["receipted"] == 0 \
and e["refused"] + e["duplicate"] <= e["objects"]
if e["dir"] == 2:
return kind_ok and e["refused"] == 0 and e["duplicate"] == 0 and e["receipted"] <= e["timeout"] + e["closed"]
return False
rules = (x["role"] in (1, 2, 3) and x["start"] < x["end"] and all(a < z for a, z in zip(order, order[1:]))
and all(entry_ok(e) for e in x["tracks"])
and (x["sessions"] != 0 or (not any(x["sub"]) and x["events"] == 0))
and ((x["grants"] == 0) == (x["ledger"] == ZERO)))
if not rules:
raise Refuse("record_invalid")
if x["room"] != room:
raise Refuse("wrong_room")
check_sig(x["hop"], HOP_DOMAIN, b[:-64], sig)
x["digest"] = sha(b[:-64])
return x
def read_ledger(b):
"""§3.6: the ledger's entries, or Refuse("ledger_invalid")."""
if len(b) % 143:
raise Refuse("ledger_invalid")
out = []
for k in range(0, len(b), 143):
c = b[k:k + 143]
e = {"raw": c, "grant": c[:32], "jc": int.from_bytes(c[32:40], "little"), "fc": int.from_bytes(c[40:48], "little"),
"currency": c[48:51], "sessions": int.from_bytes(c[51:55], "little"),
"in": int.from_bytes(c[55:63], "little"), "out": int.from_bytes(c[63:71], "little"),
"sub": [int.from_bytes(c[71 + 8 * i:79 + 8 * i], "little") for i in range(CLASSES)]}
currency_ok = e["currency"] == b"\x00\x00\x00" if e["fc"] == NO_CEILING else all(0x41 <= x <= 0x5A for x in e["currency"])
zero_ok = e["grant"] != ZERO or (e["jc"] == NO_CEILING and e["fc"] == NO_CEILING)
if not currency_ok or not zero_ok or (out and out[-1]["grant"] >= e["grant"]):
raise Refuse("ledger_invalid")
out.append(e)
return out
def ledger_root(entries):
return mth([sha(LEDGER_DOMAIN, e["raw"]) for e in entries])
# ---------------------------------------------------------------- §4 the receipt
def read_receipt(b, room):
"""§4.2: the receipt, `classes_summed` for a total of tag 0, its rules, its room, its signature."""
read_head(b, SESSION_MAGIC)
r = R(b, 5)
x = {"room": r.take(32), "signer": r.take(32), "start": r.u64(), "end": r.u64()}
x["hops"], classes_ok = [], True
for _ in range(r.u8()):
h = {"record": r.take(32), "hop": r.take(32), "role": r.u8(), "joules": r.u64()}
h["class"] = read_class(r)
classes_ok = classes_ok and energy_ok(h["joules"], h["class"])
x["hops"].append(h)
x["takes"] = [r.take(32) for _ in range(r.u8())]
x["compose_count"], x["compose_digest"] = r.u32(), r.take(32)
x["qoe_count"], x["qoe_digest"] = r.u32(), r.take(32)
x["units"] = units_of([r.u64() for _ in range(15)])
x["unmetered"] = r.u8()
x["totals"] = [(r.u8(), r.u32(), r.u64()) for _ in range(r.u8())]
x["currency"] = r.take(3)
x["prices"] = [r.u32() for _ in range(13)]
x["grants"], x["root"] = r.u32(), r.take(32)
x["past"] = [(r.take(32), r.u8()) for _ in range(r.u32())]
cell = [r.u8()] + [r.u32() for _ in range(3)] + [r.u64() for _ in range(3)] + [r.u32(), r.u64()]
x["cell"] = cell_dict(cell)
sig = r.take(64)
if not r.done():
raise Refuse("malformed")
if any(t[0] == 0 for t in x["totals"]):
raise Refuse("classes_summed")
tariff_ok = all(p == 0 for p in x["prices"]) if x["currency"] == b"\x00\x00\x00" else all(0x41 <= c <= 0x5A for c in x["currency"])
recs, takes, tags, past = [h["record"] for h in x["hops"]], x["takes"], [t[0] for t in x["totals"]], [p[0] for p in x["past"]]
rules = (x["start"] < x["end"] and all(a < z for a, z in zip(recs, recs[1:])) and all(h["role"] in (1, 2, 3) for h in x["hops"])
and classes_ok and all(a < z for a, z in zip(takes, takes[1:]))
and ((x["compose_count"] == 0) == (x["compose_digest"] == ZERO))
and ((x["qoe_count"] == 0) == (x["qoe_digest"] == ZERO))
and all(a < z for a, z in zip(tags, tags[1:])) and all(2 <= t[0] <= 5 and t[1] > 0 for t in x["totals"])
and tariff_ok and all(a < z for a, z in zip(past, past[1:])) and all(f in (1, 2, 3) for _, f in x["past"])
and (cell[0] == 0 or (cell[0] == 1 and not any(cell[1:]))))
if not rules:
raise Refuse("session_invalid")
if x["room"] != room:
raise Refuse("wrong_room")
check_sig(x["signer"], SESSION_DOMAIN, b[:-64], sig)
x["digest"] = sha(b[:-64])
return x
def units_of(v):
return {"publisher_us": v[0], "subscriber_us": v[1:10], "sessions": v[10], "composition_us": v[11],
"events_sent": v[12], "events_delivered": v[13], "recorded_us": v[14]}
def cell_dict(c):
return {"suppressed": c[0] == 1, "started": c[1], "start_failed": c[2], "exit_before_start": c[3], "stalls": c[4],
"stall_ms": c[5], "engaged_ms": c[6], "vst_count": c[7], "vst_ms": c[8]}
# ---------------------------------------------------------------- §5.3 the room
EMPTY = {"present": {}, "own": {}, "slots": None}
class Ctx:
"""A room that verified: its descriptor, grants, key log, accepted records and replay."""
def __init__(self, room):
descriptor = bytes.fromhex(room["descriptor"])
grants = [bytes.fromhex(g) for g in room["grants"]]
head = room["key_log_head"]
self.chains = [[bytes.fromhex(x) for x in c] for c in room["chains"]]
out = LRV.verify_room(descriptor, grants, head, self.chains)
if out["outcome"] == "refuse":
raise Stop(out["reason"], ["room"] + out["at"])
self.d = LRV.read_descriptor(descriptor)
self.id = self.d["room"]
if out["outcome"] == "gap" and any(LRV.is_room_key(self.d, bytes.fromhex(g["principal"])) for g in out["gaps"]):
raise Stop("lifecycle_incomplete", ["room"])
self.gaps = out.get("gaps", [])
held, _ = LRV.check_grants(self.d, grants)
self.grants = {g["digest"]: g for g in held}
self.log = json.loads(head) if self.d["origin"] != b"" else None
self.view = LCV.view_room(room)
self.seen, digests, instants = [], set(), set()
for c, i, g in out["order"]:
ev = LEV.read_record(self.chains[c][i])
if ev["digest"] in digests:
continue
digests.add(ev["digest"])
self.seen.append({"chain": c, "record": i, "ev": ev, "grant": bytes.fromhex(g) if g else None})
if LRV.is_room_key(self.d, ev["principal"]) and ev["kind"] in LIFECYCLE:
instants.add(ev["t"])
empty = {"present": {}, "own": {}, "slots": [{"live": None, "standby": None} for _ in self.d["slots"]]}
self.segments = [[0, empty]]
for x in sorted(instants):
st = self.view.at(x)
if x == 0:
self.segments[0][1] = st
else:
self.segments.append([x, st])
def over(self, s, e):
out = []
for k, (a, st) in enumerate(self.segments):
z = self.segments[k + 1][0] if k + 1 < len(self.segments) else 2 ** 64
lo, hi = max(a, s), min(z, e)
if lo < hi:
out.append((st, lo, hi))
return out
def at(self, x):
return next(st for a, st in reversed(self.segments) if a <= x)
def published(self, st):
out = set()
for (p, m), (_, layers) in st["own"].items():
out |= {LCV.track_digest(self.id, p.hex(), m, str(n)) for n in range(layers)}
for slot, s in zip(self.d["slots"], st["slots"]):
if s["live"]:
out |= {LCV.track_digest(self.id, "_" + slot["name"].decode(), slot["media"], str(n)) for n in range(s["live"][2])}
return out
def ceilings(self, g):
if g == ZERO:
return (NO_CEILING, NO_CEILING, b"\x00\x00\x00")
x = self.grants.get(g)
return None if x is None else (x["joules"], x["funds"], x["currency"])
def entry(self, key):
if self.log is None:
return None
return next((e for e in self.log["keys"] if e["key"] == key.hex()), None)
def publishers(st):
out = {p for p, _ in st["own"]}
for s in st["slots"]:
out |= {h[0] for h in (s["live"], s["standby"]) if h}
return out
def hop_authorised(ctx, x):
"""§5.2."""
if ctx.d["origin"] == b"":
if not LRV.is_room_key(ctx.d, x["hop"]):
raise Refuse("hop_not_authorised")
return
e = ctx.entry(x["hop"])
if e is None:
raise Refuse("hop_not_authorised")
need = [HOP_AUTHORITY[x["role"]]] + (["energy/" + LABELS[x["class"][0]]] if x["class"] else [])
if not all(a in e["may_sign"] for a in need):
raise Refuse("hop_not_authorised")
if e["status"] == "revoked":
raise Refuse("hop_revoked")
def signer_authorised(ctx, rc):
"""§4.3."""
d = ctx.d
if d["origin"] == b"":
if rc["signer"] != d["authority"]:
raise Refuse("signer_not_authorised")
return
e = ctx.entry(rc["signer"])
if e is None or not (rc["signer"] == d["authority"] or METER_AUTHORITY in e["may_sign"]):
raise Refuse("signer_not_authorised")
if not all("energy/" + LABELS[t[0]] in e["may_sign"] for t in rc["totals"]):
raise Refuse("signer_not_authorised")
if e["status"] == "revoked":
raise Refuse("signer_revoked")
# ---------------------------------------------------------------- delivery-telemetry §2, §7
EVENTS = {"request", "response", "start", "present", "switch", "layer-change", "stall-start", "stall-end", "frame-drop",
"capable", "abandon", "end"}
MEMBERS = {"v", "sid", "sn", "ts_ms", "event", "cid", "st", "sf", "object", "present", "switch", "layer_change", "capable",
"cohort", "metrics", "claim"}
WITH = {"object": ("request", "response"), "present": ("present",), "switch": ("switch",),
"layer_change": ("layer-change",), "capable": ("capable",)}
def is_int(v):
return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 2 ** 53 - 1
def read_observation(o):
"""delivery-telemetry §2.1 and §2.4, as far as the session metrics read an observation."""
if not isinstance(o, dict) or set(o) != MEMBERS or o["v"] != 1 or isinstance(o["v"], bool):
raise Refuse("qoe_invalid")
if not isinstance(o["sid"], str) or not re.fullmatch(r"[0-9a-f]{32}", o["sid"]):
raise Refuse("qoe_invalid")
if not is_int(o["sn"]) or not (o["ts_ms"] is None or is_int(o["ts_ms"])) or o["event"] not in EVENTS:
raise Refuse("qoe_invalid")
if o["st"] not in ("v", "l", "ll") or o["sf"] not in ("o", "h", "d"):
raise Refuse("qoe_invalid")
for k, events in WITH.items():
if (o[k] is not None) != (o["event"] in events):
raise Refuse("qoe_invalid")
p = o["present"]
if p is not None:
if not isinstance(p, dict) or set(p) != {"unit", "layers", "first", "late_ms", "decode_us"}:
raise Refuse("qoe_invalid")
if not is_int(p["unit"]) or not is_int(p["layers"]) or p["layers"] == 0 or not isinstance(p["first"], bool):
raise Refuse("qoe_invalid")
if o["claim"] is not None and o["event"] != "end":
raise Refuse("qoe_invalid")
event_mode = o["event"] != "request" and (o["event"] != "present" or p["first"])
if event_mode and o["ts_ms"] is None:
raise Refuse("qoe_invalid")
return o
def session_metrics(log):
"""delivery-telemetry §7: one log's metrics, or Refuse("qoe_invalid") for one that is not a log (§7.1)."""
m = {"started": False, "vst": None, "exit_before_start": False, "start_failed": False, "stalls": 0, "stall_ms": 0, "engaged_ms": 0}
start = present = stall = None
last, ended = 0, False
for i, o in enumerate(log):
if o["sid"] != log[0]["sid"] or (i > 0 and o["sn"] <= log[i - 1]["sn"]) or ended:
raise Refuse("qoe_invalid")
t = o["ts_ms"]
if t is not None:
if t < last:
raise Refuse("qoe_invalid")
last = t
e = o["event"]
if e == "start" and start is None:
m["started"], start = True, t
elif e == "present":
if o["present"]["first"]:
if present is not None:
raise Refuse("qoe_invalid")
present = t
m["vst"] = t - min(start, t) if start is not None and t is not None else None
elif e == "stall-start" and present is not None and stall is None:
stall = t
elif e == "stall-end":
if stall is not None and t is not None:
m["stalls"] += 1
m["stall_ms"] += t - stall
stall = None
elif e in ("abandon", "end"):
ended = True
if stall is not None:
m["stalls"] += 1
m["stall_ms"] += t - stall
stall = None
if present is not None:
m["engaged_ms"] = t - present
elif e == "abandon":
m["exit_before_start"] = True
else:
m["start_failed"] = m["started"]
return m
def read_logs(b):
"""§6.1: each log's metrics."""
try:
doc = json.loads(b.decode("utf-8"))
except (UnicodeDecodeError, ValueError):
raise Refuse("qoe_invalid")
if not isinstance(doc, list):
raise Refuse("qoe_invalid")
out = []
for log in doc:
if not isinstance(log, list) or not log:
raise Refuse("qoe_invalid")
out.append(session_metrics([read_observation(o) for o in log]))
return out
def cell_of(ms):
"""§4.7."""
if len(ms) < QOE_MIN:
return cell_dict([1] + [0] * 8)
return cell_dict([0, sum(m["started"] for m in ms), sum(m["start_failed"] for m in ms), sum(m["exit_before_start"] for m in ms),
sum(m["stalls"] for m in ms), sum(m["stall_ms"] for m in ms), sum(m["engaged_ms"] for m in ms),
sum(m["vst"] is not None for m in ms), sum(m["vst"] for m in ms if m["vst"] is not None)])
# ---------------------------------------------------------------- §5 verification
def u64s(*v):
return b"".join(struct.pack("<Q", x) for x in v)
def evaluate(ctx, s, e, hops, takes, compositions, descriptors, logs, prices, currency):
"""§5.1 steps 4 to 7 and the computations of §4: what the session gives, or Stop."""
room = ctx.id
published = set()
for st, _, _ in ctx.over(s, e):
published |= ctx.published(st)
m = len(published)
b_ = bound(m)
records = []
for i, (given, entry) in enumerate(hops):
at = ["hop", i]
try:
if given is None:
raise Refuse("record_missing")
rb, lb = given
if len(rb) > b_:
raise Refuse("record_too_large")
x = read_hop(rb, room)
if entry is not None and (entry["hop"], entry["role"], entry["joules"], entry["class"]) != (x["hop"], x["role"], x["joules"], x["class"]):
raise Refuse("unit_mismatch")
hop_authorised(ctx, x)
if x["start"] < s or x["end"] > e:
raise Refuse("span_outside_session")
if any(t["kind"] == 1 and t["track"] not in published for t in x["tracks"]):
raise Refuse("track_unknown")
led = read_ledger(lb)
if len(led) != x["grants"] or ledger_root(led) != x["ledger"] \
or any(ctx.ceilings(l["grant"]) != (l["jc"], l["fc"], l["currency"]) for l in led):
raise Refuse("ledger_mismatch")
if any(sum(l["sub"][c] for l in led) != x["sub"][c] for c in range(CLASSES)):
raise Refuse("unit_mismatch")
except Refuse as err:
raise Stop(err.reason, at)
x["ledger_entries"], x["size"], x["bound"] = led, len(rb), b_
records.append(x)
take_digests, recorded = [], []
for i, given in enumerate(takes):
try:
if given is None:
raise Refuse("record_missing")
mf = CT.read_manifest(given, room)
_, _, _, commits = CT.find(ctx.d, ctx.grants, ctx.seen, ctx.gaps, mf)
except Refuse as err:
raise Stop(err.reason, ["take", i])
except CT.Stop as err:
raise Stop(err.reason, ["take", i])
take_digests.append(sha(given[:-64]))
us = 0
if commits:
o, size = commits[0][1]["offset"], commits[-1][1]["size"]
for c in mf["chunks"][:min(size, len(mf["chunks"]))]:
us += max(min(c["end"] + o, e) - max(c["start"] + o, s), 0)
recorded.append((us, commits[0][0]["grant"] if commits else None))
digests = [given_digest(b) for b in compositions]
outputs = {}
for i, b in enumerate(compositions):
try:
rc = LCV.read_receipt(b)
dbytes = next((d for d in descriptors if given_digest(d) == rc["descriptor"]), None)
if dbytes is None:
raise Refuse("record_missing")
out = LCV.verify_receipt(ctx.view, dbytes, b)
if out["outcome"] == "refuse":
raise Refuse(out["reason"])
if rc["t"] < s or rc["t"] >= e:
raise Refuse("span_outside_session")
except Refuse as err:
raise Stop(err.reason, ["compose", i])
outputs.setdefault((rc["compositor"], rc["media"], rc["output"]["track"]), []).append((rc["t"], rc["compositor"]))
metrics = []
if logs is not None:
try:
metrics = read_logs(logs)
except Refuse as err:
raise Stop(err.reason, ["qoe"])
# §4.4 the units and §4.6 the consumption.
units = {"publisher_us": 0, "subscriber_us": [0] * CLASSES, "sessions": 0, "composition_us": 0, "events_sent": 0,
"events_delivered": 0, "recorded_us": 0}
per = {}
def grant(g):
if g not in per:
jc, fc, cur = ctx.ceilings(g) or (NO_CEILING, NO_CEILING, b"\x00\x00\x00")
per[g] = {"grant": g, "jc": jc, "fc": fc, "currency": cur, "pub": 0, "sub": [0] * CLASSES, "comp": 0, "rec": 0,
"ev": 0, "attr": [0, 0, 0, 0], "unmetered": False, "metered": False}
return per[g]
for st, a, z in ctx.over(s, e):
for p in publishers(st):
units["publisher_us"] += z - a
if p in st["present"]:
grant(st["present"][p])["pub"] += z - a
for g in st["present"].values():
grant(g)
for x in records:
for c in range(CLASSES):
units["subscriber_us"][c] += x["sub"][c]
units["sessions"] += x["sessions"]
units["events_delivered"] += x["events"]
moved = sum(l["in"] + l["out"] for l in x["ledger_entries"])
for l in x["ledger_entries"]:
g = grant(l["grant"])
for c in range(CLASSES):
g["sub"][c] += l["sub"][c]
b = l["in"] + l["out"]
if b == 0:
continue
if x["class"] is None:
g["unmetered"] = True
else:
g["attr"][x["class"][0] - 2] += x["joules"] * b // moved
g["metered"] = True
for runs in outputs.values():
runs.sort()
for (t1, comp), (t2, _) in zip(runs, runs[1:]):
if t2 - t1 <= GAP_US:
units["composition_us"] += t2 - t1
g = ctx.at(t1)["present"].get(comp)
if g is not None:
grant(g)["comp"] += t2 - t1
for x in ctx.seen:
if s <= x["ev"]["t"] < e:
units["events_sent"] += 1
if x["grant"] is not None:
grant(x["grant"])["ev"] += 1
for us, g in recorded:
units["recorded_us"] += us
if g is not None:
grant(g)["rec"] += us
totals = {}
for x in records:
if x["class"] is not None:
f, j = totals.get(x["class"][0], (0, 0))
totals[x["class"][0]] = (f + 1, j + x["joules"])
consumption = []
for g in sorted(per):
c = per[g]
attributed = sum(c["attr"])
if c["jc"] == NO_CEILING:
jv = 0
elif attributed > c["jc"]:
jv = 2
elif not c["metered"]:
jv = 4
elif c["unmetered"]:
jv = 3
else:
jv = 1
priced = currency != b"\x00\x00\x00" and currency == c["currency"]
funds = NO_FUNDS
if priced:
n = c["pub"] * prices[0] + sum(c["sub"][k] * prices[1 + k] for k in range(CLASSES)) + c["comp"] * prices[10] \
+ c["rec"] * prices[11] + c["ev"] * prices[12] * 10 ** 6
funds = min(-(-n // 10 ** 12), NO_FUNDS - 1)
if c["fc"] == NO_CEILING:
fv = 0
elif not priced:
fv = 3
elif funds > c["fc"]:
fv = 2
else:
fv = 1
raw = (g + u64s(c["jc"], c["fc"]) + c["currency"] + u64s(c["pub"], *c["sub"], c["comp"], c["rec"], c["ev"], *c["attr"])
+ bytes([1 if c["unmetered"] else 0]) + u64s(funds) + bytes([jv, fv]))
assert len(raw) == 198
c.update(funds=funds, jv=jv, fv=fv, leaf=sha(CONSUMPTION_DOMAIN, raw))
consumption.append(c)
return {"records": records, "m": m, "units": units, "totals": totals, "takes": take_digests, "compose_digests": digests,
"metrics": metrics, "consumption": consumption}
def list_digest(digests):
return sha(b"".join(digests)) if digests else ZERO
def check_totals(rc, comp):
"""§5.1 step 9."""
unmetered = sum(1 for x in comp["records"] if x["class"] is None)
if rc["unmetered"] != unmetered:
raise Refuse("unit_mismatch")
tt = comp["totals"]
for tag, figures, joules in rc["totals"]:
if tag not in tt:
raise Refuse("unit_mismatch")
if joules != tt[tag][1]:
others = [c for c in tt if c != tag]
summed = any(tt[tag][1] + sum(tt[o][1] for k, o in enumerate(others) if mask >> k & 1) == joules
for mask in range(1, 1 << len(others)))
raise Refuse("classes_summed" if summed else "unit_mismatch")
if figures != tt[tag][0]:
raise Refuse("unit_mismatch")
if len(rc["totals"]) != len(tt):
raise Refuse("unit_mismatch")
def verify_vector(v, doc, cache):
"""§5: the outcome the vector should state."""
try:
ctx = cache.ctx(doc["rooms"][v["room"]])
except Stop as st:
return {"outcome": "refuse", "reason": st.reason, "at": st.at}
given_hops = [(bytes.fromhex(h["record"]), bytes.fromhex(h["ledger"])) for h in v["hops"]]
descriptors = [bytes.fromhex(x) for x in v["descriptors"]]
compositions = [bytes.fromhex(x) for x in v["compositions"]]
takes = [bytes.fromhex(x) for x in v["takes"]]
logs = v["logs"].encode("utf-8") if v["logs"] is not None else None
try:
rc = read_receipt(bytes.fromhex(v["receipt"]), ctx.id)
signer_authorised(ctx, rc)
except Refuse as err:
return {"outcome": "refuse", "reason": err.reason, "at": ["receipt"]}
hops = [(next(((r, l) for r, l in given_hops if given_digest(r) == h["record"]), None), h) for h in rc["hops"]]
named_takes = [next((t for t in takes if given_digest(t) == d), None) for d in rc["takes"]]
comps = sorted(compositions, key=given_digest)
s, e = rc["start"], rc["end"]
try:
if len(comps) != rc["compose_count"] or list_digest([given_digest(b) for b in comps]) != rc["compose_digest"]:
evaluate(ctx, s, e, hops, named_takes, [], descriptors, None, rc["prices"], rc["currency"])
raise Stop("record_missing", ["compose"])
named_logs = None
if rc["qoe_count"] != 0:
if logs is None or sha(logs) != rc["qoe_digest"]:
evaluate(ctx, s, e, hops, named_takes, comps, descriptors, None, rc["prices"], rc["currency"])
raise Stop("record_missing", ["qoe"])
named_logs = logs
comp = evaluate(ctx, s, e, hops, named_takes, comps, descriptors, named_logs, rc["prices"], rc["currency"])
if len(comp["metrics"]) != rc["qoe_count"]:
raise Stop("unit_mismatch", ["qoe"])
if comp["units"] != rc["units"]:
raise Stop("unit_mismatch", ["units"])
try:
check_totals(rc, comp)
except Refuse as err:
raise Stop(err.reason, ["energy"])
cell = cell_of(comp["metrics"])
if len(comp["metrics"]) < QOE_MIN and not rc["cell"]["suppressed"]:
raise Stop("qoe_not_suppressed", ["qoe"])
if cell != rc["cell"]:
raise Stop("unit_mismatch", ["qoe"])
root = mth([c["leaf"] for c in comp["consumption"]])
past = [(c["grant"], (c["jv"] == 2) | ((c["fv"] == 2) << 1)) for c in comp["consumption"] if c["jv"] == 2 or c["fv"] == 2]
if len(comp["consumption"]) != rc["grants"] or root != rc["root"] or past != rc["past"]:
raise Stop("ceiling_mismatch", ["ceilings"])
except Stop as st:
return {"outcome": "refuse", "reason": st.reason, "at": st.at}
whole = not any(x["class"] is None for x in comp["records"]) and len(comp["records"]) > 0
def ceiling(x):
return None if x == NO_CEILING else x
return {
"outcome": "accept",
"digest": rc["digest"].hex(),
"media_tracks": comp["m"],
"records": [{"digest": x["digest"].hex(), "size": x["size"], "bound": x["bound"]} for x in comp["records"]],
"units": comp["units"],
"unmetered": rc["unmetered"],
"totals": [{"class": LABELS[t], "figures": f, "joules": j} for t, (f, j) in sorted(comp["totals"].items())],
"coverage": "whole" if whole else "partial",
"consumption": [{
"grant": c["grant"].hex(), "joules_ceiling": ceiling(c["jc"]), "funds_ceiling": ceiling(c["fc"]),
"currency": None if c["currency"] == b"\x00\x00\x00" else c["currency"].decode(),
"publisher_us": c["pub"], "subscriber_us": c["sub"], "composition_us": c["comp"], "recorded_us": c["rec"],
"events_sent": c["ev"], "attributed": {LABELS[t]: c["attr"][t - 2] for t in (2, 3, 4, 5)},
"unmetered": c["unmetered"], "funds": None if c["funds"] == NO_FUNDS else c["funds"],
"joules_verdict": JOULES_VERDICT[c["jv"]], "funds_verdict": FUNDS_VERDICT[c["fv"]], "leaf": c["leaf"].hex(),
} for c in comp["consumption"]],
"consumption_root": root.hex(),
"past": [{"grant": g.hex(), "flags": f} for g, f in past],
"qoe_cell": cell,
}
class Cache:
"""Each room of the corpus verified once, by its content."""
def __init__(self):
self.rooms = {}
def ctx(self, room):
k = json.dumps(room, sort_keys=True)
if k not in self.rooms:
try:
self.rooms[k] = Ctx(room)
except Stop as st:
self.rooms[k] = st
got = self.rooms[k]
if isinstance(got, Stop):
raise got
return got
OUTCOME_KEYS = ("outcome", "reason", "at", "digest", "media_tracks", "records", "units", "unmetered", "totals", "coverage",
"consumption", "consumption_root", "past", "qoe_cell")
def check(doc, cache, quiet=False, only=None):
fails = []
for v in doc["vectors"]:
if only is not None and v["name"] not in only:
continue
try:
got = verify_vector(v, doc, cache)
except Exception as e: # a crash on a mutated copy is a failure to read it
got, problems = None, ["the vector does not verify: %s: %s" % (type(e).__name__, e)]
else:
problems = [k for k in OUTCOME_KEYS if v.get(k) != got.get(k)]
if not quiet:
what = "?" if got is None else got["outcome"] + (" " + got["reason"] if got["outcome"] == "refuse" else "")
print(("PASS" if not problems else "FAIL") + " vector %s: %s" % (v["name"], what))
for k in problems:
fails.append("vector %s: %s is %s, the corpus states %s" % (
v["name"], k, json.dumps(got.get(k))[:200] if got else k, json.dumps(v.get(k))[:200]))
return fails
def flip(h, at, bit=0x01):
b = bytearray(bytes.fromhex(h))
b[at] ^= bit
return b.hex()
def mutations(doc):
"""Copies of the corpus, each with one defect a conforming verifier must catch, and the vectors it touches."""
idx = {v["name"]: i for i, v in enumerate(doc["vectors"])}
def vec(d, name):
return d["vectors"][idx[name]]
def mutate(label, names, f):
d = copy.deepcopy(doc)
f(d)
return label, names, d
def relay_hop(d, name):
relay = doc["principals"]["relay"]
return next(h for h in vec(d, name)["hops"] if h["record"][74:138] == relay)
def drop_join(d):
clock = doc["principals"]["relay"]
chain = next(c for c in d["rooms"]["lesson"]["chains"] if LEV.read_record(bytes.fromhex(c[0]))["principal"].hex() == clock)
chain.pop(4)
def cons(d, name, k):
return vec(d, name)["consumption"][k]
lesson_units = ["lesson", "signed_by_authority"]
return [
mutate("lesson: a bit of the receipt's signature", ["lesson"], lambda d: vec(d, "lesson").update(receipt=flip(vec(d, "lesson")["receipt"], len(vec(d, "lesson")["receipt"]) // 2 - 1))),
mutate("lesson: publisher-seconds stated one more", ["lesson"], lambda d: vec(d, "lesson")["units"].update(publisher_us=vec(d, "lesson")["units"]["publisher_us"] + 1)),
mutate("lesson: recorded-seconds stated one less", ["lesson"], lambda d: vec(d, "lesson")["units"].update(recorded_us=vec(d, "lesson")["units"]["recorded_us"] - 1)),
mutate("lesson: a byte of the relay's record", ["lesson"], lambda d: relay_hop(d, "lesson").update(record=flip(relay_hop(d, "lesson")["record"], 200))),
mutate("lesson: a byte of the relay's ledger", ["lesson"], lambda d: relay_hop(d, "lesson").update(ledger=flip(relay_hop(d, "lesson")["ledger"], 60))),
mutate("lesson: a composition receipt not given", ["lesson"], lambda d: vec(d, "lesson")["compositions"].pop()),
mutate("lesson: a take not given", ["lesson"], lambda d: vec(d, "lesson")["takes"].pop()),
mutate("lesson: a log not given", ["lesson"], lambda d: vec(d, "lesson").update(logs=None)),
mutate("lesson: a stated verdict", ["lesson"], lambda d: cons(d, "lesson", 2).update(joules_verdict="past")),
mutate("lesson: a stated attributed share", ["lesson"], lambda d: cons(d, "lesson", 3)["attributed"].update(OnChipCounter=1)),
mutate("lesson: the stated coverage", ["lesson"], lambda d: vec(d, "lesson").update(coverage="partial")),
mutate("lesson: a record's stated bound", ["lesson"], lambda d: vec(d, "lesson")["records"][0].update(bound=1)),
mutate("mixed_classes: two totals stated as one class", ["mixed_classes"], lambda d: vec(d, "mixed_classes")["totals"][0].update(**{"class": "OnChipCounter"})),
mutate("ceilings_past: a grant past stated within", ["ceilings_past"], lambda d: vec(d, "ceilings_past").update(past=vec(d, "ceilings_past")["past"][:1])),
mutate("large_audience: the QoE cell stated suppressed", ["large_audience"], lambda d: vec(d, "large_audience")["qoe_cell"].update(suppressed=True)),
mutate("large_audience: an observation's sn", ["large_audience"], lambda d: vec(d, "large_audience").update(logs=vec(d, "large_audience")["logs"].replace('"sn":1,', '"sn":0,', 1))),
mutate("relay_recorded: a byte of the relay's own record", ["relay_recorded"], lambda d: vec(d, "relay_recorded")["hops"][0].update(record=flip(vec(d, "relay_recorded")["hops"][0]["record"], 120))),
mutate("relay_recorded: subscriber-seconds stated other", ["relay_recorded"], lambda d: vec(d, "relay_recorded")["units"]["subscriber_us"].__setitem__(0, 1)),
mutate("track_unknown: stated record_invalid", ["track_unknown"], lambda d: vec(d, "track_unknown").update(reason="record_invalid")),
mutate("classes_summed: stated unit_mismatch", ["classes_summed"], lambda d: vec(d, "classes_summed").update(reason="unit_mismatch")),
mutate("record_too_large: stated at another hop", ["record_too_large"], lambda d: vec(d, "record_too_large").update(at=["hop", 0])),
mutate("lesson room: a lifecycle record not delivered", lesson_units, drop_join),
mutate("lesson room: a byte of a grant", lesson_units, lambda d: d["rooms"]["lesson"]["grants"].__setitem__(4, flip(d["rooms"]["lesson"]["grants"][4], 230))),
]
def reasons_listed():
"""The codes §9's table lists, in order."""
with open(os.path.join(HERE, "..", "extensions", "live-metering.md"), encoding="utf-8") as f:
text = f.read()
at = text.index("\n## 9. Reasons")
end = text.find("\n## ", at + 1)
section = text[at:end if end != -1 else len(text)]
return [m.group(1) for m in re.finditer(r"^\| `([a-z_]+)` \|", section, re.M)]
def main(corpus_dir):
fails = []
st = ED._self_test()
print(("PASS" if not st else "FAIL") + " Ed25519 strict rule self-test " + ",".join(st))
if st:
fails.append("ed25519 self-test")
with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as f:
doc = json.load(f)
cache = Cache()
fails += check(doc, cache)
seen = {v.get("reason") for v in doc["vectors"]}
missing = [r for r in REASONS if r not in seen]
if missing:
fails.append("no vector is refused %s" % ", ".join(missing))
print("%s every reason of §9 refuses a vector" % ("PASS" if not missing else "FAIL"))
listed = reasons_listed()
if listed != REASONS:
fails.append("§9 lists %s" % listed)
print("%s §9 lists exactly the reasons this verifier knows" % ("PASS" if listed == REASONS else "FAIL"))
for label, names, mutated in mutations(doc):
caught = check(mutated, Cache() if label.startswith("lesson room") else cache, quiet=True, only=set(names))
print(("PASS" if caught else "FAIL") + " mutated copy fails: %s%s" % (label, " (%s)" % caught[0][:120] if caught else ""))
if not caught:
fails.append("a mutated copy verifies: %s" % label)
for f in fails:
print("FAIL " + f)
print(("PASS" if not fails else "FAIL") + " live-metering corpus: %d vectors" % len(doc["vectors"]))
return 1 if fails else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: live_metering_verify.py <live-conformance/metering>", file=sys.stderr)
sys.exit(2)
sys.exit(main(sys.argv[1]))

View file

@ -0,0 +1,123 @@
# live-conformance/metering — live metering
The corpus of [`extensions/live-metering.md`](../../extensions/live-metering.md) §10: rooms,
and vectors, each a room-session receipt (`WMR1`) with what it names (the per-hop session
records, `WMH1`, each with its ledger; the composition descriptors and receipts, `WCD1` and
`WCR1`; the take manifests, `WTM1`; and the logs document of QoE observations), and the
outcome a verifier gives it.
**Metering equivalence.** For each vector, a conforming receipt verifier given its room (by
name, from `rooms`: the descriptor, the grants, the key-log head and the chains, each
hexadecimal), its `receipt`, `hops` (each `record` and `ledger`), `descriptors`,
`compositions`, `takes` and `logs` (the document's text, or `null`) reaches the outcome the
vector states (§5.4):
- `refuse`: the `reason` and where, `at`: `["room"]` or `["room", …]` with the room
verifier's own place (live-rooms §9.1), `["receipt"]`, `["hop", i]` for the receipt's hop
entry `i`, `["take", i]`, `["compose"]` for the list of composition receipts or
`["compose", i]` for the receipt `i` in ascending order of digest, `["qoe"]`, `["units"]`,
`["energy"]` or `["ceilings"]`;
- `accept`: the receipt's `digest`; the number of media tracks the room published in the
session (`media_tracks`, the *M* of §3.7); each hop record's `digest`, `size` and size
`bound`; the `units`; the `unmetered` hop entries; the `totals`, one per class; the
`coverage`, `whole` or `partial`; each grant's `consumption` entry (its ceilings, its units,
its attributed joules by class, whether its share is `unmetered`, its `funds`, its two
verdicts and its `leaf`); the `consumption_root`; the grants `past` a ceiling with their
flags; and the `qoe_cell`.
A verifier that reaches another outcome, reason, place or fact does not conform.
**Provenance.** Generated here: `wai_live_metering_vectors gen` writes `vectors.json` from
sessions authored in code, from fixed seeds, by capture binding's corpus rules: each key is
SHA-256 of that corpus's fixed label and the principal's index (the principals it shares with
that corpus and with live recording's are the same principals), every time is a constant
(the lesson room's `t0` is 2026-10-16T00:00:00Z), every chunk's bytes are SHA-256 output under
a fixed label, and Ed25519 signing is deterministic (RFC 8032), so the file is the same bytes
on every machine. The lesson's per-hop records are aggregated from sessions written in code
by the rules a hop counts by (§3), and stand for what a relay, a compositor and an ingest
gateway would count; their joule figures are constants and were measured by nothing. The
key-log heads are sealed by the reference's key log under a fixed root; the origin is an
`.example` name (RFC 2606). The generator asserts that each vector reaches the outcome it was
written to have before it writes it.
**Recorded data, one record.** `relay-record.wmh` and its ledger `relay-ledger.wml` were signed
by `joule-moq-relay` itself, in its test `tests/room_metering.rs` (two publishers and three
subscribers of the `relay` room over native QUIC and WebTransport, one subscriber slow, each
reading its streams concurrently), run once on 2026-10-18 with `WAI_METERING_FIXTURE_DIR`
set; the relay's key is the room's clock key, under the same label. Its counts, its span
(3.196 s of room clock from 11:45:35.966 UTC)
and its subscriber time are what that run measured on loopback, so a second run gives other
bytes: the pair is committed as recorded and read by the generator, which writes the `relay`
room's lifecycle around the record's span. The relay meters no energy, and the record says so
with the unmetered marker. The `relay` room's grants run thirteen days from its `t0`, so a
pair recorded after 2026-10-31 would fall outside them.
## Layout
One file, `vectors.json`, beside the recorded pair:
- `principals`: the keys, by name (informative).
- `rooms`: `lesson`, a lesson room (a director with the record power, a performer's device and
an accompanist's capturing two takes, a compositor laying the room out at one frame a
second, three viewers, a key log listing the room authority, the store, the relay, the
compositor, an ingest gateway and a metering key, and a revoked metering key and relay key);
`lesson_ceilings`, the same with the second viewer's joule ceiling at 1 000 µJ and the third
viewer's funds ceiling at 0; `lesson_gap`, the clock's `join` of the third viewer not
delivered; `lesson_bad_chat`, the viewer's chat carrying a bad signature; and `relay`, the
reference relay's test room, with no origin.
- `vectors`: each with `name`, `about`, `room`, `receipt`, `hops`, `descriptors`,
`compositions`, `takes`, `logs`, `outcome`, and as above `reason` and `at`, or the facts.
The vectors:
- accepted: `lesson` (a relay and a compositor read from an on-chip counter, two takes, ten
composition receipts, three viewers' logs and a suppressed QoE cell, under a tariff in
euros; signed by the metering key), `signed_by_authority`, `mixed_classes` (a relay's counter
reading, a compositor's model and an ingest gateway's estimate, one total per class),
`ceilings_past` (the second viewer past its joule ceiling, the third past its funds
ceiling), `large_audience` (ten thousand more subscriber sessions with no grant: the relay's
record is the lesson's size; sixty logs publish the QoE cell) and `relay_recorded` (the
reference relay's record, unmetered, so the energy is partial);
- refused, one defect each: the receipt (`receipt_bad_signature`, `receipt_truncated`,
`receipt_wrong_room`, `session_invalid`, `signer_not_authorised`, `signer_revoked`,
`total_of_no_class`); the hop records (`record_missing`, `record_too_large`,
`record_invalid`, `energy_invalid`, `record_wrong_room`, `record_bad_signature`,
`entry_restated_wrong`, `hop_not_authorised`, `hop_class_not_authorised`, `hop_revoked`,
`span_outside_session`, `track_unknown`, `ledger_invalid`, `ledger_ceiling_mismatch`,
`ledger_root_mismatch`, `hop_units_mismatch`); the takes, compositions and logs
(`take_missing`, `take_unknown`, `composition_missing`, `composition_refused`,
`composition_outside_session`, `logs_missing`, `qoe_invalid`, `qoe_not_suppressed`); the
units, energy and ceilings (`units_mismatch`, `recorded_mismatch`, `classes_summed`,
`total_mismatch`, `ceiling_mismatch`, `consumption_root_mismatch`); and the room
(`room_refused`, `lifecycle_incomplete`).
## Running
```
cd ../../wai-rs
cargo run --no-default-features --features live_metering --bin wai_live_metering_vectors -- verify ../live-conformance/metering
cargo run --no-default-features --features live_metering --bin wai_live_metering_vectors -- gen ../live-conformance/metering # regenerate
cargo test --no-default-features --features live_metering --test live_metering
cd ../live-conformance
python3 live_metering_verify.py metering
```
`wai_live_metering_vectors verify` and `tests/live_metering.rs` require the file to regenerate
byte for byte and `wai-rs` to give every vector its outcome; the test also requires every
reason of §9 to refuse a vector, the accepted vectors to be the ones above, the large
audience's relay record to be the lesson's size within the same bound, and §9 to list exactly
the reasons the reference reports. `live_metering_verify.py` is the second implementation,
written from the extension's text and sharing no code with `wai-rs`: it verifies each room
with `live_rooms_verify.py`, each take with `capture_take_verify.py` and each composition
receipt with `live_compose_verify.py`, recomputes every unit, every total, every grant's
consumption and the QoE cell itself, gives every vector the same outcome, requires the same
coverage, and then mutates copies of the corpus, one defect each, each of which must fail.
Pass = an exit status of 0 from each.
To record the relay's pair again (it changes the corpus), run the relay's test with
`WAI_METERING_FIXTURE_DIR` set to this directory, then `gen`:
```
cd ../../../joule-code/joule-code-rs
WAI_METERING_FIXTURE_DIR=$PWD/../../wai/live-conformance/metering cargo test -p joule-moq-relay --test room_metering
```

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View file

@ -616,6 +616,20 @@
"edition": null,
"registered": "2026-10-16"
},
{
"capability": "wai.live.metering",
"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-18"
},
{
"capability": "wai.live.recording",
"kind": "record",
@ -1836,6 +1850,20 @@
"section": "§6.2",
"status": "defined"
},
{
"name": "WMH1",
"kind": "magic",
"document": "extensions/live-metering.md",
"section": "§3.1",
"status": "defined"
},
{
"name": "WMR1",
"kind": "magic",
"document": "extensions/live-metering.md",
"section": "§4.1",
"status": "defined"
},
{
"name": "WRD1",
"kind": "magic",

View file

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

View file

@ -212,6 +212,15 @@ live_compose = ["live_room"]
# records whose RFC 6962 range proofs reach a take's commitment, verified without the take.
# SHA-256, Ed25519 and the key log only; no new dependency.
live_record = ["capture_take"]
# Live metering (extensions/live-metering): `wai.live.metering` — the per-hop session record
# (WMH1) a relay, compositor or ingest gateway signs, with its ledger of the grants its sessions
# ran under and a size bound independent of the audience; the room-session receipt (WMR1) a room
# authority or a `live/meter` key signs, its units, joules per hop with their acquisition class and
# a total per class, each grant's consumption against its ceilings, and the QoE cell; and the QoE
# observations' CMCD event-timeline record and `application/cmcd` batch. It reads rooms, takes and
# composition receipts through their modules and the logs through the telemetry module. SHA-256,
# Ed25519 and the key log only; no new dependency.
live_metering = ["live_compose", "capture_take", "telemetry"]
# 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,
@ -671,6 +680,11 @@ name = "wai_live_record_vectors"
path = "src/bin/wai_live_record_vectors.rs"
required-features = ["live_record"]
[[bin]]
name = "wai_live_metering_vectors"
path = "src/bin/wai_live_metering_vectors.rs"
required-features = ["live_metering"]
[[bin]]
name = "wai_timed_text_vectors"
path = "src/bin/wai_timed_text_vectors.rs"
@ -1337,6 +1351,11 @@ name = "live_record"
path = "tests/live_record.rs"
required-features = ["live_record"]
[[test]]
name = "live_metering"
path = "tests/live_metering.rs"
required-features = ["live_metering"]
[[test]]
name = "timed_text"
path = "tests/timed_text.rs"

View file

@ -0,0 +1,57 @@
//! Conformance corpus for live metering (`live-conformance/metering/`, extensions/live-metering.md §10).
//!
//! wai_live_metering_vectors gen <corpus-dir> write vectors.json, from fixed seeds and the
//! reference relay's recorded record
//! wai_live_metering_vectors verify <corpus-dir> vectors.json regenerates byte for byte, and
//! every vector gets the outcome it states
//! wai_live_metering_vectors relay-room print what the reference relay's test needs
//! of its room: its id, keys and grants
//!
//! The generator and this verifier are one implementation. The corpus's second
//! implementation is `live-conformance/live_metering_verify.py`, written from the extension's
//! text and sharing no code with this binary.
use std::path::Path;
use wai::live_metering::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)) => {
let p = Path::new(d).join("vectors.json");
if let Err(e) = std::fs::create_dir_all(d).and_then(|_| std::fs::write(&p, corpus::generate())) {
eprintln!("write {}: {e}", p.display());
std::process::exit(1);
}
println!("wrote {}", p.display());
}
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
(Some("relay-room"), None) => print!("{}", corpus::relay_room_facts()),
_ => {
eprintln!("usage: wai_live_metering_vectors gen|verify <corpus-dir> | relay-room");
std::process::exit(2);
}
}
}
fn verify(dir: &Path) -> i32 {
let p = dir.join("vectors.json");
let doc = match std::fs::read_to_string(&p) {
Ok(s) => s,
Err(e) => {
eprintln!("read {}: {e}", p.display());
return 1;
}
};
let mut fail = 0;
let same = doc == corpus::generate();
println!("{} vectors.json regenerates byte for byte from its seeds", if same { "PASS" } else { "FAIL" });
fail += (!same) as i32;
let fails = corpus::verify(&doc);
for f in &fails {
println!("FAIL {f}");
}
println!("{} every vector gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
fail += (!fails.is_empty()) as i32;
(fail != 0) as i32
}

View file

@ -364,6 +364,153 @@ pub fn recover(manifest: &Manifest) -> Result<RecoveredBindings, EmitError> {
}
}
#[cfg(feature = "live_metering")]
pub use session::{recover_session, RecoveredSession, SessionManifest, SuppliedFigure};
/// A room-session receipt (`extensions/live-metering.md` §7) in a real C2PA manifest: the
/// standard `c2pa.environmental-sustainability` assertion where one figure can honestly be
/// written, and always the companion `science.transaction.wai.live.metering`.
#[cfg(feature = "live_metering")]
mod session {
use super::*;
use crate::live_metering::{hex, tag_label, SessionReceipt};
/// The companion assertion's label (live-metering §7).
pub const COMPANION_LABEL: &str = "science.transaction.wai.live.metering";
/// A GHG or water figure the caller supplies, with the method that gave it. The receipt
/// holds neither, and this module never derives one from the energy (energy-binding §4).
#[derive(Clone, Debug, PartialEq)]
pub struct SuppliedFigure {
pub amount: f64,
pub method: String,
}
/// The assertions of one room session, ready to be sealed into a manifest of an asset of
/// that session: its recording, its composite.
pub struct SessionManifest {
standard: Option<Value>,
companion: Value,
}
impl SessionManifest {
/// The assertions of `r`. The standard assertion's `energy_kwh` is written only when
/// the receipt's energy is whole and every hop entry is of one class (§4.5): one
/// figure of several classes would sum them, and one with an unmetered hop would
/// present a part as the whole. Supplied GHG and water figures go into the standard
/// assertion, which is then written whether or not it carries energy.
pub fn new(r: &SessionReceipt, carbon_kgco2e: Option<SuppliedFigure>, water_litres: Option<SuppliedFigure>) -> SessionManifest {
let whole = r.unmetered == 0 && !r.hops.is_empty();
let one_class = (r.totals.len() == 1).then(|| r.totals[0]);
let mut standard = serde_json::Map::new();
if let (true, Some(t)) = (whole, one_class) {
// 1 µJ is 1/3.6e12 kWh, a derived interop value (energy-binding §4).
standard.insert("energy_kwh".into(), Value::from(t.joules as f64 / 3.6e12));
standard.insert(
"measurementMethod".into(),
Value::from(format!("wai.live.metering: {} hop records, every one {}", t.figures, tag_label(t.tag))),
);
}
let mut companion = serde_json::json!({
"receipt": hex(&r.digest()),
"room": hex(&r.room),
"span": {"start": r.start, "end": r.end},
"coverage": if whole { "whole" } else { "partial" },
"unmetered": r.unmetered,
"totals": r.totals.iter().map(|t| serde_json::json!({"acquisition": tag_label(t.tag), "figures": t.figures, "microjoules": t.joules})).collect::<Vec<_>>(),
"units": {
"publisherUs": r.units.publisher_us, "subscriberUs": r.units.subscriber_us, "sessions": r.units.sessions,
"compositionUs": r.units.composition_us, "eventsSent": r.units.events_sent,
"eventsDelivered": r.units.events_delivered, "recordedUs": r.units.recorded_us,
},
"grants": r.grants,
"consumptionRoot": hex(&r.consumption_root),
});
for (key, method_key, f) in [("carbon_kgco2e", "carbonMethod", carbon_kgco2e), ("water_litres", "waterMethod", water_litres)] {
if let Some(f) = f {
standard.insert(key.into(), Value::from(f.amount));
companion[method_key] = Value::from(f.method);
}
}
SessionManifest { standard: (!standard.is_empty()).then_some(Value::Object(standard)), companion }
}
/// The standard assertion's body, when one is written.
pub fn standard(&self) -> Option<&Value> {
self.standard.as_ref()
}
/// The companion assertion's body.
pub fn companion(&self) -> &Value {
&self.companion
}
/// A builder with `parent` (the session's recording, or the receipt itself) as the
/// parent ingredient and the assertions attached, every assertion created.
pub fn builder(&self, parent: &[u8], parent_format: &str) -> Result<Builder, EmitError> {
let mut b = Builder::default();
b.set_claim_generator_info(ClaimGeneratorInfo::new("wai"));
b.set_intent(BuilderIntent::Edit);
b.add_ingredient_from_stream(
serde_json::json!({"relationship": "parentOf", "title": "WAI room session", "format": parent_format}).to_string(),
parent_format,
&mut std::io::Cursor::new(parent.to_vec()),
)?;
b.add_action(Action::new(c2pa_action::CONVERTED).set_software_agent("wai").set_description("an asset of a WAI room session whose units and energy a room-session receipt states"))?;
if let Some(s) = &self.standard {
b.add_assertion(EnergyBinding::C2PA_STANDARD_LABEL, s)?;
}
b.add_assertion(COMPANION_LABEL, &self.companion)?;
for a in &mut b.definition.assertions {
a.created = true;
}
Ok(b)
}
/// Sign `source` into `dest`, embedding the manifest. Returns the manifest bytes.
#[allow(clippy::too_many_arguments)]
pub fn sign_stream<R, W>(&self, signer: &dyn Signer, parent: &[u8], parent_format: &str, format: &str, source: &mut R, dest: &mut W) -> Result<Vec<u8>, EmitError>
where
R: Read + Seek + Send,
W: Write + Read + Seek + Send,
{
Ok(self.builder(parent, parent_format)?.sign(signer, format, source, dest)?)
}
}
/// A session's assertions recovered from a validated manifest.
#[derive(Debug, Clone)]
pub struct RecoveredSession {
pub standard: Option<Value>,
pub companion: Value,
}
impl RecoveredSession {
/// Whether the companion is about this receipt: the receipt's digest, and its totals
/// class by class.
pub fn describes(&self, r: &SessionReceipt) -> bool {
SessionManifest::new(r, None, None).companion.get("totals") == self.companion.get("totals")
&& self.companion.get("receipt").and_then(Value::as_str) == Some(hex(&r.digest()).as_str())
}
}
/// Recover a session's assertions, refusing a standard assertion with no companion and
/// an `energy_kwh` beside a companion that states several classes or a partial total.
pub fn recover_session(manifest: &Manifest) -> Result<RecoveredSession, EmitError> {
let find = |label: &str| manifest.assertions().iter().find(|a| a.label() == label).and_then(|a| a.value().ok().cloned());
let standard = find(EnergyBinding::C2PA_STANDARD_LABEL);
let Some(companion) = find(COMPANION_LABEL) else {
return Err(if standard.is_some() { EmitError::UnaccompaniedStandardAssertion } else { EmitError::NoBinding });
};
let classes = companion.get("totals").and_then(Value::as_array).map_or(0, Vec::len);
let whole = companion.get("coverage").and_then(Value::as_str) == Some("whole");
if standard.as_ref().is_some_and(|s| s.get("energy_kwh").is_some()) && !(whole && classes == 1) {
return Err(EmitError::Malformed("energy_kwh beside several classes or a partial total"));
}
Ok(RecoveredSession { standard, companion })
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -786,4 +933,90 @@ mod tests {
}
}
}
// ---- the room-session receipt (live-metering §7) ---------------------------------
#[cfg(feature = "live_metering")]
fn session_receipt(totals: Vec<crate::live_metering::Total>, unmetered: u8) -> crate::live_metering::SessionReceipt {
use crate::live_metering::*;
SessionReceipt {
room: [7; 32],
signer: [8; 32],
start: 0,
end: 24_000_000,
hops: (0..totals.iter().map(|t| t.figures).sum::<u32>() + unmetered as u32)
.map(|k| HopEntry { record: [k as u8; 32], hop: [9; 32], role: role::RELAY, joules: 0, class: None })
.collect(),
takes: vec![],
compose_count: 0,
compose_digest: [0; 32],
qoe_count: 0,
qoe_digest: [0; 32],
units: Units { publisher_us: 80_500_000, ..Units::default() },
unmetered,
totals,
tariff: Tariff::default(),
grants: 8,
consumption_root: [3; 32],
past: vec![],
qoe_cell: QoeCell::suppressed(),
sig: [0; 64],
}
}
#[cfg(feature = "live_metering")]
fn sign_session(m: &SessionManifest, receipt: &[u8], asset: &[u8]) -> Vec<u8> {
let signer = untrusted_local_signer().unwrap();
let mut src = Cursor::new(asset.to_vec());
let mut dst = Cursor::new(Vec::new());
m.sign_stream(&signer, receipt, "application/octet-stream", "image/png", &mut src, &mut dst).unwrap();
dst.into_inner()
}
/// A session whose hops are all one class, whole: both assertions in a real signed
/// manifest, `energy_kwh` derived from the one total, the companion naming the receipt.
#[cfg(feature = "live_metering")]
#[test]
fn a_session_of_one_class_writes_the_standard_assertion() {
use crate::live_metering::Total;
let r = session_receipt(vec![Total { tag: 4, figures: 2, joules: 60_400_000 }], 0);
let m = SessionManifest::new(&r, None, None);
let rgb = pixels();
let signed = sign_session(&m, &r.to_bytes(), &png(W, H, &rgb));
let reader = Reader::from_stream("image/png", Cursor::new(signed)).unwrap();
assert_eq!(reader.validation_state(), ValidationState::Valid, "{:?}", reader.validation_status());
let got = recover_session(reader.active_manifest().unwrap()).unwrap();
let kwh = got.standard.as_ref().unwrap()["energy_kwh"].as_f64().unwrap();
assert!((kwh - 60_400_000.0 / 3.6e12).abs() < 1e-18);
assert!(got.standard.as_ref().unwrap()["measurementMethod"].as_str().unwrap().contains("OnChipCounter"));
assert!(got.standard.as_ref().unwrap().get("carbon_kgco2e").is_none(), "never invented");
assert!(got.describes(&r));
assert_eq!(got.companion["coverage"], "whole");
}
/// Several classes, or an unmetered hop: no `energy_kwh`, the companion alone states
/// the totals, class by class; a supplied water figure is written with its method.
#[cfg(feature = "live_metering")]
#[test]
fn a_session_of_several_classes_writes_the_companion_alone() {
use crate::live_metering::Total;
let mixed = session_receipt(vec![Total { tag: 2, figures: 1, joules: 35_000_000 }, Total { tag: 4, figures: 1, joules: 18_400_000 }], 0);
let m = SessionManifest::new(&mixed, None, None);
assert!(m.standard().is_none(), "one figure would sum two classes");
let partial = session_receipt(vec![Total { tag: 4, figures: 1, joules: 18_400_000 }], 1);
let water = SuppliedFigure { amount: 0.25, method: "the operator's metered cooling loop".into() };
let m = SessionManifest::new(&partial, None, Some(water));
let standard = m.standard().unwrap();
assert!(standard.get("energy_kwh").is_none(), "a part is not the whole");
assert_eq!(standard["water_litres"], 0.25);
let rgb = pixels();
let signed = sign_session(&m, &partial.to_bytes(), &png(W, H, &rgb));
let reader = Reader::from_stream("image/png", Cursor::new(signed)).unwrap();
assert_eq!(reader.validation_state(), ValidationState::Valid, "{:?}", reader.validation_status());
let got = recover_session(reader.active_manifest().unwrap()).unwrap();
assert_eq!(got.companion["coverage"], "partial");
assert_eq!(got.companion["waterMethod"], "the operator's metered cooling loop");
assert!(got.describes(&partial));
assert!(!got.describes(&mixed));
}
}

View file

@ -1323,6 +1323,36 @@ pub fn verify_take(i: &TakeInputs) -> TakeRun {
run
}
/// One held commitment of a take, as [`find_take`] gives it: its record's digest and `t`,
/// its payload, and the grant the room verifier verified it under.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HeldCommit {
pub digest: [u8; 32],
pub t: u64,
pub commit: Commit,
pub grant: Option<[u8; 32]>,
}
/// A take found in its room: the `t` of its start and its held commitments, in order.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FoundTake {
pub start_t: u64,
pub held: Vec<HeldCommit>,
}
/// §7.2 and §9.2 over a room the room verifier did not refuse: the manifest read and
/// verified, and the take as the room holds it. Live metering (extensions/live-metering.md
/// §4.4) counts a take's committed capture from it.
pub fn find_take(run: &RoomRun, chains: &[Vec<Vec<u8>>], manifest: &[u8]) -> Result<(Manifest, FoundTake), (Place, Reason)> {
let room_id = run.room.expect("a room that verified");
let m = Manifest::parse(manifest, &room_id).map_err(|r| (Place::Manifest, r))?;
let seen = accepted(run, chains);
let f = find(run, &seen, &m)?;
let held = f.commits.iter().map(|h| HeldCommit { digest: h.seen.digest, t: h.seen.event.t, commit: h.c.clone(), grant: h.seen.grant }).collect();
let found = FoundTake { start_t: f.start.event.t, held };
Ok((m, found))
}
fn fail_all() -> Verdicts {
let v = Verdict::Fails(Reason::TakeUnknown);
Verdicts { single_take: v, aligned: v, complete: v, publishable: [v; 4], stands: false }

View file

@ -214,6 +214,7 @@ pub const CAP_LIVE_ROOM: &str = "wai.live.room";
pub const CAP_CAPTURE_TAKE: &str = "wai.capture.take";
pub const CAP_LIVE_COMPOSE: &str = "wai.live.compose";
pub const CAP_LIVE_RECORDING: &str = "wai.live.recording";
pub const CAP_LIVE_METERING: &str = "wai.live.metering";
pub const CAP_TEXT_TIMED: &str = "wai.text.timed";
/// One row per string SPEC §5 registers that is a payload, companion or record:
@ -297,6 +298,7 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[
(CAP_CAPTURE_TAKE, S_INTERACTIVE, None, "2026-10-17"),
(CAP_LIVE_COMPOSE, S_INTERACTIVE, None, "2026-10-17"),
(CAP_LIVE_RECORDING, S_INTERACTIVE, None, "2026-10-17"),
(CAP_LIVE_METERING, S_INTERACTIVE, None, "2026-10-18"),
(CAP_TEXT_TIMED, S_INTERACTIVE, None, "2026-10-17"),
(CAP_AUDIO_IAMF, S_IMMERSIVE, None, "2026-06-17"),
(CAP_AUDIO_AC4, S_IMMERSIVE, None, "2026-06-17"),
@ -345,7 +347,7 @@ const RECORDS: &[&str] = &[
CAP_ASSET_PROVENANCE, CAP_QUANTUM_CALIBRATION, CAP_QUANTUM_JOB,
CAP_VIDEO_MANIFEST, CAP_VIDEO_SSAI, CAP_VIDEO_KEYS, CAP_VIDEO_PACKAGE, CAP_VIDEO_CHANNEL,
CAP_LIVE_EVENTS, CAP_LIVE_ROOM, CAP_CAPTURE_TAKE, CAP_LIVE_COMPOSE, CAP_LIVE_RECORDING,
CAP_TEXT_TIMED,
CAP_LIVE_METERING, CAP_TEXT_TIMED,
];
/// A named identifier SPEC uses that has the shape of a capability string but is
@ -489,6 +491,7 @@ const CAPTURE_BINDING: &str = "extensions/capture-binding.md";
const LIVE_COMPOSE: &str = "extensions/live-compose.md";
const LIVE_RECORDING: &str = "extensions/live-recording.md";
const TIMED_TEXT: &str = "extensions/timed-text.md";
const LIVE_METERING: &str = "extensions/live-metering.md";
const fn wire(name: &'static str, kind: WireKind, document: &'static str, section: &'static str,
status: WireStatus) -> WireIdentifier {
@ -498,8 +501,8 @@ const fn wire(name: &'static str, kind: WireKind, document: &'static str, sectio
/// The wire identifiers of the container, of its two delivery bindings
/// (http-delivery, moq-streaming-format), of the key discovery those bindings serve
/// (jwp-receipts §7), of packaging, of live events, of live rooms, of capture binding, of
/// live composition, of live recording and of timed text, in document order. The export
/// lists them sorted by name and kind.
/// live composition, of live recording, of timed text and of live metering, 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),
@ -564,6 +567,8 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
wire("tag:transaction.science,2026:wai/model/", WireKind::Scheme, TIMED_TEXT, "§10.3", WireStatus::Defined),
wire("tag:transaction.science,2026:wai/timed-text", WireKind::Scheme, TIMED_TEXT, "§10.4", WireStatus::Defined),
wire("science.transaction.wai.timed-text", WireKind::DataId, TIMED_TEXT, "§10.5", WireStatus::Defined),
wire("WMH1", WireKind::Magic, LIVE_METERING, "§3.1", WireStatus::Defined),
wire("WMR1", WireKind::Magic, LIVE_METERING, "§4.1", WireStatus::Defined),
];
/// A string with the shape of a capability that the reference implementation uses

View file

@ -432,6 +432,16 @@ pub mod live_compose;
/// cbindgen:ignore
#[cfg(feature = "live_record")]
pub mod live_record;
// Live metering (extensions/live-metering.md): `wai.live.metering` — the per-hop session record a
// relay, compositor or ingest gateway signs, batched per session with a size bound independent of
// the audience and a ledger of the grants its sessions ran under; the room-session receipt in units
// (publisher-, subscriber-, composition- and recorded-seconds, events sent and delivered), joules per
// hop with their acquisition class and a total per class, each grant's consumption against its
// ceilings, and the QoE cell. Rust-only, like the other live modules: its constants must not become
// macros in include/wai.h.
/// cbindgen:ignore
#[cfg(feature = "live_metering")]
pub mod live_metering;
// Timed text and live captions (extensions/timed-text.md): `wai.text.timed` — the cue model
// both WebVTT and the IMSC1 Text Profile express, live caption revisions as `caption`
// events of `wai.live.events` with the WTX1 annotation, and the exact mappings onto WebVTT

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,80 @@
//! `wai.live.metering` (extensions/live-metering.md): the committed corpus
//! `live-conformance/metering/vectors.json` regenerates byte for byte from its fixed seeds and
//! the reference relay's recorded record, every vector in it gets the outcome it states, every
//! reason the extension adds refuses a vector, the large audience's relay record is the size of
//! the lesson's, and the extension's §9 lists exactly the reasons the reference reports.
#![cfg(feature = "live_metering")]
use std::collections::BTreeSet;
use std::path::PathBuf;
use wai::live_metering::{corpus, Reason};
fn wai_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn read(p: &str) -> String {
std::fs::read_to_string(wai_dir().join(p)).unwrap_or_else(|e| panic!("{p}: {e}"))
}
fn committed() -> String {
read("live-conformance/metering/vectors.json")
}
#[test]
fn the_committed_corpus_regenerates_from_its_seeds() {
assert!(committed() == corpus::generate(), "live-conformance/metering/vectors.json differs from `wai_live_metering_vectors gen`");
}
#[test]
fn every_vector_gets_its_stated_outcome() {
let fails = corpus::verify(&committed());
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
/// Every reason this extension adds refuses a vector; the accepted vectors are the ones §10
/// names; the large audience's relay record is the lesson's size, within the same bound; the
/// mixed vector states one total per class; the ceilings vector names two grants past.
#[test]
fn the_corpus_covers_every_reason_and_case() {
let doc: serde_json::Value = serde_json::from_str(&committed()).unwrap();
let vectors = doc["vectors"].as_array().unwrap();
let seen: BTreeSet<&str> = vectors.iter().filter_map(|v| v["reason"].as_str()).collect();
for r in Reason::ALL {
assert!(seen.contains(r.code()), "nothing in the corpus is refused {r}");
}
let get = |n: &str| vectors.iter().find(|v| v["name"] == n).unwrap_or_else(|| panic!("no vector {n}"));
for n in ["lesson", "signed_by_authority", "mixed_classes", "ceilings_past", "large_audience", "relay_recorded"] {
assert_eq!(get(n)["outcome"], "accept", "{n}");
}
let relay_record = |n: &str| {
let v = get(n);
let relay = &doc["principals"]["relay"];
let i = v["hops"].as_array().unwrap().iter().position(|h| h["record"].as_str().unwrap()[74..138] == relay.as_str().unwrap()[..]).unwrap();
let rec = v["hops"][i]["record"].as_str().unwrap().len() / 2;
v["records"].as_array().unwrap().iter().find(|r| r["size"] == rec).unwrap().clone()
};
let (lesson, large) = (relay_record("lesson"), relay_record("large_audience"));
assert_eq!(lesson["size"], large["size"], "the record does not grow with the audience");
assert_eq!(lesson["bound"], large["bound"]);
assert!(large["size"].as_u64() <= large["bound"].as_u64());
assert!(get("large_audience")["units"]["sessions"].as_u64().unwrap() > 10_000);
assert_eq!(get("large_audience")["qoe_cell"]["suppressed"], false);
assert_eq!(get("lesson")["qoe_cell"]["suppressed"], true);
assert_eq!(get("lesson")["coverage"], "whole");
assert_eq!(get("mixed_classes")["totals"].as_array().unwrap().len(), 3);
assert_eq!(get("relay_recorded")["coverage"], "partial");
let past = get("ceilings_past")["past"].as_array().unwrap();
assert_eq!(past.iter().map(|p| p["flags"].as_u64().unwrap()).collect::<BTreeSet<_>>(), BTreeSet::from([1, 2]));
}
/// §9 of the extension lists exactly the codes `Reason::ALL` has, each once, in order.
#[test]
fn the_extension_lists_the_reasons() {
let text = read("extensions/live-metering.md");
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> = Reason::ALL.iter().map(|r| r.code().to_string()).collect();
assert_eq!(listed, want, "§9's table and Reason::ALL differ");
}