joule-moq-relay: DVR on the relay, seek back by FETCH, and the seek power (L7b) #351

Merged
dcharlot merged 47 commits from joule-moq/dvr into main 2026-10-20 08:29:33 -04:00
52 changed files with 6465 additions and 239 deletions

View file

@ -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.

View file

@ -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) {

View file

@ -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"] }

View file

@ -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 `["*", "<clock>", "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/<the first 16 bytes of the SHA-256
of its rendered name, hex>/`, holding `TRACK` (namespace, name, properties, and a SHA-256
check) and the track's **segments**, `s<n, 20 digits>.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

View file

@ -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`

View file

@ -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<GrantHead> {
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();

View file

@ -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<u64>,
/// The track ended (an End of Track status), at this location.
pub end_of_track: Option<Location>,
/// 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<u64> {
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<u64> {
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);

View file

@ -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<DvrConfig>,
/// 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<DvrWindow>,
/// 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<String>,
}
/// 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<String>,
/// 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");
}
}

View file

@ -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<ReceiptVerifier, String> {
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<TrackName> {
if self.suffix.is_empty() || name.0.len() <= self.suffix.len() || !name.0.ends_with(&self.suffix) {

File diff suppressed because it is too large Load diff

View file

@ -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":"<base64url>"}}`, 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<u8>,
/// 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<Event> {
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<(Box<RawValue>, Vec<u8>)>> {
let entries: Vec<Box<RawValue>> = 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<std::borrow::Cow<'_, [u8]>> {
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<u8> {
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[..]);
}
}

View file

@ -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;

View file

