L5 hardening: receipt chains, receipts in WAI's binary form, room track names, proof of possession of a grant's key (stacked on #324) #327

Closed
dcharlot wants to merge 0 commits from joule-moq/harden into main
Owner

L5 hardening: receipt chains, the receipts' binary form, room track names, proof of possession

Stacked on #324 (joule-moq/web): this branch starts from #324's head (8c80ef34) with
origin/main merged in (and merged again at a8b02f72), and holds #324's commits. Review #324 first; once it lands, this
diff is the hardening alone. Base: main. Not merged here.

The browser client of #324 found four defects and gaps in what is on main. Each is fixed
here, in the spec text, the reference implementation, the corpora and their independent
verifiers, joule-moq, the relay and the browser client.

1. ReceiptChains: a join's fill from group 0

The defect. A late joiner's live receipts anchor its chain at group N; it then
fetches the track from group 0. Groups 1 to N−1 were held as history and admitted back
from the anchor, but group 0, whose parent is null, was refused chain_break ("a
second chain head"), although group 1 links to it.

The fix. Below a join's anchor the gap has no lower end, and a null parent there is
the chain's true start (WAI extensions/jwp-receipts.md §3 step 3, Joining): the group
is parent_not_held, held as history, and admitted when the group above it links back to
it. A null parent outside such a gap is still a second chain head, and a fork still
chain_break. The same rule in joule-moq's ReceiptChains, wai-rs's and
jwp_verify.py; §3 step 3 and §5.9 say so.

Tests. joule-moq receipt::tests::a_joiners_fill_from_group_0_admits_the_chains_start
(the case #324 hit: anchored at 3, 4 linked, then 0 to 5 fetched in ascending order, 3
and 4 again; then a second group 0 and a second child of 0, both still chain_break).
Corpus vectors chain_join_fetch_from_group_0 and chain_join_second_start_after_the_fill.
The e2e checks that the late joiner's chains break nowhere. Which of its receipts comes
first varies under load: where the live receipts anchored a chain first, the fetched groups,
group 0 among them, are admitted back from the anchor as history (about 480 groups in such a
run); where the fetch's receipts came first, group 0 starts the chain and the rest link.

Falsified. With the fix committed (173500ed, vectors 57cc57b9), the old rule restored
in all three verifiers and the same commands run:

Command With the fix Old rule
cargo test -p joule-moq --lib receipt:: 0: 22 passed 101: a_joiners_fill_from_group_0_admits_the_chains_start FAILED at its group 0 assertion
wai_jwp_vectors verify ../jwp-conformance 0 1: both new vectors give chain_break for group 0
python3 jwp-conformance/jwp_verify.py 0 1: the same two vectors

2. The receipts in WAI's binary form

The gap. The receipts track carried joule-moq's own JSON (its serde form: hashes and
the signature as arrays of numbers), larger than the audio object it covers, and not a
form WAI defines.

What WAI now defines. extensions/jwp-receipts.md §2.7 (new), normative:

  • A group receipt is its canonical bytes (§3 step 3): its §2.3 signing payload in the
    form it was signed in, then its 64-byte signature; a legacy group adds its parent flag
    and parent, which its payload does not sign. The signing domain says which form. A
    group's link hash is computed over the bytes as received, with nothing re-serialised.
  • An object receipt is its members in fixed widths: object_id, the content hash,
    the figure, the origin, the proof's siblings, direction bits and leaf index, then
    leaf_version (1 the legacy leaf, 2 the labelled leaf, followed by its class).
    Standing alone it is prefixed by track_alias and group_id.
  • A group's receipts (WJR1): the magic, the group, a u32 count, each object's body.
  • Reading (§2.7.4) yields the JSON form's members, and §3 applies to them unchanged;
    what does not read is malformed at its §3 step. The form is canonical: a writer
    reproduces the bytes it read.

extensions/moq-streaming-format.md §4.1: a receipts-track object carries that form.
§5.12 lists the vectors.

Implemented in wai-rs (jwp_delivery::binary, which reads into the JSON members and
then through from_json), jwp_verify.py (from the text), and joule-moq
(GroupReceipt/ObjectReceipt to_binary/from_binary, read_receipts, and
ReceiptBundle::to_payload/from_payload, whose signatures are unchanged). The relay's
door, the browser's TrackSigner/TrackVerifier, the mock and the QUIC transport all go
through ReceiptBundle, so they carry the binary form with no change of their own. The
JWP_OBJECT_RECEIPT property carries an object receipt standing alone in it. No JSON path
is kept: a JSON payload does not read.

Vectors (jwp-conformance, procedure binary, 20): a live track's group of one
object, the mixed legacy group (five objects, their proofs, every label tag), the labelled
group (tag 4, coverage Partial, its parent), a group with no object, a legacy group and an
object standing alone, each read and written again byte for byte; and refused malformed
at their step: another magic, an unknown signing domain, a record ending inside its group,
a trailing byte, a count past the objects, parent flags 2 and 0-beside-a-parent, coverage
2, a non-UTF-8 signer_id, leaf_version 3, class tag 9, filtering byte 3, a legacy group
without its parent; and one that reads and is refused by §3 step 1 (coverage Partial beside
the unmetered marker). wai_jwp_vectors, jwp_verify.py and joule-moq's
tests/wai_jwp_corpus.rs give every one its outcome.

Bytes per receipts object, measured in the e2e on every receipts object a page
received: the binary form as received, and the same receipts in the JSON form the receipts
track carried before (joule-moq's serde form of the same group and object receipts, the
payload ReceiptBundle::to_payload wrote on #324's head):

Track (one object per group) Before (JSON) After (binary)
audio, 20 ms frames 1,100 to 1,155 B 359 B
video 1,094 to 1,152 B 359 B

Over the six runs of the final e2e, both transports: about 330 to 370 audio and 100 to 110
video receipts objects per subscription.

Every object of a track has the same size in the binary form: every field but the strings
has a fixed width, and the strings (two RFC 3339 times, the publisher's id, the origin) do
not change between groups. Of the 359 bytes, 192 are the signature, the publisher's key,
the root, the parent link and the object's content hash; 56 are the two timestamps; the
rest are the ids, counts, the class and the framing. A participant named with five
characters instead of three adds 4 bytes. The JSON size varies with the digits of the
group id and the parent's presence.

3. live-rooms §6 and §10.2: the catalog and receipts tracks

extensions/live-rooms.md §6.1 names a media track's receipts track (in its
namespace, its Track Name followed by _receipts: 0_receipts) and a publisher's
catalog track (Track Namespace the room and owner fields alone, Track Name catalog).
Every byte stays in a–z, 0–9 and _, so MOQT §8.8's rendering is still the bytes
themselves (<room>-<principal>-video--0_receipts, <room>-<principal>--catalog). §6.2
ties the catalog's wai_receipts_track to it. §10.2's scopes add: the publisher's catalog
[[2, 6], [R, P, nil], "catalog"], each slot's catalog, and for a subscriber
[[4, 7, 8], [R], "catalog"] and [[4, 7, 8], [R], "l_receipts"] per layer (the media
scopes already covered the receipts tracks for publishing). wai-rs (RECEIPTS_SUFFIX,
CATALOG_TRACK, receipts_track_name, catalog_namespace, moqt_scopes) and
live_rooms_verify.py agree; the rooms corpus's moqt scopes are regenerated.

The browser client publishes and subscribes to exactly those names, and
joule-moq-relay's receipts_track_suffix defaults to _receipts.

4. Proof of possession of a grant's key

A room grant is public: every participant holds every grant, to verify the room. So no
binding takes a grant, or a token mapped from it, as a bearer credential:
extensions/live-rooms.md §10.5 (new) requires a proof beside it, one record for MOQT
and HTTP.

The record (WRP1, §10.5.1): magic, version 1, the grant's digest (§4.2), the
principal's key, the verifier's 32-byte challenge, a context, and an Ed25519 signature by
the key over wai:live-room-proof 0x01 and the rest. The context is a verb and the target
the client addressed, //authority/path with no scheme, query or fragment: SETUP //relay.example/moq over MOQT, POST //gateway.example/whip/r1 over HTTP. The verifier
compares it with what it received (a WebTransport CONNECT's :authority and :path, an
HTTP request's or a WebSocket upgrade's Host and path, native QUIC's AUTHORITY and
PATH), so a server the client chose cannot pass the challenge on and present the proof
elsewhere; the scheme is left out because a front that terminates TLS changes it.

The MOQT form, and why (§10.5.2). Of the two directions, the signed challenge in a
dedicated Setup Option is the cleaner against draft-ietf-moq-transport-22's setup:

  • draft-22 has each endpoint send its own SETUP, either first (§6.3), and nothing in the
    relay's depends on the client's. So the relay sends its SETUP at once with the
    challenge, GRANT_CHALLENGE (0x3A47, 32 random bytes per session); the client waits for
    it (an extension that changes the session's authorisation does not pipeline, §9.1) and
    sends its SETUP with the grant as the AUTHORIZATION TOKEN (§9.1.4, USE_VALUE, type 0) and
    the proof as GRANT_PROOF (0x3A49, which may appear once per grant). The proof is
    checked before any request of the session is served, and a SETUP presenting a grant
    without a proof that passes closes the session UNAUTHORIZED (§12.2, 0x2). That is
    session-level authorisation, which is what SETUP's token is for.
  • The token stays what §8.9 says it is, a type and a value reusable by alias; the grant
    record is its value, unchanged. A proof inside the token would make the token
    session-specific and need a non-standard value under type 0, and CTA-5007-B's
    catdpop keeps the proof outside the token too (beside it, as RFC 9449's header is):
    the token's cnf names the key, the proof shows it is held.
  • A challenge in a WebTransport or HTTP response header cannot reach a page (the
    WebTransport API exposes no response headers), and native QUIC has none; a TLS exporter
    is not available to a page either. In SETUP, the form is the same over native QUIC,
    WebTransport and the WebSocket binding.
  • Its cost is the client's SETUP waiting for one flight from the relay; the first request
    waits for the challenge in either direction.

The bytes, for a grant G and a relay at https://relay.example/moq: GRANT_CHALLENGE is
the 32 bytes; the client's AUTHORIZATION TOKEN is 03 00 ‖ G's record; GRANT_PROOF is
WRP1 01 ‖ digest(G) ‖ principal ‖ challenge ‖ 19 00 ‖ SETUP //relay.example/moq ‖
sig = 192 bytes.

The HTTP form (§10.5.3, for WHIP and WHEP, whose RFC 9725 Bearer token is the grant):
Authorization: Bearer and the grant's record, WAI-Room-Proof: and the proof record,
both base64url without padding; the challenge in WAI-Room-Challenge:, sent on a 401
(with WWW-Authenticate: Bearer error="invalid_token") to a request whose grant has no
proof that passes, and on every accepted response, for the next request (a session's
PATCH and DELETE). Each challenge is good for one request, consumed when a proof over
it is accepted and kept unconsumed for a time the server states, as RFC 9449 §9's
server-provided nonce is.

The checks (§10.5.4), in order, each with its reason in §11: a proof present
(pop_missing), it reads (pop_malformed), it names the grant (pop_wrong_grant), its key
is the principal (pop_wrong_key), the signature verifies strictly (pop_bad_signature),
the challenge is this session's or request's (pop_replayed for one consumed before,
pop_wrong_challenge otherwise), the context is what the verifier received
(pop_wrong_context). The CAT mapping (§10.1) and §13 say so. The code points and the two
HTTP fields are proposed for their registries and not registered (registry: WRP1,
GRANT_CHALLENGE, GRANT_PROOF, WAI-Room-Proof, WAI-Room-Challenge; new kinds
setup-option and http-header).

Implemented in:

  • wai-rs: live_room::{Proof, check_proof, grant_digest_and_principal, base64url},
    and 17 proofs in the rooms corpus (proofs): over MOQT, a good one; a grant with no
    proof; the accepted proof presented again on a later session (pop_replayed); one over
    another session's challenge (pop_wrong_challenge); one by another participant's key
    (pop_wrong_key); one naming the principal's key signed by another (pop_bad_signature);
    one naming another grant; one for another relay (pop_wrong_context); three that do not
    read. Over HTTP, in their header forms: a WHIP POST and the session's DELETE accepted;
    the Bearer token alone; the POST's proof sent again; a POST's proof on a DELETE; a
    MOQT proof presented over HTTP.
  • live_rooms_verify.py: the record, its base64url header form and the seven checks,
    from the text.
  • joule-moq (grant_proof): GrantProof (sign, read, to_bytes), check_proof
    (the grant's digest and principal from the caller, so a verifier with its own grant
    reader uses it as it is), grant_digest_and_principal (for one with none), the SETUP
    helpers (add_challenge, challenge_of, add_grant, proofs_of, proof_for),
    context, the header names, base64url. The client side: add_grant with a proof over
    challenge_of the relay's SETUP.
  • The relay: its SETUP goes first with a fresh challenge; a grant rule with
    proof = true names a room grant's record, and the relay checks each such grant in the
    client's SETUP before serving any request, closes the session UNAUTHORIZED otherwise
    (the reason in its log), and refuses a request resting on a grant its session never
    proved (REQUEST_ERROR UNAUTHORIZED). The transports carry the target the client
    addressed. Its test client presents grants with proofs (tests/grant_proof.rs).
  • The browser client: the per-participant secret is gone. A participant's token is its
    grant's record; the page waits for the relay's SETUP and signs the proof in wasm
    (clientSetupWithGrant, by the same Ed25519 code the relay's checks are
    held to), over the challenge and the URL's //authority/path. The fixture's relay rules
    carry proof = true. The room's clock, a room key with no grant, keeps a token of its
    own (finding 4 of #324: its lifecycle is still authored by a page for the test).

What #325's gateway must change

#325 (joule-moq/ingest, 5f0b7a95) is not edited here. To land on this:

  1. Proof on every request that carries a grant (POST /whip, POST /whep/<room>/<owner>,
    and PATCH, DELETE, GET, HEAD on /whip/s/<id> and /whep/s/<id>): after
    admit (its own reader, §4.3, the window, the origins), call
    joule_moq::grant_proof::check_proof(&ad.digest, &ad.grant.principal, proof, &challenge, |c| consumed.contains(c), &context) with proof the WAI-Room-Proof header through
    grant_proof::from_base64url (a header that does not decode is Some(vec![]), so
    pop_malformed), and context = grant_proof::context(method, host, path) from the
    request's Host (or :authority) and path, its query cut (/whep/<room>/<owner>?layer=1
    is signed as /whep/<room>/<owner>).
  2. Challenges: 32 random bytes each, issued in WAI-Room-Challenge (base64url) on the
    401 it already sends (unauthorized, with error_description the pop_* code) and on
    every accepted response (201, 204); kept unconsumed for a stated time
    (configuration, for example http.challenge_ttl_s), consumed on acceptance, and the
    consumed ones kept as long, bounded, to report pop_replayed.
  3. CORS: WAI-Room-Challenge in Access-Control-Expose-Headers, WAI-Room-Proof in
    Access-Control-Allow-Headers.
  4. Names: catalog.rs's RECEIPTS_SUFFIX is _receipts (live-rooms §6.1), and the
    test at whep.rs that names 0/receipts.
  5. Its corpus test (tests/room_corpus.rs): run the rooms corpus's proofs with
    binding: "http" through its header parsing and check_proof.
  6. Nothing else of its joule-moq use changes: ReceiptBundle::to_payload/from_payload
    keep their signatures and now write and read the binary form; its relay link's
    ClientOptions { tokens, ..ClientOptions::default() } still builds, its link token being
    a plain rule (proof = false).

The API L6a (#325) and #317 meet

  • joule-moq: ReceiptBundle::to_payload, from_payload, encode_stream,
    decode_stream unchanged in signature, binary on the wire; new ReceiptBundle::read
    (with the link hash over the bytes received), GroupReceipt::to_binary /
    from_binary, ObjectReceipt::to_binary / from_binary / body_binary,
    read_receipts, link_hash_of, Malformed, RECEIPTS_MAGIC; ReceiptChains keeps
    its API, the one behaviour change above; module grant_proof (new); dependency sha2
    0.10 (already locked). The serde derives on the receipt types stay; nothing on the wire
    uses them.
  • joule-moq-relay: Authorizer::allows takes a fourth argument, the session's proven
    set; Authorizer::requires_proof; config::Grant.proof (default false);
    SessionCtx.proven; Connection::target() (a default method), WtConnection::with_target,
    MuxConnection::with_target; ClientOptions gains grants, target, sign_challenge
    (filled by Default); session::serve sends the relay's SETUP before reading the
    client's and declares the receipt extension whatever the client says (on when both do);
    receipts_track_suffix defaults to _receipts.
  • #317 (wai/live-metering) edits the relay's session.rs (serve), config.rs,
    README and the registry beside this: its MeteredGrant names a grant by the token's
    SHA-256, which for a room grant is now the record's SHA-256 with no secret; after merging,
    wai registry export again (both add wire identifiers; this adds two kinds).

Also found under load, and fixed

One of four runs of e2e/run.sh --runs 4 failed in goaway: a page met a VP9 decoding
error and never decoded that track again. A codec that errs is closed for good, and #324's
page went on handing frames to the closed decoder. Each group rides a stream of its own and
waits for its receipts, so under load a group can be released after a later one, and
decoded then it corrupts the decoder's references. The page now drops a live video group
older than the last it decoded (late_dropped), and makes a decoder again after an error,
waiting for the next key frame (decoder_restarts).

Over WebTransport, a session the relay refuses reaches the page as a lost connection, not as
UNAUTHORIZED: the relay's WebTransport library closes the QUIC connection rather than the
WebTransport session. Over the WebSocket binding the page reads the code (2) and the reason
(live-rooms §10.5: pop_missing). The e2e reads the reason from the relay's log over
WebTransport. Closing the WebTransport session itself, with its code, is left to the relay's
transport.

Results, on the build host through rb

Every command ran through ~/kani-logs/remote/rb <worktree> -- '<command>' on the build
host, under its memory guard, its output written to a log there; load averages are the
host's own.

Command Exit
cargo test -p joule-moq --lib receipt:: (finding 1, with the fix) 0: 22 passed
the same, the old rule restored 101: the new test fails
wai_jwp_vectors gen then verify ../jwp-conformance (features receipt_claims,key_discovery) 0
the same verify, the old rule restored 1
python3 jwp-conformance/jwp_verify.py jwp-conformance/vectors.json 0: 165 vectors; old rule 1
chain_differential.py <wai_jwp_vectors> --n 3000 and chain_gap_differential.py … --n 400, seeds 1 and 2 0, all four
cargo test --no-default-features --features receipt_claims,key_discovery --lib jwp_delivery:: 0: 41 passed
wai_live_room_vectors gen then verify ../live-conformance/rooms 0
cargo test --no-default-features --features live_room --test live_room and --lib live_room:: 0: 4 and 6 passed
python3 -I live-conformance/live_rooms_verify.py live-conformance/rooms 0: 64 vectors, 17 proofs
capture_take_verify.py takes, live_compose_verify.py compose, live_record_verify.py recordings (they read rooms through live_rooms_verify.py) 0, all three
cargo test --no-default-features --features live_room,live_compose,live_record,timed_text,capture_take (wai-rs) 0: 634 lib tests and every integration test
cargo test --locked -p joule-moq, -p joule-moq-relay, -p joule-moq-web (final head) 0: 134 passed, 0 failed, tests/grant_proof.rs and the corpus tests among them
cargo clippy --locked -p joule-moq -p joule-moq-relay -p joule-moq-web --all-targets 0; no warning on a line this change wrote
cargo clippy -p joule-moq-web --target wasm32-unknown-unknown; cargo check -p joule-moq --no-default-features; cargo check -p lockstep-quic 0
cargo clippy --no-default-features --features receipt_claims,key_discovery,live_room,live_compose,live_record,timed_text --all-targets (wai-rs) 0; none on a line this change wrote
cd wai/wai-live-web && cargo test --locked, cargo clippy --locked --all-targets 0
cargo run --no-default-features --bin wai -- registry export, then --check; --lib codecs::registry:: 0: 14 passed
cargo update --workspace and cargo check --locked in examples/transport-moqt; scripts/check-lockfiles.sh 0: 40 lockfiles current
scripts/check-tests-in-ci.py, scripts/check-orphan-sources.py 0
locally (no toolchain): check-comparative-prose.sh, check-no-vanity-metrics.sh, check-workflow-concurrency.sh, check-spec-refs.py, check-wai-section-refs.py 0
e2e/run.sh --runs 1 (first run, every scenario) 1: 73 of 74 checks; the bare-grant check over WebTransport expected the close code, which that transport does not carry (above), so the check reads the relay's log there
e2e/run.sh --runs 4, load average 77 to 136 1: 3 of 4 runs, 287 of 288 checks; the failure was the decoder defect above, then fixed
e2e/run.sh --runs 6, load average 35 to 92 1: 4 of 6 runs; the two failures were the new join-fill check's own assumption (it required history, and in those runs the fetch's receipts came first and linked from group 0, 0 chain_break), then made order-independent
e2e/run.sh --runs 6 on a70edf76, the head before the last merge of main (which touches nothing the e2e builds), load average 38 to 63, 78% of memory free 0: 6 of 6 runs passed, 444 of 444 checks; every scenario: compose, webtransport, websocket (with the page that falls back to the binding by itself), goaway (every page moved and closed its old session itself, 0 closed at the timeout)
after the last merge: scripts/check-lockfiles.sh, wai registry export --check, wai_jwp_vectors verify, wai_live_room_vectors verify, cd wai/wai-live-web && cargo test --locked 0

Text and registry

WAI and joule-code CHANGELOGs; jwp-receipts §1, §2.4, §2.7 (new), §3 step 3, §5.9, §5.12
(new); moq-streaming-format §4.1; live-rooms §1, §6.1, §6.2, §6.3, §10, §10.1, §10.2,
§10.4, §10.5 (new), §11, §12, §13; the corpora's READMEs; the relay README's Receipts and
Tokens and room grants; joule-moq's and joule-moq-web's READMEs; the registry (six
wire identifiers, two kinds) and wai registry export. SPEC §5 registers no new
capability or record, so it is unchanged.

🤖 Generated with Claude Code


Closed: landed in main through #325 (joule-moq/ingest), which contains this branch's head 46dbd1a6 unchanged. #325's relevant CI was green but two failures outside this work: a wai-quantum nondeterminism test and a jouleclaw GPU-energy test on the macOS runner.

## L5 hardening: receipt chains, the receipts' binary form, room track names, proof of possession **Stacked on #324** (`joule-moq/web`): this branch starts from #324's head (8c80ef34) with `origin/main` merged in (and merged again at a8b02f72), and holds #324's commits. Review #324 first; once it lands, this diff is the hardening alone. Base: main. Not merged here. The browser client of #324 found four defects and gaps in what is on main. Each is fixed here, in the spec text, the reference implementation, the corpora and their independent verifiers, `joule-moq`, the relay and the browser client. ### 1. `ReceiptChains`: a join's fill from group 0 **The defect.** A late joiner's live receipts anchor its chain at group *N*; it then fetches the track from group 0. Groups 1 to *N*−1 were held as history and admitted back from the anchor, but group 0, whose parent is `null`, was refused `chain_break` ("a second chain head"), although group 1 links to it. **The fix.** Below a join's anchor the gap has no lower end, and a `null` parent there is the chain's true start (WAI `extensions/jwp-receipts.md` §3 step 3, *Joining*): the group is `parent_not_held`, held as history, and admitted when the group above it links back to it. A `null` parent outside such a gap is still a second chain head, and a fork still `chain_break`. The same rule in `joule-moq`'s `ReceiptChains`, `wai-rs`'s and `jwp_verify.py`; §3 step 3 and §5.9 say so. **Tests.** `joule-moq` `receipt::tests::a_joiners_fill_from_group_0_admits_the_chains_start` (the case #324 hit: anchored at 3, 4 linked, then 0 to 5 fetched in ascending order, 3 and 4 again; then a second group 0 and a second child of 0, both still `chain_break`). Corpus vectors `chain_join_fetch_from_group_0` and `chain_join_second_start_after_the_fill`. The e2e checks that the late joiner's chains break nowhere. Which of its receipts comes first varies under load: where the live receipts anchored a chain first, the fetched groups, group 0 among them, are admitted back from the anchor as history (about 480 groups in such a run); where the fetch's receipts came first, group 0 starts the chain and the rest link. **Falsified.** With the fix committed (173500ed, vectors 57cc57b9), the old rule restored in all three verifiers and the same commands run: | Command | With the fix | Old rule | |---|---|---| | `cargo test -p joule-moq --lib receipt::` | 0: 22 passed | 101: `a_joiners_fill_from_group_0_admits_the_chains_start` FAILED at its group 0 assertion | | `wai_jwp_vectors verify ../jwp-conformance` | 0 | 1: both new vectors give `chain_break` for group 0 | | `python3 jwp-conformance/jwp_verify.py` | 0 | 1: the same two vectors | ### 2. The receipts in WAI's binary form **The gap.** The receipts track carried `joule-moq`'s own JSON (its serde form: hashes and the signature as arrays of numbers), larger than the audio object it covers, and not a form WAI defines. **What WAI now defines.** `extensions/jwp-receipts.md` §2.7 (new), normative: - **A group receipt** is its canonical bytes (§3 step 3): its §2.3 signing payload in the form it was signed in, then its 64-byte signature; a legacy group adds its parent flag and parent, which its payload does not sign. The signing domain says which form. A group's link hash is computed over the bytes as received, with nothing re-serialised. - **An object receipt** is its members in fixed widths: `object_id`, the content hash, the figure, the origin, the proof's siblings, direction bits and leaf index, then `leaf_version` (1 the legacy leaf, 2 the labelled leaf, followed by its class). Standing alone it is prefixed by `track_alias` and `group_id`. - **A group's receipts** (`WJR1`): the magic, the group, a u32 count, each object's body. - **Reading** (§2.7.4) yields the JSON form's members, and §3 applies to them unchanged; what does not read is `malformed` at its §3 step. The form is canonical: a writer reproduces the bytes it read. `extensions/moq-streaming-format.md` §4.1: a receipts-track object carries that form. §5.12 lists the vectors. **Implemented** in `wai-rs` (`jwp_delivery::binary`, which reads into the JSON members and then through `from_json`), `jwp_verify.py` (from the text), and `joule-moq` (`GroupReceipt`/`ObjectReceipt` `to_binary`/`from_binary`, `read_receipts`, and `ReceiptBundle::to_payload`/`from_payload`, whose signatures are unchanged). The relay's door, the browser's `TrackSigner`/`TrackVerifier`, the mock and the QUIC transport all go through `ReceiptBundle`, so they carry the binary form with no change of their own. The `JWP_OBJECT_RECEIPT` property carries an object receipt standing alone in it. No JSON path is kept: a JSON payload does not read. **Vectors** (`jwp-conformance`, procedure `binary`, 20): a live track's group of one object, the mixed legacy group (five objects, their proofs, every label tag), the labelled group (tag 4, coverage Partial, its parent), a group with no object, a legacy group and an object standing alone, each read and written again byte for byte; and refused `malformed` at their step: another magic, an unknown signing domain, a record ending inside its group, a trailing byte, a count past the objects, parent flags 2 and 0-beside-a-parent, coverage 2, a non-UTF-8 `signer_id`, `leaf_version` 3, class tag 9, filtering byte 3, a legacy group without its parent; and one that reads and is refused by §3 step 1 (coverage Partial beside the unmetered marker). `wai_jwp_vectors`, `jwp_verify.py` and `joule-moq`'s `tests/wai_jwp_corpus.rs` give every one its outcome. **Bytes per receipts object**, measured in the e2e on every receipts object a page received: the binary form as received, and the same receipts in the JSON form the receipts track carried before (`joule-moq`'s serde form of the same group and object receipts, the payload `ReceiptBundle::to_payload` wrote on #324's head): | Track (one object per group) | Before (JSON) | After (binary) | |---|---|---| | audio, 20 ms frames | 1,100 to 1,155 B | 359 B | | video | 1,094 to 1,152 B | 359 B | Over the six runs of the final e2e, both transports: about 330 to 370 audio and 100 to 110 video receipts objects per subscription. Every object of a track has the same size in the binary form: every field but the strings has a fixed width, and the strings (two RFC 3339 times, the publisher's id, the origin) do not change between groups. Of the 359 bytes, 192 are the signature, the publisher's key, the root, the parent link and the object's content hash; 56 are the two timestamps; the rest are the ids, counts, the class and the framing. A participant named with five characters instead of three adds 4 bytes. The JSON size varies with the digits of the group id and the parent's presence. ### 3. live-rooms §6 and §10.2: the catalog and receipts tracks `extensions/live-rooms.md` §6.1 names a media track's **receipts track** (in its namespace, its Track Name followed by `_receipts`: `0_receipts`) and a publisher's **catalog track** (Track Namespace the room and owner fields alone, Track Name `catalog`). Every byte stays in `a`–`z`, `0`–`9` and `_`, so MOQT §8.8's rendering is still the bytes themselves (`<room>-<principal>-video--0_receipts`, `<room>-<principal>--catalog`). §6.2 ties the catalog's `wai_receipts_track` to it. §10.2's scopes add: the publisher's catalog `[[2, 6], [R, P, nil], "catalog"]`, each slot's catalog, and for a subscriber `[[4, 7, 8], [R], "catalog"]` and `[[4, 7, 8], [R], "l_receipts"]` per layer (the media scopes already covered the receipts tracks for publishing). `wai-rs` (`RECEIPTS_SUFFIX`, `CATALOG_TRACK`, `receipts_track_name`, `catalog_namespace`, `moqt_scopes`) and `live_rooms_verify.py` agree; the rooms corpus's `moqt` scopes are regenerated. The browser client publishes and subscribes to exactly those names, and `joule-moq-relay`'s `receipts_track_suffix` defaults to `_receipts`. ### 4. Proof of possession of a grant's key A room grant is public: every participant holds every grant, to verify the room. So no binding takes a grant, or a token mapped from it, as a bearer credential: `extensions/live-rooms.md` §10.5 (new) requires a **proof** beside it, one record for MOQT and HTTP. **The record** (`WRP1`, §10.5.1): magic, version 1, the grant's digest (§4.2), the principal's key, the verifier's 32-byte challenge, a context, and an Ed25519 signature by the key over `wai:live-room-proof` 0x01 and the rest. The context is a verb and the target the client addressed, `//authority/path` with no scheme, query or fragment: `SETUP //relay.example/moq` over MOQT, `POST //gateway.example/whip/r1` over HTTP. The verifier compares it with what it received (a WebTransport CONNECT's `:authority` and `:path`, an HTTP request's or a WebSocket upgrade's `Host` and path, native QUIC's AUTHORITY and PATH), so a server the client chose cannot pass the challenge on and present the proof elsewhere; the scheme is left out because a front that terminates TLS changes it. **The MOQT form, and why** (§10.5.2). Of the two directions, the signed challenge in a dedicated Setup Option is the cleaner against `draft-ietf-moq-transport-22`'s setup: - draft-22 has each endpoint send its own SETUP, either first (§6.3), and nothing in the relay's depends on the client's. So the relay sends its SETUP at once with the challenge, `GRANT_CHALLENGE` (0x3A47, 32 random bytes per session); the client waits for it (an extension that changes the session's authorisation does not pipeline, §9.1) and sends its SETUP with the grant as the AUTHORIZATION TOKEN (§9.1.4, USE_VALUE, type 0) and the proof as `GRANT_PROOF` (0x3A49, which may appear once per grant). The proof is checked before any request of the session is served, and a SETUP presenting a grant without a proof that passes closes the session `UNAUTHORIZED` (§12.2, 0x2). That is session-level authorisation, which is what SETUP's token is for. - The token stays what §8.9 says it is, a type and a value reusable by alias; the grant record is its value, unchanged. A proof inside the token would make the token session-specific and need a non-standard value under type 0, and CTA-5007-B's `catdpop` keeps the proof outside the token too (beside it, as RFC 9449's header is): the token's `cnf` names the key, the proof shows it is held. - A challenge in a WebTransport or HTTP response header cannot reach a page (the WebTransport API exposes no response headers), and native QUIC has none; a TLS exporter is not available to a page either. In SETUP, the form is the same over native QUIC, WebTransport and the WebSocket binding. - Its cost is the client's SETUP waiting for one flight from the relay; the first request waits for the challenge in either direction. The bytes, for a grant G and a relay at `https://relay.example/moq`: `GRANT_CHALLENGE` is the 32 bytes; the client's AUTHORIZATION TOKEN is `03 00` ‖ G's record; `GRANT_PROOF` is `WRP1 01` ‖ digest(G) ‖ principal ‖ challenge ‖ `19 00` ‖ `SETUP //relay.example/moq` ‖ sig = 192 bytes. **The HTTP form** (§10.5.3, for WHIP and WHEP, whose RFC 9725 Bearer token is the grant): `Authorization: Bearer` and the grant's record, `WAI-Room-Proof:` and the proof record, both base64url without padding; the challenge in `WAI-Room-Challenge:`, sent on a `401` (with `WWW-Authenticate: Bearer error="invalid_token"`) to a request whose grant has no proof that passes, and on every accepted response, for the next request (a session's `PATCH` and `DELETE`). Each challenge is good for one request, consumed when a proof over it is accepted and kept unconsumed for a time the server states, as RFC 9449 §9's server-provided nonce is. **The checks** (§10.5.4), in order, each with its reason in §11: a proof present (`pop_missing`), it reads (`pop_malformed`), it names the grant (`pop_wrong_grant`), its key is the principal (`pop_wrong_key`), the signature verifies strictly (`pop_bad_signature`), the challenge is this session's or request's (`pop_replayed` for one consumed before, `pop_wrong_challenge` otherwise), the context is what the verifier received (`pop_wrong_context`). The CAT mapping (§10.1) and §13 say so. The code points and the two HTTP fields are proposed for their registries and not registered (registry: `WRP1`, `GRANT_CHALLENGE`, `GRANT_PROOF`, `WAI-Room-Proof`, `WAI-Room-Challenge`; new kinds `setup-option` and `http-header`). **Implemented in:** - **`wai-rs`**: `live_room::{Proof, check_proof, grant_digest_and_principal, base64url}`, and 17 proofs in the rooms corpus (`proofs`): over MOQT, a good one; a grant with no proof; the accepted proof presented again on a later session (`pop_replayed`); one over another session's challenge (`pop_wrong_challenge`); one by another participant's key (`pop_wrong_key`); one naming the principal's key signed by another (`pop_bad_signature`); one naming another grant; one for another relay (`pop_wrong_context`); three that do not read. Over HTTP, in their header forms: a WHIP `POST` and the session's `DELETE` accepted; the Bearer token alone; the `POST`'s proof sent again; a `POST`'s proof on a `DELETE`; a MOQT proof presented over HTTP. - **`live_rooms_verify.py`**: the record, its base64url header form and the seven checks, from the text. - **`joule-moq`** (`grant_proof`): `GrantProof` (`sign`, `read`, `to_bytes`), `check_proof` (the grant's digest and principal from the caller, so a verifier with its own grant reader uses it as it is), `grant_digest_and_principal` (for one with none), the SETUP helpers (`add_challenge`, `challenge_of`, `add_grant`, `proofs_of`, `proof_for`), `context`, the header names, base64url. The client side: `add_grant` with a proof over `challenge_of` the relay's SETUP. - **The relay**: its SETUP goes first with a fresh challenge; a grant rule with `proof = true` names a room grant's record, and the relay checks each such grant in the client's SETUP before serving any request, closes the session `UNAUTHORIZED` otherwise (the reason in its log), and refuses a request resting on a grant its session never proved (`REQUEST_ERROR` `UNAUTHORIZED`). The transports carry the target the client addressed. Its test client presents grants with proofs (`tests/grant_proof.rs`). - **The browser client**: the per-participant secret is gone. A participant's token is its grant's record; the page waits for the relay's SETUP and signs the proof in wasm (`clientSetupWithGrant`, by the same Ed25519 code the relay's checks are held to), over the challenge and the URL's `//authority/path`. The fixture's relay rules carry `proof = true`. The room's clock, a room key with no grant, keeps a token of its own (finding 4 of #324: its lifecycle is still authored by a page for the test). ### What #325's gateway must change #325 (`joule-moq/ingest`, 5f0b7a95) is not edited here. To land on this: 1. **Proof on every request that carries a grant** (`POST /whip`, `POST /whep/<room>/<owner>`, and `PATCH`, `DELETE`, `GET`, `HEAD` on `/whip/s/<id>` and `/whep/s/<id>`): after `admit` (its own reader, §4.3, the window, the origins), call `joule_moq::grant_proof::check_proof(&ad.digest, &ad.grant.principal, proof, &challenge, |c| consumed.contains(c), &context)` with `proof` the `WAI-Room-Proof` header through `grant_proof::from_base64url` (a header that does not decode is `Some(vec![])`, so `pop_malformed`), and `context` = `grant_proof::context(method, host, path)` from the request's `Host` (or `:authority`) and path, its query cut (`/whep/<room>/<owner>?layer=1` is signed as `/whep/<room>/<owner>`). 2. **Challenges**: 32 random bytes each, issued in `WAI-Room-Challenge` (base64url) on the `401` it already sends (`unauthorized`, with `error_description` the `pop_*` code) and on every accepted response (`201`, `204`); kept unconsumed for a stated time (configuration, for example `http.challenge_ttl_s`), consumed on acceptance, and the consumed ones kept as long, bounded, to report `pop_replayed`. 3. **CORS**: `WAI-Room-Challenge` in `Access-Control-Expose-Headers`, `WAI-Room-Proof` in `Access-Control-Allow-Headers`. 4. **Names**: `catalog.rs`'s `RECEIPTS_SUFFIX` is `_receipts` (live-rooms §6.1), and the test at `whep.rs` that names `0/receipts`. 5. **Its corpus test** (`tests/room_corpus.rs`): run the rooms corpus's `proofs` with `binding: "http"` through its header parsing and `check_proof`. 6. Nothing else of its `joule-moq` use changes: `ReceiptBundle::to_payload`/`from_payload` keep their signatures and now write and read the binary form; its relay link's `ClientOptions { tokens, ..ClientOptions::default() }` still builds, its link token being a plain rule (`proof = false`). ### The API L6a (#325) and #317 meet - `joule-moq`: `ReceiptBundle::to_payload`, `from_payload`, `encode_stream`, `decode_stream` unchanged in signature, binary on the wire; new `ReceiptBundle::read` (with the link hash over the bytes received), `GroupReceipt::to_binary` / `from_binary`, `ObjectReceipt::to_binary` / `from_binary` / `body_binary`, `read_receipts`, `link_hash_of`, `Malformed`, `RECEIPTS_MAGIC`; `ReceiptChains` keeps its API, the one behaviour change above; module `grant_proof` (new); dependency `sha2` 0.10 (already locked). The serde derives on the receipt types stay; nothing on the wire uses them. - `joule-moq-relay`: `Authorizer::allows` takes a fourth argument, the session's proven set; `Authorizer::requires_proof`; `config::Grant.proof` (default `false`); `SessionCtx.proven`; `Connection::target()` (a default method), `WtConnection::with_target`, `MuxConnection::with_target`; `ClientOptions` gains `grants`, `target`, `sign_challenge` (filled by `Default`); `session::serve` sends the relay's SETUP before reading the client's and declares the receipt extension whatever the client says (on when both do); `receipts_track_suffix` defaults to `_receipts`. - **#317** (`wai/live-metering`) edits the relay's `session.rs` (`serve`), `config.rs`, README and the registry beside this: its `MeteredGrant` names a grant by the token's SHA-256, which for a room grant is now the record's SHA-256 with no secret; after merging, `wai registry export` again (both add wire identifiers; this adds two kinds). ### Also found under load, and fixed One of four runs of `e2e/run.sh --runs 4` failed in `goaway`: a page met a VP9 decoding error and never decoded that track again. A codec that errs is closed for good, and #324's page went on handing frames to the closed decoder. Each group rides a stream of its own and waits for its receipts, so under load a group can be released after a later one, and decoded then it corrupts the decoder's references. The page now drops a live video group older than the last it decoded (`late_dropped`), and makes a decoder again after an error, waiting for the next key frame (`decoder_restarts`). Over WebTransport, a session the relay refuses reaches the page as a lost connection, not as `UNAUTHORIZED`: the relay's WebTransport library closes the QUIC connection rather than the WebTransport session. Over the WebSocket binding the page reads the code (2) and the reason (`live-rooms §10.5: pop_missing`). The e2e reads the reason from the relay's log over WebTransport. Closing the WebTransport session itself, with its code, is left to the relay's transport. ### Results, on the build host through `rb` Every command ran through `~/kani-logs/remote/rb <worktree> -- '<command>'` on the build host, under its memory guard, its output written to a log there; load averages are the host's own. | Command | Exit | |---|---| | `cargo test -p joule-moq --lib receipt::` (finding 1, with the fix) | 0: 22 passed | | the same, the old rule restored | 101: the new test fails | | `wai_jwp_vectors gen` then `verify ../jwp-conformance` (features `receipt_claims,key_discovery`) | 0 | | the same `verify`, the old rule restored | 1 | | `python3 jwp-conformance/jwp_verify.py jwp-conformance/vectors.json` | 0: 165 vectors; old rule 1 | | `chain_differential.py <wai_jwp_vectors> --n 3000` and `chain_gap_differential.py … --n 400`, seeds 1 and 2 | 0, all four | | `cargo test --no-default-features --features receipt_claims,key_discovery --lib jwp_delivery::` | 0: 41 passed | | `wai_live_room_vectors gen` then `verify ../live-conformance/rooms` | 0 | | `cargo test --no-default-features --features live_room --test live_room` and `--lib live_room::` | 0: 4 and 6 passed | | `python3 -I live-conformance/live_rooms_verify.py live-conformance/rooms` | 0: 64 vectors, 17 proofs | | `capture_take_verify.py takes`, `live_compose_verify.py compose`, `live_record_verify.py recordings` (they read rooms through `live_rooms_verify.py`) | 0, all three | | `cargo test --no-default-features --features live_room,live_compose,live_record,timed_text,capture_take` (wai-rs) | 0: 634 lib tests and every integration test | | `cargo test --locked -p joule-moq`, `-p joule-moq-relay`, `-p joule-moq-web` (final head) | 0: 134 passed, 0 failed, `tests/grant_proof.rs` and the corpus tests among them | | `cargo clippy --locked -p joule-moq -p joule-moq-relay -p joule-moq-web --all-targets` | 0; no warning on a line this change wrote | | `cargo clippy -p joule-moq-web --target wasm32-unknown-unknown`; `cargo check -p joule-moq --no-default-features`; `cargo check -p lockstep-quic` | 0 | | `cargo clippy --no-default-features --features receipt_claims,key_discovery,live_room,live_compose,live_record,timed_text --all-targets` (wai-rs) | 0; none on a line this change wrote | | `cd wai/wai-live-web && cargo test --locked`, `cargo clippy --locked --all-targets` | 0 | | `cargo run --no-default-features --bin wai -- registry export`, then `--check`; `--lib codecs::registry::` | 0: 14 passed | | `cargo update --workspace` and `cargo check --locked` in `examples/transport-moqt`; `scripts/check-lockfiles.sh` | 0: 40 lockfiles current | | `scripts/check-tests-in-ci.py`, `scripts/check-orphan-sources.py` | 0 | | locally (no toolchain): `check-comparative-prose.sh`, `check-no-vanity-metrics.sh`, `check-workflow-concurrency.sh`, `check-spec-refs.py`, `check-wai-section-refs.py` | 0 | | `e2e/run.sh --runs 1` (first run, every scenario) | 1: 73 of 74 checks; the bare-grant check over WebTransport expected the close code, which that transport does not carry (above), so the check reads the relay's log there | | `e2e/run.sh --runs 4`, load average 77 to 136 | 1: 3 of 4 runs, 287 of 288 checks; the failure was the decoder defect above, then fixed | | `e2e/run.sh --runs 6`, load average 35 to 92 | 1: 4 of 6 runs; the two failures were the new join-fill check's own assumption (it required history, and in those runs the fetch's receipts came first and linked from group 0, 0 `chain_break`), then made order-independent | | `e2e/run.sh --runs 6` on a70edf76, the head before the last merge of main (which touches nothing the e2e builds), load average 38 to 63, 78% of memory free | **0: 6 of 6 runs passed, 444 of 444 checks**; every scenario: `compose`, `webtransport`, `websocket` (with the page that falls back to the binding by itself), `goaway` (every page moved and closed its old session itself, 0 closed at the timeout) | | after the last merge: `scripts/check-lockfiles.sh`, `wai registry export --check`, `wai_jwp_vectors verify`, `wai_live_room_vectors verify`, `cd wai/wai-live-web && cargo test --locked` | 0 | ### Text and registry WAI and joule-code CHANGELOGs; jwp-receipts §1, §2.4, §2.7 (new), §3 step 3, §5.9, §5.12 (new); moq-streaming-format §4.1; live-rooms §1, §6.1, §6.2, §6.3, §10, §10.1, §10.2, §10.4, §10.5 (new), §11, §12, §13; the corpora's READMEs; the relay README's *Receipts* and *Tokens and room grants*; `joule-moq`'s and `joule-moq-web`'s READMEs; the registry (six wire identifiers, two kinds) and `wai registry export`. SPEC §5 registers no new capability or record, so it is unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- **Closed:** landed in main through #325 (joule-moq/ingest), which contains this branch's head 46dbd1a6 unchanged. #325's relevant CI was green but two failures outside this work: a wai-quantum nondeterminism test and a jouleclaw GPU-energy test on the macOS runner.
The endpoint traits, the in-memory mock and the QUIC transport stay where they were,
under the default feature runtime (quic implies it). Turned off, the crate is the
draft-22 codec, the carriage of objects and receipts, the receipt builder and
verifiers and the session's setup rules, with no tokio and no native dependency, so a
browser crate can build the same code for wasm32-unknown-unknown. Additive: every
dependent that takes the default features builds what it built before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A wasm-bindgen surface, byte-oriented: the client's SETUP, every request and answer a
client sends from a JSON description, framed control messages and data streams read as
their bytes arrive, subgroup streams written object by object, a publisher's signer
(each group one object, signed into a group receipt chained to the one before, written
as the receipts-track object and the media object with its WAI_RECEIPT property) and a
subscriber's verifier (receipts admitted to the joined chain under the publisher's key,
each object verified against the receipt of its position, or accounted for by
inclusion). Native tests hold the pure-Rust half.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The live-compose layout function and mixer, the WCD1 descriptor and the composition of
a room at an instant (its lifecycle replayed, its participant set, its tiles and
voices), in the JSON view live-conformance/compose states its cases in; WEV1 events
signed and read with their payloads (captions with their origin) and the MSF timeline
binding; what a page reads of a room descriptor and its grants; a Room that holds the
records a page receives, chain by chain, and verifies and composes from them; and the
WAI1 envelope packed and read. The same functions build natively: a helper binary makes
a test room (descriptor, grants, composition descriptor, relay token rules) and computes
on the host the composition a page computes in the browser.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
web/moq: the WebSocket binding's capsules, WebTransport and the binding behind one
transport interface (WebTransport first, offering moqt-22 and the relay's certificate
hash; the binding as the fallback, or forced), a MOQT session (SETUP with the token,
requests, every data stream read concurrently, GOAWAY), and the payload framings a WAI
track carries. web/room: the room client (WebCodecs publishing with receipts, verified
subscriptions, a late joiner's FETCH, the composed canvas and the mixed AudioWorklet,
the events timeline and captions with their origin, the move after GOAWAY). web/compose:
the composition corpus in the page. e2e: the relay on loopback from a test
configuration, real Chrome through Playwright with a fake camera and microphone, each
browser under memguard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
An object whose receipts-track object reached the relay just before the receipts
subscription did never gets it live; after a second its group of the receipts track is
fetched (moq-streaming-format §4.1). e2e/package-lock.json pins the automation package
by integrity (playwright 1.60.0 and playwright-core, Apache-2.0; fsevents, MIT, optional,
its install script never run: npm ci --ignore-scripts).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
joule-moq-web e2e: the browser's executable comes from BROWSER_BIN alone
Some checks failed
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 2m3s
joule-moq-web browser client / joule-moq-web · does this change touch it (pull_request) Successful in 6s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 15s
joule-code conformance gate / joule-moq-relay, every target (pull_request) Successful in 1m4s
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 1m29s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (pull_request) Successful in 5m51s
standards workspaces build + test / macOS energy backends (pull_request) Has been cancelled
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Has been cancelled
8c80ef3434
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A late joiner whose live receipts anchored its chain, then fetched the track from
group 0, had group 0 refused as chain_break although group 1 links to it. Below a
join's anchor the gap has no lower end, and a group with a null parent there is the
chain's true start (jwp-receipts §3 step 3, Joining): it is now parent_not_held, held
as history, and admitted when the group above it links back to it. A null parent
outside such a gap is still a second chain head, and a fork still chain_break.

The same rule in joule-moq's ReceiptChains, wai-rs's and jwp_verify.py, the
spec text of §3 step 3 and §5.9, and two corpus vectors
(chain_join_fetch_from_group_0, chain_join_second_start_after_the_fill).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
jwp-receipts §2.7 defines a binary form for group and object receipts and for a
group's receipts together (magic WJR1): a group is its canonical bytes (§3 step 3),
its link hash computed over them as received; an object is its members in fixed
widths, its leaf_version 1 or 2 followed by its class. Reading yields the JSON
form's members, and every rule of §3 applies to them unchanged. §2.7.4 says what
does not read (malformed, at its §3 step); §5.12 the vectors.

moq-streaming-format §4.1: a receipts-track object carries that form.

wai-rs: jwp_delivery::binary (reader and writer, through from_json), and 20
binary vectors in jwp-conformance; jwp_verify.py reads and writes them from the
text. joule-moq: GroupReceipt/ObjectReceipt to_binary/from_binary, read_receipts,
ReceiptBundle::to_payload/from_payload in the binary form (and read, with the link
hash over the bytes received); the JWP_OBJECT_RECEIPT property carries an object
receipt standing alone. The JSON payload is not read. joule-moq-web's verifier
measures each receipts object, and the same receipts in the JSON form.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
§6.1 names a media track's receipts track (its namespace, its name followed by
_receipts) and a publisher's catalog track (the room and owner fields, Track Name
catalog), every byte still in a-z, 0-9 and _; §6.2 ties the catalog's wai_receipts_track
to it. §10.2 adds the scopes: the publisher's and each slot's catalog track, and for a
subscriber every catalog and each layer's receipts track. wai-rs (RECEIPTS_SUFFIX,
CATALOG_TRACK, receipts_track_name, catalog_namespace, moqt_scopes) and
live_rooms_verify.py agree.

