route-record: version 3 states what a hop delivered by its count and root, so a hop may deliver any number of objects #563

Merged
dcharlot merged 4 commits from wai/route-outputs into main 2026-11-02 03:22:10 -05:00
15 changed files with 2047 additions and 85 deletions

View file

@ -115,7 +115,8 @@ mod tests {
assert_eq!(Keys::from_km(&km, "another passphrase").unwrap_err(), KmError::BadSecret);
let mut p = vec![0x47u8; 1316];
assert!(tx.apply(1, 77, &mut p));
assert_ne!(p[0], 0x47);
// The whole payload: under a random key, one byte keeps its value one time in 256.
assert_ne!(p, vec![0x47u8; 1316]);
assert!(rx.apply(1, 77, &mut p));
assert_eq!(p, vec![0x47u8; 1316]);
assert!(!rx.apply(2, 77, &mut p), "no odd key is held");

View file

@ -290,6 +290,8 @@ async fn contribution_at_twenty_megabits_passes_the_door() {
/// follow the group's last object, so it reads each group whole off its stream before it
/// releases any of it, which the relay's stream window (`limits.quic_stream_window_bytes`)
/// must hold: under QUIC's default of about 1.25 MB the stream stops and the door refuses.
/// The link's window is two seconds: the door is the subject, and on a loaded runner a stall
/// past 300 ms had the gateway drop access units as too late (§4.6) before they reached it.
async fn big_groups() {
let w = World::start(|c| {
c.contrib.srt.listen = Some("127.0.0.1:0".parse().unwrap());
@ -299,15 +301,21 @@ async fn big_groups() {
.await;
let auth = w.auth(&w.publisher, 0);
let s = open_srt(&w, &auth).await;
let link = timeout(STEP, srt::call(w.gateway.srt_addr.unwrap(), call_cfg(&s))).await.unwrap().unwrap();
let cfg = CallConfig { latency_ms: 2_000, ..call_cfg(&s) };
let link = timeout(STEP, srt::call(w.gateway.srt_addr.unwrap(), cfg)).await.unwrap().unwrap();
assert!(contrib_live(&w, s["session"].as_str().unwrap()).await);
let watcher = watch(&w).await;
let chunks = joule_contrib::cli::synthetic(4, 20_000);
joule_contrib::cli::pace(&link, &chunks, 800).await.unwrap();
assert!(until(STEP, || link.sent.lock().unwrap().unacknowledged == 0).await, "the gateway acknowledged every packet before the publisher closed");
link.close();
assert!(until(STEP, || watcher.ended.lock().unwrap().len() == 2).await, "PUBLISH_DONE on both media tracks");
settle(&watcher, Duration::from_millis(500)).await;
assert_eq!(w.relay.relay().stats().refused_at_door, 0, "the door released every group");
assert!(until(STEP, || w.gateway.gateway.link_records().iter().any(|b| LinkRecord::read(b).is_ok_and(|r| r.last))).await, "the last part is signed");
let ps = parts(&w);
assert!(ps.iter().all(|p| p.window == 2_000_000), "the caller's window, the larger of the two ends'");
assert_eq!(ps.iter().map(|p| p.account.dropped).sum::<u64>(), 0, "no packet dropped as too late");
let all: Vec<u8> = chunks.iter().flat_map(|c| c.1.clone()).collect();
let mut d = ts::Demux::new();
let mut pes = d.push(&all);

View file

@ -6,6 +6,40 @@ wire format may change and such changes are recorded here.
## Unreleased
### The route record states what a hop delivered by its count and root, so a hop may deliver any number of objects
- **Route record version 3** ([extensions/route-record.md](extensions/route-record.md) §3.4, §5.2, §6, §6.1), on a
price book of version 2: the attempt that served states `delivered`, the count of the objects it delivered and their
commitment (the root its hop's receipt already names), not their list; a failed attempt, `0` and zeros. Version 2's
list states at most 1024 outputs and grows with them, and a package of a long title holds an object per piece and
rung, thousands; a record of version 3 is the same size whatever was delivered, and nothing a hop signs changes. The
served hop's receipt is held to the root (`receipt_output_mismatch`). A record of version 1 rests on a price book of
version 1, and one of version 2 or 3 on one of version 2 (`pricebook_mismatch`). Records of versions 1 and 2 read and
verify exactly as before.
- **An output's inclusion proof** (§6.1): `index` and `siblings` from the leaf up, held as jwp-receipts §3 step 5
holds an object's: the index below the count, exactly ⌈log₂ count⌉ siblings, the walk ending at the root, so the last
node of an odd level is proved at its own index only. `route_record::output_proof` makes one and
`route_record::verify_output_proof` checks it; `route_record::seal_committed` writes a record of version 3.
- **The receipt ledger** ([extensions/receipt-ledger.md](extensions/receipt-ledger.md) §5.1, §4.8): a job source whose
route record is of version 3 gives each output `proof`, and an output is one its hop delivered when its proof holds
its object to the served attempt's count and root (`output_not_delivered` otherwise; a missing or misshapen proof,
or one under a record of version 1 or 2, `malformed`); a decode of version 5 is held to its places and its count. The
count is the router's statement, which only the objects check; a count stated larger lets one object be proved at a
second place, and no source bills one object twice. Every tariff version that reads job sources reads them.
- **The corpora.** Routes 109 → 126 vectors: six accepted of version 3 (one output, three, 5,000, a failover, a late
hop, receipts not checked) and eleven controls, the unknown-version control now of version 4.
`route_record_verify.py` reads version 3 and has the proof check (`inclusion_proof`); two more checks dropped
(`delivered-form`, `receipt-root`). Ledger 108 → 120 vectors: one accepted (an encode, a decode of version 5 and a
package's run of 1,501 objects, each output proved) and eleven controls; `receipt_ledger_verify.py` checks every proof
with the route verifier's `inclusion_proof`, one more check dropped (`output-proof`), three more mutated copies.
- **Breaking:** `record:corpus:wai/live-conformance/routes/vectors.json`: the control `record_version_3` is now
`record_version_4`, a record of version 4 refused `unknown_version` as before; every other vector keeps its outcome,
and the new vectors are added.
- **Breaking:** `api:rust:wai::route_record::Accepted::delivered`: an accepted record of version 3 states what its
served hop delivered by its count and root, which `Accepted` carries in this new field (`None` for records of
versions 1 and 2, as `outputs` is `None` for version 3). A struct literal or an exhaustive pattern of `Accepted`
outside the crate adds the field, or matches with `..`.
### The receipt ledger holds a decode to the frames it delivered, as one animated PNG
- **Tariff and invoice version 5** ([extensions/receipt-ledger.md](extensions/receipt-ledger.md) §4.8): version 4's

View file

@ -314,7 +314,9 @@ of version 5 a decode's output is held to what it delivered:
decode's animations, by content hash, in the order of its outputs, and nothing else: an
animation not stated at its place is refused `output_not_delivered` at `outputs`, the
output's index and `frames`, and an object stated past the last, the codestream among them,
at `outputs`.
at `outputs`. Under a route record of version 3 an animation is at its place when its proof
holds it there (§5.1, its `index` its place), and there is none past the last when the
attempt's `count` is the number of animations.
A decode delivers its frames as one object because a clip's frames are many and repeat: a route
record states each of a hop's outputs once, and at most 1024 of them (route-record §5.2), while
@ -347,7 +349,7 @@ extension's verifier is given. Bytes are lowercase hexadecimal strings.
| `package` | `receipt` (the text of `package.receipt.json`), `indexes` (the index files in the receipt's component order) | packaging §9, a receipt of version 2 against its indexes |
| `ads` | `descriptor`, `grants`, `key_log`, `chains`, `policy`, `pt0_ms`, `receipts` (form-2 receipts in their JSON carriage), `pods`, `claims`, `creatives`, `issuers` | live-ads §9, with ad-presentation §9 under a policy that requires claims |
| `stitched` | `records` (pod-input records), `claims` (stitched claims), `manifests`, `responses` (VAST and VMAP documents), `receipts` (form-1 receipts in their JSON carriage), `creatives`, `issuers` | stitched-presentation §7, its signers the tariff's (§4.4) |
| `job` (versions 2 to 4) | `request`, `pricebook`, `policy`, `record`, `receipts`, `keys` (a route record's verification document, route-record §7.1, its `receipts` and `keys` not `null`) and `outputs`, a list of objects of exactly `receipt` (the text of a `wai.video.int_motion` decode receipt's JSON form, integer-payloads §15.2) and `codestream` (the `WIV1` payload it describes: the object the hop delivered, named by its content hash; in version 5 a decode's output also has `frames`, the animation of its frames it delivered, and its codestream is what it read, §4.8), or, in version 4, of exactly `package` (the text of a package receipt of version 2, `package.receipt.json`: the object the hop delivered) and `indexes` (its index files in its component order) | route-record §7, accepted with its hop receipts checked; then each output receipt read by integer-payloads §15.2 and re-derived from its `codestream` (§15.1 there), each package verified against its indexes by packaging §9, or `output_invalid` at `outputs` and its index; then each output held to what its hop delivered, or `output_not_delivered` there |
| `job` (versions 2 to 4) | `request`, `pricebook`, `policy`, `record`, `receipts`, `keys` (a route record's verification document, route-record §7.1, its `receipts` and `keys` not `null`) and `outputs`, a list of objects of exactly `receipt` (the text of a `wai.video.int_motion` decode receipt's JSON form, integer-payloads §15.2) and `codestream` (the `WIV1` payload it describes: the object the hop delivered, named by its content hash; in version 5 a decode's output also has `frames`, the animation of its frames it delivered, and its codestream is what it read, §4.8), or, in version 4, of exactly `package` (the text of a package receipt of version 2, `package.receipt.json`: the object the hop delivered) and `indexes` (its index files in its component order); under a route record of version 3, each output also has `proof`, its object's inclusion proof (route-record §6.1) | route-record §7, accepted with its hop receipts checked; then each output receipt read by integer-payloads §15.2 and re-derived from its `codestream` (§15.1 there), each package verified against its indexes by packaging §9, or `output_invalid` at `outputs` and its index; then each output held to what its hop delivered, or `output_not_delivered` there |
A source is accepted when its extension's verifier accepts it; for `ads`, when the outcome is
**accept** (live-ads §9.4), a **gap** being `source_gap`. A `stitched` source is always
@ -381,9 +383,25 @@ the hop did not deliver bills subpixels nobody delivered for this job, as a rece
twice bills them twice. So the chain closes: the hop's receipt names its outputs, the record
names the hop's receipt and the outputs, and each output receipt is of one of them. Under a
tariff of version 5 a decode's object is the animation of the frames it delivered, not the
codestream it read (§4.8). Under a tariff of version 4, a record that states no outputs (a record of version 1, or one no attempt
served) holds every output to none, and a job it routed is billed by the job alone; under a
tariff of version 3, a record of version 1's outputs are read as before.
codestream it read (§4.8). Under a tariff of version 4, a record that states no outputs (a
record of version 1, or one no attempt served) holds every output to none, and a job it
routed is billed by the job alone; under a tariff of version 3, a record of version 1's
outputs are read as before.
A route record of version 3 states what its served attempt delivered by its count and root,
not by a list (route-record §3.4, §5.2), so a hop may deliver any number of objects. Each
output of a job source under one gives `proof`, the inclusion proof of its object (route-record
§6.1), and is one of the objects the hop delivered when its proof holds its object's content
hash to the served attempt's `count` and `root`; an output under a record of version 1 or 2
gives none. An output without its proof, or with one under a record of version 1 or 2, or a
proof that is not an object of exactly an integer `index` and a list of content hashes'
texts, `siblings`, is `malformed`; a proof that does not hold is `output_not_delivered`, at
`outputs` and the output's index, as an object a list does not state is; and no two outputs
are one object, as before. The ledger does not hold the count to the objects, which it is not
given: the hop's receipt names the root alone (route-record §6). A count stated larger than
the objects lets the last of an odd level be proved at a second place, and that place is one
object, which no source bills twice; a decode is held to its places and its count (§4.8).
Under a record of version 3 no attempt served, the outputs are held to none.
**Routing figures are never charges.** A route record's candidates' `cost`, `energy`,
`expected_cost` and `expected_ttfb_ms`, and its price book's `price` and `failures`, are the
@ -852,7 +870,10 @@ outcome §7.3 gives it:
work billed again and its receipt cited again, beside a second package listed; and, under a
tariff of version 5, an `int_motion` decode whose served hop states it delivered one animated
PNG of the clip's frames, its receipt re-derived from the codestream it read, beside an encode
held to its codestream.
held to its codestream; and jobs whose route records are of version 3, each output proved
among what its hop delivered: an encode whose clip is the second of two objects, a decode of
version 5 whose animation is its one object, and a package's run whose receipt is the first
of 1,501 objects.
- **Refused**: each reason of §10 at least once, each vector a control of one defect; a source of
each kind outside the scope, a room receipt and a statement under other prices, and a source of
each timed kind outside the period; a refusal of each source kind by its own extension; and, for
@ -882,7 +903,11 @@ outcome §7.3 gives it:
the picture, skips a sequence number, keeps its default image apart from its frames, counts a
frame more than it holds, or has a frame with no data (`output_invalid`), a decode in
version 5's form under a tariff of version 4, and an invoice of version 4 under a tariff of
version 5.
version 5; and, for jobs routed under the route record's version 3, an output without its proof,
a proof under a record of version 2 and a proof whose index is text (`malformed`), a proof of
another place, one past the count, one a sibling short, an output its hop did not deliver, one
output twice, a decode's animation at another place, a decode with an object past its animation,
and an output of a job no hop served (`output_not_delivered`).
`wai_receipt_ledger_vectors verify` and the test `tests/receipt_ledger.rs` check that the file
regenerates byte for byte, that `wai-rs` gives every vector its outcome, that every reason of §10

View file

@ -27,6 +27,12 @@
> its outputs, to which its hop's receipt is held (§6). Documents and records of version 1 are
> read and verified exactly as before.
>
> This revision adds **version 3** of the record (§3.4), on a price book of version 2: the
> attempt that served states what it delivered by its count and the root its hop's receipt
> names (§5.2, §6), not by a list, so a hop may deliver any number of objects and the record is
> the same size whatever it delivered; an object is shown to be one of them by its inclusion
> proof (§6.1). Records of versions 1 and 2 are read and verified exactly as before.
>
> Texts read for this revision: the WAI texts this one cites; RFC 8259; RFC 8785; RFC 8032;
> ISO 4217 (the alphabetic currency code); FIPS 180-4; the BLAKE3 specification.
@ -35,13 +41,15 @@
In scope: the three documents a routing decision rests on, the request, the price book and
the policy (§3); how a router computes its candidates, their removals, their ranking and
its decision from them (§4); the record, its attempts and its signature (§5); how an
attempt is held to its hop's OER/2 receipt, and in version 2 to the outputs it delivered
(§6); verification (§7); and the reference implementation and corpus (§10).
attempt is held to its hop's OER/2 receipt, and in versions 2 and 3 to the outputs it
delivered (§6); an output's inclusion proof (§6.1); verification (§7); and the reference
implementation and corpus (§10).
Out of scope: a transport; how a router learns its prices and measures its endpoints'
failures, which the price book states and this extension takes as stated; accounts, credit
and payment; the contents of the job's output and its own receipts, which their extensions
define (a record of version 2 names the output by its content hash and nothing more); and
define (a record of version 2 names the output by its content hash and nothing more, and one
of version 3 by its count and root); and
the energy of a hop, which its OER/2 receipt states ([oer-receipts](oer-receipts.md)). An
invoice that bills a job from its route record is the receipt ledger's
([receipt-ledger](receipt-ledger.md) §5).
@ -214,16 +222,26 @@ version and digest, and a policy whose rules change takes a new version.
### 3.4 Versions
The request and the policy have one form. The price book and the record have two: version 1,
this extension's first, and version 2, which adds the members that price an operation, state
an endpoint's failures and name a hop's outputs. A record's version is its price book's
(§7.2 step 5).
The request and the policy have one form. The price book has two: version 1, this
extension's first, and version 2, which adds the members that price an operation and state an
endpoint's failures. The record has three: version 1; version 2, which adds those members and
names a hop's outputs by their list; and version 3, version 2's but for naming them by their
count and root (§5.2). A record of version 1 rests on a price book of version 1, and one of
version 2 or 3 on a price book of version 2 (§7.2 step 5). A router writes one version of
record over a price book of version 2; a reader holds each as its version says.
A reader of version 1 reads every document of version 1 as before, and refuses one of
version 2 rather than misreading it: a price book with a `version` member, or an entry with
`operation` or `failures`, is not of version 1's members (`pricebook_invalid`), and a record
of version 2 is `unknown_version`. A price book whose `version` is other than 2 is
`pricebook_invalid`; a record whose `version` is other than 1 and 2 is `unknown_version`.
of version 2 is `unknown_version`; a reader of version 2 refuses a record of version 3 so. A
price book whose `version` is other than 2 is `pricebook_invalid`; a record whose `version` is
other than 1, 2 and 3 is `unknown_version`.
Version 3 exists because a list bounds what a hop may deliver and grows with it: version 2
states at most 1024 outputs, and a package of a long title holds an object per piece and rung,
thousands of them. The count and the root are an integer and 32 bytes whatever was delivered,
and the root is the commitment the hop's receipt already names (§6), so nothing a hop signs
changes.
## 4. Routing
@ -414,6 +432,7 @@ An attempt is one hop the router sent the job to:
| `not_billed` | `null` when billed; otherwise `failed` or `deadline_missed` |
| `receipt` | the `receipt_id` of the hop's OER/2 receipt (oer-receipts §5): `sha256:` or `blake3:` and 64 lowercase hexadecimal digits |
| `outputs` | version 2: the content hashes (§2) of the objects the hop delivered, in the order it delivered them: when served, 1 to 1024, no two the same; when failed, none (an empty list) |
| `delivered` | version 3: an object of exactly `count`, an integer, and `root`, 64 lowercase hexadecimal digits: when served, the number of objects the hop delivered, at least 1, no two the same, and their commitment in the order it delivered them (§6); when failed, `0` and 64 zeros |
The attempts **MUST** follow the order: the attempt at position *i* names the *i*-th survivor
of the order, and there are at most the allowed attempts (§4.5). So no attempt names a
@ -461,16 +480,44 @@ holds each attempt to its receipt:
attempt states: its `subject.output` is their commitment, as oer-receipts §3 *Output*
defines it, the content hash of the one output, or the jwp-receipts §2.2 root over their
content hashes in their order, every leaf of the unlabelled form, as 64 lowercase
hexadecimal digits: otherwise `receipt_output_mismatch`.
hexadecimal digits: otherwise `receipt_output_mismatch`;
- in a record of version 3, its `subject.output` is the attempt's `root`: otherwise
`receipt_output_mismatch`.
So a receipt cannot be moved to vouch for an output it did not produce. The hop signs what it
produced, in its receipt; the router signs what the job delivered and which receipt is the
hop's, in its record; and a record that pairs a hop's receipt with outputs the receipt does
not name is refused. A holder of the job's output checks the remaining step, that its
objects' content hashes are the record's outputs (§7.3). The attempt that served is held to
objects' content hashes are the record's outputs (§7.3), or in version 3 that their count is
the record's and their commitment its root: the receipt names the root alone, so the count is
the router's statement, which the objects check. The attempt that served is held to
its outputs whether or not it is billed: one that served past the deadline delivered them
too. A failed attempt delivered nothing, and its receipt is not held to an output.
### 6.1 An output's inclusion proof (version 3)
A record of version 3 does not list what the hop delivered. A reader that must show one
object to be among them, as a receipt ledger shows a billed output to be (receipt-ledger
§5.1), gives that object's **inclusion proof**: an object of exactly `index`, an integer, the
object's place in the order the hop delivered, from 0, and `siblings`, a list of content
hashes' texts (§2). The proof holds the object whose content hash is *h* to an attempt that
delivered `count` objects under `root` when:
- `index` is below `count`, and `siblings` holds exactly ⌈log₂ `count`⌉ hashes, none for
one object;
- for one object, *h* is the root (oer-receipts §3 *Output*); otherwise, from *h*'s leaf
(jwp-receipts §2.2, the unlabelled form), each sibling in turn combined with the value so far
by jwp-receipts §2.2's node, the sibling on the left where bit *i* of `index` is 1 and on the
right where it is 0, at level *i* from 0, ends at `root`.
A router makes it from the tree §6 commits to: the sibling at each level, the last node of an
odd level paired with itself. The count and the bits fix the place, as jwp-receipts §3 step 5
fixes an object's, so a node paired with itself is proved at its own index only, and two
proofs at one index are of one object. The leaf and node domains alone keep a walk of another
length from ending at the root; the number of siblings is checked all the same, as jwp-receipts
checks it, so such a proof is refused before anything is hashed. This extension's verification (§7) is given no object
and checks no proof; the reader that is given them checks it, with its own refusal.
A receipt states the hop's energy with its grade, and, in minor version 1, its class or the
unmetered marker; the record states no figure of its own. A reader of the hops' energy reads
it from their receipts, and an invoice bills a job from them (receipt-ledger §5).
@ -495,21 +542,22 @@ and where (`at`, a list: the member, then indexes or members within it):
2. **The price book** (§3.2), at `pricebook`: `malformed`, `pricebook_invalid`.
3. **The policy** (§3.3), at `policy`: `malformed`, `policy_invalid`.
4. **The record** (§5), at `record`: a text that is not a document, or whose `kind` is not
`wai-route-record`, is `malformed`; a `version` that is an integer other than `1` and `2`
is `unknown_version`, at `record`, `version`; one that is not of §5.1's and §5.2's members
for its version, each of the form its table gives it (a restated member of a candidate, the
form §3.2 gives it, and of `failures`, each member an integer and `hops` at least 1), is
`record_invalid` (an attempt whose `hop` is not its position, or whose `error` or
`outputs` is not as its `outcome` requires, among them; the relations between a
`wai-route-record`, is `malformed`; a `version` that is an integer other than `1`, `2` and
`3` is `unknown_version`, at `record`, `version`; one that is not of §5.1's and §5.2's
members for its version, each of the form its table gives it (a restated member of a
candidate, the form §3.2 gives it for the record's price book version, and of `failures`,
each member an integer and `hops` at least 1), is `record_invalid` (an attempt whose `hop`
is not its position, or whose `error`, `outputs` or `delivered` is not as its `outcome`
requires, among them; the relations between a
candidate's members, such as an energy figure and its class, or a failure count and its
hops, are step 6's); then its signature (§5.3): `bad_key`, `bad_signature`, at `record`,
`sig`.
5. **The binding**, at `record` and the member: its `job`, `operation`, `capability`,
`fallback`, `quantities`, `request` and `deadline` are not the request's
(`request_mismatch`); its `policy` is not the policy's name, version and digest
(`policy_mismatch`); its `version` is not the price book's (`pricebook_mismatch`, at
`record`, `version`), or its `pricebook` is not the price book's digest, currency and
`issued` (`pricebook_mismatch`).
(`policy_mismatch`); its `version` is not one that rests on the price book's (§3.4:
`pricebook_mismatch`, at `record`, `version`), or its `pricebook` is not the price book's
digest, currency and `issued` (`pricebook_mismatch`).
6. **The candidates**, at `record`, `candidates` and the index, recomputed by §4, each in
turn and for each these three in this order: a candidate missing, added or out of order,
or with a member other than `removed` and `rank` not what §4 gives it
@ -537,7 +585,8 @@ and where (`at`, a list: the member, then indexes or members within it):
- **refuse**: the reason and where.
- **accept**: the record's digest; the job and its operation; the served endpoint and the hop
that served it, and in a record of version 2 the content hashes of its outputs, in their
order, or `null`s for a job no hop served; the billed hop, or `null`; the deadline,
order, or in one of version 3 their `delivered` count and root, or `null`s for a job no hop
served; the billed hop, or `null`; the deadline,
`none` when none was declared and otherwise `met` or `missed` by the served hop, or `none`
too when no hop served; the order; the decision; each removed candidate's endpoint with the
limits that removed it; whether the hop receipts were `checked` or `not_checked`; and, when
@ -618,8 +667,17 @@ verification document with the outcome §7.3 gives it:
and a basis restated, and the other operation's entry listed; a served hop with no output,
a failed one with one, an output twice and one in capitals; and a served hop's receipt moved
from another job, a late hop's too, outputs restated in another order, one output named by
its leaf and three by the first of them (`receipt_output_mismatch`). A record of version 3
is `unknown_version`.
its leaf and three by the first of them (`receipt_output_mismatch`).
- **Accepted, version 3**, on version 2's price book: a hop that served one output, whose root
is its content hash; three; and 5,000, more than a list of version 2 holds, its record the
size of the others; a failover whose failed hop delivered nothing; a hop that served past the
deadline; and a record verified without its receipts.
- **Refused, version 3**: a served hop's count of 0; a failed hop with a count, and with a root;
a root in capitals; a count as text; a member besides the count and the root; outputs listed
as version 2 lists them; `delivered` in a record of version 2 (`record_invalid`); a record of
version 3 on a price book of version 1 (`pricebook_mismatch`); and a served hop's receipt
moved from another job, and a root not its receipt's (`receipt_output_mismatch`). A record of
version 4 is `unknown_version`.
`wai_route_record_vectors verify` and the test `tests/route_record.rs` check that the file
regenerates byte for byte, that `wai-rs` gives every vector its outcome, that every reason of

View file

@ -78,6 +78,13 @@ The accepted vectors:
version 4. The animation is written here by `png_still::encode_animation` from the clip the
decode reads, and each refused animation is that one with one rule broken, its chunks
rewritten with good CRCs.
- `route_record_3_outputs_proved_among_what_was_delivered` (route-record §3.4, §6.1; §5.1
here): jobs routed here under the route record's version 3, whose served hops state what they
delivered by its count and root, each output giving its inclusion proof: an encode
(`job_v3r_encode`) whose clip is the second of two objects, a decode of version 5
(`job_v3r_decode`) whose animation is its one object, and a package's run
(`package_run_v3r`) whose receipt is the first of 1,501 objects, the rest standing for the
package's own; billed under the tariff of version 5.
Every other vector is refused, a control of one defect: each reason of §10, the reused codes, and a
refusal of each source kind by its own extension.

File diff suppressed because one or more lines are too long

View file

@ -635,6 +635,30 @@ def job_source(s, version):
delivered = None
if "output-delivered" in SKIP:
delivered = None
# §5.1: under a route record of version 3 the served hop states what it delivered by its
# count and root, and each output proves its place by its inclusion proof (route-record
# §6.1); one no attempt served holds every output to none.
served_att = next((a for a in rec["attempts"] if a["outcome"] == "served"), None)
committed = rec["version"] == 3 and served_att is not None
if rec["version"] == 3 and not committed and "output-delivered" not in SKIP:
delivered = set()
count, root = (served_att["delivered"]["count"], bytes.fromhex(served_att["delivered"]["root"])) if committed else (0, b"")
held = "output-delivered" not in SKIP and (delivered is not None or committed)
def holds(h, proof, place):
"""Whether the object `h` is one the hop delivered, at `place` where one is required."""
if committed:
if "output-proof" in SKIP:
return True
return proof is not None and (place is None or proof[0] == place) and RRV.inclusion_proof(h, proof[0], proof[1], count, root)
if place is not None:
return place < len(stated) and stated[place] == h
return h in delivered
def is_index(v):
return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 2 ** 53 - 1
hex64 = re.compile(r"[0-9a-f]{64}")
seen = set()
outputs, packages = [], []
hexs = re.compile(r"(?:[0-9a-f]{2})*")
@ -644,6 +668,18 @@ def job_source(s, version):
stated = [bytes.fromhex(h) for a in rec["attempts"] if a["outcome"] == "served" for h in a["outputs"]] if rec["version"] == 2 else []
nxt = 0
for i, x in enumerate(s["outputs"]):
# Under a record of version 3 each output gives `proof`, and under one of version 1 or 2
# none (§5.1).
proof = None
if committed:
if not isinstance(x, dict) or "proof" not in x:
raise Refuse("malformed", [])
x = dict(x)
pr = x.pop("proof")
if not (isinstance(pr, dict) and set(pr) == {"index", "siblings"} and is_index(pr["index"]) and isinstance(pr["siblings"], list)
and all(isinstance(h, str) and hex64.fullmatch(h) for h in pr["siblings"])):
raise Refuse("malformed", [])
proof = (pr["index"], [bytes.fromhex(h) for h in pr["siblings"]])
if decode:
if not isinstance(x, dict) or set(x) != {"receipt", "codestream", "frames"} or not isinstance(x["receipt"], str) \
or not isinstance(x["codestream"], str) or not hexs.fullmatch(x["codestream"]) \
@ -671,7 +707,7 @@ def job_source(s, version):
raise Refuse("output_invalid", ["outputs", i, "frames", k])
outputs.append((IMR.receipt_hash(r), r["work"]))
if "output-delivered" not in SKIP:
if nxt >= len(stated) or stated[nxt] != content_hash(anim):
if not holds(content_hash(anim), proof, nxt):
raise Refuse("output_not_delivered", ["outputs", i, "frames"])
nxt += 1
continue
@ -699,12 +735,12 @@ def job_source(s, version):
raise Refuse("output_invalid", ["outputs", i])
obj = bytes.fromhex(x["codestream"])
outputs.append((IMR.receipt_hash(r), r["work"]))
if delivered is not None:
if held:
h = content_hash(obj)
if h not in delivered or h in seen:
if not holds(h, proof, None) or h in seen:
raise Refuse("output_not_delivered", ["outputs", i])
seen.add(h)
if decode and "output-delivered" not in SKIP and nxt != len(stated):
if decode and "output-delivered" not in SKIP and nxt != (count if committed else len(stated)):
raise Refuse("output_not_delivered", ["outputs"]) # §4.8, 3: an object past the animations
return {"kind": JOB, "digest": bytes.fromhex(RRV.digest(rec)), "router": bytes.fromhex(rec["signer"]), "job": rec["job"],
"operation": rec["operation"].encode("utf-8"), "accepted": rec["accepted"], "hops": [oer_identity(a["receipt"]) for a in rec["attempts"]], "billed": billed,
@ -718,7 +754,7 @@ def verify_source(s, signers, version=1):
"""§5.1: the source verified by its own extension, a stitched source with `signers`, a job
source under a tariff of version 2 or 3, or Refuse with that extension's place."""
key = json.dumps([s, sorted(k.hex() for k in signers), version if isinstance(s, dict) and s.get("kind") == "job" else version in JOB_VERSIONS,
sorted(SKIP & {"job-figure", "output", "output-delivered", "unbilled-hops", "run-work", "decode-frames"})], sort_keys=True)
sorted(SKIP & {"job-figure", "output", "output-delivered", "unbilled-hops", "run-work", "decode-frames", "output-proof"})], sort_keys=True)
if key not in _SOURCES:
try:
_SOURCES[key] = read_source(s, signers, version)
@ -1364,6 +1400,16 @@ def mutations(doc):
m = copy.deepcopy(doc)
m["sources"]["job_v5_decode"]["outputs"][0]["frames"] = doc["sources"][other]["outputs"][0]["frames"]
out.append((f"job_v5_decode: {what}", m))
if "job_v3r_encode" in doc["sources"]: # §5.1: an output proved at its place
m = copy.deepcopy(doc)
m["sources"]["job_v3r_encode"]["outputs"][0]["proof"]["index"] = 0
out.append(("job_v3r_encode: its proof stating another place", m))
m = copy.deepcopy(doc)
m["sources"]["job_v3r_encode"]["outputs"][0]["proof"]["siblings"][0] = "00" * 32
out.append(("job_v3r_encode: its proof's sibling replaced", m))
m = copy.deepcopy(doc)
m["sources"]["package_run_v3r"]["outputs"][0]["proof"]["index"] = 1
out.append(("package_run_v3r: its receipt proved at the second place", m))
for k, v in enumerate(doc["vectors"]):
if v["outcome"]["outcome"] == "refuse":
m = copy.deepcopy(doc)
@ -1377,7 +1423,7 @@ CHECKS = ["signatures", "buyer-signature", "tariff-rules", "invoice-rules", "row
"previous-given", "previous-overlap", "scope", "conflict", "period", "duplicate", "overlap", "rebilled", "quantity",
"summed", "citations", "mismatch", "job-figure", "output", "job-duplicate", "private-kind", "operation", "output-once",
"output-delivered", "unbilled-hops", "storage-held", "package-again", "run-work", "bytes-out", "conflict-bytes",
"decode-frames"]
"decode-frames", "output-proof"]
def main(corpus_dir):

View file

@ -21,7 +21,11 @@ else is here:
cost and time to first byte per successful job and the ranking that reads them (§4.2, §4.4),
a record's version held to its price book's, and the served hop's receipt held to the
outputs the record states, their commitment computed with ../claim-conformance/_blake3.py
(BLAKE3 written from its specification) as oer-receipts §3 *Output* defines it.
(BLAKE3 written from its specification) as oer-receipts §3 *Output* defines it;
- version 3 of the record (§3.4), on a price book of version 2: what the served hop delivered
stated by its count and root, the served hop's receipt held to the root; and an output's
inclusion proof (§6.1, `inclusion_proof`), which the receipt ledger's verifier checks a job
source's outputs with.
For every vector it requires the outcome the corpus states; of the corpus, that every reason of
§8 and every code it reuses appears in it, that §8 names every reason it raises, and that no
@ -71,6 +75,11 @@ ATTEMPT = ["hop", "endpoint", "outcome", "error", "start", "end", "billed", "not
LEAF, NODE = b"moq-jwp:merkle-leaf\x01", b"moq-jwp:merkle-node\x01" # jwp-receipts §2.2
def book_of(version):
"""§3.4: the price book version a record of `version` rests on: 1, or 2 for versions 2 and 3."""
return 2 if version in (2, 3) else 1
def candidate_members(version):
"""§5.1: an entry's members, the five a candidate adds, and in version 2 the expected two."""
return (ENTRY_2 if version == 2 else ENTRY) + ["via_fallback", "cost", "energy", "removed", "rank"] \
@ -558,11 +567,23 @@ def outputs_form(a):
return 1 <= len(xs) <= 1024 if a["outcome"] == "served" else not xs
def delivered_form(a):
"""§5.2, version 3: a served attempt's count, at least 1, and the root of what it delivered,
64 lowercase hexadecimal digits; a failed one's, 0 and 64 zeros."""
d = a["delivered"]
if not (exactly(d, ["count", "root"]) and is_int(d["count"]) and is_hex(d["root"], 64)):
return False
return d["count"] >= 1 if a["outcome"] == "served" else d["count"] == 0 and d["root"] == "00" * 32
def attempt_form(i, a, version=1):
if not exactly(a, ATTEMPT + (["outputs"] if version == 2 else [])) or a["outcome"] not in ("served", "failed"):
extra = {2: ["outputs"], 3: ["delivered"]}.get(version, [])
if not exactly(a, ATTEMPT + extra) or a["outcome"] not in ("served", "failed"):
return False
if version == 2 and on("outputs-form") and not outputs_form(a):
return False
if version == 3 and on("delivered-form") and not delivered_form(a):
return False
served = a["outcome"] == "served"
error_ok = a["error"] is None if served else a["error"] in ERRORS
return (is_int(a["hop"]) and a["hop"] == i + 1 and is_token(a["endpoint"]) and error_ok and is_int(a["start"])
@ -574,7 +595,7 @@ def record_form(r):
if not exactly(r, RECORD):
return False
v = r["version"]
return (is_int(v) and v in (1, 2) and is_token(r["job"]) and isinstance(r["operation"], str)
return (is_int(v) and v in (1, 2, 3) and is_token(r["job"]) and isinstance(r["operation"], str)
and OPERATION.match(r["operation"]) is not None and is_cap(r["capability"]) and null_or(r["fallback"], is_cap)
and quantities_ok(r["quantities"]) and is_hex(r["request"], 64)
and exactly(r["policy"], ["name", "version", "digest"]) and is_token(r["policy"]["name"])
@ -582,7 +603,7 @@ def record_form(r):
and exactly(r["pricebook"], ["digest", "currency", "issued"]) and is_hex(r["pricebook"]["digest"], 64)
and isinstance(r["pricebook"]["currency"], str) and CURRENCY.match(r["pricebook"]["currency"]) is not None
and is_int(r["pricebook"]["issued"])
and isinstance(r["candidates"], list) and len(r["candidates"]) <= 1024 and all(candidate_form(c, v) for c in r["candidates"])
and isinstance(r["candidates"], list) and len(r["candidates"]) <= 1024 and all(candidate_form(c, book_of(v)) for c in r["candidates"])
and isinstance(r["order"], list) and len(r["order"]) <= 1024 and all(is_token(x) for x in r["order"])
and exactly(r["decision"], ["decided_by", "runner_up"]) and r["decision"]["decided_by"] in DECIDED_BY
and null_or(r["decision"]["runner_up"], is_token)
@ -640,7 +661,7 @@ def verify_steps(doc):
rec = read_document(doc["record"])
if not isinstance(rec, dict) or rec.get("kind") != "wai-route-record":
raise Refuse("malformed", ["record"])
if on("version") and is_int(rec.get("version")) and rec["version"] not in (1, 2):
if on("version") and is_int(rec.get("version")) and rec["version"] not in (1, 2, 3):
raise Refuse("unknown_version", ["record", "version"])
if on("record-form") and not record_form(rec):
raise Refuse("record_invalid", ["record"])
@ -658,7 +679,7 @@ def verify_steps(doc):
raise Refuse("request_mismatch", ["record", m])
if on("binding-policy") and not same(rec["policy"], {"name": pol["name"], "version": pol["version"], "digest": digest(pol)}):
raise Refuse("policy_mismatch", ["record", "policy"])
if on("record-version") and not same(rec["version"], book_version(book)):
if on("record-version") and not book_of(rec["version"]) == book_version(book):
raise Refuse("pricebook_mismatch", ["record", "version"])
if on("binding-pricebook") and not same(rec["pricebook"], {"digest": digest(book), "currency": book["currency"], "issued": book["issued"]}):
raise Refuse("pricebook_mismatch", ["record", "pricebook"])
@ -720,7 +741,8 @@ def verify_steps(doc):
"operation": req["operation"],
"served": None if served_attempt is None else dict(
{"endpoint": served_attempt["endpoint"], "hop": served_attempt["hop"]},
**({"outputs": served_attempt["outputs"]} if "outputs" in served_attempt else {})),
**({"outputs": served_attempt["outputs"]} if "outputs" in served_attempt else {}),
**({"delivered": served_attempt["delivered"]} if "delivered" in served_attempt else {})),
"billed": billed_hop,
"deadline": dl,
"order": order,
@ -745,6 +767,22 @@ def commitment(outputs):
return level[0].hex() if level else "00" * 32
def inclusion_proof(h, index, siblings, count, root):
"""§6.1: whether `siblings` proves the object whose content hash is `h` (bytes) the one at
`index` of `count` objects delivered under `root` (bytes): `index` below `count`, exactly
ceil(log2 count) siblings (none for one object, whose root is its content hash), and the walk
from its unlabelled leaf, the sibling on the left where bit i of `index` is 1, ends at
`root`."""
if not (0 <= index < count) or len(siblings) != (count - 1).bit_length():
return False
if count == 1:
return h == root
node = blake3(LEAF + h)
for i, sib in enumerate(siblings):
node = blake3(NODE + sib + node) if (index >> i) & 1 else blake3(NODE + node + sib)
return node == root
def hop_receipts(rec, texts, keys):
"""§6: each attempt held to its hop's OER/2 receipt. The hops whose receipts state the
unmetered marker."""
@ -775,6 +813,11 @@ def hop_receipts(rec, texts, keys):
subject = r.get("subject") if isinstance(r.get("subject"), dict) else {}
if subject.get("output") != commitment(a["outputs"]):
raise Refuse("receipt_output_mismatch", at)
# Version 3 (§6): it names the root the attempt states.
if on("receipt-root") and a["outcome"] == "served" and "delivered" in a:
subject = r.get("subject") if isinstance(r.get("subject"), dict) else {}
if subject.get("output") != a["delivered"]["root"]:
raise Refuse("receipt_output_mismatch", at)
if OER.is_unmetered(r):
unmetered.append(a["hop"])
return unmetered
@ -851,6 +894,10 @@ def mutations(doc):
copy_with("its stated outputs reversed and one more", k, lambda x: x["outcome"]["served"].__setitem__(
"outputs", list(reversed(o["served"]["outputs"])) + ["00" * 32]))
copy_with("its served hop's outputs dropped from the outcome", k, lambda x: x["outcome"]["served"].pop("outputs"))
if o["served"] and "delivered" in o["served"]:
copy_with("its stated count one more", k, lambda x: x["outcome"]["served"]["delivered"].__setitem__("count", o["served"]["delivered"]["count"] + 1))
copy_with("its stated root another", k, lambda x: x["outcome"]["served"]["delivered"].__setitem__("root", "00" * 32))
copy_with("its served hop's delivered dropped from the outcome", k, lambda x: x["outcome"]["served"].pop("delivered"))
copy_with("its stated deadline changed", k, lambda x: x["outcome"].__setitem__("deadline", "met" if o["deadline"] != "met" else "missed"))
copy_with("its stated order reversed", k, lambda x: x["outcome"].__setitem__("order", list(reversed(o["order"])) + ["x"]))
copy_with("its stated decision changed", k, lambda x: x["outcome"]["decision"].__setitem__("decided_by", "price" if o["decision"]["decided_by"] != "price" else "ttfb"))
@ -865,7 +912,8 @@ CHECKS = ["request_invalid", "pricebook_invalid", "policy_invalid", "version", "
"allowed", "removed", "in-order", "served-last", "times", "billing", "complete",
"receipt-given", "receipt-verifies", "receipt-matches",
"pricebook-version", "failures-relations", "operation-candidates", "thin-basis", "unbounded", "expected-price",
"expected-ttfb", "expected-targets", "record-version", "outputs-form", "receipt-output",
"expected-ttfb", "expected-targets", "record-version", "outputs-form", "receipt-output", "delivered-form",
"receipt-root",
"limit-policy", "limit-unit", "limit-region", "limit-residency", "limit-training", "limit-retention", "limit-ga",
"limit-consent", "limit-unit-price", "limit-spend"]

View file

@ -17,7 +17,10 @@ a price book that prices each operation of an endpoint and states its measured f
ranking by the expected cost and time to first byte per successful job, and the served hop's
outputs, to which its receipt is held. The outputs are the content hashes of fixed byte
strings standing for the envelopes a hop delivered; the verifier reads only their content
hashes.
hashes. Then those of version 3 (§3.4), on version 2's price book, whose names begin `v3_` but
for the control that pairs one with a price book of version 1: the served hop's outputs stated
by their count and root, to which its receipt is held, 5,000 of them in one record the size of
the others.
```sh
# wai-rs: regenerates byte for byte, and every vector gets its outcome

File diff suppressed because one or more lines are too long

View file

@ -1471,6 +1471,48 @@ fn decode_output(x: &Map<String, Value>, i: usize) -> Result<DecodeOutput, Refus
Ok(DecodeOutput { receipt: (r.receipt_hash(), r.work_pixel_ops), object: crate::route_record::content_hash(&frames) })
}
/// What the hop that served delivered, as its route record states it (route-record §5.2).
enum Delivered {
/// The content hashes of its outputs, in their order (version 2), or none.
Listed(Vec<[u8; 32]>),
/// Their count and root (version 3): each output gives its inclusion proof
/// (route-record §6.1).
Committed { count: u64, root: [u8; 32] },
}
impl Delivered {
/// Whether `object` (a content hash) is one the hop delivered, at `place` where the rule
/// holds it to one: in a list, by its place in it; under a root, by its proof.
fn holds(&self, object: &[u8; 32], proof: Option<&(u64, Vec<[u8; 32]>)>, place: Option<usize>) -> bool {
match self {
Delivered::Listed(d) => match place {
Some(k) => d.get(k) == Some(object),
None => d.contains(object),
},
Delivered::Committed { count, root } => {
proof.is_some_and(|(index, siblings)| place.is_none_or(|k| *index == k as u64) && crate::route_record::verify_output_proof(object, *index, siblings, *count, root))
}
}
}
/// How many objects it delivered.
fn count(&self) -> u64 {
match self {
Delivered::Listed(d) => d.len() as u64,
Delivered::Committed { count, .. } => *count,
}
}
}
/// An output's inclusion proof (route-record §6.1): exactly `index`, an integer, and `siblings`,
/// content hashes as 64 lowercase hexadecimal digits.
fn read_proof(v: &Value) -> Option<(u64, Vec<[u8; 32]>)> {
let o = v.as_object().filter(|o| has_exactly(o, &["index", "siblings"]))?;
let index = o["index"].as_u64().filter(|i| *i <= crate::jcs::MAX_INTEGER)?;
let siblings = o["siblings"].as_array()?.iter().map(|h| h.as_str().filter(|h| h.len() == 64).and_then(unhex).and_then(|b| b.try_into().ok())).collect::<Option<Vec<[u8; 32]>>>()?;
Some((index, siblings))
}
fn job_source(o: &Map<String, Value>, version: u8) -> Result<Facts, Refusal> {
use crate::route_record as rr;
if !has_exactly(o, &["kind", "request", "pricebook", "policy", "record", "receipts", "keys", "outputs"]) {
@ -1507,23 +1549,25 @@ fn job_source(o: &Map<String, Value>, version: u8) -> Result<Facts, Refusal> {
unbilled.push(job_figure(&texts[j]).ok_or((Reason::JobFigureInvalid.code(), json!(["receipts", j])))?);
}
}
// What the hop that served states it delivered (route-record §5.2, version 2), by content
// hash, in its order; a record of version 1 states nothing, and under a tariff of version 4
// or 5 holds its outputs to nothing (§5.1). Under version 3 a record of version 1's outputs
// are read as before.
let delivered: Option<Vec<[u8; 32]>> = if record["version"] == 2 {
Some(
attempts
.iter()
.filter(|a| a["outcome"] == "served")
.flat_map(|a| a["outputs"].as_array().expect("a list").iter().map(|h| unhex(h.as_str().expect("a hash")).expect("hex").try_into().expect("32 bytes")))
.collect(),
)
} else if bills_spending(version) {
Some(Vec::new())
} else {
None
// What the hop that served states it delivered (route-record §5.2): in version 2 by content
// hash, in its order; in version 3 by its count and root, each output proving its place; a
// record of version 1 states nothing, and under a tariff of version 4 or 5 holds its outputs
// to nothing (§5.1), as one no attempt served does. Under version 3 a record of version 1's
// outputs are read as before.
let served_attempt = attempts.iter().find(|a| a["outcome"] == "served");
let delivered: Option<Delivered> = match (record["version"].as_u64(), served_attempt) {
(Some(rr::VERSION_3), Some(a)) => Some(Delivered::Committed {
count: a["delivered"]["count"].as_u64().expect("a count"),
root: unhex(a["delivered"]["root"].as_str().expect("a root")).expect("hex").try_into().expect("32 bytes"),
}),
(Some(rr::VERSION_3), None) => Some(Delivered::Listed(Vec::new())),
(Some(rr::VERSION_2), a) => Some(Delivered::Listed(
a.into_iter().flat_map(|a| a["outputs"].as_array().expect("a list").iter().map(|h| unhex(h.as_str().expect("a hash")).expect("hex").try_into().expect("32 bytes"))).collect(),
)),
_ if bills_spending(version) => Some(Delivered::Listed(Vec::new())),
_ => None,
};
let committed = matches!(delivered, Some(Delivered::Committed { .. }));
// Under a tariff of version 5 a decode delivers its reconstruction, one animation of its
// frames, and its codestream is what it read (§4.8).
let decode = holds_decode_to_frames(version) && record["operation"] == DECODE;
@ -1535,11 +1579,21 @@ fn job_source(o: &Map<String, Value>, version: u8) -> Result<Facts, Refusal> {
for (i, x) in outputs.iter().enumerate() {
let bad = || (Reason::OutputInvalid.code(), json!(["outputs", i]));
let x = x.as_object().ok_or_else(bad_source)?;
// With a record of version 3 each output also gives its inclusion proof, and with one of
// version 1 or 2 none (§5.1).
let (x, proof) = if committed {
let mut m = x.clone();
let proof = m.remove("proof").as_ref().and_then(read_proof).ok_or_else(bad_source)?;
(m, Some(proof))
} else {
(x.clone(), None)
};
let x = &x;
if decode {
let out = decode_output(x, i)?;
outs.push(out.receipt);
// The animation is the object the record states at its place (§4.8, 3).
if delivered.as_deref().unwrap_or(&[]).get(next) != Some(&out.object) {
if !delivered.as_ref().is_some_and(|d| d.holds(&out.object, proof.as_ref(), Some(next))) {
return Err((Reason::OutputNotDelivered.code(), json!(["outputs", i, "frames"])));
}
next += 1;
@ -1567,14 +1621,14 @@ fn job_source(o: &Map<String, Value>, version: u8) -> Result<Facts, Refusal> {
// The output is its object: one the served hop delivered, once.
if let Some(d) = &delivered {
let h = rr::content_hash(&object);
if !d.contains(&h) || !seen.insert(h) {
if !d.holds(&h, proof.as_ref(), None) || !seen.insert(h) {
return Err((Reason::OutputNotDelivered.code(), json!(["outputs", i])));
}
}
}
// A decode delivered its animations and nothing else: an object the record states past them,
// the codestream it read among them, is one its hop did not deliver (§4.8, 3).
if decode && delivered.as_ref().is_some_and(|d| d.len() != next) {
if decode && delivered.as_ref().is_some_and(|d| d.count() != next as u64) {
return Err((Reason::OutputNotDelivered.code(), json!(["outputs"])));
}
Ok(Facts::Job(JobFacts {

View file

@ -666,6 +666,77 @@ pub fn generate(wai: &Path) -> Result<String, String> {
cs.remove(last);
}));
// Jobs routed under the route record's version 3 (route-record §3.4): the hop that served
// states what it delivered by its count and root, and each output proves its place by its
// inclusion proof (§6.1). An encode whose hop delivered a sidecar and its clip, the clip
// second; a decode of version 5, its animation at its place; and a package's run whose hop
// delivered the package's receipt among 1,500 more objects, standing for the package's own,
// more than a list of version 2 holds. Then an output given without its proof; a proof given
// under a record of version 2; a proof that is not one; a proof of another place; one past
// the count, its walk the clip's own (the index's bits below the depth are the place's); a
// proof a sibling short; an output its hop did not deliver, proved in another tree; one output given
// twice; a decode's animation proved at another place than its own; a decode with an
// object past its animation; and an output of a job no hop served, which delivered nothing.
let proved = |x: &Value, objects: &[[u8; 32]], index: usize| -> Value {
let mut x = x.clone();
let siblings = crate::route_record::output_proof(objects, index).expect("a place");
x["proof"] = json!({"index": index, "siblings": siblings.iter().map(|h| hex(h)).collect::<Vec<_>>()});
x
};
let routed_3 = |job: &str, operation: &str, hops: &[rr::Hop], objects: &[[u8; 32]], given: Vec<Value>| routed(rr::job_document_3(job, operation, accepted, hops, objects), given);
let with_outputs_of = |source: &Value, outputs: Vec<Value>| {
let mut j = source.clone();
j["outputs"] = Value::Array(outputs);
j
};
let sidecar = rr_hash(b"wai receipt-ledger corpus: job-6001, a sidecar");
let enc_objects = [sidecar, rr_hash(&clip)];
let enc_out = out("job-6001").remove(0);
let encode_3 = routed_3("job-6001", ENC, &[served("im.alpha", rr::hop_receipt(ENC, "job-6001", "im.alpha", Some("30.5")))], &enc_objects, vec![proved(&enc_out, &enc_objects, 1)]);
pool.insert("job_v3r_encode", encode_3.clone());
let dec_hop = |job: &str| served("im.alpha", rr::hop_receipt(DEC, job, "im.alpha", Some("12.0")));
let anim_object = [rr_hash(&anim)];
pool.insert("job_v3r_decode", routed_3("job-6002", DEC, &[dec_hop("job-6002")], &anim_object, vec![proved(&output_5("job-6002", &anim)[0], &anim_object, 0)]));
let mut pack_objects = vec![base_object[0]];
pack_objects.extend((0..1_500).map(|i| rr_hash(format!("wai receipt-ledger corpus: pack-0002, object {i}").as_bytes())));
pool.insert(
"package_run_v3r",
routed(rr::package_job_document_3("pack-0002", accepted, &[pack_hop(1_000, rr::hop_receipt("package.build", "pack-0002", "pack.alpha", Some("4.75")))], &pack_objects), vec![proved(&base_output, &pack_objects, 0)]),
);
pool.insert("job_v3r_without_its_proof", with_outputs_of(&encode_3, vec![enc_out.clone()]));
let encode_2_listed = pool["job_v2_encode"].clone();
pool.insert("job_v2_given_a_proof", with_outputs_of(&encode_2_listed, vec![proved(&out("job-3001")[0], &[rr_hash(&clip)], 0)]));
let mut not_one = proved(&enc_out, &enc_objects, 1);
not_one["proof"]["index"] = json!("1");
pool.insert("job_v3r_a_proof_that_is_not_one", with_outputs_of(&encode_3, vec![not_one]));
pool.insert("job_v3r_a_proof_of_another_place", with_outputs_of(&encode_3, vec![proved(&enc_out, &enc_objects, 0)]));
let mut short = proved(&enc_out, &enc_objects, 1);
short["proof"]["siblings"].as_array_mut().ok_or("siblings")?.pop();
pool.insert("job_v3r_a_proof_a_sibling_short", with_outputs_of(&encode_3, vec![short]));
let mut past = proved(&enc_out, &enc_objects, 1);
past["proof"]["index"] = json!(3);
pool.insert("job_v3r_a_proof_past_the_count", with_outputs_of(&encode_3, vec![past]));
pool.insert("job_v3r_an_output_its_hop_did_not_deliver", with_outputs_of(&encode_3, vec![proved(&output_of(&other_clip, &other_clip, "job-6001"), &[sidecar, rr_hash(&other_clip)], 1)]));
pool.insert("job_v3r_an_output_twice", with_outputs_of(&encode_3, vec![proved(&enc_out, &enc_objects, 1), proved(&enc_out, &enc_objects, 1)]));
let clip_then_anim = [rr_hash(&clip), rr_hash(&anim)];
pool.insert("job_v3r_a_decode_at_another_place", routed_3("job-6003", DEC, &[dec_hop("job-6003")], &clip_then_anim, vec![proved(&output_5("job-6003", &anim)[0], &clip_then_anim, 1)]));
let anim_then_clip = [rr_hash(&anim), rr_hash(&clip)];
pool.insert("job_v3r_a_decode_with_an_object_past_it", routed_3("job-6004", DEC, &[dec_hop("job-6004")], &anim_then_clip, vec![proved(&output_5("job-6004", &anim)[0], &anim_then_clip, 0)]));
pool.insert(
"job_v3r_no_hop_served",
routed_3(
"job-6005",
ENC,
&[
failed("im.alpha", 1_000, 2_000_000, rr::failed_hop_receipt(ENC, "job-6005", "im.alpha", Some("1.5"))),
failed("im.eta", 2_000_100, 3_000_000, rr::failed_hop_receipt(ENC, "job-6005", "im.eta", None)),
failed("im.iota", 3_000_100, 4_000_000, rr::failed_hop_receipt(ENC, "job-6005", "im.iota", Some("0.75"))),
],
&[],
out("job-6005"),
),
);
// The rooms' prices are the lesson receipts'; the tariff's scope is every party of the
// accepted invoices.
let room_prices = {
@ -1024,6 +1095,27 @@ pub fn generate(wai: &Path) -> Result<String, String> {
Vector { name: "version_4_invoice_under_a_version_5_tariff", about: "an invoice of version 4 naming a tariff of version 5", tariff: t5b.clone(), invoice: sealed_after(&t_5, None, (MONTH.0, mid), MONTH.0, mid, &refs(&day_5), |i| i.version = VERSION_4).to_bytes(), previous: None, sources: day_5.to_vec() },
]);
// Jobs routed under the route record's version 3 (route-record §3.4), under the tariff of
// version 5: each output proved among what its hop delivered by its inclusion proof (§5.1).
let day_r3 = ["job_v3r_encode", "job_v3r_decode", "package_run_v3r"];
let inv_r3 = make_invoice(&seller, &t_5, None, MONTH.0, mid, &src(&day_r3)).map_err(|e| format!("invoice of records of version 3: {e:?}"))?;
let ar3 = inv_r3.to_bytes();
let refused_r3 = |name: &'static str, about: &'static str, source: &'static str| Vector { name, about, tariff: t5b.clone(), invoice: ar3.clone(), previous: None, sources: vec![source] };
vectors.extend([
Vector { name: "route_record_3_outputs_proved_among_what_was_delivered", about: "jobs whose route records are of version 3, stating what each served hop delivered by its count and root: an encode whose clip is the second of two objects, a decode of version 5 whose animation is its one object, and a package's run whose receipt is the first of 1,501 objects, each output proved at its place by its inclusion proof, billed under version 5", tariff: t5b.clone(), invoice: ar3.clone(), previous: None, sources: day_r3.to_vec() },
refused_r3("route_record_3_an_output_without_its_proof", "a job whose route record is of version 3, its output given without its inclusion proof", "job_v3r_without_its_proof"),
refused_r3("route_record_2_an_output_given_a_proof", "a job whose route record is of version 2, which lists what its hop delivered, its output given an inclusion proof", "job_v2_given_a_proof"),
refused_r3("route_record_3_a_proof_that_is_not_one", "a job whose route record is of version 3, its output's proof stating its index as text", "job_v3r_a_proof_that_is_not_one"),
refused_r3("route_record_3_a_proof_of_another_place", "a job whose route record is of version 3, its output proved with the siblings of another object's place", "job_v3r_a_proof_of_another_place"),
refused_r3("route_record_3_a_proof_past_the_count", "a job whose route record is of version 3, its output's proof at index 3 of 2 objects, whose walk is that of its place, 1", "job_v3r_a_proof_past_the_count"),
refused_r3("route_record_3_a_proof_a_sibling_short", "a job whose route record is of version 3, its output's proof one sibling short", "job_v3r_a_proof_a_sibling_short"),
refused_r3("route_record_3_an_output_its_hop_did_not_deliver", "a job whose route record is of version 3, its output another clip, proved in another tree than the root its hop delivered under", "job_v3r_an_output_its_hop_did_not_deliver"),
refused_r3("route_record_3_an_output_twice", "a job whose route record is of version 3, its one output given twice, each proved", "job_v3r_an_output_twice"),
refused_r3("route_record_3_a_decode_at_another_place", "a decode of version 5 whose route record is of version 3 and states the codestream it read before its animation, the animation proved at its own place, the second", "job_v3r_a_decode_at_another_place"),
refused_r3("route_record_3_a_decode_with_an_object_past_it", "a decode of version 5 whose route record is of version 3 and states the codestream it read after its animation, the animation proved at the first place", "job_v3r_a_decode_with_an_object_past_it"),
refused_r3("route_record_3_an_output_of_a_job_no_hop_served", "a job whose route record is of version 3 and whose every allowed hop failed, given an output, which no hop delivered", "job_v3r_no_hop_served"),
]);
let mut sources = Map::new();
for (n, v) in &pool {
sources.insert((*n).into(), v.clone());

View file

@ -17,6 +17,12 @@
//! by [`output_commitment`] (§6). [`seal_delivered`] writes a record of its price book's
//! version. Version 1 reads, routes and verifies exactly as before.
//!
//! Version 3 of the record (§3.4), on a price book of version 2: the attempt that served states
//! what it delivered by its count and root (`delivered`), the commitment its hop's receipt
//! names, so a record is the same size whatever a hop delivered; [`seal_committed`] writes one.
//! An object is shown to be one of them by its inclusion proof ([`output_proof`],
//! [`verify_output_proof`], §6.1).
//!
//! The corpus `live-conformance/routes/` is written by [`corpus::generate`] and checked by
//! [`corpus::verify`] here, and by `live-conformance/route_record_verify.py`, which shares no
//! code with this module.
@ -37,9 +43,12 @@ pub const CAPABILITY: &str = "wai.route.record";
pub const VERSION: u64 = 1;
/// The second version of the price book and the record (§3.4).
pub const VERSION_2: u64 = 2;
/// The third version of the record (§3.4), on a price book of version 2: the attempt that served
/// states what it delivered by its count and root.
pub const VERSION_3: u64 = 3;
/// The fewest hops a failure basis may count and still mean anything (§3.2.1).
pub const MIN_BASIS_HOPS: u64 = 100;
/// The most outputs an attempt states (§5.2).
/// The most outputs an attempt of a record of version 2 states (§5.2).
pub const MAX_OUTPUTS: usize = 1024;
/// The content hash's domain (jwp-receipts §2.1).
pub const CONTENT_DOMAIN: &[u8] = b"moq-jwp:object\x01";
@ -974,6 +983,26 @@ pub fn seal_delivered(key: &SigningKey, r: &Request, b: &PriceBook, p: &Policy,
sign(body, key)
}
/// [`seal`], writing a record of version 3 on a price book of version 2 (§3.4): the attempt that
/// served states `delivered`, the count of `outputs` (the content hashes of what it delivered, in
/// their order) and their commitment ([`output_commitment`]), and a failed attempt a count of 0
/// and a root of zeros (§5.2). On a price book of version 1 it writes version 1's record, as
/// [`seal`] does.
pub fn seal_committed(key: &SigningKey, r: &Request, b: &PriceBook, p: &Policy, accepted: u64, attempts: &[Attempt], outputs: &[[u8; 32]]) -> Value {
let mut record = seal_delivered(key, r, b, p, accepted, attempts, outputs);
if b.version != VERSION_2 {
return record;
}
let root = hex(&output_commitment(outputs));
for a in record["attempts"].as_array_mut().expect("a list") {
let served = a["outcome"] == "served";
a.as_object_mut().expect("an attempt").remove("outputs");
a["delivered"] = if served { json!({"count": outputs.len(), "root": root}) } else { json!({"count": 0, "root": hex(&[0; 32])}) };
}
record["version"] = json!(VERSION_3);
resign(record, key)
}
/// The members of a record (§5.1).
pub const RECORD_MEMBERS: [&str; 18] = [
"kind", "version", "job", "operation", "capability", "fallback", "quantities", "request", "policy", "pricebook", "candidates", "order", "decision", "accepted",
@ -983,12 +1012,18 @@ pub const RECORD_MEMBERS: [&str; 18] = [
/// The members of an attempt (§5.2).
pub const ATTEMPT_MEMBERS: [&str; 9] = ["hop", "endpoint", "outcome", "error", "start", "end", "billed", "not_billed", "receipt"];
/// The candidate's members: its entry's and the five §5.1 adds, and in version 2 the two
/// expected figures.
/// The version of the price book a record of `version` rests on (§3.4): 1 for version 1, 2 for
/// versions 2 and 3.
pub fn book_version(version: u64) -> u64 {
if version >= VERSION_2 { VERSION_2 } else { VERSION }
}
/// The candidate's members: its entry's and the five §5.1 adds, and on a price book of version 2
/// the two expected figures.
fn candidate_members(version: u64) -> Vec<&'static str> {
let mut m = entry_members(version);
let mut m = entry_members(book_version(version));
m.extend(["via_fallback", "cost", "energy", "removed", "rank"]);
if version == VERSION_2 {
if book_version(version) == VERSION_2 {
m.extend(["expected_cost", "expected_ttfb_ms"]);
}
m
@ -1035,12 +1070,77 @@ pub fn output_commitment(outputs: &[[u8; 32]]) -> [u8; 32] {
}
}
/// The inclusion proof (§6.1) of the output at `index` among `outputs`, in their order: the
/// sibling at each level of the tree [`output_commitment`] builds, from the leaves up, the last
/// node of an odd level paired with itself; none for one output. `None` past the last.
pub fn output_proof(outputs: &[[u8; 32]], index: usize) -> Option<Vec<[u8; 32]>> {
if index >= outputs.len() {
return None;
}
let b3 = |parts: &[&[u8]]| -> [u8; 32] {
let mut h = blake3::Hasher::new();
for p in parts {
h.update(p);
}
*h.finalize().as_bytes()
};
let mut siblings = Vec::new();
if outputs.len() == 1 {
return Some(siblings);
}
let mut level: Vec<[u8; 32]> = outputs.iter().map(|o| b3(&[LEAF_DOMAIN, o])).collect();
let mut at = index;
while level.len() > 1 {
if !level.len().is_multiple_of(2) {
level.push(*level.last().expect("a node"));
}
siblings.push(level[at ^ 1]);
level = level.as_chunks::<2>().0.iter().map(|[l, r]| b3(&[NODE_DOMAIN, l, r])).collect();
at /= 2;
}
Some(siblings)
}
/// Whether `siblings` proves the object whose content hash is `output` the one at `index` of
/// `count` outputs committed to by `root` (§6.1): `index` below `count`, exactly ⌈log₂ `count`⌉
/// siblings (none for one output, whose commitment is its content hash), and the walk from its
/// leaf, node by node, each sibling on the side `index`'s bit at that level gives, ends at
/// `root`. The count and the bits fix the position, so the last node of an odd level, paired
/// with itself, is proved at one index only.
pub fn verify_output_proof(output: &[u8; 32], index: u64, siblings: &[[u8; 32]], count: u64, root: &[u8; 32]) -> bool {
if index >= count {
return false;
}
let depth = if count <= 1 { 0 } else { 64 - (count - 1).leading_zeros() as usize };
if siblings.len() != depth {
return false;
}
if count == 1 {
return output == root;
}
let b3 = |parts: &[&[u8]]| -> [u8; 32] {
let mut h = blake3::Hasher::new();
for p in parts {
h.update(p);
}
*h.finalize().as_bytes()
};
let mut node = b3(&[LEAF_DOMAIN, output]);
for (i, s) in siblings.iter().enumerate() {
node = if (index >> i) & 1 == 1 { b3(&[NODE_DOMAIN, s, &node]) } else { b3(&[NODE_DOMAIN, &node, s]) };
}
&node == root
}
fn record_form(v: &Value) -> bool {
let version = v.get("version").and_then(int).unwrap_or(0);
let mut attempt_members = ATTEMPT_MEMBERS.to_vec();
if version == VERSION_2 {
attempt_members.push("outputs");
}
if version == VERSION_3 {
attempt_members.push("delivered");
}
let Some(o) = exactly(v, &RECORD_MEMBERS) else { return false };
let hexn = |v: &Value, n: usize| v.as_str().is_some_and(|s| s.len() == n && s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)));
let cap_or_null = |v: &Value| v.is_null() || v.as_str().is_some_and(capability);
@ -1050,7 +1150,7 @@ fn record_form(v: &Value) -> bool {
let scalar = |v: &Value| !(v.is_array() || v.is_object());
let candidate = |c: &Value| -> bool {
let Some(c) = exactly(c, &candidate_members(version)) else { return false };
let v2 = version != VERSION_2
let v2 = book_version(version) != VERSION_2
|| (c["operation"].as_str().is_some_and(operation)
&& (c["failures"].is_null() || Failures::form(&c["failures"]).is_some())
&& int_or_null(&c["expected_cost"])
@ -1095,7 +1195,15 @@ fn record_form(v: &Value) -> bool {
let distinct = xs.iter().filter_map(Value::as_str).collect::<std::collections::BTreeSet<_>>().len() == xs.len();
xs.iter().all(content_hash_text) && distinct && if served { (1..=MAX_OUTPUTS).contains(&xs.len()) } else { xs.is_empty() }
});
// Version 3 (§5.2): a served attempt's count, at least 1, and the root of what it
// delivered; a failed attempt's, 0 and zeros.
let delivered_ok = version != VERSION_3
|| exactly(&a["delivered"], &["count", "root"]).is_some_and(|d| {
let zero = d["root"].as_str() == Some(hex(&[0; 32]).as_str());
content_hash_text(&d["root"]) && int(&d["count"]).is_some_and(|n| if served { n >= 1 } else { n == 0 && zero })
});
outputs_ok
&& delivered_ok
&& int(&a["hop"]) == Some(i as u64 + 1)
&& tok(&a["endpoint"]).is_some()
&& error_ok
@ -1105,7 +1213,7 @@ fn record_form(v: &Value) -> bool {
&& (a["not_billed"].is_null() || a["not_billed"].as_str().is_some_and(|x| x == "failed" || x == "deadline_missed"))
&& a["receipt"].as_str().is_some_and(receipt_id)
};
(version == VERSION || version == VERSION_2)
(version == VERSION || version == VERSION_2 || version == VERSION_3)
&& tok(&o["job"]).is_some()
&& o["operation"].as_str().is_some_and(operation)
&& o["capability"].as_str().is_some_and(capability)
@ -1138,8 +1246,11 @@ pub struct Accepted {
/// `(endpoint, hop)` of the attempt that served.
pub served: Option<(String, u64)>,
/// In a record of version 2, the content hashes of the served attempt's outputs, in their
/// order; `None` in version 1 and for a job no hop served.
/// order; `None` in versions 1 and 3 and for a job no hop served.
pub outputs: Option<Vec<String>>,
/// In a record of version 3, the count and root of what the served attempt delivered;
/// `None` in versions 1 and 2 and for a job no hop served.
pub delivered: Option<(u64, String)>,
pub billed: Option<u64>,
/// `none`, `met` or `missed`.
pub deadline: &'static str,
@ -1206,6 +1317,7 @@ pub fn verify_document(d: &Document<'_>) -> Result<Accepted, Refusal> {
if let Some(v) = rec.get("version").and_then(int)
&& v != VERSION
&& v != VERSION_2
&& v != VERSION_3
{
return Err(Refusal::new(Reason::UnknownVersion, at(&[json!("record"), json!("version")])));
}
@ -1240,7 +1352,9 @@ pub fn verify_document(d: &Document<'_>) -> Result<Accepted, Refusal> {
if rec["policy"] != json!({"name": p.name, "version": p.version, "digest": hex(&p.digest)}) {
return Err(Refusal::new(Reason::PolicyMismatch, field("policy")));
}
if rec["version"] != json!(b.version) {
// A record of version 1 rests on a price book of version 1, and one of version 2 or 3 on a
// price book of version 2 (§3.4).
if book_version(rec["version"].as_u64().expect("a version")) != b.version {
return Err(Refusal::new(Reason::PricebookMismatch, field("version")));
}
if rec["pricebook"] != json!({"digest": hex(&b.digest), "currency": b.currency, "issued": b.issued}) {
@ -1322,6 +1436,7 @@ pub fn verify_document(d: &Document<'_>) -> Result<Accepted, Refusal> {
let outputs = served_hop
.and_then(|a| a.get("outputs"))
.map(|xs| xs.as_array().expect("a list").iter().map(|x| x.as_str().expect("a content hash").to_string()).collect());
let delivered = served_hop.and_then(|a| a.get("delivered")).map(|d| (d["count"].as_u64().expect("a count"), d["root"].as_str().expect("a root").to_string()));
let billed = attempts.iter().find(|a| a["billed"] == true).map(|a| a["hop"].as_u64().expect("a hop"));
let deadline_verdict = match (deadline, served_hop) {
(Some(dl), Some(a)) => if a["end"].as_u64().expect("a time") > dl { "missed" } else { "met" },
@ -1333,6 +1448,7 @@ pub fn verify_document(d: &Document<'_>) -> Result<Accepted, Refusal> {
operation: r.operation.clone(),
served,
outputs,
delivered,
billed,
deadline: deadline_verdict,
order: routed.order_endpoints(),
@ -1375,6 +1491,13 @@ fn check_receipts(rec: &Value, texts: &[&str], keys: &Map<String, Value>) -> Res
return Err(Refusal::new(Reason::ReceiptOutputMismatch, here));
}
}
// Version 3: the served hop's receipt names the root the attempt states (§6).
if let Some(d) = a.get("delivered").filter(|_| a["outcome"] == "served") {
let named = r.get("subject").and_then(|s| s.get("output")).and_then(Value::as_str);
if named != d["root"].as_str() {
return Err(Refusal::new(Reason::ReceiptOutputMismatch, here));
}
}
if crate::oer::is_unmetered(r) {
unmetered.push(a["hop"].as_u64().expect("a hop"));
}
@ -1391,9 +1514,10 @@ pub fn outcome_json(r: &Result<Accepted, Refusal>) -> Value {
"digest": hex(&a.digest),
"job": a.job,
"operation": a.operation,
"served": a.served.as_ref().map(|(e, h)| match &a.outputs {
Some(o) => json!({"endpoint": e, "hop": h, "outputs": o}),
None => json!({"endpoint": e, "hop": h}),
"served": a.served.as_ref().map(|(e, h)| match (&a.outputs, &a.delivered) {
(Some(o), _) => json!({"endpoint": e, "hop": h, "outputs": o}),
(None, Some((count, root))) => json!({"endpoint": e, "hop": h, "delivered": {"count": count, "root": root}}),
(None, None) => json!({"endpoint": e, "hop": h}),
}),
"billed": a.billed,
"deadline": a.deadline,
@ -1410,6 +1534,48 @@ pub fn outcome_json(r: &Result<Accepted, Refusal>) -> Value {
mod tests {
use super::*;
/// An output's inclusion proof (§6.1) closes at the commitment for every output of every count
/// to 37, and nowhere else: not at another index or one past the last, not with a sibling
/// changed, more or fewer, not for another object or under a count of another depth; one
/// output's root is its content hash. The last node of an odd level, paired with itself, is proved at its
/// own index only.
#[test]
fn an_outputs_inclusion_proof_closes_at_its_commitment_and_nowhere_else() {
for n in 1..=37usize {
let outs: Vec<[u8; 32]> = (0..n).map(|i| content_hash(format!("output {i}").as_bytes())).collect();
let (root, c) = (output_commitment(&outs), n as u64);
if n == 1 {
assert_eq!(root, outs[0]);
}
for (i, o) in outs.iter().enumerate() {
let p = output_proof(&outs, i).unwrap();
let at = i as u64;
assert!(verify_output_proof(o, at, &p, c, &root), "{n}: {i}");
assert!(!verify_output_proof(o, at + c, &p, c, &root), "{n}: {i} past the last");
assert!(!verify_output_proof(&content_hash(b"another object"), at, &p, c, &root), "{n}: {i} another object");
let mut more = p.clone();
more.push(root);
assert!(!verify_output_proof(o, at, &more, c, &root), "{n}: {i} a sibling more");
assert!(!verify_output_proof(o, at, &p, 2 * c + 1, &root), "{n}: {i} a count of another depth");
if n > 1 {
assert!(!verify_output_proof(o, ((i + 1) % n) as u64, &p, c, &root), "{n}: {i} another index");
assert!(!verify_output_proof(o, at, &p[..p.len() - 1], c, &root), "{n}: {i} a sibling fewer");
let mut changed = p.clone();
changed[0][0] ^= 1;
assert!(!verify_output_proof(o, at, &changed, c, &root), "{n}: {i} a sibling changed");
}
}
assert!(output_proof(&outs, n).is_none());
}
let outs: Vec<[u8; 32]> = (0u8..5).map(|i| content_hash(&[i])).collect();
let root = output_commitment(&outs);
let p = output_proof(&outs, 4).unwrap();
assert!(verify_output_proof(&outs[4], 4, &p, 5, &root));
for at in [5u64, 6, 7] {
assert!(!verify_output_proof(&outs[4], at, &p, 5, &root), "the paired node at {at}");
}
}
#[test]
fn a_document_is_read_strictly_and_held_to_the_subset() {
assert!(read_document(r#"{"a":1}"#).is_some());

View file

@ -8,8 +8,9 @@
//! refused at: [`generate`] refuses to write a corpus whose control reaches another, so a
//! control cannot be mistaken for the defect it names. The hop receipts are OER/2 receipts
//! of minor version 1 (oer-receipts §3.1), sealed here under the published hop key. The
//! vectors of version 2 (§3.4) follow those of version 1, which are unchanged but for the
//! control of an unknown version.
//! vectors of version 2 (§3.4) follow those of version 1, and those of version 3 follow
//! version 2's; each version's are unchanged by the next but for the control of an unknown
//! version.
use ed25519_dalek::{Signer, SigningKey};
use serde_json::{json, Value};
@ -408,7 +409,7 @@ fn vectors() -> Vec<Vector> {
let mut req_float = d.clone();
req_float["request"] = json!(canonical(&b.request).replacen("\"deadline_us\":30000000", "\"deadline_us\":3e7", 1));
v.push(refuse("request_with_an_exponent", req_float, "malformed", json!(["request"])));
v.push(refuse("record_version_3", b.edited(|r| r["version"] = json!(3)).document(), "unknown_version", rec(&[json!("version")])));
v.push(refuse("record_version_4", b.edited(|r| r["version"] = json!(4)).document(), "unknown_version", rec(&[json!("version")])));
// ── refused: the documents ──
let mut x = d.clone();
@ -666,8 +667,33 @@ pub(crate) fn package_job_document(job: &str, accepted: u64, attempts: &[Hop], o
routed_2(request, pricebook_2_of(vec![entry]), accepted, attempts, outputs)
}
/// [`job_document_2`], its record of version 3 (§3.4): the attempt that served states the count
/// and root of `outputs`, which its receipt names, and a holder proves each output among them by
/// [`super::output_proof`].
#[cfg(feature = "receipt_ledger")]
pub(crate) fn job_document_3(job: &str, operation: &str, accepted: u64, attempts: &[Hop], outputs: &[[u8; 32]]) -> Value {
routed(request_2(job, operation, no_preference()), pricebook_2(), accepted, attempts, outputs, true)
}
/// [`package_job_document`], its record of version 3.
#[cfg(feature = "receipt_ledger")]
pub(crate) fn package_job_document_3(job: &str, accepted: u64, attempts: &[Hop], outputs: &[[u8; 32]]) -> Value {
let mut request = request_2(job, "package.build", no_preference());
request["capability"] = json!(PACKAGE);
request["fallback"] = Value::Null;
request["quantities"] = json!([{"unit": "packages", "quantity": 1}]);
let entry = entry_2("pack.alpha", "package.build", "prov-alpha", PACKAGE, EU_C, ("packages", 1, 400_000), Some(500), Some((4_500_000, "OnChipCounter")), Some(950), failures(1_000, 0, 0, 0));
routed(request, pricebook_2_of(vec![entry]), accepted, attempts, outputs, true)
}
#[cfg(feature = "receipt_ledger")]
fn routed_2(request: Value, pricebook: Value, accepted: u64, attempts: &[Hop], outputs: &[[u8; 32]]) -> Value {
routed(request, pricebook, accepted, attempts, outputs, false)
}
/// A job routed under `pricebook`, its record of version 2, or of version 3 when `committed`.
#[cfg(feature = "receipt_ledger")]
fn routed(request: Value, pricebook: Value, accepted: u64, attempts: &[Hop], outputs: &[[u8; 32]], committed: bool) -> Value {
let r = Request::read(&canonical(&request)).expect("the request reads");
let b = PriceBook::read(&canonical(&pricebook)).expect("the price book reads");
let p = Policy::read(&canonical(&policy(3))).expect("the policy reads");
@ -688,7 +714,7 @@ fn routed_2(request: Value, pricebook: Value, accepted: u64, attempts: &[Hop], o
.zip(&receipts)
.map(|(h, rc)| Attempt { endpoint: h.endpoint.to_string(), error: h.error, start: accepted + h.start, end: accepted + h.end, receipt: id(rc) })
.collect();
let record = super::seal_delivered(&router(), &r, &b, &p, accepted, &atts, outputs);
let record = if committed { super::seal_committed(&router(), &r, &b, &p, accepted, &atts, outputs) } else { super::seal_delivered(&router(), &r, &b, &p, accepted, &atts, outputs) };
json!({
"request": canonical(&request),
"pricebook": canonical(&pricebook),
@ -702,7 +728,7 @@ fn routed_2(request: Value, pricebook: Value, accepted: u64, attempts: &[Hop], o
/// The corpus, as `vectors.json` holds it.
pub fn generate() -> Result<String, String> {
let mut out = Vec::new();
for vec in vectors().into_iter().chain(vectors_2()) {
for vec in vectors().into_iter().chain(vectors_2()).chain(vectors_3()) {
let got = outcome_json(&verify(&vec.document));
if let Some((reason, at)) = &vec.expect
&& (got["outcome"] != "refuse" || got["reason"] != *reason || got["at"] != *at)
@ -1028,3 +1054,85 @@ fn vectors_2() -> Vec<Vector> {
v.push(refuse("v2_three_outputs_named_by_the_first", three.with_receipt(0, naming(three.receipts.as_ref().unwrap()[0].clone(), first)).document(), "receipt_output_mismatch", ro(0)));
v
}
// ───────────────────────────── version 3 (§3.4) ─────────────────────────────
impl Job {
/// A job whose record is of version 3: as [`Job::new_2`], its record sealed by
/// [`super::seal_committed`], the served attempt stating the count and root of `outputs`.
fn new_3(request: Value, attempts: &[(&str, Option<&'static str>, u64, u64, Value)], outputs: &[[u8; 32]], receipts: bool) -> Job {
let j = Job::new_2(request, pricebook_2(), policy(3), attempts, outputs, receipts);
let r = Request::read(&canonical(&j.request)).expect("the request reads");
let b = PriceBook::read(&canonical(&j.pricebook)).expect("the price book reads");
let p = Policy::read(&canonical(&j.policy)).expect("the policy reads");
let atts: Vec<Attempt> = j.record["attempts"]
.as_array()
.expect("a list")
.iter()
.map(|a| Attempt {
endpoint: a["endpoint"].as_str().expect("a token").to_string(),
error: attempts[a["hop"].as_u64().expect("a hop") as usize - 1].1,
start: a["start"].as_u64().expect("a time"),
end: a["end"].as_u64().expect("a time"),
receipt: a["receipt"].as_str().expect("an id").to_string(),
})
.collect();
let record = super::seal_committed(&router(), &r, &b, &p, ACCEPTED, &atts, outputs);
Job { record, ..j }
}
}
fn vectors_3() -> Vec<Vector> {
let mut v = Vec::new();
let rec = |p: &[Value]| {
let mut a = vec![json!("record")];
a.extend_from_slice(p);
Value::Array(a)
};
let alpha = || ("im.alpha", None, 1_000, 4_000_000, metered("im.alpha"));
// ── accepted ──
// One output, whose root is its content hash; three; and more than a list of version 2 holds
// (§5.2), its record the size of the others.
let one = Job::new_3(request_2("job-3001", ENC, no_preference()), &[alpha()], &delivered("job-3001", 1), true);
v.push(accept("v3_served_one_output", &one));
let three = Job::new_3(request_2("job-3002", ENC, no_preference()), &[alpha()], &delivered("job-3002", 3), true);
v.push(accept("v3_served_three_outputs", &three));
v.push(accept("v3_served_more_outputs_than_a_list_holds", &Job::new_3(request_2("job-3003", ENC, no_preference()), &[alpha()], &delivered("job-3003", 5_000), true)));
let fo = Job::new_3(
request_2("job-3004", ENC, no_preference()),
&[("im.alpha", Some("server_error"), 1_000, 2_000_000, failed("im.alpha")), ("im.eta", None, 2_000_100, 9_000_000, model("im.eta"))],
&delivered("job-3004", 2),
true,
);
v.push(accept("v3_failover_whose_failed_hop_delivered_nothing", &fo));
v.push(accept("v3_served_after_the_deadline", &Job::new_3(request_2("job-3005", ENC, no_preference()), &[("im.alpha", None, 1_000, 31_000_000, metered("im.alpha"))], &delivered("job-3005", 1), true)));
v.push(accept("v3_receipts_not_checked", &Job::new_3(request_2("job-3001", ENC, no_preference()), &[alpha()], &delivered("job-3001", 1), false)));
// ── refused: the form of what was delivered ──
let invalid = |name: &'static str, j: &Job, f: &dyn Fn(&mut Value)| refuse(name, j.edited(|r| f(r)).document(), "record_invalid", json!(["record"]));
v.push(invalid("v3_served_with_a_count_of_0", &one, &|r| r["attempts"][0]["delivered"]["count"] = json!(0)));
v.push(invalid("v3_a_failed_hop_with_a_count", &fo, &|r| r["attempts"][0]["delivered"]["count"] = json!(1)));
v.push(invalid("v3_a_failed_hop_with_a_root", &fo, &|r| r["attempts"][0]["delivered"]["root"] = r["attempts"][1]["delivered"]["root"].clone()));
v.push(invalid("v3_a_root_in_capitals", &one, &|r| r["attempts"][0]["delivered"]["root"] = json!(r["attempts"][0]["delivered"]["root"].as_str().unwrap().to_uppercase())));
v.push(invalid("v3_a_count_as_text", &three, &|r| r["attempts"][0]["delivered"]["count"] = json!("3")));
v.push(invalid("v3_a_member_besides_the_count_and_the_root", &three, &|r| r["attempts"][0]["delivered"]["outputs"] = json!(3)));
v.push(invalid("v3_outputs_listed_as_version_2_lists_them", &one, &|r| {
let a = r["attempts"][0].as_object_mut().unwrap();
a.remove("delivered");
a.insert("outputs".into(), json!([hex(&delivered("job-3001", 1)[0])]));
}));
v.push(invalid("v3_delivered_in_a_record_of_version_2", &one, &|r| r["version"] = json!(2)));
// ── refused: the record's version against its price book's ──
let mut three_on_one = one.edited(|r| r["pricebook"] = json!({"digest": hex(&super::digest(&pricebook())), "currency": "EUR", "issued": pricebook()["issued"]}));
three_on_one.pricebook = pricebook();
v.push(refuse("a_record_of_version_3_on_a_price_book_of_version_1", three_on_one.document(), "pricebook_mismatch", rec(&[json!("version")])));
// ── refused: a receipt held to the root ──
let ro = |i: usize| rec(&[json!("attempts"), json!(i), json!("receipt")]);
v.push(refuse("v3_a_receipt_moved_from_another_job", one.with_receipt(0, fo.receipts.as_ref().unwrap()[1].clone()).document(), "receipt_output_mismatch", ro(0)));
let two_of_three = hex(&super::output_commitment(&delivered("job-3002", 3)[..2]));
v.push(refuse("v3_a_root_not_the_receipts", three.edited(|r| r["attempts"][0]["delivered"]["root"] = json!(two_of_three)).document(), "receipt_output_mismatch", ro(0)));
v
}