# Changelog All notable changes to the Joule Code standard are recorded here. The format follows the spirit of Keep a Changelog; the standard versions independently of its siblings (tags `joule-code-vX.Y.Z`). ## [Unreleased] ### Added — `joule-moq`: MoQ objects in files and V3C tracks; `joule-moq-room`: playout timing Three mappings of MoQ drafts, each a library module with a corpus under `wai/moqt-conformance/` and a second implementation in Python there. None changes a byte a session sends: nothing here is wired into the relay, the room service's listeners or the browser client. - **`joule_moq::file`** (`draft-jennings-moq-file-05`; WAI live-recording §8.7). A track's objects as a `.moq` file, one JSON member an object, beside a `.dat` file of their payloads, and read back byte for byte: `FileObject`, `export`, `import`, `write_dir`, `read_dir`, `read_file`, `file_stem`, `check_data_path`, `FileRefusal` (ten codes, `moq_file_*`). The draft is written against MOQT -14; the module writes -23's objects (and -22's, -18's and -16's, which carry the same): Location, Subgroup ID or datagram, status, Publisher Priority and every Property as an `ext` member, Immutable Properties as one. A media object's receipt and `WAI_RECEIPT` property are among its Properties and a group's receipts are an object of the receipts track, so a directory that holds both verifies from the files alone (`file::corpus::check_receipts`). A reader opens no file outside the directory it was given, by name or through a link, and refuses a data file shorter than its objects. An object that repeats a Property type has no file form and is refused on writing. - **`joule_moq::v3c`** (`draft-yuyou-moq-v3c-00`; WAI moq-streaming-format §4.5). The payload of a `wai.pc.vpcc` object, a V3C sample stream, split into the draft's component tracks (`split`, `Projection`, `track_entry`, `catalog`) and joined back byte for byte (`join`), with the catalog member `wai_v3c`; `V3cRefusal` (eight codes). Unit headers are RFC 10034 §4.3.1's with the draft's packed-video layout. ISO/IEC 23090-5 was not read, the corpus's unit payloads are opaque test bytes, and unit types 7 and above (basemesh, displacement) are refused: no public text lays their headers out. - **`joule_moq_room::tempo`** (`draft-nandakumar-moq-tempo-00`; WAI co-watch §15). The draft's CaptureTimestamp, PlayoutDelay and HopTime properties read and written (`Timing`), short timestamps, time-aligned groups and the catalog member `wai_tempo` (`CatalogTempo`); the instant a source's object is due under co-watch's controlled position (`due`), the stamp that states it (`stamp`) and a claim held to it and to the track's bound on a delay (`check_claim`, `Stated`); a change of delay as the controls that make it (`delay_change_as_controls`, `DelayAdvice`); a sink's target in the draft's three scenarios (`playout_target`, `OneWay`, `HopCheck`); `Reason` (ten codes). The draft's property types are unassigned: `tempo::prop` holds placeholders in the application range (`0x3A71`, `0x3A73`, `0x3A75`), which a peer using other values does not read. - **Not done.** `joule-moq-relay` does not rewrite HopTime or read the draft's Sync Support parameter, and forwards the three properties as ones it does not know. No publisher, relay or page sends V3C tracks, FEC tracks or stamped objects on a session. - **The catalog check** (`wai/ietf/catalog_check.py`) reads two more writers' samples here (`joule-moq/tests/catalogs/v3c.json`, `joule-moq-room/tests/catalogs/tempo.json`) and holds `wai_tempo`, `wai_v3c`, `fec` and the packaging values `v3c`, `fec-repair` and `fec-source` to their rules. ### Changed — the relay reads the client's SETUP first (Privacy Pass auth -04), and what the draft-23 package left - **SETUP order** (`draft-ietf-moq-privacy-pass-auth-04` §3.5.1; WAI live-rooms §10.5.2). `joule-moq-relay` reads and processes the client's SETUP before it sends its own, at every draft; it sent its own first. Every SETUP it sends carries a fresh `GRANT_CHALLENGE`, kept (`src/challenge.rs`, `Relay::issue_challenge`, `Relay::check_proof`) for the proofs of one later SETUP at this relay within `limits.grant_challenge_ms` (60 s), so a client's SETUP proves over a challenge an earlier session gave. A SETUP refused as not authorised is answered with the relay's SETUP carrying `GRANT_REFUSED` (`0x3A53`, the reason's code; `joule-moq`'s `grant_proof::{SETUP_OPTION_GRANT_REFUSED, add_refusal, refusal_of, refusal_asks_for_proof, add_grant_unproved}`) and the challenge; the session is closed `UNAUTHORIZED` once the client has left or `limits.setup_refusal_wait_ms` (2 s) has passed. A client that holds no challenge presents its grant unproved, is refused `pop_missing`, and proves on a second session; a consumed challenge is `pop_replayed`, one never issued or too old `pop_wrong_challenge`. - **The clients.** `joule_moq_relay::client`: `Client::open` (its refusal typed, `OpenError::Refused`), `Client::connect_proving` (the second session), `Client::challenge`, `ClientOptions::{challenge, ignore_refusal}`; `Client::connect` sends its SETUP at once in every case. The room service's clock proves its room keys so. The browser client (`joule-moq-web`): `MoqSession.connect` takes `redial` and opens the second session, `SetupRefused` carries the reason and the challenge, `grantRefusedOption()`. The interop client and the ingest gateway already sent their SETUP first and present no room grant. A link between relays (`cascade`) proves its relay id the same way: its first SETUP is refused `relay_id_unproved` and given a challenge, and it dials again at once. - **Not interoperable with the order before.** A client that waits for the relay's SETUP is sent none and is closed `CONTROL_MESSAGE_TIMEOUT`; a relay of the earlier order holds no challenge of an earlier session. A relay, the clients that present grants to it, and the relays it peers with change together. Sessions that present no grant are unaffected at every draft. - **-23's object rules on -23 sessions.** A -23 session's subgroup streams are read with `draft22::data::UniStreamDecoder::objects_at_23` (`wire::UniReader::at`): a supported property outside its scope or repeated makes the track malformed, and a grease value in the Mandatory Track Property range is no Mandatory Track Property. A -22 session keeps -22's reading. - **Relays peer at -23.** `RELAY_PATH` is read and written by the -23 codec (`draft23::params::Parameter::RelayPath`, in the messages -22 allows it in) and bridged both ways; a link offers -23 and -22 and the upstream chooses (`websocket::connect_any`, the QUIC client endpoint's ALPNs, WebTransport trying -23 then -22); a relay id is taken at -23 as at -22. An unnegotiated path closes a -23 session `INVALID_PARAMETER`. The -23 corpus gains two messages that carry it and four refused (125 vectors, 70 negative), and `moqt23_check.py` reads it. - **A grant that expires** ends its subscriptions and not the session on a -23 session, each with PUBLISH_DONE `EXPIRED_AUTH_TOKEN` (`0x7`; `Relay::grant_expired`, `relay::EXPIRED_AUTH_TOKEN_DONE`), the session's threshold in the room falling to 0 and a later request by the grant refused. A -23 session that publishes by the grant, and every session at -22, -18 and -16, is closed `EXPIRED_AUTH_TOKEN` as before. - **LARGEST_OBJECT** against -23 §7.5: the relay's is the largest of what its upstream publisher stated and the objects it received on its (unpaused, unfiltered) upstream subscriptions. One source was missing and is taken now: a publisher's PUBLISH_STATE_NOTIFY (`Relay::stated_largest`). Kept, and stated: an object counts once whole, not from its first byte. - **Tests.** `grant_proof` (rewritten: the order itself, the second session, seven planted refusals), `room_authority` (expiry at -23 and -22), `draft23` (the object rules against the same objects at -22; the LARGEST_OBJECT vector), `cascade` (a relay id proved on its second session; the path at -23), `control_sessions` (a link at -23, opened on its second dial), `src/challenge.rs`. ### Added — `joule-moq-relay`: the open sessions stated, one request from a shell, and the open window kept on disk - **`sessions`** on the operator's surface (`Relay::sessions_statement`, `src/sessions.rs`): the sessions open now, by room, signed with the summary key under `joule-moq-relay/sessions/1`: each session's id, the draft it speaks, its transport, when it opened, its peer relay where it is a hop link, and `sealed`, whether the room's objects are sealed (known by the room's first object that carries the secure-objects Key ID, as the DVR store knows it). A session that holds nothing of any room is listed under `other`; with `room`, one room's sessions alone. - **`wai-relay --control ''`**: one request to a running relay's surface, its answer printed, exit 0 on `"ok": true`, 1 on a refusal, 2 for a request it would not send, 3 where the surface did not answer (`control::ask`). The socket keeps its mode and its check of the peer. An operator on an edge's own host closes and seals its window with it. - **`control.checkpoint_path`, `control.checkpoint_interval_ms`** (`src/checkpoint.rs`, `Relay::checkpoint`): the open window's counts written whole and signed at the interval, at each seal and at a clean stop, the last sealed window beside them at `.sealed`, both read at start. A relay killed outright loses at most the interval and the time of one write of its open sessions' counts. The resumed window keeps its index and its start, holds the killed sessions' parts, and its statement carries `resumed` (`window::Resumed`): the restart, the checkpoint's instant, and the span between as `unmetered`, never zero; its record carries the unmetered marker. Refused, each with its reason and no count read: `checkpoint_truncated`, `checkpoint_other_relay`, `checkpoint_bad_signature`, `checkpoint_stale` (of a window sealed since, whose counts would be billed twice), `sealed_unreadable`, `checkpoint_missing`. A checkpoint needs `summary.key_path`. New series: `wai_relay_checkpoints_written_total`, `wai_relay_checkpoints_failed_total`, `wai_relay_window_resumed`. - **Tests.** `tests/control_sessions.rs` and `tests/checkpoint.rs`, the second killing the `wai-relay` program itself with SIGKILL (bound to port 0, its port read from its log, under a guard that kills and reaps it). Planted faults, each caught: 4 of the statement of sessions, 4 of the program, the stale checkpoint put back after a seal, and a truncated checkpoint, another relay's and a changed one. Falsified: ten of the changes reverted in turn, each failing a test. - **For a caller**: `relay::Window` and `window::WindowStatement` gain `resumed`; `control::OPS` gains `sessions`; `ControlConfig` gains the two fields; `summary::Clock::resumed`. ### Changed — `joule-moq`: LOC objects follow `draft-ietf-moq-loc-05`, with its Codec String - **`joule-moq`**: module `loc` reads `draft-ietf-moq-loc-05` (2 November 2026). `prop::CODEC_STRING` (`0x11`) is LOC §2.3.1.3's Codec String, the WebCodecs codec string of the payload's codec, and `LocProperties` has the field `codec_string` (`Option`): `to_loc` writes it in the Immutable Properties where the caller states one, and never derives it from the payload; `from_loc` reads it at most once across both property lists, as UTF-8 and not empty, and refuses another with `loc_property_invalid` (WAI loc-carriage §2.2). A struct literal of `LocProperties` adds the field or `..Default::default()`. - **The corpus.** `examples/loc_vectors.rs` writes WAI's `live-conformance/loc/` with an AVC object that states a Codec String and three objects the reader refuses for it, and states `codec_string` in each object's `loc` and in `property_types`; `tests/loc_vectors.rs` regenerates and checks it, and WAI's `loc_verify.py` reads the same rule. ### Added — MOQT draft-23 in joule-moq, its relay, its clients, the ingest gateway and the browser - **`joule-moq`: `draft-ietf-moq-transport-23`** (2 November 2026), module `draft23`, beside -22, -18 and -16; `Draft::D23` (ALPN `moqt-23`) is offered and chosen first. -22 stays the model: `draft23::bridge` maps every message and fetch entry to and from it, naming what one draft cannot say. What -23 changed (its §A.1), each implemented: a varint control-message Length bounded by MAX_CONTROL_MESSAGE_SIZE (Setup Option `0x09`, read as bytes holding one varint, its type being odd); FETCH answered by REQUEST_OK with LARGEST_OBJECT, `0x18` reserved; REQUEST_ERROR's Error Details (REDIRECT, UNSUPPORTED_PROPERTY); NAMESPACE's parameters; PAUSED (`0x11`) in place of FORWARD; SUBSCRIBE_TRACKS_ID; a counted FILL_PARAMETERS, also in SUBSCRIBE_TRACKS and PUBLISH; each message's parameter list; non-empty Track Names; GOAWAY on a request stream from its receiver only; fetch streams with Object ID Delta + 2, delta-coded End of Range, End of Gone Range, End of Group and End of Track; properties outside their scope or repeated make a track malformed, and grease values are not Mandatory Track Properties; the session codes of §12.2 in place of PROTOCOL_VIOLATION (`draft23::error`, by plane), UNAVAILABLE, EXPIRED_AUTH_TOKEN for PUBLISH_DONE. The README tables every change and how the bridge carries it, and the readings taken where -23 leaves a point open. - **The -23 corpus.** `tests/vectors/draft-23/vectors.json` (`tests/draft23_vectors.rs`, 119 vectors) and `moqt23_check.py`, a second decoder written from -23's text, which joule-code-conformance runs. Its 66 negative vectors are planted faults, each refused with the session code -23 gives it, among them each -22 form a -23 endpoint meets (FETCH_OK, FORWARD, a 16-bit Length, a length-prefixed FILL_PARAMETERS, NAMESPACE without its count). - **`joule-moq-relay`** speaks -23 over native QUIC, WebTransport and the WebSocket binding (which now carries `moqt-23` and `moqt-22`), through `transport::bridge::Bridge23`: control and request streams translated message by message, fetch streams re-encoded both ways, a FETCH's range kept with its stream for FETCH_OK, a refusal closing the session with -23's code. A -23 session may hold overlapping namespace subscriptions; a -23 requester's GOAWAY on its own request stream closes its session (PROTOCOL_VIOLATION). Each session summary's `draft` is now the draft the session spoke (it said `moqt-22` for every session). `tests/draft23.rs`. - **`joule-moq-interop`** speaks -23 (`--draft 23`); `scripts/local.sh` runs every draft over QUIC and WebTransport and across drafts, binds the relay to a port the system chooses, refuses to run without its binaries, and counts a tampered run as refused only on the client's own verdict. - **`joule-moq-ingest`**: `[relay] draft` (23, 22, 18 or 16; 22 by default) is the draft the gateway's sessions with the relay offer; `tests/ertmp.rs` publishes at -23, -18 and -16. - **`joule-moq-web`**: `setDraft` (22 or 23); the page's transports offer the one draft the page names (`moqt=23`), and the wasm codec then speaks it, reading and writing -22's JSON view at either draft; `MessageStream.forRequest` lets a FETCH's REQUEST_OK read as FETCH_OK. The browser end-to-end run has the room scenario at -23 over WebTransport and over the binding (`webtransport23`, `websocket23`). - **Lints.** `joule-moq` and `joule-moq-relay` are clean under `cargo clippy --all-targets -D warnings` with the pinned toolchain (collapsible `if`s, a type alias, `is_multiple_of`, the `next` methods that are not iterators named so). ### Fixed — the MoQ catalogs of joule-moq-ingest, joule-moq-remote, joule-moq-interop and joule-moq-web are MSF's - **The version** (MSF §5.1.1). Each wrote the catalog's `version` as the number `1`; each writes `"draft-01"`, draft-ietf-moq-msf-01's. The gateway's WHEP session (`catalog::read`, `catalog::version_known`) and the room page (`web/room/catalog.js`, `catalogVersionKnown`) read a catalog of `"draft-01"` or of the number `1` (which the room deployed at wai.transaction.science/room/ writes until it is deployed again) and no other; the interop client's subscriber requires `"draft-01"`, which its publisher writes. - **Event timelines** (MSF §8.2). The remote host's and the room page's events tracks write `mimeType` (they wrote `mimetype`, which MSF does not read) and `depends` `[]`. - **`wai-link`**, the contribution gateway's link track, is now a defined packaging value (WAI live-contribution §5.4; draft-charlot-moq-wai-packaging-01 §9). The gateway writes it as before. - **Samples the catalog check reads.** Each writer's test holds a committed sample of its catalog (`joule-moq-ingest/tests/catalogs/{whip,contribution}.json`, `joule-moq-remote/tests/catalogs/host.json`, `joule-moq-interop/tests/catalogs/wai-receipts.json`, `joule-moq-web/e2e/catalogs/room.json`; `JOULE_MOQ_*_WRITE_CATALOGS=1` writes them), and `wai/ietf/catalog_check.py --strict`, in the repository checks, holds them to MSF and the MSF packaging draft. The room page builds its catalog in `web/room/catalog.js`, tested by `web/room/catalog.test.mjs` with the page's other unit tests. ### Added — joule-moq-relay closes a window at one cut across relays, hands over its counts by session, and drains its summaries - **One cut across relays** (WAI live-metering §3.8, §3.10). A window ends, track by track, at a cut: the first group of the window after it. `close` on the operator's surface closes the window and answers the relay's own cut (`Relay::flush`, `Relay::cut`); the operator joins the cuts of the relays that carry a room (`window::Cut::join`, the greatest by track) and each `seal`s at the join (`Relay::seal_at`, `Relay::seal_after`), refusing a cut below its own (`cut_too_early`). A group first seen while a closed window waits for its cut is counted under a provisional window, placed at the seal with every count of it. Every relay then counts each group in the same window, their records of each link chain window by window, and an edge rides through a window's end with no GOAWAY to its viewers. `flush` answers the cut it sealed at, and `cut_sha256`. - **A window's counts by session.** `window` (`Relay::window_statement`) answers a sealed window's parts, its cut and its DVR writes, signed with the relay's summary key (`joule-moq-relay/window/1`, over `joule-moq-relay:window\x01`), optionally of one room. `SignedWindow::check_record` holds a room's record over the window to the parts it recomputes from (`metering::counts_over`), refusing parts that do not give it (`parts_mismatch`), and `window::link_chain` holds two relays' statements of their link to each other. - **Summaries drained, and bounded** (`src/outbox.rs`). Each ended session's signed summary is held, numbered, until the operator acknowledges it (`summaries`, `ack`; `Relay::drain_summaries`, `Relay::ack_summaries`). At most `summary.held_max` (65536) in memory; past it they are appended to `summary.spill_path`, synced, read back by the drain and handed over again after a restart unless acknowledged (`.acked`); with no spill file, or a write that fails, each is held all the same and new sessions are refused `summaries_unacknowledged` until enough is acknowledged. `/metrics` adds `wai_relay_summaries_held`, `_spilled`, `_spill_failed_total`, `_unreadable` and `_refusing`. - **Changed:** a relay past 65536 unacknowledged summaries with no spill file refuses new sessions; `Relay::summaries` is the summaries not yet acknowledged; `relay::Window` has a `cut`; `Track::window_of` takes the relay's clock; a group arriving after a flush below its cut counts in the window the flush closed. - **`tests/window_cuts.rs`** holds each against a planted fault: parts raised, left out, changed or of another window refused against the record; a core and two edges sealed at one cut chain exactly over their links, an edge sealed at its own cut caught in both windows, a cut below the core's own refused; an edge's viewer sent no GOAWAY across two window ends, a restarted edge's GOAWAY caught; an acknowledged summary freed, an acknowledgement of what was not handed out refused, every summary past the bound held and new sessions refused, and the spill file read back across a restart with nothing acknowledged handed over twice. ### Added — the browser sink page as a WAI conformance record - **`joule-moq-web/web/binding/sink.html`** answers each call of WAI's browser-sink corpus (`wai/live-conformance/browser`) in the page: the normative parts by `wai-live-web`'s wasm, the side table by the page's own `web/room/binding.js`, each answer one JSON text in RFC 8785 form. **`e2e/conform_sink.mjs`** drives it in a browser and is `wai conform sink --suite browser`'s HTTP adapter for it; with `--wai` it runs the conformance runner itself, signs the record (named for the page and the browser's version) and admits it with `wai verify --conformant`. `--plant` turns every frame the page shows as refused into one shown as verified: that run must fail at `frames/object_refused`, and its record be refused at admission rule 2. `e2e/run.sh` runs both as its `conform` step, in the browser the other scenarios use. ### Added — the interop client as a relay's conformance runner, and its sessions closed before it exits - **`joule-moq-interop`** is the client `wai conform relay --suite moqt` holds a relay to the interop cases with: one run a case at the draft given, its TAP version 14 read as the record's cases. Its test names are the corpus's (`wai/moqt-conformance/cases.json`), in its order. - **Fixed: `joule-moq-interop` closes every session before its next test and before it exits.** A test closed its sessions, but the close was a CONNECTION_CLOSE queued for the QUIC endpoint's driver, and a run of one test (`--test`, as the interop runner and `wai conform` run it) exited before the driver sent it. The relay kept the session until its idle timeout, with the namespace it had published, and the next run's `subscribe-before-announce` and `announce-subscribe` reached that dead publisher: against `wai-relay` they failed at drafts 22, 18 and 16 run a test a process. Each test's endpoints are now closed and waited idle (a WebTransport session's close is given 250 ms), and every case passes at each draft over QUIC and WebTransport. ### Security — `joule-moq-ingest` takes str0m 0.25.0: its WebRTC stack holds no crypto-bigint 0.5 - **str0m `=0.24.1` → `=0.25.0`** (with `str0m-rust-crypto` 0.7, `str0m-proto` 0.8 and `dimpl` 0.9). 0.24.1's DTLS ran on p256 and p384 0.13 over `elliptic-curve` 0.13, which holds `crypto-bigint` at 0.5.5 (RUSTSEC-2026-0387: secret-dependent branches in operations meant to be constant-time), and no 0.5 release is patched. 0.25.0's run on p256 and p384 0.14 over `elliptic-curve` 0.14 and `crypto-bigint` 0.7.10, so `cargo tree -p joule-moq-ingest -i crypto-bigint@0.5.5` matches nothing. The 0.5.5 copy left in this workspace's lock and in `joule-moq-room`'s comes through openmls (`joule-moq-mls`, and `joule-code-collab`'s tests), not the gateway. - The gateway's code is unchanged: none of 0.25.0's breaking changes (the bandwidth estimator's `set_bitrate`, the removed SDP `merge()` and video reordering timeout, VP9 picture ids of 64 bits) reaches an API it calls. Every crate new to the lockfiles is MIT or Apache-2.0. - The README's WebRTC section said the stack links no C library. It builds one, aws-lc, before and after this change: `dimpl`'s certificate feature makes each session's DTLS certificate with `rcgen` on `aws-lc-rs`. It now says so. ### Fixed — a start refused part way, and a task ended past the shutdown's bound, leave no port bound: `joule-moq-ingest`, `joule-moq-relay`, `joule-contrib` - **`GatewayServer::start`** bound HTTP, spawned the HTTP and SRT accept loops, then bound RTMP, RTMPS and the injector's port. Refused at any of those (the port held, the RTMPS identity, the injector's grant), its `?` dropped the loops' handles, which leaves the tasks running: the SRT loop waits for a shutdown that never comes, so the HTTP and SRT ports stayed bound for the life of the process and every later start failed "Address already in use". It now binds every port and makes every check before it spawns anything, and closes the SRT listener (whose task runs from its bind) when the injector, last, is refused. - **`RelayServer::start`** spawned each QUIC endpoint's accept loop before binding the WebSocket listener, and the control surface after; refused at either, it left the endpoint and its UDP socket running (a WebSocket refusal) or told the tasks to go without waiting (a control refusal). Each retry of a start that waits out a port another process holds then failed on the UDP port its own first try kept. A refused start is now shut down as a relay is before its error returns. **`Control::start`** refused part way (the socket path) closes the TCP listener it had started. - **A session still dialing the relay at a gateway's shutdown** was in no list the shutdown ends, and a dial lasts up to 10 s, past the 5 s the shutdown waits for its tasks: it was ended without its record. The dial now ends at the stop, and the session ends at once and signs its record, `gateway_stopped`, with nothing published (`Publisher::detached`). - **A RIST listener held by a task the shutdown ends past its bound** was dropped unclosed, which only tells its receiver to go: the shutdown returned with its port bound. `rist::RistCloser` (from `RistListener::closer`) closes a listener from outside the task that holds it; the gateway keeps one for each RIST listener its tasks hold (`Gateway::hold_rist`) and closes them once its tasks have ended. `contrib::serve_rist` serves a RIST session at its listener, held so; `/rist` uses it. - New `tests/start_ports.rs` (gateway and relay) hold each refused start from a task on a runtime of one worker; two new tests in the gateway's `tests/shutdown_ports.rs` hold the dialing session and the held listener, and one in `joule-contrib`'s `tests/listener_close.rs` the closer. Each failed before. - These tests, and the rest of `listener_close`, take fixed ports below the range a system hands out for port 0. A port first handed out for port 0 can go to any socket on the host the moment a close lets it go: on a shared Linux host, 5 of 1000 runs of `listener_close` failed so, the unchanged SRT check among them. ### Fixed — a shutdown returns with its ports free: `joule-moq-ingest`, `joule-moq-relay`, `joule-contrib` - **`GatewayServer::shutdown`** aborted its accept tasks (HTTP, RTMP, RTMPS, SRT) and never awaited them, and left its connections and RIST sessions running, so it returned with its ports still bound: a gateway started again at once on the same ports, from a spawned task, failed every time with "Address already in use". It now stops listening, ends every session (each still signs its record, ended `deleted`), and returns once every task it ran has ended: every listener closed (the injector's too), every connection it accepted, each RIST session's ports, waiting or running. An RTMP connection mid-handshake hears the stop; a session that starts once the shutdown has begun ends at once, `gateway_stopped`. `Gateway::stopping`, `Gateway::stopped` and `Gateway::spawn` (a task the shutdown waits for) are new. - **`RelayServer::shutdown`** now returns once every session's task has ended, its summary signed, and once its UDP sockets (each of a group), the WebSocket listener and the control listener are closed. `Control::close` is new; `joule_moq_relay::tasks::Tasks` holds the tasks a server waits for, and the gateway uses it too. - **`srt::Listener::close`** and **`rist::RistListener::close`** return once the listener's sockets are closed (an SRT listener's connection tasks, which hold its socket, ended first, each with its SHUTDOWN sent). A drop still only tells the task to go. - `tests/shutdown_ports.rs` (gateway and relay) and `joule-contrib`'s `tests/listener_close.rs` hold each from a task on a runtime of one worker, many times over; each failed at its first round before. ### Added — `joule-moq-relay`: grants that change while it runs, and records over windows of open sessions - **Grants while it runs.** The rules (`[auth]`), the room authorities and the metered grants (`[[metering.grants]]`) change while the relay runs: on the operator's surface a new `[control]` section names (a Unix socket made mode 0600, or a TCP address on loopback, one JSON request a line: `show`, `set`, `grant`, `withdraw`, `authority`, `drop_authority`, `revoke`, `meter`, `reload`, `flush`, `record`), from a grants file read at start and again on SIGHUP, and through the library (`Relay::set_grants` and its kin). A change is checked whole and put in force at once. A session that presented a grant the change withdraws or revokes, or that holds a subscription or a publication its tokens no longer give it, is closed `UNAUTHORIZED`, its reason beginning `grant_revoked`; every other session goes on, in every room, with no GOAWAY. - **Revocation lists** (WAI live-rooms §5). `[[auth.revocations]]` holds an authority's revoked grant digests and viewer tags. A grant either names is refused at SETUP before its proof is read, and in any request that presents it; a revocation given while the relay runs ends the sessions that presented the grant. A list only grows, and `control.revocations_path` keeps it across a restart. - **An authority's grants metered from the grant itself.** `joule-moq`'s `GrantHead` reads a grant's `viewer`, `joules_ceiling`, `funds_ceiling` and `currency`, with `viewer_tag`, `currency_holds` and `currency_code`, and `grant_room` reads a record's room. A session that proved an authority's grant runs under it with no metered grant configured, and a session holding grants for several rooms runs in each under that room's (`room_grants` in its summary). A grant whose funds ceiling and currency break live-rooms §4.3 is refused, `grant_invalid`. - **Windows of open sessions** (WAI live-metering §3.8). `Relay::flush` closes a window at an instant with no session ended, each track cut at a group boundary; `Relay::seal` takes every session's counts in it a grace later; `Relay::window_record`, and the surface's `record`, sign a room's per-hop record over a sealed window with the relay's own key and meter. A session's windows sum to its summary. `control.flush_interval_ms` has the relay flush and seal by itself. - **A joule ceiling held at the hop** (WAI live-metering §4.6). With `control.hold_joule_ceilings` the relay, at each seal, shares its own figure for a room it served alone in the window among that room's grants, by the bytes each moved, and ends the sessions of a grant whose shares pass its `joules_ceiling`, refusing its next SETUP. A funds ceiling is held by the meter that prices the room's records: the relay holds no tariff, states each grant's funds ceiling and currency in the summary and the ledger, and ends a use when the meter withdraws or revokes it. - **`tests/live_control.rs`** holds each of these on loopback, and reads a record signed over open sessions with WAI's `live-conformance/live_metering_verify.py`. ### Added — `wai-relay --version` and `wai-room --version` - Each prints its own crate's version and the WAI toolchain it belongs to, the version of `wai/wai-rs/Cargo.toml` in the tree it was built from, and with `--json` the same as an envelope. `wai doctor` reads that to tell whether these programs and a `wai` agree. `tests/version.rs` in each crate holds both forms. Neither form starts a server. The program's other command lines are as they were. ### Changed — `joule-moq-room`: the example's `relay_url` is the relay's WebSocket port, 4480 - **`room.example.toml`** named the relay at `ws://127.0.0.1:9681/moq`, a port nothing listens on: the relay's example, and `wai-relay --dev`, listen for WebSocket on `127.0.0.1:4480`. The two examples now connect as shipped. `relay_url` has no default in code, so a deployment's own configuration is unchanged. - **`tests/ports.rs`** holds the local stack's ports in one place: the room example's `relay_url` is the relay example's WebSocket endpoint and path, the ingest gateway example's `[relay] addr` is its QUIC endpoint and each listener it names is a row (off until set where it is commented out), the relay example's endpoints are `wai-relay --dev`'s, `wai dev`'s origin is the one the remote desktop template's host reaches, and the defaults of the audit collector, the ledger, the fleet service, the quantum job service, the worlds' relay, the stitched presentation verifier and `wai http origin` are their sources'; each is a row of the port table in WAI's quickstart README, and no port is two programs'. The room service started from its example opens its clock's session on the relay started from the relay's. - **The gateway's example is read by the gateway's own reader** (`GatewayConfig::from_toml`; `joule-moq-ingest` is a dev-dependency of `joule-moq-room`), not as a TOML table matched by text: its relay address and each listener's come from what the gateway reads, a commented-out listener is one it reads as off and, uncommented, at its row's port, and a key the gateway renamed or stopped reading fails the test. The gateway started from its example, its ports moved to free ones and the files it names made, serves HTTP, opens RTMP, and opens none of the listeners the example leaves off. ### Changed — `joule-moq-ingest`: the contribution link record's magic is `WLK1` - **Wire change.** `linkrec::MAGIC` is `WLK1` (WAI `extensions/live-contribution.md` §6.2), so the link record no longer shares `WCL1` with WAI's clip record (live-recording §5.1), which had it first. A part written before this change is refused as `malformed`; nothing else in the record changes. `joule-moq-ingest-link-vectors` writes `.wlk1` files, and adds `clip_record_magic`, a part bearing the clip record's magic, refused `malformed`. ### Changed — `joule-moq-relay`: one object held once, and a socket that keeps what arrives - **`limits.quic_socket_buffer_bytes`** (4 MiB by default, and for the links a relay dials too): the size asked of the kernel for the UDP socket's buffers. Each fan-out's acknowledgements arrive together, and past the buffer the kernel drops them, which reads as loss. The system caps the size (`net.core.rmem_max` on Linux), and the relay logs a warning when it gets less. - **An object is held once**, from when it is read until the last subscriber's QUIC stream has it acknowledged: the cache, each session's queue and each QUIC send buffer share one allocation (`SendStream::write_parts`, `SubgroupCodec::encode_object_head`). The fan-out no longer copies a payload per subscriber while it holds the relay's lock. A WebTransport or WebSocket session, and a session bridged to an earlier draft, still has its own copy in its transport. - **A fan-out delivers outside the relay's lock**, from a copy-on-write snapshot of its track's subscribers, so a large audience holds up no other track. - **`limits.quic_sockets`** (0, one per core up to 16; Linux, elsewhere one): UDP sockets on the listener's one port, each its own endpoint. The kernel steers each packet by the first byte of its connection ID, and each socket's endpoint issues IDs that come back to it, so a viewer whose address changes keeps its connection, which steering by address would lose. - **`examples/fanout_load.rs`**: one publisher, N subscribers of one track and a bystander on its own, reporting the relay's memory and CPU, what arrived and how late, and the packets the kernel dropped at each end. ### Added — `joule-moq-relay`: relay-to-relay cascading - **`[[upstream]]`**: relays this one dials, over native QUIC (`moqt://`), WebTransport (`https://`) or the WebSocket binding (`wss://`, or `ws://` on a trusted path), each trusting a CA file, a pinned certificate's SHA-256 or the platform's roots, presenting a token read from a file and named by its SHA-256, and routing the namespace prefixes it lists. A link redials after its session ends, backing off to 30 seconds. - **Routing**: a SUBSCRIBE, FETCH or TRACK_STATUS for a track no session of the relay publishes goes upstream once, on the first link of the configuration's order whose session is open; the subscription it makes is the relay's own, cached and fanned out to every local subscriber; a link that refuses or does not answer is passed over for the next, and when none answers the request is refused `TIMEOUT` with `no_upstream`. Namespaces published to the relay, by PUBLISH_NAMESPACE or by a track's PUBLISH, are published on every link that routes them, and a SUBSCRIBE_NAMESPACE is forwarded on every link it overlaps, the upstream's NAMESPACE and NAMESPACE_DONE merged with the relay's own. - **Loops**: both ends of a link declare the Setup Option `RELAY_ID` (`0x3A4F`, the relay's summary key), the dialer proving it over the listener's challenge; every request between relays then carries `RELAY_PATH` (`0x3A51`, negotiated so, -22 §9.20), and no relay forwards a request back where it came from, to a relay on its path, or past `limits.max_relay_hops`; one that receives its own id is refused `relay_loop`. - **GOAWAY** from an upstream moves the link's subscriptions to a session to the URI it names (else the link's own) and closes the old one; it is never passed on downstream. The relay's own GOAWAY on a session it dialed names no URI. - **WAI evidence at every hop**: the door of every relay checks what a peer forwards; each relay's per-hop record counts a link in its track entries and not as a subscriber session or in its ledger; the floor and audience inputs are decided at the relay the performer or the author publishes to, and read for their statements alone over a link; a FETCH or a fill of a track the relay knows nothing of learns its live edge upstream first, so the seek power is held at the viewer's relay. - **`joule-moq`**: `Parameter::RelayPath` and `RELAY_ID_OPTION`; the bridges refuse the parameter by name. - `tests/cascade.rs`: two relays over each transport, a chain of three with a per-hop record at each hop, a door refusing a tampered object another relay forwarded, a loop, failover and `no_upstream`, GOAWAY, a DVR window fetched through an edge, and co-performance votes and the floor across relays. ### Added — `joule-moq`: WAI envelopes on LOC objects - **`joule-moq`**: module `loc` (WAI `extensions/loc-carriage.md`). An envelope whose payload is one WebCodecs chunk (`wai.audio.opus.packet`, `wai.video.avc`, `wai.video.hevc`, `wai.audio.aac`) is written as one object of a track of packaging `loc` (`draft-ietf-moq-loc-04`): its payload as the LOC Payload, and in the Immutable Properties the LOC properties, the manifest as `WAI_MANIFEST` (`0x3A61`) and the `WAI_RECEIPT` property. `from_loc` reads an object back into the envelope, byte for byte, refusing with the extension's codes. `examples/loc_vectors.rs` writes WAI's corpus `live-conformance/loc/` and `tests/loc_vectors.rs` regenerates and checks it; WAI's `loc_verify.py` is its second implementation. ### Added — `joule-moq`: `draft-ietf-moq-transport-18` and `-16` beside `-22`, and runs against other implementations' relays - **`joule-moq`**: codec modules `draft18` and `draft16` (control messages, parameters, Key-Value-Pairs, subgroup, fetch and padding streams, datagrams), and `draft::Draft`, which chooses a session's draft before its first MOQT byte by ALPN `moqt-NN` or the `WT-Available-Protocols` value, preferring -22, then -18, then -16; -22 stays the default. `draft18::bridge` and `draft16::bridge` map every message and object to and from the -22 model and name what one draft can say and the other cannot (refused with REQUEST_ERROR, or left out and noted). The README tables each difference. Each draft has a vector corpus (`tests/vectors/draft-18`, `tests/vectors/draft-16`) that the crate regenerates and checks, and that a decoder written from that draft's text reads (`moqt18_check.py`, `moqt16_check.py`, run in CI); the drafts' own examples are among them. - **`joule-moq-relay`**: sessions at -18 and -16 run behind `transport::bridge`, which presents them to the relay as -22 sessions: at -18 stream by stream, at -16 by giving each request on the control stream a stream of its own (Request IDs renumbered, MAX_REQUEST_ID granted, cancelled streams become -16's cancel messages). The client takes a draft, and the WebTransport client a TLS configuration. `tests/drafts.rs` runs the relay's scenario at each draft and across drafts, and sessions written from the -18 and -16 messages themselves. - **`joule-moq-interop`**, new: the interoperability runner's test cases at -22, -18 or -16 against any relay, through the runner's test-client interface (TAP 14), and `wai-receipts`, which publishes a WAI track, its receipts track and a catalog through a relay and verifies every object at a second session against its receipt (inclusion, the receipts chain, the properties carried in the object), with `--tamper` to show the check refuses altered payloads, receipts or objects in flight. `scripts/public.py` runs it against the relays the runner's registry lists, `scripts/clients.sh` runs other implementations' test clients against `joule-moq-relay`, and `scripts/local.sh` runs both on loopback. The runs that reach the public internet are not part of CI; the results of 2026-10-21 are in `joule-moq`'s README. ### Fixed — `joule-moq-relay`, found by those runs - **A PUBLISH_DONE that arrives before the streams it counts** no longer cuts them: the relay waits until it has read as many of the publisher's data streams as the Stream Count says, or three seconds (-22 §9.9; the request stream and data streams are not ordered). - **A track published again after TRACK_ENDED** is a new instance: the ended one's cache, Largest Location and subgroup owners go, so the new publisher's objects are not dropped as duplicates (a DVR-kept track is kept whole). - **A publisher that closes its control stream as it leaves** (a protocol violation) loses no object or PUBLISH_DONE it sent: the session closes once its publishing has ended, or after three seconds; the end of its session ends its tracks with the PUBLISH_DONE status already read, not GOING_AWAY. - **At -18 a requester's FIN** on PUBLISH_NAMESPACE, SUBSCRIBE_NAMESPACE or SUBSCRIBE_TRACKS cancels it (-18 §6.1, §6.2), where at -22 it does not (§6.4.2.2). - **At -16 a Request ID ahead of one in flight on another stream** (SUBSCRIBE_NAMESPACE rides its own) is admitted; a repeated one still closes the session. ### Added — end-to-end encrypted rooms in the browser (L10b): `joule-moq-web`'s e2ee build, the room service's log - **`joule-moq-mls`**: a member behind a carrier that orders the room's MLS messages. `stage_create` and `stage_commit` hold the member's GroupInfo or commit until the carrier returns it (`receive` applies it then, checked as every member checks it); another's commit first, or a refusal, withdraws it (`withdraw`). Before its Welcome a principal follows the group from its handshake messages, sharing the clock's code; a Welcome for another and a GroupInfo of a group it holds are ignored. `Clock::key_package_if` keeps a KeyPackage only from a principal the room admitted. Feature `js` builds the crate for `wasm32-unknown-unknown`: randomness and the clock from the page's own APIs, for the MLS library and for its crypto provider's generator. - **`joule-moq`**: a keyring holds the locations it opened by epoch, and drops them with the epoch's keys. - **`joule-moq-web`**, feature `e2ee`, built as a second wasm, `joule_moq_web_e2ee`, under a new workspace profile `wasm` (release, link-time optimisation, one codegen unit, size): the member (`E2eeMember`: KeyPackage, lifecycle events, the log's messages in order, staged create and commit, seal and open with the reason or the epoch to wait for), the record's check, `TrackSigner.signSealed` (the receipt over the sealed bytes, no `WAI_RECEIPT`), and `UniItem.properties`. The room page in such a room checks the record, loads that build, leaves a KeyPackage, follows the room's log (`web/room/e2ee.js`), seals each frame before it is signed and published, opens each object before it is decoded, shows its epoch and roster, and offers no watching and no seeking back. `e2e/room.e2e.mjs` gains the `e2ee` scenario. - **`joule-moq-room`**: `[room] e2ee = true` gives each room a `WRE1` record and a clock that is the MLS group's external sender, and the room's log: `GET /mls`, `POST /mls/key-package`, `POST /mls/post`; `GET /room` carries the record. ### Changed — `joule-moq-relay`: a session over WebTransport closes with its code and reason - **The relay closes a WebTransport session, not the QUIC connection under it** (`draft-ietf-moq-transport-22` §6.6): a WT_CLOSE_SESSION capsule on the CONNECT stream carries the MOQT session termination code as the session's 32-bit application error code, unmapped, and the reason (`draft-ietf-webtrans-http3-16` §6); the QUIC connection closes after the peer has closed its side of that stream, or two seconds on. A page now reads the relay's code and reason over WebTransport as it does over the WebSocket binding: a grant presented without its proof, for one, is `UNAUTHORIZED` (2) with its live-rooms §10.5 reason, where the page saw only that the session was lost. - **Nothing of the session overtakes its close.** A stream the relay lets go once a WebTransport session is closing is held until the QUIC connection has closed, so no FIN or STOP_SENDING reaches the page ahead of the capsule (a page whose stream was stopped just before the close was seen to lose its renderer); a stream the relay refuses before SETUP is kept until then too. - **A peer's close is read the same way.** `closed()` over WebTransport gives the code and reason of the peer's WT_CLOSE_SESSION (`by_peer: true`), so a page's close code reaches the session's signed summary; a QUIC connection closed with the session still open gives no MOQT code (its code is HTTP/3's). - **`wtransport` 0.7.2 is vendored** (`joule-code-rs/vendor/wtransport`, MIT OR Apache-2.0, patched in for the joule-code workspace and `joule-moq-room`), because the release reads WT_CLOSE_SESSION but cannot send it. The change gives it `Connection::close_session` and `ConnectionError::SessionClosed`, and makes `closed()` report the session's end; `VENDORED.md` lists every change and why. Its unit tests, the capsule's bytes against the draft's layout among them, run in the relay's CI job. - **Tests.** `tests/webtransport_close.rs`: the relay refusing a grant without its proof, refusing a stream that breaks a rule before SETUP (with no STOP_SENDING ahead of the close), refusing a session past its session count, and ending one at its time limit, each read by the client as the exact code and reason; and a client's own close, recorded as the peer's in the relay's summary. The browser end-to-end check of a bare grant now requires code 2 and the reason over WebTransport too. ### 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. - `joule-code-cascade` ships a reference **L0 (Cache)** stage: a content-addressed cache of resolved coding operations keyed by `(prior, intent)`. A hit closes the request at L0 (picojoule class) without consulting any higher tier — the first concrete cascade stage, making stop-at-first-close demonstrable in the reference library. A third party may use it or substitute its own. - `joule-code-cascade` ships a generic **`FnStage`**: any tier (L1 Lawful, L2 Embed, L3 Model) can be expressed by supplying a resolver closure, so a consumer binds the JouleClaw agent schema — or any other runtime/model — to the cascade without this crate depending on it. (A test wires an L3 model stage behind an empty L0 and confirms it closes at L3.) - `examples/l3-jouleclaw/` — a consumer **binding** showing JouleClaw's L3 (Model) tier wired into the cascade: a `jouleclaw_llm_cheap::LlmBackend` behind a `FnStage(L3)` that generates a `CodingOp` + joules-class receipt. **Three** backends share the slot: `EchoBackend` (model-free, for the unit tests); `JouleclawRuntimeBackend`, which runs a GGUF model **in-process on JouleClaw's own from-scratch tensor runtime** (`jouleclaw-runtime` over `jouleclaw-loader-gguf` and the reference/Apple kernel backends — no llama.cpp, no FFI); and `LlamaCompletionBackend`, which shells out to llama.cpp. The `run` binary generates with a local model (in-process runtime by default; `JOULE_CODE_L3_ENGINE=llama` for the shell-out), content-addresses the result as a `CodingOp`, and **meters host energy over the inference** for the receipt (honest provenance: `HwShunt` on RAPL/NVML, `Estimator` on Apple). Verified live: the llama.cpp path against a local 350M GGUF, and the in-process from-scratch runtime against `ternary-bonsai-1.7b-q2_0` (qwen3, Q2_0) — real text, a real `op_id`, the cascade closing at L3 (`tests/runtime_l3.rs`, opt-in via `--ignored`). Standalone workspace, so the standard's lib stays free of a JouleClaw dependency; `jouleclaw_dispatch::ModelBank` selects the cheapest capable backend in production. - `joule-code-collab` (Research) lands the **federated-collaboration interface** (`Collab` trait: submit / committed / membership), a **single-node reference cluster** (`LocalCluster`), and the **BFT consensus core** (`LockstepCluster`): a multi-node *lockstep deterministic simulation with BFT supermajority commit* — an op commits only when ≥`2f+1` of `n=3f+1` honest nodes independently validate it, and committed ops are `op_id`-ordered so every honest node derives a byte-identical log. Tested: commits with honest agreement, tolerates `f` Byzantine nodes (safety), loses liveness only beyond `f`, rejects non-members despite quorum, deterministic round ordering. It also adds `protected` — **FROST threshold authorization** of protected operations: a `t`-of-`n` FROST (Ed25519) aggregate signature over an op's `op_id`, verifiable against the cluster's single group key (no member holds the whole key; fewer than `t` cannot authorize). Tested: a 2-of-3 threshold authorizes and binds exactly one op_id; below threshold cannot. And **MLS group keying** (RFC 9420, via `openmls`, `tests/mls_keying.rs`): the cluster members form an MLS group and derive a shared group key with no central server — verified that members agree on the exported secret, that adding a member advances the epoch, and that a member an epoch behind derives a different (forward-secret) key. (SPEC §7, `spec/collab.md`.) - `examples/transport-moqt/` — the **MoQT transport binding**, the last of the four federation layers. A consumer binding that carries a collaboration's committed (consensus-ordered) coding operations over [`inv-moq`](../../joulesperbit/crates/inv-moq) — the MoQT (Media over QUIC Transport) protocol with its JWP receipt extension. The end-to-end test wires the whole stack in-process: `LockstepCluster` commits ops → each is published as a MoQT object → every object carries a real Ed25519/BLAKE3 **signed receipt** → a subscriber receives all ops, verifies the receipts against the publisher key, and recovers the original `SignedOp` intact; a second test shows a receipt-requiring subscriber rejecting an untrusted publisher's group (ownerless authenticity). Driven through `inv-moq`'s `MockFabric` (real signatures + BLAKE3 merkle receipts, no QUIC; the `quic` feature is the real-wire path behind the same trait surface). Standalone workspace, so the reference library stays free of a joulesperbit dependency. **The four federation layers — consensus, threshold authorization, group keying, transport — are now all implemented and tested.** (SPEC §7, `spec/collab.md`.) ### Changed - **§6 / `spec/receipt.md` §2 — a vendor counter is a hardware reading only while it is demonstrably live.** A reading from a vendor energy counter (RAPL, NVML, Apple SMC/IOReport) MAY be tagged `HwShunt`, and supply `measured_uj`, only while the counter has been observed advancing under load for the domain the figure claims (for IOReport: the CPU energy rail itself, in at least 5 sample intervals, at least 4 of every 5, over at least 300 ms of CPU burn, counting only intervals, each from one sample in hand to the next, no longer than three of the probe's 25 ms ticks: a longer one counts as nothing, neither among the intervals, nor as an advance, nor towards the 300 ms, which is the summed length of the intervals that count). A counter that is present but not live — frozen, or republished in lumps — MUST NOT produce either; the reading falls back to the estimator. A figure MUST NOT claim coverage of a domain its counter does not read. No wire or conformance-vector change. ### Added — the public room (L5c-2): `joule-moq-room`, room authorities at the relay - **`joule-moq-room`**, a room service (a workspace of its own, on `wai-rs`'s room, event and composition records): it holds a room's authority and clock keys (a 0600 file it refuses to read otherwise), signs short-lived `WRG1` grants for keys pages make (`POST /grant`, a publisher's or a viewer's, bounded per client address, overall and per room), authors `join`, `publish` and `leave` on the clock's events track from what the relay carries (its clock watches each publisher's video track), and serves the signed descriptor and grants (`GET /room`). A new room starts when the last publisher has left. - **`joule-moq-relay`**: `[[auth.authorities]]` honours every grant an authority's key signs, beside its proof, within its window, mapped as live-rooms §10.2 maps it (`joule-moq`'s `GrantHead` reads it); a field `*` in a rule's prefix; a door rule's `trust_field`; `limits.max_session_ms`, `max_ingress_kbps` and `max_resident_mb`; a rule's `unlimited`; a session ends when its authority grant expires; SIGTERM stops it as Ctrl-C does. - **`joule-moq-relay` fixes found on the deployed room**: the WebSocket binding's multiplexer dropped the half of a stream that arrived after its session closed while it held its state's lock, which that half's Drop takes, and the relay's runtime deadlocked (any session the relay closed while its peer still opened streams could do it); and a FETCH not yet covered only because groups are still arriving now waits for them, briefly, rather than going to a publisher that takes no requests. - **`joule-moq-web`**: the room page's service mode (its own key, a grant from the service, the WebSocket binding, Join, Watch, Leave and a typed caption), `Room.setGrants` in `wai-live-web`, `grantProofBy` and `clientSetupWithProof` for a test's proof by another key, and the `service` end-to-end scenario, which also runs against a deployment (`--base`). ### Changed — `joule-moq`, `joule-moq-relay` and `joule-moq-web`: L5 hardening - **`ReceiptChains`: a join's fill from group 0.** A group fetched into the gap below a join's anchor, with a `null` parent, is held as history and admitted when the group above it links back to it (WAI jwp-receipts §3 step 3), where it was refused `chain_break`. A `null` parent outside that gap, and a fork, are still `chain_break`. - **Receipts in WAI's binary form (jwp-receipts §2.7).** `GroupReceipt::to_binary` / `from_binary` (the canonical bytes, and a legacy group's parent), `ObjectReceipt:: to_binary` / `from_binary` / `body_binary`, `read_receipts`, `link_hash_of`, `Malformed`, `RECEIPTS_MAGIC`. `ReceiptBundle::to_payload` and `from_payload` keep their signatures and now write and read `WJR1`; `ReceiptBundle::read` also gives the link hash over the bytes received. The `JWP_OBJECT_RECEIPT` property carries an object receipt standing alone in that form. The JSON payload is not read any more. Serde derives on the receipt types stay, for callers' own use; nothing on the wire uses them. - **Proof of possession of a room grant (WAI live-rooms §10.5).** `joule-moq::grant_proof`: the `WRP1` record (`GrantProof::sign`, `read`, `to_bytes`), `check_proof` (§10.5.4, the grant's digest and principal from the caller's own reader), `grant_digest_and_principal` (for a verifier with none), the Setup Options `SETUP_OPTION_GRANT_CHALLENGE` (`0x3A47`) and `SETUP_OPTION_GRANT_PROOF` (`0x3A49`) with `add_challenge`, `challenge_of`, `add_grant`, `proofs_of`, `proof_for`, the HTTP header names, `context` and base64url. New dependency: `sha2` 0.10 (already in the lockfile). - **`joule-moq-relay`.** The relay sends its SETUP first, with a fresh challenge and the receipt extension declared whatever the client says. A grant rule with `proof = true` names a room grant's record: a SETUP that presents it without a proof that passes is closed `UNAUTHORIZED` (§12.2), and a request resting on it is refused unless its session proved it. The transports give the session the target the client addressed (WebTransport `:authority` and `:path`, the WebSocket upgrade's `Host` and path; native QUIC's AUTHORITY and PATH). `Authorizer::allows` takes the proven set; `Authorizer::requires_proof`; `ClientOptions` gains `grants`, `target` and `sign_challenge`. `receipts_track_suffix` defaults to `_receipts`, live-rooms §6.1's. - **`joule-moq-web`.** A participant presents its grant with a proof signed in wasm (`clientSetupWithGrant`), the per-participant secret gone; the receipts tracks are `0_receipts`; the verifier measures each receipts object, and the same receipts as JSON. ### Changed — `joule-moq` 0.3.0: `draft-ietf-moq-transport-22` `joule-moq` speaks `draft-ietf-moq-transport-22` (1 October 2026), negotiated by the ALPN `moqt-22`, in place of its draft-08-style codec. No byte of the old wire form survives: -22 changed the varint, the control streams, every message and the object framing. The crate's version moves to 0.3.0 because its public API changes: - `draft22`: the codec of -22, a module of its own selected through `draft::Draft`, so another draft can be added beside it. Every control message and parameter, Key-Value-Pairs, Properties, Setup Options, the Authorization Token, Request IDs and request-stream rules, subgroup streams, datagrams, fetch streams and padding, and the status and error codes. Every refusal is a typed `WireError` carrying the draft's consequence; `MoqError::Wire` wraps it. - `MOQT_DRAFT_VERSION` is `0xff000016`. `transport_quic::ALPN_MOQT` is `moqt-22`. - `messages` re-exports -22's control messages. `SetupMessage`, `AnnounceMessage`, `SubscribeMessage`, `SubscribeFilter` and the draft-08 `ControlMessage` are gone. SUBSCRIBE's `max_mrl_layer` is an OBJECT_PROPERTY_FILTER on the `MRL_LAYER` property (`messages::mrl_cap_filter`); `require_receipts` is the subscriber's own policy, met by subscribing to a track's receipts track. - `carriage` (new): the crate's objects ride -22 subgroup objects, with the object receipt, the `WAI_RECEIPT` property of WAI `extensions/moq-streaming-format.md` §5 and the layer fields as properties in an application range; a group's receipts ride its receipts track (§4.1). `ReceiptBundle` moved here from `mock`, which re-exports it. - `session`: the draft by ALPN (`MoqSession::select_alpn`); SETUP carries options and no version or role, and `build_setup` takes no label; Request IDs by end. - `transport_quic`: a pair of unidirectional control streams opened by SETUP, a request stream per request, subgroup, fetch and padding streams, and datagrams. `ControlStream`, `write_object_to_uni`, `read_object_from_uni`, `write_group_receipt_to_uni` and `read_group_receipt_from_uni` are replaced by `QuicSession::{open_control, accept_control, open_request, accept_request, open_subgroup, open_fetch, accept_data}`, `send_object`, `send_receipt_bundle` and `DataReceiver`. - `wire::encode_varint` and `decode_varint` are -22's integer; every `u64` encodes. - `mock`: objects, receipt bundles and requests cross the -22 codec; the relay reads an object's delivery timeout from its OBJECT_DELIVERY_TIMEOUT property. - `tests/vectors/draft-22/`: a corpus of -22 vectors, good and negative, generated by `tests/draft22_vectors.rs`, and an independent decoder that checks it in CI. ### Added — `joule-moq-ingest` 0.1.0: WHIP into a room's MoQ tracks, WHEP out of them A new crate, library and binary, between WebRTC and the MoQ tracks of a WAI room, on `joule-moq`'s codec and `joule-moq-relay`'s client. The mapping is WAI's `extensions/live-ingest.md`. - **WHIP (RFC 9725).** POST an offer for the answer, `Location` and `ETag`; PATCH for trickle ICE and ICE restarts, with `If-Match` (`428`, `412`); DELETE. The bearer token is a WAI room grant, checked against the room's signed descriptor; a grant without the publish power for a media kind of the offer is `403` with `insufficient_scope` (RFC 6750 §3.1). Every request that carries a grant also proves possession of its key (`WAI-Room-Proof` over a single-use `WAI-Room-Challenge`, WAI live-rooms §10.5.3), on the session's PATCH, DELETE, GET and HEAD as on the POST. - **Into MoQ.** str0m depacketises H.264, VP8, VP9, AV1 and Opus into frames; each frame is one `WAI1` envelope in its capability's framing, video grouped at keyframes and audio in fixed windows, with the frame's room-clock time (anchored at the first frame's arrival, re-anchored past `max_skew_ms`) and, on each group's first object, the grant reference. The gateway publishes the tracks and a signed catalog at the relay as their publisher and signs every group's receipts as the first hop, chained per session. - **WHEP (`draft-ietf-wish-whep-05`).** A player POSTs an offer for an owner's tracks; the gateway reads the owner's catalog once its receipt verifies (`409` with `Retry-After` until then), joins each track at its latest group, verifies every object against its receipt before it packetises the frame back to RTP, and drops and counts what fails. - **Bounds.** Sessions, tracks per session, bitrate (stated as `b=AS`), frame size and the queue to the relay, each a configuration field; `gateway.example.toml` holds no secret. - **A signed record per session**, in the shape of live metering's per-hop record: the seam a `WMH1` writer plugs into. - **The WebRTC stack** is `str0m` 0.24.1 (MIT OR Apache-2.0), pinned, with its pure-Rust crypto backend; the HTTP resources are `hyper` 1.11.1 (MIT). Every crate new to the lockfile is MIT, Apache-2.0 or ISC. - **Tests**, on loopback: WHIP to a relay subscriber with every receipt verified and the bytes equal, DELETE ending the tracks; WHEP playback from the joined group; VP8, VP9 and AV1 end to end; the refusals; trickle ICE and an ICE restart with media across it; the room reader against WAI's live-rooms corpus. A CI job runs every target on linux-arm64. ### Added — `joule-moq-relay`: the per-hop session record of a WAI room The relay signs WAI's per-hop session record, `WMH1` (`wai/extensions/live-metering.md` §3), of a room from its session summaries (`metering::hop_record`, `Relay::hop_record`): for each track of the room its sessions carried, the objects and bytes in and out, the objects dropped for their delivery timeout or still queued at the end with those whose receipts it forwarded, the objects refused at the door and deduplicated; its subscriber sessions' time by layer class, from the subscriptions each held; the events it delivered (objects of Object ID above 0 on the room's events tracks); and a ledger of the room grants its sessions ran under, by the token each presented in SETUP (`[[metering.grants]]` in the configuration). Its own energy is the unmetered marker. The record holds nothing per object or per viewer: media tracks are counted one by one, events tracks and every other track of the room together, subscribers by layer class. - The session summary carries what the record is made from, and its format is `joule-moq-relay/session-summary/2`: per track, as a publisher, objects and bytes received, refused at the door and deduplicated; per track, as a subscriber, fetched bytes, objects still queued at the end and objects of Object ID above 0; the session's layer spans per room; and the room grant its SETUP token maps to. A datagram's bytes are now counted among a publisher's bytes received. - `tests/room_metering.rs`: two publishers and three subscribers of one room, one subscriber slow, over QUIC and WebTransport; the record verifies under the relay's key and every count in it is the sum of the summaries' counts. Run with `WAI_METERING_FIXTURE_DIR`, the test writes the record and its ledger, which WAI's metering corpus holds as recorded. ### Added — `joule-moq-relay` 0.1.0: a `draft-ietf-moq-transport-22` relay A new crate, library and binary, on `joule-moq`'s codec. Publishers send it tracks, subscribers ask it for them, and it fans each object out to every subscription in the order -22's priorities give (§5.1.2), dropping what passes a subscriber's delivery timeout at this hop (§5.2). Whatever it drops, the object's receipts still reach the subscriber: a track's receipts track is never given a delivery timeout. - **Three transports, one draft:** native QUIC (ALPN `moqt-22`) and WebTransport over HTTP/3 (`WT-Available-Protocols` / `WT-Protocol`, `draft-ietf-webtrans-http3-16`) on one UDP endpoint, and a WebSocket fallback that carries `draft-ietf-webtrans-http2-15` capsules, one per binary message, under the subprotocol `moqt-22`. The fallback is a non-normative binding, written out in the crate's README with example bytes. - **The relay protocol:** session setup; namespace publish and subscribe routing; PUBLISH; SUBSCRIBE served from an upstream the relay holds or opens; FETCH and fills from its cache or forwarded upstream; TRACK_STATUS; REQUEST_UPDATE; GOAWAY on shutdown; Request-ID parity and duplicate checks; the authorization-token alias cache; several publishers of one track, deduplicated by (group, subgroup). - **What the cache covers** is worked out from the groups it holds whole, each time it is asked, so a group whose streams finish after a later group's is still served from the cache once it is whole; a group never seen is a gap it does not answer across. - **A signed session summary** per session. The relay's own energy is the unmetered marker, since no meter backs it. - **Not yet:** SUBSCRIBE_TRACKS and Range Filters, WebTransport over HTTP/2, 0-RTT, the relay moving its own upstream subscriptions, and finding a receipts track from the catalog rather than by its name; the README lists each. ### Changed — `joule-moq` 0.2.0: WAI's labelled JWP forms, and a strict verifier `joule-moq` reads and writes the forms of WAI's `extensions/jwp-receipts.md` that sign an object's energy figure, its class and its origin, and a group total's class. Every receipt in the legacy forms keeps its bytes and verifies as before; a verifier that predates the labelled forms refuses them. The crate's version moves to 0.2.0 because its public API changes: - `ObjectReceipt` gains `energy_class: Option>` (the class in WAI energy-measurement §2.7's signed encoding). Present, the object's leaf is `BLAKE3("moq-jwp:merkle-leaf\x02" ‖ content_hash ‖ u64_be(joules_micro) ‖ class ‖ u32_be(len(origin)) ‖ origin)`; absent, the legacy leaf. New: `ObjectReceipt::leaf`, `ObjectReceipt::check_label`, `ReceiptBuilder::add_labelled_object`, `ReceiptBuilder::class_for`. - `GroupReceipt` gains `energy_class`, `energy_coverage` and `parent_receipt_hash`. With a class, the group is signed under `moq-jwp:group-sign\x02`, which covers the class, the coverage and the parent link. `energy_coverage` is written only when it is non-zero, and a legacy group carrying a non-zero coverage is refused: its signature does not cover one. New: `ReceiptBuilder::finalize_labelled`, `GroupReceipt::link_hash` and `compose_group_class`, the rule a labelled group's class is composed by. - `ReceiptVerifier::verify_group` returns a `VerifiedGroup` (the root with the group's leaf count and coordinates) instead of the bare root, and `verify_object` and `verify_payload` take `&VerifiedGroup`. An object is accepted only at the position its proof walks: its coordinates are the group's, `object_id` is its leaf index, the proof has the group's depth and its direction bits are the index's. - `MerkleProof::verify` is replaced by `MerkleProof::verify_leaf`, which takes the leaf in its form and the group's leaf count, and `MerkleProof::walks_its_position`. - `verify_group` holds the key and the signature to WAI jwp-receipts §3.1 step 3's rule: the publisher key must be the canonical encoding of a point not of small order (a `y` coordinate ≥ p is refused), `S` must be below L (checked by the crate as well as by `verify_strict`), `R` must not be of small order, and the cofactorless equation must hold. - `JWP_MOQ_EXTENSION_VERSION` is 2. - `tests/wai_jwp_corpus.rs` runs every leaf, group and object vector of `wai/jwp-conformance/vectors.json` through the crate. External JWP implementations need the same update to verify labelled leaves and groups, and to refuse what this verifier refuses. ## [0.2.0] — draft ### Changed - **Receipts now seal as JCR-1 envelopes.** The receipt's signed form is the family-shared JCR-1 wire format — canonical det-CBOR (RFC 8949 §4.2.1, length-first key order) inside a COSE_Sign1 envelope (RFC 9052, EdDSA `alg = -8`), as the `joule-code/1` profile. This replaces v0.1's bespoke `estimated_uj ‖ sequence ‖ nonce` preimage. A Joule Code receipt now verifies under the family's existing JCR-1 tooling and its cross-language (Python/Go) reference verifiers. (SPEC §6.1.) - `joule-code-receipt` gains a `jcr1` module (canonical CBOR + COSE_Sign1, vendored from the JCR-1 reference, no JouleClaw dependency) and `Receipt::to_jcr1_wire` / `seal` / `verify_envelope`; the bespoke `sign`/`verify` are removed. - Conformance C3 vectors regenerated as JCR-1 COSE_Sign1 envelopes; the harness verifies the envelope and that its payload is the receipt's canonical core. A byte-equality test pins the canonical encoder against a published JCR-1 vector. ## [0.1.0] — draft Initial draft standard. ### Added - `SPEC.md` — normative specification: the cascade requirement (L0–L4, stop-at-first-close), the receipt requirement (`measured + estimated`, `Provenance`), and the no-capture requirement. - The coding-operation cargo: content-addressed (BLAKE3-256), signed (Ed25519, prior-state-bound) transitions over a source tree; deterministic CBOR canonical form. - The receipt schema in Joule Wire Protocol form, energy-signed. - The cascade interface and the agent-schema binding to JouleClaw; the L4 binding to remote frontier agents over ACP. - The federated-collaboration binding, specified at interface level and classified Research. - `joule-code-rs/` reference library: `joule-code-schema`, `joule-code-cargo`, `joule-code-receipt`, `joule-code-cascade`, `joule-code-conformance`. - `conformance/v1/` reference vector pack (C1–C4), internally consistent pending regeneration against the reference library.