The browser client and the relay use those names: the receipts tracks are 0_receipts,
and joule-moq-relay's receipts_track_suffix defaults to _receipts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A grant is public, so no binding takes it as a bearer credential. §10.5 requires a
WRP1 proof record beside every grant: the grant's digest, the principal's key, the
verifier's fresh challenge and a context (a verb and the //authority/path the client
addressed), signed by the principal. Over MOQT the relay's SETUP carries the
challenge (GRANT_CHALLENGE, 0x3A47), sent at once, and the client's SETUP the grant
as its AUTHORIZATION TOKEN and the proof (GRANT_PROOF, 0x3A49); a SETUP whose grant
has no proof that passes is closed UNAUTHORIZED. Over HTTP (WHIP, WHEP) the grant is
the Bearer token, the proof the WAI-Room-Proof header, and the challenge comes in
WAI-Room-Challenge on a 401 and on each accepted response, single-use. §10.5.4 gives
the seven checks and their reasons (pop_missing to pop_wrong_context, in §11); the
CAT mapping and the security text say so.

wai-rs: Proof (sign, read), check_proof, grant_digest_and_principal, base64url;
the rooms corpus gains 17 proofs (MOQT and HTTP, good, replayed, wrong key, forged,
another session's challenge, another grant, another endpoint or method, a missing
proof and unreadable records). live_rooms_verify.py checks them from the text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
joule-moq: grant_proof (the WRP1 record, check_proof, the SETUP options and helpers,
grant_digest_and_principal, base64url) and a strict Ed25519 check shared with the
receipts. The relay sends its SETUP first with a fresh GRANT_CHALLENGE, checks every
grant its rules mark proof against the client's GRANT_PROOF before serving any
request, and closes a session that presents one without a proof that passes with
UNAUTHORIZED; the transports give it the target the client addressed. Its test client
presents grants with proofs; tests/grant_proof.rs. The web client presents its grant
with a proof signed in wasm, the per-participant secret gone.

