diff --git a/joule-code/CHANGELOG.md b/joule-code/CHANGELOG.md index 9bc63fce0..317a29b4e 100644 --- a/joule-code/CHANGELOG.md +++ b/joule-code/CHANGELOG.md @@ -4,6 +4,47 @@ 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, 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 + 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. +- **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 + 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 a real browser 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-ingest/src/room.rs b/joule-code/joule-code-rs/crates/joule-moq-ingest/src/room.rs index fad5a9fea..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 @@ -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) @@ -997,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-relay/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml index 685dc9654..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" @@ -30,7 +32,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"] } @@ -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"] } 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..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 @@ -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. | @@ -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 @@ -310,6 +316,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 +399,127 @@ 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. 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. + +**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 +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 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) +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. 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.** 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 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 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 +528,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 +560,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 +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 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/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..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 @@ -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 { @@ -86,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. @@ -103,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)) { @@ -114,6 +122,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; } @@ -133,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()) @@ -174,6 +185,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) @@ -236,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 } @@ -258,6 +278,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, @@ -270,11 +302,27 @@ 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 } + /// 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; @@ -408,6 +456,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/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..cd2d13175 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/dvr.rs @@ -0,0 +1,1792 @@ +//! 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, 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) +//! 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. 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.** 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 +//! 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 (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; +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::secure_objects::{self, Refusal}; +use joule_moq::{GroupId, ObjectId, TrackName, TrackNamespace}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +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, 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; +/// 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() +} + +fn track_digest(key: &TrackKey) -> [u8; 32] { + sha256(&[render(&key.0, Some(&key.1)).as_bytes()]) +} + +// ── The index ────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug)] +struct DObject { + id: u64, + subgroup: Option, + priority: u8, + status: ObjectStatus, + /// Where its properties begin in its segment; 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)>, + /// Its standing record: the segment, and the frame's place and record length. + seg: u64, + frame_at: u64, + record_len: u32, +} + +#[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, + /// Each segment, with the groups whose standing record is in it. + segs: BTreeMap, +} + +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>, + /// Rooms whose objects are sealed: nothing of them is kept. + sealed: HashSet<[u8; 32]>, +} + +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. +#[derive(Default)] +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, + /// 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`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +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, + pub decisions: u64, + pub syncs: u64, + 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. +#[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), + Forget([u8; 32]), + Flush(mpsc::Sender<()>), +} + +struct Window { + prefix: TrackNamespace, + max_age_us: u64, + 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, + 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(&track_digest(key)[..16]) +} + +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) { + 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 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); + 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 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; + } + 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, 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 { + 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 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])[..] == 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; + } + 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(Decoded { group, arrived_us, 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. +/// 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)) +} + +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 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> { + 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(), + }); + 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(w, 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) + } + + /// 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); + } + } + + /// 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(60)); + } + + /// 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.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; + } + { + 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()) { + 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> { + // 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) + }; + 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()); + } + None + } + + /// 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), + 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), + 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(), + rooms_sealed: ix.sealed.len() as u64, + } + } + + /// 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() + } + + /// 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 ────────────────────────────────────────────────────────── + + /// 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); + if !self.index.lock().unwrap().sealed.contains(&room) { + self.send(Job::Log(rec)); + } + } + } + + /// 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 None; + } + 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 }); + 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, 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.as_chunks::<64>().0 { + 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) { + 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)) { + // 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; + } + 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, 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 { + 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() }; + 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::OpenOptions::new().write(true).open(path).and_then(|f| f.set_len(p as u64)); + break; + } + 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); + } + } + 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 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) { + continue; + } + let v = verifier(&k.0); + let groups: Vec = t.groups.keys().copied().collect(); + for g in groups { + 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()); + 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(|x| x.object_id == ObjectId(o.id)).is_some_and(|x| v.verify_payload(payload, x, vg).is_ok()), + _ => o.status != ObjectStatus::Normal, + } + } else { + match events::records(payload) { + Some(rs) => rs.iter().all(|(_, x)| Event::read(x).is_some_and(|e| e.verifies(x))), + None => true, + } + }; + if !ok { + 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); + } + } + // 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)); + } + } + // 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); + } + } + 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(()) + } + + /// 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 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, mut w: Writer, rx: mpsc::Receiver) { + loop { + 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::Forget(room)) => self.forget(&mut w, room), + 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) => { + 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); + } + } + } + + 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); + 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"); + } + } + + /// 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); + 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; + } + 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() }); + } + // 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(); + { + 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, 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 Some(t) = ix.tracks.get_mut(&key) else { return }; + if t.evicted_through.is_some_and(|e| group <= e) { + drop(ix); + self.kill(w, &dir, seq, at, bytes.len() as u32); + self.stats.groups_late.fetch_add(1, Ordering::Relaxed); + return; + } + for (_, o) in &objects { + if let Some(r) = prior_groups(&o.properties, group) { + t.absent.insert(r.0, r.1); + } + } + 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); + } + } + for o in &index { + 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(w, window); + } + + /// 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 (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(grp) = t.groups.get(&g).cloned() else { continue }; + (t.dir.clone(), grp) + }; + 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)) = rows.iter().find(|x| x.0.id == oid) else { continue }; + events::withhold(payload, |d| *d == digest).map(|p| Bytes::from(p.into_owned())) + }; + 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) + } + 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 + } + }; + 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(); + 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", dir = %dir.display(), group = g, object = oid, whole = replaced.is_none(), "a redaction erased from the store"); + } + } + + fn evict_by_bytes(&self, w: &mut Writer, 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(w, &k, g), + None => return, + } + } + } + + 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 == i).filter(|(k, g)| ix.tracks[k].groups[g].arrived_us < floor) + }; + match victim { + 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. 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] { + // 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); + t.groups.range(..=through).map(|(gg, _)| *gg).collect() + }; + 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); + } + } + } +} + +/// 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().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)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + 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: 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 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 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, &[]); + 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)); + } + + #[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/src/events.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/events.rs new file mode 100644 index 000000000..819173ce5 --- /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[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(); + 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..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 @@ -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.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(); + 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 @@ -475,12 +515,42 @@ 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 { - if let Some(k) = st.track_ids.get(&t).cloned() - && let Some(tr) = st.tracks.get_mut(&k) + let mut budget = self.inner.budget.lock().unwrap(); + let evict = budget.add_sized(track_id, group, n); + for (t, g, b) in evict { + 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.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); + continue; + } + 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) { + budget.take(tr.id, g); cache::evict(&mut tr.cache, g); } } @@ -491,7 +561,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.stores(&key.0)); + st.tracks.insert(key.clone(), t); } st.tracks.get_mut(key).expect("the track") } @@ -574,6 +646,7 @@ impl Relay { object_id: oid, receipts_subscribed: rsessions.contains(&sub.session), fetched: false, + dvr: false, signed: o.signed, }; sub.sched.push( @@ -581,6 +654,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 +668,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 +713,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 +732,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 +840,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 +848,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..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 @@ -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) { @@ -1032,30 +1114,75 @@ 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; } let CachedObject { subgroup, publisher_priority, properties, payload, .. } = c; - 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, signed: None }; - sched.push(lane, Item { op: Op::FetchEntry { request_id, order, entry }, prio: p, deadline: None, meta: Some(meta) }); - n += 1; + let payload = match withhold { + None => payload, + Some(w) => match w.apply(payload) { + Some(p) => p, + None => { + slots.push((o, None)); + continue; + } + }, + }; + 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> { @@ -1140,7 +1267,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 +1294,136 @@ 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(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 = 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 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) = c.and_then(|c| c.absent_through(g)).or_else(|| snap.absent_through(g)) { + hi.checked_add(1) + } 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`. + 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) + }; + 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 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) => { + 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 => Some(c.payload), + Some(w) => w.apply(c.payload), + }; + 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 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 read { + None => sched.push(lane, unknown_item(request_id, order, prio, fill, g, to)), + Some(read) => { + let slots = read + .into_iter() + .filter_map(|slot| match slot { + Slot::Object(so) if so.status == ObjectStatus::Normal => { + 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(_) => None, + Slot::Unknown(o) => Some((o, None)), + }) + .collect(); + pending += push_group(&sched, lane, g, slots, request_id, order, prio, fill).1; + } + } + } + } + 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 +1439,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.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()); + 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 +1499,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 +1545,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 +1555,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 +1581,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 +1598,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.3). + 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).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); + 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 +1646,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(); @@ -1525,8 +1856,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"); @@ -1534,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); + } +} 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/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/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/dvr.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs new file mode 100644 index 000000000..e3b11070e --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs @@ -0,0 +1,753 @@ +//! 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. +//! - **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, 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}; +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; +/// The cache's budget: about three groups with their receipts, so that what is older comes +/// from the store. +const CACHE: usize = 16 * 1024; + +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: &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 { + 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 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 + d.counts().groups_late >= 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 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 = CACHE; + 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 + 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. + 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, 0), first + 2).with(Parameter::GroupOrder(GroupOrder::Descending))).await; + let groups: Vec = desc.order.iter().map(|x| x.0).collect(); + 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. + 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(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"); + 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, 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); +} + +#[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| { + // 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; + 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; + // 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 + // 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); + }) + .await + .expect("in time"); +} + +/// 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 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; + 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 = CACHE; + 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, 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 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_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; + let f = fetch_verified(&v, &t, &publisher, Parameters::new().with(Parameter::LocationFilter(LocationFilter::AbsoluteStart { start: Location::new(0, 0) }))).await; + // 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; + 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 = CACHE; + 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 >= 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) })); + 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 - 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 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); + 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))); + assert_eq!(f.objects.len() as u64, GROUPS * OBJECTS - 2); + + // 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"); +} + +/// 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]).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, 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; + } + 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(); + 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 = CACHE; + 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()); +} 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/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" 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![] }; 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-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/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" 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

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())); diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 2b36338ac..e54fe264c 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,39 @@ 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. §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 + 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.9: cut-rank amplitudes with the circuit's own global phase Additive. Every pinned digest is unchanged. diff --git a/wai/SPEC.md b/wai/SPEC.md index 162bd070b..17a837d0a 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -878,7 +878,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 @@ -927,7 +928,12 @@ 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, 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 of styling (italic runs, line breaks, six anchors) that WebVTT and the IMSC1 Text Profile 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-metering.md b/wai/extensions/live-metering.md index cd6c2fd76..18c655a7b 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 §8) signs version 2, and a hop +without one 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` diff --git a/wai/extensions/live-recording.md b/wai/extensions/live-recording.md index b9a892b53..1276d0afc 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,112 @@ 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). +- **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 + +- **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: diff --git a/wai/extensions/live-rooms.md b/wai/extensions/live-rooms.md index 1c5244afa..af4699bdb 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 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 +(§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 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/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/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 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", 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. 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" + } ] } 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 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/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_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. 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.