wai: stage receipts — chains per key and track, verified by inclusion, and the stage and delivery-gap claims #171
Merged
dcharlot
merged 24 commits from 2026-10-06 08:37:30 -04:00
wai/w1-5-stage-receipts into main 2026-10-06
Merge main (HTTP delivery merged as #170) into wai/w1-5-stage-receipts
All checks were successful
standards workspaces build + test / joulehook-rs (pull_request) Successful in 1m8s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m12s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 6m48s
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Successful in 5m11s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m14s
wai cross-architecture byte-equality / macos-arm64 (reference) (pull_request) Successful in 37s
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Successful in 23s
standards workspaces build + test / sense-rs (pull_request) Successful in 1m4s
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Successful in 1m39s
wai-pack / wai-pack · linux-arm64 (pull_request) Successful in 44s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 3m36s
wai cross-architecture byte-equality / linux-x86_64 (pull_request) Successful in 23s
WAI conformance / cargo-class corpora · linux (pull_request) Successful in 5m37s
WAI conformance / v2 vocabulary · manifests validate as stated (pull_request) Successful in 12s
WAI conformance / cargo-class corpora · macos (pull_request) Successful in 11m33s
WAI conformance / cross-arch byte-equality · macos (pull_request) Successful in 37s
WAI energy receipts / energy receipts · macos (pull_request) Successful in 3m28s
wai-pack / wai-pack · macos (pull_request) Successful in 1m31s
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Successful in 14m48s
WAI conformance / delivery telemetry · CMCD and CMSD against an independent library (pull_request) Successful in 8s
WAI conformance / cross-arch byte-equality · linux (pull_request) Successful in 22s
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Successful in 44s
WAI energy receipts / energy receipts · linux (pull_request) Successful in 1m32s
WAI neural sink gates / model-free refusals of the neural sink (pull_request) Successful in 1m3s
wai-pack / wai-pack · linux (pull_request) Successful in 42s
wai-pack / wai-pack · independent verifier (Python) (pull_request) Successful in 54s
wai-pack / wai-pack · dependency licences (pull_request) Successful in 1m6s
WAI section references / cited sections exist (pull_request) Successful in 9s
wai-web wasm32 / wai-web wasm32 build · corpus, menu and writers (pull_request) Successful in 1m51s
workflow hygiene / every workflow supersedes its own queue (pull_request) Successful in 3s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai-rs: the stage observer follows the sink's per-base epoch, not a caller's polling
Round-3 item 4. - StageSink::base_epoch(hash) counts the times the sink has forgotten a base: evicted under the record budget, discarded by forget_base, or dropped with nothing to keep. - StageObserver records the epoch with every event (arrived, dropped, reports take it). A later epoch starts the observation over: an envelope the sink still holds for the base keeps its arrival, bytes and evidence and is held again with no decision, every other object is dropped, and no delivered stage is stated until the caller says the base's receipt was verified first. A stale epoch's event is ignored. claim(chain, epoch) never spans two epochs: if the sink forgot the base since the last event, the claim is made over the observation started over. forget() is gone. - Tests from the review's probe3: a layer held across the forget is stated applied once the base is decoded again and the layer released; and a base attested, refined, forgotten and decoded again unattested states no delivered stage and no layer over units at stage 0, though `remembers` is true again by claim time. The corpus replay now feeds each observer the sink's epochs instead of polling `remembers`. - staged-delivery §10.2 and the CHANGELOG say so. Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai: stage rule 5 counts per unit — partial objects, and layers over units the layer below reached
Round-3 item 3. Rule 5 counted coverage per layer, so a partial object counted as though applied to its whole span, and a layer could be applied over units the layer below never reached. Now, for every layer s: an applied object of layer s lies within the spans of layer s - 1's applied and partial objects (the set, for layer 1), and a partial one meets them; and the units at stage s or above are no more than layer s can have reached — its applied objects' units, and, of the units below them within layer s - 1's spans, those its partial objects cover, at most each one's span less one. A partial object covers at least two units (rule 4). Interval arithmetic in both verifiers, since a claim may name very large unit counts. claim-conformance (through `wai_claim_vectors gen`): the review's three cases as refusals and its honest partial layer as an accept, and a partial object over one unit. The corpus replay still seals every honest claim; claim_verify.py mirrors the rule, and the differential agrees (0 differ). §2.4.10 rule 5 and the rule-6 justification, which overstated for partial objects, are rewritten; §3.1's reasons, §5.10, the README and the CHANGELOG follow. Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai: jwp-conformance gains a stale group that must move no live edge
chain_stale_group_moves_no_edge: a stale group 2 naming a group not held, then a stale group 1 linking to the head. Only a group that arrived inside the window moves the edge, so group 1 is refused as stale; a verifier that lets a stale group move the edge would accept it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai-rs: a stale group exactly at the live edge is refused
The exemption is for a group_id strictly below the edge: a second, stale group with the edge's group_id that links to the head is not history. The test now shows it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai: the freshness exemption follows the signed group_id, not a requested set; corpus prefix pinned
Round-3 items 1, 2, 5 and 6. - A relay could forge the "requested" exemption for a legacy group at the live edge (it rewrote the stale group's unsigned link to name a group not held, and the genuine group then passed as requested), and a gap past 64 groups still wedged the chain. The requested-links set and CHAIN_REQUESTED_LINKS are gone. A chain's live edge is the highest group_id of a group that verified and arrived inside the window, linked or not; a group whose group_id is below it is history and exempt from the bound. Both payloads sign group_id (it is the second field of each), so no rewritten link moves the edge or earns the exemption; no count bounds a gap, and it fills in any order. ReceiptChains::live_edge replaces requested(). - jwp_verify.py mirrors it. New vectors: the rewritten legacy link (refused) and its labelled control, and a gap of seventy groups walked back and forward (every group accepted). chain_gap_differential.py, the review's differential over long, shuffled, withheld and rewritten chains, is committed and runs in CI on two seeds (0 differences). - group_parent_not_the_previous moves to the end of jwp-conformance, so the pre-branch vectors are again its prefix; a test pins them, less group_chain_break, by a digest computed from the corpus before this branch. - §3 step 3, §6.1 (which now states the rule and why it is safe), §3.3, §5.9, the README and the CHANGELOG follow. §6.1's older duplicate "Both are per track" bullet is deleted. Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai: §2.4.10 as the code now checks it — six rules, unchecked spans, delivered against presented
Text for fix-round items 2-6 and the observation. - jwp-receipts §2.4.10: an object refused before its `refines` was checked lists its units as stated and delivers nothing; an arrived object has bytes. Rule 4 makes the evidence of an applied layer depend on whether a delivered stage is stated, counts the base's bytes in the set's, and refuses one layer applied twice to a unit. New rule 5, the units against the objects (`stage_units_unsupported`); rule 6, the delivered stage. `stage_presented_above_delivered` is gone: rules 4 and 5 make it unreachable, and the text says why. A new paragraph says delivered and presented are separate measures, and that a set whose layers were all refused meets its commitment with stage 0 presented. - §3.1's reason table and §5.10 follow; staged-delivery §10.2 says a claim's observation starts over when the sink forgets the base; the CHANGELOG and the claim-conformance README say the same, and that one existing jwp vector changed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai-rs: a layer applied, then refused on a later offer, stays applied
staged::claim_tests::a_layer_applied_then_refused_on_a_later_offer_stays_applied: what the sink applied decides an object's status before any later envelope refusal. Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai-rs: a layer refused then held is stated as held
staged::claim_tests::a_layer_refused_then_held_states_the_hold: a second layer refused for inclusion-only evidence, then offered again with delivered evidence and held for units below its stage, is stated refused with layer-gap, the sink's last decision, not with the receipt reason it no longer has. The refused-then-applied test could not show the clearing of an earlier refusal, since what was applied decides the status first. Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai-rs: the observer states what the sink did — refusals cleared, spans from the envelope, forgotten bases
Fix-round items 2 and 4. - A report that refuses nothing for the envelope, or applies a unit, clears an earlier refusal and hold: a layer refused (refinement-unattested, chain-over-limit) and then offered again and applied was stated refused. What the sink applied now decides the status first. - On arrival the envelope's own `refines` sets the object's layer and span, which its content hash authenticates, over whatever a receipt-only sighting supposed: a guessed span let a claim state a delivered stage no delivered object reached. - A base the sink forgets (§4.4, §14) is, decoded again, one it never decoded: `StageSink::remembers` says whether the sink still holds or keeps anything of a base, and `StageObserver::forget` starts the observation over. Without it, a first layer applied before the record budget forgot the base and another applied after would both be stated applied to the same units, which §2.4.10 now refuses. - staged::claim_tests: every staged-conformance case replayed through StageSink and a StageObserver per base (started over when the sink forgets it); each claim must seal, verify, and state for every object exactly what the sink did. The review's probe2 (a) and (b), and a layer refused then applied, are tests of their own. Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai: claim-conformance gains a vector where an unchecked layer would lift the delivered stage
stage_unchecked_layer_delivers_nothing_accept: with the second layer dropped, another second layer refused refines-malformed and delivered over its bytes would complete layer 2's coverage if it counted; it does not, so the stated delivered stage of 1 stands. A verifier that counts it (Rust or Python) now fails the corpus. Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai-rs: stage_claim_rules shows an unchecked object delivers nothing
The earlier assertion placed the unchecked object at a layer the set's objects already covered, so counting it changed nothing and its mutation survived. It now sits at layer 2, where only it would complete the coverage. Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai: the stage rules tie units and bytes to the objects, and let honest sinks sign
Fix-round items 3, 5 and 6, and the observation on delivered against presented. - Rule 4 (objects): an object refused before its `refines` was checked against the set (staged-delivery §4.1 steps 1-2: refines-malformed, capability-not-refinement, base-absent) lists its units as its refines states them and is exempt from the range check, so a layer whose span runs past a public base no longer stops a sink signing; such an object delivers nothing to the set. Applied or partial objects must be delivered-verified only when a delivered stage is stated (the base's receipt verified first); otherwise any evidence is allowed, so a sink over an unattested base can sign. An object that arrived has bytes and a dropped one has none; the objects' bytes are fewer than the set's, which counts the base. Two objects of one layer applied to one unit are refused. - New rule 5 (`stage_units_unsupported`): the units at stage s or above are no more than the applied and partial objects of layer s cover. - Rule 6 (delivered) keeps `stage_delivered_disagrees`. `stage_presented_above_delivered` is gone: with rules 4 and 5 a stated delivered stage is never below the one presented, so the reason could not be reached. - claim-conformance (through `wai_claim_vectors gen`): the review's cases a-e as refusals, a set over an unattested base applied on inclusion evidence, a refused span past the set accepted and the same span refused for a checked reason, a set whose layers were all refused meeting its commitment with stage 0 presented, and the objects counting the whole set's bytes. `stage_presented_above_delivered` is now `stage_delivered_yet_applied_without_receipt`, and the null-delivered vector is named for what it shows (`stage_null_delivered_states_no_delivery`). Every vector before this branch is unchanged. claim_verify.py mirrors every rule, and differential.py's stage family now draws spans past the set and the unchecked reasons (0 differ). Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai: jwp-receipts — a legacy link authenticates nothing; forks, gaps, and what changes for issued receipts
Text for fix-round items 1, 7, 8 and 9. - §3 step 3 now lists its cases against the chain of the group's key and track: the head offered again; no head, where a null parent starts the chain; a head held, where the head's link links, a null parent or a held non-head parent is chain_break and any other parent is parent_not_held (requested). It says a legacy link authenticates nothing, and that the legacy payload signs group_id, so a verifier MUST refuse a legacy group whose group_id does not exceed its head's; the guard stays opt-in for labelled groups. A group requested to fill a gap is exempt from the freshness bound. - §6.1 says the same of replay, says what a legacy chain still cannot rule out (a dropped group with the next link rewritten), and says that receipts chained per key across non-zero tracks now fail at step 3 (parent_not_held). - §3.3 splits chain_break from parent_not_held; §5.9 and the jwp-conformance README describe the new vectors and chain_differential.py. The CHANGELOG no longer says no issued receipt changes meaning: that holds for track_alias 0 and per-track chains. Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai: a legacy chain's history cannot be replayed as new; gaps are told from forks and refilled
Fix round, items 1, 7, 9 and 10 of the review of W1-5. - A legacy group's parent link is outside its signature, and its link hash does not cover the link: rewriting the first group's link to name the head re-linked the whole history, and every replay was accepted with the guard off. The legacy payload does sign `group_id`, so ReceiptChains now holds every legacy group to the strictly increasing `group_id` guard, whatever the opt-in setting; the guard stays opt-in for labelled groups, whose payload signs the link. - ReceiptChains keeps, per chain, the link hashes of the groups it accepted. With a head held, a parent that names one of them other than the head is `chain_break` (a fork); a parent that names none is `parent_not_held` (a gap), and is recorded as requested. A requested group fills a gap, is history, and is exempt from the freshness bound, so one group a relay delays past the window no longer wedges the chain. - GroupReceipt::check_parent treats a previous group of another key or track as not held: a null parent starts the chain, any other is `parent_not_held`; a parent that is not the held head's link names a group not held, and is `parent_not_held` too. - jwp_verify.py mirrors all three, and computes days from civil dates in integers, so a `group_end` in year 0000 no longer crashes it. - jwp-conformance: v6's base track is now labelled, so the headline vector does not rest on legacy links; new vectors for the legacy guard, the legacy replay and its loop, a labelled replay, a fork, a gap, the freshness gap refilled, receipts chained per key across tracks, and previous groups of another track or key. `group_chain_break`, which predates this branch, now holds a genuine chain break (a second chain head); its old input, a parent that is not the previous group held, is `group_parent_not_the_previous` and is `parent_not_held`. A lib test now regenerates and runs the pinned JWP corpus. - chain_differential.py, the review's differential over signed chains, is committed and runs in CI on two seeds (0 differences on each). Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai: staged-measurement §10.2 says what a dropped object is in receipt terms
The simulator models no receipt; over a network a dropped object's receipts still reach the sink (jwp-receipts §4.1), which accounts for it as inclusion-verified and lists it as dropped in a stage claim. CHANGELOG notes it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai: a group on another track is never a predecessor, whoever hands it over
GroupReceipt::check_parent required the previous group to be the same key's; it now requires the same key and track, as jwp-receipts §3 step 3 says, so a caller that hands over a group of another track does not link to it even when the hashes agree. jwp_verify.py reads it the same way, and jwp-conformance gains group_parent_on_another_track (through `wai_jwp_vectors gen`; every other vector is unchanged). Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai: jwp-receipts — chains per (key, track), inclusion-verified, the stage and delivery-gap classes
The text W1-5 owes, on the code and corpora already landed on this branch: - jwp-receipts §2.3, §2.4.1, §3 step 3 and step 6, §6.1: receipt chains and the `group_id` guard are per (`signer_pubkey`, `track_alias`). The text disagreed with itself (per key in §2.4.1 and §6.1's first bullets, per track in the signing payload and §6.1's last); it now says per key and track throughout. A group that passes steps 1-3 can head its chain whether or not its objects arrived. §6.1 names the reference verifier's opt-in guard and freshness bound, and the new step-3 reasons `group_id_not_increasing` and `group_not_fresh` join §3.3. - §3 *Verified by inclusion*: an object receipt checked without the object's bytes is `inclusion-verified`, never acceptance or delivery. §4.1 makes a relay forward the receipts of objects it does not forward; §4.2 lets it sign a `delivery-gap` claim; §4.4 says how a subscriber accounts for an object it did not receive. - §2.4.4 rows and new §2.4.10 `stage` and §2.4.11 `delivery-gap`, their reasons in §3.1, their authorities in §7.5, their corpora in §5.9 (v6_per_track_chain) and §5.10. §2.4.9's vocabulary row names staged delivery's `urn:wai:staged-delivery:1`, which energy-binding §7.2 registers. - staged-delivery §10 splits into §10.1 attestation (naming the two evidences and the per-track chains) and §10.2 claims; its attestation references point to §10.1. http-delivery §10.5 says what an abandoned refinement is in receipt terms. SPEC's pointer to jwp-receipts lists the new classes. CHANGELOG entry. - The rule-5 vector is named for rule 5 (`stage_null_delivered_skips_rule5`). Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai-rs: staged::claim — a sink states its
stage claim from what it observed
`StageObserver` watches one stage set at a sink (staged-delivery §10.2): the layer envelopes that arrived with their receipts' evidence, the layer objects known only by their receipts (dropped at a relay or abandoned at a deadline, verified by inclusion), and every OfferReport the sink gave. Over the base's chain it states the jwp-receipts §2.4.10 body: each object's status and first reason (a unit still held counts as `layer-gap`, an envelope held for its base as `base-absent`), its evidence and bytes; units by stage, frozen units and the units a layer missed the deadline of; the tier and stage hash of what is presented; and, only where the base's receipt was verified over its bytes before its first layer, the delivered stage the objects give, with the request and commitment beside it. A test drives a chain through three sets — a dropped second layer, a live set with a late first layer and a partial second, and an unattested base — and each body seals and verifies. Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai: §7.5's
stage and delivery-gap authorities, in the key-discovery corpus
A `stage` claim needs the authority `stage` and a `delivery-gap` claim `delivery-gap` (claim_authorities reads the class label, so no code changes). signer-conformance gains a revision listing a sink's key with `stage` and a relay's with `delivery-gap`, and four confirm vectors: each class confirmed under a fetched anchor (neither is pinned-only), and each refused (`authority_not_granted`) under a key holding another authority — a delivery key does not observe a stage set. `wai_signer_vectors gen` appends them; every existing vector is unchanged. signer_verify.py reports every outcome, and its verdicts equal `wai_signer_vectors verdicts` line for line. The key-log test over the claim corpus now requires a confirmable claim of both classes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai: claim-conformance gains a stage claim whose delivered objects leave a hole
The differential found that no corpus vector exercised a delivered stage over objects whose spans leave a unit uncovered: claim_verify.py with its hole check removed still passed the corpus. `stage_delivered_over_a_hole` (frame 2's first layer dropped, a delivered stage of 1 stated) is refused at step 4 with stage_delivered_disagrees, and the same mutation now fails the corpus. Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai: the
stage and delivery-gap claim classes, with their corpus, Python verifier and differential
claim_receipt gains two classes of jwp-receipts §2.4.4, both about one object: - `stage` (§2.4.10): what a party observed of a stage set, about its base — the stage requested, committed, delivered and presented, as points of staged delivery's vocabulary `urn:wai:staged-delivery:1` (`wai-stage:layers`); units by stage; the tier and stage hash of what was presented; every layer object with its units, bytes, status (`applied`, `partial`, `late`, `dropped`, `refused`), evidence (`delivered-verified`, `inclusion-verified`, `none`) and staged-delivery §4.2 reason; conflicts, deadline misses and envelope bytes. Step 4 checks five rules in order — points, stage hash, units, objects, delivered stage — and the delivered stage must be the one the objects give (`StageClaim::delivered_stage`). Whether a commitment was met is derived, never stated. - `delivery-gap` (§2.4.11): a relay's statement that it did not forward the object at (track_alias, group_id, object_id), with `delivery-timeout`, `discretion` or `not-received`; checked at step 6 against the object receipt at those coordinates (`gap_subject_mismatch`). Eight reasons are added (`stage_point_malformed`, `stage_point_incomplete`, `stage_hash_without_tier`, `stage_units_malformed`, `stage_objects_malformed`, `stage_delivered_disagrees`, `stage_presented_above_delivered`, `gap_subject_mismatch`). `wai_claim_vectors gen` appends 49 vectors; the 237 before them are unchanged, pinned by a digest computed before the classes were added. claim_verify.py reports every outcome, and differential.py gains body defects for both classes and a random family around every stage rule (0 differ at --random 2000 --seed 1). Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai: jwp-conformance gains the per-track chain (v6) and inclusion-only vectors
`wai_jwp_vectors gen` appends two procedures to jwp-conformance/vectors.json; every existing vector is unchanged byte for byte. - `chain`: group receipts in arrival order through §3 steps 1-3 against one chain per (signer_pubkey, track_alias). `v6_per_track_chain_*`: one key's base track (7) and layer track (8), three chained groups each, alone and interleaved; the layer's second group without its first (parent_not_held); a parent on another track (chain_break). Beside them: a fork at the head, the head offered again, another key on one track, the opt-in group_id guard off, on and per track, the opt-in freshness bound (at the clock, with an offset, at the window, stale, ahead of the clock, stale with no bound), and a group whose signature fails heading nothing. - `inclusion`: an object receipt checked without its envelope is `inclusion-verified`; a forged group, a rewritten content hash and a moved position are refused. jwp_verify.py, written from the text, reports every outcome; its verdicts equal `wai_jwp_vectors verdicts` line for line. Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai-rs: JWP chains per (key, track), inclusion evidence, the opt-in group_id guard and freshness bound
jwp_delivery gains what jwp-receipts §3 step 3, §4.4 and §6.1 ask of a verifier: - `ReceiptChains`: one receipt chain per (signer_pubkey, track_alias). A group on one track neither breaks nor links to another's, so a subscriber verifies the tracks it holds without the receipts of tracks it does not. A group heads its chain once it passes steps 1-3, whether or not its objects arrived, so a dropped object does not break the next group's link. - `Evidence` and `ObjectReceipt::verify_against(group, envelope: Option<&[u8]>)`: over the envelope, `delivered-verified` (step 6 acceptance); without it, steps 1, 2 and 5 only, `inclusion-verified`, which is never delivery. - The opt-in checks §6.1 says the reference verifier exposes, now real: the monotonic group_id guard per (key, track) (`group_id_not_increasing`) and the freshness bound on `group_end` (`group_not_fresh`), both at step 3, both off by default. - `claim_receipt::rfc3339_seconds`, the inverse of `rfc3339_utc`. - staged: `ReceiptEvidence: From<jwp_delivery::Evidence>`, and a test in which a layer's receipt verified without its bytes is refused and over its bytes applies. Co-Authored-By: Claude Opus 5.5 <[email protected]>