Not yet built or run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Under load (e2e --runs 4, host load near 100) one run's page met a VP9 decoding
error and never decoded that track again: WebCodecs closes a codec that errs, and
the page went on handing frames to the closed decoder. Each group rides a stream of
its own and waits for its receipts, so a group can be released after a later one;
decoded then it corrupts the decoder's references. The page now drops such a group
(counted as late_dropped), and makes a decoder again after an error, the video
waiting for its next key frame (decoder_restarts).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
live-rooms §10.5.3: cite RFC 9449's resource-server nonce (§9), and RFC 9725 without sections
Some checks failed
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · macos (pull_request) Has been cancelled
WAI conformance / codec wrappers · fuzz smoke, text decode limits, C entry points, provenance, the semantic path (pull_request) Has been cancelled
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Has been cancelled
WAI energy receipts / does this change touch it (pull_request) Has been cancelled
WAI energy receipts / energy receipts · linux (pull_request) Has been cancelled
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Has been cancelled
WAI energy receipts / energy receipts · macos (pull_request) Has been cancelled
WAI HTTP delivery / WAI HTTP · does this change touch it (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify mutations 0/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify mutations 1/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify mutations 2/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify patches 0/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify patches 1/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify patches 2/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · every patch and mutation in exactly one shard (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · verified both ways, and the differential (pull_request) Has been cancelled
WAI HTTP delivery / cache rules · controls and labelled caches (pull_request) Has been cancelled
wai-pack / wai-pack · does this change touch it (pull_request) Has been cancelled
wai-pack / wai-pack · linux (pull_request) Has been cancelled
wai-pack / wai-pack · linux-arm64 (pull_request) Has been cancelled
wai-pack / wai-pack · macos (pull_request) Has been cancelled
wai-pack / wai-pack · independent verifier (Python) (pull_request) Has been cancelled
wai-pack / wai-pack · the floor through a media prober (pull_request) Has been cancelled
wai-pack / wai-pack · dependency licences (pull_request) Has been cancelled
wai-web reach builds / wai-web reach · does this change touch it (pull_request) Has been cancelled
wai-web reach builds / wai-web reach builds · the package of record, every output as recorded (pull_request) Has been cancelled
wai-web reach builds / wai-web reach builds · this tree's wai-web, every rung on its engine line (pull_request) Has been cancelled
wai-web wasm32 / does this change touch it (pull_request) Has been cancelled
wai-web wasm32 / wai-web wasm32 build · corpus, menu and writers (pull_request) Has been cancelled
a70edf764a
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Merge origin/main into joule-moq/harden
Some checks failed
WAI HTTP delivery / HTTP corpus · falsify mutations 0/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify mutations 1/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify mutations 2/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify patches 0/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify patches 1/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify patches 2/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · every patch and mutation in exactly one shard (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · verified both ways, and the differential (pull_request) Has been cancelled
WAI HTTP delivery / cache rules · controls and labelled caches (pull_request) Has been cancelled
WAI energy receipts / does this change touch it (pull_request) Has been cancelled
WAI energy receipts / energy receipts · linux (pull_request) Has been cancelled
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Has been cancelled
WAI energy receipts / energy receipts · macos (pull_request) Has been cancelled
WAI conformance / does this change touch it (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · linux (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · macos (pull_request) Has been cancelled
WAI conformance / quantum core and corpus under wasm32-wasip2 (wasmtime) (pull_request) Has been cancelled
WAI conformance / v2 vocabulary · manifests validate as stated (pull_request) Has been cancelled
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Has been cancelled
WAI conformance / delivery telemetry · CMCD and CMSD against an independent library (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · linux (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · macos (pull_request) Has been cancelled
WAI conformance / codec wrappers · fuzz smoke, text decode limits, C entry points, provenance, the semantic path (pull_request) Has been cancelled
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Has been cancelled
standards workspaces build + test / which workspaces this change touches (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backends (pull_request) Has been cancelled
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Has been cancelled
joule-code conformance gate / joule-moq-relay, every target (pull_request) Has been cancelled
a8b02f7284
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The relay's session setup now proves every room grant (live-rooms §10.5) and then opens
the session's tally on the room's metered grant (live-metering). REGISTRY lists WJR1,
WMH1 and WMR1; the CHANGELOG keeps both entries; capabilities.json is regenerated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai registry export: WJR1 and the metering records together
Some checks failed
WAI HTTP delivery / HTTP corpus · falsify mutations 0/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify mutations 1/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify mutations 2/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify patches 0/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify patches 1/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify patches 2/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · every patch and mutation in exactly one shard (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · verified both ways, and the differential (pull_request) Has been cancelled
WAI HTTP delivery / cache rules · controls and labelled caches (pull_request) Has been cancelled
WAI energy receipts / does this change touch it (pull_request) Has been cancelled
WAI energy receipts / energy receipts · linux (pull_request) Has been cancelled
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Has been cancelled
WAI energy receipts / energy receipts · macos (pull_request) Has been cancelled
WAI conformance / does this change touch it (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · linux (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · macos (pull_request) Has been cancelled
WAI conformance / quantum core and corpus under wasm32-wasip2 (wasmtime) (pull_request) Has been cancelled
WAI conformance / v2 vocabulary · manifests validate as stated (pull_request) Has been cancelled
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Has been cancelled
WAI conformance / delivery telemetry · CMCD and CMSD against an independent library (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · linux (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · macos (pull_request) Has been cancelled
WAI conformance / codec wrappers · fuzz smoke, text decode limits, C entry points, provenance, the semantic path (pull_request) Has been cancelled
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Has been cancelled
standards workspaces build + test / which workspaces this change touches (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backends (pull_request) Has been cancelled
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Has been cancelled
joule-code conformance gate / joule-moq-relay, every target (pull_request) Has been cancelled
46dbd1a669
Co-Authored-By: Claude Opus 5.5 <[email protected]>
dcharlot closed this pull request 2026-10-19 05:55:26 -04:00
Some checks failed
joule-moq-web browser client / joule-moq-web · does this change touch it (pull_request) Successful in 6s
wai-web reach builds / wai-web reach · does this change touch it (pull_request) Successful in 5s
WAI mbt2018_mean integer decode (regenerated vectors) / does this change touch it (pull_request) Has been cancelled
WAI mbt2018_mean integer decode (regenerated vectors) / regenerate mbt2018_mean integer vectors → Rust decode, required (pull_request) Has been cancelled
wai-quantum WebGPU member / does this change touch it (pull_request) Has been cancelled
wai-quantum WebGPU member / shader checks · browser build · wasip2 inertness · MSRV (pull_request) Has been cancelled
wai-quantum WebGPU member / member · software Vulkan (linux) (pull_request) Has been cancelled
wai-quantum WebGPU member / member · Metal (macos) (pull_request) Has been cancelled
wai-quantum WebGPU member / member · discrete GPU, Vulkan (jetson-hub) (pull_request) Has been cancelled
wai-quantum WebGPU member / member · integrated GPU, Vulkan (framework-128gb) (pull_request) Has been cancelled
wai-quantum WebGPU member / member · browser WebGPU (macos) (pull_request) Has been cancelled
wai-quantum WebGPU member / member · DX12 and Vulkan (cbio-windows) (pull_request) Has been cancelled
WAI neural sink gates / does this change touch it (pull_request) Has been cancelled
WAI neural sink gates / model-free refusals of the neural sink (pull_request) Has been cancelled
WAI JPEG-AI whole decode (pinned inputs, required) / does this change touch it (pull_request) Has been cancelled
WAI JPEG-AI whole decode (pinned inputs, required) / fetch pinned JPEG-AI inputs → whole decode + pixel hash, required (pull_request) Has been cancelled
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 2m12s
wai-web reach builds / wai-web reach builds · the package of record, every output as recorded (pull_request) Successful in 2m51s
wai-web reach builds / wai-web reach builds · this tree's wai-web, every rung on its engine line (pull_request) Successful in 4m3s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (pull_request) Successful in 6m10s
wai-web wasm32 / does this change touch it (pull_request) Has been cancelled
wai-web wasm32 / wai-web wasm32 build · corpus, menu and writers (pull_request) Has been cancelled
wai-pack / wai-pack · does this change touch it (pull_request) Has been cancelled
wai-pack / wai-pack · linux (pull_request) Has been cancelled
wai-pack / wai-pack · linux-arm64 (pull_request) Has been cancelled
wai-pack / wai-pack · macos (pull_request) Has been cancelled
wai-pack / wai-pack · independent verifier (Python) (pull_request) Has been cancelled
wai-pack / wai-pack · the floor through a media prober (pull_request) Has been cancelled
wai-pack / wai-pack · dependency licences (pull_request) Has been cancelled
WAI HTTP delivery / WAI HTTP · does this change touch it (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify mutations 0/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify mutations 1/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify mutations 2/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify patches 0/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify patches 1/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify patches 2/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · every patch and mutation in exactly one shard (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · verified both ways, and the differential (pull_request) Has been cancelled
WAI HTTP delivery / cache rules · controls and labelled caches (pull_request) Has been cancelled
WAI energy receipts / does this change touch it (pull_request) Has been cancelled
WAI energy receipts / energy receipts · linux (pull_request) Has been cancelled
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Has been cancelled
WAI energy receipts / energy receipts · macos (pull_request) Has been cancelled
WAI conformance / does this change touch it (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · linux (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · macos (pull_request) Has been cancelled
WAI conformance / quantum core and corpus under wasm32-wasip2 (wasmtime) (pull_request) Has been cancelled
WAI conformance / v2 vocabulary · manifests validate as stated (pull_request) Has been cancelled
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Has been cancelled
WAI conformance / delivery telemetry · CMCD and CMSD against an independent library (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · linux (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · macos (pull_request) Has been cancelled
WAI conformance / codec wrappers · fuzz smoke, text decode limits, C entry points, provenance, the semantic path (pull_request) Has been cancelled
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Has been cancelled
standards workspaces build + test / which workspaces this change touches (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backends (pull_request) Has been cancelled
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Has been cancelled
joule-code conformance gate / joule-moq-relay, every target (pull_request) Has been cancelled

Pull request closed

Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!327
No description provided.