@ -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<LedgerEntry>,
/// The DVR figures: present in a version 2 record alone.
pub dvr: Option<DvrFigures>,
pub sig: [u8; 64],
}
@ -189,7 +216,7 @@ impl HopRecord {
pub fn body(&self) -> Vec<u8> {
let mut o = Vec::with_capacity(281 + 90 * self.entries.len());
o.extend_from_slice(MAGIC);
o.push(VERSION);
o.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)

View file

@ -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<Arc<Dvr>>,
pub key: SigningKey,
pub state: Mutex<State>,
pub budget: Mutex<Budget>,
@ -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<Dvr>> {
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<Location> {
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<Arc<SessionCtx>> = 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<TrackNamespace> {
let mut out: Vec<TrackNamespace> = st.namespaces.iter().filter(|n| n.session != except && prefix_matches(prefix, &n.namespace)).map(|n| n.namespace.clone()).collect();

View file

@ -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;

View file

@ -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<SessionCtx>, 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<u64> = None;
let mut subgroup_id: Option<u64> = None;
@ -579,6 +588,9 @@ async fn upstream_subgroup(relay: Relay, ctx: Arc<SessionCtx>, 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<SessionCtx>, 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<SessionCtx>, 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<SessionCtx>, 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<Dvr>,
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<Withhold> {
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<Bytes> {
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<u64> = None;
let mut slots: Vec<(u64, Option<Item>)> = 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<str>, request_id: u64, order: GroupOrder, prio: u8, fill: bool, g: u64, o: u64, subgroup: Option<u64>, 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<Item>)>, 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<Arc<SessionCtx>> {
@ -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<Vec<Src>> {
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<Src>, start: Location, end: Location, order: GroupOrder, prio: u8, request_id: u64, lane_sub: u64, fill: bool, withhold: Option<Withhold>, moderator: bool) {
let render: Arc<str> = 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<SessionCtx>, r: TrackRequest, tx: ReqTx, mut rx: ReqRx, tokens: Vec<Resolved>) {
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<SessionCtx>, 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<Src> },
Upstream { start: Location, end: Option<Location> },
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<SessionCtx>, 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<SessionCtx>, 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<SessionCtx>, 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<tokio::task::JoinHandle<()>>) {
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<SessionCtx>, key: &TrackKey, filter: Option<LocationFilter>, order: GroupOrder, prio: u8, request_id: u64, lane_sub: u64, budget: Option<u64>) {
async fn serve_fill(relay: &Relay, ctx: &Arc<SessionCtx>, key: &TrackKey, filter: Option<LocationFilter>, order: GroupOrder, prio: u8, request_id: u64, lane_sub: u64, budget: Option<u64>, 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<SessionCtx>, 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<SessionCtx>, 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<SessionCtx>, 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);
}
}

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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<String>) -> 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<u8> {
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<u8>]) -> 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<Vec<u8>> = (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<SubgroupObject> = 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<u8>>,
unknown: Vec<Location>,
other_ends: Vec<(RangeEnd, Location)>,
}
fn fetched(entries: Vec<FetchEntry>) -> 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::<Vec<_>>(), "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<u64> = 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::<Vec<_>>(), 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::<Vec<_>>());
// 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<u8> {
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<u8> {
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<u8> {
use base64::Engine;
let e: Vec<String> = 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<u8>]) {
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<Vec<u8>> = (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<SubgroupObject> = 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<u8> {
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<u8>, 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());
}

View file

@ -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);

View file

@ -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;

View file

@ -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"

View file

@ -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

View file

@ -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![] };

View file

@ -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")

View file

@ -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;
}

View file

@ -4,7 +4,7 @@
//
// node e2e/room.e2e.mjs --relay <joule-moq-relay> --wai <wai_live_web> --web <web dir>
// --corpus <live-conformance/compose/vectors.json> [--browser <executable>]
// [--only compose,webtransport,websocket,goaway,service] [--runs N] [--between <s>]
// [--only compose,webtransport,websocket,goaway,dvr,service] [--runs N] [--between <s>]
// [--out <dir>] [--room <joule-moq-room>] [--base <origin>]
//
// 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);

View file

@ -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"

View file

@ -22,6 +22,9 @@
// transport=<t> `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=<n> 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,

View file

@ -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 @@
<h2>The room, composed</h2>
<canvas id="canvas" width="640" height="360"></canvas>
<div id="caption" aria-live="polite"></div>
<div id="dvr" class="controls" style="margin: 8px 0 0">
<label for="seek-s">Seconds back</label>
<input id="seek-s" type="number" min="1" max="900" value="10" style="width: 5em">
<button id="seek" type="button" disabled>Seek back</button>
<button id="live" type="button" hidden>Back to live</button>
<span id="dvr-status" role="status" aria-live="polite"></span>
</div>
<canvas id="dvr-canvas" width="640" height="360" hidden style="margin-top: 8px" aria-label="The participant's earlier video, from the relay's window"></canvas>
</section>
<section>
<h2>Events</h2>

View file

@ -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 {

View file

@ -578,7 +578,7 @@ fn publisher_key(bytes: &[u8; 32]) -> MoqResult<VerifyingKey> {
/// 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()));

View file

@ -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.

View file

@ -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

View file

@ -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:

View file

@ -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`

View file

@ -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:

View file

@ -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

View file

@ -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))),
]

View file

@ -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 <live-conformance/rooms>
@ -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

View file

@ -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

View file

@ -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",

View file

@ -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.

View file

@ -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"
}
]
}

View file

@ -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))

View file

@ -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<u8, Reason> {
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<Vec<LedgerEntry>, 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<DvrFigures, Reason> {
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<Item = (u64, u64)>) -> 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<DvrFigures>,
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<u8> {
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<HopRecord, Reason> {
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<SessionReceipt, Reason> {
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<Consumption>,
}
/// 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<LedgerEntry>,
}
@ -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<u8> = 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

View file

@ -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<Vector> {
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<Vector> {
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<String, Value> {
let mut m = Map::new();
match o {
@ -1330,7 +1492,7 @@ fn outcome_json(o: &SessionOutcome) -> Map<String, Value> {
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());

View file

@ -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<events::Reason> for Reason {
@ -172,7 +179,7 @@ impl From<events::Reason> 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 {

View file

@ -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<Vector> {
let mut out = Vec::new();
let (b, _) = lesson();
@ -507,6 +534,12 @@ fn good_vectors() -> Vec<Vector> {
&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<Vector> {
&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<Vector> {
&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::<Vec<_>>()}))
.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::<Vec<_>>(),
})
})
.collect(),
)
}
@ -1287,6 +1346,87 @@ fn proof_outcome(v: &Value) -> Result<Result<(), Reason>, 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<SeekCase> {
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<Result<(), Reason>, 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<u64> = v["start"].as_array().ok_or("start")?.iter().map(Value::as_u64).collect::<Option<_>>().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<Vector> {
let mut v = good_vectors();
@ -1299,6 +1439,10 @@ fn vectors() -> Vec<Vector> {
/// 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<Value> = 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::<Vec<_>>(),
"vectors": vectors().iter().map(vector_json).collect::<Vec<_>>(),
"proofs": proof_vectors().iter().map(proof_json).collect::<Vec<_>>(),
"seek": seek,
});
serde_json::to_string_pretty(&doc).expect("the corpus serialises") + "\n"
}
@ -1369,5 +1514,30 @@ pub fn verify(doc: &str) -> Vec<String> {
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
}

View file

@ -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<_>>(), 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.

View file

@ -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.