From a9f1f9a293332f8a0ed36d28894cee6ab351b2e8 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:32:38 -0400 Subject: [PATCH 01/43] joule-moq-relay: a DVR store on disk, FETCH from it, the seek power, and WMH1 version 2 (work in progress) Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-ingest/src/room.rs | 4 +- .../crates/joule-moq-relay/Cargo.toml | 2 +- .../crates/joule-moq-relay/relay.example.toml | 19 + .../crates/joule-moq-relay/src/auth.rs | 38 +- .../crates/joule-moq-relay/src/cache.rs | 11 + .../crates/joule-moq-relay/src/config.rs | 74 + .../crates/joule-moq-relay/src/door.rs | 9 + .../crates/joule-moq-relay/src/dvr.rs | 1304 +++++++++++++++++ .../crates/joule-moq-relay/src/events.rs | 185 +++ .../crates/joule-moq-relay/src/lib.rs | 9 + .../crates/joule-moq-relay/src/metering.rs | 68 +- .../crates/joule-moq-relay/src/relay.rs | 65 +- .../crates/joule-moq-relay/src/sched.rs | 6 + .../crates/joule-moq-relay/src/session.rs | 350 ++++- .../crates/joule-moq-relay/src/summary.rs | 6 + .../joule-moq-relay/tests/auth_tokens.rs | 2 +- .../joule-moq-relay/tests/grant_proof.rs | 2 +- .../joule-moq-relay/tests/room_authority.rs | 2 +- .../joule-moq-room/tests/room_service.rs | 4 +- .../crates/joule-moq/src/grant_proof.rs | 20 + .../crates/joule-moq/src/receipt.rs | 2 +- 21 files changed, 2132 insertions(+), 50 deletions(-) create mode 100644 joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq-relay/src/events.rs diff --git a/joule-code/joule-code-rs/crates/joule-moq-ingest/src/room.rs b/joule-code/joule-code-rs/crates/joule-moq-ingest/src/room.rs index fad5a9fea..93116fef1 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-ingest/src/room.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-ingest/src/room.rs @@ -58,6 +58,8 @@ pub mod power { pub const DIRECT: u8 = 2; pub const COMPOSE: u8 = 4; pub const RECORD: u8 = 8; + /// It may fetch a track of the room below its live edge (§10.6). + pub const SEEK: u8 = 16; } /// A profile's layering (§8.1). @@ -740,7 +742,7 @@ impl Descriptor { let c = role.caps; if !seen.insert(role.name.clone()) || !bits_within(c.publish, 0b111) - || !bits_within(c.powers, 0b1111) + || !bits_within(c.powers, 0b1_1111) || (!c.subscribe && c.max_layer != 0) || !kinds_within(&role.events, &ceiling.kinds) || !powers_cover(&role.events, c.powers) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml index 7bc70bcc8..d5454099b 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml @@ -30,7 +30,7 @@ async-trait = "0.1" tracing = "0.1" tracing-subscriber = "0.3" serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = { version = "1", features = ["raw_value"] } toml = "0.9" ed25519-dalek = { version = "2", features = ["rand_core"] } rand_core_06 = { package = "rand_core", version = "0.6", features = ["getrandom"] } diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/relay.example.toml b/joule-code/joule-code-rs/crates/joule-moq-relay/relay.example.toml index 391ef36b3..ba6f1c325 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/relay.example.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/relay.example.toml @@ -84,6 +84,25 @@ subscribe = true proof = false # true: a session presenting this token is exempt from max_session_ms and max_ingress_kbps. unlimited = false +# true: the token may seek back, fetching a track below its live edge (WAI live-rooms +# §10.6). A room grant from an authority below seeks back when its powers hold seek. +seek = false + +# The DVR store (an example; not a default): a window of the tracks under each prefix, kept +# on disk with their receipts and served to FETCH below the live edge (WAI live-recording +# §8). Groups go oldest first, whole, past max_age_s (at most 86400, 24 hours) or past +# max_bytes for the window's tracks together. The store survives a restart: on start every +# object is checked against its receipt again, and one that does not verify is dropped, +# counted, and never served. `room_keys` are the room authorities and clocks whose decision +# events withhold what they deny or redact (WAI live-moderation §6.3). +[dvr] +path = "/var/lib/joule-moq-relay/dvr" +room_keys = [] + +[[dvr.window]] +prefix = ["*"] +max_age_s = 900 +max_bytes = 4294967296 # A room authority (an example; not a default): every WRG1 grant its key signs is honoured, # beside its proof and within its window, with the rights live-rooms §10.2 gives it. `rooms` diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/auth.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/auth.rs index 8e7e58c93..eaf93a342 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/auth.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/auth.rs @@ -118,6 +118,7 @@ struct GrantRule { subscribe: bool, proof: bool, unlimited: bool, + seek: bool, } struct AuthorityRule { @@ -187,7 +188,7 @@ impl Authorizer { let mut grants = Vec::new(); for g in &c.grants { let h = key32("a grant's token_sha256", &g.token_sha256)?; - grants.push(GrantRule { prefix: TrackNamespace::from_strs(g.prefix.iter().map(String::as_str)), token_sha256: h, publish: g.publish, subscribe: g.subscribe, proof: g.proof, unlimited: g.unlimited }); + grants.push(GrantRule { prefix: TrackNamespace::from_strs(g.prefix.iter().map(String::as_str)), token_sha256: h, publish: g.publish, subscribe: g.subscribe, proof: g.proof, unlimited: g.unlimited, seek: g.seek }); } let mut authorities = Vec::new(); for a in &c.authorities { @@ -239,6 +240,37 @@ impl Authorizer { }) } + /// Whether `tokens` may seek back on a track under `ns`: fetch it, or fill it, from + /// below its live edge (WAI live-rooms §10.6). With no authorization required, every + /// session may. Otherwise a token whose rule gives subscribe and `seek` over `ns`, or an + /// authority's grant, proved and within its window, that gives the track to subscribe + /// (§10.2) and whose powers hold seek (§3.3, bit 4). + pub fn may_seek(&self, ns: &TrackNamespace, tokens: &[Resolved], proven: &HashSet<[u8; 32]>) -> bool { + if !self.required { + return true; + } + let now = unix_s(); + tokens.iter().any(|(ty, value)| { + if *ty != 0 { + return false; + } + let h = sha256(value); + let by_rule = self.grants.iter().any(|g| g.token_sha256 == h && (!g.proof || proven.contains(&h)) && pattern_matches(&g.prefix, ns) && g.subscribe && g.seek); + by_rule || (proven.contains(&h) && self.authority_grant(value).is_some_and(|g| g.good_at(now) && g.seeks() && room_grant_allows(&g, ns, Right::Subscribe))) + }) + } + + /// The heads of the authority-signed grants among `tokens` the session proved: whose + /// powers a relay reads (a moderator's, WAI live-rooms §3.3). + pub fn proven_grants(&self, tokens: &[Resolved], proven: &HashSet<[u8; 32]>) -> Vec { + tokens.iter().filter(|(ty, v)| *ty == 0 && proven.contains(&sha256(v))).filter_map(|(_, v)| self.authority_grant(v)).collect() + } + + /// The keys of the configured room authorities. + pub fn authority_keys(&self) -> Vec<[u8; 32]> { + self.authorities.iter().map(|a| a.key).collect() + } + /// Whether the session's SETUP tokens name a rule that exempts it from the session and /// ingress limits. pub fn unlimited(&self, tokens: &[Resolved], proven: &HashSet<[u8; 32]>) -> bool { @@ -285,8 +317,8 @@ mod tests { required: true, authorities: vec![], grants: vec![ - crate::config::Grant { prefix: vec!["live".into()], token_sha256: hex::encode(sha256(b"t")), publish: false, subscribe: true, proof: false, unlimited: true }, - crate::config::Grant { prefix: vec!["live".into()], token_sha256: hex::encode(sha256(b"g")), publish: true, subscribe: false, proof: true, unlimited: false }, + crate::config::Grant { prefix: vec!["live".into()], token_sha256: hex::encode(sha256(b"t")), publish: false, subscribe: true, proof: false, unlimited: true, seek: false }, + crate::config::Grant { prefix: vec!["live".into()], token_sha256: hex::encode(sha256(b"g")), publish: true, subscribe: false, proof: true, unlimited: false, seek: false }, ], }; let a = Authorizer::from_config(&cfg).unwrap(); diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/cache.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/cache.rs index a26420178..ab1667273 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/cache.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/cache.rs @@ -65,6 +65,9 @@ pub struct GroupState { pub broken: bool, /// An End of Group or End of Track status, or a datagram with END_OF_GROUP, ended it. pub ended: bool, + /// When its first object reached the relay, in microseconds since the epoch (the DVR + /// store's window counts from it). + pub first_us: u64, } impl GroupState { @@ -114,6 +117,9 @@ impl TrackCache { let prior_objects = gap(prop::PRIOR_OBJECT_ID_GAP); let prior_groups = gap(prop::PRIOR_GROUP_ID_GAP).filter(|n| *n > 0 && *n <= group); let st = self.groups.entry(group).or_default(); + if st.first_us == 0 { + st.first_us = crate::relay::now_unix_us(); + } if object == 0 || prior_objects == Some(object) { st.start = true; } @@ -174,6 +180,11 @@ impl TrackCache { } } + /// Whether `group` is known not to exist, and the last group of its run if so. + pub fn absent_through(&self, group: u64) -> Option { + self.absent.range(..=group).next_back().filter(|(_, hi)| **hi >= group).map(|(_, hi)| *hi) + } + /// Whether `group` is known not to exist. fn absent_range(&self, group: u64) -> Option { self.absent.range(..=group).next_back().filter(|(_, hi)| **hi >= group).map(|(lo, _)| *lo) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/config.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/config.rs index 1361e45ef..0266fcb23 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/config.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/config.rs @@ -36,6 +36,10 @@ pub struct RelayConfig { pub auth: AuthConfig, #[serde(default)] pub metering: MeteringConfig, + /// The DVR store: a window of the tracks under each configured prefix, with their + /// receipts, kept on disk and served to FETCH (WAI live-recording §8). Absent: none. + #[serde(default)] + pub dvr: Option, /// The New Session URI a GOAWAY names (§9.2); empty: reuse the current one. #[serde(default)] pub goaway_uri: String, @@ -339,6 +343,70 @@ pub struct Grant { /// own long-lived sessions, such as a room service's clock. #[serde(default)] pub unlimited: bool, + /// The token may seek back: FETCH, or fill, a track under `prefix` from below its live + /// edge (WAI live-rooms §10.6), from the cache or the DVR store. Without it, a request + /// that seeks back is refused `UNAUTHORIZED`, `seek_not_granted`. With `subscribe` only. + #[serde(default)] + pub seek: bool, +} + +/// The longest window a DVR rule may keep: 24 hours, the start-over buffer a live-to-VOD +/// harvest clips from. +pub const DVR_MAX_AGE_S: u64 = 86_400; + +/// The DVR store (WAI live-recording §8): where it lives and the window it keeps of each +/// namespace prefix. +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DvrConfig { + /// The store's directory, created if absent. It holds one directory per track and the + /// moderation log; nothing else should write there. + pub path: PathBuf, + /// The window of each prefix; the rule with the longest prefix that matches a track's + /// namespace keeps it. A track no rule matches is not stored. + #[serde(default)] + pub window: Vec, + /// Room keys (Ed25519, hex) whose `decision` events the relay honours in every room: + /// a room's authority and its clock (WAI live-rooms §2). A principal whose session + /// proved a grant with the moderate power is honoured in its room besides (§3.3). The + /// keys of `auth.authorities` are room keys too. + #[serde(default)] + pub room_keys: Vec, +} + +/// One window: what the store keeps of the tracks under `prefix`. +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DvrWindow { + /// The Track Namespace prefix, one entry per field; a field `*` matches any one field. + pub prefix: Vec, + /// The longest a group is kept, in seconds, from when its first object reached the + /// relay: 1 to 86 400 (24 hours). + pub max_age_s: u64, + /// The payload bytes the window's tracks may hold together; past it the oldest group + /// goes, whole, with its receipts. + pub max_bytes: u64, +} + +impl DvrConfig { + pub fn check(&self) -> Result<(), String> { + if self.window.is_empty() { + return Err("dvr: no window".into()); + } + for w in &self.window { + if w.max_age_s == 0 || w.max_age_s > DVR_MAX_AGE_S { + return Err(format!("dvr: a window's max_age_s is {}; it is 1 to {DVR_MAX_AGE_S}", w.max_age_s)); + } + if w.max_bytes == 0 { + return Err("dvr: a window's max_bytes is 0".into()); + } + } + for k in &self.room_keys { + let b: [u8; 32] = hex::decode(k).map_err(|e| format!("dvr: a room key: {e}"))?.try_into().map_err(|_| "dvr: a room key is not 32 bytes".to_string())?; + ed25519_dalek::VerifyingKey::from_bytes(&b).map_err(|e| format!("dvr: a room key: {e}"))?; + } + Ok(()) + } } /// The room grants the relay's tokens carry (WAI live-rooms §10.1), for its per-hop session @@ -392,6 +460,7 @@ impl RelayConfig { summary: SummaryConfig::default(), auth: AuthConfig::default(), metering: MeteringConfig::default(), + dvr: None, goaway_uri: String::new(), } } @@ -416,6 +485,11 @@ mod tests { assert!(c.quic_listen.is_some() && c.websocket_listen.is_some()); assert_eq!(c.receipts.door.len(), 1); assert_eq!(c.auth.grants.len(), 1); + let d = c.dvr.as_ref().expect("the example keeps a DVR window"); + d.check().expect("the example's DVR configuration holds"); + assert_eq!(d.window[0].max_age_s, 900); assert!(RelayConfig::from_toml("unknown_field = 1").is_err()); + let long = DvrConfig { path: "/x".into(), window: vec![DvrWindow { prefix: vec![], max_age_s: DVR_MAX_AGE_S + 1, max_bytes: 1 }], room_keys: vec![] }; + assert!(long.check().is_err(), "a window past 24 hours"); } } diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/door.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/door.rs index e4b7767f9..ad64fe964 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/door.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/door.rs @@ -149,6 +149,15 @@ impl Door { self.rule(ns).is_some() } + /// The verifier a stored group of `ns` is checked with (the DVR store's load): its rule's + /// trusted keys, or the key its `trust_field` names; any key whose signature verifies + /// where no rule covers `ns`. + pub fn verifier_for(&self, ns: &TrackNamespace) -> Result { + let Some(rule) = self.rule(ns) else { return Ok(ReceiptVerifier::new()) }; + let v = rule.verifier(ns)?; + Ok(ReceiptVerifier { trust_anchors: v.trust_anchors.clone(), require_known_publisher: v.require_known_publisher }) + } + /// The media track a receipts track carries the receipts of, by the configured suffix. pub fn media_of(&self, name: &TrackName) -> Option { if self.suffix.is_empty() || name.0.len() <= self.suffix.len() || !name.0.ends_with(&self.suffix) { diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs new file mode 100644 index 000000000..ea5f6a661 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs @@ -0,0 +1,1304 @@ +//! The DVR store: a window of a room's tracks, with their receipts, kept on disk and served +//! to FETCH below the live edge (WAI `live-recording.md` §8). +//! +//! **What it keeps.** Every track whose namespace a `[[dvr.window]]` rule matches (the rule +//! with the longest prefix): media tracks, their receipts tracks, events tracks, catalogs +//! and takes alike. A group is stored once the cache holds it whole (`crate::cache`): its +//! objects as they arrived, with every field a FETCH carries (Subgroup ID or datagram, +//! publisher priority, properties, status, payload). Group *g* of a receipts track is the +//! receipts of media group *g* (WAI `moq-streaming-format.md` §4.1), so a viewer that +//! fetches both checks a recorded object exactly as it checks a live one. +//! +//! **The window.** Each rule has an age (`max_age_s`, at most 24 hours) and a byte budget +//! (`max_bytes`) over its tracks together. Past either, the oldest group goes first, whole, +//! and group *g* of a media track goes with group *g* of its receipts track. A group at or +//! below a track's eviction mark is no longer known to the relay: a FETCH that reaches it +//! is told so with an End of Unknown Range (MOQT §3.2.2, §11.4.1.2), since an evicted +//! object existed, and an unmarked gap would say it did not. +//! +//! **On disk.** One directory per track under `tracks/`, named by the first 16 bytes of the +//! SHA-256 of its rendered name (MOQT §8.8) in hexadecimal, holding `TRACK` (its namespace, +//! name and properties) and one file per group, `g.wdg`: +//! +//! ```text +//! header "WDG1" 1 track(32) group(u64) arrived_us(u64) count(u32) erased(u32) check(32) +//! object id(u64) subgroup(u64, 2^64-1 for a datagram) priority(u8) status(u8) +//! props_len(u32) payload_len(u32) digest(32) check(32) props payload +//! erased id(u64) digest(32) why(u8: 1 redacted, 2 did not verify) +//! ``` +//! +//! Integers are little-endian. `track` is the SHA-256 of the rendered track; a header's +//! `check` is SHA-256 over the header's bytes before it; an object's `digest` is the content +//! hash its receipt names it by (WAI `jwp-receipts.md` §2.1), and its `check` SHA-256 over +//! its fixed fields before `check`, its properties and its payload. +//! +//! **Durability.** A group is written to `g….tmp`, synced, renamed into place and its +//! directory synced, so a group file is whole or absent; a `.tmp` found at start is a write +//! a crash cut short and is removed. On start the store reads every file again: a header +//! that does not check drops its group; an object whose `check` fails, or, on a track with +//! a receipts track, whose payload does not verify against group *g*'s receipts (the group +//! receipt's signature under the door's trust for the namespace, the object's receipt at +//! its position, its Merkle proof to the signed root), or, on an events track, a record of +//! which does not verify under its principal, is dropped and counted, and its file is +//! written again with the object among the erased. It is never served: a FETCH that reaches +//! it is told End of Unknown Range. Each object's digest is checked again as it is read. +//! +//! **Moderation.** The relay reads the `decision` events (WAI `live-events.md` §4.11) on the +//! events tracks it already forwards. A decision is honoured when its record verifies, its +//! author owns the track it rides and is a moderator the relay knows: a room key it is +//! configured with (`dvr.room_keys`, the keys of `auth.authorities`), or the principal of a +//! grant with the moderate power its publishing session proved. The last honoured decision +//! on a target stands (WAI `live-moderation.md` §4.5): deny and redact withhold the target +//! from every DVR read, and from every FETCH the cache answers; redact also erases its bytes +//! from the store. Allow restores what deny withheld. The honoured decisions are kept in +//! `decisions.wdl` and survive a restart. + +use crate::auth::pattern_matches; +use crate::cache::CachedObject; +use crate::config::DvrConfig; +use crate::events::{self, Event, REDACT}; +use crate::relay::TrackKey; +use bytes::Bytes; +use joule_moq::carriage::ReceiptBundle; +use joule_moq::draft22::data::ObjectStatus; +use joule_moq::draft22::kvp::{prop, Properties}; +use joule_moq::draft22::names::render; +use joule_moq::draft22::Location; +use joule_moq::receipt::{ObjectReceipt, ReceiptVerifier}; +use joule_moq::{GroupId, ObjectId, TrackName, TrackNamespace}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::Duration; + +const GROUP_MAGIC: &[u8; 4] = b"WDG1"; +const TRACK_MAGIC: &[u8; 4] = b"WDT1"; +const VERSION: u8 = 1; +const HEADER_LEN: usize = 4 + 1 + 32 + 8 + 8 + 4 + 4 + 32; +const OBJECT_FIXED: usize = 8 + 8 + 1 + 1 + 4 + 4 + 32 + 32; +const DATAGRAM: u64 = u64::MAX; +/// Why an object is among a group's erased. +pub const ERASED_REDACTED: u8 = 1; +pub const ERASED_UNVERIFIED: u8 = 2; +/// The resolution of the store's metering series. +const POINT_US: u64 = 1_000_000; +/// How long the metering series keeps its points. +const SERIES_KEEP_US: u64 = 48 * 3_600 * 1_000_000; + +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") +} + +pub fn now_us() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_micros() as u64) +} + +/// The room a namespace is a track of (WAI live-rooms §6.1): its first field, 64 lowercase +/// hexadecimal digits. +pub fn room_of(ns: &TrackNamespace) -> Option<[u8; 32]> { + let f = ns.0.first()?; + if f.len() != 64 || !f.iter().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(b)) { + return None; + } + hex::decode(f).ok()?.try_into().ok() +} + +/// A key a namespace field names, 64 lowercase hexadecimal digits. +fn key_field(ns: &TrackNamespace, i: usize) -> Option<[u8; 32]> { + let f = ns.0.get(i)?; + if f.len() != 64 { + return None; + } + hex::decode(f).ok()?.try_into().ok() +} + +/// Whether a track is a room's events track (live-events §8.1): `[R, P, events]`, `0`. +pub fn is_events_track(key: &TrackKey) -> bool { + key.0 .0.len() == 3 && key.0 .0[2] == b"events" && key.1 .0 == b"0" && room_of(&key.0).is_some() +} + +// ── The index ────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug)] +struct DObject { + id: u64, + subgroup: Option, + priority: u8, + status: ObjectStatus, + /// Where its properties begin in the group's file; its payload follows them. + at: u64, + props_len: u32, + payload_len: u32, + digest: [u8; 32], + /// The digests of its event records, on an events track. + records: Vec<[u8; 32]>, +} + +#[derive(Clone, Debug)] +struct DGroup { + arrived_us: u64, + /// Payload bytes of its objects. + bytes: u64, + objects: Vec, + /// Objects no longer held, by Object ID, with their digests. + erased: Vec<(u64, [u8; 32], u8)>, +} + +#[derive(Debug)] +struct DTrack { + dir: PathBuf, + window: usize, + room: Option<[u8; 32]>, + events: bool, + props: Properties, + groups: BTreeMap, + /// Groups known not to exist (a Prior Group ID Gap, MOQT §10.8), `lo ..= hi` by `lo`. + absent: BTreeMap, + /// Groups at or below this are no longer known to the store. + evicted_through: Option, + end_of_track: Option, +} + +impl DTrack { + fn largest(&self) -> Option { + let (g, grp) = self.groups.iter().next_back()?; + let o = grp.objects.iter().map(|o| o.id).chain(grp.erased.iter().map(|e| e.0)).max()?; + Some(Location::new(*g, o)) + } +} + +/// The store's metering of one room (WAI live-metering §3.9): what it wrote, what it holds +/// over time, what it dropped. Points are kept each second a change falls in. +#[derive(Clone, Debug, Default)] +struct Series { + t_last: u64, + held: u64, + acc: u128, + stored_objects: u64, + stored_bytes: u64, + dropped: u64, + points: Vec<(u64, u128, u64, u64, u64, u64)>, +} + +impl Series { + fn change(&mut self, now: u64, held: i128, objects: u64, bytes: u64, dropped: u64) { + if self.t_last != 0 && now > self.t_last { + self.acc += self.held as u128 * (now - self.t_last) as u128; + } + self.t_last = self.t_last.max(now); + self.held = (self.held as i128 + held).max(0) as u64; + self.stored_objects += objects; + self.stored_bytes += bytes; + self.dropped += dropped; + let p = (self.t_last, self.acc, self.held, self.stored_objects, self.stored_bytes, self.dropped); + match self.points.last_mut() { + Some(last) if last.0 / POINT_US == p.0 / POINT_US => *last = p, + _ => self.points.push(p), + } + if self.points.first().is_some_and(|f| f.0 + SERIES_KEEP_US < now) { + self.points.retain(|x| x.0 + SERIES_KEEP_US >= now); + } + } + + /// (byte-microseconds held, objects stored, bytes stored, dropped) up to `x`. + fn at(&self, x: u64) -> (u128, u64, u64, u64) { + if self.t_last != 0 && x >= self.t_last { + return (self.acc + self.held as u128 * (x - self.t_last) as u128, self.stored_objects, self.stored_bytes, self.dropped); + } + match self.points.partition_point(|p| p.0 <= x) { + 0 => (0, 0, 0, 0), + i => { + let p = self.points[i - 1]; + (p.1 + p.2 as u128 * (x - p.0) as u128, p.3, p.4, p.5) + } + } + } +} + +/// What the store did for one room over a span (WAI live-metering §3.9). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RoomFigures { + pub objects_stored: u64, + pub bytes_stored: u64, + /// Payload bytes held, integrated over the span, in byte-seconds, rounded down. + pub byte_s: u64, + /// Objects dropped because they did not verify against their receipts. + pub dropped: u64, +} + +#[derive(Default)] +struct Index { + tracks: HashMap, + window_bytes: Vec, + /// Where each digest is held: (track, group, Object ID). + by_digest: HashMap<[u8; 32], Vec<(TrackKey, u64, u64)>>, + /// The standing decision on each (room, target): its verdict. + standing: HashMap<([u8; 32], [u8; 32]), u8>, + decided: HashSet<[u8; 32]>, + series: HashMap<[u8; 32], Series>, +} + +impl Index { + fn withheld(&self, room: Option<[u8; 32]>, digest: &[u8; 32]) -> bool { + room.is_some_and(|r| self.standing.get(&(r, *digest)).is_some_and(|v| *v != events::ALLOW)) + } +} + +/// Counters across the store. +#[derive(Default)] +pub struct DvrStats { + pub groups_written: AtomicU64, + pub objects_written: AtomicU64, + pub groups_evicted: AtomicU64, + /// Objects dropped because their check or their receipt did not verify. + pub objects_dropped: AtomicU64, + /// Group files a crash cut short (`.tmp`), removed at start. + pub torn_writes: AtomicU64, + /// Objects erased by a redaction. + pub objects_redacted: AtomicU64, + pub decisions: AtomicU64, +} + +/// A snapshot of [`DvrStats`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct DvrCounts { + pub groups_written: u64, + pub objects_written: u64, + pub groups_evicted: u64, + pub objects_dropped: u64, + pub torn_writes: u64, + pub objects_redacted: u64, + pub decisions: u64, + pub groups_held: u64, + pub bytes_held: u64, +} + +/// One object read from the store, or a place it can no longer serve. +#[derive(Clone, Debug)] +pub enum Slot { + Object(StoredObject), + /// No longer held, or withheld: its Object ID. + Unknown(u64), +} + +#[derive(Clone, Debug)] +pub struct StoredObject { + pub id: u64, + pub subgroup: Option, + pub priority: u8, + pub status: ObjectStatus, + pub properties: Properties, + pub payload: Bytes, +} + +/// What the store holds of a track over a span of groups, for a FETCH's plan. +#[derive(Clone, Debug, Default)] +pub struct Snapshot { + pub held: BTreeSet, + pub absent: Vec<(u64, u64)>, + pub evicted_through: Option, + pub largest: Option, + pub end_of_track: Option, + pub props: Properties, +} + +impl Snapshot { + /// Whether group `g` is known not to exist, and the last group of its run if so. + pub fn absent_through(&self, g: u64) -> Option { + self.absent.iter().filter(|(lo, hi)| *lo <= g && g <= *hi).map(|(_, hi)| *hi).max() + } +} + +enum Job { + Seal { key: TrackKey, props: Properties, group: u64, arrived_us: u64, objects: Vec<((u64, u64), CachedObject)> }, + Erase { room: [u8; 32], digest: [u8; 32] }, + Log(Vec), + Flush(mpsc::Sender<()>), +} + +struct Window { + prefix: TrackNamespace, + max_age_us: u64, + max_bytes: u64, +} + +pub struct Dvr { + root: PathBuf, + windows: Vec, + suffix: Vec, + room_keys: Vec<[u8; 32]>, + index: Mutex, + tx: Mutex>>, + worker: Mutex>>, + pub stats: DvrStats, +} + +fn put_short_field(o: &mut Vec, b: &[u8]) { + o.extend_from_slice(&(b.len() as u16).to_le_bytes()); + o.extend_from_slice(b); +} + +fn track_dir_name(key: &TrackKey) -> String { + hex::encode(&sha256(&[render(&key.0, Some(&key.1)).as_bytes()])[..16]) +} + +fn group_file(dir: &Path, g: u64) -> PathBuf { + dir.join(format!("g{g:020}.wdg")) +} + +fn sync_dir(dir: &Path) { + if let Ok(f) = std::fs::File::open(dir) { + let _ = f.sync_all(); + } +} + +/// Write `bytes` to `path` durably: a temporary file, synced, renamed into place, and the +/// directory synced. +fn write_durably(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let tmp = path.with_extension("tmp"); + { + let mut f = std::fs::File::create(&tmp)?; + f.write_all(bytes)?; + f.sync_all()?; + } + std::fs::rename(&tmp, path)?; + if let Some(d) = path.parent() { + sync_dir(d); + } + Ok(()) +} + +fn status_code(s: ObjectStatus) -> u8 { + s.code() as u8 +} + +/// The bytes of a group file (module documentation). +fn encode_group(track_digest: &[u8; 32], group: u64, arrived_us: u64, objects: &[(DObject, Bytes, Bytes)], erased: &[(u64, [u8; 32], u8)]) -> (Vec, Vec) { + let mut o = Vec::new(); + o.extend_from_slice(GROUP_MAGIC); + o.push(VERSION); + o.extend_from_slice(track_digest); + o.extend_from_slice(&group.to_le_bytes()); + o.extend_from_slice(&arrived_us.to_le_bytes()); + o.extend_from_slice(&(objects.len() as u32).to_le_bytes()); + o.extend_from_slice(&(erased.len() as u32).to_le_bytes()); + let check = sha256(&[&o]); + o.extend_from_slice(&check); + let mut index = Vec::with_capacity(objects.len()); + for (d, props, payload) in objects { + let mut fixed = Vec::with_capacity(OBJECT_FIXED); + fixed.extend_from_slice(&d.id.to_le_bytes()); + fixed.extend_from_slice(&d.subgroup.unwrap_or(DATAGRAM).to_le_bytes()); + fixed.push(d.priority); + fixed.push(status_code(d.status)); + fixed.extend_from_slice(&(props.len() as u32).to_le_bytes()); + fixed.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + fixed.extend_from_slice(&d.digest); + let check = sha256(&[&fixed, props, payload]); + o.extend_from_slice(&fixed); + o.extend_from_slice(&check); + let at = o.len() as u64; + o.extend_from_slice(props); + o.extend_from_slice(payload); + index.push(DObject { at, props_len: props.len() as u32, payload_len: payload.len() as u32, ..d.clone() }); + } + for (id, digest, why) in erased { + o.extend_from_slice(&id.to_le_bytes()); + o.extend_from_slice(digest); + o.push(*why); + } + (o, index) +} + +/// A group file read: its header facts, each object with its properties and payload and +/// whether its `check` held, and its erased entries. `None` when the header does not +/// check; objects past a truncation are left out (and so are erased below). +#[allow(clippy::type_complexity)] +fn decode_group(b: &[u8], track_digest: &[u8; 32]) -> Option<(u64, u64, u32, Vec<(DObject, Bytes, Bytes, bool)>, Vec<(u64, [u8; 32], u8)>)> { + if b.len() < HEADER_LEN || &b[..4] != GROUP_MAGIC || b[4] != VERSION || &b[5..37] != track_digest { + return None; + } + if sha256(&[&b[..HEADER_LEN - 32]])[..] != b[HEADER_LEN - 32..HEADER_LEN] { + return None; + } + let u64_at = |i: usize| u64::from_le_bytes(b[i..i + 8].try_into().expect("8")); + let u32_at = |i: usize| u32::from_le_bytes(b[i..i + 4].try_into().expect("4")); + let group = u64_at(37); + let arrived = u64_at(45); + let count = u32_at(53); + let erased_n = u32_at(57) as usize; + let mut p = HEADER_LEN; + let mut objects = Vec::new(); + for _ in 0..count { + if b.len() < p + OBJECT_FIXED { + break; + } + let f = &b[p..p + OBJECT_FIXED]; + let id = u64::from_le_bytes(f[..8].try_into().expect("8")); + let sg = u64::from_le_bytes(f[8..16].try_into().expect("8")); + let priority = f[16]; + let status = ObjectStatus::from_code(f[17] as u64).unwrap_or(ObjectStatus::Normal); + let props_len = u32::from_le_bytes(f[18..22].try_into().expect("4")); + let payload_len = u32::from_le_bytes(f[22..26].try_into().expect("4")); + let digest: [u8; 32] = f[26..58].try_into().expect("32"); + let check = &f[58..90]; + let at = p + OBJECT_FIXED; + let end = at + props_len as usize + payload_len as usize; + if b.len() < end { + break; + } + let props = Bytes::copy_from_slice(&b[at..at + props_len as usize]); + let payload = Bytes::copy_from_slice(&b[at + props_len as usize..end]); + let ok = sha256(&[&f[..58], &props, &payload])[..] == *check; + objects.push((DObject { id, subgroup: (sg != DATAGRAM).then_some(sg), priority, status, at: at as u64, props_len, payload_len, digest, records: Vec::new() }, props, payload, ok)); + p = end; + } + let mut erased = Vec::new(); + if objects.len() == count as usize { + for _ in 0..erased_n { + if b.len() < p + 41 { + break; + } + erased.push((u64::from_le_bytes(b[p..p + 8].try_into().expect("8")), b[p + 8..p + 40].try_into().expect("32"), b[p + 40])); + p += 41; + } + } + Some((group, arrived, count, objects, erased)) +} + +fn encode_track(key: &TrackKey, props: &Properties) -> Vec { + let mut o = TRACK_MAGIC.to_vec(); + o.push(VERSION); + o.push(key.0 .0.len() as u8); + for f in &key.0 .0 { + put_short_field(&mut o, f); + } + put_short_field(&mut o, &key.1 .0); + let p = props.to_bytes().unwrap_or_default(); + o.extend_from_slice(&(p.len() as u32).to_le_bytes()); + o.extend_from_slice(&p); + let check = sha256(&[&o]); + o.extend_from_slice(&check); + o +} + +fn decode_track(b: &[u8]) -> Option<(TrackKey, Properties)> { + if b.len() < 4 + 1 + 1 + 32 || &b[..4] != TRACK_MAGIC || b[4] != VERSION { + return None; + } + let (body, check) = b.split_at(b.len() - 32); + if sha256(&[body])[..] != *check { + return None; + } + let mut p = 6; + let mut field = || -> Option> { + let n = u16::from_le_bytes(body.get(p..p + 2)?.try_into().ok()?) as usize; + let v = body.get(p + 2..p + 2 + n)?.to_vec(); + p += 2 + n; + Some(v) + }; + let mut fields = Vec::new(); + for _ in 0..body[5] { + fields.push(field()?); + } + let name = field()?; + let n = u32::from_le_bytes(body.get(p..p + 4)?.try_into().ok()?) as usize; + let props = Properties::decode(body.get(p + 4..p + 4 + n)?, "dvr track properties").ok()?; + if p + 4 + n != body.len() { + return None; + } + Some(((TrackNamespace(fields), TrackName(name)), props)) +} + +/// The Prior Group ID Gap an object's properties carry (MOQT §10.8): the groups before it +/// that do not exist. +fn prior_groups(props: &Properties, group: u64) -> Option<(u64, u64)> { + let n = props.find(prop::PRIOR_GROUP_ID_GAP).ok().flatten().and_then(|k| k.as_varint())?; + (n > 0 && n <= group).then(|| (group - n, group - 1)) +} + +impl Dvr { + /// Open the store at `cfg.path`: read every track and group in it, check every object + /// again, and start its writer. `verifier` gives the receipts verifier of a namespace; + /// `authorities` are the keys of `auth.authorities`, room keys besides `cfg.room_keys`. + pub fn open(cfg: &DvrConfig, suffix: &str, authorities: Vec<[u8; 32]>, verifier: impl Fn(&TrackNamespace) -> ReceiptVerifier) -> Result, String> { + cfg.check()?; + let root = cfg.path.clone(); + std::fs::create_dir_all(root.join("tracks")).map_err(|e| format!("dvr: {}: {e}", root.display()))?; + let mut room_keys: Vec<[u8; 32]> = cfg.room_keys.iter().filter_map(|k| hex::decode(k).ok()?.try_into().ok()).collect(); + room_keys.extend(authorities); + let windows = cfg + .window + .iter() + .map(|w| Window { prefix: TrackNamespace::from_strs(w.prefix.iter().map(String::as_str)), max_age_us: w.max_age_s * 1_000_000, max_bytes: w.max_bytes }) + .collect::>(); + let dvr = Arc::new(Dvr { + root, + index: Mutex::new(Index { window_bytes: vec![0; windows.len()], ..Index::default() }), + windows, + suffix: suffix.as_bytes().to_vec(), + room_keys, + tx: Mutex::new(None), + worker: Mutex::new(None), + stats: DvrStats::default(), + }); + dvr.load(&verifier)?; + let (tx, rx) = mpsc::channel(); + *dvr.tx.lock().unwrap() = Some(tx); + let me = dvr.clone(); + let worker = std::thread::Builder::new().name("joule-moq-relay-dvr".into()).spawn(move || me.run(rx)).map_err(|e| e.to_string())?; + *dvr.worker.lock().unwrap() = Some(worker); + Ok(dvr) + } + + /// The window that keeps `ns`: the rule with the longest prefix that matches it. + pub fn window_of(&self, ns: &TrackNamespace) -> Option { + self.windows.iter().enumerate().filter(|(_, w)| pattern_matches(&w.prefix, ns)).max_by_key(|(_, w)| w.prefix.0.len()).map(|(i, _)| i) + } + + pub fn keeps(&self, ns: &TrackNamespace) -> bool { + self.window_of(ns).is_some() + } + + fn send(&self, j: Job) { + if let Some(tx) = self.tx.lock().unwrap().as_ref() { + let _ = tx.send(j); + } + } + + /// Wait until every write asked for so far is on disk. + pub fn flush(&self) { + let (tx, rx) = mpsc::channel(); + self.send(Job::Flush(tx)); + let _ = rx.recv_timeout(Duration::from_secs(30)); + } + + /// Flush, then stop the writer. The store is closed; a new one can open its path. + pub fn close(&self) { + self.flush(); + self.tx.lock().unwrap().take(); + if let Some(w) = self.worker.lock().unwrap().take() { + let _ = w.join(); + } + } + + /// Group `group` of `key` is whole in the cache: store it, unless it is stored with as + /// many objects already. Cheap: the write is the writer's. + pub fn seal(&self, key: &TrackKey, props: &Properties, group: u64, arrived_us: u64, objects: Vec<((u64, u64), CachedObject)>) { + if objects.is_empty() || !self.keeps(&key.0) { + return; + } + { + let ix = self.index.lock().unwrap(); + if let Some(t) = ix.tracks.get(key) { + if t.evicted_through.is_some_and(|e| group <= e) { + return; + } + if t.groups.get(&group).is_some_and(|g| g.objects.len() + g.erased.len() >= objects.len()) { + return; + } + } + } + self.send(Job::Seal { key: key.clone(), props: props.clone(), group, arrived_us, objects }); + } + + /// What the store holds of `key` from group `lo` to `hi`. + pub fn snapshot(&self, key: &TrackKey, lo: u64, hi: u64) -> Option { + let ix = self.index.lock().unwrap(); + let t = ix.tracks.get(key)?; + Some(Snapshot { + held: t.groups.range(lo..=hi).map(|(g, _)| *g).collect(), + absent: t.absent.iter().filter(|(l, h)| **h >= lo && **l <= hi).map(|(l, h)| (*l, *h)).collect(), + evicted_through: t.evicted_through, + largest: t.largest(), + end_of_track: t.end_of_track, + props: t.props.clone(), + }) + } + + /// The Largest Object the store holds of `key`. + pub fn largest(&self, key: &TrackKey) -> Option { + self.index.lock().unwrap().tracks.get(key).and_then(DTrack::largest) + } + + /// The track's properties as the store recorded them. + pub fn props(&self, key: &TrackKey) -> Option { + self.index.lock().unwrap().tracks.get(key).map(|t| t.props.clone()) + } + + /// Whether any decision withholds something in `room`. + pub fn withholds_in(&self, room: Option<[u8; 32]>) -> bool { + room.is_some_and(|r| self.index.lock().unwrap().standing.iter().any(|((rr, _), v)| *rr == r && *v != events::ALLOW)) + } + + /// Whether the standing decision on `digest` in `room` withholds it. + pub fn withheld(&self, room: Option<[u8; 32]>, digest: &[u8; 32]) -> bool { + self.index.lock().unwrap().withheld(room, digest) + } + + /// The objects of group `group` of `key` from `from` to `to` (Object IDs, inclusive), + /// read from disk, in Object ID order, with what is no longer held or withheld marked. + /// An events-track object some of whose records are withheld comes back without them + /// (`crate::events::withhold`). With `withhold` false (a moderator's session, WAI + /// live-moderation §6.3) nothing is withheld; what is erased stays unknown. `None` when + /// the store no longer holds the group. + pub fn read_group(&self, key: &TrackKey, group: u64, from: u64, to: u64, withhold: bool) -> Option> { + let (path, objs, erased, room, is_events) = { + let ix = self.index.lock().unwrap(); + let t = ix.tracks.get(key)?; + let g = t.groups.get(&group)?; + (group_file(&t.dir, group), g.objects.clone(), g.erased.clone(), t.room, t.events) + }; + let mut f = std::fs::File::open(&path).ok()?; + let mut out: Vec<(u64, Slot)> = Vec::new(); + for (id, _, _) in erased.iter().filter(|e| e.0 >= from && e.0 <= to) { + out.push((*id, Slot::Unknown(*id))); + } + for o in objs.iter().filter(|o| o.id >= from && o.id <= to) { + let mut b = vec![0u8; o.props_len as usize + o.payload_len as usize]; + if f.seek(SeekFrom::Start(o.at)).and_then(|_| f.read_exact(&mut b)).is_err() { + out.push((o.id, Slot::Unknown(o.id))); + continue; + } + let payload = Bytes::from(b.split_off(o.props_len as usize)); + if ObjectReceipt::hash_payload(&payload) != o.digest { + self.stats.objects_dropped.fetch_add(1, Ordering::Relaxed); + out.push((o.id, Slot::Unknown(o.id))); + continue; + } + let ix = self.index.lock().unwrap(); + let room = room.filter(|_| withhold); + if ix.withheld(room, &o.digest) { + out.push((o.id, Slot::Unknown(o.id))); + continue; + } + let payload = if is_events && o.records.iter().any(|d| ix.withheld(room, d)) { + match events::withhold(&payload, |d| ix.withheld(room, d)) { + Some(p) => Bytes::from(p.into_owned()), + None => { + out.push((o.id, Slot::Unknown(o.id))); + continue; + } + } + } else { + payload + }; + drop(ix); + let properties = if b.is_empty() { Properties::new() } else { Properties::decode(&b, "dvr object properties").unwrap_or_default() }; + out.push((o.id, Slot::Object(StoredObject { id: o.id, subgroup: o.subgroup, priority: o.priority, status: o.status, properties, payload }))); + } + out.sort_by_key(|(id, _)| *id); + Some(out.into_iter().map(|(_, s)| s).collect()) + } + + /// What the store did for `room` between two instants (WAI live-metering §3.9). + pub fn figures(&self, room: [u8; 32], from_us: u64, to_us: u64) -> RoomFigures { + let ix = self.index.lock().unwrap(); + let Some(s) = ix.series.get(&room) else { return RoomFigures::default() }; + let (a0, o0, b0, d0) = s.at(from_us); + let (a1, o1, b1, d1) = s.at(to_us.max(from_us)); + RoomFigures { objects_stored: o1 - o0, bytes_stored: b1 - b0, byte_s: ((a1 - a0) / 1_000_000) as u64, dropped: d1 - d0 } + } + + pub fn counts(&self) -> DvrCounts { + let s = &self.stats; + let ix = self.index.lock().unwrap(); + DvrCounts { + groups_written: s.groups_written.load(Ordering::Relaxed), + objects_written: s.objects_written.load(Ordering::Relaxed), + groups_evicted: s.groups_evicted.load(Ordering::Relaxed), + objects_dropped: s.objects_dropped.load(Ordering::Relaxed), + torn_writes: s.torn_writes.load(Ordering::Relaxed), + objects_redacted: s.objects_redacted.load(Ordering::Relaxed), + decisions: s.decisions.load(Ordering::Relaxed), + groups_held: ix.tracks.values().map(|t| t.groups.len() as u64).sum(), + bytes_held: ix.window_bytes.iter().sum(), + } + } + + /// The groups the store holds of `key`, ascending. + pub fn groups(&self, key: &TrackKey) -> Vec { + self.index.lock().unwrap().tracks.get(key).map(|t| t.groups.keys().copied().collect()).unwrap_or_default() + } + + /// The file of group `group` of `key`, for tests and tools. + pub fn group_path(&self, key: &TrackKey, group: u64) -> Option { + self.index.lock().unwrap().tracks.get(key).map(|t| group_file(&t.dir, group)) + } + + // ── Moderation ────────────────────────────────────────────────────────── + + /// One object of an events track `ns` the relay forwards: each `decision` record of it + /// is honoured when it verifies, its author owns the track, and the author is a + /// moderator (`moderators`: the principals of grants with the moderate power the + /// publishing session proved, in this room). + pub fn events_object(&self, ns: &TrackNamespace, payload: &[u8], moderators: &[[u8; 32]]) { + let (Some(room), Some(owner)) = (room_of(ns), key_field(ns, 1)) else { return }; + if !(ns.0.len() == 3 && ns.0[2] == b"events") { + return; + } + let Some(recs) = events::records(payload) else { return }; + for (_, rec) in recs { + let Some(e) = Event::read(&rec) else { continue }; + if e.decision().is_none() || e.room != room || e.principal != owner { + continue; + } + if self.index.lock().unwrap().decided.contains(&e.digest) { + continue; + } + if !(self.room_keys.contains(&owner) || moderators.contains(&owner)) || !e.verifies(&rec) { + continue; + } + self.apply_decision(&e); + self.send(Job::Log(rec)); + } + } + + fn apply_decision(&self, e: &Event) { + let Some((verdict, target)) = e.decision() else { return }; + let mut ix = self.index.lock().unwrap(); + if !ix.decided.insert(e.digest) { + return; + } + ix.standing.insert((e.room, target), verdict); + drop(ix); + self.stats.decisions.fetch_add(1, Ordering::Relaxed); + tracing::info!(target: "joule_moq_relay::dvr", room = %hex::encode(e.room), target_digest = %hex::encode(target), verdict, "a decision is honoured"); + if verdict == REDACT { + self.send(Job::Erase { room: e.room, digest: target }); + } + } + + // ── Loading ───────────────────────────────────────────────────────────── + + fn load(&self, verifier: &dyn Fn(&TrackNamespace) -> ReceiptVerifier) -> Result<(), String> { + let now = now_us(); + // The decisions honoured before, each verified again. + let log = self.root.join("decisions.wdl"); + if let Ok(b) = std::fs::read(&log) { + let mut p = 0; + let mut good = 0; + while b.len() >= p + 4 { + let n = u32::from_le_bytes(b[p..p + 4].try_into().expect("4")) as usize; + let Some(rec) = b.get(p + 4..p + 4 + n) else { break }; + let Some(check) = b.get(p + 4 + n..p + 4 + n + 32) else { break }; + if sha256(&[rec])[..] != *check { + break; + } + if let Some(e) = Event::read(rec).filter(|e| e.verifies(rec)) { + self.apply_decision(&e); + } + p += 4 + n + 32; + good = p; + } + if good != b.len() { + // A record a crash cut short: the log keeps what precedes it. + let _ = std::fs::OpenOptions::new().write(true).open(&log).and_then(|f| f.set_len(good as u64)); + } + } + let tracks_dir = self.root.join("tracks"); + let mut loaded: Vec<(TrackKey, DTrack)> = Vec::new(); + for d in std::fs::read_dir(&tracks_dir).map_err(|e| e.to_string())?.flatten() { + let dir = d.path(); + let Some((key, props)) = std::fs::read(dir.join("TRACK")).ok().and_then(|b| decode_track(&b)) else { + tracing::warn!(target: "joule_moq_relay::dvr", dir = %dir.display(), "a track directory whose TRACK does not read: left alone"); + continue; + }; + let Some(window) = self.window_of(&key.0) else { continue }; + let digest = sha256(&[render(&key.0, Some(&key.1)).as_bytes()]); + let mut t = DTrack { dir: dir.clone(), window, room: room_of(&key.0), events: is_events_track(&key), props, groups: BTreeMap::new(), absent: BTreeMap::new(), evicted_through: None, end_of_track: None }; + let mut files: Vec = std::fs::read_dir(&dir).map_err(|e| e.to_string())?.flatten().map(|e| e.path()).collect(); + files.sort(); + for f in files { + match f.extension().and_then(|x| x.to_str()) { + Some("tmp") => { + self.stats.torn_writes.fetch_add(1, Ordering::Relaxed); + let _ = std::fs::remove_file(&f); + } + Some("wdg") => { + let Ok(b) = std::fs::read(&f) else { continue }; + match decode_group(&b, &digest) { + None => { + self.stats.objects_dropped.fetch_add(1, Ordering::Relaxed); + self.series_change(t.room, now, 0, 0, 0, 1); + tracing::warn!(target: "joule_moq_relay::dvr", file = %f.display(), "a group whose header does not check: dropped"); + let _ = std::fs::remove_file(&f); + } + Some((g, arrived, count, objects, erased)) => { + let short = count as usize - objects.len(); + t.groups.insert(g, DGroup { arrived_us: arrived, bytes: 0, objects: Vec::new(), erased }); + let mut keep = Vec::new(); + for (o, props, payload, ok) in objects { + if !ok || ObjectReceipt::hash_payload(&payload) != o.digest { + self.drop_unverified(&mut t, g, &o, "its check does not hold"); + continue; + } + keep.push((o, props, payload)); + } + if short > 0 { + self.stats.objects_dropped.fetch_add(short as u64, Ordering::Relaxed); + self.series_change(t.room, now, 0, 0, 0, short as u64); + } + let grp = t.groups.get_mut(&g).expect("inserted"); + for (o, props, payload) in keep { + if let Some(r) = prior_groups(&Properties::decode(&props, "dvr").unwrap_or_default(), g) { + t.absent.insert(r.0, r.1); + } + if o.status == ObjectStatus::EndOfTrack { + t.end_of_track = Some(Location::new(g, o.id)); + } + let mut o = o; + if t.events { + o.records = events::digests(&payload); + } + grp.bytes += payload.len() as u64; + grp.objects.push(o); + } + } + } + } + _ => {} + } + } + loaded.push((key, t)); + } + // Every object of a track with a receipts track, against its group's receipts; every + // record of an events track, under its principal. + let held: HashSet = loaded.iter().map(|(k, _)| k.clone()).collect(); + let mut receipts: HashMap>> = HashMap::new(); + for (k, t) in &loaded { + if self.media_of(&k.1).is_some() { + let mut m = BTreeMap::new(); + for (g, grp) in &t.groups { + if let Some(o) = grp.objects.first() + && let Ok(b) = read_at(&group_file(&t.dir, *g), o.at + o.props_len as u64, o.payload_len as usize) + { + m.insert(*g, b); + } + } + receipts.insert(k.clone(), m); + } + } + for (k, t) in &mut loaded { + let rkey = (k.0.clone(), self.receipts_of(&k.1)); + let check_receipts = self.media_of(&k.1).is_none() && held.contains(&rkey); + if !(check_receipts || t.events) { + continue; + } + let v = verifier(&k.0); + let groups: Vec = t.groups.keys().copied().collect(); + for g in groups { + let path = group_file(&t.dir, g); + let objs = t.groups[&g].objects.clone(); + let bundle = if check_receipts { receipts.get(&rkey).and_then(|m| m.get(&g)).and_then(|b| ReceiptBundle::from_payload(b).ok()) } else { None }; + let vg = bundle.as_ref().and_then(|b| (b.group.group_id == GroupId(g)).then(|| v.verify_group(&b.group).ok()).flatten()); + for o in objs { + let ok = if check_receipts { + match (&bundle, &vg) { + (Some(b), Some(vg)) => b.objects.iter().find(|r| r.object_id == ObjectId(o.id)).is_some_and(|r| { + read_at(&path, o.at + o.props_len as u64, o.payload_len as usize).is_ok_and(|p| v.verify_payload(&p, r, vg).is_ok() && v.verify_object(r, vg).is_ok()) + }), + _ => o.status != ObjectStatus::Normal, + } + } else { + read_at(&path, o.at + o.props_len as u64, o.payload_len as usize).is_ok_and(|p| match events::records(&p) { + Some(rs) => rs.iter().all(|(_, r)| Event::read(r).is_some_and(|e| e.verifies(r))), + None => true, + }) + }; + if !ok { + self.drop_unverified(t, g, &o, if check_receipts { "it does not verify against its receipt" } else { "a record of it does not verify" }); + } + } + } + } + // Erasures the log asks for that a crash left undone, and every group whose dropped + // objects must be written out of its file. + let mut ix = self.index.lock().unwrap(); + for (k, mut t) in loaded { + for (g, grp) in t.groups.iter_mut() { + let redacted: Vec = grp.objects.iter().filter(|o| t.room.is_some_and(|r| ix.standing.get(&(r, o.digest)) == Some(&REDACT))).cloned().collect(); + for o in &redacted { + grp.objects.retain(|x| x.id != o.id); + grp.erased.push((o.id, o.digest, ERASED_REDACTED)); + self.stats.objects_redacted.fetch_add(1, Ordering::Relaxed); + } + let path = group_file(&t.dir, *g); + if let Ok(b) = std::fs::read(&path) + && let Some((_, _, count, objs, erased)) = decode_group(&b, &sha256(&[render(&k.0, Some(&k.1)).as_bytes()])) + && (count as usize != grp.objects.len() || erased.len() != grp.erased.len() || objs.len() != grp.objects.len()) + { + rewrite(&path, &k, *g, grp, &objs); + } + ix.window_bytes[t.window] += grp.bytes; + for o in &grp.objects { + ix.by_digest.entry(o.digest).or_default().push((k.clone(), *g, o.id)); + for d in &o.records { + ix.by_digest.entry(*d).or_default().push((k.clone(), *g, o.id)); + } + } + if let Some(room) = t.room { + ix.series.entry(room).or_default().change(now, grp.bytes as i128, 0, 0, 0); + } + } + // What is below the first group held is no longer known to the store. + t.evicted_through = t.groups.keys().next().and_then(|g| g.checked_sub(1)); + ix.tracks.insert(k, t); + } + let n: usize = ix.tracks.values().map(|t| t.groups.len()).sum(); + drop(ix); + tracing::info!(target: "joule_moq_relay::dvr", path = %self.root.display(), groups = n, dropped = self.stats.objects_dropped.load(Ordering::Relaxed), torn = self.stats.torn_writes.load(Ordering::Relaxed), "the DVR store is open"); + Ok(()) + } + + /// An object that did not verify at load: out of the index, among the group's erased, + /// counted. Its file is written again at the end of the load. + fn drop_unverified(&self, t: &mut DTrack, g: u64, o: &DObject, why: &str) { + self.stats.objects_dropped.fetch_add(1, Ordering::Relaxed); + self.series_change(t.room, now_us(), 0, 0, 0, 1); + tracing::warn!(target: "joule_moq_relay::dvr", dir = %t.dir.display(), group = g, object = o.id, reason = why, "a stored object dropped: never served"); + if let Some(grp) = t.groups.get_mut(&g) { + if grp.objects.iter().any(|x| x.id == o.id) { + grp.bytes = grp.bytes.saturating_sub(o.payload_len as u64); + } + grp.objects.retain(|x| x.id != o.id); + if !grp.erased.iter().any(|e| e.0 == o.id) { + grp.erased.push((o.id, o.digest, ERASED_UNVERIFIED)); + } + } + } + + fn series_change(&self, room: Option<[u8; 32]>, now: u64, held: i128, objects: u64, bytes: u64, dropped: u64) { + if let Some(r) = room { + self.index.lock().unwrap().series.entry(r).or_default().change(now, held, objects, bytes, dropped); + } + } + + fn media_of(&self, name: &TrackName) -> Option { + (!self.suffix.is_empty() && name.0.len() > self.suffix.len() && name.0.ends_with(&self.suffix)).then(|| TrackName(name.0[..name.0.len() - self.suffix.len()].to_vec())) + } + + fn receipts_of(&self, name: &TrackName) -> TrackName { + let mut v = name.0.clone(); + v.extend_from_slice(&self.suffix); + TrackName(v) + } + + // ── The writer ────────────────────────────────────────────────────────── + + fn run(self: Arc, rx: mpsc::Receiver) { + loop { + match rx.recv_timeout(Duration::from_secs(1)) { + Ok(Job::Seal { key, props, group, arrived_us, objects }) => self.write_seal(key, props, group, arrived_us, objects), + Ok(Job::Erase { room, digest }) => self.erase(room, digest), + Ok(Job::Log(rec)) => self.log(&rec), + Ok(Job::Flush(done)) => { + let _ = done.send(()); + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => return, + } + self.evict_by_age(now_us()); + } + } + + fn log(&self, rec: &[u8]) { + let path = self.root.join("decisions.wdl"); + let mut b = (rec.len() as u32).to_le_bytes().to_vec(); + b.extend_from_slice(rec); + b.extend_from_slice(&sha256(&[rec])); + let r = std::fs::OpenOptions::new().create(true).append(true).open(&path).and_then(|mut f| { + f.write_all(&b)?; + f.sync_all() + }); + if let Err(e) = r { + tracing::error!(target: "joule_moq_relay::dvr", error = %e, "the decisions log could not be written"); + } + } + + fn write_seal(&self, key: TrackKey, props: Properties, group: u64, arrived_us: u64, objects: Vec<((u64, u64), CachedObject)>) { + let Some(window) = self.window_of(&key.0) else { return }; + let room = room_of(&key.0); + let events = is_events_track(&key); + let digest = sha256(&[render(&key.0, Some(&key.1)).as_bytes()]); + let dir = self.root.join("tracks").join(track_dir_name(&key)); + let known = self.index.lock().unwrap().tracks.contains_key(&key); + if !known { + if let Err(e) = std::fs::create_dir_all(&dir).and_then(|_| write_durably(&dir.join("TRACK"), &encode_track(&key, &props))) { + tracing::error!(target: "joule_moq_relay::dvr", error = %e, "a track could not be stored"); + return; + } + sync_dir(&self.root.join("tracks")); + } + let mut rows = Vec::with_capacity(objects.len()); + let mut erased = Vec::new(); + { + let ix = self.index.lock().unwrap(); + for ((_, oid), o) in &objects { + let d = ObjectReceipt::hash_payload(&o.payload); + if room.is_some_and(|r| ix.standing.get(&(r, d)) == Some(&REDACT)) { + erased.push((*oid, d, ERASED_REDACTED)); + continue; + } + let records = if events { events::digests(&o.payload) } else { Vec::new() }; + let row = DObject { id: *oid, subgroup: o.subgroup, priority: o.publisher_priority, status: o.status, at: 0, props_len: 0, payload_len: 0, digest: d, records }; + rows.push((row, Bytes::from(o.properties.to_bytes().unwrap_or_default()), o.payload.clone())); + } + } + let (bytes, index) = encode_group(&digest, group, arrived_us, &rows, &erased); + let path = group_file(&dir, group); + if let Err(e) = write_durably(&path, &bytes) { + tracing::error!(target: "joule_moq_relay::dvr", error = %e, file = %path.display(), "a group could not be stored"); + return; + } + let payload_bytes: u64 = index.iter().map(|o| o.payload_len as u64).sum(); + let mut ix = self.index.lock().unwrap(); + let t = ix.tracks.entry(key.clone()).or_insert_with(|| DTrack { dir: dir.clone(), window, room, events, props: props.clone(), groups: BTreeMap::new(), absent: BTreeMap::new(), evicted_through: None, end_of_track: None }); + if t.evicted_through.is_some_and(|e| group <= e) { + drop(ix); + let _ = std::fs::remove_file(&path); + return; + } + let (old_bytes, old_n) = t.groups.get(&group).map_or((0, 0), |g| (g.bytes, g.objects.len())); + for (_, o) in &objects { + if let Some(r) = prior_groups(&o.properties, group) { + t.absent.insert(r.0, r.1); + } + if o.status == ObjectStatus::EndOfTrack { + t.end_of_track = objects.iter().find(|(_, x)| x.status == ObjectStatus::EndOfTrack).map(|((g, oo), _)| Location::new(*g, *oo)); + } + } + t.groups.insert(group, DGroup { arrived_us, bytes: payload_bytes, objects: index.clone(), erased }); + ix.window_bytes[window] = ix.window_bytes[window] - old_bytes + payload_bytes; + for o in &index { + let e = ix.by_digest.entry(o.digest).or_default(); + if !e.iter().any(|x| x.0 == key && x.1 == group && x.2 == o.id) { + e.push((key.clone(), group, o.id)); + } + for d in &o.records { + let e = ix.by_digest.entry(*d).or_default(); + if !e.iter().any(|x| x.0 == key && x.1 == group && x.2 == o.id) { + e.push((key.clone(), group, o.id)); + } + } + } + let new_objects = index.len().saturating_sub(old_n) as u64; + if let Some(r) = room { + ix.series.entry(r).or_default().change(now_us(), payload_bytes as i128 - old_bytes as i128, new_objects, payload_bytes.saturating_sub(old_bytes), 0); + } + drop(ix); + self.stats.groups_written.fetch_add(1, Ordering::Relaxed); + self.stats.objects_written.fetch_add(new_objects, Ordering::Relaxed); + self.evict_by_bytes(window); + } + + /// Erase what a redaction targets from every group that holds it: the group's file + /// is written again with the object among its erased. + fn erase(&self, room: [u8; 32], digest: [u8; 32]) { + let places: Vec<(TrackKey, u64, u64)> = self.index.lock().unwrap().by_digest.get(&digest).cloned().unwrap_or_default(); + for (key, g, oid) in places { + let (path, grp) = { + let mut ix = self.index.lock().unwrap(); + let Some(t) = ix.tracks.get_mut(&key) else { continue }; + if t.room != Some(room) { + continue; + } + let path = group_file(&t.dir, g); + let Some(grp) = t.groups.get_mut(&g) else { continue }; + let Some(o) = grp.objects.iter().find(|o| o.id == oid).cloned() else { continue }; + if o.digest != digest { + // An events object holding a redacted record: withheld at each read, + // since its other records stay; its bytes stay with them. + continue; + } + grp.objects.retain(|x| x.id != oid); + grp.bytes = grp.bytes.saturating_sub(o.payload_len as u64); + grp.erased.push((oid, digest, ERASED_REDACTED)); + let window = t.window; + ix.window_bytes[window] = ix.window_bytes[window].saturating_sub(o.payload_len as u64); + ix.series.entry(room).or_default().change(now_us(), -(o.payload_len as i128), 0, 0, 0); + (path, ix.tracks[&key].groups[&g].clone()) + }; + if let Ok(b) = std::fs::read(&path) + && let Some((_, _, _, objs, _)) = decode_group(&b, &sha256(&[render(&key.0, Some(&key.1)).as_bytes()])) + { + let fresh = rewrite(&path, &key, g, &grp, &objs); + if let Some(index) = fresh { + let mut ix = self.index.lock().unwrap(); + if let Some(gr) = ix.tracks.get_mut(&key).and_then(|t| t.groups.get_mut(&g)) { + gr.objects = index; + } + } + self.stats.objects_redacted.fetch_add(1, Ordering::Relaxed); + tracing::info!(target: "joule_moq_relay::dvr", file = %path.display(), object = oid, "a redacted object erased from the store"); + } + } + } + + fn evict_by_bytes(&self, window: usize) { + loop { + let victim = { + let ix = self.index.lock().unwrap(); + if ix.window_bytes[window] <= self.windows[window].max_bytes { + return; + } + oldest(&ix, |t| t.window == window) + }; + match victim { + Some((k, g)) => self.evict(&k, g), + None => return, + } + } + } + + fn evict_by_age(&self, now: u64) { + for (w, win) in self.windows.iter().enumerate() { + let floor = now.saturating_sub(win.max_age_us); + loop { + let victim = { + let ix = self.index.lock().unwrap(); + oldest(&ix, |t| t.window == w).filter(|(k, g)| ix.tracks[k].groups[g].arrived_us < floor) + }; + match victim { + Some((k, g)) => self.evict(&k, g), + None => break, + } + } + } + } + + /// Evict group `g` of `key`, and group `g` of its receipts or media track with it. + fn evict(&self, key: &TrackKey, g: u64) { + let partner = match self.media_of(&key.1) { + Some(m) => (key.0.clone(), m), + None => (key.0.clone(), self.receipts_of(&key.1)), + }; + for k in [key.clone(), partner] { + let removed = { + let mut ix = self.index.lock().unwrap(); + let Some(t) = ix.tracks.get_mut(&k) else { continue }; + let through = t.evicted_through.map_or(g, |e| e.max(g)); + t.evicted_through = Some(through); + t.absent.retain(|_, hi| *hi > through); + let Some(grp) = t.groups.remove(&g) else { continue }; + let (dir, window, room) = (t.dir.clone(), t.window, t.room); + ix.window_bytes[window] = ix.window_bytes[window].saturating_sub(grp.bytes); + for o in &grp.objects { + for d in std::iter::once(&o.digest).chain(o.records.iter()) { + if let Some(v) = ix.by_digest.get_mut(d) { + v.retain(|x| !(x.0 == k && x.1 == g)); + if v.is_empty() { + ix.by_digest.remove(d); + } + } + } + } + if let Some(r) = room { + ix.series.entry(r).or_default().change(now_us(), -(grp.bytes as i128), 0, 0, 0); + } + Some(group_file(&dir, g)) + }; + if let Some(p) = removed { + let _ = std::fs::remove_file(&p); + self.stats.groups_evicted.fetch_add(1, Ordering::Relaxed); + } + } + } +} + +/// The oldest group held among the tracks `f` selects: by arrival, then track and group. +fn oldest(ix: &Index, f: impl Fn(&DTrack) -> bool) -> Option<(TrackKey, u64)> { + ix.tracks + .iter() + .filter(|(_, t)| f(t)) + .filter_map(|(k, t)| t.groups.iter().min_by_key(|(g, grp)| (grp.arrived_us, **g)).map(|(g, grp)| (grp.arrived_us, k.clone(), *g))) + .min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.2.cmp(&b.2))) + .map(|(_, k, g)| (k, g)) +} + +fn read_at(path: &Path, at: u64, len: usize) -> std::io::Result> { + let mut f = std::fs::File::open(path)?; + f.seek(SeekFrom::Start(at))?; + let mut b = vec![0u8; len]; + f.read_exact(&mut b)?; + Ok(b) +} + +/// Write group `g`'s file again from what the index keeps of it (`grp`), taking each +/// object's bytes from the old file's `objs`. Returns the new index of its objects. +fn rewrite(path: &Path, key: &TrackKey, g: u64, grp: &DGroup, objs: &[(DObject, Bytes, Bytes, bool)]) -> Option> { + let rows: Vec<(DObject, Bytes, Bytes)> = grp + .objects + .iter() + .filter_map(|o| objs.iter().find(|(x, _, _, _)| x.id == o.id).map(|(_, props, payload, _)| (o.clone(), props.clone(), payload.clone()))) + .collect(); + let (bytes, index) = encode_group(&sha256(&[render(&key.0, Some(&key.1)).as_bytes()]), g, grp.arrived_us, &rows, &grp.erased); + let index: Vec = index.into_iter().zip(&grp.objects).map(|(mut n, old)| { + n.records = old.records.clone(); + n + }).collect(); + match write_durably(path, &bytes) { + Ok(()) => Some(index), + Err(e) => { + tracing::error!(target: "joule_moq_relay::dvr", error = %e, file = %path.display(), "a group could not be written again"); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_group_file_reads_back_and_a_flipped_byte_is_found() { + let td = [7u8; 32]; + let o = |id: u64| DObject { id, subgroup: Some(0), priority: 3, status: ObjectStatus::Normal, at: 0, props_len: 0, payload_len: 0, digest: ObjectReceipt::hash_payload(format!("p{id}").as_bytes()), records: vec![] }; + let rows: Vec<(DObject, Bytes, Bytes)> = (0..3).map(|i| (o(i), Bytes::new(), Bytes::from(format!("p{i}")))).collect(); + let (mut b, index) = encode_group(&td, 5, 99, &rows, &[(3, [1; 32], ERASED_REDACTED)]); + let (g, arrived, count, objs, erased) = decode_group(&b, &td).unwrap(); + assert_eq!((g, arrived, count, erased.len()), (5, 99, 3, 1)); + assert!(objs.iter().all(|x| x.3)); + assert_eq!(index[1].at as usize + 0, objs[1].0.at as usize); + // A flipped payload byte fails that object's check alone. + let at = index[1].at as usize; + b[at] ^= 0x40; + let (_, _, _, objs, _) = decode_group(&b, &td).unwrap(); + assert_eq!(objs.iter().map(|x| x.3).collect::>(), vec![true, false, true]); + // A flipped header byte drops the group; so does another track's file. + b[40] ^= 1; + assert!(decode_group(&b, &td).is_none()); + let (b2, _) = encode_group(&td, 5, 99, &rows, &[]); + assert!(decode_group(&b2, &[8; 32]).is_none()); + } + + #[test] + fn a_track_record_reads_back() { + let key = (TrackNamespace::from_strs(["r", "p", "video"]), TrackName::from_str("0")); + let mut p = Properties::new(); + p.push(joule_moq::draft22::kvp::KeyValuePair::varint(prop::DEFAULT_PUBLISHER_PRIORITY, 7)); + let b = encode_track(&key, &p); + assert_eq!(decode_track(&b), Some((key, p))); + let mut bad = b.clone(); + bad[7] ^= 1; + assert!(decode_track(&bad).is_none()); + } + + #[test] + fn the_series_integrates_what_is_held() { + let mut s = Series::default(); + s.change(1_000_000, 100, 1, 100, 0); + s.change(3_000_000, 50, 1, 50, 0); + // 100 bytes for 2 s, then 150. + assert_eq!(s.at(3_000_000).0, 100 * 2_000_000); + assert_eq!(s.at(4_000_000).0, 100 * 2_000_000 + 150 * 1_000_000); + assert_eq!(s.at(0), (0, 0, 0, 0)); + assert_eq!((s.at(5_000_000).1, s.at(5_000_000).2), (2, 150)); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/events.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/events.rs new file mode 100644 index 000000000..d7de6ede2 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/events.rs @@ -0,0 +1,185 @@ +//! The events of a WAI room as the relay reads them: what it needs to apply moderation +//! decisions to what it keeps (WAI `live-moderation.md` §6.3). +//! +//! A room's events ride each principal's events track, an MSF event timeline (WAI +//! `live-events.md` §8.1): every object is a JSON array of records +//! `{"t":…,"data":{"wev1":""}}`, each record a `WEV1` event (§3.1) signed by +//! its principal (§3.2). The relay reads `decision` events (§4.11) from the tracks it +//! already forwards, and the digests of the events it keeps, so that a decision that +//! restricts one withholds it. +//! +//! Nothing here is a room verifier: a record is read and its signature checked, and the +//! relay decides whose decisions it honours (`crate::dvr`). + +use base64::Engine; +use serde_json::value::RawValue; + +/// The domain of an event's signature (live-events §3.2). +pub const EVENT_DOMAIN: &[u8] = b"wai:live-event\x01"; +/// The `decision` kind (live-events §4.11). +pub const KIND_DECISION: u8 = 0x0B; +/// The verdicts of a decision. +pub const ALLOW: u8 = 1; +pub const DENY: u8 = 2; +pub const REDACT: u8 = 3; + +/// One event record, read (live-events §3.1). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Event { + pub room: [u8; 32], + pub principal: [u8; 32], + pub seq: u64, + pub t: u64, + pub kind: u8, + pub payload: Vec, + /// SHA-256 over the record from `magic` through `payload` (§3.2). + pub digest: [u8; 32], + sig: [u8; 64], + body_len: usize, +} + +fn sha256(b: &[u8]) -> [u8; 32] { + ring::digest::digest(&ring::digest::SHA256, b).as_ref().try_into().expect("32 bytes") +} + +impl Event { + /// Read exactly one record (§3.3); `None` for anything that does not read. + pub fn read(b: &[u8]) -> Option { + if b.len() < 118 || &b[..4] != b"WEV1" || b[4] != 1 { + return None; + } + let a = match b[117] { + 0 => 118, + 1 => 166, + _ => return None, + }; + if b.len() < a + 3 { + return None; + } + let kind = b[a]; + let len = u16::from_le_bytes([b[a + 1], b[a + 2]]) as usize; + let body_len = a + 3 + len; + if b.len() != body_len + 64 { + return None; + } + let at32 = |i: usize| -> [u8; 32] { b[i..i + 32].try_into().expect("32") }; + Some(Event { + room: at32(5), + principal: at32(37), + seq: u64::from_le_bytes(b[69..77].try_into().expect("8")), + t: u64::from_le_bytes(b[109..117].try_into().expect("8")), + kind, + payload: b[a + 3..body_len].to_vec(), + digest: sha256(&b[..body_len]), + sig: b[body_len..].try_into().expect("64"), + body_len, + }) + } + + /// Whether its signature verifies under its principal, under WAI's strict rule (SPEC + /// §7 *Signatures*), given the record's bytes. + pub fn verifies(&self, record: &[u8]) -> bool { + let mut m = EVENT_DOMAIN.to_vec(); + m.extend_from_slice(&record[..self.body_len]); + joule_moq::receipt::verify_strict(&self.principal, &m, &self.sig).is_ok() + } + + /// A decision's `verdict` and `target` (live-events §4.11), when it is one that reads. + pub fn decision(&self) -> Option<(u8, [u8; 32])> { + if self.kind != KIND_DECISION || self.payload.len() != 1 + 32 + 2 || !(ALLOW..=REDACT).contains(&self.payload[0]) { + return None; + } + Some((self.payload[0], self.payload[1..33].try_into().expect("32"))) + } +} + +/// The records of one object of an events track, each with the raw JSON of its entry, in +/// order; `None` when the object is not a JSON array of entries whose `data.wev1` reads as +/// base64url. +pub fn records(payload: &[u8]) -> Option, Vec)>> { + let entries: Vec> = serde_json::from_slice(payload).ok()?; + let mut out = Vec::with_capacity(entries.len()); + for e in entries { + let v: serde_json::Value = serde_json::from_str(e.get()).ok()?; + let b64 = v.get("data")?.get("wev1")?.as_str()?; + let rec = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(b64).ok()?; + out.push((e, rec)); + } + Some(out) +} + +/// The digests of the records of an events-track object that read; empty for one that +/// does not. +pub fn digests(payload: &[u8]) -> Vec<[u8; 32]> { + records(payload).map(|rs| rs.iter().filter_map(|(_, r)| Event::read(r).map(|e| e.digest)).collect()).unwrap_or_default() +} + +/// What withholding leaves of an events-track object: the object unchanged when none of +/// its records is withheld; the array of the others, each entry byte for byte, when some +/// are; `None` when every one is (the object is withheld whole). A record is withheld and +/// never replaced (live-moderation §8.1): its author's chain shows the gap. +pub fn withhold(payload: &[u8], withheld: impl Fn(&[u8; 32]) -> bool) -> Option> { + let Some(rs) = records(payload) else { return Some(payload.into()) }; + let keep: Vec<&RawValue> = rs.iter().filter(|(_, r)| Event::read(r).is_none_or(|e| !withheld(&e.digest))).map(|(raw, _)| raw.as_ref()).collect(); + if keep.len() == rs.len() { + return Some(payload.into()); + } + if keep.is_empty() { + return None; + } + let joined: Vec<&str> = keep.iter().map(|r| r.get()).collect(); + Some(format!("[{}]", joined.join(",")).into_bytes().into()) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + + /// A record of `kind` by `key`, as live-events §3.1 lays it out, without an anchor. + pub fn record(key: &SigningKey, room: [u8; 32], seq: u64, t: u64, kind: u8, payload: &[u8]) -> Vec { + let mut b = b"WEV1\x01".to_vec(); + b.extend_from_slice(&room); + b.extend_from_slice(&key.verifying_key().to_bytes()); + b.extend_from_slice(&seq.to_le_bytes()); + b.extend_from_slice(&[0; 32]); + b.extend_from_slice(&t.to_le_bytes()); + b.push(0); + b.push(kind); + b.extend_from_slice(&(payload.len() as u16).to_le_bytes()); + b.extend_from_slice(payload); + let mut m = EVENT_DOMAIN.to_vec(); + m.extend_from_slice(&b); + b.extend_from_slice(&key.sign(&m).to_bytes()); + b + } + + /// An MSF entry of a record (live-events §8.1). + pub fn entry(rec: &[u8], t_ms: u64) -> String { + format!("{{\"t\":{t_ms},\"data\":{{\"wev1\":\"{}\"}}}}", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(rec)) + } + + #[test] + fn records_read_verify_and_withhold() { + let k = SigningKey::from_bytes(&[9; 32]); + let chat = record(&k, [1; 32], 0, 5, 0x03, b"\x02\x00hi"); + let mut d = vec![REDACT]; + let target = Event::read(&chat).unwrap().digest; + d.extend_from_slice(&target); + d.extend_from_slice(&7u16.to_le_bytes()); + let decision = record(&k, [1; 32], 1, 9, KIND_DECISION, &d); + let e = Event::read(&decision).unwrap(); + assert!(e.verifies(&decision)); + assert_eq!(e.decision(), Some((REDACT, target))); + let mut forged = decision.clone(); + forged[120] ^= 1; + assert!(!Event::read(&forged).unwrap().verifies(&forged), "a changed record does not verify"); + // An object of two records: the withheld one is left out, the other kept byte for byte. + let obj = format!("[{},{}]", entry(&chat, 1), entry(&decision, 2)).into_bytes(); + assert_eq!(digests(&obj), vec![target, e.digest]); + let kept = withhold(&obj, |x| *x == target).unwrap(); + assert_eq!(&*kept, format!("[{}]", entry(&decision, 2)).as_bytes()); + assert!(withhold(&obj, |_| true).is_none(), "every record withheld: the object is"); + assert_eq!(&*withhold(&obj, |_| false).unwrap(), &obj[..]); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/lib.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/lib.rs index 271614b2e..0a365dea8 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/lib.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/lib.rs @@ -24,6 +24,13 @@ //! (`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. +//! With a DVR store it signs version 2, with the store's figures (§3.9). +//! - **DVR** ([`dvr`]): a window of the tracks under each configured prefix, with their +//! receipts, on disk, served to FETCH below the live edge (WAI `live-recording.md` §8): +//! evicted oldest first by whole group, kept across a restart with every object checked +//! again against its receipt, decisions of the room's moderators (read from its events +//! tracks, [`events`]) withholding what they deny or redact, and a FETCH that seeks back +//! served only to a grant with the seek power (WAI `live-rooms.md` §10.6). //! //! [`server::RelayServer`] binds the listeners; [`client::Client`] is a small client for //! the tests and for tools. @@ -34,6 +41,8 @@ pub mod cert; pub mod client; pub mod config; pub mod door; +pub mod dvr; +pub mod events; pub mod metering; pub mod relay; pub mod sched; diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/metering.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/metering.rs index 4bb0f5a7f..415273ce6 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/metering.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/metering.rs @@ -9,6 +9,12 @@ //! 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). //! +//! A relay that keeps a DVR store signs version 2 of the record, which adds the store's +//! figures for the room over the span (§3.9): the objects and bytes it served from the +//! store, those it wrote to it, the bytes it held integrated over the span, and the objects +//! it dropped because they did not verify. Version 1 is signed otherwise, byte for byte as +//! before. +//! //! 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. @@ -20,6 +26,8 @@ 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 version with the DVR figures (§3.9). +pub const VERSION_DVR: u8 = 2; /// 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). @@ -165,6 +173,23 @@ pub fn root(leaves: &[[u8; 32]]) -> [u8; 32] { } } +/// The DVR figures of a version 2 record (§3.9), in their order on the wire. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct DvrFigures { + pub objects_out: u64, + pub bytes_out: u64, + pub objects_stored: u64, + pub bytes_stored: u64, + pub byte_s: u64, + pub dropped: u64, +} + +impl DvrFigures { + fn fields(&self) -> [u64; 6] { + [self.objects_out, self.bytes_out, self.objects_stored, self.bytes_stored, self.byte_s, self.dropped] + } +} + /// A per-hop record, as the relay writes it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct HopRecord { @@ -181,6 +206,8 @@ pub struct HopRecord { pub subscriber_us: [u64; CLASSES], pub events: u64, pub ledger: Vec, + /// The DVR figures: present in a version 2 record alone. + pub dvr: Option, pub sig: [u8; 64], } @@ -189,7 +216,7 @@ impl HopRecord { pub fn body(&self) -> Vec { let mut o = Vec::with_capacity(281 + 90 * self.entries.len()); o.extend_from_slice(MAGIC); - o.push(VERSION); + o.push(if self.dvr.is_some() { VERSION_DVR } else { VERSION }); o.extend_from_slice(&self.room); o.extend_from_slice(&self.hop); o.push(self.role); @@ -213,6 +240,11 @@ impl HopRecord { 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()); + if let Some(d) = &self.dvr { + for x in d.fields() { + o.extend_from_slice(&x.to_le_bytes()); + } + } o } @@ -261,8 +293,12 @@ impl HopRecord { 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()); + if take(4)? != MAGIC { + return Err("not a WMH1 record".into()); + } + let version = take(1)?[0]; + if version != VERSION && version != VERSION_DVR { + return Err("not a WMH1 record of a version this reader reads".into()); } let room: [u8; 32] = take(32)?.try_into().expect("32"); let hop: [u8; 32] = take(32)?.try_into().expect("32"); @@ -292,6 +328,15 @@ impl HopRecord { 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 dvr = if version == VERSION_DVR { + let mut x = [0u64; 6]; + for v in &mut x { + *v = u64_of(take(8)?); + } + Some(DvrFigures { objects_out: x[0], bytes_out: x[1], objects_stored: x[2], bytes_stored: x[3], byte_s: x[4], dropped: x[5] }) + } else { + None + }; let sig: [u8; 64] = take(64)?.try_into().expect("64"); if p != b.len() { return Err("bytes after the signature".into()); @@ -318,7 +363,7 @@ impl HopRecord { } }) .collect(); - let r = HopRecord { room, hop, role, start, end, joules, class_tag, entries, sessions, subscriber_us, events, ledger, sig }; + let r = HopRecord { room, hop, role, start, end, joules, class_tag, entries, sessions, subscriber_us, events, ledger, dvr, sig }; if r.ledger_root() != ledger_root { return Err("the ledger is not the one the record signs".into()); } @@ -349,14 +394,18 @@ fn ledger_base(t: &Tally) -> LedgerEntry { /// 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 { +/// summaries' counts, so the record recomputes from them. With `dvr`, the DVR store's figures +/// for the room between two instants (microseconds since the epoch), the record is version 2 +/// and carries them over its span, with the objects and bytes the summaries say were served +/// from the store. +pub fn hop_record(summaries: &[SessionSummary], room: [u8; 32], t0_unix_us: u64, key: &SigningKey, dvr: Option<&dyn Fn(u64, u64) -> crate::dvr::RoomFigures>) -> 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); + let (mut dvr_objects_out, mut dvr_bytes_out) = (0u64, 0u64); for s in summaries { let t = &s.tally; let mut touched = false; @@ -371,6 +420,8 @@ pub fn hop_record(summaries: &[SessionSummary], room: [u8; 32], t0_unix_us: u64, e.dropped_closed += x.undelivered_at_close; e.receipted += x.dropped_receipts_track_subscribed; bytes_out += x.object_bytes_forwarded + x.fetch_bytes; + dvr_objects_out += x.dvr_objects; + dvr_bytes_out += x.dvr_bytes; } for (name, x) in &t.publisher.tracks { let Some((_, kind)) = classify(name).filter(|(r, _)| *r == room) else { continue }; @@ -408,6 +459,10 @@ pub fn hop_record(summaries: &[SessionSummary], room: [u8; 32], t0_unix_us: u64, if start >= end { (start, end) = (0, 1); } + let dvr = dvr.map(|f| { + let x = f(t0_unix_us + start, t0_unix_us + end); + DvrFigures { objects_out: dvr_objects_out, bytes_out: dvr_bytes_out, objects_stored: x.objects_stored, bytes_stored: x.bytes_stored, byte_s: x.byte_s, dropped: x.dropped } + }); HopRecord { room, hop: key.verifying_key().to_bytes(), @@ -421,6 +476,7 @@ pub fn hop_record(summaries: &[SessionSummary], room: [u8; 32], t0_unix_us: u64, subscriber_us, events, ledger: ledger.into_values().collect(), + dvr, sig: [0; 64], } .sign(key) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs index b4b73817e..0804ab652 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs @@ -8,6 +8,7 @@ use crate::auth::{Authorizer, Resolved, TokenCache}; use crate::cache::{self, Budget, CachedObject, Insert, TrackCache}; use crate::config::RelayConfig; use crate::door::Door; +use crate::dvr::Dvr; use crate::sched::{Item, LaneKey, LaneKind, ObjMeta, Op, Prio, SchedHandle}; use crate::summary::{class_label, RoomGrantRef, SignedSessionSummary, Tally}; use crate::transport::{BoxSend, SharedConnection, TransportKind}; @@ -267,6 +268,10 @@ pub struct Stats { pub fetch_served_upstream: AtomicU64, pub refused_at_door: AtomicU64, pub groups_dropped_at_door: AtomicU64, + /// FETCHes and fills answered with the DVR store's groups. + pub fetch_served_from_dvr: AtomicU64, + /// Requests that sought back without the power to (WAI live-rooms §10.6). + pub seek_refused: AtomicU64, } /// A snapshot of [`Stats`]. @@ -279,6 +284,8 @@ pub struct RelayStats { pub fetch_served_upstream: u64, pub refused_at_door: u64, pub groups_dropped_at_door: u64, + pub fetch_served_from_dvr: u64, + pub seek_refused: u64, /// Items pushed to sessions' schedulers and not yet dispatched. pub queued: u64, } @@ -287,6 +294,8 @@ pub struct Inner { pub cfg: RelayConfig, pub auth: Authorizer, pub door: Door, + /// The DVR store, where the configuration keeps one. + pub dvr: Option>, pub key: SigningKey, pub state: Mutex, pub budget: Mutex, @@ -355,11 +364,18 @@ impl Relay { None => SigningKey::generate(&mut rand_core_06::OsRng), }; let budget = Budget::new(cfg.cache.max_bytes); + let dvr = match &cfg.dvr { + Some(d) => Some(Dvr::open(d, &cfg.receipts.receipts_track_suffix, auth.authority_keys(), |ns| { + door.verifier_for(ns).unwrap_or_else(|_| joule_moq::receipt::ReceiptVerifier::with_anchors(Vec::new())) + })?), + None => None, + }; Ok(Relay { inner: Arc::new(Inner { cfg, auth, door, + dvr, key, state: Mutex::new(State::default()), budget: Mutex::new(budget), @@ -377,6 +393,22 @@ impl Relay { &self.inner.cfg } + /// The DVR store, where the configuration keeps one. + pub fn dvr(&self) -> Option<&Arc> { + self.inner.dvr.as_ref() + } + + /// Store group `group` of `t` in the DVR store once the cache holds it whole. + fn dvr_seal(&self, t: &Track, group: u64) { + let Some(dvr) = &self.inner.dvr else { return }; + let Some(gs) = t.cache.groups.get(&group) else { return }; + if !gs.whole() || !dvr.keeps(&t.key.0) { + return; + } + let objects: Vec<((u64, u64), CachedObject)> = t.cache.objects.range((group, 0)..=(group, u64::MAX)).map(|(k, o)| (*k, o.clone())).collect(); + dvr.seal(&t.key, &t.props, group, gs.first_us, objects); + } + /// The public key the relay signs session summaries with. pub fn summary_key(&self) -> ed25519_dalek::VerifyingKey { self.inner.key.verifying_key() @@ -407,9 +439,12 @@ impl Relay { /// 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. + /// A relay that keeps a DVR store signs version 2, with the store's figures for the + /// room over the record's span (§3.9). 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) + let figures = self.inner.dvr.as_ref().map(|d| move |from: u64, to: u64| d.figures(room, from, to)); + crate::metering::hop_record(&summaries, room, t0_unix_us, &self.inner.key, figures.as_ref().map(|f| f as &dyn Fn(u64, u64) -> crate::dvr::RoomFigures)) } pub fn stats(&self) -> RelayStats { @@ -423,6 +458,8 @@ impl Relay { fetch_served_upstream: s.fetch_served_upstream.load(Ordering::Relaxed), refused_at_door: s.refused_at_door.load(Ordering::Relaxed), groups_dropped_at_door: s.groups_dropped_at_door.load(Ordering::Relaxed), + fetch_served_from_dvr: s.fetch_served_from_dvr.load(Ordering::Relaxed), + seek_refused: s.seek_refused.load(Ordering::Relaxed), queued: st.sessions.values().map(|c| c.sched.queued()).sum(), } } @@ -432,9 +469,12 @@ impl Relay { self.inner.state.lock().unwrap().tracks.get(key).is_some_and(|t| t.cache.covers(start)) } - /// The Largest Object the relay knows of a track (§3.1.4). + /// The Largest Object the relay knows of a track (§3.1.4): what it holds live, or in + /// its DVR store. pub fn largest(&self, key: &TrackKey) -> Option { - self.inner.state.lock().unwrap().tracks.get(key).and_then(|t| t.largest) + let live = self.inner.state.lock().unwrap().tracks.get(key).and_then(|t| t.largest); + let kept = self.inner.dvr.as_ref().and_then(|d| d.largest(key)); + live.max(kept) } /// Each session: its id, its peer, its transport's counters, and the items its @@ -574,6 +614,7 @@ impl Relay { object_id: oid, receipts_subscribed: rsessions.contains(&sub.session), fetched: false, + dvr: false, signed: o.signed, }; sub.sched.push( @@ -581,6 +622,9 @@ impl Relay { Item { op: Op::Object { header, object: o.object.clone() }, prio, deadline, meta: Some(meta) }, ); } + if matches!(o.object.status, joule_moq::draft22::data::ObjectStatus::EndOfGroup | joule_moq::draft22::data::ObjectStatus::EndOfTrack) { + self.dvr_seal(track, group); + } if ins == Insert::New { self.charge_cache(&mut st, track_id, group, size); } @@ -592,6 +636,9 @@ impl Relay { pub fn cache_stream_ended(&self, key: &TrackKey, group: u64, clean: bool) { if let Some(t) = self.inner.state.lock().unwrap().tracks.get_mut(key) { t.cache.stream_ended(group, clean); + if clean { + self.dvr_seal(t, group); + } } } @@ -634,6 +681,7 @@ impl Relay { let ins = track.cache.insert(d.group_id, d.object_id, cached); if d.end_of_group && ins != Insert::Evicted { track.cache.group_ended(d.group_id); + self.dvr_seal(track, d.group_id); } if ins != Insert::New { // A datagram already forwarded (another publisher's, or a repeat): once only. @@ -652,7 +700,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, object_id: d.object_id, 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, dvr: 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); @@ -760,6 +808,7 @@ impl Relay { } let deadline = Instant::now() + timeout; if self.wait_sessions_gone(deadline).await { + self.close_dvr().await; return 0; } let left: Vec> = self.inner.state.lock().unwrap().sessions.values().cloned().collect(); @@ -767,9 +816,17 @@ impl Relay { ctx.close(joule_moq::draft22::SessionError::GOAWAY_TIMEOUT.0, "GOAWAY timeout"); } let _ = self.wait_sessions_gone(Instant::now() + Duration::from_secs(5)).await; + self.close_dvr().await; left.len() } + /// Write out what the DVR store holds pending and stop its writer. + pub async fn close_dvr(&self) { + if let Some(d) = self.inner.dvr.clone() { + let _ = tokio::task::spawn_blocking(move || d.close()).await; + } + } + /// Namespaces published to the relay that `prefix` matches, other than `except`'s. pub fn published_under(&self, st: &State, prefix: &TrackNamespace, except: u64) -> Vec { let mut out: Vec = st.namespaces.iter().filter(|n| n.session != except && prefix_matches(prefix, &n.namespace)).map(|n| n.namespace.clone()).collect(); diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/sched.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/sched.rs index 0d4c71460..887ee72e0 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/sched.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/sched.rs @@ -98,6 +98,8 @@ pub struct ObjMeta { pub receipts_subscribed: bool, /// A fetch or fill object. pub fetched: bool, + /// A fetch or fill object read from the DVR store. + pub dvr: bool, /// The publisher's signed figure for the object and its class label, where the door /// verified its receipt. pub signed: Option<(u64, &'static str)>, @@ -618,6 +620,10 @@ impl Sched { let tr = t.track(&m.track); tr.fetch_objects += 1; tr.fetch_bytes += m.payload_len; + if m.dvr { + tr.dvr_objects += 1; + tr.dvr_bytes += m.payload_len; + } } else { t.subscriber.objects_forwarded += 1; t.subscriber.object_bytes_forwarded += m.payload_len; diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs index 4432a2352..08408c9f6 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs @@ -4,6 +4,7 @@ use crate::auth::{Resolved, Right, TokenCache}; use crate::cache::CachedObject; use crate::door::Verdict; +use crate::dvr::{room_of, is_events_track, Dvr, Slot, Snapshot}; use crate::relay::{ now_unix_ms, signed_of, DownSub, NsEvent, NsSub, ObjectIn, PublishedNs, Relay, SessionCtx, SubParams, TrackKey, Upstream, UpstreamKind, DEFAULT_PRIORITY, @@ -13,8 +14,9 @@ use crate::summary::{RelayEnergy, SessionSummary, SignedSessionSummary, Tally, F use crate::transport::{BoxRecv, BoxSend, SharedConnection, TransportError, TransportKind}; use crate::wire::{open_request, responder, MessageReader, ReqRx, ReqTx, StreamError, UniReader}; use joule_moq::carriage::{read_object_properties, SETUP_OPTION_JWP_RECEIPTS}; +use bytes::Bytes; use joule_moq::draft22::data::{Datagram, FetchEntry, FetchObject, ObjectStatus, RangeEnd}; -use joule_moq::draft22::kvp::{opt, prop, KeyValuePair, SetupOptions}; +use joule_moq::draft22::kvp::{opt, prop, KeyValuePair, Properties, SetupOptions}; use joule_moq::draft22::names::{is_reserved, prefix_matches, prefixes_overlap, SESSION_NAMESPACE}; use joule_moq::draft22::params::param; use joule_moq::draft22::{ @@ -498,6 +500,13 @@ async fn upstream_subgroup(relay: Relay, ctx: Arc, h: joule_moq::dra let st = relay.inner.state.lock().unwrap(); st.tracks.get(&key).map_or((0, 0), |t| (t.pub_object_timeout, t.pub_subgroup_timeout)) }; + // A room's events track the DVR store keeps: its decisions are read as they pass, and + // the moderators among this publisher's grants are the ones it honours (dvr.rs). + let events_dvr = relay.dvr().filter(|d| is_events_track(&key) && d.keeps(&key.0)).cloned(); + let moderators: Vec<[u8; 32]> = match &events_dvr { + Some(_) => relay.inner.auth.proven_grants(&ctx.setup_tokens, &ctx.proven).into_iter().filter(|g| g.moderates() && Some(g.room) == room_of(&key.0)).map(|g| g.principal).collect(), + None => Vec::new(), + }; let mut started = HashSet::new(); let mut first: Option = None; let mut subgroup_id: Option = None; @@ -579,6 +588,9 @@ async fn upstream_subgroup(relay: Relay, ctx: Arc, h: joule_moq::dra if is_first { count.counted = taken; } + if let Some(d) = &events_dvr { + d.events_object(&key.0, &o.payload, &moderators); + } } Ok(Some(_)) => { ctx.close(SessionError::PROTOCOL_VIOLATION.0, "a subgroup stream carried something other than an object"); @@ -842,6 +854,15 @@ async fn subscribe(relay: Relay, ctx: Arc, r: TrackRequest, tx: ReqT if r.params.range_filters().next().is_some() { return reject(&tx, RequestErrorCode::INVALID_FILTER, "Range Filters need MAX_FILTER_RANGES, which this relay declares 0").await; } + // A fill that starts below the live edge seeks back (WAI live-rooms §10.6). + if let Some(Parameter::FillParameters(fill)) = r.params.get(param::FILL_PARAMETERS) + && let Some(largest) = relay.largest(&key) + && fetch_range(fill.location_filter().or(r.params.location_filter()), largest).is_some_and(|(s, _)| s.group < largest.group) + && !relay.inner.auth.may_seek(&key.0, &tokens, &ctx.proven) + { + seek_refused(&relay, &ctx, &key); + return reject(&tx, RequestErrorCode::UNAUTHORIZED, SEEK_NOT_GRANTED).await; + } let rendezvous = varint_param(&r.params, param::RENDEZVOUS_TIMEOUT).unwrap_or(0).min(relay.config().limits.max_rendezvous_ms); if let Err((code, why)) = ensure_upstream(&relay, &key, ctx.id, rendezvous).await { return reject(&tx, code, &why).await; @@ -905,8 +926,9 @@ async fn subscribe(relay: Relay, ctx: Arc, r: TrackRequest, tx: ReqT let prio = fill.subscriber_priority().unwrap_or(p.subscriber_priority); let budget = varint_param(&fill, param::FILL_TIMEOUT); let (relay, ctx, key, request_id, lane_sub) = (relay.clone(), ctx.clone(), key.clone(), r.request_id, sub.id); + let may_seek = relay.inner.auth.may_seek(&key.0, &tokens, &ctx.proven); tokio::spawn(async move { - serve_fill(&relay, &ctx, &key, filter, order, prio, request_id, lane_sub, budget).await; + serve_fill(&relay, &ctx, &key, filter, order, prio, request_id, lane_sub, budget, may_seek).await; }); } } @@ -1019,10 +1041,70 @@ async fn publish(relay: Relay, ctx: Arc, p: Publish, tx: ReqTx, mut tx.fin().await; } +/// The reason phrase of a request refused for seeking back without the power to (WAI +/// live-rooms §10.6). +pub const SEEK_NOT_GRANTED: &str = "seek_not_granted"; + +fn seek_refused(relay: &Relay, ctx: &SessionCtx, key: &TrackKey) { + relay.inner.stats.seek_refused.fetch_add(1, Ordering::Relaxed); + tracing::info!(session = ctx.id, remote = %ctx.remote, track = %joule_moq::draft22::names::render(&key.0, Some(&key.1)), "a request seeks back below the live edge without the seek power: refused"); +} + +/// What withholds an object from a FETCH or a fill (WAI live-moderation §6.3): the standing +/// decisions the DVR store honoured in the track's room, unless the session moderates there. +#[derive(Clone)] +struct Withhold { + dvr: Arc, + room: Option<[u8; 32]>, + events: bool, +} + +/// Whether the session proved a grant with the moderate power in `room` (live-rooms §3.3): +/// a moderator's session is given what is withheld from the others (live-moderation §6.3). +fn moderates(relay: &Relay, ctx: &SessionCtx, room: Option<[u8; 32]>) -> bool { + room.is_some() && relay.inner.auth.proven_grants(&ctx.setup_tokens, &ctx.proven).iter().any(|g| g.moderates() && Some(g.room) == room) +} + +impl Withhold { + fn of(relay: &Relay, ctx: &SessionCtx, key: &TrackKey) -> Option { + let dvr = relay.dvr()?.clone(); + let room = room_of(&key.0); + if !dvr.withholds_in(room) || moderates(relay, ctx, room) { + return None; + } + Some(Withhold { dvr, room, events: is_events_track(key) }) + } + + /// The payload to send, or `None` when it is withheld whole. Of an events-track object + /// only the withheld records are left out (`crate::events::withhold`). + fn apply(&self, payload: Bytes) -> Option { + if self.events { + return match crate::events::withhold(&payload, |d| self.dvr.withheld(self.room, d)) { + None => None, + Some(std::borrow::Cow::Borrowed(_)) => Some(payload.clone()), + Some(std::borrow::Cow::Owned(v)) => Some(Bytes::from(v)), + }; + } + (!self.dvr.withheld(self.room, &joule_moq::receipt::ObjectReceipt::hash_payload(&payload))).then_some(payload) + } +} + +/// An End of Unknown Range at `(g, o)` on a fetch lane: what the relay cannot or must not +/// serve there (MOQT §3.2.2, §11.4.1.2). +fn unknown_item(request_id: u64, order: GroupOrder, prio: u8, fill: bool, g: u64, o: u64) -> Item { + Item { + op: Op::FetchEntry { request_id, order, entry: FetchEntry::EndOfRange { kind: RangeEnd::Unknown, location: Location::new(g, o) } }, + prio: Prio { subscriber: prio, publisher: u8::MAX, group_rank: Prio::group_rank(order, g), fill_rank: if fill { 0 } else { 1 }, mode_rank: 1, within: o }, + deadline: None, + meta: None, + } +} + /// Push the objects from the cache in `[start, end]` onto a fetch or fill lane, then its -/// end. Returns how many objects. +/// end. Returns how many objects. An object a standing decision withholds is an End of +/// Unknown Range in its place. #[allow(clippy::too_many_arguments)] -fn queue_from_cache(relay: &Relay, sched: &SchedHandle, key: &TrackKey, start: Location, end: Location, order: GroupOrder, prio: u8, request_id: u64, lane_sub: u64, fill: bool) -> usize { +fn queue_from_cache(relay: &Relay, sched: &SchedHandle, key: &TrackKey, start: Location, end: Location, order: GroupOrder, prio: u8, request_id: u64, lane_sub: u64, fill: bool, withhold: Option<&Withhold>) -> usize { let (objects, render) = { let st = relay.inner.state.lock().unwrap(); match st.tracks.get(key) { @@ -1038,6 +1120,16 @@ fn queue_from_cache(relay: &Relay, sched: &SchedHandle, key: &TrackKey, start: L continue; } let CachedObject { subgroup, publisher_priority, properties, payload, .. } = c; + let payload = match withhold { + None => payload, + Some(w) => match w.apply(payload) { + Some(p) => p, + None => { + sched.push(lane, unknown_item(request_id, order, prio, fill, g, o)); + continue; + } + }, + }; let entry = FetchEntry::Object(FetchObject { group_id: g, object_id: o, @@ -1047,7 +1139,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, object_id: o, 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, dvr: false, signed: None }; sched.push(lane, Item { op: Op::FetchEntry { request_id, order, entry }, prio: p, deadline: None, meta: Some(meta) }); n += 1; } @@ -1140,7 +1232,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, object_id: o, 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, dvr: false, 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 }); } @@ -1167,6 +1259,132 @@ async fn upstream_fetch( Ok(ok) } +/// Where a group of a FETCH's range comes from when the relay answers it alone. +enum Src { + /// The cache holds it: its objects in the range. + Cache(Vec<((u64, u64), CachedObject)>), + /// The DVR store holds it. + Dvr(u64), + /// Groups `lo ..= hi` were evicted from the DVR store: no longer known. + Unknown(u64, u64), +} + +/// The groups of `[start, end]`, ascending, as the relay answers them alone: each held whole +/// in the cache (the group of the Largest Object, `last`, from its start), held in the DVR +/// store, known not to exist (left out), or evicted from the store. `None` when a group is +/// none of these: the relay then asks upstream (the cache's rule). +fn local_plan(t: Option<&crate::relay::Track>, snap: &Snapshot, start: Location, end: Location, last: u64) -> Option> { + let mut out = Vec::new(); + let mut g = start.group; + while g <= end.group { + let cs = t.and_then(|t| t.cache.groups.get(&g).copied().filter(|_| t.cache.evicted_through.is_none_or(|e| g > e))); + let next = if cs.is_some_and(|s| s.whole() || (g == last && s.start && !s.broken)) { + let t = t.expect("held"); + let objs = t.cache.objects.range((g, 0)..=(g, u64::MAX)).filter(|((gg, oo), _)| (start..=end).contains(&Location::new(*gg, *oo))).map(|(k, o)| (*k, o.clone())).collect(); + out.push(Src::Cache(objs)); + g.checked_add(1) + } else if snap.held.contains(&g) { + out.push(Src::Dvr(g)); + g.checked_add(1) + } else if let Some(hi) = t.and_then(|t| t.cache.absent_through(g)).or_else(|| snap.absent_through(g)) { + hi.checked_add(1) + } else if let Some(e) = snap.evicted_through.filter(|e| g <= *e) { + let hi = e.min(end.group); + out.push(Src::Unknown(g, hi)); + hi.checked_add(1) + } else { + return None; + }; + match next { + Some(n) => g = n, + None => break, + } + } + Some(out) +} + +/// Serve a planned range from the cache and the DVR store onto a fetch or fill lane, groups +/// in the requested order, then its end. The store's groups are read as the lane drains, +/// about a mebibyte at a time, so a long range is never held in memory whole. What the +/// store evicted, dropped or a decision withholds is an End of Unknown Range: those objects +/// existed, and an unmarked gap would say they did not (MOQT §3.2.2). +#[allow(clippy::too_many_arguments)] +async fn serve_local(relay: Relay, sched: SchedHandle, key: TrackKey, groups: Vec, start: Location, end: Location, order: GroupOrder, prio: u8, request_id: u64, lane_sub: u64, fill: bool, withhold: Option, moderator: bool) { + let render: Arc = joule_moq::draft22::names::render(&key.0, Some(&key.1)).into(); + let lane = LaneKey { sub: lane_sub, kind: LaneKind::Fetch { request_id } }; + let descending = order == GroupOrder::Descending; + let mut groups = groups; + if descending { + groups.reverse(); + } + let dvr = relay.dvr().cloned(); + let object = |g: u64, o: u64, subgroup: Option, publisher_priority: u8, properties: Properties, payload: Bytes, from_dvr: bool| { + let meta = ObjMeta { track: render.clone(), payload_len: payload.len() as u64, object_id: o, receipts_subscribed: false, fetched: true, dvr: from_dvr, signed: None }; + 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 entry = FetchEntry::Object(FetchObject { group_id: g, object_id: o, subgroup_id: subgroup, publisher_priority, properties: (!properties.is_empty()).then_some(properties), payload: payload.to_vec() }); + Item { op: Op::FetchEntry { request_id, order, entry }, prio: p, deadline: None, meta: Some(meta) } + }; + let mut pending = 0usize; + for src in groups { + match src { + Src::Unknown(lo, hi) => { + let at = if descending { Location::new(lo, u64::MAX) } else { Location::new(hi, u64::MAX).min(end) }; + sched.push(lane, unknown_item(request_id, order, prio, fill, at.group, at.object)); + } + Src::Cache(objs) => { + for ((g, o), c) in objs { + if c.status != ObjectStatus::Normal { + continue; + } + let payload = match &withhold { + None => c.payload, + Some(w) => match w.apply(c.payload) { + Some(p) => p, + None => { + sched.push(lane, unknown_item(request_id, order, prio, fill, g, o)); + continue; + } + }, + }; + sched.push(lane, object(g, o, c.subgroup, c.publisher_priority, c.properties, payload, false)); + } + } + Src::Dvr(g) => { + let from = if g == start.group { start.object } else { 0 }; + let to = if g == end.group { end.object } else { u64::MAX }; + let slots = match dvr.clone() { + Some(d) => { + let k = key.clone(); + tokio::task::spawn_blocking(move || d.read_group(&k, g, from, to, !moderator)).await.ok().flatten() + } + None => None, + }; + match slots { + None => sched.push(lane, unknown_item(request_id, order, prio, fill, g, to)), + Some(slots) => { + for slot in slots { + match slot { + Slot::Object(so) if so.status == ObjectStatus::Normal => { + pending += so.payload.len(); + sched.push(lane, object(g, so.id, so.subgroup, so.priority, so.properties, so.payload, true)); + } + Slot::Object(_) => {} + Slot::Unknown(o) => sched.push(lane, unknown_item(request_id, order, prio, fill, g, o)), + } + } + } + } + } + } + if pending >= 1 << 20 { + sched.drained(lane_sub).await; + pending = 0; + } + } + let p = Prio { subscriber: prio, publisher: u8::MAX, group_rank: u64::MAX, fill_rank: 1, mode_rank: 1, within: u64::MAX }; + sched.push(lane, Item { op: Op::FetchFin { request_id, order }, prio: p, deadline: None, meta: None }); +} + async fn fetch(relay: Relay, ctx: Arc, r: TrackRequest, tx: ReqTx, mut rx: ReqRx, tokens: Vec) { let key: TrackKey = (r.namespace.clone(), r.name.clone()); if is_session_namespace(&key.0) { @@ -1182,26 +1400,52 @@ async fn fetch(relay: Relay, ctx: Arc, r: TrackRequest, tx: ReqTx, m let prio = r.params.subscriber_priority().unwrap_or(DEFAULT_PRIORITY); let budget_ms = varint_param(&r.params, param::FILL_TIMEOUT).unwrap_or(relay.config().limits.fill_timeout_ms).min(relay.config().limits.fill_timeout_ms); let lane_sub = relay.next_id(); + let may_seek = relay.inner.auth.may_seek(&key.0, &tokens, &ctx.proven); + let moderator = moderates(&relay, &ctx, room_of(&key.0)); enum Plan { Cache { start: Location, end: Location, eot: bool, props: joule_moq::draft22::kvp::Properties }, + Local { start: Location, end: Location, eot: bool, props: joule_moq::draft22::kvp::Properties, groups: Vec }, Upstream { start: Location, end: Option }, Refuse(RequestErrorCode, &'static str), } let plan_now = || { let st = relay.inner.state.lock().unwrap(); - match st.tracks.get(&key) { - None => (Plan::Upstream { start: Location::new(0, 0), end: None }, false), - Some(t) => match t.largest { - None => (Plan::Refuse(RequestErrorCode::INVALID_RANGE, "no object has been published on the track"), false), - Some(largest) => match fetch_range(r.params.location_filter(), largest) { - None => (Plan::Refuse(RequestErrorCode::INVALID_RANGE, "the range starts after the Largest Object"), false), - Some((start, end)) if t.cache.covers(start) => (Plan::Cache { start, end, eot: t.cache.end_of_track == Some(end), props: t.props.clone() }, false), - // Not covered while the track's publisher is still sending and nothing - // from `start` was evicted: the groups it lacks may be ones still - // arriving (each group its own stream, finishing out of order). - Some((start, end)) => (Plan::Upstream { start, end: Some(end) }, !t.upstreams.is_empty() && t.cache.evicted_through.is_none_or(|e| start.group > e)), - }, - }, + let t = st.tracks.get(&key); + let dvr = relay.dvr().filter(|d| d.keeps(&key.0)); + let kept = dvr.and_then(|d| d.largest(&key)); + let live = t.and_then(|t| t.largest); + if t.is_none() && kept.is_none() { + return (Plan::Upstream { start: Location::new(0, 0), end: None }, false); + } + let Some(largest) = live.max(kept) else { return (Plan::Refuse(RequestErrorCode::INVALID_RANGE, "no object has been published on the track"), false) }; + let Some((start, end)) = fetch_range(r.params.location_filter(), largest) else { + return (Plan::Refuse(RequestErrorCode::INVALID_RANGE, "the range starts after the Largest Object"), false); + }; + // Below the live edge, the group of the Largest Object, a request seeks back + // (WAI live-rooms §10.6). + if start.group < largest.group && !may_seek { + return (Plan::Refuse(RequestErrorCode::UNAUTHORIZED, SEEK_NOT_GRANTED), false); + } + if let Some(t) = t + && live == Some(largest) + && t.cache.covers(start) + { + return (Plan::Cache { start, end, eot: t.cache.end_of_track == Some(end), props: t.props.clone() }, false); + } + if let Some(d) = dvr + && let Some(snap) = d.snapshot(&key, start.group, end.group) + && let Some(groups) = local_plan(t, &snap, start, end, largest.group) + { + let eot = t.is_some_and(|t| t.cache.end_of_track == Some(end)) || snap.end_of_track == Some(end); + let props = t.map(|t| t.props.clone()).filter(|p| !p.is_empty()).unwrap_or_else(|| snap.props.clone()); + return (Plan::Local { start, end, eot, props, groups }, false); + } + match t { + None => (Plan::Upstream { start, end: Some(end) }, false), + // Not covered while the track's publisher is still sending and nothing from + // `start` was evicted: the groups it lacks may be ones still arriving (each + // group its own stream, finishing out of order). + Some(t) => (Plan::Upstream { start, end: Some(end) }, !t.upstreams.is_empty() && t.cache.evicted_through.is_none_or(|e| start.group > e)), } }; // A fetch the cache does not yet cover, of a track still arriving, waits a little for @@ -1216,15 +1460,35 @@ async fn fetch(relay: Relay, ctx: Arc, r: TrackRequest, tx: ReqTx, m tokio::time::sleep(Duration::from_millis(20)).await; }; match plan { - Plan::Refuse(code, why) => reject(&tx, code, why).await, + Plan::Refuse(code, why) => { + if why == SEEK_NOT_GRANTED { + seek_refused(&relay, &ctx, &key); + } + reject(&tx, code, why).await + } Plan::Cache { start, end, eot, props } => { relay.inner.stats.fetch_served_from_cache.fetch_add(1, Ordering::Relaxed); let ok = FetchOk { end_of_track: eot, end_location: end, params: Parameters::new(), track_properties: props }; if tx.send(&ControlMessage::FetchOk(ok)).await.is_err() { return; } - queue_from_cache(&relay, &ctx.sched, &key, start, end, order, prio, r.request_id, lane_sub, false); - finish_fetch(&ctx, lane_sub, &tx, &mut rx).await; + let withhold = Withhold::of(&relay, &ctx, &key); + queue_from_cache(&relay, &ctx.sched, &key, start, end, order, prio, r.request_id, lane_sub, false, withhold.as_ref()); + finish_fetch(&ctx, lane_sub, &tx, &mut rx, None).await; + } + Plan::Local { start, end, eot, props, groups } => { + if groups.iter().any(|g| !matches!(g, Src::Cache(_))) { + relay.inner.stats.fetch_served_from_dvr.fetch_add(1, Ordering::Relaxed); + } else { + relay.inner.stats.fetch_served_from_cache.fetch_add(1, Ordering::Relaxed); + } + let ok = FetchOk { end_of_track: eot, end_location: end, params: Parameters::new(), track_properties: props }; + if tx.send(&ControlMessage::FetchOk(ok)).await.is_err() { + return; + } + let withhold = Withhold::of(&relay, &ctx, &key); + let producer = tokio::spawn(serve_local(relay.clone(), ctx.sched.clone(), key.clone(), groups, start, end, order, prio, r.request_id, lane_sub, false, withhold, moderator)); + finish_fetch(&ctx, lane_sub, &tx, &mut rx, Some(producer)).await; } Plan::Upstream { start, end } => { let Some(pubc) = fetch_upstream_of(&relay, &key, ctx.id) else { @@ -1242,7 +1506,7 @@ async fn fetch(relay: Relay, ctx: Arc, r: TrackRequest, tx: ReqTx, m ctx.sched.cancel(lane_sub, StreamResetCode::CANCELLED.0); return; } - finish_fetch(&ctx, lane_sub, &tx, &mut rx).await; + finish_fetch(&ctx, lane_sub, &tx, &mut rx, None).await; } Err((code, why)) => reject(&tx, code, &why).await, } @@ -1252,9 +1516,17 @@ async fn fetch(relay: Relay, ctx: Arc, r: TrackRequest, tx: ReqTx, m /// After FETCH_OK: answer priority updates until the fetch stream is sent, then FIN; a /// cancelled request cancels the stream (§3.2.4). -async fn finish_fetch(ctx: &SessionCtx, lane_sub: u64, tx: &ReqTx, rx: &mut ReqRx) { +async fn finish_fetch(ctx: &SessionCtx, lane_sub: u64, tx: &ReqTx, rx: &mut ReqRx, producer: Option>) { let sched = ctx.sched.clone(); - let drained = sched.drained(lane_sub); + let abort = producer.as_ref().map(|p| p.abort_handle()); + // With a producer still pushing (a range read from the DVR store as the lane drains), + // the lane is drained only once it has pushed the end. + let drained = async { + if let Some(p) = producer { + let _ = p.await; + } + sched.drained(lane_sub).await + }; tokio::pin!(drained); let mut reading = true; loop { @@ -1270,6 +1542,9 @@ async fn finish_fetch(ctx: &SessionCtx, lane_sub: u64, tx: &ReqTx, rx: &mut ReqR Ok(Some(_)) => {} Ok(None) => reading = false, Err(_) => { + if let Some(a) = &abort { + a.abort(); + } ctx.sched.cancel(lane_sub, StreamResetCode::CANCELLED.0); tx.reset(StreamResetCode::CANCELLED.0).await; return; @@ -1284,17 +1559,32 @@ async fn finish_fetch(ctx: &SessionCtx, lane_sub: u64, tx: &ReqTx, rx: &mut ReqR /// fetch stream whose header names the SUBSCRIBE's Request ID. A fill the relay cannot /// serve is a fetch stream reset right after its header (§3.4.1). #[allow(clippy::too_many_arguments)] -async fn serve_fill(relay: &Relay, ctx: &Arc, key: &TrackKey, filter: Option, order: GroupOrder, prio: u8, request_id: u64, lane_sub: u64, budget: Option) { +async fn serve_fill(relay: &Relay, ctx: &Arc, key: &TrackKey, filter: Option, order: GroupOrder, prio: u8, request_id: u64, lane_sub: u64, budget: Option, may_seek: bool) { let range = { let st = relay.inner.state.lock().unwrap(); - st.tracks.get(key).and_then(|t| t.largest.and_then(|l| fetch_range(filter, l)).map(|(s, e)| (s, e, t.cache.covers(s)))) + st.tracks.get(key).and_then(|t| t.largest.and_then(|l| fetch_range(filter, l)).map(|(s, e)| (s, e, t.cache.covers(s), l))) }; - let Some((start, end, covered)) = range else { return }; + let Some((start, end, covered, largest)) = range else { return }; + let withhold = Withhold::of(relay, ctx, key); if covered { relay.inner.stats.fetch_served_from_cache.fetch_add(1, Ordering::Relaxed); - queue_from_cache(relay, &ctx.sched, key, start, end, order, prio, request_id, lane_sub, true); + queue_from_cache(relay, &ctx.sched, key, start, end, order, prio, request_id, lane_sub, true, withhold.as_ref()); return; } + // The fill's earlier groups from the DVR store: a window whose end lies ahead of the + // live edge is a SUBSCRIBE with that range and a fill (WAI live-recording §8.4). + if may_seek || start.group >= largest.group { + let local = { + let st = relay.inner.state.lock().unwrap(); + relay.dvr().filter(|d| d.keeps(&key.0)).and_then(|d| d.snapshot(key, start.group, end.group)).and_then(|snap| local_plan(st.tracks.get(key), &snap, start, end, largest.group)) + }; + if let Some(groups) = local { + relay.inner.stats.fetch_served_from_dvr.fetch_add(1, Ordering::Relaxed); + let moderator = moderates(relay, ctx, room_of(&key.0)); + serve_local(relay.clone(), ctx.sched.clone(), key.clone(), groups, start, end, order, prio, request_id, lane_sub, true, withhold, moderator).await; + return; + } + } let budget = Duration::from_millis(budget.unwrap_or(relay.config().limits.fill_timeout_ms).min(relay.config().limits.fill_timeout_ms)); let fail_lane = |sched: &SchedHandle| { let lane = LaneKey { sub: lane_sub, kind: LaneKind::Fetch { request_id } }; @@ -1317,6 +1607,8 @@ async fn track_status(relay: Relay, ctx: Arc, r: TrackRequest, tx: R return reject(&tx, RequestErrorCode::UNAUTHORIZED, "no grant covers this track").await; } let found = relay.inner.state.lock().unwrap().tracks.get(&key).map(|t| (t.largest, t.props.clone())); + // A track the relay keeps in its DVR store, its publisher gone (or before a restart). + let found = found.or_else(|| relay.dvr().and_then(|d| d.largest(&key).map(|l| (Some(l), d.props(&key).unwrap_or_default())))); match found { Some((largest, props)) => { let mut params = Parameters::new(); diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/summary.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/summary.rs index 299f37219..85cfe5fbb 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/summary.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/summary.rs @@ -51,6 +51,12 @@ pub struct TrackTally { /// incremental updates, one event each (WAI live-events §8.1). #[serde(default)] pub incremental_objects: u64, + /// Of the objects served by fetch or fill, those read from the DVR store, and their + /// payload bytes (WAI live-metering §3.9). + #[serde(default)] + pub dvr_objects: u64, + #[serde(default)] + pub dvr_bytes: u64, } /// One span of a subscriber session's layer class in a room (WAI live-metering §3.3): the diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/auth_tokens.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/auth_tokens.rs index 1944fdec7..a09814972 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/auth_tokens.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/auth_tokens.rs @@ -24,7 +24,7 @@ async fn run() { let hash = hex::encode(joule_moq_relay::auth::sha256(&token)); let server = relay(|c| { c.auth.required = true; - c.auth.grants = vec![Grant { prefix: vec!["live".into()], token_sha256: hash, publish: true, subscribe: true, proof: false, unlimited: false }]; + c.auth.grants = vec![Grant { prefix: vec!["live".into()], token_sha256: hash, publish: true, subscribe: true, proof: false, unlimited: false, seek: false }]; c.limits.max_auth_token_cache_size = 1024; }) .await; diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/grant_proof.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/grant_proof.rs index 846c49602..a539fdfdd 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/grant_proof.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/grant_proof.rs @@ -59,7 +59,7 @@ async fn run() { let hash = hex::encode(joule_moq_relay::auth::sha256(&record)); let server = relay(|c| { c.auth.required = true; - c.auth.grants = vec![Grant { prefix: vec!["room".into()], token_sha256: hash, publish: true, subscribe: true, proof: true, unlimited: false }]; + c.auth.grants = vec![Grant { prefix: vec!["room".into()], token_sha256: hash, publish: true, subscribe: true, proof: true, unlimited: false, seek: false }]; }) .await; let t = Tracks::new(ns(&["room", "host"]), "0", 10, 5, 128); diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/room_authority.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/room_authority.rs index 1b5cc6f7c..36e0a0927 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/room_authority.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/room_authority.rs @@ -152,7 +152,7 @@ async fn run() { // At its session limit; a session whose token's rule is `unlimited` is not. let s = limited(|c| { c.limits.max_session_ms = 1_500; - c.auth.grants = vec![joule_moq_relay::config::Grant { prefix: vec![], token_sha256: hex::encode(joule_moq_relay::auth::sha256(b"service")), publish: false, subscribe: true, proof: false, unlimited: true }]; + c.auth.grants = vec![joule_moq_relay::config::Grant { prefix: vec![], token_sha256: hex::encode(joule_moq_relay::auth::sha256(b"service")), publish: false, subscribe: true, proof: false, unlimited: true, seek: false }]; }) .await; let (c, _) = connect(&s, Via::WebSocket, with(&s, &gv, &viewer)).await; diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs b/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs index a4ccf57a5..dc4b34165 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs @@ -40,8 +40,8 @@ async fn relay(keys: &Keys) -> RelayServer { let t = hex::encode(Sha256::digest(keys.clock_token)); let clock = hex::encode(keys.clock.verifying_key().to_bytes()); c.auth.grants = vec![ - Grant { prefix: vec!["*".into(), clock, "events".into()], token_sha256: t.clone(), publish: true, subscribe: false, proof: false, unlimited: true }, - Grant { prefix: vec![], token_sha256: t, publish: false, subscribe: true, proof: false, unlimited: false }, + Grant { prefix: vec!["*".into(), clock, "events".into()], token_sha256: t.clone(), publish: true, subscribe: false, proof: false, unlimited: true, seek: false }, + Grant { prefix: vec![], token_sha256: t, publish: false, subscribe: true, proof: false, unlimited: false, seek: false }, ]; c.receipts.door = ["audio", "video"].iter().map(|m| DoorRule { prefix: vec!["*".into(), "*".into(), m.to_string()], trust: vec![], trust_field: Some(1), hold_ms: 2_000 }).collect(); RelayServer::start(c).await.expect("the relay starts") diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/grant_proof.rs b/joule-code/joule-code-rs/crates/joule-moq/src/grant_proof.rs index 008bf67fd..74f8dd36c 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/grant_proof.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/grant_proof.rs @@ -225,6 +225,11 @@ pub const GRANT_DOMAIN: &[u8] = b"wai:live-room-grant\x01"; /// `profiles` and `origins` (a count each), an empty `attrs` (its u16 length) and `sig`. const GRANT_MIN_LEN: usize = 247 + 1 + 1 + 2 + 64; +/// The moderate power of a capability set (WAI live-rooms §3.3, bit 0). +pub const POWER_MODERATE: u8 = 1 << 0; +/// The seek power (WAI live-rooms §3.3, bit 4; §10.6). +pub const POWER_SEEK: u8 = 1 << 4; + /// The fields of a room grant (live-rooms §4.1) that a relay maps onto rights (§10.2): the /// room, the principal, the window and the capability set, read from a `WRG1` record whose /// signature verified under the authority the relay trusts. @@ -245,6 +250,9 @@ pub struct GrantHead { pub publish: u8, pub subscribe: bool, pub max_layer: u8, + /// Its powers (§3.3), a bit each: bit 0 moderate, bit 1 direct, bit 2 compose, bit 3 + /// record, bit 4 seek (§10.6). + pub powers: u8, /// Its policy permits some event kind (§10.2 item 4: its events track). pub sends_events: bool, } @@ -280,6 +288,7 @@ impl GrantHead { publish: record[126], subscribe, max_layer: record[128], + powers: record[129], // The policy (a WEP1 record, live-events §5) is at 163; its kinds bitmap // follows its magic and version. sends_events: record[168..200].iter().any(|&b| b != 0), @@ -291,6 +300,17 @@ impl GrantHead { self.nbf <= unix_s && unix_s < self.exp } + /// Whether it holds the moderate power (live-rooms §3.3, bit 0). + pub fn moderates(&self) -> bool { + self.powers & POWER_MODERATE != 0 + } + + /// Whether it holds the seek power (live-rooms §3.3, bit 4): it may fetch a track of the + /// room below its live edge (§10.6). + pub fn seeks(&self) -> bool { + self.powers & POWER_SEEK != 0 + } + /// Whether it may publish media kind `media` (live-rooms §6.1: 1 audio, 2 video, /// 3 screen). pub fn publishes(&self, media: u8) -> bool { diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs b/joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs index 1350ab56d..0a04e9492 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs @@ -578,7 +578,7 @@ fn publisher_key(bytes: &[u8; 32]) -> MoqResult { /// An Ed25519 signature checked under WAI's strict rule (jwp-receipts §3.1 step 3, SPEC §7 /// *Signatures*): the key decodes canonically and is not of small order, `S` is below L, /// and the cofactorless equation holds with `R` not of small order. -pub(crate) fn verify_strict(key: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> MoqResult<()> { +pub fn verify_strict(key: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> MoqResult<()> { let k = publisher_key(key)?; if !scalar_is_canonical(&sig[32..]) { return Err(MoqError::ReceiptVerification("signature: S is not below L".into())); -- 2.54.0 (Apple Git-157) From dd75aa38bfb68505281f8dd55856b1286f7c2d60 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:35:46 -0400 Subject: [PATCH 02/43] joule-moq-relay: the events reader's test flips a payload byte Co-Authored-By: Claude Opus 5.5 --- joule-code/joule-code-rs/crates/joule-moq-relay/src/events.rs | 2 +- joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/events.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/events.rs index d7de6ede2..819173ce5 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/events.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/events.rs @@ -172,7 +172,7 @@ pub(crate) mod tests { assert!(e.verifies(&decision)); assert_eq!(e.decision(), Some((REDACT, target))); let mut forged = decision.clone(); - forged[120] ^= 1; + forged[125] ^= 1; assert!(!Event::read(&forged).unwrap().verifies(&forged), "a changed record does not verify"); // An object of two records: the withheld one is left out, the other kept byte for byte. let obj = format!("[{},{}]", entry(&chat, 1), entry(&decision, 2)).into_bytes(); diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs index 08408c9f6..9dfc32329 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs @@ -1562,7 +1562,7 @@ async fn finish_fetch(ctx: &SessionCtx, lane_sub: u64, tx: &ReqTx, rx: &mut ReqR async fn serve_fill(relay: &Relay, ctx: &Arc, key: &TrackKey, filter: Option, order: GroupOrder, prio: u8, request_id: u64, lane_sub: u64, budget: Option, may_seek: bool) { let range = { let st = relay.inner.state.lock().unwrap(); - st.tracks.get(key).and_then(|t| t.largest.and_then(|l| fetch_range(filter, l)).map(|(s, e)| (s, e, t.cache.covers(s), l))) + st.tracks.get(key).and_then(|t| t.largest.and_then(|l| fetch_range(filter, l).map(|(s, e)| (s, e, t.cache.covers(s), l)))) }; let Some((start, end, covered, largest)) = range else { return }; let withhold = Withhold::of(relay, ctx, key); -- 2.54.0 (Apple Git-157) From 68a7b00593ee3496fc638fd2713860807cb0dbb7 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:38:44 -0400 Subject: [PATCH 03/43] joule-moq-relay: the DVR tests Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-relay/src/session.rs | 4 +- .../crates/joule-moq-relay/tests/dvr.rs | 601 ++++++++++++++++++ 2 files changed, 604 insertions(+), 1 deletion(-) create mode 100644 joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs index 9dfc32329..20f96099d 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs @@ -1289,7 +1289,9 @@ fn local_plan(t: Option<&crate::relay::Track>, snap: &Snapshot, start: Location, } else if let Some(hi) = t.and_then(|t| t.cache.absent_through(g)).or_else(|| snap.absent_through(g)) { hi.checked_add(1) } else if let Some(e) = snap.evicted_through.filter(|e| g <= *e) { - let hi = e.min(end.group); + // The run stops before the next group the cache still holds whole. + let cached = t.and_then(|t| t.cache.groups.range(g..=e.min(end.group)).find(|(gg, s)| s.whole() || (**gg == last && s.start && !s.broken)).map(|(gg, _)| *gg)); + let hi = cached.map_or(e.min(end.group), |c| c - 1); out.push(Src::Unknown(g, hi)); hi.checked_add(1) } else { diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs new file mode 100644 index 000000000..77650b4ac --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs @@ -0,0 +1,601 @@ +//! The DVR store (WAI live-recording §8): a window of a room's tracks with their receipts, +//! on disk, served to FETCH below the live edge. +//! +//! - **The window.** A long publish crosses the window's byte budget: the oldest groups go, +//! whole, each media group with its receipts group, and the window holds what it says. A +//! window by age empties as its groups pass it. +//! - **FETCH inside, across and before the window**, ascending and descending: inside, every +//! object, each verifying against the fetched receipts; across, an End of Unknown Range +//! for what was evicted and every object after it, from the store and the cache together; +//! before, an End of Unknown Range alone. +//! - **A restart** keeps the store: every object is checked again, a corrupted one (a +//! flipped byte; bytes changed with their checks made again, so only the receipt finds +//! them) is dropped, counted and never served, and a write a crash cut short is removed. +//! - **Moderation.** A room key's redaction withholds its target from every DVR read and +//! erases it from disk; a deny withholds until an allow restores it; a record of an events +//! track is withheld from the object that carries it; a decision by someone who is not a +//! moderator changes nothing; and the decisions hold across a restart. +//! - **The seek power** (WAI live-rooms §10.6): a viewer whose grant lacks it is refused a +//! FETCH and a fill below the live edge (`seek_not_granted`) and served one at the live +//! edge; a viewer whose grant holds it is served the window. +//! - **Metering**: the relay signs `WMH1` version 2, with the store's figures. + +mod common; + +use common::*; +use ed25519_dalek::{Signer, SigningKey}; +use joule_moq::carriage::ReceiptBundle; +use joule_moq::draft22::data::{FetchEntry, RangeEnd, SubgroupHeader, SubgroupId, SubgroupObject}; +use joule_moq::draft22::{GroupOrder, Location, LocationFilter, Parameter, Parameters}; +use joule_moq::receipt::{ObjectReceipt, ReceiptBuilder, ReceiptVerifier}; +use joule_moq::{GroupId, ObjectId, TrackAlias, TrackNamespace}; +use joule_moq_relay::client::{Client, ClientOptions}; +use joule_moq_relay::config::{Authority, DvrConfig, DvrWindow}; +use joule_moq_relay::{RelayConfig, RelayServer}; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::time::timeout; + +const OBJECTS: u64 = 3; +const LEN: usize = 1000; + +fn now_s() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() +} + +/// A directory of its own for a store. +fn store_dir(name: &str) -> PathBuf { + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let d = std::env::temp_dir().join(format!("joule-moq-dvr-{name}-{}-{nanos}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn dvr_config(path: &PathBuf, max_age_s: u64, max_bytes: u64, room_keys: Vec) -> DvrConfig { + DvrConfig { path: path.clone(), window: vec![DvrWindow { prefix: vec!["*".into()], max_age_s, max_bytes }], room_keys } +} + +fn hexkey(k: &SigningKey) -> String { + hex::encode(k.verifying_key().to_bytes()) +} + +fn object_payload(tag: &str, g: u64, o: u64) -> Vec { + let mut v = format!("{tag}:{g}:{o}:").into_bytes(); + v.resize(LEN, (g * 7 + o) as u8); + v +} + +/// The receipts of a group of `payloads`, as group `g` of its receipts track carries them. +fn bundle_of(key: &SigningKey, receipt_alias: u64, g: u64, payloads: &[Vec]) -> ReceiptBundle { + let mut b = ReceiptBuilder::new(TrackAlias(receipt_alias), GroupId(g)); + for p in payloads { + b.add_labelled_object(ObjectReceipt::hash_payload(p), 0, "test-publisher", "2026-10-19T00:00:00Z", vec![0]).unwrap(); + } + let objects = payloads + .iter() + .enumerate() + .map(|(i, p)| ObjectReceipt { + track_alias: TrackAlias(receipt_alias), + group_id: GroupId(g), + object_id: ObjectId(i as u64), + content_hash: ObjectReceipt::hash_payload(p), + joules_micro: 0, + origin: "test-publisher".into(), + merkle_proof: b.proof_for(i).unwrap(), + energy_class: Some(vec![0]), + }) + .collect(); + let group = b.finalize_labelled(key, "test-publisher", "2026-10-19T00:00:01Z", None).unwrap(); + ReceiptBundle { group, objects } +} + +/// Publish group `g` of `t`: its receipts, then its objects on one subgroup stream. +async fn publish_multi(c: &Client, key: &SigningKey, t: &Tracks, g: u64, tag: &str) { + let payloads: Vec> = (0..OBJECTS).map(|o| object_payload(tag, g, o)).collect(); + let bundle = bundle_of(key, t.receipt_alias, g, &payloads); + let rh = SubgroupHeader { track_alias: t.receipts_alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(t.priority), properties: false, end_of_group: true, first_object: true }; + c.send_subgroup(rh, &[SubgroupObject::normal(0, bundle.to_payload().unwrap())]).await.expect("the receipts"); + let mh = SubgroupHeader { track_alias: t.media_alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(t.priority), properties: false, end_of_group: true, first_object: true }; + let objects: Vec = payloads.into_iter().enumerate().map(|(o, p)| SubgroupObject::normal(o as u64, p)).collect(); + c.send_subgroup(mh, &objects).await.expect("the objects"); +} + +fn room_tracks(room: [u8; 32], publisher: &SigningKey) -> Tracks { + Tracks::new(ns(&[&hex::encode(room), &hexkey(publisher), "video"]), "0", 10, 5, 128) +} + +async fn flush(server: &RelayServer) { + let d = server.relay().dvr().expect("a DVR store").clone(); + tokio::task::spawn_blocking(move || d.flush()).await.unwrap(); +} + +/// Wait, bounded, until the store has written `n` groups. +async fn written(server: &RelayServer, n: u64) { + let d = server.relay().dvr().expect("a DVR store").clone(); + assert!(until(STEP, || d.counts().groups_written >= n).await, "{n} groups written: {:?}", d.counts()); + flush(server).await; +} + +fn range(start: (u64, u64), end_group: u64) -> Parameters { + Parameters::new().with(Parameter::LocationFilter(LocationFilter::AbsoluteStartGroupEnd { start: Location::new(start.0, start.1), end_group_delta: end_group - start.0 })) +} + +/// What a fetch stream carried: its objects by location, and its End of Range entries. +#[derive(Debug, Default)] +struct Fetched { + order: Vec<(u64, u64)>, + objects: BTreeMap<(u64, u64), Vec>, + unknown: Vec, + other_ends: Vec<(RangeEnd, Location)>, +} + +fn fetched(entries: Vec) -> Fetched { + let mut f = Fetched::default(); + for e in entries { + match e { + FetchEntry::Object(o) => { + f.order.push((o.group_id, o.object_id)); + f.objects.insert((o.group_id, o.object_id), o.payload); + } + FetchEntry::EndOfRange { kind: RangeEnd::Unknown, location } => f.unknown.push(location), + FetchEntry::EndOfRange { kind, location } => f.other_ends.push((kind, location)), + } + } + f +} + +/// FETCH a range of the media track and of its receipts track; every media object fetched +/// verifies against the fetched receipts of its group under the publisher's key. Returns +/// the media fetched. +async fn fetch_verified(c: &Client, t: &Tracks, key: &SigningKey, params: Parameters) -> Fetched { + let (_, media) = c.fetch(t.ns.clone(), t.media.clone(), params.clone()).await.expect("FETCH the media track"); + let (_, rec) = c.fetch(t.ns.clone(), t.receipts.clone(), params).await.expect("FETCH the receipts track"); + let media = fetched(media); + let rec = fetched(rec); + let v = ReceiptVerifier::with_anchors(vec![key.verifying_key()]); + for ((g, o), p) in &media.objects { + let b = ReceiptBundle::from_payload(rec.objects.get(&(*g, 0)).unwrap_or_else(|| panic!("the receipts of group {g} come with it"))).unwrap(); + let vg = v.verify_group(&b.group).expect("the group receipt verifies"); + let r = b.objects.iter().find(|r| r.object_id == ObjectId(*o)).expect("a receipt for the object"); + v.verify_payload(p, r, &vg).unwrap_or_else(|e| panic!("({g}, {o}) verifies against its receipt: {e}")); + } + media +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_window_and_fetch_inside_across_and_before_it() { + timeout(Duration::from_secs(180), window_run()).await.expect("the test finishes in time"); +} + +async fn window_run() { + const GROUPS: u64 = 40; + let dir = store_dir("window"); + let room = [0x51; 32]; + let publisher = SigningKey::from_bytes(&[0x52; 32]); + let t = room_tracks(room, &publisher); + // About eight groups of media and receipts in the window; the cache holds fewer. + let max_bytes = 30_000; + let cfg = dvr_config(&dir, 900, max_bytes, vec![]); + let server = relay(|c| { + c.cache.max_bytes = 12 * 1024; + c.dvr = Some(cfg.clone()); + }) + .await; + let (p, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; + let _pub = publish_tracks(&p, &t, None).await; + for g in 0..GROUPS { + publish_multi(&p, &publisher, &t, g, "w").await; + } + written(&server, 2 * GROUPS).await; + let d = server.relay().dvr().unwrap().clone(); + let media_key = (t.ns.clone(), t.media.clone()); + let rec_key = (t.ns.clone(), t.receipts.clone()); + let held = d.groups(&media_key); + let first = *held.first().expect("groups held"); + assert!(first > 0, "the oldest groups were evicted"); + assert_eq!(held, (first..GROUPS).collect::>(), "the window is the newest groups, no gap"); + assert_eq!(d.groups(&rec_key), held, "each media group goes with its receipts group"); + let counts = d.counts(); + assert!(counts.bytes_held <= max_bytes, "the window holds its budget: {counts:?}"); + assert_eq!(counts.groups_evicted, 2 * first, "whole groups, media and receipts"); + assert_eq!(counts.objects_written, GROUPS * (OBJECTS + 1)); + + let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; + // Inside the window: every object, verified, in the requested order. + let inside = fetch_verified(&v, &t, &publisher, range((first + 1, 0), first + 3)).await; + assert_eq!(inside.objects.len() as u64, 3 * OBJECTS); + assert!(inside.unknown.is_empty() && inside.other_ends.is_empty()); + for ((g, o), p) in &inside.objects { + assert_eq!(p, &object_payload("w", *g, *o)); + } + let desc = fetch_verified(&v, &t, &publisher, range((first + 1, 0), first + 3).with(Parameter::GroupOrder(GroupOrder::Descending))).await; + let groups: Vec = desc.order.iter().map(|x| x.0).collect(); + assert_eq!(groups, [first + 3, first + 3, first + 3, first + 2, first + 2, first + 2, first + 1, first + 1, first + 1], "descending groups, objects ascending"); + assert!(desc.order.windows(2).all(|w| w[0].0 != w[1].0 || w[0].1 < w[1].1)); + + // Across: what was evicted is unknown, and the rest comes, from the store and the cache. + let across = fetch_verified(&v, &t, &publisher, Parameters::new().with(Parameter::LocationFilter(LocationFilter::AbsoluteStart { start: Location::new(0, 0) }))).await; + assert_eq!(across.unknown, vec![Location::new(first - 1, u64::MAX)], "End of Unknown Range through the last evicted group"); + assert!(across.other_ends.is_empty()); + assert_eq!(across.objects.len() as u64, (GROUPS - first) * OBJECTS, "every object the window holds and the cache's"); + assert_eq!(across.order.first(), Some(&(first, 0))); + assert_eq!(across.order.last(), Some(&(GROUPS - 1, OBJECTS - 1))); + assert!(server.relay().stats().fetch_served_from_dvr >= 2); + + // Before: nothing but the End of Unknown Range. + assert!(first > 3); + let (ok, before) = v.fetch(t.ns.clone(), t.media.clone(), range((0, 0), 2)).await.expect("a FETCH before the window"); + assert_eq!(ok.end_location, Location::new(2, u64::MAX)); + let before = fetched(before); + assert!(before.objects.is_empty()); + assert_eq!(before.unknown, vec![Location::new(2, u64::MAX)]); + + // The record of the room is version 2, with the store's figures. + v.close(0, "done"); + p.close(0, "done"); + assert!(until(STEP, || server.relay().summaries().len() >= 2).await); + let rec = server.relay().hop_record(room, 0); + let bytes = rec.to_bytes(); + assert_eq!(&bytes[..5], b"WMH1\x02", "version 2"); + rec.verify().expect("the record verifies under the relay's key"); + let back = joule_moq_relay::metering::HopRecord::decode(&bytes, &rec.ledger_bytes()).expect("it reads back"); + assert_eq!(back, rec); + let dv = rec.dvr.expect("the DVR figures"); + let served: u64 = server.relay().summaries().iter().flat_map(|s| s.summary.tally.subscriber.tracks.values().map(|x| x.dvr_objects)).sum(); + assert_eq!(dv.objects_out, served); + assert!(dv.objects_out >= 2 * 3 * OBJECTS, "the inside fetches came from the store: {dv:?}"); + let out: u64 = rec.entries.iter().filter(|e| e.dir == 2).map(|e| e.objects).sum(); + let inn: u64 = rec.entries.iter().filter(|e| e.dir == 1).map(|e| e.objects).sum(); + assert!(dv.objects_out <= out && dv.objects_stored <= inn, "{dv:?}, out {out}, in {inn}"); + assert_eq!(dv.objects_stored, GROUPS * (OBJECTS + 1), "every object of the room written in the span"); + assert!(dv.byte_s > 0 && dv.dropped == 0); + server.shutdown().await; + let _ = std::fs::remove_dir_all(&dir); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_window_by_age_empties_as_its_groups_pass_it() { + timeout(Duration::from_secs(60), async { + let dir = store_dir("age"); + let room = [0x53; 32]; + let publisher = SigningKey::from_bytes(&[0x54; 32]); + let t = room_tracks(room, &publisher); + let cfg = dvr_config(&dir, 2, 1 << 30, vec![]); + let server = relay(|c| { + c.cache.max_bytes = 8 * 1024; + c.dvr = Some(cfg.clone()); + }) + .await; + let (p, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; + let _pub = publish_tracks(&p, &t, None).await; + for g in 0..3 { + publish_multi(&p, &publisher, &t, g, "a").await; + } + written(&server, 6).await; + let d = server.relay().dvr().unwrap().clone(); + let (key, rkey) = ((t.ns.clone(), t.media.clone()), (t.ns.clone(), t.receipts.clone())); + assert!(until(Duration::from_secs(10), || d.groups(&key).is_empty() && d.groups(&rkey).is_empty()).await, "past max_age_s every group goes"); + assert_eq!(d.counts().groups_evicted, 6); + // The next group, fetched at once: what aged out is unknown, and the group is served. + publish_multi(&p, &publisher, &t, 3, "a").await; + let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; + let f = fetch_verified(&v, &t, &publisher, range((0, 0), 3)).await; + assert_eq!(f.unknown, vec![Location::new(2, u64::MAX)]); + assert_eq!(f.objects.keys().copied().collect::>(), (0..OBJECTS).map(|o| (3, o)).collect::>()); + server.shutdown().await; + let _ = std::fs::remove_dir_all(&dir); + }) + .await + .expect("in time"); +} + +/// Rewrite one object of a group file in place: `f` changes its payload (same length); +/// with `fix`, its digest and check are made again so that only its receipt finds it. +fn tamper(path: &std::path::Path, object: u64, fix: bool, f: impl Fn(&mut [u8])) { + let mut b = std::fs::read(path).unwrap(); + let count = u32::from_le_bytes(b[53..57].try_into().unwrap()) as u64; + let mut p = 93; + for i in 0..count { + let props_len = u32::from_le_bytes(b[p + 18..p + 22].try_into().unwrap()) as usize; + let payload_len = u32::from_le_bytes(b[p + 22..p + 26].try_into().unwrap()) as usize; + let at = p + 90 + props_len; + if i == object { + f(&mut b[at..at + payload_len]); + if fix { + let digest = ObjectReceipt::hash_payload(&b[at..at + payload_len]); + b[p + 26..p + 58].copy_from_slice(&digest); + let mut c = ring::digest::Context::new(&ring::digest::SHA256); + c.update(&b[p..p + 58]); + c.update(&b[p + 90..at + payload_len]); + let check = c.finish(); + b[p + 58..p + 90].copy_from_slice(check.as_ref()); + } + std::fs::write(path, &b).unwrap(); + return; + } + p = at + payload_len; + } + panic!("no object {object}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_restart_keeps_the_store_and_drops_what_does_not_verify() { + timeout(Duration::from_secs(120), async { + const GROUPS: u64 = 10; + let dir = store_dir("restart"); + let room = [0x55; 32]; + let publisher = SigningKey::from_bytes(&[0x56; 32]); + let t = room_tracks(room, &publisher); + let cfg = dvr_config(&dir, 900, 1 << 30, vec![]); + let config = |c: &mut RelayConfig| { + c.cache.max_bytes = 4 * 1024; + c.dvr = Some(cfg.clone()); + }; + let server = relay(config).await; + let (p, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; + let _pub = publish_tracks(&p, &t, None).await; + for g in 0..GROUPS { + publish_multi(&p, &publisher, &t, g, "r").await; + } + written(&server, 2 * GROUPS).await; + let key = (t.ns.clone(), t.media.clone()); + let d = server.relay().dvr().unwrap().clone(); + let (g3, g5) = (d.group_path(&key, 3).unwrap(), d.group_path(&key, 5).unwrap()); + p.close(0, "done"); + server.shutdown().await; + + // A flipped byte in (3, 1); (5, 0) rewritten with its checks made again; and a write a + // crash cut short. + tamper(&g3, 1, false, |p| p[10] ^= 0x20); + tamper(&g5, 0, true, |p| p.reverse()); + std::fs::write(g3.with_file_name("g00000000000000000010.tmp"), b"cut short").unwrap(); + + let server = relay(config).await; + let d = server.relay().dvr().unwrap().clone(); + let c = d.counts(); + assert_eq!((c.objects_dropped, c.torn_writes), (2, 1), "{c:?}"); + assert!(!g3.with_file_name("g00000000000000000010.tmp").exists(), "the cut-short write is removed"); + assert_eq!(d.groups(&key), (0..GROUPS).collect::>()); + // No publisher now: the store answers alone. + let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; + let f = fetch_verified(&v, &t, &publisher, Parameters::new().with(Parameter::LocationFilter(LocationFilter::AbsoluteStart { start: Location::new(0, 0) }))).await; + assert_eq!(f.objects.len() as u64, GROUPS * OBJECTS - 2, "every object but the two dropped"); + assert!(!f.objects.contains_key(&(3, 1)) && !f.objects.contains_key(&(5, 0)), "never served"); + assert_eq!(f.unknown, vec![Location::new(3, 1), Location::new(5, 0)], "each in its place as unknown"); + assert_eq!(server.relay().largest(&key), Some(Location::new(GROUPS - 1, OBJECTS - 1))); + // The dropped objects are out of their files too: a second restart finds nothing to drop. + server.shutdown().await; + let server = relay(config).await; + assert_eq!(server.relay().dvr().unwrap().counts().objects_dropped, 0); + let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; + let f = fetch_verified(&v, &t, &publisher, range((3, 0), 5)).await; + assert_eq!(f.unknown, vec![Location::new(3, 1), Location::new(5, 0)]); + server.shutdown().await; + let _ = std::fs::remove_dir_all(&dir); + }) + .await + .expect("in time"); +} + +/// A record of live-events §3.1, without an anchor, signed by `key`. +fn event(key: &SigningKey, room: [u8; 32], seq: u64, t: u64, kind: u8, payload: &[u8]) -> Vec { + let mut b = b"WEV1\x01".to_vec(); + b.extend_from_slice(&room); + b.extend_from_slice(&key.verifying_key().to_bytes()); + b.extend_from_slice(&seq.to_le_bytes()); + b.extend_from_slice(&[0; 32]); + b.extend_from_slice(&t.to_le_bytes()); + b.push(0); + b.push(kind); + b.extend_from_slice(&(payload.len() as u16).to_le_bytes()); + b.extend_from_slice(payload); + let mut m = b"wai:live-event\x01".to_vec(); + m.extend_from_slice(&b); + b.extend_from_slice(&key.sign(&m).to_bytes()); + b +} + +fn event_digest(rec: &[u8]) -> [u8; 32] { + ring::digest::digest(&ring::digest::SHA256, &rec[..rec.len() - 64]).as_ref().try_into().unwrap() +} + +fn decision(key: &SigningKey, room: [u8; 32], seq: u64, verdict: u8, target: [u8; 32]) -> Vec { + let mut p = vec![verdict]; + p.extend_from_slice(&target); + p.extend_from_slice(&1u16.to_le_bytes()); + event(key, room, seq, 1_000 + seq, 0x0B, &p) +} + +fn msf(records: &[&[u8]]) -> Vec { + use base64::Engine; + let e: Vec = records.iter().map(|r| format!("{{\"t\":1,\"data\":{{\"wev1\":\"{}\"}}}}", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r))).collect(); + format!("[{}]", e.join(",")).into_bytes() +} + +/// A principal's events track: PUBLISH it, and send objects of group 0 one stream each. +struct EventsTrack { + ns: TrackNamespace, + alias: u64, +} + +impl EventsTrack { + async fn open(c: &Client, room: [u8; 32], who: &SigningKey, alias: u64) -> (EventsTrack, (joule_moq_relay::wire::ReqTx, joule_moq_relay::wire::ReqRx)) { + let ns = ns(&[&hex::encode(room), &hexkey(who), "events"]); + let held = c.publish(ns.clone(), name("0"), alias, Parameters::new(), track_props(16, None)).await.expect("PUBLISH an events track"); + (EventsTrack { ns, alias }, held) + } + + /// Group `g`'s objects: the join set, then one object a record. + async fn group(&self, c: &Client, g: u64, records: &[Vec]) { + let h = SubgroupHeader { track_alias: self.alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(16), properties: false, end_of_group: true, first_object: true }; + let mut objects = vec![SubgroupObject::normal(0, b"[]".to_vec())]; + for (i, r) in records.iter().enumerate() { + objects.push(SubgroupObject::normal(i as u64 + 1, msf(&[r]))); + } + c.send_subgroup(h, &objects).await.expect("the events"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn decisions_withhold_from_the_store_and_hold_across_a_restart() { + timeout(Duration::from_secs(120), async { + const GROUPS: u64 = 6; + let dir = store_dir("moderation"); + let room = [0x57; 32]; + let publisher = SigningKey::from_bytes(&[0x58; 32]); + let moderator = SigningKey::from_bytes(&[0x59; 32]); + let t = room_tracks(room, &publisher); + let cfg = dvr_config(&dir, 900, 1 << 30, vec![hexkey(&moderator)]); + let config = |c: &mut RelayConfig| { + c.cache.max_bytes = 4 * 1024; + c.dvr = Some(cfg.clone()); + }; + let server = relay(config).await; + let (p, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; + let _pub = publish_tracks(&p, &t, None).await; + for g in 0..GROUPS { + publish_multi(&p, &publisher, &t, g, "m").await; + } + // The publisher's events: two chat messages. + let (pe, _pe_held) = EventsTrack::open(&p, room, &publisher, 40).await; + let chat1 = event(&publisher, room, 0, 10, 0x03, b"\x02\x00hi"); + let chat2 = event(&publisher, room, 1, 11, 0x03, b"\x03\x00bye"); + pe.group(&p, 0, &[chat1.clone(), chat2.clone()]).await; + written(&server, 2 * GROUPS + 1).await; + let d = server.relay().dvr().unwrap().clone(); + + let h = |g: u64, o: u64| ObjectReceipt::hash_payload(&object_payload("m", g, o)); + // The moderator (a room key): redact (2, 1), deny (3, 0), redact the first chat. The + // publisher, no moderator, redacts (4, 0): nothing comes of it. + let (m, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; + let (me, _me_held) = EventsTrack::open(&m, room, &moderator, 50).await; + me.group(&m, 0, &[decision(&moderator, room, 0, 3, h(2, 1)), decision(&moderator, room, 1, 2, h(3, 0)), decision(&moderator, room, 2, 3, event_digest(&chat1))]).await; + pe.group(&p, 1, &[decision(&publisher, room, 2, 3, h(4, 0))]).await; + assert!(until(STEP, || d.counts().decisions >= 3 && d.counts().objects_redacted >= 1).await, "{:?}", d.counts()); + assert_eq!(d.counts().decisions, 3, "the publisher's decision is not honoured"); + + let all = || Parameters::new().with(Parameter::LocationFilter(LocationFilter::AbsoluteStart { start: Location::new(0, 0) })); + let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; + let f = fetch_verified(&v, &t, &publisher, all()).await; + assert_eq!(f.unknown, vec![Location::new(2, 1), Location::new(3, 0)], "withheld, each in its place"); + assert_eq!(f.objects.len() as u64, GROUPS * OBJECTS - 2); + assert!(f.objects.contains_key(&(4, 0)), "a decision by no moderator withholds nothing"); + // The redacted object's bytes are gone from its file; the denied one's stay. + let file = std::fs::read(d.group_path(&(t.ns.clone(), t.media.clone()), 2).unwrap()).unwrap(); + assert!(!file.windows(LEN).any(|w| w == object_payload("m", 2, 1).as_slice()), "redacted: erased from disk"); + let file = std::fs::read(d.group_path(&(t.ns.clone(), t.media.clone()), 3).unwrap()).unwrap(); + assert!(file.windows(LEN).any(|w| w == object_payload("m", 3, 0).as_slice()), "denied: kept"); + // The publisher's events: the redacted chat is withheld, the other is served as sent. + let (_, ev) = v.fetch(pe.ns.clone(), name("0"), all()).await.expect("FETCH the events track"); + let ev = fetched(ev); + assert_eq!(ev.unknown, vec![Location::new(0, 1)], "the object holding the redacted record"); + assert_eq!(ev.objects.get(&(0, 2)), Some(&msf(&[&chat2]))); + + // An allow supersedes the deny: (3, 0) is served again. + me.group(&m, 1, &[decision(&moderator, room, 3, 1, h(3, 0))]).await; + assert!(until(STEP, || d.counts().decisions >= 4).await); + let f = fetch_verified(&v, &t, &publisher, all()).await; + assert_eq!(f.unknown, vec![Location::new(2, 1)]); + assert!(f.objects.contains_key(&(3, 0))); + + // Across a restart the decisions hold. + for c in [&p, &m, &v] { + c.close(0, "done"); + } + server.shutdown().await; + let server = relay(config).await; + let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; + let f = fetch_verified(&v, &t, &publisher, all()).await; + assert_eq!(f.unknown, vec![Location::new(2, 1)]); + let (_, ev) = v.fetch(pe.ns.clone(), name("0"), all()).await.expect("FETCH the events track"); + assert_eq!(fetched(ev).unknown, vec![Location::new(0, 1)]); + server.shutdown().await; + let _ = std::fs::remove_dir_all(&dir); + }) + .await + .expect("in time"); +} + +/// A grant laid out as live-rooms §4.1 lays it out, with these media bits and powers. +fn grant(authority: &SigningKey, room: [u8; 32], principal: &SigningKey, publish: u8, powers: u8) -> Vec { + let now = now_s(); + let mut g = b"WRG1\x01".to_vec(); + g.extend_from_slice(&room); + g.extend_from_slice(&principal.verifying_key().to_bytes()); + g.extend_from_slice(&[0; 40]); + g.extend_from_slice(&(now - 60).to_le_bytes()); + g.extend_from_slice(&(now + 600).to_le_bytes()); + g.extend_from_slice(&[0, publish, 1, 0, powers, 0]); + g.extend_from_slice(&[0; 32]); + g.extend_from_slice(b"WEP1\x01"); + g.extend_from_slice(&[0; 32]); + g.extend_from_slice(&[0; 28]); + g.extend_from_slice(&[0xff; 16]); + g.extend_from_slice(&[0; 3]); + g.extend_from_slice(&[0, 0, 0, 0]); + let mut m = joule_moq::grant_proof::GRANT_DOMAIN.to_vec(); + m.extend_from_slice(&g); + g.extend_from_slice(&authority.sign(&m).to_bytes()); + g +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn seeking_back_needs_the_seek_power() { + timeout(Duration::from_secs(120), async { + const GROUPS: u64 = 6; + let dir = store_dir("seek"); + let authority = SigningKey::from_bytes(&[0x5a; 32]); + let (publisher, plain, seeker) = (SigningKey::from_bytes(&[0x5b; 32]), SigningKey::from_bytes(&[0x5c; 32]), SigningKey::from_bytes(&[0x5d; 32])); + let room = [0x5e; 32]; + let t = room_tracks(room, &publisher); + let cfg = dvr_config(&dir, 900, 1 << 30, vec![]); + let server = relay(|c| { + c.cache.max_bytes = 4 * 1024; + c.dvr = Some(cfg.clone()); + c.auth.required = true; + c.auth.authorities = vec![Authority { key: hexkey(&authority), rooms: vec![] }]; + }) + .await; + let target = format!("//{}{}", server.websocket_addr.unwrap(), server.relay().config().path); + let with = |g: Vec, k: &SigningKey| ClientOptions { grants: vec![(g, k.clone())], target: target.clone(), ..ClientOptions::default() }; + let (p, _) = connect(&server, Via::WebSocket, with(grant(&authority, room, &publisher, 0b010, 0), &publisher)).await; + let _pub = publish_tracks(&p, &t, None).await; + for g in 0..GROUPS { + publish_multi(&p, &publisher, &t, g, "s").await; + } + written(&server, 2 * GROUPS).await; + let (a, _) = connect(&server, Via::WebSocket, with(grant(&authority, room, &plain, 0, 0), &plain)).await; + let (b, _) = connect(&server, Via::WebSocket, with(grant(&authority, room, &seeker, 0, 1 << 4), &seeker)).await; + + // Without the power: refused below the live edge, a FETCH and a fill alike. + let refused = a.fetch(t.ns.clone(), t.media.clone(), range((0, 0), GROUPS - 1)).await.expect_err("below the live edge"); + assert!(refused.contains("seek_not_granted") && refused.contains(&joule_moq::draft22::RequestErrorCode::UNAUTHORIZED.to_string()), "{refused}"); + let fill = Parameters::new().with(Parameter::LocationFilter(LocationFilter::AbsoluteStart { start: Location::new(1, 0) })); + let params = Parameters::new().with(Parameter::LocationFilter(LocationFilter::NextObject)).with(Parameter::FillParameters(fill)); + let refused = a.subscribe(t.ns.clone(), t.media.clone(), params).await.expect_err("a fill below the live edge"); + assert!(refused.contains("seek_not_granted"), "{refused}"); + // At the live edge: served. + let f = fetch_verified(&a, &t, &publisher, range((GROUPS - 1, 0), GROUPS - 1)).await; + assert_eq!(f.objects.len() as u64, OBJECTS); + // With it: the window. + let f = fetch_verified(&b, &t, &publisher, range((0, 0), GROUPS - 1)).await; + assert_eq!(f.objects.len() as u64, GROUPS * OBJECTS); + assert!(server.relay().stats().seek_refused >= 2); + for c in [&p, &a, &b] { + c.close(0, "done"); + } + server.shutdown().await; + let _ = std::fs::remove_dir_all(&dir); + }) + .await + .expect("in time"); +} + +#[test] +fn a_window_past_a_day_is_refused() { + let cfg = dvr_config(&PathBuf::from("/nonexistent"), 86_401, 1, vec![]); + let mut c = RelayConfig::loopback(); + c.dvr = Some(cfg); + assert!(joule_moq_relay::Relay::new(c).is_err()); +} -- 2.54.0 (Apple Git-157) From ed8bebb330fcf72a29c2aff5f6ea8b9c0d21fa99 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:22:52 -0400 Subject: [PATCH 04/43] live-metering: WMH1 version 2, a hop's DVR figures Version 2 of the per-hop session record adds one field, dvr, after ledger and before sig: six u64 of what a hop that keeps a DVR window served from it, stored in it, held in it (byte-seconds, rounded down) and dropped from it for not verifying against their receipts. A reader reads version 1 or 2 by its own layout; version 2's out and stored figures are held to the out and in entries (record_invalid); its bound is B2(M) = 600 + 180(M+2), chosen by the fifth byte before anything else is read. The units do not sum the DVR figures; the outcome states each record's version and, for version 2, its figures. The receipt stays version 1. Co-Authored-By: Claude Opus 5.5 --- wai/extensions/live-metering.md | 104 +++++++++++++++++++++++--------- 1 file changed, 74 insertions(+), 30 deletions(-) diff --git a/wai/extensions/live-metering.md b/wai/extensions/live-metering.md index cd6c2fd76..fcf896f82 100644 --- a/wai/extensions/live-metering.md +++ b/wai/extensions/live-metering.md @@ -33,12 +33,12 @@ ## 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). +In scope: the per-hop session record in its two versions, its track entries, its +subscriber time by layer class, its ledger, version 2's DVR figures, 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 @@ -87,7 +87,7 @@ Each hop signs one record per room session, `WMH1`, when its part in the session | field | size | meaning | |---|---|---| | `magic` | 4 | `WMH1` (ASCII) | -| `version` | 1 | 1 in this revision | +| `version` | 1 | 1 or 2 | | `room` | 32 | the room id | | `hop` | 32 | the hop's key, which signs the record | | `role` | 1 | 1 relay, 2 compositor, 3 ingest gateway | @@ -100,8 +100,13 @@ Each hop signs one record per room session, `WMH1`, when its part in the session | `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) | +| `dvr` | 48 | version 2 only: six u64, the hop's DVR figures (§3.9) | | `sig` | 64 | §3.5 | +Version 2 is version 1's fields in the same order and one more, `dvr`, after `ledger` and +before `sig`. A hop that keeps a DVR window ([live-recording](live-recording.md) §8) signs +version 2; one that keeps none signs version 1. + ### 3.2 Track entries A track entry is 90 bytes: @@ -183,9 +188,10 @@ that fails step 1 or 2 is `bad_key`, and a signature that fails a later step is ### 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 +A reader reads exactly the bytes it is given as one record, as live-events §3.3 reads a +record with `WMH1` for `WEV1` (`truncated`, `malformed`, `unknown_version`), except that it +reads version 1 or 2, each in the order of §3.1 for its version; any other version is +`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`: @@ -209,7 +215,11 @@ and as `record_invalid` one that breaks any of these: `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. +- `ledger` is 32 zero bytes exactly when `grants` is 0; +- in a version 2 record (§3.9), `dvr_objects_out` is at most the sum of `objects` over the + out entries, and `dvr_bytes_out` at most the sum of their `bytes`; `dvr_objects_stored` is + at most the sum of `objects` over the in entries, and `dvr_bytes_stored` at most the sum of + their `bytes`. 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 @@ -220,14 +230,21 @@ 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 +room session (§5.3). A record of version 1 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 +bytes, and one of version 2 at most + + B₂(M) = 600 + 180 × (M + 2) + +bytes. A record longer than the bound of its version is refused `record_too_large` before it +is read: its version is read from its fifth byte before anything else is read, and a record +whose fifth byte is not 2, or that has no fifth byte, is held to B(M). 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. +of 255 bytes); 600 is those and version 2's `dvr`, 48 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 @@ -243,6 +260,26 @@ the span. A hop that signs more than one record in a session (a long session flu 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. +### 3.9 DVR figures + +A hop that keeps a DVR window of the room ([live-recording](live-recording.md) §8) signs +version 2, whose `dvr` field is six u64 over the record's span, in this order: + +| field | meaning | +|---|---| +| `dvr_objects_out` | of the objects the record's out entries count (§3.2), those the hop sent from its DVR store in answer to a FETCH or a fill | +| `dvr_bytes_out` | those objects' payload bytes | +| `dvr_objects_stored` | the objects of the room the hop wrote to its DVR store in the span | +| `dvr_bytes_stored` | those objects' payload bytes | +| `dvr_byte_s` | the payload bytes of the room its store held, integrated over the span, in byte-seconds, rounded down: ⌊Σ *b* × *h* / 10^6⌋ over the objects it held, *b* an object's payload bytes and *h* the microseconds within the span during which the store held it | +| `dvr_dropped` | the objects of the room the hop dropped from its store because they did not verify against their receipts (jwp-receipts §4.1), which it never served | + +A hop that keeps no DVR window signs version 1, which states no DVR figures. The objects a +hop sends from its store are among those its out entries count, and the objects it stores +among those its in entries count, so §3.6 holds the out figures to the out entries and the +stored figures to the in entries. A record flushed in parts (§3.8) states in each part the +figures of its own span. + ## 4. The room-session receipt ### 4.1 Layout @@ -343,6 +380,8 @@ Every unit is an integer of room-clock microseconds or a count, over the room se take with no held commitment records nothing. What a device captured and did not commit while capturing is not counted. +The units do not sum the DVR figures of version 2 records (§3.9) in this revision. + ### 4.5 Joules per hop and per class Each hop entry restates its record's `hop`, `role`, `joules` and `class`, so the receipt @@ -460,10 +499,11 @@ reason and where: 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`). + 2. Its length is at most the bound of its version, read from its fifth byte (§3.7): + otherwise `record_too_large`. + 3. It reads as a record of version 1 or 2 (§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`. @@ -534,9 +574,9 @@ state is constant. From it: ### 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. +- **accept**: the receipt's digest, the session's units, each hop record's digest, size, + bound and version, with a version 2 record's six DVR figures (§3.9), the totals and the + coverage, each grant's consumption entry, the consumption root, and the QoE cell. ## 6. Quality-of-experience observations @@ -645,11 +685,12 @@ reader's rules above, and `RecoveredSession::describes` checks them against the 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). +held, the events it delivered, and a ledger from the room grants its tokens map to. It signs +version 2 when it keeps a DVR store, with that store's figures (§3.9), and version 1 +otherwise. 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 @@ -696,11 +737,14 @@ receipt and what it names, with the outcome §5.4 gives them: 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. + than fifty logs; a relay that keeps a DVR window of the composite and signs a version 2 + record of what it stored, held, dropped and served from it; 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. + extensions this one reads; among them version 2 records whose DVR figures exceed their + entries' (`record_invalid`) or whose length is past B₂(*M*) (`record_too_large`), and a + record of version 3 (`unknown_version`). 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` -- 2.54.0 (Apple Git-157) From d577ce246b207a33a7d7c4a5c5dc1ebd2c0820cb Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:24:07 -0400 Subject: [PATCH 05/43] wai-rs live_metering: read and write WMH1 version 2 HopRecord gains dvr: Option; Some is version 2, None version 1, whose bytes are unchanged. A record reads as version 1 or 2 by its own layout, any other version unknown_version; version 2's out figures are held to the out entries and its stored figures to the in entries (record_invalid). The size bound is chosen by the fifth byte before anything else is read: B2(M) = B(M) + 48 for version 2, B(M) otherwise. DvrFigures::byte_seconds floors the sum once. energy_steering's corpus builds its records as version 1. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/energy_steering/corpus.rs | 1 + wai/wai-rs/src/live_metering.rs | 190 ++++++++++++++++++++--- 2 files changed, 172 insertions(+), 19 deletions(-) diff --git a/wai/wai-rs/src/energy_steering/corpus.rs b/wai/wai-rs/src/energy_steering/corpus.rs index aa23edb6b..da225bf43 100644 --- a/wai/wai-rs/src/energy_steering/corpus.rs +++ b/wai/wai-rs/src/energy_steering/corpus.rs @@ -258,6 +258,7 @@ fn record(room_id: &[u8; 32], world: World, hop: u8, k: u64) -> HopRecord { events: 0, grants: 0, ledger: [0; 32], + dvr: None, sig: [0; 64], } .sign(&key(hop)) diff --git a/wai/wai-rs/src/live_metering.rs b/wai/wai-rs/src/live_metering.rs index af229b956..2a1401b87 100644 --- a/wai/wai-rs/src/live_metering.rs +++ b/wai/wai-rs/src/live_metering.rs @@ -12,7 +12,8 @@ //! //! What this module implements, by section of the extension: //! -//! - §3: the per-hop record, its track entries, its ledger and its size bound; +//! - §3: the per-hop record in versions 1 and 2, its track entries, its ledger, version 2's +//! DVR figures and the size bound of each version; //! - §4: the receipt, its units, its totals, its ceilings and its QoE cell; //! - §5: verification, over the room ([`crate::live_room`]), the takes //! ([`crate::capture_take`]) and the composition receipts ([`crate::live_compose`]); @@ -49,8 +50,11 @@ pub const CAPABILITY: &str = "wai.live.metering"; pub const HOP_MAGIC: [u8; 4] = *b"WMH1"; /// The magic of a room-session receipt (§4.1). pub const SESSION_MAGIC: [u8; 4] = *b"WMR1"; -/// The version of both records in this revision. +/// The version of a receipt (§4.1), and of a per-hop record that states no DVR figures +/// (§3.1). pub const RECORD_VERSION: u8 = 1; +/// The version of a per-hop record with DVR figures (§3.1, §3.9). +pub const HOP_VERSION_DVR: u8 = 2; /// The domain a per-hop record's signature is made over (§3.5). pub const HOP_DOMAIN: &[u8] = b"wai:metering-hop\x01"; /// The domain a receipt's signature is made over (§4.2). @@ -79,9 +83,11 @@ pub const LEDGER_ENTRY_LEN: usize = 143; pub const CONSUMPTION_LEN: usize = 198; /// The longest calibration reference a record carries (§3.6). pub const MAX_CALIBRATION_REF: usize = 255; -/// The fixed fields of a record (§3.7), and its longest class evidence. +/// The fixed fields of a version 1 record (§3.7), and its longest class evidence. pub const RECORD_FIXED: u64 = 281; pub const MAX_EVIDENCE: u64 = 271; +/// The length of version 2's `dvr` field (§3.9): six u64. +pub const DVR_LEN: usize = 48; /// Funds that were not computed (§4.6). pub const NO_FUNDS: u64 = u64::MAX; @@ -116,12 +122,24 @@ pub mod verdict { pub const UNMETERED: u8 = 4; } -/// The size bound of a record of a session in which the room published `m` media tracks -/// (§3.7): `552 + 180 × (m + 2)` bytes, whatever the audience. +/// The size bound of a version 1 record of a session in which the room published `m` media +/// tracks (§3.7): `B(m) = 552 + 180 × (m + 2)` bytes, whatever the audience. pub fn size_bound(m: u64) -> u64 { RECORD_FIXED + MAX_EVIDENCE + 2 * ENTRY_LEN as u64 * (m + 2) } +/// The size bound of a version 2 record (§3.7): `B₂(m) = 600 + 180 × (m + 2)` bytes, version +/// 1's and the 48 bytes of `dvr`. +pub fn size_bound_dvr(m: u64) -> u64 { + size_bound(m) + DVR_LEN as u64 +} + +/// The bound given bytes are held to (§3.7), by the version their fifth byte names, read +/// before anything else: B₂(m) when it is 2, and B(m) for any other byte or none. +pub fn bound_of(b: &[u8], m: u64) -> u64 { + if b.get(4) == Some(&HOP_VERSION_DVR) { size_bound_dvr(m) } else { size_bound(m) } +} + // --------------------------------------------------------------------------------------- // §9 Reasons // --------------------------------------------------------------------------------------- @@ -323,8 +341,9 @@ impl<'a> Rd<'a> { } } -/// live-events §3.3's first two steps, with `magic` for `WEV1`. -fn read_head(b: &[u8], magic: &[u8; 4]) -> Result<(), Reason> { +/// live-events §3.3's first two steps, with `magic` for `WEV1` and `versions` the versions +/// this revision reads: the version read. +fn read_head(b: &[u8], magic: &[u8; 4], versions: &[u8]) -> Result { if b.len() < 4 { return Err(TRUNCATED); } @@ -334,10 +353,10 @@ fn read_head(b: &[u8], magic: &[u8; 4]) -> Result<(), Reason> { if b.len() < 5 { return Err(TRUNCATED); } - if b[4] != RECORD_VERSION { + if !versions.contains(&b[4]) { return Err(UNKNOWN_VERSION); } - Ok(()) + Ok(b[4]) } fn verify_sig(key: &[u8; 32], domain: &[u8], body: &[u8], sig: &[u8; 64]) -> Result<(), Reason> { @@ -585,6 +604,55 @@ pub fn read_ledger(b: &[u8]) -> Result, Reason> { Ok(out) } +/// A hop's DVR figures (§3.9), which a version 2 record states over its span. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct DvrFigures { + /// `dvr_objects_out`: of the objects the out entries count, those sent from the store in + /// answer to a FETCH or a fill. + pub objects_out: u64, + /// `dvr_bytes_out`: their payload bytes. + pub bytes_out: u64, + /// `dvr_objects_stored`: the objects of the room written to the store in the span. + pub objects_stored: u64, + /// `dvr_bytes_stored`: their payload bytes. + pub bytes_stored: u64, + /// `dvr_byte_s`: the payload bytes the store held, integrated over the span, in + /// byte-seconds, rounded down. + pub byte_s: u64, + /// `dvr_dropped`: the objects dropped from the store because they did not verify against + /// their receipts, never served. + pub dropped: u64, +} + +impl DvrFigures { + /// The six u64 in §3.9's order. + pub fn fields(&self) -> [u64; 6] { + [self.objects_out, self.bytes_out, self.objects_stored, self.bytes_stored, self.byte_s, self.dropped] + } + + fn read(r: &mut Rd) -> Result { + let [objects_out, bytes_out, objects_stored, bytes_stored, byte_s, dropped] = r.u64s()?; + Ok(DvrFigures { objects_out, bytes_out, objects_stored, bytes_stored, byte_s, dropped }) + } + + /// `dvr_byte_s` of the objects a store held (§3.9): each object's payload bytes and the + /// microseconds within the span it was held, ⌊Σ bytes × µs / 10^6⌋. + pub fn byte_seconds(held: impl IntoIterator) -> u64 { + let total: u128 = held.into_iter().map(|(b, us)| b as u128 * us as u128).sum(); + u64::try_from(total / 1_000_000).unwrap_or(u64::MAX) + } + + /// §3.6's version 2 rules: the out figures within the out entries, the stored figures + /// within the in entries. + fn rules_hold(&self, tracks: &[TrackEntry]) -> bool { + let sum = |d: u8, f: fn(&TrackEntry) -> u64| -> u128 { tracks.iter().filter(|t| t.dir == d).map(|t| f(t) as u128).sum() }; + self.objects_out as u128 <= sum(dir::OUT, |t| t.objects) + && self.bytes_out as u128 <= sum(dir::OUT, |t| t.bytes) + && self.objects_stored as u128 <= sum(dir::IN, |t| t.objects) + && self.bytes_stored as u128 <= sum(dir::IN, |t| t.bytes) + } +} + /// A per-hop session record (§3.1). #[derive(Clone, Debug, PartialEq, Eq)] pub struct HopRecord { @@ -602,15 +670,22 @@ pub struct HopRecord { pub events: u64, pub grants: u32, pub ledger: [u8; 32], + /// The DVR figures (§3.9): `Some` in a version 2 record, `None` in a version 1 record. + pub dvr: Option, pub sig: [u8; 64], } impl HopRecord { + /// Its version (§3.1): 2 when it states DVR figures, 1 when it does not. + pub fn version(&self) -> u8 { + if self.dvr.is_some() { HOP_VERSION_DVR } else { RECORD_VERSION } + } + /// The record without `sig` (§3.1). pub fn body(&self) -> Vec { - let mut o = Vec::with_capacity(RECORD_FIXED as usize + ENTRY_LEN * self.tracks.len()); + let mut o = Vec::with_capacity(RECORD_FIXED as usize + DVR_LEN + ENTRY_LEN * self.tracks.len()); o.extend_from_slice(&HOP_MAGIC); - o.push(RECORD_VERSION); + o.push(self.version()); o.extend_from_slice(&self.room); o.extend_from_slice(&self.hop); o.push(self.role); @@ -627,6 +702,9 @@ impl HopRecord { o.extend_from_slice(&self.events.to_le_bytes()); o.extend_from_slice(&self.grants.to_le_bytes()); o.extend_from_slice(&self.ledger); + if let Some(d) = &self.dvr { + put_u64s(&mut o, &d.fields()); + } o } @@ -654,10 +732,10 @@ impl HopRecord { self } - /// Read a record (§3.6) of the room `room`: the record, its class, its rules, its - /// room, its signature. + /// Read a record (§3.6) of the room `room`, of version 1 or 2: the record, its class, its + /// rules, its room, its signature. pub fn parse(b: &[u8], room_id: &[u8; 32]) -> Result { - read_head(b, &HOP_MAGIC)?; + let version = read_head(b, &HOP_MAGIC, &[RECORD_VERSION, HOP_VERSION_DVR])?; let mut r = Rd { b, p: 5 }; let (room, hop, role) = (r.arr()?, r.arr()?, r.u8()?); let (start, end, joules) = (r.u64()?, r.u64()?, r.u64()?); @@ -672,6 +750,7 @@ impl HopRecord { let events = r.u64()?; let grants = r.u32()?; let ledger = r.arr()?; + let dvr = if version == HOP_VERSION_DVR { Some(DvrFigures::read(&mut r)?) } else { None }; let sig = r.arr()?; if !r.done() { return Err(MALFORMED); @@ -684,7 +763,8 @@ impl HopRecord { && tracks.windows(2).all(|w| w[0].order() < w[1].order()) && tracks.iter().all(TrackEntry::rules_hold) && (sessions != 0 || (subscriber_us.iter().all(|&x| x == 0) && events == 0)) - && ((grants == 0) == (ledger == [0; 32])); + && ((grants == 0) == (ledger == [0; 32])) + && dvr.is_none_or(|d| d.rules_hold(&tracks)); if !rules { return Err(Reason::RecordInvalid); } @@ -693,7 +773,7 @@ impl HopRecord { } verify_sig(&hop, HOP_DOMAIN, &b[..b.len() - 64], &sig)?; let class = class.expect("checked by energy_ok"); - Ok(HopRecord { room, hop, role, start, end, joules, class, tracks, sessions, subscriber_us, events, grants, ledger, sig }) + Ok(HopRecord { room, hop, role, start, end, joules, class, tracks, sessions, subscriber_us, events, grants, ledger, dvr, sig }) } } @@ -896,7 +976,7 @@ impl SessionReceipt { /// Read a receipt (§4.2) of the room `room_id`: the record, `classes_summed` for a /// total of tag 0, its rules, its room, its signature. pub fn parse(b: &[u8], room_id: &[u8; 32]) -> Result { - read_head(b, &SESSION_MAGIC)?; + read_head(b, &SESSION_MAGIC, &[RECORD_VERSION])?; let mut r = Rd { b, p: 5 }; let (room, signer, start, end) = (r.arr()?, r.arr()?, r.u64()?, r.u64()?); let n = r.u8()? as usize; @@ -1202,12 +1282,13 @@ pub struct Computed { pub consumption: Vec, } -/// One hop record as verified. +/// One hop record as verified; its version and DVR figures are its record's (§5.4). #[derive(Clone, Debug, PartialEq, Eq)] pub struct HopFacts { pub record: HopRecord, pub digest: [u8; 32], pub size: u64, + /// The bound of its version (§3.7). pub bound: u64, pub ledger: Vec, } @@ -1276,12 +1357,13 @@ fn evaluate(ctx: &Ctx, start: u64, end: u64, named: &Named, tariff: &Tariff) -> // The media tracks the room published in the session (§5.3). let published: BTreeSet<[u8; 32]> = ctx.over(start, end).iter().flat_map(|(st, _, _)| ctx.published(st)).collect(); let m = published.len() as u64; - let bound = size_bound(m); // Step 4: each hop entry. let mut hops = Vec::with_capacity(named.hops.len()); for (i, (given, expect)) in named.hops.iter().enumerate() { let at = |r| (Place::Hop(i), r); let (b, lb) = given.ok_or(at(Reason::RecordMissing))?; + // The bound of the version its fifth byte names, before anything else is read (§3.7). + let bound = bound_of(b, m); if b.len() as u64 > bound { return Err(at(Reason::RecordTooLarge)); } @@ -1828,6 +1910,7 @@ mod tests { events: 0, grants: 0, ledger: [0; 32], + dvr: None, sig: [0; 64], } .sign(&SigningKey::from_bytes(&[1; 32])) @@ -1886,6 +1969,75 @@ mod tests { } let full = record(tracks, Some(r), 1_000); assert_eq!(full.to_bytes().len() as u64, size_bound(m)); + // Version 2: 48 bytes more, and a record at its own bound has it. + assert_eq!(size_bound_dvr(0), 600 + 360); + let full2 = HopRecord { dvr: Some(DvrFigures::default()), ..full.clone() }; + let b2 = full2.to_bytes(); + assert_eq!(b2.len() as u64, size_bound_dvr(m)); + assert_eq!(bound_of(&b2, m), size_bound_dvr(m)); + assert_eq!(bound_of(&full.to_bytes(), m), size_bound(m)); + // A fifth byte other than 2, or none, is held to version 1's bound. + let mut v3 = b2.clone(); + v3[4] = 3; + assert_eq!(bound_of(&v3, m), size_bound(m)); + assert_eq!(bound_of(&b2[..4], m), size_bound(m)); + } + + fn dvr_record(dvr: DvrFigures) -> HopRecord { + let out = TrackEntry { kind: kind::MEDIA, track: [9; 32], dir: dir::OUT, objects: 10, bytes: 1_000, ..Default::default() }; + let inn = TrackEntry { kind: kind::MEDIA, track: [9; 32], dir: dir::IN, objects: 4, bytes: 400, ..Default::default() }; + let other = TrackEntry { kind: kind::OTHER, dir: dir::OUT, objects: 2, bytes: 30, ..Default::default() }; + HopRecord { dvr: Some(dvr), ..record(vec![inn, out, other], None, 0) }.sign(&SigningKey::from_bytes(&[1; 32])) + } + + /// §3.1: a version 2 record is version 1's bytes with the version 2 and the 48 bytes of + /// `dvr` before `sig`; it re-reads to itself, and a version 1 record keeps its bytes. + #[test] + fn a_version_2_record_round_trips() { + let d = DvrFigures { objects_out: 3, bytes_out: 300, objects_stored: 4, bytes_stored: 400, byte_s: 1_234, dropped: 1 }; + let r2 = dvr_record(d); + let r1 = HopRecord { dvr: None, ..r2.clone() }.sign(&SigningKey::from_bytes(&[1; 32])); + let (b1, b2) = (r1.to_bytes(), r2.to_bytes()); + assert_eq!((b1[4], b2[4], r1.version(), r2.version()), (1, 2, 1, 2)); + assert_eq!(b2.len(), b1.len() + DVR_LEN); + assert_eq!(b1[5..b1.len() - 64], b2[5..b1.len() - 64]); + let tail: Vec = d.fields().iter().flat_map(|x| x.to_le_bytes()).collect(); + assert_eq!(b2[b1.len() - 64..b2.len() - 64], tail[..]); + assert_eq!(HopRecord::parse(&b2, &[7; 32]).unwrap(), r2); + assert_eq!(HopRecord::parse(&b1, &[7; 32]).unwrap(), r1); + // A version 1 record's bytes read as version 2 lack the figures; a version 2 + // record's read as version 1 have 48 bytes too many. + let mut short = b1.clone(); + short[4] = 2; + assert_eq!(HopRecord::parse(&short, &[7; 32]), Err(TRUNCATED)); + let mut long = b2.clone(); + long[4] = 1; + assert_eq!(HopRecord::parse(&long, &[7; 32]), Err(MALFORMED)); + let mut v3 = b2; + v3[4] = 3; + assert_eq!(HopRecord::parse(&v3, &[7; 32]), Err(UNKNOWN_VERSION)); + } + + /// §3.6: version 2's out figures are held to the out entries and its stored figures to + /// the in entries; at the sums they read. + #[test] + fn version_2_rules() { + let parse = |d: DvrFigures| HopRecord::parse(&dvr_record(d).to_bytes(), &[7; 32]).map(|_| ()); + let at = DvrFigures { objects_out: 12, bytes_out: 1_030, objects_stored: 4, bytes_stored: 400, byte_s: u64::MAX, dropped: u64::MAX }; + assert_eq!(parse(at), Ok(())); + assert_eq!(parse(DvrFigures { objects_out: 13, ..at }), Err(Reason::RecordInvalid)); + assert_eq!(parse(DvrFigures { bytes_out: 1_031, ..at }), Err(Reason::RecordInvalid)); + assert_eq!(parse(DvrFigures { objects_stored: 5, ..at }), Err(Reason::RecordInvalid)); + assert_eq!(parse(DvrFigures { bytes_stored: 401, ..at }), Err(Reason::RecordInvalid)); + } + + /// §3.9: byte-seconds are the floor of the sum, not a sum of floors. + #[test] + fn byte_seconds_round_down_once() { + assert_eq!(DvrFigures::byte_seconds([(3, 333_333)]), 0); + assert_eq!(DvrFigures::byte_seconds([(1, 600_000), (1, 600_000)]), 1); + assert_eq!(DvrFigures::byte_seconds([(20_000, 10_000_000)]), 200_000); + assert_eq!(DvrFigures::byte_seconds([]), 0); } /// §4.5: a total of two classes labelled with one is `classes_summed`; a total that is -- 2.54.0 (Apple Git-157) From 36ceb357d5b5442510ab143ce3b7f236eb0a843f Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:26:45 -0400 Subject: [PATCH 06/43] live-metering corpus: a version 2 relay record and its refusals dvr_relay (accepted): the lesson's relay keeps a ten-second DVR window of the composite and signs a version 2 record of it, a viewer joining at 20 s filled from the window. Refused: dvr_out_exceeds and dvr_stored_exceeds (record_invalid), record_unknown_version (version 3), dvr_record_too_large (past B2(M) by less than an entry). Each accepted record's facts state its version, and a version 2 record's six DVR figures; every existing record keeps its bytes. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/live_metering/corpus.rs | 170 ++++++++++++++++++++++++- wai/wai-rs/tests/live_metering.rs | 25 +++- 2 files changed, 188 insertions(+), 7 deletions(-) diff --git a/wai/wai-rs/src/live_metering/corpus.rs b/wai/wai-rs/src/live_metering/corpus.rs index 513271ba1..967a47471 100644 --- a/wai/wai-rs/src/live_metering/corpus.rs +++ b/wai/wai-rs/src/live_metering/corpus.rs @@ -6,9 +6,10 @@ //! 8032). The per-hop records of the lesson room are written from sessions authored here //! ([`Session`]), aggregated by the rules a hop counts by (§3); the one record that is not is //! the reference relay's, `relay-record.wmh` with its ledger `relay-ledger.wml`, which -//! joule-moq-relay signed in its test `room_metering` and which is read here as recorded. Each -//! vector states the outcome it was written to have, and the generator asserts that this -//! module reaches it before writing it. +//! joule-moq-relay signed in its test `room_metering` and which is read here as recorded. The +//! version 2 record of the `dvr_relay` vector states the DVR window of [`dvr_window`], written +//! here too. Each vector states the outcome it was written to have, and the generator asserts +//! that this module reaches it before writing it. //! //! [`verify`] checks a corpus file against this module. `live-conformance/live_metering_verify.py` //! checks the same file from the extension's text, sharing no code with this module. @@ -654,6 +655,7 @@ fn hop_record(room: &WrittenRoom, hop: u8, role_: u8, span: (u64, u64), energy: events, grants: 0, ledger: [0; 32], + dvr: None, sig: [0; 64], } .with_ledger(&ledger); @@ -824,6 +826,63 @@ fn lesson_compositor(room: &WrittenRoom, energy: (u64, EnergyProvenance)) -> Hop (signed(r, COMPOSITOR), l) } +// ---- a DVR window (§3.9) ---- + +/// The composite's frames, one a second from 0 s, and each one's payload bytes. +const DVR_FRAMES: u64 = 23; +const DVR_FRAME_BYTES: u64 = 20_000; +/// How long the window holds a frame. +const DVR_WINDOW: u64 = 10 * S; +/// The frame dropped from the window, its group's receipt not verifying it. +const DVR_DROPPED: u64 = 5; +/// When the late viewer joins, filled from the window. +const DVR_JOIN: u64 = 20 * S; + +/// When frame `k` of the composite arrives at the relay: a third of a second into its second. +fn dvr_arrival(k: u64) -> u64 { + k * S + S / 3 +} + +/// The DVR window the relay of the `dvr_relay` vector keeps of the lesson's composite, and +/// the objects the late viewer gets: the relay writes each frame to its store as it arrives +/// and holds it ten seconds or to the span's end, except the sixth, which it drops a second +/// after it arrived when its group's receipt does not verify it; the viewer joining at 20 s +/// is filled from the window with the frames it holds then, then gets the live ones. The +/// figures are what §3.9 counts of that window; nothing measured them. +fn dvr_window() -> (DvrFigures, u64) { + let (mut held, mut filled, mut live) = (Vec::new(), 0u64, 0u64); + for k in 0..DVR_FRAMES { + let a = dvr_arrival(k); + let until = if k == DVR_DROPPED { a + S } else { (a + DVR_WINDOW).min(SESSION_END) }; + held.push((DVR_FRAME_BYTES, until - a)); + if a >= DVR_JOIN { + live += 1; + } else if k != DVR_DROPPED && until > DVR_JOIN { + filled += 1; + } + } + let figures = DvrFigures { + objects_out: filled, + bytes_out: filled * DVR_FRAME_BYTES, + objects_stored: DVR_FRAMES, + bytes_stored: DVR_FRAMES * DVR_FRAME_BYTES, + byte_s: DvrFigures::byte_seconds(held), + dropped: 1, + }; + (figures, filled + live) +} + +/// The lesson's relay keeping that window: its sessions and the late viewer, admitted with no +/// grant, and its version 2 record, unsigned. +fn dvr_relay_record(room: &WrittenRoom) -> Hop { + let (figures, objects) = dvr_window(); + let mut sessions = relay_sessions(room, 0); + let composite = lesson_tracks(&room.id).composite; + sessions.push(Session { who: None, class: Some((0, DVR_JOIN, 23_800 * MS)), flows: vec![flow_out(kind::MEDIA, composite, objects, objects * DVR_FRAME_BYTES)], events: 0 }); + let (r, l) = hop_record(room, RELAY, role::RELAY, (0, SESSION_END), Some(relay_energy()), &sessions); + (HopRecord { dvr: Some(figures), ..r }, l) +} + fn lesson_ingest(room: &WrittenRoom) -> Hop { let (r, l) = hop_record(room, INGEST, role::INGEST, (600 * MS, 21 * S), Some((2_000_000, EnergyProvenance::Estimator)), &ingest_sessions(room)); (signed(r, INGEST), l) @@ -1014,6 +1073,20 @@ fn vectors(rooms: &[(&'static str, WrittenRoom)]) -> Vec { let relay_given = Given { hops: vec![(RELAY_RECORD.to_vec(), RELAY_LEDGER.to_vec())], ..Given::default() }; let rr_r = receipt(rr, &relay_given, (rec.start, rec.end), AUTHORITY); v.push(vector("relay_recorded", "the record joule-moq-relay signed in its test: two publishers, three subscribers, one slow; unmetered, so the session's energy is partial", "relay", &rr_r, relay_given, Intent::Accept)); + // A relay that keeps a DVR window: its record is version 2. + let dvr_relay = { + let (r, led) = dvr_relay_record(l); + (signed(r, RELAY), led) + }; + let dvr_given = Given { hops: vec![bytes_of(&dvr_relay), bytes_of(&comp)], ..given.clone() }; + v.push(vector( + "dvr_relay", + "a relay keeping a ten-second DVR window of the composite signs a version 2 record: the frames it stored and the byte-seconds it held them, one dropped for not verifying against its receipt, and a viewer joining at 20 s filled from the window", + "lesson", + &receipt(l, &dvr_given, span, METER), + dvr_given.clone(), + Intent::Accept, + )); // ---- refused: the receipt ---- let mut flipped = base.to_bytes(); @@ -1145,6 +1218,76 @@ fn vectors(rooms: &[(&'static str, WrittenRoom)]) -> Vec { v.push(vector("ledger_root_mismatch", "a relay ledger changed after the relay signed its root", "lesson", &rc, g, Intent::Refuse("ledger_mismatch"))); } push_relay(&mut v, "hop_units_mismatch", "a relay record whose class-0 subscriber time is not its ledger's", relay_with(&|r, _| r.subscriber_us[0] += 1, RELAY), "unit_mismatch"); + // Version 2 (§3.6, §3.7, §3.9). + let dvr_with = |f: &dyn Fn(&mut HopRecord)| -> Hop { + let (mut r, led) = dvr_relay_record(l); + f(&mut r); + (r.sign(&key(RELAY)), led) + }; + let sum = |r: &HopRecord, d: u8, f: fn(&TrackEntry) -> u64| -> u64 { r.tracks.iter().filter(|t| t.dir == d).map(f).sum() }; + push_relay( + &mut v, + "dvr_out_exceeds", + "a version 2 relay record whose bytes sent from its DVR store are one more than its out entries' bytes", + dvr_with(&|r| { + let out = sum(&*r, dir::OUT, |t| t.bytes); + r.dvr.as_mut().expect("version 2").bytes_out = out + 1; + }), + "record_invalid", + ); + push_relay( + &mut v, + "dvr_stored_exceeds", + "a version 2 relay record whose objects written to its DVR store are one more than its in entries' objects", + dvr_with(&|r| { + let inn = sum(&*r, dir::IN, |t| t.objects); + r.dvr.as_mut().expect("version 2").objects_stored = inn + 1; + }), + "record_invalid", + ); + { + // The DVR relay's record with its fifth byte 3, signed again: only its version is + // wrong. + let (r, led) = dvr_relay_record(l); + let mut b = r.to_bytes(); + b[4] = 3; + let n = b.len() - 64; + let sig = sign(&key(RELAY), HOP_DOMAIN, &b[..n]); + b[n..].copy_from_slice(&sig); + let g = Given { hops: vec![(b.clone(), ledger_bytes(&led)), bytes_of(&comp)], ..given.clone() }; + let mut rc = base.clone(); + let entry = HopEntry { record: given_digest(&b), hop: r.hop, role: r.role, joules: r.joules, class: r.class.clone() }; + rc.hops = vec![entry, rc.hops.iter().find(|h| h.hop == pk(COMPOSITOR)).expect("the compositor's").clone()]; + rc.hops.sort_by_key(|h| h.record); + v.push(raw("record_unknown_version", "a relay record of version 3: the DVR relay's with its fifth byte 3, signed again", "lesson", resign(rc, METER), g, Intent::Refuse("unknown_version"))); + } + { + // Past B₂(M) by less than an entry, with entries the room never published, which + // reading would refuse. + let plan = SessionPlan { + room: l.input(), + start: span.0, + end: span.1, + hops: &dvr_given.hops, + descriptors: &dvr_given.descriptors, + compositions: &dvr_given.compositions, + takes: &dvr_given.takes, + logs: dvr_given.logs.as_deref(), + tariff: tariff(), + }; + let m = aggregate(&plan, pk(METER)).expect("the DVR relay's session aggregates").1.media_tracks; + let big = dvr_with(&|r| { + let mut k = 0u8; + while r.to_bytes().len() as u64 <= size_bound_dvr(m) { + r.tracks.push(TrackEntry { kind: kind::MEDIA, track: [0xE0 | (k >> 4), k, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2], dir: dir::OUT, ..Default::default() }); + r.tracks.sort_by_key(|t| (t.kind, t.track, t.dir)); + k += 1; + } + }); + let len = big.0.to_bytes().len() as u64; + assert!(len > size_bound_dvr(m) && len <= size_bound_dvr(m) + ENTRY_LEN as u64, "just past B₂(M)"); + push_relay(&mut v, "dvr_record_too_large", "a version 2 relay record with entries the room never published, past its size bound B₂(M) by less than one entry", big, "record_too_large"); + } // ---- refused: takes, compositions, logs ---- v.push(vector("take_missing", "a receipt naming the accompanist's take, which is not given", "lesson", &base, Given { takes: given.takes[..1].to_vec(), ..given.clone() }, Intent::Refuse("record_missing"))); @@ -1318,6 +1461,25 @@ fn consumption_json(c: &Consumption) -> Value { }) } +/// The names of version 2's DVR figures, in §3.9's order. +const DVR_FIELDS: [&str; 6] = ["dvr_objects_out", "dvr_bytes_out", "dvr_objects_stored", "dvr_bytes_stored", "dvr_byte_s", "dvr_dropped"]; + +/// A hop record's facts (§5.4): its digest, size, bound and version, and a version 2 +/// record's DVR figures. +fn record_json(h: &HopFacts) -> Value { + let mut m = Map::new(); + m.insert("digest".into(), hex(&h.digest).into()); + m.insert("size".into(), h.size.into()); + m.insert("bound".into(), h.bound.into()); + m.insert("version".into(), h.record.version().into()); + if let Some(d) = &h.record.dvr { + for (k, x) in DVR_FIELDS.iter().zip(d.fields()) { + m.insert((*k).into(), x.into()); + } + } + Value::Object(m) +} + fn outcome_json(o: &SessionOutcome) -> Map { let mut m = Map::new(); match o { @@ -1330,7 +1492,7 @@ fn outcome_json(o: &SessionOutcome) -> Map { m.insert("outcome".into(), "accept".into()); m.insert("digest".into(), hex(digest).into()); m.insert("media_tracks".into(), c.media_tracks.into()); - m.insert("records".into(), c.hops.iter().map(|h| json!({"digest": hex(&h.digest), "size": h.size, "bound": h.bound})).collect()); + m.insert("records".into(), c.hops.iter().map(record_json).collect()); m.insert("units".into(), units_json(&c.units)); m.insert("unmetered".into(), c.unmetered.into()); m.insert("totals".into(), c.totals.iter().map(|(t, (f, j))| json!({"class": tag_label(*t), "figures": f, "joules": *j as u64})).collect()); diff --git a/wai/wai-rs/tests/live_metering.rs b/wai/wai-rs/tests/live_metering.rs index 0473d452a..353fb73b1 100644 --- a/wai/wai-rs/tests/live_metering.rs +++ b/wai/wai-rs/tests/live_metering.rs @@ -2,7 +2,8 @@ //! `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. +//! the lesson's, a relay keeping a DVR window signs a version 2 record and every other record +//! is version 1, and the extension's §9 lists exactly the reasons the reference reports. #![cfg(feature = "live_metering")] use std::collections::BTreeSet; @@ -34,7 +35,9 @@ fn every_vector_gets_its_stated_outcome() { /// 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. +/// mixed vector states one total per class; the ceilings vector names two grants past; the +/// DVR relay's record is version 2, 48 bytes longer than the lesson relay's under a bound 48 +/// bytes higher, with every DVR figure above 0, and every other accepted record is version 1. #[test] fn the_corpus_covers_every_reason_and_case() { let doc: serde_json::Value = serde_json::from_str(&committed()).unwrap(); @@ -44,7 +47,7 @@ fn the_corpus_covers_every_reason_and_case() { 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"] { + for n in ["lesson", "signed_by_authority", "mixed_classes", "ceilings_past", "large_audience", "relay_recorded", "dvr_relay"] { assert_eq!(get(n)["outcome"], "accept", "{n}"); } let relay_record = |n: &str| { @@ -66,6 +69,22 @@ fn the_corpus_covers_every_reason_and_case() { 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::from([1, 2])); + let dvr = relay_record("dvr_relay"); + assert_eq!(dvr["version"], 2); + assert_eq!(dvr["size"].as_u64().unwrap(), lesson["size"].as_u64().unwrap() + 48); + assert_eq!(dvr["bound"].as_u64().unwrap(), lesson["bound"].as_u64().unwrap() + 48); + for k in ["dvr_objects_out", "dvr_bytes_out", "dvr_objects_stored", "dvr_bytes_stored", "dvr_byte_s", "dvr_dropped"] { + assert!(dvr[k].as_u64().unwrap() > 0, "{k}"); + } + for v in vectors.iter().filter(|v| v["outcome"] == "accept" && v["name"] != "dvr_relay") { + for r in v["records"].as_array().unwrap() { + assert_eq!(r["version"], 1, "{}", v["name"]); + assert!(r.get("dvr_byte_s").is_none()); + } + } + for (n, why) in [("dvr_out_exceeds", "record_invalid"), ("dvr_stored_exceeds", "record_invalid"), ("record_unknown_version", "unknown_version"), ("dvr_record_too_large", "record_too_large")] { + assert_eq!(get(n)["reason"], why, "{n}"); + } } /// §9 of the extension lists exactly the codes `Reason::ALL` has, each once, in order. -- 2.54.0 (Apple Git-157) From 98ea7833b3257052f61dd0e3d7e5e0620bbd45aa Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:30:18 -0400 Subject: [PATCH 07/43] live-metering corpus: regenerated with version 2 Five vectors added (dvr_relay accepted; dvr_out_exceeds, dvr_stored_exceeds, record_unknown_version, dvr_record_too_large refused); every existing vector's records and receipt are unchanged and its accepted records gain only their version, 1. Co-Authored-By: Claude Opus 5.5 --- wai/live-conformance/metering/vectors.json | 562 ++++++++++++++++++++- 1 file changed, 550 insertions(+), 12 deletions(-) diff --git a/wai/live-conformance/metering/vectors.json b/wai/live-conformance/metering/vectors.json index 3afd7870e..7ee79f372 100644 --- a/wai/live-conformance/metering/vectors.json +++ b/wai/live-conformance/metering/vectors.json @@ -383,12 +383,14 @@ { "digest": "8070e1923387154875aacbeb894c20dc919c95dd4128758b5d47df86652c4a21", "size": 744, - "bound": 2172 + "bound": 2172, + "version": 1 }, { "digest": "c3193ab4736af61acb0b4177aa6ec8fa8ee0924b3f727e9f21eb20b9dc72ef2d", "size": 1824, - "bound": 2172 + "bound": 2172, + "version": 1 } ], "units": { @@ -733,12 +735,14 @@ { "digest": "8070e1923387154875aacbeb894c20dc919c95dd4128758b5d47df86652c4a21", "size": 744, - "bound": 2172 + "bound": 2172, + "version": 1 }, { "digest": "c3193ab4736af61acb0b4177aa6ec8fa8ee0924b3f727e9f21eb20b9dc72ef2d", "size": 1824, - "bound": 2172 + "bound": 2172, + "version": 1 } ], "units": { @@ -1087,17 +1091,20 @@ { "digest": "9f2e96e3cf68df9d2fce5457c50c8469006e663b04a03ec8d389b77a51a646b7", "size": 731, - "bound": 2172 + "bound": 2172, + "version": 1 }, { "digest": "c3193ab4736af61acb0b4177aa6ec8fa8ee0924b3f727e9f21eb20b9dc72ef2d", "size": 1824, - "bound": 2172 + "bound": 2172, + "version": 1 }, { "digest": "d969cc740d80efd2c77bb615616ea1325471a58c89a1740db6d40fa8c85be6a8", "size": 461, - "bound": 2172 + "bound": 2172, + "version": 1 } ], "units": { @@ -1452,12 +1459,14 @@ { "digest": "376c83a4bbd92300b1e83cc5d6f2db2b535233b278b06995d300b337ba50e2ac", "size": 1824, - "bound": 2172 + "bound": 2172, + "version": 1 }, { "digest": "8070e1923387154875aacbeb894c20dc919c95dd4128758b5d47df86652c4a21", "size": 744, - "bound": 2172 + "bound": 2172, + "version": 1 } ], "units": { @@ -1811,12 +1820,14 @@ { "digest": "2fac36b3f81e22df2b33e71a1ebe504feec95e33b940b94b12b9b464093eb802", "size": 1824, - "bound": 2172 + "bound": 2172, + "version": 1 }, { "digest": "8070e1923387154875aacbeb894c20dc919c95dd4128758b5d47df86652c4a21", "size": 744, - "bound": 2172 + "bound": 2172, + "version": 1 } ], "units": { @@ -2141,7 +2152,8 @@ { "digest": "8a5ce3302cb410fe4344c89094a21edd5c2d2958c41da3bf09525e8e7cc051d7", "size": 1001, - "bound": 1272 + "bound": 1272, + "version": 1 } ], "units": { @@ -2342,6 +2354,364 @@ "vst_ms": 0 } }, + { + "name": "dvr_relay", + "about": "a relay keeping a ten-second DVR window of the composite signs a version 2 record: the frames it stored and the byte-seconds it held them, one dropped for not verifying against its receipt, and a viewer joining at 20 s filled from the window", + "room": "lesson", + "receipt": "574d5231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaa8be2039bc5d06e7cf9d0188c3f22c659f1687b08b69b97f50b134552c46da783000000000000000000366e01000000000261004c6584cec3f49113451b8ba6a04ebf2752df164a3ab7a3c13bb708bbb7450a29cf514deebfd5ca029b1d658b198bade764af98e09848c6fd53b129678b720100c31801000000000400004e2000000000016e3600018070e1923387154875aacbeb894c20dc919c95dd4128758b5d47df86652c4a21a693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c10280de8002000000000400003a9800000000016e3600010265b94ec2ec2932104a7583c08050f61547332fe434464d598cad6464025b3040b598d58ceff895b484641dfdea9bf329f40b464895eb31b2271a83af17cce7fb0a000000d2e2268edd1bfc8a0dbebf1f2287b93e200bc8affd02c3e92ac872b3200731a103000000d290530d77935fe173d0d5dcbb2588170f730b423f93543b4f3ceaee06d4e78e2055cc040000000080f15c0400000000801b680100000000801b680100000000000000000000000000000000000000000000000000000000000000000000000000000000000000001856620200000000080000000000000040548900000000002c00000000000000f000000000000000002d3101000000000001040200000080a1990300000000455552da0a00006d050000da0a0000b41500008d2000000000000000000000000000000000000016010000b41500006d05000064000000080000000881532eb7e7e56875380457d076c0dab18316e1f482411f957d3f579d763b490000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b847d82212078f9a632efb734e810c3d083a0ae5dba31bb2cecc94069a9078b7d621f5a86360741e5e0febc7c80cc5ce8bef81e18dc41982c3774c563f8df607", + "hops": [ + { + "record": "574d4831022c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaa0a29cf514deebfd5ca029b1d658b198bade764af98e09848c6fd53b129678b7201000000000000000000366e010000000000c31801000000000400004e2000000000016e360001110001025980e26a212cf4c098b0f8a1a36a15333c6975497e60c073b9cd8242943b4c01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001025980e26a212cf4c098b0f8a1a36a15333c6975497e60c073b9cd8242943b4c02d80e00000000000000470900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f5939013a02000000000000d80b0d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f5939023a02000000000000d80b0d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001313ea2ca301a5e17a28cb2d4b11014eaed6c09c8bb27b777993c556d712aa84d01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001313ea2ca301a5e17a28cb2d4b11014eaed6c09c8bb27b777993c556d712aa84d02d80e00000000000000470900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f011700000000000000e0040700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f024f00000000000000e01b180000000000030000000000000000000000000000000000000000000000000000000000000003000000000000000195ab21fa90d692e9fe90bade8cd132b0d17d75f25efa1fab73239d188617366501b6030000000000007094000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000195ab21fa90d692e9fe90bade8cd132b0d17d75f25efa1fab73239d188617366502d80e000000000000c05102000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000019d7a22d6b2f60ddeaf9f0b43a147004709d988a73527b65d08e0ea6372f910db013a0200000000000040ca22000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000019d7a22d6b2f60ddeaf9f0b43a147004709d988a73527b65d08e0ea6372f910db023a0200000000000040ca2200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c2c18a1d4c5f9a784336c16e2755ec99f12ba6990cacb81cb23f94aa048d6034013a02000000000000485904000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000011600000000000000781e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000002cd000000000000003cf0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000000172000000000000009885000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000000258000000000000002067000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000080f15c0400000000801b680100000000801b680100000000000000000000000000000000000000000000000000000000000000000000000000000000000000001856620200000000f00000000000000008000000f6cb34442422f6cb031318abae7d1679c18fde412c21a6fc1e06d7126c38c5710a00000000000000400d0300000000001700000000000000e004070000000000c08a3700000000000100000000000000e779011dbb4e9b6efbcf689246d7d8af927767d84d5d61122c7cb9b75b2dd7345c04e6571f6bb7b42f7b8b483e24d5db47bd4c3fc0df19ae6c5f3af216b1eb0a", + "ledger": "0000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffff000000030000000000000000000000b857040000000000c0fb390000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000018566202000000000c2a9846621d91f9309170b29b95e158e278002ce4cc03a2d097917836a391c0ffffffffffffffffffffffffffffffff000000010000000000000000000000d0870c0000000000a087630100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006dde44606fd8377d9fa0809b2b36b708de3e6119d8c1c3d1f0d675917c28c6198096980000000000ffffffffffffffff000000010000000000000000000000709d0b0000000000607a60010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b9091845dcf9a7fc8e2a12e1e1b2f986888b33fb22f1b42b384cdf47ecfc33a0ffffffffffffffffffffffffffffffff00000001000000e004070000000000c8431200000000000000000000000000801b6801000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cf4a5892c8a1bef2afba730a4ec868d0830f9355d10f540b757205945c588cb5ffffffffffffffffffffffffffffffff000000010000002c720200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d1ee853e5c092553edc99a0cc2468a7307afd83533a3a34f401b0695da343314ffffffffffffffffffffffffffffffff0000000100000020e53600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d436e9616073a533831cfbec646020b5d4dcc9d1df4b792ccaff6e7e23ee686affffffffffffffffffffffffffffffff0000000100000014b400000000000084e022000000000000000000000000000000000000000000801b680100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e433cf0201e9b443d808e4406925b6a4a90e9963c6235c742a0ecec87a59e478ffffffffffffffff6400000000000000455552010000000000000000000000d0870c0000000000c0f35e010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "record": "574d4831012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c1020000000000000000801b68010000000080de8002000000000400003a9800000000016e360001050001025980e26a212cf4c098b0f8a1a36a15333c6975497e60c073b9cd8242943b4c01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f5939013a02000000000000d80b0d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001313ea2ca301a5e17a28cb2d4b11014eaed6c09c8bb27b777993c556d712aa84d01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f021700000000000000e004070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000195ab21fa90d692e9fe90bade8cd132b0d17d75f25efa1fab73239d188617366501b60300000000000070940000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000b2083f6a8d3e1c901b93bfb87f5ffe83344214430e91d08f4e422c5ac7f0c0869f25a4e826f34f770f8f63bcea36ec697bbc398f81c06aa8ad11bb1f7a33323de1911cf31bd046195483ef8a8b22a5778adb66cfde02d9e8f8e46360a5577b06", + "ledger": "b9091845dcf9a7fc8e2a12e1e1b2f986888b33fb22f1b42b384cdf47ecfc33a0ffffffffffffffffffffffffffffffff00000001000000c843120000000000e004070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + ], + "descriptors": [ + "57434431012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaad6b5a6849f61a8bbd9d4db526a9fc1c73ef949590cdcd9b0b65ce6375262d9bd0000000000000000a693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c10005d00201101010010108000100086665617475726564056f72646572000000000000000000000280bb0000046761696e0370616e0c0c13afc5df1dffed9f80c566f13be392cc90ad1ceba5db69473a7993f8a7ff2e64cb5d094b2fdf651392e4a2d4f317cc6a436ab9b0cd5afe626493549677937002" + ], + "compositions": [ + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f000127a00000000008a826f7851f7a89f5ecf581141292c7f69e9d004ed08b01d25dd3c66e30f43f602540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f08000000000000000000000000000000c264cd78485650e03db23e16774c0566ac52d21be1b9edacaad39fa34dc3100c01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593908000000000000000000000000000000e43f0568538eef406fb5ed7901b131c0b789d9c0fdb04d61d590af9bec169a1dd2ed35929ea1d408e2da06ee1c840bacab38929016f1a7ba820a80472485c9c6159b98672266a380458e260007c2baec97749e4941eb6ac370d1660fb0e5f900", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f04054890000000000ee27588426463e6a765b4b8b5791670a7ad61be12ef1995102f9220928d6038d02540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f090000000000000000000000000000005e05448cb224cb6aa2726e4c0fe448b127e032169bd2ffea55b4693d53ef487f01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593909000000000000000000000000000000f993d88a6b1a587e0980017103aa4fcdbdf1d7b1c948f95a8bfa36a26493bb1e89b7c717020a8a296d3688d7988d8f2e70e54228d60c58514564a12937b2bb9421199fdee27ccf49acb52a32fa0f698b3eb5b28c35c47a797ae5e7d531f7db0a", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f08096980000000000d8bb5ef530c1fe8d1a0478d5ae231aac802d9f99dff906a66aa111420948048a02540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0a000000000000000000000000000000539e2bb145135d7de5b00ebf1b1178f83ef60d1b5ceedabf98bbb424a1d0a96301e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390a00000000000000000000000000000031a3cdcd31f70593af11aaf7bc4d22cc1ec5c18ee0d918fb2edd5a9c98b5a4758e74da34c2fb00e235278a3010c44dac99131fa49acc910e04f3929e7a6014833b553fad2cd31d127ad862b76999324556e76388192020c8022db9db6cafb803", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0c0d8a70000000000c65182e1c2868d42d9c1a244545f3c3416b14e481ade5c42b1979419f360b3e502540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0b000000000000000000000000000000491fe4bc5c4bafa0066ec9c2baf4b4287fcd09a778e36d5996a97ab827b7eaae01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390b000000000000000000000000000000414a7eb6f2353358646d5b861979c533b344cc01fb9dafdb9d6518c4ad7e3f6f01a03c915891dac04736b7858fdf34c4137e10f593ab5cb3a64900914b360f6b242ee3ead8c5499f325411942dbe827bd51176f2b049c4453a654c49dc701b09", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0001bb70000000000381618c2dfecc2dea3881cb1d4ed69e43dec01f07ebda92e08cd08ff935ed44502540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0c0000000000000000000000000000001915289342627f7f5a8bf4ccaf04c0418735ae65e41ca8692ebd3fca27ea764b01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390c000000000000000000000000000000194d05dbb7218051137b7537fcb320653e7f67071a19ae363969aee47cf08bede3b8b1012f1fc03a132f8793e0eae4389c23da524a7f6482a1d5cdf26ff04249cb81d4f19d8b164ec8eb381c66ba2704c57c7bb6f2389114f14bbfd632199302", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0405dc600000000007629d9fb8d6ac9e846d7ebdbe0a3a64b29ad51f7e4db40be5f96e447abcf6f0202540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0d000000000000000000000000000000f98b3489983bd0476673dc05db4f09c092411512ed2da6578e9c254eee0d72f501e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390d000000000000000000000000000000bc87dab2315f8ea4f21a4b5adcb838b8d89f6d67f93bbdf5bf175069ad81fa5d123e5e3721d1b781cfcc4650a2d571df1142f2160b377be26b692636e885cd037553b9041ac8f13b1f2064568957993ff69556b8673f0ad6e6caafd8b4887c07", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0809fd50000000000d32fbadccdb9742ffff99344fe07069e2e89b1d7f2d99859ad48336fadacd7c202540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0e00000000000000000000000000000053c09229456148744a940ba828e839eca0cf46d7c15e2c3533b4c65dcb0b4ff701e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390e000000000000000000000000000000b5702d26fbc4708196f576c1d46b7fc92471154fe81689707069bcb43173ecedc36859b37258c17a4294d56d36befaa9fbd975ce62e5deb542372aef4c9240b43ba584fc98e7c5a5066e1ba4060e96ca4e50daa116366c3aca9dfccef285f405", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0c0e1e40000000000fe93ba9f3d79ff2d3aeb01726ac4728adf60676494fbd0e596f5972be5f4639102540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0f000000000000000000000000000000341b9ec871ad41102a671b153ccd014f32cfe167bf5cca3989470a574196d0a401e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390f0000000000000000000000000000001eafd27ec73cf8676b02ebb4fa82a5bc67b2298a442322797db8cd996f8866d7bfdca1dadbfc723c27a3b6963a74c78570a41403a9cb454643631157e8bddbda8ccd54062aeec216b94406595aeb140b59afd5e697733f45980831357c0bc804", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f00024f4000000000092a195ef7c5e97512b5eed1dd778db0bd1f1c75cf8cf25b61ec800296e459e6302540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f10000000000000000000000000000000c693ed9bd4639c29ceb962b071e0531512929a139ebed7c5e12989d28b316ddc01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593910000000000000000000000000000000c5043e90fe94a06a8b47242bb084c7dc5f8e46aebbe9b2c5a8db30d44d1a8b55ab981922c453fc2c78b58f7ec56293bb26d02c335079fdd1e246e33607ef473f513bd939cfe20da8b94af0963156c7218be49ffd1efbc9c14429fddf91bf5006", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f04066030100000000d00e25bb474153c30c4bf88f0fb890a0dd10342388c0be87aa93399d54bbaf5502540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f110000000000000000000000000000005f50d92c07b2f57953a701633e9d59575664bfe9b13644d5db11c5d04629b08c01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593911000000000000000000000000000000339aa736ed16c43427079481a1b58bb9d370f104bbcba7a9d915ba1528b1a75da2dabf39b4d5d2518a8bef4340b8c5e7b32250ac222cc3e8648a42730b81eeac78da15f7fa7f42f7bb131311eb22aa7f89ada8940b2dd41c18c113a694a50808" + ], + "takes": [ + "57544d31012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaae57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f559425465004500032d6893f980a7dad483af70b4e116a10b7caa4e2052593699b2c4d5251f98cf34d11f86821c2cd542f6bbf5152a07ad54b271f65b25309018a0bf0e356fbe70268d3166f7ac16582f4cde87f1ce46d109e4bd0562c3d436da0f222415114ff3e773663df797c88d6e11b094c4ffc6694581d843dbfbe8ba1307020a0000000000000000000000807f612a01000000c0c1702a01000000300000000000000037090d56c333ad6c4392ffbb8f80b7854c7ae3d562e182d6b76685f99db2ec1f0100000000000000c0c1702a010000000004802a0100000055000000000000003b9b3da8c3121692dbe9b75ff7b37a77773cf7d71d8b7c885253d5b3855c53b902000000000000000004802a0100000040468f2a010000003a0000000000000097f5ee9829746c16c29946a44489846e4a385eb6d62804ba22a18a99a92642ab030000000000000040468f2a0100000080889e2a010000005f000000000000004bb0df0483b6370199dc9196f695ec65b029d8ffb09dfd7ec24bf5d5c59a72f3040000000000000080889e2a01000000c0caad2a010000004400000000000000733b22d5d80b9a80443b60a14334000a8ab43f139364fa9cd3a547e8a7c523640500000000000000c0caad2a01000000000dbd2a0100000069000000000000009395a39fcaaab116f14a5e4f1e51abe694d712e0f912827045471c6283730a480600000000000000000dbd2a01000000404fcc2a010000004e000000000000002b869dfefc2fe0873e06d23c09f1629470a788c6b8f2aa8e6aafb1298cd73fe60700000000000000404fcc2a010000008091db2a0100000033000000000000009f10cb7a3feccf63f5c0c398375eaf998628d314dc8159d15845fe5a0f8a006b08000000000000008091db2a01000000c0d3ea2a0100000058000000000000008b03281ec96d96d0e76e48ef184b7a60d4856eddf77629af961fe54746404ef60900000000000000c0d3ea2a010000000016fa2a010000003d000000000000001fb42eb87b4ea4dc72d02c9e6ea82aab91be4bbef122892b8921e2f552a462678883ea090e9f374f91bacaac0864af7b6dafa57c4f8a3ac0040ec7fc669f1f2506000000d7b83f99f213619227fbccadf7069c3f6cffca8e86f2aba6ffb8d15212698fa51be10b08c6e09a1b9314f45c65bf3c9b6bd507cfa5df3f013d50657245ee48c5602f2236c9c17b8a1cca6bae144f8f162627d27095a7256e1c5b8bc199c84fb3d535c9375be68b107355dcfeaab2a78192ba09570642f09de57b7bab1438eadc703cab1c2158dee2277fea9a9b909df1f7926a3a56996b7db1bfbaaba40ffa2970412cc3dc8306ba8f88774f1a622d1bf21b9ef51fb85b990d279f66fed977340159425465004500032d6893f980a7dad483af70b4e116a10b7caa4e205259369900000000000000000000000000000000000000000000000000000000000000001f250bafd7daed705bd36f8138b7419700ffe4c20991a2a46993dfd25b592a3643977ddbb511d2b51471498d51135c1a724a069a507032f2a3ff93aaa9787f0d", + "57544d31012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaa7d3b3098d82fddc8585faa3e84b8f2c13cb7fc723a01bfb8bbacfba4ed388eee2b956ba992dbd2d36d81fc4a4a771b7048b732a2e36ceea2f57448216ef566c6b7091c801fbc133089d9612f76d94401ffd0199edd399f9db0beaf3e8a611d45859f57a4fec068477f3a45544119f5202eaf32ee3796401d6319ec7b1bf692810f222415114ff3e773663df797c88d6e11b094c4ffc6694581d843dbfbe8ba130702080000000000000000000000405489000000000010679c000000000030000000000000008fae533ea900dd435a27c7ac6bbe43a78ca53f4ac8bf916accd92a8d374cba94010000000000000010679c0000000000e079af000000000055000000000000006cb63b81a4b11a270dfd91c37eaaed324b1b55999789174218e9990aa186455f0200000000000000e079af0000000000b08cc200000000003a00000000000000e515ec9e690d2bf9be7ce9bd8547b84406445c82682346f3cf84ccb305cf4ae50300000000000000b08cc20000000000809fd500000000005f000000000000008ab19bbbd6173bd1339419c327fadbf9337f7d77aff9e7082ee68da5132756c80400000000000000809fd5000000000050b2e80000000000440000000000000017fca124fd9c32581a3a6f032bc985481cef9852f5f07ba3120fd851511d20ba050000000000000050b2e8000000000020c5fb00000000006900000000000000be2b55d6011931c758ef4700f002f24a947e8e95428049653bc0fc44055b5563060000000000000020c5fb0000000000f0d70e01000000004e0000000000000083da93c67f107a2933b1befbdaaf558a44b2adf41cd3e085f1d48767e8dc3fde0700000000000000f0d70e0100000000c0ea2101000000003300000000000000989f78372ee677d01a1a45c0f4e33989e1e30935ef1f70e0426de38e2cb5ae8a5b8ad24298b11d4afe4acabf254c55d99e1badc4d20a3f293fef3c8e9870e6eb060000000571c24d887ed7ee13662e1b6b5916b05446674f04a48bb9657a88aa791aa4f78c7c1730862c2541afc6faf68f8f1a4b34c99f6c9ea331b8be3a60e97734ac814bcc3352c9690a6b19b9aaa4301286ce746571ec9a1dfd777ce3cdb1ab47ca1ea6e6c44a5d482193b91fac6a4b86aae708a8fe967079b4dc31dbe6e7da840cab0b041b0865ddff65ae4f592bac31b3e3627cb44ed3bfd4678f33dfcdd03c9e684fa04199a174e37753164a4ac950986de9a4abcc3c2cdce827249a9f063289b8012b956ba992dbd2d36d81fc4a4a771b7048b732a2e36ceea2f57448216ef566c600000000000000000000000000000000000000000000000000000000000000008fdd3b426a73d6a2e5ba04781aafc491c5950f29be46baca5bc57f7e54ba95b22dee29ffae444fb5eeee0e941d74e4e71f8dffbe4f45979a1e8c49aebc023c0c" + ], + "logs": "[[{\"v\":1,\"sid\":\"aecd92c612a3b9704723441772d4704e\",\"sn\":0,\"ts_ms\":1792108800500,\"event\":\"start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"aecd92c612a3b9704723441772d4704e\",\"sn\":1,\"ts_ms\":1792108801100,\"event\":\"present\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":{\"unit\":0,\"layers\":1,\"first\":true,\"late_ms\":0,\"decode_us\":900},\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"aecd92c612a3b9704723441772d4704e\",\"sn\":2,\"ts_ms\":1792108822500,\"event\":\"end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null}],[{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":0,\"ts_ms\":1792108800510,\"event\":\"start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":1,\"ts_ms\":1792108801147,\"event\":\"present\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":{\"unit\":0,\"layers\":1,\"first\":true,\"late_ms\":0,\"decode_us\":900},\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":2,\"ts_ms\":1792108805510,\"event\":\"stall-start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":3,\"ts_ms\":1792108805910,\"event\":\"stall-end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":4,\"ts_ms\":1792108822510,\"event\":\"end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null}],[{\"v\":1,\"sid\":\"59b8375b353f73cd469c9ecaf790f7bb\",\"sn\":0,\"ts_ms\":1792108800520,\"event\":\"start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"59b8375b353f73cd469c9ecaf790f7bb\",\"sn\":1,\"ts_ms\":1792108801194,\"event\":\"present\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":{\"unit\":0,\"layers\":1,\"first\":true,\"late_ms\":0,\"decode_us\":900},\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"59b8375b353f73cd469c9ecaf790f7bb\",\"sn\":2,\"ts_ms\":1792108822520,\"event\":\"end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null}]]", + "outcome": "accept", + "digest": "67d2dda10c5159c7cf0f468f27929c5bc3dc58558f9d3db079094dd7906452b2", + "media_tracks": 7, + "records": [ + { + "digest": "61004c6584cec3f49113451b8ba6a04ebf2752df164a3ab7a3c13bb708bbb745", + "size": 1872, + "bound": 2220, + "version": 2, + "dvr_objects_out": 10, + "dvr_bytes_out": 200000, + "dvr_objects_stored": 23, + "dvr_bytes_stored": 460000, + "dvr_byte_s": 3640000, + "dvr_dropped": 1 + }, + { + "digest": "8070e1923387154875aacbeb894c20dc919c95dd4128758b5d47df86652c4a21", + "size": 744, + "bound": 2172, + "version": 1 + } + ], + "units": { + "publisher_us": 80500000, + "subscriber_us": [ + 73200000, + 23600000, + 23600000, + 0, + 0, + 0, + 0, + 0, + 39999000 + ], + "sessions": 8, + "composition_us": 9000000, + "events_sent": 44, + "events_delivered": 240, + "recorded_us": 20000000 + }, + "unmetered": 0, + "totals": [ + { + "class": "OnChipCounter", + "figures": 2, + "joules": 60400000 + } + ], + "coverage": "whole", + "consumption": [ + { + "grant": "0000000000000000000000000000000000000000000000000000000000000000", + "joules_ceiling": null, + "funds_ceiling": null, + "currency": null, + "publisher_us": 0, + "subscriber_us": [ + 3800000, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 39999000 + ], + "composition_us": 0, + "recorded_us": 0, + "events_sent": 0, + "attributed": { + "ModelBased": 0, + "Estimator": 0, + "OnChipCounter": 501839, + "CalibratedInstrument": 0 + }, + "unmetered": false, + "funds": null, + "joules_verdict": "none", + "funds_verdict": "none", + "leaf": "26ddf854e3c064c72059eb449ad4ab4ba74ca6f6d170af258be5e3d36af461cc" + }, + { + "grant": "0c2a9846621d91f9309170b29b95e158e278002ce4cc03a2d097917836a391c0", + "joules_ceiling": null, + "funds_ceiling": null, + "currency": null, + "publisher_us": 0, + "subscriber_us": [ + 23300000, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "composition_us": 0, + "recorded_us": 0, + "events_sent": 1, + "attributed": { + "ModelBased": 0, + "Estimator": 0, + "OnChipCounter": 1448033, + "CalibratedInstrument": 0 + }, + "unmetered": false, + "funds": null, + "joules_verdict": "none", + "funds_verdict": "none", + "leaf": "eb9b15d6fd286d5c20c46f62711ec644fa9adcacf3b254a1f7e8b25f8c98d30e" + }, + { + "grant": "6dde44606fd8377d9fa0809b2b36b708de3e6119d8c1c3d1f0d675917c28c619", + "joules_ceiling": 10000000, + "funds_ceiling": null, + "currency": null, + "publisher_us": 0, + "subscriber_us": [ + 23100000, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "composition_us": 0, + "recorded_us": 0, + "events_sent": 1, + "attributed": { + "ModelBased": 0, + "Estimator": 0, + "OnChipCounter": 1342234, + "CalibratedInstrument": 0 + }, + "unmetered": false, + "funds": null, + "joules_verdict": "within", + "funds_verdict": "none", + "leaf": "e99987059edd4adeeb329348a9e195997543651c60d2124b5e13e1fd9e570415" + }, + { + "grant": "b9091845dcf9a7fc8e2a12e1e1b2f986888b33fb22f1b42b384cdf47ecfc33a0", + "joules_ceiling": null, + "funds_ceiling": null, + "currency": null, + "publisher_us": 23500000, + "subscriber_us": [ + 0, + 23600000, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "composition_us": 9000000, + "recorded_us": 0, + "events_sent": 0, + "attributed": { + "ModelBased": 0, + "Estimator": 0, + "OnChipCounter": 44921810, + "CalibratedInstrument": 0 + }, + "unmetered": false, + "funds": null, + "joules_verdict": "none", + "funds_verdict": "none", + "leaf": "c2be86ffac0564ac6077259144cb214620a39767eeac202105e06b2523afb5a3" + }, + { + "grant": "cf4a5892c8a1bef2afba730a4ec868d0830f9355d10f540b757205945c588cb5", + "joules_ceiling": null, + "funds_ceiling": null, + "currency": null, + "publisher_us": 19000000, + "subscriber_us": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "composition_us": 0, + "recorded_us": 10000000, + "events_sent": 6, + "attributed": { + "ModelBased": 0, + "Estimator": 0, + "OnChipCounter": 282659, + "CalibratedInstrument": 0 + }, + "unmetered": false, + "funds": null, + "joules_verdict": "none", + "funds_verdict": "none", + "leaf": "00f477698dd0a7a7f7b2c9279d4d694debe1041f73287da6aeb094a8daccec9e" + }, + { + "grant": "d1ee853e5c092553edc99a0cc2468a7307afd83533a3a34f401b0695da343314", + "joules_ceiling": null, + "funds_ceiling": null, + "currency": null, + "publisher_us": 19000000, + "subscriber_us": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "composition_us": 0, + "recorded_us": 10000000, + "events_sent": 6, + "attributed": { + "ModelBased": 0, + "Estimator": 0, + "OnChipCounter": 6343696, + "CalibratedInstrument": 0 + }, + "unmetered": false, + "funds": null, + "joules_verdict": "none", + "funds_verdict": "none", + "leaf": "5f81d7c3b30982f738254b2280d82732086252de48179d510043be8d93709513" + }, + { + "grant": "d436e9616073a533831cfbec646020b5d4dcc9d1df4b792ccaff6e7e23ee686a", + "joules_ceiling": null, + "funds_ceiling": null, + "currency": null, + "publisher_us": 19000000, + "subscriber_us": [ + 0, + 0, + 23600000, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "composition_us": 0, + "recorded_us": 0, + "events_sent": 6, + "attributed": { + "ModelBased": 0, + "Estimator": 0, + "OnChipCounter": 4111694, + "CalibratedInstrument": 0 + }, + "unmetered": false, + "funds": null, + "joules_verdict": "none", + "funds_verdict": "none", + "leaf": "326852eefebe587c093919b7a557dc0806c62fb6b952c6099565b11b8829573f" + }, + { + "grant": "e433cf0201e9b443d808e4406925b6a4a90e9963c6235c742a0ecec87a59e478", + "joules_ceiling": null, + "funds_ceiling": 100, + "currency": "EUR", + "publisher_us": 0, + "subscriber_us": [ + 23000000, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "composition_us": 0, + "recorded_us": 0, + "events_sent": 0, + "attributed": { + "ModelBased": 0, + "Estimator": 0, + "OnChipCounter": 1448033, + "CalibratedInstrument": 0 + }, + "unmetered": false, + "funds": 1, + "joules_verdict": "none", + "funds_verdict": "within", + "leaf": "881830c0045f42e80ef1da5399bd6acb723228edc45fb480d8061d410cefc6ac" + } + ], + "consumption_root": "0881532eb7e7e56875380457d076c0dab18316e1f482411f957d3f579d763b49", + "past": [], + "qoe_cell": { + "suppressed": true, + "started": 0, + "start_failed": 0, + "exit_before_start": 0, + "stalls": 0, + "stall_ms": 0, + "engaged_ms": 0, + "vst_count": 0, + "vst_ms": 0 + } + }, { "name": "receipt_bad_signature", "about": "the receipt's signature with one bit flipped", @@ -3297,6 +3667,174 @@ 1 ] }, + { + "name": "dvr_out_exceeds", + "about": "a version 2 relay record whose bytes sent from its DVR store are one more than its out entries' bytes", + "room": "lesson", + "receipt": "574d5231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaa8be2039bc5d06e7cf9d0188c3f22c659f1687b08b69b97f50b134552c46da783000000000000000000366e0100000000028070e1923387154875aacbeb894c20dc919c95dd4128758b5d47df86652c4a21a693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c10280de8002000000000400003a9800000000016e36000197cbf7ac56e5d80a6d1b0992b8b9346d7b53d008fa418cc9e1b374ffa2e2dc6c0a29cf514deebfd5ca029b1d658b198bade764af98e09848c6fd53b129678b720100c31801000000000400004e2000000000016e3600010265b94ec2ec2932104a7583c08050f61547332fe434464d598cad6464025b3040b598d58ceff895b484641dfdea9bf329f40b464895eb31b2271a83af17cce7fb0a000000d2e2268edd1bfc8a0dbebf1f2287b93e200bc8affd02c3e92ac872b3200731a103000000d290530d77935fe173d0d5dcbb2588170f730b423f93543b4f3ceaee06d4e78e2055cc0400000000c0f5220400000000801b680100000000801b680100000000000000000000000000000000000000000000000000000000000000000000000000000000000000001856620200000000070000000000000040548900000000002c00000000000000f000000000000000002d3101000000000001040200000080a1990300000000455552da0a00006d050000da0a0000b41500008d2000000000000000000000000000000000000016010000b41500006d05000064000000080000000d3a5365cdd4a6acda275ad18cbec48f1fae9e6f3af5513449d166bffeee68950000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f4eb6f34204e10eaf06836568aa19a41805e09007c6c4a5eacba5cd194847eb5428e60f1d87158442ce55bc169980ab3e5cf27965014e522f84fd0b1c20ca200", + "hops": [ + { + "record": "574d4831022c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaa0a29cf514deebfd5ca029b1d658b198bade764af98e09848c6fd53b129678b7201000000000000000000366e010000000000c31801000000000400004e2000000000016e360001110001025980e26a212cf4c098b0f8a1a36a15333c6975497e60c073b9cd8242943b4c01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001025980e26a212cf4c098b0f8a1a36a15333c6975497e60c073b9cd8242943b4c02d80e00000000000000470900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f5939013a02000000000000d80b0d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f5939023a02000000000000d80b0d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001313ea2ca301a5e17a28cb2d4b11014eaed6c09c8bb27b777993c556d712aa84d01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001313ea2ca301a5e17a28cb2d4b11014eaed6c09c8bb27b777993c556d712aa84d02d80e00000000000000470900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f011700000000000000e0040700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f024f00000000000000e01b180000000000030000000000000000000000000000000000000000000000000000000000000003000000000000000195ab21fa90d692e9fe90bade8cd132b0d17d75f25efa1fab73239d188617366501b6030000000000007094000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000195ab21fa90d692e9fe90bade8cd132b0d17d75f25efa1fab73239d188617366502d80e000000000000c05102000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000019d7a22d6b2f60ddeaf9f0b43a147004709d988a73527b65d08e0ea6372f910db013a0200000000000040ca22000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000019d7a22d6b2f60ddeaf9f0b43a147004709d988a73527b65d08e0ea6372f910db023a0200000000000040ca2200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c2c18a1d4c5f9a784336c16e2755ec99f12ba6990cacb81cb23f94aa048d6034013a02000000000000485904000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000011600000000000000781e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000002cd000000000000003cf0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000000172000000000000009885000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000000258000000000000002067000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000080f15c0400000000801b680100000000801b680100000000000000000000000000000000000000000000000000000000000000000000000000000000000000001856620200000000f00000000000000008000000f6cb34442422f6cb031318abae7d1679c18fde412c21a6fc1e06d7126c38c5710a0000000000000015295e00000000001700000000000000e004070000000000c08a37000000000001000000000000003db2a802cbe5d40393536631d6d1a0c3a75d27fcbc59536fd3f8a4318d077c8de932b0f1be052dfa23702a23327d33213d9531644960153300b5e95c0a9ffa0d", + "ledger": "0000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffff000000030000000000000000000000b857040000000000c0fb390000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000018566202000000000c2a9846621d91f9309170b29b95e158e278002ce4cc03a2d097917836a391c0ffffffffffffffffffffffffffffffff000000010000000000000000000000d0870c0000000000a087630100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006dde44606fd8377d9fa0809b2b36b708de3e6119d8c1c3d1f0d675917c28c6198096980000000000ffffffffffffffff000000010000000000000000000000709d0b0000000000607a60010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b9091845dcf9a7fc8e2a12e1e1b2f986888b33fb22f1b42b384cdf47ecfc33a0ffffffffffffffffffffffffffffffff00000001000000e004070000000000c8431200000000000000000000000000801b6801000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cf4a5892c8a1bef2afba730a4ec868d0830f9355d10f540b757205945c588cb5ffffffffffffffffffffffffffffffff000000010000002c720200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d1ee853e5c092553edc99a0cc2468a7307afd83533a3a34f401b0695da343314ffffffffffffffffffffffffffffffff0000000100000020e53600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d436e9616073a533831cfbec646020b5d4dcc9d1df4b792ccaff6e7e23ee686affffffffffffffffffffffffffffffff0000000100000014b400000000000084e022000000000000000000000000000000000000000000801b680100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e433cf0201e9b443d808e4406925b6a4a90e9963c6235c742a0ecec87a59e478ffffffffffffffff6400000000000000455552010000000000000000000000d0870c0000000000c0f35e010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "record": "574d4831012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c1020000000000000000801b68010000000080de8002000000000400003a9800000000016e360001050001025980e26a212cf4c098b0f8a1a36a15333c6975497e60c073b9cd8242943b4c01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f5939013a02000000000000d80b0d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001313ea2ca301a5e17a28cb2d4b11014eaed6c09c8bb27b777993c556d712aa84d01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f021700000000000000e004070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000195ab21fa90d692e9fe90bade8cd132b0d17d75f25efa1fab73239d188617366501b60300000000000070940000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000b2083f6a8d3e1c901b93bfb87f5ffe83344214430e91d08f4e422c5ac7f0c0869f25a4e826f34f770f8f63bcea36ec697bbc398f81c06aa8ad11bb1f7a33323de1911cf31bd046195483ef8a8b22a5778adb66cfde02d9e8f8e46360a5577b06", + "ledger": "b9091845dcf9a7fc8e2a12e1e1b2f986888b33fb22f1b42b384cdf47ecfc33a0ffffffffffffffffffffffffffffffff00000001000000c843120000000000e004070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + ], + "descriptors": [ + "57434431012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaad6b5a6849f61a8bbd9d4db526a9fc1c73ef949590cdcd9b0b65ce6375262d9bd0000000000000000a693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c10005d00201101010010108000100086665617475726564056f72646572000000000000000000000280bb0000046761696e0370616e0c0c13afc5df1dffed9f80c566f13be392cc90ad1ceba5db69473a7993f8a7ff2e64cb5d094b2fdf651392e4a2d4f317cc6a436ab9b0cd5afe626493549677937002" + ], + "compositions": [ + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f000127a00000000008a826f7851f7a89f5ecf581141292c7f69e9d004ed08b01d25dd3c66e30f43f602540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f08000000000000000000000000000000c264cd78485650e03db23e16774c0566ac52d21be1b9edacaad39fa34dc3100c01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593908000000000000000000000000000000e43f0568538eef406fb5ed7901b131c0b789d9c0fdb04d61d590af9bec169a1dd2ed35929ea1d408e2da06ee1c840bacab38929016f1a7ba820a80472485c9c6159b98672266a380458e260007c2baec97749e4941eb6ac370d1660fb0e5f900", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f04054890000000000ee27588426463e6a765b4b8b5791670a7ad61be12ef1995102f9220928d6038d02540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f090000000000000000000000000000005e05448cb224cb6aa2726e4c0fe448b127e032169bd2ffea55b4693d53ef487f01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593909000000000000000000000000000000f993d88a6b1a587e0980017103aa4fcdbdf1d7b1c948f95a8bfa36a26493bb1e89b7c717020a8a296d3688d7988d8f2e70e54228d60c58514564a12937b2bb9421199fdee27ccf49acb52a32fa0f698b3eb5b28c35c47a797ae5e7d531f7db0a", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f08096980000000000d8bb5ef530c1fe8d1a0478d5ae231aac802d9f99dff906a66aa111420948048a02540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0a000000000000000000000000000000539e2bb145135d7de5b00ebf1b1178f83ef60d1b5ceedabf98bbb424a1d0a96301e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390a00000000000000000000000000000031a3cdcd31f70593af11aaf7bc4d22cc1ec5c18ee0d918fb2edd5a9c98b5a4758e74da34c2fb00e235278a3010c44dac99131fa49acc910e04f3929e7a6014833b553fad2cd31d127ad862b76999324556e76388192020c8022db9db6cafb803", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0c0d8a70000000000c65182e1c2868d42d9c1a244545f3c3416b14e481ade5c42b1979419f360b3e502540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0b000000000000000000000000000000491fe4bc5c4bafa0066ec9c2baf4b4287fcd09a778e36d5996a97ab827b7eaae01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390b000000000000000000000000000000414a7eb6f2353358646d5b861979c533b344cc01fb9dafdb9d6518c4ad7e3f6f01a03c915891dac04736b7858fdf34c4137e10f593ab5cb3a64900914b360f6b242ee3ead8c5499f325411942dbe827bd51176f2b049c4453a654c49dc701b09", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0001bb70000000000381618c2dfecc2dea3881cb1d4ed69e43dec01f07ebda92e08cd08ff935ed44502540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0c0000000000000000000000000000001915289342627f7f5a8bf4ccaf04c0418735ae65e41ca8692ebd3fca27ea764b01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390c000000000000000000000000000000194d05dbb7218051137b7537fcb320653e7f67071a19ae363969aee47cf08bede3b8b1012f1fc03a132f8793e0eae4389c23da524a7f6482a1d5cdf26ff04249cb81d4f19d8b164ec8eb381c66ba2704c57c7bb6f2389114f14bbfd632199302", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0405dc600000000007629d9fb8d6ac9e846d7ebdbe0a3a64b29ad51f7e4db40be5f96e447abcf6f0202540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0d000000000000000000000000000000f98b3489983bd0476673dc05db4f09c092411512ed2da6578e9c254eee0d72f501e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390d000000000000000000000000000000bc87dab2315f8ea4f21a4b5adcb838b8d89f6d67f93bbdf5bf175069ad81fa5d123e5e3721d1b781cfcc4650a2d571df1142f2160b377be26b692636e885cd037553b9041ac8f13b1f2064568957993ff69556b8673f0ad6e6caafd8b4887c07", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0809fd50000000000d32fbadccdb9742ffff99344fe07069e2e89b1d7f2d99859ad48336fadacd7c202540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0e00000000000000000000000000000053c09229456148744a940ba828e839eca0cf46d7c15e2c3533b4c65dcb0b4ff701e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390e000000000000000000000000000000b5702d26fbc4708196f576c1d46b7fc92471154fe81689707069bcb43173ecedc36859b37258c17a4294d56d36befaa9fbd975ce62e5deb542372aef4c9240b43ba584fc98e7c5a5066e1ba4060e96ca4e50daa116366c3aca9dfccef285f405", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0c0e1e40000000000fe93ba9f3d79ff2d3aeb01726ac4728adf60676494fbd0e596f5972be5f4639102540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0f000000000000000000000000000000341b9ec871ad41102a671b153ccd014f32cfe167bf5cca3989470a574196d0a401e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390f0000000000000000000000000000001eafd27ec73cf8676b02ebb4fa82a5bc67b2298a442322797db8cd996f8866d7bfdca1dadbfc723c27a3b6963a74c78570a41403a9cb454643631157e8bddbda8ccd54062aeec216b94406595aeb140b59afd5e697733f45980831357c0bc804", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f00024f4000000000092a195ef7c5e97512b5eed1dd778db0bd1f1c75cf8cf25b61ec800296e459e6302540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f10000000000000000000000000000000c693ed9bd4639c29ceb962b071e0531512929a139ebed7c5e12989d28b316ddc01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593910000000000000000000000000000000c5043e90fe94a06a8b47242bb084c7dc5f8e46aebbe9b2c5a8db30d44d1a8b55ab981922c453fc2c78b58f7ec56293bb26d02c335079fdd1e246e33607ef473f513bd939cfe20da8b94af0963156c7218be49ffd1efbc9c14429fddf91bf5006", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f04066030100000000d00e25bb474153c30c4bf88f0fb890a0dd10342388c0be87aa93399d54bbaf5502540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f110000000000000000000000000000005f50d92c07b2f57953a701633e9d59575664bfe9b13644d5db11c5d04629b08c01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593911000000000000000000000000000000339aa736ed16c43427079481a1b58bb9d370f104bbcba7a9d915ba1528b1a75da2dabf39b4d5d2518a8bef4340b8c5e7b32250ac222cc3e8648a42730b81eeac78da15f7fa7f42f7bb131311eb22aa7f89ada8940b2dd41c18c113a694a50808" + ], + "takes": [ + "57544d31012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaae57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f559425465004500032d6893f980a7dad483af70b4e116a10b7caa4e2052593699b2c4d5251f98cf34d11f86821c2cd542f6bbf5152a07ad54b271f65b25309018a0bf0e356fbe70268d3166f7ac16582f4cde87f1ce46d109e4bd0562c3d436da0f222415114ff3e773663df797c88d6e11b094c4ffc6694581d843dbfbe8ba1307020a0000000000000000000000807f612a01000000c0c1702a01000000300000000000000037090d56c333ad6c4392ffbb8f80b7854c7ae3d562e182d6b76685f99db2ec1f0100000000000000c0c1702a010000000004802a0100000055000000000000003b9b3da8c3121692dbe9b75ff7b37a77773cf7d71d8b7c885253d5b3855c53b902000000000000000004802a0100000040468f2a010000003a0000000000000097f5ee9829746c16c29946a44489846e4a385eb6d62804ba22a18a99a92642ab030000000000000040468f2a0100000080889e2a010000005f000000000000004bb0df0483b6370199dc9196f695ec65b029d8ffb09dfd7ec24bf5d5c59a72f3040000000000000080889e2a01000000c0caad2a010000004400000000000000733b22d5d80b9a80443b60a14334000a8ab43f139364fa9cd3a547e8a7c523640500000000000000c0caad2a01000000000dbd2a0100000069000000000000009395a39fcaaab116f14a5e4f1e51abe694d712e0f912827045471c6283730a480600000000000000000dbd2a01000000404fcc2a010000004e000000000000002b869dfefc2fe0873e06d23c09f1629470a788c6b8f2aa8e6aafb1298cd73fe60700000000000000404fcc2a010000008091db2a0100000033000000000000009f10cb7a3feccf63f5c0c398375eaf998628d314dc8159d15845fe5a0f8a006b08000000000000008091db2a01000000c0d3ea2a0100000058000000000000008b03281ec96d96d0e76e48ef184b7a60d4856eddf77629af961fe54746404ef60900000000000000c0d3ea2a010000000016fa2a010000003d000000000000001fb42eb87b4ea4dc72d02c9e6ea82aab91be4bbef122892b8921e2f552a462678883ea090e9f374f91bacaac0864af7b6dafa57c4f8a3ac0040ec7fc669f1f2506000000d7b83f99f213619227fbccadf7069c3f6cffca8e86f2aba6ffb8d15212698fa51be10b08c6e09a1b9314f45c65bf3c9b6bd507cfa5df3f013d50657245ee48c5602f2236c9c17b8a1cca6bae144f8f162627d27095a7256e1c5b8bc199c84fb3d535c9375be68b107355dcfeaab2a78192ba09570642f09de57b7bab1438eadc703cab1c2158dee2277fea9a9b909df1f7926a3a56996b7db1bfbaaba40ffa2970412cc3dc8306ba8f88774f1a622d1bf21b9ef51fb85b990d279f66fed977340159425465004500032d6893f980a7dad483af70b4e116a10b7caa4e205259369900000000000000000000000000000000000000000000000000000000000000001f250bafd7daed705bd36f8138b7419700ffe4c20991a2a46993dfd25b592a3643977ddbb511d2b51471498d51135c1a724a069a507032f2a3ff93aaa9787f0d", + "57544d31012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaa7d3b3098d82fddc8585faa3e84b8f2c13cb7fc723a01bfb8bbacfba4ed388eee2b956ba992dbd2d36d81fc4a4a771b7048b732a2e36ceea2f57448216ef566c6b7091c801fbc133089d9612f76d94401ffd0199edd399f9db0beaf3e8a611d45859f57a4fec068477f3a45544119f5202eaf32ee3796401d6319ec7b1bf692810f222415114ff3e773663df797c88d6e11b094c4ffc6694581d843dbfbe8ba130702080000000000000000000000405489000000000010679c000000000030000000000000008fae533ea900dd435a27c7ac6bbe43a78ca53f4ac8bf916accd92a8d374cba94010000000000000010679c0000000000e079af000000000055000000000000006cb63b81a4b11a270dfd91c37eaaed324b1b55999789174218e9990aa186455f0200000000000000e079af0000000000b08cc200000000003a00000000000000e515ec9e690d2bf9be7ce9bd8547b84406445c82682346f3cf84ccb305cf4ae50300000000000000b08cc20000000000809fd500000000005f000000000000008ab19bbbd6173bd1339419c327fadbf9337f7d77aff9e7082ee68da5132756c80400000000000000809fd5000000000050b2e80000000000440000000000000017fca124fd9c32581a3a6f032bc985481cef9852f5f07ba3120fd851511d20ba050000000000000050b2e8000000000020c5fb00000000006900000000000000be2b55d6011931c758ef4700f002f24a947e8e95428049653bc0fc44055b5563060000000000000020c5fb0000000000f0d70e01000000004e0000000000000083da93c67f107a2933b1befbdaaf558a44b2adf41cd3e085f1d48767e8dc3fde0700000000000000f0d70e0100000000c0ea2101000000003300000000000000989f78372ee677d01a1a45c0f4e33989e1e30935ef1f70e0426de38e2cb5ae8a5b8ad24298b11d4afe4acabf254c55d99e1badc4d20a3f293fef3c8e9870e6eb060000000571c24d887ed7ee13662e1b6b5916b05446674f04a48bb9657a88aa791aa4f78c7c1730862c2541afc6faf68f8f1a4b34c99f6c9ea331b8be3a60e97734ac814bcc3352c9690a6b19b9aaa4301286ce746571ec9a1dfd777ce3cdb1ab47ca1ea6e6c44a5d482193b91fac6a4b86aae708a8fe967079b4dc31dbe6e7da840cab0b041b0865ddff65ae4f592bac31b3e3627cb44ed3bfd4678f33dfcdd03c9e684fa04199a174e37753164a4ac950986de9a4abcc3c2cdce827249a9f063289b8012b956ba992dbd2d36d81fc4a4a771b7048b732a2e36ceea2f57448216ef566c600000000000000000000000000000000000000000000000000000000000000008fdd3b426a73d6a2e5ba04781aafc491c5950f29be46baca5bc57f7e54ba95b22dee29ffae444fb5eeee0e941d74e4e71f8dffbe4f45979a1e8c49aebc023c0c" + ], + "logs": "[[{\"v\":1,\"sid\":\"aecd92c612a3b9704723441772d4704e\",\"sn\":0,\"ts_ms\":1792108800500,\"event\":\"start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"aecd92c612a3b9704723441772d4704e\",\"sn\":1,\"ts_ms\":1792108801100,\"event\":\"present\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":{\"unit\":0,\"layers\":1,\"first\":true,\"late_ms\":0,\"decode_us\":900},\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"aecd92c612a3b9704723441772d4704e\",\"sn\":2,\"ts_ms\":1792108822500,\"event\":\"end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null}],[{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":0,\"ts_ms\":1792108800510,\"event\":\"start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":1,\"ts_ms\":1792108801147,\"event\":\"present\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":{\"unit\":0,\"layers\":1,\"first\":true,\"late_ms\":0,\"decode_us\":900},\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":2,\"ts_ms\":1792108805510,\"event\":\"stall-start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":3,\"ts_ms\":1792108805910,\"event\":\"stall-end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":4,\"ts_ms\":1792108822510,\"event\":\"end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null}],[{\"v\":1,\"sid\":\"59b8375b353f73cd469c9ecaf790f7bb\",\"sn\":0,\"ts_ms\":1792108800520,\"event\":\"start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"59b8375b353f73cd469c9ecaf790f7bb\",\"sn\":1,\"ts_ms\":1792108801194,\"event\":\"present\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":{\"unit\":0,\"layers\":1,\"first\":true,\"late_ms\":0,\"decode_us\":900},\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"59b8375b353f73cd469c9ecaf790f7bb\",\"sn\":2,\"ts_ms\":1792108822520,\"event\":\"end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null}]]", + "outcome": "refuse", + "reason": "record_invalid", + "at": [ + "hop", + 1 + ] + }, + { + "name": "dvr_stored_exceeds", + "about": "a version 2 relay record whose objects written to its DVR store are one more than its in entries' objects", + "room": "lesson", + "receipt": "574d5231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaa8be2039bc5d06e7cf9d0188c3f22c659f1687b08b69b97f50b134552c46da783000000000000000000366e0100000000028070e1923387154875aacbeb894c20dc919c95dd4128758b5d47df86652c4a21a693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c10280de8002000000000400003a9800000000016e360001935c7d68341ea266098c2ea4016ffcf7b6a5b7a6b97402ce0dfdf43f79c0acc40a29cf514deebfd5ca029b1d658b198bade764af98e09848c6fd53b129678b720100c31801000000000400004e2000000000016e3600010265b94ec2ec2932104a7583c08050f61547332fe434464d598cad6464025b3040b598d58ceff895b484641dfdea9bf329f40b464895eb31b2271a83af17cce7fb0a000000d2e2268edd1bfc8a0dbebf1f2287b93e200bc8affd02c3e92ac872b3200731a103000000d290530d77935fe173d0d5dcbb2588170f730b423f93543b4f3ceaee06d4e78e2055cc0400000000c0f5220400000000801b680100000000801b680100000000000000000000000000000000000000000000000000000000000000000000000000000000000000001856620200000000070000000000000040548900000000002c00000000000000f000000000000000002d3101000000000001040200000080a1990300000000455552da0a00006d050000da0a0000b41500008d2000000000000000000000000000000000000016010000b41500006d05000064000000080000000d3a5365cdd4a6acda275ad18cbec48f1fae9e6f3af5513449d166bffeee6895000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000028d65ccd73d54c216d2ce0171ffd84b791066423fa504f1f0036492d7baef291d2c994556a6f6ff84520464e7aa045c16b78f605333d9d82f116a657174db605", + "hops": [ + { + "record": "574d4831022c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaa0a29cf514deebfd5ca029b1d658b198bade764af98e09848c6fd53b129678b7201000000000000000000366e010000000000c31801000000000400004e2000000000016e360001110001025980e26a212cf4c098b0f8a1a36a15333c6975497e60c073b9cd8242943b4c01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001025980e26a212cf4c098b0f8a1a36a15333c6975497e60c073b9cd8242943b4c02d80e00000000000000470900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f5939013a02000000000000d80b0d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f5939023a02000000000000d80b0d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001313ea2ca301a5e17a28cb2d4b11014eaed6c09c8bb27b777993c556d712aa84d01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001313ea2ca301a5e17a28cb2d4b11014eaed6c09c8bb27b777993c556d712aa84d02d80e00000000000000470900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f011700000000000000e0040700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f024f00000000000000e01b180000000000030000000000000000000000000000000000000000000000000000000000000003000000000000000195ab21fa90d692e9fe90bade8cd132b0d17d75f25efa1fab73239d188617366501b6030000000000007094000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000195ab21fa90d692e9fe90bade8cd132b0d17d75f25efa1fab73239d188617366502d80e000000000000c05102000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000019d7a22d6b2f60ddeaf9f0b43a147004709d988a73527b65d08e0ea6372f910db013a0200000000000040ca22000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000019d7a22d6b2f60ddeaf9f0b43a147004709d988a73527b65d08e0ea6372f910db023a0200000000000040ca2200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c2c18a1d4c5f9a784336c16e2755ec99f12ba6990cacb81cb23f94aa048d6034013a02000000000000485904000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000011600000000000000781e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000002cd000000000000003cf0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000000172000000000000009885000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000000258000000000000002067000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000080f15c0400000000801b680100000000801b680100000000000000000000000000000000000000000000000000000000000000000000000000000000000000001856620200000000f00000000000000008000000f6cb34442422f6cb031318abae7d1679c18fde412c21a6fc1e06d7126c38c5710a00000000000000400d0300000000007012000000000000e004070000000000c08a370000000000010000000000000093d0c7dc7dca29a1dbf01476c12ae082bc50c3c286ee0282c76e46eb9b7732758bc45471b857b1b898478fa9c5ec4b86b81980c88ff49ce0093be81dbd25250e", + "ledger": "0000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffff000000030000000000000000000000b857040000000000c0fb390000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000018566202000000000c2a9846621d91f9309170b29b95e158e278002ce4cc03a2d097917836a391c0ffffffffffffffffffffffffffffffff000000010000000000000000000000d0870c0000000000a087630100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006dde44606fd8377d9fa0809b2b36b708de3e6119d8c1c3d1f0d675917c28c6198096980000000000ffffffffffffffff000000010000000000000000000000709d0b0000000000607a60010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b9091845dcf9a7fc8e2a12e1e1b2f986888b33fb22f1b42b384cdf47ecfc33a0ffffffffffffffffffffffffffffffff00000001000000e004070000000000c8431200000000000000000000000000801b6801000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cf4a5892c8a1bef2afba730a4ec868d0830f9355d10f540b757205945c588cb5ffffffffffffffffffffffffffffffff000000010000002c720200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d1ee853e5c092553edc99a0cc2468a7307afd83533a3a34f401b0695da343314ffffffffffffffffffffffffffffffff0000000100000020e53600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d436e9616073a533831cfbec646020b5d4dcc9d1df4b792ccaff6e7e23ee686affffffffffffffffffffffffffffffff0000000100000014b400000000000084e022000000000000000000000000000000000000000000801b680100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e433cf0201e9b443d808e4406925b6a4a90e9963c6235c742a0ecec87a59e478ffffffffffffffff6400000000000000455552010000000000000000000000d0870c0000000000c0f35e010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "record": "574d4831012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c1020000000000000000801b68010000000080de8002000000000400003a9800000000016e360001050001025980e26a212cf4c098b0f8a1a36a15333c6975497e60c073b9cd8242943b4c01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f5939013a02000000000000d80b0d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001313ea2ca301a5e17a28cb2d4b11014eaed6c09c8bb27b777993c556d712aa84d01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f021700000000000000e004070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000195ab21fa90d692e9fe90bade8cd132b0d17d75f25efa1fab73239d188617366501b60300000000000070940000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000b2083f6a8d3e1c901b93bfb87f5ffe83344214430e91d08f4e422c5ac7f0c0869f25a4e826f34f770f8f63bcea36ec697bbc398f81c06aa8ad11bb1f7a33323de1911cf31bd046195483ef8a8b22a5778adb66cfde02d9e8f8e46360a5577b06", + "ledger": "b9091845dcf9a7fc8e2a12e1e1b2f986888b33fb22f1b42b384cdf47ecfc33a0ffffffffffffffffffffffffffffffff00000001000000c843120000000000e004070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + ], + "descriptors": [ + "57434431012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaad6b5a6849f61a8bbd9d4db526a9fc1c73ef949590cdcd9b0b65ce6375262d9bd0000000000000000a693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c10005d00201101010010108000100086665617475726564056f72646572000000000000000000000280bb0000046761696e0370616e0c0c13afc5df1dffed9f80c566f13be392cc90ad1ceba5db69473a7993f8a7ff2e64cb5d094b2fdf651392e4a2d4f317cc6a436ab9b0cd5afe626493549677937002" + ], + "compositions": [ + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f000127a00000000008a826f7851f7a89f5ecf581141292c7f69e9d004ed08b01d25dd3c66e30f43f602540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f08000000000000000000000000000000c264cd78485650e03db23e16774c0566ac52d21be1b9edacaad39fa34dc3100c01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593908000000000000000000000000000000e43f0568538eef406fb5ed7901b131c0b789d9c0fdb04d61d590af9bec169a1dd2ed35929ea1d408e2da06ee1c840bacab38929016f1a7ba820a80472485c9c6159b98672266a380458e260007c2baec97749e4941eb6ac370d1660fb0e5f900", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f04054890000000000ee27588426463e6a765b4b8b5791670a7ad61be12ef1995102f9220928d6038d02540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f090000000000000000000000000000005e05448cb224cb6aa2726e4c0fe448b127e032169bd2ffea55b4693d53ef487f01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593909000000000000000000000000000000f993d88a6b1a587e0980017103aa4fcdbdf1d7b1c948f95a8bfa36a26493bb1e89b7c717020a8a296d3688d7988d8f2e70e54228d60c58514564a12937b2bb9421199fdee27ccf49acb52a32fa0f698b3eb5b28c35c47a797ae5e7d531f7db0a", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f08096980000000000d8bb5ef530c1fe8d1a0478d5ae231aac802d9f99dff906a66aa111420948048a02540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0a000000000000000000000000000000539e2bb145135d7de5b00ebf1b1178f83ef60d1b5ceedabf98bbb424a1d0a96301e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390a00000000000000000000000000000031a3cdcd31f70593af11aaf7bc4d22cc1ec5c18ee0d918fb2edd5a9c98b5a4758e74da34c2fb00e235278a3010c44dac99131fa49acc910e04f3929e7a6014833b553fad2cd31d127ad862b76999324556e76388192020c8022db9db6cafb803", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0c0d8a70000000000c65182e1c2868d42d9c1a244545f3c3416b14e481ade5c42b1979419f360b3e502540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0b000000000000000000000000000000491fe4bc5c4bafa0066ec9c2baf4b4287fcd09a778e36d5996a97ab827b7eaae01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390b000000000000000000000000000000414a7eb6f2353358646d5b861979c533b344cc01fb9dafdb9d6518c4ad7e3f6f01a03c915891dac04736b7858fdf34c4137e10f593ab5cb3a64900914b360f6b242ee3ead8c5499f325411942dbe827bd51176f2b049c4453a654c49dc701b09", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0001bb70000000000381618c2dfecc2dea3881cb1d4ed69e43dec01f07ebda92e08cd08ff935ed44502540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0c0000000000000000000000000000001915289342627f7f5a8bf4ccaf04c0418735ae65e41ca8692ebd3fca27ea764b01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390c000000000000000000000000000000194d05dbb7218051137b7537fcb320653e7f67071a19ae363969aee47cf08bede3b8b1012f1fc03a132f8793e0eae4389c23da524a7f6482a1d5cdf26ff04249cb81d4f19d8b164ec8eb381c66ba2704c57c7bb6f2389114f14bbfd632199302", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0405dc600000000007629d9fb8d6ac9e846d7ebdbe0a3a64b29ad51f7e4db40be5f96e447abcf6f0202540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0d000000000000000000000000000000f98b3489983bd0476673dc05db4f09c092411512ed2da6578e9c254eee0d72f501e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390d000000000000000000000000000000bc87dab2315f8ea4f21a4b5adcb838b8d89f6d67f93bbdf5bf175069ad81fa5d123e5e3721d1b781cfcc4650a2d571df1142f2160b377be26b692636e885cd037553b9041ac8f13b1f2064568957993ff69556b8673f0ad6e6caafd8b4887c07", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0809fd50000000000d32fbadccdb9742ffff99344fe07069e2e89b1d7f2d99859ad48336fadacd7c202540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0e00000000000000000000000000000053c09229456148744a940ba828e839eca0cf46d7c15e2c3533b4c65dcb0b4ff701e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390e000000000000000000000000000000b5702d26fbc4708196f576c1d46b7fc92471154fe81689707069bcb43173ecedc36859b37258c17a4294d56d36befaa9fbd975ce62e5deb542372aef4c9240b43ba584fc98e7c5a5066e1ba4060e96ca4e50daa116366c3aca9dfccef285f405", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0c0e1e40000000000fe93ba9f3d79ff2d3aeb01726ac4728adf60676494fbd0e596f5972be5f4639102540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0f000000000000000000000000000000341b9ec871ad41102a671b153ccd014f32cfe167bf5cca3989470a574196d0a401e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390f0000000000000000000000000000001eafd27ec73cf8676b02ebb4fa82a5bc67b2298a442322797db8cd996f8866d7bfdca1dadbfc723c27a3b6963a74c78570a41403a9cb454643631157e8bddbda8ccd54062aeec216b94406595aeb140b59afd5e697733f45980831357c0bc804", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f00024f4000000000092a195ef7c5e97512b5eed1dd778db0bd1f1c75cf8cf25b61ec800296e459e6302540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f10000000000000000000000000000000c693ed9bd4639c29ceb962b071e0531512929a139ebed7c5e12989d28b316ddc01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593910000000000000000000000000000000c5043e90fe94a06a8b47242bb084c7dc5f8e46aebbe9b2c5a8db30d44d1a8b55ab981922c453fc2c78b58f7ec56293bb26d02c335079fdd1e246e33607ef473f513bd939cfe20da8b94af0963156c7218be49ffd1efbc9c14429fddf91bf5006", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f04066030100000000d00e25bb474153c30c4bf88f0fb890a0dd10342388c0be87aa93399d54bbaf5502540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f110000000000000000000000000000005f50d92c07b2f57953a701633e9d59575664bfe9b13644d5db11c5d04629b08c01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593911000000000000000000000000000000339aa736ed16c43427079481a1b58bb9d370f104bbcba7a9d915ba1528b1a75da2dabf39b4d5d2518a8bef4340b8c5e7b32250ac222cc3e8648a42730b81eeac78da15f7fa7f42f7bb131311eb22aa7f89ada8940b2dd41c18c113a694a50808" + ], + "takes": [ + "57544d31012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaae57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f559425465004500032d6893f980a7dad483af70b4e116a10b7caa4e2052593699b2c4d5251f98cf34d11f86821c2cd542f6bbf5152a07ad54b271f65b25309018a0bf0e356fbe70268d3166f7ac16582f4cde87f1ce46d109e4bd0562c3d436da0f222415114ff3e773663df797c88d6e11b094c4ffc6694581d843dbfbe8ba1307020a0000000000000000000000807f612a01000000c0c1702a01000000300000000000000037090d56c333ad6c4392ffbb8f80b7854c7ae3d562e182d6b76685f99db2ec1f0100000000000000c0c1702a010000000004802a0100000055000000000000003b9b3da8c3121692dbe9b75ff7b37a77773cf7d71d8b7c885253d5b3855c53b902000000000000000004802a0100000040468f2a010000003a0000000000000097f5ee9829746c16c29946a44489846e4a385eb6d62804ba22a18a99a92642ab030000000000000040468f2a0100000080889e2a010000005f000000000000004bb0df0483b6370199dc9196f695ec65b029d8ffb09dfd7ec24bf5d5c59a72f3040000000000000080889e2a01000000c0caad2a010000004400000000000000733b22d5d80b9a80443b60a14334000a8ab43f139364fa9cd3a547e8a7c523640500000000000000c0caad2a01000000000dbd2a0100000069000000000000009395a39fcaaab116f14a5e4f1e51abe694d712e0f912827045471c6283730a480600000000000000000dbd2a01000000404fcc2a010000004e000000000000002b869dfefc2fe0873e06d23c09f1629470a788c6b8f2aa8e6aafb1298cd73fe60700000000000000404fcc2a010000008091db2a0100000033000000000000009f10cb7a3feccf63f5c0c398375eaf998628d314dc8159d15845fe5a0f8a006b08000000000000008091db2a01000000c0d3ea2a0100000058000000000000008b03281ec96d96d0e76e48ef184b7a60d4856eddf77629af961fe54746404ef60900000000000000c0d3ea2a010000000016fa2a010000003d000000000000001fb42eb87b4ea4dc72d02c9e6ea82aab91be4bbef122892b8921e2f552a462678883ea090e9f374f91bacaac0864af7b6dafa57c4f8a3ac0040ec7fc669f1f2506000000d7b83f99f213619227fbccadf7069c3f6cffca8e86f2aba6ffb8d15212698fa51be10b08c6e09a1b9314f45c65bf3c9b6bd507cfa5df3f013d50657245ee48c5602f2236c9c17b8a1cca6bae144f8f162627d27095a7256e1c5b8bc199c84fb3d535c9375be68b107355dcfeaab2a78192ba09570642f09de57b7bab1438eadc703cab1c2158dee2277fea9a9b909df1f7926a3a56996b7db1bfbaaba40ffa2970412cc3dc8306ba8f88774f1a622d1bf21b9ef51fb85b990d279f66fed977340159425465004500032d6893f980a7dad483af70b4e116a10b7caa4e205259369900000000000000000000000000000000000000000000000000000000000000001f250bafd7daed705bd36f8138b7419700ffe4c20991a2a46993dfd25b592a3643977ddbb511d2b51471498d51135c1a724a069a507032f2a3ff93aaa9787f0d", + "57544d31012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaa7d3b3098d82fddc8585faa3e84b8f2c13cb7fc723a01bfb8bbacfba4ed388eee2b956ba992dbd2d36d81fc4a4a771b7048b732a2e36ceea2f57448216ef566c6b7091c801fbc133089d9612f76d94401ffd0199edd399f9db0beaf3e8a611d45859f57a4fec068477f3a45544119f5202eaf32ee3796401d6319ec7b1bf692810f222415114ff3e773663df797c88d6e11b094c4ffc6694581d843dbfbe8ba130702080000000000000000000000405489000000000010679c000000000030000000000000008fae533ea900dd435a27c7ac6bbe43a78ca53f4ac8bf916accd92a8d374cba94010000000000000010679c0000000000e079af000000000055000000000000006cb63b81a4b11a270dfd91c37eaaed324b1b55999789174218e9990aa186455f0200000000000000e079af0000000000b08cc200000000003a00000000000000e515ec9e690d2bf9be7ce9bd8547b84406445c82682346f3cf84ccb305cf4ae50300000000000000b08cc20000000000809fd500000000005f000000000000008ab19bbbd6173bd1339419c327fadbf9337f7d77aff9e7082ee68da5132756c80400000000000000809fd5000000000050b2e80000000000440000000000000017fca124fd9c32581a3a6f032bc985481cef9852f5f07ba3120fd851511d20ba050000000000000050b2e8000000000020c5fb00000000006900000000000000be2b55d6011931c758ef4700f002f24a947e8e95428049653bc0fc44055b5563060000000000000020c5fb0000000000f0d70e01000000004e0000000000000083da93c67f107a2933b1befbdaaf558a44b2adf41cd3e085f1d48767e8dc3fde0700000000000000f0d70e0100000000c0ea2101000000003300000000000000989f78372ee677d01a1a45c0f4e33989e1e30935ef1f70e0426de38e2cb5ae8a5b8ad24298b11d4afe4acabf254c55d99e1badc4d20a3f293fef3c8e9870e6eb060000000571c24d887ed7ee13662e1b6b5916b05446674f04a48bb9657a88aa791aa4f78c7c1730862c2541afc6faf68f8f1a4b34c99f6c9ea331b8be3a60e97734ac814bcc3352c9690a6b19b9aaa4301286ce746571ec9a1dfd777ce3cdb1ab47ca1ea6e6c44a5d482193b91fac6a4b86aae708a8fe967079b4dc31dbe6e7da840cab0b041b0865ddff65ae4f592bac31b3e3627cb44ed3bfd4678f33dfcdd03c9e684fa04199a174e37753164a4ac950986de9a4abcc3c2cdce827249a9f063289b8012b956ba992dbd2d36d81fc4a4a771b7048b732a2e36ceea2f57448216ef566c600000000000000000000000000000000000000000000000000000000000000008fdd3b426a73d6a2e5ba04781aafc491c5950f29be46baca5bc57f7e54ba95b22dee29ffae444fb5eeee0e941d74e4e71f8dffbe4f45979a1e8c49aebc023c0c" + ], + "logs": "[[{\"v\":1,\"sid\":\"aecd92c612a3b9704723441772d4704e\",\"sn\":0,\"ts_ms\":1792108800500,\"event\":\"start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"aecd92c612a3b9704723441772d4704e\",\"sn\":1,\"ts_ms\":1792108801100,\"event\":\"present\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":{\"unit\":0,\"layers\":1,\"first\":true,\"late_ms\":0,\"decode_us\":900},\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"aecd92c612a3b9704723441772d4704e\",\"sn\":2,\"ts_ms\":1792108822500,\"event\":\"end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null}],[{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":0,\"ts_ms\":1792108800510,\"event\":\"start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":1,\"ts_ms\":1792108801147,\"event\":\"present\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":{\"unit\":0,\"layers\":1,\"first\":true,\"late_ms\":0,\"decode_us\":900},\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":2,\"ts_ms\":1792108805510,\"event\":\"stall-start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":3,\"ts_ms\":1792108805910,\"event\":\"stall-end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":4,\"ts_ms\":1792108822510,\"event\":\"end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null}],[{\"v\":1,\"sid\":\"59b8375b353f73cd469c9ecaf790f7bb\",\"sn\":0,\"ts_ms\":1792108800520,\"event\":\"start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"59b8375b353f73cd469c9ecaf790f7bb\",\"sn\":1,\"ts_ms\":1792108801194,\"event\":\"present\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":{\"unit\":0,\"layers\":1,\"first\":true,\"late_ms\":0,\"decode_us\":900},\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"59b8375b353f73cd469c9ecaf790f7bb\",\"sn\":2,\"ts_ms\":1792108822520,\"event\":\"end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null}]]", + "outcome": "refuse", + "reason": "record_invalid", + "at": [ + "hop", + 1 + ] + }, + { + "name": "record_unknown_version", + "about": "a relay record of version 3: the DVR relay's with its fifth byte 3, signed again", + "room": "lesson", + "receipt": "574d5231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaa8be2039bc5d06e7cf9d0188c3f22c659f1687b08b69b97f50b134552c46da783000000000000000000366e0100000000028070e1923387154875aacbeb894c20dc919c95dd4128758b5d47df86652c4a21a693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c10280de8002000000000400003a9800000000016e360001e5c18ff362c93132d24eb6bfd66e658fb584a06bb0dd67c0eb699305395b5ccd0a29cf514deebfd5ca029b1d658b198bade764af98e09848c6fd53b129678b720100c31801000000000400004e2000000000016e3600010265b94ec2ec2932104a7583c08050f61547332fe434464d598cad6464025b3040b598d58ceff895b484641dfdea9bf329f40b464895eb31b2271a83af17cce7fb0a000000d2e2268edd1bfc8a0dbebf1f2287b93e200bc8affd02c3e92ac872b3200731a103000000d290530d77935fe173d0d5dcbb2588170f730b423f93543b4f3ceaee06d4e78e2055cc0400000000c0f5220400000000801b680100000000801b680100000000000000000000000000000000000000000000000000000000000000000000000000000000000000001856620200000000070000000000000040548900000000002c00000000000000f000000000000000002d3101000000000001040200000080a1990300000000455552da0a00006d050000da0a0000b41500008d2000000000000000000000000000000000000016010000b41500006d05000064000000080000000d3a5365cdd4a6acda275ad18cbec48f1fae9e6f3af5513449d166bffeee689500000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005e9dbd35803e648d65ca4472604d57614ffe249d13da8adba46066659889fbcc805d7ecf8c4d66a99b17c6f894e97a96466cad3a42d34e4340a57be993848a06", + "hops": [ + { + "record": "574d4831032c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaa0a29cf514deebfd5ca029b1d658b198bade764af98e09848c6fd53b129678b7201000000000000000000366e010000000000c31801000000000400004e2000000000016e360001110001025980e26a212cf4c098b0f8a1a36a15333c6975497e60c073b9cd8242943b4c01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001025980e26a212cf4c098b0f8a1a36a15333c6975497e60c073b9cd8242943b4c02d80e00000000000000470900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f5939013a02000000000000d80b0d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f5939023a02000000000000d80b0d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001313ea2ca301a5e17a28cb2d4b11014eaed6c09c8bb27b777993c556d712aa84d01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001313ea2ca301a5e17a28cb2d4b11014eaed6c09c8bb27b777993c556d712aa84d02d80e00000000000000470900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f011700000000000000e0040700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f024f00000000000000e01b180000000000030000000000000000000000000000000000000000000000000000000000000003000000000000000195ab21fa90d692e9fe90bade8cd132b0d17d75f25efa1fab73239d188617366501b6030000000000007094000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000195ab21fa90d692e9fe90bade8cd132b0d17d75f25efa1fab73239d188617366502d80e000000000000c05102000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000019d7a22d6b2f60ddeaf9f0b43a147004709d988a73527b65d08e0ea6372f910db013a0200000000000040ca22000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000019d7a22d6b2f60ddeaf9f0b43a147004709d988a73527b65d08e0ea6372f910db023a0200000000000040ca2200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c2c18a1d4c5f9a784336c16e2755ec99f12ba6990cacb81cb23f94aa048d6034013a02000000000000485904000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000011600000000000000781e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000002cd000000000000003cf0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000000172000000000000009885000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000000258000000000000002067000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000080f15c0400000000801b680100000000801b680100000000000000000000000000000000000000000000000000000000000000000000000000000000000000001856620200000000f00000000000000008000000f6cb34442422f6cb031318abae7d1679c18fde412c21a6fc1e06d7126c38c5710a00000000000000400d0300000000001700000000000000e004070000000000c08a3700000000000100000000000000029f59a652e57479fda622614c228bf09ec2b9b5994c7e5c7516cf3b3d1b00b09fd38a424ad708c18a4e6dd7c7db8c16123183c48876a81611310e990f8e7a0b", + "ledger": "0000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffff000000030000000000000000000000b857040000000000c0fb390000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000018566202000000000c2a9846621d91f9309170b29b95e158e278002ce4cc03a2d097917836a391c0ffffffffffffffffffffffffffffffff000000010000000000000000000000d0870c0000000000a087630100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006dde44606fd8377d9fa0809b2b36b708de3e6119d8c1c3d1f0d675917c28c6198096980000000000ffffffffffffffff000000010000000000000000000000709d0b0000000000607a60010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b9091845dcf9a7fc8e2a12e1e1b2f986888b33fb22f1b42b384cdf47ecfc33a0ffffffffffffffffffffffffffffffff00000001000000e004070000000000c8431200000000000000000000000000801b6801000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cf4a5892c8a1bef2afba730a4ec868d0830f9355d10f540b757205945c588cb5ffffffffffffffffffffffffffffffff000000010000002c720200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d1ee853e5c092553edc99a0cc2468a7307afd83533a3a34f401b0695da343314ffffffffffffffffffffffffffffffff0000000100000020e53600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d436e9616073a533831cfbec646020b5d4dcc9d1df4b792ccaff6e7e23ee686affffffffffffffffffffffffffffffff0000000100000014b400000000000084e022000000000000000000000000000000000000000000801b680100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e433cf0201e9b443d808e4406925b6a4a90e9963c6235c742a0ecec87a59e478ffffffffffffffff6400000000000000455552010000000000000000000000d0870c0000000000c0f35e010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "record": "574d4831012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c1020000000000000000801b68010000000080de8002000000000400003a9800000000016e360001050001025980e26a212cf4c098b0f8a1a36a15333c6975497e60c073b9cd8242943b4c01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f5939013a02000000000000d80b0d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001313ea2ca301a5e17a28cb2d4b11014eaed6c09c8bb27b777993c556d712aa84d01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f021700000000000000e004070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000195ab21fa90d692e9fe90bade8cd132b0d17d75f25efa1fab73239d188617366501b60300000000000070940000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000b2083f6a8d3e1c901b93bfb87f5ffe83344214430e91d08f4e422c5ac7f0c0869f25a4e826f34f770f8f63bcea36ec697bbc398f81c06aa8ad11bb1f7a33323de1911cf31bd046195483ef8a8b22a5778adb66cfde02d9e8f8e46360a5577b06", + "ledger": "b9091845dcf9a7fc8e2a12e1e1b2f986888b33fb22f1b42b384cdf47ecfc33a0ffffffffffffffffffffffffffffffff00000001000000c843120000000000e004070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + ], + "descriptors": [ + "57434431012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaad6b5a6849f61a8bbd9d4db526a9fc1c73ef949590cdcd9b0b65ce6375262d9bd0000000000000000a693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c10005d00201101010010108000100086665617475726564056f72646572000000000000000000000280bb0000046761696e0370616e0c0c13afc5df1dffed9f80c566f13be392cc90ad1ceba5db69473a7993f8a7ff2e64cb5d094b2fdf651392e4a2d4f317cc6a436ab9b0cd5afe626493549677937002" + ], + "compositions": [ + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f000127a00000000008a826f7851f7a89f5ecf581141292c7f69e9d004ed08b01d25dd3c66e30f43f602540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f08000000000000000000000000000000c264cd78485650e03db23e16774c0566ac52d21be1b9edacaad39fa34dc3100c01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593908000000000000000000000000000000e43f0568538eef406fb5ed7901b131c0b789d9c0fdb04d61d590af9bec169a1dd2ed35929ea1d408e2da06ee1c840bacab38929016f1a7ba820a80472485c9c6159b98672266a380458e260007c2baec97749e4941eb6ac370d1660fb0e5f900", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f04054890000000000ee27588426463e6a765b4b8b5791670a7ad61be12ef1995102f9220928d6038d02540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f090000000000000000000000000000005e05448cb224cb6aa2726e4c0fe448b127e032169bd2ffea55b4693d53ef487f01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593909000000000000000000000000000000f993d88a6b1a587e0980017103aa4fcdbdf1d7b1c948f95a8bfa36a26493bb1e89b7c717020a8a296d3688d7988d8f2e70e54228d60c58514564a12937b2bb9421199fdee27ccf49acb52a32fa0f698b3eb5b28c35c47a797ae5e7d531f7db0a", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f08096980000000000d8bb5ef530c1fe8d1a0478d5ae231aac802d9f99dff906a66aa111420948048a02540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0a000000000000000000000000000000539e2bb145135d7de5b00ebf1b1178f83ef60d1b5ceedabf98bbb424a1d0a96301e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390a00000000000000000000000000000031a3cdcd31f70593af11aaf7bc4d22cc1ec5c18ee0d918fb2edd5a9c98b5a4758e74da34c2fb00e235278a3010c44dac99131fa49acc910e04f3929e7a6014833b553fad2cd31d127ad862b76999324556e76388192020c8022db9db6cafb803", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0c0d8a70000000000c65182e1c2868d42d9c1a244545f3c3416b14e481ade5c42b1979419f360b3e502540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0b000000000000000000000000000000491fe4bc5c4bafa0066ec9c2baf4b4287fcd09a778e36d5996a97ab827b7eaae01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390b000000000000000000000000000000414a7eb6f2353358646d5b861979c533b344cc01fb9dafdb9d6518c4ad7e3f6f01a03c915891dac04736b7858fdf34c4137e10f593ab5cb3a64900914b360f6b242ee3ead8c5499f325411942dbe827bd51176f2b049c4453a654c49dc701b09", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0001bb70000000000381618c2dfecc2dea3881cb1d4ed69e43dec01f07ebda92e08cd08ff935ed44502540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0c0000000000000000000000000000001915289342627f7f5a8bf4ccaf04c0418735ae65e41ca8692ebd3fca27ea764b01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390c000000000000000000000000000000194d05dbb7218051137b7537fcb320653e7f67071a19ae363969aee47cf08bede3b8b1012f1fc03a132f8793e0eae4389c23da524a7f6482a1d5cdf26ff04249cb81d4f19d8b164ec8eb381c66ba2704c57c7bb6f2389114f14bbfd632199302", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0405dc600000000007629d9fb8d6ac9e846d7ebdbe0a3a64b29ad51f7e4db40be5f96e447abcf6f0202540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0d000000000000000000000000000000f98b3489983bd0476673dc05db4f09c092411512ed2da6578e9c254eee0d72f501e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390d000000000000000000000000000000bc87dab2315f8ea4f21a4b5adcb838b8d89f6d67f93bbdf5bf175069ad81fa5d123e5e3721d1b781cfcc4650a2d571df1142f2160b377be26b692636e885cd037553b9041ac8f13b1f2064568957993ff69556b8673f0ad6e6caafd8b4887c07", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0809fd50000000000d32fbadccdb9742ffff99344fe07069e2e89b1d7f2d99859ad48336fadacd7c202540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0e00000000000000000000000000000053c09229456148744a940ba828e839eca0cf46d7c15e2c3533b4c65dcb0b4ff701e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390e000000000000000000000000000000b5702d26fbc4708196f576c1d46b7fc92471154fe81689707069bcb43173ecedc36859b37258c17a4294d56d36befaa9fbd975ce62e5deb542372aef4c9240b43ba584fc98e7c5a5066e1ba4060e96ca4e50daa116366c3aca9dfccef285f405", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0c0e1e40000000000fe93ba9f3d79ff2d3aeb01726ac4728adf60676494fbd0e596f5972be5f4639102540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0f000000000000000000000000000000341b9ec871ad41102a671b153ccd014f32cfe167bf5cca3989470a574196d0a401e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390f0000000000000000000000000000001eafd27ec73cf8676b02ebb4fa82a5bc67b2298a442322797db8cd996f8866d7bfdca1dadbfc723c27a3b6963a74c78570a41403a9cb454643631157e8bddbda8ccd54062aeec216b94406595aeb140b59afd5e697733f45980831357c0bc804", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f00024f4000000000092a195ef7c5e97512b5eed1dd778db0bd1f1c75cf8cf25b61ec800296e459e6302540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f10000000000000000000000000000000c693ed9bd4639c29ceb962b071e0531512929a139ebed7c5e12989d28b316ddc01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593910000000000000000000000000000000c5043e90fe94a06a8b47242bb084c7dc5f8e46aebbe9b2c5a8db30d44d1a8b55ab981922c453fc2c78b58f7ec56293bb26d02c335079fdd1e246e33607ef473f513bd939cfe20da8b94af0963156c7218be49ffd1efbc9c14429fddf91bf5006", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f04066030100000000d00e25bb474153c30c4bf88f0fb890a0dd10342388c0be87aa93399d54bbaf5502540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f110000000000000000000000000000005f50d92c07b2f57953a701633e9d59575664bfe9b13644d5db11c5d04629b08c01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593911000000000000000000000000000000339aa736ed16c43427079481a1b58bb9d370f104bbcba7a9d915ba1528b1a75da2dabf39b4d5d2518a8bef4340b8c5e7b32250ac222cc3e8648a42730b81eeac78da15f7fa7f42f7bb131311eb22aa7f89ada8940b2dd41c18c113a694a50808" + ], + "takes": [ + "57544d31012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaae57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f559425465004500032d6893f980a7dad483af70b4e116a10b7caa4e2052593699b2c4d5251f98cf34d11f86821c2cd542f6bbf5152a07ad54b271f65b25309018a0bf0e356fbe70268d3166f7ac16582f4cde87f1ce46d109e4bd0562c3d436da0f222415114ff3e773663df797c88d6e11b094c4ffc6694581d843dbfbe8ba1307020a0000000000000000000000807f612a01000000c0c1702a01000000300000000000000037090d56c333ad6c4392ffbb8f80b7854c7ae3d562e182d6b76685f99db2ec1f0100000000000000c0c1702a010000000004802a0100000055000000000000003b9b3da8c3121692dbe9b75ff7b37a77773cf7d71d8b7c885253d5b3855c53b902000000000000000004802a0100000040468f2a010000003a0000000000000097f5ee9829746c16c29946a44489846e4a385eb6d62804ba22a18a99a92642ab030000000000000040468f2a0100000080889e2a010000005f000000000000004bb0df0483b6370199dc9196f695ec65b029d8ffb09dfd7ec24bf5d5c59a72f3040000000000000080889e2a01000000c0caad2a010000004400000000000000733b22d5d80b9a80443b60a14334000a8ab43f139364fa9cd3a547e8a7c523640500000000000000c0caad2a01000000000dbd2a0100000069000000000000009395a39fcaaab116f14a5e4f1e51abe694d712e0f912827045471c6283730a480600000000000000000dbd2a01000000404fcc2a010000004e000000000000002b869dfefc2fe0873e06d23c09f1629470a788c6b8f2aa8e6aafb1298cd73fe60700000000000000404fcc2a010000008091db2a0100000033000000000000009f10cb7a3feccf63f5c0c398375eaf998628d314dc8159d15845fe5a0f8a006b08000000000000008091db2a01000000c0d3ea2a0100000058000000000000008b03281ec96d96d0e76e48ef184b7a60d4856eddf77629af961fe54746404ef60900000000000000c0d3ea2a010000000016fa2a010000003d000000000000001fb42eb87b4ea4dc72d02c9e6ea82aab91be4bbef122892b8921e2f552a462678883ea090e9f374f91bacaac0864af7b6dafa57c4f8a3ac0040ec7fc669f1f2506000000d7b83f99f213619227fbccadf7069c3f6cffca8e86f2aba6ffb8d15212698fa51be10b08c6e09a1b9314f45c65bf3c9b6bd507cfa5df3f013d50657245ee48c5602f2236c9c17b8a1cca6bae144f8f162627d27095a7256e1c5b8bc199c84fb3d535c9375be68b107355dcfeaab2a78192ba09570642f09de57b7bab1438eadc703cab1c2158dee2277fea9a9b909df1f7926a3a56996b7db1bfbaaba40ffa2970412cc3dc8306ba8f88774f1a622d1bf21b9ef51fb85b990d279f66fed977340159425465004500032d6893f980a7dad483af70b4e116a10b7caa4e205259369900000000000000000000000000000000000000000000000000000000000000001f250bafd7daed705bd36f8138b7419700ffe4c20991a2a46993dfd25b592a3643977ddbb511d2b51471498d51135c1a724a069a507032f2a3ff93aaa9787f0d", + "57544d31012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaa7d3b3098d82fddc8585faa3e84b8f2c13cb7fc723a01bfb8bbacfba4ed388eee2b956ba992dbd2d36d81fc4a4a771b7048b732a2e36ceea2f57448216ef566c6b7091c801fbc133089d9612f76d94401ffd0199edd399f9db0beaf3e8a611d45859f57a4fec068477f3a45544119f5202eaf32ee3796401d6319ec7b1bf692810f222415114ff3e773663df797c88d6e11b094c4ffc6694581d843dbfbe8ba130702080000000000000000000000405489000000000010679c000000000030000000000000008fae533ea900dd435a27c7ac6bbe43a78ca53f4ac8bf916accd92a8d374cba94010000000000000010679c0000000000e079af000000000055000000000000006cb63b81a4b11a270dfd91c37eaaed324b1b55999789174218e9990aa186455f0200000000000000e079af0000000000b08cc200000000003a00000000000000e515ec9e690d2bf9be7ce9bd8547b84406445c82682346f3cf84ccb305cf4ae50300000000000000b08cc20000000000809fd500000000005f000000000000008ab19bbbd6173bd1339419c327fadbf9337f7d77aff9e7082ee68da5132756c80400000000000000809fd5000000000050b2e80000000000440000000000000017fca124fd9c32581a3a6f032bc985481cef9852f5f07ba3120fd851511d20ba050000000000000050b2e8000000000020c5fb00000000006900000000000000be2b55d6011931c758ef4700f002f24a947e8e95428049653bc0fc44055b5563060000000000000020c5fb0000000000f0d70e01000000004e0000000000000083da93c67f107a2933b1befbdaaf558a44b2adf41cd3e085f1d48767e8dc3fde0700000000000000f0d70e0100000000c0ea2101000000003300000000000000989f78372ee677d01a1a45c0f4e33989e1e30935ef1f70e0426de38e2cb5ae8a5b8ad24298b11d4afe4acabf254c55d99e1badc4d20a3f293fef3c8e9870e6eb060000000571c24d887ed7ee13662e1b6b5916b05446674f04a48bb9657a88aa791aa4f78c7c1730862c2541afc6faf68f8f1a4b34c99f6c9ea331b8be3a60e97734ac814bcc3352c9690a6b19b9aaa4301286ce746571ec9a1dfd777ce3cdb1ab47ca1ea6e6c44a5d482193b91fac6a4b86aae708a8fe967079b4dc31dbe6e7da840cab0b041b0865ddff65ae4f592bac31b3e3627cb44ed3bfd4678f33dfcdd03c9e684fa04199a174e37753164a4ac950986de9a4abcc3c2cdce827249a9f063289b8012b956ba992dbd2d36d81fc4a4a771b7048b732a2e36ceea2f57448216ef566c600000000000000000000000000000000000000000000000000000000000000008fdd3b426a73d6a2e5ba04781aafc491c5950f29be46baca5bc57f7e54ba95b22dee29ffae444fb5eeee0e941d74e4e71f8dffbe4f45979a1e8c49aebc023c0c" + ], + "logs": "[[{\"v\":1,\"sid\":\"aecd92c612a3b9704723441772d4704e\",\"sn\":0,\"ts_ms\":1792108800500,\"event\":\"start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"aecd92c612a3b9704723441772d4704e\",\"sn\":1,\"ts_ms\":1792108801100,\"event\":\"present\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":{\"unit\":0,\"layers\":1,\"first\":true,\"late_ms\":0,\"decode_us\":900},\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"aecd92c612a3b9704723441772d4704e\",\"sn\":2,\"ts_ms\":1792108822500,\"event\":\"end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null}],[{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":0,\"ts_ms\":1792108800510,\"event\":\"start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":1,\"ts_ms\":1792108801147,\"event\":\"present\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":{\"unit\":0,\"layers\":1,\"first\":true,\"late_ms\":0,\"decode_us\":900},\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":2,\"ts_ms\":1792108805510,\"event\":\"stall-start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":3,\"ts_ms\":1792108805910,\"event\":\"stall-end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":4,\"ts_ms\":1792108822510,\"event\":\"end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null}],[{\"v\":1,\"sid\":\"59b8375b353f73cd469c9ecaf790f7bb\",\"sn\":0,\"ts_ms\":1792108800520,\"event\":\"start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"59b8375b353f73cd469c9ecaf790f7bb\",\"sn\":1,\"ts_ms\":1792108801194,\"event\":\"present\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":{\"unit\":0,\"layers\":1,\"first\":true,\"late_ms\":0,\"decode_us\":900},\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"59b8375b353f73cd469c9ecaf790f7bb\",\"sn\":2,\"ts_ms\":1792108822520,\"event\":\"end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null}]]", + "outcome": "refuse", + "reason": "unknown_version", + "at": [ + "hop", + 1 + ] + }, + { + "name": "dvr_record_too_large", + "about": "a version 2 relay record with entries the room never published, past its size bound B₂(M) by less than one entry", + "room": "lesson", + "receipt": "574d5231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaa8be2039bc5d06e7cf9d0188c3f22c659f1687b08b69b97f50b134552c46da783000000000000000000366e0100000000028070e1923387154875aacbeb894c20dc919c95dd4128758b5d47df86652c4a21a693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c10280de8002000000000400003a9800000000016e360001ea265f46bda01959e61e0371e68b9168dfbc077f75449b7684d3d95c858cd3860a29cf514deebfd5ca029b1d658b198bade764af98e09848c6fd53b129678b720100c31801000000000400004e2000000000016e3600010265b94ec2ec2932104a7583c08050f61547332fe434464d598cad6464025b3040b598d58ceff895b484641dfdea9bf329f40b464895eb31b2271a83af17cce7fb0a000000d2e2268edd1bfc8a0dbebf1f2287b93e200bc8affd02c3e92ac872b3200731a103000000d290530d77935fe173d0d5dcbb2588170f730b423f93543b4f3ceaee06d4e78e2055cc0400000000c0f5220400000000801b680100000000801b680100000000000000000000000000000000000000000000000000000000000000000000000000000000000000001856620200000000070000000000000040548900000000002c00000000000000f000000000000000002d3101000000000001040200000080a1990300000000455552da0a00006d050000da0a0000b41500008d2000000000000000000000000000000000000016010000b41500006d05000064000000080000000d3a5365cdd4a6acda275ad18cbec48f1fae9e6f3af5513449d166bffeee68950000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d3f85e0ec28fc19995fc70a589c4d2296ca50f6ca61c75301c31d8b209c3805ca9c25f40eb7e78045c9d73d8d7e4f609a1771dff378ff8e07923781b96ed9c06", + "hops": [ + { + "record": "574d4831022c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaa0a29cf514deebfd5ca029b1d658b198bade764af98e09848c6fd53b129678b7201000000000000000000366e010000000000c31801000000000400004e2000000000016e360001150001025980e26a212cf4c098b0f8a1a36a15333c6975497e60c073b9cd8242943b4c01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001025980e26a212cf4c098b0f8a1a36a15333c6975497e60c073b9cd8242943b4c02d80e00000000000000470900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f5939013a02000000000000d80b0d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f5939023a02000000000000d80b0d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001313ea2ca301a5e17a28cb2d4b11014eaed6c09c8bb27b777993c556d712aa84d01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001313ea2ca301a5e17a28cb2d4b11014eaed6c09c8bb27b777993c556d712aa84d02d80e00000000000000470900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f011700000000000000e0040700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f024f00000000000000e01b180000000000030000000000000000000000000000000000000000000000000000000000000003000000000000000195ab21fa90d692e9fe90bade8cd132b0d17d75f25efa1fab73239d188617366501b6030000000000007094000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000195ab21fa90d692e9fe90bade8cd132b0d17d75f25efa1fab73239d188617366502d80e000000000000c05102000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000019d7a22d6b2f60ddeaf9f0b43a147004709d988a73527b65d08e0ea6372f910db013a0200000000000040ca22000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000019d7a22d6b2f60ddeaf9f0b43a147004709d988a73527b65d08e0ea6372f910db023a0200000000000040ca2200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c2c18a1d4c5f9a784336c16e2755ec99f12ba6990cacb81cb23f94aa048d6034013a0200000000000048590400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e00000000000000000000000000000000000000000000000000000000000000202000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e00100000000000000000000000000000000000000000000000000000000000202000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e00200000000000000000000000000000000000000000000000000000000000202000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e003000000000000000000000000000000000000000000000000000000000002020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000011600000000000000781e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000002cd000000000000003cf0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000000172000000000000009885000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000000258000000000000002067000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000080f15c0400000000801b680100000000801b680100000000000000000000000000000000000000000000000000000000000000000000000000000000000000001856620200000000f00000000000000008000000f6cb34442422f6cb031318abae7d1679c18fde412c21a6fc1e06d7126c38c5710a00000000000000400d0300000000001700000000000000e004070000000000c08a3700000000000100000000000000643f9302a11be7c92170ed9edef3968f4e43e52d87253c89c7355cad52bf9985c666e5a338079227c2eafc896d37feac8e2444d5455c58332f436e85f269e90f", + "ledger": "0000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffff000000030000000000000000000000b857040000000000c0fb390000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000018566202000000000c2a9846621d91f9309170b29b95e158e278002ce4cc03a2d097917836a391c0ffffffffffffffffffffffffffffffff000000010000000000000000000000d0870c0000000000a087630100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006dde44606fd8377d9fa0809b2b36b708de3e6119d8c1c3d1f0d675917c28c6198096980000000000ffffffffffffffff000000010000000000000000000000709d0b0000000000607a60010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b9091845dcf9a7fc8e2a12e1e1b2f986888b33fb22f1b42b384cdf47ecfc33a0ffffffffffffffffffffffffffffffff00000001000000e004070000000000c8431200000000000000000000000000801b6801000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cf4a5892c8a1bef2afba730a4ec868d0830f9355d10f540b757205945c588cb5ffffffffffffffffffffffffffffffff000000010000002c720200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d1ee853e5c092553edc99a0cc2468a7307afd83533a3a34f401b0695da343314ffffffffffffffffffffffffffffffff0000000100000020e53600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d436e9616073a533831cfbec646020b5d4dcc9d1df4b792ccaff6e7e23ee686affffffffffffffffffffffffffffffff0000000100000014b400000000000084e022000000000000000000000000000000000000000000801b680100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e433cf0201e9b443d808e4406925b6a4a90e9963c6235c742a0ecec87a59e478ffffffffffffffff6400000000000000455552010000000000000000000000d0870c0000000000c0f35e010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "record": "574d4831012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c1020000000000000000801b68010000000080de8002000000000400003a9800000000016e360001050001025980e26a212cf4c098b0f8a1a36a15333c6975497e60c073b9cd8242943b4c01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f5939013a02000000000000d80b0d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001313ea2ca301a5e17a28cb2d4b11014eaed6c09c8bb27b777993c556d712aa84d01b603000000000000c0510200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f021700000000000000e004070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000195ab21fa90d692e9fe90bade8cd132b0d17d75f25efa1fab73239d188617366501b60300000000000070940000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000b2083f6a8d3e1c901b93bfb87f5ffe83344214430e91d08f4e422c5ac7f0c0869f25a4e826f34f770f8f63bcea36ec697bbc398f81c06aa8ad11bb1f7a33323de1911cf31bd046195483ef8a8b22a5778adb66cfde02d9e8f8e46360a5577b06", + "ledger": "b9091845dcf9a7fc8e2a12e1e1b2f986888b33fb22f1b42b384cdf47ecfc33a0ffffffffffffffffffffffffffffffff00000001000000c843120000000000e004070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + ], + "descriptors": [ + "57434431012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaad6b5a6849f61a8bbd9d4db526a9fc1c73ef949590cdcd9b0b65ce6375262d9bd0000000000000000a693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c10005d00201101010010108000100086665617475726564056f72646572000000000000000000000280bb0000046761696e0370616e0c0c13afc5df1dffed9f80c566f13be392cc90ad1ceba5db69473a7993f8a7ff2e64cb5d094b2fdf651392e4a2d4f317cc6a436ab9b0cd5afe626493549677937002" + ], + "compositions": [ + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f000127a00000000008a826f7851f7a89f5ecf581141292c7f69e9d004ed08b01d25dd3c66e30f43f602540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f08000000000000000000000000000000c264cd78485650e03db23e16774c0566ac52d21be1b9edacaad39fa34dc3100c01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593908000000000000000000000000000000e43f0568538eef406fb5ed7901b131c0b789d9c0fdb04d61d590af9bec169a1dd2ed35929ea1d408e2da06ee1c840bacab38929016f1a7ba820a80472485c9c6159b98672266a380458e260007c2baec97749e4941eb6ac370d1660fb0e5f900", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f04054890000000000ee27588426463e6a765b4b8b5791670a7ad61be12ef1995102f9220928d6038d02540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f090000000000000000000000000000005e05448cb224cb6aa2726e4c0fe448b127e032169bd2ffea55b4693d53ef487f01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593909000000000000000000000000000000f993d88a6b1a587e0980017103aa4fcdbdf1d7b1c948f95a8bfa36a26493bb1e89b7c717020a8a296d3688d7988d8f2e70e54228d60c58514564a12937b2bb9421199fdee27ccf49acb52a32fa0f698b3eb5b28c35c47a797ae5e7d531f7db0a", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f08096980000000000d8bb5ef530c1fe8d1a0478d5ae231aac802d9f99dff906a66aa111420948048a02540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0a000000000000000000000000000000539e2bb145135d7de5b00ebf1b1178f83ef60d1b5ceedabf98bbb424a1d0a96301e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390a00000000000000000000000000000031a3cdcd31f70593af11aaf7bc4d22cc1ec5c18ee0d918fb2edd5a9c98b5a4758e74da34c2fb00e235278a3010c44dac99131fa49acc910e04f3929e7a6014833b553fad2cd31d127ad862b76999324556e76388192020c8022db9db6cafb803", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0c0d8a70000000000c65182e1c2868d42d9c1a244545f3c3416b14e481ade5c42b1979419f360b3e502540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0b000000000000000000000000000000491fe4bc5c4bafa0066ec9c2baf4b4287fcd09a778e36d5996a97ab827b7eaae01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390b000000000000000000000000000000414a7eb6f2353358646d5b861979c533b344cc01fb9dafdb9d6518c4ad7e3f6f01a03c915891dac04736b7858fdf34c4137e10f593ab5cb3a64900914b360f6b242ee3ead8c5499f325411942dbe827bd51176f2b049c4453a654c49dc701b09", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0001bb70000000000381618c2dfecc2dea3881cb1d4ed69e43dec01f07ebda92e08cd08ff935ed44502540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0c0000000000000000000000000000001915289342627f7f5a8bf4ccaf04c0418735ae65e41ca8692ebd3fca27ea764b01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390c000000000000000000000000000000194d05dbb7218051137b7537fcb320653e7f67071a19ae363969aee47cf08bede3b8b1012f1fc03a132f8793e0eae4389c23da524a7f6482a1d5cdf26ff04249cb81d4f19d8b164ec8eb381c66ba2704c57c7bb6f2389114f14bbfd632199302", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0405dc600000000007629d9fb8d6ac9e846d7ebdbe0a3a64b29ad51f7e4db40be5f96e447abcf6f0202540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0d000000000000000000000000000000f98b3489983bd0476673dc05db4f09c092411512ed2da6578e9c254eee0d72f501e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390d000000000000000000000000000000bc87dab2315f8ea4f21a4b5adcb838b8d89f6d67f93bbdf5bf175069ad81fa5d123e5e3721d1b781cfcc4650a2d571df1142f2160b377be26b692636e885cd037553b9041ac8f13b1f2064568957993ff69556b8673f0ad6e6caafd8b4887c07", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0809fd50000000000d32fbadccdb9742ffff99344fe07069e2e89b1d7f2d99859ad48336fadacd7c202540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0e00000000000000000000000000000053c09229456148744a940ba828e839eca0cf46d7c15e2c3533b4c65dcb0b4ff701e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390e000000000000000000000000000000b5702d26fbc4708196f576c1d46b7fc92471154fe81689707069bcb43173ecedc36859b37258c17a4294d56d36befaa9fbd975ce62e5deb542372aef4c9240b43ba584fc98e7c5a5066e1ba4060e96ca4e50daa116366c3aca9dfccef285f405", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f0c0e1e40000000000fe93ba9f3d79ff2d3aeb01726ac4728adf60676494fbd0e596f5972be5f4639102540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f0f000000000000000000000000000000341b9ec871ad41102a671b153ccd014f32cfe167bf5cca3989470a574196d0a401e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f59390f0000000000000000000000000000001eafd27ec73cf8676b02ebb4fa82a5bc67b2298a442322797db8cd996f8866d7bfdca1dadbfc723c27a3b6963a74c78570a41403a9cb454643631157e8bddbda8ccd54062aeec216b94406595aeb140b59afd5e697733f45980831357c0bc804", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f00024f4000000000092a195ef7c5e97512b5eed1dd778db0bd1f1c75cf8cf25b61ec800296e459e6302540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f10000000000000000000000000000000c693ed9bd4639c29ceb962b071e0531512929a139ebed7c5e12989d28b316ddc01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593910000000000000000000000000000000c5043e90fe94a06a8b47242bb084c7dc5f8e46aebbe9b2c5a8db30d44d1a8b55ab981922c453fc2c78b58f7ec56293bb26d02c335079fdd1e246e33607ef473f513bd939cfe20da8b94af0963156c7218be49ffd1efbc9c14429fddf91bf5006", + "57435231012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaaa693d88d083f28b47f2645ed8b2cff1c4c564f4dbef3f273acf037d6375060c185eb41cedc9e3c326c0cc6087de900fb1cd1f72636e2f81db1070f1675c148f04066030100000000d00e25bb474153c30c4bf88f0fb890a0dd10342388c0be87aa93399d54bbaf5502540adcb6a36dc21f91576d7d44c1a247caa4f09cd659376e20820bd77215fc1f110000000000000000000000000000005f50d92c07b2f57953a701633e9d59575664bfe9b13644d5db11c5d04629b08c01e57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f5159614c2c42bc1b1258aee7faf878dcc0ae9505962d2efde8ce5b156a44f593911000000000000000000000000000000339aa736ed16c43427079481a1b58bb9d370f104bbcba7a9d915ba1528b1a75da2dabf39b4d5d2518a8bef4340b8c5e7b32250ac222cc3e8648a42730b81eeac78da15f7fa7f42f7bb131311eb22aa7f89ada8940b2dd41c18c113a694a50808" + ], + "takes": [ + "57544d31012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaae57d3fbe7257b2118009296bf654b41c7823e730f86568799b01b442d7a300f559425465004500032d6893f980a7dad483af70b4e116a10b7caa4e2052593699b2c4d5251f98cf34d11f86821c2cd542f6bbf5152a07ad54b271f65b25309018a0bf0e356fbe70268d3166f7ac16582f4cde87f1ce46d109e4bd0562c3d436da0f222415114ff3e773663df797c88d6e11b094c4ffc6694581d843dbfbe8ba1307020a0000000000000000000000807f612a01000000c0c1702a01000000300000000000000037090d56c333ad6c4392ffbb8f80b7854c7ae3d562e182d6b76685f99db2ec1f0100000000000000c0c1702a010000000004802a0100000055000000000000003b9b3da8c3121692dbe9b75ff7b37a77773cf7d71d8b7c885253d5b3855c53b902000000000000000004802a0100000040468f2a010000003a0000000000000097f5ee9829746c16c29946a44489846e4a385eb6d62804ba22a18a99a92642ab030000000000000040468f2a0100000080889e2a010000005f000000000000004bb0df0483b6370199dc9196f695ec65b029d8ffb09dfd7ec24bf5d5c59a72f3040000000000000080889e2a01000000c0caad2a010000004400000000000000733b22d5d80b9a80443b60a14334000a8ab43f139364fa9cd3a547e8a7c523640500000000000000c0caad2a01000000000dbd2a0100000069000000000000009395a39fcaaab116f14a5e4f1e51abe694d712e0f912827045471c6283730a480600000000000000000dbd2a01000000404fcc2a010000004e000000000000002b869dfefc2fe0873e06d23c09f1629470a788c6b8f2aa8e6aafb1298cd73fe60700000000000000404fcc2a010000008091db2a0100000033000000000000009f10cb7a3feccf63f5c0c398375eaf998628d314dc8159d15845fe5a0f8a006b08000000000000008091db2a01000000c0d3ea2a0100000058000000000000008b03281ec96d96d0e76e48ef184b7a60d4856eddf77629af961fe54746404ef60900000000000000c0d3ea2a010000000016fa2a010000003d000000000000001fb42eb87b4ea4dc72d02c9e6ea82aab91be4bbef122892b8921e2f552a462678883ea090e9f374f91bacaac0864af7b6dafa57c4f8a3ac0040ec7fc669f1f2506000000d7b83f99f213619227fbccadf7069c3f6cffca8e86f2aba6ffb8d15212698fa51be10b08c6e09a1b9314f45c65bf3c9b6bd507cfa5df3f013d50657245ee48c5602f2236c9c17b8a1cca6bae144f8f162627d27095a7256e1c5b8bc199c84fb3d535c9375be68b107355dcfeaab2a78192ba09570642f09de57b7bab1438eadc703cab1c2158dee2277fea9a9b909df1f7926a3a56996b7db1bfbaaba40ffa2970412cc3dc8306ba8f88774f1a622d1bf21b9ef51fb85b990d279f66fed977340159425465004500032d6893f980a7dad483af70b4e116a10b7caa4e205259369900000000000000000000000000000000000000000000000000000000000000001f250bafd7daed705bd36f8138b7419700ffe4c20991a2a46993dfd25b592a3643977ddbb511d2b51471498d51135c1a724a069a507032f2a3ff93aaa9787f0d", + "57544d31012c3f0f62be557631417a492ef4abb5499bf32c6c980dbc9c0539769ecc2bcaaa7d3b3098d82fddc8585faa3e84b8f2c13cb7fc723a01bfb8bbacfba4ed388eee2b956ba992dbd2d36d81fc4a4a771b7048b732a2e36ceea2f57448216ef566c6b7091c801fbc133089d9612f76d94401ffd0199edd399f9db0beaf3e8a611d45859f57a4fec068477f3a45544119f5202eaf32ee3796401d6319ec7b1bf692810f222415114ff3e773663df797c88d6e11b094c4ffc6694581d843dbfbe8ba130702080000000000000000000000405489000000000010679c000000000030000000000000008fae533ea900dd435a27c7ac6bbe43a78ca53f4ac8bf916accd92a8d374cba94010000000000000010679c0000000000e079af000000000055000000000000006cb63b81a4b11a270dfd91c37eaaed324b1b55999789174218e9990aa186455f0200000000000000e079af0000000000b08cc200000000003a00000000000000e515ec9e690d2bf9be7ce9bd8547b84406445c82682346f3cf84ccb305cf4ae50300000000000000b08cc20000000000809fd500000000005f000000000000008ab19bbbd6173bd1339419c327fadbf9337f7d77aff9e7082ee68da5132756c80400000000000000809fd5000000000050b2e80000000000440000000000000017fca124fd9c32581a3a6f032bc985481cef9852f5f07ba3120fd851511d20ba050000000000000050b2e8000000000020c5fb00000000006900000000000000be2b55d6011931c758ef4700f002f24a947e8e95428049653bc0fc44055b5563060000000000000020c5fb0000000000f0d70e01000000004e0000000000000083da93c67f107a2933b1befbdaaf558a44b2adf41cd3e085f1d48767e8dc3fde0700000000000000f0d70e0100000000c0ea2101000000003300000000000000989f78372ee677d01a1a45c0f4e33989e1e30935ef1f70e0426de38e2cb5ae8a5b8ad24298b11d4afe4acabf254c55d99e1badc4d20a3f293fef3c8e9870e6eb060000000571c24d887ed7ee13662e1b6b5916b05446674f04a48bb9657a88aa791aa4f78c7c1730862c2541afc6faf68f8f1a4b34c99f6c9ea331b8be3a60e97734ac814bcc3352c9690a6b19b9aaa4301286ce746571ec9a1dfd777ce3cdb1ab47ca1ea6e6c44a5d482193b91fac6a4b86aae708a8fe967079b4dc31dbe6e7da840cab0b041b0865ddff65ae4f592bac31b3e3627cb44ed3bfd4678f33dfcdd03c9e684fa04199a174e37753164a4ac950986de9a4abcc3c2cdce827249a9f063289b8012b956ba992dbd2d36d81fc4a4a771b7048b732a2e36ceea2f57448216ef566c600000000000000000000000000000000000000000000000000000000000000008fdd3b426a73d6a2e5ba04781aafc491c5950f29be46baca5bc57f7e54ba95b22dee29ffae444fb5eeee0e941d74e4e71f8dffbe4f45979a1e8c49aebc023c0c" + ], + "logs": "[[{\"v\":1,\"sid\":\"aecd92c612a3b9704723441772d4704e\",\"sn\":0,\"ts_ms\":1792108800500,\"event\":\"start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"aecd92c612a3b9704723441772d4704e\",\"sn\":1,\"ts_ms\":1792108801100,\"event\":\"present\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":{\"unit\":0,\"layers\":1,\"first\":true,\"late_ms\":0,\"decode_us\":900},\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"aecd92c612a3b9704723441772d4704e\",\"sn\":2,\"ts_ms\":1792108822500,\"event\":\"end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null}],[{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":0,\"ts_ms\":1792108800510,\"event\":\"start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":1,\"ts_ms\":1792108801147,\"event\":\"present\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":{\"unit\":0,\"layers\":1,\"first\":true,\"late_ms\":0,\"decode_us\":900},\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":2,\"ts_ms\":1792108805510,\"event\":\"stall-start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":3,\"ts_ms\":1792108805910,\"event\":\"stall-end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"b5b91401f0762725dedbee0f0e447680\",\"sn\":4,\"ts_ms\":1792108822510,\"event\":\"end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null}],[{\"v\":1,\"sid\":\"59b8375b353f73cd469c9ecaf790f7bb\",\"sn\":0,\"ts_ms\":1792108800520,\"event\":\"start\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"59b8375b353f73cd469c9ecaf790f7bb\",\"sn\":1,\"ts_ms\":1792108801194,\"event\":\"present\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":{\"unit\":0,\"layers\":1,\"first\":true,\"late_ms\":0,\"decode_us\":900},\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null},{\"v\":1,\"sid\":\"59b8375b353f73cd469c9ecaf790f7bb\",\"sn\":2,\"ts_ms\":1792108822520,\"event\":\"end\",\"cid\":null,\"st\":\"ll\",\"sf\":\"o\",\"object\":null,\"present\":null,\"switch\":null,\"layer_change\":null,\"capable\":null,\"cohort\":null,\"metrics\":null,\"claim\":null}]]", + "outcome": "refuse", + "reason": "record_too_large", + "at": [ + "hop", + 1 + ] + }, { "name": "take_missing", "about": "a receipt naming the accompanist's take, which is not given", -- 2.54.0 (Apple Git-157) From a2529d13391f1a4cd6c2445662a313f8e6166baf Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:31:34 -0400 Subject: [PATCH 08/43] live_metering_verify.py and the corpus README: version 2 The second implementation reads WMH1 version 1 or 2 by its own layout, holds version 2's out and stored figures to the entries, chooses the bound by the fifth byte (B2(M) for 2, B(M) otherwise), and states each record's version and a version 2 record's DVR figures. New mutations cover the stated version, figures and bound, the record's DVR bytes, and the four new refusals. The README describes the new vectors. Co-Authored-By: Claude Opus 5.5 --- wai/live-conformance/live_metering_verify.py | 94 ++++++++++++++++---- wai/live-conformance/metering/README.md | 32 +++++-- 2 files changed, 102 insertions(+), 24 deletions(-) diff --git a/wai/live-conformance/live_metering_verify.py b/wai/live-conformance/live_metering_verify.py index bb337013a..d333e17b8 100644 --- a/wai/live-conformance/live_metering_verify.py +++ b/wai/live-conformance/live_metering_verify.py @@ -9,8 +9,9 @@ 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 per-hop session record (§3): read in version 1 or 2 by its own layout, its class, its + rules (version 2's DVR figures held to its entries), the size bound of its version, 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; @@ -19,8 +20,9 @@ live_compose_verify.py (live-compose §8.3). Everything else is here, from the t (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 +records (each one's digest, size, bound and version, and a version 2 record's DVR figures), +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. @@ -49,7 +51,10 @@ import _ed25519 as ED # noqa: E402 Refuse = LEV.Refuse HOP_MAGIC = b"WMH1" +HOP_VERSIONS = (1, 2) SESSION_MAGIC = b"WMR1" +SESSION_VERSIONS = (1,) +DVR_FIELDS = ("dvr_objects_out", "dvr_bytes_out", "dvr_objects_stored", "dvr_bytes_stored", "dvr_byte_s", "dvr_dropped") HOP_DOMAIN = b"wai:metering-hop\x01" SESSION_DOMAIN = b"wai:metering-session\x01" LEDGER_DOMAIN = b"wai:metering-ledger\x01" @@ -90,9 +95,15 @@ def sha(*parts): return h.digest() -def bound(m): - """§3.7: 552 + 180 × (M + 2).""" - return 552 + 180 * (m + 2) +def bound(m, version=1): + """§3.7: B(M) = 552 + 180 × (M + 2) for version 1, B₂(M) = 600 + 180 × (M + 2) for version 2.""" + return (600 if version == 2 else 552) + 180 * (m + 2) + + +def bound_of(b, m): + """§3.7: the bound of the version the fifth byte names, read before anything else; B(M) + for a fifth byte other than 2, or none.""" + return bound(m, 2 if len(b) >= 5 and b[4] == 2 else 1) def given_digest(b): @@ -151,15 +162,17 @@ class R: return self.p == len(self.b) -def read_head(b, magic): +def read_head(b, magic, versions): + """live-events §3.3's first two steps: the version, one of `versions`.""" 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: + if b[4] not in versions: raise Refuse("unknown_version") + return b[4] INVALID = "invalid" @@ -208,10 +221,11 @@ def check_sig(key, domain, body, sig): # ---------------------------------------------------------------- §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) + """§3.6: the record, of version 1 or 2, its class, its rules, its room, its signature.""" + version = read_head(b, HOP_MAGIC, HOP_VERSIONS) 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 = {"version": version, "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()): @@ -222,6 +236,8 @@ def read_hop(b, room): 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) + # §3.1: version 2 has `dvr`, six u64, after `ledger` and before `sig` (§3.9). + x["dvr"] = {k: r.u64() for k in DVR_FIELDS} if version == 2 else None sig = r.take(64) if not r.done(): raise Refuse("malformed") @@ -242,10 +258,22 @@ def read_hop(b, room): if e["dir"] == 2: return kind_ok and e["refused"] == 0 and e["duplicate"] == 0 and e["receipted"] <= e["timeout"] + e["closed"] return False + + def dvr_ok(d): + """§3.6, version 2: what it sent from its store within its out entries, what it + stored within its in entries.""" + if d is None: + return True + out = [e for e in x["tracks"] if e["dir"] == 2] + inn = [e for e in x["tracks"] if e["dir"] == 1] + return (d["dvr_objects_out"] <= sum(e["objects"] for e in out) and d["dvr_bytes_out"] <= sum(e["bytes"] for e in out) + and d["dvr_objects_stored"] <= sum(e["objects"] for e in inn) + and d["dvr_bytes_stored"] <= sum(e["bytes"] for e in inn)) 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))) + and ((x["grants"] == 0) == (x["ledger"] == ZERO)) + and dvr_ok(x["dvr"])) if not rules: raise Refuse("record_invalid") if x["room"] != room: @@ -282,7 +310,7 @@ def ledger_root(entries): 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) + read_head(b, SESSION_MAGIC, SESSION_VERSIONS) r = R(b, 5) x = {"room": r.take(32), "signer": r.take(32), "start": r.u64(), "end": r.u64()} x["hops"], classes_ok = [], True @@ -574,7 +602,6 @@ def evaluate(ctx, s, e, hops, takes, compositions, descriptors, logs, prices, cu 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] @@ -582,6 +609,7 @@ def evaluate(ctx, s, e, hops, takes, compositions, descriptors, logs, prices, cu if given is None: raise Refuse("record_missing") rb, lb = given + b_ = bound_of(rb, m) if len(rb) > b_: raise Refuse("record_too_large") x = read_hop(rb, room) @@ -761,6 +789,14 @@ def check_totals(rc, comp): raise Refuse("unit_mismatch") +def record_facts(x): + """§5.4: a hop record's digest, size, bound and version, and a version 2 record's DVR figures.""" + out = {"digest": x["digest"].hex(), "size": x["size"], "bound": x["bound"], "version": x["version"]} + if x["dvr"] is not None: + out.update(x["dvr"]) + return out + + def verify_vector(v, doc, cache): """§5: the outcome the vector should state.""" try: @@ -819,7 +855,7 @@ def verify_vector(v, doc, cache): "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"]], + "records": [record_facts(x) 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())], @@ -911,6 +947,20 @@ def mutations(doc): def cons(d, name, k): return vec(d, name)["consumption"][k] + def dvr_record(d): + return next(r for r in vec(d, "dvr_relay")["records"] if r["version"] == 2) + + def as_version_1(d): + r = dvr_record(d) + for k in DVR_FIELDS: + del r[k] + r.update(version=1, size=r["size"] - 48, bound=r["bound"] - 48) + + def dvr_byte(d, k): + h = relay_hop(d, "dvr_relay") + n = len(h["record"]) // 2 + h.update(record=flip(h["record"], n - 64 - 48 + 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))), @@ -934,6 +984,18 @@ def mutations(doc): 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: a record stated as version 2", ["lesson"], lambda d: vec(d, "lesson")["records"][0].update(version=2)), + mutate("dvr_relay: its record stated as version 1, with no DVR figures", ["dvr_relay"], as_version_1), + mutate("dvr_relay: the stated byte-seconds one more", ["dvr_relay"], lambda d: dvr_record(d).update(dvr_byte_s=dvr_record(d)["dvr_byte_s"] + 1)), + mutate("dvr_relay: the stated drops left out", ["dvr_relay"], lambda d: dvr_record(d).pop("dvr_dropped")), + mutate("dvr_relay: the stated bound version 1's", ["dvr_relay"], lambda d: dvr_record(d).update(bound=dvr_record(d)["bound"] - 48)), + mutate("dvr_relay: a byte of the relay's DVR figures", ["dvr_relay"], lambda d: dvr_byte(d, 16)), + mutate("dvr_relay: the relay's record without its DVR figures", ["dvr_relay"], + lambda d: relay_hop(d, "dvr_relay").update(record=relay_hop(d, "dvr_relay")["record"][:-2 * (64 + 48)] + relay_hop(d, "dvr_relay")["record"][-128:])), + mutate("dvr_out_exceeds: stated unknown_version", ["dvr_out_exceeds"], lambda d: vec(d, "dvr_out_exceeds").update(reason="unknown_version")), + mutate("dvr_stored_exceeds: stated accepted", ["dvr_stored_exceeds"], lambda d: vec(d, "dvr_stored_exceeds").update(outcome="accept")), + mutate("record_unknown_version: stated record_invalid", ["record_unknown_version"], lambda d: vec(d, "record_unknown_version").update(reason="record_invalid")), + mutate("dvr_record_too_large: stated record_invalid", ["dvr_record_too_large"], lambda d: vec(d, "dvr_record_too_large").update(reason="record_invalid")), 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))), ] diff --git a/wai/live-conformance/metering/README.md b/wai/live-conformance/metering/README.md index e5bf61e55..28b3a6ff4 100644 --- a/wai/live-conformance/metering/README.md +++ b/wai/live-conformance/metering/README.md @@ -2,7 +2,7 @@ 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 +records, `WMH1`, of version 1 or of version 2 with a hop's DVR figures, 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. @@ -18,8 +18,10 @@ vector states (§5.4): `["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 + session (`media_tracks`, the *M* of §3.7); each hop record's `digest`, `size`, size + `bound` (B(*M*) for version 1, B₂(*M*) for version 2) and `version`, and for a version 2 + record its six DVR figures by their §3.9 names (`dvr_objects_out`, `dvr_bytes_out`, + `dvr_objects_stored`, `dvr_bytes_stored`, `dvr_byte_s`, `dvr_dropped`); 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 @@ -36,6 +38,10 @@ a fixed label, and Ed25519 signing is deterministic (RFC 8032), so the file is t 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 +`dvr_relay` vector's DVR figures are counted, by §3.9's rules, from a DVR window written in +code (the composite's 23 frames of 20 000 bytes, each written as it arrives a third of a +second into its second and held ten seconds or to the span's end, one dropped a second after +it arrived, a viewer joining at 20 s filled from the window): measured by nothing either. 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. @@ -76,15 +82,24 @@ The vectors: 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); + record is the lesson's size; sixty logs publish the QoE cell), `relay_recorded` (the + reference relay's record, unmetered, so the energy is partial) and `dvr_relay` (the lesson's + relay keeping a ten-second DVR window of the composite: a version 2 record, 48 bytes longer + than the lesson relay's under a bound 48 bytes higher, stating the frames it stored, the + byte-seconds it held them, one dropped for not verifying against its receipt, and the ten + it filled a late viewer with; every other record of the corpus is version 1); - 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 + `ledger_root_mismatch`, `hop_units_mismatch`, and for version 2 `dvr_out_exceeds` and + `dvr_stored_exceeds`, its bytes sent from its store above its out entries' and its objects + stored above its in entries' (`record_invalid`), `record_unknown_version`, the DVR relay's + record with its fifth byte 3 and signed again (`unknown_version`), and + `dvr_record_too_large`, past B₂(*M*) by less than one entry (`record_too_large`)); 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`, @@ -105,8 +120,9 @@ 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, +audience's relay record to be the lesson's size within the same bound, the DVR relay's record +to be version 2 with every figure above 0 and every other accepted record version 1, 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 -- 2.54.0 (Apple Git-157) From 8dd2c316909689d832f72e691dc269d55e5d2504 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:42:33 -0400 Subject: [PATCH 09/43] joule-moq-relay: mark what cannot be served group by group, and keep arriving groups of kept tracks in the cache Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-relay/src/cache.rs | 14 ++- .../crates/joule-moq-relay/src/relay.rs | 14 ++- .../crates/joule-moq-relay/src/session.rs | 107 +++++++++++------- .../crates/joule-moq-relay/tests/dvr.rs | 20 +++- 4 files changed, 107 insertions(+), 48 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/cache.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/cache.rs index ab1667273..971e15f19 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/cache.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/cache.rs @@ -269,6 +269,18 @@ impl Budget { /// Account `n` bytes of track `track`'s group `group`. Returns the (track, group) /// pairs to evict, oldest first, until the budget holds. pub fn add(&mut self, track: u64, group: u64, n: usize) -> Vec<(u64, u64)> { + self.add_sized(track, group, n).into_iter().map(|(t, g, _)| (t, g)).collect() + } + + /// Put back a group [`Budget::add_sized`] chose, with its bytes, as the newest: one the + /// relay keeps for now (a group still arriving whose DVR store takes it once whole). + pub fn keep(&mut self, track: u64, group: u64, n: usize) { + self.used += n; + self.order.push_back((track, group, n)); + } + + /// As [`Budget::add`], with the bytes of each group chosen. + pub fn add_sized(&mut self, track: u64, group: u64, n: usize) -> Vec<(u64, u64, usize)> { self.used += n; match self.order.back_mut() { Some((t, g, b)) if *t == track && *g == group => *b += n, @@ -281,7 +293,7 @@ impl Budget { while self.used > self.max { let Some((t, g, b)) = self.order.pop_front() else { break }; self.used = self.used.saturating_sub(b); - evict.push((t, g)); + evict.push((t, g, b)); } evict } diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs index 0804ab652..30ad7486f 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs @@ -515,12 +515,22 @@ impl Relay { } /// Account bytes to the cache budget, evicting the oldest groups past it. + /// + /// A group of a track the DVR store keeps is not evicted while a stream of it is still + /// arriving: the store takes a group from the cache once it is whole, so the cache keeps + /// it until then, past its budget by that group if it must. fn charge_cache(&self, st: &mut State, track_id: u64, group: u64, n: usize) { - let evict = self.inner.budget.lock().unwrap().add(track_id, group, n); - for (t, g) in evict { + let mut budget = self.inner.budget.lock().unwrap(); + let evict = budget.add_sized(track_id, group, n); + for (t, g, b) in evict { if let Some(k) = st.track_ids.get(&t).cloned() && let Some(tr) = st.tracks.get_mut(&k) { + let arriving = tr.cache.groups.get(&g).is_some_and(|s| s.open > 0 && !s.broken); + if arriving && self.inner.dvr.as_ref().is_some_and(|d| d.keeps(&k.0)) { + budget.keep(t, g, b); + continue; + } cache::evict(&mut tr.cache, g); } } diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs index 20f96099d..010c0693b 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs @@ -1114,7 +1114,15 @@ fn queue_from_cache(relay: &Relay, sched: &SchedHandle, key: &TrackKey, start: L }; let lane = LaneKey { sub: lane_sub, kind: LaneKind::Fetch { request_id } }; let mut n = 0; + let mut group: Option = None; + let mut slots: Vec<(u64, Option)> = Vec::new(); for ((g, o), c) in objects { + if group != Some(g) { + if let Some(prev) = group { + n += push_group(sched, lane, prev, std::mem::take(&mut slots), request_id, order, prio, fill).0; + } + group = Some(g); + } if c.status != ObjectStatus::Normal { // A fetch carries no status (§11.4.1); a gap says an object does not exist. continue; @@ -1125,29 +1133,56 @@ fn queue_from_cache(relay: &Relay, sched: &SchedHandle, key: &TrackKey, start: L Some(w) => match w.apply(payload) { Some(p) => p, None => { - sched.push(lane, unknown_item(request_id, order, prio, fill, g, o)); + slots.push((o, None)); continue; } }, }; - let entry = FetchEntry::Object(FetchObject { - group_id: g, - object_id: o, - subgroup_id: subgroup, - publisher_priority, - properties: (!properties.is_empty()).then_some(properties), - 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, object_id: o, receipts_subscribed: false, fetched: true, dvr: false, signed: None }; - sched.push(lane, Item { op: Op::FetchEntry { request_id, order, entry }, prio: p, deadline: None, meta: Some(meta) }); - n += 1; + slots.push((o, Some(fetch_item(&render, request_id, order, prio, fill, g, o, subgroup, publisher_priority, properties, payload, false)))); + } + if let Some(prev) = group { + n += push_group(sched, lane, prev, slots, request_id, order, prio, fill).0; } let p = Prio { subscriber: prio, publisher: u8::MAX, group_rank: u64::MAX, fill_rank: 1, mode_rank: 1, within: u64::MAX }; sched.push(lane, Item { op: Op::FetchFin { request_id, order }, prio: p, deadline: None, meta: None }); n } +/// One object on a fetch or fill lane. +#[allow(clippy::too_many_arguments)] +fn fetch_item(render: &Arc, request_id: u64, order: GroupOrder, prio: u8, fill: bool, g: u64, o: u64, subgroup: Option, publisher_priority: u8, properties: Properties, payload: Bytes, from_dvr: bool) -> Item { + let meta = ObjMeta { track: render.clone(), payload_len: payload.len() as u64, object_id: o, receipts_subscribed: false, fetched: true, dvr: from_dvr, signed: None }; + 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 entry = FetchEntry::Object(FetchObject { group_id: g, object_id: o, subgroup_id: subgroup, publisher_priority, properties: (!properties.is_empty()).then_some(properties), payload: payload.to_vec() }); + Item { op: Op::FetchEntry { request_id, order, entry }, prio: p, deadline: None, meta: Some(meta) } +} + +/// Push one group's objects onto a fetch lane, in Object ID order; a slot without an item is +/// an object the relay cannot or must not serve. An End of Range moves to a later group than +/// the entry before it (MOQT §11.4.1.2, as `joule-moq` encodes it: its Group ID is a delta +/// from the prior entry's), so it cannot mark one object in the middle of a group. When +/// objects of the group cannot be served, one End of Unknown Range from the group's start +/// through the last of them goes first, and the group's objects after it follow. Returns +/// the objects pushed and their payload bytes. +#[allow(clippy::too_many_arguments)] +fn push_group(sched: &SchedHandle, lane: LaneKey, g: u64, slots: Vec<(u64, Option)>, request_id: u64, order: GroupOrder, prio: u8, fill: bool) -> (usize, usize) { + let last_unknown = slots.iter().filter(|(_, i)| i.is_none()).map(|(o, _)| *o).max(); + if let Some(u) = last_unknown { + sched.push(lane, unknown_item(request_id, order, prio, fill, g, u)); + } + let (mut n, mut bytes) = (0, 0); + for (o, item) in slots { + if let Some(item) = item + && last_unknown.is_none_or(|u| o > u) + { + bytes += item.meta.as_ref().map_or(0, |m| m.payload_len as usize); + sched.push(lane, item); + n += 1; + } + } + (n, bytes) +} + /// The session of an upstream that can answer a FETCH for `key`: one of the track's /// upstreams, else a publisher of its namespace (§7.6: the same matching as SUBSCRIBE). fn fetch_upstream_of(relay: &Relay, key: &TrackKey, except: u64) -> Option> { @@ -1320,12 +1355,6 @@ async fn serve_local(relay: Relay, sched: SchedHandle, key: TrackKey, groups: Ve groups.reverse(); } let dvr = relay.dvr().cloned(); - let object = |g: u64, o: u64, subgroup: Option, publisher_priority: u8, properties: Properties, payload: Bytes, from_dvr: bool| { - let meta = ObjMeta { track: render.clone(), payload_len: payload.len() as u64, object_id: o, receipts_subscribed: false, fetched: true, dvr: from_dvr, signed: None }; - 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 entry = FetchEntry::Object(FetchObject { group_id: g, object_id: o, subgroup_id: subgroup, publisher_priority, properties: (!properties.is_empty()).then_some(properties), payload: payload.to_vec() }); - Item { op: Op::FetchEntry { request_id, order, entry }, prio: p, deadline: None, meta: Some(meta) } - }; let mut pending = 0usize; for src in groups { match src { @@ -1334,46 +1363,44 @@ async fn serve_local(relay: Relay, sched: SchedHandle, key: TrackKey, groups: Ve sched.push(lane, unknown_item(request_id, order, prio, fill, at.group, at.object)); } Src::Cache(objs) => { - for ((g, o), c) in objs { + let Some(g) = objs.first().map(|((g, _), _)| *g) else { continue }; + let mut slots = Vec::with_capacity(objs.len()); + for ((_, o), c) in objs { if c.status != ObjectStatus::Normal { continue; } let payload = match &withhold { - None => c.payload, - Some(w) => match w.apply(c.payload) { - Some(p) => p, - None => { - sched.push(lane, unknown_item(request_id, order, prio, fill, g, o)); - continue; - } - }, + None => Some(c.payload), + Some(w) => w.apply(c.payload), }; - sched.push(lane, object(g, o, c.subgroup, c.publisher_priority, c.properties, payload, false)); + slots.push((o, payload.map(|p| fetch_item(&render, request_id, order, prio, fill, g, o, c.subgroup, c.publisher_priority, c.properties, p, false)))); } + push_group(&sched, lane, g, slots, request_id, order, prio, fill); } Src::Dvr(g) => { let from = if g == start.group { start.object } else { 0 }; let to = if g == end.group { end.object } else { u64::MAX }; - let slots = match dvr.clone() { + let read = match dvr.clone() { Some(d) => { let k = key.clone(); tokio::task::spawn_blocking(move || d.read_group(&k, g, from, to, !moderator)).await.ok().flatten() } None => None, }; - match slots { + match read { None => sched.push(lane, unknown_item(request_id, order, prio, fill, g, to)), - Some(slots) => { - for slot in slots { - match slot { + Some(read) => { + let slots = read + .into_iter() + .filter_map(|slot| match slot { Slot::Object(so) if so.status == ObjectStatus::Normal => { - pending += so.payload.len(); - sched.push(lane, object(g, so.id, so.subgroup, so.priority, so.properties, so.payload, true)); + Some((so.id, Some(fetch_item(&render, request_id, order, prio, fill, g, so.id, so.subgroup, so.priority, so.properties, so.payload, true)))) } - Slot::Object(_) => {} - Slot::Unknown(o) => sched.push(lane, unknown_item(request_id, order, prio, fill, g, o)), - } - } + Slot::Object(_) => None, + Slot::Unknown(o) => Some((o, None)), + }) + .collect(); + pending += push_group(&sched, lane, g, slots, request_id, order, prio, fill).1; } } } diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs index 77650b4ac..249a24b1d 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs @@ -281,8 +281,13 @@ async fn a_window_by_age_empties_as_its_groups_pass_it() { publish_multi(&p, &publisher, &t, 3, "a").await; let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; let f = fetch_verified(&v, &t, &publisher, range((0, 0), 3)).await; - assert_eq!(f.unknown, vec![Location::new(2, u64::MAX)]); - assert_eq!(f.objects.keys().copied().collect::>(), (0..OBJECTS).map(|o| (3, o)).collect::>()); + // What aged out is unknown through the last group neither the store nor the cache + // holds; every group after it comes whole. + assert_eq!(f.unknown.len(), 1); + let k = f.unknown[0]; + assert!(k.object == u64::MAX && k.group < 3, "{k:?}"); + let want: Vec<(u64, u64)> = (k.group + 1..=3).flat_map(|g| (0..OBJECTS).map(move |o| (g, o))).collect(); + assert_eq!(f.objects.keys().copied().collect::>(), want); server.shutdown().await; let _ = std::fs::remove_dir_all(&dir); }) @@ -360,9 +365,12 @@ async fn a_restart_keeps_the_store_and_drops_what_does_not_verify() { // No publisher now: the store answers alone. let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; let f = fetch_verified(&v, &t, &publisher, Parameters::new().with(Parameter::LocationFilter(LocationFilter::AbsoluteStart { start: Location::new(0, 0) }))).await; - assert_eq!(f.objects.len() as u64, GROUPS * OBJECTS - 2, "every object but the two dropped"); - assert!(!f.objects.contains_key(&(3, 1)) && !f.objects.contains_key(&(5, 0)), "never served"); + // An End of Range moves to a later group than the entry before it (MOQT §11.4.1.2), + // so (3, 1)'s marks group 3 from its start: (3, 0) goes with it. assert_eq!(f.unknown, vec![Location::new(3, 1), Location::new(5, 0)], "each in its place as unknown"); + assert!(!f.objects.contains_key(&(3, 1)) && !f.objects.contains_key(&(5, 0)), "never served"); + assert_eq!(f.objects.len() as u64, GROUPS * OBJECTS - 3, "every object but the two dropped and the one before (3, 1)"); + assert!(f.objects.contains_key(&(3, 2)) && f.objects.contains_key(&(5, 1)), "the objects after them follow"); assert_eq!(server.relay().largest(&key), Some(Location::new(GROUPS - 1, OBJECTS - 1))); // The dropped objects are out of their files too: a second restart finds nothing to drop. server.shutdown().await; @@ -479,7 +487,8 @@ async fn decisions_withhold_from_the_store_and_hold_across_a_restart() { let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; let f = fetch_verified(&v, &t, &publisher, all()).await; assert_eq!(f.unknown, vec![Location::new(2, 1), Location::new(3, 0)], "withheld, each in its place"); - assert_eq!(f.objects.len() as u64, GROUPS * OBJECTS - 2); + assert_eq!(f.objects.len() as u64, GROUPS * OBJECTS - 3, "and (2, 0), which an End of Range through (2, 1) covers"); + assert!(f.objects.contains_key(&(2, 2)) && f.objects.contains_key(&(3, 1))); assert!(f.objects.contains_key(&(4, 0)), "a decision by no moderator withholds nothing"); // The redacted object's bytes are gone from its file; the denied one's stay. let file = std::fs::read(d.group_path(&(t.ns.clone(), t.media.clone()), 2).unwrap()).unwrap(); @@ -498,6 +507,7 @@ async fn decisions_withhold_from_the_store_and_hold_across_a_restart() { let f = fetch_verified(&v, &t, &publisher, all()).await; assert_eq!(f.unknown, vec![Location::new(2, 1)]); assert!(f.objects.contains_key(&(3, 0))); + assert_eq!(f.objects.len() as u64, GROUPS * OBJECTS - 2); // Across a restart the decisions hold. for c in [&p, &m, &v] { -- 2.54.0 (Apple Git-157) From 41bb4369dfaaa1b8b36b0108445bd2010ee2a9da Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:44:22 -0400 Subject: [PATCH 10/43] joule-moq-relay: DVR tests with a cache of a few groups; warn when a kept group comes after the cache moved past it Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-relay/src/relay.rs | 3 +++ .../crates/joule-moq-relay/tests/dvr.rs | 23 +++++++++++-------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs index 30ad7486f..0204d987c 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs @@ -593,6 +593,9 @@ impl Relay { }; let size = cache::size_of(&cached); let ins = track.cache.insert(group, oid, cached); + if ins == Insert::Evicted && o.first_in_stream == Some(oid) && self.inner.dvr.as_ref().is_some_and(|d| d.keeps(&key.0)) { + tracing::warn!(target: "joule_moq_relay::dvr", track = %track.render, group, "a group arrived after the cache evicted a later group of its track: not cached, so not stored (raise cache.max_bytes)"); + } if ins == Insert::Conflict { tracing::warn!(track = %track.render, group, object = oid, "a duplicate object that differs: the track is malformed (§7.1)"); track.cache.group_broken(group); diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs index 249a24b1d..fb6daae19 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs @@ -39,6 +39,11 @@ use tokio::time::timeout; const OBJECTS: u64 = 3; const LEN: usize = 1000; +/// The cache's budget: about three groups with their receipts, so that what is older comes +/// from the store. A group reaching the relay after the cache evicted a later group of its +/// track is not cached, and so not stored (README, *DVR*); a cache of a few groups or more +/// keeps a test clear of that. +const CACHE: usize = 16 * 1024; fn now_s() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() @@ -174,11 +179,11 @@ async fn window_run() { let room = [0x51; 32]; let publisher = SigningKey::from_bytes(&[0x52; 32]); let t = room_tracks(room, &publisher); - // About eight groups of media and receipts in the window; the cache holds fewer. + // About seven groups of media and receipts in the window; the cache holds fewer. let max_bytes = 30_000; let cfg = dvr_config(&dir, 900, max_bytes, vec![]); let server = relay(|c| { - c.cache.max_bytes = 12 * 1024; + c.cache.max_bytes = CACHE; c.dvr = Some(cfg.clone()); }) .await; @@ -203,15 +208,15 @@ async fn window_run() { let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; // Inside the window: every object, verified, in the requested order. - let inside = fetch_verified(&v, &t, &publisher, range((first + 1, 0), first + 3)).await; + let inside = fetch_verified(&v, &t, &publisher, range((first, 0), first + 2)).await; assert_eq!(inside.objects.len() as u64, 3 * OBJECTS); assert!(inside.unknown.is_empty() && inside.other_ends.is_empty()); for ((g, o), p) in &inside.objects { assert_eq!(p, &object_payload("w", *g, *o)); } - let desc = fetch_verified(&v, &t, &publisher, range((first + 1, 0), first + 3).with(Parameter::GroupOrder(GroupOrder::Descending))).await; + let desc = fetch_verified(&v, &t, &publisher, range((first, 0), first + 2).with(Parameter::GroupOrder(GroupOrder::Descending))).await; let groups: Vec = desc.order.iter().map(|x| x.0).collect(); - assert_eq!(groups, [first + 3, first + 3, first + 3, first + 2, first + 2, first + 2, first + 1, first + 1, first + 1], "descending groups, objects ascending"); + assert_eq!(groups, [first + 2, first + 2, first + 2, first + 1, first + 1, first + 1, first, first, first], "descending groups, objects ascending"); assert!(desc.order.windows(2).all(|w| w[0].0 != w[1].0 || w[0].1 < w[1].1)); // Across: what was evicted is unknown, and the rest comes, from the store and the cache. @@ -263,7 +268,7 @@ async fn a_window_by_age_empties_as_its_groups_pass_it() { let t = room_tracks(room, &publisher); let cfg = dvr_config(&dir, 2, 1 << 30, vec![]); let server = relay(|c| { - c.cache.max_bytes = 8 * 1024; + c.cache.max_bytes = CACHE; c.dvr = Some(cfg.clone()); }) .await; @@ -334,7 +339,7 @@ async fn a_restart_keeps_the_store_and_drops_what_does_not_verify() { let t = room_tracks(room, &publisher); let cfg = dvr_config(&dir, 900, 1 << 30, vec![]); let config = |c: &mut RelayConfig| { - c.cache.max_bytes = 4 * 1024; + c.cache.max_bytes = CACHE; c.dvr = Some(cfg.clone()); }; let server = relay(config).await; @@ -456,7 +461,7 @@ async fn decisions_withhold_from_the_store_and_hold_across_a_restart() { let t = room_tracks(room, &publisher); let cfg = dvr_config(&dir, 900, 1 << 30, vec![hexkey(&moderator)]); let config = |c: &mut RelayConfig| { - c.cache.max_bytes = 4 * 1024; + c.cache.max_bytes = CACHE; c.dvr = Some(cfg.clone()); }; let server = relay(config).await; @@ -561,7 +566,7 @@ async fn seeking_back_needs_the_seek_power() { let t = room_tracks(room, &publisher); let cfg = dvr_config(&dir, 900, 1 << 30, vec![]); let server = relay(|c| { - c.cache.max_bytes = 4 * 1024; + c.cache.max_bytes = CACHE; c.dvr = Some(cfg.clone()); c.auth.required = true; c.auth.authorities = vec![Authority { key: hexkey(&authority), rooms: vec![] }]; -- 2.54.0 (Apple Git-157) From 1e89809b371749e5e2494cef1b0520dd2745f0e6 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:44:56 -0400 Subject: [PATCH 11/43] joule-moq-relay: a group written again at load keeps its new offsets Co-Authored-By: Claude Opus 5.5 --- joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs | 3 ++- joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs index ea5f6a661..25328c12e 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs @@ -933,8 +933,9 @@ impl Dvr { if let Ok(b) = std::fs::read(&path) && let Some((_, _, count, objs, erased)) = decode_group(&b, &sha256(&[render(&k.0, Some(&k.1)).as_bytes()])) && (count as usize != grp.objects.len() || erased.len() != grp.erased.len() || objs.len() != grp.objects.len()) + && let Some(index) = rewrite(&path, &k, *g, grp, &objs) { - rewrite(&path, &k, *g, grp, &objs); + grp.objects = index; } ix.window_bytes[t.window] += grp.bytes; for o in &grp.objects { diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs index fb6daae19..b5cef65e5 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs @@ -268,7 +268,8 @@ async fn a_window_by_age_empties_as_its_groups_pass_it() { let t = room_tracks(room, &publisher); let cfg = dvr_config(&dir, 2, 1 << 30, vec![]); let server = relay(|c| { - c.cache.max_bytes = CACHE; + // About two groups: what aged out of the store is out of the cache too. + c.cache.max_bytes = 8 * 1024; c.dvr = Some(cfg.clone()); }) .await; -- 2.54.0 (Apple Git-157) From ad01ff7d2fc0c097cbc25124acf3dd8dc5cfc3b2 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:22:06 -0400 Subject: [PATCH 12/43] =?UTF-8?q?live-rooms:=20the=20seek=20power,=20and?= =?UTF-8?q?=20=C2=A710.6=20seeking=20back=20below=20a=20track's=20live=20e?= =?UTF-8?q?dge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit powers gains bit 4 seek; a role's and a grant's powers set none above bit 4, and a grant's seek stays within its role's (§4.3 step 7). §10.6 fixes the live edge (MOQT §3.1.4) and when a FETCH or a SUBSCRIBE's fill seeks back, which a relay serves only under a proved grant holding seek, refusing any other with REQUEST_ERROR UNAUTHORIZED and the reason phrase seek_not_granted (§11). §12 describes the new corpus content. Co-Authored-By: Claude Opus 5.5 --- wai/extensions/live-rooms.md | 74 ++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/wai/extensions/live-rooms.md b/wai/extensions/live-rooms.md index 1c5244afa..5ab9483e0 100644 --- a/wai/extensions/live-rooms.md +++ b/wai/extensions/live-rooms.md @@ -29,8 +29,9 @@ In scope: the room descriptor, its digest (the room id) and signature (§3); the grant, its checks, its attributes, how a receipt names it, and its exchange (§4); revocation through the key log (§5); track naming (§6); precedence and redundancy over slots, and the `detail` of every lifecycle kind of live-events §4.13 (§7); the publish -profiles (§8); room verification (§9); and the bindings onto token formats, with the proof -of possession every binding requires (§10). +profiles (§8); room verification (§9); and the bindings onto token formats (§10), with the +proof of possession every binding requires (§10.5) and seeking back below a track's live +edge (§10.6). Out of scope: a transport; how a principal obtains a grant; composition, which reads the grant's attributes ([live-compose](live-compose.md)); the review workflow, of which this extension @@ -112,7 +113,8 @@ A **role** maps a name to a **capability set**: moderator's), bit 1 **direct** (it may send `control` commands other than recording), bit 2 **compose** (it may sign the room's composition descriptor and publish a composed track, [live-compose](live-compose.md) §3.5, §7), bit 3 - **record** (it may send `record_start` and `record_stop`). + **record** (it may send `record_start` and `record_stop`), bit 4 **seek** (it may fetch + a track of the room below its live edge, §10.6). A role whose `events` permit `decision` has the moderate power; one whose `events` permit `control` has direct or record. A grant narrows its role's set and never widens it @@ -136,7 +138,7 @@ It then holds the descriptor to these rules, and refuses one that breaks any of - `ceiling` is a policy live-events §5 reads, and it permits no lifecycle kind (`0x10` to `0x1F`). - There are 1 to 16 roles, each named by a name no other role has, with `subscribe` 0 or - 1, `publish` setting no bit above bit 2, `powers` none above bit 3, `max_layer` 0 when + 1, `publish` setting no bit above bit 2, `powers` none above bit 4, `max_layer` 0 when `subscribe` is 0, `events` within the ceiling's `kinds`, and the powers §3.3 requires of its `events`. - There are at most 16 slots, each named by a slot name no other slot has, of a media @@ -239,7 +241,7 @@ refuses at the first check that fails, naming the grant by its index: - a first grant's `effective` is 0, and an exchanged grant's a multiple of the descriptor's `tick`; - `role` names a role; `subscribe` is 0 or 1; `publish` sets no bit above bit 2, - `powers` none above bit 3; `max_layer` is 0 when `subscribe` is 0; + `powers` none above bit 4; `max_layer` is 0 when `subscribe` is 0; - `policy` is a policy live-events §5 reads, permits no lifecycle kind, and the powers §3.3 requires of its kinds are among `powers`; - `funds_ceiling` is 2^64 − 1 exactly when `currency` is three zero bytes, and @@ -653,7 +655,7 @@ CTA-5007-B's. | client network, geography | `catnip` (311), `catgeoiso3166` (316) | no grant claim: a deployment **MAY** add them; a room's restriction by place is a sink-evaluated predicate ([unidirectional-delivery](unidirectional-delivery.md) §2) | | caps, `max_layer`, the slots | `moqt` | §10.2 | | `reval` (descriptor) | `moqt-reval` | §10.2 | -| `role`, `precedence`, `policy`, `profiles`, `attrs`, `joules_ceiling`, `funds_ceiling`, `viewer` | none | WAI's alone; the relay holds the grant, and a deployment that links the two inside the token carries the grant's digest in a private-use claim (RFC 8392 §9.1) | +| `role`, `powers`, `precedence`, `policy`, `profiles`, `attrs`, `joules_ceiling`, `funds_ceiling`, `viewer` | none | WAI's alone; the relay holds the grant, and a deployment that links the two inside the token carries the grant's digest in a private-use claim (RFC 8392 §9.1) | `catv` (310) is the version CTA-5007-B gives; `catpor` (309), `catm` (313), `catalpn` (314), `catgeocoord` (317), `catgeoalt` (318), `catif` (322) and `catifdata` (320) have @@ -693,6 +695,8 @@ re-validation, and then only expiry ends the token. A grant exchange (§4.6) iss token at the tick. The corpus states each accepted vector's scopes per grant, written as JSON: an exact match as a string and `nil` as `null`. +A grant's seek power is applied by the relay, §10.6. + ### 10.3 URI Signing (RFC 9246) Over HTTP, a room's delivery (an LL-HLS or DASH rendition of its tracks) is authorised with @@ -851,6 +855,28 @@ these that fails: It then consumes the challenge. A verifier that keeps no consumed challenges reports a proof presented again as `pop_wrong_challenge`, and refuses it all the same. +### 10.6 Seeking back + +A relay that keeps a window of a room's tracks, a DVR window +([live-recording](live-recording.md) §8), lets a viewer seek back into it. The **live +edge** of a track at a relay is the group of the Largest Object the relay knows of the +track (MOQT §3.1.4). A FETCH (MOQT §3.2) **seeks back** when its range starts in a group +below the live edge, and so does the fill of a SUBSCRIBE (MOQT §3.4); a joining fetch of +the group in progress starts at the live edge, and does not. + +A relay serves a request that seeks back on a track of the room only to a session whose +SETUP proved (§10.5) a grant that gives it the track (§10.2) and holds the seek power +(§3.3). It refuses any other with `REQUEST_ERROR` `UNAUTHORIZED` (MOQT §12.3) and the +reason phrase `seek_not_granted`. The power has no `moqt` action of its own: +`draft-ietf-moq-c4m-01` enables FETCH for a track, not for a range of it, so §10.2 step 5 +enables FETCH for every grant that may subscribe, and the relay, which holds the grant +beside the token (§10.1), applies this rule. A viewer admitted by Privacy Pass (§10.4) +holds no grant, and does not seek back. + +The rule is a function of the grant, the live edge and the request's start: with *L* the +live edge and (*g*, *o*) the start Location, the request seeks back when *g* < *L*, and is +then served only when the grant's `powers` has bit 4. + ## 11. Reasons The codes this extension adds, each with where it is raised: @@ -898,6 +924,7 @@ The codes this extension adds, each with where it is raised: | `pop_replayed` | §10.5.4 step 6 | | `pop_wrong_challenge` | §10.5.3, §10.5.4 step 6 | | `pop_wrong_context` | §10.5.4 step 7 | +| `seek_not_granted` | §10.6 | Of live-events §9's codes, a room verifier also raises `truncated`, `malformed` and `unknown_version` when a descriptor or a grant does not read (§3.4, §4.3); `bad_key` and @@ -915,14 +942,19 @@ key-log head or none, and chains, with the outcome §9.4 gives them. - **Accepted**: a lesson-style room (a director, a performer, an audience, and the director's remote record control of the performer, under a key log whose head revokes a grant and a viewer the room never admits); a broadcast room (one publisher, many - viewers, a moderator); a takeover; a redundant pair with failover; and a grant - exchange that promotes a viewer to a publisher. Each states the room id, every grant's - digest, the merged order with the grant each record was verified under, the room state - (§9.3) with the names of the tracks a subscriber can reach (§6.1), the tally - (live-events §7), and each grant's `moqt` scopes and re-validation interval (§10.2). + viewers, a moderator); a takeover; a redundant pair with failover; a grant exchange + that promotes a viewer to a publisher; and a broadcast room with a DVR window, whose + viewers' role holds the seek power, one viewer's grant holding it and another's + narrowing it away (§10.6). Each states the room id, every grant's digest, the merged + order with the grant each record was verified under, the room state (§9.3) with the + names of the tracks a subscriber can reach (§6.1), the tally (live-events §7), each + grant's `moqt` scopes and re-validation interval (§10.2), and whether the grant holds + the seek power (§3.3). - **Gap**: a chain with a record not delivered. - **Refused**: each reason of §11 at least once, each vector a control of one defect, and - the codes of live-events §9 that a descriptor, a grant or a record of a room raises. + the codes of live-events §9 that a descriptor, a grant or a record of a room raises; + among them a grant with the seek power whose role lacks it, and a grant and a role with + a power above bit 4. - **Proofs** (§10.5), in the file's `proofs`: a grant of a room presented with a proof over MOQT (verb `SETUP`) and over HTTP (a WHIP `POST` and a `DELETE`, in their header forms), each accepted; and, each refused with its reason, a grant presented with no proof, a @@ -932,16 +964,28 @@ key-log head or none, and chains, with the outcome §9.4 gives them. MOQT proof presented over HTTP, and records that do not read. Each states the grant, the proof (or the headers), the challenge the verifier holds, the challenges it consumed, the verb and target it received, and the outcome. +- **Seeking back** (§10.6), in the file's `seek`: requests on a track of the DVR room, each + naming a grant of it by the vector's name and the grant's index, and stating the live + edge, the request's start Location and whether it is a FETCH or the fill of a + SUBSCRIBE. Served: a FETCH and a fill below the live edge under a grant with the seek + power; and, under a grant without it, a FETCH from the live edge's first object and + from within its group, a fill that joins the group in progress, and a FETCH from the + start of a track whose live edge is group 0. Refused `seek_not_granted`: a FETCH and a + fill below the live edge under the viewer's grant that narrows the power away, among + them one from the group just below the edge, and a FETCH below it under the + publisher's grant, whose role lacks the power. Each states the outcome, `serve` or + `refuse` with its reason. A conforming verifier gives every vector the outcome, the reason, the place, the gaps and -the state, order, tally and scopes the corpus states, and every proof its outcome. `wai_live_room_vectors verify` and +the state, order, tally, scopes and seek power the corpus states, every proof its +outcome, and every request of `seek` its outcome. `wai_live_room_vectors verify` and the test `tests/live_room.rs` check that the file regenerates byte for byte, that `wai-rs` gives every vector its outcome, and that §11 lists exactly the reference's codes. `live-conformance/live_rooms_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 event with the live-events -corpus's own second implementation (`live_events_verify.py`), and gives every vector the -same outcome. +corpus's own second implementation (`live_events_verify.py`), and gives every vector, +every proof and every request of `seek` the same outcome. ## 13. Security and privacy considerations -- 2.54.0 (Apple Git-157) From ab1b91e63366dc862d4395237fcdd5f9cc50c0dd Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:24:01 -0400 Subject: [PATCH 13/43] =?UTF-8?q?wai-rs=20live=5Froom:=20the=20seek=20powe?= =?UTF-8?q?r,=20seek=5Fcheck=20(=C2=A710.6),=20and=20the=20corpus's=20DVR?= =?UTF-8?q?=20room=20and=20seek=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit power::SEEK is bit 4, which the descriptor's and the grant's field rules now allow; seek_check refuses seek_not_granted a request that starts in a group below the live edge under a grant without it. The corpus gains the accepted dvr room, three refused vectors (a grant with seek its role lacks; a grant and a role with powers bit 5), each accepted grant's seek, and the seek section. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/live_room.rs | 47 +++++++- wai/wai-rs/src/live_room/corpus.rs | 172 ++++++++++++++++++++++++++++- wai/wai-rs/tests/live_room.rs | 11 +- 3 files changed, 221 insertions(+), 9 deletions(-) diff --git a/wai/wai-rs/src/live_room.rs b/wai/wai-rs/src/live_room.rs index 6fc25667e..561b5e630 100644 --- a/wai/wai-rs/src/live_room.rs +++ b/wai/wai-rs/src/live_room.rs @@ -24,7 +24,9 @@ //! - §9: room verification, which merges every principal's chain by room clock, verifies //! every event with [`crate::live_events::RoomVerifier`] under the policy of the grant //! in effect at its `t`, and replays the lifecycle into room state; -//! - §10: the `moqt` claim scopes a grant maps onto (`draft-ietf-moq-c4m-01`), as data. +//! - §10: the `moqt` claim scopes a grant maps onto (`draft-ietf-moq-c4m-01`), as data; +//! the proof of possession (§10.5); and the seek power's rule for a request below a +//! track's live edge (§10.6). //! //! The corpus `live-conformance/rooms/` is written by [`corpus::generate`] and checked by //! [`corpus::verify`] here, and by `live-conformance/live_rooms_verify.py`, which shares no @@ -97,8 +99,12 @@ pub mod power { pub const DIRECT: u8 = 2; pub const COMPOSE: u8 = 4; pub const RECORD: u8 = 8; + pub const SEEK: u8 = 16; } +/// Every power bit a capability set may set (§3.4, §4.3 step 5): none above bit 4. +const POWERS: u8 = power::MODERATE | power::DIRECT | power::COMPOSE | power::RECORD | power::SEEK; + /// The layering of a publish profile (§8.1). pub mod layering { pub const SINGLE: u8 = 0; @@ -162,6 +168,7 @@ pub enum Reason { PopReplayed, PopWrongChallenge, PopWrongContext, + SeekNotGranted, } impl From for Reason { @@ -172,7 +179,7 @@ impl From for Reason { impl Reason { /// The codes this extension adds, in §11's order. - pub const ALL: [Reason; 41] = [ + pub const ALL: [Reason; 42] = [ Reason::DescriptorInvalid, Reason::KeyLogMissing, Reason::KeyLogNotHeld, @@ -214,6 +221,7 @@ impl Reason { Reason::PopReplayed, Reason::PopWrongChallenge, Reason::PopWrongContext, + Reason::SeekNotGranted, ]; /// The conformance code. @@ -261,6 +269,7 @@ impl Reason { Reason::PopReplayed => "pop_replayed", Reason::PopWrongChallenge => "pop_wrong_challenge", Reason::PopWrongContext => "pop_wrong_context", + Reason::SeekNotGranted => "seek_not_granted", } } @@ -801,7 +810,7 @@ impl Descriptor { return bad; } let c = role.caps; - if !subset(c.publish, 0b111) || !subset(c.powers, 0b1111) || (!c.subscribe && c.max_layer != 0) { + if !subset(c.publish, 0b111) || !subset(c.powers, POWERS) || (!c.subscribe && c.max_layer != 0) { return bad; } if !kinds_subset(&role.events, &ceiling.kinds) || !powers_cover(&role.events, c.powers) { @@ -1170,7 +1179,7 @@ impl Grant { return bad; } let c = g.caps; - if !subset(c.publish, 0b111) || !subset(c.powers, 0b1111) || (!c.subscribe && c.max_layer != 0) { + if !subset(c.publish, 0b111) || !subset(c.powers, POWERS) || (!c.subscribe && c.max_layer != 0) { return bad; } let policy = match Policy::parse(&raw.policy) { @@ -2187,6 +2196,24 @@ pub fn check_proof(grant: &[u8; 32], principal: &[u8; 32], proof: Option<&[u8]>, Ok(p) } +// --------------------------------------------------------------------------------------- +// §10.6 Seeking back +// --------------------------------------------------------------------------------------- + +/// §10.6: whether a relay serves a FETCH, or a SUBSCRIBE's fill, whose range starts in +/// group `start_group` of a track whose live edge (the group of its Largest Object, MOQT +/// §3.1.4) is `live_edge`, under a grant whose powers are `powers`. A request that starts +/// below the live edge seeks back, and is served only when `powers` has the seek power; +/// one that starts at the live edge, or within its group, does not seek back. The grant is +/// one the session's SETUP proved (§10.5) and that gives it the track (§10.2): the caller +/// has checked both. +pub fn seek_check(powers: u8, live_edge: u64, start_group: u64) -> Result<(), Reason> { + if start_group < live_edge && powers & power::SEEK == 0 { + return Err(Reason::SeekNotGranted); + } + Ok(()) +} + const B64URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; /// Base64url without padding (RFC 4648 §5), as §10.5.3's headers carry their bytes. @@ -2298,6 +2325,18 @@ mod tests { } } + #[test] + fn seeking_back_needs_the_seek_power() { + assert_eq!(seek_check(power::SEEK, 120, 90), Ok(())); + assert_eq!(seek_check(0, 120, 90), Err(Reason::SeekNotGranted)); + assert_eq!(seek_check(0, 120, 119), Err(Reason::SeekNotGranted)); + assert_eq!(seek_check(power::MODERATE | power::DIRECT | power::COMPOSE | power::RECORD, 120, 119), Err(Reason::SeekNotGranted)); + assert_eq!(seek_check(0, 120, 120), Ok(())); + assert_eq!(seek_check(0, 0, 0), Ok(())); + assert_eq!(seek_check(POWERS, u64::MAX, 0), Ok(())); + assert_eq!(Reason::SeekNotGranted.code(), "seek_not_granted"); + } + #[test] fn a_grant_covers_its_window_by_the_second() { let g = Grant { diff --git a/wai/wai-rs/src/live_room/corpus.rs b/wai/wai-rs/src/live_room/corpus.rs index a3a831198..018b2d3c5 100644 --- a/wai/wai-rs/src/live_room/corpus.rs +++ b/wai/wai-rs/src/live_room/corpus.rs @@ -126,6 +126,11 @@ fn guest() -> Role { use kind::*; role("guest", A | V, true, 1, 0, &[CHAT, REACTION, VOTE, MARKER]) } +/// An audience whose role holds the seek power (§3.3, §10.6). +fn seeking_audience() -> Role { + use kind::*; + role("audience", 0, true, 1, power::SEEK, &[CHAT, REACTION]) +} fn profile(name: &str) -> Profile { registered_profile(name).expect("a registered profile") @@ -460,6 +465,28 @@ fn promote() -> (Build, [u8; 32], [u8; 32]) { (b, dv, dg) } +/// The DVR room: a broadcast whose relay keeps a window of its tracks. The audience's role +/// holds the seek power; viewer_a's grant holds it, and viewer_b's narrows it away (§10.6). +/// The grants: 0 the host's, 1 viewer_a's, 2 viewer_b's. +fn dvr() -> Build { + let mut d = descriptor(AUTHORITY, "", vec![host(), seeking_audience()], vec![], vec![profile("speech"), profile("camera-720")]); + d.limits = Limits { max_present: 64, max_publishers: 1, reval_s: 60, ceiling: Policy::default() }; + let mut b = Build::new(d); + let gh = Grant { profiles: vec![0, 1], ..b.grant(HOST, 0) }; + let ga = b.grant(VIEWER_A, 1); + let gb = b.grant(VIEWER_B, 1); + let gb = Grant { caps: Caps { powers: 0, ..gb.caps }, ..gb }; + let (dh, da, db) = (b.add(gh), b.add(ga), b.add(gb)); + b.join(0, HOST, dh); + b.publish(0, HOST, "", media::VIDEO, 1, 2); + b.publish(0, HOST, "", media::AUDIO, 0, 1); + b.join(S, VIEWER_A, da); + b.join(2 * S, VIEWER_B, db); + b.ev(VIEWER_A, 3 * S, chat("going back to the opening")); + b.ev(VIEWER_B, 4 * S, Payload::Reaction(Reaction { rtype: 4, value: 1, target: None, remove: false })); + b +} + fn good_vectors() -> Vec { let mut out = Vec::new(); let (b, _) = lesson(); @@ -507,6 +534,12 @@ fn good_vectors() -> Vec { &b, Intent::Accept, )); + out.push(vector( + "dvr", + "a broadcast room whose relay keeps a DVR window of its tracks: one publisher, and an audience whose role holds the seek power; viewer_a's grant holds it, viewer_b's narrows it away, and neither changes the scopes a grant maps onto (§10.6)", + &dvr(), + Intent::Accept, + )); let (mut b, _, _) = promote(); b.ev(VIEWER_B, 3 * S, chat("one")); let lost = b.raw(VIEWER_B, 4 * S, kind::CHAT, chat("two").encode()); @@ -604,6 +637,9 @@ fn descriptor_vectors() -> Vec { &Build::new(d), refuse(Reason::DescriptorInvalid), )); + let mut d = base(); + d.roles[2].caps.powers = 1 << 5; + out.push(vector("descriptor_role_power_bit_5", "a role whose powers set bit 5, above the seek power's bit 4 (§3.4)", &Build::new(d), refuse(Reason::DescriptorInvalid))); out } @@ -719,6 +755,22 @@ fn grant_vectors() -> Vec { &b, refuse(1, Reason::ExchangeMismatch), )); + let with_powers = |b: &Build, powers: u8| { + let g = b.grant(VIEWER_A, 2); + Grant { caps: Caps { powers, ..g.caps }, ..g }.sign(&key(AUTHORITY)).to_bytes() + }; + out.push(vector( + "caps_exceed_role_seek", + "an audience grant with the seek power, which its role does not hold (§4.3 step 7, §10.6)", + &one(&|b| with_powers(b, power::SEEK)), + refuse(1, Reason::CapsExceedRole), + )); + out.push(vector( + "grant_power_bit_5", + "a grant whose powers set bit 5, above the seek power's bit 4 (§4.3 step 5)", + &one(&|b| with_powers(b, 1 << 5)), + refuse(1, Reason::GrantInvalid), + )); out } @@ -1050,7 +1102,14 @@ fn moqt_json(run: &RoomRun) -> Value { Value::Array( run.grants .iter() - .map(|h| json!({"grant": hex(&h.digest), "reval": d.limits.reval_s, "scopes": moqt_scopes(d, &room, &h.grant).iter().map(scope_json).collect::>()})) + .map(|h| { + json!({ + "grant": hex(&h.digest), + "reval": d.limits.reval_s, + "seek": h.grant.caps.powers & power::SEEK != 0, + "scopes": moqt_scopes(d, &room, &h.grant).iter().map(scope_json).collect::>(), + }) + }) .collect(), ) } @@ -1287,6 +1346,87 @@ fn proof_outcome(v: &Value) -> Result, String> { Ok(check_proof(&dg, &pr, proof.as_deref(), &h32(s("challenge")?)?, &consumed, &context).map(|_| ())) } +// --------------------------------------------------------------------------------------- +// §10.6 Seeking back +// --------------------------------------------------------------------------------------- + +/// The vector whose grants the seek cases name. +const SEEK_ROOM: &str = "dvr"; + +/// One request on a track of the DVR room: the grant it rests on (its index in the room's +/// grants), the track's live edge, the start Location, whether it is a FETCH or a +/// SUBSCRIBE's fill, and the outcome. +struct SeekCase { + name: &'static str, + about: &'static str, + grant: usize, + live_edge: u64, + start: (u64, u64), + request: &'static str, + want: Result<(), Reason>, +} + +fn seek_cases() -> Vec { + let no = Err(Reason::SeekNotGranted); + let c = |name, about, grant, live_edge, start, request, want| SeekCase { name, about, grant, live_edge, start, request, want }; + vec![ + c("seek_fetch_back_granted", "viewer_a, whose grant holds the seek power, fetches from group 90 of a track whose live edge is group 120", 1, 120, (90, 0), "fetch", Ok(())), + c("seek_fetch_back_not_granted", "viewer_b, whose grant narrows the seek power away, fetches from group 90 below the live edge 120", 2, 120, (90, 0), "fetch", no), + c("seek_fetch_group_below_edge", "viewer_b fetches from object 7 of group 119, the group just below the live edge 120", 2, 120, (119, 7), "fetch", no), + c("seek_fetch_from_live_edge", "viewer_b fetches from the first object of the live edge's group, (120, 0): it does not seek back", 2, 120, (120, 0), "fetch", Ok(())), + c("seek_fetch_within_live_edge", "viewer_b fetches from (120, 3), within the live edge's group: it does not seek back", 2, 120, (120, 3), "fetch", Ok(())), + c("seek_fill_back_granted", "viewer_a subscribes with a fill from group 100, below the live edge 120", 1, 120, (100, 0), "fill", Ok(())), + c("seek_fill_back_not_granted", "viewer_b subscribes with a fill from group 100, below the live edge 120", 2, 120, (100, 0), "fill", no), + c("seek_fill_joins_live_edge", "viewer_b subscribes with a fill from (120, 0), joining the group in progress: it does not seek back", 2, 120, (120, 0), "fill", Ok(())), + c("seek_fetch_at_start", "viewer_b fetches from (0, 0) of a track whose live edge is group 0", 2, 0, (0, 0), "fetch", Ok(())), + c("seek_publisher_not_granted", "the host, a publisher whose role lacks the seek power, fetches from group 90 below the live edge 120", 0, 120, (90, 0), "fetch", no), + ] +} + +fn seek_json(c: &SeekCase, grants: &[HeldGrant]) -> Value { + let got = seek_check(grants[c.grant].grant.caps.powers, c.live_edge, c.start.0); + assert_eq!(got, c.want, "seek case {} does not reach the outcome it was written to have", c.name); + let mut o = Map::new(); + o.insert("name".into(), json!(c.name)); + o.insert("about".into(), json!(c.about)); + o.insert("vector".into(), json!(SEEK_ROOM)); + o.insert("grant".into(), json!(c.grant)); + o.insert("live_edge".into(), json!(c.live_edge)); + o.insert("start".into(), json!([c.start.0, c.start.1])); + o.insert("request".into(), json!(c.request)); + match c.want { + Ok(()) => { + o.insert("outcome".into(), json!("serve")); + } + Err(r) => { + o.insert("outcome".into(), json!("refuse")); + o.insert("reason".into(), json!(r.code())); + } + } + Value::Object(o) +} + +/// A seek case's outcome under this module, from what the file states. +fn seek_outcome(root: &Value, v: &Value) -> Result, String> { + let room = v["vector"].as_str().ok_or("vector is a string")?; + let vector = root["vectors"].as_array().and_then(|vs| vs.iter().find(|x| x["name"] == room)).ok_or_else(|| format!("no vector {room}"))?; + let run = run_of(vector).ok_or_else(|| format!("the vector {room} does not read"))?; + if !matches!(run.outcome, RoomOutcome::Accept) { + return Err(format!("the vector {room} is not accepted")); + } + let i = v["grant"].as_u64().ok_or("grant is an index")? as usize; + let h = run.grants.get(i).ok_or_else(|| format!("the vector {room} has no grant {i}"))?; + let live_edge = v["live_edge"].as_u64().ok_or("live_edge is an integer")?; + let start: Vec = v["start"].as_array().ok_or("start")?.iter().map(Value::as_u64).collect::>().ok_or("start is two integers")?; + if start.len() != 2 { + return Err("start is two integers".into()); + } + if !matches!(v["request"].as_str(), Some("fetch" | "fill")) { + return Err(format!("request {}", v["request"])); + } + Ok(seek_check(h.grant.caps.powers, live_edge, start[0])) +} + /// The vectors, in the order the file lists them. fn vectors() -> Vec { let mut v = good_vectors(); @@ -1299,6 +1439,10 @@ fn vectors() -> Vec { /// The corpus file, `vectors.json`. pub fn generate() -> String { + let b = dvr(); + let room = verify_room(&b.descriptor, &b.grants, b.head.as_deref(), &b.chains()); + assert!(matches!(room.outcome, RoomOutcome::Accept), "the DVR room is accepted"); + let seek: Vec = seek_cases().iter().map(|c| seek_json(c, &room.grants)).collect(); let doc = json!({ "corpus": "wai.live.room", "extension": "extensions/live-rooms.md", @@ -1310,6 +1454,7 @@ pub fn generate() -> String { }).collect::>(), "vectors": vectors().iter().map(vector_json).collect::>(), "proofs": proof_vectors().iter().map(proof_json).collect::>(), + "seek": seek, }); serde_json::to_string_pretty(&doc).expect("the corpus serialises") + "\n" } @@ -1369,5 +1514,30 @@ pub fn verify(doc: &str) -> Vec { Err(e) => fails.push(format!("{name}: {e}")), } } + if root["seek"].as_array().map_or(0, Vec::len) == 0 { + fails.push("the corpus holds no seek case".into()); + } + for v in root["seek"].as_array().into_iter().flatten() { + let name = v["name"].as_str().unwrap_or("?"); + let stated = match (v["outcome"].as_str(), v["reason"].as_str()) { + (Some("serve"), None) => Ok(()), + (Some("refuse"), Some(r)) => match Reason::from_code(r) { + Some(r) => Err(r), + None => { + fails.push(format!("{name}: no reason {r}")); + continue; + } + }, + _ => { + fails.push(format!("{name}: no outcome")); + continue; + } + }; + match seek_outcome(&root, v) { + Ok(got) if got == stated => {} + Ok(got) => fails.push(format!("{name}: {got:?}, the corpus states {stated:?}")), + Err(e) => fails.push(format!("{name}: {e}")), + } + } fails } diff --git a/wai/wai-rs/tests/live_room.rs b/wai/wai-rs/tests/live_room.rs index 6a74e8c93..00da60711 100644 --- a/wai/wai-rs/tests/live_room.rs +++ b/wai/wai-rs/tests/live_room.rs @@ -27,21 +27,24 @@ fn every_vector_gets_its_stated_outcome() { assert!(fails.is_empty(), "{}", fails.join("\n")); } -/// Every reason this extension adds has a vector, or a proof (§10.5), refused with it, and -/// the corpus has the five room shapes §12 names, accepted. +/// Every reason this extension adds has a vector, a proof (§10.5) or a request of `seek` +/// (§10.6) refused with it, and the corpus has the room shapes §12 names, accepted. #[test] fn the_corpus_covers_every_reason_and_room() { let doc: serde_json::Value = serde_json::from_str(&committed()).unwrap(); let vectors = doc["vectors"].as_array().unwrap(); let proofs = doc["proofs"].as_array().unwrap(); - let reasons: BTreeSet<&str> = vectors.iter().chain(proofs).filter_map(|v| v["reason"].as_str()).collect(); + let seek = doc["seek"].as_array().unwrap(); + let reasons: BTreeSet<&str> = vectors.iter().chain(proofs).chain(seek).filter_map(|v| v["reason"].as_str()).collect(); for r in Reason::ALL { assert!(reasons.contains(r.code()), "nothing in the corpus is refused with {r}"); } - for name in ["lesson", "broadcast", "takeover", "redundant_failover", "promote_viewer"] { + for name in ["lesson", "broadcast", "takeover", "redundant_failover", "promote_viewer", "dvr"] { let v = vectors.iter().find(|v| v["name"] == name).unwrap_or_else(|| panic!("no vector {name}")); assert_eq!(v["outcome"], "accept", "{name}"); } + let outcomes: BTreeSet<&str> = seek.iter().filter_map(|c| c["outcome"].as_str()).collect(); + assert_eq!(outcomes, BTreeSet::from(["refuse", "serve"]), "seek has requests served and refused"); } /// §11 of the extension lists exactly the codes `Reason::ALL` has, each once, in order. -- 2.54.0 (Apple Git-157) From f9f3cb9b3caf5038b99ee49ec52f21f16f184273 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:27:30 -0400 Subject: [PATCH 14/43] live-conformance/rooms: regenerate with the dvr room, the seek-power refusals and the seek section Every existing record and stated outcome is unchanged; each accepted grant's moqt entry gains its seek, false for every grant before the dvr room. Co-Authored-By: Claude Opus 5.5 --- wai/live-conformance/rooms/vectors.json | 643 ++++++++++++++++++++++++ 1 file changed, 643 insertions(+) diff --git a/wai/live-conformance/rooms/vectors.json b/wai/live-conformance/rooms/vectors.json index abe99dd3f..5c22690ff 100644 --- a/wai/live-conformance/rooms/vectors.json +++ b/wai/live-conformance/rooms/vectors.json @@ -246,6 +246,7 @@ { "grant": "b42f9cd478eafb3122efd0bca16746e00924722cd85a42ec10ab414cc10f59a3", "reval": 300, + "seek": false, "scopes": [ [ [ @@ -379,6 +380,7 @@ { "grant": "c9572d2b23648824de14dd5a375fb5cf305db9474b338f754c4b76cbb9de68e3", "reval": 300, + "seek": false, "scopes": [ [ [ @@ -524,6 +526,7 @@ { "grant": "1e86384f6141b38d5a34f34e0dc80d0bfeed4a1adc8c7eeb38bb357196587eb6", "reval": 300, + "seek": false, "scopes": [ [ [ @@ -611,6 +614,7 @@ { "grant": "035d9546c8fdcffa822fe69a3cb31d8646213891bf1fc88c0acb3b4846fe4a1a", "reval": 300, + "seek": false, "scopes": [ [ [ @@ -676,6 +680,7 @@ { "grant": "c43e1d0afa038c2a10ca7c5be4b1bf56ab408c91e5fda8d518b06220ecda89e2", "reval": 300, + "seek": false, "scopes": [ [ [ @@ -985,6 +990,7 @@ { "grant": "5d341a5f8f3665350011af6e1cb707f08a960df9e38cbbbb06f3eb61c0c92f89", "reval": 0, + "seek": false, "scopes": [ [ [ @@ -1120,6 +1126,7 @@ { "grant": "c8d72255cb0be291be12917055b21dc642ba9d469a24265a1ab95836d6ad1130", "reval": 0, + "seek": false, "scopes": [ [ [ @@ -1185,6 +1192,7 @@ { "grant": "595d14118a463c65e5de942c0276370d89c356e0e3f27a6db71b2cf967f83454", "reval": 0, + "seek": false, "scopes": [ [ [ @@ -1272,6 +1280,7 @@ { "grant": "4aa33bf3baf499bf721c9cbd59bb63ac31dd3788f17935d557aa5df05b08e9fd", "reval": 0, + "seek": false, "scopes": [ [ [ @@ -1359,6 +1368,7 @@ { "grant": "ba553a1a0d8e74ad9359d6cf08451c5d2830817e29d43a90432349ed87618c65", "reval": 0, + "seek": false, "scopes": [ [ [ @@ -1574,6 +1584,7 @@ { "grant": "3a83f68f0efedeb9ed22ef252574bc7fb60682dd5b77f0b28d85cbe0bdc4c9b6", "reval": 300, + "seek": false, "scopes": [ [ [ @@ -1741,6 +1752,7 @@ { "grant": "2642724ba54b0d20afc5b8fb1d20723691b6dd5db5002ff0ec97ae84526d3d24", "reval": 300, + "seek": false, "scopes": [ [ [ @@ -1898,6 +1910,7 @@ { "grant": "93d0a6e6b8cc6057d9cc605cd9cd251cc91521160595b03bc81289f5057be392", "reval": 300, + "seek": false, "scopes": [ [ [ @@ -2106,6 +2119,7 @@ { "grant": "b59957908d67c72552ec52e8a4b80ebedc51b4016b6fcdff3fa779b02c0db122", "reval": 300, + "seek": false, "scopes": [ [ [ @@ -2175,6 +2189,7 @@ { "grant": "beed3ce25265deff9dc89403006fb235bddf27025ef02c7ef7aa8ecae8038129", "reval": 300, + "seek": false, "scopes": [ [ [ @@ -2244,6 +2259,7 @@ { "grant": "b6c5d77d76d83c1fb8f250c399a13f4ab659a4f2dc31cda4623dc2a6d6b62caf", "reval": 300, + "seek": false, "scopes": [ [ [ @@ -2462,6 +2478,7 @@ { "grant": "063953f01aa48c3a682aa5edd52cc28a46e3c299b71c892d72b7cf4f575b4971", "reval": 300, + "seek": false, "scopes": [ [ [ @@ -2549,6 +2566,7 @@ { "grant": "a9bf151f689aab582c481fd84fb67e00598438c55f0bd556949ef3690736e0d7", "reval": 300, + "seek": false, "scopes": [ [ [ @@ -2636,6 +2654,7 @@ { "grant": "a71e15d272c944bc492f928feffd4cc621ef71fb2a11d2b6351e9393819729ed", "reval": 300, + "seek": false, "scopes": [ [ [ @@ -2758,6 +2777,447 @@ } ] }, + { + "name": "dvr", + "about": "a broadcast room whose relay keeps a DVR window of its tracks: one publisher, and an audience whose role holds the seek power; viewer_a's grant holds it, viewer_b's narrows it away, and neither changes the scopes a grant maps onto (§10.6)", + "descriptor": "5752443101988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c28589285988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c2858928500406cd8e95d060040420f000000000000004000000001003c0000005745503101fe1f0000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f00000000000204686f737407010102ae060000000000000000000000000000000000000000000000000000000000000861756469656e63650001011018000000000000000000000000000000000000000000000000000000000000000002067370656563680101046f707573000001000000000000100000004000000080bb0000010f0a63616d6572612d37323002020d617630312e302e30354d2e30380b617663312e363430303146010002800268011e0096000000f40100000005d0021e00f4010000dc05000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bc4c255eaa0749634be608c8320d808612400872db202823ec74d465c9becb11ffacdd924ccd9c16648c9923475ab6ddbb40748a20b332a1795bf561449b3207", + "grants": [ + "575247310142e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f302df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa100000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a0000000000070101020000000000000000000000000000000000000000000000000000000000000000005745503101ae060000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff000000020001011568747470733a2f2f72656c61792e6578616d706c650000d0c9d215ad64eb5afc961b55606e926f8a922e6ae19a025d6225503a7075b9d133e7caba10496df34856b43a00435a41c553d95890ff5acddbf48c398b2fd00d", + "575247310142e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c00000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a000000000100010110000000000000000000000000000000000000000000000000000000000000000000574550310118000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011568747470733a2f2f72656c61792e6578616d706c6500000ad775ed8db1bf53d940a7bfe523aff28eb0475002f776dbed0318244ebba8d7a6fe55f9636ee662887f16c7bb6ecb08c11420ad6df091f3d8b19e1826d4fb06", + "575247310142e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30f13c5bf5891361c68de9a2df70c5613d760e1dd3ca67ce44989f5168db2addfb00000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a000000000100010100000000000000000000000000000000000000000000000000000000000000000000574550310118000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011568747470733a2f2f72656c61792e6578616d706c650000c98eb5964d56909cf8af10108ff4671022d6343dd767d6a0b8d0034b5b965eb5ac4cb24326fe9e6d4052420080a1ab300bedbd52f36a3070fb9612a5c87b1608" + ], + "key_log_head": null, + "chains": [ + [ + "574556310142e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c28589285000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001040002df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1d8884208325cda028fdbe6283196ad444080a5468a2b050fb81b8d516f8664197c9b886bbe932c1c8e2b4f7970fd130ec0f4ee6550cbc83a1a27f0befabbfb45cd4dbfdd16d14f0e8df0926f10d8a737321705b64cd9daee4d0e0fcd73325708", + "574556310142e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c285892850100000000000000fc2a772987b8b5b6b3d8afc2b96715473d0b3ac37c42c0ed73a2b2a0e5b9a1730000000000000000001224002df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa100020102483c8c5d24ec0cf3b888e70f00f11b009ebb20a72086fe6d27ffcaa1a5ebd98fe783d7aa065758d34f9a9ff01ce071c0a723f82cbc097afad40e9ffa84ea0f0a", + "574556310142e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c2858928502000000000000008a943f2eb02f35cfcb7828759cf1ea63cb4a409d7364d8eb160c6dbfc9739ab10000000000000000001224002df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa10001000196eb73e59e00586cc2a0f25caf9ed406fc09421ef2e8e2e11ce1ea918b17f0eda0eff60e0db5fa0ad1871214d616500e63494d1d6c6e6b33d25feda18209c90d", + "574556310142e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c285892850300000000000000f4415c544158c92ce7547b0b14e18e28baf2649143a0ac3b468110e15d7add0c40420f000000000000104000fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c3c95d910abab400e1dc60f12cdcd0d821af9559858c210627b1488be3b2d5ab36f52c5354c092591361a6a6a66658383717345ce1fdb8a89f9b3661c9a37c9bb62c2eb0cc22f3190e5e7f957296c99e0a90e2144a5f6008d1ac2a10ded24330a", + "574556310142e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c28589285040000000000000000b53c51fe674f092469ee0c6e2f439a644ba857b902a31cf95609b3b572597380841e000000000000104000f13c5bf5891361c68de9a2df70c5613d760e1dd3ca67ce44989f5168db2addfb819a76183e7b5816b8089d3e9677fd8262fcbd119e4d9cb285be1afff653bccddb8e4e875e8b38d4e14bfa1c3b51f1f2f4f2ff06ed022ec9d76ee3f608b26684c06f7467cbab77f73573656ccdcefaa2e980e7924aa92955c6beb2a4d0c66b04" + ], + [ + "574556310142e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c00000000000000000000000000000000000000000000000000000000000000000000000000000000c0c62d000000000000031b001900676f696e67206261636b20746f20746865206f70656e696e67baf0aaf6764cc7498145150e63aa5f80a729e6b9e5fe6454d920c485c71eaa5f85652e860bd0daa8ccad048b6fcee98c3ac413b9a8cee0bdf55b4a42a9482305" + ], + [ + "574556310142e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30f13c5bf5891361c68de9a2df70c5613d760e1dd3ca67ce44989f5168db2addfb0000000000000000000000000000000000000000000000000000000000000000000000000000000000093d00000000000004040004010000e6675ecedaa3ae755e06e05337fc245b14d8b6588b0a452336c4ae54ee68f8698e89ad9d8fb4356a47cf779b1b9a71b52b5477020a4075150c5a2b8af8db6907" + ] + ], + "outcome": "accept", + "room": "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30", + "grant_digests": [ + "d8884208325cda028fdbe6283196ad444080a5468a2b050fb81b8d516f866419", + "3c95d910abab400e1dc60f12cdcd0d821af9559858c210627b1488be3b2d5ab3", + "819a76183e7b5816b8089d3e9677fd8262fcbd119e4d9cb285be1afff653bccd" + ], + "order": [ + [ + 0, + 0, + null + ], + [ + 0, + 1, + null + ], + [ + 0, + 2, + null + ], + [ + 0, + 3, + null + ], + [ + 0, + 4, + null + ], + [ + 1, + 0, + "3c95d910abab400e1dc60f12cdcd0d821af9559858c210627b1488be3b2d5ab3" + ], + [ + 2, + 0, + "819a76183e7b5816b8089d3e9677fd8262fcbd119e4d9cb285be1afff653bccd" + ] + ], + "state": { + "present": [ + { + "principal": "2df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1", + "grant": "d8884208325cda028fdbe6283196ad444080a5468a2b050fb81b8d516f866419" + }, + { + "principal": "f13c5bf5891361c68de9a2df70c5613d760e1dd3ca67ce44989f5168db2addfb", + "grant": "819a76183e7b5816b8089d3e9677fd8262fcbd119e4d9cb285be1afff653bccd" + }, + { + "principal": "fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c", + "grant": "3c95d910abab400e1dc60f12cdcd0d821af9559858c210627b1488be3b2d5ab3" + } + ], + "published": [ + { + "principal": "2df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1", + "media": "audio", + "profile": "speech", + "layers": 1 + }, + { + "principal": "2df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1", + "media": "video", + "profile": "camera-720", + "layers": 2 + } + ], + "slots": [], + "tracks": [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30-2df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1-audio--0", + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30-2df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1-video--0", + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30-2df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1-video--1" + ], + "timeline": [ + { + "principal": "2df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1", + "grant": "d8884208325cda028fdbe6283196ad444080a5468a2b050fb81b8d516f866419", + "t": 0 + }, + { + "principal": "fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c", + "grant": "3c95d910abab400e1dc60f12cdcd0d821af9559858c210627b1488be3b2d5ab3", + "t": 1000000 + }, + { + "principal": "f13c5bf5891361c68de9a2df70c5613d760e1dd3ca67ce44989f5168db2addfb", + "grant": "819a76183e7b5816b8089d3e9677fd8262fcbd119e4d9cb285be1afff653bccd", + "t": 2000000 + } + ] + }, + "tally": { + "state_hash": "771b30268d4b5a93046d9e87dbbc10bae6fc76c4c2d5dde9071f801bfdb9a68d", + "polls": [] + }, + "moqt": [ + { + "grant": "d8884208325cda028fdbe6283196ad444080a5468a2b050fb81b8d516f866419", + "reval": 60, + "seek": false, + "scopes": [ + [ + [ + 0 + ] + ], + [ + [ + 2, + 6 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30", + "2df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1", + "audio", + null + ] + ], + [ + [ + 2, + 6 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30", + "2df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1", + "video", + null + ] + ], + [ + [ + 2, + 6 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30", + "2df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1", + "screen", + null + ] + ], + [ + [ + 2, + 6 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30", + "2df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1", + null + ], + "catalog" + ], + [ + [ + 2, + 6 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30", + "2df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa1", + "events", + null + ], + "0" + ], + [ + [ + 3 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30" + ] + ], + [ + [ + 4, + 7, + 8 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30" + ], + "catalog" + ], + [ + [ + 4, + 7, + 8 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30" + ], + "0" + ], + [ + [ + 4, + 7, + 8 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30" + ], + "0_receipts" + ], + [ + [ + 4, + 7, + 8 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30" + ], + "1" + ], + [ + [ + 4, + 7, + 8 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30" + ], + "1_receipts" + ] + ] + }, + { + "grant": "3c95d910abab400e1dc60f12cdcd0d821af9559858c210627b1488be3b2d5ab3", + "reval": 60, + "seek": true, + "scopes": [ + [ + [ + 0 + ] + ], + [ + [ + 2, + 6 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30", + "fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c", + "events", + null + ], + "0" + ], + [ + [ + 3 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30" + ] + ], + [ + [ + 4, + 7, + 8 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30" + ], + "catalog" + ], + [ + [ + 4, + 7, + 8 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30" + ], + "0" + ], + [ + [ + 4, + 7, + 8 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30" + ], + "0_receipts" + ], + [ + [ + 4, + 7, + 8 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30" + ], + "1" + ], + [ + [ + 4, + 7, + 8 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30" + ], + "1_receipts" + ] + ] + }, + { + "grant": "819a76183e7b5816b8089d3e9677fd8262fcbd119e4d9cb285be1afff653bccd", + "reval": 60, + "seek": false, + "scopes": [ + [ + [ + 0 + ] + ], + [ + [ + 2, + 6 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30", + "f13c5bf5891361c68de9a2df70c5613d760e1dd3ca67ce44989f5168db2addfb", + "events", + null + ], + "0" + ], + [ + [ + 3 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30" + ] + ], + [ + [ + 4, + 7, + 8 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30" + ], + "catalog" + ], + [ + [ + 4, + 7, + 8 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30" + ], + "0" + ], + [ + [ + 4, + 7, + 8 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30" + ], + "0_receipts" + ], + [ + [ + 4, + 7, + 8 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30" + ], + "1" + ], + [ + [ + 4, + 7, + 8 + ], + [ + "42e9ce241ec6706a5ae1fe04e6dc11b273563f8bc2ff2892f7c958d73cda9f30" + ], + "1_receipts" + ] + ] + } + ] + }, { "name": "room_gap", "about": "a viewer's chain whose record seq 1 was not delivered: a delivery gap the room reports, not a refusal (§9.4)", @@ -2923,6 +3383,19 @@ "descriptor" ] }, + { + "name": "descriptor_role_power_bit_5", + "about": "a role whose powers set bit 5, above the seek power's bit 4 (§3.4)", + "descriptor": "5752443101988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c28589285c73c9d1dc51b9a83c180ec0bd17f1c8520c0ab70c4874225109cadb2d493ec1b00406cd8e95d060040420f0000000000150068747470733a2f2f726f6f6d732e6578616d706c650800000003003c0000005745503101fe1f0000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f00000000000504686f737407010102ae06000000000000000000000000000000000000000000000000000000000000096d6f64657261746f720001000108080000000000000000000000000000000000000000000000000000000000000861756469656e636500010120580000000000000000000000000000000000000000000000000000000000000005677565737403010100580100000000000000000000000000000000000000000000000000000000000007656e636f64657203000000000000000000000000000000000000000000000000000000000000000000000002057374616765020100000000046d61696e0202d007000002067370656563680101046f707573000001000000000000100000004000000080bb0000010f0a63616d6572612d37323002020d617630312e302e30354d2e30380b617663312e363430303146010002800268011e0096000000f40100000005d0021e00f4010000dc05000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fbb6c9609b54f4047fbc189ee6379b357cda6018634b98d318c633dcc1c0c95182c8c41665da6afc13864a6c84725ece1145fdcc04c030bf489ee0e721847f0b", + "grants": [], + "key_log_head": "{\"grant_max_s\":86400,\"issued_at\":\"2026-10-15T00:00:00Z\",\"keys\":[{\"compromised_since\":null,\"id\":\"room-authority\",\"key\":\"988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c28589285\",\"may_sign\":[\"live/room\"],\"status\":\"active\",\"until\":null}],\"kind\":\"wai-key-set\",\"next_root\":\"3328656f3df5d9617e108d46080e23610712c9979d4437e706240f53d6cadf7b\",\"origin\":\"https://rooms.example\",\"prev\":null,\"revoked_modules\":[],\"root\":\"687972706312d9675734bf13b4bcf695ecb1b374de7809242aa91f130c6a1c0c\",\"seq\":0,\"sig\":\"ba55b157ebe88fc97f2b5a5e3556415563552ed88b9931060b9e99ae0a7decefdc63e2cb916b9a5d03c80a0c1823de14e7d8d3fe0b726c2464d823611780290a\",\"status_max_age_s\":3600}", + "chains": [], + "outcome": "refuse", + "reason": "descriptor_invalid", + "at": [ + "descriptor" + ] + }, { "name": "key_log_missing", "about": "a descriptor that names an origin, and no head of its key log (§5)", @@ -3158,6 +3631,40 @@ 1 ] }, + { + "name": "caps_exceed_role_seek", + "about": "an audience grant with the seek power, which its role does not hold (§4.3 step 7, §10.6)", + "descriptor": "5752443101988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c28589285c73c9d1dc51b9a83c180ec0bd17f1c8520c0ab70c4874225109cadb2d493ec1b00406cd8e95d060040420f0000000000150068747470733a2f2f726f6f6d732e6578616d706c650800000003003c0000005745503101fe1f0000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f00000000000504686f737407010102ae06000000000000000000000000000000000000000000000000000000000000096d6f64657261746f720001000108080000000000000000000000000000000000000000000000000000000000000861756469656e636500010100580000000000000000000000000000000000000000000000000000000000000005677565737403010100580100000000000000000000000000000000000000000000000000000000000007656e636f64657203000000000000000000000000000000000000000000000000000000000000000000000002057374616765020100000000046d61696e0202d007000002067370656563680101046f707573000001000000000000100000004000000080bb0000010f0a63616d6572612d37323002020d617630312e302e30354d2e30380b617663312e363430303146010002800268011e0096000000f40100000005d0021e00f4010000dc0500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000086c97e1a4b2609cb3438fd6c165e0bdc6f016bae7d16c4d37a387f4be3891b46f97c63fbf4814de11aadb3ee409d246a182017b43cacb6a6c64905bceb31c30b", + "grants": [ + "575247310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a2036872df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa100000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a0000000000070101020000000000000000000000000000000000000000000000000000000000000000005745503101ae060000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011568747470733a2f2f72656c61792e6578616d706c65000008067f81349126761a6b7fe0cf3bd66d94800e6a65dc2f9d1d07de1a362f6d95bd8122dad96eed33117981aa9923f907d8a1a236fcf8c14579e586cbe500470b", + "575247310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c00000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a000000000200010110000000000000000000000000000000000000000000000000000000000000000000574550310158000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011568747470733a2f2f72656c61792e6578616d706c650000d75a143d5f0871d61dea5c2b87d6116c81648038b2c7d007c267a287bc9547ba00f12f1d0087db07ff3cb9b43085a3157cd96113a2b2d29e24440aa75ce08d0b" + ], + "key_log_head": "{\"grant_max_s\":86400,\"issued_at\":\"2026-10-15T00:00:00Z\",\"keys\":[{\"compromised_since\":null,\"id\":\"room-authority\",\"key\":\"988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c28589285\",\"may_sign\":[\"live/room\"],\"status\":\"active\",\"until\":null}],\"kind\":\"wai-key-set\",\"next_root\":\"3328656f3df5d9617e108d46080e23610712c9979d4437e706240f53d6cadf7b\",\"origin\":\"https://rooms.example\",\"prev\":null,\"revoked_modules\":[],\"root\":\"687972706312d9675734bf13b4bcf695ecb1b374de7809242aa91f130c6a1c0c\",\"seq\":0,\"sig\":\"ba55b157ebe88fc97f2b5a5e3556415563552ed88b9931060b9e99ae0a7decefdc63e2cb916b9a5d03c80a0c1823de14e7d8d3fe0b726c2464d823611780290a\",\"status_max_age_s\":3600}", + "chains": [], + "outcome": "refuse", + "reason": "caps_exceed_role", + "at": [ + "grant", + 1 + ] + }, + { + "name": "grant_power_bit_5", + "about": "a grant whose powers set bit 5, above the seek power's bit 4 (§4.3 step 5)", + "descriptor": "5752443101988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c28589285c73c9d1dc51b9a83c180ec0bd17f1c8520c0ab70c4874225109cadb2d493ec1b00406cd8e95d060040420f0000000000150068747470733a2f2f726f6f6d732e6578616d706c650800000003003c0000005745503101fe1f0000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f00000000000504686f737407010102ae06000000000000000000000000000000000000000000000000000000000000096d6f64657261746f720001000108080000000000000000000000000000000000000000000000000000000000000861756469656e636500010100580000000000000000000000000000000000000000000000000000000000000005677565737403010100580100000000000000000000000000000000000000000000000000000000000007656e636f64657203000000000000000000000000000000000000000000000000000000000000000000000002057374616765020100000000046d61696e0202d007000002067370656563680101046f707573000001000000000000100000004000000080bb0000010f0a63616d6572612d37323002020d617630312e302e30354d2e30380b617663312e363430303146010002800268011e0096000000f40100000005d0021e00f4010000dc0500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000086c97e1a4b2609cb3438fd6c165e0bdc6f016bae7d16c4d37a387f4be3891b46f97c63fbf4814de11aadb3ee409d246a182017b43cacb6a6c64905bceb31c30b", + "grants": [ + "575247310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a2036872df53b5dcc0c0ad294a87b904069ccadbde41494fa7e39cbf6720e1b2d5c5aa100000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a0000000000070101020000000000000000000000000000000000000000000000000000000000000000005745503101ae060000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011568747470733a2f2f72656c61792e6578616d706c65000008067f81349126761a6b7fe0cf3bd66d94800e6a65dc2f9d1d07de1a362f6d95bd8122dad96eed33117981aa9923f907d8a1a236fcf8c14579e586cbe500470b", + "575247310191ac4a45ebdee375b286d920fb2d07f064a31e7b7243829126eee2e27a203687fcb90cb92576cc567d08bedfa255bb94366c5b882a03ce247745dfd0e32ab09c00000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a000000000200010120000000000000000000000000000000000000000000000000000000000000000000574550310158000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011568747470733a2f2f72656c61792e6578616d706c650000d3bf7c8cd4211db0b5d5674bf38dc1d7ced502fd03ae3cffc744ab2b4d37bd885610003065c55086a153598ff445f733a8f6d89f8f11d6e398083dd71c7bcd0b" + ], + "key_log_head": "{\"grant_max_s\":86400,\"issued_at\":\"2026-10-15T00:00:00Z\",\"keys\":[{\"compromised_since\":null,\"id\":\"room-authority\",\"key\":\"988ec20b60d2d23c4c475c1f4e623f115ffbe7659bd0c81c0af9233c28589285\",\"may_sign\":[\"live/room\"],\"status\":\"active\",\"until\":null}],\"kind\":\"wai-key-set\",\"next_root\":\"3328656f3df5d9617e108d46080e23610712c9979d4437e706240f53d6cadf7b\",\"origin\":\"https://rooms.example\",\"prev\":null,\"revoked_modules\":[],\"root\":\"687972706312d9675734bf13b4bcf695ecb1b374de7809242aa91f130c6a1c0c\",\"seq\":0,\"sig\":\"ba55b157ebe88fc97f2b5a5e3556415563552ed88b9931060b9e99ae0a7decefdc63e2cb916b9a5d03c80a0c1823de14e7d8d3fe0b726c2464d823611780290a\",\"status_max_age_s\":3600}", + "chains": [], + "outcome": "refuse", + "reason": "grant_invalid", + "at": [ + "grant", + 1 + ] + }, { "name": "event_bad_signature", "about": "one bit of a viewer's event signature flipped (live-events §3.2)", @@ -4344,5 +4851,141 @@ "outcome": "refuse", "reason": "pop_wrong_context" } + ], + "seek": [ + { + "name": "seek_fetch_back_granted", + "about": "viewer_a, whose grant holds the seek power, fetches from group 90 of a track whose live edge is group 120", + "vector": "dvr", + "grant": 1, + "live_edge": 120, + "start": [ + 90, + 0 + ], + "request": "fetch", + "outcome": "serve" + }, + { + "name": "seek_fetch_back_not_granted", + "about": "viewer_b, whose grant narrows the seek power away, fetches from group 90 below the live edge 120", + "vector": "dvr", + "grant": 2, + "live_edge": 120, + "start": [ + 90, + 0 + ], + "request": "fetch", + "outcome": "refuse", + "reason": "seek_not_granted" + }, + { + "name": "seek_fetch_group_below_edge", + "about": "viewer_b fetches from object 7 of group 119, the group just below the live edge 120", + "vector": "dvr", + "grant": 2, + "live_edge": 120, + "start": [ + 119, + 7 + ], + "request": "fetch", + "outcome": "refuse", + "reason": "seek_not_granted" + }, + { + "name": "seek_fetch_from_live_edge", + "about": "viewer_b fetches from the first object of the live edge's group, (120, 0): it does not seek back", + "vector": "dvr", + "grant": 2, + "live_edge": 120, + "start": [ + 120, + 0 + ], + "request": "fetch", + "outcome": "serve" + }, + { + "name": "seek_fetch_within_live_edge", + "about": "viewer_b fetches from (120, 3), within the live edge's group: it does not seek back", + "vector": "dvr", + "grant": 2, + "live_edge": 120, + "start": [ + 120, + 3 + ], + "request": "fetch", + "outcome": "serve" + }, + { + "name": "seek_fill_back_granted", + "about": "viewer_a subscribes with a fill from group 100, below the live edge 120", + "vector": "dvr", + "grant": 1, + "live_edge": 120, + "start": [ + 100, + 0 + ], + "request": "fill", + "outcome": "serve" + }, + { + "name": "seek_fill_back_not_granted", + "about": "viewer_b subscribes with a fill from group 100, below the live edge 120", + "vector": "dvr", + "grant": 2, + "live_edge": 120, + "start": [ + 100, + 0 + ], + "request": "fill", + "outcome": "refuse", + "reason": "seek_not_granted" + }, + { + "name": "seek_fill_joins_live_edge", + "about": "viewer_b subscribes with a fill from (120, 0), joining the group in progress: it does not seek back", + "vector": "dvr", + "grant": 2, + "live_edge": 120, + "start": [ + 120, + 0 + ], + "request": "fill", + "outcome": "serve" + }, + { + "name": "seek_fetch_at_start", + "about": "viewer_b fetches from (0, 0) of a track whose live edge is group 0", + "vector": "dvr", + "grant": 2, + "live_edge": 0, + "start": [ + 0, + 0 + ], + "request": "fetch", + "outcome": "serve" + }, + { + "name": "seek_publisher_not_granted", + "about": "the host, a publisher whose role lacks the seek power, fetches from group 90 below the live edge 120", + "vector": "dvr", + "grant": 0, + "live_edge": 120, + "start": [ + 90, + 0 + ], + "request": "fetch", + "outcome": "refuse", + "reason": "seek_not_granted" + } ] } -- 2.54.0 (Apple Git-157) From 78b45a5d1d257585971856054f3276453196f20f Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:28:58 -0400 Subject: [PATCH 15/43] =?UTF-8?q?live=5Frooms=5Fverify.py=20and=20the=20ro?= =?UTF-8?q?oms=20README:=20the=20seek=20power=20and=20=C2=A710.6's=20seek?= =?UTF-8?q?=20requests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second implementation allows powers bit 4, states each accepted grant's seek, and gives every request of seek its outcome by §10.6's rule from the room's grants as §4.3 checks them; the README describes the dvr room, the new refusals and the seek section. Co-Authored-By: Claude Opus 5.5 --- wai/live-conformance/live_rooms_verify.py | 73 +++++++++++++++++++---- wai/live-conformance/rooms/README.md | 53 +++++++++++----- 2 files changed, 100 insertions(+), 26 deletions(-) diff --git a/wai/live-conformance/live_rooms_verify.py b/wai/live-conformance/live_rooms_verify.py index 5f4f91dba..1a3834268 100644 --- a/wai/live-conformance/live_rooms_verify.py +++ b/wai/live-conformance/live_rooms_verify.py @@ -22,12 +22,15 @@ written from its text alone. Everything else is here: outcome, and the `moqt` scopes of each grant (§10.2), with the catalog and receipts tracks of §6.1; - proof of possession (§10.5): the proof record, its base64url header form, and the seven - checks of §10.5.4 in their order. + checks of §10.5.4 in their order; +- seeking back (§10.6): a request that starts in a group below the track's live edge is + served only under a grant whose powers have bit 4, seek. For every vector it requires the outcome, reason and place, and for one that does not refuse the room id, the gaps or the grant digests, merged order, state, tally and scopes -the corpus states; for every proof its outcome and reason; and of the corpus, that every -reason of §11 refuses a vector or a proof. +the corpus states, with each grant's seek power; for every proof and every request of +`seek` its outcome and reason; and of the corpus, that every reason of §11 refuses a +vector, a proof or a request. python3 live_rooms_verify.py @@ -58,7 +61,8 @@ AUTHORITY = "live/room" MAX_GRANT_S = 14 * 86400 NO_CEILING = 2 ** 64 - 1 MEDIA = {1: "audio", 2: "video", 3: "screen"} -MODERATE, DIRECT, COMPOSE, RECORD = 1, 2, 4, 8 +MODERATE, DIRECT, COMPOSE, RECORD, SEEK = 1, 2, 4, 8, 16 +POWERS = MODERATE | DIRECT | COMPOSE | RECORD | SEEK # §3.4, §4.3 step 5: none above bit 4 CONTROL, DECISION = 0x09, 0x0B JOIN, LEAVE, PUBLISH, UNPUBLISH, EXCHANGE, TAKEOVER, FAILOVER = range(0x10, 0x17) @@ -73,7 +77,7 @@ REASONS = [ "not_published", "slot_unknown", "slot_full", "publisher_limit", "exchange_not_current", "exchange_off_tick", "takeover_not_higher", "failover_not_standby", "failover_early", "pop_missing", "pop_malformed", "pop_wrong_grant", "pop_wrong_key", "pop_bad_signature", - "pop_replayed", "pop_wrong_challenge", "pop_wrong_context", + "pop_replayed", "pop_wrong_challenge", "pop_wrong_context", "seek_not_granted", ] @@ -277,7 +281,7 @@ def descriptor_ok(d): if len(set(names)) != len(names): return False for x in d["roles"]: - if not is_name(x["name"]) or x["subscribe"] > 1 or x["publish"] & ~0b111 or x["powers"] & ~0b1111: + if not is_name(x["name"]) or x["subscribe"] > 1 or x["publish"] & ~0b111 or x["powers"] & ~POWERS: return False if x["subscribe"] == 0 and x["max_layer"] != 0: return False @@ -370,7 +374,7 @@ def grant_fields_ok(g, d, held): return False if g["role"] >= len(d["roles"]) or g["subscribe"] > 1: return False - if g["publish"] & ~0b111 or g["powers"] & ~0b1111 or (g["subscribe"] == 0 and g["max_layer"] != 0): + if g["publish"] & ~0b111 or g["powers"] & ~POWERS or (g["subscribe"] == 0 and g["max_layer"] != 0): return False try: policy = LEV.read_policy(g["policy_bytes"]) @@ -902,6 +906,33 @@ def proof_outcome(v): return {"outcome": "accept"} +# ---------------------------------------------------------------- §10.6 seeking back + +def seeks_back(live_edge, start): + """§10.6: a request seeks back when its start Location (g, o) has g below the live + edge L, the group of the track's Largest Object; one from (L, o) does not.""" + return start[0] < live_edge + + +def seek_outcome(doc, v): + """A request of `seek`: the grant it names, by vector and index, from that room's + grants as §4.3 checks them, then §10.6's rule.""" + room = next(x for x in doc["vectors"] if x["name"] == v["vector"]) + d = read_descriptor(bytes.fromhex(room["descriptor"])) + held, bad = check_grants(d, [bytes.fromhex(g) for g in room["grants"]]) + if bad: + raise ValueError("the room's grants do not check: %s" % (bad,)) + g = held[v["grant"]] + if v["request"] not in ("fetch", "fill"): + raise ValueError("a request is a fetch or a fill") + start = v["start"] + if not (isinstance(start, list) and len(start) == 2 and all(is_int(x) for x in start) and is_int(v["live_edge"])): + raise ValueError("a live edge and a start Location of integers") + if seeks_back(v["live_edge"], start) and not g["powers"] & SEEK: + return {"outcome": "refuse", "reason": "seek_not_granted"} + return {"outcome": "serve"} + + # ---------------------------------------------------------------- §9 room verification def authority_policy(): @@ -963,7 +994,8 @@ def verify_room(descriptor, grants, head, chains): return {"outcome": "refuse", "reason": "tally_mismatch", "at": ["record", c, i]} return {"outcome": "accept", "room": d["room"].hex(), "grant_digests": [g["digest"].hex() for g in held], "order": order, "state": state_of(d, room), "tally": LEV.tally(ver.accepted), - "moqt": [{"grant": g["digest"].hex(), "reval": d["reval"], "scopes": scopes(d, g)} for g in held]} + "moqt": [{"grant": g["digest"].hex(), "reval": d["reval"], "seek": g["powers"] & SEEK != 0, + "scopes": scopes(d, g)} for g in held]} def step(d, room, ver, seen, auth, b): @@ -1072,17 +1104,36 @@ def main(corpus_dir): print(("PASS" if ok else "FAIL") + " proof %s: %s" % (v["name"], got["outcome"] + (" " + got["reason"] if "reason" in got else ""))) if not ok: fails.append("%s: %s, the corpus states %s" % (v["name"], got, want)) + seek = doc.get("seek", []) + if not seek: + fails.append("the corpus holds no seek request") + for v in seek: + try: + got = seek_outcome(doc, v) + except (ValueError, StopIteration, IndexError, KeyError, TypeError, Refuse) as e: + got = {"outcome": "unreadable: %r" % (e,)} + want = {k: v[k] for k in ("outcome", "reason") if k in v} + if want.get("reason"): + reasons_seen.add(want["reason"]) + ok = got == want + print(("PASS" if ok else "FAIL") + " seek %s: %s" % (v["name"], got["outcome"] + (" " + got["reason"] if "reason" in got else ""))) + if not ok: + fails.append("%s: %s, the corpus states %s" % (v["name"], got, want)) + if {v.get("outcome") for v in seek} != {"serve", "refuse"}: + fails.append("seek does not hold both a request served and one refused") missing = [r for r in REASONS if r not in reasons_seen] if missing: fails.append("no vector refused with %s" % ", ".join(missing)) - rooms = ["lesson", "broadcast", "takeover", "redundant_failover", "promote_viewer"] + rooms = ["lesson", "broadcast", "takeover", "redundant_failover", "promote_viewer", "dvr"] absent = [r for r in rooms if outcomes.get(r) != "accept"] if absent: fails.append("rooms not accepted: %s" % ", ".join(absent)) - print("%s every reason of §11 refuses a vector, and every room of §12 is accepted" % ("PASS" if not (missing or absent) else "FAIL")) + print("%s every reason of §11 refuses a vector, a proof or a request, and every room of §12 is accepted" + % ("PASS" if not (missing or absent) else "FAIL")) for f in fails: print("FAIL " + f) - print(("PASS" if not fails else "FAIL") + " live-rooms corpus: %d vectors, %d proofs" % (len(doc["vectors"]), len(proofs))) + print(("PASS" if not fails else "FAIL") + " live-rooms corpus: %d vectors, %d proofs, %d seek requests" + % (len(doc["vectors"]), len(proofs), len(seek))) return 1 if fails else 0 diff --git a/wai/live-conformance/rooms/README.md b/wai/live-conformance/rooms/README.md index 2a9caf4bc..f70c81f5e 100644 --- a/wai/live-conformance/rooms/README.md +++ b/wai/live-conformance/rooms/README.md @@ -17,10 +17,16 @@ records in stream order) reaches the outcome the vector states (§9.4): (§9.2), each entry `[chain, record, grant]` with the digest of the grant the record was verified under, or `null` for a room key's record; the room `state` (§9.3); the `tally` of the room's polls (live-events §7); and, per grant, the `moqt` scopes and `reval` - interval it maps onto (§10.2). + interval it maps onto (§10.2), and whether it holds the seek power, `seek` (§3.3). -A verifier that reaches another outcome, reason, place, gap, digest, order, state, tally -or scope does not conform. +A verifier that reaches another outcome, reason, place, gap, digest, order, state, tally, +scope or seek power does not conform. + +**Seeking back.** For each request of `seek`, a conforming relay, given the grant the +request names, the track's live edge and the request's start Location, serves it or +refuses it `seek_not_granted` as the request states (§10.6): a request whose start group +is below the live edge seeks back, and is served only under a grant whose `powers` has +bit 4. **Provenance.** Generated here: `wai_live_room_vectors gen` writes every descriptor, grant, key-log head and record from rooms authored in code, from fixed seeds. Each key is SHA-256 @@ -50,6 +56,11 @@ One file, `vectors.json`: verifier holds: the `challenge` it issued for this session or request, the `consumed` challenges, and the `verb` and `target` it received; and the `outcome`, `accept` or `refuse` with its `reason` (§10.5.4). +- `seek` (§10.6): each with `name`, `about`, the grant the request rests on — `vector`, the + name of the accepted room, and `grant`, its index among that room's `grants` — the + track's `live_edge` (the group of its Largest Object), the request's `start` Location + `[group, object]`, the `request`, `fetch` (a FETCH) or `fill` (a SUBSCRIBE's fill), and + the `outcome`, `serve` or `refuse` with its `reason`. `state` has `present` (each principal and its grant in effect, by principal), `published` (each own track: principal, media kind, profile name, number of layers, by principal and @@ -64,16 +75,20 @@ Vectors: under single-use grants, review and recording policies with a region pin, a key-log head revoking a grant and a viewer the room never admits), `broadcast` (one publisher, viewers voting, a moderator's decision, no key log, the authority its own clock), - `takeover`, `redundant_failover` and `promote_viewer`; + `takeover`, `redundant_failover`, `promote_viewer`, and `dvr` (a broadcast whose relay + keeps a DVR window: one publisher, and an audience whose role holds the seek power, + viewer_a's grant holding it and viewer_b's narrowing it away; its grants are 0 the + host's, 1 viewer_a's, 2 viewer_b's); - `room_gap`: a chain with a record not delivered; - one negative control per refusal, each a single defect: the descriptor truncated, with trailing bytes, of another version, with a flipped signature bit, with a tick of 0, - with a registered profile changed, with a role beyond the ceiling, and with a recording - policy whose jitter exceeds its commit interval; the key log - missing, of another origin, re-signed wrongly, not listing the authority, and revoking - it; a grant truncated, signed by another key, of another room, permitting a lifecycle + with a registered profile changed, with a role beyond the ceiling, with a recording + policy whose jitter exceeds its commit interval, and with a role whose powers set bit 5; + the key log missing, of another origin, re-signed wrongly, not listing the authority, + and revoking it; a grant truncated, signed by another key, of another room, permitting a lifecycle kind, with oversized attributes, too long, beyond its role, beyond the room's ceiling, - chained to nothing, and chained to another principal's; and in the records, a bad + chained to nothing, chained to another principal's, with the seek power its role does + not hold, and with powers setting bit 5; and in the records, a bad signature, the wrong room, a kind not in the grant, a participant's lifecycle event, events outside a grant's window, a principal not present, a revoked grant and a revoked viewer, a reused single-use grant, an unknown grant, another principal's grant, @@ -87,7 +102,14 @@ Vectors: signed by the moderator's, one naming the moderator's grant, one for another relay, and three records that do not read; over HTTP, a WHIP `POST` and the session's `DELETE` accepted, and refused the Bearer token alone, the `POST`'s proof sent again, a `POST`'s - proof on a `DELETE`, and a MOQT proof presented over HTTP. + proof on a `DELETE`, and a MOQT proof presented over HTTP; +- seek requests, under the `dvr` room's grants, on a track whose live edge is group 120 + unless stated: served, a FETCH from group 90 and a fill from group 100 under viewer_a's + grant, which holds the seek power; refused `seek_not_granted`, the same FETCH and fill + under viewer_b's, which does not, a FETCH from (119, 7) under it, and a FETCH from group + 90 under the host's, whose role lacks the power; and served under viewer_b's, a FETCH + from (120, 0) and from (120, 3), a fill from (120, 0) joining the group in progress, and + a FETCH from (0, 0) of a track whose live edge is group 0. ## Running @@ -101,10 +123,11 @@ python3 live_rooms_verify.py rooms ``` `wai_live_room_vectors verify` and `tests/live_room.rs` require the file to regenerate -byte for byte and `wai-rs` to give every vector and every proof its outcome; the test also -requires every reason of §11 to refuse a vector or a proof and §11 to list exactly the -reasons the reference reports. +byte for byte and `wai-rs` to give every vector, every proof and every seek request its +outcome; the test also requires every reason of §11 to refuse a vector, a proof or a seek +request, `seek` to hold requests served and refused, and §11 to list exactly the reasons +the reference reports. `live_rooms_verify.py` is the second implementation, written from the extension's text and sharing no code with `wai-rs`: it verifies every event with `live_events_verify.py`, the -live-events corpus's own second implementation, gives every vector the same outcome, and -requires the same coverage. Pass = an exit status of 0 from each. +live-events corpus's own second implementation, gives every vector, proof and seek request +the same outcome, and requires the same coverage. Pass = an exit status of 0 from each. -- 2.54.0 (Apple Git-157) From 4eacf7027b69d4edc7933a6923ce277c9a3a995b Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:48:24 -0400 Subject: [PATCH 16/43] joule-moq-relay: count groups that become whole after the window passed them Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-relay/src/dvr.rs | 7 +++++++ .../crates/joule-moq-relay/tests/dvr.rs | 14 ++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs index 25328c12e..b37b037b3 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs @@ -256,6 +256,9 @@ pub struct DvrStats { pub groups_written: AtomicU64, pub objects_written: AtomicU64, pub groups_evicted: AtomicU64, + /// Groups that became whole after the window had moved past them (their receipts or + /// media group already evicted): not stored. + pub groups_late: AtomicU64, /// Objects dropped because their check or their receipt did not verify. pub objects_dropped: AtomicU64, /// Group files a crash cut short (`.tmp`), removed at start. @@ -271,6 +274,7 @@ pub struct DvrCounts { pub groups_written: u64, pub objects_written: u64, pub groups_evicted: u64, + pub groups_late: u64, pub objects_dropped: u64, pub torn_writes: u64, pub objects_redacted: u64, @@ -598,6 +602,7 @@ impl Dvr { let ix = self.index.lock().unwrap(); if let Some(t) = ix.tracks.get(key) { if t.evicted_through.is_some_and(|e| group <= e) { + self.stats.groups_late.fetch_add(1, Ordering::Relaxed); return; } if t.groups.get(&group).is_some_and(|g| g.objects.len() + g.erased.len() >= objects.len()) { @@ -713,6 +718,7 @@ impl Dvr { groups_written: s.groups_written.load(Ordering::Relaxed), objects_written: s.objects_written.load(Ordering::Relaxed), groups_evicted: s.groups_evicted.load(Ordering::Relaxed), + groups_late: s.groups_late.load(Ordering::Relaxed), objects_dropped: s.objects_dropped.load(Ordering::Relaxed), torn_writes: s.torn_writes.load(Ordering::Relaxed), objects_redacted: s.objects_redacted.load(Ordering::Relaxed), @@ -1064,6 +1070,7 @@ impl Dvr { if t.evicted_through.is_some_and(|e| group <= e) { drop(ix); let _ = std::fs::remove_file(&path); + self.stats.groups_late.fetch_add(1, Ordering::Relaxed); return; } let (old_bytes, old_n) = t.groups.get(&group).map_or((0, 0), |g| (g.bytes, g.objects.len())); diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs index b5cef65e5..4ec54534a 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs @@ -115,10 +115,11 @@ async fn flush(server: &RelayServer) { tokio::task::spawn_blocking(move || d.flush()).await.unwrap(); } -/// Wait, bounded, until the store has written `n` groups. +/// Wait, bounded, until the store has taken `n` groups: written, or found late (whole +/// after the window had moved past them, under load). async fn written(server: &RelayServer, n: u64) { let d = server.relay().dvr().expect("a DVR store").clone(); - assert!(until(STEP, || d.counts().groups_written >= n).await, "{n} groups written: {:?}", d.counts()); + assert!(until(STEP, || d.counts().groups_written + d.counts().groups_late >= n).await, "{n} groups written: {:?}", d.counts()); flush(server).await; } @@ -203,8 +204,9 @@ async fn window_run() { assert_eq!(d.groups(&rec_key), held, "each media group goes with its receipts group"); let counts = d.counts(); assert!(counts.bytes_held <= max_bytes, "the window holds its budget: {counts:?}"); - assert_eq!(counts.groups_evicted, 2 * first, "whole groups, media and receipts"); - assert_eq!(counts.objects_written, GROUPS * (OBJECTS + 1)); + assert_eq!(counts.groups_evicted + counts.groups_late, 2 * first, "whole groups, media and receipts"); + let all = GROUPS * (OBJECTS + 1); + assert!(counts.objects_written <= all && counts.objects_written + counts.groups_late * OBJECTS >= all, "every object, but a group the window had passed: {counts:?}"); let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; // Inside the window: every object, verified, in the requested order. @@ -239,7 +241,7 @@ async fn window_run() { // The record of the room is version 2, with the store's figures. v.close(0, "done"); p.close(0, "done"); - assert!(until(STEP, || server.relay().summaries().len() >= 2).await); + assert!(until(2 * STEP, || server.relay().summaries().len() >= 2).await, "both sessions ended: {:?}", server.relay().stats()); let rec = server.relay().hop_record(room, 0); let bytes = rec.to_bytes(); assert_eq!(&bytes[..5], b"WMH1\x02", "version 2"); @@ -253,7 +255,7 @@ async fn window_run() { let out: u64 = rec.entries.iter().filter(|e| e.dir == 2).map(|e| e.objects).sum(); let inn: u64 = rec.entries.iter().filter(|e| e.dir == 1).map(|e| e.objects).sum(); assert!(dv.objects_out <= out && dv.objects_stored <= inn, "{dv:?}, out {out}, in {inn}"); - assert_eq!(dv.objects_stored, GROUPS * (OBJECTS + 1), "every object of the room written in the span"); + assert_eq!(dv.objects_stored, counts.objects_written, "every object of the room written in the span"); assert!(dv.byte_s > 0 && dv.dropped == 0); server.shutdown().await; let _ = std::fs::remove_dir_all(&dir); -- 2.54.0 (Apple Git-157) From 9be13006a1a457f80a47ebf18aa95e6d34674065 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:49:00 -0400 Subject: [PATCH 17/43] joule-moq-room: every role holds the seek power Co-Authored-By: Claude Opus 5.5 --- .../joule-code-rs/crates/joule-moq-room/README.md | 5 ++++- .../joule-code-rs/crates/joule-moq-room/src/epoch.rs | 12 ++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/README.md b/joule-code/joule-code-rs/crates/joule-moq-room/README.md index f14e79cee..c2574958a 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-room/README.md @@ -9,7 +9,10 @@ A room service for WAI live rooms (`wai/extensions/live-rooms.md`) over public, so it is useless without its key: the relay, configured with the authority's key (`[[auth.authorities]]`), honours it only beside a proof of possession over the relay's own challenge (§10.5), which only the page that holds the key can make. Grants are - bounded per client address and overall, and publishers per room. + bounded per client address and overall, and publishers per room. Every role, and so + every grant, holds the seek power (live-rooms §3.3, bit 4): a visitor may fetch a track + below its live edge (§10.6), from the relay's cache or its DVR window, as a late joiner + does for the groups before it joined. A relay refuses such a fetch to a grant without it. - **authors the lifecycle** (§7.2). The service's clock is a session on the relay. It watches each publisher's video track (a SUBSCRIBE that asks for no objects): when the track appears the clock signs `join` and `publish` for the publisher on its events track diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/epoch.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/epoch.rs index bdfac1462..df5dcec3f 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/epoch.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/epoch.rs @@ -15,6 +15,10 @@ use wai::live_compose::{Compose, NO_COMPOSITOR}; use wai::live_events::{kind, Event, Payload, Policy}; use wai::live_room::{layering, media, registered_profile, Caps, Descriptor, Detail, Grant, Layer, Limits, Profile, Recording, Review, NO_CEILING}; +/// Every visitor may seek back into the relay's DVR window, as a late joiner fetching the +/// groups before it joined does (WAI live-rooms §3.3, bit 4; §10.6). +const SEEK: u8 = wai::live_room::power::SEEK; + /// The kinds a participant may send: chats, reactions, markers and captions. pub const PARTICIPANT_KINDS: [u8; 4] = [kind::CHAT, kind::REACTION, kind::MARKER, kind::CAPTION]; /// The descriptor's profiles, by index: the registered `speech`, then the room's camera. @@ -172,8 +176,8 @@ impl Epoch { origin: String::new(), limits: Limits { max_present: settings.max_present, max_publishers: settings.max_publishers, reval_s: 0, ceiling: Policy::default() }, roles: vec![ - Role_ { name: "participant", caps: Caps { publish: 0b011, subscribe: true, max_layer: 0, powers: 0 }, events: bits(&PARTICIPANT_KINDS) }.into(), - Role_ { name: "viewer", caps: Caps { publish: 0, subscribe: true, max_layer: 0, powers: 0 }, events: bits(&[kind::CHAT]) }.into(), + Role_ { name: "participant", caps: Caps { publish: 0b011, subscribe: true, max_layer: 0, powers: SEEK }, events: bits(&PARTICIPANT_KINDS) }.into(), + Role_ { name: "viewer", caps: Caps { publish: 0, subscribe: true, max_layer: 0, powers: SEEK }, events: bits(&[kind::CHAT]) }.into(), ], slots: vec![], profiles: vec![registered_profile("speech").expect("a registered profile"), camera_profile()], @@ -235,8 +239,8 @@ impl Epoch { } } let (r, caps, policy, profiles, ttl) = match role { - Role::Publisher => (ROLE_PARTICIPANT, Caps { publish: 0b011, subscribe: true, max_layer: 0, powers: 0 }, Policy::with_kinds(&PARTICIPANT_KINDS), vec![PROFILE_AUDIO, PROFILE_VIDEO], self.settings.publisher_grant_s), - Role::Viewer => (ROLE_VIEWER, Caps { publish: 0, subscribe: true, max_layer: 0, powers: 0 }, Policy::with_kinds(&[kind::CHAT]), vec![], self.settings.viewer_grant_s), + Role::Publisher => (ROLE_PARTICIPANT, Caps { publish: 0b011, subscribe: true, max_layer: 0, powers: SEEK }, Policy::with_kinds(&PARTICIPANT_KINDS), vec![PROFILE_AUDIO, PROFILE_VIDEO], self.settings.publisher_grant_s), + Role::Viewer => (ROLE_VIEWER, Caps { publish: 0, subscribe: true, max_layer: 0, powers: SEEK }, Policy::with_kinds(&[kind::CHAT]), vec![], self.settings.viewer_grant_s), }; let order = self.next_order; let attrs = if role == Role::Publisher { vec![("order".to_string(), order.to_string())] } else { vec![] }; -- 2.54.0 (Apple Git-157) From c0bebc7a82434db039637c30d817e71481ca8674 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:49:11 -0400 Subject: [PATCH 18/43] =?UTF-8?q?live-rooms=20=C2=A710.6:=20joining=20the?= =?UTF-8?q?=20group=20in=20progress=20is=20a=20fill=20(MOQT=20=C2=A73.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- wai/extensions/live-rooms.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wai/extensions/live-rooms.md b/wai/extensions/live-rooms.md index 5ab9483e0..af4699bdb 100644 --- a/wai/extensions/live-rooms.md +++ b/wai/extensions/live-rooms.md @@ -861,8 +861,8 @@ A relay that keeps a window of a room's tracks, a DVR window ([live-recording](live-recording.md) §8), lets a viewer seek back into it. The **live edge** of a track at a relay is the group of the Largest Object the relay knows of the track (MOQT §3.1.4). A FETCH (MOQT §3.2) **seeks back** when its range starts in a group -below the live edge, and so does the fill of a SUBSCRIBE (MOQT §3.4); a joining fetch of -the group in progress starts at the live edge, and does not. +below the live edge, and so does the fill of a SUBSCRIBE (MOQT §3.4); a fill that joins +the group in progress (MOQT §3.5) starts at the live edge, and does not. A relay serves a request that seeks back on a track of the room only to a session whose SETUP proved (§10.5) a grant that gives it the track (§10.2) and holds the seek power -- 2.54.0 (Apple Git-157) From 0018fba9d6d9165b58e59aebc54fbf0252dbecaa Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:50:24 -0400 Subject: [PATCH 19/43] =?UTF-8?q?live-recording=20=C2=A78:=20the=20DVR=20w?= =?UTF-8?q?indow,=20answering=20a=20FETCH=20from=20it,=20moderation,=20a?= =?UTF-8?q?=20restart,=20and=20the=20time-shift=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- wai/extensions/live-recording.md | 115 +++++++++++++++++++++++++++++-- 1 file changed, 110 insertions(+), 5 deletions(-) diff --git a/wai/extensions/live-recording.md b/wai/extensions/live-recording.md index b9a892b53..877467cc5 100644 --- a/wai/extensions/live-recording.md +++ b/wai/extensions/live-recording.md @@ -15,7 +15,9 @@ > the clip, the take's commitments, the device's key and the room descriptor knows the > clip's bytes are exactly those bytes of that take, unedited, without the take. The HLS and > LL-CMAF presentation of a recording (§7) and the addressing of its chunks for a MoQ FETCH -> (§8) are mappings. No new transport and no new container. The reference implementation is +> (§8.1) are mappings. A relay's DVR window of a room's tracks (§8.2 to §8.5) keeps them, with +> their receipts, for a viewer to seek back into by FETCH. No new transport and no new +> container. The reference implementation is > the `live_record` module of `wai-rs` (feature `live_record`); the corpus is > [`live-conformance/recordings/`](../live-conformance/recordings/), and its second > implementation is `live-conformance/live_record_verify.py`. @@ -35,9 +37,10 @@ and recording verification, with the verdicts of `complete` and `publishable` ( record, the range proof it carries, clip verification, and cutting a clip by chunks, by bytes or by a window of the room clock (§5); a clip across takes, its time correspondence and its bound (§6); the mapping of a recording onto HLS and LL-CMAF (§7); and the addressing -of a recording's chunks for a MoQ FETCH (§8). +of a recording's chunks for a MoQ FETCH, with the DVR window a relay keeps of a room's tracks +and serves to FETCH (§8). -Out of scope: a transport; how a relay keeps, windows and serves a recording; a muxer for a +Out of scope: a transport; how a relay lays its DVR window out on disk; a muxer for a master that is not CMAF; the composition itself, which `wai.live.compose` defines (live-compose §8, a later extension); and what a party does with a verdict. @@ -562,8 +565,13 @@ corpus's masters are not CMAF, the reference has no muxer for a device's master ## 8. DVR over MoQ FETCH -This section fixes only how a recording's chunks are addressed for a FETCH; how a relay -keeps a recording and answers one is a later extension's. +This section fixes how a recording's chunks are addressed for a FETCH (§8.1), and what a +relay that keeps a window of a room's tracks, a **DVR window**, does with it: what it keeps +and evicts (§8.2), how it answers a FETCH from it (§8.3), what moderation withholds from it +(§8.4), and how it keeps it across a restart (§8.5); and the window's presentation over HTTP +(§8.6). + +### 8.1 Addressing a take - **The track.** A recorded take is one track. Its Track Namespace (MOQT §2.4.1) has three fields: the room id and the device's key, each as 64 lowercase hexadecimal digits, and @@ -585,6 +593,103 @@ keeps a recording and answers one is a later extension's. at or past it is a chunk no commitment has added yet, which a sink holds unverified until one does (§5.5: a clip of a running take is proved against the commitment that covers it). +A take's track kept in a DVR window is kept and answered as any other track (§8.2, §8.3), so +a clip's part is a FETCH the window answers. + +### 8.2 The window + +A relay **MAY** keep a window of the tracks of a room: its media tracks with their receipts +tracks, its events tracks, its catalogs and its takes. + +- **What it keeps.** A group, once the relay holds it whole: its first object arrived (or + a Prior Object ID Gap says the objects before do not exist, MOQT §10.9), none of its + subgroup streams is open or was reset, and its end is known. The objects are kept with + every field a fetch stream carries (MOQT §11.4.1). +- **Its bounds.** An age, counted from when a group's first object reached the relay, of at + most 86 400 seconds: 24 hours, the start-over buffer a live-to-VOD harvest clips from; + and a byte budget over the window's tracks together. Past either, the oldest group goes + first, whole. Group *g* of a media track and group *g* of its receipts track + ([moq-streaming-format](moq-streaming-format.md) §4.1) go together, so that every group + the window serves comes with its receipts and a viewer checks a recorded object exactly as + it checks a live one ([jwp-receipts](jwp-receipts.md) §4.1). A group that becomes whole + after the window has moved past its group number is not kept. +- **Where.** The window is media and events the relay keeps beyond delivery, so the room's + region pin (live-rooms §3.7) binds where it is stored. +- **Who reads it.** A FETCH or fill below a track's live edge is served only to a grant + with the seek power (live-rooms §10.6). + +### 8.3 Answering a FETCH + +- **Where from.** A FETCH whose range the relay's cache holds is answered from the cache. + Otherwise, when every group from its start to its end is held whole by the cache or the + window, is known not to exist (MOQT §10.8), or was evicted from the window, the relay + answers it from them; otherwise it asks upstream (MOQT §3.2.3). Groups come in the + requested order (MOQT §3.2.1), objects within a group in Object ID order. A FETCH of the + receipts track over the same range brings each group's receipts the same way. +- **What was evicted** is no longer known to the relay. The relay marks the evicted groups + of the range with an End of Unknown Range (MOQT §11.4.1.2, Serialization Flags `0x10C`) + through the last of them, then sends the groups it holds: a range partly evicted gets what + remains and the marker, and a range wholly evicted gets FETCH_OK and the marker alone. It + uses neither an unmarked gap nor an End of Non-Existent Range: an evicted object existed, + an unmarked gap would say it did not (MOQT §3.2.2), and MOQT §10.3 makes an object + that has left a cache an object of unknown state (§2.1 there). +- **Inside a group.** An End of Range moves to a later group than the entry before it: its + Group ID is coded as an object's Group ID Delta (MOQT §11.4.1.1, §11.4.1.2), the reading + the `joule-moq` codec and the MOQT -22 vectors of `joule-moq` take. So when objects of a + group cannot be served (withheld, §8.4; dropped, §8.5), one End of Unknown Range from the + group's start through the last of them goes first, and the group's objects after it + follow. The objects before the last such one are not sent. +- **A window whose end lies ahead.** A live-to-VOD harvest clips a window of at most 24 hours + whose end may lie in the future. Its past is a FETCH. The whole is one SUBSCRIBE whose + `LOCATION_FILTER` is the window, as an AbsoluteRange (MOQT §9.20.9), with + `FILL_PARAMETERS` (MOQT §3.4): the fill is answered from the cache and the window as a + FETCH is, and the subscription delivers the rest up to the window's end. + +### 8.4 Moderation + +What [live-moderation](live-moderation.md) withholds is withheld from the window, as §6.3 +there has a relay withhold it in answer to a FETCH: + +- **How the relay learns of decisions.** It reads the `decision` events (live-events §4.11) + on the room's events tracks, which it already forwards. It honours a decision whose record + verifies under its principal (live-events §3.2), whose principal owns the events track it + rides (live-rooms §6.1), and whose principal is a moderator it knows: a room key, or the + principal of a grant with the moderate power (live-rooms §3.3) that its publishing session + proved (live-rooms §10.5). The last decision it honoured on a target stands + (live-moderation §4.5), and the honoured decisions are kept with the window. +- **Deny and redact** withhold the target from every answer to every session but a + moderator's; **redact** also erases it from the window. **Allow** restores what deny + withheld. A withheld media object is the one whose receipt names it by its digest + (live-moderation §4.1); its receipts stay where they are (live-moderation §8.1). A withheld + event is left out of the events-track object that carries it, the object's other records + sent byte for byte; an object left with none is withheld whole. Either is withheld, never + replaced: each is an End of Unknown Range in its place (§8.3), and the decision that + restricts it says why. + +### 8.5 Across a restart + +A window kept across a restart is checked again before it is served. Every object is +checked against its receipt: a media object of a track with a receipts track against group +*g*'s receipts, as jwp-receipts §4.1 checks one at the door (the group receipt's signature +under the trust the relay holds for the namespace, the object's receipt at its position, +its Merkle proof to the signed root, its content hash); an events-track object's records each +under its principal (live-events §3.2). An object that does not verify, or whose stored +bytes do not hold, is dropped and counted, and never served: a FETCH that reaches it is told +End of Unknown Range (§8.3). How a relay lays the window out on disk is its own; the +reference relay's layout and its durability rule are in its README. + +### 8.6 Over HTTP + +The window is a time-shift buffer. In DASH (ISO/IEC 23009-1) its age is the MPD's +`timeShiftBufferDepth`. In HLS a live Media Playlist keeps the window's segments and +removes them from its start as the window evicts their groups; `EXT-X-PLAYLIST-TYPE` +`EVENT` (RFC 8216 §4.3.3.5), whose segments are added only at the end, fits only a window +that holds the session from its start and evicts nothing. + +What this revision implements of §8.2 to §8.5: the reference relay, `joule-moq-relay` +(`joule-code/joule-code-rs/crates/joule-moq-relay`), keeps a window per namespace prefix +and its tests show each rule above. §8.6 is a mapping, and nothing writes it. + ## 9. Reasons The codes this extension adds, each with where it is raised: -- 2.54.0 (Apple Git-157) From a29b84fb0c5cccb1e32a85c9251858e5e78249c5 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:55:12 -0400 Subject: [PATCH 20/43] joule-moq-web: the Seek back control and its e2e scenario; joule-moq-ingest reads the seek power Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-ingest/src/room.rs | 2 +- .../crates/joule-moq-web/e2e/lib.mjs | 7 +- .../crates/joule-moq-web/e2e/room.e2e.mjs | 103 +++++++++- .../crates/joule-moq-web/web/room/app.js | 176 ++++++++++++++++++ .../crates/joule-moq-web/web/room/index.html | 9 + 5 files changed, 292 insertions(+), 5 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-ingest/src/room.rs b/joule-code/joule-code-rs/crates/joule-moq-ingest/src/room.rs index 93116fef1..4091edf32 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-ingest/src/room.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-ingest/src/room.rs @@ -999,7 +999,7 @@ impl Grant { } else if effective % d.tick_us != 0 { return bad; } - if role as usize >= d.roles.len() || !bits_within(publish, 0b111) || !bits_within(powers, 0b1111) || (!caps.subscribe && max_layer != 0) { + if role as usize >= d.roles.len() || !bits_within(publish, 0b111) || !bits_within(powers, 0b1_1111) || (!caps.subscribe && max_layer != 0) { return bad; } let policy = match Policy::read(&policy_bytes) { diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs index 73635fe77..ab8e0e309 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs @@ -105,10 +105,15 @@ function relayToml(c) { t += `[receipts]\nreceipts_track_suffix = "_receipts"\n\n`; for (const d of c.door || []) t += `[[receipts.door]]\nprefix = ${arr(d.prefix)}\ntrust = ${arr(d.trust || [])}\n${d.trust_field != null ? `trust_field = ${d.trust_field}\n` : ""}hold_ms = ${d.hold_ms}\n\n`; if (c.limits) for (const [k, v] of Object.entries(c.limits)) t = t.replace("[limits]\n", `[limits]\n${k} = ${v}\n`); + if (c.cache_bytes) t += `[cache]\nmax_bytes = ${c.cache_bytes}\n\n`; + // A DVR window of every track (WAI live-recording §8). + if (c.dvr) t += `[dvr]\npath = ${q(c.dvr.path)}\nroom_keys = ${arr(c.dvr.room_keys || [])}\n\n[[dvr.window]]\nprefix = ["*"]\nmax_age_s = ${c.dvr.max_age_s}\nmax_bytes = ${c.dvr.max_bytes}\n\n`; t += `[summary]\nout_path = ${q(c.summary_out)}\n\n[auth]\nrequired = ${c.auth ? "true" : "false"}\n\n`; // Lines a room service prints for the relay that trusts it (`joule-moq-room relay-lines`). if (c.extra) t += c.extra + "\n"; - for (const g of c.grants || []) t += `[[auth.grants]]\nprefix = ${arr(g.prefix)}\ntoken_sha256 = ${q(g.token_sha256)}\npublish = ${g.publish}\nsubscribe = ${g.subscribe}\nproof = ${g.proof === true}\n\n`; + // A grant that may subscribe may also seek back below the live edge (live-rooms §10.6), + // as a late joiner does, unless the test says otherwise. + for (const g of c.grants || []) t += `[[auth.grants]]\nprefix = ${arr(g.prefix)}\ntoken_sha256 = ${q(g.token_sha256)}\npublish = ${g.publish}\nsubscribe = ${g.subscribe}\nproof = ${g.proof === true}\nseek = ${g.seek != null ? g.seek : g.subscribe}\n\n`; return t; } diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs index 7e75a27f1..d34386fb2 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs @@ -4,7 +4,7 @@ // // node e2e/room.e2e.mjs --relay --wai --web // --corpus [--browser ] -// [--only compose,webtransport,websocket,goaway,service] [--runs N] [--between ] +// [--only compose,webtransport,websocket,goaway,dvr,service] [--runs N] [--between ] // [--out ] [--room ] [--base ] // // Scenarios: @@ -22,6 +22,12 @@ // goaway the relay is shut down with GOAWAY naming a second relay: every page // moves there, closes its old session itself before the timeout, and media // flows again; +// dvr a relay keeping a DVR window (WAI live-recording §8) with a cache too +// small for the window: a participant seeks back eight seconds into the +// other's tracks from its page's control; every object fetched from the +// window verifies against its receipt, the video plays from its first key +// frame in the page's own view, the page returns to live and live video goes +// on decoding; the relay's summaries show the objects came from the window; // service a room service (joule-moq-room) keeps the room, as in production: each // page makes its own key and enters from its buttons, the service grants and // its clock authors the lifecycle, the relay (WebSocket only, behind a @@ -51,7 +57,7 @@ const BASE = args.base ? args.base.replace(/\/$/, "") : null; const WAI = need("wai"); const [RELAY, WEB, CORPUS] = BASE ? [null, null, null] : [need("relay"), need("web"), need("corpus")]; const ROOM = args.room ? path.resolve(args.room) : null; -const ONLY = (args.only || (BASE ? "service" : `compose,webtransport,websocket,goaway${ROOM ? ",service" : ""}`)).split(","); +const ONLY = (args.only || (BASE ? "service" : `compose,webtransport,websocket,goaway,dvr${ROOM ? ",service" : ""}`)).split(","); const RUNS = Number(args.runs || 1); const OUT = args.out ? path.resolve(args.out) : tmpdir("out"); fs.mkdirSync(OUT, { recursive: true }); @@ -530,6 +536,97 @@ async function scenarioService(run) { } } +// ------------------------------------------------------------------------------------- dvr + +async function scenarioDvr(run) { + const label = "dvr"; + const SECONDS = 8; + const dir = path.join(OUT, `run${run}-dvr`); + fs.mkdirSync(dir, { recursive: true }); + const { fixture: fx, file } = makeFixture(WAI, ["alice", "bob"], dir); + const summaries = path.join(dir, "summaries.jsonl"); + // A cache far smaller than the seconds sought back: what is older comes from the window. + const relay = await startRelay(RELAY, { auth: true, grants: fx.relay_grants, door: fx.door_rules, summary_out: summaries, cache_bytes: 128 * 1024, dvr: { path: path.join(dir, "dvr-store"), max_age_s: 900, max_bytes: 256 * 1024 * 1024 } }, dir, "relay"); + const srv = await serve(WEB, { "/fixture.json": file }); + const browser = await launch(dir); + const force = { transport: "websocket" }; + const pages = []; + try { + check(`${label}: the relay opened its DVR store`, relay.lines.some((l) => l.includes("the DVR store is open")), (relay.lines.find((l) => l.includes("DVR")) || "").replace(/^.*INFO /, "").slice(0, 160)); + const clock = await page(browser, roomUrl(srv.url, relay, file, { role: "clock", ...force }), "clock", dir); + pages.push(clock); + await waitPhase(clock, "ready"); + const A = principalOf(fx, "alice"); + const alice = await page(browser, roomUrl(srv.url, relay, file, { who: "alice", ...force }), "alice", dir); + pages.push(alice); + await waitPhase(alice, "publishing"); + await announce(clock, fx, "alice"); + const bob = await page(browser, roomUrl(srv.url, relay, file, { who: "bob", ...force }), "bob", dir); + pages.push(bob); + await waitPhase(bob, "publishing"); + await announce(clock, fx, "bob"); + // More than the seconds sought back of alice's video and audio, live at bob's. + const fps = 15; + await until(`${label}: bob has ${SECONDS + 4} s of alice live`, STEP * 2, async () => { + const s = await bob.snap(); + if (s.errors.length) throw new Error(s.errors[0]); + const t = s.subs[A]; + return t && t.video.decoded > 0 && t.video.last_live_group != null && t.video.last_live_group - t.video.first_live_group >= (SECONDS + 4) * fps && t.audio.verified > 0; + }, 500); + + // The control: seconds back, then Seek back. + await until(`${label}: the Seek back control is enabled`, STEP, () => bob.evaluate(() => !document.getElementById("seek").disabled), 250); + await bob.fill("#seek-s", String(SECONDS)); + await bob.click("#seek"); + const playing = await until(`${label}: bob plays from the window`, STEP, async () => { + const s = await bob.snap(); + return s.dvr.mode === "playing" && !(await bob.evaluate(() => document.getElementById("dvr-canvas").hidden)) && s.dvr; + }, 100); + check(`${label}: the page shows the window's video in a view of its own while it plays`, playing.runs.length === 1, (await bob.evaluate(() => document.getElementById("dvr-status").textContent)).slice(0, 200)); + const done = await until(`${label}: bob returns to live`, STEP * 2, async () => { + const s = await bob.snap(); + if (s.errors.length) throw new Error(s.errors[0]); + const r = s.dvr.runs[0]; + return r && r.live_at != null && s.dvr.mode === "live" && s; + }, 250); + const r = done.dvr.runs[0]; + fs.writeFileSync(path.join(dir, "seek-back.json"), JSON.stringify(r, null, 1)); + const v = r.video, a = r.audio; + check(`${label}: every fetched video object verifies against its receipt`, v.fetched > 0 && v.verified === v.fetched && v.refused === 0 && v.receipts_refused === 0, + `groups ${v.from}..${v.to}: ${v.verified} of ${v.fetched} verified, ${v.refused} refused, ${v.receipts_refused} receipts refused`); + check(`${label}: every fetched audio object verifies against its receipt`, a.fetched > 0 && a.verified === a.fetched && a.refused === 0 && a.receipts_refused === 0, + `groups ${a.from}..${a.to}: ${a.verified} of ${a.fetched} verified, ${a.refused} refused`); + check(`${label}: the window holds the whole span, nothing unknown`, v.fetched === v.to - v.from + 1 && a.fetched === a.to - a.from + 1 && v.unknown + a.unknown === 0, + `video ${v.fetched} of ${v.to - v.from + 1} groups, audio ${a.fetched} of ${a.to - a.from + 1}; ${v.unknown + a.unknown} unknown`); + const playedMs = r.live_at - r.playing_at; + check(`${label}: the video plays from its first key frame at the pace it was captured`, v.decoded > 0 && v.played >= (SECONDS - 2) * fps * 0.5 && playedMs >= (SECONDS - 2.5) * 1000 && a.decoded > 0, + `${v.played} frames shown, ${v.decoded} decoded, over ${(playedMs / 1000).toFixed(1)} s; ${a.decoded} audio frames decoded`); + check(`${label}: the page is back to live, its view of the window hidden`, done.dvr.mode === "live" && (await bob.evaluate(() => document.getElementById("dvr-canvas").hidden)), done.dvr.mode); + const d0 = done.subs[A].video.decoded; + const after = await until(`${label}: live video goes on decoding after the seek back`, STEP, async () => { + const s = await bob.snap(); + return s.subs[A].video.decoded >= d0 + 2 * fps && s.subs[A].video; + }, 500); + check(`${label}: live video goes on decoding after the seek back`, after.refused === 0, `${after.decoded} decoded (${d0} at the return), ${after.verified} verified`); + for (const p of pages) { + const s = await p.snap(); + fs.writeFileSync(path.join(dir, `${p.name}.snapshot.json`), JSON.stringify(s, null, 1)); + check(`${label}: ${p.name} reports no error`, s.errors.length === 0, s.errors.slice(0, 2).join(" | ")); + } + } finally { + for (const p of pages) { try { p.dump(); } catch { /* closed */ } } + await browser.close().catch(() => {}); + await srv.close(); + const e = await relay.stop(); + console.log(`relay stopped (${JSON.stringify(e)}); log ${relay.log}`); + killStrays(); + } + // The relay's signed summaries: the objects the seek back fetched came from its window. + const lines = fs.existsSync(summaries) ? fs.readFileSync(summaries, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []; + const fromWindow = lines.reduce((n, l) => n + Object.values(l.summary.tally.subscriber.tracks || {}).reduce((m, t) => m + (t.dvr_objects || 0), 0), 0); + check(`${label}: the relay served the seek back from its DVR window`, fromWindow > 0, `${fromWindow} objects from the window over ${lines.length} sessions`); +} + // ------------------------------------------------------------------------------------- main let passedRuns = 0; @@ -542,7 +639,7 @@ for (let run = 1; run <= RUNS; run++) { const before = failed; const t = Date.now(); console.log(`== run ${run} of ${RUNS}`); - for (const [name, f] of [["compose", scenarioCompose], ["webtransport", (r) => scenarioRoom(r, "webtransport")], ["websocket", (r) => scenarioRoom(r, "websocket")], ["goaway", scenarioGoaway], ["service", scenarioService]]) { + for (const [name, f] of [["compose", scenarioCompose], ["webtransport", (r) => scenarioRoom(r, "webtransport")], ["websocket", (r) => scenarioRoom(r, "websocket")], ["goaway", scenarioGoaway], ["dvr", scenarioDvr], ["service", scenarioService]]) { if (!ONLY.includes(name)) continue; try { await f(run); diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js index c0c65e031..8706ba89f 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js @@ -22,6 +22,9 @@ // transport= `websocket` forces the WebSocket binding; otherwise WebTransport is // tried first and the binding is the fallback // fetch=1 a late joiner: earlier groups of every media track by FETCH +// (the "Seek back" control, #seek, fetches a participant's last seconds from the relay's +// DVR window, verifies every object, plays the video from its first key frame in a view of +// its own, and returns to live; `roomTest.seekBack(seconds)` does the same) // audioFrameMs= the Opus frame duration, 20 by default // // Everything a test reads is on `window.roomTest`. @@ -77,6 +80,9 @@ const state = { sessions: 0, mode: null, ended: null, + // Seeking back into the relay's DVR window: "live", "seeking" (fetching and verifying) + // or "playing", and each run's counts. + dvr: { mode: "live", runs: [] }, }; window.roomTest = { state, logLines }; @@ -672,6 +678,7 @@ async function subscribeMedia(sub, tr) { st.seen.add(h.group_id); T.received++; if (T.first_live_group == null) T.first_live_group = h.group_id; + if (T.last_live_group == null || h.group_id > T.last_live_group) T.last_live_group = h.group_id; onObject(h.group_id, p, false); }, reset: () => { T.reset++; }, @@ -850,6 +857,174 @@ function sweepMissing() { } } +// ---------------------------------------------------------------------------- seeking back + +// Seeking back (WAI live-recording §8, live-rooms §10.6): a participant's last seconds, by +// FETCH below the live edge of its tracks and of their receipts tracks, which the relay +// answers from its DVR window. Every object is verified against its receipt exactly as a +// live one is (the same verifier, which admits the earlier groups as history), the video +// is played from its first key frame in a view of its own at the pace it was captured, the +// audio decoded and counted, and the page then returns to live. What the relay no longer +// holds, or withholds, comes as an End of Unknown Range, counted and never played. +const AUDIO_FRAME_MS = Number(q.get("audioFrameMs") || 20); +let dvrStop = null; + +function renderDvr() { + const d = state.dvr; + const run = d.runs[d.runs.length - 1]; + const canvas = $("dvr-canvas"); + if (canvas) canvas.hidden = d.mode !== "playing"; + if ($("live")) $("live").hidden = d.mode === "live"; + if ($("seek")) $("seek").disabled = d.mode !== "live" || !subscribedVideo(); + const el = $("dvr-status"); + if (!el) return; + if (!run) { el.textContent = ""; return; } + const v = run.video, a = run.audio; + const what = `${run.who}, ${run.seconds} s back: video ${v.verified} of ${v.fetched} verified, audio ${a.verified} of ${a.fetched}${v.unknown + a.unknown ? `; ${v.unknown + a.unknown} range(s) the relay no longer holds` : ""}`; + el.textContent = d.mode === "seeking" ? `Fetching ${what}` : d.mode === "playing" ? `Playing ${what}; ${v.played} frames shown` : `Back to live after ${what}; ${v.played} frames shown`; +} + +function subscribedVideo() { + return [...subscribed.values()].find((s) => s.tracks.video && tally(s.principal).video.last_live_group != null); +} + +// FETCH groups `from` to `to` of a track and of its receipts track, the receipts first, +// each verified; returns the verified objects in group order. +async function fetchVerified(st, m, from, to, c) { + const params = { group_order: 1, subscriber_priority: SUB_PRIORITY[m] + 2, location_filter: { type: "absolute_group_end", start: [from, 0], end_group_delta: to - from } }; + const rec = []; + const r1 = await session.fetch(st.ns, st.tr.wai_receipts_track, params, (j, p) => rec.push([j.group_id, p])); + for (const [g, p] of rec) { + const r = JSON.parse(st.verifier.receipts(BigInt(g), p, BigInt(Date.now()))); + if (!r.ok) c.receipts_refused++; + } + const objs = []; + const r2 = await session.fetch(st.ns, st.tr.name, params, (j, p) => objs.push([j.group_id, j.object_id, p])); + c.unknown += r1.ranges.length + r2.ranges.length; + const ok = []; + for (const [g, o, p] of objs) { + c.fetched++; + const r = JSON.parse(st.verifier.object(BigInt(g), BigInt(o), p)); + if (r.ok) { c.verified++; ok.push([g, p]); } else { c.refused++; log(`seek back: ${m} group ${g} refused: ${r.reason}`); } + } + return ok; +} + +async function seekBack(seconds = 10) { + if (state.dvr.mode !== "live") return state.dvr.runs[state.dvr.runs.length - 1]; + const sub = subscribedVideo(); + if (!sub) throw new Error("no participant's video to seek back into"); + const counts = () => ({ from: null, to: null, fetched: 0, verified: 0, refused: 0, receipts_refused: 0, unknown: 0, decoded: 0, played: 0 }); + const run = { principal: sub.principal, who: nameOf(sub.principal), seconds, video: counts(), audio: counts(), started_at: performance.now(), playing_at: null, live_at: null, error: null }; + state.dvr.runs.push(run); + state.dvr.mode = "seeking"; + renderDvr(); + try { + const got = {}; + for (const m of ["video", "audio"]) { + const st = sub.tracks[m]; + const T = tally(sub.principal)[m]; + if (!st || T.last_live_group == null) continue; + const perSecond = m === "video" ? Number(st.tr.framerate || 30) : 1000 / AUDIO_FRAME_MS; + const to = T.last_live_group; + const from = Math.max(0, to - Math.round(seconds * perSecond)); + run[m].from = from; + run[m].to = to; + got[m] = await fetchVerified(st, m, from, to, run[m]); + } + log(`seek back ${seconds} s into ${run.who}: video groups ${run.video.from}..${run.video.to}, ${run.video.verified} of ${run.video.fetched} verified; audio ${run.audio.verified} of ${run.audio.fetched}; ${run.video.unknown + run.audio.unknown} unknown range(s)`); + state.dvr.mode = "playing"; + run.playing_at = performance.now(); + renderDvr(); + await playBack(sub, got, run); + } catch (e) { + run.error = String(e.message || e); + log(`seek back: ${run.error}`); + } finally { + backToLive(); + } + return run; +} + +// The fetched video, decoded and drawn at the pace of its timestamps from its first key +// frame; the audio decoded alongside and counted. +async function playBack(sub, got, run) { + const canvas = $("dvr-canvas"); + const ctx = canvas && canvas.getContext("2d"); + const vst = sub.tracks.video; + const vdec = new VideoDecoder({ + output: (vf) => { + run.video.decoded++; + if (ctx && state.dvr.mode === "playing") { ctx.drawImage(vf, 0, 0, canvas.width, canvas.height); run.video.played++; renderDvr(); } + vf.close(); + }, + error: (e) => log(`seek back: video decoder: ${e.message || e}`), + }); + vdec.configure({ codec: vst.tr.codec, codedWidth: vst.tr.width, codedHeight: vst.tr.height }); + const ast = sub.tracks.audio; + let adec = null; + if (ast && got.audio) { + adec = new AudioDecoder({ output: (ad) => { run.audio.decoded++; ad.close(); }, error: (e) => log(`seek back: audio decoder: ${e.message || e}`) }); + adec.configure({ codec: "opus", sampleRate: ast.tr.samplerate, numberOfChannels: Number(ast.tr.channelConfig || 1) }); + for (const [, env] of got.audio) { + try { + const e = wai.unpack(env); + const manifest = JSON.parse(e.manifest); + const payload = e.payload(); + e.free(); + for (const pk of media.oggOpusPackets(payload)) adec.decode(new EncodedAudioChunk({ type: "key", timestamp: manifest.target.timestamp_us, data: pk })); + } catch (err) { log(`seek back: audio: ${err.message || err}`); } + } + } + const frames = []; + for (const [, env] of got.video || []) { + try { + const e = wai.unpack(env); + const manifest = JSON.parse(e.manifest); + const cap = e.capability; + const payload = e.payload(); + e.free(); + const units = cap === "wai.video.vp9" ? media.ivfFrames(payload).map((f) => f.data) : media.av1Units(payload); + frames.push({ key: !!manifest.target.key, ts: manifest.target.timestamp_us, units }); + } catch (err) { log(`seek back: video: ${err.message || err}`); } + } + const first = frames.findIndex((f) => f.key); + let stopped = false; + dvrStop = () => { stopped = true; }; + if (first >= 0) { + const t0 = performance.now(); + const ts0 = frames[first].ts; + for (const f of frames.slice(first)) { + if (stopped) break; + const due = t0 + (f.ts - ts0) / 1000; + const wait = due - performance.now(); + if (wait > 0) await new Promise((r) => setTimeout(r, Math.min(wait, 1000))); + for (const u of f.units) vdec.decode(new EncodedVideoChunk({ type: f.key ? "key" : "delta", timestamp: f.ts, data: u })); + } + } + try { await vdec.flush(); } catch { /* closed */ } + if (adec) { try { await adec.flush(); } catch { /* closed */ } adec.close(); } + vdec.close(); +} + +function backToLive() { + if (dvrStop) { dvrStop(); dvrStop = null; } + const run = state.dvr.runs[state.dvr.runs.length - 1]; + if (run && run.live_at == null) run.live_at = performance.now(); + state.dvr.mode = "live"; + renderDvr(); +} + +window.roomTest.seekBack = (seconds) => seekBack(seconds); +if ($("seek")) { + $("seek").addEventListener("click", () => { + const s = Math.max(1, Math.min(900, Number(($("seek-s") || {}).value || 10))); + seekBack(s).catch(fail); + }); +} +if ($("live")) $("live").addEventListener("click", () => { if (dvrStop) dvrStop(); }); +setInterval(() => { if (state.dvr.mode === "live") renderDvr(); }, 1000); + // ------------------------------------------------------------------------------- GOAWAY // GOAWAY (MOQT §9.2): no new request on the old session; a new session to the URI it @@ -934,6 +1109,7 @@ window.roomTest.snapshot = () => ({ codec: state.publishCodec, capture: state.capture, subs: state.subs, + dvr: state.dvr, captions: state.captions, timeline: state.timeline.map((e) => ({ t: e.t, who: e.who, kind: e.kind })), goaways: state.goaways, diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/index.html b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/index.html index 7a6d9e453..d9a4c727d 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/index.html +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/index.html @@ -9,6 +9,7 @@ :root { --bg: #f6f7f9; --fg: #15171a; --muted: #5d6470; --panel: #ffffff; --line: #d9dde3; --human: #1f6f43; --machine: #8a4b00; } @media (prefers-color-scheme: dark) { :root { --bg: #111317; --fg: #e8eaee; --muted: #9aa2ae; --panel: #1a1d23; --line: #2c313a; --human: #6fd39b; --machine: #f0a957; } } * { box-sizing: border-box; } + [hidden] { display: none !important; } body { margin: 0; padding: 16px; background: var(--bg); color: var(--fg); font: 14px/1.45 system-ui, sans-serif; } header { display: flex; gap: 12px; align-items: baseline; flex-wrap: wrap; margin-bottom: 12px; } h1 { font-size: 16px; margin: 0; } @@ -44,6 +45,14 @@

The room, composed

+
+ + + + + +
+

Events

-- 2.54.0 (Apple Git-157) From db2d92106fa77b0629b2c3b97b735db9ea7f39c2 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:56:40 -0400 Subject: [PATCH 21/43] joule-moq-relay: clippy in the DVR test and module Co-Authored-By: Claude Opus 5.5 --- joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs | 2 +- joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs index b37b037b3..62c3ad866 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs @@ -1273,7 +1273,7 @@ mod tests { let (g, arrived, count, objs, erased) = decode_group(&b, &td).unwrap(); assert_eq!((g, arrived, count, erased.len()), (5, 99, 3, 1)); assert!(objs.iter().all(|x| x.3)); - assert_eq!(index[1].at as usize + 0, objs[1].0.at as usize); + assert_eq!(index[1].at, objs[1].0.at); // A flipped payload byte fails that object's check alone. let at = index[1].at as usize; b[at] ^= 0x40; diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs index 4ec54534a..a2df6bc28 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs @@ -57,8 +57,8 @@ fn store_dir(name: &str) -> PathBuf { d } -fn dvr_config(path: &PathBuf, max_age_s: u64, max_bytes: u64, room_keys: Vec) -> DvrConfig { - DvrConfig { path: path.clone(), window: vec![DvrWindow { prefix: vec!["*".into()], max_age_s, max_bytes }], room_keys } +fn dvr_config(path: &std::path::Path, max_age_s: u64, max_bytes: u64, room_keys: Vec) -> DvrConfig { + DvrConfig { path: path.to_path_buf(), window: vec![DvrWindow { prefix: vec!["*".into()], max_age_s, max_bytes }], room_keys } } fn hexkey(k: &SigningKey) -> String { -- 2.54.0 (Apple Git-157) From 038fc5cc9298cc4a0681d5e1939a69c275037296 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:59:29 -0400 Subject: [PATCH 22/43] =?UTF-8?q?joule-moq-relay=20README:=20the=20DVR=20s?= =?UTF-8?q?tore;=20CHANGELOGs;=20SPEC=20=C2=A75:=20the=20DVR=20window=20an?= =?UTF-8?q?d=20the=20seek=20power?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- joule-code/CHANGELOG.md | 32 +++++ .../crates/joule-moq-relay/README.md | 124 +++++++++++++++++- wai/CHANGELOG.md | 30 +++++ wai/SPEC.md | 9 +- 4 files changed, 190 insertions(+), 5 deletions(-) diff --git a/joule-code/CHANGELOG.md b/joule-code/CHANGELOG.md index 9bc63fce0..5f179b6a1 100644 --- a/joule-code/CHANGELOG.md +++ b/joule-code/CHANGELOG.md @@ -4,6 +4,38 @@ All notable changes to the Joule Code standard are recorded here. The format fol ## [Unreleased] +### Added — DVR on the relay (L7b): `joule-moq-relay`'s DVR store, the seek power, the room page's Seek back + +- **`joule-moq-relay` keeps a DVR window on disk** (`[dvr]`, `[[dvr.window]]`; WAI + live-recording §8): every track under a window's prefix, with its receipts, a group stored + once the cache holds it whole, in a file per group written durably (a temporary file, + synced, renamed, its directory synced). A window by age (at most 24 hours) and by bytes, + evicted oldest first by whole group, each media group with its receipts group. On start + every object is checked again (its own check, then its receipt, or each event record's + signature): one that does not verify is dropped, counted, written out of its file, and + never served. A FETCH the cache does not cover is answered from the cache and the store + together where they cover it, read as the fetch stream drains; what was evicted is an End of + Unknown Range. TRACK_STATUS and FETCH answer from the store once a publisher is gone. +- **The seek power at the relay** (WAI live-rooms §10.6): a FETCH or fill below the live edge + needs an authority's grant whose powers hold bit 4, or a rule with `seek = true`; otherwise + `UNAUTHORIZED`, `seek_not_granted`. `[[auth.grants]]` rules gain `seek` (default false). +- **Moderation in the store**: the relay reads `decision` events from the events tracks it + forwards, honours a room key's (`dvr.room_keys`, the authorities' keys) or a moderator + grant's, and withholds what deny or redact target from every FETCH and fill, erasing a + redacted object from disk; the decisions are kept in `decisions.wdl` across a restart. +- **`WMH1` version 2** from a relay with a store, with its DVR figures; the session summary's + tracks gain `dvr_objects` and `dvr_bytes`. +- **`joule-moq`**: `GrantHead` reads a grant's `powers` (`moderates`, `seeks`); + `receipt::verify_strict` is public. +- **`joule-moq-room`**: every role, and so every grant, holds the seek power, so a late + joiner's FETCH of earlier groups is served by a relay that checks it. +- **`joule-moq-ingest`** reads roles and grants with the seek power. +- **`joule-moq-web`**: the room page's **Seek back** control fetches a participant's last + seconds from the relay's window, verifies every object against its receipt, plays the video + from its first key frame in a view of its own, and returns to live; the browser end-to-end + test's `dvr` scenario covers it in Chrome against a relay whose cache is far smaller than + the span sought back. + ### Added - **§5.1 Grant-gated consultation (OPTIONAL)** — the JCP binding: tier as granted action (`l0`…`l4`), energy class as chained budget debit, denial as fallthrough, every consultation sealed as a chained JCR-1 receipt. Reference binding: the `joule-code-jcp` crate (`JcpGate::wrap` over any `Stage`); no `l4` in the chain → no remote frontier agent, structurally. Conformance gains class **C5** with a published vector file (`gated_cascade_vectors.json`, fixed fixture identities, deterministic end to end) run by the harness against the real jcp runtime alongside C1–C4. diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/README.md b/joule-code/joule-code-rs/crates/joule-moq-relay/README.md index e2a39eb0a..99e6961a0 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/README.md @@ -150,8 +150,8 @@ and a session it closes at the end of a GOAWAY's timeout ends with: | §7.6, §9.8 PUBLISH | Accepted with REQUEST_OK; its Track Alias is the publisher's for its data. A second track under one alias in one session closes it with DUPLICATE_TRACK_ALIAS. PUBLISH_DONE ends the track's downstream subscriptions. | | §7.4, §7.6, §9.6 SUBSCRIBE | SUBSCRIBE_OK only once an upstream subscription is Established (§7.4): one the relay holds, or a SUBSCRIBE it sends to every publisher of a matching namespace. The relay's own upstream SUBSCRIBE asks for every object at priority 128 with no timeout, copying no subscriber's priority, group order or timeout (§5.1.3, §9.20.3, §9.20.4). RENDEZVOUS_TIMEOUT holds a SUBSCRIBE for a publisher, up to `max_rendezvous_ms`; without it a track with no publisher is DOES_NOT_EXIST. Location Filters set where the subscription starts and ends. A publisher of a namespace that arrives later is sent a SUBSCRIBE for every subscription the relay holds under it. When the last subscriber leaves, the relay cancels its own upstream SUBSCRIBE. | | §9.5 REQUEST_UPDATE | Subscriber priority (re-applied to what is queued), FORWARD, the Location Filter and both delivery timeouts. | -| §3.2, §3.4, §7.1, §9.11 FETCH and fills | A fetch the cache covers is served from it, groups in the requested order (coverage, below); otherwise it goes to a publisher of the track or its namespace and its fetch stream is piped through, past the Fill Timeout a Timed-Out range. A range that starts after the Largest Object is INVALID_RANGE. A SUBSCRIBE with FILL_PARAMETERS gets its fill range the same way on a fill fetch stream named by its Request ID; a fill it cannot serve is a fetch stream reset after its header (§3.4.1). | -| §9.13 TRACK_STATUS | Answered from what the relay holds: the Largest Object and the track's properties. | +| §3.2, §3.4, §7.1, §9.11 FETCH and fills | A fetch the cache covers is served from it, groups in the requested order (coverage, below); otherwise from the cache and the DVR store together where they cover it (*The DVR store*, below); otherwise it goes to a publisher of the track or its namespace and its fetch stream is piped through, past the Fill Timeout a Timed-Out range. A range that starts after the Largest Object is INVALID_RANGE; one that starts below the live edge needs the seek power (*Tokens and room grants*). A SUBSCRIBE with FILL_PARAMETERS gets its fill range the same way on a fill fetch stream named by its Request ID; a fill it cannot serve is a fetch stream reset after its header (§3.4.1). | +| §9.13 TRACK_STATUS | Answered from what the relay holds: the Largest Object and the track's properties, from the DVR store when the track's publisher is gone (or before a restart). | | §7.3 several publishers | A track may have several upstreams; each (group, subgroup) is taken from the first that sends it, and the rest are counted as deduplicated. | | §5.1 priorities, §5.2 delivery timeouts | Below. | | §6.6.1, §9.2 GOAWAY | On shutdown every session gets GOAWAY with `goaway_uri` and the timeout; new requests are refused with GOING_AWAY; a session still open at the timeout is closed with GOAWAY_TIMEOUT. | @@ -310,6 +310,15 @@ rights are per namespace, so §10.2's track matches (layers up to `max_layer`) a narrowed. A session ends, `EXPIRED_AUTH_TOKEN`, when the earliest such grant it proved expires. +**Seeking back** (live-rooms §10.6). The **live edge** of a track is the group of its Largest +Object. A FETCH whose range starts below it, or a SUBSCRIBE whose fill does, seeks back, and +with `auth.required` it is served only to a session holding the **seek** power: an +authority's grant whose `powers` hold bit 4 (§3.3), proved as above, or a `[[auth.grants]]` +rule with `subscribe` and `seek`. Any other is refused `REQUEST_ERROR` `UNAUTHORIZED` with +the reason phrase `seek_not_granted`. A FETCH that starts at the live edge, as a page joining +the group in progress does, needs nothing more. The relay's `seek_refused` counter counts the +refusals. + **Patterns.** A field `*` in a rule's prefix, or a door rule's, matches any one field: a room's clock token can be given `["*", "", "events"]`, its events track in every room. A door rule's `trust_field` names the namespace field holding the one key it accepts @@ -384,6 +393,108 @@ session. Nothing in the record is per object or per viewer, so its size depends room's published tracks and not on its audience. `tests/room_metering.rs` checks that the record recomputes from the summaries. +## The DVR store + +With `[dvr]` configured the relay keeps, on disk, a window of the tracks under each +`[[dvr.window]]` prefix with their receipts, and serves it to FETCH below the live edge (WAI +`extensions/live-recording.md` §8). Its module is [`dvr`](src/dvr.rs). + +**What it keeps.** Every track a window's prefix matches (the rule with the longest prefix +keeps it): media tracks, their receipts tracks, events tracks, catalogs, takes. A group is +stored once the cache holds it whole (*What the cache covers*), with every field a FETCH +carries. While a stream of a kept group is still arriving, the cache does not evict it, past +its budget by that group if it must; a group that reaches the relay after the cache evicted a +later group of its track is not cached, and so not stored, and the relay logs a warning: +give the cache a few groups of each track. + +**The window.** Each `[[dvr.window]]` has a `max_age_s`, from when a group's first object +reached the relay, of 1 to 86 400 (24 hours, the start-over buffer a live-to-VOD harvest clips +from), and a `max_bytes` of payload over its tracks together. Past either the oldest group goes +first, whole, and group *g* of a media track goes with group *g* of its receipts track. A group +that becomes whole after the window has moved past its number is not stored (counted as +`groups_late`). + +**On disk.** Under `path`, one directory per track, `tracks//`, holding `TRACK` (namespace, name, properties, and a SHA-256 +check) and one file per group, `g.wdg`: + +```text +header "WDG1" 1 track(32) group(u64) arrived_us(u64) count(u32) erased(u32) check(32) +object id(u64) subgroup(u64, 2^64-1 for a datagram) priority(u8) status(u8) + props_len(u32) payload_len(u32) digest(32) check(32) props payload +erased id(u64) digest(32) why(u8: 1 redacted, 2 did not verify) +``` + +Integers are little-endian. `track` is the SHA-256 of the rendered track; the header's `check` +is SHA-256 over the header before it; an object's `digest` is the content hash its receipt +names it by (jwp-receipts §2.1), and its `check` SHA-256 over its fixed fields before `check`, +its properties and its payload. `decisions.wdl` beside `tracks/` holds the moderation decisions +the relay honoured, each a length, the `WEV1` record and its SHA-256. + +**Durability.** A group is written to `g….tmp`, synced, renamed into place, and its directory +synced: a group file is whole or absent. On start the relay reads every file again: + +- a `.tmp` is a write a crash cut short, and is removed (`torn_writes`); +- a header that does not check drops its group; +- every object is checked: its `check` and `digest`; on a media track whose receipts track is + stored, against group *g*'s receipts (the group receipt's signature under the door's trust + for the namespace, or any key that verifies where no door rule covers it; the object's + receipt at its position; its Merkle proof to the signed root; its content hash); on an + events track, every record under its principal. One that does not verify is dropped, counted + (`objects_dropped`, and the `WMH1` record's `dvr_dropped`), and its group's file is written + again with it among the erased. It is never served. + +What is below the first group the store holds of a track after a restart is unknown to it. +Each object's digest is checked again as it is read. + +**Answering a FETCH.** A FETCH the cache covers is answered from it, as before. Otherwise, +where every group of the range is held whole by the cache or the store, known not to exist, or +evicted from the store, the relay answers from the cache and the store together, groups in the +requested order, reading the store's groups as the fetch stream drains (about a mebibyte at a +time). Otherwise it asks upstream. A FETCH of the receipts track brings each group's receipts +the same way, so a viewer checks a recorded object exactly as a live one. + +**What is no longer there.** Evicted groups are marked with an **End of Unknown Range** +(`0x10C`, MOQT §11.4.1.2) through the last of them: a range partly evicted gets what remains +and the marker, one wholly evicted the marker alone. Not an unmarked gap and not an End of +Non-Existent Range: an evicted object existed, and an unmarked gap would say it did not (MOQT +§3.2.2). An End of Range moves to a later group than the entry before it (its Group ID is +delta-coded, as `joule-moq` reads -22), so an object of a group that cannot be served (dropped, +withheld) is marked by one End of Unknown Range from the group's start through the last such +object, and the group's objects after it follow. + +**Moderation.** The relay reads the `decision` events (WAI live-events §4.11) of the events +tracks it forwards in a kept room. It honours one whose record verifies, whose principal owns +the track (`[R, P, events]`), and whose principal is a room key (`dvr.room_keys`, or a key of +`[[auth.authorities]]`) or proved a grant with the moderate power on its publishing session. +The last honoured decision on a target stands. Deny and redact withhold the target from every +FETCH and fill, from the store and from the cache, for every session but a moderator's; redact +also erases it from its group's file; allow restores what deny withheld. A media object is +named by its receipt's digest, an event by its record's digest: a withheld event is left out of +the events-track object that carries it, the other records byte for byte, and an object left +with none is withheld whole. Live subscriptions are not filtered here (*Not yet implemented*). + +**Metering.** A relay with a store signs `WMH1` version 2 (live-metering §3.9): the objects and +bytes its sessions were served from the store (`dvr_objects`, `dvr_bytes` in each summary's +tracks), and from the store's own series, kept a point a second, the objects and bytes it wrote +for the room, the bytes it held integrated over the span in byte-seconds, and the objects it +dropped. `Dvr::counts` gives the store's counters, `Dvr::figures` a room's figures over a span. + +**Configuration.** + +```toml +[dvr] +path = "/var/lib/joule-moq-relay/dvr" +room_keys = [] # a room's authority and clock, hex; the authorities' keys count too + +[[dvr.window]] +prefix = ["*"] # a field "*" matches any one field +max_age_s = 900 +max_bytes = 4294967296 +``` + +The store must be kept where the room's region pin allows (live-rooms §3.7). + ## Configuration `joule-moq-relay --config relay.toml`, or `joule-moq-relay --dev` for loopback with a @@ -392,7 +503,8 @@ path, the certificate files (or the names for a development certificate), the ca budget, the limits (sessions, requests and streams in flight, MAX_REQUEST_UPDATES, the token cache, the rendezvous, upstream and fill timeouts, write concurrency, the QUIC send window, the GOAWAY timeout, the session, ingress and memory limits), the receipts suffix and door -rules, the summary's key and file, the grants and the room authorities. The configuration holds no secret: keys are files, and tokens are named by their +rules, the summary's key and file, the grants and the room authorities, and the DVR store and its +windows. The configuration holds no secret: keys are files, and tokens are named by their SHA-256. `JOULE_MOQ_RELAY_LOG` sets the log level. ## Not yet implemented @@ -423,6 +535,11 @@ SHA-256. `JOULE_MOQ_RELAY_LOG` sets the log level. datagrams at the door are checked against their group's receipts, not a property. - **Energy:** no meter backs the relay, so its own figure is always the unmetered marker. Attributing a metered host's energy to sessions is not done here. +- **The DVR store:** decisions withhold from FETCH and fills, not yet from live subscriptions + (a reviewed item's `held` state, live-moderation §6.3, needs the review policy); objects + are checked against their receipts at a restart, and by the door as they arrive where a door + rule covers them, not again as they are stored; no HLS or DASH presentation of the window is + written (live-recording §8.6 is a mapping). - **Re-validation** (`moqt-reval`, live-rooms §10.2): a grant is checked at SETUP and on each request, and its expiry ends the session; revocation through the key log (§5) is not read. @@ -440,6 +557,7 @@ All on loopback, every wait bounded: | `auth_tokens` | Tokens in SETUP, by value and by alias; UNAUTHORIZED; UNKNOWN_AUTH_TOKEN_ALIAS. | | `grant_proof` | A room grant with its proof over each transport gives its rights, each session with a challenge of its own; the grant as a bare token, a proof by another key, one for another relay and one over an earlier session's challenge each close the session with UNAUTHORIZED; a grant presented only in a request is refused it. | | `request_ids` | INVALID_REQUEST_ID for the wrong parity and for a repeat; requests in flight bounded by the stream limit. | +| `dvr` | A long publish crosses the window: the oldest groups go, whole, each media group with its receipts group, within the byte budget; a window by age empties as its groups pass it. FETCH inside the window (ascending and descending), across it (an End of Unknown Range through the last evicted group, then every object, from the store and the cache together) and before it (the marker alone); every object verifies against the fetched receipts. A restart keeps the store, drops and counts a flipped byte and bytes changed with their checks made again (only the receipt finds those), removes a cut-short write, and never serves what it dropped. A room key's redaction erases its target and withholds it, a deny withholds until an allow, a redacted event leaves the object that carries it, a decision by no moderator changes nothing, and the decisions hold across a restart. Without the seek power a FETCH and a fill below the live edge are refused `seek_not_granted`, and a FETCH at the live edge is served; with it, the window. The record of the room is `WMH1` version 2 with the store's figures. A window past 24 hours is refused. | | `priority_fan_out` | Two publishers, four tracks, three subscribers with their own subscriber priorities and group orders, their transports held shut while the load arrives: each receives every object in §5.1.2's order. | ## Licence diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index edeb744a4..68b22525b 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,36 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### Live L7b: the DVR window, the seek power, and WMH1 version 2 + +Additive: no existing record changes its bytes, and every existing corpus vector keeps its +records. + +- **live-recording §8** gains what a relay that keeps a **DVR window** of a room's tracks + does with it (§8.2 to §8.5): a window by age (at most 24 hours) and by bytes, evicted + oldest first by whole group, each media group with its receipts group; a FETCH answered from + the cache and the window together, groups in the requested order; what was evicted marked + with an End of Unknown Range (MOQT §11.4.1.2, `0x10C`), never with an unmarked gap or an End + of Non-Existent Range, since an evicted object existed; an object that cannot be served + inside a group marked from the group's start, since -22's End of Range moves to a later + group; a harvest window whose end lies ahead as a SUBSCRIBE with that range and a fill; + decisions read from the room's events tracks withholding what they deny or redact, never + replacing it; every object checked again against its receipt after a restart. §8.6 maps the + window onto DASH's `timeShiftBufferDepth` and an HLS live playlist. §8.1 is the take + addressing as before. +- **live-rooms**: the **seek** power, bit 4 of `powers` (§3.3), and **§10.6 Seeking back**: + a FETCH, or a fill, that starts below a track's live edge is served only to a grant with the + power, and refused `UNAUTHORIZED`, `seek_not_granted`, otherwise. Roles and grants may now + set bits 0 to 4. `wai-rs` `live_room::seek_check`; the corpus gains a `dvr` room, three + refusals (a grant's seek beyond its role, power bit 5 in a grant and in a role) and a + `seek` section of requests served and refused, which `live_rooms_verify.py` checks too. +- **live-metering**: `WMH1` **version 2** (§3.9) adds a hop's DVR figures before `sig`: the + objects and bytes served from its window, those it wrote to it, the bytes it held in + byte-seconds, and the objects it dropped because they did not verify; with their rules + (§3.6) and bound `B₂(M) = 600 + 180 × (M + 2)` (§3.7). A hop with no window signs version 1. + The receipt stays version 1 and does not sum the figures. The corpus gains a version 2 relay + record and four refusals; `live_metering_verify.py` reads both versions. + ### wai-quantum 0.6.5 and the service: programs that measure in the middle Additive. Every pinned digest is unchanged, and a program that measures only at the end diff --git a/wai/SPEC.md b/wai/SPEC.md index 8fd799a2f..68b33fa50 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -877,7 +877,8 @@ carrying its capabilities, a window of at most fourteen days, a single-use nonce event policy it is held to, its joule and funds ceilings and its composition attributes. A grant is exchanged at a tick of the room clock and revoked through the authority's key log; a room verifier replays every principal's events under the grant in effect at each -instant. The grant maps onto existing token formats rather than defining one: +instant. The grant maps onto existing token formats rather than defining one, and its +seek power decides who may fetch a track below its live edge, from a relay's DVR window: [`extensions/live-rooms.md`](extensions/live-rooms.md). And for a **take** recorded in a room — `wai.capture.take`: a device's full-quality master @@ -926,7 +927,11 @@ over their run of leaves against a commitment the device made while it captured, verifier holding the clip, the take's commitments, the device's key and the room descriptor knows the clip's bytes are exactly those bytes of the take, without the take. The mapping of a recording onto HLS and LL-CMAF and the addressing of its chunks for a MoQ FETCH are field -semantics: [`extensions/live-recording.md`](extensions/live-recording.md). +semantics. A relay may keep a **DVR window** of a room's tracks, with their receipts, for at +most 24 hours: it answers a FETCH from it in the requested order, so a viewer checks a recorded +object exactly as a live one, marks what it evicted as unknown rather than absent, withholds +what a moderation decision restricts, and checks every object it kept again after a restart: +[`extensions/live-recording.md`](extensions/live-recording.md). And for the **captions** of a room — `wai.text.timed`: timed text, as cues in a closed set of styling (italic runs, line breaks, six anchors) that WebVTT and the IMSC1 Text Profile -- 2.54.0 (Apple Git-157) From c52270a17cfd3f68715cda61e4a2abdc0b236212 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 12:00:05 -0400 Subject: [PATCH 23/43] =?UTF-8?q?joule-moq-relay:=20cite=20live-recording?= =?UTF-8?q?=20=C2=A78.3=20for=20a=20harvest=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs index 010c0693b..adcd51c5b 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs @@ -1601,7 +1601,7 @@ async fn serve_fill(relay: &Relay, ctx: &Arc, key: &TrackKey, filter return; } // The fill's earlier groups from the DVR store: a window whose end lies ahead of the - // live edge is a SUBSCRIBE with that range and a fill (WAI live-recording §8.4). + // live edge is a SUBSCRIBE with that range and a fill (WAI live-recording §8.3). if may_seek || start.group >= largest.group { let local = { let st = relay.inner.state.lock().unwrap(); -- 2.54.0 (Apple Git-157) From 9953ede352047905b99b0e85cd89b5c5a4613b66 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 12:01:36 -0400 Subject: [PATCH 24/43] joule-moq-relay: a redacted event record is erased from the stored object that carries it Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-relay/src/dvr.rs | 98 +++++++++++++------ .../crates/joule-moq-relay/tests/dvr.rs | 6 +- 2 files changed, 71 insertions(+), 33 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs index 62c3ad866..23798678e 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs @@ -50,7 +50,8 @@ //! grant with the moderate power its publishing session proved. The last honoured decision //! on a target stands (WAI `live-moderation.md` §4.5): deny and redact withhold the target //! from every DVR read, and from every FETCH the cache answers; redact also erases its bytes -//! from the store. Allow restores what deny withheld. The honoured decisions are kept in +//! from the store (an event record from the object that carries it, the object's other +//! records kept). Allow restores what deny withheld. The honoured decisions are kept in //! `decisions.wdl` and survive a restart. use crate::auth::pattern_matches; @@ -1106,46 +1107,79 @@ impl Dvr { self.evict_by_bytes(window); } - /// Erase what a redaction targets from every group that holds it: the group's file - /// is written again with the object among its erased. + /// Erase what a redaction targets from every group that holds it: the group's file is + /// written again with the object among its erased. An event record is erased from the + /// events-track object that carries it, the object's other records kept byte for byte; + /// an object left with none is erased whole. fn erase(&self, room: [u8; 32], digest: [u8; 32]) { let places: Vec<(TrackKey, u64, u64)> = self.index.lock().unwrap().by_digest.get(&digest).cloned().unwrap_or_default(); for (key, g, oid) in places { - let (path, grp) = { + let track_digest = sha256(&[render(&key.0, Some(&key.1)).as_bytes()]); + let (path, o) = { + let ix = self.index.lock().unwrap(); + let Some(t) = ix.tracks.get(&key).filter(|t| t.room == Some(room)) else { continue }; + let Some(o) = t.groups.get(&g).and_then(|grp| grp.objects.iter().find(|o| o.id == oid)).cloned() else { continue }; + (group_file(&t.dir, g), o) + }; + let Ok(b) = std::fs::read(&path) else { continue }; + let Some((_, _, _, mut objs, _)) = decode_group(&b, &track_digest) else { continue }; + // The object's new payload, when a record is taken out of it and others stay. + let replaced: Option = if o.digest == digest { + None + } else { + let Some((_, _, payload, _)) = objs.iter().find(|x| x.0.id == oid) else { continue }; + events::withhold(payload, |d| *d == digest).map(|p| Bytes::from(p.into_owned())) + }; + let grp = { let mut ix = self.index.lock().unwrap(); let Some(t) = ix.tracks.get_mut(&key) else { continue }; - if t.room != Some(room) { - continue; - } - let path = group_file(&t.dir, g); - let Some(grp) = t.groups.get_mut(&g) else { continue }; - let Some(o) = grp.objects.iter().find(|o| o.id == oid).cloned() else { continue }; - if o.digest != digest { - // An events object holding a redacted record: withheld at each read, - // since its other records stay; its bytes stay with them. - continue; - } - grp.objects.retain(|x| x.id != oid); - grp.bytes = grp.bytes.saturating_sub(o.payload_len as u64); - grp.erased.push((oid, digest, ERASED_REDACTED)); let window = t.window; - ix.window_bytes[window] = ix.window_bytes[window].saturating_sub(o.payload_len as u64); - ix.series.entry(room).or_default().change(now_us(), -(o.payload_len as i128), 0, 0, 0); - (path, ix.tracks[&key].groups[&g].clone()) - }; - if let Ok(b) = std::fs::read(&path) - && let Some((_, _, _, objs, _)) = decode_group(&b, &sha256(&[render(&key.0, Some(&key.1)).as_bytes()])) - { - let fresh = rewrite(&path, &key, g, &grp, &objs); - if let Some(index) = fresh { - let mut ix = self.index.lock().unwrap(); - if let Some(gr) = ix.tracks.get_mut(&key).and_then(|t| t.groups.get_mut(&g)) { - gr.objects = index; + let Some(grp) = t.groups.get_mut(&g) else { continue }; + let delta: i128 = match &replaced { + None => { + grp.objects.retain(|x| x.id != oid); + grp.erased.push((oid, o.digest, ERASED_REDACTED)); + -(o.payload_len as i128) + } + Some(p) => { + let Some(x) = grp.objects.iter_mut().find(|x| x.id == oid) else { continue }; + x.digest = ObjectReceipt::hash_payload(p); + x.records = events::digests(p); + p.len() as i128 - o.payload_len as i128 + } + }; + grp.bytes = (grp.bytes as i128 + delta).max(0) as u64; + let grp = grp.clone(); + ix.window_bytes[window] = (ix.window_bytes[window] as i128 + delta).max(0) as u64; + ix.series.entry(room).or_default().change(now_us(), delta, 0, 0, 0); + for d in std::iter::once(&o.digest).chain(o.records.iter()) { + if let Some(v) = ix.by_digest.get_mut(d) { + v.retain(|x| !(x.0 == key && x.1 == g && x.2 == oid)); + if v.is_empty() { + ix.by_digest.remove(d); + } } } - self.stats.objects_redacted.fetch_add(1, Ordering::Relaxed); - tracing::info!(target: "joule_moq_relay::dvr", file = %path.display(), object = oid, "a redacted object erased from the store"); + if let Some(x) = grp.objects.iter().find(|x| x.id == oid) { + for d in std::iter::once(&x.digest).chain(x.records.iter()) { + ix.by_digest.entry(*d).or_default().push((key.clone(), g, oid)); + } + } + grp + }; + if let Some(p) = &replaced { + for x in objs.iter_mut().filter(|x| x.0.id == oid) { + x.2 = p.clone(); + } } + if let Some(index) = rewrite(&path, &key, g, &grp, &objs) { + let mut ix = self.index.lock().unwrap(); + if let Some(gr) = ix.tracks.get_mut(&key).and_then(|t| t.groups.get_mut(&g)) { + gr.objects = index; + } + } + self.stats.objects_redacted.fetch_add(1, Ordering::Relaxed); + tracing::info!(target: "joule_moq_relay::dvr", file = %path.display(), object = oid, whole = replaced.is_none(), "a redaction erased from the store"); } } diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs index a2df6bc28..a40372dbe 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs @@ -488,7 +488,7 @@ async fn decisions_withhold_from_the_store_and_hold_across_a_restart() { let (me, _me_held) = EventsTrack::open(&m, room, &moderator, 50).await; me.group(&m, 0, &[decision(&moderator, room, 0, 3, h(2, 1)), decision(&moderator, room, 1, 2, h(3, 0)), decision(&moderator, room, 2, 3, event_digest(&chat1))]).await; pe.group(&p, 1, &[decision(&publisher, room, 2, 3, h(4, 0))]).await; - assert!(until(STEP, || d.counts().decisions >= 3 && d.counts().objects_redacted >= 1).await, "{:?}", d.counts()); + assert!(until(STEP, || d.counts().decisions >= 3 && d.counts().objects_redacted >= 2).await, "{:?}", d.counts()); assert_eq!(d.counts().decisions, 3, "the publisher's decision is not honoured"); let all = || Parameters::new().with(Parameter::LocationFilter(LocationFilter::AbsoluteStart { start: Location::new(0, 0) })); @@ -503,6 +503,10 @@ async fn decisions_withhold_from_the_store_and_hold_across_a_restart() { assert!(!file.windows(LEN).any(|w| w == object_payload("m", 2, 1).as_slice()), "redacted: erased from disk"); let file = std::fs::read(d.group_path(&(t.ns.clone(), t.media.clone()), 3).unwrap()).unwrap(); assert!(file.windows(LEN).any(|w| w == object_payload("m", 3, 0).as_slice()), "denied: kept"); + // The redacted chat is erased from the events track's file; the other stays. + let file = std::fs::read(d.group_path(&(pe.ns.clone(), name("0")), 0).unwrap()).unwrap(); + assert!(!file.windows(msf(&[&chat1]).len()).any(|w| w == msf(&[&chat1]).as_slice()), "redacted: erased from disk"); + assert!(file.windows(msf(&[&chat2]).len()).any(|w| w == msf(&[&chat2]).as_slice())); // The publisher's events: the redacted chat is withheld, the other is served as sent. let (_, ev) = v.fetch(pe.ns.clone(), name("0"), all()).await.expect("FETCH the events track"); let ev = fetched(ev); -- 2.54.0 (Apple Git-157) From 398d55e4934118a35e1a5eb94c3c9e4084218d2d Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 12:02:33 -0400 Subject: [PATCH 25/43] joule-code CHANGELOG: name no browser Co-Authored-By: Claude Opus 5.5 --- joule-code/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/joule-code/CHANGELOG.md b/joule-code/CHANGELOG.md index 5f179b6a1..fb8cc8389 100644 --- a/joule-code/CHANGELOG.md +++ b/joule-code/CHANGELOG.md @@ -33,7 +33,7 @@ All notable changes to the Joule Code standard are recorded here. The format fol - **`joule-moq-web`**: the room page's **Seek back** control fetches a participant's last seconds from the relay's window, verifies every object against its receipt, plays the video from its first key frame in a view of its own, and returns to live; the browser end-to-end - test's `dvr` scenario covers it in Chrome against a relay whose cache is far smaller than + test's `dvr` scenario covers it in a real browser against a relay whose cache is far smaller than the span sought back. ### Added -- 2.54.0 (Apple Git-157) From 3175fa56a30596f43696c97aace22d1cca1a7d77 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 12:11:30 -0400 Subject: [PATCH 26/43] joule-moq-relay: the DVR store appends groups to per-track segments and syncs them once a second One durable sync per group file could not keep up with a room's audio groups (fifty a second per publisher), so the writer fell behind and recent groups were neither cached nor stored. Segments take ten seconds of a track each; a record a later one replaces is marked and zeroed; a crash loses at most the last second, and what it leaves cut short is cut off and counted at start. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-relay/src/dvr.rs | 985 +++++++++++------- .../crates/joule-moq-relay/tests/dvr.rs | 53 +- 2 files changed, 659 insertions(+), 379 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs index 23798678e..286fbe57f 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs @@ -18,7 +18,10 @@ //! //! **On disk.** One directory per track under `tracks/`, named by the first 16 bytes of the //! SHA-256 of its rendered name (MOQT §8.8) in hexadecimal, holding `TRACK` (its namespace, -//! name and properties) and one file per group, `g.wdg`: +//! name and properties, with a SHA-256 check) and its **segments**, `s.wds`: +//! files a group record is appended to as each group is stored, a new one begun every ten +//! seconds or 16 MiB. A segment is a sequence of frames, `len(u32) state(u8) record`, the +//! state 1 for a record that stands and 0 for one a later record replaced; a record is //! //! ```text //! header "WDG1" 1 track(32) group(u64) arrived_us(u64) count(u32) erased(u32) check(32) @@ -30,18 +33,23 @@ //! Integers are little-endian. `track` is the SHA-256 of the rendered track; a header's //! `check` is SHA-256 over the header's bytes before it; an object's `digest` is the content //! hash its receipt names it by (WAI `jwp-receipts.md` §2.1), and its `check` SHA-256 over -//! its fixed fields before `check`, its properties and its payload. +//! its fixed fields before `check`, its properties and its payload. A record that replaces +//! another (a group written again: an object erased, or a subgroup that came late) is +//! appended, and the old frame's state set to 0 and its record's bytes overwritten with +//! zeros. A segment is deleted once no group's standing record is in it. //! -//! **Durability.** A group is written to `g….tmp`, synced, renamed into place and its -//! directory synced, so a group file is whole or absent; a `.tmp` found at start is a write -//! a crash cut short and is removed. On start the store reads every file again: a header -//! that does not check drops its group; an object whose `check` fails, or, on a track with -//! a receipts track, whose payload does not verify against group *g*'s receipts (the group -//! receipt's signature under the door's trust for the namespace, the object's receipt at -//! its position, its Merkle proof to the signed root), or, on an events track, a record of -//! which does not verify under its principal, is dropped and counted, and its file is -//! written again with the object among the erased. It is never served: a FETCH that reaches -//! it is told End of Unknown Range. Each object's digest is checked again as it is read. +//! **Durability.** The writer syncs every segment it wrote, and the directories it made +//! files in, at least once a second and before it answers a flush or stops; a shutdown +//! flushes. A crash loses at most the last second's groups. On start the store reads every +//! segment again: a frame a crash cut short, at a segment's end, is cut off and counted +//! (`torn_writes`); a record whose header does not check is dropped; an object whose +//! `check` fails, or, on a track with a receipts track, whose payload does not verify +//! against group *g*'s receipts (the group receipt's signature under the door's trust for +//! the namespace, the object's receipt at its position, its Merkle proof to the signed +//! root), or, on an events track, a record of which does not verify under its principal, is +//! dropped and counted, and its group written again with the object among the erased. It is +//! never served: a FETCH that reaches it is told End of Unknown Range. Each object's digest +//! is checked again as it is read. //! //! **Moderation.** The relay reads the `decision` events (WAI `live-events.md` §4.11) on the //! events tracks it already forwards. A decision is honoured when its record verifies, its @@ -68,18 +76,26 @@ use joule_moq::draft22::Location; use joule_moq::receipt::{ObjectReceipt, ReceiptVerifier}; use joule_moq::{GroupId, ObjectId, TrackName, TrackNamespace}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; -use std::io::{Read, Seek, SeekFrom, Write}; +use std::io::Write; +use std::os::unix::fs::FileExt; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{mpsc, Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; const GROUP_MAGIC: &[u8; 4] = b"WDG1"; const TRACK_MAGIC: &[u8; 4] = b"WDT1"; const VERSION: u8 = 1; const HEADER_LEN: usize = 4 + 1 + 32 + 8 + 8 + 4 + 4 + 32; const OBJECT_FIXED: usize = 8 + 8 + 1 + 1 + 4 + 4 + 32 + 32; +/// A frame's length and state, before its record. +const FRAME: u64 = 5; const DATAGRAM: u64 = u64::MAX; +/// How long a segment takes groups, and how large it grows, before another is begun. +const SEGMENT_SPAN_US: u64 = 10_000_000; +const SEGMENT_MAX: u64 = 16 << 20; +/// How often the writer syncs what it wrote. +const SYNC_EVERY: Duration = Duration::from_secs(1); /// Why an object is among a group's erased. pub const ERASED_REDACTED: u8 = 1; pub const ERASED_UNVERIFIED: u8 = 2; @@ -124,6 +140,10 @@ pub fn is_events_track(key: &TrackKey) -> bool { key.0 .0.len() == 3 && key.0 .0[2] == b"events" && key.1 .0 == b"0" && room_of(&key.0).is_some() } +fn track_digest(key: &TrackKey) -> [u8; 32] { + sha256(&[render(&key.0, Some(&key.1)).as_bytes()]) +} + // ── The index ────────────────────────────────────────────────────────────── #[derive(Clone, Debug)] @@ -132,7 +152,7 @@ struct DObject { subgroup: Option, priority: u8, status: ObjectStatus, - /// Where its properties begin in the group's file; its payload follows them. + /// Where its properties begin in its segment; its payload follows them. at: u64, props_len: u32, payload_len: u32, @@ -149,6 +169,10 @@ struct DGroup { objects: Vec, /// Objects no longer held, by Object ID, with their digests. erased: Vec<(u64, [u8; 32], u8)>, + /// Its standing record: the segment, and the frame's place and record length. + seg: u64, + frame_at: u64, + record_len: u32, } #[derive(Debug)] @@ -164,6 +188,8 @@ struct DTrack { /// Groups at or below this are no longer known to the store. evicted_through: Option, end_of_track: Option, + /// Each segment, with the groups whose standing record is in it. + segs: BTreeMap, } impl DTrack { @@ -249,6 +275,26 @@ impl Index { fn withheld(&self, room: Option<[u8; 32]>, digest: &[u8; 32]) -> bool { room.is_some_and(|r| self.standing.get(&(r, *digest)).is_some_and(|v| *v != events::ALLOW)) } + + fn unindex(&mut self, key: &TrackKey, g: u64, o: &DObject) { + for d in std::iter::once(&o.digest).chain(o.records.iter()) { + if let Some(v) = self.by_digest.get_mut(d) { + v.retain(|x| !(x.0 == *key && x.1 == g && x.2 == o.id)); + if v.is_empty() { + self.by_digest.remove(d); + } + } + } + } + + fn reindex(&mut self, key: &TrackKey, g: u64, o: &DObject) { + for d in std::iter::once(&o.digest).chain(o.records.iter()) { + let e = self.by_digest.entry(*d).or_default(); + if !e.iter().any(|x| x.0 == *key && x.1 == g && x.2 == o.id) { + e.push((key.clone(), g, o.id)); + } + } + } } /// Counters across the store. @@ -262,11 +308,13 @@ pub struct DvrStats { pub groups_late: AtomicU64, /// Objects dropped because their check or their receipt did not verify. pub objects_dropped: AtomicU64, - /// Group files a crash cut short (`.tmp`), removed at start. + /// Frames a crash cut short at a segment's end, cut off at start. pub torn_writes: AtomicU64, /// Objects erased by a redaction. pub objects_redacted: AtomicU64, pub decisions: AtomicU64, + /// Segments synced, and the syncs that wrote them out. + pub syncs: AtomicU64, } /// A snapshot of [`DvrStats`]. @@ -280,8 +328,10 @@ pub struct DvrCounts { pub torn_writes: u64, pub objects_redacted: u64, pub decisions: u64, + pub syncs: u64, pub groups_held: u64, pub bytes_held: u64, + pub segments: u64, } /// One object read from the store, or a place it can no longer serve. @@ -333,6 +383,24 @@ struct Window { max_bytes: u64, } +/// The segment a track appends to. +struct OpenSeg { + seq: u64, + file: std::fs::File, + started_us: u64, + len: u64, +} + +/// What the writer alone holds: each track's open segment, and what it has to sync. +#[derive(Default)] +struct Writer { + open: HashMap, + next: HashMap, + dirty: HashSet, + dirs: HashSet, + last_sync: Option, +} + pub struct Dvr { root: PathBuf, windows: Vec, @@ -350,11 +418,16 @@ fn put_short_field(o: &mut Vec, b: &[u8]) { } fn track_dir_name(key: &TrackKey) -> String { - hex::encode(&sha256(&[render(&key.0, Some(&key.1)).as_bytes()])[..16]) + hex::encode(&track_digest(key)[..16]) } -fn group_file(dir: &Path, g: u64) -> PathBuf { - dir.join(format!("g{g:020}.wdg")) +fn seg_path(dir: &Path, seq: u64) -> PathBuf { + dir.join(format!("s{seq:020}.wds")) +} + +fn seg_seq(path: &Path) -> Option { + let n = path.file_name()?.to_str()?; + n.strip_prefix('s')?.strip_suffix(".wds")?.parse().ok() } fn sync_dir(dir: &Path) { @@ -383,7 +456,8 @@ fn status_code(s: ObjectStatus) -> u8 { s.code() as u8 } -/// The bytes of a group file (module documentation). +/// The bytes of a group record (module documentation), and its objects' index, their +/// places counted from the record's start. fn encode_group(track_digest: &[u8; 32], group: u64, arrived_us: u64, objects: &[(DObject, Bytes, Bytes)], erased: &[(u64, [u8; 32], u8)]) -> (Vec, Vec) { let mut o = Vec::new(); o.extend_from_slice(GROUP_MAGIC); @@ -421,11 +495,20 @@ fn encode_group(track_digest: &[u8; 32], group: u64, arrived_us: u64, objects: & (o, index) } -/// A group file read: its header facts, each object with its properties and payload and -/// whether its `check` held, and its erased entries. `None` when the header does not -/// check; objects past a truncation are left out (and so are erased below). -#[allow(clippy::type_complexity)] -fn decode_group(b: &[u8], track_digest: &[u8; 32]) -> Option<(u64, u64, u32, Vec<(DObject, Bytes, Bytes, bool)>, Vec<(u64, [u8; 32], u8)>)> { +/// A group record that read: its header facts, each object with its properties and payload +/// and whether its `check` held (places counted from the record's start), and its erased +/// entries. +struct Decoded { + group: u64, + arrived_us: u64, + count: u32, + objects: Vec<(DObject, Bytes, Bytes, bool)>, + erased: Vec<(u64, [u8; 32], u8)>, +} + +/// Read a group record. `None` when its header does not check; objects past a truncation +/// are left out, and with them the erased entries. +fn decode_group(b: &[u8], track_digest: &[u8; 32]) -> Option { if b.len() < HEADER_LEN || &b[..4] != GROUP_MAGIC || b[4] != VERSION || &b[5..37] != track_digest { return None; } @@ -434,10 +517,7 @@ fn decode_group(b: &[u8], track_digest: &[u8; 32]) -> Option<(u64, u64, u32, Vec } let u64_at = |i: usize| u64::from_le_bytes(b[i..i + 8].try_into().expect("8")); let u32_at = |i: usize| u32::from_le_bytes(b[i..i + 4].try_into().expect("4")); - let group = u64_at(37); - let arrived = u64_at(45); - let count = u32_at(53); - let erased_n = u32_at(57) as usize; + let (group, arrived_us, count, erased_n) = (u64_at(37), u64_at(45), u32_at(53), u32_at(57) as usize); let mut p = HEADER_LEN; let mut objects = Vec::new(); for _ in 0..count { @@ -452,7 +532,6 @@ fn decode_group(b: &[u8], track_digest: &[u8; 32]) -> Option<(u64, u64, u32, Vec let props_len = u32::from_le_bytes(f[18..22].try_into().expect("4")); let payload_len = u32::from_le_bytes(f[22..26].try_into().expect("4")); let digest: [u8; 32] = f[26..58].try_into().expect("32"); - let check = &f[58..90]; let at = p + OBJECT_FIXED; let end = at + props_len as usize + payload_len as usize; if b.len() < end { @@ -460,7 +539,7 @@ fn decode_group(b: &[u8], track_digest: &[u8; 32]) -> Option<(u64, u64, u32, Vec } let props = Bytes::copy_from_slice(&b[at..at + props_len as usize]); let payload = Bytes::copy_from_slice(&b[at + props_len as usize..end]); - let ok = sha256(&[&f[..58], &props, &payload])[..] == *check; + let ok = sha256(&[&f[..58], &props, &payload])[..] == f[58..90]; objects.push((DObject { id, subgroup: (sg != DATAGRAM).then_some(sg), priority, status, at: at as u64, props_len, payload_len, digest, records: Vec::new() }, props, payload, ok)); p = end; } @@ -474,7 +553,7 @@ fn decode_group(b: &[u8], track_digest: &[u8; 32]) -> Option<(u64, u64, u32, Vec p += 41; } } - Some((group, arrived, count, objects, erased)) + Some(Decoded { group, arrived_us, count, objects, erased }) } fn encode_track(key: &TrackKey, props: &Properties) -> Vec { @@ -528,8 +607,19 @@ fn prior_groups(props: &Properties, group: u64) -> Option<(u64, u64)> { (n > 0 && n <= group).then(|| (group - n, group - 1)) } +fn read_at(path: &Path, at: u64, len: usize) -> std::io::Result> { + let f = std::fs::File::open(path)?; + let mut b = vec![0u8; len]; + f.read_exact_at(&mut b, at)?; + Ok(b) +} + +/// A group's record held in memory to be written again: each object with its properties +/// and payload. +type Rows = Vec<(DObject, Bytes, Bytes)>; + impl Dvr { - /// Open the store at `cfg.path`: read every track and group in it, check every object + /// Open the store at `cfg.path`: read every track and segment in it, check every object /// again, and start its writer. `verifier` gives the receipts verifier of a namespace; /// `authorities` are the keys of `auth.authorities`, room keys besides `cfg.room_keys`. pub fn open(cfg: &DvrConfig, suffix: &str, authorities: Vec<[u8; 32]>, verifier: impl Fn(&TrackNamespace) -> ReceiptVerifier) -> Result, String> { @@ -553,11 +643,12 @@ impl Dvr { worker: Mutex::new(None), stats: DvrStats::default(), }); - dvr.load(&verifier)?; + let mut w = Writer::default(); + dvr.load(&mut w, &verifier)?; let (tx, rx) = mpsc::channel(); *dvr.tx.lock().unwrap() = Some(tx); let me = dvr.clone(); - let worker = std::thread::Builder::new().name("joule-moq-relay-dvr".into()).spawn(move || me.run(rx)).map_err(|e| e.to_string())?; + let worker = std::thread::Builder::new().name("joule-moq-relay-dvr".into()).spawn(move || me.run(w, rx)).map_err(|e| e.to_string())?; *dvr.worker.lock().unwrap() = Some(worker); Ok(dvr) } @@ -577,11 +668,11 @@ impl Dvr { } } - /// Wait until every write asked for so far is on disk. + /// Wait until every write asked for so far is on disk, synced. pub fn flush(&self) { let (tx, rx) = mpsc::channel(); self.send(Job::Flush(tx)); - let _ = rx.recv_timeout(Duration::from_secs(30)); + let _ = rx.recv_timeout(Duration::from_secs(60)); } /// Flush, then stop the writer. The store is closed; a new one can open its path. @@ -655,52 +746,65 @@ impl Dvr { /// live-moderation §6.3) nothing is withheld; what is erased stays unknown. `None` when /// the store no longer holds the group. pub fn read_group(&self, key: &TrackKey, group: u64, from: u64, to: u64, withhold: bool) -> Option> { - let (path, objs, erased, room, is_events) = { - let ix = self.index.lock().unwrap(); - let t = ix.tracks.get(key)?; - let g = t.groups.get(&group)?; - (group_file(&t.dir, group), g.objects.clone(), g.erased.clone(), t.room, t.events) - }; - let mut f = std::fs::File::open(&path).ok()?; - let mut out: Vec<(u64, Slot)> = Vec::new(); - for (id, _, _) in erased.iter().filter(|e| e.0 >= from && e.0 <= to) { - out.push((*id, Slot::Unknown(*id))); - } - for o in objs.iter().filter(|o| o.id >= from && o.id <= to) { - let mut b = vec![0u8; o.props_len as usize + o.payload_len as usize]; - if f.seek(SeekFrom::Start(o.at)).and_then(|_| f.read_exact(&mut b)).is_err() { - out.push((o.id, Slot::Unknown(o.id))); - continue; - } - let payload = Bytes::from(b.split_off(o.props_len as usize)); - if ObjectReceipt::hash_payload(&payload) != o.digest { - self.stats.objects_dropped.fetch_add(1, Ordering::Relaxed); - out.push((o.id, Slot::Unknown(o.id))); - continue; - } - let ix = self.index.lock().unwrap(); - let room = room.filter(|_| withhold); - if ix.withheld(room, &o.digest) { - out.push((o.id, Slot::Unknown(o.id))); - continue; - } - let payload = if is_events && o.records.iter().any(|d| ix.withheld(room, d)) { - match events::withhold(&payload, |d| ix.withheld(room, d)) { - Some(p) => Bytes::from(p.into_owned()), - None => { - out.push((o.id, Slot::Unknown(o.id))); - continue; - } - } - } else { - payload + // A group written again between the index and the read (an erasure) is read again. + for attempt in 0..2 { + let (path, place, objs, erased, room, is_events) = { + let ix = self.index.lock().unwrap(); + let t = ix.tracks.get(key)?; + let g = t.groups.get(&group)?; + (seg_path(&t.dir, g.seg), (g.seg, g.frame_at), g.objects.clone(), g.erased.clone(), t.room.filter(|_| withhold), t.events) }; - drop(ix); - let properties = if b.is_empty() { Properties::new() } else { Properties::decode(&b, "dvr object properties").unwrap_or_default() }; - out.push((o.id, Slot::Object(StoredObject { id: o.id, subgroup: o.subgroup, priority: o.priority, status: o.status, properties, payload }))); + let Ok(f) = std::fs::File::open(&path) else { + if attempt == 0 { + continue; + } + return None; + }; + let mut out: Vec<(u64, Slot)> = erased.iter().filter(|e| e.0 >= from && e.0 <= to).map(|e| (e.0, Slot::Unknown(e.0))).collect(); + let mut moved = false; + for o in objs.iter().filter(|o| o.id >= from && o.id <= to) { + let mut b = vec![0u8; o.props_len as usize + o.payload_len as usize]; + let payload = match f.read_exact_at(&mut b, o.at) { + Ok(()) => Bytes::from(b.split_off(o.props_len as usize)), + Err(_) => Bytes::new(), + }; + if ObjectReceipt::hash_payload(&payload) != o.digest { + moved = true; + out.push((o.id, Slot::Unknown(o.id))); + continue; + } + let ix = self.index.lock().unwrap(); + if ix.withheld(room, &o.digest) { + out.push((o.id, Slot::Unknown(o.id))); + continue; + } + let payload = if is_events && o.records.iter().any(|d| ix.withheld(room, d)) { + match events::withhold(&payload, |d| ix.withheld(room, d)) { + Some(p) => Bytes::from(p.into_owned()), + None => { + out.push((o.id, Slot::Unknown(o.id))); + continue; + } + } + } else { + payload + }; + drop(ix); + let properties = if b.is_empty() { Properties::new() } else { Properties::decode(&b, "dvr object properties").unwrap_or_default() }; + out.push((o.id, Slot::Object(StoredObject { id: o.id, subgroup: o.subgroup, priority: o.priority, status: o.status, properties, payload }))); + } + if moved { + // Written again since the index was read: read it where it stands now. + let now = self.index.lock().unwrap().tracks.get(key).and_then(|t| t.groups.get(&group)).map(|g| (g.seg, g.frame_at)); + if attempt == 0 && now.is_some() && now != Some(place) { + continue; + } + tracing::warn!(target: "joule_moq_relay::dvr", file = %path.display(), group, "a stored object whose bytes no longer hold its digest: not served"); + } + out.sort_by_key(|(id, _)| *id); + return Some(out.into_iter().map(|(_, s)| s).collect()); } - out.sort_by_key(|(id, _)| *id); - Some(out.into_iter().map(|(_, s)| s).collect()) + None } /// What the store did for `room` between two instants (WAI live-metering §3.9). @@ -724,8 +828,10 @@ impl Dvr { torn_writes: s.torn_writes.load(Ordering::Relaxed), objects_redacted: s.objects_redacted.load(Ordering::Relaxed), decisions: s.decisions.load(Ordering::Relaxed), + syncs: s.syncs.load(Ordering::Relaxed), groups_held: ix.tracks.values().map(|t| t.groups.len() as u64).sum(), bytes_held: ix.window_bytes.iter().sum(), + segments: ix.tracks.values().map(|t| t.segs.len() as u64).sum(), } } @@ -734,9 +840,18 @@ impl Dvr { self.index.lock().unwrap().tracks.get(key).map(|t| t.groups.keys().copied().collect()).unwrap_or_default() } - /// The file of group `group` of `key`, for tests and tools. - pub fn group_path(&self, key: &TrackKey, group: u64) -> Option { - self.index.lock().unwrap().tracks.get(key).map(|t| group_file(&t.dir, group)) + /// Where group `group` of `key` stands on disk: its segment and the place of its + /// frame, for tests and tools. + pub fn group_location(&self, key: &TrackKey, group: u64) -> Option<(PathBuf, u64)> { + let ix = self.index.lock().unwrap(); + let t = ix.tracks.get(key)?; + let g = t.groups.get(&group)?; + Some((seg_path(&t.dir, g.seg), g.frame_at)) + } + + /// The directory of `key`'s files, for tests and tools. + pub fn track_dir(&self, key: &TrackKey) -> Option { + self.index.lock().unwrap().tracks.get(key).map(|t| t.dir.clone()) } // ── Moderation ────────────────────────────────────────────────────────── @@ -767,11 +882,12 @@ impl Dvr { } } - fn apply_decision(&self, e: &Event) { - let Some((verdict, target)) = e.decision() else { return }; + /// Returns the target to erase, for a redaction. + fn apply_decision(&self, e: &Event) -> Option<[u8; 32]> { + let (verdict, target) = e.decision()?; let mut ix = self.index.lock().unwrap(); if !ix.decided.insert(e.digest) { - return; + return None; } ix.standing.insert((e.room, target), verdict); drop(ix); @@ -779,12 +895,130 @@ impl Dvr { tracing::info!(target: "joule_moq_relay::dvr", room = %hex::encode(e.room), target_digest = %hex::encode(target), verdict, "a decision is honoured"); if verdict == REDACT { self.send(Job::Erase { room: e.room, digest: target }); + return Some(target); } + None + } + + // ── Segments ──────────────────────────────────────────────────────────── + + /// Append `record` to `key`'s open segment (beginning another past its span or size), + /// returning the segment and the frame's place. + fn append(&self, w: &mut Writer, key: &TrackKey, dir: &Path, record: &[u8]) -> std::io::Result<(u64, u64)> { + let now = now_us(); + let stale = w.open.get(key).is_none_or(|s| now.saturating_sub(s.started_us) >= SEGMENT_SPAN_US || s.len >= SEGMENT_MAX); + if stale { + if let Some(old) = w.open.remove(key) { + self.retire(key, old.seq, None); + } + let seq = *w.next.get(key).unwrap_or(&0); + w.next.insert(key.clone(), seq + 1); + let path = seg_path(dir, seq); + let file = std::fs::OpenOptions::new().create(true).truncate(true).read(true).write(true).open(&path)?; + w.dirs.insert(dir.to_path_buf()); + w.open.insert(key.clone(), OpenSeg { seq, file, started_us: now, len: 0 }); + if let Some(t) = self.index.lock().unwrap().tracks.get_mut(key) { + t.segs.entry(seq).or_insert(0); + } + } + let s = w.open.get_mut(key).expect("opened above"); + let mut frame = Vec::with_capacity(FRAME as usize + record.len()); + frame.extend_from_slice(&(record.len() as u32).to_le_bytes()); + frame.push(1); + frame.extend_from_slice(record); + s.file.write_all_at(&frame, s.len)?; + let at = s.len; + s.len += frame.len() as u64; + w.dirty.insert(seg_path(dir, s.seq)); + Ok((s.seq, at)) + } + + /// Mark a record replaced: its frame's state 0 and its bytes zeros. + fn kill(&self, w: &mut Writer, dir: &Path, seq: u64, frame_at: u64, record_len: u32) { + let path = seg_path(dir, seq); + let r = std::fs::OpenOptions::new().write(true).open(&path).and_then(|f| { + f.write_all_at(&[0], frame_at + 4)?; + f.write_all_at(&vec![0u8; record_len as usize], frame_at + FRAME) + }); + if r.is_ok() { + w.dirty.insert(path); + } + } + + /// Delete segment `seq` of `key` when no group's standing record is in it and it is not + /// the one being appended to (`open`, the writer's). + fn retire(&self, key: &TrackKey, seq: u64, open: Option) { + if open == Some(seq) { + return; + } + let path = { + let mut ix = self.index.lock().unwrap(); + let Some(t) = ix.tracks.get_mut(key) else { return }; + if t.segs.get(&seq).is_some_and(|n| *n > 0) { + return; + } + t.segs.remove(&seq); + seg_path(&t.dir, seq) + }; + let _ = std::fs::remove_file(&path); + } + + /// Sync every segment written since the last sync, and the directories files were made + /// in. + fn sync(&self, w: &mut Writer) { + for p in w.dirty.drain() { + if let Ok(f) = std::fs::OpenOptions::new().write(true).open(&p) { + let _ = f.sync_data(); + } + self.stats.syncs.fetch_add(1, Ordering::Relaxed); + } + for d in w.dirs.drain() { + sync_dir(&d); + } + w.last_sync = Some(Instant::now()); + } + + /// Write group `g` of `key` again from `rows` and `erased`: append the new record, kill + /// the old, and update the index. Returns the group's new objects. + #[allow(clippy::too_many_arguments)] + fn rewrite(&self, w: &mut Writer, key: &TrackKey, dir: &Path, g: u64, arrived_us: u64, rows: &Rows, erased: &[(u64, [u8; 32], u8)], old: (u64, u64, u32)) -> Option> { + let (bytes, mut index) = encode_group(&track_digest(key), g, arrived_us, rows, erased); + let (seq, at) = match self.append(w, key, dir, &bytes) { + Ok(x) => x, + Err(e) => { + tracing::error!(target: "joule_moq_relay::dvr", error = %e, "a group could not be written again"); + return None; + } + }; + for (o, r) in index.iter_mut().zip(rows) { + o.at += at + FRAME; + o.records = r.0.records.clone(); + } + { + let mut ix = self.index.lock().unwrap(); + if let Some(t) = ix.tracks.get_mut(key) { + *t.segs.entry(seq).or_insert(0) += 1; + if let Some(n) = t.segs.get_mut(&old.0) { + *n = n.saturating_sub(1); + } + if let Some(gr) = t.groups.get_mut(&g) { + gr.objects = index.clone(); + gr.erased = erased.to_vec(); + gr.seg = seq; + gr.frame_at = at; + gr.record_len = bytes.len() as u32; + } + } + } + self.kill(w, dir, old.0, old.1, old.2); + let open = w.open.get(key).map(|s| s.seq); + self.retire(key, old.0, open); + Some(index) } // ── Loading ───────────────────────────────────────────────────────────── - fn load(&self, verifier: &dyn Fn(&TrackNamespace) -> ReceiptVerifier) -> Result<(), String> { + fn load(&self, w: &mut Writer, verifier: &dyn Fn(&TrackNamespace) -> ReceiptVerifier) -> Result<(), String> { let now = now_us(); // The decisions honoured before, each verified again. let log = self.root.join("decisions.wdl"); @@ -799,7 +1033,13 @@ impl Dvr { break; } if let Some(e) = Event::read(rec).filter(|e| e.verifies(rec)) { - self.apply_decision(&e); + // Erasures are made below, once the groups are read. + let (verdict, target) = e.decision().unwrap_or((0, [0; 32])); + let mut ix = self.index.lock().unwrap(); + if verdict != 0 && ix.decided.insert(e.digest) { + ix.standing.insert((e.room, target), verdict); + self.stats.decisions.fetch_add(1, Ordering::Relaxed); + } } p += 4 + n + 32; good = p; @@ -810,7 +1050,9 @@ impl Dvr { } } let tracks_dir = self.root.join("tracks"); - let mut loaded: Vec<(TrackKey, DTrack)> = Vec::new(); + let mut loaded: Vec<(TrackKey, DTrack, HashMap)> = Vec::new(); + // Groups whose standing record no longer says what the index holds: written again. + let mut changed: HashSet<(TrackKey, u64)> = HashSet::new(); for d in std::fs::read_dir(&tracks_dir).map_err(|e| e.to_string())?.flatten() { let dir = d.path(); let Some((key, props)) = std::fs::read(dir.join("TRACK")).ok().and_then(|b| decode_track(&b)) else { @@ -818,81 +1060,102 @@ impl Dvr { continue; }; let Some(window) = self.window_of(&key.0) else { continue }; - let digest = sha256(&[render(&key.0, Some(&key.1)).as_bytes()]); - let mut t = DTrack { dir: dir.clone(), window, room: room_of(&key.0), events: is_events_track(&key), props, groups: BTreeMap::new(), absent: BTreeMap::new(), evicted_through: None, end_of_track: None }; - let mut files: Vec = std::fs::read_dir(&dir).map_err(|e| e.to_string())?.flatten().map(|e| e.path()).collect(); - files.sort(); - for f in files { - match f.extension().and_then(|x| x.to_str()) { - Some("tmp") => { + let digest = track_digest(&key); + let mut t = DTrack { dir: dir.clone(), window, room: room_of(&key.0), events: is_events_track(&key), props, groups: BTreeMap::new(), absent: BTreeMap::new(), evicted_through: None, end_of_track: None, segs: BTreeMap::new() }; + let mut segs: Vec<(u64, PathBuf)> = std::fs::read_dir(&dir).map_err(|e| e.to_string())?.flatten().filter_map(|e| seg_seq(&e.path()).map(|s| (s, e.path()))).collect(); + segs.sort(); + // Each group's standing record: the last that reads; the earlier ones killed. + let mut rows: HashMap = HashMap::new(); + let mut replaced: Vec<(u64, u64, u32)> = Vec::new(); + for (seq, path) in &segs { + let Ok(b) = std::fs::read(path) else { continue }; + t.segs.insert(*seq, 0); + let mut p = 0usize; + while p < b.len() { + let whole = b.len() >= p + FRAME as usize && { + let n = u32::from_le_bytes(b[p..p + 4].try_into().expect("4")) as usize; + b.len() >= p + FRAME as usize + n + }; + if !whole { + // A frame a crash cut short: cut off. self.stats.torn_writes.fetch_add(1, Ordering::Relaxed); - let _ = std::fs::remove_file(&f); + let _ = std::fs::OpenOptions::new().write(true).open(path).and_then(|f| f.set_len(p as u64)); + break; } - Some("wdg") => { - let Ok(b) = std::fs::read(&f) else { continue }; - match decode_group(&b, &digest) { - None => { - self.stats.objects_dropped.fetch_add(1, Ordering::Relaxed); - self.series_change(t.room, now, 0, 0, 0, 1); - tracing::warn!(target: "joule_moq_relay::dvr", file = %f.display(), "a group whose header does not check: dropped"); - let _ = std::fs::remove_file(&f); - } - Some((g, arrived, count, objects, erased)) => { - let short = count as usize - objects.len(); - t.groups.insert(g, DGroup { arrived_us: arrived, bytes: 0, objects: Vec::new(), erased }); - let mut keep = Vec::new(); - for (o, props, payload, ok) in objects { - if !ok || ObjectReceipt::hash_payload(&payload) != o.digest { - self.drop_unverified(&mut t, g, &o, "its check does not hold"); - continue; - } - keep.push((o, props, payload)); - } - if short > 0 { - self.stats.objects_dropped.fetch_add(short as u64, Ordering::Relaxed); - self.series_change(t.room, now, 0, 0, 0, short as u64); - } - let grp = t.groups.get_mut(&g).expect("inserted"); - for (o, props, payload) in keep { - if let Some(r) = prior_groups(&Properties::decode(&props, "dvr").unwrap_or_default(), g) { - t.absent.insert(r.0, r.1); - } - if o.status == ObjectStatus::EndOfTrack { - t.end_of_track = Some(Location::new(g, o.id)); - } - let mut o = o; - if t.events { - o.records = events::digests(&payload); - } - grp.bytes += payload.len() as u64; - grp.objects.push(o); - } - } + let n = u32::from_le_bytes(b[p..p + 4].try_into().expect("4")) as usize; + let state = b[p + 4]; + let rec = &b[p + FRAME as usize..p + FRAME as usize + n]; + let frame_at = p as u64; + p += FRAME as usize + n; + if state != 1 { + continue; + } + let Some(dg) = decode_group(rec, &digest) else { + self.stats.objects_dropped.fetch_add(1, Ordering::Relaxed); + self.series_change(t.room, now, 0, 0, 0, 1); + tracing::warn!(target: "joule_moq_relay::dvr", file = %path.display(), at = frame_at, "a group record whose header does not check: dropped"); + replaced.push((*seq, frame_at, n as u32)); + continue; + }; + if let Some(prev) = t.groups.remove(&dg.group) { + replaced.push((prev.seg, prev.frame_at, prev.record_len)); + if let Some(c) = t.segs.get_mut(&prev.seg) { + *c = c.saturating_sub(1); } } - _ => {} + let base = frame_at + FRAME; + let short = dg.count as usize - dg.objects.len(); + let mut grp = DGroup { arrived_us: dg.arrived_us, bytes: 0, objects: Vec::new(), erased: dg.erased, seg: *seq, frame_at, record_len: n as u32 }; + let mut kept: Rows = Vec::new(); + if short > 0 { + changed.insert((key.clone(), dg.group)); + } + for (mut o, props, payload, ok) in dg.objects { + o.at += base; + if !ok || ObjectReceipt::hash_payload(&payload) != o.digest { + self.dropped(&t, dg.group, &o, &mut grp, "its check does not hold"); + changed.insert((key.clone(), dg.group)); + continue; + } + if t.events { + o.records = events::digests(&payload); + } + kept.push((o, props, payload)); + } + if short > 0 { + self.stats.objects_dropped.fetch_add(short as u64, Ordering::Relaxed); + self.series_change(t.room, now, 0, 0, 0, short as u64); + } + for (o, props, payload) in &kept { + if let Some(r) = prior_groups(&Properties::decode(props, "dvr").unwrap_or_default(), dg.group) { + t.absent.insert(r.0, r.1); + } + if o.status == ObjectStatus::EndOfTrack { + t.end_of_track = Some(Location::new(dg.group, o.id)); + } + grp.bytes += payload.len() as u64; + grp.objects.push(o.clone()); + } + *t.segs.entry(*seq).or_insert(0) += 1; + t.groups.insert(dg.group, grp); + rows.insert(dg.group, kept); } } - loaded.push((key, t)); + for (seq, at, len) in replaced { + self.kill(w, &dir, seq, at, len); + } + w.next.insert(key.clone(), segs.last().map_or(0, |(s, _)| s + 1)); + loaded.push((key, t, rows)); } // Every object of a track with a receipts track, against its group's receipts; every // record of an events track, under its principal. - let held: HashSet = loaded.iter().map(|(k, _)| k.clone()).collect(); - let mut receipts: HashMap>> = HashMap::new(); - for (k, t) in &loaded { - if self.media_of(&k.1).is_some() { - let mut m = BTreeMap::new(); - for (g, grp) in &t.groups { - if let Some(o) = grp.objects.first() - && let Ok(b) = read_at(&group_file(&t.dir, *g), o.at + o.props_len as u64, o.payload_len as usize) - { - m.insert(*g, b); - } - } - receipts.insert(k.clone(), m); - } - } - for (k, t) in &mut loaded { + let held: HashSet = loaded.iter().map(|(k, _, _)| k.clone()).collect(); + let receipts: HashMap> = loaded + .iter() + .filter(|(k, _, _)| self.media_of(&k.1).is_some()) + .map(|(k, _, rows)| (k.clone(), rows.iter().filter_map(|(g, r)| r.first().map(|(_, _, p)| (*g, p.clone()))).collect())) + .collect(); + for (k, t, rows) in &mut loaded { let rkey = (k.0.clone(), self.receipts_of(&k.1)); let check_receipts = self.media_of(&k.1).is_none() && held.contains(&rkey); if !(check_receipts || t.events) { @@ -901,84 +1164,111 @@ impl Dvr { let v = verifier(&k.0); let groups: Vec = t.groups.keys().copied().collect(); for g in groups { - let path = group_file(&t.dir, g); - let objs = t.groups[&g].objects.clone(); let bundle = if check_receipts { receipts.get(&rkey).and_then(|m| m.get(&g)).and_then(|b| ReceiptBundle::from_payload(b).ok()) } else { None }; let vg = bundle.as_ref().and_then(|b| (b.group.group_id == GroupId(g)).then(|| v.verify_group(&b.group).ok()).flatten()); - for o in objs { + let Some(r) = rows.get_mut(&g) else { continue }; + let mut bad = Vec::new(); + for (o, _, payload) in r.iter() { let ok = if check_receipts { match (&bundle, &vg) { - (Some(b), Some(vg)) => b.objects.iter().find(|r| r.object_id == ObjectId(o.id)).is_some_and(|r| { - read_at(&path, o.at + o.props_len as u64, o.payload_len as usize).is_ok_and(|p| v.verify_payload(&p, r, vg).is_ok() && v.verify_object(r, vg).is_ok()) - }), + (Some(b), Some(vg)) => b.objects.iter().find(|x| x.object_id == ObjectId(o.id)).is_some_and(|x| v.verify_payload(payload, x, vg).is_ok()), _ => o.status != ObjectStatus::Normal, } } else { - read_at(&path, o.at + o.props_len as u64, o.payload_len as usize).is_ok_and(|p| match events::records(&p) { - Some(rs) => rs.iter().all(|(_, r)| Event::read(r).is_some_and(|e| e.verifies(r))), + match events::records(payload) { + Some(rs) => rs.iter().all(|(_, x)| Event::read(x).is_some_and(|e| e.verifies(x))), None => true, - }) + } }; if !ok { - self.drop_unverified(t, g, &o, if check_receipts { "it does not verify against its receipt" } else { "a record of it does not verify" }); + bad.push(o.clone()); } } + let mut grp = t.groups.remove(&g).expect("held"); + if !bad.is_empty() { + changed.insert((k.clone(), g)); + } + for o in &bad { + self.dropped(t, g, o, &mut grp, if check_receipts { "it does not verify against its receipt" } else { "a record of it does not verify" }); + r.retain(|x| x.0.id != o.id); + } + t.groups.insert(g, grp); } } - // Erasures the log asks for that a crash left undone, and every group whose dropped - // objects must be written out of its file. - let mut ix = self.index.lock().unwrap(); - for (k, mut t) in loaded { - for (g, grp) in t.groups.iter_mut() { - let redacted: Vec = grp.objects.iter().filter(|o| t.room.is_some_and(|r| ix.standing.get(&(r, o.digest)) == Some(&REDACT))).cloned().collect(); - for o in &redacted { - grp.objects.retain(|x| x.id != o.id); - grp.erased.push((o.id, o.digest, ERASED_REDACTED)); - self.stats.objects_redacted.fetch_add(1, Ordering::Relaxed); - } - let path = group_file(&t.dir, *g); - if let Ok(b) = std::fs::read(&path) - && let Some((_, _, count, objs, erased)) = decode_group(&b, &sha256(&[render(&k.0, Some(&k.1)).as_bytes()])) - && (count as usize != grp.objects.len() || erased.len() != grp.erased.len() || objs.len() != grp.objects.len()) - && let Some(index) = rewrite(&path, &k, *g, grp, &objs) - { - grp.objects = index; - } - ix.window_bytes[t.window] += grp.bytes; - for o in &grp.objects { - ix.by_digest.entry(o.digest).or_default().push((k.clone(), *g, o.id)); - for d in &o.records { - ix.by_digest.entry(*d).or_default().push((k.clone(), *g, o.id)); + // Into the index; then every group whose record no longer says what the index holds + // (an object dropped, or a redaction the log asks for that a crash left undone) is + // written again. + let mut again: Vec<(TrackKey, PathBuf, u64)> = Vec::new(); + { + let mut ix = self.index.lock().unwrap(); + for (k, mut t, _) in std::mem::take(&mut loaded) { + for (g, grp) in t.groups.iter_mut() { + let redacted: Vec = grp.objects.iter().filter(|o| t.room.is_some_and(|r| ix.standing.get(&(r, o.digest)) == Some(&REDACT))).cloned().collect(); + if !redacted.is_empty() { + changed.insert((k.clone(), *g)); + } + for o in &redacted { + grp.objects.retain(|x| x.id != o.id); + grp.bytes = grp.bytes.saturating_sub(o.payload_len as u64); + grp.erased.push((o.id, o.digest, ERASED_REDACTED)); + self.stats.objects_redacted.fetch_add(1, Ordering::Relaxed); + } + ix.window_bytes[t.window] += grp.bytes; + if let Some(room) = t.room { + ix.series.entry(room).or_default().change(now, grp.bytes as i128, 0, 0, 0); + } + for o in &grp.objects { + ix.reindex(&k, *g, o); + } + if changed.contains(&(k.clone(), *g)) { + again.push((k.clone(), t.dir.clone(), *g)); } } - if let Some(room) = t.room { - ix.series.entry(room).or_default().change(now, grp.bytes as i128, 0, 0, 0); - } + // What is below the first group held is no longer known to the store. + t.evicted_through = t.groups.keys().next().and_then(|g| g.checked_sub(1)); + ix.tracks.insert(k, t); } - // What is below the first group held is no longer known to the store. - t.evicted_through = t.groups.keys().next().and_then(|g| g.checked_sub(1)); - ix.tracks.insert(k, t); } - let n: usize = ix.tracks.values().map(|t| t.groups.len()).sum(); - drop(ix); + for (k, dir, g) in again { + self.write_again_if_changed(w, &k, &dir, g); + } + // Event records a redaction the log holds targets, erased from the objects that + // carry them. + let redactions: Vec<([u8; 32], [u8; 32])> = self.index.lock().unwrap().standing.iter().filter(|(_, v)| **v == REDACT).map(|(k, _)| *k).collect(); + for (room, target) in redactions { + self.erase(w, room, target); + } + self.sync(w); + let n: usize = self.index.lock().unwrap().tracks.values().map(|t| t.groups.len()).sum(); tracing::info!(target: "joule_moq_relay::dvr", path = %self.root.display(), groups = n, dropped = self.stats.objects_dropped.load(Ordering::Relaxed), torn = self.stats.torn_writes.load(Ordering::Relaxed), "the DVR store is open"); Ok(()) } - /// An object that did not verify at load: out of the index, among the group's erased, - /// counted. Its file is written again at the end of the load. - fn drop_unverified(&self, t: &mut DTrack, g: u64, o: &DObject, why: &str) { + /// Write group `g` again when its standing record holds other objects than the index + /// does (some dropped, or erased). + fn write_again_if_changed(&self, w: &mut Writer, key: &TrackKey, dir: &Path, g: u64) { + let Some(grp) = self.index.lock().unwrap().tracks.get(key).and_then(|t| t.groups.get(&g)).cloned() else { return }; + let path = seg_path(dir, grp.seg); + let Ok(rec) = read_at(&path, grp.frame_at + FRAME, grp.record_len as usize) else { return }; + let Some(dg) = decode_group(&rec, &track_digest(key)) else { return }; + if dg.count as usize == grp.objects.len() && dg.erased.len() == grp.erased.len() { + return; + } + let rows: Rows = grp.objects.iter().filter_map(|o| dg.objects.iter().find(|x| x.0.id == o.id).map(|(_, props, payload, _)| (o.clone(), props.clone(), payload.clone()))).collect(); + self.rewrite(w, key, dir, g, grp.arrived_us, &rows, &grp.erased, (grp.seg, grp.frame_at, grp.record_len)); + } + + /// An object that did not verify at load: out of the group, among its erased, counted. + fn dropped(&self, t: &DTrack, g: u64, o: &DObject, grp: &mut DGroup, why: &str) { self.stats.objects_dropped.fetch_add(1, Ordering::Relaxed); self.series_change(t.room, now_us(), 0, 0, 0, 1); tracing::warn!(target: "joule_moq_relay::dvr", dir = %t.dir.display(), group = g, object = o.id, reason = why, "a stored object dropped: never served"); - if let Some(grp) = t.groups.get_mut(&g) { - if grp.objects.iter().any(|x| x.id == o.id) { - grp.bytes = grp.bytes.saturating_sub(o.payload_len as u64); - } - grp.objects.retain(|x| x.id != o.id); - if !grp.erased.iter().any(|e| e.0 == o.id) { - grp.erased.push((o.id, o.digest, ERASED_UNVERIFIED)); - } + if grp.objects.iter().any(|x| x.id == o.id) { + grp.bytes = grp.bytes.saturating_sub(o.payload_len as u64); + } + grp.objects.retain(|x| x.id != o.id); + if !grp.erased.iter().any(|e| e.0 == o.id) { + grp.erased.push((o.id, o.digest, ERASED_UNVERIFIED)); } } @@ -1000,19 +1290,28 @@ impl Dvr { // ── The writer ────────────────────────────────────────────────────────── - fn run(self: Arc, rx: mpsc::Receiver) { + fn run(self: Arc, mut w: Writer, rx: mpsc::Receiver) { loop { - match rx.recv_timeout(Duration::from_secs(1)) { - Ok(Job::Seal { key, props, group, arrived_us, objects }) => self.write_seal(key, props, group, arrived_us, objects), - Ok(Job::Erase { room, digest }) => self.erase(room, digest), + let job = rx.recv_timeout(SYNC_EVERY); + match job { + Ok(Job::Seal { key, props, group, arrived_us, objects }) => self.write_seal(&mut w, key, props, group, arrived_us, objects), + Ok(Job::Erase { room, digest }) => self.erase(&mut w, room, digest), Ok(Job::Log(rec)) => self.log(&rec), Ok(Job::Flush(done)) => { + self.evict_by_age(&mut w, now_us()); + self.sync(&mut w); let _ = done.send(()); } Err(mpsc::RecvTimeoutError::Timeout) => {} - Err(mpsc::RecvTimeoutError::Disconnected) => return, + Err(mpsc::RecvTimeoutError::Disconnected) => { + self.sync(&mut w); + return; + } + } + if w.last_sync.is_none_or(|t| t.elapsed() >= SYNC_EVERY) { + self.evict_by_age(&mut w, now_us()); + self.sync(&mut w); } - self.evict_by_age(now_us()); } } @@ -1030,11 +1329,10 @@ impl Dvr { } } - fn write_seal(&self, key: TrackKey, props: Properties, group: u64, arrived_us: u64, objects: Vec<((u64, u64), CachedObject)>) { + fn write_seal(&self, w: &mut Writer, key: TrackKey, props: Properties, group: u64, arrived_us: u64, objects: Vec<((u64, u64), CachedObject)>) { let Some(window) = self.window_of(&key.0) else { return }; let room = room_of(&key.0); let events = is_events_track(&key); - let digest = sha256(&[render(&key.0, Some(&key.1)).as_bytes()]); let dir = self.root.join("tracks").join(track_dir_name(&key)); let known = self.index.lock().unwrap().tracks.contains_key(&key); if !known { @@ -1042,9 +1340,11 @@ impl Dvr { tracing::error!(target: "joule_moq_relay::dvr", error = %e, "a track could not be stored"); return; } - sync_dir(&self.root.join("tracks")); + w.dirs.insert(self.root.join("tracks")); + let mut ix = self.index.lock().unwrap(); + ix.tracks.entry(key.clone()).or_insert_with(|| DTrack { dir: dir.clone(), window, room, events, props: props.clone(), groups: BTreeMap::new(), absent: BTreeMap::new(), evicted_through: None, end_of_track: None, segs: BTreeMap::new() }); } - let mut rows = Vec::with_capacity(objects.len()); + let mut rows: Rows = Vec::with_capacity(objects.len()); let mut erased = Vec::new(); { let ix = self.index.lock().unwrap(); @@ -1059,131 +1359,128 @@ impl Dvr { rows.push((row, Bytes::from(o.properties.to_bytes().unwrap_or_default()), o.payload.clone())); } } - let (bytes, index) = encode_group(&digest, group, arrived_us, &rows, &erased); - let path = group_file(&dir, group); - if let Err(e) = write_durably(&path, &bytes) { - tracing::error!(target: "joule_moq_relay::dvr", error = %e, file = %path.display(), "a group could not be stored"); - return; + let (bytes, mut index) = encode_group(&track_digest(&key), group, arrived_us, &rows, &erased); + let (seq, at) = match self.append(w, &key, &dir, &bytes) { + Ok(x) => x, + Err(e) => { + tracing::error!(target: "joule_moq_relay::dvr", error = %e, "a group could not be stored"); + return; + } + }; + for o in &mut index { + o.at += at + FRAME; } let payload_bytes: u64 = index.iter().map(|o| o.payload_len as u64).sum(); let mut ix = self.index.lock().unwrap(); - let t = ix.tracks.entry(key.clone()).or_insert_with(|| DTrack { dir: dir.clone(), window, room, events, props: props.clone(), groups: BTreeMap::new(), absent: BTreeMap::new(), evicted_through: None, end_of_track: None }); + let Some(t) = ix.tracks.get_mut(&key) else { return }; if t.evicted_through.is_some_and(|e| group <= e) { drop(ix); - let _ = std::fs::remove_file(&path); + self.kill(w, &dir, seq, at, bytes.len() as u32); self.stats.groups_late.fetch_add(1, Ordering::Relaxed); return; } - let (old_bytes, old_n) = t.groups.get(&group).map_or((0, 0), |g| (g.bytes, g.objects.len())); for (_, o) in &objects { if let Some(r) = prior_groups(&o.properties, group) { t.absent.insert(r.0, r.1); } - if o.status == ObjectStatus::EndOfTrack { - t.end_of_track = objects.iter().find(|(_, x)| x.status == ObjectStatus::EndOfTrack).map(|((g, oo), _)| Location::new(*g, *oo)); + } + if let Some(((g, oo), _)) = objects.iter().find(|(_, x)| x.status == ObjectStatus::EndOfTrack) { + t.end_of_track = Some(Location::new(*g, *oo)); + } + *t.segs.entry(seq).or_insert(0) += 1; + let old = t.groups.insert(group, DGroup { arrived_us, bytes: payload_bytes, objects: index.clone(), erased, seg: seq, frame_at: at, record_len: bytes.len() as u32 }); + if let Some(o) = &old + && let Some(n) = t.segs.get_mut(&o.seg) + { + *n = n.saturating_sub(1); + } + let (old_bytes, old_n) = old.as_ref().map_or((0, 0), |g| (g.bytes, g.objects.len())); + ix.window_bytes[window] = (ix.window_bytes[window] + payload_bytes).saturating_sub(old_bytes); + if let Some(o) = &old { + for x in &o.objects { + ix.unindex(&key, group, x); } } - t.groups.insert(group, DGroup { arrived_us, bytes: payload_bytes, objects: index.clone(), erased }); - ix.window_bytes[window] = ix.window_bytes[window] - old_bytes + payload_bytes; for o in &index { - let e = ix.by_digest.entry(o.digest).or_default(); - if !e.iter().any(|x| x.0 == key && x.1 == group && x.2 == o.id) { - e.push((key.clone(), group, o.id)); - } - for d in &o.records { - let e = ix.by_digest.entry(*d).or_default(); - if !e.iter().any(|x| x.0 == key && x.1 == group && x.2 == o.id) { - e.push((key.clone(), group, o.id)); - } - } + ix.reindex(&key, group, o); } let new_objects = index.len().saturating_sub(old_n) as u64; if let Some(r) = room { ix.series.entry(r).or_default().change(now_us(), payload_bytes as i128 - old_bytes as i128, new_objects, payload_bytes.saturating_sub(old_bytes), 0); } drop(ix); + if let Some(o) = old { + self.kill(w, &dir, o.seg, o.frame_at, o.record_len); + let open = w.open.get(&key).map(|s| s.seq); + self.retire(&key, o.seg, open); + } self.stats.groups_written.fetch_add(1, Ordering::Relaxed); self.stats.objects_written.fetch_add(new_objects, Ordering::Relaxed); - self.evict_by_bytes(window); + self.evict_by_bytes(w, window); } - /// Erase what a redaction targets from every group that holds it: the group's file is - /// written again with the object among its erased. An event record is erased from the - /// events-track object that carries it, the object's other records kept byte for byte; - /// an object left with none is erased whole. - fn erase(&self, room: [u8; 32], digest: [u8; 32]) { + /// Erase what a redaction targets from every group that holds it: the group is written + /// again with the object among its erased, and its old record's bytes are overwritten. + /// An event record is erased from the events-track object that carries it, the object's + /// other records kept byte for byte; an object left with none is erased whole. + fn erase(&self, w: &mut Writer, room: [u8; 32], digest: [u8; 32]) { let places: Vec<(TrackKey, u64, u64)> = self.index.lock().unwrap().by_digest.get(&digest).cloned().unwrap_or_default(); for (key, g, oid) in places { - let track_digest = sha256(&[render(&key.0, Some(&key.1)).as_bytes()]); - let (path, o) = { + let (dir, grp) = { let ix = self.index.lock().unwrap(); let Some(t) = ix.tracks.get(&key).filter(|t| t.room == Some(room)) else { continue }; - let Some(o) = t.groups.get(&g).and_then(|grp| grp.objects.iter().find(|o| o.id == oid)).cloned() else { continue }; - (group_file(&t.dir, g), o) + let Some(grp) = t.groups.get(&g).cloned() else { continue }; + (t.dir.clone(), grp) }; - let Ok(b) = std::fs::read(&path) else { continue }; - let Some((_, _, _, mut objs, _)) = decode_group(&b, &track_digest) else { continue }; + let Some(o) = grp.objects.iter().find(|o| o.id == oid).cloned() else { continue }; + let Ok(rec) = read_at(&seg_path(&dir, grp.seg), grp.frame_at + FRAME, grp.record_len as usize) else { continue }; + let Some(dg) = decode_group(&rec, &track_digest(&key)) else { continue }; + let mut rows: Rows = grp.objects.iter().filter_map(|x| dg.objects.iter().find(|y| y.0.id == x.id).map(|(_, props, payload, _)| (x.clone(), props.clone(), payload.clone()))).collect(); + let mut erased = grp.erased.clone(); // The object's new payload, when a record is taken out of it and others stay. let replaced: Option = if o.digest == digest { None } else { - let Some((_, _, payload, _)) = objs.iter().find(|x| x.0.id == oid) else { continue }; + let Some((_, _, payload)) = rows.iter().find(|x| x.0.id == oid) else { continue }; events::withhold(payload, |d| *d == digest).map(|p| Bytes::from(p.into_owned())) }; - let grp = { - let mut ix = self.index.lock().unwrap(); - let Some(t) = ix.tracks.get_mut(&key) else { continue }; - let window = t.window; - let Some(grp) = t.groups.get_mut(&g) else { continue }; - let delta: i128 = match &replaced { - None => { - grp.objects.retain(|x| x.id != oid); - grp.erased.push((oid, o.digest, ERASED_REDACTED)); - -(o.payload_len as i128) - } - Some(p) => { - let Some(x) = grp.objects.iter_mut().find(|x| x.id == oid) else { continue }; - x.digest = ObjectReceipt::hash_payload(p); - x.records = events::digests(p); - p.len() as i128 - o.payload_len as i128 - } - }; - grp.bytes = (grp.bytes as i128 + delta).max(0) as u64; - let grp = grp.clone(); - ix.window_bytes[window] = (ix.window_bytes[window] as i128 + delta).max(0) as u64; - ix.series.entry(room).or_default().change(now_us(), delta, 0, 0, 0); - for d in std::iter::once(&o.digest).chain(o.records.iter()) { - if let Some(v) = ix.by_digest.get_mut(d) { - v.retain(|x| !(x.0 == key && x.1 == g && x.2 == oid)); - if v.is_empty() { - ix.by_digest.remove(d); - } - } + let delta: i128 = match &replaced { + None => { + rows.retain(|x| x.0.id != oid); + erased.push((oid, o.digest, ERASED_REDACTED)); + -(o.payload_len as i128) } - if let Some(x) = grp.objects.iter().find(|x| x.id == oid) { - for d in std::iter::once(&x.digest).chain(x.records.iter()) { - ix.by_digest.entry(*d).or_default().push((key.clone(), g, oid)); + Some(p) => { + for x in rows.iter_mut().filter(|x| x.0.id == oid) { + x.0.digest = ObjectReceipt::hash_payload(p); + x.0.records = events::digests(p); + x.2 = p.clone(); } + p.len() as i128 - o.payload_len as i128 } - grp }; - if let Some(p) = &replaced { - for x in objs.iter_mut().filter(|x| x.0.id == oid) { - x.2 = p.clone(); - } - } - if let Some(index) = rewrite(&path, &key, g, &grp, &objs) { + let Some(index) = self.rewrite(w, &key, &dir, g, grp.arrived_us, &rows, &erased, (grp.seg, grp.frame_at, grp.record_len)) else { continue }; + { let mut ix = self.index.lock().unwrap(); - if let Some(gr) = ix.tracks.get_mut(&key).and_then(|t| t.groups.get_mut(&g)) { - gr.objects = index; + ix.unindex(&key, g, &o); + if let Some(x) = index.iter().find(|x| x.id == oid) { + ix.reindex(&key, g, x); } + if let Some(t) = ix.tracks.get_mut(&key) { + let window = t.window; + if let Some(gr) = t.groups.get_mut(&g) { + gr.bytes = (gr.bytes as i128 + delta).max(0) as u64; + } + ix.window_bytes[window] = (ix.window_bytes[window] as i128 + delta).max(0) as u64; + } + ix.series.entry(room).or_default().change(now_us(), delta, 0, 0, 0); } self.stats.objects_redacted.fetch_add(1, Ordering::Relaxed); - tracing::info!(target: "joule_moq_relay::dvr", file = %path.display(), object = oid, whole = replaced.is_none(), "a redaction erased from the store"); + tracing::info!(target: "joule_moq_relay::dvr", dir = %dir.display(), group = g, object = oid, whole = replaced.is_none(), "a redaction erased from the store"); } } - fn evict_by_bytes(&self, window: usize) { + fn evict_by_bytes(&self, w: &mut Writer, window: usize) { loop { let victim = { let ix = self.index.lock().unwrap(); @@ -1193,63 +1490,59 @@ impl Dvr { oldest(&ix, |t| t.window == window) }; match victim { - Some((k, g)) => self.evict(&k, g), + Some((k, g)) => self.evict(w, &k, g), None => return, } } } - fn evict_by_age(&self, now: u64) { - for (w, win) in self.windows.iter().enumerate() { + fn evict_by_age(&self, w: &mut Writer, now: u64) { + for (i, win) in self.windows.iter().enumerate() { let floor = now.saturating_sub(win.max_age_us); loop { let victim = { let ix = self.index.lock().unwrap(); - oldest(&ix, |t| t.window == w).filter(|(k, g)| ix.tracks[k].groups[g].arrived_us < floor) + oldest(&ix, |t| t.window == i).filter(|(k, g)| ix.tracks[k].groups[g].arrived_us < floor) }; match victim { - Some((k, g)) => self.evict(&k, g), + Some((k, g)) => self.evict(w, &k, g), None => break, } } } } - /// Evict group `g` of `key`, and group `g` of its receipts or media track with it. - fn evict(&self, key: &TrackKey, g: u64) { + /// Evict group `g` of `key`, and group `g` of its receipts or media track with it. A + /// segment no group stands in any longer is deleted. + fn evict(&self, w: &mut Writer, key: &TrackKey, g: u64) { let partner = match self.media_of(&key.1) { Some(m) => (key.0.clone(), m), None => (key.0.clone(), self.receipts_of(&key.1)), }; for k in [key.clone(), partner] { - let removed = { + let seg = { let mut ix = self.index.lock().unwrap(); let Some(t) = ix.tracks.get_mut(&k) else { continue }; let through = t.evicted_through.map_or(g, |e| e.max(g)); t.evicted_through = Some(through); t.absent.retain(|_, hi| *hi > through); let Some(grp) = t.groups.remove(&g) else { continue }; - let (dir, window, room) = (t.dir.clone(), t.window, t.room); + if let Some(n) = t.segs.get_mut(&grp.seg) { + *n = n.saturating_sub(1); + } + let (window, room) = (t.window, t.room); ix.window_bytes[window] = ix.window_bytes[window].saturating_sub(grp.bytes); for o in &grp.objects { - for d in std::iter::once(&o.digest).chain(o.records.iter()) { - if let Some(v) = ix.by_digest.get_mut(d) { - v.retain(|x| !(x.0 == k && x.1 == g)); - if v.is_empty() { - ix.by_digest.remove(d); - } - } - } + ix.unindex(&k, g, o); } if let Some(r) = room { ix.series.entry(r).or_default().change(now_us(), -(grp.bytes as i128), 0, 0, 0); } - Some(group_file(&dir, g)) + grp.seg }; - if let Some(p) = removed { - let _ = std::fs::remove_file(&p); - self.stats.groups_evicted.fetch_add(1, Ordering::Relaxed); - } + self.stats.groups_evicted.fetch_add(1, Ordering::Relaxed); + let open = w.open.get(&k).map(|s| s.seq); + self.retire(&k, seg, open); } } } @@ -1264,56 +1557,26 @@ fn oldest(ix: &Index, f: impl Fn(&DTrack) -> bool) -> Option<(TrackKey, u64)> { .map(|(_, k, g)| (k, g)) } -fn read_at(path: &Path, at: u64, len: usize) -> std::io::Result> { - let mut f = std::fs::File::open(path)?; - f.seek(SeekFrom::Start(at))?; - let mut b = vec![0u8; len]; - f.read_exact(&mut b)?; - Ok(b) -} - -/// Write group `g`'s file again from what the index keeps of it (`grp`), taking each -/// object's bytes from the old file's `objs`. Returns the new index of its objects. -fn rewrite(path: &Path, key: &TrackKey, g: u64, grp: &DGroup, objs: &[(DObject, Bytes, Bytes, bool)]) -> Option> { - let rows: Vec<(DObject, Bytes, Bytes)> = grp - .objects - .iter() - .filter_map(|o| objs.iter().find(|(x, _, _, _)| x.id == o.id).map(|(_, props, payload, _)| (o.clone(), props.clone(), payload.clone()))) - .collect(); - let (bytes, index) = encode_group(&sha256(&[render(&key.0, Some(&key.1)).as_bytes()]), g, grp.arrived_us, &rows, &grp.erased); - let index: Vec = index.into_iter().zip(&grp.objects).map(|(mut n, old)| { - n.records = old.records.clone(); - n - }).collect(); - match write_durably(path, &bytes) { - Ok(()) => Some(index), - Err(e) => { - tracing::error!(target: "joule_moq_relay::dvr", error = %e, file = %path.display(), "a group could not be written again"); - None - } - } -} - #[cfg(test)] mod tests { use super::*; #[test] - fn a_group_file_reads_back_and_a_flipped_byte_is_found() { + fn a_group_record_reads_back_and_a_flipped_byte_is_found() { let td = [7u8; 32]; let o = |id: u64| DObject { id, subgroup: Some(0), priority: 3, status: ObjectStatus::Normal, at: 0, props_len: 0, payload_len: 0, digest: ObjectReceipt::hash_payload(format!("p{id}").as_bytes()), records: vec![] }; - let rows: Vec<(DObject, Bytes, Bytes)> = (0..3).map(|i| (o(i), Bytes::new(), Bytes::from(format!("p{i}")))).collect(); + let rows: Rows = (0..3).map(|i| (o(i), Bytes::new(), Bytes::from(format!("p{i}")))).collect(); let (mut b, index) = encode_group(&td, 5, 99, &rows, &[(3, [1; 32], ERASED_REDACTED)]); - let (g, arrived, count, objs, erased) = decode_group(&b, &td).unwrap(); - assert_eq!((g, arrived, count, erased.len()), (5, 99, 3, 1)); - assert!(objs.iter().all(|x| x.3)); - assert_eq!(index[1].at, objs[1].0.at); + let dg = decode_group(&b, &td).unwrap(); + assert_eq!((dg.group, dg.arrived_us, dg.count, dg.erased.len()), (5, 99, 3, 1)); + assert!(dg.objects.iter().all(|x| x.3)); + assert_eq!(index[1].at, dg.objects[1].0.at); // A flipped payload byte fails that object's check alone. let at = index[1].at as usize; b[at] ^= 0x40; - let (_, _, _, objs, _) = decode_group(&b, &td).unwrap(); - assert_eq!(objs.iter().map(|x| x.3).collect::>(), vec![true, false, true]); - // A flipped header byte drops the group; so does another track's file. + let dg = decode_group(&b, &td).unwrap(); + assert_eq!(dg.objects.iter().map(|x| x.3).collect::>(), vec![true, false, true]); + // A flipped header byte drops the group; so does another track's record. b[40] ^= 1; assert!(decode_group(&b, &td).is_none()); let (b2, _) = encode_group(&td, 5, 99, &rows, &[]); @@ -1343,4 +1606,10 @@ mod tests { assert_eq!(s.at(0), (0, 0, 0, 0)); assert_eq!((s.at(5_000_000).1, s.at(5_000_000).2), (2, 150)); } + + #[test] + fn segment_names_read_back() { + assert_eq!(seg_seq(&seg_path(Path::new("/x"), 42)), Some(42)); + assert_eq!(seg_seq(Path::new("/x/TRACK")), None); + } } diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs index a40372dbe..27ac962a2 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs @@ -303,12 +303,14 @@ async fn a_window_by_age_empties_as_its_groups_pass_it() { .expect("in time"); } -/// Rewrite one object of a group file in place: `f` changes its payload (same length); -/// with `fix`, its digest and check are made again so that only its receipt finds it. -fn tamper(path: &std::path::Path, object: u64, fix: bool, f: impl Fn(&mut [u8])) { +/// Rewrite one object of the group record whose frame is at `frame_at` in a segment, in +/// place: `f` changes its payload (same length); with `fix`, its digest and check are made +/// again so that only its receipt finds it. +fn tamper(path: &std::path::Path, frame_at: u64, object: u64, fix: bool, f: impl Fn(&mut [u8])) { let mut b = std::fs::read(path).unwrap(); - let count = u32::from_le_bytes(b[53..57].try_into().unwrap()) as u64; - let mut p = 93; + let r = frame_at as usize + 5; + let count = u32::from_le_bytes(b[r + 53..r + 57].try_into().unwrap()) as u64; + let mut p = r + 93; for i in 0..count { let props_len = u32::from_le_bytes(b[p + 18..p + 22].try_into().unwrap()) as usize; let payload_len = u32::from_le_bytes(b[p + 22..p + 26].try_into().unwrap()) as usize; @@ -354,21 +356,27 @@ async fn a_restart_keeps_the_store_and_drops_what_does_not_verify() { written(&server, 2 * GROUPS).await; let key = (t.ns.clone(), t.media.clone()); let d = server.relay().dvr().unwrap().clone(); - let (g3, g5) = (d.group_path(&key, 3).unwrap(), d.group_path(&key, 5).unwrap()); + let ((g3, at3), (g5, at5), (g9, _)) = (d.group_location(&key, 3).unwrap(), d.group_location(&key, 5).unwrap(), d.group_location(&key, 9).unwrap()); p.close(0, "done"); server.shutdown().await; - // A flipped byte in (3, 1); (5, 0) rewritten with its checks made again; and a write a - // crash cut short. - tamper(&g3, 1, false, |p| p[10] ^= 0x20); - tamper(&g5, 0, true, |p| p.reverse()); - std::fs::write(g3.with_file_name("g00000000000000000010.tmp"), b"cut short").unwrap(); + // A flipped byte in (3, 1); (5, 0) rewritten with its checks made again; and a frame a + // crash cut short at the end of a segment. + tamper(&g3, at3, 1, false, |p| p[10] ^= 0x20); + tamper(&g5, at5, 0, true, |p| p.reverse()); + let whole = std::fs::metadata(&g9).unwrap().len(); + { + use std::io::Write; + let mut f = std::fs::OpenOptions::new().append(true).open(&g9).unwrap(); + f.write_all(&1000u32.to_le_bytes()).unwrap(); + f.write_all(&[1, b'W', b'D', b'G', b'1']).unwrap(); + } let server = relay(config).await; let d = server.relay().dvr().unwrap().clone(); let c = d.counts(); assert_eq!((c.objects_dropped, c.torn_writes), (2, 1), "{c:?}"); - assert!(!g3.with_file_name("g00000000000000000010.tmp").exists(), "the cut-short write is removed"); + assert_eq!(std::fs::metadata(&g9).unwrap().len(), whole, "the cut-short frame is cut off"); assert_eq!(d.groups(&key), (0..GROUPS).collect::>()); // No publisher now: the store answers alone. let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; @@ -498,15 +506,18 @@ async fn decisions_withhold_from_the_store_and_hold_across_a_restart() { assert_eq!(f.objects.len() as u64, GROUPS * OBJECTS - 3, "and (2, 0), which an End of Range through (2, 1) covers"); assert!(f.objects.contains_key(&(2, 2)) && f.objects.contains_key(&(3, 1))); assert!(f.objects.contains_key(&(4, 0)), "a decision by no moderator withholds nothing"); - // The redacted object's bytes are gone from its file; the denied one's stay. - let file = std::fs::read(d.group_path(&(t.ns.clone(), t.media.clone()), 2).unwrap()).unwrap(); - assert!(!file.windows(LEN).any(|w| w == object_payload("m", 2, 1).as_slice()), "redacted: erased from disk"); - let file = std::fs::read(d.group_path(&(t.ns.clone(), t.media.clone()), 3).unwrap()).unwrap(); - assert!(file.windows(LEN).any(|w| w == object_payload("m", 3, 0).as_slice()), "denied: kept"); - // The redacted chat is erased from the events track's file; the other stays. - let file = std::fs::read(d.group_path(&(pe.ns.clone(), name("0")), 0).unwrap()).unwrap(); - assert!(!file.windows(msf(&[&chat1]).len()).any(|w| w == msf(&[&chat1]).as_slice()), "redacted: erased from disk"); - assert!(file.windows(msf(&[&chat2]).len()).any(|w| w == msf(&[&chat2]).as_slice())); + // The redacted object's bytes are gone from every file of its track; the denied one's stay. + let on_disk = |key: &(TrackNamespace, joule_moq::TrackName), bytes: &[u8]| -> bool { + let dir = d.track_dir(key).unwrap(); + std::fs::read_dir(dir).unwrap().flatten().any(|e| std::fs::read(e.path()).unwrap().windows(bytes.len()).any(|w| w == bytes)) + }; + let media = (t.ns.clone(), t.media.clone()); + assert!(!on_disk(&media, &object_payload("m", 2, 1)), "redacted: erased from disk"); + assert!(on_disk(&media, &object_payload("m", 3, 0)), "denied: kept"); + // The redacted chat is erased from the events track's files; the other stays. + let ev_key = (pe.ns.clone(), name("0")); + assert!(!on_disk(&ev_key, &msf(&[&chat1])), "redacted: erased from disk"); + assert!(on_disk(&ev_key, &msf(&[&chat2]))); // The publisher's events: the redacted chat is withheld, the other is served as sent. let (_, ev) = v.fetch(pe.ns.clone(), name("0"), all()).await.expect("FETCH the events track"); let ev = fetched(ev); -- 2.54.0 (Apple Git-157) From 911ecb2d2a93f478eefdb8009f66793cd8cdb325 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 12:12:22 -0400 Subject: [PATCH 27/43] joule-moq-relay README: segments and their durability rule Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-relay/README.md | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/README.md b/joule-code/joule-code-rs/crates/joule-moq-relay/README.md index 99e6961a0..30f254dfd 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/README.md @@ -416,7 +416,11 @@ that becomes whole after the window has moved past its number is not stored (cou **On disk.** Under `path`, one directory per track, `tracks//`, holding `TRACK` (namespace, name, properties, and a SHA-256 -check) and one file per group, `g.wdg`: +check) and the track's **segments**, `s.wds`: files each group's record is +appended to as it is stored, a new one begun every ten seconds or 16 MiB. A segment is a +sequence of frames, `len(u32) state(u8) record`, the state 1 for a record that stands and 0 for +one a later record replaced (a group written again: an object erased, or a subgroup that came +late), whose bytes are then overwritten with zeros. A record is ```text header "WDG1" 1 track(32) group(u64) arrived_us(u64) count(u32) erased(u32) check(32) @@ -428,21 +432,25 @@ erased id(u64) digest(32) why(u8: 1 redacted, 2 did not verify) Integers are little-endian. `track` is the SHA-256 of the rendered track; the header's `check` is SHA-256 over the header before it; an object's `digest` is the content hash its receipt names it by (jwp-receipts §2.1), and its `check` SHA-256 over its fixed fields before `check`, -its properties and its payload. `decisions.wdl` beside `tracks/` holds the moderation decisions -the relay honoured, each a length, the `WEV1` record and its SHA-256. +its properties and its payload. A segment is deleted once no group's standing record is in it, +so on disk a window holds up to a segment of each track more than its budget. `decisions.wdl` +beside `tracks/` holds the moderation decisions the relay honoured, each a length, the `WEV1` +record and its SHA-256. -**Durability.** A group is written to `g….tmp`, synced, renamed into place, and its directory -synced: a group file is whole or absent. On start the relay reads every file again: +**Durability.** The writer syncs every segment it wrote, and the directories it made files in, +at least once a second and before it answers a flush or stops; a shutdown flushes. A crash +loses at most the last second's groups. (One sync per group could not keep up with a room's +audio, a group every 20 ms per publisher.) On start the relay reads every segment again: -- a `.tmp` is a write a crash cut short, and is removed (`torn_writes`); -- a header that does not check drops its group; +- a frame a crash cut short, at a segment's end, is cut off (`torn_writes`); +- a record whose header does not check is dropped; - every object is checked: its `check` and `digest`; on a media track whose receipts track is stored, against group *g*'s receipts (the group receipt's signature under the door's trust for the namespace, or any key that verifies where no door rule covers it; the object's receipt at its position; its Merkle proof to the signed root; its content hash); on an events track, every record under its principal. One that does not verify is dropped, counted - (`objects_dropped`, and the `WMH1` record's `dvr_dropped`), and its group's file is written - again with it among the erased. It is never served. + (`objects_dropped`, and the `WMH1` record's `dvr_dropped`), and its group written again with + it among the erased. It is never served. What is below the first group the store holds of a track after a restart is unknown to it. Each object's digest is checked again as it is read. -- 2.54.0 (Apple Git-157) From f7b7ea2be5e42fb3f22f1df6afb933ded6182dfe Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 20:26:33 -0400 Subject: [PATCH 28/43] joule-moq-relay: each session summary is appended in one write Two sessions that ended together could interleave a line and its newline, leaving two summaries on one line of summary.out_path. Co-Authored-By: Claude Opus 5.5 --- .../joule-code-rs/crates/joule-moq-relay/src/session.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs index adcd51c5b..dd56aa870 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs @@ -1846,8 +1846,10 @@ async fn cleanup(relay: &Relay, ctx: &Arc, why: TransportError) { && let Ok(line) = serde_json::to_string(&signed) { use std::io::Write; + // One write of the line and its newline: sessions that end together append whole + // lines, never one inside another. if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) { - let _ = writeln!(f, "{line}"); + let _ = f.write_all(format!("{line}\n").as_bytes()); } } tracing::info!(session = ctx.id, transport = ctx.kind.label(), "session ended"); -- 2.54.0 (Apple Git-157) From 31ddb28c042c35681322c1f66881f806cbceecb6 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 20:38:54 -0400 Subject: [PATCH 29/43] joule-moq-relay: a kept track's cache takes a group whose streams finish after a later group was evicted Under load a group's stream can finish after the cache evicted a later group of its track; the cache refused it, so the DVR store never got it. For a track the store keeps, the cache now takes it and eviction drops the evicted group's state alone; coverage is unchanged. The mux test of a stream that arrives after the close no longer fails when, on a loaded host, the close arrives before its writes do. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-relay/README.md | 8 ++--- .../crates/joule-moq-relay/src/cache.rs | 33 +++++++++++++++++-- .../crates/joule-moq-relay/src/relay.rs | 7 ++-- .../joule-moq-relay/src/transport/mux.rs | 8 +++-- .../crates/joule-moq-relay/tests/dvr.rs | 4 +-- 5 files changed, 44 insertions(+), 16 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/README.md b/joule-code/joule-code-rs/crates/joule-moq-relay/README.md index 30f254dfd..878cc9707 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/README.md @@ -402,10 +402,10 @@ With `[dvr]` configured the relay keeps, on disk, a window of the tracks under e **What it keeps.** Every track a window's prefix matches (the rule with the longest prefix keeps it): media tracks, their receipts tracks, events tracks, catalogs, takes. A group is stored once the cache holds it whole (*What the cache covers*), with every field a FETCH -carries. While a stream of a kept group is still arriving, the cache does not evict it, past -its budget by that group if it must; a group that reaches the relay after the cache evicted a -later group of its track is not cached, and so not stored, and the relay logs a warning: -give the cache a few groups of each track. +carries. For a track the store keeps, the cache does not evict a group while a stream of it is +still arriving, past its budget by that group if it must, and it takes a group whose streams +finish after a later group was evicted (coverage is unchanged: nothing at or below an evicted +group is covered), so the store gets every group whatever order its streams finish in. **The window.** Each `[[dvr.window]]` has a `max_age_s`, from when a group's first object reached the relay, of 1 to 86 400 (24 hours, the start-over buffer a live-to-VOD harvest clips diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/cache.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/cache.rs index 971e15f19..3d2f0cab3 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/cache.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/cache.rs @@ -89,6 +89,11 @@ pub struct TrackCache { pub evicted_through: Option, /// The track ended (an End of Track status), at this location. pub end_of_track: Option, + /// A group that arrives after a later group of the track was evicted is still taken, and + /// eviction drops the evicted group's state alone: for a track the DVR store keeps, whose + /// groups it takes from the cache once whole, whatever order their streams finish in. + /// Coverage is unchanged: nothing at or below an evicted group is covered. + pub keep_late: bool, } /// How an object entering the cache relates to what it holds. @@ -106,7 +111,7 @@ pub enum Insert { impl TrackCache { pub fn insert(&mut self, group: u64, object: u64, o: CachedObject) -> Insert { - if self.evicted_through.is_some_and(|e| group <= e) { + if self.evicted_through.is_some_and(|e| group <= e) && !self.keep_late { return Insert::Evicted; } if let Some(prev) = self.objects.get(&(group, object)) { @@ -139,7 +144,7 @@ impl TrackCache { } fn group(&mut self, group: u64) -> Option<&mut GroupState> { - if self.evicted_through.is_some_and(|e| group <= e) { + if self.evicted_through.is_some_and(|e| group <= e) && !self.keep_late { return None; } Some(self.groups.entry(group).or_default()) @@ -247,7 +252,11 @@ impl TrackCache { } let through = self.evicted_through.map_or(g, |e| e.max(g)); self.evicted_through = Some(through); - self.groups = self.groups.split_off(&through.saturating_add(1)); + if self.keep_late { + self.groups.remove(&g); + } else { + self.groups = self.groups.split_off(&through.saturating_add(1)); + } self.absent.retain(|_, hi| *hi > through); freed } @@ -431,6 +440,24 @@ mod tests { assert_eq!(c.insert(0, 2, obj(b"late")), Insert::Evicted); } + #[test] + fn a_kept_track_takes_a_group_that_comes_after_a_later_one_was_evicted() { + let mut c = TrackCache { keep_late: true, ..TrackCache::default() }; + whole(&mut c, 5); + evict(&mut c, 5); + // Group 4's stream finishes after group 5 was evicted: it is taken, and becomes whole. + c.stream_opened(4, true); + assert_eq!(c.insert(4, 0, obj(b"late")), Insert::New); + c.stream_ended(4, true); + assert!(c.groups[&4].whole()); + // Evicting another group drops its own state alone. + whole(&mut c, 6); + evict(&mut c, 6); + assert!(c.groups.contains_key(&4) && !c.groups.contains_key(&6)); + // Coverage is unchanged: nothing at or below an evicted group. + assert!(!c.covers(Location::new(4, 0))); + } + #[test] fn the_budget_evicts_oldest_groups_first() { let mut b = Budget::new(100); diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs index 0204d987c..27d411be4 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs @@ -541,7 +541,9 @@ impl Relay { if !st.tracks.contains_key(key) { let id = self.next_id(); st.track_ids.insert(id, key.clone()); - st.tracks.insert(key.clone(), Track::new(id, key.clone())); + let mut t = Track::new(id, key.clone()); + t.cache.keep_late = self.inner.dvr.as_ref().is_some_and(|d| d.keeps(&key.0)); + st.tracks.insert(key.clone(), t); } st.tracks.get_mut(key).expect("the track") } @@ -593,9 +595,6 @@ impl Relay { }; let size = cache::size_of(&cached); let ins = track.cache.insert(group, oid, cached); - if ins == Insert::Evicted && o.first_in_stream == Some(oid) && self.inner.dvr.as_ref().is_some_and(|d| d.keeps(&key.0)) { - tracing::warn!(target: "joule_moq_relay::dvr", track = %track.render, group, "a group arrived after the cache evicted a later group of its track: not cached, so not stored (raise cache.max_bytes)"); - } if ins == Insert::Conflict { tracing::warn!(track = %track.render, group, object = oid, "a duplicate object that differs: the track is malformed (§7.1)"); track.cache.group_broken(group); diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/transport/mux.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/transport/mux.rs index 7176750ae..545c6139b 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/transport/mux.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/transport/mux.rs @@ -826,9 +826,13 @@ mod tests { async fn a_stream_that_arrives_after_the_close_leaves_the_state_unlocked() { let (c, s, _) = memory_pair(MuxConfig::default(), false); s.inner.set_closed(TransportError::Closed { code: 0, reason: "closed here".into(), by_peer: false }); + // On a loaded host the close can reach this end before every stream is written: a + // write that finds the session closed is the case at hand too, and is not a failure. for i in 0..4u8 { - let mut w = c.open_uni().await.unwrap(); - w.write_all(&[i; 64]).await.unwrap(); + let Ok(mut w) = c.open_uni().await else { break }; + if w.write_all(&[i; 64]).await.is_err() { + break; + } let _ = w.finish(); } tokio::time::sleep(Duration::from_millis(300)).await; diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs index 27ac962a2..a50021d78 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs @@ -40,9 +40,7 @@ use tokio::time::timeout; const OBJECTS: u64 = 3; const LEN: usize = 1000; /// The cache's budget: about three groups with their receipts, so that what is older comes -/// from the store. A group reaching the relay after the cache evicted a later group of its -/// track is not cached, and so not stored (README, *DVR*); a cache of a few groups or more -/// keeps a test clear of that. +/// from the store. const CACHE: usize = 16 * 1024; fn now_s() -> u64 { -- 2.54.0 (Apple Git-157) From 93eea43c57ee0280628721bbfbb0294f061003e6 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 21:48:17 -0400 Subject: [PATCH 30/43] joule-moq-relay: a FETCH's evicted run never ends before it starts local_plan's run of groups the DVR store evicted searched the cache for the next whole group from the run's first group itself, which the cache had already declined (it was at or below the cache's own eviction mark), so the run ended at g - 1: an inverted range, and at group 0 a subtraction overflow that panicked the session's task (seen in the DVR test a_window_by_age_empties_as_its_groups_pass_it). The search now starts after g and skips groups the cache evicted. local_plan takes the track's cache rather than the track, so the two cases are unit tests. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-relay/src/session.rs | 73 ++++++++++++++++--- 1 file changed, 63 insertions(+), 10 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs index dd56aa870..09f147b8f 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs @@ -1308,25 +1308,35 @@ enum Src { /// in the cache (the group of the Largest Object, `last`, from its start), held in the DVR /// store, known not to exist (left out), or evicted from the store. `None` when a group is /// none of these: the relay then asks upstream (the cache's rule). -fn local_plan(t: Option<&crate::relay::Track>, snap: &Snapshot, start: Location, end: Location, last: u64) -> Option> { +fn local_plan(c: Option<&crate::cache::TrackCache>, snap: &Snapshot, start: Location, end: Location, last: u64) -> Option> { let mut out = Vec::new(); let mut g = start.group; while g <= end.group { - let cs = t.and_then(|t| t.cache.groups.get(&g).copied().filter(|_| t.cache.evicted_through.is_none_or(|e| g > e))); + let cs = c.and_then(|c| c.groups.get(&g).copied().filter(|_| c.evicted_through.is_none_or(|e| g > e))); let next = if cs.is_some_and(|s| s.whole() || (g == last && s.start && !s.broken)) { - let t = t.expect("held"); - let objs = t.cache.objects.range((g, 0)..=(g, u64::MAX)).filter(|((gg, oo), _)| (start..=end).contains(&Location::new(*gg, *oo))).map(|(k, o)| (*k, o.clone())).collect(); + let c = c.expect("held"); + let objs = c.objects.range((g, 0)..=(g, u64::MAX)).filter(|((gg, oo), _)| (start..=end).contains(&Location::new(*gg, *oo))).map(|(k, o)| (*k, o.clone())).collect(); out.push(Src::Cache(objs)); g.checked_add(1) } else if snap.held.contains(&g) { out.push(Src::Dvr(g)); g.checked_add(1) - } else if let Some(hi) = t.and_then(|t| t.cache.absent_through(g)).or_else(|| snap.absent_through(g)) { + } else if let Some(hi) = c.and_then(|c| c.absent_through(g)).or_else(|| snap.absent_through(g)) { hi.checked_add(1) } else if let Some(e) = snap.evicted_through.filter(|e| g <= *e) { - // The run stops before the next group the cache still holds whole. - let cached = t.and_then(|t| t.cache.groups.range(g..=e.min(end.group)).find(|(gg, s)| s.whole() || (**gg == last && s.start && !s.broken)).map(|(gg, _)| *gg)); - let hi = cached.map_or(e.min(end.group), |c| c - 1); + // The run stops before the next group the cache still holds whole and has not + // evicted. The search starts after `g`, which the cache cannot serve (the first + // branch), so the run is never empty and `c - 1` never passes below `g`. + let top = e.min(end.group); + let cached = g.checked_add(1).filter(|from| *from <= top).and_then(|from| { + c.and_then(|c| { + c.groups + .range(from..=top) + .find(|(gg, s)| c.evicted_through.is_none_or(|ce| **gg > ce) && (s.whole() || (**gg == last && s.start && !s.broken))) + .map(|(gg, _)| *gg) + }) + }); + let hi = cached.map_or(top, |c| c - 1); out.push(Src::Unknown(g, hi)); hi.checked_add(1) } else { @@ -1463,7 +1473,7 @@ async fn fetch(relay: Relay, ctx: Arc, r: TrackRequest, tx: ReqTx, m } if let Some(d) = dvr && let Some(snap) = d.snapshot(&key, start.group, end.group) - && let Some(groups) = local_plan(t, &snap, start, end, largest.group) + && let Some(groups) = local_plan(t.map(|t| &t.cache), &snap, start, end, largest.group) { let eot = t.is_some_and(|t| t.cache.end_of_track == Some(end)) || snap.end_of_track == Some(end); let props = t.map(|t| t.props.clone()).filter(|p| !p.is_empty()).unwrap_or_else(|| snap.props.clone()); @@ -1605,7 +1615,7 @@ async fn serve_fill(relay: &Relay, ctx: &Arc, key: &TrackKey, filter if may_seek || start.group >= largest.group { let local = { let st = relay.inner.state.lock().unwrap(); - relay.dvr().filter(|d| d.keeps(&key.0)).and_then(|d| d.snapshot(key, start.group, end.group)).and_then(|snap| local_plan(st.tracks.get(key), &snap, start, end, largest.group)) + relay.dvr().filter(|d| d.keeps(&key.0)).and_then(|d| d.snapshot(key, start.group, end.group)).and_then(|snap| local_plan(st.tracks.get(key).map(|t| &t.cache), &snap, start, end, largest.group)) }; if let Some(groups) = local { relay.inner.stats.fetch_served_from_dvr.fetch_add(1, Ordering::Relaxed); @@ -1857,3 +1867,46 @@ async fn cleanup(relay: &Relay, ctx: &Arc, why: TransportError) { relay.inner.sessions_gone.notify_waiters(); relay.inner.change.notify_waiters(); } + +#[cfg(test)] +mod local_plan_tests { + use super::{local_plan, Src}; + use crate::cache::{GroupState, TrackCache}; + use crate::dvr::Snapshot; + use joule_moq::draft22::Location; + + fn whole() -> GroupState { + GroupState { start: true, finished: 1, ..GroupState::default() } + } + + /// A group the cache still holds whole at or below its own eviction mark is not the + /// cache's to serve, and a run of groups the store evicted that starts there must end + /// at or after it. Group 0 used to underflow (`0 - 1`) and panic the relay's task. + #[test] + fn an_evicted_run_starting_at_a_whole_group_the_cache_evicted() { + let mut c = TrackCache::default(); + c.groups.insert(0, whole()); + c.groups.insert(1, whole()); + c.evicted_through = Some(0); + let snap = Snapshot { evicted_through: Some(0), ..Snapshot::default() }; + let plan = local_plan(Some(&c), &snap, Location::new(0, 0), Location::new(1, u64::MAX), 1).expect("a plan"); + assert!(matches!(plan[0], Src::Unknown(0, 0)), "group 0 is evicted, alone"); + assert!(matches!(plan[1], Src::Cache(_)), "group 1 comes from the cache"); + assert_eq!(plan.len(), 2); + } + + /// The same at a later group: the run is never inverted. + #[test] + fn an_evicted_run_is_never_inverted() { + let mut c = TrackCache::default(); + for g in 4..=7 { + c.groups.insert(g, whole()); + } + c.evicted_through = Some(5); + let snap = Snapshot { evicted_through: Some(5), ..Snapshot::default() }; + let plan = local_plan(Some(&c), &snap, Location::new(4, 0), Location::new(7, u64::MAX), 7).expect("a plan"); + assert!(matches!(plan[0], Src::Unknown(4, 5)), "groups 4 and 5 are evicted, as one run"); + assert!(matches!(plan[1], Src::Cache(_)) && matches!(plan[2], Src::Cache(_))); + assert_eq!(plan.len(), 3); + } +} -- 2.54.0 (Apple Git-157) From 4a9affc4d5d536713ea2723f8c7fbdef4482faa7 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 22:19:05 -0400 Subject: [PATCH 31/43] joule-moq-relay: the DVR window stays one run of groups under load Groups finish, and are sealed, out of order under load. The store evicted by arrival across all groups, so a group sealed late could sit below groups already evicted, leaving gaps in the window (seen as [29, 32, 34, ...] in the window test under concurrent runs). Each track now evicts its lowest group first (across tracks, the one whose lowest group arrived first), and the writer refuses a seal below the evicted mark when it writes it, not only when it was queued. Co-Authored-By: Claude Opus 5.5 --- .../joule-code-rs/crates/joule-moq-relay/src/dvr.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs index 286fbe57f..47217d0f0 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs @@ -1344,6 +1344,13 @@ impl Dvr { let mut ix = self.index.lock().unwrap(); ix.tracks.entry(key.clone()).or_insert_with(|| DTrack { dir: dir.clone(), window, room, events, props: props.clone(), groups: BTreeMap::new(), absent: BTreeMap::new(), evicted_through: None, end_of_track: None, segs: BTreeMap::new() }); } + // A seal checked against the window when it was queued can reach the writer after + // the window has moved past its group: it is late here too, and never written below + // the evicted mark (where it would leave the window two runs). + if self.index.lock().unwrap().tracks.get(&key).is_some_and(|t| t.evicted_through.is_some_and(|e| group <= e)) { + self.stats.groups_late.fetch_add(1, Ordering::Relaxed); + return; + } let mut rows: Rows = Vec::with_capacity(objects.len()); let mut erased = Vec::new(); { @@ -1549,10 +1556,14 @@ impl Dvr { /// The oldest group held among the tracks `f` selects: by arrival, then track and group. fn oldest(ix: &Index, f: impl Fn(&DTrack) -> bool) -> Option<(TrackKey, u64)> { + // Within a track, the lowest group goes first, so a track's window stays one run of + // groups: groups finish, and are sealed, out of order under load, and evicting by + // arrival left gaps below a late group. Across tracks, the track whose lowest group + // arrived first. ix.tracks .iter() .filter(|(_, t)| f(t)) - .filter_map(|(k, t)| t.groups.iter().min_by_key(|(g, grp)| (grp.arrived_us, **g)).map(|(g, grp)| (grp.arrived_us, k.clone(), *g))) + .filter_map(|(k, t)| t.groups.iter().next().map(|(g, grp)| (grp.arrived_us, k.clone(), *g))) .min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.2.cmp(&b.2))) .map(|(_, k, g)| (k, g)) } -- 2.54.0 (Apple Git-157) From d3bf0a236660ce2557605d2eec16f09595f51408 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 22:20:35 -0400 Subject: [PATCH 32/43] joule-moq-relay: a media group and its receipts group leave the cache together The cache's byte budget evicted oldest first across tracks, and a group's receipts are cached just before its media, so under pressure a media group could stay cached while its receipts group was gone: a FETCH then served media no receipts covered (seen in the DVR test a_window_by_age_empties_as_its_groups_pass_it under concurrent runs). The pair is now evicted together, as the DVR store already evicts it, and kept together while either is still arriving on a track the store keeps. Budget::take drops the partner's accounting. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-relay/src/cache.rs | 16 ++++++++++ .../crates/joule-moq-relay/src/relay.rs | 30 ++++++++++++++----- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/cache.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/cache.rs index 3d2f0cab3..eef077c39 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/cache.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/cache.rs @@ -307,6 +307,22 @@ impl Budget { evict } + /// Stop accounting group `group` of track `track`, evicted out of the budget's order + /// (with its media or receipts partner); the bytes it held. + pub fn take(&mut self, track: u64, group: u64) -> usize { + let mut freed = 0; + self.order.retain(|(t, g, b)| { + if *t == track && *g == group { + freed += b; + false + } else { + true + } + }); + self.used = self.used.saturating_sub(freed); + freed + } + /// Forget the accounting of a track that is gone. pub fn forget(&mut self, track: u64) { let mut freed = 0; diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs index 27d411be4..ad278c5ad 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs @@ -523,14 +523,30 @@ impl Relay { let mut budget = self.inner.budget.lock().unwrap(); let evict = budget.add_sized(track_id, group, n); for (t, g, b) in evict { - if let Some(k) = st.track_ids.get(&t).cloned() - && let Some(tr) = st.tracks.get_mut(&k) + let Some(k) = st.track_ids.get(&t).cloned() else { continue }; + if !st.tracks.contains_key(&k) { + continue; + } + // A media group and its receipts group leave the cache together, as they leave + // the DVR store together: a group served without its receipts could not be + // verified, and receipts are cached just before their media, so the budget + // reaches them first. + let partner = if self.is_receipts_track(&k.1) { self.inner.door.media_of(&k.1).map(|m| (k.0.clone(), m)) } else { Some(self.receipts_of(&k)) }; + let partner = partner.filter(|p| st.tracks.contains_key(p)); + let kept = self.inner.dvr.as_ref().is_some_and(|d| d.keeps(&k.0)); + let arriving = |key: &TrackKey| st.tracks.get(key).and_then(|tr| tr.cache.groups.get(&g)).is_some_and(|s| s.open > 0 && !s.broken); + if kept && (arriving(&k) || partner.as_ref().is_some_and(arriving)) { + budget.keep(t, g, b); + continue; + } + if let Some(tr) = st.tracks.get_mut(&k) { + cache::evict(&mut tr.cache, g); + } + if let Some(p) = partner + && let Some(tr) = st.tracks.get_mut(&p) + && tr.cache.groups.contains_key(&g) { - let arriving = tr.cache.groups.get(&g).is_some_and(|s| s.open > 0 && !s.broken); - if arriving && self.inner.dvr.as_ref().is_some_and(|d| d.keeps(&k.0)) { - budget.keep(t, g, b); - continue; - } + budget.take(tr.id, g); cache::evict(&mut tr.cache, g); } } -- 2.54.0 (Apple Git-157) From 68a3c57d07928055b113416cd2bc532c0f14be0e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 22:29:16 -0400 Subject: [PATCH 33/43] joule-moq-web e2e: each run starts from no results A second run in the same output directory failed its service scenario: the room service's keygen refuses the key file the first run left. Co-Authored-By: Claude Opus 5.5 --- joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh index 5af3673d2..a4434a775 100755 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh @@ -28,6 +28,9 @@ TARGET=${CARGO_TARGET_DIR:-$JC/target} export CARGO_TARGET_DIR="$TARGET" OUT=${MOQ_WEB_OUT:-${TMPDIR:-/tmp}/moq-web-e2e} mkdir -p "$OUT" +# Each run starts from no results: a scenario writes keys and logs there, and the room +# service's keygen refuses a key file a previous run left. +rm -rf "$OUT/results" PKG="$CRATE/web/pkg" mkdir -p "$PKG" -- 2.54.0 (Apple Git-157) From 070795105fb7e4e752a95d7cbc0e945b99400e41 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 03:14:24 -0400 Subject: [PATCH 34/43] joule-moq-relay: evicting a stored group evicts every group at or below the mark The DVR store evicts the track whose lowest group arrived first; a group's arrival time can be later than a higher group's (the cache recreates a group's state when it takes a late group), so the store could evict groups 31 and 32 while group 30 stayed held below the new mark: a gap in the window (1 of 16 concurrent runs of the window test). Moving a track's evicted mark now removes every group at or below it, in the track and its partner, so none is ever held below the mark. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-relay/src/dvr.rs | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs index 47217d0f0..98edc52ac 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs @@ -1527,29 +1527,40 @@ impl Dvr { None => (key.0.clone(), self.receipts_of(&key.1)), }; for k in [key.clone(), partner] { - let seg = { + // The mark moves to `g`, and every group at or below it goes: none is ever held + // below a track's evicted mark. Groups are sealed out of order under load and a + // group's arrival time can be later than a higher group's, so evicting `g` alone + // could leave a lower group held while higher ones went: a gap in the window. + let victims: Vec = { let mut ix = self.index.lock().unwrap(); let Some(t) = ix.tracks.get_mut(&k) else { continue }; let through = t.evicted_through.map_or(g, |e| e.max(g)); t.evicted_through = Some(through); t.absent.retain(|_, hi| *hi > through); - let Some(grp) = t.groups.remove(&g) else { continue }; - if let Some(n) = t.segs.get_mut(&grp.seg) { - *n = n.saturating_sub(1); - } - let (window, room) = (t.window, t.room); - ix.window_bytes[window] = ix.window_bytes[window].saturating_sub(grp.bytes); - for o in &grp.objects { - ix.unindex(&k, g, o); - } - if let Some(r) = room { - ix.series.entry(r).or_default().change(now_us(), -(grp.bytes as i128), 0, 0, 0); - } - grp.seg + t.groups.range(..=through).map(|(gg, _)| *gg).collect() }; - self.stats.groups_evicted.fetch_add(1, Ordering::Relaxed); - let open = w.open.get(&k).map(|s| s.seq); - self.retire(&k, seg, open); + for v in victims { + let seg = { + let mut ix = self.index.lock().unwrap(); + let Some(t) = ix.tracks.get_mut(&k) else { break }; + let Some(grp) = t.groups.remove(&v) else { continue }; + if let Some(n) = t.segs.get_mut(&grp.seg) { + *n = n.saturating_sub(1); + } + let (window, room) = (t.window, t.room); + ix.window_bytes[window] = ix.window_bytes[window].saturating_sub(grp.bytes); + for o in &grp.objects { + ix.unindex(&k, v, o); + } + if let Some(r) = room { + ix.series.entry(r).or_default().change(now_us(), -(grp.bytes as i128), 0, 0, 0); + } + grp.seg + }; + self.stats.groups_evicted.fetch_add(1, Ordering::Relaxed); + let open = w.open.get(&k).map(|s| s.seq); + self.retire(&k, seg, open); + } } } } -- 2.54.0 (Apple Git-157) From 1dcb3bffa06156c584e77fdc1e18ce419b9d8756 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 03:16:14 -0400 Subject: [PATCH 35/43] joule-moq-relay: local_plan's last branch with ? Co-Authored-By: Claude Opus 5.5 --- .../joule-code-rs/crates/joule-moq-relay/src/session.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs index 09f147b8f..9256bb804 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs @@ -1323,7 +1323,9 @@ fn local_plan(c: Option<&crate::cache::TrackCache>, snap: &Snapshot, start: Loca g.checked_add(1) } else if let Some(hi) = c.and_then(|c| c.absent_through(g)).or_else(|| snap.absent_through(g)) { hi.checked_add(1) - } else if let Some(e) = snap.evicted_through.filter(|e| g <= *e) { + } else { + // Evicted from the store, or none of these: the relay asks upstream. + let e = snap.evicted_through.filter(|e| g <= *e)?; // The run stops before the next group the cache still holds whole and has not // evicted. The search starts after `g`, which the cache cannot serve (the first // branch), so the run is never empty and `c - 1` never passes below `g`. @@ -1339,8 +1341,6 @@ fn local_plan(c: Option<&crate::cache::TrackCache>, snap: &Snapshot, start: Loca let hi = cached.map_or(top, |c| c - 1); out.push(Src::Unknown(g, hi)); hi.checked_add(1) - } else { - return None; }; match next { Some(n) => g = n, -- 2.54.0 (Apple Git-157) From b74ed0e5cc17d7a3b19c9c6f579813e0a4bf2954 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 03:17:54 -0400 Subject: [PATCH 36/43] joule-moq-relay DVR test: a session that does not end is named, with its diagnostics Co-Authored-By: Claude Opus 5.5 --- joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs index a50021d78..c9c2c0d09 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs @@ -239,7 +239,7 @@ async fn window_run() { // The record of the room is version 2, with the store's figures. v.close(0, "done"); p.close(0, "done"); - assert!(until(2 * STEP, || server.relay().summaries().len() >= 2).await, "both sessions ended: {:?}", server.relay().stats()); + assert!(until(2 * STEP, || server.relay().summaries().len() >= 2).await, "both sessions ended: {:?}; open: {:?}", server.relay().stats(), server.relay().session_diagnostics()); let rec = server.relay().hop_record(room, 0); let bytes = rec.to_bytes(); assert_eq!(&bytes[..5], b"WMH1\x02", "version 2"); -- 2.54.0 (Apple Git-157) From b7d5dd61e1e85256457b5795b9c558b54a429701 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 03:22:22 -0400 Subject: [PATCH 37/43] joule-moq-relay DVR test: the by-age case fetches only after the relay took group 3 Taking group 3 can evict a media and receipts pair from the cache; the test's media and receipts FETCHes are two requests, so an eviction between them left media without its receipts ("the receipts of group 2 come with it", 16 of 64 runs under load with logging). The test now waits until the relay's largest object of both tracks is group 3's. Co-Authored-By: Claude Opus 5.5 --- .../joule-code-rs/crates/joule-moq-relay/tests/dvr.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs index c9c2c0d09..09953f54b 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs @@ -285,6 +285,14 @@ async fn a_window_by_age_empties_as_its_groups_pass_it() { assert_eq!(d.counts().groups_evicted, 6); // The next group, fetched at once: what aged out is unknown, and the group is served. publish_multi(&p, &publisher, &t, 3, "a").await; + // The relay has taken group 3 into both tracks before the fetches: taking it may + // evict a pair from the cache, and the media and receipts FETCHes are two requests, + // so a pair evicted between them would leave media without receipts. + let relay = server.relay().clone(); + assert!( + until(STEP, || relay.largest(&key) == Some(Location::new(3, OBJECTS - 1)) && relay.largest(&rkey) == Some(Location::new(3, 0))).await, + "the relay took group 3" + ); let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; let f = fetch_verified(&v, &t, &publisher, range((0, 0), 3)).await; // What aged out is unknown through the last group neither the store nor the cache -- 2.54.0 (Apple Git-157) From f0c540208078900f1324e8060ecb449875c3ddc9 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 03:24:07 -0400 Subject: [PATCH 38/43] joule-moq-relay: an evicted group moves its partner track's mark too A group's receipts are cached before its media, and under load the media's stream can be taken after later groups, so the budget could evict receipts group g before media group g had arrived: the pairing found no partner to evict, and media g was then cached and served without receipts (the DVR by-age test, 8 of 64 runs under load). Evicting group g now moves the partner track's evicted mark to g as well, so a partner group arriving later is never served from the cache alone; a DVR-kept track still takes it for the store. Co-Authored-By: Claude Opus 5.5 --- .../joule-code-rs/crates/joule-moq-relay/src/relay.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs index ad278c5ad..8e893c908 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs @@ -542,9 +542,13 @@ impl Relay { if let Some(tr) = st.tracks.get_mut(&k) { cache::evict(&mut tr.cache, g); } + // The partner's mark moves to `g` even when its group `g` has not arrived yet + // (a group's receipts are sent before its media, and under load the media's + // stream can be taken after later groups): a partner group arriving later is then + // never served from the cache without its pair. A track the DVR store keeps + // still takes it for the store, which holds the pair whole. if let Some(p) = partner && let Some(tr) = st.tracks.get_mut(&p) - && tr.cache.groups.contains_key(&g) { budget.take(tr.id, g); cache::evict(&mut tr.cache, g); -- 2.54.0 (Apple Git-157) From 6cad445cd1d348463d8fa731c046c29f3944c76c Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 03:44:17 -0400 Subject: [PATCH 39/43] joule-moq-relay: a sealed room is never kept in the DVR window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit confidentiality §9 (merged with L10) says a relay keeps nothing of an end-to-end encrypted room's tracks beyond delivery; the DVR store kept every track its prefix rules matched. The store now knows such a room by its first object carrying a secure-objects Key ID (or properties that do not read), never writes that group, deletes what it held of the room (its events and receipts tracks are in the clear and can be whole first), drops the room's decisions from decisions.wdl, writes nothing more of it, and records it in sealed.wdl so a restart keeps refusing it. Moderation still honours the room's decisions for what the cache serves. live-recording §8.2 states the rule; confidentiality §9 points to it. The joule-code changelog's stale "a file per group" now describes the segments, and the cache's media/receipts pairing is documented. Co-Authored-By: Claude Opus 5.5 --- joule-code/CHANGELOG.md | 15 +- .../crates/joule-moq-relay/README.md | 19 ++- .../crates/joule-moq-relay/src/dvr.rs | 159 +++++++++++++++++- .../crates/joule-moq-relay/src/relay.rs | 6 +- .../crates/joule-moq-relay/tests/dvr.rs | 115 ++++++++++++- wai/CHANGELOG.md | 5 +- wai/SPEC.md | 3 +- wai/extensions/confidentiality.md | 4 +- wai/extensions/live-recording.md | 9 + 9 files changed, 322 insertions(+), 13 deletions(-) diff --git a/joule-code/CHANGELOG.md b/joule-code/CHANGELOG.md index fb8cc8389..317a29b4e 100644 --- a/joule-code/CHANGELOG.md +++ b/joule-code/CHANGELOG.md @@ -8,9 +8,10 @@ All notable changes to the Joule Code standard are recorded here. The format fol - **`joule-moq-relay` keeps a DVR window on disk** (`[dvr]`, `[[dvr.window]]`; WAI live-recording §8): every track under a window's prefix, with its receipts, a group stored - once the cache holds it whole, in a file per group written durably (a temporary file, - synced, renamed, its directory synced). A window by age (at most 24 hours) and by bytes, - evicted oldest first by whole group, each media group with its receipts group. On start + once the cache holds it whole, appended to the track's current segment file (a new one every + ten seconds or 16 MiB) and synced at least once a second. A window by age (at most 24 hours) + and by bytes, evicted lowest group first by whole group, each media group with its receipts + group, so a track's window is always one run of groups. On start every object is checked again (its own check, then its receipt, or each event record's signature): one that does not verify is dropped, counted, written out of its file, and never served. A FETCH the cache does not cover is answered from the cache and the store @@ -23,8 +24,16 @@ All notable changes to the Joule Code standard are recorded here. The format fol forwards, honours a room key's (`dvr.room_keys`, the authorities' keys) or a moderator grant's, and withholds what deny or redact target from every FETCH and fill, erasing a redacted object from disk; the decisions are kept in `decisions.wdl` across a restart. +- **A sealed room is never kept** (WAI confidentiality §9, live-recording §8.2). The store + knows the room by its first object carrying a secure-objects Key ID. That object's group is + never written. The store then deletes what it held of the room (its events and receipts, in + the clear) and drops the room's decisions from the log. It writes nothing more of the room, + and `sealed.wdl` keeps it so across a restart. - **`WMH1` version 2** from a relay with a store, with its DVR figures; the session summary's tracks gain `dvr_objects` and `dvr_bytes`. +- **The cache, for every relay:** a media group and its receipts group leave the cache + together, and evicting one moves the other's mark, so a FETCH never serves media from the + cache without its receipts. - **`joule-moq`**: `GrantHead` reads a grant's `powers` (`moderates`, `seeks`); `receipt::verify_strict` is public. - **`joule-moq-room`**: every role, and so every grant, holds the seek power, so a late diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/README.md b/joule-code/joule-code-rs/crates/joule-moq-relay/README.md index 878cc9707..c7c43410d 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/README.md @@ -180,6 +180,12 @@ is less, before it asks upstream. A track of one group per object, each on its o (a page's audio is a group every 20 ms), always has a stream or two in flight, and a publisher by PUBLISH need not answer a FETCH at all. +A media group and its receipts group leave the cache together, since a group served without +its receipts could not be verified. Receipts are cached just before their media, so the +cache's budget reaches them first. Evicting either moves the other track's mark to that group +as well, even when the other's group has not arrived yet, so a late member of the pair is +never served from the cache alone. + ### Priorities and delivery timeouts Each downstream session has a scheduler. Every data stream it will send (a subgroup, a @@ -407,6 +413,17 @@ still arriving, past its budget by that group if it must, and it takes a group w finish after a later group was evicted (coverage is unchanged: nothing at or below an evicted group is covered), so the store gets every group whatever order its streams finish in. +**What it never keeps.** A room whose objects are sealed (WAI `confidentiality.md`: an +end-to-end encrypted room) is never recorded, and a relay keeps nothing of its tracks beyond +delivery (§9; live-recording §8.2). The store knows such a room by its first object whose +properties carry the secure-objects Key ID (`SECURE_OBJECT_KEY_ID`, read without a key), or +whose properties do not read. That object's group is never written. From then on the store +writes nothing of the room and deletes what it held of it: the room's events and receipts +tracks are in the clear and can be whole before its first sealed group. It also drops the +room's decisions from `decisions.wdl`, while still honouring them for what the cache serves. +The room is recorded in `sealed.wdl` (`room(32) check(32)`, `check` the SHA-256 of `room`), so +a restart keeps refusing it; `rooms_sealed` counts them. + **The window.** Each `[[dvr.window]]` has a `max_age_s`, from when a group's first object reached the relay, of 1 to 86 400 (24 hours, the start-over buffer a live-to-VOD harvest clips from), and a `max_bytes` of payload over its tracks together. Past either the oldest group goes @@ -565,7 +582,7 @@ All on loopback, every wait bounded: | `auth_tokens` | Tokens in SETUP, by value and by alias; UNAUTHORIZED; UNKNOWN_AUTH_TOKEN_ALIAS. | | `grant_proof` | A room grant with its proof over each transport gives its rights, each session with a challenge of its own; the grant as a bare token, a proof by another key, one for another relay and one over an earlier session's challenge each close the session with UNAUTHORIZED; a grant presented only in a request is refused it. | | `request_ids` | INVALID_REQUEST_ID for the wrong parity and for a repeat; requests in flight bounded by the stream limit. | -| `dvr` | A long publish crosses the window: the oldest groups go, whole, each media group with its receipts group, within the byte budget; a window by age empties as its groups pass it. FETCH inside the window (ascending and descending), across it (an End of Unknown Range through the last evicted group, then every object, from the store and the cache together) and before it (the marker alone); every object verifies against the fetched receipts. A restart keeps the store, drops and counts a flipped byte and bytes changed with their checks made again (only the receipt finds those), removes a cut-short write, and never serves what it dropped. A room key's redaction erases its target and withholds it, a deny withholds until an allow, a redacted event leaves the object that carries it, a decision by no moderator changes nothing, and the decisions hold across a restart. Without the seek power a FETCH and a fill below the live edge are refused `seek_not_granted`, and a FETCH at the live edge is served; with it, the window. The record of the room is `WMH1` version 2 with the store's figures. A window past 24 hours is refused. | +| `dvr` | A long publish crosses the window: the oldest groups go, whole, each media group with its receipts group, within the byte budget; a window by age empties as its groups pass it. FETCH inside the window (ascending and descending), across it (an End of Unknown Range through the last evicted group, then every object, from the store and the cache together) and before it (the marker alone); every object verifies against the fetched receipts. A restart keeps the store, drops and counts a flipped byte and bytes changed with their checks made again (only the receipt finds those), removes a cut-short write, and never serves what it dropped. A room key's redaction erases its target and withholds it, a deny withholds until an allow, a redacted event leaves the object that carries it, a decision by no moderator changes nothing, and the decisions hold across a restart. Without the seek power a FETCH and a fill below the live edge are refused `seek_not_granted`, and a FETCH at the live edge is served; with it, the window. The record of the room is `WMH1` version 2 with the store's figures. A room whose objects are sealed is never kept: its first sealed group is not written, its events and receipts already held and its decisions are deleted, nothing more of it is written, and so after a restart, while a room in the clear beside it keeps its window. A window past 24 hours is refused. | | `priority_fan_out` | Two publishers, four tracks, three subscribers with their own subscriber priorities and group orders, their transports held shut while the load arrives: each receives every object in §5.1.2's order. | ## Licence diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs index 98edc52ac..4ec0c5224 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs @@ -61,6 +61,17 @@ //! from the store (an event record from the object that carries it, the object's other //! records kept). Allow restores what deny withheld. The honoured decisions are kept in //! `decisions.wdl` and survive a restart. +//! +//! **A room whose objects are sealed** (WAI `confidentiality.md`: an end-to-end encrypted +//! room) is never kept, since such a room is never recorded and a relay keeps nothing of its +//! tracks beyond delivery (§9). The store knows one by its first sealed object: an object +//! whose properties carry the secure-objects Key ID (§4.3), which a relay reads without a key. +//! A group holding one is never written. From then on the store writes nothing of the room, +//! deletes what it already held (the room's events and receipts tracks, in the clear, can be +//! whole before its first media group is), and drops the room's decisions from +//! `decisions.wdl`; it still honours them for what the cache serves. The room is recorded in +//! `sealed.wdl`, `room(32) check(32)` per room with `check` the SHA-256 of the room, so a +//! restart keeps refusing it. use crate::auth::pattern_matches; use crate::cache::CachedObject; @@ -74,6 +85,7 @@ use joule_moq::draft22::kvp::{prop, Properties}; use joule_moq::draft22::names::render; use joule_moq::draft22::Location; use joule_moq::receipt::{ObjectReceipt, ReceiptVerifier}; +use joule_moq::secure_objects::{self, Refusal}; use joule_moq::{GroupId, ObjectId, TrackName, TrackNamespace}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::io::Write; @@ -269,6 +281,8 @@ struct Index { standing: HashMap<([u8; 32], [u8; 32]), u8>, decided: HashSet<[u8; 32]>, series: HashMap<[u8; 32], Series>, + /// Rooms whose objects are sealed: nothing of them is kept. + sealed: HashSet<[u8; 32]>, } impl Index { @@ -332,6 +346,8 @@ pub struct DvrCounts { pub groups_held: u64, pub bytes_held: u64, pub segments: u64, + /// Rooms whose objects are sealed, of which the store keeps nothing. + pub rooms_sealed: u64, } /// One object read from the store, or a place it can no longer serve. @@ -374,6 +390,7 @@ enum Job { Seal { key: TrackKey, props: Properties, group: u64, arrived_us: u64, objects: Vec<((u64, u64), CachedObject)> }, Erase { room: [u8; 32], digest: [u8; 32] }, Log(Vec), + Forget([u8; 32]), Flush(mpsc::Sender<()>), } @@ -602,6 +619,13 @@ fn decode_track(b: &[u8]) -> Option<(TrackKey, Properties)> { /// The Prior Group ID Gap an object's properties carry (MOQT §10.8): the groups before it /// that do not exist. +/// Whether an object may be sealed (WAI confidentiality §4.3): its properties carry a Key +/// ID, or do not read (Immutable Properties that do not decode could hide one). Only an +/// object whose properties read and carry none is in the clear. +pub fn is_sealed(props: &Properties) -> bool { + !matches!(secure_objects::key_id_of(props), Err(Refusal::KeyIdMissing)) +} + fn prior_groups(props: &Properties, group: u64) -> Option<(u64, u64)> { let n = props.find(prop::PRIOR_GROUP_ID_GAP).ok().flatten().and_then(|k| k.as_varint())?; (n > 0 && n <= group).then(|| (group - n, group - 1)) @@ -658,10 +682,27 @@ impl Dvr { self.windows.iter().enumerate().filter(|(_, w)| pattern_matches(&w.prefix, ns)).max_by_key(|(_, w)| w.prefix.0.len()).map(|(i, _)| i) } + /// Whether a window rule matches `ns`. Moderation reads the decisions of every events + /// track it matches, a sealed room's among them. pub fn keeps(&self, ns: &TrackNamespace) -> bool { self.window_of(ns).is_some() } + /// Whether the store writes the tracks of `ns`: a rule matches, and it is no room whose + /// objects are sealed. + pub fn stores(&self, ns: &TrackNamespace) -> bool { + self.keeps(ns) && !room_of(ns).is_some_and(|r| self.index.lock().unwrap().sealed.contains(&r)) + } + + /// Room `room`'s objects are sealed: from now the store keeps nothing of it, and the + /// writer deletes what it held. + fn seal_room(&self, room: [u8; 32]) { + if self.index.lock().unwrap().sealed.insert(room) { + tracing::info!(target: "joule_moq_relay::dvr", room = %hex::encode(room), "a room's objects are sealed: the store keeps nothing of it"); + self.send(Job::Forget(room)); + } + } + fn send(&self, j: Job) { if let Some(tx) = self.tx.lock().unwrap().as_ref() { let _ = tx.send(j); @@ -687,7 +728,15 @@ impl Dvr { /// Group `group` of `key` is whole in the cache: store it, unless it is stored with as /// many objects already. Cheap: the write is the writer's. pub fn seal(&self, key: &TrackKey, props: &Properties, group: u64, arrived_us: u64, objects: Vec<((u64, u64), CachedObject)>) { - if objects.is_empty() || !self.keeps(&key.0) { + if objects.is_empty() || !self.stores(&key.0) { + return; + } + // A sealed object is never written, and its room is never kept again + // (confidentiality §9). + if objects.iter().any(|(_, o)| is_sealed(&o.properties)) { + if let Some(room) = room_of(&key.0) { + self.seal_room(room); + } return; } { @@ -832,6 +881,7 @@ impl Dvr { groups_held: ix.tracks.values().map(|t| t.groups.len() as u64).sum(), bytes_held: ix.window_bytes.iter().sum(), segments: ix.tracks.values().map(|t| t.segs.len() as u64).sum(), + rooms_sealed: ix.sealed.len() as u64, } } @@ -878,7 +928,9 @@ impl Dvr { continue; } self.apply_decision(&e); - self.send(Job::Log(rec)); + if !self.index.lock().unwrap().sealed.contains(&room) { + self.send(Job::Log(rec)); + } } } @@ -1020,6 +1072,26 @@ impl Dvr { fn load(&self, w: &mut Writer, verifier: &dyn Fn(&TrackNamespace) -> ReceiptVerifier) -> Result<(), String> { let now = now_us(); + // The rooms found sealed before. Nothing of them is loaded: their decisions and the + // tracks a crash left of them are deleted. + let path = self.root.join("sealed.wdl"); + if let Ok(b) = std::fs::read(&path) { + let mut good = 0; + for c in b.chunks_exact(64) { + if sha256(&[&c[..32]])[..] != c[32..] { + break; + } + self.index.lock().unwrap().sealed.insert(c[..32].try_into().expect("32")); + good += 64; + } + if good != b.len() { + let _ = std::fs::OpenOptions::new().write(true).open(&path).and_then(|f| f.set_len(good as u64)); + } + } + let sealed = self.index.lock().unwrap().sealed.clone(); + if !sealed.is_empty() { + self.rewrite_decisions(|room| !sealed.contains(room)); + } // The decisions honoured before, each verified again. let log = self.root.join("decisions.wdl"); if let Ok(b) = std::fs::read(&log) { @@ -1059,6 +1131,10 @@ impl Dvr { tracing::warn!(target: "joule_moq_relay::dvr", dir = %dir.display(), "a track directory whose TRACK does not read: left alone"); continue; }; + if room_of(&key.0).is_some_and(|r| sealed.contains(&r)) { + let _ = std::fs::remove_dir_all(&dir); + continue; + } let Some(window) = self.window_of(&key.0) else { continue }; let digest = track_digest(&key); let mut t = DTrack { dir: dir.clone(), window, room: room_of(&key.0), events: is_events_track(&key), props, groups: BTreeMap::new(), absent: BTreeMap::new(), evicted_through: None, end_of_track: None, segs: BTreeMap::new() }; @@ -1297,6 +1373,7 @@ impl Dvr { Ok(Job::Seal { key, props, group, arrived_us, objects }) => self.write_seal(&mut w, key, props, group, arrived_us, objects), Ok(Job::Erase { room, digest }) => self.erase(&mut w, room, digest), Ok(Job::Log(rec)) => self.log(&rec), + Ok(Job::Forget(room)) => self.forget(&mut w, room), Ok(Job::Flush(done)) => { self.evict_by_age(&mut w, now_us()); self.sync(&mut w); @@ -1316,6 +1393,10 @@ impl Dvr { } fn log(&self, rec: &[u8]) { + // A room found sealed after the record was queued: its decisions are not kept. + if Event::read(rec).is_some_and(|e| self.index.lock().unwrap().sealed.contains(&e.room)) { + return; + } let path = self.root.join("decisions.wdl"); let mut b = (rec.len() as u32).to_le_bytes().to_vec(); b.extend_from_slice(rec); @@ -1329,9 +1410,83 @@ impl Dvr { } } + /// Forget room `room`, whose objects are sealed: record it in `sealed.wdl`, delete every + /// track of it the store holds, and drop its decisions from `decisions.wdl`. + fn forget(&self, w: &mut Writer, room: [u8; 32]) { + let mut rec = room.to_vec(); + rec.extend_from_slice(&sha256(&[&room])); + let r = std::fs::OpenOptions::new().create(true).append(true).open(self.root.join("sealed.wdl")).and_then(|mut f| { + f.write_all(&rec)?; + f.sync_all() + }); + if let Err(e) = r { + tracing::error!(target: "joule_moq_relay::dvr", error = %e, "a sealed room could not be recorded"); + } + sync_dir(&self.root); + let keys: Vec = self.index.lock().unwrap().tracks.iter().filter(|(_, t)| t.room == Some(room)).map(|(k, _)| k.clone()).collect(); + for k in &keys { + self.drop_track(w, k); + } + self.rewrite_decisions(|r| *r != room); + tracing::info!(target: "joule_moq_relay::dvr", room = %hex::encode(room), tracks = keys.len(), "a sealed room is forgotten"); + } + + /// Delete track `key` from the store: its groups, its directory, its writer state. + fn drop_track(&self, w: &mut Writer, key: &TrackKey) { + let t = { + let mut ix = self.index.lock().unwrap(); + let Some(t) = ix.tracks.remove(key) else { return }; + let held: u64 = t.groups.values().map(|g| g.bytes).sum(); + ix.window_bytes[t.window] = ix.window_bytes[t.window].saturating_sub(held); + for (g, grp) in &t.groups { + for o in &grp.objects { + ix.unindex(key, *g, o); + } + } + if let Some(r) = t.room { + ix.series.entry(r).or_default().change(now_us(), -(held as i128), 0, 0, 0); + } + t + }; + w.open.remove(key); + w.next.remove(key); + w.dirty.retain(|p| !p.starts_with(&t.dir)); + if let Err(e) = std::fs::remove_dir_all(&t.dir) { + tracing::error!(target: "joule_moq_relay::dvr", error = %e, dir = %t.dir.display(), "a sealed room's track could not be deleted"); + } + w.dirs.insert(self.root.join("tracks")); + } + + /// Write `decisions.wdl` again with only the records whose room `keep` keeps; a record + /// that does not read as an event stays. Nothing is written when every record stays. + fn rewrite_decisions(&self, keep: impl Fn(&[u8; 32]) -> bool) { + let path = self.root.join("decisions.wdl"); + let Ok(b) = std::fs::read(&path) else { return }; + let (mut out, mut p, mut dropped) = (Vec::with_capacity(b.len()), 0, false); + while b.len() >= p + 4 { + let n = u32::from_le_bytes(b[p..p + 4].try_into().expect("4")) as usize; + let Some(frame) = b.get(p..p + 4 + n + 32) else { break }; + if Event::read(&frame[4..4 + n]).is_some_and(|e| !keep(&e.room)) { + dropped = true; + } else { + out.extend_from_slice(frame); + } + p += 4 + n + 32; + } + if dropped && let Err(e) = write_durably(&path, &out) { + tracing::error!(target: "joule_moq_relay::dvr", error = %e, "the decisions log could not be written again"); + } + } + fn write_seal(&self, w: &mut Writer, key: TrackKey, props: Properties, group: u64, arrived_us: u64, objects: Vec<((u64, u64), CachedObject)>) { let Some(window) = self.window_of(&key.0) else { return }; let room = room_of(&key.0); + // A seal queued before its room was found sealed. The room is marked before its + // forgetting is queued, and this thread takes jobs in order: a seal it takes after the + // mark is refused here, and one it took before is deleted by the forgetting. + if room.is_some_and(|r| self.index.lock().unwrap().sealed.contains(&r)) { + return; + } let events = is_events_track(&key); let dir = self.root.join("tracks").join(track_dir_name(&key)); let known = self.index.lock().unwrap().tracks.contains_key(&key); diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs index 8e893c908..2c5fe29e8 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs @@ -402,7 +402,7 @@ impl Relay { fn dvr_seal(&self, t: &Track, group: u64) { let Some(dvr) = &self.inner.dvr else { return }; let Some(gs) = t.cache.groups.get(&group) else { return }; - if !gs.whole() || !dvr.keeps(&t.key.0) { + if !gs.whole() || !dvr.stores(&t.key.0) { return; } let objects: Vec<((u64, u64), CachedObject)> = t.cache.objects.range((group, 0)..=(group, u64::MAX)).map(|(k, o)| (*k, o.clone())).collect(); @@ -533,7 +533,7 @@ impl Relay { // reaches them first. let partner = if self.is_receipts_track(&k.1) { self.inner.door.media_of(&k.1).map(|m| (k.0.clone(), m)) } else { Some(self.receipts_of(&k)) }; let partner = partner.filter(|p| st.tracks.contains_key(p)); - let kept = self.inner.dvr.as_ref().is_some_and(|d| d.keeps(&k.0)); + let kept = self.inner.dvr.as_ref().is_some_and(|d| d.stores(&k.0)); let arriving = |key: &TrackKey| st.tracks.get(key).and_then(|tr| tr.cache.groups.get(&g)).is_some_and(|s| s.open > 0 && !s.broken); if kept && (arriving(&k) || partner.as_ref().is_some_and(arriving)) { budget.keep(t, g, b); @@ -562,7 +562,7 @@ impl Relay { let id = self.next_id(); st.track_ids.insert(id, key.clone()); let mut t = Track::new(id, key.clone()); - t.cache.keep_late = self.inner.dvr.as_ref().is_some_and(|d| d.keeps(&key.0)); + t.cache.keep_late = self.inner.dvr.as_ref().is_some_and(|d| d.stores(&key.0)); st.tracks.insert(key.clone(), t); } st.tracks.get_mut(key).expect("the track") diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs index 09953f54b..4b1f0f551 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs @@ -19,13 +19,17 @@ //! FETCH and a fill below the live edge (`seek_not_granted`) and served one at the live //! edge; a viewer whose grant holds it is served the window. //! - **Metering**: the relay signs `WMH1` version 2, with the store's figures. +//! - **A sealed room** (WAI confidentiality §9) is never kept: its first sealed object is +//! never written, what the store held of the room (its events and receipts, in the clear) +//! is deleted with its decisions, nothing more of it is written, and that holds across a +//! restart, while a room in the clear beside it keeps its window. mod common; use common::*; use ed25519_dalek::{Signer, SigningKey}; use joule_moq::carriage::ReceiptBundle; -use joule_moq::draft22::data::{FetchEntry, RangeEnd, SubgroupHeader, SubgroupId, SubgroupObject}; +use joule_moq::draft22::data::{FetchEntry, ObjectStatus, RangeEnd, SubgroupHeader, SubgroupId, SubgroupObject}; use joule_moq::draft22::{GroupOrder, Location, LocationFilter, Parameter, Parameters}; use joule_moq::receipt::{ObjectReceipt, ReceiptBuilder, ReceiptVerifier}; use joule_moq::{GroupId, ObjectId, TrackAlias, TrackNamespace}; @@ -556,6 +560,115 @@ async fn decisions_withhold_from_the_store_and_hold_across_a_restart() { .expect("in time"); } +/// Publish group `g` of `t` sealed: its receipts, then objects each carrying a secure-objects +/// Key ID in its Immutable Properties (WAI confidentiality §4.3). +async fn publish_sealed(c: &Client, key: &SigningKey, t: &Tracks, g: u64) { + use joule_moq::secure_objects::{immutable_properties, object_properties}; + let payloads: Vec> = (0..OBJECTS).map(|o| object_payload("sealed", g, o)).collect(); + let bundle = bundle_of(key, t.receipt_alias, g, &payloads); + let rh = SubgroupHeader { track_alias: t.receipts_alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(t.priority), properties: false, end_of_group: true, first_object: true }; + c.send_subgroup(rh, &[SubgroupObject::normal(0, bundle.to_payload().unwrap())]).await.expect("the receipts"); + let props = object_properties(&immutable_properties(7, &[]), &[]); + let mh = SubgroupHeader { track_alias: t.media_alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(t.priority), properties: true, end_of_group: true, first_object: true }; + let objects: Vec = payloads.into_iter().enumerate().map(|(o, p)| SubgroupObject { object_id: o as u64, properties: props.clone(), status: ObjectStatus::Normal, payload: p }).collect(); + c.send_subgroup(mh, &objects).await.expect("the sealed objects"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_sealed_room_is_never_kept() { + timeout(Duration::from_secs(120), async { + let dir = store_dir("sealed"); + let (sealed, clear) = ([0x5a; 32], [0x5b; 32]); + let publisher = SigningKey::from_bytes(&[0x5c; 32]); + let moderator = SigningKey::from_bytes(&[0x5d; 32]); + let ts = room_tracks(sealed, &publisher); + let tc = Tracks::new(ns(&[&hex::encode(clear), &hexkey(&publisher), "video"]), "0", 20, 6, 128); + let cfg = dvr_config(&dir, 900, 1 << 30, vec![hexkey(&moderator)]); + let config = |c: &mut RelayConfig| { + c.cache.max_bytes = CACHE; + c.dvr = Some(cfg.clone()); + }; + let server = relay(config).await; + let d = server.relay().dvr().unwrap().clone(); + let (p, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; + let _ps = publish_tracks(&p, &ts, None).await; + let _pc = publish_tracks(&p, &tc, None).await; + // Before the sealed room's first media group: its events, in the clear, and a + // moderator's decision in each room. + let (pe, _pe_held) = EventsTrack::open(&p, sealed, &publisher, 40).await; + let chat = event(&publisher, sealed, 0, 10, 0x03, b"\x02\x00hi"); + pe.group(&p, 0, &[chat.clone()]).await; + let (m, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; + let (ms, _ms_held) = EventsTrack::open(&m, sealed, &moderator, 50).await; + let (mc, _mc_held) = EventsTrack::open(&m, clear, &moderator, 60).await; + let (ds, dc) = (decision(&moderator, sealed, 0, 2, [1; 32]), decision(&moderator, clear, 0, 2, [2; 32])); + ms.group(&m, 0, &[ds.clone()]).await; + mc.group(&m, 0, &[dc.clone()]).await; + for g in 0..2 { + publish_multi(&p, &publisher, &tc, g, "c").await; + } + written(&server, 7).await; + assert!(until(STEP, || d.counts().decisions >= 2).await, "{:?}", d.counts()); + let ev_key = (pe.ns.clone(), name("0")); + let ev_dir = d.track_dir(&ev_key).expect("the sealed room's events, kept so far"); + assert!(ev_dir.exists() && d.groups(&ev_key) == vec![0]); + let log = dir.join("decisions.wdl"); + let holds = |path: &std::path::Path, bytes: &[u8]| std::fs::read(path).unwrap_or_default().windows(bytes.len()).any(|w| w == bytes); + assert!(holds(&log, &ds) && holds(&log, &dc)); + + // The first sealed group: never written, and the room is forgotten. + publish_sealed(&p, &publisher, &ts, 0).await; + assert!(until(STEP, || d.counts().rooms_sealed == 1).await, "{:?}", d.counts()); + flush(&server).await; + let room_keys = [(ts.ns.clone(), ts.media.clone()), (ts.ns.clone(), ts.receipts.clone()), ev_key.clone(), (ms.ns.clone(), name("0"))]; + for k in &room_keys { + assert!(d.groups(k).is_empty() && d.track_dir(k).is_none(), "{k:?} is kept"); + } + assert!(!ev_dir.exists(), "the sealed room's events are deleted from disk"); + assert!(!holds(&log, &ds), "its decision leaves the log"); + assert!(holds(&log, &dc), "the clear room's stays"); + assert_eq!(std::fs::metadata(dir.join("sealed.wdl")).unwrap().len(), 64); + let clear_media = (tc.ns.clone(), tc.media.clone()); + assert_eq!(d.groups(&clear_media), vec![0, 1], "the clear room keeps its window"); + + // Nothing more of it is written: its events and its next sealed group, then a group + // of the clear room, which is. + pe.group(&p, 1, &[event(&publisher, sealed, 1, 11, 0x03, b"\x03\x00bye")]).await; + publish_sealed(&p, &publisher, &ts, 1).await; + assert!(until(STEP, || server.relay().largest(&(ts.ns.clone(), ts.media.clone())) == Some(Location::new(1, OBJECTS - 1))).await); + publish_multi(&p, &publisher, &tc, 2, "c").await; + assert!(until(STEP, || d.groups(&clear_media).contains(&2)).await); + flush(&server).await; + for k in &room_keys { + assert!(d.groups(k).is_empty(), "{k:?} is written after the room was sealed"); + } + + // Across a restart: the room is still sealed, nothing of it is loaded or written, and + // only the clear room's decision is honoured again. + for c in [&p, &m] { + c.close(0, "done"); + } + server.shutdown().await; + let server = relay(config).await; + let d = server.relay().dvr().unwrap().clone(); + assert_eq!((d.counts().rooms_sealed, d.counts().decisions), (1, 1), "{:?}", d.counts()); + assert_eq!(d.groups(&clear_media), vec![0, 1, 2]); + let (p, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; + let (pe, _pe_held) = EventsTrack::open(&p, sealed, &publisher, 40).await; + pe.group(&p, 2, &[event(&publisher, sealed, 2, 12, 0x03, b"\x02\x00ok")]).await; + let _pc = publish_tracks(&p, &tc, None).await; + publish_multi(&p, &publisher, &tc, 3, "c").await; + assert!(until(STEP, || d.groups(&clear_media).contains(&3)).await); + flush(&server).await; + assert!(d.groups(&ev_key).is_empty() && d.track_dir(&ev_key).is_none()); + p.close(0, "done"); + server.shutdown().await; + let _ = std::fs::remove_dir_all(&dir); + }) + .await + .expect("in time"); +} + /// A grant laid out as live-rooms §4.1 lays it out, with these media bits and powers. fn grant(authority: &SigningKey, room: [u8; 32], principal: &SigningKey, publish: u8, powers: u8) -> Vec { let now = now_s(); diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 265a0f5c5..d24607d77 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -22,7 +22,10 @@ records. decisions read from the room's events tracks withholding what they deny or redact, never replacing it; every object checked again against its receipt after a restart. §8.6 maps the window onto DASH's `timeShiftBufferDepth` and an HLS live playlist. §8.1 is the take - addressing as before. + addressing as before. §8.2 also says what a relay never keeps: a room with a confidentiality + record, known by its first sealed object. The relay keeps nothing of the room from then on, + deletes what it kept of it before, and remembers the room across a restart. confidentiality + §9 points to it. - **live-rooms**: the **seek** power, bit 4 of `powers` (§3.3), and **§10.6 Seeking back**: a FETCH, or a fill, that starts below a track's live edge is served only to a grant with the power, and refused `UNAUTHORIZED`, `seek_not_granted`, otherwise. Roles and grants may now diff --git a/wai/SPEC.md b/wai/SPEC.md index a5233ec8f..17a837d0a 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -931,7 +931,8 @@ a recording onto HLS and LL-CMAF and the addressing of its chunks for a MoQ FETC semantics. A relay may keep a **DVR window** of a room's tracks, with their receipts, for at most 24 hours: it answers a FETCH from it in the requested order, so a viewer checks a recorded object exactly as a live one, marks what it evicted as unknown rather than absent, withholds -what a moderation decision restricts, and checks every object it kept again after a restart: +what a moderation decision restricts, keeps nothing of an end-to-end encrypted room, and +checks every object it kept again after a restart: [`extensions/live-recording.md`](extensions/live-recording.md). And for the **captions** of a room — `wai.text.timed`: timed text, as cues in a closed set diff --git a/wai/extensions/confidentiality.md b/wai/extensions/confidentiality.md index 4df5c872d..aba91be96 100644 --- a/wai/extensions/confidentiality.md +++ b/wai/extensions/confidentiality.md @@ -575,7 +575,9 @@ its content ([live-moderation](live-moderation.md)). `mode` is 0 (live-rooms §3.7), and §3.3 step 5 refuses a record for a room whose mode is not (`recording_refused`): no principal holds the record power to any effect, no device commits a take (capture-binding needs a `record_start`), no recording manifest is signed, no clip is -cut, and a relay keeps nothing of the room's tracks beyond delivery. +cut, and a relay keeps nothing of the room's tracks beyond delivery: no DVR window either, +which a relay that keeps one stops at the room's first sealed object +([live-recording](live-recording.md) §8.2). The alternative was a recording of the relay's ciphertext with each epoch's keys escrowed to the room authority. It is not taken, for these reasons: diff --git a/wai/extensions/live-recording.md b/wai/extensions/live-recording.md index 877467cc5..1276d0afc 100644 --- a/wai/extensions/live-recording.md +++ b/wai/extensions/live-recording.md @@ -617,6 +617,15 @@ tracks, its events tracks, its catalogs and its takes. region pin (live-rooms §3.7) binds where it is stored. - **Who reads it.** A FETCH or fill below a track's live edge is served only to a grant with the seek power (live-rooms §10.6). +- **A room it never keeps.** A room with a confidentiality record is never recorded, and a + relay keeps nothing of its tracks beyond delivery ([confidentiality](confidentiality.md) + §9). A relay knows such a room by its first sealed object, one whose Immutable Properties + carry `SECURE_OBJECT_KEY_ID` (confidentiality §4.3), which it reads without a key. It + **MUST NOT** keep that object's group. From then on it keeps nothing of the room's tracks, + deletes what it kept of them before (the room's events and receipts tracks are in the clear, + and can be whole before its first sealed group), and remembers the room across a restart. + It still honours the moderation decisions it reads on the room's events tracks for what it + delivers, and does not keep them. ### 8.3 Answering a FETCH -- 2.54.0 (Apple Git-157) From 7de2c27cf7b362067b3c93ff5f0bcd6de3260792 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 03:47:32 -0400 Subject: [PATCH 40/43] joule-moq-relay: clippy's notes on the sealed-room code and its test Co-Authored-By: Claude Opus 5.5 --- joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs | 2 +- .../joule-code-rs/crates/joule-moq-relay/tests/dvr.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs index 4ec0c5224..cd2d13175 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs @@ -1077,7 +1077,7 @@ impl Dvr { let path = self.root.join("sealed.wdl"); if let Ok(b) = std::fs::read(&path) { let mut good = 0; - for c in b.chunks_exact(64) { + for c in b.as_chunks::<64>().0 { if sha256(&[&c[..32]])[..] != c[32..] { break; } diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs index 4b1f0f551..e3b11070e 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs @@ -597,13 +597,13 @@ async fn a_sealed_room_is_never_kept() { // moderator's decision in each room. let (pe, _pe_held) = EventsTrack::open(&p, sealed, &publisher, 40).await; let chat = event(&publisher, sealed, 0, 10, 0x03, b"\x02\x00hi"); - pe.group(&p, 0, &[chat.clone()]).await; + pe.group(&p, 0, &[chat]).await; let (m, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; let (ms, _ms_held) = EventsTrack::open(&m, sealed, &moderator, 50).await; let (mc, _mc_held) = EventsTrack::open(&m, clear, &moderator, 60).await; let (ds, dc) = (decision(&moderator, sealed, 0, 2, [1; 32]), decision(&moderator, clear, 0, 2, [2; 32])); - ms.group(&m, 0, &[ds.clone()]).await; - mc.group(&m, 0, &[dc.clone()]).await; + ms.group(&m, 0, std::slice::from_ref(&ds)).await; + mc.group(&m, 0, std::slice::from_ref(&dc)).await; for g in 0..2 { publish_multi(&p, &publisher, &tc, g, "c").await; } -- 2.54.0 (Apple Git-157) From 7af51333529dd52070076dbd85aebf9e6c6a99bd Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 03:51:15 -0400 Subject: [PATCH 41/43] joule-moq-relay: depend on joule-moq's secure-objects feature, for the Key ID reader The DVR store reads an object's Key ID to know a sealed room; the feature was on only through the dev-dependency, so the relay built as another crate's dependency (joule-moq-ingest, the e2e's release binary) did not compile. tests/sealed_objects.rs now gets it from the normal dependency. Co-Authored-By: Claude Opus 5.5 --- joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml index 0f0224c2e..87070b8cc 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml @@ -15,7 +15,9 @@ name = "joule-moq-relay" path = "src/main.rs" [dependencies] -joule-moq = { path = "../joule-moq" } +# `secure-objects` for its Key ID reader alone: the DVR store keeps nothing of a room whose +# objects are sealed (dvr.rs), and the relay never opens one. +joule-moq = { path = "../joule-moq", features = ["secure-objects"] } quinn = "0.11" rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } ring = "0.17" @@ -41,5 +43,3 @@ thiserror = "2" [dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros"] } -# tests/sealed_objects.rs seals a room's objects end to end; the relay itself never opens one. -joule-moq = { path = "../joule-moq", features = ["secure-objects"] } -- 2.54.0 (Apple Git-157) From c297283ff672cefae57ced09c0d77363e4d7353e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 03:51:44 -0400 Subject: [PATCH 42/43] joule-moq-room: Cargo.lock with the relay's secure-objects dependencies Additions only, at the versions joule-code-rs's lock already holds. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-room/Cargo.lock | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock index 5a24449a6..e6a1552ef 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock +++ b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "android_system_properties" version = "0.1.6" @@ -243,6 +278,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "combine" version = "4.6.8" @@ -330,6 +375,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -401,6 +455,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -649,6 +704,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "half" version = "2.7.1" @@ -678,6 +743,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "httlib-huffman" version = "0.3.4" @@ -910,6 +993,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "itoa" version = "1.0.18" @@ -979,12 +1071,17 @@ dependencies = [ name = "joule-moq" version = "0.3.0" dependencies = [ + "aes", + "aes-gcm", "async-trait", "blake3", "bytes", "chrono", + "ctr", "ed25519-dalek", "futures", + "hkdf", + "hmac", "jwp", "rand_core 0.6.4", "serde", @@ -1223,6 +1320,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -1267,6 +1370,18 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2167,6 +2282,16 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" -- 2.54.0 (Apple Git-157) From f8b199a657ab7320adae801e1abf33d5c547c01e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 04:06:13 -0400 Subject: [PATCH 43/43] =?UTF-8?q?live-metering=20=C2=A73.1:=20the=20versio?= =?UTF-8?q?n=202=20sentence=20cites=20live-recording=20=C2=A78=20in=20pros?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The link after the table's [start, end) interval read, to the section reference check, as one link text reaching back to energy-measurement §2.7; and "keeps none signs" matched the public-text check's "none signs". Co-Authored-By: Claude Opus 5.5 --- wai/extensions/live-metering.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wai/extensions/live-metering.md b/wai/extensions/live-metering.md index fcf896f82..18c655a7b 100644 --- a/wai/extensions/live-metering.md +++ b/wai/extensions/live-metering.md @@ -104,8 +104,8 @@ Each hop signs one record per room session, `WMH1`, when its part in the session | `sig` | 64 | §3.5 | Version 2 is version 1's fields in the same order and one more, `dvr`, after `ledger` and -before `sig`. A hop that keeps a DVR window ([live-recording](live-recording.md) §8) signs -version 2; one that keeps none signs version 1. +before `sig`. A hop that keeps a DVR window (live-recording §8) signs version 2, and a hop +without one signs version 1. ### 3.2 Track entries -- 2.54.0 (Apple Git-157)