wai: one-way delivery — timeliness, rung price, cache authority and impression aggregates #633
90 changed files with 91827 additions and 368 deletions
33
.github/wai-conformance/verifiers/cache-authority.sh
vendored
Normal file
33
.github/wai-conformance/verifiers/cache-authority.sh
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# step: cache authority · wai-rs and cache_authority_verify.py
|
||||
# order: 557
|
||||
# features: cache_authority
|
||||
# paths: wai/extensions/cache-authority.md
|
||||
#
|
||||
# wai.delivery.cache (extensions/cache-authority.md): the reference module's unit tests (an
|
||||
# instant rounded up to the millisecond, coverage as the union of ranges from zero, a
|
||||
# population's one form and a digest that binds its region, a retired key placed against its
|
||||
# `until`, the codes listed once and the serving roles settlement's, and the corpus reaching
|
||||
# every stated outcome and failing a changed one) with those of the bearer claims' bodies (a
|
||||
# bitmap read in object order and refused for each defect, a cache-transmission claim held to
|
||||
# its rules in order, the bearer classes' labels), and tests/cache_authority.rs
|
||||
# (vectors.json regenerates byte for byte from its fixed seeds and no other file is there,
|
||||
# wai-rs gives every case its stated outcome, §11 lists exactly the reference's codes, and
|
||||
# jwp-receipts §3.1 lists the class's codes at their steps); then the corpus's second
|
||||
# implementation, cache_authority_verify.py, written from the extension's text and sharing no
|
||||
# code with wai-rs, which verifies groups and objects with jwp_verify.py, claims through steps
|
||||
# 1-5 with claim_verify.py, transmissions and reconciliations with bearer_verify.py,
|
||||
# acknowledgements with settlement_verify.py, key logs with signer_verify.py, held-at
|
||||
# statements with timeliness_verify.py and regions with rung_price_verify.py, writes the
|
||||
# placement, the claim against its group, the verdict and the tally itself, reads §11,
|
||||
# requires every mutated copy of the corpus to fail, and drops each of its own checks in
|
||||
# turn; run isolated (-I). Last, claim-conformance's own verifier reads every claim of the
|
||||
# corpus through jwp-receipts §3.1 steps 1 to 4 and requires the stated outcome.
|
||||
#
|
||||
# The paths this step brings to the workflow's trigger, and why:
|
||||
# wai/extensions/cache-authority.md: tests/cache_authority.rs requires §11 to list the reference's
|
||||
# codes, and cache_authority_verify.py reads it.
|
||||
set -euo pipefail
|
||||
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib -- cache_authority:: claim_receipt::bearer::)
|
||||
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test cache_authority)
|
||||
python3 -I live-conformance/cache_authority_verify.py live-conformance/cache-authority
|
||||
python3 -I claim-conformance/claim_verify.py cache-authority live-conformance/cache-authority/vectors.json
|
||||
33
.github/wai-conformance/verifiers/impression-aggregates.sh
vendored
Normal file
33
.github/wai-conformance/verifiers/impression-aggregates.sh
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# step: impression aggregates · wai-rs and impression_aggregate_verify.py
|
||||
# order: 605
|
||||
# features: impression_aggregate
|
||||
# paths: wai/extensions/impression-aggregates.md
|
||||
#
|
||||
# wai.delivery.impressions (extensions/impression-aggregates.md), telemetry-aggregation's
|
||||
# measure 5: the reference module's unit tests (the cell index, region columns, the chunk
|
||||
# length, the retention arithmetic, the deadline at the flight's last hour, the plan's order,
|
||||
# settlement on every DAP error value, the integer sum, the codes and a table's totals past
|
||||
# 2^64) and tests/impression_aggregate.rs (vectors.json regenerates byte for byte from its
|
||||
# fixed seeds and no other file is there, wai-rs gives every case its stated outcome and every
|
||||
# reason of §10 appears, §10 lists exactly the reference's codes, each standing receipt's
|
||||
# result is its contributions' tally and the flight's table gives per creative
|
||||
# stitched-presentation's own counts, and a task file and an upload file of exactly their
|
||||
# bound read while one byte more is refused); then the corpus's second implementation,
|
||||
# impression_aggregate_verify.py, written from the extension's text and sharing no code with
|
||||
# wai-rs, which evaluates Prio3 and opens every report with telemetry_aggregate_verify.py,
|
||||
# judges records and claims with stitched_presentation_verify.py, verifies tokens with
|
||||
# ad_presentation_verify.py, confirms task-file signers with signer_verify.py and reads
|
||||
# regions with rung_price_verify.py, writes the catalogue, task file, upload file,
|
||||
# contribution, plan, settlement, receipt check and table itself, requires every mutated copy
|
||||
# of the corpus to fail and drops each of its own checks in turn; run isolated (-I). The QoE,
|
||||
# stitched, key-log and signer steps still run their own corpora, which the shared refactors
|
||||
# leave byte for byte as they were.
|
||||
#
|
||||
# The paths this step brings to the workflow's trigger, and why:
|
||||
# wai/extensions/impression-aggregates.md: tests/impression_aggregate.rs requires §10 to list the
|
||||
# reference's codes, impression_aggregate_verify.py reads it, and the registry's wire
|
||||
# identifiers test reads §4.1, §5.4, §6.4 and §7.2.
|
||||
set -euo pipefail
|
||||
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib -- impression_aggregate::)
|
||||
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test impression_aggregate)
|
||||
python3 -I live-conformance/impression_aggregate_verify.py live-conformance/impressions
|
||||
32
.github/wai-conformance/verifiers/rung-price.sh
vendored
Normal file
32
.github/wai-conformance/verifiers/rung-price.sh
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# step: rung price · wai-rs and rung_price_verify.py
|
||||
# order: 555
|
||||
# features: rung_price
|
||||
# paths: wai/extensions/rung-price.md
|
||||
#
|
||||
# wai.delivery.rung_price (extensions/rung-price.md): the reference module's unit tests (a
|
||||
# list read back with a digest that ignores its signature, every rule refusing, the bounds no
|
||||
# vector carries, a seller's latest list standing alone and a lapsed one reviving none, a
|
||||
# shared transmission's whole cost per receiver unless an audience is stated, each limit,
|
||||
# energy ranked by checked evidence first (a label whose records are not given, not current
|
||||
# or not confirmed ranking as an estimate, one its own records refute as unknown), a hardware
|
||||
# figure compared one declared uncertainty above itself, the break-even audience, the pass
|
||||
# rate and its tier, and the corpus reaching every stated outcome and failing a changed one)
|
||||
# with those of the region code its §2 defines (the grammar, a footprint holding a code or
|
||||
# its country's and never upward, and the exact CDNI case mapping), and tests/rung_price.rs
|
||||
# (vectors.json regenerates byte for byte from its fixed seeds, wai-rs gives every case its
|
||||
# stated outcome, §8 lists exactly the reference's codes and §6.2 its limits, in order); then
|
||||
# the corpus's second implementation, rung_price_verify.py, written from the extension's text
|
||||
# and sharing no code with wai-rs, which reads documents with route_record_verify.py, per-hop
|
||||
# records with live_metering_verify.py, room descriptors with fleet_verify.py and signatures
|
||||
# with the shared _ed25519.py, writes the region code, the list's rules, the evidence, the
|
||||
# verdict and the comparison itself, reads §8 and §6.2, requires every mutated copy of the
|
||||
# corpus to fail and drops each of its own checks in turn; run isolated (-I).
|
||||
#
|
||||
# The paths this step brings to the workflow's trigger, and why:
|
||||
# wai/extensions/rung-price.md: tests/rung_price.rs requires §8 to list the reference's codes and
|
||||
# §6.2 its limits, rung_price_verify.py reads both, and the registry's wire identifiers test
|
||||
# reads §4.1 and §6.1.
|
||||
set -euo pipefail
|
||||
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib -- rung_price:: region::)
|
||||
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test rung_price)
|
||||
python3 -I live-conformance/rung_price_verify.py live-conformance/prices
|
||||
|
|
@ -13,8 +13,9 @@
|
|||
# extension's text and sharing no code with wai-rs, which computes every closure, holder,
|
||||
# purge time, clock verdict and counts row itself, reads derivation records and delivery
|
||||
# receipts with edge_verify.py, origin claims with synthesis_verify.py, acknowledgements
|
||||
# with settlement_verify.py and every record with WAI's strict Ed25519 rule and BLAKE3
|
||||
# from claim-conformance, requires every mutated copy of the corpus to fail, and drops
|
||||
# with settlement_verify.py, cache-transmission claims through jwp-receipts §3.1 steps 1-4
|
||||
# with claim_verify.py, and every record with WAI's strict Ed25519 rule and BLAKE3 from
|
||||
# claim-conformance, requires every mutated copy of the corpus to fail, and drops
|
||||
# each of its own checks in turn and requires the corpus to catch the weakened verifier;
|
||||
# run isolated (-I). The reference relay's DVR store honours takedowns (§10.1), tested by
|
||||
# joule-moq-relay's tests/dvr.rs in joule-code-conformance.yml.
|
||||
|
|
|
|||
29
.github/wai-conformance/verifiers/timeliness.sh
vendored
Normal file
29
.github/wai-conformance/verifiers/timeliness.sh
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# step: timeliness · wai-rs and timeliness_verify.py
|
||||
# order: 552
|
||||
# features: timeliness
|
||||
# paths: wai/extensions/timeliness.md
|
||||
#
|
||||
# wai.delivery.timeliness (extensions/timeliness.md): the reference module's unit tests (the
|
||||
# value function against an exact reference, the collinearity rule, each rule refusing, the
|
||||
# judgement, the held-at statement's before-group test and the plan's sources, and the
|
||||
# corpus reaching every stated outcome and failing a changed one) and tests/timeliness.rs
|
||||
# (vectors.json regenerates byte for byte from its fixed seeds, wai-rs gives every case its
|
||||
# stated outcome, §9 lists exactly the reference's codes and §6.3 its outcomes, jwp-receipts
|
||||
# §3.1 lists the class codes at their steps, and delivery-telemetry §5.1 lists the switch
|
||||
# reason); then the corpus's second implementation, timeliness_verify.py, written from the
|
||||
# extension's text and sharing no code with wai-rs, which verifies groups and objects with
|
||||
# jwp_verify.py, claims through steps 1-5 with claim_verify.py, schedules and passes with
|
||||
# carousel_verify.py and records with holdings_verify.py, writes the context checks, the
|
||||
# value function, the judgement and the plans itself, reads §9 and §6.3, requires every
|
||||
# mutated copy of the corpus to fail, and drops each of its own checks in turn; run isolated
|
||||
# (-I). Last, claim-conformance's own verifier reads every claim of the corpus through
|
||||
# jwp-receipts §3.1 steps 1 to 4 and requires the stated outcome.
|
||||
#
|
||||
# The paths this step brings to the workflow's trigger, and why:
|
||||
# wai/extensions/timeliness.md: tests/timeliness.rs requires §9 to list the reference's codes and
|
||||
# §6.3 its outcomes, and timeliness_verify.py reads both.
|
||||
set -euo pipefail
|
||||
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib -- timeliness:: claim_receipt::timeliness::)
|
||||
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test timeliness)
|
||||
python3 -I live-conformance/timeliness_verify.py live-conformance/timeliness
|
||||
python3 -I claim-conformance/claim_verify.py timeliness live-conformance/timeliness/vectors.json
|
||||
|
|
@ -71,8 +71,9 @@ they serve ([jwp-receipts](extensions/jwp-receipts.md) §7),
|
|||
[loc-carriage](extensions/loc-carriage.md) and [stream-termination](extensions/stream-termination.md)
|
||||
put on the wire outside a manifest — magic numbers, a media type, `codecs` prefixes, paths,
|
||||
playlist tags and attributes, document kinds, catalog fields, MOQT Setup Options, HTTP fields, a
|
||||
member of a decoded frame's metadata, glTF extension names and the custom keys of CMCD and CMSD
|
||||
reports — each with its kind, the section that
|
||||
member of a decoded frame's metadata, glTF extension names, the custom keys of CMCD and CMSD
|
||||
reports and report extensions of the Distributed Aggregation Protocol — each with its kind, the
|
||||
section that
|
||||
defines it, and whether it is defined, provisional (test material only until
|
||||
registered) or proposed (not registered). A library test checks that each is written
|
||||
in the section it names.
|
||||
|
|
@ -186,12 +187,16 @@ and a role are fixed once registered.
|
|||
| `wai.bearer.receipts` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.capture.take` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.delivery.audit` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.delivery.cache` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.delivery.fleet` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.delivery.holdings` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.delivery.impressions` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.delivery.ledger` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.delivery.opportunity` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.delivery.revenue_share` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.delivery.rung_price` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.delivery.settlement` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.delivery.timeliness` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.derive.crop` | derivation | registered | — | — | patent-pool | — | — | §5 Prior artifacts, general tensors and derivation operations |
|
||||
| `wai.derive.dissolve` | derivation | registered | — | — | patent-pool | — | — | §5 Prior artifacts, general tensors and derivation operations |
|
||||
| `wai.derive.grid_composition` | derivation | registered | — | — | patent-pool | — | — | §5 Prior artifacts, general tensors and derivation operations |
|
||||
|
|
@ -404,6 +409,9 @@ and a role are fixed once registered.
|
|||
| `WFR1` | magic | defined | fleet §6.1 |
|
||||
| `WGM1` | magic | defined | exact-tonemap §4.1 |
|
||||
| `WHP1` | magic | defined | http-delivery §6.2 |
|
||||
| `WIC1` | magic | defined | impression-aggregates §4.1 |
|
||||
| `WIT1` | magic | defined | impression-aggregates §5.4 |
|
||||
| `WIU1` | magic | defined | impression-aggregates §6.4 |
|
||||
| `WJR1` | magic | defined | jwp-receipts §2.7.3 |
|
||||
| `WKE1` | magic | defined | key-release §5.1 |
|
||||
| `WKL1` | magic | defined | key-release §4.1 |
|
||||
|
|
@ -563,6 +571,8 @@ and a role are fixed once registered.
|
|||
| `wai-route-record` | document-kind | defined | route-record §5.1 |
|
||||
| `wai-route-request` | document-kind | defined | route-record §3.1 |
|
||||
| `wai-route-third-party-hop` | document-kind | defined | route-record §6.2 |
|
||||
| `wai-rung-prices` | document-kind | defined | rung-price §4.1 |
|
||||
| `wai-rung-request` | document-kind | defined | rung-price §6.1 |
|
||||
| `wai-scene/1` | scheme | defined | scene-carriage §2.3 |
|
||||
| `wai-sequence` | document-kind | defined | direction §6 |
|
||||
| `wai-session` | document-kind | defined | contribution §3.1 |
|
||||
|
|
@ -594,6 +604,7 @@ and a role are fixed once registered.
|
|||
| `wai_capabilities` | catalog | defined | moq-streaming-format §4 |
|
||||
| `wai_delivery_timeout_ms` | catalog | defined | packaging §8 |
|
||||
| `wai_determinism` | catalog | defined | moq-streaming-format §4 |
|
||||
| `wai_impression_token` | dap-report-extension | proposed | impression-aggregates §7.2 |
|
||||
| `wai_machine_generated` | catalog | defined | timed-text §7.4 |
|
||||
| `wai_model` | catalog | defined | timed-text §7.2 |
|
||||
| `wai_origin` | catalog | defined | timed-text §7.2 |
|
||||
|
|
|
|||
61
wai/SPEC.md
61
wai/SPEC.md
|
|
@ -1817,6 +1817,47 @@ two of one instant that differ, answers whether a record holds an object, and de
|
|||
objects each neighbour holds, in the asker's order, and which must come from the origin. An object
|
||||
a neighbour serves is verified under its receipts like any other; a record is never evidence of
|
||||
bytes: [`extensions/holdings.md`](extensions/holdings.md).
|
||||
And for **when** an object is still worth delivering — `wai.delivery.timeliness`: a publisher's
|
||||
signed statement, a jwp-receipts claim of class `timeliness` beside the group receipt and one for
|
||||
every bearer, of each object's instant of use, its deadline, how its value falls after it (a stated
|
||||
piecewise-linear function in parts per million, floored) and the rendition a receiver presents in
|
||||
its place, a separate object derived from it. A verifier holds the statement to its group and
|
||||
objects, refuses one for a staged layer, sets aside two that differ, and judges each object for
|
||||
each holder from holdings records, as a sink that decides at the deadline would present: on time,
|
||||
replaced, held only after its deadline at a value it shows at least, or not shown
|
||||
(`deadline_not_stated` where nothing is stated). An asker plans which of its sources, in its own
|
||||
order, delivers each object in time; the deadline maps onto MoQ's delivery timeout, CMCD's `dl`,
|
||||
DVB's target acquisition latency, ATSC's entry-package load time and a bundle's lifetime:
|
||||
[`extensions/timeliness.md`](extensions/timeliness.md).
|
||||
And for **what delivering a rung costs** — `wai.delivery.rung_price`: a seller's signed list
|
||||
(`wai-rung-prices`) of prices per direction, bearer class, footprint of regions delivered to,
|
||||
ceiling of the rung's rate and unit, each unit at the divisor of the ledger field that bills it, in
|
||||
route-record's amounts with the energy the seller states; the latest of a seller's lists stands
|
||||
alone, and one that lapses revives none. rung-price §2 is WAI's one region code (ISO 3166-1 alpha-2
|
||||
or ISO 3166-2, in capitals), held by a footprint that lists it or, for a subdivision, its country. A
|
||||
comparer's request removes what its limits remove (a `null` is most restrictive, units are never
|
||||
converted, and the least ceiling that covers a rate prices it) and ranks the rest by a cost per
|
||||
receiver: a shared transmission's is held to its pass rate and divided by the audience the comparer
|
||||
states or by one, never by an assumed one, and the plan states the audience at which sharing is no
|
||||
dearer. A list is a quote, billed only at a tariff the buyer signed:
|
||||
[`extensions/rung-price.md`](extensions/rung-price.md).
|
||||
And for **who served** what a node holds, and on whose authority — `wai.delivery.cache`: the role
|
||||
`cache/serve`, which a node's operator gives in its key log to the caches and peers it runs, and the
|
||||
class authority `cache-transmission`. Under the role a key signs its holdings records and its
|
||||
declarations, and is the upstream its receivers acknowledge; neither lets it author, transform or
|
||||
sign a publisher's statement. A verifier places a server key by the heads its relying party names,
|
||||
in jwp-receipts' three levels, and a placement never changes a verdict. The `cache-transmission`
|
||||
claim (jwp-receipts §2.4.18), beside the group receipt, declares a multicast or unidirectional
|
||||
instance of the node's own and the objects it sent over it from what it held, with a root under the
|
||||
publisher's tree checked without the bytes; receivers' reconciliations over it count, the node
|
||||
credited per instance and only for what it says it sent, and it never makes a group transmitted. Of
|
||||
a population of receiver keys a relying party states for a region (rung-price §2's codes), fixed by
|
||||
its digest, a verifier derives how many held an object by a deadline (stated, or a timeliness
|
||||
entry's), on their own signed word: acknowledgements, counted reconciliations and holdings records,
|
||||
read as timeliness' held-at statements, with any before the group's start set aside. The miss rate
|
||||
is in parts per million, rounded up and held to a target, each hit credited to the publisher or the
|
||||
nodes its evidence names, and every unknown a miss:
|
||||
[`extensions/cache-authority.md`](extensions/cache-authority.md).
|
||||
And for **exploration** with the exact generator, and **taste** as a declared, owned objective —
|
||||
`wai.media.exploration`: six integer descriptors over a picture's `RGB8` samples (mean luma,
|
||||
chroma, detail, palette diversity and the luma centroid's two coordinates) place it without
|
||||
|
|
@ -1842,9 +1883,10 @@ Aggregation Protocol (`draft-ietf-ppm-dap-20`): each measurement is secret-share
|
|||
and a helper aggregator and each share encrypted to its aggregator, the two check its range proof
|
||||
without either seeing it and refuse an out-of-range one, and neither learns a measurement as long
|
||||
as one of them follows the protocol. A task's `task_info` names its measure, its scope (a room id
|
||||
for a room's) and the keys of its two aggregators and its collector, so every report is bound at
|
||||
upload to who aggregates it; a task's minimum batch size is at least fifty, and each aggregator
|
||||
refuses to release a share of a smaller batch. Each aggregator signs a statement of its aggregate
|
||||
for a room's, an impression catalogue's digest for measure 5) and the keys of its two aggregators
|
||||
and its collector, so every report is bound at upload to who aggregates it; a task's minimum batch
|
||||
size is at least fifty, and each aggregator refuses to release a share of a smaller batch. Each
|
||||
aggregator signs a statement of its aggregate
|
||||
share with the batch's task, count and checksum, `WQS1`; the collector's aggregate receipt, `WQA1`,
|
||||
carries the task, both statements and the result, and a reader recomputes the result and refuses a
|
||||
receipt with one aggregator, a batch below the minimum, statements that disagree, or a batch whose
|
||||
|
|
@ -1852,6 +1894,19 @@ reports were uploaded to another task. A room's quality-of-experience cell is co
|
|||
tasks' receipts. Delivery telemetry classes every CMCD key it writes as `delivery` or
|
||||
`measurement`, with what each reveals, and a delivery-only Request mode carries the delivery
|
||||
class alone: [`extensions/telemetry-aggregation.md`](extensions/telemetry-aggregation.md).
|
||||
And for **ad presentations counted over a bearer with no return path** — `wai.delivery.impressions`:
|
||||
a receiver judges each ad of a shared pod-input record it presented by stitched-presentation's
|
||||
rules, and reports each presentation once through the Distributed Aggregation Protocol, to a task
|
||||
of `wai.telemetry.aggregate`'s measure 5, as a Prio3Histogram measurement whose one set element is
|
||||
its creative, region and quartile, with the hour in DAP's own report time; the shares, statements
|
||||
and receipt are that record's. A catalogue fixes the vector's meaning and is the task's scope; a
|
||||
receiver accepts a task only from a task file whose signer its pinned key log lists with the
|
||||
authority `aggregate/task`, generates each report once and re-sends its bytes over whatever path
|
||||
it later has, and the leader holds each collection for a stated delay so late reports count. With
|
||||
attestation each report carries a Privacy Pass token bound to its task and report id. A reader
|
||||
recomputes, per batch, each creative's and region's presentations and quartile counts: a lower
|
||||
bound of what receivers reported, never an audience:
|
||||
[`extensions/impression-aggregates.md`](extensions/impression-aggregates.md).
|
||||
And for **robustness evidence** — what a soft binding survives, measured so anyone recomputes it —
|
||||
`wai.media.robustness`: a corpus document names pictures or clips each by the SHA-256 of its
|
||||
samples, and itself by the SHA-256 of its JCS; an attack battery is a list of registered transforms,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
### Ad presentations counted over a bearer with no return path
|
||||
|
||||
- **Impression aggregates** ([extensions/impression-aggregates.md](extensions/impression-aggregates.md)),
|
||||
the record `wai.delivery.impressions`, and telemetry-aggregation's measure 5 `impressions`: a
|
||||
receiver judges each shared stitched ad it presented by stitched-presentation's rules (steps 1
|
||||
to 10), and reports each presentation once as a Prio3Histogram report of a DAP task of that
|
||||
measure: creative × region × quartile in the vector, the hour in DAP's own batch bucket. The task
|
||||
layout, statements (`WQS1`) and receipt (`WQA1`) are telemetry-aggregation's; a reader checks
|
||||
that the result's elements, as integers, sum to the report count (age-assurance's
|
||||
`count_mismatch` otherwise) and that the batch lies in the flight, and an accepted receipt states
|
||||
telemetry-aggregation's outcome and whether its reports were checked.
|
||||
- **The records**: the catalogue (`WIC1`) fixing which index is which creative, region (rung-price
|
||||
§2's codes, and `other`) and quartile, bound as the task's scope; the task file (`WIT1`), whose
|
||||
signer a receiver confirms under its pinned key log with the new pinned-only authority
|
||||
`aggregate/task` (jwp-receipts §7.5), so a task key can be revoked over the bearer; the upload
|
||||
file (`WIU1`) relays forward unchanged. The registry lists the three magics and the report
|
||||
extension, the last under a new kind of wire identifier, `dap-report-extension`.
|
||||
- **One-way delivery**: a report is generated once, stored and re-sent until settled (never
|
||||
regenerated); a duplicate is discarded by its id; the leader holds each collection for the
|
||||
catalogue's `hold_s` and aggregators keep the task until its retention instant, so late reports
|
||||
count; one arriving later is lost, never a phantom. With `attest`, each report carries a Privacy
|
||||
Pass token bound to its task and id, in a private report extension (`wai_impression_token`,
|
||||
0x5749, proposed for DAP's registry).
|
||||
- **What a reader derives**: the table per batch and in total, presentations and five quartile
|
||||
counts per creative and region, a row under fifty flagged, whether each receipt's reports were
|
||||
checked; a lower bound of reported presentations, never an audience, and its own class, never
|
||||
billed as presented.
|
||||
- **Beside it**: telemetry-aggregation registers measure 5 (§3.1), its scope (§4.1), its rule 3
|
||||
(§4.3), its reports (§5.1) and its receipt check (§8.1) by reference to the extension, and
|
||||
measures 1 to 4 are unchanged. The reference client seals to the first configuration of DAP's
|
||||
suite in each list; the reference aggregators hold a report's extensions to a rule (none, for
|
||||
measures 1 to 4) and a collection until a batch's end and the hold have passed (a hold of 0 for
|
||||
measures 1 to 4). `aggregate/task` joins the pinned-only authorities of `key_log` and
|
||||
`signer_verify.py`. The QoE, stitched, key-log and signer corpora are unchanged.
|
||||
- **The corpus** [`live-conformance/impressions/`](live-conformance/impressions/): catalogues,
|
||||
tasks and task files (key-log confirmation among them) with a control for each rule and both
|
||||
sides of every bound, contributions, end-to-end flows, uploads, plans, settlements, receipts and
|
||||
tables. `impression_aggregate_verify.py` is a second implementation.
|
||||
- **Breaking:** `api:rust:wai::telemetry_aggregate::measure::MEASURES`: the measures with
|
||||
`impressions` (5) last, an array of 5.
|
||||
- **Breaking:** `api:rust:wai::telemetry_aggregate::measure::Task::new`: refuses with the code of
|
||||
the rule that fails, `Result<Task, &'static str>`, as `Task::with` does, which takes the rule 3
|
||||
of a measure whose VDAF telemetry-aggregation §3.1 does not fix; `new` refuses measure 5
|
||||
`catalogue_mismatch`.
|
||||
- **Breaking:** `api:rust:wai::telemetry_aggregate::receipt::Refusal`: a code and a place,
|
||||
`(&'static str, &'static str)`, so `receipt::verify_with` can refuse a task with another text's
|
||||
code.
|
||||
- **Breaking:** `api:rust:wai::telemetry_aggregate::aggregator::client_report`: takes each
|
||||
aggregator's HpkeConfigList and seals to the first configuration of DAP's suite in it
|
||||
(`aggregator::choose`), and takes the report's public extensions and each input share's
|
||||
private ones.
|
||||
- **Breaking:** `api:rust:wai::telemetry_aggregate::aggregator::Aggregator::rule` and
|
||||
`api:rust:wai::telemetry_aggregate::aggregator::Aggregator::reasons`: the rule an aggregator
|
||||
holds a report's extensions to (`ReportRule`; `Aggregator::new` holds telemetry-aggregation §5.1's,
|
||||
no extension) and the WAI reason it recorded beside a report error, which a struct literal of
|
||||
`Aggregator` needs.
|
||||
- **Breaking:** `api:rust:wai::telemetry_aggregate::aggregator::Leader::collect`: takes the
|
||||
instant `now_s` and the hold `hold_s`, both in seconds, and returns `Ok(None)` while the job's
|
||||
batch has not ended `hold_s` seconds before; a task that holds no collection passes 0.
|
||||
- **Breaking:** `api:rust:wai::codecs::registry::WireKind::DapReportExtension` and
|
||||
`api:rust:wai::codecs::registry::WireKind::all`: the kind `dap-report-extension`, which an
|
||||
exhaustive match over `WireKind` needs, and the list of kinds that holds it, an array of 18.
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
### What delivering a rung costs, by bearer and direction
|
||||
|
||||
- **Rung price** ([extensions/rung-price.md](extensions/rung-price.md)), the record
|
||||
`wai.delivery.rung_price`: a seller's signed list of delivery prices (`wai-rung-prices`), each
|
||||
for a direction (`downlink`, `uplink`, as RFC 8506 §8.25 and §8.24 count octets), a bearer class
|
||||
(bearer-receipts §2), the regions it delivers to (a footprint of region codes), a ceiling of the
|
||||
rung's rate in bits per second, and a unit: `bytes`, `subscriber_us` or `publisher_us` on a
|
||||
two-way bearer, or `bytes_sent` or `pass_us` on a shared one, each at the divisor of the ledger
|
||||
field that bills it. Prices are in route-record's amounts, with the energy the seller states
|
||||
beside them; a figure of a class above `Estimator` names the seller's own per-hop records
|
||||
(`WMH1`) by digest, and one of a hardware class its declared uncertainty in parts per million.
|
||||
The list is a strict JSON document signed over `wai:rung-prices\x01` and named by the digest of
|
||||
its body without `sig`. A seller's latest list stands alone, and one that lapses revives none.
|
||||
- **The region code** (rung-price §2): an ISO 3166-1 alpha-2 or ISO 3166-2 code in capitals, its
|
||||
country, and when a footprint *holds* it (it lists the code, or the country of a subdivision;
|
||||
never a country because it lists a subdivision of it), mapped to and from CDNI's lowercase
|
||||
codes by case alone. It is WAI's one definition, which an extension that states a region cites.
|
||||
In `wai-rs` it is the ungated `region` module, and `rung_price_verify.py` exports its Python
|
||||
twin.
|
||||
- **Comparing delivery options**: a request's candidates and the limits that remove each (a `null`
|
||||
is most restrictive, units are never converted, and the least tier that covers the rung or the
|
||||
pass rate prices it); a cost and an energy per receiver, a shared transmission's divided by the
|
||||
audience the comparer states or by one; a ranking by price or by energy (a class counts only when
|
||||
the comparer is given the seller's own current per-hop records that bear the figure out and has
|
||||
confirmed the seller's key for that class; any other figure ranks as an estimate, one its own
|
||||
records contradict is set aside, and a hardware figure is compared one declared uncertainty above
|
||||
itself); each transmission's break-even audience; and the neighbour order a plan gives an asker
|
||||
of holdings §5.4, priced holders first and the unpriced after them in key order.
|
||||
- **What bills each unit**: a list is a quote, billed only at a tariff the buyer signed. Room time
|
||||
is billed by a room's prices, per layer class for `subscriber_us`; downlink `bytes` by a
|
||||
delivery price per object class or a room's `bytes_out` price. A transmission's pass rate is a
|
||||
pure function of its carousel schedule; `bytes_sent` and `pass_us` are compared, and no tariff
|
||||
field bills them yet.
|
||||
- **The corpus** [`live-conformance/prices/`](live-conformance/prices/): lists with a control for
|
||||
each rule, verdicts at each edge of a window, and comparisons covering every limit, decision,
|
||||
evidence verdict and energy rank, among them a calibrated label against an honest model-based
|
||||
figure. `rung_price_verify.py` is a second implementation.
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
### When an object is still worth delivering, and whether it was delivered in time
|
||||
|
||||
- **Timeliness** ([extensions/timeliness.md](extensions/timeliness.md)), the record
|
||||
`wai.delivery.timeliness`: a publisher's signed statement, a jwp-receipts claim of class
|
||||
`timeliness` (§2.4.17) beside the group receipt and one for every bearer, of when each object of
|
||||
a group is of use: the instant it is useful from, the deadline by which a receiver must hold it
|
||||
for its full value, how that value falls after it — a stated piecewise-linear function in parts
|
||||
per million, floored, of one to sixteen points, every one a change of slope, 0 from its end —
|
||||
and the rendition a receiver presents in its place, a separate object whose `derived_from` names
|
||||
the primary, as packaging's floor objects do. Each entry is held to its group, its object's
|
||||
content hash, and its fallback's position and derivation where they are held; an entry for a
|
||||
staged layer, and a layer named as a fallback, are refused, since staged-delivery §8 alone
|
||||
decides a layer's lateness. Two statements that differ for one object set both aside.
|
||||
- **Judging delivery.** From holdings records (a holder's word that it held an object at an
|
||||
instant), a verifier judges each object for each holder, as a sink that decides at the deadline
|
||||
with what it holds would present: on time, replaced by its fallback, held only after its
|
||||
deadline and at what value at least, or not shown. Every instant is an upper bound on arrival,
|
||||
so every value is a lower bound; a record dated before its group began is refused; an object
|
||||
with no statement is never on time.
|
||||
- **Planning.** An asker plans each object from an instant it states, over its sources in its own
|
||||
order — a carousel's passes, a neighbour's holdings, the origin — and gets the first source on
|
||||
time, the fallback, or the best late source. Planned from each object's own `from_ms` over one
|
||||
instance, it is the promise a publisher's schedule makes to a receiver that tunes in when the
|
||||
object becomes of use; carousel §6.4 at the deadline is the promise to one that hears every
|
||||
pass.
|
||||
- **Mappings**: the MoQ per-hop `OBJECT_DELIVERY_TIMEOUT` (MOQT -23 §5.2, §10.2; numbered as in
|
||||
-22) from the slack to the object's end, never 0 and never below a base's floor; priorities
|
||||
unchanged; an HTTP client's abandonment at the end (http-delivery §10.1 admits it) and CMCD `dl`
|
||||
(CTA-5004-B §4.3); DVB multicast ABR's `@targetAcquisitionLatency` for carouselled resources; an
|
||||
ATSC 3.0 entry package's HELD `@validFrom`; a bundle's `Lifetime` (RFC 9171 §4.3.1).
|
||||
delivery-telemetry §5's switch reasons gain `not-held-by-deadline`. The end-to-end MoQ timeout
|
||||
draft is read and not bound.
|
||||
- **The corpus** [`live-conformance/timeliness/`](live-conformance/timeliness/): claims at every
|
||||
bound of the rules, the value function at every edge (a span whose products pass 2^64 among
|
||||
them), verdicts, and plans. `timeliness_verify.py` is a second implementation;
|
||||
`claim_verify.py timeliness` reads every claim through steps 1–4; the claim corpus holds a
|
||||
`reconstruct-session` claim that switches for `not-held-by-deadline`, which both claim verifiers
|
||||
accept; and http-delivery §8.2's receipt-index check holds a `timeliness` claim to its group in
|
||||
both implementations.
|
||||
- **Breaking:** `api:rust:wai::claim_receipt::ClaimClass::Timeliness` and
|
||||
`api:rust:wai::claim_receipt::ClaimBody::Timeliness`: the class jwp-receipts §2.4.17 registers,
|
||||
which an exhaustive match over either needs.
|
||||
- **Breaking:** the reasons of the class, which an exhaustive match over `Reason` needs:
|
||||
`api:rust:wai::claim_receipt::Reason::TimelinessNotValued`,
|
||||
`api:rust:wai::claim_receipt::Reason::TimelinessObjectsMalformed`,
|
||||
`api:rust:wai::claim_receipt::Reason::TimelinessWindowInvalid`,
|
||||
`api:rust:wai::claim_receipt::Reason::TimelinessDecayInvalid`,
|
||||
`api:rust:wai::claim_receipt::Reason::TimelinessFallbackInvalid`,
|
||||
`api:rust:wai::claim_receipt::Reason::TimelinessSubjectMismatch`,
|
||||
`api:rust:wai::claim_receipt::Reason::TimelinessSignerMismatch`,
|
||||
`api:rust:wai::claim_receipt::Reason::TimelinessLeafOutOfRange`,
|
||||
`api:rust:wai::claim_receipt::Reason::TimelinessBeforeGroup`,
|
||||
`api:rust:wai::claim_receipt::Reason::TimelinessContentMismatch`,
|
||||
`api:rust:wai::claim_receipt::Reason::TimelinessOnLayer`,
|
||||
`api:rust:wai::claim_receipt::Reason::TimelinessFallbackMismatch` and
|
||||
`api:rust:wai::claim_receipt::Reason::TimelinessFallbackNotDerived`.
|
||||
56
wai/changes/2026-11-03-served-node-holds-whose-authority.md
Normal file
56
wai/changes/2026-11-03-served-node-holds-whose-authority.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
### Who served what a node holds, and on whose authority
|
||||
|
||||
- **Cache authority** ([extensions/cache-authority.md](extensions/cache-authority.md)), the record
|
||||
`wai.delivery.cache`: the role `cache/serve`, which a node's operator gives in its key log to the
|
||||
caches and peers it runs, and the class authority `cache-transmission`. Under the role a key signs
|
||||
its holdings records and its declarations, and is the upstream its receivers acknowledge. Neither
|
||||
lets it author, transform or sign a publisher's statement. A relying party places a server key by
|
||||
the heads it names, over the serving roles settlement already honours and `cache/serve`, in
|
||||
jwp-receipts' three levels: a revoked key is refused, a retired one past its `until` is refused,
|
||||
a head that lists the key with no serving role refuses it, a key no head lists is unconfirmed, and
|
||||
an unreadable head confirms nothing. A placement labels a server and never changes a verdict.
|
||||
- **The `cache-transmission` claim** (jwp-receipts §2.4.18): a node's declaration that it sent, from
|
||||
what it held, objects of a group another key signed, over a multicast or unidirectional instance
|
||||
of its own, with a bitmap and a root under the publisher's tree, checked without the bytes, and
|
||||
its energy. It is refused when signed by the group's key or on a two-way instance. Receivers'
|
||||
reconciliations over a cache's instance now count, credited per instance and only for what the
|
||||
cache says it sent. A cache's instance never makes a group `transmitted`.
|
||||
- **Held by a deadline**: of a population of receiver keys a relying party states for a region
|
||||
(rung-price §2's codes), fixed by its digest, how many held an object by a deadline (stated, or a
|
||||
timeliness entry's `by_ms`) on their own signed word, read as timeliness' held-at statements: an
|
||||
acknowledgement window's last microsecond, a counted reconciliation's time, or a holdings
|
||||
record's instant, each rounded up to the millisecond. Evidence earlier than the group's start is
|
||||
set aside (`held_before_group`). Parts from several peers complete an object together. The miss
|
||||
rate is in parts per million, rounded up, and is met or `miss_target_exceeded`. Each hit is
|
||||
credited to the publisher or the nodes its evidence names, and every unknown is a miss.
|
||||
- **Timeliness lines from every kind of held-at statement**: cache-authority §9 derives timeliness
|
||||
§6.3's lines for an entry from acknowledgements and counted reconciliations as well as holdings
|
||||
records, so a key on time in one of them is a hit at that deadline in a tally of that object
|
||||
whose population holds the key. Timeliness §6's own verdict and statement still read holdings
|
||||
records alone.
|
||||
- **Beside it**: takedown reads a cache-transmission claim as its signer's own word that it held
|
||||
what it sent; settlement §13 places a serving node by `cache/serve`; holdings §9 says which key a
|
||||
node signs with to be placed; and http-delivery §8.2's receipt-index check holds a
|
||||
`cache-transmission` claim to its group in both implementations, where before it accepted one
|
||||
unchecked.
|
||||
- **The corpus** [`live-conformance/cache-authority/`](live-conformance/cache-authority/): claims,
|
||||
placements, verdicts, tallies and timeliness lines, with a control for every reason, every
|
||||
outcome of placing a key, an acknowledgement refused for each reason a tally reads, and each
|
||||
bound on both sides. `cache_authority_verify.py` is a second implementation, and
|
||||
`claim_verify.py cache-authority` reads every claim through steps 1–4. The takedown corpus holds a
|
||||
node's claim with its purge of what it sent made, missing, and not verifying, which
|
||||
`takedown_verify.py` reads too.
|
||||
- **Breaking:** `api:rust:wai::claim_receipt::ClaimClass::CacheTransmission` and
|
||||
`api:rust:wai::claim_receipt::ClaimBody::CacheTransmission`: the class jwp-receipts §2.4.18
|
||||
registers, which an exhaustive match over either needs.
|
||||
- **Breaking:** the reasons of the class, which an exhaustive match over `Reason` needs:
|
||||
`api:rust:wai::claim_receipt::Reason::CacheTransmissionNotTransmitted`,
|
||||
`api:rust:wai::claim_receipt::Reason::CacheTransmissionOnBidirectional`,
|
||||
`api:rust:wai::claim_receipt::Reason::CacheTransmissionBitmapMalformed`,
|
||||
`api:rust:wai::claim_receipt::Reason::CacheTransmissionSubjectMismatch`,
|
||||
`api:rust:wai::claim_receipt::Reason::CacheTransmissionByPublisher`,
|
||||
`api:rust:wai::claim_receipt::Reason::CacheTransmissionLeafCountMismatch`,
|
||||
`api:rust:wai::claim_receipt::Reason::CacheTransmissionObjectsNotHeld` and
|
||||
`api:rust:wai::claim_receipt::Reason::CacheTransmissionRootMismatch`.
|
||||
- **Breaking:** `api:rust:wai::takedown::Case::cache_transmissions`: the cache-transmission claims
|
||||
a takedown case is given (takedown §8.1), which a struct literal of `Case` needs.
|
||||
|
|
@ -219,10 +219,12 @@ The vectors cover:
|
|||
context: its presentations, re-decodes of byte-exact presentations that match, the
|
||||
objects received, its base's `WAI1` envelope and the `WAI2` envelope, and the `stage`
|
||||
claim it names; the model with no
|
||||
context, and with its `stage` claim but neither presentations nor objects; a session
|
||||
with no classical length beside an envelope whose second rendition is WAI's own; a
|
||||
classical rendition of two other video capabilities; a base forgotten under the record
|
||||
budget and decoded again with no layer sent again (`staged-conformance/`'s
|
||||
context, and with its `stage` claim but neither presentations nor objects; the model
|
||||
switching to its fallback for `not-held-by-deadline`, the reason timeliness §8.6 adds to
|
||||
delivery-telemetry §5, against every context; a session with no classical length beside
|
||||
an envelope whose second rendition is WAI's own; a classical rendition of two other video
|
||||
capabilities; a base forgotten under the record budget and decoded again with no layer
|
||||
sent again (`staged-conformance/`'s
|
||||
`record_budget`, its envelopes and the `stage` claim of each epoch as the sink's
|
||||
observer states them), naming both epochs' claims and only the first's; the base
|
||||
fetched twice and listed once; an encrypted `WAI2` envelope received and named by no
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ claim are read, verified and their figures read by the OER reference verifier
|
|||
python3 claim_verify.py carousel <file> steps 1-4 over the `transmission` and
|
||||
`schedule` claims of the carousel corpus
|
||||
(live-conformance/carousel)
|
||||
python3 claim_verify.py timeliness <file>
|
||||
steps 1-4 over the claims of the timeliness
|
||||
corpus (live-conformance/timeliness)
|
||||
python3 claim_verify.py cache-authority <file>
|
||||
steps 1-4 over the claims of the cache
|
||||
authority corpus (live-conformance/cache-authority)
|
||||
|
||||
Exit status 0 only if every vector reports its declared outcome, and the fixture and the
|
||||
bracket arithmetic this script computes equal the literals pinned in wai-rs.
|
||||
|
|
@ -183,7 +189,7 @@ ADMITS = {"energy": {"object"}, "lawful-basis": {"object", "receipt"}, "log-incl
|
|||
"measurement": {"object", "receipt"}, "operating-point": {"object", "receipt"},
|
||||
"stage": {"object"}, "delivery-gap": {"object"}, "reconstruct-session": {"object"},
|
||||
"consumption": {"object"}, "transmission": {"receipt"}, "reconciliation": {"receipt"},
|
||||
"schedule": {"receipt"}}
|
||||
"schedule": {"receipt"}, "timeliness": {"receipt"}, "cache-transmission": {"receipt"}}
|
||||
FIGURE = ["figure_profile", "figure_index", "group", "joules_micro", "energy_provenance", "energy_uncertainty",
|
||||
"energy_counter_filtering", "energy_calibration_ref"]
|
||||
BODY = {
|
||||
|
|
@ -206,6 +212,10 @@ BODY = {
|
|||
"reconciliation": ["bearer", "bearer_id", "leaf_count", "received", "received_root", "attests"],
|
||||
# §2.4.16: carousel §4.1.
|
||||
"schedule": ["bearer_id", "objects", "attests"],
|
||||
# §2.4.17: timeliness §4.1.
|
||||
"timeliness": ["objects", "attests"],
|
||||
# §2.4.18: cache-authority §5.1.
|
||||
"cache-transmission": ["bearer", "bearer_id", "leaf_count", "sent", "sent_root", "energy", "attests"],
|
||||
"reconstruct-session": ["session_nonce", "media", "host", "decoders", "units", "leaf_count", "output_root",
|
||||
"primary_units", "fallback_units", "switches", "switches_total", "deadline_us", "missed",
|
||||
"missed_units", "stage_sets", "framing", "counted_primary", "counted_fallback",
|
||||
|
|
@ -286,7 +296,7 @@ SWITCH_REASONS = ("pin-malformed", "form-not-registered", "form-unreadable", "fo
|
|||
"prior-not-authorised", "prior-not-held", "digest-superseded", "set-malformed", "part-mismatch",
|
||||
"set-incomplete", "missing-capability", "module-unverified", "module-revoked", "trap",
|
||||
"fuel-exhausted", "memory-exhausted", "deadline-threshold", "unit-hash-mismatch",
|
||||
"budget-exhausted", "recovered")
|
||||
"budget-exhausted", "not-held-by-deadline", "recovered")
|
||||
# The capability registry (../registry/capabilities.json): each capability's kind, status
|
||||
# and replicate class, for a session's tiers, re-decodes and classical rendition.
|
||||
with open(os.path.join(HERE, "..", "registry", "capabilities.json"), encoding="utf-8") as _f:
|
||||
|
|
@ -456,17 +466,19 @@ def check_body(cls, b):
|
|||
need(is_hex(b["received"]), 2)
|
||||
need(is_hex(b["received_root"], 32), 2)
|
||||
else:
|
||||
e = b["energy"]
|
||||
if e is not None:
|
||||
nested(e, SESSION_ENERGY)
|
||||
need(is_int(e["joules_micro"]), 2)
|
||||
need(isinstance(e["energy_provenance"], str), 2)
|
||||
u = e["energy_uncertainty"]
|
||||
if u is not None:
|
||||
nested(u, UNCERTAINTY)
|
||||
need(is_int(u["relative_ppm"]) and is_int(u["window_us"]), 2)
|
||||
need(e["energy_counter_filtering"] is None or isinstance(e["energy_counter_filtering"], str), 2)
|
||||
need(e["energy_calibration_ref"] is None or isinstance(e["energy_calibration_ref"], str), 2)
|
||||
bearer_energy_body(b["energy"])
|
||||
need(isinstance(b["attests"], str), 2)
|
||||
return
|
||||
if cls == "cache-transmission":
|
||||
# cache-authority §5.1, in its table's order: the class from bearer-receipts' enumeration,
|
||||
# the instance an absolute URI, the bitmap a byte string of any length (its length is a
|
||||
# class rule, step 4), the root a hash, and the energy as a transmission's.
|
||||
need(b["bearer"] in BEARERS, 2)
|
||||
need(is_uri(b["bearer_id"]), 2)
|
||||
need(is_int(b["leaf_count"]), 2)
|
||||
need(is_hex(b["sent"]), 2)
|
||||
need(is_hex(b["sent_root"], 32), 2)
|
||||
bearer_energy_body(b["energy"])
|
||||
need(isinstance(b["attests"], str), 2)
|
||||
return
|
||||
if cls == "schedule":
|
||||
|
|
@ -485,6 +497,14 @@ def check_body(cls, b):
|
|||
need(all(is_int(e[k]) for k in ("first_ms", "period_ms", "duration_ms", "count")), 2)
|
||||
need(isinstance(b["attests"], str), 2)
|
||||
return
|
||||
if cls == "timeliness":
|
||||
# timeliness §4.1: each entry's members, then each one's type in the table's order, a
|
||||
# point's members and the fallback's where the table reaches them.
|
||||
need(isinstance(b["objects"], list), 2)
|
||||
for e in b["objects"]:
|
||||
timeliness_entry_body(e)
|
||||
need(isinstance(b["attests"], str), 2)
|
||||
return
|
||||
if cls == "consumption":
|
||||
# ai-usage §6.1: the arrays' lengths and a term's are in the type column.
|
||||
need(isinstance(b["objects"], list) and 1 <= len(b["objects"]) <= 1024
|
||||
|
|
@ -582,6 +602,22 @@ def check_body(cls, b):
|
|||
need(is_token(b["alg"]) and is_uri(b["resolution_endpoint"]), 2)
|
||||
|
||||
|
||||
def bearer_energy_body(e):
|
||||
"""bearer-receipts §4.1 `energy` at step 2, as a transmission and a cache-transmission claim
|
||||
carry it: `null`, or a session's energy object, its members and then their types."""
|
||||
if e is None:
|
||||
return
|
||||
nested(e, SESSION_ENERGY)
|
||||
need(is_int(e["joules_micro"]), 2)
|
||||
need(isinstance(e["energy_provenance"], str), 2)
|
||||
u = e["energy_uncertainty"]
|
||||
if u is not None:
|
||||
nested(u, UNCERTAINTY)
|
||||
need(is_int(u["relative_ppm"]) and is_int(u["window_us"]), 2)
|
||||
need(e["energy_counter_filtering"] is None or isinstance(e["energy_counter_filtering"], str), 2)
|
||||
need(e["energy_calibration_ref"] is None or isinstance(e["energy_calibration_ref"], str), 2)
|
||||
|
||||
|
||||
def nested(v, required):
|
||||
"""A nested object a table fixes: an object, then its members, missing then unknown."""
|
||||
need(isinstance(v, dict), 2)
|
||||
|
|
@ -1597,6 +1633,50 @@ def schedule_entry_rules(e):
|
|||
return blocks
|
||||
|
||||
|
||||
# timeliness §4.1: an entry's members, a decay point's and a fallback's; §2's full value.
|
||||
TIMELINESS_ENTRY = ["leaf_index", "content_hash", "from_ms", "by_ms", "decay", "fallback"]
|
||||
TIMELINESS_POINT = ["at_ms", "value_ppm"]
|
||||
TIMELINESS_FALLBACK = ["group", "leaf_index", "content_hash", "value_ppm"]
|
||||
FULL_VALUE = 1_000_000
|
||||
|
||||
|
||||
def timeliness_entry_body(e):
|
||||
"""timeliness §4.1 at §3.1 step 2: one entry of `objects`, its members and then their types in
|
||||
the table's order, each point's and the fallback's members where the table reaches them."""
|
||||
nested(e, TIMELINESS_ENTRY)
|
||||
need(is_int(e["leaf_index"]) and is_hex(e["content_hash"], 32), 2)
|
||||
need(is_int(e["from_ms"]) and is_int(e["by_ms"]), 2)
|
||||
need(isinstance(e["decay"], list), 2)
|
||||
for p in e["decay"]:
|
||||
nested(p, TIMELINESS_POINT)
|
||||
need(is_int(p["at_ms"]) and is_int(p["value_ppm"]), 2)
|
||||
f = e["fallback"]
|
||||
if f is not None:
|
||||
nested(f, TIMELINESS_FALLBACK)
|
||||
need(is_hex(f["group"], 32) and is_int(f["leaf_index"]), 2)
|
||||
need(is_hex(f["content_hash"], 32) and is_int(f["value_ppm"]), 2)
|
||||
|
||||
|
||||
def timeliness_entry_rules(e, subject):
|
||||
"""timeliness §4.2 rule 3 for one entry of a claim whose `subject` is given, the first of its
|
||||
three to fail: the window, the decay, the fallback."""
|
||||
need(e["from_ms"] <= e["by_ms"] < LAST_INSTANT_MS, 4, "timeliness_window_invalid")
|
||||
# Rule 3.2, with (t0, v0) = (by_ms, full value) before the points. Python's integers are
|
||||
# exact, so the products, which reach 2^68, need no wider type.
|
||||
pts = [(e["by_ms"], FULL_VALUE)] + [(p["at_ms"], p["value_ppm"]) for p in e["decay"]]
|
||||
n = len(pts) - 1
|
||||
formed = (1 <= n <= 16 and all(a[0] < b[0] for a, b in zip(pts, pts[1:])) and pts[-1][0] <= LAST_INSTANT_MS
|
||||
and all(v < FULL_VALUE for _, v in pts[1:]) and all(b[1] <= a[1] for a, b in zip(pts, pts[1:]))
|
||||
and pts[-1][1] == 0 and all(v >= 1 for _, v in pts[1:-1])
|
||||
and all((pts[k - 1][1] - pts[k][1]) * (pts[k + 1][0] - pts[k][0])
|
||||
!= (pts[k][1] - pts[k + 1][1]) * (pts[k][0] - pts[k - 1][0]) for k in range(1, n)))
|
||||
need(formed, 4, "timeliness_decay_invalid")
|
||||
f = e["fallback"]
|
||||
need(f is None or (1 <= f["value_ppm"] <= FULL_VALUE and f["content_hash"] != e["content_hash"]
|
||||
and (f["group"] != subject or f["leaf_index"] != e["leaf_index"])),
|
||||
4, "timeliness_fallback_invalid")
|
||||
|
||||
|
||||
def class_rules(c):
|
||||
"""§3.1 step 4 — §2.4.5-§2.4.7 and §2.6."""
|
||||
cls, b = c["class"], c["body"]
|
||||
|
|
@ -1660,6 +1740,26 @@ def class_rules(c):
|
|||
need(len(idx) > 0 and all(x < y for x, y in zip(idx, idx[1:])), 4, "schedule_objects_malformed")
|
||||
for e in b["objects"]:
|
||||
schedule_entry_rules(e)
|
||||
elif cls == "timeliness":
|
||||
# timeliness §4.2.
|
||||
need(b["attests"] == "valued", 4, "timeliness_not_valued")
|
||||
idx = [e["leaf_index"] for e in b["objects"]]
|
||||
need(len(idx) > 0 and all(x < y for x, y in zip(idx, idx[1:])), 4, "timeliness_objects_malformed")
|
||||
for e in b["objects"]:
|
||||
timeliness_entry_rules(e, c["subject"])
|
||||
elif cls == "cache-transmission":
|
||||
# cache-authority §5.2, rules 1 to 4 in order; the bitmap as a reconciliation's.
|
||||
need(b["attests"] == "transmitted", 4, "cache_transmission_not_transmitted")
|
||||
need(b["bearer"] != "bidirectional", 4, "cache_transmission_on_bidirectional")
|
||||
n, bits = b["leaf_count"], bytes.fromhex(b["sent"])
|
||||
bad = "cache_transmission_bitmap_malformed"
|
||||
need(n >= 1 and len(bits) == (n + 7) // 8, 4, bad)
|
||||
need(not any(bits[i // 8] & (0x80 >> (i % 8)) for i in range(n, 8 * len(bits))), 4, bad)
|
||||
need(any(bits), 4, bad)
|
||||
e = b["energy"]
|
||||
if e is not None:
|
||||
need(e["joules_micro"] != 0, 4, "energy_zero_figure")
|
||||
acquisition(e)
|
||||
elif cls == "log-inclusion":
|
||||
need(b["log_receipt"] != "", 4, "log_inclusion_no_evidence")
|
||||
elif cls in ("soft-binding", "soft-binding-span", "binding-recovery"):
|
||||
|
|
@ -2385,6 +2485,79 @@ def carousel_claims(path):
|
|||
return 1 if fail or n == 0 else 0
|
||||
|
||||
|
||||
def timeliness_claims(path):
|
||||
"""§3.1 steps 1-4 over the claims of the timeliness corpus (live-conformance/timeliness/vectors.json,
|
||||
timeliness §10): a claim a verdict or a plan states refused at one of those steps is refused there
|
||||
with the stated reason, and every other `timeliness`, `transmission` or `schedule` claim passes
|
||||
them; and each claim case gets the outcome it states. Steps 5 and 6 are the timeliness
|
||||
verifier's."""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
doc = json.load(f)
|
||||
fail = n = 0
|
||||
checks = []
|
||||
for case in doc["verdicts"] + doc["plans"]:
|
||||
if not isinstance(case["outcome"], dict):
|
||||
continue
|
||||
for wire, stated in zip(case["claims"], case["outcome"]["claims"]):
|
||||
m = re.fullmatch(r"reject step=(\d) reason=(\S+)", stated)
|
||||
checks.append((case["name"], wire, (int(m[1]), m[2]) if m and int(m[1]) <= 4 else None, True))
|
||||
for case in doc["claims"]:
|
||||
m = re.fullmatch(r"reject step=(\d) reason=(\S+)", case["outcome"])
|
||||
checks.append((case["name"], case["claim"], (int(m[1]), m[2]) if m else None, False))
|
||||
for name, wire, want, in_context in checks:
|
||||
try:
|
||||
c = verify_claim(wire)
|
||||
if in_context and c["class"] not in ("timeliness", "transmission", "schedule"):
|
||||
continue
|
||||
got = None
|
||||
except Reject as r:
|
||||
got = (r.step, r.reason)
|
||||
n += 1
|
||||
fail += got != want
|
||||
print(f"{'PASS' if got == want else 'FAIL'} {name} expected={want} got={got}")
|
||||
print(f"{n} timeliness claims, {fail} failure(s)")
|
||||
return 1 if fail or n == 0 else 0
|
||||
|
||||
|
||||
def cache_authority_claims(path):
|
||||
"""§3.1 steps 1-4 over the claims of the cache authority corpus
|
||||
(live-conformance/cache-authority/vectors.json, cache-authority §12): a claim a verdict or a
|
||||
tally states refused at one of those steps is refused there with the stated reason, and every
|
||||
other claim it gives passes them; each claim case's claim gets the outcome it states, and the
|
||||
earlier claims of its signer pass; and a timeliness claim a tally takes its deadline from, or a
|
||||
line case its intent from, passes. Steps 5 and 6 are the cache authority verifier's."""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
doc = json.load(f)
|
||||
fail = n = 0
|
||||
checks = []
|
||||
for case in doc["verdicts"] + doc["tallies"]:
|
||||
if not isinstance(case["outcome"], dict):
|
||||
continue
|
||||
for wire, stated in zip(case["claims"], case["outcome"]["claims"]):
|
||||
m = re.fullmatch(r"reject step=(\d) reason=(\S+)", stated)
|
||||
checks.append((case["name"], wire, (int(m[1]), m[2]) if m and int(m[1]) <= 4 else None))
|
||||
if case.get("deadline_claim") is not None:
|
||||
checks.append((case["name"], case["deadline_claim"], None))
|
||||
for case in doc["claims"]:
|
||||
m = re.fullmatch(r"reject step=(\d) reason=(\S+)", case["outcome"])
|
||||
checks.append((case["name"], case["claim"], (int(m[1]), m[2]) if m and int(m[1]) <= 4 else None))
|
||||
checks += [(case["name"], wire, None) for wire in case["earlier"]]
|
||||
for case in doc["lines"]:
|
||||
if case["outcome"]:
|
||||
checks.append((case["name"], case["claim"], None))
|
||||
for name, wire, want in checks:
|
||||
try:
|
||||
verify_claim(wire)
|
||||
got = None
|
||||
except Reject as r:
|
||||
got = (r.step, r.reason)
|
||||
n += 1
|
||||
fail += got != want
|
||||
print(f"{'PASS' if got == want else 'FAIL'} {name} expected={want} got={got}")
|
||||
print(f"{n} cache authority claims, {fail} failure(s)")
|
||||
return 1 if fail or n == 0 else 0
|
||||
|
||||
|
||||
def main(argv):
|
||||
if argv[1:2] == ["brackets"]:
|
||||
for name, br, _, _ in bracket_fixture():
|
||||
|
|
@ -2399,6 +2572,10 @@ def main(argv):
|
|||
return bearer_claims(argv[2])
|
||||
if argv[1:2] == ["carousel"] and len(argv) == 3:
|
||||
return carousel_claims(argv[2])
|
||||
if argv[1:2] == ["timeliness"] and len(argv) == 3:
|
||||
return timeliness_claims(argv[2])
|
||||
if argv[1:2] == ["cache-authority"] and len(argv) == 3:
|
||||
return cache_authority_claims(argv[2])
|
||||
if argv[1:2] == ["fixture"]:
|
||||
c, canon, link = fixture()
|
||||
print("sig ", c["sig"])
|
||||
|
|
|
|||
|
|
@ -24863,6 +24863,166 @@
|
|||
"step": 6,
|
||||
"reason": "session_stage_hash_not_exact"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "session_switch_not_held_by_deadline_accept",
|
||||
"about": "the model session switching to the fallback because the primary was not held by its deadline, the reason timeliness section 8.6 adds to delivery-telemetry section 5",
|
||||
"procedure": "claim",
|
||||
"receipt": "{\"body\":{\"classical_declared\":700,\"classical_source\":\"wai2-table\",\"counted_discarded\":1904,\"counted_fallback\":0,\"counted_primary\":5935,\"deadline_us\":40000,\"decoders\":[{\"capability\":\"wai.video.int_hyper\",\"decoder\":\"builtin:[email protected]\",\"first_unit\":0,\"leaves\":4,\"module_status\":null,\"object\":\"d09526cec3961c21e36cc08f0b393323eb82dcc4203cb08df57fee36784b1113\",\"path\":\"primary\",\"pin\":\"7b4371854df0aa1645ac6525b373387c0617b873d5a57748932ee0f386ea164b\",\"rendition\":null,\"tier\":\"decode-equivalence\",\"variant\":null},{\"capability\":\"wai.video.int_hyper\",\"decoder\":\"builtin:[email protected]\",\"first_unit\":4,\"leaves\":1,\"module_status\":null,\"object\":\"f18f33c4eebadf2caf00fb22a38cf394e19d31a62ebe68f3043c31c84ecbcf44\",\"path\":\"primary\",\"pin\":\"7b4371854df0aa1645ac6525b373387c0617b873d5a57748932ee0f386ea164b\",\"rendition\":0,\"tier\":\"decode-equivalence\",\"variant\":null},{\"capability\":\"wai.video.av1\",\"decoder\":\"builtin:[email protected]\",\"first_unit\":5,\"leaves\":1,\"module_status\":null,\"object\":\"13b04249eca2b1a9061dba8ca2846169d3f5fa3548d9e138a0cf2cb553798cf4\",\"path\":\"fallback\",\"pin\":null,\"rendition\":null,\"tier\":null,\"variant\":null},{\"capability\":\"wai.video.int_refine\",\"decoder\":\"builtin:[email protected]\",\"first_unit\":0,\"leaves\":1,\"module_status\":null,\"object\":\"47a2c23727d2eb9d2beef0931c3e9b2675408d372660420cce427dc27fd21fe1\",\"path\":\"primary\",\"pin\":null,\"rendition\":null,\"tier\":null,\"variant\":null},{\"capability\":\"wai.video.int_refine\",\"decoder\":\"builtin:[email protected]\",\"first_unit\":0,\"leaves\":1,\"module_status\":null,\"object\":\"6b74be11e64461c7d972fc5cb6ba15e98427e9d6e60622b0a753222138de28cf\",\"path\":\"primary\",\"pin\":null,\"rendition\":null,\"tier\":null,\"variant\":null},{\"capability\":\"wai.video.int_refine\",\"decoder\":\"builtin:[email protected]\",\"first_unit\":0,\"leaves\":1,\"module_status\":null,\"object\":\"5e7a38ca5c04442cbe00b040d58e4494f5aa36fb81e8bbe0d304fd2d1ce722b2\",\"path\":\"primary\",\"pin\":null,\"rendition\":null,\"tier\":null,\"variant\":null},{\"capability\":\"wai.video.int_refine\",\"decoder\":\"builtin:[email protected]\",\"first_unit\":0,\"leaves\":1,\"module_status\":null,\"object\":\"be5055af8e32359265852c512667b10ab6a07455e5ab06603e278763f6633a85\",\"path\":\"primary\",\"pin\":null,\"rendition\":null,\"tier\":null,\"variant\":null}],\"energy\":null,\"fallback_units\":1,\"framing\":\"registered\",\"host\":{\"build\":{\"profile\":\"release\",\"target\":\"aarch64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.95.0\"},\"profile\":\"native-ffi\",\"target\":\"aarch64-unknown-linux-gnu\",\"threads\":4},\"leaf_count\":10,\"media\":\"video\",\"missed\":1,\"missed_units\":[{\"late_us\":1200,\"layers\":1,\"unit\":5}],\"output_root\":\"b62a8425b7fe3a085a14f2f2156ea22ae3b4349884d799b5afe66dccc7d4dd7f\",\"primary_units\":5,\"receipt_bytes\":640,\"session_nonce\":\"57c5462dfdabd1b7bdd204d34ba33649c057eb535349f29f45eb8f8c7d4a7e7f\",\"stage_sets\":[{\"claim\":\"7ff63c64ae3826c013fd8c2387f51307c4bf715842b0a803528349e200d95551\",\"first_unit\":0}],\"switches\":[{\"from\":\"primary\",\"reason\":\"not-held-by-deadline\",\"to\":\"fallback\",\"unit\":5}],\"switches_total\":1,\"transferred\":9995,\"uncounted\":2,\"units\":6},\"class\":\"reconstruct-session\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"2fd7cc2e8a538691b92585b89b84ebdfe600e44ae5b8bec8a4b9f8aed574106fa3c2d6430d0903a11a3e5e940cb3aa0899bb188a221e27672c7a6e4e62b61606\",\"signer_id\":\"did:example:sink\",\"signer_pubkey\":\"d759793bbc13a2819a827c76adb6fba8a49aee007f49f2d0992d99b825ad2c48\",\"subject\":\"d09526cec3961c21e36cc08f0b393323eb82dcc4203cb08df57fee36784b1113\",\"subject_type\":\"object\"}",
|
||||
"context": {
|
||||
"outputs": [
|
||||
{
|
||||
"unit": 0,
|
||||
"layers": 1,
|
||||
"decoder": 0,
|
||||
"out_digest": "55f7e628ca707e2ca3159709eeb68b9000813f2916a299acd33a23e6fe012584"
|
||||
},
|
||||
{
|
||||
"unit": 1,
|
||||
"layers": 1,
|
||||
"decoder": 0,
|
||||
"out_digest": "a3ee8a4b96eceb663f3d94417957d02f897fc166dc96ebe9bf447c4a36a36264"
|
||||
},
|
||||
{
|
||||
"unit": 2,
|
||||
"layers": 1,
|
||||
"decoder": 0,
|
||||
"out_digest": "00576d2c4d09ef5d7aa807f9b3b1587e200dea6f6694b4da062b41aa2a5ab8af"
|
||||
},
|
||||
{
|
||||
"unit": 3,
|
||||
"layers": 1,
|
||||
"decoder": 0,
|
||||
"out_digest": "26cd9b2805b604b26e08d9a461db599c4e249bfd4df41a3befb202bc6d0e0700"
|
||||
},
|
||||
{
|
||||
"unit": 4,
|
||||
"layers": 1,
|
||||
"decoder": 1,
|
||||
"out_digest": "1cd1984bc553c43ee5a48fe7e182a77e60d8ef3c2b0f959ef1a0efd3813b411f"
|
||||
},
|
||||
{
|
||||
"unit": 5,
|
||||
"layers": 1,
|
||||
"decoder": 2,
|
||||
"out_digest": "e4c4344155dc2c789a1df265f8064b9ae56f3e0932d85c5ceb38782c135749fc"
|
||||
},
|
||||
{
|
||||
"unit": 0,
|
||||
"layers": 2,
|
||||
"decoder": 3,
|
||||
"out_digest": "cedffc10c0d2e777c954459c198af496e316170b3a8c35276a3f473edf438a0e"
|
||||
},
|
||||
{
|
||||
"unit": 1,
|
||||
"layers": 2,
|
||||
"decoder": 4,
|
||||
"out_digest": "f0348794b5be95fd5c4e8f268957b6d188c35f76146f4479c960124a4392841d"
|
||||
},
|
||||
{
|
||||
"unit": 2,
|
||||
"layers": 2,
|
||||
"decoder": 5,
|
||||
"out_digest": "446c9941549464ba1b15c36c6e738380f8918f4a63a47ebbbdd67058a1975a80"
|
||||
},
|
||||
{
|
||||
"unit": 0,
|
||||
"layers": 3,
|
||||
"decoder": 6,
|
||||
"out_digest": "a80c01cb3ded2f965b69ec4a5b3f1dcb6996e05454d9963e0b05455f9f27bc2f"
|
||||
}
|
||||
],
|
||||
"redecoded": [
|
||||
{
|
||||
"index": 0,
|
||||
"out_digest": "55f7e628ca707e2ca3159709eeb68b9000813f2916a299acd33a23e6fe012584"
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"out_digest": "1cd1984bc553c43ee5a48fe7e182a77e60d8ef3c2b0f959ef1a0efd3813b411f"
|
||||
}
|
||||
],
|
||||
"objects": [
|
||||
{
|
||||
"content_hash": "d09526cec3961c21e36cc08f0b393323eb82dcc4203cb08df57fee36784b1113",
|
||||
"path": "primary",
|
||||
"counted": 328,
|
||||
"transferred": 328,
|
||||
"presented": true
|
||||
},
|
||||
{
|
||||
"content_hash": "47a2c23727d2eb9d2beef0931c3e9b2675408d372660420cce427dc27fd21fe1",
|
||||
"path": "primary",
|
||||
"counted": 900,
|
||||
"transferred": 900,
|
||||
"presented": true
|
||||
},
|
||||
{
|
||||
"content_hash": "6b74be11e64461c7d972fc5cb6ba15e98427e9d6e60622b0a753222138de28cf",
|
||||
"path": "primary",
|
||||
"counted": 901,
|
||||
"transferred": 901,
|
||||
"presented": true
|
||||
},
|
||||
{
|
||||
"content_hash": "5e7a38ca5c04442cbe00b040d58e4494f5aa36fb81e8bbe0d304fd2d1ce722b2",
|
||||
"path": "primary",
|
||||
"counted": 902,
|
||||
"transferred": 902,
|
||||
"presented": true
|
||||
},
|
||||
{
|
||||
"content_hash": "792a12990c5207ca83a86b7420815f1f2d34918553a0130a4f9570a11354f2ca",
|
||||
"path": "primary",
|
||||
"counted": 903,
|
||||
"transferred": 903,
|
||||
"presented": false
|
||||
},
|
||||
{
|
||||
"content_hash": "be5055af8e32359265852c512667b10ab6a07455e5ab06603e278763f6633a85",
|
||||
"path": "primary",
|
||||
"counted": 1000,
|
||||
"transferred": 1000,
|
||||
"presented": true
|
||||
},
|
||||
{
|
||||
"content_hash": "1c6eae0ed41ed7fc1b2f00b46ea1de98f21a6d6cbf62bf6b22cf76a664a49376",
|
||||
"path": "primary",
|
||||
"counted": 1001,
|
||||
"transferred": 1001,
|
||||
"presented": false
|
||||
},
|
||||
{
|
||||
"content_hash": "f18f33c4eebadf2caf00fb22a38cf394e19d31a62ebe68f3043c31c84ecbcf44",
|
||||
"path": "primary",
|
||||
"counted": null,
|
||||
"transferred": 1060,
|
||||
"presented": true
|
||||
},
|
||||
{
|
||||
"content_hash": "13b04249eca2b1a9061dba8ca2846169d3f5fa3548d9e138a0cf2cb553798cf4",
|
||||
"path": "fallback",
|
||||
"counted": null,
|
||||
"transferred": 3000,
|
||||
"presented": true
|
||||
}
|
||||
],
|
||||
"envelopes": [
|
||||
{
|
||||
"envelope": "57414931980000007b22776169223a22312e30222c226d65646961223a22766964656f222c22696e74656e74223a227265706c6963617465222c226d6f64656c5f726571756972656d656e74223a7b226361706162696c697479223a227761692e766964656f2e696e745f6879706572227d2c22636f6e646974696f6e696e67223a7b226b696e64223a226e6f6e65227d2c22746172676574223a6e756c6c7da40000005749563101010100040000001000000010000000100000000100000000000000030000000000000000800000000001000000000000000000000000000800000033333333333333335800000057494831010000000400000004000000040000000200000002000000020000000800000088ed3fe30c2b720028000000207259165ca81100a400842d62ce1516f300b254190111b4810047cdf7b87ea2235e87911bb87ecb"
|
||||
},
|
||||
{
|
||||
"envelope": "57414932b60000007b22776169223a22312e31222c226d65646961223a22766964656f222c22696e74656e74223a227265706c6963617465222c2272656e646974696f6e73223a5b7b226361706162696c697479223a227761692e766964656f2e696e745f6879706572222c226b696e64223a22696e745f6879706572227d2c7b226361706162696c697479223a227761692e766964656f2e617663222c226b696e64223a227365636f6e64227d5d2c22746172676574223a6e756c6c7d0200000000009800000098000000bc0200005749563101010100010000001000000010000000010000000100000000000000030000000000000000800000000001000800000033333333333333335800000057494831010000000400000004000000040000000200000002000000020000000800000088ed3fe30c2b720028000000207259165ca81100a400842d62ce1516f300b254190111b4810047cdf7b87ea2235e87911bb87ecb22222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222"
|
||||
}
|
||||
],
|
||||
"stage_claims": [
|
||||
"{\"body\":{\"bytes_envelopes\":10607,\"conflicts\":0,\"deadline_misses\":2,\"objects\":[{\"bytes\":900,\"evidence\":\"none\",\"layer\":1,\"object\":\"47a2c23727d2eb9d2beef0931c3e9b2675408d372660420cce427dc27fd21fe1\",\"reason\":null,\"status\":\"applied\",\"units\":{\"count\":1,\"from\":0}},{\"bytes\":901,\"evidence\":\"none\",\"layer\":1,\"object\":\"6b74be11e64461c7d972fc5cb6ba15e98427e9d6e60622b0a753222138de28cf\",\"reason\":null,\"status\":\"applied\",\"units\":{\"count\":1,\"from\":1}},{\"bytes\":902,\"evidence\":\"none\",\"layer\":1,\"object\":\"5e7a38ca5c04442cbe00b040d58e4494f5aa36fb81e8bbe0d304fd2d1ce722b2\",\"reason\":null,\"status\":\"applied\",\"units\":{\"count\":1,\"from\":2}},{\"bytes\":903,\"evidence\":\"none\",\"layer\":1,\"object\":\"792a12990c5207ca83a86b7420815f1f2d34918553a0130a4f9570a11354f2ca\",\"reason\":null,\"status\":\"late\",\"units\":{\"count\":1,\"from\":3}},{\"bytes\":1000,\"evidence\":\"none\",\"layer\":2,\"object\":\"be5055af8e32359265852c512667b10ab6a07455e5ab06603e278763f6633a85\",\"reason\":null,\"status\":\"applied\",\"units\":{\"count\":1,\"from\":0}},{\"bytes\":1001,\"evidence\":\"none\",\"layer\":2,\"object\":\"1c6eae0ed41ed7fc1b2f00b46ea1de98f21a6d6cbf62bf6b22cf76a664a49376\",\"reason\":null,\"status\":\"late\",\"units\":{\"count\":1,\"from\":1}}],\"op_committed\":null,\"op_delivered\":null,\"op_requested\":null,\"presented\":{\"wai-stage:layers\":0},\"stage_hash\":null,\"tier\":\"none\",\"units_by_stage\":[1,2,1],\"vocabulary\":\"urn:wai:staged-delivery:1\"},\"class\":\"stage\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"4dd3d2224ea284c09e0b1d28842547fdb90f2887c7adfccd35d58d192932741d10f2b89b96426fc87f445b7dddcd0484ddb8b9fd46864ae335cb219059d5660d\",\"signer_id\":\"did:example:sink\",\"signer_pubkey\":\"d759793bbc13a2819a827c76adb6fba8a49aee007f49f2d0992d99b825ad2c48\",\"subject\":\"d09526cec3961c21e36cc08f0b393323eb82dcc4203cb08df57fee36784b1113\",\"subject_type\":\"object\"}"
|
||||
]
|
||||
},
|
||||
"expected": {
|
||||
"outcome": "accept",
|
||||
"link_hash": "146e116daf523e37f7c7e3c6af654d50ae0410659d70aefcac10af07a906e352"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ When each object passes on a bearer with no return path, and with which forward
|
|||
correction, is [carousel](carousel.md)'s: a publisher's `schedule` claim beside the transmission
|
||||
claim here, from which a verifier derives when a receiver within a loss bound holds each object.
|
||||
|
||||
A node that sends again, over a bearer instance of its own, objects it holds of a group another key
|
||||
signed, declares that instance in a `cache-transmission` claim, and reconciliations over it are
|
||||
counted beside the publisher's instances: [cache-authority](cache-authority.md) §5, §7.
|
||||
|
||||
Out of scope: a transport or a bearer; a reception-reporting protocol — a reconciliation
|
||||
travels on whatever path a sink later has, as a receipt file (http-delivery §8.1) or inside a
|
||||
bearer's own reporting (§9); a pre-emption authority ([unidirectional-delivery](unidirectional-delivery.md)
|
||||
|
|
@ -56,7 +60,8 @@ verifies. It names no payload format and is not dispatchable.
|
|||
- `multicast`: one transmission reaches every sink that joined it, with no per-sink path on
|
||||
the bearer itself; a sink may have another path;
|
||||
- `unidirectional`: one transmission, and no path back from any sink.
|
||||
- **Bearer instance**: one transmission a publisher made, named by an absolute URI, its
|
||||
- **Bearer instance**: one transmission a publisher made, or a node that sends again what it holds
|
||||
([cache-authority](cache-authority.md) §5), named by an absolute URI, its
|
||||
`bearer_id`: a multicast session, a broadcast service, a relay chain's origin. WAI reads
|
||||
nothing from it and compares it byte for byte.
|
||||
- **Transmission**: putting a group's objects and their receipts onto a bearer instance.
|
||||
|
|
@ -157,7 +162,7 @@ never in the publisher's chain, and nothing in the publisher's chain changes whe
|
|||
| member | type | meaning |
|
||||
|---|---|---|
|
||||
| `bearer` | string | The class of the bearer the sink received over (§2). |
|
||||
| `bearer_id` | absolute URI | The bearer instance it received over: the `bearer_id` of the publisher's transmission claim for that instance. |
|
||||
| `bearer_id` | absolute URI | The bearer instance it received over: the `bearer_id` of the publisher's transmission claim, or of a node's cache-transmission claim, for that instance. |
|
||||
| `leaf_count` | integer | The group's `leaf_count`. |
|
||||
| `received` | hex | A bitmap of ⌈`leaf_count` / 8⌉ bytes. Object *i* is bit `0x80 >> (i mod 8)` of byte ⌊*i* / 8⌋, so the hexadecimal reads left to right in object order; the bit is set exactly when the sink accepted object *i* under jwp-receipts §3 steps 1–6 over its bytes. |
|
||||
| `received_root` | 32 B hex | The root jwp-receipts §2.2 computes over the leaves of the received objects, in increasing `leaf_index`, each leaf in the form its object receipt takes (legacy or labelled). With every bit set it is the group's `root_hash`. |
|
||||
|
|
|
|||
734
wai/extensions/cache-authority.md
Normal file
734
wai/extensions/cache-authority.md
Normal file
|
|
@ -0,0 +1,734 @@
|
|||
# WAI Extension: Cache Authority
|
||||
|
||||
> Status: Draft. Registers the record `wai.delivery.cache`: who served what a node holds, and on
|
||||
> whose authority. A neighbourhood cache or a peer that serves, from what it holds, objects of
|
||||
> groups another key signed changes no byte: the publisher's receipts verify whatever it serves
|
||||
> (jwp-receipts §3), and a receiver's acknowledgement of it (settlement §3) is already the signed
|
||||
> word that it served. What was missing is the authority to place such a node and a statement for
|
||||
> a bearer instance of its own with no path back. This extension adds two authorities a node's
|
||||
> operator gives in its key log — the role `cache/serve` and the class authority
|
||||
> `cache-transmission` (§4) — and one claim, a jwp-receipts claim of class
|
||||
> **cache-transmission** (jwp-receipts §2.4.18, §5 here), by which a node declares a `multicast`
|
||||
> or `unidirectional` instance of its own and the objects of a group it sent over it, with a root
|
||||
> under the publisher's tree checked without the bytes. A verifier places a server key by the key
|
||||
> logs its relying party names, in jwp-receipts' three levels (§4.4); derives the bearer verdict
|
||||
> with the caches' instances beside the publisher's, crediting a cache per instance and only for
|
||||
> what it says it sent (§7); and counts, of a population of receiver keys stated for a region, how
|
||||
> many held an object by a deadline on their own signed word, with a miss rate in parts per million
|
||||
> held to a target (§8). WAI defines no transport. The reference implementation is the
|
||||
> `cache_authority` module of `wai-rs` (feature `cache_authority`), with the class's body in
|
||||
> `claim_receipt::bearer`; the corpus is
|
||||
> [`live-conformance/cache-authority/`](../live-conformance/cache-authority/), and its second
|
||||
> implementation is `live-conformance/cache_authority_verify.py`.
|
||||
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
|
||||
>
|
||||
> Texts read for this revision: `draft-ietf-moq-transport-23` (MOQT) §2.1, §7.1 and §7.9; RFC 9421
|
||||
> §2.3, §2.5, §3.3.6, §4.3 and §7.2.8; RFC 9211 §2, §2.1, §2.2, §2.5 and §6; RFC 7574 §3.2, §9.2
|
||||
> and §11.2.4; RFC 8006 §4.3.8 and RFC 9388 §2.1.1.1 and §4.1. A clause number of one of them in
|
||||
> this text is that text's.
|
||||
|
||||
## 1. Scope
|
||||
|
||||
In scope: the authorities a node that serves from what it holds signs under, who gives them, and
|
||||
how a verifier places a server key (§4); the cache-transmission claim (§5); what a receiver of a
|
||||
peer-served object does (§6); the bearer verdict with a cache's instances (§7); and the count of a
|
||||
stated population's receivers that held an object by a deadline (§8).
|
||||
|
||||
Out of scope: a transport; how a node chooses what to keep, or an asker whom to fetch from, which
|
||||
are [holdings](holdings.md) §5.4's; a record of service beside settlement's acknowledgement and
|
||||
declaration (settlement §3, §4), which this extension does not add; where a receiver is, which is
|
||||
its own state (§8.1); a price, which is [rung-price](rung-price.md)'s; and which settlement object
|
||||
class carries an object a node serves on request from what it holds outside a room (§9).
|
||||
|
||||
Relationship to the core spec: `wai.delivery.cache` is a **record** ([SPEC](../SPEC.md) §5): the
|
||||
cache-transmission claim is a signed record a WAI tool emits and verifies. It names no payload
|
||||
format and is not dispatchable.
|
||||
|
||||
## 2. Terms
|
||||
|
||||
- **Group**, **link hash**, **leaf count**, **content hash**: as [jwp-receipts](jwp-receipts.md)
|
||||
§2.1, §2.4.1 and §3 step 3. Object *i* of a group is its object at position *i*, from 0.
|
||||
- **Publisher**: the key that signed a group. **Node**: a party that serves, from what it holds,
|
||||
objects of groups another key signed — a neighbourhood cache, a peer, a home gateway.
|
||||
- **Bearer**, **bearer class**, **bearer instance**: as [bearer-receipts](bearer-receipts.md) §2.
|
||||
A **cache instance** is a bearer instance a node declares as its own (§5).
|
||||
- **Head**: the head revision of an origin's key log (jwp-receipts §7.2), as a JSON text, keyed by
|
||||
the origin; **held**: passing jwp-receipts §7.2 rules 1 to 3 for that origin.
|
||||
- **Instant**: a count of milliseconds since 1970-01-01T00:00:00Z, leap seconds not counted, as
|
||||
[holdings](holdings.md) §2 writes one. **Last instant**: 253,402,300,799,999
|
||||
(9999-12-31T23:59:59.999Z), as [timeliness](timeliness.md) §2 bounds a deadline. A settlement
|
||||
window is in microseconds and excludes its `end` ([settlement](settlement.md) §2).
|
||||
- **Held-at statement**: timeliness §2's: a key, a group, a position and an instant, the key's
|
||||
signed word that it held that object no later than that instant, and its rule for a statement
|
||||
before its group.
|
||||
- **Region**: a region code of rung-price §2. **ppm**: parts per million.
|
||||
- **Bitmap**: object *i* is bit `0x80 >> (i mod 8)` of byte ⌊*i* / 8⌋, as a reconciliation's
|
||||
`received` is (bearer-receipts §5.1).
|
||||
|
||||
## 3. Why two authorities and one claim
|
||||
|
||||
- **No new record of service.** "These bytes, I served them" exists: a settlement declaration is
|
||||
the server's word, and an acknowledgement the receiver's countersigned word, with byte ranges and
|
||||
a signed window. Nothing here adds a third.
|
||||
- **Two authorities, as relays already hold them.** A relay holds a role authority (`live/relay`,
|
||||
[live-metering](live-metering.md) §5.2, settlement §13) and, to sign a `delivery-gap` claim,
|
||||
that class's own (jwp-receipts §7.5 *Stage and delivery gap*). A node does the same: the role
|
||||
`cache/serve` places it as a server, and the class authority `cache-transmission` lets it sign
|
||||
the one claim this extension registers.
|
||||
- **One claim, for an instance with no path back.** Over a two-way link the receiver acknowledges
|
||||
what it received. Over a node's own `multicast` or `unidirectional` instance it can only
|
||||
reconcile later (bearer-receipts §5), and a reconciliation over an instance nobody declared is
|
||||
counted nowhere (bearer-receipts §6.2 step 5). jwp-receipts §4.3 requires relay attestation to be
|
||||
"carried as a separate, parallel receipt", and §7.5's last item notes it has no class: the
|
||||
cache-transmission claim is that receipt.
|
||||
- **A statement about one group, as a claim.** It is about one group, travels with its receipts
|
||||
on the same bearer, and is the counterpart of `transmission` and `reconciliation`, so it is a
|
||||
jwp-receipts claim under the claim domain, chained per signer (jwp-receipts §2.4.3), not a binary
|
||||
record.
|
||||
|
||||
## 4. Authorities
|
||||
|
||||
### 4.1 What `cache/serve` lets a key do
|
||||
|
||||
A key that holds `cache/serve` acts as a node that serves, from what it holds, objects of groups
|
||||
another key signed. Under it the key signs its holdings records (holdings §4) and its
|
||||
declarations of what it served (settlement §4), and is the `upstream` of the acknowledgements its
|
||||
receivers sign (settlement §3). A key that also holds `cache-transmission` signs
|
||||
cache-transmission claims (§5), each declaring a bearer instance of its own and the objects of one
|
||||
group it sent over it. That is all either covers.
|
||||
|
||||
### 4.2 What neither lets a key claim
|
||||
|
||||
- **Authorship**: one signer per group (jwp-receipts §4.3). Neither is `delivery`, and a key with
|
||||
them alone confirms no group receipt (jwp-receipts §7.4 step 6, `authority_not_granted`).
|
||||
- **Transformation**: other bytes are a new object ([http-delivery](http-delivery.md) §9;
|
||||
[deriving-edge](deriving-edge.md) §3.2), which needs `edge/derive`.
|
||||
- **The publisher's statements and rights**: every other class keeps its own authority
|
||||
(jwp-receipts §7.5), and a node forwards those statements unaltered (jwp-receipts §4.1).
|
||||
- **The publisher's transmission**: a `transmission` claim is signed by the group's key
|
||||
(bearer-receipts §4.3 step 2). A node declares only an instance of its own (§5), never the
|
||||
publisher's (§7.2 step 4), and a cache instance never makes a group `transmitted` (§7.3).
|
||||
- **A room's hop**: neither is `live/relay`; a per-hop session record keeps its role
|
||||
(live-metering §5.2).
|
||||
|
||||
A node's class under jwp-receipts §4 is set by what it does, not by these authorities: a §4.1 relay
|
||||
when it reads the receipts it forwards (jwp-receipts §4.5), an HTTP cache under jwp-receipts §4.5
|
||||
when it stores and serves byte for byte. jwp-receipts §4.3 binds it either way.
|
||||
|
||||
### 4.3 Who gives them
|
||||
|
||||
The **node's operator's origin** lists, in its key log, the nodes it runs with `cache/serve`, and
|
||||
with `cache-transmission` those that declare instances of their own, as it lists its relays with
|
||||
`live/relay` and `delivery-gap`:
|
||||
|
||||
- **Integrity needs no grant.** A node changes no byte, so jwp-receipts §3 verifies what it serves
|
||||
against the publisher's receipts, whoever it is. `edge/derive` comes from the source's origin
|
||||
(deriving-edge §8) only because an edge publishes new bytes under the source's content.
|
||||
- **The payer places the server.** Settlement §13 pays on keys "the hop's origin lists with the
|
||||
authority of its role".
|
||||
- **It scales without the publisher.** A neighbourhood node holds the groups of every publisher on
|
||||
a broadcast; making each publisher list every node would make the content's owner run the
|
||||
carrier's fleet.
|
||||
|
||||
A publisher that runs its own nodes lists them under its own origin, which is then the operator's.
|
||||
An origin **SHOULD NOT** give `cache/serve` or `cache-transmission` to a key it also gives
|
||||
`delivery`: such a key's sending of its own groups is the publisher's (bearer-receipts §4), and its
|
||||
cache-transmission claim over them is refused (`cache_transmission_by_publisher`, §5.3 step 2).
|
||||
Neither authority is pinned-only: nothing a sink presents or obeys depends on it.
|
||||
|
||||
### 4.4 Placing a server key
|
||||
|
||||
**Inputs.** A 32-byte Ed25519 key *K*; an instant `at_ms`, the time of the item the key is placed
|
||||
for; and the heads the relying party names, each a JSON text keyed by its origin (as deriving-edge
|
||||
§7.1 holds them). A JSON object cannot hold one origin twice (jwp-receipts §3.1 step 1), so there is
|
||||
one head per origin. No randomness and no clock.
|
||||
|
||||
**Serving roles**, in this order: `cache/serve`, `live/relay`, `live/compositor`, `live/ingest`
|
||||
(the last three the roles settlement §13 places).
|
||||
|
||||
**Steps.** Heads are read in increasing byte order of their origins (jwp-receipts §7.1); "least"
|
||||
below is first in that order. A retired entry's `until` is read as seconds since 1970, and
|
||||
`at_ms` is **later than** it when `at_ms` > `until` × 1000, as jwp-receipts §7.4 step 5 compares
|
||||
times. The placement is the first of these that holds:
|
||||
|
||||
1. **refused `key_revoked`**, with the least such origin and the entry's `compromised_since`: a
|
||||
held head has an entry whose `key` is *K* and whose `status` is `revoked`, whatever its
|
||||
`may_sign`. A revocation attests that another party may hold the key (jwp-receipts §7.2), so no
|
||||
role outlives it.
|
||||
2. **unconfirmed `key_log_not_held`**, with the least such origin: a head is not held. It might
|
||||
revoke *K*, so nothing is confirmed while it cannot be read.
|
||||
3. **confirmed**, with the least such origin, the entry's `id`, the first serving role in the order
|
||||
above that its `may_sign` holds and, for a retired entry, its `until`: a held head has an entry
|
||||
whose `key` is *K*, whose `may_sign` holds a serving role, and which is `active`, or `retired`
|
||||
with `at_ms` not later than its `until`.
|
||||
4. **refused `key_retired`**, with the least such origin and its `until`: a held head has an entry
|
||||
whose `key` is *K* and whose `may_sign` holds a serving role, and every such entry is retired
|
||||
with `at_ms` later than its `until`.
|
||||
5. **refused `authority_not_granted`**, with the least such origin: a held head lists *K* with no
|
||||
serving role.
|
||||
6. **unconfirmed `server_not_listed`**: no head lists *K*, including when no head is given.
|
||||
|
||||
A placement is written `confirmed origin=… key_id=… role=…`, followed by ` until=…` for a retired
|
||||
entry; `unconfirmed reason=server_not_listed`; `unconfirmed reason=key_log_not_held origin=…`;
|
||||
`refused reason=key_revoked origin=… compromised_since=…`;
|
||||
`refused reason=key_retired origin=… until=…`; or `refused reason=authority_not_granted origin=…`.
|
||||
|
||||
The levels are jwp-receipts §7's, never reported as one another. A key that claims no origin
|
||||
contradicts nothing by being absent from a head, so step 6 is unconfirmed; a head that lists the
|
||||
key and withholds the role, or revokes or retires it, speaks against it, so steps 1, 4 and 5 are
|
||||
refused. A placement is not jwp-receipts §7.4's confirmation: it uses the heads the relying party
|
||||
names, held under rules 1 to 3, with no anchor (jwp-receipts §7.3), as deriving-edge §8 checks
|
||||
`edge/derive`. A cache-transmission claim's own confirmation of its `signer_id` (with
|
||||
`cache-transmission`) is reported beside it, as for any claim; neither changes the other.
|
||||
|
||||
**Which instant.** For a cache instance (§7.2 step 4), the latest instant any of its claims'
|
||||
`issued_at` allows (§8.3). For a server in a tally (§8.4), the latest on-time evidence instant that
|
||||
credits it. For any other item, its own signed time.
|
||||
|
||||
A placement **never** changes a verdict (§7) or a hit (§8). It decides only how a server is
|
||||
reported, and which keys a payer may honour (settlement §13). A placement is never an error: no
|
||||
head makes a verdict or a tally fail.
|
||||
|
||||
### 4.5 The holdings key and the service key
|
||||
|
||||
Holdings §9 lets a node sign each record with a fresh key, for privacy; settlement §13 pays only
|
||||
keys it can place. A node whose service is to be placed signs its holdings records, its
|
||||
declarations and its claims with **the key its operator lists**. An asker names, as `upstream`, the
|
||||
key it found the object under (holdings §5.4), and a payer places that key. A node that signs with
|
||||
fresh keys has chosen not to be linked, and privacy wins where the holder chooses it: its askers
|
||||
still verify what it serves (§6), its service still counts as hits (§8), and it is reported
|
||||
`unconfirmed server_not_listed`, paid as no one's.
|
||||
|
||||
## 5. The cache-transmission claim (`class: "cache-transmission"`)
|
||||
|
||||
A node that sent, from what it held, objects of a group another key signed, over a `multicast` or
|
||||
`unidirectional` bearer instance of its own, **MAY** declare it in a `cache-transmission` claim,
|
||||
one per instance and group, after it has sent what it declares. Its `subject_type` is `receipt`
|
||||
and its `subject` the group receipt's link hash (jwp-receipts §3 step 3). It is signed by the
|
||||
node's key, never the group's. `signer_id` is the node's operator's origin-bound `did:web` where
|
||||
its log lists the key, or its `did:key` otherwise; the second form is unconfirmed under
|
||||
jwp-receipts §7.4 step 1 and still placeable by §4.4, which matches the key's bytes.
|
||||
|
||||
### 5.1 Body
|
||||
|
||||
| member | type | meaning |
|
||||
|---|---|---|
|
||||
| `bearer` | string | The instance's class, one of bearer-receipts §2's; another string is `field_type` at jwp-receipts §3.1 step 2. |
|
||||
| `bearer_id` | absolute URI | The node's own instance. WAI reads nothing from it and compares it byte for byte. |
|
||||
| `leaf_count` | integer | The group's leaf count. |
|
||||
| `sent` | hex | A bitmap of ⌈`leaf_count` / 8⌉ bytes. The bit of object *i* is set exactly when the node sent object *i* over the instance, from what it held, as the bytes the group's receipts commit to. |
|
||||
| `sent_root` | 32 B hex | jwp-receipts §2.2's root over the leaves of the sent objects, in increasing position, each leaf in the form its object receipt takes (legacy or labelled). With every bit set it is the group's `root_hash`. |
|
||||
| `energy` | object or `null` | As bearer-receipts §4.1 `energy`: what the instance spent sending these objects, every repetition included. `null` when not metered, never a figure of 0. |
|
||||
| `attests` | string | **MUST** be `"transmitted"`. |
|
||||
|
||||
One meaning has one form: the bitmap sets at least one bit and none at or past `leaf_count`
|
||||
(§5.2 rule 3), hex is lowercase (jwp-receipts §2.4.3), and `energy` is `null` or an object, never
|
||||
absent.
|
||||
|
||||
```json
|
||||
{"body":{"attests":"transmitted","bearer":"multicast","bearer_id":"https://bearer.example/mesh-c1",
|
||||
"energy":null,"leaf_count":4,"sent":"50","sent_root":"<32 B hex>"},"class":"cache-transmission",
|
||||
"issued_at":"2026-11-03T11:00:05Z","kind":"claim","parent_receipt_hash":null,"seq":0,
|
||||
"sig":"<64 B hex>","signer_id":"did:web:operator.example#c1","signer_pubkey":"<32 B hex>",
|
||||
"subject":"<group link hash>","subject_type":"receipt"}
|
||||
```
|
||||
|
||||
(`"50"` holds objects 1 and 3 of four.)
|
||||
|
||||
### 5.2 Rules
|
||||
|
||||
At jwp-receipts §3.1 step 4, in order:
|
||||
|
||||
1. `attests` is `"transmitted"`: otherwise `cache_transmission_not_transmitted`. A declaration that
|
||||
says it attests more is refused, never read as a weaker one.
|
||||
2. `bearer` is not `bidirectional`: otherwise `cache_transmission_on_bidirectional`. Over a path
|
||||
back the receiver acknowledges what it received (settlement §3), and a second statement of the
|
||||
same service would count it twice.
|
||||
3. `leaf_count` is at least 1, `sent` holds exactly ⌈`leaf_count` / 8⌉ bytes, no bit at or past
|
||||
`leaf_count` is set, and at least one bit is set: otherwise
|
||||
`cache_transmission_bitmap_malformed`. A node that sent nothing has nothing to declare.
|
||||
4. Where `energy` is not `null`: bearer-receipts §4.2 rule 2 (`energy_zero_figure`,
|
||||
`energy_unknown_acquisition`, `energy_legacy_acquisition`, `energy_acquisition_evidence`).
|
||||
|
||||
### 5.3 Against the group
|
||||
|
||||
At jwp-receipts §3.1 step 6, against the group receipt the verifier holds, which passes
|
||||
jwp-receipts §3 steps 1 and 2 (`context_invalid` otherwise; a receipt of another kind is
|
||||
`context_mismatch`), and the object receipts it holds under that group, each of which passes
|
||||
jwp-receipts §3 step 5 (inclusion at least), in order:
|
||||
|
||||
1. the group's link hash is `subject`: otherwise `cache_transmission_subject_mismatch`;
|
||||
2. `signer_pubkey` is not the group's: otherwise `cache_transmission_by_publisher`. A publisher's
|
||||
own sending is a `transmission` (bearer-receipts §4);
|
||||
3. `leaf_count` is the group's: otherwise `cache_transmission_leaf_count_mismatch`;
|
||||
4. with every bit set, `sent_root` is the group's `root_hash`. Otherwise the verifier holds an
|
||||
object receipt at every set position (`cache_transmission_objects_not_held`, recoverable by
|
||||
obtaining them, never accepted silently), and `sent_root` is the root over their leaves
|
||||
(`cache_transmission_root_mismatch`). A root over a leaf that is not the group's at its
|
||||
position is refused here: the digests served are checked against a group the node did not
|
||||
author, without the bytes.
|
||||
|
||||
A claim checked without its group stops at step 5 and is counted nowhere (§7).
|
||||
|
||||
### 5.4 What it attests
|
||||
|
||||
The node says it sent those objects, from what it held, over its instance. It does not attest that
|
||||
any receiver received them (a reconciliation's, bearer-receipts §5), that it authored or changed
|
||||
them, or that it had any right of the publisher's to send them. A transmission is not a delivery,
|
||||
on any bearer (bearer-receipts §2).
|
||||
|
||||
Each of a node's statements binds to the group by hash and none re-signs or re-roots anything
|
||||
(jwp-receipts §4.3): a cache-transmission claim by `subject` and by `sent_root` under the group's
|
||||
root; a holdings record by its entry's `group`; a settlement record by its items' content hashes.
|
||||
A node's claims chain per signer through `parent_receipt_hash` and `seq` (jwp-receipts §2.4.3),
|
||||
across every group it declares, so a verifier is given its earlier claims or reports
|
||||
`parent_not_held`. There is no hop bound: a peer served by a peer is two services, each checked
|
||||
against the publisher's receipts at its own door (jwp-receipts §4.1).
|
||||
|
||||
### 5.5 Authority
|
||||
|
||||
Confirmed under jwp-receipts §7, the claim needs `cache-transmission`, which is neither pinned-only
|
||||
nor `delivery`. Where `energy` is not `null`, its figure is a second item that needs
|
||||
`cache-transmission` and `energy/<class>`, as a transmission's does (bearer-receipts §4.4). The
|
||||
claim's signer is placed as a server by §4.4, reported beside.
|
||||
|
||||
## 6. A peer-served object at the receiver
|
||||
|
||||
- **The verdict is unchanged.** The receiver verifies the object under its object receipt and the
|
||||
group receipt (jwp-receipts §3): `delivered-verified` over the bytes it received, whoever sent
|
||||
them (holdings §6).
|
||||
- **Placement is not evidence.** A receiver **MUST NOT** treat a server's placement (§4.4) or its
|
||||
claims as evidence of the bytes. It **MAY** report the placement beside the verdict, never
|
||||
changing it.
|
||||
- **What counts the service.** With a path back, the receiver acknowledges the server (settlement
|
||||
§3, `upstream` the key it fetched from). With none, it reconciles over the server's instance
|
||||
(bearer-receipts §5, `bearer_id` the node's). Either is what §8 counts.
|
||||
- **Learning the server's key.** From the server's holdings record, or over HTTP from a response
|
||||
signature (§10). An object whose bytes fail is refused as jwp-receipts §3 refuses it and is never
|
||||
acknowledged (settlement §10).
|
||||
|
||||
## 7. The verdict
|
||||
|
||||
### 7.1 Inputs
|
||||
|
||||
Bearer-receipts §6.1's — the group receipt, the object receipts with envelopes where held, the
|
||||
claims in each signer's chain order, optionally a sink's own class and instance, a statement
|
||||
offered and `count_only` — with cache-transmission claims among the claims, and the heads of §4.4.
|
||||
|
||||
A verifier that implements bearer-receipts and not this extension refuses a cache-transmission
|
||||
claim (`unknown_class` before this registration, `context_mismatch` at bearer-receipts §6.2 step 3
|
||||
after it) and sets aside a reconciliation over a cache instance as `bearer_not_declared`: the
|
||||
unknown class is reported, as jwp-receipts §2.4.4 requires, never counted.
|
||||
|
||||
### 7.2 Steps
|
||||
|
||||
1. **The group and its objects**: bearer-receipts §6.2 steps 1 and 2. A group that fails is refused
|
||||
with its reason, and nothing is derived from it.
|
||||
2. **The claims**, in order: jwp-receipts §3.1 steps 1 to 5, then step 6: a transmission claim by
|
||||
bearer-receipts §4.3, a reconciliation by bearer-receipts §5.3, a cache-transmission claim by
|
||||
§5.3 here. A claim of any other class is `context_mismatch`. A refused claim takes no further
|
||||
part.
|
||||
3. **The publisher's declarations**: bearer-receipts §6.2 step 4.
|
||||
4. **The caches' instances**: the accepted cache-transmission claims, by `bearer_id`. Every claim of
|
||||
an instance is set aside `bearer_contradicted` when:
|
||||
- an accepted transmission claim has that `bearer_id`, whether its declarations stand or were set
|
||||
aside at step 3: the instance is the publisher's, and a party that names it as its own
|
||||
contradicts the publisher, whose declarations keep what step 3 gave them; or
|
||||
- two of them differ in signer or in body: one instance is one party's transmission, and a node
|
||||
that changes its account of it has stated neither. Such an instance is **contradicted**.
|
||||
|
||||
Two with the same signer and body are one, whatever their `issued_at` and `seq`. Otherwise the
|
||||
instance **stands**; its **server** is the claims' signer, placed by §4.4.
|
||||
5. **The reconciliations**, each accepted one in order, are counted as bearer-receipts §6.2 step 5
|
||||
counts them, over the publisher's declarations and the caches' instances that stand. One is set
|
||||
aside, and counted nowhere, at the first of these that holds:
|
||||
- step 3 set aside the declarations of its `bearer_id`, or its `bearer_id` is a contradicted
|
||||
cache instance (`bearer_contradicted`);
|
||||
- neither a declaration nor a cache instance that stands has its `bearer_id`
|
||||
(`bearer_not_declared`);
|
||||
- that declaration or instance names another `bearer` (`bearer_contradicted`);
|
||||
- it is over a cache instance and signed by its server (`self_acknowledged`, settlement §3.6):
|
||||
a party's statement that it received what it sent;
|
||||
- `count_only` is given and its signer is not among it (`sink_not_confirmed`).
|
||||
|
||||
Otherwise it is **counted** for its instance. A second counted reconciliation of one key for one
|
||||
instance adds no sink (`reconciliation_duplicate_sink`); its bits are joined to the first's.
|
||||
6. **The counts**:
|
||||
- bearer-receipts §6.2 step 6 over every counted reconciliation, of either kind of instance:
|
||||
`sinks`, `whole` and `received_by`, each key's bits joined across all instances;
|
||||
- for each publisher's declaration that stands, its `sinks`, as bearer-receipts gives them;
|
||||
- for each cache instance that stands, in increasing byte order of `bearer_id`: its `bearer_id`,
|
||||
`bearer`, server, placement and the objects it sent; its `sinks`, the distinct keys counted
|
||||
for it; and, for each object it sent, the distinct keys whose bits **over this instance's
|
||||
counted reconciliations alone** hold it. A cache is never credited for an object it does not
|
||||
say it sent, nor for one a key received over another instance.
|
||||
7. **Energy per receiver** (§7.4).
|
||||
|
||||
A claim's label is bearer-receipts §6.2's (`declared`, `counted`,
|
||||
`joined reason=reconciliation_duplicate_sink`, `set-aside reason=…`,
|
||||
`reject step=N reason=…`), and `cache-declared` for a cache-transmission claim whose instance
|
||||
stands.
|
||||
|
||||
### 7.3 Status, statements and a sink's own instance
|
||||
|
||||
- **Status** is bearer-receipts §6.3's over the publisher's declarations and every counted
|
||||
reconciliation: `reconciled` when a reconciliation is counted for any instance (the sinks'
|
||||
word); otherwise `transmitted` when a **publisher's** declaration stands; otherwise
|
||||
`published`. A cache instance that stands never makes a group `transmitted`, whose meaning is
|
||||
that the publisher sent it over the bearers declared; the instances are reported beside the
|
||||
status.
|
||||
- **Statements** (bearer-receipts §6.4): "transmitted over a bearer class" holds only on a
|
||||
publisher's declaration of that class that stands; "delivered to *k* sinks" reads `whole`, as
|
||||
there.
|
||||
- **A sink's own instance** (bearer-receipts §6.5): `bearer_contradicted` when its instance's
|
||||
declarations were set aside or it is a contradicted cache instance; where a publisher's
|
||||
declaration stands for it, `declared` when the classes agree and `bearer_contradicted` when they
|
||||
do not; where a cache instance stands for it, `cache-declared` when the classes agree and
|
||||
`bearer_contradicted` when they do not; otherwise `bearer_not_declared`.
|
||||
|
||||
Never delivered on the publisher's word (bearer-receipts §6.3) holds unchanged, and never
|
||||
delivered on a node's.
|
||||
|
||||
### 7.4 Energy per receiver
|
||||
|
||||
Bearer-receipts §7 for each cache instance that stands whose `energy` is not `null`:
|
||||
⌈`joules_micro` / max(1, *n*)⌉, *n* its counted keys. It is an upper bound, stated with the
|
||||
figure's class, and never added to another instance's or class's. A `null` figure gives no bound,
|
||||
never 0.
|
||||
|
||||
## 8. Held by a deadline
|
||||
|
||||
### 8.1 The population
|
||||
|
||||
The relying party states a **population**: a **region** and the receiver keys it counts there. The
|
||||
keys are the bearer verdict's `count_only` set (bearer-receipts §6.1): the tally runs the verdict
|
||||
with `count_only` the population's keys, replacing any given.
|
||||
|
||||
- **Region**: a region code of rung-price §2, in its capitals, checked by its grammar alone, so
|
||||
ISO's user-assigned codes pass. This text restates neither the grammar nor its CDNI case mapping.
|
||||
A price entry (rung-price §4.2) applies to a population when its `footprint` holds the region
|
||||
(rung-price §2 *Holds*).
|
||||
- **Keys**: 1 to 1,048,576 Ed25519 keys, in strictly increasing order of their bytes: one
|
||||
canonical form, no key twice.
|
||||
- **Population digest**: SHA-256 over `wai:cache-population` (ASCII) and the byte 0x01; one byte,
|
||||
the region's length, then the region (ASCII); a four-byte big-endian *n*, then the *n* keys in
|
||||
order. Region and keys are both inside it, so a population cannot be relabelled.
|
||||
|
||||
The population is the relying party's word. Nothing here places a key in a region: a receiver's
|
||||
location is its own state, and "a sink can lie about its own state"
|
||||
([unidirectional-delivery](unidirectional-delivery.md) §2). A key is not a receiver
|
||||
(bearer-receipts §7).
|
||||
|
||||
### 8.2 Inputs and checks
|
||||
|
||||
The verdict's inputs (§7.1), and: the **acknowledgements**, each a `WDA1` record's bytes with the
|
||||
item list of each of its entries; the **holdings records**, each a `WHD1` record's bytes (holdings
|
||||
§4); the object's position *i*; the **deadline**; the population; and `miss_target_ppm`.
|
||||
|
||||
The deadline is either `deadline_ms`, an instant the relying party states, or a timeliness claim
|
||||
(its text) whose entry for object *i* stands (timeliness §4, §6.2 steps 2 and 3, against the same
|
||||
group): `deadline_ms` is then that entry's `by_ms`, and the tally reports the claim's link hash
|
||||
beside it.
|
||||
|
||||
The tally is refused at the first of these that holds, and derives nothing:
|
||||
|
||||
1. the population breaks §8.1: `population_invalid`;
|
||||
2. a stated `deadline_ms` is past the last instant, or `miss_target_ppm` is above 999,999:
|
||||
`target_invalid`;
|
||||
3. the group fails: its reason (§7.2 step 1); or its `group_start` is not an RFC 3339 date-time
|
||||
(jwp-receipts §2.4.3): `context_invalid`, as [carousel](carousel.md) §4.3 refuses a schedule's
|
||||
group;
|
||||
4. *i* is at or past the group's leaf count: `object_out_of_range` (holdings §7);
|
||||
5. the verifier does not hold object *i* `delivered-verified` (§7.2 step 1, with its envelope):
|
||||
`object_not_verified`. Object receipts carry no length (jwp-receipts §2.4.2), and without the
|
||||
envelope's length ℓ a whole object cannot be told from part of one. The envelope gives ℓ and
|
||||
the content hash *h*;
|
||||
6. the deadline is a timeliness claim that is refused at jwp-receipts §3.1 steps 1 to 6 against
|
||||
this group, the object receipts the verifier holds under it and their envelopes where it holds
|
||||
them (timeliness §4.3, its steps 6 and 8 among them), with no claim of its signer held before
|
||||
it, or that has no entry for *i*: `deadline_not_stated` (timeliness §9). An object with no
|
||||
deadline is never on time.
|
||||
|
||||
A head that is not held refuses nothing: it leaves placements unconfirmed (§4.4 step 2).
|
||||
|
||||
### 8.3 Evidence
|
||||
|
||||
Each piece of evidence is a **held-at statement** of timeliness §2, whose instant is the latest the
|
||||
signed word allows, in milliseconds, rounded up where the word is finer. This text adds two kinds to
|
||||
the one timeliness derives from holdings records, and restates neither the form nor the rule:
|
||||
|
||||
- a counted reconciliation's instant is its `issued_at`, read as jwp-receipts §2.4.3 reads it
|
||||
(offset applied, second 60 read as the next minute's first), rounded up to the millisecond:
|
||||
1000 × those seconds + ⌈*f* × 1000⌉, with *f* the written fraction as a decimal (so
|
||||
`…:00.000001Z` adds 1 and `…:00.2Z` adds 200). Its whole seconds are those seconds, the fraction
|
||||
dropped;
|
||||
- an acknowledgement's instant is its window's **last microsecond**, `end` − 1, since a window's
|
||||
`end` is excluded (settlement §2), rounded up to the millisecond: ⌈(`end` − 1) / 1000⌉. Its whole
|
||||
seconds are ⌊(`end` − 1) / 1,000,000⌋. The window's `start` is never read.
|
||||
|
||||
**Before the group.** By timeliness §2's rule, a statement whose whole seconds are below the seconds
|
||||
jwp-receipts §2.4.3 reads from the group's `group_start` is before its group, and is set aside
|
||||
`held_before_group`: it gives no evidence. For an acknowledgement, that is an `end` at or below the
|
||||
group's start in microseconds. A receiver whose clock was never set signs instants near 1970, and
|
||||
they are never on time.
|
||||
|
||||
Only a population key's evidence is read. For each population key *k*:
|
||||
|
||||
- **Reconciliation evidence.** Each reconciliation counted at §7.2 step 5 (its label `counted` or
|
||||
`joined`) whose signer is *k* and whose bit *i* is set: a held-at statement at its instant, set
|
||||
aside when before the group. Its **server** is its instance's sender when it sent *i*: the
|
||||
group's key for a publisher's declaration, the cache's server when its `sent` holds *i*; none
|
||||
otherwise. A reconciliation attests no timeliness (bearer-receipts §5.4); what is read is its
|
||||
signer's signed word that it held the objects when it signed the claim, and a receiver with no
|
||||
path back signs at reception and sends the claim later.
|
||||
- **Acknowledgement evidence.** Each acknowledgement given is read under settlement §3.6 (its
|
||||
codes, among them `truncated`, `malformed`, `unknown_version`, `ack_invalid`,
|
||||
`self_acknowledged`, `bad_key` and `bad_signature`); a declaration (`mode` 2) is its server's own
|
||||
word and never evidence (settlement §4.2); a record of `mode` 1 must be given one item list for
|
||||
each of its entries (`record_missing`), each reading and recomputing against its entry
|
||||
(`items_invalid`, `items_mismatch`). Each is labelled, in order: `reject reason=…`,
|
||||
`declaration`, `before-group` when it reads and its last microsecond is before the group, and
|
||||
`read` otherwise. A record that does not read gives no evidence. Items of every object class
|
||||
count: the class is settlement's, and the tally reads only content hashes and ranges. Then, of the
|
||||
records that read, with `downstream` *k*:
|
||||
1. take those that hold an item of *h*, setting aside those before the group, and order the rest
|
||||
by `end`, then by digest (settlement §3.5);
|
||||
2. the first record at which the union of *h*'s byte ranges, in it and in the records before it,
|
||||
covers every byte from 0 to ℓ − 1 gives the evidence: a held-at statement at that record's
|
||||
last microsecond;
|
||||
3. its **servers** are the `upstream`s of the records up to and including that one. Parts from
|
||||
several peers, as a swarm delivers them, complete an object together, and each peer that
|
||||
served part of it is credited.
|
||||
|
||||
Acknowledgement chains (settlement §3.7) are settlement's and are not checked here; each record
|
||||
is read as its signer's word about its own window.
|
||||
- **Holdings evidence.** Each holdings record given is read (holdings §4.4) and held to the group as
|
||||
timeliness §6.2 step 4 holds it: refused `holdings_leaf_count_mismatch` when its entry for the
|
||||
group states another leaf count, and `held_before_group` when it has an entry for the group and
|
||||
its `as_of_ms` is before the group; of one holder's others, two with one `as_of_ms` and different
|
||||
digests set both aside (`holdings_contradicted`). Each is labelled as timeliness labels a record.
|
||||
A record of *k* that stands and holds object *i* (holdings §5.4) is a held-at statement at its
|
||||
`as_of_ms` (timeliness §6.2 step 5). It names no server.
|
||||
|
||||
*k* is a **hit** when some held-at statement of *k* for object *i* has an instant at or before
|
||||
`deadline_ms`, and a **miss** otherwise. For an acknowledgement, that is `end` − 1 ≤
|
||||
`deadline_ms` × 1000, with no overflow in 64 bits. A line of §9 for object *i* and a hit read the
|
||||
same statements, so a key `on_time` in such a line by `by_ms` is a hit at that deadline in a tally
|
||||
of object *i* whose population holds it.
|
||||
|
||||
### 8.4 The count
|
||||
|
||||
- *n* is the population's size; `hits`; `misses` = *n* − `hits`.
|
||||
- `miss_ppm` = ⌈`misses` × 1,000,000 / *n*⌉, rounded up so the reported rate is never below the
|
||||
true one.
|
||||
- `missing`: the keys that missed, in population order: the list an operator repairs before a
|
||||
deadline, or reports after it.
|
||||
- `set_aside`: each held-at statement of a population key set aside `held_before_group`, as its
|
||||
key, its source (`reconciliation`, or an acknowledgement's digest in lowercase hex) and the
|
||||
reason, in increasing order of key, then source.
|
||||
- **Servers.** For a hit *k*, *S*(*k*) is the set of servers of its on-time evidence, and *U*(*k*)
|
||||
holds when some on-time evidence of *k* credits no server. For each key in the union of the
|
||||
*S*(*k*), in increasing byte order:
|
||||
- its **placement**: `publisher` for the group's key, otherwise §4.4's at the latest on-time
|
||||
evidence instant that credits it;
|
||||
- `hits` = the number of hits *k* with the server in *S*(*k*);
|
||||
- `sole` = the number of hits *k* with *S*(*k*) = {server} and not *U*(*k*): the hits it alone
|
||||
served.
|
||||
- `unattributed` = the number of hits *k* with *S*(*k*) empty.
|
||||
|
||||
### 8.5 Outcome
|
||||
|
||||
**Met** when `miss_ppm` ≤ `miss_target_ppm`; otherwise refused `miss_target_exceeded`. Reported
|
||||
either way: the region, the population digest, `deadline_ms` and a timeliness claim's link hash,
|
||||
*n*, `hits`, `misses`, `miss_ppm`, `missing`, `set_aside`, the servers, `unattributed`, each
|
||||
acknowledgement's label, each holdings record's label, and each claim's label under §7.2.
|
||||
|
||||
A met tally says that, of the stated keys, the share given held the object, by their own signed
|
||||
word, by the deadline. It does not say that they presented it, that the population is everyone in
|
||||
the region, that any key is one receiver, or that the population was chosen before the deadline
|
||||
(§13). It is not carousel §6.4's "held by *t*", which is the publisher's plan for a receiver within
|
||||
a loss bound; a relying party may hold both. The asker's plan of whom to fetch from stays holdings
|
||||
§5.4's, and the operator's plan of whom to repair is `missing`.
|
||||
|
||||
## 9. Composition
|
||||
|
||||
- **holdings**: a holder signs with the key its operator lists to be placed (§4.5; holdings §9). A
|
||||
record is the third kind of held-at statement §8.3 reads.
|
||||
- **carousel**: availability (carousel §6.3) is the publisher's plan for a receiver within a loss
|
||||
bound; a node repairs what that plan did not cover, and §8 counts the outcome. A cache instance
|
||||
has no schedule: a schedule is signed by the group's key (carousel §4.3 step 2), and a
|
||||
cache-transmission claim and a schedule share no instance (§7.2 step 4).
|
||||
- **bearer-receipts**: the verdict extends its §6.2 here, as carousel's does. Its §7 bound applies
|
||||
to each cache instance, and its §8 holds for a node's key: a receiver with no path back places a
|
||||
node only by heads provisioned in advance, and a key log arriving over the node's instance
|
||||
anchors nothing (`anchor_on_bearer`).
|
||||
- **settlement**: no new object class and no figure changes. Settlement §13 places a node that
|
||||
serves from what it holds by `cache/serve` from its operator's origin. A node **MAY** state a
|
||||
one-way re-sending's bytes in a declaration (settlement §4, zero `downstream`) beside its
|
||||
cache-transmission claim; a declaration bills nothing (settlement §4.2), and no tariff prices a
|
||||
cache instance, so a node's one-way re-sending is counted, credited and placed here and billed
|
||||
nowhere. Which settlement object class carries an object a node serves on request from what it
|
||||
holds outside a room, and on whose word a payer honours a sink's acknowledgement outside a room,
|
||||
settlement §3.3 and §13 do not say, and this text does not decide.
|
||||
- **takedown**: a cache-transmission claim is its signer's own word that it held what it sent, as a
|
||||
declaration is ([takedown](takedown.md) §7.1, `served`).
|
||||
- **jwp-receipts §4**: the claim is the parallel receipt jwp-receipts §4.3 permits, and §4.2 lets a
|
||||
relay, or a node that is not one, sign it.
|
||||
- **timeliness**: it owns the per-object deadline and the held-at statement, which this text
|
||||
extends with acknowledgements and counted reconciliations (§8.3). Timeliness §6's verdict and
|
||||
the statement it answers (its §6.4) read holdings records alone. A verifier that holds this
|
||||
text's inputs (§7.1, §8.2) and a timeliness claim **MAY** derive the lines of the claim's entry
|
||||
for object *i* by timeliness §6.3 from held-at statements of all three kinds:
|
||||
- the holdings records' exactly as timeliness §6.2 step 5 derives them, for the primary and its
|
||||
fallback whatever envelopes the verifier holds, with a line for each holder with a record that
|
||||
stands and has an entry for the group or the fallback's group;
|
||||
- those §8.3 derives from acknowledgements and counted reconciliations, for every object of the
|
||||
group the verifier holds `delivered-verified`, the primary and a fallback in the group among
|
||||
them, with a line for each key with one. A fallback in another group is held by records alone,
|
||||
since this text judges one group.
|
||||
|
||||
The claim is checked as §8.2 step 6 checks a deadline's, and a claim refused there, or with no
|
||||
entry for *i*, derives no lines (`deadline_not_stated`). A fallback rendition is another object
|
||||
with its own content hash, so it has its own tally and is never a hit for the primary.
|
||||
- **rung-price**: it owns price, direction and a price list's `footprint`, in the region codes of
|
||||
its §2, which §8.1 uses. It carries no deadline and no miss target, so the tally takes the
|
||||
deadline, the target and the population as stated inputs and reports the population's digest (and
|
||||
a timeliness claim's link hash) so that a contract can name them. Joules are stated, never priced.
|
||||
A comparer **MAY** accept a seller whose key it places with a serving role (§4.4).
|
||||
- **http-delivery**: a cache-transmission claim listed in a receipt index is checked at its §8.2
|
||||
step 6 against the group of an item whose link hash is its subject and the object receipts of the
|
||||
items listed under it.
|
||||
- **live-rooms**: inside a room a payer may take the grants' principals as the population; §8.1 is
|
||||
its analogue outside one.
|
||||
|
||||
## 10. Mappings
|
||||
|
||||
| standard | binding |
|
||||
|---|---|
|
||||
| MOQT, `draft-ietf-moq-transport-23` §7.1 (*Caching Relays*: "Relays MAY cache Objects, but are not required to"), §7.9 (*Relay Object Handling*: a relay "MUST treat the object payload as opaque" and "MUST NOT combine, split, or otherwise modify object payloads"), §2.1 ("The Original Publisher is solely responsible for the content of the object payload") | A node with `cache/serve` serving over MoQ is a §7.1 caching relay held to §7.9 and to jwp-receipts §4.1 and §4.3. |
|
||||
| HTTP Message Signatures, RFC 9421 §3.3.6 (`ed25519`), §2.3 (`keyid`, `tag`), §2.5, §4.3 ("Multiple distinct signatures MAY be included"), §7.2.8 | A node serving over HTTP **MAY** sign each response with `alg="ed25519"`, `keyid` its key's `did:key` (jwp-receipts §7.1) and `tag="wai-cache"`, covering `content-digest` (RFC 9530 §2). A downstream that verifies signature and digest learns the key it names as `upstream`. An RFC 9421 signature base begins with `"` (§2.5), and every WAI signing input begins with its ASCII domain, so one key's two kinds of signature never sign the same bytes. |
|
||||
| Cache-Status, RFC 9211 §2, §2.1 (`hit`), §2.2 (`fwd`), §2.5 (`stored`) | A member with `hit` is HTTP's own unsigned "served from what it held" (settlement §9.1); `fwd` with `stored` is a fill. RFC 9211 defines no signature, so its identifier is not a key and places nothing. |
|
||||
| CTA-5006 `CMSD-Dynamic`, as [delivery-telemetry](delivery-telemetry.md) §6, its paragraph 6.3, maps it | A node **MAY** write its `did:key` as its entry's server string; a player that reads it learns a key it may name as `upstream`, unsigned, and **SHOULD** prefer an RFC 9421 signature where one is present. |
|
||||
| PPSPP, RFC 7574 §3.2 (`HAVE`), §5, §9.2 ("reciprocity enforcement is the sole responsibility of the sending peer"), §11.2.4 | Informative: a holdings record is close to `HAVE`, and jwp-receipts §3 to PPSPP's content integrity. PPSPP leaves reciprocity and accounting local; this extension gives their portable form. |
|
||||
| DVB-NIP, ETSI TS 103 876 §6.2.2 and §6.5 | A gateway that stores broadcast assets and serves them to home clients is a node: its key holds `cache/serve`, its holdings record advertises, its clients acknowledge it. |
|
||||
| CDNI footprints, RFC 8006 §4.3.8 and RFC 9388 §2.1.1.1 | Through rung-price §2, which binds them: a region is ISO's capital form. |
|
||||
|
||||
## 11. Reasons
|
||||
|
||||
The codes this extension adds, each with where it is raised:
|
||||
|
||||
| reason | raised at |
|
||||
|---|---|
|
||||
| `server_not_listed` | §4.4 |
|
||||
| `cache_transmission_not_transmitted` | §5.2 rule 1 |
|
||||
| `cache_transmission_on_bidirectional` | §5.2 rule 2 |
|
||||
| `cache_transmission_bitmap_malformed` | §5.2 rule 3 |
|
||||
| `cache_transmission_subject_mismatch` | §5.3 step 1 |
|
||||
| `cache_transmission_by_publisher` | §5.3 step 2 |
|
||||
| `cache_transmission_leaf_count_mismatch` | §5.3 step 3 |
|
||||
| `cache_transmission_objects_not_held` | §5.3 step 4 |
|
||||
| `cache_transmission_root_mismatch` | §5.3 step 4 |
|
||||
| `population_invalid` | §8.2 |
|
||||
| `target_invalid` | §8.2 |
|
||||
| `object_not_verified` | §8.2 |
|
||||
| `miss_target_exceeded` | §8.5 |
|
||||
|
||||
The extension also raises `key_revoked`, `key_retired` and `authority_not_granted` (jwp-receipts
|
||||
§7.7), at the levels §4.4 gives them, and `key_log_not_held` (deriving-edge §9), at §4.4;
|
||||
`bearer_contradicted`, `bearer_not_declared`, `sink_not_confirmed`,
|
||||
`reconciliation_duplicate_sink`, `delivery_not_reconciled` and `delivery_exceeds_reconciled`
|
||||
(bearer-receipts §10) at §7.2 and §7.3; `self_acknowledged` (settlement §11) at §7.2 step 5;
|
||||
`object_out_of_range` (holdings §7), `context_invalid` (jwp-receipts §3.1) and
|
||||
`deadline_not_stated` (timeliness §9) at §8.2; settlement §3.6's codes and `record_missing` for an
|
||||
acknowledgement, holdings §7's codes for a holdings record, and `held_before_group` (timeliness §9)
|
||||
for any held-at statement, at §8.3; for a group or object the codes of jwp-receipts §3.3; and for a
|
||||
claim every code of jwp-receipts §3.1 steps 1 to 5, bearer-receipts §4.2 rule 2's energy codes, and
|
||||
those of step 6 named in §5.3.
|
||||
|
||||
## 12. Conformance
|
||||
|
||||
[`live-conformance/cache-authority/`](../live-conformance/cache-authority/) holds the corpus, written
|
||||
from fixed seeds by `wai_cache_authority_vectors gen` as `vectors.json`. Every key comes from a
|
||||
published seed of one byte repeated 32 times (jwp-receipts §7.8), both key logs are test logs, and
|
||||
regions are ISO 3166-1's user-assigned codes. It has:
|
||||
|
||||
- **Claims**, each through jwp-receipts §3.1 steps 1 to 6 against the context it gives: objects
|
||||
sent from a group, every object with no object receipt held, the last object of a group of nine,
|
||||
labelled leaves, a metered instance, a chained second claim; a chain broken and a parent not
|
||||
held; one negative control for each rule of §5.2 and each step of §5.3, an unknown bearer class,
|
||||
an extra member and an energy figure of 0; a context that is not a group receipt; and a claim with
|
||||
no group, which stops at step 5.
|
||||
- **Placements**: each step of §4.4, the least origin, a serving role other than `cache/serve`, a
|
||||
retired key at its `until` and a millisecond past it, a revocation beating a confirmation and
|
||||
outliving another role, a head not held, a head under another origin, and a revocation beside a
|
||||
head not held.
|
||||
- **Verdicts**: a cache instance beside the publisher's with per-instance credit; reconciliations
|
||||
only over a cache's instance; a cache instance alone, published, with a statement of
|
||||
transmission refused; an instance taken from the publisher, with the publisher's declarations
|
||||
standing and contradicted; two caches on one instance; one claim twice and a cache contradicting
|
||||
itself; each reason of §7.2 step 5; placements of every level beside unchanged counts, and a
|
||||
retired node's two claims of one instance on either side of its `until`; a sink on a cache
|
||||
instance; the energy bound before and after keys are counted, and unmetered; another class; and a
|
||||
group that fails.
|
||||
- **Tallies**: a target met exactly and missed by one ppm, a rate rounded up; an acknowledgement
|
||||
ending at the deadline and one and two microseconds past it; a reconciliation signed at the
|
||||
deadline, a microsecond past it, below a second, with an offset and at second 60; evidence before
|
||||
the group of each kind, and a reconciliation in the last second before it whose instant rounds up
|
||||
to its start; an object completed by two peers, in time and late, the late one with its records
|
||||
given latest first, two records of one `end` that their digests order, and a part never
|
||||
completed; a declaration, a key outside the population, and an acknowledgement refused for each
|
||||
reason §8.3 names; an object of any class; a bit a cache did not send; servers placed at each
|
||||
outcome of §4.4, a retired key before its `until` among them, a retired key credited on either
|
||||
side of its `until`, and the publisher; the target and deadline bounds on both sides; a region of
|
||||
every length rung-price §2 admits, and a hyphen with no subdivision, a subdivision of four
|
||||
characters and a region in lowercase, each refused; a population empty, out of order and holding
|
||||
a key twice; and each refusal of §8.2, with a deadline taken from a timeliness claim, one with no
|
||||
entry for the object, one about another group and one whose fallback's envelope is held and not
|
||||
derived.
|
||||
- **Lines** (§9): from an acknowledgement and from a counted reconciliation; from a holdings record
|
||||
whose object's envelope is not held; with a fallback in another group that a holder's record
|
||||
alone names; and with a fallback in the group, held by an acknowledgement and a reconciliation.
|
||||
|
||||
The population bound of 2<sup>20</sup> keys is carried by no case; both implementations check it
|
||||
themselves.
|
||||
|
||||
A conforming verifier gives every case the outcome, the reason and everything else the corpus
|
||||
states. `wai_cache_authority_vectors verify` and the test `tests/cache_authority.rs` check that the
|
||||
file regenerates byte for byte, that `wai-rs` gives every case its outcome, that every reason of §11
|
||||
appears, and that §11 lists exactly the reference's codes.
|
||||
`live-conformance/cache_authority_verify.py` is the second implementation, written from this text:
|
||||
it shares no code with `wai-rs`; it verifies groups and objects with the JWP corpus's second
|
||||
implementation (`jwp-conformance/jwp_verify.py`), claims through steps 1 to 5 with the claim
|
||||
corpus's (`claim-conformance/claim_verify.py`), reconciliations with the bearer corpus's
|
||||
(`live-conformance/bearer_verify.py`), acknowledgements with the settlement corpus's
|
||||
(`live-conformance/settlement_verify.py`), key logs with the signer corpus's
|
||||
(`signer-conformance/signer_verify.py`), held-at statements with the timeliness corpus's
|
||||
(`live-conformance/timeliness_verify.py`) and regions with the rung price corpus's
|
||||
(`live-conformance/rung_price_verify.py`); it writes §4.4, §5.3, §7 and §8 itself, reads §11 from
|
||||
this text, mutates copies of the corpus and requires every copy to fail, and drops each of its own
|
||||
checks in turn and requires the corpus to catch the weakened verifier.
|
||||
|
||||
## 13. Security and privacy considerations
|
||||
|
||||
- **A node's claim is its word.** A false one is evidence against its key.
|
||||
- **A placement is as good as the heads named.** It is the relying party's choice of logs, held
|
||||
under jwp-receipts §7.2 rules 1 to 3 only; a head that is not the head of an origin it anchored
|
||||
(jwp-receipts §7.3) is its own say-so. A revocation the relying party has not retrieved is not in
|
||||
its heads (jwp-receipts §7.4), and the report names each head's origin. Placement never vouches
|
||||
for bytes.
|
||||
- **Evidence instants are the receivers' clocks.** A receiver can sign any time. The tally sets
|
||||
aside times before the group exists, and otherwise reads each instant as the latest it allows. A
|
||||
population of the relying party's own receivers, whose software it ships, is what makes them
|
||||
worth reading.
|
||||
- **A population chosen after the deadline proves nothing.** Its digest proves which keys were
|
||||
counted, not when they were chosen. A relying party that must show the population was fixed first
|
||||
registers a statement naming the digest with a Transparency Service before the deadline
|
||||
([transparency](transparency.md) §3); a registration witnesses existence at a time and attests
|
||||
nothing else (transparency §6). This extension checks no such registration.
|
||||
- **Collusion and sybils** (settlement §13). Only population keys count; a node cannot count its own
|
||||
reconciliations (`self_acknowledged`) or acknowledge itself (settlement §3.6).
|
||||
- **What is disclosed.** Reconciling or acknowledging to a node gives up unidirectional-delivery
|
||||
§2's property for the objects named, as bearer-receipts §12 says. The tally shows each population
|
||||
key's evidence to its verifier. A fresh key per record unlinks a holder and unplaces it.
|
||||
- **Bounded work.** At most 2<sup>20</sup> keys; each acknowledgement and reconciliation is read
|
||||
once; the byte ranges are sorted once per key; a placement is a lookup per head.
|
||||
|
|
@ -301,6 +301,10 @@ pass the schedule names, holds the object by *t*, but for a decoding failure no
|
|||
`failure_one_in` states. It does not say the passes were sent (the declaration's) or heard (a
|
||||
reconciliation's).
|
||||
|
||||
A publisher's own deadline for an object is its timeliness claim's `by_ms`
|
||||
([timeliness](timeliness.md) §4.1); held by it, the statement is the publisher's plan against its
|
||||
own deadline.
|
||||
|
||||
## 7. The passes a receiver listens to
|
||||
|
||||
A receiver that holds a standing schedule's entry, the instant *t* it is at, its loss bound *L*,
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ host that serves the object, an Event-mode report to the content's own service (
|
|||
| `st` | delivery | whether the session plays on demand, live or low-latency live, which a host may serve differently | every report |
|
||||
| `ot` | delivery | the kind of object requested, which a host may schedule or cache by | Request mode |
|
||||
| `d` | delivery | the requested object's playback duration, which a host may prefetch by | Request mode |
|
||||
| `dl` | delivery | the time until the sink's buffer runs out: its buffer level | Request mode |
|
||||
| `dl` | delivery | the time until the object is needed: the sink's buffer level, or, for an object no buffer governs, its deadline ([timeliness](timeliness.md) §8.2) | Request mode |
|
||||
| `science.transaction.wai-ch` | delivery | the exact object, by content hash, which a content-addressed host serves by | Request mode, `e=rr` |
|
||||
| `science.transaction.wai-lc` | delivery | the layer cap the request asks the host to apply: the sink's quality ceiling | Request mode, `wai-layer-change` |
|
||||
| `sid` | measurement | that reports belong to one session; it is fresh per session (§8.1), so nothing across sessions | every report |
|
||||
|
|
@ -216,6 +216,8 @@ told is delivery-only refuses one that carries a key not of the delivery class,
|
|||
- `missing-capability`;
|
||||
- `module-unverified`, `module-revoked`, `trap`, `fuel-exhausted`, `memory-exhausted`,
|
||||
`deadline-threshold`, `unit-hash-mismatch`, `budget-exhausted`;
|
||||
- `not-held-by-deadline`: the primary was not held by its deadline and its fallback was
|
||||
presented ([timeliness](timeliness.md) §5.3, §8.6);
|
||||
- `recovered`.
|
||||
|
||||
A later extension adds its reasons here.
|
||||
|
|
|
|||
|
|
@ -197,7 +197,9 @@ and drops each of its own checks in turn and requires the corpus to catch the we
|
|||
range could hold, and **SHOULD NOT** advertise what it fetched for itself alone. A node **MAY**
|
||||
sign each record with a fresh key, which unlinks its records from each other and from its
|
||||
other statements; a neighbour then cannot tell a node's later record from another node's, and
|
||||
§5.3 does not apply across them.
|
||||
§5.3 does not apply across them. A node whose service is to be placed signs with the key its
|
||||
operator lists ([cache-authority](cache-authority.md) §4.5); a fresh key unlinks its records and
|
||||
leaves its service unplaced.
|
||||
- **A record is its holder's word**, and costs a wasted exchange when it is false (§6). An asker
|
||||
that is sent wrong bytes refuses them and **MAY** stop asking that holder; nothing here scores
|
||||
holders.
|
||||
|
|
|
|||
|
|
@ -534,7 +534,15 @@ states; and step 6, against the receipts it names that the client holds here:
|
|||
one that names an object this index does not list is `context_not_held`;
|
||||
- a `schedule` claim, against the group of an item whose link hash is its subject and the
|
||||
object receipts of the items listed under it here ([carousel](carousel.md) §4.3); an
|
||||
index carries no envelope bytes, so its lengths are not checked here.
|
||||
index carries no envelope bytes, so its lengths are not checked here;
|
||||
- a `timeliness` claim, against the group of an item whose link hash is its subject, the
|
||||
object receipts of the items listed under it here, and a fallback's group and object
|
||||
receipt where an item lists them ([timeliness](timeliness.md) §4.3); an index carries no
|
||||
envelope bytes, so its steps 6 and 8 do not run here;
|
||||
- a `cache-transmission` claim, against the group of an item whose link hash is its subject and
|
||||
the object receipts of the items listed under it here
|
||||
([cache-authority](cache-authority.md) §5.3): one whose set bits name an object this index does
|
||||
not list is `context_not_held`, as a reconciliation's is.
|
||||
|
||||
A claim is accepted only when step 6 ran against what it needs. A `measurement` claim
|
||||
whose figure the client does not hold is `figure_not_held`, as §3.1 states; any other
|
||||
|
|
@ -733,7 +741,9 @@ response never reaches its base's bytes, before or after the base verified. A cl
|
|||
and urgency 5 with incremental for a refinement (RFC 9218 §4.1, §4.2): by the
|
||||
`Priority` header (§5 there), or in a browser by the fetch `priority` option. A
|
||||
deadline never applies to a base: a base request that is never answered ends the fetch
|
||||
(`base_unanswered`).
|
||||
(`base_unanswered`). The one exception is the end a base's timeliness entry states
|
||||
([timeliness](timeliness.md) §8.2), past which the base is of no value: a client **MAY** abandon
|
||||
the request then, and the fetch ends as `base_unanswered`.
|
||||
|
||||
### 10.2 Deadlines
|
||||
|
||||
|
|
|
|||
707
wai/extensions/impression-aggregates.md
Normal file
707
wai/extensions/impression-aggregates.md
Normal file
|
|
@ -0,0 +1,707 @@
|
|||
# WAI Extension: Impression Aggregates
|
||||
|
||||
> Status: Draft. Registers the record `wai.delivery.impressions` and measure 5, `impressions`, of
|
||||
> [telemetry-aggregation](telemetry-aggregation.md) §3 (its §3.1): ad presentations counted over
|
||||
> a bearer with no return path. A receiver judges each ad of a shared pod-input record it
|
||||
> presented by [stitched-presentation](stitched-presentation.md)'s rules, and turns each
|
||||
> presentation into one Prio3Histogram report of a task of that measure, whose `scope` is the
|
||||
> digest of a **catalogue** (`WIC1`, §4) that fixes which index of the vector is which creative,
|
||||
> region and quartile. It generates the report once, keeps its bytes, and sends them over
|
||||
> whatever path it later has, in an **upload file** (`WIU1`, §6.4) that relays forward unchanged.
|
||||
> It accepts a task only from a **task file** (`WIT1`, §5.4) whose signer its pinned key log
|
||||
> lists with the authority `aggregate/task`, and with attestation each report carries a Privacy
|
||||
> Pass token bound to its task and its report id, in a private report extension (§7.2). The
|
||||
> leader holds each collection for the catalogue's `hold_s`, and the aggregators keep the task
|
||||
> until its retention instant, so late reports still count (§7.1). The task layout, the task id,
|
||||
> the aggregators, the statements (`WQS1`) and the receipt (`WQA1`) are telemetry-aggregation's;
|
||||
> a reader verifies a receipt against its catalogue and recomputes a table of presentations and
|
||||
> quartile counts per creative and region (§8): a lower bound of what receivers reported, never
|
||||
> an audience. The reference implementation is the `impression_aggregate` module of `wai-rs`
|
||||
> (feature `impression_aggregate`); the corpus is
|
||||
> [`live-conformance/impressions/`](../live-conformance/impressions/), and its second
|
||||
> implementation is `live-conformance/impression_aggregate_verify.py`.
|
||||
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
|
||||
>
|
||||
> Texts read for this revision: `draft-ietf-ppm-dap-20` (DAP) §2.3, §3.1, §3.6, §4.1, §4.1.1,
|
||||
> §4.2, §4.2.2, §4.2.3, §4.4.1, §4.4.2, §4.4.2.1, §4.4.2.2, §4.4.3, §4.5.3.3, §4.5.3.4, §4.5.4.3,
|
||||
> §4.6.1, §5.1, §6.1.1, §6.3, §6.4.1, §8.1 to §8.4, §8.6.4, §9.2, §9.2.4 and Appendix B.4;
|
||||
> `draft-irtf-cfrg-vdaf-22` (VDAF) §7.4.3.1, §7.4.4, §7.4.5, §9.2, Table 4, Table 13 and §10
|
||||
> Table 19; RFC 9576 §3.3; RFC 9577 §2.1.1, §2.1.1.2 and §2.2.1; RFC 9578 §6.4 and §6.5; and,
|
||||
> informatively, `draft-ietf-privacypass-batched-tokens-10` §2, §3 and §7. ISO 3166-1 and
|
||||
> ISO 3166-2 were not read; the code form is the one [rung-price](rung-price.md) §2 states. A
|
||||
> clause number of one of them in this text is that text's.
|
||||
|
||||
## 1. Scope
|
||||
|
||||
In scope: the catalogue (§4); measure 5's task, its rules and the task file (§5); the receiver's
|
||||
contribution, report, upload file, upload plan and settlement (§6); what the aggregators add to
|
||||
DAP: retention, the hold and the token check (§7); the verification of a receipt of an
|
||||
impression task, and the impression table (§8).
|
||||
|
||||
Out of scope: issuing tokens (RFC 9578, and the batched issuance of
|
||||
`draft-ietf-privacypass-batched-tokens-10`); how a receiver learns its region or sets its clock;
|
||||
running aggregators; choosing a helper's operator (DAP §8.7); differential privacy; prices and
|
||||
invoices; an audience or a reach; and two-way paths, which keep
|
||||
[stream-audit](stream-audit.md) and stitched-presentation.
|
||||
|
||||
Relationship to the core spec: `wai.delivery.impressions` is a **record** ([SPEC](../SPEC.md)
|
||||
§5). It names no payload format and is not dispatchable.
|
||||
|
||||
Relationship to telemetry-aggregation: a task of measure 5 is one of its tasks. This text
|
||||
supplies the measure's configuration and rules (§5.3), its reports (§6.3), what its aggregators
|
||||
add (§7) and the layer a reader puts over its accepted receipts (§8). Every other rule of that
|
||||
text stands.
|
||||
|
||||
## 2. Terms
|
||||
|
||||
- **DAP**, **task**, **report**, **leader**, **helper**, **collector**, **batch**, **batch
|
||||
interval**, **measure**: as [telemetry-aggregation](telemetry-aggregation.md) §2.
|
||||
- **Record**, **claim**, **marker**, **presented**: as stitched-presentation §2.
|
||||
- **Creative digest**: as [ad-presentation](ad-presentation.md) §4. **Token**: as
|
||||
ad-presentation §2.
|
||||
- **Key log**, **anchor**, **pinned**, **confirmed**: as [jwp-receipts](jwp-receipts.md) §7.
|
||||
- **Region code**, **holds**: as rung-price §2.
|
||||
- **Instant**: a count of milliseconds since 1970-01-01T00:00:00Z, leap seconds not counted, as
|
||||
[carousel](carousel.md) §2 and [holdings](holdings.md) §2.
|
||||
- **Receiver**: the sink that presents the ads of a bearer and reports them.
|
||||
- **Presentation**: a claim the receiver's player signs that is presented under §6.2.
|
||||
- **Flight**: a task's `task_interval` (DAP §4.2.3).
|
||||
- **Retention instant**: §5.3 step 3.
|
||||
- **Relay**: any node that carries an upload file toward the leader.
|
||||
|
||||
## 3. What one-way delivery changes
|
||||
|
||||
Telemetry-aggregation §5 (its §5.1) has a sink report when its session ends, over a path it has
|
||||
then, and DAP §6.1.1 says that "Clients are not expected to store any state across any upload
|
||||
flows, nor are they required to implement any sort of report upload retry mechanism". On a
|
||||
bearer with no return path:
|
||||
|
||||
- **Late reports.** A receiver reports hours or days after it presented. DAP discards a report
|
||||
of a batch already collected (§4.4.2.2), and aggregators may delete a task after its
|
||||
`task_interval` (§6.4.1). So the catalogue states a hold; the leader collects no batch before
|
||||
its end plus the hold, and the aggregators keep the task until the retention instant (§7.1);
|
||||
a receiver sends the reports nearest their deadline first (§6.5). A report later than its
|
||||
batch's collection is lost: a count is lower, never higher.
|
||||
- **Duplicates.** A receiver that crashed, kept two copies of its state, or sent by two relays
|
||||
could count one presentation twice. So a report is generated once, stored, and re-sent as the
|
||||
same bytes until it is settled (§6.3, §6.6), and DAP's report-id check discards every later
|
||||
copy. A playback session gives one report for a record and an ad (stitched-presentation §7.3
|
||||
step 7). A report is never regenerated: its id is VDAF §9.2's nonce, and a second id would be
|
||||
a second count.
|
||||
- **Replay.** A captured upload replayed to the same task is discarded by its ids. Replayed to
|
||||
another task, its shares do not decrypt, since their authenticated data holds the task. A
|
||||
token answers one report id of one task (§7.2) and travels sealed.
|
||||
- **A receiver that never returns.** Its presentations are never counted. A table is a lower
|
||||
bound on reported presentations, never an audience, and never the *n* of
|
||||
[bearer-receipts](bearer-receipts.md) §7 (§8.3).
|
||||
- **No path at play time.** No challenge reaches a receiver before an ad plays, so a token
|
||||
cannot be bound to the presentation as ad-presentation §7.2 binds one; it is bound to a report
|
||||
id drawn ahead (§6.1, §7.2). No HPKE configuration can be fetched (DAP §4.4.1), so the task
|
||||
file carries them (§5.4). No task is trusted because it arrived on the bearer, so its signer
|
||||
must be confirmed under a pinned anchor (§5.4; [unidirectional-delivery](unidirectional-delivery.md)
|
||||
§3).
|
||||
|
||||
## 4. The catalogue
|
||||
|
||||
### 4.1 Layout
|
||||
|
||||
A catalogue, `WIC1`, fixes the meaning of a task's vector. Integers are big-endian, except
|
||||
inside `terms`, which is copied byte for byte from a pod-input record and is little-endian there
|
||||
(stitched-presentation §4). A *short* field is a u8 length and that many bytes.
|
||||
|
||||
| size | field | meaning |
|
||||
|---|---|---|
|
||||
| 4 | `magic` | `WIC1` (ASCII) |
|
||||
| 1 | `version` | 1 |
|
||||
| 4 + long | `terms` | the measurement terms exactly as a pod-input record carries them (stitched-presentation §4.1): `presentation` (1), `viewability` (1), `token_type` (u16), `issuer` (a u16 length, then its bytes); ad-presentation §8's fields |
|
||||
| 4 | `hold_s` | u32: the seconds after a batch interval ends before the leader completes a collection of it (§7.1) |
|
||||
| 1 | `s` | the signers' count |
|
||||
| 32 × `s` | `signers` | Ed25519 keys whose shared pod-input records count, strictly ascending |
|
||||
| 1 | `c` | the creatives' count |
|
||||
| 32 × `c` | `creatives` | creative digests (ad-presentation §4), strictly ascending |
|
||||
| 1 | `r` | the named regions' count |
|
||||
| short × `r` | `regions` | region codes, strictly ascending by bytes |
|
||||
| 1 | `k` | the issuer keys' count |
|
||||
| (2 + n) × `k` | `keys` | each a u16 length, then the DER SubjectPublicKeyInfo of a type-0x0002 issuer key (ad-presentation §7.3), strictly ascending by bytes |
|
||||
|
||||
The catalogue's **digest** is SHA-256 over all its bytes. It carries no signature: a task binds
|
||||
it as its `scope` (§5.1), which enters every input share, every aggregate share and the
|
||||
collector-signed receipt, and a task file signs it (§5.4).
|
||||
|
||||
### 4.2 Regions and cells
|
||||
|
||||
- A **code** is a region code of rung-price §2: an ISO 3166-1 alpha-2 code, or an ISO 3166-2
|
||||
code, in capitals.
|
||||
- Column *j*, for 0 ≤ *j* < `r`, is `regions[j]`; column `r` is **`other`**.
|
||||
- A receiver's region *g*, a byte string or none, has the column of the listed code that holds
|
||||
*g* (rung-price §2 *Holds*: the code itself, or the country of which *g* is a subdivision),
|
||||
and otherwise `other`. Rule 6 of §4.3 leaves at most one such code. A *g* that is none, or is
|
||||
not a code, has column `other`. Relations among ISO 3166-2 subdivisions are not followed: no
|
||||
code shows its parent, so a receiver in a subdivision of a listed subdivision is `other`
|
||||
unless its declared code is the listed one. A deployment lists codes at the granularity its
|
||||
receivers declare.
|
||||
|
||||
With creative index *i* (the catalogue's order), column *j* and quartile *q* (1 to 5,
|
||||
stitched-presentation §7.4), the **cell** is (*i* · (`r` + 1) + *j*) · 5 + (*q* − 1), and the
|
||||
vector's length is *L* = `c` · (`r` + 1) · 5. The **chunk length** is ⌈√*L*⌉, the least integer
|
||||
whose square is at least *L*: VDAF §7.4.4 recommends a chunk length "near the square root of
|
||||
length", and §7.4.3.1 says the square root gives proofs "up to 50% larger than the optimal". It
|
||||
fixes the configuration as one value.
|
||||
|
||||
### 4.3 Reading and rules
|
||||
|
||||
A reader reads exactly the given bytes, at most 1,048,576 of them. It refuses them
|
||||
`catalogue_malformed` when they end before a field, go on past the last key, or when:
|
||||
|
||||
1. `magic` is not `WIC1`, or `version` is not 1;
|
||||
2. `terms` breaks ad-presentation §8, does not set bit 0 (**claim**), or sets **attest** with a
|
||||
`token_type` other than 2;
|
||||
3. `hold_s` is 0: a collection at a bucket's end loses every report of a receiver that had no
|
||||
path at that moment;
|
||||
4. `s` is 0 or above 16, or `signers` is not strictly ascending;
|
||||
5. `c` is 0, or `creatives` is not strictly ascending;
|
||||
6. a region is not a code; `regions` is not strictly ascending; or a listed two-byte code *x*
|
||||
has a listed code beginning *x*`-` (rung-price §4.5 rule 3's rule, for one footprint);
|
||||
7. *L* is above 4,096. *L* is a multiple of 5, so the largest is 4,095, and a leader's
|
||||
measurement share is then at most 65,520 bytes (Field128: VDAF Table 4 and Table 13);
|
||||
8. `k` is not 0 when **attest** is clear, or not 1 to 4 when it is set; a key is not an
|
||||
id-RSASSA-PSS SubjectPublicKeyInfo with a 2,048-bit modulus (ad-presentation §7.3); or
|
||||
`keys` is not strictly ascending by bytes.
|
||||
|
||||
Each catalogue has exactly one encoding. The size bound decides no outcome: a catalogue that meets
|
||||
these rules is under 340,000 bytes, its only long fields being the issuer and at most four keys,
|
||||
each of a u16 length, so a longer buffer is refused whatever the bound. It lets a reader refuse
|
||||
one without reading it.
|
||||
|
||||
## 5. The task
|
||||
|
||||
### 5.1 Measure 5 and its scope
|
||||
|
||||
A task of telemetry-aggregation's measure **5, `impressions`**, has that text's `task_info`
|
||||
(telemetry-aggregation §4.1: 153 bytes, label `wai-qoe1`) with `measure` 5 and `scope` the
|
||||
digest of its **catalogue**. Its VDAF is Prio3Histogram (VDAF §7.4.4, identifier 0x00000004 in
|
||||
§10 Table 19) of the catalogue's *L* and chunk length, two aggregators. Its measurement is one
|
||||
presentation's cell (§6.2).
|
||||
|
||||
### 5.2 Task id and verification key
|
||||
|
||||
Its task id is telemetry-aggregation §4.2's, and its verification key §4.4's. Two catalogues give
|
||||
two scopes, two TaskConfigurations and two ids.
|
||||
|
||||
### 5.3 Rules
|
||||
|
||||
Given a catalogue *C* that reads (§4.3), a TaskConfiguration is an impression task of *C* unless,
|
||||
in order:
|
||||
|
||||
1. `task_invalid`: telemetry-aggregation §4.3 rule 1 refuses it;
|
||||
2. `catalogue_mismatch`: its `measure` is not 5, or its `scope` is not *C*'s digest;
|
||||
3. `task_invalid` when its `time_precision` is below 3,600; its extensions are not exactly one
|
||||
`task_interval` (DAP §4.2.3) holding an Interval of a duration of at least 1; or its
|
||||
**retention instant**, in seconds, (`task_interval.start` + `task_interval.duration`) ·
|
||||
`time_precision` + `hold_s`, overflows a u64 or is above the last instant in seconds,
|
||||
253,402,300,799 (carousel §4.2; [timeliness](timeliness.md) §2);
|
||||
4. `measure_mismatch`: `vdaf_type` is not 0x00000004, or `vdaf_configuration` is not
|
||||
Prio3HistogramConfig {`length` *L*, `chunk_length` ⌈√*L*⌉} (DAP Appendix B.4);
|
||||
5. `floor_too_low`: `min_batch_size` is below 50;
|
||||
6. `aggregator_alone`: `leader` is `helper`.
|
||||
|
||||
These are telemetry-aggregation §4.3 for a task of measure 5: its rule 2 cannot fail, since 5 is
|
||||
a code of its §3, and its rule 3 is steps 2 to 4 here. The codes of steps 1 and 3 to 6 are that
|
||||
text's, with its meaning.
|
||||
|
||||
### 5.4 The task file
|
||||
|
||||
A task file, `WIT1`, is how a task reaches receivers. Integers are big-endian.
|
||||
|
||||
| size | field | meaning |
|
||||
|---|---|---|
|
||||
| 4 | `magic` | `WIT1` (ASCII) |
|
||||
| 1 | `version` | 1 |
|
||||
| 2 + n | `task` | a u16 length, then the TaskConfiguration (DAP §4.2) |
|
||||
| 4 + n | `catalogue` | a u32 length, then the catalogue |
|
||||
| n | `leader_hpke` | the leader's HpkeConfigList as DAP §4.4.1 encodes it, its own u16 length first |
|
||||
| n | `helper_hpke` | the helper's |
|
||||
| 32 | `signer` | an Ed25519 key |
|
||||
| 2 + n | `signer_id` | a u16 length, then the signer's identity string (jwp-receipts §7.1), ASCII |
|
||||
| 64 | `sig` | Ed25519 by `signer` over `wai:impression-task` (ASCII), 0x01, then every byte before `sig`, verified strictly (SPEC §7 *Signatures*) |
|
||||
|
||||
The task file's **digest** is SHA-256 over the bytes before `sig`. Its signed bytes include
|
||||
`signer` and `signer_id`, so it is a receipt of another WAI family that jwp-receipts §7.4
|
||||
confirms.
|
||||
|
||||
A receiver holds key-log revisions and anchor records as bearer-receipts §8 (its §8.3) says:
|
||||
provisioned logs pinned, and logs that arrive on the bearer held. It **accepts** a task file
|
||||
unless, in order:
|
||||
|
||||
1. `task_file_malformed`: the file exceeds 1,048,576 bytes, has another magic or version, ends
|
||||
early or has bytes after `sig`, or its `signer_id` is empty or not ASCII; or an HpkeConfigList
|
||||
does not read (DAP §4.4.1: `HpkeConfig HpkeConfigList<10..2^16-1>`), holds two configurations
|
||||
of one `id` (DAP §4.4.1: "Aggregators MUST allocate distinct id values"), or holds none of
|
||||
KEM 0x0020, KDF 0x0001 and AEAD 0x0001 with a 32-byte key (DAP §7; telemetry-aggregation §5);
|
||||
2. `catalogue_malformed`: the catalogue does not read (§4.3);
|
||||
3. the task fails §5.3 against that catalogue, with that step's reason;
|
||||
4. `bad_key`, `bad_signature`: the signature does not verify (SPEC §7);
|
||||
5. the signer is not **confirmed** under jwp-receipts §7.4 with the authority `aggregate/task`,
|
||||
which is pinned-only (jwp-receipts §7.5), the item untimed: the reason of the step that
|
||||
fails, one of jwp-receipts §7.7's (`signer_id_not_origin_bound`,
|
||||
`signer_id_names_another_key`, `origin_not_anchored`, a key-log reason of step 3,
|
||||
`key_not_listed`, `key_mismatch`, `key_revoked`, `authority_not_granted`,
|
||||
`anchor_not_pinned`, `origin_not_expected`). Unconfirmed and refused are both not accepted;
|
||||
the reason says which.
|
||||
|
||||
A file that reaches step 5 has been read, checked and signature-verified; only the trust
|
||||
decision remains. A revocation of the task key in a revision that arrived on the bearer reaches
|
||||
a receiver whose anchor that revision descends from (bearer-receipts §8.2), and refuses the
|
||||
file `key_revoked`. An untimed item passes a retired key (jwp-receipts §7.4 step 5): an origin
|
||||
stops a task key by revoking it.
|
||||
|
||||
## 6. The receiver
|
||||
|
||||
### 6.1 What a receiver holds
|
||||
|
||||
- The task files it accepted, and the key logs it confirmed them under.
|
||||
- Its region and its clock, as its own declared state ([unidirectional-delivery](unidirectional-delivery.md)
|
||||
§2).
|
||||
- For a catalogue that sets **attest**, a **pool** of pairs (report id, token) for the task.
|
||||
Each id comes from a cryptographically secure random number generator (VDAF §9.2; DAP
|
||||
§4.4.2). Each token is a type-0x0002 token for §7.2's TokenChallenge of that id, obtained while
|
||||
the receiver had a path, by RFC 9578 issuance or by batched issuance. A receiver **SHOULD**
|
||||
refill its pool to a fixed size rather than by what it used since the last refill, so issuance
|
||||
shows the issuer a rate and not a count of presentations.
|
||||
|
||||
Evaluating anything here **MUST NOT** cause a request (unidirectional-delivery §2). A receiver
|
||||
sends only §6.4's upload files, and token requests, on a path it already has.
|
||||
|
||||
### 6.2 Contribution
|
||||
|
||||
**Inputs:** an accepted task file; the pod-input record, by its bytes; the manifests the player
|
||||
read; the creatives it holds; `claims`, the claims of this playback session already judged and
|
||||
then the one judged now, each signed by the session key (stitched-presentation §5.2); `region`,
|
||||
bytes or none; `at_ms`, the instant the presentation ended by the receiver's clock, or none when
|
||||
the clock is not set; and `pool`, the unused pairs of an attested catalogue.
|
||||
|
||||
**Output:** **report**, with the cell, the creative index, the column, the quartile, the time
|
||||
⌊`at_ms` / (1000 · `time_precision`)⌋, the report id (the chosen pair's, or a fresh one without
|
||||
attestation) and the pool's ids discarded; or **no report**, with the reason of the first step
|
||||
that fails, and the ids discarded:
|
||||
|
||||
1. The record, under stitched-presentation §7.2 steps 1 to 3 with the catalogue's `signers`
|
||||
as the keys it accepts. Its response is not checked, as that section's step 5 allows, and no
|
||||
price is used. Reasons: `pod_input_malformed`, `bad_key`, `bad_signature`,
|
||||
`pod_signer_unknown`.
|
||||
2. `record_not_shared`: the record's `session` is not 16 zero bytes. A record of one session
|
||||
needs the session key to reach its signer (stitched-presentation §4.6), which a receiver with
|
||||
no path cannot do.
|
||||
3. `record_terms_differ`: the record's measurement-term bytes are not the catalogue's `terms`.
|
||||
Every count of a table means one rule.
|
||||
4. The claim judged now, under stitched-presentation §7.3 **steps 1 to 10**, over `claims` in
|
||||
order, against that record: its reason (`stitched_claim_malformed`, `bad_key`,
|
||||
`bad_signature`, `claim_key_unbound`, `claim_no_decision`, `claim_manifest_unknown`,
|
||||
`claim_marker_absent`, `claim_duplicate`, `claim_asset_mismatch`, `claim_frame_mismatch`,
|
||||
`claim_not_viewable`). Step 11 is not run: with **attest**, the report's token takes its
|
||||
place (§7.2). `claim_duplicate` here is one report for a record, an ad and a session.
|
||||
5. `creative_not_listed`: the ad's creative digest is not among `creatives`.
|
||||
6. `instant_unknown`: `at_ms` is none. The upload time is never used in its place.
|
||||
7. `outside_task`: the time is not within `task_interval`, which is half-open (DAP §4.1.1).
|
||||
8. `token_exhausted`: the catalogue sets **attest** and no pair of `pool` passes §7.2 for this
|
||||
task. Pairs are tried in ascending order of report id; a pair whose token fails is discarded
|
||||
and listed.
|
||||
|
||||
Otherwise the quartile *q* is stitched-presentation §7.4's for the claim, the column is §4.2's
|
||||
for `region`, and the cell is §4.2's.
|
||||
|
||||
### 6.3 The report
|
||||
|
||||
For a contribution that gives a report, the receiver generates **one** report as soon as the
|
||||
presentation ends:
|
||||
|
||||
- **Report id**: the chosen pair's with **attest**; otherwise 16 bytes from a cryptographically
|
||||
secure random number generator.
|
||||
- **Time**: the contribution's.
|
||||
- **Measurement**: the one-hot vector of length *L* at the cell.
|
||||
- **Sharding and sealing**: as DAP §4.4.2.1 and telemetry-aggregation §5 (its §5.1), with the
|
||||
application context `dap-20` followed by the task id; each input share sealed to the
|
||||
**first** configuration of its aggregator's list in the task file with KEM 0x0020, KDF
|
||||
0x0001 and AEAD 0x0001, since DAP §4.4.1 lists configurations "in decreasing order of
|
||||
preference".
|
||||
- **Extensions**: no public extension. With **attest**, the private extension
|
||||
`wai_impression_token` (§7.2) in **both** input shares, its data the pair's token in
|
||||
ad-presentation §7.1's form (354 bytes). Without it, no private extension.
|
||||
- **Persistence**: the receiver stores the report's bytes under the presentation's key (the
|
||||
record's digest, the ad's slot and the session), atomically, before anything is sent. It
|
||||
**MUST NOT** generate a second report for a presentation that has a stored one, and **MUST
|
||||
NOT** regenerate a report. VDAF §9.2: the nonce must come from a cryptographically secure
|
||||
random number generator, and "Uniqueness of the nonce is not sufficient"; a regenerated report
|
||||
has a new id while an earlier copy may still reach the leader by another path.
|
||||
|
||||
This departs from DAP §6.1.1 in what it expects of a client, not in anything DAP forbids. DAP
|
||||
§4.4.2.2's "When the Client receives an hpke_unknown_config_id error, it SHOULD invalidate any
|
||||
cached HpkeConfigList and retry with a freshly generated Report" is not followed: that is the
|
||||
regeneration this section forbids (§6.6).
|
||||
|
||||
### 6.4 The upload file
|
||||
|
||||
| size | field | meaning |
|
||||
|---|---|---|
|
||||
| 4 | `magic` | `WIU1` (ASCII) |
|
||||
| 1 | `version` | 1 |
|
||||
| 32 | `task_id` | the task the reports were made for |
|
||||
| 4 + n | `upload` | a u32 length, then a DAP UploadRequest: one or more Reports (DAP §4.4.2), in strictly ascending order of report id |
|
||||
|
||||
A reader reads exactly the given bytes, at most 16,777,216 of them, and refuses them
|
||||
`upload_malformed` when they end early or go on past `upload`, the magic or the version is
|
||||
wrong, `upload` holds no report, a report does not read as a DAP Report, or the ids are not
|
||||
strictly ascending.
|
||||
|
||||
- The file is unsigned: DAP reports are anonymous, and each input share's authenticated data
|
||||
binds its task.
|
||||
- It travels as bearer-receipts §9 (its §9.1) sends a reconciliation: when the receiver has a
|
||||
path; and where a bearer's own reporting carries files or opaque payloads, it **MAY** travel
|
||||
there, for a gateway to forward.
|
||||
- A relay forwards `upload` unchanged as the body of a POST to `{leader}/tasks/{task-id}/reports`
|
||||
(DAP §4.4.2), the leader being the `leader_aggregator_endpoint` of the TaskConfiguration of the
|
||||
task file of `task_id`, which the relay holds: the task file rode the bearer. It **MAY** do so
|
||||
over Oblivious HTTP (RFC 9458; DAP §8.4).
|
||||
- A relay **MAY** merge files of one `task_id` into one, ascending by id. Two reports of one id
|
||||
with equal bytes are one; with different bytes, each is forwarded in a separate file and the
|
||||
leader keeps the first. A relay that merged gives each source file the entries of the response
|
||||
whose ids are its own, in their order. Merging hides from the leader which reports came from
|
||||
one receiver.
|
||||
|
||||
### 6.5 The upload plan
|
||||
|
||||
**Inputs:** an accepted task file, the stored reports (bytes), an instant *t* (ms) and a budget
|
||||
*b* (bytes, or none for no limit).
|
||||
|
||||
For a report of time τ, in this order: it is **dropped** when τ is outside `task_interval`, or
|
||||
when *t* is at or past the retention instant · 1000, since the aggregators may then have deleted
|
||||
the task (DAP §6.4.1). Otherwise its **deadline** is ((τ + 1) · `time_precision` + `hold_s`) ·
|
||||
1000, which cannot overflow since τ is within the flight: the earliest instant the hold allows
|
||||
any batch holding τ to be collected. It is **timely** when *t* is before its deadline, and
|
||||
**late** otherwise; a late report may still count.
|
||||
|
||||
**Order:** timely before late; within each, ascending deadline, then ascending id. **Budget:**
|
||||
reports are taken in that order while 41 plus the sum of their lengths stays within *b*; the first
|
||||
that does not fit ends the taking. One task's reports are all of one length (one *L*, one set of
|
||||
extensions), so nothing smaller follows.
|
||||
|
||||
**Outputs:** `send` (ids in order, each with its class), `defer` (ids), `drop` (ids), and `file`:
|
||||
the `WIU1` of `send` in ascending order of id, or none when `send` is empty.
|
||||
|
||||
### 6.6 Settlement
|
||||
|
||||
**Inputs:** a `WIU1` the receiver sent, and the response body.
|
||||
|
||||
- The response reads as DAP's UploadErrors (DAP §4.4.2.2: ReportUploadStatus entries of a
|
||||
16-byte id and a 1-byte error, to the message's end, "in the same order as in the request")
|
||||
whose ids are a subsequence of the file's. Otherwise it **settles nothing**, and every report
|
||||
is kept.
|
||||
- A report with no entry was accepted: **delete** it.
|
||||
- `report_too_early` (8): **keep** it; DAP §4.4.2.2: "the Client MAY re-upload the report later
|
||||
on".
|
||||
- Any other report error of DAP §4.1 (1 to 7, 9 and 10): **delete** it. That includes
|
||||
`report_replayed` (2), which a leader **MAY** set for a report of a batch already collected
|
||||
(DAP §4.4.2.2), and `hpke_unknown_config_id` (4), whose retry would be a regeneration: the
|
||||
report is lost, an undercount.
|
||||
- A value DAP §4.1 does not define: **keep** it. A re-send never counts twice (the report id), so
|
||||
keeping costs bandwidth only, and an unknown value never reads as final.
|
||||
|
||||
An upload with no response keeps every report. A pending report is dropped by the plan at the
|
||||
retention instant. **Outputs:** `delete` and `keep`, each a list of ids.
|
||||
|
||||
## 7. The aggregators
|
||||
|
||||
The aggregators and the collector run telemetry-aggregation §5, §6 and §7 as written for a task
|
||||
of measure 5, with these additions.
|
||||
|
||||
### 7.1 Retention and the hold
|
||||
|
||||
**Retention.** An aggregator keeps the task, its batch buckets, its replay state and the HPKE
|
||||
configurations the task file names until the task's retention instant (§5.3), and accepts
|
||||
reports sealed to those configurations until then. DAP §6.4.1 lets it delete a task's data after
|
||||
its `task_interval`, and §4.4.1 asks it to "continue to accept reports with old keys"; this sets
|
||||
both bounds for a client that holds no newer configuration.
|
||||
|
||||
**The hold.** The leader **MUST NOT** finish a collection job until its clock is at or past its
|
||||
batch interval's end in seconds, (`start` + `duration`) · `time_precision`, plus `hold_s`; until
|
||||
then the job is processing (DAP §3.1, §4.6.1).
|
||||
|
||||
- A report uploaded after its batch is collected is discarded: the leader **MAY** mark it
|
||||
`report_replayed` at upload (DAP §4.4.2.2), and one that reaches aggregation is
|
||||
`batch_collected` (§4.5.4.3). It is lost.
|
||||
- An early collection is the collector's undercount. No reader can see when a collection was
|
||||
made, and nothing here claims otherwise.
|
||||
|
||||
### 7.2 Report extensions and the token
|
||||
|
||||
At DAP §4.5.3.4, after its input share decrypts, an aggregator refuses a report:
|
||||
|
||||
- with any **public** extension, `invalid_message`;
|
||||
- of a catalogue without **attest**, with any private extension, `invalid_message` (a type the
|
||||
task does not use: §4.5.3.4 step 4);
|
||||
- of a catalogue with **attest**, unless its private extensions are exactly one
|
||||
`wai_impression_token` (0x5749), recorded as `attestation_absent` (`invalid_message` on the
|
||||
wire); and unless its data reads as a token of type 0x0002 (ad-presentation §7.1), has a
|
||||
`challenge_digest` that is the SHA-256 of the TokenChallenge {0x0002, the catalogue's issuer,
|
||||
the redemption context *R*, an empty origin_info} in ad-presentation §7.2's form, with *R* the
|
||||
SHA-256 of `wai:impression-report-attest` (ASCII), 0x01, the task id and the report id; has a
|
||||
`token_key_id` that is the SHA-256 of one of the catalogue's `keys`; and verifies under that
|
||||
key (ad-presentation §7.3: RSASSA-PSS with SHA-384 and a 48-byte salt), recorded as
|
||||
`attestation_invalid` (`invalid_message` on the wire).
|
||||
|
||||
Each aggregator checks its own input share, so a token in one share alone is refused by the
|
||||
other. `attestation_absent` and `attestation_invalid` are ad-presentation §11's codes. Double
|
||||
spending is the report-id replay check (DAP §2.3): RFC 9577 §2.1.1.2 says that for a non-empty
|
||||
redemption context the double-spend state "only needs to be stored across the set of Origin
|
||||
servers that will accept tokens with that redemption context", and *R* names one task and one
|
||||
report.
|
||||
|
||||
**The codepoint.** DAP §9.2.4 sets aside no range for unregistered use, and its registries are
|
||||
Specification Required (DAP §9.2). `wai_impression_token`, 0x5749, is proposed, with this text as
|
||||
its specification, and is not registered. An aggregator that does not know it refuses every
|
||||
attested report (DAP §4.5.3.4 step 4): no count, never a false one.
|
||||
|
||||
## 8. Verification
|
||||
|
||||
### 8.1 A receipt of an impression task
|
||||
|
||||
**Inputs:** the receipt, the catalogue, and optionally the batch's reports (each report's id,
|
||||
time and task).
|
||||
|
||||
**Steps**, stopping at the first refusal, which is stated with its reason and place: the
|
||||
catalogue reads (§4.3; `catalogue_malformed`, at `catalogue`). Then telemetry-aggregation §8.1
|
||||
steps 1 to 11 as written, its step 2 being §5.3 here against the catalogue. Then:
|
||||
|
||||
12. at `leader`: the statements' batch falls within the `task_interval` (DAP §4.1.1), otherwise
|
||||
`batch_invalid` (telemetry-aggregation's code: a batch the task does not admit);
|
||||
13. at `result`: each element of the result is at most the report count, and the elements' sum,
|
||||
as integers, is the report count, otherwise `count_mismatch`. With *L* at most 4,095 and each
|
||||
element at most a u64 count, the sum fits in a u128.
|
||||
|
||||
**Outcome:** accepted, stating telemetry-aggregation §8.2's fields (measure `impressions`, scope
|
||||
the catalogue's digest) and **`reports_checked`**, whether step 11 ran; or the first refusal.
|
||||
|
||||
### 8.2 The table
|
||||
|
||||
**Inputs:** the catalogue, the TaskConfiguration, and receipts in an order, each with its reports
|
||||
where they are given.
|
||||
|
||||
1. The catalogue reads (`catalogue_malformed`) and the task meets §5.3 against it; otherwise the
|
||||
**table** is refused with that reason.
|
||||
2. Each receipt, in order, is verified by §8.1, and its `task` must then be the given
|
||||
TaskConfiguration byte for byte (`task_mismatch` at `task`, telemetry-aggregation's code). A
|
||||
refused receipt is stated with its reason and place and takes no further part.
|
||||
3. Of the accepted receipts, those with equal digests are one. Two whose batch intervals share a
|
||||
time unit are **both** set aside, `receipts_overlap`: DAP collects a bucket once (DAP §2.3;
|
||||
`batchOverlap`), so an honest pair cannot exist, and a reader holds no ground to choose
|
||||
between them (carousel §6.2 step 4, holdings §5.3). With the receipts sorted by batch
|
||||
start, a receipt overlaps an earlier one when the greatest end before it is past its start,
|
||||
and a later one when the next start is before its end.
|
||||
4. Each receipt left **stands**.
|
||||
|
||||
**Outcome:**
|
||||
|
||||
- for each receipt, in order: its digest where it reads, and its verdict (`stands`,
|
||||
`set-aside reason=…` or `refuse reason=… at=…`); for a standing one, its batch (`start` and
|
||||
`duration` in `time_precision` units, `from_ms` and `to_ms`), `collectable_from_ms` =
|
||||
((`start` + `duration`) · `time_precision` + `hold_s`) · 1000, the earliest instant the hold
|
||||
allows its collection, `report_count`, `reports_checked`, and its **rows**;
|
||||
- **totals**: the rows summed over the standing receipts, each counted once, with the list of
|
||||
their batch intervals and whether every one's reports were checked. A receipt given more than
|
||||
once is checked when any of its occurrences was: they are the same bytes, and §8.1 accepted the
|
||||
one given with its reports, so the order in which they arrive changes nothing. Each total is the
|
||||
exact integer sum. The standing batches share no time unit, so there are fewer than 2^27 of
|
||||
them (§5.3 bounds the retention instant), and each element is at most a u64 count (§8.1 step
|
||||
13): a total is below 2^91 and fits in a u128, and a reader **MUST NOT** wrap or saturate it at
|
||||
2^64. When none stands, the totals are empty: no row, never a row of zeros.
|
||||
|
||||
A **row** is a (creative index, column) pair, for every creative and column in the catalogue's
|
||||
order, `other` last. It states `presentations`, the sum of its five cells; `quartiles`, five
|
||||
counts, those at quartile at least 1, 2, 3, 4 and 5, as stitched-presentation §7.4 states them;
|
||||
and `floor_met`, whether `presentations` is at least 50. Every row is stated: a 0 is a counted
|
||||
zero of a collected batch, never an unknown.
|
||||
|
||||
### 8.3 What a table states
|
||||
|
||||
- An accepted receipt means that two keys signed shares whose sum is the result, over the
|
||||
reports of the batch the task's aggregators named (telemetry-aggregation §8.2), with
|
||||
`reports_checked` saying whether the reports' ids, times and checksum were held to it.
|
||||
- A row counts the presentations that receivers reported and that reached the leader before the
|
||||
collection: a **lower bound**. It does not count receivers, people or sessions; it is not an
|
||||
audience or a reach ([opportunity-report](opportunity-report.md) §10.3: "no record of this
|
||||
text states an audience"); and it is never the *n* of bearer-receipts §7, where a larger *n*
|
||||
lowers the bound per receiver and would read an unknown audience as the optimum.
|
||||
- The time resolution is the batch, never finer than `time_precision`. A sparse hour is
|
||||
collected inside a wider batch (DAP §6.3).
|
||||
- A party that publishes a table beyond the holders of its receipts withholds every row whose
|
||||
`floor_met` is false (delivery-telemetry §8, its §8.4: "suppressed, not rounded") and
|
||||
publishes no figure that is a sum over rows that include a withheld one: a total row, a
|
||||
receipt's `report_count`, or a sum over creatives or regions.
|
||||
|
||||
## 9. Class, money and composition
|
||||
|
||||
- **A class of its own.** A table's counts are the class **aggregated**: never summed into
|
||||
[revenue-share](revenue-share.md) §5.2's presented, beacons or unplaced, and never billable
|
||||
under [receipt-ledger](receipt-ledger.md) §4.1's `presented_impressions` or
|
||||
`billable_impressions` until a revision of the ledger names a unit for them. This is
|
||||
[settlement](settlement.md) §4.2's rule for its unacknowledged class: "never billable as
|
||||
acknowledged". A price list of rung-price bills delivery by the units of its §4.3 and takes no
|
||||
impression.
|
||||
- **Never both.** A receiver whose claim of a presentation leaves the device (stitched-presentation,
|
||||
stream-audit §6.3) makes no report of it, and one that reports it sends no claim of it.
|
||||
- **No energy.** A table states no energy. A transmission's energy is bearer-receipts §7's, and
|
||||
its bound never divides by a table count.
|
||||
- **carousel.** The task file, the key-log revisions, the pod-input records, the manifests and
|
||||
the creatives' objects ride as objects of groups on a carousel. A publisher **SHOULD** schedule
|
||||
the task file's group so that carousel §6.4's "the group is held by *t*" holds with *t* at
|
||||
least the time its receivers take to fill a token pool before the flight starts, and the
|
||||
creatives' and records' groups by the flight's start. That is the checkable promise that
|
||||
receivers within a loss bound can report attested presentations from the first hour. Nothing
|
||||
in a count depends on the schedule.
|
||||
- **timeliness.** A creative's deadline to be on a receiver before its break is a timeliness
|
||||
entry of the creative's object, with `by_ms` no later than the splice (timeliness §8.5). A
|
||||
publisher **MAY** also state one for the task file's object, its `by_ms` the flight's start
|
||||
less the time its receivers take to fill a pool, so that carousel §6.4 at that `by_ms` is the
|
||||
promise of the bullet above. A count depends on neither.
|
||||
- **holdings.** A receiver that missed the task file or a creative fetches it from a neighbour
|
||||
whose holdings record lists its group (holdings §5.4) and verifies it as any object (holdings
|
||||
§6). The task file still needs its signer confirmed (§5.4). A neighbour with a path back is the
|
||||
natural relay.
|
||||
- **cache-authority.** A peer that served a creative is not a party to its count, and a tally of
|
||||
the keys that held a creative by its deadline ([cache-authority](cache-authority.md) §8) counts
|
||||
holding, never presentation: the two are never summed. Both write regions in rung-price §2's
|
||||
codes.
|
||||
- **bearer-receipts.** An upload file travels as a reconciliation does (§6.4). A reconciliation
|
||||
attests receipt and a report counts a presentation: never the same fact, never merged. Key logs
|
||||
on the bearer are bearer-receipts §8 (its §8.2): held, anchoring nothing, carrying revocations.
|
||||
- **unidirectional-delivery.** The contribution is a sink-evaluated, total, deterministic
|
||||
function (unidirectional-delivery §2), and the task authority is anchored out of band (§3
|
||||
there).
|
||||
- **age-assurance.** [age-assurance](age-assurance.md) §6 (its §6.1 to §6.3) holds device-held
|
||||
type-0x0002 tokens on a one-way bearer in the same way; here the challenge is per report, not
|
||||
per device.
|
||||
- **live-rooms.** A binary record whose signer must hold a named authority in an origin's key
|
||||
log is [live-rooms](live-rooms.md) §5's `live/room` and [capture-binding](capture-binding.md)
|
||||
§6 (its §6.4) `capture/store`; `aggregate/task` is a third.
|
||||
|
||||
## 10. Reasons
|
||||
|
||||
The codes this extension adds, each with where it is raised:
|
||||
|
||||
| reason | raised at |
|
||||
|---|---|
|
||||
| `catalogue_malformed` | §4.3, §5.4 step 2, §8.1, §8.2 step 1 |
|
||||
| `catalogue_mismatch` | §5.3 step 2 |
|
||||
| `task_file_malformed` | §5.4 step 1 |
|
||||
| `record_not_shared` | §6.2 step 2 |
|
||||
| `record_terms_differ` | §6.2 step 3 |
|
||||
| `creative_not_listed` | §6.2 step 5 |
|
||||
| `instant_unknown` | §6.2 step 6 |
|
||||
| `outside_task` | §6.2 step 7 |
|
||||
| `token_exhausted` | §6.2 step 8 |
|
||||
| `upload_malformed` | §6.4 |
|
||||
| `receipts_overlap` | §8.2 step 3 |
|
||||
|
||||
This extension also raises: telemetry-aggregation §11's codes, `task_invalid`,
|
||||
`measure_mismatch`, `floor_too_low` and `aggregator_alone` at §5.3, every code of its §8.1 at
|
||||
§8.1, `batch_invalid` also at §8.1 step 12, and `task_mismatch` also at §8.2 step 2;
|
||||
age-assurance §7.3's `count_mismatch` (a receipt's counts are not the counts they must be) at
|
||||
§8.1 step 13; `truncated`, `malformed`, `unknown_version`, `bad_key` and `bad_signature`;
|
||||
jwp-receipts §7.7's confirmation reasons at §5.4 step 5; stitched-presentation §9's codes and the
|
||||
ad-presentation codes it reuses, at §6.2 steps 1 and 4; ad-presentation's `attestation_absent`
|
||||
and `attestation_invalid` at §7.2; and DAP's report errors (DAP §4.1) and problem types (DAP
|
||||
§3.6), by DAP's names, at §6.6 and §7.
|
||||
|
||||
## 11. Conformance
|
||||
|
||||
[`live-conformance/impressions/`](../live-conformance/impressions/) holds the corpus, written
|
||||
from fixed seeds by `wai_impression_aggregate_vectors gen` as `vectors.json`. Its regions are
|
||||
ISO 3166-1 user-assigned codes and their subdivisions, its origins reserved example domains, and
|
||||
its issuer key RFC 9578's published test key. It has:
|
||||
|
||||
- **Catalogues**: the flight's and the open one's, the bounds read at their edge (cells, regions,
|
||||
signers, keys, the hold), and a control for each rule of §4.3 and each way the layout breaks.
|
||||
- **Tasks**: accepted ones, with the retention instant at the last instant exactly, and a control
|
||||
for each step of §5.3, an overflowing retention instant among them.
|
||||
- **Task files**: accepted ones, one whose lists name another suite first; a control for each
|
||||
way a file is malformed; and the confirmation's refusals under the key logs held: a signer of
|
||||
an unanchored origin, a key with `delivery` alone, a fetched anchor, a key revoked by a
|
||||
revision received on the bearer, and the same key accepted before that revision is held.
|
||||
- **Contributions**: each quartile at its mark, both carriages, every form of region, the hour,
|
||||
flight and deadline edges, a pool whose least pair fails, a catalogue without attestation, and
|
||||
a control for each step of §6.2.
|
||||
- **Flows**, end to end, in telemetry-aggregation's flow form with the uploads and token
|
||||
outcomes: an attested flight whose reports go through two overlapping relays, directly and
|
||||
through a relay that merges two receivers' files; each kind of report §7.2 and DAP refuse; a
|
||||
collection held, then completed; a batch below the floor refused by both aggregators; and a
|
||||
report uploaded after its batch's collection. A flight without attestation, the flight's
|
||||
catalogue under another task, and a batch collected past the task's interval.
|
||||
- **Uploads, plans and settlements**: files that read and a control for each rule of §6.4;
|
||||
plans at each edge of the deadline, the retention instant and the budget; settlement of every
|
||||
DAP report error, a value DAP does not define, and responses that settle nothing.
|
||||
- **Receipts** (§8.1) accepted with and without their reports, and refused at each step this text
|
||||
adds and at the telemetry steps a receipt of another catalogue or task meets; **tables** (§8.2)
|
||||
with a receipt given twice, a receipt given with and without its reports in both orders,
|
||||
overlapping receipts, receipts of another task and catalogue, and the table's own refusals.
|
||||
|
||||
A conforming verifier gives every case the outcome, the reason and everything else the corpus
|
||||
states. `wai_impression_aggregate_vectors verify` and the test `tests/impression_aggregate.rs`
|
||||
check that the file regenerates byte for byte, that `wai-rs` gives every case its outcome, that
|
||||
every reason of §10 appears, and that §10 lists exactly the reference's codes. The test also
|
||||
checks, without storing it, that each standing receipt's result is the plaintext tally of the
|
||||
cells of the contributions its reports came from, and that stitched-presentation's own
|
||||
verification of the flight's claims, step 11 left out, gives per creative the presentations and
|
||||
quartile counts the flight's table gives summed over its columns. The size bounds of §5.4 and
|
||||
§6.4 are checked in code, since no vector carries them: a file of exactly the bound that reads in
|
||||
every other respect is read, a task file then reaching step 2, and the same file one byte longer
|
||||
is refused at step 1. §4.3's bound decides no outcome (§4.3), so no check can observe it.
|
||||
`live-conformance/impression_aggregate_verify.py` is the second implementation, written from this
|
||||
text: it shares no code with `wai-rs`, evaluates Prio3 and opens every report with the QoE
|
||||
corpus's second implementation, judges records and claims with stitched-presentation's, verifies
|
||||
tokens with ad-presentation's, confirms task-file signers with the signer corpus's, writes the
|
||||
catalogue, the task file, the upload file, the contribution, the plan, the settlement and the
|
||||
table itself, requires every mutated copy of the corpus to fail, and drops each of its own checks
|
||||
in turn.
|
||||
|
||||
## 12. Security and privacy considerations
|
||||
|
||||
- **What the source learns.** Reporting gives up part of unidirectional-delivery §2's "the
|
||||
source learns nothing": the collector learns batch sums over at least 50 presentations; each
|
||||
aggregator learns each report's id, time bucket, length, task, validity and arrival; relays
|
||||
learn the same, less validity; the issuer learns when a device refilled its pool.
|
||||
- **Non-collusion** (telemetry-aggregation §13). The receipt names three keys and checks that the
|
||||
aggregators' differ. Whether two operators pool their shares is in no byte.
|
||||
- **Who may define a task.** Only a key that an origin's pinned key log lists with
|
||||
`aggregate/task`. A task decides which two aggregators receive every receiver's shares, so a
|
||||
fetched anchor, authority taken on first use, confirms none. A key-log revision on the bearer
|
||||
revokes a task key on receivers with no path (bearer-receipts §8.2). A key compromised and not
|
||||
yet revoked can name colluding aggregators for new tasks: the bound is the origin's
|
||||
revocation.
|
||||
- **Sybil.** Without **attest**, anyone holding a task file submits reports: the count is of
|
||||
reports, not of presentations. A deployment that bills **SHOULD** set attest, as
|
||||
stitched-presentation §4 (its §4.6) says of shared breaks. With attest, each counted report costs
|
||||
a token issued to a device its attester vouched for, and the bound per device is the issuer's
|
||||
issuance policy. A farm of genuine devices attests truthfully (ad-presentation §13).
|
||||
- **The local verdict is the receiver's word** (unidirectional-delivery §2: determinism, not
|
||||
enforcement). A modified receiver reports any cell in range; the range proof stops only values
|
||||
out of range (DAP §2.2; telemetry-aggregation §13).
|
||||
- **The aggregators' word.** `count_mismatch` catches a share that changes the integer sum or
|
||||
wraps an element. It does not catch a share that moves counts between two cells that both stay
|
||||
in range. A leader that holds a report's token (its private extension) can drop that report and
|
||||
submit one of its own making under the same id and token, moving that presentation to another
|
||||
cell. Neither adds a presentation. A correct result needs both aggregators honest (DAP §8;
|
||||
telemetry-aggregation §8.2).
|
||||
- **Small cells.** The batch floor of each aggregator, regions no finer than ISO 3166-2 and
|
||||
buckets of at least an hour bound what a cell reveals. A collector that inserts reports it
|
||||
knows shrinks a batch's effective set (DAP §8.1); attestation raises that cost. Differential
|
||||
privacy (DAP §8.5) is not used, because these counts are billed, and noise in a billed figure is
|
||||
not deterministic.
|
||||
- **Linking.** Reports of one receiver uploaded together can be linked by their arrival, though
|
||||
not by their values; relays that merge files, and Oblivious HTTP (DAP §8.4), break that. Tokens
|
||||
are unlinkable to their issuance (RFC 9576 §3.3); a refill is a timing fact the issuer sees.
|
||||
- **Lateness.** A receiver that never returns is never counted. The direction is always under.
|
||||
- **No consent rule** (telemetry-aggregation §10). Where a regime asks, a `lawful-basis` claim
|
||||
(jwp-receipts §2.4.6) states the basis.
|
||||
- **Bounded work.** A catalogue is at most 1 MiB, a task file 1 MiB and an upload file 16 MiB,
|
||||
each read once. A contribution is one stitched verdict and lookups; a task file's confirmation
|
||||
is jwp-receipts §7.4's; a table is linear in receipts times *L*, plus a sort of the receipts.
|
||||
|
|
@ -41,9 +41,10 @@ In scope:
|
|||
`claim` receipt (§2.4.3) carries one of the others — `energy`,
|
||||
`lawful-basis`, `log-inclusion`, `measurement`, `operating-point`,
|
||||
`stage`, `delivery-gap`, `reconstruct-session`, `consumption`,
|
||||
`transmission`, `reconciliation`, `schedule` (§2.4.5–§2.4.16) — on one
|
||||
envelope, one canonical form and one signature rule, so a statement other
|
||||
than delivery does not need a field set of its own.
|
||||
`transmission`, `reconciliation`, `schedule`, `timeliness`,
|
||||
`cache-transmission` (§2.4.5–§2.4.18) — on one envelope, one canonical form and one signature
|
||||
rule, so a statement other than delivery does not need a field set of its
|
||||
own.
|
||||
- **Objects not delivered** (§3, §4.4): an object a subscriber did not
|
||||
receive is accounted for by its receipt, checked without the object's bytes
|
||||
and reported as `inclusion-verified`, never as delivered; a relay forwards
|
||||
|
|
@ -341,7 +342,7 @@ nullable member is present and `null`, so one claim has one canonical form.
|
|||
| `subject` | 32 B hex | For `object`: the §2.1 content hash of the object the claim is about. For `receipt`: the link hash of a receipt — §3 step 3's for a group receipt, `link_hash` below for a claim — or, for a class that names a figure profile (§2.4.8–§2.4.9), the identity that profile defines ([energy-measurement](energy-measurement.md) §6.1). |
|
||||
| `seq` | integer | Strictly increasing across the claims of one `signer_pubkey`. |
|
||||
| `issued_at` | string | A date-time (below) at which the signer issued the claim. Self-asserted, like `group_end`. |
|
||||
| `body` | object | Class-scoped; its members are fixed by `class` and by nothing else (§2.4.5–§2.4.12, §2.6). |
|
||||
| `body` | object | Class-scoped; its members are fixed by `class` and by nothing else (§2.4.5–§2.4.18, §2.6). |
|
||||
| `signer_pubkey` | 32 B hex | Ed25519 verifying key per RFC 8032. |
|
||||
| `signer_id` | string | Bound identity string, as for a group receipt (§2.4.1, §7.1). |
|
||||
| `parent_receipt_hash` | 32 B hex or `null` | `link_hash` of this signer's previous claim; `null` for its first. |
|
||||
|
|
@ -418,6 +419,8 @@ key.
|
|||
| `transmission` | `claim` | `receipt` (the group transmitted) | §2.4.14 | the group's publisher: the key that signed the group |
|
||||
| `reconciliation` | `claim` | `receipt` (the group reconciled) | §2.4.15 | the sink that received the objects, never the publisher |
|
||||
| `schedule` | `claim` | `receipt` (the group scheduled) | §2.4.16 | the group's publisher: the key that signed the group |
|
||||
| `timeliness` | `claim` | `receipt` (the group whose objects it times) | §2.4.17 | the group's publisher: the key that signed the group |
|
||||
| `cache-transmission` | `claim` | `receipt` (the group sent again) | §2.4.18 | a node that sent the objects from what it held, never the group's publisher |
|
||||
|
||||
Rules:
|
||||
|
||||
|
|
@ -1383,6 +1386,31 @@ unchanged. [carousel](carousel.md) defines it: its body's members are §4.1 ther
|
|||
group receipt, whose link hash is its `subject` and whose key signs it, and the object receipts
|
||||
and envelopes at the positions it names.
|
||||
|
||||
#### 2.4.17. `class: "timeliness"`
|
||||
|
||||
A publisher's statement of when each object of a group is of use: the instant it is useful from,
|
||||
the deadline by which a receiver must hold it for its full value, how that value falls after the
|
||||
deadline, and the rendition a receiver presents in its place when it does not hold it in time. It
|
||||
attests a value over time, not a transmission or a delivery, and sits beside the group receipt,
|
||||
whose bytes it leaves unchanged, one statement for every bearer. [timeliness](timeliness.md)
|
||||
defines it: its body's members are §4.1 there (`objects` and `attests`), read at §3.1 step 2; its
|
||||
class rules are §4.2 there, at step 4; and its context is §4.3 there, at step 6: the group
|
||||
receipt, whose link hash is its `subject` and whose key signs it, the object receipts and
|
||||
envelopes at the positions it names, and each fallback's group receipt, object receipt and
|
||||
envelope.
|
||||
|
||||
#### 2.4.18. `class: "cache-transmission"`
|
||||
|
||||
A node's declaration that it sent, from what it held, objects of a group another key signed, over
|
||||
a bearer instance of its own with no path back on it: a bitmap of their positions and a root over
|
||||
their leaves under the group's own tree (§2.2), checked against the publisher's root without the
|
||||
objects. It attests transmission, never delivery, and it is never in the publisher's chain.
|
||||
[cache-authority](cache-authority.md) defines it: its body's members are §5.1 there (`bearer`,
|
||||
`bearer_id`, `leaf_count`, `sent`, `sent_root`, `energy` and `attests`), read at §3.1 step 2; its
|
||||
class rules are §5.2 there, at step 4; and its context is §5.3 there, at step 6: the group
|
||||
receipt, whose link hash is its `subject` and whose key does not sign it, and the object receipts
|
||||
at the positions it names.
|
||||
|
||||
### 2.5. Worked example
|
||||
|
||||
Manifest JSON (group receipt, formatted for readability — wire form
|
||||
|
|
@ -2128,8 +2156,8 @@ order listed.
|
|||
is not 64 of them; a value outside an enumeration this extension
|
||||
defines here (`record`, `binding_type`, a bracket's `opening`, a build's
|
||||
`profile`, a stage's `tier`, a stage object's `status` and `evidence`, a
|
||||
consumption claim's usage `vocabulary`, a transmission or reconciliation
|
||||
claim's `bearer`, a schedule entry's `fec` and `esi`, a gap's `reason`, a session's
|
||||
consumption claim's usage `vocabulary`, a transmission, reconciliation or
|
||||
cache-transmission claim's `bearer`, a schedule entry's `fec` and `esi`, a gap's `reason`, a session's
|
||||
`framing`, its host's `profile`, and the paths
|
||||
of its decoder entries and switches); a date-time that is not one of §2.4.3 (a gap's
|
||||
`observed_at`); a URI that is not absolute;
|
||||
|
|
@ -2138,7 +2166,8 @@ order listed.
|
|||
`oer_receipt`; or an operating point whose member names are not terms
|
||||
or whose values are not integers of §2.4.3. A member's type and encoding are checked together,
|
||||
before the next member is read. An object a class fixes the members
|
||||
of — a lawful-basis purpose, an `energy_uncertainty`, a span — is checked
|
||||
of — a lawful-basis purpose, an `energy_uncertainty`, a span, a timeliness
|
||||
entry, its decay points and its fallback — is checked
|
||||
the same way, its members and then their types, where the table
|
||||
reaches it, and the entries of an array in the array's order. Of a
|
||||
member's value, this step checks the type column and nothing else.
|
||||
|
|
@ -2166,7 +2195,10 @@ order listed.
|
|||
its classical rendition; in a `transmission` or `reconciliation` claim, its
|
||||
`attests`, a transmission's energy figure and a reconciliation's bitmap; in a
|
||||
`schedule` claim, its `attests`, the order of its entries, and each entry's code
|
||||
parameters, symbols and passes;
|
||||
parameters, symbols and passes; in a `timeliness` claim, its `attests`, the order
|
||||
of its entries, and each entry's window, decay and fallback; in a
|
||||
`cache-transmission` claim, its `attests`, its `bearer`, its bitmap and its energy
|
||||
figure;
|
||||
and, in a `consumption` claim, the order of its
|
||||
objects, usage entries and seen digests, its subject, its terms and its
|
||||
`signer_id`.
|
||||
|
|
@ -2182,7 +2214,7 @@ order listed.
|
|||
verified in the order of [pq-signatures](pq-signatures.md) §4.2 (`hybrid_required`
|
||||
under its policy, `pq_signature_invalid`), and its hybrid members are read at step 1,
|
||||
after the subject type is admitted (`pq_malformed`, `pq_suite_unknown`).
|
||||
4. **Class rules**, per §2.4.5–§2.4.15 and §2.6, each with its own
|
||||
4. **Class rules**, per §2.4.5–§2.4.18 and §2.6, each with its own
|
||||
reason (below). They run after the signature because a rule about
|
||||
what a signer said only means something once it is established who
|
||||
said it.
|
||||
|
|
@ -2221,9 +2253,15 @@ order listed.
|
|||
`reconciliation` claim against that group receipt and the object receipts
|
||||
at the positions it names, each verified by inclusion at least, as
|
||||
[bearer-receipts](bearer-receipts.md) §4.3 and §5.3 order them (§2.4.14,
|
||||
§2.4.15); and a `schedule` claim against the group receipt its subject names and
|
||||
§2.4.15); a `schedule` claim against the group receipt its subject names and
|
||||
the object receipts and envelopes at the positions it names, as
|
||||
[carousel](carousel.md) §4.3 orders them (§2.4.16). A group receipt is verified here under §3 steps 1 and 2, and one
|
||||
[carousel](carousel.md) §4.3 orders them (§2.4.16); and a `timeliness` claim against the
|
||||
group receipt its subject names, the object receipts and envelopes at the positions it
|
||||
names, and each fallback's group receipt, object receipt and envelope that the verifier
|
||||
holds, as [timeliness](timeliness.md) §4.3 orders them (§2.4.17); and a `cache-transmission`
|
||||
claim against the group receipt its subject names and the object receipts at the positions it
|
||||
names, each verified by inclusion at least, as [cache-authority](cache-authority.md) §5.3
|
||||
orders them (§2.4.18). A group receipt is verified here under §3 steps 1 and 2, and one
|
||||
that fails them is `context_invalid`. A
|
||||
declaration is verified here under steps 1–4, and one that fails
|
||||
them — or is not JSON — is `context_invalid`: its members,
|
||||
|
|
@ -2328,6 +2366,14 @@ and never folds it in: confirmation changes the outcome of no step here.
|
|||
| 4 | `schedule_oti_invalid` | an entry's code parameters are not ones its code admits (carousel §5.1, §5.2) |
|
||||
| 4 | `schedule_symbols_invalid` | an entry's symbols are not ones its code can name (carousel §5.4) |
|
||||
| 4 | `schedule_passes_invalid` | an entry's passes are not well formed: none, of no duration, overlapping, ending past 9999, or sending more than 2<sup>53</sup> − 1 octets |
|
||||
| 4 | `timeliness_not_valued` | a `timeliness` claim's `attests` is not `valued` ([timeliness](timeliness.md) §4.2) |
|
||||
| 4 | `timeliness_objects_malformed` | it has no entry, or its entries' `leaf_index` values do not strictly increase |
|
||||
| 4 | `timeliness_window_invalid` | an entry's `from_ms` is after its `by_ms`, or its `by_ms` is not below the last instant, 9999-12-31T23:59:59.999Z |
|
||||
| 4 | `timeliness_decay_invalid` | an entry's decay is not well formed: not 1 to 16 points; instants not strictly increasing from after `by_ms` to at most the last instant; a value at or above 1,000,000, above the one before it, a last one other than 0 or another one of 0; or a point but the last on the line through its neighbours |
|
||||
| 4 | `timeliness_fallback_invalid` | an entry's fallback is valued at 0 or above 1,000,000, has the entry's content hash, or names the entry's own position |
|
||||
| 4 | `cache_transmission_not_transmitted` | a `cache-transmission` claim's `attests` is not `transmitted` ([cache-authority](cache-authority.md) §5.2) |
|
||||
| 4 | `cache_transmission_on_bidirectional` | its `bearer` is `bidirectional` |
|
||||
| 4 | `cache_transmission_bitmap_malformed` | its `leaf_count` is 0, its `sent` is not ⌈`leaf_count` / 8⌉ bytes, sets a bit at or past `leaf_count`, or sets none |
|
||||
| 6 | `energy_disagrees_with_delivery` | the figure differs from the `object` receipt's |
|
||||
| 6 | `energy_class_disagrees_with_delivery` | the object's leaf is labelled, and the `energy` claim's class or evidence differs from the label |
|
||||
| 6 | `figure_disagrees` | the receipt a `measurement` or `operating-point` claim names carries another figure at `figure_index` |
|
||||
|
|
@ -2380,6 +2426,19 @@ and never folds it in: confirmation changes the outcome of no step here.
|
|||
| 6 | `schedule_before_group` | an entry's first pass starts before the group's `group_start` |
|
||||
| 6 | `schedule_content_mismatch` | an entry's `content_hash` is not that of the object receipt the verifier holds at its position |
|
||||
| 6 | `schedule_length_mismatch` | an entry's transfer length is not the length of the object's envelope the verifier holds |
|
||||
| 6 | `timeliness_subject_mismatch` | a `timeliness` claim's `subject` is not the link hash of the group receipt it is checked against ([timeliness](timeliness.md) §4.3) |
|
||||
| 6 | `timeliness_signer_mismatch` | it is not signed by the group's key |
|
||||
| 6 | `timeliness_leaf_out_of_range` | an entry's `leaf_index` is not less than the group's `leaf_count` |
|
||||
| 6 | `timeliness_before_group` | an entry's `from_ms` is before the group's `group_start`, whole seconds compared |
|
||||
| 6 | `timeliness_content_mismatch` | an entry's `content_hash` is not that of the object receipt the verifier holds at its position |
|
||||
| 6 | `timeliness_on_layer` | an envelope the verifier holds whose content hash is an entry's or a fallback's is a `WAI1` of a refinement-only capability |
|
||||
| 6 | `timeliness_fallback_mismatch` | a fallback's `leaf_index` is not less than the leaf count of its group, where the verifier holds that group receipt, or its `content_hash` is not that of the object receipt the verifier holds at its position |
|
||||
| 6 | `timeliness_fallback_not_derived` | an envelope the verifier holds whose content hash is a fallback's is not a `WAI1` whose `derived_from` names the entry's `content_hash` as its `motif` |
|
||||
| 6 | `cache_transmission_subject_mismatch` | a `cache-transmission` claim's `subject` is not the link hash of the group receipt it is checked against ([cache-authority](cache-authority.md) §5.3) |
|
||||
| 6 | `cache_transmission_by_publisher` | it is signed by the group's key |
|
||||
| 6 | `cache_transmission_leaf_count_mismatch` | its `leaf_count` is not the group's |
|
||||
| 6 | `cache_transmission_objects_not_held` | it names a position whose object receipt the verifier does not hold: recoverable, never accepted |
|
||||
| 6 | `cache_transmission_root_mismatch` | its `sent_root` is not the root over the leaves at the positions it names |
|
||||
| 6 | `session_stage_disagrees` | a `stage` claim the session names is about a base, or lists an arrived layer object, the session did not receive; counts a unit the session never presented, or more units at a stage than the session presented at it or above; or no claim whose set holds a unit lists an applied layer that reaches a stage the session presented it at |
|
||||
| 6 | `session_stage_hash_not_exact` | a `stage` claim the session names states a `stage_hash`, and a decoder entry names its base at a step weaker than `decode-equivalence`, or names its base or a layer it lists without stating an exact path, whichever units it presented |
|
||||
|
||||
|
|
@ -2585,6 +2644,9 @@ below bind a relay that reads the receipts it forwards.
|
|||
the subscriber. The relay MUST NOT renumber `object_id`.
|
||||
- Sign a `delivery-gap` claim (§2.4.11) for an object it did not forward,
|
||||
under its own key, as the parallel receipt §4.3 permits.
|
||||
- Sign a `cache-transmission` claim (§2.4.18) for objects it sends again,
|
||||
from what it holds, over a bearer instance of its own, as the parallel
|
||||
receipt §4.3 permits. A node that is not a relay (§4.5) may sign one too.
|
||||
- Derive new objects from those it forwards and publish them as a
|
||||
publisher of its own ([deriving-edge](deriving-edge.md)): each a new
|
||||
object with its own content hash, in a group it signs on a track of its
|
||||
|
|
@ -3606,18 +3668,21 @@ A confirmation needs every one of them:
|
|||
| item | authorities |
|
||||
|---|---|
|
||||
| a `group` receipt, and the `object` receipts under it | `delivery`; and `energy/<class>` for each acquisition class an `object` receipt under it labels, where its leaf binds a label (§2.2), and for the class a labelled group payload signs for the group's total (§2.3) |
|
||||
| a `claim` | its class's authority: `energy/` followed by its `energy_provenance` for `energy`, for example `energy/OnChipCounter`; `lawful-basis/declaration` or `lawful-basis/decision` by its `record`; its class name for every other class — `log-inclusion`, `soft-binding`, `soft-binding-span`, `binding-recovery`, `measurement`, `operating-point`, `stage`, `delivery-gap`, `reconstruct-session`, `consumption`, `transmission`, `reconciliation` and `schedule`. And `energy/` followed by its `energy_provenance` whenever its body carries that member, whatever its class |
|
||||
| a `claim` | its class's authority: `energy/` followed by its `energy_provenance` for `energy`, for example `energy/OnChipCounter`; `lawful-basis/declaration` or `lawful-basis/decision` by its `record`; its class name for every other class — `log-inclusion`, `soft-binding`, `soft-binding-span`, `binding-recovery`, `measurement`, `operating-point`, `stage`, `delivery-gap`, `reconstruct-session`, `consumption`, `transmission`, `reconciliation`, `schedule`, `timeliness` and `cache-transmission`. And `energy/` followed by its `energy_provenance` whenever its body carries that member, whatever its class |
|
||||
| the energy figure of a `reconstruct-session` claim whose `energy` is not `null` | `reconstruct-session`, and `energy/` followed by `energy.energy_provenance` |
|
||||
| the energy figure of a `transmission` claim whose `energy` is not `null` | `transmission`, and `energy/` followed by `energy.energy_provenance` |
|
||||
| the energy figure of a `cache-transmission` claim whose `energy` is not `null` | `cache-transmission`, and `energy/` followed by `energy.energy_provenance` |
|
||||
| a receipt of another WAI family that signs its own `signer_pubkey` and `signer_id` | `receipt`; and `energy/<class>` for each class its figures carry a label for |
|
||||
| a module grant (§7.9) | `module/decoder` or `module/reconstruct`, by its `form` |
|
||||
| a room descriptor or room grant ([live-rooms](live-rooms.md) §3, §4), or a room usage record ([ai-usage](ai-usage.md) §4.2) | `live/room` |
|
||||
| an upload receipt of a take ([capture-binding](capture-binding.md) §6) | `capture/store` |
|
||||
| an impression task file ([impression-aggregates](impression-aggregates.md) §5.4) | `aggregate/task` |
|
||||
| a per-hop session record ([live-metering](live-metering.md) §3) | `live/relay`, `live/compositor` or `live/ingest`, by its role; and `energy/<class>` for its figure's class where it is metered |
|
||||
| a room-session receipt (live-metering §4) signed by a key other than the room authority | `live/meter`; and, from any signer, `energy/<class>` for each class it totals |
|
||||
| a steering policy or steering decision ([energy-steering](energy-steering.md) §3.5) signed by a key other than the room authority | `live/steer`; and, from any signer of a decision, `energy/<class>` for each class among its figures |
|
||||
| a watermark session record ([forensic-watermark](forensic-watermark.md) §5) | `live/watermark` |
|
||||
| a derivation record ([deriving-edge](deriving-edge.md) §5) | `edge/derive`, from the origin of each source object it derives from (deriving-edge §8); and `energy/<class>` for its figure's class where it is metered |
|
||||
| a holdings record's `holder` ([holdings](holdings.md) §4), and the `upstream` of a settlement acknowledgement or declaration ([settlement](settlement.md) §3, §4), placed as a serving node ([cache-authority](cache-authority.md) §4.4) | `cache/serve` |
|
||||
|
||||
A class registered in §2.4.4 registers its authority here with it, and a
|
||||
family that needs a narrower authority than `receipt` registers one here.
|
||||
|
|
@ -3629,9 +3694,9 @@ that class's authority without this table naming the class.
|
|||
**Pinned-only authorities.** The hardware-class energy authorities —
|
||||
`energy/CalibratedInstrument`, `energy/OnChipCounter` and the legacy
|
||||
`energy/HwShunt` — the lawful-basis authorities —
|
||||
`lawful-basis/declaration` and `lawful-basis/decision` — and any
|
||||
authority registered for pre-emption are confirmed only under a pinned
|
||||
anchor that has not moved (§7.4 step 6a). Every other authority is
|
||||
`lawful-basis/declaration` and `lawful-basis/decision` — `aggregate/task`,
|
||||
and any authority registered for pre-emption are confirmed only under a
|
||||
pinned anchor that has not moved (§7.4 step 6a). Every other authority is
|
||||
confirmed under a pinned or a fetched anchor, and every confirmation
|
||||
reports which.
|
||||
|
||||
|
|
@ -3712,7 +3777,10 @@ its keys is that party is the origin's decision, and its log records it:
|
|||
anchors provisioned before it was cut off, and a key log that reaches it over the bearer
|
||||
anchors nothing ([bearer-receipts](bearer-receipts.md) §8). `schedule` lets a key state the
|
||||
passes of a group it signed over an instance (§2.4.16); an origin gives it to its publishing
|
||||
keys, as it gives `transmission`, and it is neither pinned-only nor `delivery`.
|
||||
keys, as it gives `transmission`, and it is neither pinned-only nor `delivery`. `timeliness`
|
||||
lets a key state when the objects of a group it signed are of use (§2.4.17); an origin gives
|
||||
it to its publishing keys, as it gives `schedule`, and it is neither pinned-only nor
|
||||
`delivery`.
|
||||
- **Log inclusion.** A confirmation says under whose authority the log's
|
||||
evidence was attached. It does not check `log_receipt`: §2.4.7 is
|
||||
unchanged.
|
||||
|
|
@ -3732,6 +3800,12 @@ its keys is that party is the origin's decision, and its log records it:
|
|||
- **Stores.** `capture/store` lets a key act as a store a room's takes are uploaded to:
|
||||
sign the upload receipts of a take ([capture-binding](capture-binding.md) §6.4). It is
|
||||
not pinned-only, and it is not `delivery`.
|
||||
- **Aggregation tasks.** `aggregate/task` lets a key sign an impression task file: name a DAP
|
||||
task, its two aggregators and its collector, which receivers on a bearer with no return path
|
||||
report to ([impression-aggregates](impression-aggregates.md) §5.4). It is pinned-only, because
|
||||
a task decides which two aggregators receive every receiver's shares and so gives up part of
|
||||
[unidirectional-delivery](unidirectional-delivery.md) §2's "the source learns nothing", and a
|
||||
fetched anchor is authority taken on first use. It is not `delivery`.
|
||||
- **Watermark edges.** `live/watermark` lets a key act as an edge that chooses forensic A/B
|
||||
variants for a room's viewers: sign the session records of what it embedded
|
||||
([forensic-watermark](forensic-watermark.md) §5.6). It is not pinned-only, and it is not
|
||||
|
|
@ -3742,9 +3816,20 @@ its keys is that party is the origin's decision, and its log records it:
|
|||
source's, not the edge's: an origin lists the edges it trusts with its
|
||||
content, and a hop is confirmed under each origin its chain reaches. It is
|
||||
not pinned-only, and it is not `delivery` or `live/relay`.
|
||||
- **Caches and peers.** `cache/serve` lets a key act as a node that serves, from what it holds,
|
||||
objects of groups another key signed ([cache-authority](cache-authority.md) §4): sign its
|
||||
holdings records, sign its declarations, and be the `upstream` of the acknowledgements its
|
||||
receivers sign. `cache-transmission` lets a key declare a bearer instance of its own over which
|
||||
it sent such objects (§2.4.18), as `delivery-gap` lets a relay sign what it did not forward.
|
||||
The origin that gives them is the node's operator's, not the publisher's: a cache changes no
|
||||
byte, so the publisher's receipts verify whatever it serves. Neither is pinned-only, and
|
||||
neither is `delivery`, `transmission`, `live/relay` or `edge/derive`: a cache publishes
|
||||
nothing, declares none of the publisher's transmissions, signs no room's hop record and
|
||||
derives nothing. A `cache-transmission` claim's subject is the publisher's group, so a
|
||||
verifier expects an origin for one only where its relying party names one, as for a session.
|
||||
- **Pre-emption** ([unidirectional-delivery](unidirectional-delivery.md)
|
||||
§3), and relay attestation (§4.3) other than a `delivery-gap` claim and a
|
||||
derivation record, have no class and so no authority. When one is registered for pre-emption it is pinned-only,
|
||||
§3), and relay attestation (§4.3) other than a `delivery-gap` claim, a
|
||||
`cache-transmission` claim and a derivation record, have no class and so no authority. When one is registered for pre-emption it is pinned-only,
|
||||
and the pinned revision itself must list the key with it (§7.4
|
||||
step 6a): a fetched anchor is authority taken on first use, which that
|
||||
section forbids for an instruction that displaces what a person chose.
|
||||
|
|
|
|||
|
|
@ -320,6 +320,8 @@ tracks one subscriber priority.
|
|||
A slack of zero or less cannot be set, since a timeout of 0 is no timeout (MOQT
|
||||
§5.2): such an object cannot arrive in time for any unit it covers, and a publisher
|
||||
**MAY** withhold it.
|
||||
- For an object that is not a layer and whose timeliness stands, the slack is to its end, and
|
||||
on a base track the floor below stands: [timeliness](timeliness.md) §8.1.
|
||||
- A publisher **MUST NOT** set on a base track a delivery timeout of either kind
|
||||
shorter than the time from a base's publication to the presentation of its last
|
||||
unit.
|
||||
|
|
|
|||
727
wai/extensions/rung-price.md
Normal file
727
wai/extensions/rung-price.md
Normal file
|
|
@ -0,0 +1,727 @@
|
|||
# WAI Extension: Rung Price
|
||||
|
||||
> Status: Draft. Registers the record `wai.delivery.rung_price`: a seller's signed list of what it
|
||||
> charges to deliver a rung, and one rule for comparing delivery options with it. A **rung price
|
||||
> list** (`wai-rung-prices`, §4) prices delivery per direction (`downlink` or `uplink`), bearer
|
||||
> class ([bearer-receipts](bearer-receipts.md) §2), footprint of regions delivered to (ISO 3166
|
||||
> codes in WAI's capitals, §2), ceiling of the rung's rate in bits per second, and unit; each unit
|
||||
> is priced at the divisor of the ledger field that bills it, in [route-record](route-record.md)'s
|
||||
> amounts, with the energy the seller states beside it. A seller's latest list stands alone, and
|
||||
> one that lapses revives none (§5). A comparer's request removes the options its limits remove,
|
||||
> and ranks the rest by a cost per receiver: a shared transmission's is held to its pass rate and
|
||||
> divided by the audience the comparer states, or by one, never by an assumed one; the plan states
|
||||
> the audience at which sharing is no dearer (§6). An energy figure ranks by its class only when
|
||||
> the seller's own per-hop records, which the list names by digest, bear it out, are current, and
|
||||
> come from a key the comparer confirmed for that class; any other figure ranks as an estimate, and
|
||||
> one its own records contradict is set aside (§4.6, §6.4). A list is a quote: a buyer is billed
|
||||
> only at a tariff it signed ([receipt-ledger](receipt-ledger.md) §4.2), and §7 says which tariff
|
||||
> field bills each unit. The reference implementation is the `rung_price` module of `wai-rs`
|
||||
> (feature `rung_price`), with the region code in `wai-rs`'s `region` module; the corpus is
|
||||
> [`live-conformance/prices/`](../live-conformance/prices/), and its second implementation is
|
||||
> `live-conformance/rung_price_verify.py`.
|
||||
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
|
||||
>
|
||||
> Texts read for this revision: RFC 8506, RFC 8006, RFC 9388, RFC 8216 and
|
||||
> `draft-ietf-moq-msf-01`; 3GPP TS 32.291, TS 32.279 and TS 26.510 as a mirror of Release 19 gives
|
||||
> them. ISO 3166 and ISO/IEC 23009-1 were not read: this text binds only the structure of a code
|
||||
> (§2) and the name of an attribute.
|
||||
|
||||
## 1. Scope
|
||||
|
||||
In scope: the price list, its form, digest and signature, and the records its energy figures name
|
||||
(§4); what a verifier derives from lists at an instant (§5); how delivery options are compared
|
||||
under a request, with the records a comparer is given, and the neighbour order a comparison gives
|
||||
an asker (§6); and which tariff field bills each unit of a list (§7).
|
||||
|
||||
Out of scope: a transport; how a seller sets its prices; what a delivery promises, which is a
|
||||
deadline, an earliest useful time, a decay or a fallback rendition ([timeliness](timeliness.md)
|
||||
§4), a schedule's availability and decoding bound ([carousel](carousel.md) §6.3), or service
|
||||
counted against a region and a miss target, which no price states and
|
||||
[cache-authority](cache-authority.md) §8 counts; payment; the records a line
|
||||
bills from, which their own extensions define; and which sellers a comparer accepts.
|
||||
|
||||
Relationship to the core spec: `wai.delivery.rung_price` is a **record** ([SPEC](../SPEC.md) §5).
|
||||
It names no payload format and is not dispatchable.
|
||||
|
||||
## 2. Terms
|
||||
|
||||
- **Seller**: an Ed25519 key that signs a list. **Comparer**: anyone who compares lists: a router,
|
||||
a buyer, or an asker of [holdings](holdings.md) §5.4.
|
||||
- **Time**, **amount**, **document**, **canonical form**, **digest**, **unit**, **class**: as
|
||||
route-record §2. A time is in microseconds, and an amount in millionths of a minor unit
|
||||
(ISO 4217, by its alphabetic code).
|
||||
- **Bearer class**: `bidirectional`, `multicast` or `unidirectional`, as bearer-receipts §2.
|
||||
- **Direction**:
|
||||
- `downlink`: octets sent to the end user who receives the rung. This is RFC 8506 §8.25
|
||||
*CC-Output-Octets* ("sent to the end user") and 3GPP TS 32.291 §6.1.6.2.1.10
|
||||
`downlinkVolume`.
|
||||
- `uplink`: octets received from the end user who contributes it. This is RFC 8506 §8.24
|
||||
*CC-Input-Octets* ("received from the end user") and TS 32.291 `uplinkVolume`.
|
||||
|
||||
A peer is not a direction. A peer that serves a neighbour serves it `downlink` on a
|
||||
`bidirectional` bearer, and is told apart by who signs. RFC 8506 §8.23 *CC-Total-Octets*
|
||||
("regardless of the direction") states no direction, and is neither.
|
||||
- **Region code**: either of these, written as WAI writes ISO 3166 codes elsewhere
|
||||
([live-rooms](live-rooms.md) §3.7 `regions`, [channel-lineup](channel-lineup.md) §6.2
|
||||
`countries`, [forensic-watermark](forensic-watermark.md) §5.1 `region`):
|
||||
- an ISO 3166-1 alpha-2 code: two capitals `A`–`Z` (`XA`);
|
||||
- an ISO 3166-2 code: an alpha-2 code, `-`, then 1 to 3 capitals `A`–`Z` or digits `0`–`9`
|
||||
(`XA-1`).
|
||||
|
||||
A code's **country** is its first two characters. Whether ISO assigns a code is not checked,
|
||||
and codes are compared byte for byte. CDNI writes the same codes in lowercase (RFC 8006 §4.3.8
|
||||
`countrycode`, RFC 9388 §2.1.1.1 `subdivisioncode`); a code maps to and from those by changing
|
||||
the case of its letters, exactly, and a CDNI footprint of both kinds is a `footprintunion` (RFC
|
||||
9388 §2.2).
|
||||
|
||||
This is WAI's one definition of a region code, its country and *holds*: an extension that
|
||||
states a region cites this section rather than restating it.
|
||||
- **Holds**: a list of region codes holds a code *r* when it lists *r*, or when *r* is an ISO
|
||||
3166-2 code and the list names *r*'s country. It never holds a country because it lists a
|
||||
subdivision of it.
|
||||
- **Rung rate**: the bits per second a rendition delivered to each receiver takes, as its own
|
||||
description states it:
|
||||
- an HLS `BANDWIDTH` (RFC 8216 §4.3.4.2, "the peak segment bit rate");
|
||||
- an MSF `bitrate` (`draft-ietf-moq-msf-01` §5.2.22, the track's maximum bitrate in bits per
|
||||
second), a JSON number, taken **rounded up** to an integer;
|
||||
- a DASH Representation's `@bandwidth` (ISO/IEC 23009-1);
|
||||
- a packaging class's `max_kbps` × 1000 ([packaging](packaging.md) §3, its paragraph 3.3);
|
||||
- a room profile layer's `max_kbps` × 1000 (live-rooms §8.1);
|
||||
- a steering policy's tier bitrate ([energy-steering](energy-steering.md) §3.1 `tiers`);
|
||||
- a `wai:bitrate` ([capability-constraints](capability-constraints.md) §3.1).
|
||||
|
||||
A packaging class with no `max_kbps` states no rate, and a request for it states `null`, never
|
||||
0 and never the lowest tier.
|
||||
- **Pass rate**: the rate a transmission's passes take on their bearer (§7.2).
|
||||
- **Per-hop record**: a hop's signed record of its part in one room session, `WMH1`
|
||||
([live-metering](live-metering.md) §3). Its digest is SHA-256 over its bytes before `sig`
|
||||
(live-metering §3.5); its **room** is the room descriptor (live-rooms §3.1) whose digest is its
|
||||
`room`; and its **instant** is that room's `t0` + its `end` − 1, the last microsecond its span
|
||||
covers ([fleet](fleet.md) §4.5).
|
||||
- ⌊x⌋ is x rounded down and ⌈x⌉ is x rounded up; products are computed in integers wide enough not
|
||||
to overflow.
|
||||
|
||||
## 3. Why a signed list beside the price book and the tariff
|
||||
|
||||
1. **A router and a buyer compare before any contract.** The price book is a router's own unsigned
|
||||
statement (route-record §11). A tariff names one buyer, and binds only once that buyer signs
|
||||
it (receipt-ledger §4.1, §4.2). Nothing signed lets a buyer hold a seller to a price before
|
||||
both exist. The list is the seller's word to every comparer, signed, with the window it holds
|
||||
for.
|
||||
2. **Delivery is priced by what carries it, where, and in which direction**, not by an operation
|
||||
and an endpoint. A price book's `endpoint`, `provider`, `operation` and `capability` name a
|
||||
job's server and what it produces; a carrier produces nothing, and the catalogue has no
|
||||
delivery operation. Its `region` and `residency` are where an endpoint runs and where data
|
||||
rests, not where content is delivered to. Its `failures` are the router's own measurement,
|
||||
which a seller cannot sign. What delivery needs and the price book lacks is a direction, a
|
||||
bearer class, a footprint, a rate ceiling, and a signer with a validity window: those, and only
|
||||
those, are added (§4.2).
|
||||
3. **A shared transmission and a per-receiver delivery are compared honestly.** On a `multicast`
|
||||
or `unidirectional` bearer one transmission serves every receiver, and their number is unknown
|
||||
("the source learns nothing", [unidirectional-delivery](unidirectional-delivery.md) §2).
|
||||
Dividing by an assumed audience would read the unknown as the optimum. §6.3 divides by the
|
||||
audience a comparer states, and by 1 otherwise, and states the audience at which sharing stops
|
||||
costing more.
|
||||
4. **It changes no byte of any record it prices.** Lines bill only quantities that signed records
|
||||
state (§7). A list is a quote, never a charge, as the price book's figures are (receipt-ledger
|
||||
§5.1, *Routing figures are never charges*).
|
||||
|
||||
## 4. The list
|
||||
|
||||
### 4.1 Members
|
||||
|
||||
A list is a document (route-record §2) of exactly these members:
|
||||
|
||||
| member | form |
|
||||
|---|---|
|
||||
| `kind` | `"wai-rung-prices"` |
|
||||
| `version` | `1` |
|
||||
| `seller` | a key: 64 lowercase hexadecimal digits |
|
||||
| `currency` | three capitals `A`–`Z` (ISO 4217) |
|
||||
| `issued` | a time: from when the list states its prices |
|
||||
| `expires` | a time: from when it states nothing |
|
||||
| `entries` | 1 to 1,024 entries (§4.2), in §4.5 rule 4's order |
|
||||
| `sig` | 128 lowercase hexadecimal digits (§4.4) |
|
||||
|
||||
### 4.2 An entry
|
||||
|
||||
| member | form | meaning |
|
||||
|---|---|---|
|
||||
| `direction` | `"downlink"` or `"uplink"` | §2 |
|
||||
| `bearer` | `"bidirectional"`, `"multicast"` or `"unidirectional"` | bearer-receipts §2 |
|
||||
| `footprint` | 1 to 8,192 region codes (§2), strictly ascending by their bytes | the regions it delivers to |
|
||||
| `max_bps` | an integer from 1 to 2⁵³ − 1 | the tier's ceiling: it prices a rung whose rate (or pass rate, for a transmission unit) is at most this, unless a lower ceiling of the same list, direction, bearer and unit, holding the region, also covers it (§6.2 `tier`) |
|
||||
| `unit` | a unit (route-record §2): one of §4.3's (rule 5) | what is priced |
|
||||
| `divisor` | an integer at least 1: the unit's divisor (rule 5) | as route-record §3.2 |
|
||||
| `price` | an integer, at most the unit's bound (rule 5) | an amount per `divisor` units |
|
||||
| `energy_uj` | an integer of at least 1, or `null` | the microjoules the seller states per `divisor` units, as route-record §3.2; `null` when it states none, and never 0, as bearer-receipts §4.2 refuses a zero figure |
|
||||
| `energy_class` | a class (route-record §2), or `null` exactly when `energy_uj` is `null` | the class of that figure |
|
||||
| `energy_ppm` | an integer from 1 to 2³² − 1 when `energy_class` is `CalibratedInstrument` or `OnChipCounter`, and `null` otherwise | the figure's declared uncertainty, in parts per million of it ([energy-measurement](energy-measurement.md) §2.3): the largest its records declare (§4.6 rule 4) |
|
||||
| `energy_evidence` | 1 to 64 digests, each 64 lowercase hexadecimal digits, in strictly ascending order of their bytes, when `energy_class` is `CalibratedInstrument`, `OnChipCounter` or `ModelBased`; `null` otherwise | the per-hop records (§2) whose rate the figure is (§4.6) |
|
||||
|
||||
- A ceiling of 2⁵³ − 1 is how a seller prices any rung. It is not a sentinel: no document states a
|
||||
greater rate.
|
||||
- 8,192 codes exceed the codes ISO 3166-1 and ISO 3166-2 assign together, and rule 6 forbids a
|
||||
code twice in one tier, so one entry never needs more.
|
||||
- **An entry is a whole price.** It is the price of what it carries, counted in its unit alone. A
|
||||
seller that would also bill another unit for the same delivery has stated a price it does not
|
||||
hold to. Two entries of a seller are alternatives, and a comparer ranks them as such.
|
||||
- **A class above `Estimator` names its records.** A class says how a figure was obtained
|
||||
(energy-measurement §2), and a list is no reading (§10). An entry whose class is above
|
||||
`Estimator` names the per-hop records whose rate its figure is, and one of a hardware class
|
||||
states the declared uncertainty energy-measurement §2.3 requires of that class. An entry that
|
||||
names none states an estimate, and its class says so. 64 records bound what one entry costs a
|
||||
comparer to read.
|
||||
|
||||
### 4.3 Units, by bearer class and direction
|
||||
|
||||
| unit | bearer | direction | basis | divisor | price at most | counts |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `bytes` | `bidirectional` | `downlink` | receiver | 10⁹ | 2⁵³ − 1 | bytes one receiver is delivered |
|
||||
| `subscriber_us` | `bidirectional` | `downlink` | receiver | 10⁶ | 2³² − 1 | microseconds one receiver is subscribed at the rung ([live-metering](live-metering.md) §3.3) |
|
||||
| `publisher_us` | `bidirectional` | `uplink` | receiver | 10⁶ | 2³² − 1 | microseconds one contributor publishes (live-metering §4.4) |
|
||||
| `bytes_sent` | `multicast`, `unidirectional` | either | transmission | 10⁹ | 2⁵³ − 1 | octets transmitted: carousel §5.4's `bytes_sent` |
|
||||
| `pass_us` | `multicast`, `unidirectional` | either | transmission | 10⁶ | 2⁵³ − 1 | microseconds of passes: Σ `count` × `duration_ms` × 1000 over a schedule's entries (carousel §2, §4.1) |
|
||||
|
||||
**The basis follows from the bearer class.** A `bidirectional` bearer reaches each receiver over
|
||||
its own path; the other two classes reach every receiver with one transmission (bearer-receipts
|
||||
§2).
|
||||
|
||||
**A per-receiver unit on a shared bearer is refused**, because no sender can count it: the source
|
||||
learns nothing (unidirectional-delivery §2), and receiver time on an ATSC 3.0 broadcast or a 5G
|
||||
media streaming service comes only as unsigned reports (bearer-receipts §9.5; 3GPP TS 26.510 §9.6.3.2
|
||||
reports consumption under a second as 0).
|
||||
|
||||
**Each divisor and bound is the ledger field's** that bills the unit (receipt-ledger §4.1 and
|
||||
§6.3; §7 here): room prices are u32, per second, and delivery prices u64, per 10⁹ bytes. So every
|
||||
price transcribes into its tariff field exactly, and since a unit has one divisor, a price has one
|
||||
encoding.
|
||||
|
||||
**Units no record can bill are absent.** No record counts an object delivered (settlement §3.4
|
||||
acknowledges byte ranges), so there is no `objects` unit; no ledger source counts uplink bytes, so
|
||||
`bytes` is downlink only. A unit is added with the record and the ledger field that bill it.
|
||||
|
||||
**Two units take records.** A relay's per-hop record states its energy beside the media bytes it
|
||||
sent and the subscriber time it served (live-metering §3.2, §3.3), so a `bytes` or `subscriber_us`
|
||||
entry may name records (§4.6). No record a seller signs states `publisher_us` beside its energy: a
|
||||
room's publisher time is its room-session receipt's, signed by the room's authority or a metering
|
||||
key (live-metering §4.3, §4.4). Nor does one state a transmission's: its energy is its publisher's
|
||||
declaration (bearer-receipts §4.1, §4.3), and its octets and pass time are its schedule's (carousel
|
||||
§5.4). An entry of those units states an estimate or no figure. A unit takes records when a record
|
||||
its seller signs states its energy beside a quantity of the unit.
|
||||
|
||||
### 4.4 Digest and signature
|
||||
|
||||
The **digest** of a list is SHA-256 over the canonical form of the list without `sig`, written as
|
||||
64 lowercase hexadecimal digits. The **signature** is Ed25519 by `seller` over `wai:rung-prices`
|
||||
(ASCII) and 0x01, followed by that same canonical form: route-record §5.3's construction under its
|
||||
own domain. It is verified strictly, as SPEC §7 *Signatures* verifies every signature: a key that
|
||||
is not the canonical encoding of a point of large order fails, as does a signature whose scalar is
|
||||
not below the group order.
|
||||
|
||||
The digest leaves `sig` out, as [holdings](holdings.md) §4.3 digests a record's bytes without its
|
||||
signature. Ed25519 admits many valid signatures of one body, one for each nonce, and strict
|
||||
verification removes only S-malleability; a digest over `sig` would read one seller's two signings
|
||||
of one body as two lists, and two such lists at one `issued` as a contradiction. The digest names
|
||||
what the seller said; the signature is checked on its own.
|
||||
|
||||
### 4.5 Reading and rules
|
||||
|
||||
A reader is given a text. It refuses it `rung_prices_malformed` when the text holds more than
|
||||
1,048,576 bytes, is not a document, or breaks one of rules 1 to 4. A list that reads is then
|
||||
refused for rule 5 or rule 6, with that rule's own reason, and then for its signature
|
||||
(`rung_prices_signature_invalid`, §4.4). The rules, in order:
|
||||
|
||||
1. Its members are exactly §4.1's, and each entry's exactly §4.2's, each of its form. `version` is
|
||||
1.
|
||||
2. `issued` is below `expires`.
|
||||
3. Each `footprint` is strictly ascending by bytes, and lists no subdivision code whose country it
|
||||
also lists: that would be a second encoding of the same footprint.
|
||||
4. The entries are in strictly ascending order of (`direction`, `bearer`, `unit`, `max_bps`, the
|
||||
first code of `footprint`): strings by their bytes, integers by value. No two entries of one
|
||||
(`direction`, `bearer`, `unit`, `max_bps`) have the same `price`, `energy_uj`, `energy_class`,
|
||||
`energy_ppm` and `energy_evidence`: such entries are one entry over the union of their
|
||||
footprints.
|
||||
5. Each entry's `unit` is one §4.3 lists, on its `bearer` and in its `direction`; its `divisor` is
|
||||
that unit's; its `price` is at most that unit's bound; and it names records only when its unit
|
||||
is `bytes` or `subscriber_us` (`rung_prices_unit_invalid`).
|
||||
6. Within one (`direction`, `bearer`, `unit`, `max_bps`), no region code is listed by two entries,
|
||||
and no entry lists a subdivision code whose country another entry lists
|
||||
(`rung_prices_ambiguous`). Either would give one region two prices at one tier.
|
||||
|
||||
So a list reads the same whatever the order of its members, since the canonical form is what is
|
||||
signed; each region, tier and unit has at most one price in a list; and two lists that state the
|
||||
same prices are the same body, with the same digest.
|
||||
|
||||
### 4.6 Energy evidence
|
||||
|
||||
An entry's `energy_evidence` names the per-hop records whose rate its figure is. The list signs
|
||||
their digests, so the records a seller names are fixed with its prices, and a comparer that is
|
||||
given them checks the figure against them (§6.4).
|
||||
|
||||
A text a comparer is given (§6) **names** a digest when it holds 64 to 1,048,576 bytes: SHA-256
|
||||
over all its bytes but the last 64, as live-metering §5.1 finds a per-hop record and fleet §4.4 a
|
||||
room descriptor. A named record is **given** when a text naming its digest reads and verifies as a
|
||||
per-hop record of the room its own `room` names (live-metering §3.5, §3.6), live-metering §3.7's
|
||||
bound not applied, as fleet §4.3 does not apply it. Its room is given when a text naming the
|
||||
record's `room` reads and verifies as fleet §4.4 reads a room descriptor. Texts naming one digest
|
||||
differ at most in their last 64 bytes, the signature, so every text that reads and verifies gives
|
||||
the same record. A text that does not gives nothing: a damaged copy refutes nothing, and only what
|
||||
a seller named and its hop signed can refute the seller's figure.
|
||||
|
||||
Each given record is held to these, in order, and the first that fails **refutes** the entry's
|
||||
figure, with its reason:
|
||||
|
||||
1. Its `role` is 1 (`rung_prices_evidence_role`): a downlink price is a relay's, and a compositor
|
||||
or an ingest gateway serves no subscriber.
|
||||
2. Its `hop` is the list's `seller` (`rung_prices_evidence_signer`). A figure is the rate of its
|
||||
seller's own readings, never of another key's that it chose to name.
|
||||
3. Its figure is not the unmetered marker, and its class's tag is the one energy-measurement §2.7
|
||||
gives the entry's `energy_class`: 5 for `CalibratedInstrument`, 4 for `OnChipCounter`, 2 for
|
||||
`ModelBased` (`rung_prices_evidence_class`). Figures of two classes are never summed
|
||||
(live-metering §4.5).
|
||||
|
||||
When every named record and the room of each is given, the records together are held to these, in
|
||||
order:
|
||||
|
||||
4. For `CalibratedInstrument` and `OnChipCounter`, `energy_ppm` is the largest `relative_ppm` among
|
||||
them (`rung_prices_evidence_class`). When each record's error is within its declared relative
|
||||
uncertainty, their sum's is within the largest (receipt-ledger §6.6), and so is any rate of it.
|
||||
5. With *J* the sum of their `joules` and *D* the sum of their quantities in the entry's unit, *D*
|
||||
is at least 1 and `energy_uj` is ⌈*J* × `divisor` / *D*⌉ (`rung_prices_evidence_figure`): the
|
||||
records' rate per `divisor` units, rounded up so that it never states less than they do. For at
|
||||
most 64 records *J* × `divisor` is below 2¹⁰⁰. A record's quantity is:
|
||||
- in `bytes`, the `bytes` of its track entries of kind 1 (a media track) and `dir` 2 (out), as
|
||||
energy-steering §5.3 counts delivery on its basis 1;
|
||||
- in `subscriber_us`, its `subscriber_us` of layer classes 0 to 7, as energy-steering §5.3
|
||||
counts delivery on its basis 2 at floor tier 0.
|
||||
|
||||
Either charges every joule of the hop's span to its media delivery, so the figure is never less
|
||||
than any division of those joules among what the hop carried would give.
|
||||
|
||||
Records that pass all five **bear out** the figure. What they bind is a rate the seller's own relay
|
||||
spent and signed, not the delivery a comparer buys: which region, rung or receivers a record's
|
||||
session served, it does not say (§10). Whether a comparer relies on them, at its instant and for
|
||||
its seller's key, is §6.4's.
|
||||
|
||||
## 5. What a verifier derives
|
||||
|
||||
### 5.1 At an instant
|
||||
|
||||
A list that reads and verifies is refused `rung_prices_not_yet` when the verifier's instant
|
||||
`at_us` is below its `issued`. A verifier does not stretch the window. One whose clock it does not
|
||||
trust **SHOULD** say which instant it used (holdings §5.1); a plan always states it (§6.6).
|
||||
|
||||
### 5.2 One seller's lists
|
||||
|
||||
Of a seller's lists that are not refused, the one with the greatest `issued` is the seller's
|
||||
**latest**, and every other is `superseded`.
|
||||
|
||||
- Two latest lists with different digests are each set aside, `rung_prices_contradicted`.
|
||||
- Two latest lists with the same digest are one list, whatever their signatures (§4.4).
|
||||
- A latest list whose `expires` is at or before `at_us` is refused `rung_prices_expired`. The
|
||||
seller then has **no standing list**, and an earlier list stays superseded.
|
||||
- Otherwise the latest list **stands**.
|
||||
|
||||
This differs from holdings §5.3 on purpose. A seller that repriced has withdrawn its earlier
|
||||
prices. Reviving them when the later list lapses would read a withdrawn, possibly lower, price as
|
||||
current: the buyer's best case, taken from a statement its seller replaced.
|
||||
|
||||
A list's verdict is written as holdings writes a record's: `stands`, `superseded`,
|
||||
`set-aside reason=rung_prices_contradicted`, or `reject reason=` and the reason.
|
||||
|
||||
## 6. Comparing delivery options
|
||||
|
||||
A comparer computes the following from the lists it is given, the texts it is given as evidence
|
||||
(§4.6), an instant and a request, and a verifier computes the same. Nothing depends on the order
|
||||
the lists or the texts are given in, beyond the positions of the lists' verdicts in the plan, on a
|
||||
text being given twice, on the order of a document's members, or on anything else.
|
||||
|
||||
### 6.1 The request
|
||||
|
||||
A request is a document of at most 65,536 bytes, of exactly these members, each of its form.
|
||||
Otherwise it is refused `rung_request_invalid`, and nothing is derived.
|
||||
|
||||
| member | form |
|
||||
|---|---|
|
||||
| `kind` | `"wai-rung-request"` |
|
||||
| `direction` | `"downlink"` or `"uplink"` |
|
||||
| `to` | a region code (§2): the region delivered to |
|
||||
| `rate_bps` | `null`, or an integer from 1 to 2⁵³ − 1: the rung's rate (§2), held against receiver-basis entries; `null` when the comparer does not know it |
|
||||
| `pass_rate_bps` | `null`, or an integer from 1 to 2⁵³ − 1: the pass rate (§7.2) of the passes the comparer plans, held against transmission-basis entries; `null` when it does not know it |
|
||||
| `bearers` | `null`, or 1 to 3 bearer classes in strictly ascending order of their bytes: the classes it will consider |
|
||||
| `currency` | three capitals `A`–`Z` |
|
||||
| `quantities` | as route-record §3.1: 1 to 16 objects of exactly `unit` and `quantity` (an integer at least 1), in strictly ascending order of `unit`. A receiver unit's quantity is per receiver, and a transmission unit's per transmission. |
|
||||
| `max_price` | as route-record §3.1's `max_price`: 0 to 16 objects of exactly `unit`, `price` and `divisor` (an integer at least 1), in strictly ascending order of `unit` |
|
||||
| `audience` | `null`, or an integer at least 1: the receivers the comparer states a shared transmission reaches |
|
||||
| `sort` | `"price"` or `"energy"`: the first ranking key |
|
||||
| `confirmed` | 0 to 256 objects of exactly `key` (a key: 64 lowercase hexadecimal digits) and `class` (`CalibratedInstrument`, `OnChipCounter` or `ModelBased`), in strictly ascending order of `key` and, for one key, of `class`, by their bytes: the keys the comparer has confirmed for a class |
|
||||
| `evidence_age_us` | an integer from 1 to 2⁵³ − 1: the most a record's instant (§2) may be before the instant used |
|
||||
|
||||
`to` is required: a delivery that names no region cannot be held to any footprint. `bearers`
|
||||
`null` is the comparer's statement that it considers every class; it limits nothing, as
|
||||
route-record §3.1's `regions` `null` limits nothing.
|
||||
|
||||
A key is **confirmed** for a class when the comparer holds, under an anchor it relies on, a head of
|
||||
a key log ([jwp-receipts](jwp-receipts.md) §7.4) with an entry for the key whose `may_sign` holds
|
||||
`live/relay` and `energy/` followed by the class, and that is not revoked, as live-metering §5.2
|
||||
places a hop's key; for a hardware class, only under a pinned anchor (jwp-receipts §7.5,
|
||||
*Pinned-only authorities*). `confirmed` and `evidence_age_us` are the comparer's own statements, as
|
||||
its `audience` is (§6.3), and the plan restates them in the request's digest. A comparison reads no
|
||||
key log: it takes the keys a relying party has confirmed, as bearer-receipts §6.1 takes them.
|
||||
|
||||
### 6.2 Candidates and removals
|
||||
|
||||
The **candidates** are the entries of the lists that stand (§5) whose `direction` is the
|
||||
request's, in ascending order of their sellers' keys, by bytes, and within each list in entry
|
||||
order. A candidate's `entry` is its position in its list's `entries`, from 0.
|
||||
|
||||
A candidate's **rate** is the request's `rate_bps` when its basis is receiver, and its
|
||||
`pass_rate_bps` when its basis is transmission (§4.3). A `null` rate stands for 2⁵³ − 1, which only
|
||||
the unbounded ceiling covers.
|
||||
|
||||
Each candidate is held to every limit below, in this order, and its **removals** list each limit
|
||||
that holds, in this order, with the value the limit read, as route-record §4.3 lists removals:
|
||||
|
||||
| limit | removes a candidate when | value |
|
||||
|---|---|---|
|
||||
| `currency` | its list's `currency` is not the request's | its list's `currency` |
|
||||
| `bearer` | `bearers` is not `null`, and does not list its `bearer` | its `bearer` |
|
||||
| `unit` | the request states no quantity in its `unit`. Units are never converted (route-record §4.2): a `bytes` quantity never prices `bytes_sent`. | its `unit` |
|
||||
| `footprint` | its `footprint` does not hold `to` (§2) | its `footprint` |
|
||||
| `rate` | its `max_bps` is below its rate | its `max_bps` |
|
||||
| `tier` | another entry of its list, of the same `direction`, `bearer` and `unit`, holds `to` and has a `max_bps` at least the candidate's rate and below the candidate's own: a rung is priced at the least ceiling that covers it | the least such `max_bps` |
|
||||
| `unit_price` | `max_price` has an entry for its `unit`, and its `price` × that entry's `divisor` exceeds that entry's `price` × its own `divisor` (route-record §4.3) | its `price` |
|
||||
|
||||
A candidate with no removal is a **survivor**. Rules 4 and 6 and the `tier` limit leave each seller
|
||||
at most one survivor of each bearer class and unit. The least ceiling that covers a rate, for each
|
||||
(`direction`, `bearer`, `unit`) of a list, is found in one pass over its entries.
|
||||
|
||||
### 6.3 Cost and energy per receiver
|
||||
|
||||
For a candidate whose unit the request states a quantity *q* for:
|
||||
|
||||
- its **list cost** is *T* = ⌊*q* × `price` / `divisor`⌋;
|
||||
- its **list energy** is *E* = ⌊*q* × `energy_uj` / `divisor`⌋, when `energy_uj` is stated
|
||||
(route-record §4.2).
|
||||
|
||||
Each is `null` past 2⁵³ − 1, and *E* is `null` when `energy_uj` is. *T* is exactly what a ledger
|
||||
bills for *q* at that price, rounded once (receipt-ledger §3 rule 2). A candidate's `cost` and
|
||||
`energy` are then:
|
||||
|
||||
- **receiver basis** (`bidirectional`): *T* and *E*;
|
||||
- **transmission basis** (`multicast`, `unidirectional`): ⌈*T* / *n*⌉ and ⌈*E* / *n*⌉, where *n*
|
||||
is the request's `audience`, or **1** when it is `null`.
|
||||
|
||||
A candidate the request states no quantity for has a `null` cost and energy, and one whose evidence
|
||||
is refuted (§6.4) a `null` energy.
|
||||
|
||||
**Why 1.** This is bearer-receipts §7's form, ⌈ · / max(1, *n*)⌉, for its reason: unknown is not
|
||||
zero, and no figure divided by an assumed audience is anyone's share. With no stated audience, one
|
||||
receiver bears the whole transmission: the worst case for the only option whose per-receiver
|
||||
figure depends on *n*, since a receiver-basis cost does not change with *n*. Rounding up keeps each
|
||||
receiver's share of *T* an upper bound in integers.
|
||||
|
||||
**A stated audience is the comparer's own statement**, as a relying party's loss bound is its own
|
||||
(carousel §12), and the plan restates it.
|
||||
|
||||
- It **SHOULD** be at most the number of keys counted for an earlier transmission of the same
|
||||
service under bearer-receipts §6.2 step 6, counting only keys the comparer confirmed under the
|
||||
`reconciliation` authority from an origin that gives that authority to one key per receiver
|
||||
(bearer-receipts §7 *Keys are not receivers*, jwp-receipts §7.4), or the `hits` of a tally of an
|
||||
earlier transmission (cache-authority §8.4), which counts only a stated population's keys.
|
||||
Unconfirmed keys can over-count receivers, and dividing by an over-count reads a shared option
|
||||
as cheaper than it is.
|
||||
- It **MUST NOT** be a count of aggregated reports: such reports are not authenticated, and their
|
||||
floor "is a floor of reports, not of people" ([telemetry-aggregation](telemetry-aggregation.md)
|
||||
§13).
|
||||
|
||||
`energy` here is an **allocation over the stated audience**. It **MUST NOT** be reported as
|
||||
bearer-receipts §7's bound, which counts reconciliation keys, nor as any receiver's reading.
|
||||
|
||||
A candidate's `energy` is never a reading of the delivery it prices. A checked figure (§6.4) is the
|
||||
rate its seller's own records state, applied to the request's quantity: its class and its energy
|
||||
rank say what backs that rate, not how the delivery's energy will be read.
|
||||
|
||||
### 6.4 Ranking
|
||||
|
||||
Survivors are ordered by this list of keys: the request's `sort`; the other of `price` and
|
||||
`energy`; `seller`; and `entry`. Two survivors compare by the first key on which they differ:
|
||||
|
||||
- **`price`**: the smaller `cost` first. A `null` comes after every stated cost, and two `null`s
|
||||
do not differ (route-record §4.4).
|
||||
- **`energy`**: evidence first, as energy-steering §6.2 ranks pathways, where the evidence is what
|
||||
the comparer checked and never the class a list names. A survivor's **energy rank** is 4 whenever
|
||||
its `energy` is `null`, a figure past 2⁵³ − 1 and a refuted one among them; otherwise 0 for a
|
||||
`CalibratedInstrument` figure, 1 for `OnChipCounter` and 2 for `ModelBased` when its evidence is
|
||||
`checked`, and 3 for every other figure. The smaller rank first. Within rank 0 or 1, the smaller
|
||||
`energy` × (10⁶ + `energy_ppm`) first; within rank 2 or 3, the smaller `energy` first; two
|
||||
survivors of rank 4 do not differ.
|
||||
- **`seller`**: the smaller key, by its bytes, first.
|
||||
- **`entry`**: the smaller position first.
|
||||
|
||||
Survivors differ in seller or entry, so the order is total. The **order** is the survivors in it,
|
||||
each as its `seller` and `entry`, and each survivor's **rank** is its position there, from 1.
|
||||
|
||||
A candidate's **evidence** is `null` when its entry names no records. Otherwise it is the first of
|
||||
these that holds:
|
||||
|
||||
1. `refuted reason=` and the reason of the first of §4.6 rules 1 to 3 that a given record breaks,
|
||||
its records taken in the order of `energy_evidence`;
|
||||
2. `absent`: a record it names, or the room of one, is not given (§4.6);
|
||||
3. `refuted reason=` and the reason of §4.6 rule 4 or rule 5, the first that fails;
|
||||
4. `outside-window`: a record's instant (§2) is below `at_us` − `evidence_age_us`, or at or after
|
||||
`at_us`;
|
||||
5. `unconfirmed`: `confirmed` does not hold its seller with its `energy_class`;
|
||||
6. `checked`.
|
||||
|
||||
A refuted figure is contradicted by records its own seller named and its own hop signed, as two
|
||||
latest lists of one seller that differ contradict each other (§5.2): it is set aside, and its
|
||||
`energy` is `null` (§6.3). An `absent`, `outside-window` or `unconfirmed` figure is its seller's
|
||||
word, which the comparer cannot check: it ranks as an estimate, whatever class it names, at most
|
||||
[oer-receipts](oer-receipts.md) §2's `projected` (§10). So a class decides nothing unless the
|
||||
seller's own records, current and given, bear its figure out under a key the comparer confirmed for
|
||||
it.
|
||||
|
||||
**Uncertainty.** Within a hardware rank each figure is taken one declared standard uncertainty
|
||||
above itself (energy-measurement §2.3), so a precise figure ranks before a slightly smaller one its
|
||||
seller read less precisely. For 500,000,000 `bytes`, a `CalibratedInstrument` figure of 900,000,000
|
||||
per 10⁹ declared to 20,000 ppm is an `energy` of 450,000,000 µJ, compared as 450,000,000 ×
|
||||
1,020,000 = 459,000,000,000,000; one of 910,000,000 declared to 5,000 ppm is 455,000,000 µJ,
|
||||
compared as 455,000,000 × 1,005,000 = 457,275,000,000,000, and ranks first. The product is a
|
||||
comparison, not a limit: a standard uncertainty bounds nothing (energy-measurement §2.1), and a
|
||||
plan states no product. It is below 2⁸⁶. `ModelBased` and `Estimator` figures carry no declared
|
||||
uncertainty (energy-measurement §2.1), and rank by their figure.
|
||||
|
||||
Figures of two ranks are never compared, so an unknown, unchecked or weaker figure never outranks a
|
||||
checked one, however much smaller; every figure of rank 3 is read as an estimate and compared as
|
||||
one. Route-record §4.4 compares figures whatever their class; this text follows energy-steering
|
||||
§6.2, the stricter rule, with which bearer-receipts §7 and receipt-ledger §6.6 agree.
|
||||
|
||||
### 6.5 The decision and the break-even audience
|
||||
|
||||
`decided_by` is `none` when there is no survivor, `only_survivor` when there is one, and otherwise
|
||||
the first key on which the first two survivors of the order differ: `price`, `energy`, `seller` or
|
||||
`entry`. `runner_up` is the second survivor, as its `seller` and `entry`, or `null` when there are
|
||||
fewer than two.
|
||||
|
||||
Let *c*\* be the least stated `cost` among the receiver-basis survivors. Each transmission-basis
|
||||
survivor's **break-even audience**, `break_even`, is the least audience at which its cost per
|
||||
receiver is no more than *c*\*:
|
||||
|
||||
- `null` when *T* is `null`, or when there is no such *c*\*;
|
||||
- when *c*\* is 0: 1 when *T* is 0, and `null` otherwise;
|
||||
- otherwise max(1, ⌈*T* / *c*\*⌉).
|
||||
|
||||
This holds because ⌈*T* / *n*⌉ ≤ *c* exactly when *n* ≥ *T* / *c*, for an integer *c*. The figure
|
||||
needs no audience and assumes none: it tells a comparer how many receivers make sharing no dearer,
|
||||
and the comparer judges whether it has them.
|
||||
|
||||
### 6.6 Outcome
|
||||
|
||||
The outcome is either a refusal, `rung_request_invalid`, or a **plan** of these members:
|
||||
|
||||
- `request`: the request's digest;
|
||||
- `at_us`: the instant used;
|
||||
- `lists`: for each list given, in the order given, its `verdict` (§5.2) and its `digest`, or
|
||||
`null` for a list refused at reading (§4.5);
|
||||
- `evidence`: the digests of the records and rooms the texts given as evidence give (§4.6), in
|
||||
ascending order of their bytes, each once;
|
||||
- `audience`: the *n* used;
|
||||
- `candidates`: each candidate in §6.2's order, as `seller`, `entry`, `bearer`, `unit`, `max_bps`,
|
||||
`basis` (`receiver` or `transmission`), `removed` (each removal as `limit` and `value`), `cost`,
|
||||
`energy`, `energy_class`, `energy_ppm`, `evidence`, `energy_rank`, `transmission_cost`,
|
||||
`break_even` and `rank`. `energy_ppm` is its entry's; `evidence` and `energy_rank` are §6.4's,
|
||||
for every candidate; `transmission_cost` is *T* and `break_even` §6.5's figure for a
|
||||
transmission-basis survivor, and both are `null` for every other candidate; `rank` is `null` for
|
||||
a removed one;
|
||||
- `order`: the survivors, in order;
|
||||
- `decision`: `decided_by` and `runner_up`.
|
||||
|
||||
**A neighbour order.** An asker of holdings §5.4 gives its neighbours' records "in the order it
|
||||
prefers them", an order it judges itself, and a timeliness plan takes its holder sources in the
|
||||
asker's order and chooses the first that is on time (timeliness §7.1, §7.3). An asker that orders
|
||||
its neighbours by price takes the order from a plan over their lists. A neighbour's list is one
|
||||
whose `seller` is the `holder` of its holdings record (holdings §4.1), the two keys compared byte
|
||||
for byte. The asker's **neighbour order** is then:
|
||||
|
||||
1. the sellers of the plan's `order`, each at its first position there;
|
||||
2. then every other neighbour, in ascending order of its key, by bytes: those with no standing
|
||||
list (§5.2), and those whose list has no survivor.
|
||||
|
||||
A neighbour with no price for the delivery comes after every priced one, never before, as a `null`
|
||||
cost ranks last (§6.4). Holdings §5.4 then keeps, in this order, the neighbours whose record stands and
|
||||
holds the object. A holder that signs its records with a fresh key (holdings §9) has no list under
|
||||
that key, and is ordered among the unpriced: unlinking its records is its choice, and this is its
|
||||
cost.
|
||||
|
||||
## 7. What bills each unit
|
||||
|
||||
### 7.1 The table
|
||||
|
||||
A list is a quote. A buyer is billed only at the tariff it signed (receipt-ledger §4.2). Each list
|
||||
unit is billed by the tariff field that prices the record stating its quantity, and no line is
|
||||
added where a field already prices that record: a second price would bill one quantity twice.
|
||||
|
||||
| list unit | billed by | from | rung in the line |
|
||||
|---|---|---|---|
|
||||
| `subscriber_us` | `room` prices 1 to 9, u32 per second (receipt-ledger §4.1; §6.3 units 2 to 10) | a room-session receipt's `subscriber_us` per layer class | the layer class is the rung (live-metering §3.3): a buyer prices layer class *i* from the tier that covers that layer's rate (live-rooms §8.1, energy-steering §3.1) |
|
||||
| `publisher_us` | `room` price 0, u32 per second (§6.3 unit 1) | its `publisher_us` | none |
|
||||
| `bytes` (`downlink`) | `delivery` prices, u64 per 10⁹, per object class (receipt-ledger §4.1; settlement §7.1), or the `room` price of `bytes_out`, u32 per 10⁹ (receipt-ledger §4.7 item 1; §6.3 unit 14) | acknowledged bytes (settlement §3.2), or a room-session receipt's `bytes_out` | none: no acknowledgement and no room receipt names a rung, so one price bills every rung. A `bytes` price above 2³² − 1 transcribes into `delivery` only. |
|
||||
| `bytes_sent`, `pass_us` | no field of receipt-ledger's tariff | — | — |
|
||||
|
||||
A buyer that signs a tariff from a list copies the entry it accepts for each tier and unit into the
|
||||
corresponding field, and every list price fits its field exactly (§4.3). The copy is the buyer's
|
||||
own transcription, and no verifier checks it: the tariff holds its own prices (receipt-ledger §3).
|
||||
|
||||
**Transmission prices are quotes no tariff bills yet.** Billing one needs a tariff field per
|
||||
bearer class, unit and ceiling, and a ledger source that is a group with its standing carousel
|
||||
schedules and declarations (carousel §6.2, bearer-receipts §4), which receipt-ledger does not
|
||||
define. Until it does, a comparer compares transmission prices (§6), and a ledger bills none.
|
||||
|
||||
### 7.2 The pass rate
|
||||
|
||||
For an entry of a carousel schedule, *P* = `bytes_sent` / `count` is the octets one pass sends: Σ
|
||||
*m* · T over its source blocks for `raptorq`, and L for `no-code` (carousel §5.4). The division is
|
||||
exact. The entry's **pass rate** is ⌈8000 × *P* / `duration_ms`⌉ bits per second, computed in 128
|
||||
bits.
|
||||
|
||||
It is a pure function of the signed schedule. It is the carriage rate the passes take on the
|
||||
bearer, repair symbols included, which is what a seller of broadcast capacity prices by tier. A
|
||||
pass rate above every ceiling has no price.
|
||||
|
||||
### 7.3 Never delivery
|
||||
|
||||
A transmission price prices what a publisher's declaration attests, that it transmitted
|
||||
(bearer-receipts §4), in the octets and pass time its own schedule planned (carousel §5.4). It
|
||||
**MUST NOT** be presented as delivery to anyone (bearer-receipts §6.3; settlement §4.2). 3GPP TS
|
||||
32.279 §5.1.2 charges a 5G multicast-broadcast session the same way: it requires the session's
|
||||
duration, allows its transmitted volume, keeps quota per rating group per session, and names no
|
||||
quantity per receiving device.
|
||||
|
||||
## 8. Reasons
|
||||
|
||||
The codes this extension adds, each with where it is raised:
|
||||
|
||||
| reason | raised at |
|
||||
|---|---|
|
||||
| `rung_prices_malformed` | §4.5, rules 1 to 4 |
|
||||
| `rung_prices_unit_invalid` | §4.5 rule 5 |
|
||||
| `rung_prices_ambiguous` | §4.5 rule 6 |
|
||||
| `rung_prices_signature_invalid` | §4.4, §4.5 |
|
||||
| `rung_prices_evidence_role` | §4.6 rule 1; §6.4 |
|
||||
| `rung_prices_evidence_signer` | §4.6 rule 2; §6.4 |
|
||||
| `rung_prices_evidence_class` | §4.6 rules 3 and 4; §6.4 |
|
||||
| `rung_prices_evidence_figure` | §4.6 rule 5; §6.4 |
|
||||
| `rung_prices_not_yet` | §5.1 |
|
||||
| `rung_prices_expired` | §5.2 |
|
||||
| `rung_prices_contradicted` | §5.2 |
|
||||
| `rung_request_invalid` | §6.1 |
|
||||
|
||||
## 9. Conformance
|
||||
|
||||
[`live-conformance/prices/`](../live-conformance/prices/) holds the corpus, written from fixed
|
||||
seeds by `wai_rung_price_vectors gen` as `vectors.json`. Its regions are ISO 3166-1 user-assigned
|
||||
codes and subdivisions of them, and name no place. It has:
|
||||
|
||||
- **Lists**: lists that read, among them every unit of §4.3 and both shared bearer classes, a
|
||||
country beside another country's subdivision, the bounds of a ceiling, a footprint, a room price
|
||||
and a window, an entry of each class that names records, an uncertainty of 2³² − 1, 64 records
|
||||
named, two entries that differ only in their records and two that differ only in their
|
||||
uncertainty, a list with its members in another order, and one body under a second valid
|
||||
signature; and a negative control for each rule of §4.5, for each unit on a bearer or in a
|
||||
direction where §4.3 does not admit it, for a class above `Estimator` that names no records, an
|
||||
estimate that names some, records with no figure, an uncertainty on a class that declares none or
|
||||
missing from one that does, an uncertainty of 0 or 2³², records named as an empty list, out of
|
||||
order, twice, past 64, in capitals or in a unit that takes none, two entries that are one but for
|
||||
their footprints, records included, and signatures that do not verify, a key of small order and a
|
||||
scalar not below the group order among them.
|
||||
- **Verdicts**: a list at each edge of its window; a later list superseding an earlier one, a later
|
||||
one not yet issued, and a lapsed latest list reviving nothing; one list given twice, one body
|
||||
signed twice, and two lists of one instant that differ.
|
||||
- **Evidence**: per-hop records of two rooms, one a month older than the other, and their
|
||||
descriptors, and comparisons that give each verdict of §6.4 and each reason of §4.6: a figure
|
||||
checked at each class; records summed under the largest uncertainty; a figure rounded up, and one
|
||||
stated a microjoule below it or above it; subscriber time without layer class 8; media bytes
|
||||
without an events track's or the bytes a relay received; a record whose only copy's signature
|
||||
fails, and one that also has a copy that verifies; a room not given, and one whose signature
|
||||
fails; a named digest whose text is not a per-hop record; the window at both edges, and one
|
||||
record of several outside it, named first or last; each verdict of §6.4 where the next that can
|
||||
hold with it holds too; a record that breaks rules 1, 2 and 3, one that breaks rules 2 and 3, two
|
||||
records of which the first breaks rule 3 and the second rule 1, and records that fail rules 4
|
||||
and 5; and a removed candidate's evidence and energy rank. Against an honest model-based figure,
|
||||
a seller that states a calibrated figure with no records named, which does not read; with
|
||||
records it does not give; with its own records under a key nobody confirmed for that class, or
|
||||
confirmed only for another class; with another seller's records; over its counter's records;
|
||||
with a smaller figure or uncertainty than its records state, or a larger uncertainty; with a
|
||||
smaller uncertainty than the record it names first; over records of no delivery, of a compositor
|
||||
or of an ingest gateway; or over a record not given, named first, and one of another class; and
|
||||
a model-based figure over unmetered records. Two calibrated figures ordered by their
|
||||
uncertainty, and a precise figure a hundred times larger after an imprecise one; two counter
|
||||
figures ordered by theirs; and a calibrated figure before a counter's.
|
||||
- **Comparisons**: the cost per receiver of a shared transmission with no audience, below, at and
|
||||
above its break-even; each tier rule, for the rung's rate and the pass rate apart; a region held
|
||||
by its own code or its country's and never upward; every limit, several of them on one candidate;
|
||||
the energy order by checked evidence first, an unknown figure and a figure past the bound last;
|
||||
every decision; holders' prices beside one holder whose list lapsed and one that prices another
|
||||
region, the plan §6.6's neighbour order is taken from; a request that confirms 256 keys; and a
|
||||
refusal for each rule of §6.1.
|
||||
|
||||
A conforming verifier gives every case the outcome the corpus states, member for member.
|
||||
`wai_rung_price_vectors verify` and the test `tests/rung_price.rs` check that the file regenerates
|
||||
byte for byte, that `wai-rs` gives every case its outcome, that every reason of §8 appears and that
|
||||
§8 lists exactly the reference's codes, and that every limit of §6.2, every decision of §6.5, every
|
||||
evidence of §6.4 and every energy rank occurs. `live-conformance/rung_price_verify.py` is the
|
||||
second implementation, written from this text: it shares no code with `wai-rs`; it reads documents
|
||||
with route-record's second implementation, per-hop records with live-metering's
|
||||
(`live_metering_verify.py`), room descriptors with fleet's (`fleet_verify.py`) and signatures with
|
||||
the claim corpus's strict Ed25519 check, writes §2's region code, §4, §5 and §6 itself, mutates
|
||||
copies of the corpus and requires every copy to fail, and drops each of its own checks in turn and
|
||||
requires the corpus to catch the weakened verifier. The bounds no vector carries, a text of
|
||||
1,048,577 bytes, a list of 1,025 entries, a request of 65,537 bytes and an evidence text of
|
||||
1,048,577 bytes, both implementations check themselves.
|
||||
|
||||
## 10. Security and privacy considerations
|
||||
|
||||
- **A list is its seller's word.** It binds the seller to its prices for its window; nothing in it
|
||||
is a measurement. Its energy figure is what the seller expects to spend, and no reading of any
|
||||
delivery: at most [oer-receipts](oer-receipts.md) §2's `projected` grade. A figure whose evidence
|
||||
is `checked` (§6.4) is, besides, the rate its seller's own signed readings state, by stated
|
||||
arithmetic (§4.6), and ranks at their class; any other figure ranks as an estimate.
|
||||
- **A label is not evidence.** A seller can sign any class, and can sign records of any class with
|
||||
its own key: a signature settles who said a number, not how it was read (energy-measurement
|
||||
§2.2). So a class counts only under a key the comparer confirmed for it from an origin it relies
|
||||
on, pinned for a hardware class (§6.1), and only for a figure the seller's own current records
|
||||
bear out (§4.6). A seller that names records it does not give, or that nobody confirmed, ranks as
|
||||
an estimate; one whose own records contradict it ranks with the unknown. Among estimates the
|
||||
smallest figure ranks first, and no rule here tells an honest one from a low one: that is which
|
||||
sellers a comparer accepts.
|
||||
- **Chosen records.** A seller names the records it chooses. A checked figure is the rate of those
|
||||
records' spans, not of all its delivery: a seller that names its most efficient spans states
|
||||
their rate, and `evidence_age_us` bounds only how old their rooms say they are: a room
|
||||
descriptor's `t0` is its authority's word, which nothing here confirms, and a seller may sign its
|
||||
own room.
|
||||
- **Which sellers.** `compare` ranks every list it is given, and a key nobody vouches for, signing
|
||||
a price of 0, ranks first. A comparer gives only lists of sellers it accepts, as an asker orders
|
||||
only its own neighbours (holdings §5.4). A comparer **MAY** accept a seller whose key it places
|
||||
with a serving role ([cache-authority](cache-authority.md) §4.4).
|
||||
- **Ties.** Exact ties of price and energy are broken by key bytes, which a seller can choose.
|
||||
That buys only a tie.
|
||||
- **Unknown is restrictive** (route-record §11): an unstated footprint, rate, pass rate or quantity
|
||||
removes the entry or holds it to the unbounded tier; a `null` cost or energy ranks last; an
|
||||
unknown audience is one receiver; a lapsed list revives no earlier one; and a figure whose
|
||||
records are not given, not current or not confirmed ranks as an estimate, and one its own records
|
||||
refute as unknown.
|
||||
- **Repricing.** The latest list stands alone, and two of one instant that differ offer nothing.
|
||||
- **A stated audience is the comparer's.** A plan with an audience above 1 rests on that
|
||||
statement, and restates it. The break-even audience lets a comparer decide without stating one.
|
||||
- **What a list reveals.** It reveals the seller's prices, tiers and footprint to everyone it
|
||||
reaches. A peer that signs its list with the key of its holdings record links its prices to its
|
||||
holdings (holdings §9); a peer that wants its records unlinked signs with a separate key, and the
|
||||
two are then not joined. A list names its records only by digest; a seller that gives a comparer
|
||||
its per-hop records reveals their rooms, spans, sessions, subscriber time and bytes to that
|
||||
comparer.
|
||||
- **Bounded work.** A list is at most 1,048,576 bytes and a request at most 65,536, each read once.
|
||||
A comparison is one pass over each standing list's entries, and each footprint test is a search
|
||||
in sorted codes. Each text given as evidence is hashed once and read once as a per-hop record and
|
||||
once as a room descriptor, and an entry names at most 64 records.
|
||||
|
|
@ -737,6 +737,8 @@ itself with each of its checks dropped.
|
|||
hop's key that the hop's origin lists with the authority of its role (jwp-receipts §7.5,
|
||||
`live/relay`, `live/compositor`, `live/ingest`), and a sink whose entry names a grant, whose
|
||||
`principal` the acknowledgement must be (§6.3 step 3), which only the room authority can issue.
|
||||
A node that serves from what it holds is placed by `cache/serve` from its operator's origin
|
||||
([cache-authority](cache-authority.md) §4.4).
|
||||
A statement names every downstream key in its lines, so a payer sees whom it is paying for.
|
||||
- **Replay.** Windows are signed and chained (§3.7); a record counts only in the period that
|
||||
holds its window, and a proof pays for one record. A statement for one period cannot reuse a
|
||||
|
|
|
|||
|
|
@ -393,7 +393,7 @@ pairs of a holder and a subject it must purge are the **required** pairs:
|
|||
| `deriver` | the signer of each closure record | the record's inputs whose content hash is in the closure, and its output |
|
||||
| `claimant` | the signer of each closure claim | the claim's inputs whose content hash is in the closure, and its subject |
|
||||
| `publisher` | the signer of each group receipt the closure's groups are expanded from (§6.3) | that group |
|
||||
| `served` | the `upstream` of each acknowledgement or declaration (settlement §3, §4) whose item lists name an object of the closure | each such object |
|
||||
| `served` | the `upstream` of each acknowledgement or declaration (settlement §3, §4) whose item lists name an object of the closure, and the signer of each `cache-transmission` claim ([cache-authority](cache-authority.md) §5) whose `sent` holds an object of the closure, read by position through the group's object receipts as §6.3 reads them | each such object |
|
||||
| `received` | the `downstream` of each acknowledgement whose `role` is a hop (settlement §3.1) and whose item lists name an object of the closure | each such object |
|
||||
|
||||
A party that served a covered object (a cache, a relay or an edge that delivered it to a next
|
||||
|
|
@ -462,6 +462,8 @@ A takedown verifier is given a **case**:
|
|||
- `claims`: origin claim texts (synthesis §3.1), in a list;
|
||||
- `acknowledgements`: acknowledgements and declarations (settlement §3, §4), each its record's
|
||||
bytes and the item list of each of its entries, in order;
|
||||
- `cache_transmissions`: `cache-transmission` claim texts ([cache-authority](cache-authority.md)
|
||||
§5), in a list;
|
||||
- `receipts`: group and object receipts (jwp-receipts §2.4.1, §2.4.2), as JSON values;
|
||||
- `matches`: match texts, in a list;
|
||||
- `purges`: purge texts, in a list;
|
||||
|
|
@ -478,11 +480,14 @@ place, a list of names and positions:
|
|||
`["notice", i]`.
|
||||
4. **The takedown** reads, its signature verifies, and §5.3's rules hold, in their order: at
|
||||
`["takedown"]`.
|
||||
5. **The records**, then **the claims**, then **the acknowledgements**, in their lists'
|
||||
order (§6.1, `evidence_invalid`): at `["record", i]`, `["claim", i]`, `["acknowledgement", i]`.
|
||||
5. **The records**, then **the claims**, then **the acknowledgements**, then **the
|
||||
cache-transmission claims**, in their lists' order (§6.1, `evidence_invalid`): at
|
||||
`["record", i]`, `["claim", i]`, `["acknowledgement", i]`, `["cache_transmission", i]`.
|
||||
An acknowledgement reads under settlement §3.6, its signature verifies, it is given one item
|
||||
list for each of its entries, and each list reads and recomputes against its entry (settlement
|
||||
§3.6); otherwise `evidence_invalid`.
|
||||
§3.6); otherwise `evidence_invalid`. A cache-transmission claim is of that class and passes
|
||||
jwp-receipts §3.1 steps 1 to 4; otherwise `evidence_invalid`. A claim whose group is not among
|
||||
the receipts names no object here.
|
||||
6. **The matches**, each read and its signature verified: at `["match", i]`.
|
||||
7. **The purges**, each read and its signature verified, and §5.5's checks: at `["purge", i]`.
|
||||
8. **The closure** (§6.1, §6.3): a group that is not expanded, at `["group", g]` with *g* its
|
||||
|
|
@ -659,7 +664,9 @@ It has:
|
|||
- **Removals**: a request under a 48-hour deadline met, with a perceptual match purged beside
|
||||
the closure; an order under a 3-hour deadline missed by one holder's late purge, and the same
|
||||
order still pending before its deadline; a removal past its deadline with a holder's purge
|
||||
missing, and one with the purge of the cache the acknowledgements name missing; a removal of
|
||||
missing, one with the purge of the cache the acknowledgements name missing, and a node that
|
||||
declared sending the source over an instance of its own (a cache-transmission claim) with its
|
||||
purge of the source made and missing; a removal of
|
||||
the service's own initiative inside and after a 24-hour reply window,
|
||||
and one whose window a reply ended early; a removal under the unfixed clock; a group as the
|
||||
target, expanded from its receipts; and a take as the target.
|
||||
|
|
@ -680,7 +687,9 @@ of §11 appears, and that §11 lists exactly the reference's codes.
|
|||
shares no code with `wai-rs`; it reads derivation records and delivery receipts with the
|
||||
deriving-edge corpus's second implementation (`edge-conformance/edge_verify.py`), origin claims
|
||||
with the synthesis corpus's (`live-conformance/synthesis_verify.py`), acknowledgements with the
|
||||
settlement corpus's (`live-conformance/settlement_verify.py`), and every signature with
|
||||
settlement corpus's (`live-conformance/settlement_verify.py`), cache-transmission claims through
|
||||
jwp-receipts §3.1 steps 1 to 4 with the claim corpus's (`claim-conformance/claim_verify.py`), and
|
||||
every signature with
|
||||
WAI's strict rule (`claim-conformance/_ed25519.py`); it requires every mutated copy of the
|
||||
corpus to fail, and, with each of its own checks dropped in turn, requires the corpus to catch
|
||||
the weakened verifier. The relay's behaviour is tested by `tests/dvr.rs` in `joule-moq-relay`.
|
||||
|
|
|
|||
|
|
@ -40,8 +40,9 @@ suppression still applies, and a room's cell (§9).
|
|||
|
||||
Out of scope: running aggregators in production; how a deployment chooses its aggregators
|
||||
and agrees a task with them, which DAP leaves out of band (`draft-ietf-ppm-dap-20` §4.2);
|
||||
client authentication and anonymizing proxies (DAP §8.3, §8.4); differential privacy (DAP
|
||||
§8.5); any VDAF but the four Prio3 variants of §3.
|
||||
client authentication and anonymizing proxies (DAP §8.3, §8.4), except that a task of measure 5
|
||||
authenticates its reports by the token of [impression-aggregates](impression-aggregates.md) §7.2;
|
||||
differential privacy (DAP §8.5); any VDAF but the four Prio3 variants of §3.
|
||||
|
||||
Relationship to the core spec: `wai.telemetry.aggregate` is a **record** ([SPEC](../SPEC.md)
|
||||
§5): a signed record a WAI tool emits and verifies. It names no payload format and is not
|
||||
|
|
@ -72,6 +73,7 @@ one Prio3 variant of `draft-irtf-cfrg-vdaf-22` §7.4, with two aggregators and o
|
|||
| 2 | `stalls` | Prio3Sum | `max_measurement` 65535 | `stalls`, at most 65535 |
|
||||
| 3 | `durations` | Prio3SumVec | `length` 3, `max_measurement` 4294967295, `chunk_length` 10 | `[stall_ms, engaged_ms, vst_ms]`, `vst_ms` 0 when `null`, each at most 4294967295 |
|
||||
| 4 | `start_time` | Prio3Histogram | `length` 8, `chunk_length` 3 | the bucket of `vst_ms`: 0 to 5 for a time below 250, 500, 1000, 2000, 4000 and 8000 ms, the first that holds; 6 for a later start; 7 for `null` |
|
||||
| 5 | `impressions` | Prio3Histogram | `length` *L* and `chunk_length` ⌈√*L*⌉ of the catalogue whose digest is `scope` ([impression-aggregates](impression-aggregates.md) §4, §5) | not a session's: one presentation's cell ([impression-aggregates](impression-aggregates.md) §6.2) |
|
||||
|
||||
§3.2 A figure past its measure's largest value is reported as that value, and a sum counts
|
||||
it at that value. Each largest value is past what one session's figures reach in practice: a
|
||||
|
|
@ -98,7 +100,7 @@ A measure runs under one DAP task (`draft-ietf-ppm-dap-20` §4.2). Its configura
|
|||
|---|---|---|
|
||||
| `label` | 8 | `wai-qoe1` (ASCII) |
|
||||
| `measure` | 1 | the measure's code (§3.1) |
|
||||
| `scope` | 32 | 32 zero bytes for a task of the service's own; the room id for a room's (§9.2) |
|
||||
| `scope` | 32 | 32 zero bytes for a task of the service's own; the room id for a room's (§9.2); the digest of its catalogue for a task of measure 5 ([impression-aggregates](impression-aggregates.md) §5.1) |
|
||||
| `salt` | 16 | chosen by the task's author, so that two tasks of one configuration have two ids |
|
||||
| `leader` | 32 | the Ed25519 key the leader signs its statements with |
|
||||
| `helper` | 32 | the helper's |
|
||||
|
|
@ -126,7 +128,10 @@ A task configuration is refused, in this order:
|
|||
type;
|
||||
2. `measure_unknown` when `measure` is no code of §3.1;
|
||||
3. `measure_mismatch` when `vdaf_type` and `vdaf_configuration` are not the measure's: its
|
||||
VDAF identifier (VDAF Table 19) and its DAP Appendix B encoding;
|
||||
VDAF identifier (VDAF Table 19) and its DAP Appendix B encoding. For measure 5, this rule is
|
||||
[impression-aggregates](impression-aggregates.md) §5.3 steps 2 to 4 against the catalogue
|
||||
whose digest is `scope`; a verifier given no such catalogue refuses the task
|
||||
`catalogue_mismatch`;
|
||||
4. `floor_too_low` when `min_batch_size` is below 50, the floor of delivery-telemetry §8
|
||||
(its §8.4);
|
||||
5. `aggregator_alone` when `leader` and `helper` are one key.
|
||||
|
|
@ -150,7 +155,9 @@ report id, the time of the session's `end` or `abandon` in units of the task's
|
|||
`time_precision`, and the measurement of §3.1 sharded and encrypted as DAP §4.4.2.1 does, with
|
||||
the application context `dap-20` followed by the task id. It uploads the reports to the
|
||||
leader (DAP §4.4.2). A session gives at most one report to each task. Its reports carry no
|
||||
`sid`, no principal and no extension.
|
||||
`sid`, no principal and no extension. A task of measure 5 takes its reports from
|
||||
[impression-aggregates](impression-aggregates.md) §6.3 instead: one for each presentation, its
|
||||
time the presentation's, and with attestation one private extension.
|
||||
|
||||
§5.2 **The aggregators.** The leader and the helper run DAP §4.5 and §4.6 as written, over
|
||||
HTTP, with the HPKE suite of DAP §7 (DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, AES-128-GCM):
|
||||
|
|
@ -259,7 +266,9 @@ refusal, which it reports with the reason and where:
|
|||
|
||||
1. **The receipt** reads (§7.2), at `receipt`.
|
||||
2. **The task** reads as a TaskConfiguration and meets §4.3, at `task`: `task_invalid`,
|
||||
`measure_unknown`, `measure_mismatch`, `floor_too_low`, `aggregator_alone`.
|
||||
`measure_unknown`, `measure_mismatch`, `floor_too_low`, `aggregator_alone`. A task of
|
||||
measure 5 is verified by [impression-aggregates](impression-aggregates.md) §8.1, which is
|
||||
given its catalogue.
|
||||
3. **The collector**: `signer` is the `collector` key of `task_info`, otherwise
|
||||
`wrong_signer`; the signature verifies (`bad_key`, `bad_signature`), at `receipt`.
|
||||
4. **The collection** reads as a CollectionJobReq with a time-interval query of a duration of
|
||||
|
|
|
|||
608
wai/extensions/timeliness.md
Normal file
608
wai/extensions/timeliness.md
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
# WAI Extension: Timeliness
|
||||
|
||||
> Status: Draft. Registers the record `wai.delivery.timeliness`: a publisher's signed statement, for
|
||||
> each object of a group, of when the object is of use: the instant it is useful from, the deadline
|
||||
> by which a receiver must hold it for its full value, how that value falls after the deadline, as a
|
||||
> stated function in integers, and the rendition a receiver presents in its place when it does not
|
||||
> hold it by the deadline. It is a jwp-receipts claim of class **timeliness** (jwp-receipts §2.4.17,
|
||||
> §4 here), signed by the group's key beside the group receipt, one statement for every bearer. From
|
||||
> it and from a holder's signed [holdings](holdings.md) record, the signed evidence WAI has that a
|
||||
> party held an object at an instant, a verifier judges each object for each holder: on time,
|
||||
> replaced by its fallback, held only after its deadline and at what value at least, or not shown
|
||||
> (§6). An asker plans, from an instant it states, which of its sources, in its own order, delivers
|
||||
> each object by its deadline: a pass of a [carousel](carousel.md), a neighbour, or the origin (§7).
|
||||
> WAI defines no transport: the deadline is mapped onto the delivery timeouts, request keys,
|
||||
> carousel and window parameters and bundle lifetimes the bearers already define (§8). The reference
|
||||
> implementation is the `timeliness` module of `wai-rs` (feature `timeliness`), with the class's
|
||||
> body in `claim_receipt::timeliness`; the corpus is
|
||||
> [`live-conformance/timeliness/`](../live-conformance/timeliness/), and its second implementation
|
||||
> is `live-conformance/timeliness_verify.py`.
|
||||
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
|
||||
>
|
||||
> Texts read for this revision: `draft-ietf-moq-transport-23` (MOQT; the clauses cited here,
|
||||
> §5.1, §5.2 and §10.1 to §10.4, have the same numbers and meaning in -22, which
|
||||
> [moq-streaming-format](moq-streaming-format.md) binds), CTA-5004-B (CMCD), RFC 9218, RFC 9171
|
||||
> (BPv7), RFC 6726 (FLUTE), ETSI TS 103 769 V1.2.1 (DVB multicast ABR), ATSC A/331:2026-04, and
|
||||
> 3GPP TS 26.346 §11.2A.1.1 as a mirror of Release 19 gives it. Read and not bound (§8.1):
|
||||
> `draft-sharma-moq-end-to-end-delivery-timeout-00` and `draft-frindell-moq-timestamp-00`. A clause
|
||||
> number of one of them in this text is that text's.
|
||||
|
||||
## 1. Scope
|
||||
|
||||
In scope: the timeliness claim (§4); an object's value at an instant, and the rule by which a sink
|
||||
that does not hold it by its deadline is graded (§5); judging, from holdings records, whether each
|
||||
object was held by its deadline (§6); planning which source delivers it by its deadline (§7); and
|
||||
the values a carriage derives from it (§8).
|
||||
|
||||
Out of scope: a transport; how a sink in fact presents, which is its own and is recorded in its
|
||||
`reconstruct-session` claim (jwp-receipts §2.4.12), but for the switch reason of §8.6; who may sign
|
||||
as a holder, and whose word a relying party counts; counting holders, receivers, regions or rates
|
||||
of missing; and prices. A value here is a fraction of the object's full value to its publisher.
|
||||
It is not money, and no tariff grades a charge by it: a tariff prices the quantities that records
|
||||
state ([receipt-ledger](receipt-ledger.md) §4). This text states no energy.
|
||||
|
||||
A layer of a stage set ([staged-delivery](staged-delivery.md) §3) has no entry here and is no
|
||||
fallback: whether a layer is late is decided by staged-delivery §8 alone
|
||||
([moq-streaming-format](moq-streaming-format.md) §4.1), and a layer is not presented on its own
|
||||
([http-delivery](http-delivery.md) §2). §4.3 step 6 refuses both.
|
||||
|
||||
Relationship to the core spec: `wai.delivery.timeliness` is a **record** ([SPEC](../SPEC.md) §5).
|
||||
It names no payload format and is not dispatchable.
|
||||
|
||||
## 2. Terms
|
||||
|
||||
- **Group**, **link hash**, **leaf count**, **content hash**: as [jwp-receipts](jwp-receipts.md)
|
||||
§2.1, §2.4.1 and §3 step 3. Object *i* of a group is its object at position *i*, from 0.
|
||||
- **Instant**: a count of milliseconds since 1970-01-01T00:00:00Z, leap seconds not counted, as
|
||||
[channel-lineup](channel-lineup.md) §2 writes one. **Last instant**: 253,402,300,799,999
|
||||
(9999-12-31T23:59:59.999Z), as carousel §4.2 bounds a pass.
|
||||
- **Value**: an integer from 0 to 1,000,000, parts per million of an object's full value.
|
||||
- **Entry**, **primary**: an entry of a timeliness claim (§4.1), and the object it names.
|
||||
**Fallback**: the object an entry names to present in the primary's place. **End**: §5.2.
|
||||
- **Holder**: a node named by its Ed25519 key that signs holdings records
|
||||
([holdings](holdings.md) §2).
|
||||
- **Held-at statement**: a key, a group's link hash, a position and an instant: the key's signed
|
||||
word that it held that object no later than that instant. Its instant is the latest that word
|
||||
allows, in milliseconds, rounded up where the word is finer. It is **before its group** when the
|
||||
whole seconds of the time its word states, any fraction dropped, are below the seconds
|
||||
jwp-receipts §2.4.3 reads from the group's `group_start`. Such a statement states what cannot
|
||||
be, and is refused `held_before_group`. This revision derives held-at statements from holdings
|
||||
records (§6.2 steps 4 and 5). Another extension that derives them from other signed records
|
||||
states them in this form, under this rule.
|
||||
|
||||
## 3. Why a statement beside the receipt
|
||||
|
||||
WAI has deadlines that are relative leads (`lead_ms`, staged-delivery §8; a refinement's
|
||||
`deadline_ms`, [packaging](packaging.md) §3, its paragraph 3.3), timeouts that each hop restarts
|
||||
(moq-streaming-format §4.1), a sink's own schedule (jwp-receipts §2.4.12 `deadline_us`), a job's
|
||||
billing term ([route-record](route-record.md) §5.2), and a one-way bearer's plan of when a receiver
|
||||
holds an object, at an instant the relying party supplies (carousel §6.4). None is the publisher's
|
||||
signed statement of when an object is of use. That statement is made beside the receipt:
|
||||
|
||||
- **It is the publisher's, about its own group.** The claim is signed by the group's key and names
|
||||
the group by its link hash, as a transmission claim (bearer-receipts §4) and a schedule
|
||||
(carousel §4) do, and it is chained and confirmed as every claim is (jwp-receipts §3.1, §7).
|
||||
- **It is one statement for every bearer.** An object's value to a receiver does not depend on the
|
||||
path that brought it. Whether a bearer can meet it is a question about that bearer, which its
|
||||
schedule (carousel §6.4) and an asker's plan (§7) answer.
|
||||
- **It changes no byte of what it times.** A group carried before this extension, or by a party that
|
||||
does not use it, verifies exactly as before. An object with no statement has none, and is never
|
||||
judged on time (§6.4).
|
||||
- **It is a statement of value, not of delivery.** Nothing in it says that anything was sent or
|
||||
received: that is the evidence §6 reads.
|
||||
|
||||
## 4. The timeliness claim (`class: "timeliness"`)
|
||||
|
||||
A publisher **MAY** state the timeliness of objects of a group it signed in a `timeliness` claim.
|
||||
Its `subject_type` is `receipt` and its `subject` the group receipt's link hash (jwp-receipts §3
|
||||
step 3). It is signed by the key that signed the group. A publisher **MAY** state the objects of a
|
||||
group in one claim or in several; §6.2 step 3 reads them together.
|
||||
|
||||
### 4.1 Body
|
||||
|
||||
| member | type | meaning |
|
||||
|---|---|---|
|
||||
| `objects` | array | The objects stated, one entry each (below). |
|
||||
| `attests` | string | **MUST** be `"valued"`. Any string passes step 2; another value is refused at step 4. |
|
||||
|
||||
Each entry of `objects` has exactly these members:
|
||||
|
||||
| member | type | meaning |
|
||||
|---|---|---|
|
||||
| `leaf_index` | integer | The object's position in the group (jwp-receipts §2.2). |
|
||||
| `content_hash` | 32 B hex | The object's content hash (jwp-receipts §2.1). |
|
||||
| `from_ms` | integer | The instant from which the object is of use. Holding it earlier is no fault and no gain. |
|
||||
| `by_ms` | integer | The deadline: the last instant at which a receiver that first holds the object has it at its full value. |
|
||||
| `decay` | array | 1 to 16 points, each exactly `{ "at_ms": integer, "value_ppm": integer }`: the object's value at instants after `by_ms` (§5). |
|
||||
| `fallback` | object or `null` | The object to present in the primary's place when the primary is not held by `by_ms` (§5.3): exactly `{ "group": 32 B hex, "leaf_index": integer, "content_hash": 32 B hex, "value_ppm": integer }`, the link hash of its group receipt, its position in that group, its content hash, and the value of presenting it in the primary's place. `null` when there is none. |
|
||||
|
||||
Every integer is one of jwp-receipts §2.4.3, read at §3.1 step 2 with the members of each point and
|
||||
of `fallback`, in the tables' order (`field_type`).
|
||||
|
||||
An example body (wire form has no whitespace; jwp-receipts §2.4.3):
|
||||
|
||||
{"attests":"valued","objects":[{"by_ms":1793664060000,
|
||||
"content_hash":"<64 hex>","decay":[{"at_ms":1793664062000,"value_ppm":600000},
|
||||
{"at_ms":1793664070000,"value_ppm":0}],"fallback":{"content_hash":"<64 hex>",
|
||||
"group":"<64 hex>","leaf_index":0,"value_ppm":400000},"from_ms":1793664000000,
|
||||
"leaf_index":0}]}
|
||||
|
||||
### 4.2 Rules
|
||||
|
||||
Checked at jwp-receipts §3.1 step 4, in this order:
|
||||
|
||||
1. `attests` **MUST** be `"valued"` (`timeliness_not_valued`).
|
||||
2. `objects` has at least one entry, and its `leaf_index` values strictly increase
|
||||
(`timeliness_objects_malformed`).
|
||||
3. Each entry in order, the first of these to fail:
|
||||
1. its window is well formed (`timeliness_window_invalid`): `from_ms` is at most `by_ms`, and
|
||||
`by_ms` is below the last instant;
|
||||
2. its decay is well formed (`timeliness_decay_invalid`): 1 to 16 points; their `at_ms`
|
||||
strictly increase, the first above `by_ms` and the last at most the last instant; each
|
||||
`value_ppm` is below 1,000,000 and none above the one before it; the last is 0 and every
|
||||
other at least 1; and no point but the last is on the line through its neighbours: with
|
||||
(*t*<sub>0</sub>, *v*<sub>0</sub>) = (`by_ms`, 1,000,000) and the points
|
||||
(*t*<sub>1</sub>, *v*<sub>1</sub>) … (*t*<sub>n</sub>, *v*<sub>n</sub>), for each *k* from 1
|
||||
to *n* − 1, (*v*<sub>k−1</sub> − *v*<sub>k</sub>) · (*t*<sub>k+1</sub> − *t*<sub>k</sub>)
|
||||
differs from (*v*<sub>k</sub> − *v*<sub>k+1</sub>) · (*t*<sub>k</sub> − *t*<sub>k−1</sub>),
|
||||
computed without overflow (the products reach 2<sup>68</sup>);
|
||||
3. its fallback is well formed (`timeliness_fallback_invalid`): `null`, or a `value_ppm` from 1
|
||||
to 1,000,000, a `content_hash` other than the entry's, and a position other than the
|
||||
entry's own (a `group` other than the claim's `subject`, or a `leaf_index` other than the
|
||||
entry's).
|
||||
|
||||
So a stated line has one form. `by_ms` is the last instant of full value, since no point after it
|
||||
holds full value; nothing is implied, since a hard deadline is the one point (`by_ms` + 1, 0); a
|
||||
step is a fall over one millisecond; and every point is a change of slope, since none but the last
|
||||
lies on the line its neighbours give. Two entries that state different lines are two statements,
|
||||
even where their floored values (§5.1) agree at every instant.
|
||||
|
||||
### 4.3 Against the group
|
||||
|
||||
Checked at jwp-receipts §3.1 step 6 against the group receipt the verifier holds, which has passed
|
||||
jwp-receipts §3 steps 1 and 2 and whose `group_start` is an RFC 3339 date-time (`context_invalid`
|
||||
otherwise; another receipt is `context_mismatch`); the object receipts it holds under that group,
|
||||
each of which has passed jwp-receipts §3 step 5; the other group receipts it holds, each having
|
||||
passed §3 steps 1 and 2, with the object receipts it holds under them; and the envelope bytes it
|
||||
holds, each known by its content hash. In order:
|
||||
|
||||
1. the group's link hash is the claim's `subject` (`timeliness_subject_mismatch`);
|
||||
2. the claim's `signer_pubkey` is the group's (`timeliness_signer_mismatch`): only the publisher
|
||||
states the value of its own objects;
|
||||
3. every `leaf_index` is less than the group's `leaf_count` (`timeliness_leaf_out_of_range`);
|
||||
4. no entry is of use before the group begins (`timeliness_before_group`): ⌊`from_ms` / 1000⌋ is
|
||||
at least the seconds jwp-receipts §2.4.3 reads from `group_start`, its fraction dropped, as
|
||||
carousel §4.3 step 4 reads it. Nothing is of use before it exists;
|
||||
5. for each entry whose object receipt the verifier holds, the receipt's `content_hash` is the
|
||||
entry's (`timeliness_content_mismatch`);
|
||||
6. no entry and no fallback is a layer (`timeliness_on_layer`): an envelope the verifier holds
|
||||
whose content hash is an entry's or a fallback's is not a `WAI1` envelope whose capability is
|
||||
refinement-only (SPEC §5, *Refinement capabilities*; staged-delivery §3);
|
||||
7. each fallback is where it says (`timeliness_fallback_mismatch`): where the verifier holds the
|
||||
group receipt whose link hash is the fallback's `group`, the fallback's `leaf_index` is less
|
||||
than that group's `leaf_count`; and where it holds the object receipt at that position, the
|
||||
receipt's `content_hash` is the fallback's;
|
||||
8. each fallback is a rendition of its primary (`timeliness_fallback_not_derived`): an envelope the
|
||||
verifier holds whose content hash is a fallback's is a `WAI1` envelope whose manifest carries
|
||||
`derived_from` (SPEC §7.1, *Derived renditions*) with `motif` the entry's `content_hash`, as
|
||||
packaging writes on every floor object ([packaging](packaging.md) §7, its paragraph 7.2). A
|
||||
`WAI2` envelope is refused here: it offers renditions a sink selects among (SPEC §7.1), so it
|
||||
names no one rendition the publisher has valued.
|
||||
|
||||
What the verifier does not hold is not checked: a claim checked without its group stops at step 5
|
||||
and is not counted by §6; an entry whose receipt, envelope or fallback group it does not hold
|
||||
passes the steps that need them, and the publisher's word stands there.
|
||||
|
||||
### 4.4 Authority
|
||||
|
||||
Confirmed under jwp-receipts §7, a timeliness claim needs the authority `timeliness`, which is not
|
||||
pinned-only and is not `delivery`. An origin gives it to the keys that publish its groups, as it
|
||||
gives `transmission` and `schedule`.
|
||||
|
||||
## 5. Value over time
|
||||
|
||||
### 5.1 The value at an instant
|
||||
|
||||
An entry's value at an instant *h* is:
|
||||
|
||||
- 1,000,000 when *h* is at most `by_ms`;
|
||||
- otherwise, with the points of `decay` preceded by the point (`by_ms`, 1,000,000), for the two
|
||||
consecutive points (*t*<sub>a</sub>, *v*<sub>a</sub>) and (*t*<sub>b</sub>, *v*<sub>b</sub>) with
|
||||
*t*<sub>a</sub> < *h* ≤ *t*<sub>b</sub>:
|
||||
⌊(*v*<sub>a</sub> · (*t*<sub>b</sub> − *h*) + *v*<sub>b</sub> · (*h* − *t*<sub>a</sub>)) /
|
||||
(*t*<sub>b</sub> − *t*<sub>a</sub>)⌋, computed without overflow (the products reach
|
||||
2<sup>68</sup>);
|
||||
- 0 when *h* is past the last point.
|
||||
|
||||
The value is floored, so it is never above the line the publisher stated, and it never increases
|
||||
with *h* (§4.2 rule 3.2).
|
||||
|
||||
### 5.2 The end
|
||||
|
||||
An entry's **end** is the `at_ms` of its last point: the instant from which an object first held
|
||||
is of no value. Flooring can bring the value to 0 a little before the end; §8 derives from the
|
||||
end, never from an earlier zero.
|
||||
|
||||
### 5.3 The sink's rule
|
||||
|
||||
§6 judges, and §7 plans, for a sink that follows this rule; a sink **MAY** present otherwise, and
|
||||
its session claim records what it did. At `by_ms`:
|
||||
|
||||
- a sink that holds the primary presents it, at full value;
|
||||
- one that does not, and holds the fallback, presents the fallback in the primary's place, at the
|
||||
fallback's `value_ppm`;
|
||||
- one that holds neither presents the primary when it holds it, at its value then (§5.1). A
|
||||
fallback first held after `by_ms` is not presented in the primary's place.
|
||||
|
||||
The rule needs nothing the sink does not hold at `by_ms`. No sink knows at its deadline when a
|
||||
primary it lacks will arrive, so nothing is graded by what arrived after a fallback was due.
|
||||
|
||||
### 5.4 Example
|
||||
|
||||
With `by_ms` *D* and points (*D* + 2000, 600,000) and (*D* + 10,000, 0): the value is 1,000,000 at
|
||||
*D*; 999,800 at *D* + 1; 800,000 at *D* + 1000; 600,000 at *D* + 2000; 300,000 at *D* + 6000; 75
|
||||
at *D* + 9999; and 0 from *D* + 10,000, its end. A hard deadline is the one point (*D* + 1, 0):
|
||||
0 from *D* + 1, its end.
|
||||
|
||||
## 6. Judging delivery
|
||||
|
||||
### 6.1 Inputs
|
||||
|
||||
The group receipts the verifier holds, in an order it chooses (the first is the one a claim about
|
||||
none of them is checked against, §6.2 step 2), each with the object receipts it holds under it and
|
||||
their envelope bytes where it holds them; the timeliness claims it holds about them, each signer's
|
||||
in the order of its chain; the holdings records it holds; and optionally a statement offered
|
||||
(§6.4). These are the verdict's inputs, and the only ones its lines and its statement read.
|
||||
[cache-authority](cache-authority.md) §9 derives lines for an entry by §6.3 from its own inputs,
|
||||
which add acknowledgements and the reconciliations a bearer verdict counts.
|
||||
|
||||
### 6.2 Steps
|
||||
|
||||
1. **The groups and their objects**: each group under jwp-receipts §3 steps 1 and 2, and each of
|
||||
its object receipts as bearer-receipts §6.2 step 2 checks it. A group that fails refuses the
|
||||
verdict with that step's reason.
|
||||
2. **The claims**: each claim, in order, under jwp-receipts §3.1 steps 1 to 5, then step 6 by §4.3
|
||||
here against the group whose link hash is its `subject`, or the first group when none is (so a
|
||||
claim about a group not given is refused `timeliness_subject_mismatch`). A claim of another
|
||||
class is `context_mismatch` at step 6: a schedule is a plan, which carousel §6.4 and §7 here
|
||||
read, and a reconciliation attests no timeliness (bearer-receipts §5.4), so this verdict reads
|
||||
it nowhere. Its signed instant is a held-at statement only in the lines cache-authority §9
|
||||
derives, once that text's verdict counts it (cache-authority §7.2, §8.3). A claim refused at
|
||||
any step takes no further part.
|
||||
3. **The intents**: the entries of the accepted claims, by group and `leaf_index`. Two entries for
|
||||
one object that differ set aside every entry for it (`timeliness_contradicted`): a publisher
|
||||
that states two values for one object has stated neither, and a relying party holds no
|
||||
statement that can change under it. Entries that are the same are one. Otherwise the entry
|
||||
**stands**.
|
||||
4. **The records**: each holdings record as holdings §4.4 reads and verifies it. A record that
|
||||
reads and verifies is refused whole when, for a group given, its entry's `leaf_count` is not
|
||||
the group's (`holdings_leaf_count_mismatch`, holdings §5.2), or its `as_of_ms` is before the
|
||||
group begins (`held_before_group`): ⌊`as_of_ms` / 1000⌋ is below the seconds read from the
|
||||
group's `group_start`, as §4.3 step 4 reads them. A holder that states it held an object before
|
||||
the object existed misstates its holdings. Of one holder's other records, two with one
|
||||
`as_of_ms` and different digests set both aside (`holdings_contradicted`, holdings §5.3), and
|
||||
records with one digest are one. Every other record **stands** at its own `as_of_ms`, and at no
|
||||
other instant: holdings §5.1 holds a record at its `as_of_ms`, which is below its `expires_ms`
|
||||
(holdings §4.4 rule 2), and holdings §5.3 at that instant keeps it alone, since no later record
|
||||
of its holder stands yet.
|
||||
5. **Held-at statements**: for each record that stands and each object it holds (holdings §5.4,
|
||||
*held*): the record's `holder`, the object's group and position, and the record's `as_of_ms`.
|
||||
It is the holder's word that it held the object then; it says nothing of when the object
|
||||
arrived, but that it was no later. These are the held-at statements (§2) this verdict derives,
|
||||
and the only ones its lines and its statement (§6.4) read. Cache-authority §8.3 derives two
|
||||
more kinds, from acknowledgements and counted reconciliations, and its §9 reads all three.
|
||||
Step 4 refused a record dated before its group, by §2's rule.
|
||||
6. **Lines**, by §6.3; the statement, by §6.4.
|
||||
|
||||
### 6.3 Lines
|
||||
|
||||
For each intent that stands, in the order of the groups given and then of `leaf_index`, a verifier
|
||||
derives a **line** for each holder with a record that stands and has an entry for the intent's
|
||||
group or its fallback's group, in increasing order of key bytes. The lines cache-authority §9
|
||||
derives by this rule also have one for each key with a held-at statement of cache-authority §8.3's
|
||||
kinds for an object of the group. For a holder, *p* is the least instant of its held-at statements
|
||||
for the primary, and *f* the least for the fallback; either may have none. Its **outcome** and
|
||||
**value** are the first of these that holds:
|
||||
|
||||
| outcome | when | value |
|
||||
|---|---|---|
|
||||
| `on_time` | *p* is at most `by_ms` | 1,000,000 |
|
||||
| `replaced` | *f* is at most `by_ms` | the fallback's `value_ppm` |
|
||||
| `after_deadline` | *p* is after `by_ms` | the primary's value at *p* (§5.1) |
|
||||
| `not_shown` | §6 only: none of the above | 0 |
|
||||
| `no_source` | §7 only: §7.3 rule 4 | 0 |
|
||||
|
||||
A line reports its holder, *p* and *f* (each `null` where none), its outcome and its value. Each
|
||||
instant is the latest the holder's word allows for the object's arrival, and §5.3 grades what a
|
||||
sink holding exactly what the evidence shows would present, so every value is the least the
|
||||
evidence shows. `after_deadline` says the holder held the object by *p*, not that it arrived after
|
||||
`by_ms`; `not_shown` says only that this evidence shows neither object held in time, nor the
|
||||
primary at all.
|
||||
|
||||
A publisher's promise to a receiver within a loss bound that hears every pass is carousel §6.4's
|
||||
statement with *t* the entry's `by_ms`; to one that tunes in at an instant, a plan (§7). Neither
|
||||
is evidence that anyone held anything, and §6 reads neither.
|
||||
|
||||
### 6.4 A statement offered
|
||||
|
||||
A verifier **MAY** be asked to judge object *i* of a group for a holder. The answer is the outcome
|
||||
and value of that holder's line; `not_shown` and 0 when the holder has none; and refused
|
||||
`deadline_not_stated` when no intent of that object stands (§6.2 step 3): an object with no
|
||||
deadline is never on time.
|
||||
|
||||
## 7. Planning
|
||||
|
||||
### 7.1 Inputs
|
||||
|
||||
§6.1's inputs, without a statement, and with the transmission and schedule claims the asker holds
|
||||
about the groups, each signer's in the order of its chain; optionally a loss bound *L* and an
|
||||
overhead *h* (carousel §2); an instant *t* at which the asker plans, or the word `from_ms`, under
|
||||
which each object is planned from its own entry's `from_ms`; the wanted objects, each a group's
|
||||
link hash and a position; and the asker's **sources**, in the order it prefers them, each one of:
|
||||
|
||||
- a **bearer instance**, by its `bearer_id`;
|
||||
- a **holder**, by its key, with `fetch_ms`: the asker's estimate of the time from asking that
|
||||
holder to holding an object from it;
|
||||
- the **origin**, with `fetch_ms`.
|
||||
|
||||
These are checked first, in this order, and refuse the plan: *L* and *h* as carousel §6.2 checks
|
||||
them (`loss_bound_invalid`, `overhead_invalid`); *t* above the last instant (`instant_invalid`);
|
||||
and the sources (`sources_invalid`) when there is none, when one names a kind not listed, a
|
||||
`bearer_id` that is not an absolute URI, a key that is not 64 lowercase hexadecimal digits or a
|
||||
`fetch_ms` that is not an integer from 1 to 2<sup>53</sup> − 1 (no fetch completes in no time);
|
||||
when two name one bearer instance or one holder, or two are the origin; or when a bearer instance
|
||||
is among them and *L* and *h* are not given. An estimate is the asker's own and is never signed: a
|
||||
plan says what follows from it, not that it will hold.
|
||||
|
||||
### 7.2 Arrivals
|
||||
|
||||
§6.2 steps 1 to 4 run as there, but that step 2 also checks a transmission claim by
|
||||
bearer-receipts §4.3 and a schedule by carousel §4.3; then the declarations (bearer-receipts §6.2
|
||||
step 4) and the schedules (carousel §6.2 step 4). A schedule that stands there stands here. For a
|
||||
wanted object whose intent stands, from the instant *s* (*t*, or its entry's `from_ms`), each
|
||||
source's **arrival** of the primary, and of the fallback where there is one, is:
|
||||
|
||||
- **bearer instance**: where a schedule of the object's group stands on it with an entry for the
|
||||
object, the `complete_ms` of carousel §7's plan from *s*, at *L* and *h*, holding nothing of it;
|
||||
none when that is `null` or there is no such entry;
|
||||
- **holder**: *s* + `fetch_ms` where a record of that holder stands at *s* (holdings §5.1 to §5.3,
|
||||
and §6.2 step 4 here) and holds the object (holdings §5.4); none otherwise;
|
||||
- **origin**: *s* + `fetch_ms`.
|
||||
|
||||
### 7.3 The choice
|
||||
|
||||
For each wanted object, in order: refused `deadline_not_stated` when no intent of it stands;
|
||||
otherwise the first of these that holds, with the outcomes of §6.3:
|
||||
|
||||
1. `on_time`: the first source, in the asker's order, whose arrival of the primary is at most
|
||||
`by_ms`;
|
||||
2. `replaced`: when the fallback's `value_ppm` is at least the greatest value (§5.1) of any
|
||||
arrival of the primary, the first source whose arrival of the fallback is at most `by_ms`;
|
||||
3. `after_deadline`: the source whose arrival of the primary has the greatest value, the first in
|
||||
the asker's order among equals, when that value is at least 1;
|
||||
4. `no_source`.
|
||||
|
||||
A plan reports for each object its outcome, which object (`primary`, `fallback` or `null`), the
|
||||
source by its index in the asker's list, the arrival and the value. A plan whose choice is a late
|
||||
primary fetches no fallback: a sink that holds the fallback by `by_ms` presents it (§5.3). An asker
|
||||
that has no path back lists no holder and no origin; one whose plan for an object is `no_source`
|
||||
has no source that reaches it with any value.
|
||||
|
||||
### 7.4 The publisher's check
|
||||
|
||||
Planned from `from_ms` over one bearer instance alone, a plan states what the publisher's schedule
|
||||
promises a receiver within *L* that starts listening when each object becomes of use: `on_time`
|
||||
for every object is the promise. `after_deadline` names the objects for which such a receiver is
|
||||
not served in time, though one that heard every pass may be (carousel §6.4, at *t* the entry's
|
||||
`by_ms`).
|
||||
|
||||
## 8. Mappings
|
||||
|
||||
Each value below is derived from an entry that stands, for an object that is not a layer (§1). A
|
||||
carriage derives these values and adds no field of its own. Lateness is judged only by §6, never
|
||||
from them. A claim travels as a receipt file on every bearer ([bearer-receipts](bearer-receipts.md)
|
||||
§9.1; [http-delivery](http-delivery.md) §8.1), listed in the receipt index of each object of its
|
||||
group (http-delivery §8.2); no track carries it, so a party derives these values only from a claim
|
||||
it holds.
|
||||
|
||||
### 8.1 MoQ (MOQT, `draft-ietf-moq-transport-23`)
|
||||
|
||||
- **Per hop.** A publisher **SHOULD** set an object's `OBJECT_DELIVERY_TIMEOUT` (MOQT §5.2, §10.2,
|
||||
Property 0x02) to no more than its slack at publication: its end (§5.2), less the instant the
|
||||
publisher passes it to transport, less the publisher's estimate of the path. Set on a subgroup's
|
||||
first object, the property governs the subgroup and is ignored on any other object (MOQT §5.2):
|
||||
a publisher derives a subgroup's timeout only where every object of the subgroup has an entry
|
||||
that stands, at no more than the least of their slacks, and otherwise puts each such object first
|
||||
in a subgroup of its own, as moq-streaming-format §4.1 does for layers. A slack of 0 or less
|
||||
cannot be set, since 0 is no timeout (MOQT §5.2): the object cannot reach anyone with value, and
|
||||
a publisher **MAY** withhold it. On the base track of a stage set, moq-streaming-format §4.1's
|
||||
floor stands: no timeout is shorter than the time from the base's publication to the
|
||||
presentation of its last unit, and where the slack is shorter, the floor is set. The timeout runs
|
||||
at each hop (MOQT §5.2) and is not an end-to-end deadline.
|
||||
- **Priority.** Unchanged. A publisher's priorities are moq-streaming-format §4.1's and packaging
|
||||
§8's, in its paragraph 8.5; MOQT's default of 128 (§10.4) is never relied on. A priority orders
|
||||
objects within a session (MOQT §5.1.1, §5.1.2) and states no instant; the timeout carries the
|
||||
deadline.
|
||||
- `MAX_CACHE_DURATION` (MOQT §10.3) is a Track property that bounds caching, not use, and is not
|
||||
derived.
|
||||
- **End to end: not derived.** `draft-sharma-moq-end-to-end-delivery-timeout-00` defines its
|
||||
timeout for MOQT -22 alone, measures it from the first object a publisher forwards on that
|
||||
publisher's own clock (§4, §5 there), and reads object timestamps whose meaning
|
||||
`draft-frindell-moq-timestamp-00` (§5 there) does not state; a timeout derived from an end could
|
||||
expire an object that still has value. Both are individual drafts, and this text follows them
|
||||
without binding to them.
|
||||
|
||||
### 8.2 HTTP
|
||||
|
||||
- **Abandoning at the end.** A client **MAY** abandon its request for an object at the object's
|
||||
end, on its own clock as http-delivery §10.2 runs a deadline (a clock that steps back has not
|
||||
passed it): past its end the object is of no value. http-delivery §10.1 admits this abandonment
|
||||
of a base and no other; the fetch then ends as one never answered (`base_unanswered`). The
|
||||
client fetches the fallback where its plan (§7) chooses it. Request priority (RFC 9218 §4.1,
|
||||
§4.2; http-delivery §10.1) is unchanged.
|
||||
- **CMCD.** For an object no buffer level governs (a file, a creative, a still, an application
|
||||
package), a sink's `object.dl_ms`, which delivery-telemetry §3 maps to CMCD `dl` (CTA-5004-B
|
||||
§4.3, Table 1: "Deadline from the request time until the first sample of this Segment/Object
|
||||
needs to be available"), is `by_ms` less the request's instant, 0 when that is past; for an
|
||||
object within a buffered timeline, the lesser of that and the buffer level. A sink that holds an
|
||||
object whole by `by_ms` has its first sample by then, so the value is never later than CMCD's
|
||||
own. CTA-5004-B rounds `dl` to the nearest 100 ms, so it can be up to 50 ms late: it is a hint,
|
||||
and nothing is judged from it. Nothing here sets `su`.
|
||||
- **CMSD.** Nothing is derived: CMSD's `at` is an observation of availability at the origin, and
|
||||
`from_ms` a statement of use. A node that discards an object at a timeout derived here counts it
|
||||
in `science.transaction.wai-drop` (delivery-telemetry §6, its paragraph 6.3).
|
||||
|
||||
### 8.3 Broadcast
|
||||
|
||||
- **Carousel.** carousel §6.4's statement "object *i* is held by *t*", with *t* the entry's
|
||||
`by_ms`, is the publisher's plan against its own deadline for a receiver that hears every pass,
|
||||
with carousel's reasons; §7.4 is the plan for one that tunes in at `from_ms`.
|
||||
- **DVB multicast ABR** (ETSI TS 103 769 V1.2.1). A deployment that carries an object as an
|
||||
ancillary resource of an object carousel (§10.2.3.14.3) and states its `@targetAcquisitionLatency`
|
||||
(§10.2.3.14.0: how often it is repeated "so that they can be acquired by a Multicast gateway
|
||||
within the indicated target time") states no more than `by_ms` less `from_ms`: a longer target
|
||||
does not serve a gateway that joins at `from_ms`. The attribute is a repetition target, not a
|
||||
bound under loss; the schedule (carousel §4) is the signed plan. Objects carried as media
|
||||
segments (bearer-receipts §9.3) have no such attribute.
|
||||
- **ATSC 3.0** (A/331:2026-04). A Distribution Window (§7.1.9, Table 7.7) is the broadcaster's
|
||||
schedule of when NRT files are sent, as a carousel schedule is the publisher's signed one; it is
|
||||
read as a plan and nothing is derived. An object that is a broadcaster application's entry
|
||||
package is loaded from its HELD's `@validFrom` (§7.1.8, Table 7.6), and its files are available
|
||||
when the HELD names them (§7.1.8.2): a HELD that names an object whose entry stands sets no
|
||||
`@validFrom` before that entry's `by_ms`.
|
||||
- **MBMS** (3GPP TS 26.346 §11.2A.1.1, `fileSchedule`). A file's delivery windows are the bearer's
|
||||
own schedule, read as a plan; nothing is derived.
|
||||
- **FLUTE** (RFC 6726): an FDT instance's `Expires` (§3.4.2) is the expiry of the description, not
|
||||
of a file, and is not derived.
|
||||
|
||||
### 8.4 Bundles (RFC 9171)
|
||||
|
||||
A node that creates a bundle carrying an object sets its `Lifetime` (§4.3.1) to the object's end
|
||||
less the bundle's creation time (§4.2.7), both in milliseconds, the end converted to DTN time
|
||||
(§4.2.6: from 2000-01-01T00:00:00Z, leap seconds not counted, so less 946,684,800,000). It does not
|
||||
send a bundle for an object whose end is not after the creation time, and a node whose creation
|
||||
time is 0, an unknown clock (§4.2.6), derives no lifetime from the entry.
|
||||
|
||||
### 8.5 WAI
|
||||
|
||||
- **Ads.** An entry for a creative of a break states a `by_ms` no later than the break's splice:
|
||||
its cue's `t` on the room clock ([live-ads](live-ads.md) §4.1, §4.3), as an instant
|
||||
⌊(`t0` + `t`) / 1000⌋ with `t0` the room's start in microseconds since 1970-01-01T00:00:00Z
|
||||
([live-events](live-events.md) §2). The policy's `preload_ms` is when a player may fetch the
|
||||
break's asset list (live-ads §3.1, §5.3, §6.2), not when a creative is of use, and nothing is
|
||||
derived from it.
|
||||
- **Floors.** A packaged piece's floor object (packaging §7, its paragraph 7.2) is its base's
|
||||
natural fallback.
|
||||
- **Holders.** No one gains by holding an object past its end: a holder **MAY** drop it then
|
||||
(holdings §1 leaves what to keep to the node), and lists it in no later record.
|
||||
|
||||
### 8.6 Presentation
|
||||
|
||||
A sink that presents a fallback in a primary's place under §5.3, and whose path (`path`,
|
||||
[delivery-telemetry](delivery-telemetry.md) §2, its paragraph 2.2) changes with it from `primary`
|
||||
to `fallback`, records the switch with the reason `not-held-by-deadline` (delivery-telemetry §5,
|
||||
its paragraph 5.1; jwp-receipts §2.4.12 `switches`). A fallback presented on the primary's path is
|
||||
no switch.
|
||||
|
||||
## 9. Reasons
|
||||
|
||||
The codes this extension adds, each with where it is raised:
|
||||
|
||||
| reason | raised at |
|
||||
|---|---|
|
||||
| `timeliness_not_valued` | §4.2 rule 1 |
|
||||
| `timeliness_objects_malformed` | §4.2 rule 2 |
|
||||
| `timeliness_window_invalid` | §4.2 rule 3.1 |
|
||||
| `timeliness_decay_invalid` | §4.2 rule 3.2 |
|
||||
| `timeliness_fallback_invalid` | §4.2 rule 3.3 |
|
||||
| `timeliness_subject_mismatch` | §4.3 step 1 |
|
||||
| `timeliness_signer_mismatch` | §4.3 step 2 |
|
||||
| `timeliness_leaf_out_of_range` | §4.3 step 3 |
|
||||
| `timeliness_before_group` | §4.3 step 4 |
|
||||
| `timeliness_content_mismatch` | §4.3 step 5 |
|
||||
| `timeliness_on_layer` | §4.3 step 6 |
|
||||
| `timeliness_fallback_mismatch` | §4.3 step 7 |
|
||||
| `timeliness_fallback_not_derived` | §4.3 step 8 |
|
||||
| `timeliness_contradicted` | §6.2 step 3 |
|
||||
| `held_before_group` | §6.2 step 4 |
|
||||
| `deadline_not_stated` | §6.4, §7.3 |
|
||||
| `instant_invalid` | §7.1 |
|
||||
| `sources_invalid` | §7.1 |
|
||||
|
||||
A verdict or plan also raises a group's or object's codes of jwp-receipts §3.3; a claim's codes of
|
||||
jwp-receipts §3.1 steps 1 to 5 and those of step 6 named in §4.3, and in a plan in bearer-receipts
|
||||
§4.3 and carousel §4.3; a record's codes of holdings §7; and a plan `loss_bound_invalid` and
|
||||
`overhead_invalid` (carousel §9) at §7.1, and `bearer_contradicted`, `bearer_not_declared`
|
||||
(bearer-receipts §10), `schedule_contradicted` and `schedule_on_bidirectional` (carousel §9) at
|
||||
§7.2.
|
||||
|
||||
## 10. Conformance
|
||||
|
||||
[`live-conformance/timeliness/`](../live-conformance/timeliness/) holds the corpus, written from
|
||||
fixed seeds by `wai_timeliness_vectors gen` as `vectors.json`. It has:
|
||||
|
||||
- **Claims**: an entry at each bound of §4.2 on both sides — a window of one instant and an
|
||||
inverted one, a deadline one millisecond before the last instant and at it, a decay of one, of
|
||||
sixteen and of seventeen points and an empty one, a point at full value, a step, a plateau and a
|
||||
plateau with an interior point, three points on one line and three just off it, a last point at
|
||||
the last instant and past it, a fallback valued at 1 and at full value — and one negative control
|
||||
for each rule, for the order of the rules within an entry and between entries, and for a body's
|
||||
members at step 2.
|
||||
- **Values**: §5.1 at the deadline, a millisecond after, on and between points, at and past the
|
||||
end, a value floored to 0 before the end, a hard deadline, and a span whose products pass
|
||||
2<sup>64</sup>.
|
||||
- **Verdicts**: holders on time, at the deadline, a millisecond after, after the end, not shown; a
|
||||
fallback in time, at the deadline, after it, before a late primary of lower and of higher value,
|
||||
and a fallback after the deadline before a late primary; a holder's earliest record counting, for
|
||||
the primary and for the fallback; lines from several holders on one intent, each from its own
|
||||
records alone and in key order where the records come in another; records contradicting each
|
||||
other, a record at the start of its group's second and one before it, a record misstating a leaf
|
||||
count and one whose signature fails; intents contradicting and repeating; a schedule given as
|
||||
evidence and refused; and one negative control for each step of §4.3, each beside the case that
|
||||
shows the step passing at its edge.
|
||||
- **Plans**: on time in the asker's order before an earlier source, at the deadline and a
|
||||
millisecond after, a neighbour when the carousel is late, a holder's record expired and not yet
|
||||
standing, a holder asked when only another holder's record holds the object, a fallback chosen and
|
||||
refused on value, chosen at a value equal to the late primary's, from the first source in the
|
||||
asker's order before an earlier one, and arriving at the deadline, late sources chosen by value
|
||||
and order, an arrival only after the end, no source on a path with no return, the origin alone, a
|
||||
schedule with no declaration and one on a two-way instance, every pass heard from the first, the
|
||||
publisher's check from `from_ms` passing and failing, an object with no deadline, an instant at
|
||||
and past the last instant, and each rule of §7.1.
|
||||
|
||||
A conforming verifier gives every case the outcome, the reason and everything else the corpus
|
||||
states. `wai_timeliness_vectors verify` and the test `tests/timeliness.rs` check that the file
|
||||
regenerates byte for byte, that `wai-rs` gives every case its outcome, that every reason of §9 and
|
||||
every outcome of §6.3 appears, that §9 lists exactly the reference's codes and §6.3 its outcomes,
|
||||
that jwp-receipts §3.1 lists the class's codes at their steps, and that delivery-telemetry §5, in
|
||||
its paragraph 5.1, lists the switch reason of §8.6.
|
||||
`live-conformance/timeliness_verify.py` is the second implementation, written from this text: it
|
||||
shares no code with `wai-rs`; it verifies groups and objects with the JWP corpus's second
|
||||
implementation (`jwp-conformance/jwp_verify.py`), claims through steps 1–5 with the claim corpus's
|
||||
(`claim-conformance/claim_verify.py`, whose step 4 holds §4.2), schedules and passes with the
|
||||
carousel corpus's (`live-conformance/carousel_verify.py`) and records with the holdings corpus's
|
||||
(`live-conformance/holdings_verify.py`); it writes §4.3, §5, §6 and §7 itself, reads §9 and §6.3
|
||||
from this text, mutates copies of the corpus and requires every copy to fail, and drops each of
|
||||
its own checks in turn and requires the corpus to catch the weakened verifier.
|
||||
|
||||
## 11. Security and privacy considerations
|
||||
|
||||
- **A statement is its publisher's word, and binds it.** Two statements of one object set both
|
||||
aside (§6.2 step 3), so a publisher cannot move a deadline once a deliverer holds it, and a key
|
||||
that signs a second statement after a miss has stated two things under one key, which is
|
||||
evidence of it. A relying party that agreed to a deadline keeps the claim it agreed to. A
|
||||
deliverer that takes on a deadline checks it first (§7.4).
|
||||
- **A record is its holder's word.** A holder that signs an early `as_of_ms` makes a false
|
||||
statement under its key; one earlier than its group's start is refused (§6.2 step 4). Whose word
|
||||
a relying party counts is its own, and nothing here scores holders.
|
||||
- **Unknown is never on time.** An object with no statement is refused (§6.4), never on time;
|
||||
nothing in an entry is implied; a value is floored; a sink is graded only by what it held at its
|
||||
deadline (§5.3); evidence that does not show an object held is `not_shown` at 0; and every value
|
||||
is the least the evidence shows.
|
||||
- **It discloses a value, not an audience.** A statement names objects and instants that a
|
||||
carriage already exposes through its timeouts and windows. A receiver that plans (§7) over a
|
||||
bearer sends nothing, and a unidirectional bearer's property that the source learns nothing of
|
||||
its receivers ([unidirectional-delivery](unidirectional-delivery.md) §2) is kept. A record used
|
||||
as evidence discloses what holdings §9 says it does.
|
||||
- **Bounded work.** An entry has at most 16 points; a value or a rule is a few 128-bit products; a
|
||||
verdict is linear in entries times holders, and a plan in wanted objects times sources.
|
||||
|
|
@ -342,6 +342,21 @@ def declaration_text(link, fetcher):
|
|||
return t if t is not None else "\u0000" # not UTF-8: no claim reads from it (context_invalid)
|
||||
|
||||
|
||||
class IndexGroup:
|
||||
"""A group of the index as timeliness_verify.check_timeliness reads one: its receipt's link
|
||||
hash, signer, leaf count and start (None where `group_start` is not a date-time), the
|
||||
content hash of each object receipt listed under it here by position, each of which
|
||||
passed inclusion, and no envelope."""
|
||||
|
||||
def __init__(self, g):
|
||||
self.link = JV.link(g).hex()
|
||||
self.signer = g["signer_pubkey"]
|
||||
self.count = g["leaf_count"]
|
||||
self.start = JV.seconds(g["group_start"])
|
||||
self.included = {}
|
||||
self.envelopes = {}
|
||||
|
||||
|
||||
def step6(c, cid, passed, fetcher, exact_algs):
|
||||
"""§3.1 step 6 over HTTP (§8.2): against the delivery items that passed and the
|
||||
declarations the claim names."""
|
||||
|
|
@ -413,6 +428,36 @@ def step6(c, cid, passed, fetcher, exact_algs):
|
|||
if v is None:
|
||||
raise Refused("context_not_held", 6)
|
||||
KV.check_schedule(c, v["g"], [(w["o"], None) for w in passed if JV.link(w["g"]).hex() == c["subject"]])
|
||||
elif cls == "timeliness":
|
||||
# timeliness §4.3: against the group of an item its subject names, the object
|
||||
# receipts of the items listed under it here, and a fallback's group and object
|
||||
# receipt where an item lists them; an index carries no envelope bytes, so its
|
||||
# steps 6 and 8 do not run here.
|
||||
import timeliness_verify as TV
|
||||
held = {}
|
||||
for w in passed:
|
||||
link = JV.link(w["g"]).hex()
|
||||
if link not in held:
|
||||
held[link] = IndexGroup(w["g"])
|
||||
held[link].included[w["o"]["object_id"]] = w["o"]["content_hash"].hex()
|
||||
if c["subject"] not in held:
|
||||
raise Refused("context_not_held", 6)
|
||||
TV.check_timeliness(c, held[c["subject"]], held)
|
||||
elif cls == "cache-transmission":
|
||||
# cache-authority §5.3: against the group of an item its subject names and the
|
||||
# object receipts of the items listed under it here; one naming an object this index
|
||||
# does not list is context_not_held, as a reconciliation's is.
|
||||
import cache_authority_verify as CAV
|
||||
v = next((v for v in passed if JV.link(v["g"]).hex() == c["subject"]), None)
|
||||
if v is None:
|
||||
raise Refused("context_not_held", 6)
|
||||
objs = [w["o"] for w in passed if JV.link(w["g"]).hex() == c["subject"]]
|
||||
try:
|
||||
CAV.check_cache_transmission(c, v["g"], objs)
|
||||
except CV.Reject as r:
|
||||
if r.reason == "cache_transmission_objects_not_held":
|
||||
raise Refused("context_not_held", 6)
|
||||
raise
|
||||
except CV.Reject as r:
|
||||
raise Refused(r.reason, r.step)
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ and the independent verifier of each, beside it.
|
|||
| [`bearers/`](bearers/) | [bearer-receipts](../extensions/bearer-receipts.md) (`wai.bearer.receipts`) | `wai_bearer_vectors gen` | `bearer_verify.py` |
|
||||
| [`carousel/`](carousel/) | [carousel](../extensions/carousel.md) (`wai.bearer.carousel`) | `wai_carousel_vectors gen` | `carousel_verify.py` |
|
||||
| [`holdings/`](holdings/) | [holdings](../extensions/holdings.md) (`wai.delivery.holdings`) | `wai_holdings_vectors gen` | `holdings_verify.py` |
|
||||
| [`timeliness/`](timeliness/) | [timeliness](../extensions/timeliness.md) (`wai.delivery.timeliness`) | `wai_timeliness_vectors gen` | `timeliness_verify.py` |
|
||||
| [`prices/`](prices/) | [rung-price](../extensions/rung-price.md) (`wai.delivery.rung_price`) | `wai_rung_price_vectors gen` | `rung_price_verify.py` |
|
||||
| [`cache-authority/`](cache-authority/) | [cache-authority](../extensions/cache-authority.md) (`wai.delivery.cache`) | `wai_cache_authority_vectors gen` | `cache_authority_verify.py` |
|
||||
| [`impressions/`](impressions/) | [impression-aggregates](../extensions/impression-aggregates.md) (`wai.delivery.impressions`) | `wai_impression_aggregate_vectors gen` | `impression_aggregate_verify.py` |
|
||||
| [`access/`](access/) | [access-services](../extensions/access-services.md) (`wai.access.services`) | `wai_access_services_vectors gen` | `access_services_verify.py` |
|
||||
| [`presentation/`](presentation/) | [ad-presentation](../extensions/ad-presentation.md) (`wai.live.ad_presentation`) | `wai_ad_presentation_vectors gen` | `ad_presentation_verify.py` |
|
||||
| [`age/`](age/) | [age-assurance](../extensions/age-assurance.md) (`wai.age.assurance`) | `wai_age_assurance_vectors gen` | `age_assurance_verify.py` |
|
||||
|
|
|
|||
46
wai/live-conformance/cache-authority/README.md
Normal file
46
wai/live-conformance/cache-authority/README.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# live-conformance/cache-authority — who served what a node holds
|
||||
|
||||
The corpus of [`extensions/cache-authority.md`](../../extensions/cache-authority.md) §12, in one
|
||||
file, `vectors.json`.
|
||||
|
||||
**Provenance.** Generated here by `wai_cache_authority_vectors gen` (`wai-rs`, feature
|
||||
`cache_authority`), from Ed25519 keys on published seeds of one byte repeated 32 times
|
||||
(jwp-receipts §7.8), constant instants, and envelopes of 2,000 bytes whose payloads are opaque test
|
||||
bytes written by the generator; no media and no input from outside this repository.
|
||||
|
||||
`keys` names every key by its role, as 64 lowercase hexadecimal digits: the publisher `P`
|
||||
(`did:web:publisher.example#d1`), the nodes `C1` to `C8` and the receivers `R1` to `R4` and `R9`.
|
||||
`groups` gives the link hashes of `G` (four objects, the tallied group), `G9` (nine), `GL` (labelled
|
||||
leaves), `GX` (a `group_start` that is not a date-time) and `GF` (four, its object 2 a floor derived
|
||||
from its object 1). `heads` holds the two test logs' head revisions, of `https://operator.example`
|
||||
and `https://other.example`; `C2` is in neither.
|
||||
|
||||
- `claims` (§5) are each a `claim`, the signer's `earlier` claims in chain order, the `context`
|
||||
it is checked against (a group receipt, another receipt, or `null`) and the `objects` held under
|
||||
it; `outcome` is `accept`, `unchecked` (no group: it stops at step 5) or
|
||||
`reject step=N reason=…`.
|
||||
- `placements` (§4.4) are each a `key`, an instant `at_ms` and the `heads` named, keyed by origin;
|
||||
`outcome` is the placement as §4.4 writes it.
|
||||
- `verdicts` (§7) are each the `group`, the `objects` (receipt, and envelope or `null`), the
|
||||
`claims`, a `sink`'s own instance, a statement offered (`assertion`), `count_only` and the
|
||||
`heads`; `outcome` gives each object's and each claim's label, the status, `sinks`, `whole`,
|
||||
`received_by`, the publisher's `bearers` and the `caches` that stand, the sink's outcome and the
|
||||
statement's (`holds` or `reject reason=…`), or `refuse reason=…` for a group that fails.
|
||||
- `tallies` (§8) are a verdict's inputs, the `acknowledgements` (each a record and its item lists,
|
||||
in hexadecimal), the holdings `records`, the object's `index`, a `deadline_ms` or a timeliness
|
||||
`deadline_claim`, the `region`, the `population` and `miss_target_ppm`; `outcome` is `met` or
|
||||
`refuse reason=miss_target_exceeded` with §8.5's figures, or `refuse reason=…` for §8.2.
|
||||
- `lines` (§9) are a verdict's inputs, acknowledgements and holdings records, a timeliness `claim`
|
||||
and an `index`; `outcome` is timeliness §6.3's lines for that object's entry, from held-at
|
||||
statements of all three kinds.
|
||||
|
||||
Everything is synthetic: keys attest nothing, both key logs are test logs, origins and bearer
|
||||
instances are reserved example domains, and regions are ISO 3166-1's user-assigned codes. Each case
|
||||
states the outcome it was written to have, declared in the generator, never copied from a
|
||||
verifier. §8.1's bound of 1,048,576 keys is carried by no vector (32 MiB of hexadecimal); both
|
||||
implementations check it themselves.
|
||||
|
||||
`wai_cache_authority_vectors verify` and `tests/cache_authority.rs` check that the file regenerates
|
||||
byte for byte and that `wai-rs` reaches every stated outcome. `../cache_authority_verify.py` is the
|
||||
second implementation, written from the extension's text, which also mutates copies of the corpus
|
||||
and drops each of its own checks in turn and requires each to be caught.
|
||||
17591
wai/live-conformance/cache-authority/vectors.json
Normal file
17591
wai/live-conformance/cache-authority/vectors.json
Normal file
File diff suppressed because it is too large
Load diff
940
wai/live-conformance/cache_authority_verify.py
Normal file
940
wai/live-conformance/cache_authority_verify.py
Normal file
|
|
@ -0,0 +1,940 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Independent verifier of the cache authority corpus (extensions/cache-authority.md).
|
||||
|
||||
Written from the extension's text. It shares no code with wai-rs, which generates the corpus,
|
||||
and never runs it. It takes from the second implementations of the texts this extension builds
|
||||
on what they define: group and object receipts, leaves and link hashes from the JWP corpus's
|
||||
(`../jwp-conformance/jwp_verify.py`, jwp-receipts §2.2, §3); a claim's steps 1 to 5 from the
|
||||
claim corpus's (`../claim-conformance/claim_verify.py`, jwp-receipts §3.1, whose steps 2 and 4
|
||||
hold §5.1 and §5.2 here); a transmission's and a reconciliation's step 6 and the root over
|
||||
leaves from the bearer corpus's (`bearer_verify.py`, bearer-receipts §4.3, §5.3); an
|
||||
acknowledgement and its item lists from the settlement corpus's (`settlement_verify.py`,
|
||||
settlement §3.6); a key log revision held under rules 1 to 3 from the signer corpus's
|
||||
(`../signer-conformance/signer_verify.py`, jwp-receipts §7.2); the held-at statement's rule, a
|
||||
holdings record held to its group, a timeliness claim against its group and timeliness lines
|
||||
from the timeliness corpus's (`timeliness_verify.py`, timeliness §2, §4.3, §6); and region codes
|
||||
from the rung price corpus's (`rung_price_verify.py`, rung-price §2). Everything this extension
|
||||
adds is written here:
|
||||
|
||||
- §4.4: placing a server key by the heads a relying party names;
|
||||
- §5.3: a cache-transmission claim against its group and the object receipts held;
|
||||
- §7: the bearer verdict with the caches' instances beside the publisher's, the credit per
|
||||
instance, the status, a statement offered, a sink's own instance and the energy bound;
|
||||
- §8: the population, the tally's checks, the held-at statements of acknowledgements and counted
|
||||
reconciliations, the count, its servers and the outcome;
|
||||
- §9: timeliness lines that read acknowledgements and counted reconciliations too.
|
||||
|
||||
For every case it requires the outcome the corpus states, field for field; that every reason of
|
||||
§11's table appears in the corpus; then it mutates copies of the corpus, one defect each, and
|
||||
requires every copy to fail; and it drops each of its own checks in turn and requires the corpus
|
||||
to catch the weakened verifier. A bound no vector carries is checked directly.
|
||||
|
||||
python3 cache_authority_verify.py <live-conformance/cache-authority>
|
||||
|
||||
Exit status 0 only if every case verifies, every mutated copy fails, every weakened verifier is
|
||||
caught and every direct check holds. Python 3.8 or later; the `cryptography` package, which the
|
||||
claim verifier imports.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
for d in ("claim-conformance", "jwp-conformance", "signer-conformance"):
|
||||
sys.path.insert(0, os.path.join(HERE, "..", d))
|
||||
import claim_verify as CV # noqa: E402
|
||||
import jwp_verify as JV # noqa: E402
|
||||
import signer_verify as SG # noqa: E402
|
||||
import bearer_verify as BV # noqa: E402
|
||||
import settlement_verify as SET # noqa: E402
|
||||
import timeliness_verify as TV # noqa: E402
|
||||
import rung_price_verify as RP # noqa: E402
|
||||
|
||||
HV = TV.HV # the holdings corpus's reader, which the timeliness verifier loads
|
||||
|
||||
EXTENSION = os.path.join(HERE, "..", "extensions", "cache-authority.md")
|
||||
JWP_RECEIPTS = os.path.join(HERE, "..", "extensions", "jwp-receipts.md")
|
||||
SETTLEMENT = os.path.join(HERE, "..", "extensions", "settlement.md")
|
||||
LAST_INSTANT = 253_402_300_799_999 # §2, timeliness §2: 9999-12-31T23:59:59.999Z
|
||||
MAX_TARGET = 999_999 # §8.2 step 2
|
||||
MAX_KEYS = 1 << 20 # §8.1: 1,048,576 keys
|
||||
PPM = 1_000_000
|
||||
POPULATION = b"wai:cache-population\x01" # §8.1
|
||||
SERVING = ("cache/serve", "live/relay", "live/compositor", "live/ingest") # §4.4, in order
|
||||
DECLARATION = 2 # settlement §3.1 `mode`
|
||||
|
||||
# A weakened run drops one of these checks (see `WEAKENINGS`); None checks everything.
|
||||
DROPPED = None
|
||||
|
||||
|
||||
def drops(name):
|
||||
return DROPPED == name
|
||||
|
||||
|
||||
def reasons_table():
|
||||
"""§11's codes, in order, each with where the table says it is raised."""
|
||||
text = TV.text_of(EXTENSION)
|
||||
at = text.index("\n## 11. Reasons")
|
||||
end = text.find("\n## ", at + 1)
|
||||
rows = text[at:end].splitlines()
|
||||
return [(r.split("`")[1], r.split("|")[2].strip()) for r in rows if r.startswith("| `")]
|
||||
|
||||
|
||||
def need(ok, reason):
|
||||
"""jwp-receipts §3.1 step 6."""
|
||||
if not ok:
|
||||
raise CV.Reject(6, reason)
|
||||
|
||||
|
||||
def positions(hexbits):
|
||||
"""§2 *Bitmap*: the positions whose bits are set, object i being bit 0x80 >> (i mod 8) of
|
||||
byte ⌊i / 8⌋."""
|
||||
bits = bytes.fromhex(hexbits)
|
||||
return [i for i in range(8 * len(bits)) if bits[i // 8] & (0x80 >> (i % 8))]
|
||||
|
||||
|
||||
def ceil_div(a, b):
|
||||
return -(-a // b)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ §8.3: instants
|
||||
|
||||
def issued_ms(t):
|
||||
"""§8.3: a claim's `issued_at` as jwp-receipts §2.4.3 reads it, rounded up to the millisecond
|
||||
(1000 × its seconds + ⌈f × 1000⌉, f its written fraction), and its whole seconds."""
|
||||
s = JV.seconds(t)
|
||||
m = re.match(r"\.([0-9]+)", t[19:])
|
||||
digits = m.group(1) if m else ""
|
||||
num, den = int(digits or "0"), 10 ** len(digits)
|
||||
if drops("rec_second"):
|
||||
ms = 1000 if num else 0
|
||||
elif drops("rec_round"):
|
||||
ms = num * 1000 // den
|
||||
else:
|
||||
ms = ceil_div(num * 1000, den)
|
||||
return 1000 * s + ms, s
|
||||
|
||||
|
||||
def ack_ms(end):
|
||||
"""§8.3: an acknowledgement's window's last microsecond, `end` − 1, rounded up to the
|
||||
millisecond, and its whole seconds."""
|
||||
last = end if drops("ack_last_us") else end - 1
|
||||
return (last // 1000 if drops("ack_round") else ceil_div(last, 1000)), last // 1_000_000
|
||||
|
||||
|
||||
def before(word_s, start, check):
|
||||
"""timeliness §2's rule for a statement before its group."""
|
||||
return not drops(check) and TV.before_group(word_s, start)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ §4.4: placing a server key
|
||||
|
||||
_HEADS = {}
|
||||
|
||||
|
||||
def head(origin, text):
|
||||
"""A head held for `origin` (jwp-receipts §7.2 rules 1 to 3), or None."""
|
||||
k = (origin, text)
|
||||
if k not in _HEADS:
|
||||
try:
|
||||
r = SG.parse_revision(text)
|
||||
SG.check_held(r, origin)
|
||||
except (SG.Stop, TypeError):
|
||||
r = None
|
||||
_HEADS[k] = r
|
||||
return _HEADS[k]
|
||||
|
||||
|
||||
def later(at_ms, until):
|
||||
"""§4.4: `at_ms` is later than a retired entry's `until` when `at_ms` > `until` × 1000."""
|
||||
u = SG.secs(until) * 1000
|
||||
return at_ms >= u if drops("until_inclusive") else at_ms > u
|
||||
|
||||
|
||||
def roles_of(e):
|
||||
"""The serving roles an entry's `may_sign` holds, in §4.4's order."""
|
||||
if drops("serving"):
|
||||
return list(e["may_sign"])
|
||||
return [r for r in SERVING if r in e["may_sign"]]
|
||||
|
||||
|
||||
def place(key, at_ms, heads):
|
||||
"""§4.4: the placement of the key `key` (hexadecimal) for an item at `at_ms`, by the heads
|
||||
named, keyed by origin, as §4.4 writes it."""
|
||||
origins = sorted(heads, key=lambda o: o.encode(), reverse=drops("least_origin"))
|
||||
held = [(o, head(o, heads[o])) for o in origins]
|
||||
listed = []
|
||||
for o, r in held:
|
||||
e = None if r is None else next((e for e in r["keys"] if e["key"] == key), None)
|
||||
if e is not None:
|
||||
listed.append((o, e))
|
||||
first = []
|
||||
revoked = next(((o, e) for o, e in listed if e["status"] == "revoked"), None)
|
||||
if revoked is not None and not drops("revoked"):
|
||||
first.append(f"refused reason=key_revoked origin={revoked[0]} "
|
||||
f"compromised_since={revoked[1]['compromised_since']}")
|
||||
unheld = next((o for o, r in held if r is None), None)
|
||||
if unheld is not None and not drops("not_held"):
|
||||
first.append(f"unconfirmed reason=key_log_not_held origin={unheld}")
|
||||
if first:
|
||||
return first[-1] if drops("revoked_first") else first[0]
|
||||
for o, e in listed:
|
||||
roles = roles_of(e)
|
||||
in_force = e["status"] == "active" or (e["status"] == "retired"
|
||||
and (drops("until") or not later(at_ms, e["until"])))
|
||||
if roles and in_force:
|
||||
out = f"confirmed origin={o} key_id={e['id']} role={roles[0]}"
|
||||
return out + (f" until={e['until']}" if e["status"] == "retired" else "")
|
||||
retired = next(((o, e) for o, e in listed if roles_of(e) and e["status"] == "retired"), None)
|
||||
if retired is not None:
|
||||
return f"refused reason=key_retired origin={retired[0]} until={retired[1]['until']}"
|
||||
bare = next((o for o, e in listed if not roles_of(e)), None)
|
||||
if bare is not None:
|
||||
return f"refused reason=authority_not_granted origin={bare}"
|
||||
return "unconfirmed reason=server_not_listed"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ §5.3: step 6
|
||||
|
||||
def check_cache_transmission(c, g, objects):
|
||||
"""§5.3 against a group receipt that passed jwp-receipts §3 steps 1 and 2 (as jwp_verify reads
|
||||
it), and the object receipts held under it, of which those that pass §3 step 5 are used."""
|
||||
b = c["body"]
|
||||
need(drops("ct_subject") or JV.link(g).hex() == c["subject"], "cache_transmission_subject_mismatch")
|
||||
need(drops("ct_publisher") or c["signer_pubkey"] != g["signer_pubkey"], "cache_transmission_by_publisher")
|
||||
need(drops("ct_leaf_count") or b["leaf_count"] == g["leaf_count"], "cache_transmission_leaf_count_mismatch")
|
||||
sent = positions(b["sent"])
|
||||
if len(sent) == g["leaf_count"]:
|
||||
root = bytes.fromhex(g["root_hash"])
|
||||
else:
|
||||
leaves = []
|
||||
for i in sent:
|
||||
o = next((o for o in objects if o["object_id"] == i and BV.included(o, g)), None)
|
||||
if o is None and drops("ct_held"):
|
||||
continue
|
||||
need(o is not None, "cache_transmission_objects_not_held")
|
||||
leaves.append(JV.leaf(o))
|
||||
root = BV.merkle_root(leaves)
|
||||
need(drops("ct_root") or root.hex() == b["sent_root"], "cache_transmission_root_mismatch")
|
||||
|
||||
|
||||
def read_objects(receipts):
|
||||
"""The object receipts given that read (jwp-receipts §2.4.2)."""
|
||||
out = []
|
||||
for o in receipts:
|
||||
try:
|
||||
out.append(JV.read_object(o))
|
||||
except JV.Refuse:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def context_group(ctx):
|
||||
"""§5.3: the group receipt a claim is checked against, after jwp-receipts §3 steps 1 and 2."""
|
||||
if not (isinstance(ctx, dict) and ctx.get("kind") == "group") and not drops("context_kind"):
|
||||
raise CV.Reject(6, "context_mismatch")
|
||||
try:
|
||||
g = JV.read_group(ctx)
|
||||
JV.verify_group(g)
|
||||
except JV.Refuse:
|
||||
raise CV.Reject(6, "context_invalid")
|
||||
return g
|
||||
|
||||
|
||||
def claim_outcome(case):
|
||||
"""A claim through jwp-receipts §3.1 steps 1 to 6 against the context the case gives, its
|
||||
signer's previous claim the last of the earlier ones held."""
|
||||
try:
|
||||
held = {}
|
||||
for wire in case["earlier"]:
|
||||
try:
|
||||
e = TV.claim_steps(wire)
|
||||
held[e["signer_pubkey"]] = e
|
||||
except CV.Reject:
|
||||
pass
|
||||
c = TV.claim_steps(case["claim"])
|
||||
CV.check_chain(c, held.get(c["signer_pubkey"]))
|
||||
if case["context"] is None and not drops("unchecked"):
|
||||
return "unchecked"
|
||||
if c["class"] != "cache-transmission":
|
||||
raise CV.Reject(6, "context_mismatch")
|
||||
check_cache_transmission(c, context_group(case["context"]), read_objects(case["objects"]))
|
||||
return "accept"
|
||||
except CV.Reject as r:
|
||||
return f"reject step={r.step} reason={r.reason}"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ §7: the verdict
|
||||
|
||||
class Verdict:
|
||||
"""§7.2 over a case's inputs, with what §7.3, §8 and §9 read from it."""
|
||||
|
||||
def __init__(self, case, count_only):
|
||||
self.g = TV.read_groups([{"group": case["group"], "objects": case["objects"]}])[0]
|
||||
gr = self.g.receipt
|
||||
objs = [ob for ob, _ in self.g.pairs]
|
||||
# Step 2: the claims.
|
||||
labels, accepted, held = [], [], {}
|
||||
for wire in case["claims"]:
|
||||
try:
|
||||
c = TV.claim_steps(wire)
|
||||
except CV.Reject as r:
|
||||
labels.append(f"reject step={r.step} reason={r.reason}")
|
||||
accepted.append(None)
|
||||
continue
|
||||
previous = held.get(c["signer_pubkey"])
|
||||
held[c["signer_pubkey"]] = c
|
||||
try:
|
||||
CV.check_chain(c, previous)
|
||||
if c["class"] == "transmission":
|
||||
BV.check_transmission(c, gr)
|
||||
elif c["class"] == "reconciliation":
|
||||
BV.check_reconciliation(c, gr, objs)
|
||||
elif c["class"] == "cache-transmission":
|
||||
check_cache_transmission(c, gr, objs)
|
||||
else:
|
||||
raise CV.Reject(6, "context_mismatch")
|
||||
except CV.Reject as r:
|
||||
labels.append(f"reject step={r.step} reason={r.reason}")
|
||||
accepted.append(None)
|
||||
continue
|
||||
labels.append(None)
|
||||
accepted.append(c)
|
||||
|
||||
def by_instance(cls):
|
||||
out = {}
|
||||
for i, c in enumerate(accepted):
|
||||
if c is not None and c["class"] == cls:
|
||||
out.setdefault(c["body"]["bearer_id"], []).append(i)
|
||||
return out
|
||||
|
||||
# Step 3: the publisher's declarations (bearer-receipts §6.2 step 4).
|
||||
self.pub, self.pub_aside = {}, set()
|
||||
for bid, idx in by_instance("transmission").items():
|
||||
bodies = [accepted[i]["body"] for i in idx]
|
||||
stands = all(b == bodies[0] for b in bodies)
|
||||
if stands:
|
||||
self.pub[bid] = bodies[0]
|
||||
else:
|
||||
self.pub_aside.add(bid)
|
||||
for i in idx:
|
||||
labels[i] = "declared" if stands else "set-aside reason=bearer_contradicted"
|
||||
# Step 4: the caches' instances.
|
||||
self.caches, self.cache_aside = {}, set()
|
||||
for bid, idx in by_instance("cache-transmission").items():
|
||||
cs = [accepted[i] for i in idx]
|
||||
taken = (bid in self.pub or bid in self.pub_aside) and not drops("taken")
|
||||
same = drops("cache_contradiction") or all(
|
||||
(x["signer_pubkey"], x["body"]) == (cs[0]["signer_pubkey"], cs[0]["body"]) for x in cs)
|
||||
if drops("same_claim"):
|
||||
same = same and all(x["issued_at"] == cs[0]["issued_at"] for x in cs)
|
||||
if not taken and not same:
|
||||
self.cache_aside.add(bid)
|
||||
if taken or not same:
|
||||
for i in idx:
|
||||
labels[i] = "set-aside reason=bearer_contradicted"
|
||||
continue
|
||||
self.caches[bid] = {"claims": cs, "body": cs[0]["body"], "server": cs[0]["signer_pubkey"],
|
||||
"sent": positions(cs[0]["body"]["sent"])}
|
||||
for i in idx:
|
||||
labels[i] = "cache-declared"
|
||||
# Step 5: the reconciliations.
|
||||
counted, self.bits, self.instance_bits, self.counted = set(), {}, {}, []
|
||||
for i, c in enumerate(accepted):
|
||||
if c is None or c["class"] != "reconciliation":
|
||||
continue
|
||||
b, key = c["body"], c["signer_pubkey"]
|
||||
bid = b["bearer_id"]
|
||||
d, ci = self.pub.get(bid), self.caches.get(bid)
|
||||
stated = d if d is not None else None if ci is None else ci["body"]
|
||||
if bid in self.pub_aside or bid in self.cache_aside:
|
||||
aside = "bearer_contradicted"
|
||||
elif stated is None:
|
||||
aside = "bearer_not_declared"
|
||||
elif stated["bearer"] != b["bearer"]:
|
||||
aside = "bearer_contradicted"
|
||||
elif d is None and key == ci["server"] and not drops("self_ack"):
|
||||
aside = "self_acknowledged"
|
||||
elif count_only is not None and key not in count_only:
|
||||
aside = "sink_not_confirmed"
|
||||
else:
|
||||
aside = None
|
||||
if aside:
|
||||
labels[i] = "set-aside reason=" + aside
|
||||
continue
|
||||
labels[i] = "joined reason=reconciliation_duplicate_sink" if (key, bid) in counted else "counted"
|
||||
counted.add((key, bid))
|
||||
got = positions(b["received"])
|
||||
self.bits.setdefault(key, set()).update(got)
|
||||
self.instance_bits.setdefault(bid, {}).setdefault(key, set()).update(got)
|
||||
self.counted.append(i)
|
||||
self.labels, self.accepted = labels, accepted
|
||||
|
||||
def outcome(self, case):
|
||||
"""§7.2 step 6, §7.3 and §7.4, as the corpus states them."""
|
||||
g, heads = self.g, case["heads"]
|
||||
n = g.count
|
||||
sinks = len(self.bits)
|
||||
whole = sum(1 for s in self.bits.values() if len(s) == n)
|
||||
received_by = [sum(1 for s in self.bits.values() if j in s) for j in range(n)]
|
||||
|
||||
def energy(e, k):
|
||||
if e is None:
|
||||
return None, None
|
||||
j = e["joules_micro"]
|
||||
share = j // max(1, k) if drops("energy_ceiling") else ceil_div(j, max(1, k))
|
||||
return {"joules_micro": j, "class": e["energy_provenance"]}, share
|
||||
|
||||
bearers = []
|
||||
for bid in sorted(self.pub, key=lambda s: s.encode()):
|
||||
d = self.pub[bid]
|
||||
k = len(self.instance_bits.get(bid, {}))
|
||||
e, share = energy(d["energy"], k)
|
||||
bearers.append({"bearer_id": bid, "bearer": d["bearer"], "sinks": k, "energy": e,
|
||||
"per_receiver_upper_bound": share})
|
||||
caches = []
|
||||
for bid in sorted(self.caches, key=lambda s: s.encode()):
|
||||
ci = self.caches[bid]
|
||||
mine = self.instance_bits.get(bid, {})
|
||||
if drops("per_instance"):
|
||||
mine = {k: self.bits[k] for k in mine}
|
||||
at = (min if drops("cache_earliest") else max)(issued_ms(x["issued_at"])[0] for x in ci["claims"])
|
||||
if drops("cache_instant"):
|
||||
at = g.start * 1000
|
||||
e, share = energy(ci["body"]["energy"], len(mine))
|
||||
sent = list(range(n)) if drops("sent_only") else ci["sent"]
|
||||
caches.append({"bearer_id": bid, "bearer": ci["body"]["bearer"], "server": ci["server"],
|
||||
"placement": place(ci["server"], at, heads), "sent": sent, "sinks": len(mine),
|
||||
"received_by": [[j, sum(1 for s in mine.values() if j in s)] for j in sent],
|
||||
"energy": e, "per_receiver_upper_bound": share})
|
||||
transmitted = bool(self.pub) or (drops("cache_status") and bool(self.caches))
|
||||
status = "reconciled" if sinks else "transmitted" if transmitted else "published"
|
||||
sink = None
|
||||
s = case["sink"]
|
||||
if s is not None:
|
||||
sid, sb = s["bearer_id"], s["bearer"]
|
||||
if sid in self.pub_aside or sid in self.cache_aside:
|
||||
sink = "bearer_contradicted"
|
||||
elif sid in self.pub:
|
||||
sink = "declared" if self.pub[sid]["bearer"] == sb else "bearer_contradicted"
|
||||
elif sid in self.caches and not drops("sink_cache"):
|
||||
sink = "cache-declared" if self.caches[sid]["body"]["bearer"] == sb else "bearer_contradicted"
|
||||
else:
|
||||
sink = "bearer_not_declared"
|
||||
assertion = None
|
||||
a = case["assertion"]
|
||||
if a is not None:
|
||||
if "transmitted" in a:
|
||||
over = [d["bearer"] for d in self.pub.values()]
|
||||
if drops("statement_publisher"):
|
||||
over += [ci["body"]["bearer"] for ci in self.caches.values()]
|
||||
assertion = "holds" if a["transmitted"] in over else "reject reason=bearer_not_declared"
|
||||
elif whole == 0:
|
||||
assertion = "reject reason=delivery_not_reconciled"
|
||||
elif whole < a["delivered"]:
|
||||
assertion = "reject reason=delivery_exceeds_reconciled"
|
||||
else:
|
||||
assertion = "holds"
|
||||
return {"objects": g.verdicts, "claims": self.labels, "status": status, "sinks": sinks, "whole": whole,
|
||||
"received_by": received_by, "bearers": bearers, "caches": caches, "sink": sink,
|
||||
"assertion": assertion}
|
||||
|
||||
|
||||
def count_only_of(case):
|
||||
return None if case["count_only"] is None else set(case["count_only"])
|
||||
|
||||
|
||||
def verdict(case):
|
||||
try:
|
||||
v = Verdict(case, count_only_of(case))
|
||||
except JV.Refuse as r:
|
||||
return "refuse reason=" + r.reason
|
||||
return v.outcome(case)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ §8: held by a deadline
|
||||
|
||||
def population_valid(region, keys):
|
||||
"""§8.1: a region code of rung-price §2, and 1 to 2^20 keys in strictly increasing order of
|
||||
their bytes."""
|
||||
if not drops("region") and not RP.region_code(region):
|
||||
return False
|
||||
if not isinstance(keys, list) or len(keys) > MAX_KEYS or (not keys and not drops("population_empty")):
|
||||
return False
|
||||
if not all(CV.is_hex(k, 32) for k in keys):
|
||||
return False
|
||||
if drops("population_order"):
|
||||
return True
|
||||
raw = [bytes.fromhex(k) for k in keys]
|
||||
return all(a < b or (a == b and drops("population_strict")) for a, b in zip(raw, raw[1:]))
|
||||
|
||||
|
||||
def population_digest(region, keys):
|
||||
"""§8.1: SHA-256 over the domain, the region's length and the region, n and the keys."""
|
||||
r = region.encode("ascii")
|
||||
head = POPULATION + (b"" if drops("digest_region") else bytes([len(r)]) + r)
|
||||
return hashlib.sha256(head + len(keys).to_bytes(4, "big") + b"".join(bytes.fromhex(k) for k in keys)).hexdigest()
|
||||
|
||||
|
||||
def verified(g, i):
|
||||
"""§8.2 step 5: the content hash and length of object i held `delivered-verified`, or None."""
|
||||
memo = g.__dict__.setdefault("ca_verified", {})
|
||||
if i not in memo:
|
||||
memo[i] = None
|
||||
for ob, env in g.pairs:
|
||||
if env is not None and ob["object_id"] == i:
|
||||
try:
|
||||
JV.accept(ob, g.receipt, env)
|
||||
memo[i] = (ob["content_hash"].hex(), len(env))
|
||||
break
|
||||
except JV.Refuse:
|
||||
pass
|
||||
return memo[i]
|
||||
|
||||
|
||||
def deadline_entry(wire, g, i):
|
||||
"""§8.2 step 6: the entry for object i of a timeliness claim that stands against the group, with
|
||||
no claim of its signer held before it, and the claim's link hash; or None."""
|
||||
try:
|
||||
c = TV.claim_steps(wire)
|
||||
CV.check_chain(c, None)
|
||||
if c["class"] != "timeliness":
|
||||
raise CV.Reject(6, "context_mismatch")
|
||||
held = g
|
||||
if drops("deadline_envelopes"):
|
||||
held = copy.copy(g)
|
||||
held.envelopes = {}
|
||||
TV.check_timeliness(c, held, {g.link: held})
|
||||
except CV.Reject:
|
||||
return None
|
||||
entries = c["body"]["objects"]
|
||||
e = next((e for e in entries if e["leaf_index"] == i), None)
|
||||
if e is None and drops("deadline_entry"):
|
||||
e = entries[0]
|
||||
return None if e is None else (e, CV.link_hash(c))
|
||||
|
||||
|
||||
_ACKS = {}
|
||||
|
||||
|
||||
def read_ack(x):
|
||||
"""settlement §3.6 for a record given, and its item lists against its entries: (reason or None,
|
||||
record, items, the item lists' reason or None)."""
|
||||
k = (x["record"], tuple(x["items"]))
|
||||
if k not in _ACKS:
|
||||
try:
|
||||
a = SET.read_ack(bytes.fromhex(x["record"]))
|
||||
except SET.Refuse as r:
|
||||
_ACKS[k] = (r.reason, None, [], None)
|
||||
return _ACKS[k]
|
||||
items, why = [], None
|
||||
try:
|
||||
for j, e in enumerate(a["entries"]):
|
||||
if j >= len(x["items"]):
|
||||
raise SET.Refuse("record_missing")
|
||||
raw = bytes.fromhex(x["items"][j])
|
||||
its = SET.read_items(raw)
|
||||
if not SET.recomputes(e, its, raw):
|
||||
raise SET.Refuse("items_mismatch")
|
||||
items += its
|
||||
except SET.Refuse as r:
|
||||
why, items = r.reason, []
|
||||
_ACKS[k] = (None, a, items, why)
|
||||
return _ACKS[k]
|
||||
|
||||
|
||||
def acknowledgements(acks, g):
|
||||
"""§8.3: each record's label, in order, with the record and its items where it reads."""
|
||||
out = []
|
||||
for x in acks:
|
||||
why, a, items, items_why = read_ack(x)
|
||||
if why is not None:
|
||||
out.append(("reject reason=" + why, None, []))
|
||||
elif a["mode"] == DECLARATION and not drops("declaration"):
|
||||
out.append(("declaration", a, []))
|
||||
elif items_why is not None:
|
||||
out.append(("reject reason=" + items_why, None, []))
|
||||
elif before(ack_ms(a["end"])[1], g.start, "ack_before"):
|
||||
out.append(("before-group", a, items))
|
||||
else:
|
||||
out.append(("read", a, items))
|
||||
return out
|
||||
|
||||
|
||||
def covers(spans, length):
|
||||
"""Whether the union of the byte ranges `spans` covers [0, length)."""
|
||||
if drops("covers"):
|
||||
return bool(spans)
|
||||
at = 0
|
||||
for s, e in sorted(spans):
|
||||
if at >= length:
|
||||
break
|
||||
if s > at:
|
||||
return False
|
||||
at = max(at, e)
|
||||
return at >= length
|
||||
|
||||
|
||||
def held_at(v, acks, i, h, length, keys):
|
||||
"""§8.3's held-at statements of acknowledgements and counted reconciliations for object i (its
|
||||
content hash h and length), for the keys `keys` (None for every key): {key: [(instant,
|
||||
servers)]}, and the statements set aside before the group, as (key, source)."""
|
||||
g = v.g
|
||||
out, aside = {}, []
|
||||
|
||||
def wanted(k):
|
||||
return keys is None or k in keys or drops("population_only")
|
||||
|
||||
recs = v.counted
|
||||
if drops("line_counted"):
|
||||
recs = [x for x, c in enumerate(v.accepted) if c is not None and c["class"] == "reconciliation"]
|
||||
for x in recs:
|
||||
c = v.accepted[x]
|
||||
k, b = c["signer_pubkey"], c["body"]
|
||||
if not wanted(k) or i not in positions(b["received"]):
|
||||
continue
|
||||
ms, s = issued_ms(c["issued_at"])
|
||||
if before(ms // 1000 if drops("rec_before_ms") else s, g.start, "rec_before"):
|
||||
aside.append((k, "reconciliation"))
|
||||
continue
|
||||
bid, server = b["bearer_id"], None
|
||||
if bid in v.pub:
|
||||
server = g.signer
|
||||
elif bid in v.caches and (i in v.caches[bid]["sent"] or drops("sent_server")):
|
||||
server = v.caches[bid]["server"]
|
||||
out.setdefault(k, []).append((ms, set() if server is None else {server}))
|
||||
readable = [(a, items) for label, a, items in acks if label in ("read", "before-group")
|
||||
or (drops("declaration") and label == "declaration")]
|
||||
for k in sorted({a["downstream"].hex() for a, _ in readable}):
|
||||
if not wanted(k):
|
||||
continue
|
||||
mine = [(a, items) for a, items in readable
|
||||
if a["downstream"].hex() == k and any(c.hex() == h for c, _, _ in items)]
|
||||
rest = []
|
||||
for a, items in mine:
|
||||
if before(ack_ms(a["end"])[1], g.start, "ack_before"):
|
||||
aside.append((k, a["digest"].hex()))
|
||||
else:
|
||||
rest.append((a, items))
|
||||
if drops("ack_digest"):
|
||||
rest.sort(key=lambda p: p[0]["end"])
|
||||
elif not drops("ack_order"):
|
||||
rest.sort(key=lambda p: (p[0]["end"], p[0]["digest"]))
|
||||
spans = []
|
||||
for n, (a, items) in enumerate(rest):
|
||||
if drops("union"):
|
||||
spans = []
|
||||
spans += [(s, e) for c, s, e in items if c.hex() == h]
|
||||
if covers(spans, length):
|
||||
upto = rest[n:n + 1] if drops("servers_all") else rest[:n + 1]
|
||||
out.setdefault(k, []).append((ack_ms(a["end"])[0], {p[0]["upstream"].hex() for p in upto}))
|
||||
break
|
||||
return out, aside
|
||||
|
||||
|
||||
def tally(case):
|
||||
"""§8: the tally, or `refuse reason=R` for §8.2."""
|
||||
region, keys, i = case["region"], case["population"], case["index"]
|
||||
if not population_valid(region, keys):
|
||||
return "refuse reason=population_invalid"
|
||||
stated, target = case["deadline_ms"], case["miss_target_ppm"]
|
||||
if case["deadline_claim"] is None and not (CV.is_int(stated)
|
||||
and (stated <= LAST_INSTANT or drops("deadline_bound"))):
|
||||
return "refuse reason=target_invalid"
|
||||
if not (CV.is_int(target) and (target <= MAX_TARGET or drops("target_bound"))):
|
||||
return "refuse reason=target_invalid"
|
||||
try:
|
||||
v = Verdict(case, None if drops("count_only") else set(keys))
|
||||
except JV.Refuse as r:
|
||||
return "refuse reason=" + r.reason
|
||||
g = v.g
|
||||
if g.start is None and not drops("group_start"):
|
||||
return "refuse reason=context_invalid"
|
||||
if not (CV.is_int(i) and (i < g.count or drops("out_of_range"))):
|
||||
return "refuse reason=object_out_of_range"
|
||||
obj = verified(g, i)
|
||||
if obj is None:
|
||||
if not drops("verified"):
|
||||
return "refuse reason=object_not_verified"
|
||||
obj = (g.included.get(i, ""), 0)
|
||||
h, length = obj
|
||||
deadline, link = stated, None
|
||||
if case["deadline_claim"] is not None:
|
||||
got = deadline_entry(case["deadline_claim"], g, i)
|
||||
if got is None:
|
||||
return "refuse reason=deadline_not_stated"
|
||||
deadline, link = got[0]["by_ms"], got[1]
|
||||
acks = acknowledgements(case["acknowledgements"], g)
|
||||
statements, aside = held_at(v, acks, i, h, length, set(keys))
|
||||
rlabels, kept = TV.records_step(case["records"], [g])
|
||||
standing = [kept[x] for x in sorted(kept) if rlabels[x] == "stands"]
|
||||
if not drops("records"):
|
||||
for holder, _, _, at in TV.held_from_records(standing, g.link, i):
|
||||
if holder in keys:
|
||||
statements.setdefault(holder, []).append((at, set()))
|
||||
# §8.3: a hit has a held-at statement at or before the deadline; §8.4: its servers.
|
||||
counted_keys = sorted(statements) if drops("population_only") else keys
|
||||
hits = {}
|
||||
for k in counted_keys:
|
||||
on = [(t, s) for t, s in statements.get(k, []) if (t < deadline if drops("hit_inclusive") else t <= deadline)]
|
||||
if on:
|
||||
hits[k] = (set().union(*(s for _, s in on)), any(not s for _, s in on), on)
|
||||
n = len(keys)
|
||||
misses = n - len(hits)
|
||||
miss_ppm = misses * PPM // n if drops("miss_ceil") else ceil_div(misses * PPM, n)
|
||||
servers = []
|
||||
for s in sorted(set().union(set(), *(S for S, _, _ in hits.values())), key=bytes.fromhex):
|
||||
crediting = [(k, S, U, on) for k, (S, U, on) in hits.items() if s in S]
|
||||
at = (min if drops("server_earliest") else max)(t for _, _, _, on in crediting for t, ss in on if s in ss)
|
||||
if drops("server_instant"):
|
||||
at = g.start * 1000
|
||||
placement = "publisher" if s == g.signer and not drops("publisher") else place(s, at, case["heads"])
|
||||
sole = sum(1 for _, S, U, _ in crediting if S == {s} and (drops("sole_unattributed") or not U))
|
||||
servers.append({"key": s, "placement": placement, "hits": len(crediting), "sole": sole})
|
||||
met = miss_ppm < target if drops("target_inclusive") else miss_ppm <= target
|
||||
return {"outcome": "met" if met else "refuse reason=miss_target_exceeded", "region": region,
|
||||
"population_digest": population_digest(region, keys), "deadline_ms": deadline, "deadline_claim": link,
|
||||
"n": n, "hits": len(hits), "misses": misses, "miss_ppm": miss_ppm,
|
||||
"missing": [k for k in keys if k not in hits],
|
||||
"set_aside": [{"key": k, "source": src, "reason": "held_before_group"} for k, src in sorted(aside)],
|
||||
"servers": servers, "unattributed": sum(1 for S, _, _ in hits.values() if not S),
|
||||
"acknowledgements": [label for label, _, _ in acks], "records": rlabels, "claims": v.labels}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ §9: timeliness lines
|
||||
|
||||
def lines(case):
|
||||
"""§9: timeliness §6.3's lines for object `index`. Records give held-at statements as
|
||||
timeliness §6 reads them, for the primary and its fallback whatever envelopes are held, with a
|
||||
line for each holder with a record that stands and has an entry for either group;
|
||||
acknowledgements and counted reconciliations give them for each object of the group held
|
||||
`delivered-verified`, the primary and a fallback in its group among them, with a line for each
|
||||
key with one."""
|
||||
try:
|
||||
v = Verdict(case, count_only_of(case))
|
||||
except JV.Refuse as r:
|
||||
return "refuse reason=" + r.reason
|
||||
g, i = v.g, case["index"]
|
||||
got = deadline_entry(case["claim"], g, i)
|
||||
if got is None:
|
||||
return "reject reason=deadline_not_stated"
|
||||
entry = got[0]
|
||||
fb = entry["fallback"]
|
||||
acks = acknowledgements(case["acknowledgements"], g)
|
||||
rlabels, kept = TV.records_step(case["records"], [g])
|
||||
standing = [kept[x] for x in sorted(kept) if rlabels[x] == "stands"]
|
||||
holders = TV.line_holders(g.link, entry, standing)
|
||||
if drops("line_fallback_holders"):
|
||||
holders = [h for h in holders if any(TV.has_entry(r, g.link) for r in standing if r[0].hex() == h)]
|
||||
primary = TV.held_from_records(standing, g.link, i)
|
||||
if drops("line_records_verified") and verified(g, i) is None:
|
||||
primary = []
|
||||
fallback = [] if fb is None else TV.held_from_records(standing, fb["group"], fb["leaf_index"])
|
||||
for j in range(g.count):
|
||||
obj = verified(g, j)
|
||||
if obj is None:
|
||||
continue
|
||||
statements, _ = held_at(v, acks, j, obj[0], obj[1], None)
|
||||
for k, st in statements.items():
|
||||
if k not in holders and not drops("line_keys"):
|
||||
holders.append(k)
|
||||
if j == i:
|
||||
primary += [(k, g.link, i, t) for t, _ in st]
|
||||
if fb is not None and fb["group"] == g.link and fb["leaf_index"] == j and not drops("line_fallback_acks"):
|
||||
fallback += [(k, g.link, j, t) for t, _ in st]
|
||||
return TV.lines(entry, holders, primary, fallback)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the corpus
|
||||
|
||||
def placement_outcome(case):
|
||||
return place(case["key"], case["at_ms"], case["heads"])
|
||||
|
||||
|
||||
RUNS = (("claims", claim_outcome), ("placements", placement_outcome), ("verdicts", verdict), ("tallies", tally),
|
||||
("lines", lines))
|
||||
KINDS = tuple(k for k, _ in RUNS)
|
||||
|
||||
|
||||
def check(doc, quiet=False):
|
||||
"""The failures of a corpus document: each case's outcome, and every reason of §11."""
|
||||
fails = []
|
||||
for kind, run in RUNS:
|
||||
for c in doc[kind]:
|
||||
try:
|
||||
got = run(c)
|
||||
except CV.Reject as r:
|
||||
got = {"error": f"reject step={r.step} reason={r.reason}"}
|
||||
except (KeyError, TypeError, ValueError, IndexError, AttributeError, StopIteration, ZeroDivisionError,
|
||||
JV.Refuse, SET.Refuse, SG.Stop, HV.Refused) as e:
|
||||
got = {"error": repr(e)}
|
||||
if got != c["outcome"]:
|
||||
fails.append(c["name"])
|
||||
if not quiet:
|
||||
print(f"FAIL {kind} {c['name']}\n stated {json.dumps(c['outcome'])}\n got {json.dumps(got)}")
|
||||
stated = []
|
||||
for kind in KINDS:
|
||||
for c in doc[kind]:
|
||||
TV.strings(c["outcome"], stated)
|
||||
for code, _ in reasons_table():
|
||||
if not any(s == code or re.search(r"reason=" + re.escape(code) + r"(\s|$)", s) for s in stated):
|
||||
fails.append("no case gives " + code)
|
||||
return fails
|
||||
|
||||
|
||||
def mutations(doc):
|
||||
"""Copies of the corpus, one defect each, every one of which must fail."""
|
||||
def case(d, name, kinds=KINDS):
|
||||
return next(c for k in kinds for c in d[k] if c["name"] == name)
|
||||
|
||||
def m_until(d):
|
||||
case(d, "retired_at_until")["at_ms"] += 1
|
||||
|
||||
def m_least(d):
|
||||
c = case(d, "confirmed_least_origin")
|
||||
c["outcome"] = c["outcome"].replace("operator.example", "other.example")
|
||||
|
||||
def m_receipt(d):
|
||||
# Object 3's receipt held too: the claim's root over objects 1 and 3 then verifies.
|
||||
c = case(d, "objects_not_held")
|
||||
c["objects"].append(case(d, "sent_partial")["objects"][3])
|
||||
|
||||
def m_credit(d):
|
||||
case(d, "per_instance_credit")["outcome"]["caches"][0]["received_by"][1][1] = 1
|
||||
|
||||
def m_status(d):
|
||||
case(d, "cache_declared_only")["outcome"]["status"] = "transmitted"
|
||||
|
||||
def m_head(d):
|
||||
case(d, "head_not_held", ("verdicts",))["heads"]["https://operator.example"] = d["heads"]["operator"]
|
||||
|
||||
def m_count_only(d):
|
||||
case(d, "confirmed_keys_only")["count_only"] = None
|
||||
|
||||
def m_bound(d):
|
||||
case(d, "energy_bound_after")["outcome"]["caches"][0]["per_receiver_upper_bound"] = 3_333_333
|
||||
|
||||
def m_population(d):
|
||||
c = case(d, "met_by_bearer_and_peers")
|
||||
c["population"] = list(reversed(c["population"]))
|
||||
|
||||
def m_region(d):
|
||||
case(d, "region_country")["region"] = "XB"
|
||||
|
||||
def m_deadline(d):
|
||||
case(d, "ack_last_us_at_deadline")["deadline_ms"] -= 1
|
||||
|
||||
def m_ppm(d):
|
||||
case(d, "rounded_up")["outcome"]["miss_ppm"] = 333_333
|
||||
|
||||
def m_sole(d):
|
||||
case(d, "two_peers_complete")["outcome"]["servers"][0]["sole"] = 1
|
||||
|
||||
def m_items(d):
|
||||
case(d, "ack_any_class")["acknowledgements"][0]["items"] = []
|
||||
|
||||
def m_claim_link(d):
|
||||
case(d, "deadline_from_timeliness")["outcome"]["deadline_claim"] = "00" * 32
|
||||
|
||||
def m_line(d):
|
||||
case(d, "timeliness_line_from_ack")["outcome"][0]["held_ms"] += 1
|
||||
|
||||
def m_reason_gone(d):
|
||||
d["claims"].remove(case(d, "leaf_count_wrong"))
|
||||
|
||||
return [m_until, m_least, m_receipt, m_credit, m_status, m_head, m_count_only, m_bound, m_population, m_region,
|
||||
m_deadline, m_ppm, m_sole, m_items, m_claim_link, m_line, m_reason_gone]
|
||||
|
||||
|
||||
WEAKENINGS = ["rec_second", "rec_round", "ack_last_us", "ack_round", "until_inclusive", "serving", "least_origin",
|
||||
"revoked", "not_held", "revoked_first", "until", "ct_subject", "ct_publisher", "ct_leaf_count",
|
||||
"ct_held", "ct_root", "context_kind", "unchecked", "taken", "cache_contradiction", "same_claim",
|
||||
"self_ack", "energy_ceiling", "per_instance", "cache_instant", "sent_only", "cache_status",
|
||||
"sink_cache", "statement_publisher", "region", "population_empty", "population_order",
|
||||
"population_strict", "digest_region", "deadline_entry", "declaration", "ack_before", "rec_before",
|
||||
"covers", "union", "servers_all", "sent_server", "population_only", "line_counted", "deadline_bound",
|
||||
"target_bound", "count_only", "group_start", "out_of_range", "verified", "records", "hit_inclusive",
|
||||
"miss_ceil", "server_instant", "publisher", "target_inclusive", "line_keys", "deadline_envelopes",
|
||||
"ack_order", "ack_digest", "server_earliest", "cache_earliest", "rec_before_ms", "line_fallback_acks",
|
||||
"line_records_verified", "line_fallback_holders"]
|
||||
|
||||
|
||||
def direct_checks():
|
||||
"""What no vector carries, and what this text states of the texts beside it."""
|
||||
fails = []
|
||||
# §8.1's bound: 2^20 keys are a population, and one more is not.
|
||||
keys = [k.to_bytes(32, "big").hex() for k in range(1, MAX_KEYS + 2)]
|
||||
if population_valid("XA", keys):
|
||||
fails.append("a population of 2^20 + 1 keys is accepted")
|
||||
keys.pop()
|
||||
if not population_valid("XA", keys):
|
||||
fails.append("a population of 2^20 keys is refused")
|
||||
del keys
|
||||
# §8.3: an acknowledgement's hit is `end` − 1 ≤ deadline_ms × 1000 in 64 bits, its rounded
|
||||
# instant at or before the deadline the same test, at the last instant and the widest `end`.
|
||||
if LAST_INSTANT * 1000 >= 1 << 64:
|
||||
fails.append("the last instant in microseconds does not fit in 64 bits")
|
||||
for end in (LAST_INSTANT * 1000 + 1, LAST_INSTANT * 1000 + 2, (1 << 64) - 1):
|
||||
if (ack_ms(end)[0] <= LAST_INSTANT) != (end - 1 <= LAST_INSTANT * 1000):
|
||||
fails.append(f"an acknowledgement ending at {end} reads two ways")
|
||||
# jwp-receipts §3.1 lists §5.2's codes at step 4 and §5.3's at step 6; its §2.4.4 row admits a
|
||||
# receipt subject, as the claim verifier does.
|
||||
jwp = TV.text_of(JWP_RECEIPTS)
|
||||
for code, where in reasons_table():
|
||||
step = {"§5.2": 4, "§5.3": 6}.get(where.split(" ")[0])
|
||||
if step is not None and f"| {step} | `{code}` |" not in jwp:
|
||||
fails.append(f"jwp-receipts §3.1 does not list {code} at step {step}")
|
||||
if "| `cache-transmission` | `claim` | `receipt`" not in jwp or CV.ADMITS.get("cache-transmission") != {"receipt"}:
|
||||
fails.append("jwp-receipts §2.4.4 and the claim verifier differ on the class's subject")
|
||||
# §4.4: the serving roles after `cache/serve` are those settlement §13 places.
|
||||
text = TV.text_of(SETTLEMENT)
|
||||
s13 = text[text.index("\n## 13."):]
|
||||
for role in SERVING[1:]:
|
||||
if f"`{role}`" not in s13:
|
||||
fails.append(f"settlement §13 does not place {role}")
|
||||
return fails
|
||||
|
||||
|
||||
def main(argv):
|
||||
global DROPPED
|
||||
if len(argv) != 2:
|
||||
print(__doc__)
|
||||
return 2
|
||||
doc = json.load(open(os.path.join(argv[1], "vectors.json"), encoding="utf-8"))
|
||||
extra = sorted(set(os.listdir(argv[1])) - {"vectors.json", "README.md"})
|
||||
fails = check(doc)
|
||||
if extra:
|
||||
fails.append(f"files the corpus does not hold: {extra}")
|
||||
fails += direct_checks()
|
||||
print(f"{sum(len(doc[k]) for k in KINDS)} cases ({', '.join(f'{len(doc[k])} {k}' for k in KINDS)}): "
|
||||
f"{len(fails)} failure(s)")
|
||||
caught = 0
|
||||
for m in mutations(doc):
|
||||
d = copy.deepcopy(doc)
|
||||
m(d)
|
||||
if check(d, quiet=True):
|
||||
caught += 1
|
||||
else:
|
||||
fails.append(f"mutation {m.__name__} was not caught")
|
||||
print(f"{caught} of {len(mutations(doc))} mutated copies fail")
|
||||
weak = 0
|
||||
for w in WEAKENINGS:
|
||||
DROPPED = w
|
||||
if check(doc, quiet=True):
|
||||
weak += 1
|
||||
else:
|
||||
fails.append(f"a verifier without its {w} check passes the corpus")
|
||||
DROPPED = None
|
||||
print(f"{weak} of {len(WEAKENINGS)} weakened verifiers are caught")
|
||||
for f in fails:
|
||||
print("FAIL", f)
|
||||
return 1 if fails else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
1493
wai/live-conformance/impression_aggregate_verify.py
Normal file
1493
wai/live-conformance/impression_aggregate_verify.py
Normal file
File diff suppressed because it is too large
Load diff
70
wai/live-conformance/impressions/README.md
Normal file
70
wai/live-conformance/impressions/README.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# live-conformance/impressions — ad presentations counted over a bearer with no return path
|
||||
|
||||
The corpus of [`extensions/impression-aggregates.md`](../../extensions/impression-aggregates.md)
|
||||
§11, in one file, `vectors.json`.
|
||||
|
||||
**Provenance.** Generated here by `wai_impression_aggregate_vectors gen` (`wai-rs`, feature
|
||||
`impression_aggregate`), from Ed25519 keys, salts, report ids, Prio3 randomness, HPKE ephemeral
|
||||
seeds and token nonces and salts that are SHA-256 of a fixed label and a name; constant instants;
|
||||
and manifests, records and claims written by the generator. The issuer key is RFC 9578 Appendix
|
||||
A.2's first test vector, as the presentation corpora use it, and every token is signed with it by
|
||||
RSASSA-PSS over a salt from a seed. The reference aggregators of `wai-rs` run every flow. No media
|
||||
and no input from outside this repository.
|
||||
|
||||
`flight` gives the flight's first hour (`first_hour`, in `time_precision` units), its length and
|
||||
its start in milliseconds. `keys` names every key by its role, in hexadecimal: the task origin's
|
||||
keys `t1` and `t2` (`aggregate/task`) and `d1` (`delivery` alone), a stranger origin's `s1`, the
|
||||
stitcher and a stranger stitcher, the three statement keys and a stranger's, each aggregator's and
|
||||
the collector's HPKE configuration and private key, the verification keys' seed, and the issuer.
|
||||
`fixtures` holds what cases name: the creatives `A`, `B` and `C`; the manifests as text (`mpd`,
|
||||
`mpd_short` without the first two events, `playlist`); the pod-input records with what each is;
|
||||
the catalogues `flight`, `open` and `bound` (the flight's with a hold of 3,599 s); the tasks; the
|
||||
key logs (`rev0`, `rev1`, which revokes `t2` and arrives on the bearer, and `stranger`); the
|
||||
anchor records (`pinned` at `rev0`, `fetched`); `receiver`, the revisions and anchors a receiver
|
||||
holds; and the task files `flight`, `open` and `twin`.
|
||||
|
||||
- `catalogues` (§4.3) are each a catalogue's `bytes`; `outcome` is `read`, with its `digest`, its
|
||||
`cells` and its `chunk` length, or `refuse` and its `reason`.
|
||||
- `tasks` (§5.3) are a TaskConfiguration (`task`) and a `catalogue`; `outcome` is `accept`, with the
|
||||
`task_id` and the `retention_s`, or `refuse`.
|
||||
- `task_files` (§5.4) are a file's `bytes`, the `revisions` and the `anchors` held, by fixture name;
|
||||
`outcome` is `accept`, with the file's `digest`, the `task_id` and the `confirmation` as
|
||||
jwp-receipts §7.4 reports it, or `refuse`.
|
||||
- `contributions` (§6.2) name a `task_file` and a `record`, and give the session's `claims` (the
|
||||
last is the one judged), the `region`, `at_ms` and the `pool`; every manifest and creative of
|
||||
`fixtures` is held, and the task file is accepted under `receiver`. `outcome` is `report`, with
|
||||
the `cell`, `creative`, `column`, `quartile`, `time`, the report `id` (`fresh` without
|
||||
attestation) and the pool ids `discarded`, or `no_report` with its `reason`.
|
||||
- `flows` (§6–§7) are end to end: `reports` lists each report, a presentation with its `record`,
|
||||
`claim`, `region`, `at_ms` and its contribution's cell, or a report written to fail with the
|
||||
`error` and the WAI `reason` it meets; `steps` are, in order, each upload (the `WIU1` `file`, the
|
||||
leader's `response` as DAP UploadErrors and its `errors`, and for a relay's merge its `sources`
|
||||
and each source's `shares` of the response), the aggregation job (its request, response and each
|
||||
report's `outcome` and `reason`), and each collection at `now_s` (`processing`, a DAP problem
|
||||
type, or `completed` with every message, the `result` and the signed `receipt`). The `flight`
|
||||
flow's `late` report is uploaded after its batch's collection.
|
||||
- `uploads` (§6.4) are a file's `bytes`; `outcome` is `read`, with the `task_id` and the `ids`, or
|
||||
`refuse`.
|
||||
- `plans` (§6.5) are the `stored` reports, the instant `t_ms` and the `budget`, under the open task
|
||||
file; `outcome` gives `send` (each id and its class), `defer`, `drop` and the `file`.
|
||||
- `settles` (§6.6) are the file `sent` and the `response`; `outcome` gives whether it `settles`,
|
||||
and the ids to `delete` and to `keep`.
|
||||
- `receipts` (§8.1) are a `receipt`, a `catalogue` and the batch's `reports` (or `null`); `outcome`
|
||||
is `accept`, with telemetry-aggregation §8.2's fields and `reports_checked`, or `refuse` with its
|
||||
`reason` and where (`at`).
|
||||
- `tables` (§8.2) are a `catalogue`, a `task` and `receipts`, each with its reports or `null`;
|
||||
`outcome` gives each receipt's `digest` and `verdict`, a standing one's batch, instants, count
|
||||
and `rows`, and the `totals`; or `refuse` and its `reason`.
|
||||
|
||||
Everything is synthetic: keys attest nothing, the key logs are test logs, origins are reserved
|
||||
example domains, and regions are ISO 3166-1's user-assigned codes and subdivisions of them. Each
|
||||
case states the outcome it was written to have, declared in the generator, never copied from a
|
||||
verifier. The size bounds of §5.4 and §6.4 are carried by no vector; both implementations check
|
||||
each at its edge: a file of exactly the bound that reads in every other respect is read, and the
|
||||
same file one byte longer is refused. §4.3's bound decides no outcome, so neither checks it.
|
||||
|
||||
`wai_impression_aggregate_vectors verify` and `tests/impression_aggregate.rs` check that the file
|
||||
regenerates byte for byte and that `wai-rs` reaches every stated outcome; the test also checks
|
||||
the equivalence the extension's §11 describes, which is not stored. `../impression_aggregate_verify.py` is the second
|
||||
implementation, written from the extension's text, which also mutates copies of the corpus and
|
||||
drops each of its own checks in turn and requires each to be caught.
|
||||
15523
wai/live-conformance/impressions/vectors.json
Normal file
15523
wai/live-conformance/impressions/vectors.json
Normal file
File diff suppressed because one or more lines are too long
66
wai/live-conformance/prices/README.md
Normal file
66
wai/live-conformance/prices/README.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# live-conformance/prices — what delivering a rung costs, and which option a comparer takes
|
||||
|
||||
The corpus of [`extensions/rung-price.md`](../../extensions/rung-price.md) §9: `vectors.json`.
|
||||
|
||||
**Provenance.** Generated here by `wai_rung_price_vectors gen` (`wai-rs`, feature `rung_price`),
|
||||
from Ed25519 keys on fixed 32-byte seeds and constant instants; no input from outside this
|
||||
repository. Every signature is deterministic (RFC 8032) but one: `same_body_two_signatures` signs
|
||||
unicast_tiers's body a second time under a fixed, different nonce prefix, which is still a valid
|
||||
signature of the same body under the same key.
|
||||
|
||||
`keys` names every key by its role, as 64 lowercase hexadecimal digits: `seller_unicast` (A,
|
||||
two-way delivery and rooms), `seller_broadcast` (B, transmissions), `seller_c` and `seller_d` (the
|
||||
sellers the limit and ranking cases add), `seller_contribution` (uplink), `seller_other_currency`
|
||||
(the one list in EUR), five holders (`holder_a` to `holder_e`) that price their bytes as peers, of
|
||||
which `holder_d`'s list lapses a minute after issue and `holder_e`'s prices only `XA`, `seller_e`
|
||||
(a second calibrated relay), `seller_f` (a relay that prices from a model), `seller_g` (a seller
|
||||
whose class its records do not bear out), `room_authority` (signs the two room descriptors), and
|
||||
`small_order`, the identity point.
|
||||
|
||||
Instants are microseconds. Lists are issued at 1,793,664,000,000,000 (2026-11-03T00:00:00Z) and
|
||||
expire a day later unless a case says otherwise; comparisons are made an hour after issue. The
|
||||
currency is `USD`. Every region is an ISO 3166-1 user-assigned code (`XA`, `XB`, `XC`) or a
|
||||
subdivision of one (`XA-1`, `XB-1`), and names no place. The footprint of 8,192 codes is the
|
||||
subdivisions `000` to `511` of each of `XA` to `XP`, in byte order; the one of 8,193 adds `XQ-000`.
|
||||
|
||||
The per-hop records are of two rooms, both signed by `room_authority`. R1's `t0` is a day before
|
||||
issue (1,793,577,600,000,000), and R0's thirty days before it (1,791,072,000,000,000). Each record
|
||||
covers its room's first hour, so a record of R1 has the instant 1,793,581,199,999,999 and one of R0
|
||||
the instant 1,791,075,599,999,999. Each record is signed by its hop's key, one of `seller_c`,
|
||||
`seller_d`, `seller_e`, `seller_f` and `seller_g`; `seller_g` also names `seller_c`'s and
|
||||
`seller_d`'s records, which it did not sign, and `seller_c` names one of `seller_g`'s. One of
|
||||
`seller_c`'s records counts media bytes it received beside those it sent. `rc2` and R1 are each also
|
||||
given once with their last byte changed, a copy whose signature fails. The base request confirms
|
||||
`seller_c` and `seller_e` for `CalibratedInstrument`, `seller_d` for `OnChipCounter` and `seller_f`
|
||||
for `ModelBased`, and relies on records up to seven days old; a case that confirms another key or
|
||||
class, or relies on records up to a day old, says so.
|
||||
|
||||
- `lists` (§4) are each a list's `text` and its `outcome`: `read`, with its `digest`, `seller` and
|
||||
count of `entries`; or `refuse` and its `reason`.
|
||||
- `verdicts` (§5) are each `texts` and an instant `at_us`; `outcome.lists` gives each text's
|
||||
verdict, in order: `stands`, `superseded`, `set-aside reason=…` or `reject reason=…`.
|
||||
- `comparisons` (§6) are each `texts`, an instant `at_us`, a `request` text and `evidence`, the
|
||||
texts given as evidence, as lowercase hexadecimal, in ascending order of the digest each names,
|
||||
and of their bytes for one digest; `outcome` is `refuse` and its `reason`, or the plan of §6.6:
|
||||
`request` (its digest), `at_us`, `lists` (each `verdict` and `digest`), `evidence` (the digests
|
||||
of the records and rooms those texts give, a damaged copy giving none), `audience`, `candidates`
|
||||
(each with its `energy_ppm`, `evidence` and `energy_rank`), `order` and `decision`.
|
||||
|
||||
The comparisons use A's 8,000,000 tier, `bytes` at 20,000,000 per 10⁹, and B's unidirectional
|
||||
8,000,000 tier, `bytes_sent` at 100,000,000 per 10⁹. Most requests ask for 500,000,000 `bytes` per
|
||||
receiver and 500,000,000,000 `bytes_sent` per transmission, for a rung of 3,000,000 and passes of
|
||||
6,000,000 bits per second, to `XB`: a cost per receiver of 10,000,000 against *T* = 50,000,000,000,
|
||||
and a break-even audience of 5,000. A's and B's figures are estimates.
|
||||
|
||||
Everything is synthetic: keys attest nothing, and prices and figures are chosen for their
|
||||
arithmetic. Each case states the outcome it was written to have, declared in the generator, never
|
||||
copied from a verifier: the removals, the order, the decision, the break-even and each candidate's
|
||||
evidence of each comparison are written out case by case, and its costs, energies and energy ranks
|
||||
are §6.3's and §6.4's arithmetic over the entries and the declared evidence. No vector carries the
|
||||
bounds of a list of 1,048,577 bytes, a list of 1,025 entries, a request of 65,537 bytes or an
|
||||
evidence text of 1,048,577 bytes; both implementations check them themselves.
|
||||
|
||||
`wai_rung_price_vectors verify` and `tests/rung_price.rs` check that the file regenerates byte for
|
||||
byte and that `wai-rs` reaches every stated outcome. `../rung_price_verify.py` is the second
|
||||
implementation, written from the extension's text, which also mutates copies of the corpus and
|
||||
drops each of its own checks in turn and requires each to be caught.
|
||||
10908
wai/live-conformance/prices/vectors.json
Normal file
10908
wai/live-conformance/prices/vectors.json
Normal file
File diff suppressed because one or more lines are too long
1310
wai/live-conformance/rung_price_verify.py
Normal file
1310
wai/live-conformance/rung_price_verify.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -765,8 +765,9 @@ def creative_digest(objects):
|
|||
return APV.creative_digest(objects)
|
||||
|
||||
|
||||
def verify(inp):
|
||||
"""§7: what a verification states, as the corpus writes it."""
|
||||
def verify(inp, through_attestation=True):
|
||||
"""§7: what a verification states, as the corpus writes it. With `through_attestation` false,
|
||||
§7.3 step 11 is not run (impression-aggregates §6.2 step 4 judges claims through step 10)."""
|
||||
accepted, rfacts, stated = [], [], []
|
||||
for b in inp["records"]:
|
||||
f = {"digest": None, "verdict": "accepted"}
|
||||
|
|
@ -858,7 +859,7 @@ def verify(inp):
|
|||
raise Bad("claim_frame_mismatch")
|
||||
if run < 2000 and "viewable" not in SKIP:
|
||||
raise Bad("claim_not_viewable")
|
||||
if rec["terms"]["require"] & ATTEST and "attest" not in SKIP:
|
||||
if through_attestation and rec["terms"]["require"] & ATTEST and "attest" not in SKIP:
|
||||
why = attestation(c, rec["terms"], inp["issuers"])
|
||||
if why:
|
||||
raise Bad(why)
|
||||
|
|
|
|||
|
|
@ -10,16 +10,18 @@ as the repository publishes it), the service's `policy`, the derivation `records
|
|||
`claims`, all as JSON texts, the delivery `receipts` of the two groups, as JSON values, and the
|
||||
`acknowledgements` (settlement §3), each its `record` and the `items` list of each of its
|
||||
entries, in hexadecimal. A
|
||||
case holds the members it sets (`notices`, `takedown`, `matches` and `purges` as texts, and
|
||||
`as_of` as a time) and any member of the world it replaces; the case a verifier is given is the
|
||||
case holds the members it sets (`notices`, `takedown`, `matches`, `purges` and
|
||||
`cache_transmissions`, the cache-transmission claims of
|
||||
[cache-authority](../../extensions/cache-authority.md) §5, as texts, and `as_of` as a time) and any
|
||||
member of the world it replaces; the case a verifier is given is the
|
||||
world's members with the case's in their place.
|
||||
|
||||
**Takedown equivalence.** For each case, a conforming verifier reaches the outcome it states:
|
||||
|
||||
- `refuse`: the `reason` and its place, `at` (§8.2): `["registry"]`, `["policy"]`,
|
||||
`["notice", i]`, `["takedown"]`, `["record", i]`, `["claim", i]`, `["match", i]`,
|
||||
`["acknowledgement", i]`, `["purge", i]`, `["group", g]`, `["exact", k]`, `["closure", k]` or
|
||||
`["matched", k]`;
|
||||
`["acknowledgement", i]`, `["cache_transmission", i]`, `["purge", i]`, `["group", g]`,
|
||||
`["exact", k]`, `["closure", k]` or `["matched", k]`;
|
||||
- `accept`, `pending`, or `missed` with its `reason`: the `facts` of §8.3, every member of them,
|
||||
each time as its value in seconds from 1970-01-01T00:00:00Z.
|
||||
|
||||
|
|
|
|||
|
|
@ -895,6 +895,303 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cache-transmitted-purged",
|
||||
"description": "The request of request-48h-met, with the cache's claim that it sent the source over a multicast instance of its own; the cache purges the source beside what it received and served: accept",
|
||||
"case": {
|
||||
"notices": [
|
||||
"{\"contact\":\"Submitter, [email protected]\",\"ground\":\"illegal\",\"ground_ref\":\"\",\"issued_at\":\"2026-10-21T08:00:00Z\",\"kind\":\"wai-takedown-notice\",\"sig\":\"ad959ee3bef413b627b6a42ab92fb1feb5d72f8ea0c78b073c79bc14fd61a9f59bb2cd54517b5b504e396d0828d61a39629e19f7aa5cc3158f47cecdafb94208\",\"signer_pubkey\":\"a99871bf596cb20db46c57507dfa4dcf0593ef0ed194f84a85c3b3907dd74d64\",\"statement\":\"The location named shows the submitter without consent.\",\"targets\":[{\"content_hash\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"type\":\"object\"}],\"type\":\"request\"}"
|
||||
],
|
||||
"takedown": "{\"automated\":{\"decision\":\"partially\",\"detection\":\"yes\"},\"category\":3,\"clock\":\"us-pl-119-12-s3-a-3\",\"clock_start\":\"2026-10-21T08:05:00Z\",\"decided_at\":\"2026-10-21T09:00:00Z\",\"exact\":[{\"content_hash\":\"0810f6c136790c08eaf99e8805e05237793be3f794af8eda2ea2750dc544e9cf\",\"type\":\"object\"},{\"content_hash\":\"2e745f40f6477b92cf7e6721536b4e24ac2aeb0058fa4735d4eafa3a2af5cb6a\",\"type\":\"object\"},{\"content_hash\":\"46a5043c50dbf90ae1ec2952ecd402f09fae39dc9e6e138e120dce7a770a5088\",\"type\":\"object\"},{\"content_hash\":\"5a13b782b85579d57d6547967cf70151b96a8d1fddd23951c85a582236e3c6df\",\"type\":\"object\"},{\"content_hash\":\"92c9d560804549d5e5978c40ed1c28285739c3325000c427edeba13f0e7d995b\",\"type\":\"object\"},{\"content_hash\":\"975c675d46575ec70ef57678bbb3080d5c3416ca3633de39195fc3664d65988f\",\"type\":\"object\"},{\"content_hash\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"type\":\"object\"},{\"content_hash\":\"dd572a12932a13c25259b5f03590cd802a1a3a21ba6e630c8e3050b4d2248ec4\",\"type\":\"object\"},{\"from_group\":5,\"track\":\"30ee90ff4071ee3057366a408a3bc82ef395811195763fac6d338ee9988b03e2\",\"type\":\"track\"},{\"receipt\":\"edea69fd50ffa2262e9c7bda6aab8e50b012199976b8b10abd4c4e598263b4d2\",\"type\":\"group\"}],\"explanation\":\"The item is a depiction of an identifiable person published without that person's consent.\",\"facts\":\"A notice located the item by its content hash; the service compared the notice with the item and removed it.\",\"ground\":\"illegal\",\"kind\":\"wai-takedown\",\"matched\":[{\"candidate\":\"eac0d7665177bfb1a8abd47c8aed0a8a68d76691670cba4bcc18ac071b6d0030\",\"match\":\"ef4d9d6607a8ba4f100ea843bc18e939f804b0385c9d4535e4570bbc30df6494\"}],\"notice\":\"6ee97e5d08ba81c873975fe5baf2bc5bbda42c384276f139ef255846c9162690\",\"policy\":\"f64a50351a627636b363eb8d63e25194a74263f2b05039dd7faf597d67f946c4\",\"reply_at\":null,\"sig\":\"96f52c0912833c6a42ab6fce7523c5ba2923bea5842f90177763b28eaf0e562f451b9ec44a9c05083f5efdaaa18c48cc5c0184e1d1b09f89f7e8121665f83509\",\"signer_pubkey\":\"e9e0cbabbdd1b0307e1d97595e7bcfea8cb43ae78cfb78580a422d5f3fef293d\",\"targets\":[{\"content_hash\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"type\":\"object\"}],\"verdict\":\"remove\"}",
|
||||
"matches": [
|
||||
"{\"candidate\":\"eac0d7665177bfb1a8abd47c8aed0a8a68d76691670cba4bcc18ac071b6d0030\",\"distance\":6,\"issued_at\":\"2026-10-21T07:00:00Z\",\"kind\":\"wai-takedown-match\",\"method\":\"wai-takedown:example-perceptual-hash\",\"sig\":\"db17bc1852808da8498440a6d50488cfde41d0377b7407f3673661720433f0bbc118b09d3ee69f5af63b0454f5f957497e0ab4ee2235f73cdd9e667072f73a02\",\"signer_pubkey\":\"903a2dcf9da77b1117c9f29c5e9d95c3578eac3514eade0df652ca4566b4be90\",\"source\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"threshold\":10}"
|
||||
],
|
||||
"purges": [
|
||||
"{\"kind\":\"wai-takedown-purge\",\"purged_at\":\"2026-10-21T10:00:00Z\",\"sig\":\"275859283c3e388a680ea5e308d8ce925852b5e928178adcee148bff28cc4e8d7bac46ecb538354cdf757d0221f35dac96a02a1d50b3b74fafbe9f34cdb6780c\",\"signer_pubkey\":\"d174dd8b7c789e94a6617313ea5c712c82c8bd2e72c93d60e19b313e8f6eb308\",\"subjects\":[{\"content_hash\":\"0810f6c136790c08eaf99e8805e05237793be3f794af8eda2ea2750dc544e9cf\",\"type\":\"object\"},{\"content_hash\":\"2e745f40f6477b92cf7e6721536b4e24ac2aeb0058fa4735d4eafa3a2af5cb6a\",\"type\":\"object\"},{\"content_hash\":\"46a5043c50dbf90ae1ec2952ecd402f09fae39dc9e6e138e120dce7a770a5088\",\"type\":\"object\"},{\"content_hash\":\"5a13b782b85579d57d6547967cf70151b96a8d1fddd23951c85a582236e3c6df\",\"type\":\"object\"},{\"content_hash\":\"92c9d560804549d5e5978c40ed1c28285739c3325000c427edeba13f0e7d995b\",\"type\":\"object\"},{\"content_hash\":\"975c675d46575ec70ef57678bbb3080d5c3416ca3633de39195fc3664d65988f\",\"type\":\"object\"},{\"content_hash\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"type\":\"object\"},{\"content_hash\":\"dd572a12932a13c25259b5f03590cd802a1a3a21ba6e630c8e3050b4d2248ec4\",\"type\":\"object\"},{\"content_hash\":\"eac0d7665177bfb1a8abd47c8aed0a8a68d76691670cba4bcc18ac071b6d0030\",\"type\":\"object\"},{\"from_group\":5,\"track\":\"30ee90ff4071ee3057366a408a3bc82ef395811195763fac6d338ee9988b03e2\",\"type\":\"track\"},{\"receipt\":\"edea69fd50ffa2262e9c7bda6aab8e50b012199976b8b10abd4c4e598263b4d2\",\"type\":\"group\"}],\"takedown\":\"b2c787c8814d4f600b229b2dc02d9f12a2efcb065d15b28e1262db8d1ab53c67\"}",
|
||||
"{\"kind\":\"wai-takedown-purge\",\"purged_at\":\"2026-10-21T12:00:00Z\",\"sig\":\"602e01802dca912905712a596ec04da856506196350912cc5c0134840422e472055d77f807c56dae60b0cfc5d76a0dbac3dfd684f3fed607c1632cbb2d8cff02\",\"signer_pubkey\":\"c8fee39620f9f0aebedb2b4bce0ca94033a46d4d92fe1c2d26322c58f1524c13\",\"subjects\":[{\"content_hash\":\"0810f6c136790c08eaf99e8805e05237793be3f794af8eda2ea2750dc544e9cf\",\"type\":\"object\"},{\"content_hash\":\"2e745f40f6477b92cf7e6721536b4e24ac2aeb0058fa4735d4eafa3a2af5cb6a\",\"type\":\"object\"},{\"content_hash\":\"46a5043c50dbf90ae1ec2952ecd402f09fae39dc9e6e138e120dce7a770a5088\",\"type\":\"object\"},{\"content_hash\":\"5a13b782b85579d57d6547967cf70151b96a8d1fddd23951c85a582236e3c6df\",\"type\":\"object\"},{\"content_hash\":\"92c9d560804549d5e5978c40ed1c28285739c3325000c427edeba13f0e7d995b\",\"type\":\"object\"},{\"content_hash\":\"975c675d46575ec70ef57678bbb3080d5c3416ca3633de39195fc3664d65988f\",\"type\":\"object\"},{\"content_hash\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"type\":\"object\"},{\"content_hash\":\"dd572a12932a13c25259b5f03590cd802a1a3a21ba6e630c8e3050b4d2248ec4\",\"type\":\"object\"},{\"content_hash\":\"eac0d7665177bfb1a8abd47c8aed0a8a68d76691670cba4bcc18ac071b6d0030\",\"type\":\"object\"},{\"from_group\":5,\"track\":\"30ee90ff4071ee3057366a408a3bc82ef395811195763fac6d338ee9988b03e2\",\"type\":\"track\"}],\"takedown\":\"b2c787c8814d4f600b229b2dc02d9f12a2efcb065d15b28e1262db8d1ab53c67\"}",
|
||||
"{\"kind\":\"wai-takedown-purge\",\"purged_at\":\"2026-10-21T14:00:00Z\",\"sig\":\"b60c19264df6491f0793dd9fbe800367e5c6d16c7007efa0205ab243e288beac655c0b09a14c6a2d466f3dc554f8f3e5280f059dc01341b427bc4f147d8e1708\",\"signer_pubkey\":\"36fb75567322347105ae69c22b4aef9307ae5edc22f88fb20a0816359e6d9b08\",\"subjects\":[{\"content_hash\":\"975c675d46575ec70ef57678bbb3080d5c3416ca3633de39195fc3664d65988f\",\"type\":\"object\"},{\"content_hash\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"type\":\"object\"}],\"takedown\":\"b2c787c8814d4f600b229b2dc02d9f12a2efcb065d15b28e1262db8d1ab53c67\"}",
|
||||
"{\"kind\":\"wai-takedown-purge\",\"purged_at\":\"2026-10-21T16:00:00Z\",\"sig\":\"cc7a0cac728c11d43966d92b8b771b80b9fbded603471319d080fa711ab6432dff6fd234901565424c0b23cdb77f9c06e42e1856a976b5e2d1ec1341928c3d05\",\"signer_pubkey\":\"103b452f8f9638d714aefc83544a2d7c605d67d379294627316ff2290755a3b4\",\"subjects\":[{\"content_hash\":\"2e745f40f6477b92cf7e6721536b4e24ac2aeb0058fa4735d4eafa3a2af5cb6a\",\"type\":\"object\"},{\"content_hash\":\"92c9d560804549d5e5978c40ed1c28285739c3325000c427edeba13f0e7d995b\",\"type\":\"object\"},{\"content_hash\":\"975c675d46575ec70ef57678bbb3080d5c3416ca3633de39195fc3664d65988f\",\"type\":\"object\"}],\"takedown\":\"b2c787c8814d4f600b229b2dc02d9f12a2efcb065d15b28e1262db8d1ab53c67\"}",
|
||||
"{\"kind\":\"wai-takedown-purge\",\"purged_at\":\"2026-10-21T18:00:00Z\",\"sig\":\"d2f4598aaf742fb97dfff6617bc2a196f768492e0cbad613651bf1340e44dec74f2891168a085fe519316eee709f3fbbadc49864ea68f9b8f9e099dbd32eb907\",\"signer_pubkey\":\"43441b8c2f91e6e9f60e7ebf2827f809695a5f410c85feea484ee41b04743db9\",\"subjects\":[{\"content_hash\":\"0810f6c136790c08eaf99e8805e05237793be3f794af8eda2ea2750dc544e9cf\",\"type\":\"object\"},{\"content_hash\":\"2e745f40f6477b92cf7e6721536b4e24ac2aeb0058fa4735d4eafa3a2af5cb6a\",\"type\":\"object\"},{\"content_hash\":\"46a5043c50dbf90ae1ec2952ecd402f09fae39dc9e6e138e120dce7a770a5088\",\"type\":\"object\"},{\"content_hash\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"type\":\"object\"},{\"from_group\":5,\"track\":\"30ee90ff4071ee3057366a408a3bc82ef395811195763fac6d338ee9988b03e2\",\"type\":\"track\"},{\"receipt\":\"edea69fd50ffa2262e9c7bda6aab8e50b012199976b8b10abd4c4e598263b4d2\",\"type\":\"group\"}],\"takedown\":\"b2c787c8814d4f600b229b2dc02d9f12a2efcb065d15b28e1262db8d1ab53c67\"}",
|
||||
"{\"kind\":\"wai-takedown-purge\",\"purged_at\":\"2026-10-21T20:00:00Z\",\"sig\":\"d3502bb148fa740208f2d4c9450058763a6427defd27f5fb9497f2aafecdd0f99d50c6cc25f2c7d255573757adb79bd9ac4c722a373eac73ed6955487268950e\",\"signer_pubkey\":\"b8bf46d82e7ba37e4731fdc33df7282ac9ac6371780c9794d80659b8aecd0b26\",\"subjects\":[{\"receipt\":\"edea69fd50ffa2262e9c7bda6aab8e50b012199976b8b10abd4c4e598263b4d2\",\"type\":\"group\"}],\"takedown\":\"b2c787c8814d4f600b229b2dc02d9f12a2efcb065d15b28e1262db8d1ab53c67\"}",
|
||||
"{\"kind\":\"wai-takedown-purge\",\"purged_at\":\"2026-10-21T22:00:00Z\",\"sig\":\"b39048cd7a5dd7d53d014c07c99faebc43d1502cc8693aaefd4236a78ab186db76435e822ef4e0a8cde32fa8239c32281103466437f6b4ef7790dbef14636f04\",\"signer_pubkey\":\"c39f450caea2a00608a21c64dbbdaa2181a2727d27e9c9ef838e9666eb105e7e\",\"subjects\":[{\"content_hash\":\"2e745f40f6477b92cf7e6721536b4e24ac2aeb0058fa4735d4eafa3a2af5cb6a\",\"type\":\"object\"},{\"content_hash\":\"92c9d560804549d5e5978c40ed1c28285739c3325000c427edeba13f0e7d995b\",\"type\":\"object\"},{\"content_hash\":\"975c675d46575ec70ef57678bbb3080d5c3416ca3633de39195fc3664d65988f\",\"type\":\"object\"},{\"content_hash\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"type\":\"object\"}],\"takedown\":\"b2c787c8814d4f600b229b2dc02d9f12a2efcb065d15b28e1262db8d1ab53c67\"}"
|
||||
],
|
||||
"cache_transmissions": [
|
||||
"{\"body\":{\"attests\":\"transmitted\",\"bearer\":\"multicast\",\"bearer_id\":\"urn:example:wai-bearer:cache-mesh\",\"energy\":null,\"leaf_count\":2,\"sent\":\"80\",\"sent_root\":\"0910fab3ef9042775b1056dafb4c469c60871d2ae2a29d665eaec7bed2cf0d11\"},\"class\":\"cache-transmission\",\"issued_at\":\"2026-10-21T07:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"2a16e21f2cef0735446b99afea213c26d9f9f848b383ee9337ae2bc49a0d99b799e4f94d879a287548e3a8dad760ddbd0995f750820497ae53332e94f9134603\",\"signer_id\":\"did:key:z6MksctQULaYQDJmDMKBe8vep2zMFrwEDiuEEoBXxiPDWxgd\",\"signer_pubkey\":\"c39f450caea2a00608a21c64dbbdaa2181a2727d27e9c9ef838e9666eb105e7e\",\"subject\":\"64ed8925ac7eafd98980dcb81ba39edac6e372810c1573cfe27bb3c83a553c30\",\"subject_type\":\"receipt\"}"
|
||||
],
|
||||
"as_of": "2026-10-24T08:00:00Z"
|
||||
},
|
||||
"expect": {
|
||||
"outcome": "accept",
|
||||
"facts": {
|
||||
"closure": [
|
||||
{
|
||||
"type": "object",
|
||||
"content_hash": "0810f6c136790c08eaf99e8805e05237793be3f794af8eda2ea2750dc544e9cf"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"content_hash": "2e745f40f6477b92cf7e6721536b4e24ac2aeb0058fa4735d4eafa3a2af5cb6a"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"content_hash": "46a5043c50dbf90ae1ec2952ecd402f09fae39dc9e6e138e120dce7a770a5088"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"content_hash": "5a13b782b85579d57d6547967cf70151b96a8d1fddd23951c85a582236e3c6df"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"content_hash": "92c9d560804549d5e5978c40ed1c28285739c3325000c427edeba13f0e7d995b"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"content_hash": "975c675d46575ec70ef57678bbb3080d5c3416ca3633de39195fc3664d65988f"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"content_hash": "dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"content_hash": "dd572a12932a13c25259b5f03590cd802a1a3a21ba6e630c8e3050b4d2248ec4"
|
||||
},
|
||||
{
|
||||
"type": "track",
|
||||
"track": "30ee90ff4071ee3057366a408a3bc82ef395811195763fac6d338ee9988b03e2",
|
||||
"from_group": 5
|
||||
},
|
||||
{
|
||||
"type": "group",
|
||||
"receipt": "edea69fd50ffa2262e9c7bda6aab8e50b012199976b8b10abd4c4e598263b4d2"
|
||||
}
|
||||
],
|
||||
"holders": [
|
||||
{
|
||||
"key": "103b452f8f9638d714aefc83544a2d7c605d67d379294627316ff2290755a3b4",
|
||||
"roles": [
|
||||
"deriver"
|
||||
],
|
||||
"required": 3
|
||||
},
|
||||
{
|
||||
"key": "36fb75567322347105ae69c22b4aef9307ae5edc22f88fb20a0816359e6d9b08",
|
||||
"roles": [
|
||||
"deriver"
|
||||
],
|
||||
"required": 2
|
||||
},
|
||||
{
|
||||
"key": "43441b8c2f91e6e9f60e7ebf2827f809695a5f410c85feea484ee41b04743db9",
|
||||
"roles": [
|
||||
"claimant"
|
||||
],
|
||||
"required": 6
|
||||
},
|
||||
{
|
||||
"key": "b8bf46d82e7ba37e4731fdc33df7282ac9ac6371780c9794d80659b8aecd0b26",
|
||||
"roles": [
|
||||
"publisher"
|
||||
],
|
||||
"required": 1
|
||||
},
|
||||
{
|
||||
"key": "c39f450caea2a00608a21c64dbbdaa2181a2727d27e9c9ef838e9666eb105e7e",
|
||||
"roles": [
|
||||
"received",
|
||||
"served"
|
||||
],
|
||||
"required": 4
|
||||
},
|
||||
{
|
||||
"key": "c8fee39620f9f0aebedb2b4bce0ca94033a46d4d92fe1c2d26322c58f1524c13",
|
||||
"roles": [
|
||||
"served",
|
||||
"store"
|
||||
],
|
||||
"required": 10
|
||||
},
|
||||
{
|
||||
"key": "d174dd8b7c789e94a6617313ea5c712c82c8bd2e72c93d60e19b313e8f6eb308",
|
||||
"roles": [
|
||||
"store"
|
||||
],
|
||||
"required": 10
|
||||
}
|
||||
],
|
||||
"missing": [],
|
||||
"uncounted": [],
|
||||
"clock": "us-pl-119-12-s3-a-3",
|
||||
"start": 1792569900,
|
||||
"deadline": 1792742700,
|
||||
"window_end": null,
|
||||
"first_purge": 1792576800,
|
||||
"last_purge": 1792620000,
|
||||
"time_to_removal_s": 50100,
|
||||
"verdict": "met",
|
||||
"matched": {
|
||||
"candidates": [
|
||||
"eac0d7665177bfb1a8abd47c8aed0a8a68d76691670cba4bcc18ac071b6d0030"
|
||||
],
|
||||
"missing": [],
|
||||
"last_purge": 1792584000
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cache-transmitted-not-purged",
|
||||
"description": "The same, verified after its deadline, with the cache's purge naming only what it received and served: the source it sent is not purged, purge_missing",
|
||||
"case": {
|
||||
"notices": [
|
||||
"{\"contact\":\"Submitter, [email protected]\",\"ground\":\"illegal\",\"ground_ref\":\"\",\"issued_at\":\"2026-10-21T08:00:00Z\",\"kind\":\"wai-takedown-notice\",\"sig\":\"ad959ee3bef413b627b6a42ab92fb1feb5d72f8ea0c78b073c79bc14fd61a9f59bb2cd54517b5b504e396d0828d61a39629e19f7aa5cc3158f47cecdafb94208\",\"signer_pubkey\":\"a99871bf596cb20db46c57507dfa4dcf0593ef0ed194f84a85c3b3907dd74d64\",\"statement\":\"The location named shows the submitter without consent.\",\"targets\":[{\"content_hash\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"type\":\"object\"}],\"type\":\"request\"}"
|
||||
],
|
||||
"takedown": "{\"automated\":{\"decision\":\"partially\",\"detection\":\"yes\"},\"category\":3,\"clock\":\"us-pl-119-12-s3-a-3\",\"clock_start\":\"2026-10-21T08:05:00Z\",\"decided_at\":\"2026-10-21T09:00:00Z\",\"exact\":[{\"content_hash\":\"0810f6c136790c08eaf99e8805e05237793be3f794af8eda2ea2750dc544e9cf\",\"type\":\"object\"},{\"content_hash\":\"2e745f40f6477b92cf7e6721536b4e24ac2aeb0058fa4735d4eafa3a2af5cb6a\",\"type\":\"object\"},{\"content_hash\":\"46a5043c50dbf90ae1ec2952ecd402f09fae39dc9e6e138e120dce7a770a5088\",\"type\":\"object\"},{\"content_hash\":\"5a13b782b85579d57d6547967cf70151b96a8d1fddd23951c85a582236e3c6df\",\"type\":\"object\"},{\"content_hash\":\"92c9d560804549d5e5978c40ed1c28285739c3325000c427edeba13f0e7d995b\",\"type\":\"object\"},{\"content_hash\":\"975c675d46575ec70ef57678bbb3080d5c3416ca3633de39195fc3664d65988f\",\"type\":\"object\"},{\"content_hash\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"type\":\"object\"},{\"content_hash\":\"dd572a12932a13c25259b5f03590cd802a1a3a21ba6e630c8e3050b4d2248ec4\",\"type\":\"object\"},{\"from_group\":5,\"track\":\"30ee90ff4071ee3057366a408a3bc82ef395811195763fac6d338ee9988b03e2\",\"type\":\"track\"},{\"receipt\":\"edea69fd50ffa2262e9c7bda6aab8e50b012199976b8b10abd4c4e598263b4d2\",\"type\":\"group\"}],\"explanation\":\"The item is a depiction of an identifiable person published without that person's consent.\",\"facts\":\"A notice located the item by its content hash; the service compared the notice with the item and removed it.\",\"ground\":\"illegal\",\"kind\":\"wai-takedown\",\"matched\":[{\"candidate\":\"eac0d7665177bfb1a8abd47c8aed0a8a68d76691670cba4bcc18ac071b6d0030\",\"match\":\"ef4d9d6607a8ba4f100ea843bc18e939f804b0385c9d4535e4570bbc30df6494\"}],\"notice\":\"6ee97e5d08ba81c873975fe5baf2bc5bbda42c384276f139ef255846c9162690\",\"policy\":\"f64a50351a627636b363eb8d63e25194a74263f2b05039dd7faf597d67f946c4\",\"reply_at\":null,\"sig\":\"96f52c0912833c6a42ab6fce7523c5ba2923bea5842f90177763b28eaf0e562f451b9ec44a9c05083f5efdaaa18c48cc5c0184e1d1b09f89f7e8121665f83509\",\"signer_pubkey\":\"e9e0cbabbdd1b0307e1d97595e7bcfea8cb43ae78cfb78580a422d5f3fef293d\",\"targets\":[{\"content_hash\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"type\":\"object\"}],\"verdict\":\"remove\"}",
|
||||
"matches": [
|
||||
"{\"candidate\":\"eac0d7665177bfb1a8abd47c8aed0a8a68d76691670cba4bcc18ac071b6d0030\",\"distance\":6,\"issued_at\":\"2026-10-21T07:00:00Z\",\"kind\":\"wai-takedown-match\",\"method\":\"wai-takedown:example-perceptual-hash\",\"sig\":\"db17bc1852808da8498440a6d50488cfde41d0377b7407f3673661720433f0bbc118b09d3ee69f5af63b0454f5f957497e0ab4ee2235f73cdd9e667072f73a02\",\"signer_pubkey\":\"903a2dcf9da77b1117c9f29c5e9d95c3578eac3514eade0df652ca4566b4be90\",\"source\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"threshold\":10}"
|
||||
],
|
||||
"purges": [
|
||||
"{\"kind\":\"wai-takedown-purge\",\"purged_at\":\"2026-10-21T10:00:00Z\",\"sig\":\"275859283c3e388a680ea5e308d8ce925852b5e928178adcee148bff28cc4e8d7bac46ecb538354cdf757d0221f35dac96a02a1d50b3b74fafbe9f34cdb6780c\",\"signer_pubkey\":\"d174dd8b7c789e94a6617313ea5c712c82c8bd2e72c93d60e19b313e8f6eb308\",\"subjects\":[{\"content_hash\":\"0810f6c136790c08eaf99e8805e05237793be3f794af8eda2ea2750dc544e9cf\",\"type\":\"object\"},{\"content_hash\":\"2e745f40f6477b92cf7e6721536b4e24ac2aeb0058fa4735d4eafa3a2af5cb6a\",\"type\":\"object\"},{\"content_hash\":\"46a5043c50dbf90ae1ec2952ecd402f09fae39dc9e6e138e120dce7a770a5088\",\"type\":\"object\"},{\"content_hash\":\"5a13b782b85579d57d6547967cf70151b96a8d1fddd23951c85a582236e3c6df\",\"type\":\"object\"},{\"content_hash\":\"92c9d560804549d5e5978c40ed1c28285739c3325000c427edeba13f0e7d995b\",\"type\":\"object\"},{\"content_hash\":\"975c675d46575ec70ef57678bbb3080d5c3416ca3633de39195fc3664d65988f\",\"type\":\"object\"},{\"content_hash\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"type\":\"object\"},{\"content_hash\":\"dd572a12932a13c25259b5f03590cd802a1a3a21ba6e630c8e3050b4d2248ec4\",\"type\":\"object\"},{\"content_hash\":\"eac0d7665177bfb1a8abd47c8aed0a8a68d76691670cba4bcc18ac071b6d0030\",\"type\":\"object\"},{\"from_group\":5,\"track\":\"30ee90ff4071ee3057366a408a3bc82ef395811195763fac6d338ee9988b03e2\",\"type\":\"track\"},{\"receipt\":\"edea69fd50ffa2262e9c7bda6aab8e50b012199976b8b10abd4c4e598263b4d2\",\"type\":\"group\"}],\"takedown\":\"b2c787c8814d4f600b229b2dc02d9f12a2efcb065d15b28e1262db8d1ab53c67\"}",
|
||||
"{\"kind\":\"wai-takedown-purge\",\"purged_at\":\"2026-10-21T12:00:00Z\",\"sig\":\"602e01802dca912905712a596ec04da856506196350912cc5c0134840422e472055d77f807c56dae60b0cfc5d76a0dbac3dfd684f3fed607c1632cbb2d8cff02\",\"signer_pubkey\":\"c8fee39620f9f0aebedb2b4bce0ca94033a46d4d92fe1c2d26322c58f1524c13\",\"subjects\":[{\"content_hash\":\"0810f6c136790c08eaf99e8805e05237793be3f794af8eda2ea2750dc544e9cf\",\"type\":\"object\"},{\"content_hash\":\"2e745f40f6477b92cf7e6721536b4e24ac2aeb0058fa4735d4eafa3a2af5cb6a\",\"type\":\"object\"},{\"content_hash\":\"46a5043c50dbf90ae1ec2952ecd402f09fae39dc9e6e138e120dce7a770a5088\",\"type\":\"object\"},{\"content_hash\":\"5a13b782b85579d57d6547967cf70151b96a8d1fddd23951c85a582236e3c6df\",\"type\":\"object\"},{\"content_hash\":\"92c9d560804549d5e5978c40ed1c28285739c3325000c427edeba13f0e7d995b\",\"type\":\"object\"},{\"content_hash\":\"975c675d46575ec70ef57678bbb3080d5c3416ca3633de39195fc3664d65988f\",\"type\":\"object\"},{\"content_hash\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"type\":\"object\"},{\"content_hash\":\"dd572a12932a13c25259b5f03590cd802a1a3a21ba6e630c8e3050b4d2248ec4\",\"type\":\"object\"},{\"content_hash\":\"eac0d7665177bfb1a8abd47c8aed0a8a68d76691670cba4bcc18ac071b6d0030\",\"type\":\"object\"},{\"from_group\":5,\"track\":\"30ee90ff4071ee3057366a408a3bc82ef395811195763fac6d338ee9988b03e2\",\"type\":\"track\"}],\"takedown\":\"b2c787c8814d4f600b229b2dc02d9f12a2efcb065d15b28e1262db8d1ab53c67\"}",
|
||||
"{\"kind\":\"wai-takedown-purge\",\"purged_at\":\"2026-10-21T14:00:00Z\",\"sig\":\"b60c19264df6491f0793dd9fbe800367e5c6d16c7007efa0205ab243e288beac655c0b09a14c6a2d466f3dc554f8f3e5280f059dc01341b427bc4f147d8e1708\",\"signer_pubkey\":\"36fb75567322347105ae69c22b4aef9307ae5edc22f88fb20a0816359e6d9b08\",\"subjects\":[{\"content_hash\":\"975c675d46575ec70ef57678bbb3080d5c3416ca3633de39195fc3664d65988f\",\"type\":\"object\"},{\"content_hash\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"type\":\"object\"}],\"takedown\":\"b2c787c8814d4f600b229b2dc02d9f12a2efcb065d15b28e1262db8d1ab53c67\"}",
|
||||
"{\"kind\":\"wai-takedown-purge\",\"purged_at\":\"2026-10-21T16:00:00Z\",\"sig\":\"cc7a0cac728c11d43966d92b8b771b80b9fbded603471319d080fa711ab6432dff6fd234901565424c0b23cdb77f9c06e42e1856a976b5e2d1ec1341928c3d05\",\"signer_pubkey\":\"103b452f8f9638d714aefc83544a2d7c605d67d379294627316ff2290755a3b4\",\"subjects\":[{\"content_hash\":\"2e745f40f6477b92cf7e6721536b4e24ac2aeb0058fa4735d4eafa3a2af5cb6a\",\"type\":\"object\"},{\"content_hash\":\"92c9d560804549d5e5978c40ed1c28285739c3325000c427edeba13f0e7d995b\",\"type\":\"object\"},{\"content_hash\":\"975c675d46575ec70ef57678bbb3080d5c3416ca3633de39195fc3664d65988f\",\"type\":\"object\"}],\"takedown\":\"b2c787c8814d4f600b229b2dc02d9f12a2efcb065d15b28e1262db8d1ab53c67\"}",
|
||||
"{\"kind\":\"wai-takedown-purge\",\"purged_at\":\"2026-10-21T18:00:00Z\",\"sig\":\"d2f4598aaf742fb97dfff6617bc2a196f768492e0cbad613651bf1340e44dec74f2891168a085fe519316eee709f3fbbadc49864ea68f9b8f9e099dbd32eb907\",\"signer_pubkey\":\"43441b8c2f91e6e9f60e7ebf2827f809695a5f410c85feea484ee41b04743db9\",\"subjects\":[{\"content_hash\":\"0810f6c136790c08eaf99e8805e05237793be3f794af8eda2ea2750dc544e9cf\",\"type\":\"object\"},{\"content_hash\":\"2e745f40f6477b92cf7e6721536b4e24ac2aeb0058fa4735d4eafa3a2af5cb6a\",\"type\":\"object\"},{\"content_hash\":\"46a5043c50dbf90ae1ec2952ecd402f09fae39dc9e6e138e120dce7a770a5088\",\"type\":\"object\"},{\"content_hash\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"type\":\"object\"},{\"from_group\":5,\"track\":\"30ee90ff4071ee3057366a408a3bc82ef395811195763fac6d338ee9988b03e2\",\"type\":\"track\"},{\"receipt\":\"edea69fd50ffa2262e9c7bda6aab8e50b012199976b8b10abd4c4e598263b4d2\",\"type\":\"group\"}],\"takedown\":\"b2c787c8814d4f600b229b2dc02d9f12a2efcb065d15b28e1262db8d1ab53c67\"}",
|
||||
"{\"kind\":\"wai-takedown-purge\",\"purged_at\":\"2026-10-21T20:00:00Z\",\"sig\":\"d3502bb148fa740208f2d4c9450058763a6427defd27f5fb9497f2aafecdd0f99d50c6cc25f2c7d255573757adb79bd9ac4c722a373eac73ed6955487268950e\",\"signer_pubkey\":\"b8bf46d82e7ba37e4731fdc33df7282ac9ac6371780c9794d80659b8aecd0b26\",\"subjects\":[{\"receipt\":\"edea69fd50ffa2262e9c7bda6aab8e50b012199976b8b10abd4c4e598263b4d2\",\"type\":\"group\"}],\"takedown\":\"b2c787c8814d4f600b229b2dc02d9f12a2efcb065d15b28e1262db8d1ab53c67\"}",
|
||||
"{\"kind\":\"wai-takedown-purge\",\"purged_at\":\"2026-10-21T22:00:00Z\",\"sig\":\"a4a629278da0b0ba968f4bb9b6d4cd522ee16da728add83526eba5f4869eb55f4264ac598131a694d244db36db16ed4b77217cfd34d388f9f57859eae9cc6d09\",\"signer_pubkey\":\"c39f450caea2a00608a21c64dbbdaa2181a2727d27e9c9ef838e9666eb105e7e\",\"subjects\":[{\"content_hash\":\"2e745f40f6477b92cf7e6721536b4e24ac2aeb0058fa4735d4eafa3a2af5cb6a\",\"type\":\"object\"},{\"content_hash\":\"92c9d560804549d5e5978c40ed1c28285739c3325000c427edeba13f0e7d995b\",\"type\":\"object\"},{\"content_hash\":\"975c675d46575ec70ef57678bbb3080d5c3416ca3633de39195fc3664d65988f\",\"type\":\"object\"}],\"takedown\":\"b2c787c8814d4f600b229b2dc02d9f12a2efcb065d15b28e1262db8d1ab53c67\"}"
|
||||
],
|
||||
"cache_transmissions": [
|
||||
"{\"body\":{\"attests\":\"transmitted\",\"bearer\":\"multicast\",\"bearer_id\":\"urn:example:wai-bearer:cache-mesh\",\"energy\":null,\"leaf_count\":2,\"sent\":\"80\",\"sent_root\":\"0910fab3ef9042775b1056dafb4c469c60871d2ae2a29d665eaec7bed2cf0d11\"},\"class\":\"cache-transmission\",\"issued_at\":\"2026-10-21T07:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"2a16e21f2cef0735446b99afea213c26d9f9f848b383ee9337ae2bc49a0d99b799e4f94d879a287548e3a8dad760ddbd0995f750820497ae53332e94f9134603\",\"signer_id\":\"did:key:z6MksctQULaYQDJmDMKBe8vep2zMFrwEDiuEEoBXxiPDWxgd\",\"signer_pubkey\":\"c39f450caea2a00608a21c64dbbdaa2181a2727d27e9c9ef838e9666eb105e7e\",\"subject\":\"64ed8925ac7eafd98980dcb81ba39edac6e372810c1573cfe27bb3c83a553c30\",\"subject_type\":\"receipt\"}"
|
||||
],
|
||||
"as_of": "2026-10-24T08:00:00Z"
|
||||
},
|
||||
"expect": {
|
||||
"outcome": "missed",
|
||||
"reason": "purge_missing",
|
||||
"facts": {
|
||||
"closure": [
|
||||
{
|
||||
"type": "object",
|
||||
"content_hash": "0810f6c136790c08eaf99e8805e05237793be3f794af8eda2ea2750dc544e9cf"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"content_hash": "2e745f40f6477b92cf7e6721536b4e24ac2aeb0058fa4735d4eafa3a2af5cb6a"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"content_hash": "46a5043c50dbf90ae1ec2952ecd402f09fae39dc9e6e138e120dce7a770a5088"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"content_hash": "5a13b782b85579d57d6547967cf70151b96a8d1fddd23951c85a582236e3c6df"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"content_hash": "92c9d560804549d5e5978c40ed1c28285739c3325000c427edeba13f0e7d995b"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"content_hash": "975c675d46575ec70ef57678bbb3080d5c3416ca3633de39195fc3664d65988f"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"content_hash": "dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"content_hash": "dd572a12932a13c25259b5f03590cd802a1a3a21ba6e630c8e3050b4d2248ec4"
|
||||
},
|
||||
{
|
||||
"type": "track",
|
||||
"track": "30ee90ff4071ee3057366a408a3bc82ef395811195763fac6d338ee9988b03e2",
|
||||
"from_group": 5
|
||||
},
|
||||
{
|
||||
"type": "group",
|
||||
"receipt": "edea69fd50ffa2262e9c7bda6aab8e50b012199976b8b10abd4c4e598263b4d2"
|
||||
}
|
||||
],
|
||||
"holders": [
|
||||
{
|
||||
"key": "103b452f8f9638d714aefc83544a2d7c605d67d379294627316ff2290755a3b4",
|
||||
"roles": [
|
||||
"deriver"
|
||||
],
|
||||
"required": 3
|
||||
},
|
||||
{
|
||||
"key": "36fb75567322347105ae69c22b4aef9307ae5edc22f88fb20a0816359e6d9b08",
|
||||
"roles": [
|
||||
"deriver"
|
||||
],
|
||||
"required": 2
|
||||
},
|
||||
{
|
||||
"key": "43441b8c2f91e6e9f60e7ebf2827f809695a5f410c85feea484ee41b04743db9",
|
||||
"roles": [
|
||||
"claimant"
|
||||
],
|
||||
"required": 6
|
||||
},
|
||||
{
|
||||
"key": "b8bf46d82e7ba37e4731fdc33df7282ac9ac6371780c9794d80659b8aecd0b26",
|
||||
"roles": [
|
||||
"publisher"
|
||||
],
|
||||
"required": 1
|
||||
},
|
||||
{
|
||||
"key": "c39f450caea2a00608a21c64dbbdaa2181a2727d27e9c9ef838e9666eb105e7e",
|
||||
"roles": [
|
||||
"received",
|
||||
"served"
|
||||
],
|
||||
"required": 4
|
||||
},
|
||||
{
|
||||
"key": "c8fee39620f9f0aebedb2b4bce0ca94033a46d4d92fe1c2d26322c58f1524c13",
|
||||
"roles": [
|
||||
"served",
|
||||
"store"
|
||||
],
|
||||
"required": 10
|
||||
},
|
||||
{
|
||||
"key": "d174dd8b7c789e94a6617313ea5c712c82c8bd2e72c93d60e19b313e8f6eb308",
|
||||
"roles": [
|
||||
"store"
|
||||
],
|
||||
"required": 10
|
||||
}
|
||||
],
|
||||
"missing": [
|
||||
{
|
||||
"holder": "c39f450caea2a00608a21c64dbbdaa2181a2727d27e9c9ef838e9666eb105e7e",
|
||||
"subject": {
|
||||
"type": "object",
|
||||
"content_hash": "dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58"
|
||||
}
|
||||
}
|
||||
],
|
||||
"uncounted": [],
|
||||
"clock": "us-pl-119-12-s3-a-3",
|
||||
"start": 1792569900,
|
||||
"deadline": 1792742700,
|
||||
"window_end": null,
|
||||
"first_purge": 1792576800,
|
||||
"last_purge": 1792620000,
|
||||
"time_to_removal_s": null,
|
||||
"verdict": "missed",
|
||||
"matched": {
|
||||
"candidates": [
|
||||
"eac0d7665177bfb1a8abd47c8aed0a8a68d76691670cba4bcc18ac071b6d0030"
|
||||
],
|
||||
"missing": [],
|
||||
"last_purge": 1792584000
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stranger-purge-not-counted",
|
||||
"description": "As request-48h-purge-missing, with a purge of the thumbnail, the rung and the component signed by a party that never held them: its subjects are uncounted and the edge's purge is still missing",
|
||||
|
|
@ -2263,6 +2560,32 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cache-transmission-invalid",
|
||||
"description": "A cache-transmission claim whose signature does not cover what it states",
|
||||
"case": {
|
||||
"notices": [
|
||||
"{\"contact\":\"Submitter, [email protected]\",\"ground\":\"illegal\",\"ground_ref\":\"\",\"issued_at\":\"2026-10-21T08:00:00Z\",\"kind\":\"wai-takedown-notice\",\"sig\":\"ad959ee3bef413b627b6a42ab92fb1feb5d72f8ea0c78b073c79bc14fd61a9f59bb2cd54517b5b504e396d0828d61a39629e19f7aa5cc3158f47cecdafb94208\",\"signer_pubkey\":\"a99871bf596cb20db46c57507dfa4dcf0593ef0ed194f84a85c3b3907dd74d64\",\"statement\":\"The location named shows the submitter without consent.\",\"targets\":[{\"content_hash\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"type\":\"object\"}],\"type\":\"request\"}"
|
||||
],
|
||||
"takedown": "{\"automated\":{\"decision\":\"partially\",\"detection\":\"yes\"},\"category\":3,\"clock\":\"us-pl-119-12-s3-a-3\",\"clock_start\":\"2026-10-21T08:05:00Z\",\"decided_at\":\"2026-10-21T09:00:00Z\",\"exact\":[{\"content_hash\":\"0810f6c136790c08eaf99e8805e05237793be3f794af8eda2ea2750dc544e9cf\",\"type\":\"object\"},{\"content_hash\":\"2e745f40f6477b92cf7e6721536b4e24ac2aeb0058fa4735d4eafa3a2af5cb6a\",\"type\":\"object\"},{\"content_hash\":\"46a5043c50dbf90ae1ec2952ecd402f09fae39dc9e6e138e120dce7a770a5088\",\"type\":\"object\"},{\"content_hash\":\"5a13b782b85579d57d6547967cf70151b96a8d1fddd23951c85a582236e3c6df\",\"type\":\"object\"},{\"content_hash\":\"92c9d560804549d5e5978c40ed1c28285739c3325000c427edeba13f0e7d995b\",\"type\":\"object\"},{\"content_hash\":\"975c675d46575ec70ef57678bbb3080d5c3416ca3633de39195fc3664d65988f\",\"type\":\"object\"},{\"content_hash\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"type\":\"object\"},{\"content_hash\":\"dd572a12932a13c25259b5f03590cd802a1a3a21ba6e630c8e3050b4d2248ec4\",\"type\":\"object\"},{\"from_group\":5,\"track\":\"30ee90ff4071ee3057366a408a3bc82ef395811195763fac6d338ee9988b03e2\",\"type\":\"track\"},{\"receipt\":\"edea69fd50ffa2262e9c7bda6aab8e50b012199976b8b10abd4c4e598263b4d2\",\"type\":\"group\"}],\"explanation\":\"The item is a depiction of an identifiable person published without that person's consent.\",\"facts\":\"A notice located the item by its content hash; the service compared the notice with the item and removed it.\",\"ground\":\"illegal\",\"kind\":\"wai-takedown\",\"matched\":[{\"candidate\":\"eac0d7665177bfb1a8abd47c8aed0a8a68d76691670cba4bcc18ac071b6d0030\",\"match\":\"ef4d9d6607a8ba4f100ea843bc18e939f804b0385c9d4535e4570bbc30df6494\"}],\"notice\":\"6ee97e5d08ba81c873975fe5baf2bc5bbda42c384276f139ef255846c9162690\",\"policy\":\"f64a50351a627636b363eb8d63e25194a74263f2b05039dd7faf597d67f946c4\",\"reply_at\":null,\"sig\":\"96f52c0912833c6a42ab6fce7523c5ba2923bea5842f90177763b28eaf0e562f451b9ec44a9c05083f5efdaaa18c48cc5c0184e1d1b09f89f7e8121665f83509\",\"signer_pubkey\":\"e9e0cbabbdd1b0307e1d97595e7bcfea8cb43ae78cfb78580a422d5f3fef293d\",\"targets\":[{\"content_hash\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"type\":\"object\"}],\"verdict\":\"remove\"}",
|
||||
"matches": [
|
||||
"{\"candidate\":\"eac0d7665177bfb1a8abd47c8aed0a8a68d76691670cba4bcc18ac071b6d0030\",\"distance\":6,\"issued_at\":\"2026-10-21T07:00:00Z\",\"kind\":\"wai-takedown-match\",\"method\":\"wai-takedown:example-perceptual-hash\",\"sig\":\"db17bc1852808da8498440a6d50488cfde41d0377b7407f3673661720433f0bbc118b09d3ee69f5af63b0454f5f957497e0ab4ee2235f73cdd9e667072f73a02\",\"signer_pubkey\":\"903a2dcf9da77b1117c9f29c5e9d95c3578eac3514eade0df652ca4566b4be90\",\"source\":\"dc3f88f4a51fe009d3c9d5f0bd42481b6f783333667748f4c5d8d00590dd9b58\",\"threshold\":10}"
|
||||
],
|
||||
"purges": [],
|
||||
"as_of": "2026-10-24T08:00:00Z",
|
||||
"cache_transmissions": [
|
||||
"{\"body\":{\"attests\":\"transmitted\",\"bearer\":\"multicast\",\"bearer_id\":\"urn:example:wai-bearer:cache-mesh\",\"energy\":null,\"leaf_count\":2,\"sent\":\"80\",\"sent_root\":\"0910fab3ef9042775b1056dafb4c469c60871d2ae2a29d665eaec7bed2cf0d11\"},\"class\":\"cache-transmission\",\"issued_at\":\"2026-10-21T07:00:01Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"2a16e21f2cef0735446b99afea213c26d9f9f848b383ee9337ae2bc49a0d99b799e4f94d879a287548e3a8dad760ddbd0995f750820497ae53332e94f9134603\",\"signer_id\":\"did:key:z6MksctQULaYQDJmDMKBe8vep2zMFrwEDiuEEoBXxiPDWxgd\",\"signer_pubkey\":\"c39f450caea2a00608a21c64dbbdaa2181a2727d27e9c9ef838e9666eb105e7e\",\"subject\":\"64ed8925ac7eafd98980dcb81ba39edac6e372810c1573cfe27bb3c83a553c30\",\"subject_type\":\"receipt\"}"
|
||||
]
|
||||
},
|
||||
"expect": {
|
||||
"outcome": "refuse",
|
||||
"reason": "evidence_invalid",
|
||||
"at": [
|
||||
"cache_transmission",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "group-unexpanded",
|
||||
"description": "The generated group's second object receipt is not given, so the group cannot be expanded",
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ Written from the extension's text, sharing no code with wai-rs. It reads every r
|
|||
under §2's encodings, computes the derivation closure of §6 from the derivation records (read
|
||||
with the deriving-edge corpus's second implementation, edge-conformance/edge_verify.py), the
|
||||
origin claims (read with the synthesis corpus's, live-conformance/synthesis_verify.py), the
|
||||
delivery receipts of the groups it reaches (read with edge_verify.py too) and the
|
||||
delivery receipts of the groups it reaches (read with edge_verify.py too), the
|
||||
acknowledgements that show who served or received an object (read with the settlement corpus's,
|
||||
live-conformance/settlement_verify.py), holds the takedown's
|
||||
live-conformance/settlement_verify.py) and the cache-transmission claims that show what a node sent
|
||||
from what it held (read through jwp-receipts §3.1 steps 1 to 4 with the claim corpus's,
|
||||
claim-conformance/claim_verify.py), holds the takedown's
|
||||
stated set to it, finds the holders and the purges that cover them (§7), computes the time to
|
||||
removal against the clock the registry gives (§4, §7.3), and recomputes the counts (§9). Every
|
||||
signature is checked with WAI's strict Ed25519 rule (claim-conformance/_ed25519.py).
|
||||
|
|
@ -17,7 +19,8 @@ dropped in turn, requires the corpus to catch the weakened verifier.
|
|||
|
||||
python3 -I takedown_verify.py <live-conformance/takedown>
|
||||
|
||||
Standard library only. Exit status 0 when everything holds, 1 otherwise.
|
||||
The standard library and the `cryptography` package, which the claim verifier imports. Exit
|
||||
status 0 when everything holds, 1 otherwise.
|
||||
"""
|
||||
import calendar
|
||||
import copy
|
||||
|
|
@ -35,6 +38,7 @@ from _blake3 import blake3 # noqa: E402
|
|||
import edge_verify as EV # noqa: E402
|
||||
import synthesis_verify as SYV # noqa: E402
|
||||
import settlement_verify as SEV # noqa: E402
|
||||
import claim_verify as CV # noqa: E402
|
||||
|
||||
EXTENSION = os.path.join(HERE, "..", "extensions", "takedown.md")
|
||||
PUBLISHED = os.path.join(HERE, "..", "registry", "takedown-clocks.json")
|
||||
|
|
@ -47,9 +51,10 @@ ROLES = ("origin", "store", "relay", "cache", "edge")
|
|||
CHECKS = (
|
||||
"registry-shape", "signature", "registry-link", "policy-clocks", "takedown-policy", "takedown-signer",
|
||||
"notice-held", "notice-targets", "clock-in-policy", "clock-consistent", "records", "claims", "acknowledgements",
|
||||
"cache-transmissions",
|
||||
"purge-takedown", "rule-group", "rule-derivation", "rule-claim", "group-expanded",
|
||||
"exact-supported", "match-not-exact", "exact-complete", "match-held", "match-anchored",
|
||||
"store-holders", "deriver-holders", "claimant-holders", "publisher-holders", "served-holders",
|
||||
"store-holders", "deriver-holders", "claimant-holders", "publisher-holders", "served-holders", "sent-holders",
|
||||
"holder-only", "group-coverage", "deadline", "purges-complete", "window",
|
||||
"counts-as-of", "counts-rows",
|
||||
)
|
||||
|
|
@ -433,6 +438,19 @@ def evaluate(case, as_of):
|
|||
except (SEV.Refuse, Bad, ValueError, KeyError, TypeError):
|
||||
if on("acknowledgements"):
|
||||
raise Refuse("evidence_invalid", ["acknowledgement", i])
|
||||
# The cache-transmission claims (cache-authority §5): each of that class and through
|
||||
# jwp-receipts §3.1 steps 1 to 4; what each shows, (signer, group, positions sent).
|
||||
sent = []
|
||||
for i, t in enumerate(case.get("cache_transmissions", [])):
|
||||
try:
|
||||
c = CV.verify_claim(t)
|
||||
need(c["class"] == "cache-transmission")
|
||||
except (CV.Reject, Bad):
|
||||
if on("cache-transmissions"):
|
||||
raise Refuse("evidence_invalid", ["cache_transmission", i])
|
||||
continue
|
||||
bits = bytes.fromhex(c["body"]["sent"])
|
||||
sent.append((c["signer_pubkey"], c["subject"], [p for p in range(8 * len(bits)) if bits[p // 8] & (0x80 >> (p % 8))]))
|
||||
matches = [read_signed(t, "wai-takedown-match", ["kind", "source", "candidate", "method", "distance", "threshold", "issued_at",
|
||||
"signer_pubkey", "sig"], match_fields, ["match", i])
|
||||
for i, t in enumerate(case.get("matches", []))]
|
||||
|
|
@ -541,6 +559,16 @@ def evaluate(case, as_of):
|
|||
needs(up, obj(h), "served")
|
||||
if hop is not None:
|
||||
needs(hop, obj(h), "received")
|
||||
if on("sent-holders"):
|
||||
# A node's own word that it sent objects of a group, read by position through the group's
|
||||
# object receipts (§6.3); a group not among the receipts names no object.
|
||||
for signer, g, pos in sent:
|
||||
e = expand(case.get("receipts", []), g)
|
||||
if e is None:
|
||||
continue
|
||||
for p in pos:
|
||||
if p < len(e[1]) and e[1][p] in objects:
|
||||
needs(signer, obj(e[1][p]), "served")
|
||||
matched_required = {}
|
||||
cands = {e["candidate"] for e in td["matched"]}
|
||||
for k in pol["stores"]:
|
||||
|
|
@ -796,6 +824,8 @@ def mutations(doc):
|
|||
mutate("the cache's acknowledgement of the thumbnail withheld", lambda d: d["world"]["acknowledgements"].pop(0)),
|
||||
mutate("a byte of an acknowledgement's signature",
|
||||
lambda d: d["world"]["acknowledgements"][1].update(record=flip_hex_digit(d["world"]["acknowledgements"][1]["record"], len(d["world"]["acknowledgements"][1]["record"]) - 9))),
|
||||
mutate("the cache's claim that it sent the source withheld",
|
||||
lambda d: case(d, "cache-transmitted-not-purged")["case"]["cache_transmissions"].pop()),
|
||||
mutate("the unfixed request stated met", lambda d: facts(d, "request-unfixed").update(verdict="met")),
|
||||
mutate("the reply window's end moved", lambda d: facts(d, "own-reply-early").update(window_end=facts(d, "own-reply-early")["window_end"] + 1)),
|
||||
mutate("a refused case's place moved", lambda d: case(d, "derivative-left-out")["expect"].update(at=["closure", 5])),
|
||||
|
|
|
|||
|
|
@ -1041,7 +1041,8 @@ def u64(x):
|
|||
# cells (§9.2).
|
||||
|
||||
FLOOR = 50
|
||||
MEASURES = {1: "outcomes", 2: "stalls", 3: "durations", 4: "start_time"}
|
||||
# Measure 5's configuration is impression-aggregates §5.3's, against a catalogue (§4.3 rule 3).
|
||||
MEASURES = {1: "outcomes", 2: "stalls", 3: "durations", 4: "start_time", 5: "impressions"}
|
||||
START_EDGES = [250, 500, 1000, 2000, 4000, 8000]
|
||||
|
||||
|
||||
|
|
@ -1089,8 +1090,8 @@ def task_id(cfg):
|
|||
return sha256(b"wai:telemetry-aggregate/task\x00" + cfg)
|
||||
|
||||
|
||||
def check_task(cfg_bytes):
|
||||
"""§4.3, in order: the task's fields, its info and its Prio3."""
|
||||
def task_fields(cfg_bytes):
|
||||
"""§4.3 rule 1: the task's fields and its info."""
|
||||
try:
|
||||
r = Rd(cfg_bytes)
|
||||
t = read_task(r)
|
||||
|
|
@ -1104,11 +1105,24 @@ def check_task(cfg_bytes):
|
|||
types = [e[0] for e in t["extensions"]]
|
||||
if any(e[0] != 1 or len(e[1]) != 16 for e in t["extensions"]) or types != sorted(set(types)):
|
||||
raise Refuse("task_invalid", "task")
|
||||
return t, info
|
||||
|
||||
|
||||
def check_task(cfg_bytes, measure5=None):
|
||||
"""§4.3, in order: the task's fields, its info and its Prio3. For measure 5, rule 3 is
|
||||
impression-aggregates §5.3 steps 2 to 4, given as `measure5(t, info)`, which returns the
|
||||
task's Prio3 or refuses; given none, the task is refused `catalogue_mismatch`."""
|
||||
t, info = task_fields(cfg_bytes)
|
||||
if info is None or info["measure"] not in MEASURES:
|
||||
raise Refuse("measure_unknown", "task")
|
||||
p = measure_vdaf(info["measure"])
|
||||
if "measure" not in SKIP and (t["vdaf_type"] != p.v.ID or t["vdaf_config"] != dap_config(p)):
|
||||
raise Refuse("measure_mismatch", "task")
|
||||
if info["measure"] == 5:
|
||||
if measure5 is None:
|
||||
raise Refuse("catalogue_mismatch", "task")
|
||||
p = measure5(t, info)
|
||||
else:
|
||||
p = measure_vdaf(info["measure"])
|
||||
if "measure" not in SKIP and (t["vdaf_type"] != p.v.ID or t["vdaf_config"] != dap_config(p)):
|
||||
raise Refuse("measure_mismatch", "task")
|
||||
if "floor-rule" not in SKIP and t["min_batch_size"] < FLOOR:
|
||||
raise Refuse("floor_too_low", "task")
|
||||
if "one-key" not in SKIP and info["leader"] == info["helper"]:
|
||||
|
|
@ -1159,10 +1173,10 @@ def sig_check(key, domain, body, sig, at):
|
|||
raise Refuse(e, at)
|
||||
|
||||
|
||||
def verify_receipt(b, reports):
|
||||
"""§8.1, in order."""
|
||||
def verify_receipt(b, reports, task_check=check_task):
|
||||
"""§8.1, in order; step 2 is `task_check` (impression-aggregates §8.1 gives its own)."""
|
||||
rc = read_receipt(b)
|
||||
t, info, p, tid = check_task(rc["task"])
|
||||
t, info, p, tid = task_check(rc["task"])
|
||||
if "collector-signer" not in SKIP and rc["signer"] != info["collector"]:
|
||||
raise Refuse("wrong_signer", "receipt")
|
||||
if "collector-sig" not in SKIP:
|
||||
|
|
|
|||
63
wai/live-conformance/timeliness/README.md
Normal file
63
wai/live-conformance/timeliness/README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# live-conformance/timeliness — when an object is of use, and whether it was held in time
|
||||
|
||||
The corpus of [`extensions/timeliness.md`](../../extensions/timeliness.md) §10, in one file,
|
||||
`vectors.json`.
|
||||
|
||||
**Provenance.** Generated here by `wai_timeliness_vectors gen` (`wai-rs`, feature `timeliness`),
|
||||
from Ed25519 keys on fixed 32-byte seeds, constant instants, and envelopes whose payloads are
|
||||
opaque test bytes written by the generator; no media and no input from outside this repository.
|
||||
|
||||
`keys` names every key by its role, as 64 lowercase hexadecimal digits: the publisher's
|
||||
(`did:web:publisher.example#d1`), another of its origin's (`publisher_other`,
|
||||
`did:web:publisher.example#d2`), and three holders' (`holder_a`, `holder_b`, `holder_c`).
|
||||
`groups` names the link hash of each group the cases use, all the publisher's, all starting at
|
||||
2026-11-03T00:00:00.500Z: **G**, three bases and a layer of `wai.image.int_refine` (object 3);
|
||||
**F**, a floor of each of G's objects, its manifest's `derived_from.motif` the content hash of G's
|
||||
object at the same position; **F_other**, F with object 2's floor derived from G's object 1; and
|
||||
**H**, two objects, the second a `WAI2`. G's entries, used throughout, have `from_ms`
|
||||
1,793,664,000,000 (2026-11-03T00:00:00Z) and `by_ms` a minute later: entry 0 falls to 600,000 two
|
||||
seconds after its deadline and to 0 ten seconds after, with F's object 0 at 400,000 as its
|
||||
fallback; entry 1 is a hard deadline with no fallback; entry 2 holds 700,000 from one to five
|
||||
seconds after, and 0 a second later, with F's object 2 at 1,000,000.
|
||||
|
||||
- `claims` (§4.1, §4.2; jwp-receipts §3.1 steps 1 to 4) are each a claim's wire form, `claim`,
|
||||
and its `outcome`: `accept`, or `reject step=N reason=R`.
|
||||
- `values` (§5) are each an `entry` read alone and an instant `at_ms`; `outcome` gives the entry's
|
||||
`value_ppm` at that instant and its `end_ms` (§5.2). No value case's entry has a fallback.
|
||||
- `verdicts` (§6) are each the `groups` given, in order, each its `group` receipt and its
|
||||
`objects`, a `receipt` and its `envelope` in hexadecimal or `null`; the `claims`, in order; the
|
||||
holdings `records` in hexadecimal; and a `statement` offered (§6.4), `{group, index, holder}`, or
|
||||
`null`. `outcome` is `refuse reason=R` when a group fails jwp-receipts §3 steps 1 and 2, and
|
||||
otherwise gives: `objects`, each group's object verdicts (`delivered-verified`,
|
||||
`inclusion-verified`, `reject reason=…`); `claims` (`accepted`, or `reject step=N reason=R`);
|
||||
`records` (`stands`, `set-aside reason=holdings_contradicted`, `reject reason=…`); `intents`,
|
||||
in the order of the groups given and then of `leaf_index`, each with its `group`, `leaf_index`,
|
||||
`intent` (`stands`, or `set-aside reason=timeliness_contradicted`), `end_ms` (`null` for one set
|
||||
aside) and `lines`, in increasing order of the holder's key bytes, each its `holder`, `held_ms`
|
||||
(*p*), `fallback_ms` (*f*), `outcome` and `value_ppm`; and `statement`, `{outcome, value_ppm}`,
|
||||
`reject reason=deadline_not_stated`, or `null`.
|
||||
- `plans` (§7) are each a verdict's `groups`, `claims` (timeliness, transmission and schedule) and
|
||||
`records`; `loss_ppm` and `overhead`, both integers or both `null`; `at_ms`, an instant or the
|
||||
string `from_ms`; the `sources` in the asker's order, each exactly `{kind: "bearer", bearer_id}`,
|
||||
`{kind: "holder", holder, fetch_ms}` or `{kind: "origin", fetch_ms}`; and the `wanted` objects,
|
||||
each `{group, index}`. `outcome` is `refuse reason=R`, or gives `claims` (`accepted`,
|
||||
`declared`, `stands`, `set-aside reason=…`, `reject step=N reason=R`), `records` as a verdict's,
|
||||
and `choices`, one for each wanted object: `{outcome, object, source, arrival_ms, value_ppm}`,
|
||||
with `object` `primary`, `fallback` or `null` and `source` an index into `sources` or `null`, or
|
||||
`reject reason=deadline_not_stated`.
|
||||
|
||||
The plans' schedules are G's objects 0 to 2 on `urn:example:bearer:b1` and F's objects on `b1` and
|
||||
on `urn:example:bearer:b3`, each declared `unidirectional`: RaptorQ, one source block of eight
|
||||
symbols of 128 octets and two repair symbols a pass, `advance`, ten passes of a second every ten
|
||||
seconds from 2026-11-03T00:00:00Z. At the plans' loss bound of 500,000 and overhead of 2 a receiver
|
||||
gets 5 symbols a pass and needs 12, so it holds an object at the end of its third pass.
|
||||
`urn:example:bearer:b2` is declared `bidirectional`.
|
||||
|
||||
Everything is synthetic: keys attest nothing, the publisher's origin is a reserved example domain,
|
||||
and bearer instances use the example URN namespace. Each case states the outcome it was written to
|
||||
have, declared in the generator, never copied from a verifier.
|
||||
|
||||
`wai_timeliness_vectors verify` and `tests/timeliness.rs` check that the file regenerates byte for
|
||||
byte and that `wai-rs` reaches every stated outcome. `../timeliness_verify.py` is the second
|
||||
implementation, written from the extension's text, which also mutates copies of the corpus and
|
||||
drops each of its own checks in turn and requires each to be caught.
|
||||
23550
wai/live-conformance/timeliness/vectors.json
Normal file
23550
wai/live-conformance/timeliness/vectors.json
Normal file
File diff suppressed because it is too large
Load diff
871
wai/live-conformance/timeliness_verify.py
Normal file
871
wai/live-conformance/timeliness_verify.py
Normal file
|
|
@ -0,0 +1,871 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Independent verifier of the timeliness corpus (extensions/timeliness.md).
|
||||
|
||||
Written from the extension's text. It shares no code with wai-rs, which generates the corpus,
|
||||
and never runs it. It takes from the second implementations of the texts this extension builds
|
||||
on what they define: group and object receipts from the JWP corpus's
|
||||
(`../jwp-conformance/jwp_verify.py`, jwp-receipts §2.2, §3); a claim's steps 1 to 5 from the
|
||||
claim corpus's (`../claim-conformance/claim_verify.py`, jwp-receipts §3.1), whose step 4 holds
|
||||
§4.2's rules; a transmission claim's step 6, a schedule's, and a receiver's passes from the
|
||||
carousel corpus's (`carousel_verify.py`: bearer-receipts §4.3, carousel §4.3 and §7); a holdings
|
||||
record's form, signature and holdings from the holdings corpus's (`holdings_verify.py`, holdings
|
||||
§4.4, §5.4); and a WAI1 envelope's manifest from the staged corpus's reader (SPEC §2, §3).
|
||||
Everything else this extension adds is written here:
|
||||
|
||||
- §4.3: a timeliness claim against its group, the object receipts and envelopes held, and each
|
||||
fallback's group and object receipt;
|
||||
- §5: an entry's value at an instant, and its end;
|
||||
- §6: the intents that stand, the records that stand and the held-at statements they give
|
||||
(`held_from_records`, with §2's `before_group`), each intent's lines (`lines`), and a
|
||||
statement offered;
|
||||
- §7: the plan's inputs, each source's arrival, and the choice.
|
||||
|
||||
For every case it requires the outcome the corpus states, field for field; that every reason
|
||||
of §9's table and every outcome of §6.3's appears in the corpus; then it mutates copies of the
|
||||
corpus, one defect each, and requires every copy to fail; and it drops each of its own checks in
|
||||
turn and requires the corpus to catch the weakened verifier. A bound no vector carries is checked
|
||||
directly.
|
||||
|
||||
python3 timeliness_verify.py <live-conformance/timeliness>
|
||||
|
||||
Exit status 0 only if every case verifies, every mutated copy fails, every weakened verifier is
|
||||
caught and every direct check holds. Python 3.8 or later; the `cryptography` package, which the
|
||||
claim verifier imports.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
for d in ("claim-conformance", "jwp-conformance"):
|
||||
sys.path.insert(0, os.path.join(HERE, "..", d))
|
||||
import claim_verify as CV # noqa: E402
|
||||
import jwp_verify as JV # noqa: E402
|
||||
import carousel_verify as KV # noqa: E402
|
||||
import holdings_verify as HV # noqa: E402
|
||||
|
||||
SV = CV.SV # the staged corpus's strict WAI1 reader, which the claim verifier loads
|
||||
|
||||
EXTENSION = os.path.join(HERE, "..", "extensions", "timeliness.md")
|
||||
SPEC = os.path.join(HERE, "..", "SPEC.md")
|
||||
JWP_RECEIPTS = os.path.join(HERE, "..", "extensions", "jwp-receipts.md")
|
||||
TELEMETRY = os.path.join(HERE, "..", "extensions", "delivery-telemetry.md")
|
||||
FULL = 1_000_000 # §2: a value in parts per million of the full value
|
||||
LAST_INSTANT = 253_402_300_799_999 # §2: 9999-12-31T23:59:59.999Z
|
||||
MAX_INT = (1 << 53) - 1
|
||||
|
||||
# A weakened run drops one of these checks (see `WEAKENINGS`); None checks everything.
|
||||
DROPPED = None
|
||||
|
||||
|
||||
def drops(name):
|
||||
return DROPPED == name
|
||||
|
||||
|
||||
def text_of(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def table_rows(text, heading):
|
||||
"""The first backticked cell of each row of the table under `heading`, up to the next heading
|
||||
of its level or above."""
|
||||
at = text.index("\n" + heading)
|
||||
level = heading.split(" ")[0]
|
||||
ends = [text.find("\n" + h + " ", at + 1) for h in ("#" * n for n in range(2, len(level) + 1))]
|
||||
ends = [e for e in ends if e >= 0]
|
||||
rows = text[at:min(ends) if ends else len(text)].splitlines()
|
||||
return [r.split("`")[1] for r in rows if r.startswith("| `")]
|
||||
|
||||
|
||||
def reasons_table():
|
||||
"""§9's codes, in order, each with the section the table says raises it."""
|
||||
text = text_of(EXTENSION)
|
||||
at = text.index("\n## 9. Reasons")
|
||||
end = text.find("\n## ", at + 1)
|
||||
rows = text[at:end].splitlines()
|
||||
return [(r.split("`")[1], r.split("|")[2].strip()) for r in rows if r.startswith("| `")]
|
||||
|
||||
|
||||
def outcomes_table():
|
||||
"""§6.3's outcomes, in order."""
|
||||
return table_rows(text_of(EXTENSION), "### 6.3 Lines")
|
||||
|
||||
|
||||
def refinement_only():
|
||||
"""SPEC §5, *Refinement capabilities*: the capabilities that are refinement-only."""
|
||||
return set(table_rows(text_of(SPEC), "### Refinement capabilities (staged delivery)"))
|
||||
|
||||
|
||||
REFINEMENT_ONLY = refinement_only()
|
||||
|
||||
|
||||
def need(ok, reason):
|
||||
"""jwp-receipts §3.1 step 6."""
|
||||
if not ok:
|
||||
raise CV.Reject(6, reason)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ what the verifier holds
|
||||
|
||||
# Steps 1 to 4 of a claim, a group's steps 1 and 2 with its objects', and a record's reading are
|
||||
# pure in their bytes: each is computed once and kept, which nothing a weakened run drops reads.
|
||||
_CLAIMS, _GROUPS, _RECORDS = {}, {}, {}
|
||||
|
||||
|
||||
def claim_steps(wire):
|
||||
"""jwp-receipts §3.1 steps 1 to 4 (claim_verify.py). Returns the claim; raises CV.Reject."""
|
||||
if wire not in _CLAIMS:
|
||||
try:
|
||||
_CLAIMS[wire] = CV.verify_claim(wire)
|
||||
except CV.Reject as r:
|
||||
_CLAIMS[wire] = r
|
||||
v = _CLAIMS[wire]
|
||||
if isinstance(v, CV.Reject):
|
||||
raise v
|
||||
return v
|
||||
|
||||
|
||||
def read_record(b):
|
||||
"""holdings §4.4 (holdings_verify.py). Returns the record; raises HV.Refused."""
|
||||
if b not in _RECORDS:
|
||||
try:
|
||||
_RECORDS[b] = HV.read(b)
|
||||
except HV.Refused as r:
|
||||
_RECORDS[b] = r
|
||||
v = _RECORDS[b]
|
||||
if isinstance(v, HV.Refused):
|
||||
raise v
|
||||
return v
|
||||
|
||||
|
||||
class Group:
|
||||
"""A group given, after jwp-receipts §3 steps 1 and 2, with its object receipts and envelopes."""
|
||||
|
||||
def __init__(self, item):
|
||||
self.receipt = JV.read_group(item["group"])
|
||||
JV.verify_group(self.receipt)
|
||||
self.link = JV.link(self.receipt).hex()
|
||||
self.count = self.receipt["leaf_count"]
|
||||
self.signer = self.receipt["signer_pubkey"]
|
||||
self.start = JV.seconds(self.receipt["group_start"])
|
||||
self.verdicts = [] # bearer-receipts §6.2 step 2, one for each object receipt given
|
||||
self.pairs = [] # [(object receipt, envelope or None)] for each that reads
|
||||
self.included = {} # position -> content hash of each object receipt that passes step 5
|
||||
self.envelopes = {} # content hash -> envelope bytes held
|
||||
for o in item["objects"]:
|
||||
try:
|
||||
ob = JV.read_object(o["receipt"])
|
||||
except JV.Refuse as r:
|
||||
self.verdicts.append("reject reason=" + r.reason)
|
||||
continue
|
||||
env = None if o["envelope"] is None else bytes.fromhex(o["envelope"])
|
||||
self.pairs.append((ob, env))
|
||||
try:
|
||||
if env is None:
|
||||
JV.inclusion(ob, self.receipt)
|
||||
self.verdicts.append("inclusion-verified")
|
||||
else:
|
||||
JV.accept(ob, self.receipt, env)
|
||||
self.verdicts.append("delivered-verified")
|
||||
except JV.Refuse as r:
|
||||
self.verdicts.append("reject reason=" + r.reason)
|
||||
try:
|
||||
JV.inclusion(ob, self.receipt)
|
||||
self.included[ob["object_id"]] = ob["content_hash"].hex()
|
||||
except JV.Refuse:
|
||||
pass
|
||||
if env is not None:
|
||||
self.envelopes[JV.blake3(JV.OBJECT + env).hex()] = env
|
||||
|
||||
|
||||
def read_groups(items):
|
||||
"""§6.2 step 1: each group under jwp-receipts §3 steps 1 and 2, and each of its object receipts
|
||||
as bearer-receipts §6.2 step 2 checks it. Raises JV.Refuse for a group that fails."""
|
||||
out = []
|
||||
for item in items:
|
||||
key = json.dumps(item, sort_keys=True)
|
||||
if key not in _GROUPS:
|
||||
try:
|
||||
_GROUPS[key] = Group(item)
|
||||
except JV.Refuse as r:
|
||||
_GROUPS[key] = r
|
||||
g = _GROUPS[key]
|
||||
if isinstance(g, JV.Refuse):
|
||||
raise g
|
||||
out.append(g)
|
||||
return out
|
||||
|
||||
|
||||
def manifest(env):
|
||||
"""A WAI1 envelope's manifest (SPEC §2, §3), or None for bytes that are not one."""
|
||||
try:
|
||||
m, _, _ = SV.read_wai1(env)
|
||||
except SV.NotWai1:
|
||||
return None
|
||||
return m
|
||||
|
||||
|
||||
def is_layer(env):
|
||||
"""§4.3 step 6: a WAI1 whose capability is refinement-only (SPEC §5)."""
|
||||
m = manifest(env)
|
||||
return m is not None and m["model_requirement"]["capability"] in REFINEMENT_ONLY
|
||||
|
||||
|
||||
def derived_from(env, motif):
|
||||
"""§4.3 step 8: a WAI1 whose manifest carries `derived_from` (SPEC §7.1, *Derived renditions*:
|
||||
its four members in their forms) with `motif` the primary's content hash. A WAI2 is none."""
|
||||
m = manifest(env)
|
||||
df = None if m is None else m.get("derived_from")
|
||||
return (isinstance(df, dict) and all(isinstance(df.get(k), str) and df[k] for k in
|
||||
("motif", "motif_capability", "renderer", "settings"))
|
||||
and CV.is_hex(df["motif"], 32) and CV.is_hex(df["settings"], 32) and df["motif"] == motif)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ §2: the held-at statement
|
||||
|
||||
def before_group(word_s, start):
|
||||
"""§2: a held-at statement is before its group when the whole seconds of the time its word
|
||||
states, any fraction dropped (`word_s`), are below the seconds jwp-receipts §2.4.3 reads from
|
||||
the group's `group_start` (`start`, None where it is not a date-time, which nothing is before).
|
||||
§4.3 step 4 reads an entry's `from_ms` by the same rule."""
|
||||
if start is None:
|
||||
return False
|
||||
return word_s <= start if drops("same_second") else word_s < start
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ §4.3: step 6
|
||||
|
||||
def check_timeliness(c, g, groups):
|
||||
"""§4.3 against the group `g` the claim is checked against, which passed jwp-receipts §3 steps
|
||||
1 and 2, and every group given (`groups`, by link hash) with the object receipts and envelopes
|
||||
held under it."""
|
||||
need(g.start is not None, "context_invalid")
|
||||
entries = c["body"]["objects"]
|
||||
fallbacks = [e["fallback"] for e in entries if e["fallback"] is not None]
|
||||
envelopes = {}
|
||||
for x in groups.values():
|
||||
envelopes.update(x.envelopes)
|
||||
need(drops("subject") or g.link == c["subject"], "timeliness_subject_mismatch")
|
||||
need(drops("signer") or g.signer == c["signer_pubkey"], "timeliness_signer_mismatch")
|
||||
need(drops("range") or all(e["leaf_index"] < g.count for e in entries), "timeliness_leaf_out_of_range")
|
||||
need(drops("before") or not any(before_group(e["from_ms"] // 1000, g.start) for e in entries),
|
||||
"timeliness_before_group")
|
||||
need(drops("content") or all(g.included.get(e["leaf_index"], e["content_hash"]) == e["content_hash"]
|
||||
for e in entries), "timeliness_content_mismatch")
|
||||
named = [e["content_hash"] for e in entries] + [f["content_hash"] for f in fallbacks]
|
||||
need(drops("layer") or not any(h in envelopes and is_layer(envelopes[h]) for h in named), "timeliness_on_layer")
|
||||
for f in fallbacks:
|
||||
fg = groups.get(f["group"])
|
||||
if fg is not None and not drops("fallback_position"):
|
||||
need(f["leaf_index"] < fg.count and fg.included.get(f["leaf_index"], f["content_hash"]) == f["content_hash"],
|
||||
"timeliness_fallback_mismatch")
|
||||
for e in entries:
|
||||
f = e["fallback"]
|
||||
if f is not None and f["content_hash"] in envelopes and not drops("derived"):
|
||||
need(derived_from(envelopes[f["content_hash"]], e["content_hash"]), "timeliness_fallback_not_derived")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ §5: value over time
|
||||
|
||||
def value(entry, h):
|
||||
"""§5.1: the entry's value at the instant h, floored."""
|
||||
by = entry["by_ms"]
|
||||
if h <= by:
|
||||
return FULL
|
||||
pts = [(by, FULL)] + [(p["at_ms"], p["value_ppm"]) for p in entry["decay"]]
|
||||
for (ta, va), (tb, vb) in zip(pts, pts[1:]):
|
||||
if ta < h <= tb:
|
||||
num, den = va * (tb - h) + vb * (h - ta), tb - ta
|
||||
return (2 * num + den) // (2 * den) if drops("floor") else num // den
|
||||
return 0
|
||||
|
||||
|
||||
def end(entry):
|
||||
"""§5.2: the `at_ms` of its last point."""
|
||||
return entry["decay"][-1]["at_ms"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ §6: judging delivery
|
||||
|
||||
def claims_step(wires, groups, plan):
|
||||
"""§6.2 step 2, and §7.2's (`plan`): each claim under jwp-receipts §3.1 steps 1 to 5, each
|
||||
signer's previous claim the last of its claims held before it, then step 6 against the group
|
||||
whose link hash is its subject, or the first group. Returns each claim's label, and each
|
||||
claim accepted (None for one refused)."""
|
||||
by_link = {}
|
||||
for g in groups:
|
||||
by_link.setdefault(g.link, g)
|
||||
held, labels, accepted = {}, [], []
|
||||
for wire in wires:
|
||||
try:
|
||||
c = claim_steps(wire)
|
||||
except CV.Reject as r:
|
||||
labels.append(f"reject step={r.step} reason={r.reason}")
|
||||
accepted.append(None)
|
||||
continue
|
||||
previous = held.get(c["signer_pubkey"])
|
||||
held[c["signer_pubkey"]] = c
|
||||
try:
|
||||
CV.check_chain(c, previous)
|
||||
g = by_link.get(c["subject"], groups[0] if groups else None)
|
||||
if c["class"] == "timeliness":
|
||||
need(g is not None, "timeliness_subject_mismatch")
|
||||
check_timeliness(c, g, by_link)
|
||||
elif plan and c["class"] == "transmission" and g is not None:
|
||||
KV.check_transmission(c, g.receipt)
|
||||
elif plan and c["class"] == "schedule" and g is not None:
|
||||
KV.check_schedule(c, g.receipt, g.pairs)
|
||||
elif not drops("context"):
|
||||
raise CV.Reject(6, "context_mismatch")
|
||||
except CV.Reject as r:
|
||||
labels.append(f"reject step={r.step} reason={r.reason}")
|
||||
accepted.append(None)
|
||||
continue
|
||||
labels.append("accepted")
|
||||
accepted.append(c)
|
||||
return labels, accepted
|
||||
|
||||
|
||||
def intents_step(accepted, groups):
|
||||
"""§6.2 step 3: the entries of the accepted claims by group and `leaf_index`, in the order of
|
||||
the groups given and then of `leaf_index`: [(group, leaf_index, entry, or None when set
|
||||
aside)]."""
|
||||
stated = {}
|
||||
for c in accepted:
|
||||
if c is not None and c["class"] == "timeliness":
|
||||
for e in c["body"]["objects"]:
|
||||
stated.setdefault((c["subject"], e["leaf_index"]), []).append(e)
|
||||
out, seen = [], set()
|
||||
for g in groups:
|
||||
if g.link in seen:
|
||||
continue
|
||||
seen.add(g.link)
|
||||
for i in sorted(i for link, i in stated if link == g.link):
|
||||
es = stated[(g.link, i)]
|
||||
stands = drops("intent_contradiction") or all(e == es[0] for e in es)
|
||||
out.append((g.link, i, es[0] if stands else None))
|
||||
return out
|
||||
|
||||
|
||||
def records_step(records, groups):
|
||||
"""§6.2 step 4: each record as holdings §4.4 reads and verifies it, refused whole when its
|
||||
entry for a group given misstates the leaf count or is before the group, two of one holder at
|
||||
one `as_of_ms` that differ set aside. Returns each record's label and the records that are
|
||||
not refused (index -> record)."""
|
||||
by_link = {g.link: g for g in groups}
|
||||
labels, kept = [], {}
|
||||
for k, hexed in enumerate(records):
|
||||
try:
|
||||
rec = read_record(bytes.fromhex(hexed))
|
||||
mine = [(by_link[g.hex()], n) for g, n, _ in rec[3] if g.hex() in by_link]
|
||||
if not drops("leaf_count") and any(g.count != n for g, n in mine):
|
||||
raise HV.Refused("holdings_leaf_count_mismatch")
|
||||
if not drops("held_before") and any(before_group(rec[1] // 1000, g.start) for g, _ in mine):
|
||||
raise HV.Refused("held_before_group")
|
||||
kept[k] = rec
|
||||
labels.append("stands")
|
||||
except HV.Refused as r:
|
||||
labels.append("reject reason=" + r.reason)
|
||||
at = {}
|
||||
for k, rec in kept.items():
|
||||
at.setdefault((rec[0], rec[1]), []).append(k)
|
||||
for ks in at.values():
|
||||
if len({HV.digest(kept[k]) for k in ks}) > 1 and not drops("record_contradiction"):
|
||||
for k in ks:
|
||||
labels[k] = "set-aside reason=holdings_contradicted"
|
||||
return labels, kept
|
||||
|
||||
|
||||
def held_from_records(standing, group, index):
|
||||
"""§6.2 step 5: the held-at statements the records that stand give for object `index` of
|
||||
`group` (a link hash in hexadecimal): (holder, group, index, instant) for each record that holds
|
||||
it (holdings §5.4), its instant the record's `as_of_ms`."""
|
||||
g = bytes.fromhex(group)
|
||||
return [(rec[0].hex(), group, index, rec[1]) for rec in standing if HV.holds(rec, g, index) is None]
|
||||
|
||||
|
||||
def has_entry(rec, group):
|
||||
return any(g.hex() == group for g, _, _ in rec[3])
|
||||
|
||||
|
||||
def line_holders(group, entry, standing):
|
||||
"""§6.3: the holders with a record that stands and has an entry for the intent's group or its
|
||||
fallback's group, in the order of their first such record."""
|
||||
fb = entry["fallback"]
|
||||
out = []
|
||||
for rec in standing:
|
||||
h = rec[0].hex()
|
||||
if h not in out and (drops("line_holders") or has_entry(rec, group)
|
||||
or (fb is not None and has_entry(rec, fb["group"]))):
|
||||
out.append(h)
|
||||
return out
|
||||
|
||||
|
||||
def grade(entry, p, f):
|
||||
"""§6.3's table, the first outcome that holds, for a holder whose least instants for the
|
||||
primary and the fallback are p and f (None where none)."""
|
||||
by, fb = entry["by_ms"], entry["fallback"]
|
||||
if p is not None and (p < by if drops("deadline_inclusive") else p <= by):
|
||||
return "on_time", FULL
|
||||
# §5.3: what arrived after the deadline grades nothing, so a later primary of more value
|
||||
# does not displace a fallback held by then.
|
||||
graded_late = drops("sink_rule") and None not in (p, fb) and value(entry, p) > fb["value_ppm"]
|
||||
if f is not None and (f <= by or drops("fallback_deadline")) and not graded_late:
|
||||
return "replaced", fb["value_ppm"]
|
||||
if p is not None:
|
||||
return "after_deadline", value(entry, p)
|
||||
return "not_shown", 0
|
||||
|
||||
|
||||
def lines(entry, holders, primary, fallback):
|
||||
"""§6.3: an intent's line for each of `holders` (keys in hexadecimal), in increasing order of
|
||||
key bytes, from that holder's own held-at statements for its primary and its fallback."""
|
||||
def least(statements, holder, latest=False):
|
||||
instants = [s[3] for s in statements if drops("own_statements") or s[0] == holder]
|
||||
if not instants:
|
||||
return None
|
||||
return max(instants) if latest or drops("least") else min(instants)
|
||||
|
||||
out = []
|
||||
for h in (holders if drops("key_order") else sorted(holders, key=bytes.fromhex)):
|
||||
p, f = least(primary, h), least(fallback, h, drops("least_fallback"))
|
||||
outcome, v = grade(entry, p, f)
|
||||
out.append({"holder": h, "held_ms": p, "fallback_ms": f, "outcome": outcome, "value_ppm": v})
|
||||
return out
|
||||
|
||||
|
||||
def intent_lines(link, entry, standing):
|
||||
fb = entry["fallback"]
|
||||
primary = held_from_records(standing, link, entry["leaf_index"])
|
||||
fallback = [] if fb is None else held_from_records(standing, fb["group"], fb["leaf_index"])
|
||||
return lines(entry, line_holders(link, entry, standing), primary, fallback)
|
||||
|
||||
|
||||
def verdict(case):
|
||||
"""§6: the verdict, or `refuse reason=R` for a group that fails."""
|
||||
try:
|
||||
groups = read_groups(case["groups"])
|
||||
except JV.Refuse as r:
|
||||
return "refuse reason=" + r.reason
|
||||
labels, accepted = claims_step(case["claims"], groups, plan=False)
|
||||
intents = intents_step(accepted, groups)
|
||||
rlabels, kept = records_step(case["records"], groups)
|
||||
standing = [kept[k] for k in sorted(kept) if rlabels[k] == "stands"]
|
||||
out, found = [], {}
|
||||
for link, i, entry in intents:
|
||||
ls = [] if entry is None else intent_lines(link, entry, standing)
|
||||
found[(link, i)] = (entry, ls)
|
||||
out.append({"group": link, "leaf_index": i,
|
||||
"intent": "stands" if entry is not None else "set-aside reason=timeliness_contradicted",
|
||||
"end_ms": None if entry is None else end(entry), "lines": ls})
|
||||
statement = None
|
||||
s = case["statement"]
|
||||
if s is not None:
|
||||
entry, ls = found.get((s["group"], s["index"]), (None, []))
|
||||
if entry is None and not drops("no_intent"):
|
||||
statement = "reject reason=deadline_not_stated"
|
||||
else:
|
||||
line = next((x for x in ls if x["holder"] == s["holder"]), None)
|
||||
statement = ({"outcome": "not_shown", "value_ppm": 0} if line is None
|
||||
else {"outcome": line["outcome"], "value_ppm": line["value_ppm"]})
|
||||
return {"objects": [g.verdicts for g in groups], "claims": labels, "records": rlabels, "intents": out,
|
||||
"statement": statement}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ §7: planning
|
||||
|
||||
def sources_valid(sources, loss, h):
|
||||
"""§7.1: the sources, each exactly a bearer instance, a holder or the origin."""
|
||||
if not isinstance(sources, list) or (not sources and not drops("sources_empty")):
|
||||
return False
|
||||
bearers, holders, origins = set(), set(), 0
|
||||
for s in sources:
|
||||
if not isinstance(s, dict):
|
||||
return False
|
||||
kind = s.get("kind")
|
||||
fetch = s.get("fetch_ms")
|
||||
if kind != "bearer" and not (drops("sources_fetch") or (CV.is_int(fetch) and fetch >= 1)):
|
||||
return False
|
||||
if kind == "bearer":
|
||||
if set(s) != {"kind", "bearer_id"} or not (drops("sources_uri") or CV.is_uri(s["bearer_id"])):
|
||||
return False
|
||||
if s["bearer_id"] in bearers and not drops("sources_twice"):
|
||||
return False
|
||||
bearers.add(s["bearer_id"])
|
||||
elif kind == "holder":
|
||||
if set(s) != {"kind", "holder", "fetch_ms"} or not (drops("sources_key") or CV.is_hex(s["holder"], 32)):
|
||||
return False
|
||||
if s["holder"] in holders and not drops("sources_twice"):
|
||||
return False
|
||||
holders.add(s["holder"])
|
||||
elif kind == "origin":
|
||||
if set(s) != {"kind", "fetch_ms"}:
|
||||
return False
|
||||
origins += 1
|
||||
elif not drops("sources_kind"):
|
||||
return False
|
||||
if origins > 1 and not drops("sources_twice"):
|
||||
return False
|
||||
return not (bearers and (loss is None or h is None)) or drops("sources_loss")
|
||||
|
||||
|
||||
def plan_inputs(loss, h, t, sources):
|
||||
"""§7.1's checks, in their order: the reason the plan is refused, or None."""
|
||||
if loss is not None and not drops("loss_bound") and not (CV.is_int(loss) and loss <= 999_999):
|
||||
return "loss_bound_invalid"
|
||||
if h is not None and not drops("overhead") and not (CV.is_int(h) and h <= 2):
|
||||
return "overhead_invalid"
|
||||
if t != "from_ms" and not drops("instant") and not (CV.is_int(t) and t <= LAST_INSTANT):
|
||||
return "instant_invalid"
|
||||
if not sources_valid(sources, loss, h):
|
||||
return "sources_invalid"
|
||||
return None
|
||||
|
||||
|
||||
def schedules_step(labels, accepted):
|
||||
"""§7.2: the declarations (bearer-receipts §6.2 step 4) and the schedules (carousel §6.2 step
|
||||
4), each of its group. Relabels the claims; returns the schedules that stand, by (group,
|
||||
bearer_id)."""
|
||||
def key(c):
|
||||
return c["body"]["bearer_id"] if drops("per_group") else (c["subject"], c["body"]["bearer_id"])
|
||||
|
||||
def by_instance(cls):
|
||||
out = {}
|
||||
for i, c in enumerate(accepted):
|
||||
if c is not None and c["class"] == cls:
|
||||
out.setdefault(key(c), []).append(i)
|
||||
return out
|
||||
|
||||
declared, contradicted = {}, set()
|
||||
for k, idx in by_instance("transmission").items():
|
||||
bodies = [accepted[i]["body"] for i in idx]
|
||||
same = all(b == bodies[0] for b in bodies)
|
||||
if same:
|
||||
declared[k] = bodies[0]["bearer"]
|
||||
else:
|
||||
contradicted.add(k)
|
||||
for i in idx:
|
||||
labels[i] = "declared" if same else "set-aside reason=bearer_contradicted"
|
||||
schedules = {}
|
||||
for k, idx in by_instance("schedule").items():
|
||||
bodies = [accepted[i]["body"] for i in idx]
|
||||
if any(b != bodies[0] for b in bodies):
|
||||
aside = "schedule_contradicted"
|
||||
elif k in contradicted:
|
||||
aside = "bearer_contradicted"
|
||||
elif k not in declared and not drops("undeclared"):
|
||||
aside = "bearer_not_declared"
|
||||
elif declared.get(k) == "bidirectional" and not drops("bidirectional"):
|
||||
aside = "schedule_on_bidirectional"
|
||||
else:
|
||||
aside = None
|
||||
for i in idx:
|
||||
labels[i] = "stands" if aside is None else "set-aside reason=" + aside
|
||||
if aside is None:
|
||||
schedules[(accepted[i]["subject"], accepted[i]["body"]["bearer_id"])] = bodies[0]
|
||||
return schedules
|
||||
|
||||
|
||||
def standing_at(kept, rlabels, s):
|
||||
"""§7.2: the records that stand at s, by holdings §5.1 to §5.3 over those §6.2 step 4 did not
|
||||
refuse, and not set aside there."""
|
||||
live = [rec for k, rec in kept.items() if drops("record_at_s") or rec[1] <= s < rec[2]]
|
||||
out = []
|
||||
for holder in {rec[0] for rec in live}:
|
||||
mine = [rec for rec in live if rec[0] == holder]
|
||||
latest = max(rec[1] for rec in mine)
|
||||
top = [rec for rec in mine if rec[1] == latest]
|
||||
if len({HV.digest(rec) for rec in top}) == 1:
|
||||
out.append(top[0])
|
||||
aside = {HV.digest(kept[k]) for k in kept if rlabels[k] != "stands"}
|
||||
return [rec for rec in out if HV.digest(rec) not in aside]
|
||||
|
||||
|
||||
def arrival(src, group, index, s, ctx):
|
||||
"""§7.2: a source's arrival of object `index` of `group` planned from s, or None."""
|
||||
kind = src["kind"]
|
||||
if kind == "origin":
|
||||
return s + src["fetch_ms"]
|
||||
if kind == "holder":
|
||||
recs = standing_at(ctx["kept"], ctx["rlabels"], s)
|
||||
held = any((drops("holder_record") or rec[0].hex() == src["holder"])
|
||||
and HV.holds(rec, bytes.fromhex(group), index) is None for rec in recs)
|
||||
return s + src["fetch_ms"] if held else None
|
||||
if kind != "bearer":
|
||||
return None
|
||||
body = ctx["schedules"].get((group, src["bearer_id"]))
|
||||
e = None if body is None else next((x for x in body["objects"] if x["leaf_index"] == index), None)
|
||||
if e is None:
|
||||
return None
|
||||
blocks = sum(n for _, n in CV.schedule_blocks(e))
|
||||
p = KV.plan({"entry": e, "loss_ppm": ctx["loss"], "overhead": ctx["h"], "at_ms": s, "held": [0] * blocks})
|
||||
return p["complete_ms"]
|
||||
|
||||
|
||||
def choose(entry, primary, fallback):
|
||||
"""§7.3 for an object whose intent stands, from each source's arrivals."""
|
||||
by, fb = entry["by_ms"], entry["fallback"]
|
||||
|
||||
def choice(outcome, obj, i, arrived, v):
|
||||
return {"outcome": outcome, "object": obj, "source": i, "arrival_ms": arrived, "value_ppm": v}
|
||||
|
||||
timely = [i for i, a in enumerate(primary) if a is not None and a <= by]
|
||||
if timely:
|
||||
i = min(timely, key=lambda j: (primary[j], j)) if drops("plan_order") else timely[0]
|
||||
return choice("on_time", "primary", i, primary[i], FULL)
|
||||
late = [(value(entry, a), i) for i, a in enumerate(primary) if a is not None]
|
||||
best = max((v for v, _ in late), default=0)
|
||||
# Rule 2: a fallback of at least the best late value, by the deadline, first in the asker's order.
|
||||
at_least = fb is not None and (fb["value_ppm"] > best if drops("fallback_tie") else fb["value_ppm"] >= best)
|
||||
if fb is not None and (at_least or drops("fallback_value")):
|
||||
in_time = [i for i, a in enumerate(fallback)
|
||||
if a is not None and (a < by if drops("plan_fallback_deadline") else a <= by)]
|
||||
if in_time and drops("fallback_order"):
|
||||
in_time.sort(key=lambda j: (fallback[j], j))
|
||||
i = in_time[0] if in_time else None
|
||||
if i is not None:
|
||||
return choice("replaced", "fallback", i, fallback[i], fb["value_ppm"])
|
||||
if late and (best >= 1 or drops("late_zero")):
|
||||
ties = [i for v, i in late if v == best]
|
||||
i = ties[-1] if drops("late_tie") else ties[0]
|
||||
return choice("after_deadline", "primary", i, primary[i], best)
|
||||
return choice("no_source", None, None, None, 0)
|
||||
|
||||
|
||||
def plan(case):
|
||||
"""§7: the plan, or `refuse reason=R`."""
|
||||
loss, h, t, sources = case["loss_ppm"], case["overhead"], case["at_ms"], case["sources"]
|
||||
bad = plan_inputs(loss, h, t, sources)
|
||||
if bad:
|
||||
return "refuse reason=" + bad
|
||||
try:
|
||||
groups = read_groups(case["groups"])
|
||||
except JV.Refuse as r:
|
||||
return "refuse reason=" + r.reason
|
||||
labels, accepted = claims_step(case["claims"], groups, plan=True)
|
||||
intents = {(link, i): entry for link, i, entry in intents_step(accepted, groups)}
|
||||
rlabels, kept = records_step(case["records"], groups)
|
||||
ctx = {"kept": kept, "rlabels": rlabels, "schedules": schedules_step(labels, accepted), "loss": loss, "h": h}
|
||||
choices = []
|
||||
for w in case["wanted"]:
|
||||
entry = intents.get((w["group"], w["index"]))
|
||||
if entry is None:
|
||||
choices.append("reject reason=deadline_not_stated")
|
||||
continue
|
||||
s = entry["from_ms"] if t == "from_ms" else t
|
||||
fb = entry["fallback"]
|
||||
primary = [arrival(src, w["group"], w["index"], s, ctx) for src in sources]
|
||||
fallback = [None if fb is None else arrival(src, fb["group"], fb["leaf_index"], s, ctx) for src in sources]
|
||||
choices.append(choose(entry, primary, fallback))
|
||||
return {"claims": labels, "records": rlabels, "choices": choices}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the corpus
|
||||
|
||||
def claim_outcome(c):
|
||||
try:
|
||||
claim_steps(c["claim"])
|
||||
return "accept"
|
||||
except CV.Reject as r:
|
||||
return f"reject step={r.step} reason={r.reason}"
|
||||
|
||||
|
||||
def value_outcome(c):
|
||||
"""§5 for an entry read alone, as §3.1 steps 2 and 4 read an entry."""
|
||||
e = c["entry"]
|
||||
CV.timeliness_entry_body(e)
|
||||
CV.timeliness_entry_rules(e, None)
|
||||
return {"value_ppm": value(e, c["at_ms"]), "end_ms": end(e)}
|
||||
|
||||
|
||||
RUNS = (("claims", claim_outcome), ("values", value_outcome), ("verdicts", verdict), ("plans", plan))
|
||||
KINDS = tuple(k for k, _ in RUNS)
|
||||
|
||||
|
||||
def strings(v, out):
|
||||
if isinstance(v, str):
|
||||
out.append(v)
|
||||
elif isinstance(v, list):
|
||||
for x in v:
|
||||
strings(x, out)
|
||||
elif isinstance(v, dict):
|
||||
for x in v.values():
|
||||
strings(x, out)
|
||||
|
||||
|
||||
def check(doc, quiet=False):
|
||||
"""The failures of a corpus document: each case's outcome, every reason of §9 and every
|
||||
outcome of §6.3."""
|
||||
fails = []
|
||||
for kind, run in RUNS:
|
||||
for c in doc[kind]:
|
||||
try:
|
||||
got = run(c)
|
||||
except CV.Reject as r:
|
||||
got = {"error": f"reject step={r.step} reason={r.reason}"}
|
||||
except (KeyError, TypeError, ValueError, IndexError, AttributeError, StopIteration,
|
||||
ZeroDivisionError) as e:
|
||||
got = {"error": repr(e)}
|
||||
if got != c["outcome"]:
|
||||
fails.append(c["name"])
|
||||
if not quiet:
|
||||
print(f"FAIL {c['name']}\n stated {json.dumps(c['outcome'])}\n got {json.dumps(got)}")
|
||||
stated = []
|
||||
for kind in KINDS:
|
||||
for c in doc[kind]:
|
||||
strings(c["outcome"], stated)
|
||||
for code, _ in reasons_table():
|
||||
if not any(s == code or s.endswith("reason=" + code) for s in stated):
|
||||
fails.append("no case gives " + code)
|
||||
for outcome in outcomes_table():
|
||||
if outcome not in stated:
|
||||
fails.append("no case gives the outcome " + outcome)
|
||||
return fails
|
||||
|
||||
|
||||
def mutations(doc):
|
||||
"""Copies of the corpus, one defect each, every one of which must fail."""
|
||||
def case(d, name):
|
||||
return next(c for k in KINDS for c in d[k] if c["name"] == name)
|
||||
|
||||
def m_instant(d):
|
||||
# A millisecond before the end is 75; at the end, 0.
|
||||
case(d, "example_last_ms")["at_ms"] += 1
|
||||
|
||||
def m_end(d):
|
||||
case(d, "hard_one_ms")["outcome"]["end_ms"] -= 1
|
||||
|
||||
def m_floor(d):
|
||||
case(d, "step_mid")["outcome"]["value_ppm"] += 1
|
||||
|
||||
def m_accept(d):
|
||||
case(d, "decay_near_collinear")["outcome"] = "reject step=4 reason=timeliness_decay_invalid"
|
||||
|
||||
def m_line(d):
|
||||
case(d, "holder_one_ms_after")["outcome"]["intents"][0]["lines"][0]["value_ppm"] = 999_801
|
||||
|
||||
def m_earliest(d):
|
||||
c = case(d, "earliest_record_counts")
|
||||
c["records"] = c["records"][1:]
|
||||
|
||||
def m_statement(d):
|
||||
case(d, "statement_line")["statement"]["holder"] = d["keys"]["holder_c"]
|
||||
|
||||
def m_envelope(d):
|
||||
c = case(d, "on_layer")
|
||||
c["groups"][0]["objects"][3]["envelope"] = None
|
||||
|
||||
def m_order(d):
|
||||
c = case(d, "order_over_earliest")
|
||||
c["sources"] = list(reversed(c["sources"]))
|
||||
|
||||
def m_deadline(d):
|
||||
case(d, "on_time_at_deadline")["at_ms"] += 1
|
||||
|
||||
def m_fetch(d):
|
||||
# Both late sources at 300,000: the first in order, the origin.
|
||||
case(d, "late_choice_by_value")["sources"][1]["fetch_ms"] = 66_000
|
||||
|
||||
def m_loss(d):
|
||||
# 70%: 3 symbols a pass of the 12 needed, a fourth pass.
|
||||
case(d, "every_pass_from_first")["loss_ppm"] = 700_000
|
||||
|
||||
def m_from_ms(d):
|
||||
case(d, "publisher_check_fails")["at_ms"] = d["plans"][0]["at_ms"]
|
||||
|
||||
def m_object(d):
|
||||
case(d, "fallback_from_carousel")["outcome"]["choices"][0]["object"] = "primary"
|
||||
|
||||
def m_reason_gone(d):
|
||||
d["verdicts"].remove(case(d, "record_before_group"))
|
||||
|
||||
return [m_instant, m_end, m_floor, m_accept, m_line, m_earliest, m_statement, m_envelope, m_order, m_deadline,
|
||||
m_fetch, m_loss, m_from_ms, m_object, m_reason_gone]
|
||||
|
||||
|
||||
WEAKENINGS = ["subject", "signer", "range", "before", "same_second", "content", "layer", "fallback_position", "derived",
|
||||
"context", "intent_contradiction", "leaf_count", "held_before", "record_contradiction", "line_holders",
|
||||
"least", "least_fallback", "own_statements", "key_order", "deadline_inclusive", "fallback_deadline",
|
||||
"sink_rule", "floor", "no_intent", "loss_bound",
|
||||
"overhead", "instant", "sources_empty", "sources_kind", "sources_uri", "sources_key", "sources_fetch",
|
||||
"sources_twice", "sources_loss", "per_group", "undeclared", "bidirectional", "record_at_s",
|
||||
"holder_record", "plan_order", "fallback_value", "fallback_tie", "fallback_order",
|
||||
"plan_fallback_deadline", "late_zero", "late_tie"]
|
||||
|
||||
|
||||
def direct_checks():
|
||||
"""What no vector carries, and what this text states of the texts beside it."""
|
||||
fails = []
|
||||
# §7.1: the greatest fetch estimate is admitted; the corpus carries only the one past it.
|
||||
if not sources_valid([{"kind": "origin", "fetch_ms": MAX_INT}], None, None):
|
||||
fails.append("a fetch_ms of 2^53 - 1 is refused")
|
||||
# §4.2 rule 3.2 where the products pass 2^64: three points on one line are refused, and a
|
||||
# part per million off it is not. A float of 64 bits cannot tell them apart.
|
||||
half = (LAST_INSTANT - 1) // 2
|
||||
for v1, ok in ((500_000, False), (500_001, True)):
|
||||
e = {"leaf_index": 0, "content_hash": "00" * 32, "from_ms": 0, "by_ms": 0, "fallback": None,
|
||||
"decay": [{"at_ms": half, "value_ppm": v1}, {"at_ms": 2 * half, "value_ppm": 0}]}
|
||||
try:
|
||||
CV.timeliness_entry_rules(e, None)
|
||||
got = True
|
||||
except CV.Reject:
|
||||
got = False
|
||||
if got != ok:
|
||||
fails.append(f"a point of {v1} at {half} is {'refused' if ok else 'accepted'}")
|
||||
# jwp-receipts §3.1 lists §4.2's codes at step 4 and §4.3's at step 6; delivery-telemetry §5.1
|
||||
# lists §8.6's switch reason.
|
||||
jwp = text_of(JWP_RECEIPTS)
|
||||
for code, where in reasons_table():
|
||||
step = {"§4.2": 4, "§4.3": 6}.get(where.split(" ")[0])
|
||||
if step is not None and f"| {step} | `{code}` |" not in jwp:
|
||||
fails.append(f"jwp-receipts §3.1 does not list {code} at step {step}")
|
||||
if "- `not-held-by-deadline`:" not in text_of(TELEMETRY):
|
||||
fails.append("delivery-telemetry §5.1 does not list not-held-by-deadline")
|
||||
if REFINEMENT_ONLY != {"wai.image.int_refine", "wai.video.int_refine", "wai.audio.int_refine",
|
||||
"wai.splat.int_refine"}:
|
||||
fails.append(f"SPEC §5's refinement capabilities read as {sorted(REFINEMENT_ONLY)}")
|
||||
return fails
|
||||
|
||||
|
||||
def main(argv):
|
||||
global DROPPED
|
||||
if len(argv) != 2:
|
||||
print(__doc__)
|
||||
return 2
|
||||
doc = json.load(open(os.path.join(argv[1], "vectors.json"), encoding="utf-8"))
|
||||
extra = sorted(set(os.listdir(argv[1])) - {"vectors.json", "README.md"})
|
||||
fails = check(doc)
|
||||
if extra:
|
||||
fails.append(f"files the corpus does not hold: {extra}")
|
||||
fails += direct_checks()
|
||||
print(f"{sum(len(doc[k]) for k in KINDS)} cases ({', '.join(f'{len(doc[k])} {k}' for k in KINDS)}): "
|
||||
f"{len(fails)} failure(s)")
|
||||
caught = 0
|
||||
for m in mutations(doc):
|
||||
d = copy.deepcopy(doc)
|
||||
m(d)
|
||||
if check(d, quiet=True):
|
||||
caught += 1
|
||||
else:
|
||||
fails.append(f"mutation {m.__name__} was not caught")
|
||||
print(f"{caught} of {len(mutations(doc))} mutated copies fail")
|
||||
weak = 0
|
||||
for w in WEAKENINGS:
|
||||
DROPPED = w
|
||||
if check(doc, quiet=True):
|
||||
weak += 1
|
||||
else:
|
||||
fails.append(f"a verifier without its {w} check passes the corpus")
|
||||
DROPPED = None
|
||||
print(f"{weak} of {len(WEAKENINGS)} weakened verifiers are caught")
|
||||
for f in fails:
|
||||
print("FAIL", f)
|
||||
return 1 if fails else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
|
|
@ -390,6 +390,20 @@
|
|||
"edition": null,
|
||||
"registered": "2026-10-25"
|
||||
},
|
||||
{
|
||||
"capability": "wai.delivery.cache",
|
||||
"kind": "record",
|
||||
"status": "registered",
|
||||
"media": null,
|
||||
"role": null,
|
||||
"tier": null,
|
||||
"licence": null,
|
||||
"replicate": null,
|
||||
"sets": [],
|
||||
"spec_section": "§5 Interactive, temporal & computed content",
|
||||
"edition": null,
|
||||
"registered": "2026-11-03"
|
||||
},
|
||||
{
|
||||
"capability": "wai.delivery.fleet",
|
||||
"kind": "record",
|
||||
|
|
@ -418,6 +432,20 @@
|
|||
"edition": null,
|
||||
"registered": "2026-11-03"
|
||||
},
|
||||
{
|
||||
"capability": "wai.delivery.impressions",
|
||||
"kind": "record",
|
||||
"status": "registered",
|
||||
"media": null,
|
||||
"role": null,
|
||||
"tier": null,
|
||||
"licence": null,
|
||||
"replicate": null,
|
||||
"sets": [],
|
||||
"spec_section": "§5 Interactive, temporal & computed content",
|
||||
"edition": null,
|
||||
"registered": "2026-11-03"
|
||||
},
|
||||
{
|
||||
"capability": "wai.delivery.ledger",
|
||||
"kind": "record",
|
||||
|
|
@ -460,6 +488,20 @@
|
|||
"edition": null,
|
||||
"registered": "2026-10-29"
|
||||
},
|
||||
{
|
||||
"capability": "wai.delivery.rung_price",
|
||||
"kind": "record",
|
||||
"status": "registered",
|
||||
"media": null,
|
||||
"role": null,
|
||||
"tier": null,
|
||||
"licence": null,
|
||||
"replicate": null,
|
||||
"sets": [],
|
||||
"spec_section": "§5 Interactive, temporal & computed content",
|
||||
"edition": null,
|
||||
"registered": "2026-11-03"
|
||||
},
|
||||
{
|
||||
"capability": "wai.delivery.settlement",
|
||||
"kind": "record",
|
||||
|
|
@ -474,6 +516,20 @@
|
|||
"edition": null,
|
||||
"registered": "2026-10-21"
|
||||
},
|
||||
{
|
||||
"capability": "wai.delivery.timeliness",
|
||||
"kind": "record",
|
||||
"status": "registered",
|
||||
"media": null,
|
||||
"role": null,
|
||||
"tier": null,
|
||||
"licence": null,
|
||||
"replicate": null,
|
||||
"sets": [],
|
||||
"spec_section": "§5 Interactive, temporal & computed content",
|
||||
"edition": null,
|
||||
"registered": "2026-11-03"
|
||||
},
|
||||
{
|
||||
"capability": "wai.derive.crop",
|
||||
"kind": "derivation",
|
||||
|
|
@ -2961,6 +3017,27 @@
|
|||
"section": "§6.2",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "WIC1",
|
||||
"kind": "magic",
|
||||
"document": "extensions/impression-aggregates.md",
|
||||
"section": "§4.1",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "WIT1",
|
||||
"kind": "magic",
|
||||
"document": "extensions/impression-aggregates.md",
|
||||
"section": "§5.4",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "WIU1",
|
||||
"kind": "magic",
|
||||
"document": "extensions/impression-aggregates.md",
|
||||
"section": "§6.4",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "WJR1",
|
||||
"kind": "magic",
|
||||
|
|
@ -4074,6 +4151,20 @@
|
|||
"section": "§6.2",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "wai-rung-prices",
|
||||
"kind": "document-kind",
|
||||
"document": "extensions/rung-price.md",
|
||||
"section": "§4.1",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "wai-rung-request",
|
||||
"kind": "document-kind",
|
||||
"document": "extensions/rung-price.md",
|
||||
"section": "§6.1",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "wai-scene/1",
|
||||
"kind": "scheme",
|
||||
|
|
@ -4291,6 +4382,13 @@
|
|||
"section": "§4",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "wai_impression_token",
|
||||
"kind": "dap-report-extension",
|
||||
"document": "extensions/impression-aggregates.md",
|
||||
"section": "§7.2",
|
||||
"status": "proposed"
|
||||
},
|
||||
{
|
||||
"name": "wai_machine_generated",
|
||||
"kind": "catalog",
|
||||
|
|
|
|||
|
|
@ -323,7 +323,8 @@
|
|||
"http-header",
|
||||
"frame-metadata",
|
||||
"gltf-extension",
|
||||
"telemetry-key"
|
||||
"telemetry-key",
|
||||
"dap-report-extension"
|
||||
]
|
||||
},
|
||||
"document": {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ D_GRANT, D_STATUS = b"wai:module-grant\x01", b"wai:key-status\x01"
|
|||
GRANT_CEILING, STATUS_CEILING = 86400, 3600
|
||||
HARDWARE = {"CalibratedInstrument", "OnChipCounter", "HwShunt"}
|
||||
PINNED_ONLY = {"energy/CalibratedInstrument", "energy/OnChipCounter", "energy/HwShunt",
|
||||
"lawful-basis/declaration", "lawful-basis/decision"}
|
||||
"lawful-basis/declaration", "lawful-basis/decision", "aggregate/task"}
|
||||
REVISION = ["kind", "origin", "seq", "prev", "issued_at", "root", "next_root", "keys", "revoked_modules",
|
||||
"grant_max_s", "status_max_age_s", "sig"]
|
||||
ENTRY = ["id", "key", "may_sign", "status", "until", "compromised_since"]
|
||||
|
|
|
|||
|
|
@ -366,6 +366,13 @@ wai:wai.delivery.audit a wai:Capability ;
|
|||
wai:specSection "§5 Interactive, temporal & computed content" ;
|
||||
wai:registeredOn "2026-10-25"^^xsd:date .
|
||||
|
||||
wai:wai.delivery.cache a wai:Capability ;
|
||||
rdfs:label "wai.delivery.cache" ;
|
||||
wai:kind wai:kind-record ;
|
||||
wai:status wai:status-registered ;
|
||||
wai:specSection "§5 Interactive, temporal & computed content" ;
|
||||
wai:registeredOn "2026-11-03"^^xsd:date .
|
||||
|
||||
wai:wai.delivery.fleet a wai:Capability ;
|
||||
rdfs:label "wai.delivery.fleet" ;
|
||||
wai:kind wai:kind-record ;
|
||||
|
|
@ -380,6 +387,13 @@ wai:wai.delivery.holdings a wai:Capability ;
|
|||
wai:specSection "§5 Interactive, temporal & computed content" ;
|
||||
wai:registeredOn "2026-11-03"^^xsd:date .
|
||||
|
||||
wai:wai.delivery.impressions a wai:Capability ;
|
||||
rdfs:label "wai.delivery.impressions" ;
|
||||
wai:kind wai:kind-record ;
|
||||
wai:status wai:status-registered ;
|
||||
wai:specSection "§5 Interactive, temporal & computed content" ;
|
||||
wai:registeredOn "2026-11-03"^^xsd:date .
|
||||
|
||||
wai:wai.delivery.ledger a wai:Capability ;
|
||||
rdfs:label "wai.delivery.ledger" ;
|
||||
wai:kind wai:kind-record ;
|
||||
|
|
@ -401,6 +415,13 @@ wai:wai.delivery.revenue_share a wai:Capability ;
|
|||
wai:specSection "§5 Interactive, temporal & computed content" ;
|
||||
wai:registeredOn "2026-10-29"^^xsd:date .
|
||||
|
||||
wai:wai.delivery.rung_price a wai:Capability ;
|
||||
rdfs:label "wai.delivery.rung_price" ;
|
||||
wai:kind wai:kind-record ;
|
||||
wai:status wai:status-registered ;
|
||||
wai:specSection "§5 Interactive, temporal & computed content" ;
|
||||
wai:registeredOn "2026-11-03"^^xsd:date .
|
||||
|
||||
wai:wai.delivery.settlement a wai:Capability ;
|
||||
rdfs:label "wai.delivery.settlement" ;
|
||||
wai:kind wai:kind-record ;
|
||||
|
|
@ -408,6 +429,13 @@ wai:wai.delivery.settlement a wai:Capability ;
|
|||
wai:specSection "§5 Interactive, temporal & computed content" ;
|
||||
wai:registeredOn "2026-10-21"^^xsd:date .
|
||||
|
||||
wai:wai.delivery.timeliness a wai:Capability ;
|
||||
rdfs:label "wai.delivery.timeliness" ;
|
||||
wai:kind wai:kind-record ;
|
||||
wai:status wai:status-registered ;
|
||||
wai:specSection "§5 Interactive, temporal & computed content" ;
|
||||
wai:registeredOn "2026-11-03"^^xsd:date .
|
||||
|
||||
wai:wai.derive.crop a wai:Capability ;
|
||||
rdfs:label "wai.derive.crop" ;
|
||||
wai:kind wai:kind-derivation ;
|
||||
|
|
|
|||
|
|
@ -620,6 +620,42 @@ carousel = ["bearer_receipts"]
|
|||
# and to the group receipts a verifier has, queries, and an asker's plan of who holds what. SHA-256
|
||||
# and Ed25519 only; no new dependency.
|
||||
holdings = ["receipt_claims", "ed25519"]
|
||||
# Timeliness (extensions/timeliness.md): `wai.delivery.timeliness` — a publisher's signed statement of
|
||||
# when each object of a group is of use: from when, by when, how its value falls after, and the
|
||||
# rendition a receiver presents in its place. The jwp-receipts `timeliness` claim (its body is
|
||||
# claim_receipt's), the judgement of delivery from holdings records, and an asker's plan over
|
||||
# carousel passes, neighbours and the origin. Integer arithmetic only; no new dependency.
|
||||
timeliness = ["carousel", "holdings"]
|
||||
# Rung price (extensions/rung-price.md): `wai.delivery.rung_price` — a seller's signed list of
|
||||
# delivery prices (`wai-rung-prices`: per direction, bearer class, region in ISO 3166 capitals,
|
||||
# rate ceiling and unit, each unit at its ledger field's divisor, with the energy the seller states),
|
||||
# held to an instant (a seller's latest list stands alone and a lapsed one revives none), and the
|
||||
# comparison of delivery options under a request: a shared transmission's cost per receiver over a
|
||||
# stated audience or one, held to its pass rate, with its break-even audience; an energy figure
|
||||
# ranked by its class only when the seller's own per-hop records, read through live_metering and
|
||||
# placed in time through fleet's room descriptor, bear it out under a key the comparer confirmed.
|
||||
# route-record's document reader and canonical form, SHA-256 and Ed25519; `ed25519-dalek/hazmat`
|
||||
# signs the corpus's second signature of one body. No new dependency.
|
||||
rung_price = ["route_record", "fleet", "ed25519-dalek/hazmat"]
|
||||
# Cache authority (extensions/cache-authority.md): `wai.delivery.cache` — the authorities
|
||||
# `cache/serve` (a node that serves, from what it holds, objects of groups another key signed,
|
||||
# placed in its operator's key log) and `cache-transmission`; the jwp-receipts
|
||||
# `cache-transmission` claim (its body is claim_receipt's) by which such a node declares a bearer
|
||||
# instance of its own; the bearer verdict with those instances beside the publisher's; and the
|
||||
# count of a stated population's receivers that held an object by a deadline, held to a miss
|
||||
# target in parts per million, over timeliness' held-at statements (holdings records, and the
|
||||
# acknowledgements and reconciliations this module adds). Reuses bearer_receipts, settlement and
|
||||
# timeliness (which brings carousel and holdings); region codes are crate::region. No new dependency.
|
||||
cache_authority = ["bearer_receipts", "settlement", "timeliness"]
|
||||
# Impression aggregates (extensions/impression-aggregates.md): `wai.delivery.impressions` — ad
|
||||
# presentations counted over a bearer with no return path: a receiver judges each shared stitched
|
||||
# ad by stitched-presentation's rules and reports it once to a DAP task of telemetry-aggregation's
|
||||
# measure 5, `impressions`, as a Prio3Histogram report (creative × region × quartile in the vector,
|
||||
# time in DAP's bucket); the catalogue (WIC1) the task's scope names, the task file (WIT1) whose
|
||||
# signer is confirmed with the pinned-only authority `aggregate/task`, the upload file relays
|
||||
# forward (WIU1), a Privacy Pass token per report in a private report extension, and the table a
|
||||
# reader recomputes from telemetry-aggregation's signed shares and receipt. No new dependency.
|
||||
impression_aggregate = ["telemetry_aggregate", "stitched_presentation", "key_discovery"]
|
||||
# Platform and hardware encoding (extensions/platform-encode): `wai.video.encode` — AVC and HEVC
|
||||
# through the encoders a device already has (the operating system's media framework, the video
|
||||
# acceleration API's driver, a GPU driver's encode interface, or a software AVC library loaded at
|
||||
|
|
@ -1424,6 +1460,26 @@ name = "wai_holdings_vectors"
|
|||
path = "src/bin/wai_holdings_vectors.rs"
|
||||
required-features = ["holdings"]
|
||||
|
||||
[[bin]]
|
||||
name = "wai_timeliness_vectors"
|
||||
path = "src/bin/wai_timeliness_vectors.rs"
|
||||
required-features = ["timeliness"]
|
||||
|
||||
[[bin]]
|
||||
name = "wai_rung_price_vectors"
|
||||
path = "src/bin/wai_rung_price_vectors.rs"
|
||||
required-features = ["rung_price"]
|
||||
|
||||
[[bin]]
|
||||
name = "wai_cache_authority_vectors"
|
||||
path = "src/bin/wai_cache_authority_vectors.rs"
|
||||
required-features = ["cache_authority"]
|
||||
|
||||
[[bin]]
|
||||
name = "wai_impression_aggregate_vectors"
|
||||
path = "src/bin/wai_impression_aggregate_vectors.rs"
|
||||
required-features = ["impression_aggregate"]
|
||||
|
||||
[[bin]]
|
||||
name = "wai_telemetry_aggregate_vectors"
|
||||
path = "src/bin/wai_telemetry_aggregate_vectors.rs"
|
||||
|
|
@ -2501,6 +2557,26 @@ name = "holdings"
|
|||
path = "tests/holdings.rs"
|
||||
required-features = ["holdings"]
|
||||
|
||||
[[test]]
|
||||
name = "timeliness"
|
||||
path = "tests/timeliness.rs"
|
||||
required-features = ["timeliness"]
|
||||
|
||||
[[test]]
|
||||
name = "rung_price"
|
||||
path = "tests/rung_price.rs"
|
||||
required-features = ["rung_price"]
|
||||
|
||||
[[test]]
|
||||
name = "cache_authority"
|
||||
path = "tests/cache_authority.rs"
|
||||
required-features = ["cache_authority"]
|
||||
|
||||
[[test]]
|
||||
name = "impression_aggregate"
|
||||
path = "tests/impression_aggregate.rs"
|
||||
required-features = ["impression_aggregate"]
|
||||
|
||||
[[test]]
|
||||
name = "telemetry_aggregate"
|
||||
path = "tests/telemetry_aggregate.rs"
|
||||
|
|
|
|||
|
|
@ -183,16 +183,115 @@ fn bit(bits: &[bool], i: usize) -> bool {
|
|||
bits.get(i).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// §6.2: the verdict over one group. A group that fails jwp-receipts §3 steps 1–2 is the
|
||||
/// error, and nothing is derived from it.
|
||||
pub fn verdict(inp: &Inputs) -> Result<Verdict, DeliveryError> {
|
||||
/// A bearer instance that stands, as §6.2 step 5 counts reconciliations over it: a publisher's
|
||||
/// declaration, which sends every object, or an instance another extension adds beside them
|
||||
/// (cache-authority §7.2 step 4), which sends the objects it names.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Instance {
|
||||
pub bearer: Bearer,
|
||||
/// The key that sent over it. A reconciliation over an instance signed by this key is set
|
||||
/// aside `self_acknowledged` (settlement §3.6); a publisher's own is refused before, at
|
||||
/// jwp-receipts §3.1 step 6.
|
||||
pub transmitter: [u8; 32],
|
||||
}
|
||||
|
||||
/// §6.2 steps 5 and 6: what became of each accepted reconciliation, the bits each counted key
|
||||
/// holds over each instance, and the counts joined per key.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Count {
|
||||
/// For each accepted reconciliation, by its position among the claims: counted, joined, or
|
||||
/// set aside.
|
||||
pub verdicts: Vec<(usize, ClaimVerdict)>,
|
||||
/// The bits each counted key holds, per instance: (key, `bearer_id`).
|
||||
pub bits: BTreeMap<([u8; 32], String), Vec<bool>>,
|
||||
/// Distinct keys counted, over every instance.
|
||||
pub sinks: u64,
|
||||
/// Keys whose bits, joined across every instance, hold every object.
|
||||
pub whole: u64,
|
||||
/// For each object, the distinct keys whose joined bits hold it.
|
||||
pub received_by: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Count {
|
||||
/// Distinct keys counted for the instance `id`.
|
||||
pub fn instance_sinks(&self, id: &str) -> u64 {
|
||||
self.bits.keys().filter(|(_, bid)| bid == id).count() as u64
|
||||
}
|
||||
|
||||
/// The distinct keys whose bits over the instance `id` alone hold object `j`.
|
||||
#[cfg(feature = "cache_authority")]
|
||||
pub fn instance_received(&self, id: &str, j: u64) -> u64 {
|
||||
self.bits.iter().filter(|((_, bid), b)| bid == id && bit(b, j as usize)).count() as u64
|
||||
}
|
||||
}
|
||||
|
||||
/// §6.2 steps 5 and 6 over the accepted claims of a group of `n` objects: the instances that
|
||||
/// stand by `bearer_id`, and those whose declarations were set aside (`contradicted`). A
|
||||
/// reconciliation over a contradicted instance, over none that stands, of another class than its
|
||||
/// instance's, signed by its instance's transmitter, or by a key outside `count_only` when that is
|
||||
/// given, is set aside; otherwise it is counted, and a key's second over one instance is joined.
|
||||
pub(crate) fn count(
|
||||
n: u64,
|
||||
accepted: &[Option<ClaimReceipt>],
|
||||
standing: &BTreeMap<String, Instance>,
|
||||
contradicted: &BTreeSet<String>,
|
||||
count_only: Option<&[[u8; 32]]>,
|
||||
) -> Count {
|
||||
let n = n as usize;
|
||||
let mut out = Count::default();
|
||||
for (i, c) in accepted.iter().enumerate() {
|
||||
let Some(c) = c else { continue };
|
||||
let Some(r) = c.reconciliation() else { continue };
|
||||
let aside = if contradicted.contains(&r.bearer_id) {
|
||||
Some(BEARER_CONTRADICTED)
|
||||
} else {
|
||||
match standing.get(&r.bearer_id) {
|
||||
None => Some(BEARER_NOT_DECLARED),
|
||||
Some(d) if d.bearer != r.bearer => Some(BEARER_CONTRADICTED),
|
||||
Some(d) if d.transmitter == c.signer_pubkey => Some(SELF_ACKNOWLEDGED),
|
||||
Some(_) => match count_only {
|
||||
Some(keys) if !keys.contains(&c.signer_pubkey) => Some(SINK_NOT_CONFIRMED),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
};
|
||||
if let Some(reason) = aside {
|
||||
out.verdicts.push((i, ClaimVerdict::SetAside(reason)));
|
||||
continue;
|
||||
}
|
||||
let k = (c.signer_pubkey, r.bearer_id.clone());
|
||||
out.verdicts.push((i, if out.bits.contains_key(&k) { ClaimVerdict::Joined } else { ClaimVerdict::Counted }));
|
||||
let b = out.bits.entry(k).or_insert_with(|| vec![false; n]);
|
||||
for j in r.indices() {
|
||||
b[j as usize] = true;
|
||||
}
|
||||
}
|
||||
let mut joined: BTreeMap<[u8; 32], Vec<bool>> = BTreeMap::new();
|
||||
for ((key, _), b) in &out.bits {
|
||||
let into = joined.entry(*key).or_insert_with(|| vec![false; n]);
|
||||
for (x, y) in into.iter_mut().zip(b) {
|
||||
*x |= *y;
|
||||
}
|
||||
}
|
||||
out.sinks = joined.len() as u64;
|
||||
out.whole = joined.values().filter(|b| b.iter().all(|x| *x)).count() as u64;
|
||||
out.received_by = (0..n).map(|j| joined.values().filter(|b| bit(b, j)).count() as u64).collect();
|
||||
out
|
||||
}
|
||||
|
||||
/// settlement §3.6's code: a party's statement that it received what it sent.
|
||||
pub(crate) const SELF_ACKNOWLEDGED: &str = "self_acknowledged";
|
||||
|
||||
/// §6.2 steps 1 and 2: the group under jwp-receipts §3 steps 1–2 (the error, when it fails),
|
||||
/// the object receipts that read, and each object's verdict in the order given, the same
|
||||
/// whatever the bearer.
|
||||
pub(crate) fn group_and_objects(group: &Value, held: &[HeldObject]) -> Result<(GroupReceipt, Vec<ObjectReceipt>, Vec<String>), DeliveryError> {
|
||||
// Step 1.
|
||||
let group = GroupReceipt::from_json(&inp.group)?;
|
||||
let group = GroupReceipt::from_json(group)?;
|
||||
group.verify()?;
|
||||
// Step 2: the same whatever the bearer.
|
||||
let mut receipts: Vec<ObjectReceipt> = Vec::new();
|
||||
let objects: Vec<String> = inp
|
||||
.objects
|
||||
let objects: Vec<String> = held
|
||||
.iter()
|
||||
.map(|o| {
|
||||
let r = match ObjectReceipt::from_json(&o.receipt) {
|
||||
|
|
@ -207,6 +306,13 @@ pub fn verdict(inp: &Inputs) -> Result<Verdict, DeliveryError> {
|
|||
}
|
||||
})
|
||||
.collect();
|
||||
Ok((group, receipts, objects))
|
||||
}
|
||||
|
||||
/// §6.2: the verdict over one group. A group that fails jwp-receipts §3 steps 1–2 is the
|
||||
/// error, and nothing is derived from it.
|
||||
pub fn verdict(inp: &Inputs) -> Result<Verdict, DeliveryError> {
|
||||
let (group, receipts, objects) = group_and_objects(&inp.group, &inp.objects)?;
|
||||
let refs: Vec<&ObjectReceipt> = receipts.iter().collect();
|
||||
|
||||
// Step 3.
|
||||
|
|
@ -260,49 +366,24 @@ pub fn verdict(inp: &Inputs) -> Result<Verdict, DeliveryError> {
|
|||
}
|
||||
let declaration = |id: &str| standing.get(id).and_then(|&i| accepted[i].as_ref()).and_then(ClaimReceipt::transmission);
|
||||
|
||||
// Step 5: the reconciliations, in order.
|
||||
let n = group.leaf_count as usize;
|
||||
let mut bits: BTreeMap<[u8; 32], Vec<bool>> = BTreeMap::new();
|
||||
let mut counted: BTreeSet<([u8; 32], String)> = BTreeSet::new();
|
||||
for (i, c) in accepted.iter().enumerate() {
|
||||
let Some(c) = c else { continue };
|
||||
let Some(r) = c.reconciliation() else { continue };
|
||||
let aside = if contradicted.contains(&r.bearer_id) {
|
||||
Some(BEARER_CONTRADICTED)
|
||||
} else {
|
||||
match declaration(&r.bearer_id) {
|
||||
None => Some(BEARER_NOT_DECLARED),
|
||||
Some(d) if d.bearer != r.bearer => Some(BEARER_CONTRADICTED),
|
||||
Some(_) => match &inp.count_only {
|
||||
Some(keys) if !keys.contains(&c.signer_pubkey) => Some(SINK_NOT_CONFIRMED),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
};
|
||||
if let Some(reason) = aside {
|
||||
claims[i] = ClaimVerdict::SetAside(reason);
|
||||
continue;
|
||||
}
|
||||
claims[i] = if counted.insert((c.signer_pubkey, r.bearer_id.clone())) {
|
||||
ClaimVerdict::Counted
|
||||
} else {
|
||||
ClaimVerdict::Joined
|
||||
};
|
||||
let b = bits.entry(c.signer_pubkey).or_insert_with(|| vec![false; n]);
|
||||
for j in r.indices() {
|
||||
b[j as usize] = true;
|
||||
}
|
||||
// Steps 5 and 6: the reconciliations, in order, and the counts.
|
||||
let instances: BTreeMap<String, Instance> = standing
|
||||
.keys()
|
||||
.map(|id| {
|
||||
let d = declaration(id).expect("a declaration that stands");
|
||||
(id.clone(), Instance { bearer: d.bearer, transmitter: group.signer_pubkey })
|
||||
})
|
||||
.collect();
|
||||
let counted = count(group.leaf_count, &accepted, &instances, &contradicted, inp.count_only.as_deref());
|
||||
for (i, v) in &counted.verdicts {
|
||||
claims[*i] = v.clone();
|
||||
}
|
||||
|
||||
// Step 6.
|
||||
let sinks = bits.len() as u64;
|
||||
let whole = bits.values().filter(|b| b.iter().all(|x| *x)).count() as u64;
|
||||
let received_by = (0..n).map(|j| bits.values().filter(|b| bit(b, j)).count() as u64).collect();
|
||||
let (sinks, whole, received_by) = (counted.sinks, counted.whole, counted.received_by.clone());
|
||||
let bearers = standing
|
||||
.iter()
|
||||
.map(|(id, &i)| {
|
||||
let t = accepted[i].as_ref().and_then(ClaimReceipt::transmission).expect("a transmission");
|
||||
let k = counted.iter().filter(|(_, bid)| bid == id).count() as u64;
|
||||
let k = counted.instance_sinks(id);
|
||||
let energy = t.energy.as_ref().map(|e| (e.joules_micro, e.energy_provenance.clone()));
|
||||
BearerLine {
|
||||
bearer_id: id.clone(),
|
||||
|
|
|
|||
75
wai/wai-rs/src/bin/wai_cache_authority_vectors.rs
Normal file
75
wai/wai-rs/src/bin/wai_cache_authority_vectors.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
//! Conformance corpus for cache authority (`live-conformance/cache-authority/`,
|
||||
//! extensions/cache-authority.md §12).
|
||||
//!
|
||||
//! wai_cache_authority_vectors gen <corpus-dir> write vectors.json, from fixed seeds
|
||||
//! wai_cache_authority_vectors verify <corpus-dir> every file regenerates byte for byte, no
|
||||
//! other file but README.md is there, and
|
||||
//! every case gets the outcome it states
|
||||
//!
|
||||
//! The generator and this verifier are one implementation. The corpus's second
|
||||
//! implementation is `live-conformance/cache_authority_verify.py`, written from the extension's
|
||||
//! text and sharing no code with this binary.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use wai::cache_authority::corpus;
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
match (a.get(1).map(String::as_str), a.get(2)) {
|
||||
(Some("gen"), Some(d)) => {
|
||||
if let Err(e) = std::fs::create_dir_all(d) {
|
||||
eprintln!("create {d}: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
for (name, body) in corpus::generate() {
|
||||
let p = Path::new(d).join(&name);
|
||||
if let Err(e) = std::fs::write(&p, body) {
|
||||
eprintln!("write {}: {e}", p.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
println!("wrote {d}");
|
||||
}
|
||||
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
|
||||
_ => {
|
||||
eprintln!("usage: wai_cache_authority_vectors gen|verify <corpus-dir>");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verify(dir: &Path) -> i32 {
|
||||
let mut files = BTreeMap::new();
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("read {}: {e}", dir.display());
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if name == "README.md" {
|
||||
continue;
|
||||
}
|
||||
match std::fs::read_to_string(e.path()) {
|
||||
Ok(s) => {
|
||||
files.insert(name, s);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("read {name}: {err}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
|
||||
let same = files == generated;
|
||||
println!("{} every file regenerates byte for byte from its seeds, and no other file is there", if same { "PASS" } else { "FAIL" });
|
||||
let fails = corpus::verify(&files);
|
||||
for f in &fails {
|
||||
println!("FAIL {f}");
|
||||
}
|
||||
println!("{} every case gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
|
||||
(!same || !fails.is_empty()) as i32
|
||||
}
|
||||
76
wai/wai-rs/src/bin/wai_impression_aggregate_vectors.rs
Normal file
76
wai/wai-rs/src/bin/wai_impression_aggregate_vectors.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
//! Conformance corpus for impression aggregates (`live-conformance/impressions/`,
|
||||
//! extensions/impression-aggregates.md §11).
|
||||
//!
|
||||
//! wai_impression_aggregate_vectors gen <corpus-dir> write vectors.json, from fixed seeds
|
||||
//! wai_impression_aggregate_vectors verify <corpus-dir> every file regenerates byte for byte,
|
||||
//! no other file but README.md is there,
|
||||
//! and every case gets the outcome it
|
||||
//! states
|
||||
//!
|
||||
//! The generator and this verifier are one implementation. The corpus's second implementation
|
||||
//! is `live-conformance/impression_aggregate_verify.py`, written from the extension's text and
|
||||
//! sharing no code with this binary.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use wai::impression_aggregate::corpus;
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
match (a.get(1).map(String::as_str), a.get(2)) {
|
||||
(Some("gen"), Some(d)) => {
|
||||
if let Err(e) = std::fs::create_dir_all(d) {
|
||||
eprintln!("create {d}: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
for (name, body) in corpus::generate() {
|
||||
let p = Path::new(d).join(&name);
|
||||
if let Err(e) = std::fs::write(&p, body) {
|
||||
eprintln!("write {}: {e}", p.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
println!("wrote {d}");
|
||||
}
|
||||
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
|
||||
_ => {
|
||||
eprintln!("usage: wai_impression_aggregate_vectors gen|verify <corpus-dir>");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verify(dir: &Path) -> i32 {
|
||||
let mut files = BTreeMap::new();
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("read {}: {e}", dir.display());
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if name == "README.md" {
|
||||
continue;
|
||||
}
|
||||
match std::fs::read_to_string(e.path()) {
|
||||
Ok(s) => {
|
||||
files.insert(name, s);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("read {name}: {err}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
|
||||
let same = files == generated;
|
||||
println!("{} every file regenerates byte for byte from its seeds, and no other file is there", if same { "PASS" } else { "FAIL" });
|
||||
let fails = corpus::verify(&files);
|
||||
for f in &fails {
|
||||
println!("FAIL {f}");
|
||||
}
|
||||
println!("{} every case gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
|
||||
(!same || !fails.is_empty()) as i32
|
||||
}
|
||||
74
wai/wai-rs/src/bin/wai_rung_price_vectors.rs
Normal file
74
wai/wai-rs/src/bin/wai_rung_price_vectors.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
//! Conformance corpus for rung price (`live-conformance/prices/`, extensions/rung-price.md §9).
|
||||
//!
|
||||
//! wai_rung_price_vectors gen <corpus-dir> write vectors.json, from fixed seeds
|
||||
//! wai_rung_price_vectors verify <corpus-dir> every file regenerates byte for byte, no other
|
||||
//! file but README.md is there, and every case
|
||||
//! gets the outcome it states
|
||||
//!
|
||||
//! The generator and this verifier are one implementation. The corpus's second
|
||||
//! implementation is `live-conformance/rung_price_verify.py`, written from the extension's
|
||||
//! text and sharing no code with this binary.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use wai::rung_price::corpus;
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
match (a.get(1).map(String::as_str), a.get(2)) {
|
||||
(Some("gen"), Some(d)) => {
|
||||
if let Err(e) = std::fs::create_dir_all(d) {
|
||||
eprintln!("create {d}: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
for (name, body) in corpus::generate() {
|
||||
let p = Path::new(d).join(&name);
|
||||
if let Err(e) = std::fs::write(&p, body) {
|
||||
eprintln!("write {}: {e}", p.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
println!("wrote {d}");
|
||||
}
|
||||
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
|
||||
_ => {
|
||||
eprintln!("usage: wai_rung_price_vectors gen|verify <corpus-dir>");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verify(dir: &Path) -> i32 {
|
||||
let mut files = BTreeMap::new();
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("read {}: {e}", dir.display());
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if name == "README.md" {
|
||||
continue;
|
||||
}
|
||||
match std::fs::read_to_string(e.path()) {
|
||||
Ok(s) => {
|
||||
files.insert(name, s);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("read {name}: {err}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
|
||||
let same = files == generated;
|
||||
println!("{} every file regenerates byte for byte from its seeds, and no other file is there", if same { "PASS" } else { "FAIL" });
|
||||
let fails = corpus::verify(&files);
|
||||
for f in &fails {
|
||||
println!("FAIL {f}");
|
||||
}
|
||||
println!("{} every case gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
|
||||
(!same || !fails.is_empty()) as i32
|
||||
}
|
||||
74
wai/wai-rs/src/bin/wai_timeliness_vectors.rs
Normal file
74
wai/wai-rs/src/bin/wai_timeliness_vectors.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
//! Conformance corpus for timeliness (`live-conformance/timeliness/`, extensions/timeliness.md §10).
|
||||
//!
|
||||
//! wai_timeliness_vectors gen <corpus-dir> write vectors.json, from fixed seeds
|
||||
//! wai_timeliness_vectors verify <corpus-dir> every file regenerates byte for byte, no other
|
||||
//! file but README.md is there, and every case
|
||||
//! gets the outcome it states
|
||||
//!
|
||||
//! The generator and this verifier are one implementation. The corpus's second
|
||||
//! implementation is `live-conformance/timeliness_verify.py`, written from the extension's
|
||||
//! text and sharing no code with this binary.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use wai::timeliness::corpus;
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
match (a.get(1).map(String::as_str), a.get(2)) {
|
||||
(Some("gen"), Some(d)) => {
|
||||
if let Err(e) = std::fs::create_dir_all(d) {
|
||||
eprintln!("create {d}: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
for (name, body) in corpus::generate() {
|
||||
let p = Path::new(d).join(&name);
|
||||
if let Err(e) = std::fs::write(&p, body) {
|
||||
eprintln!("write {}: {e}", p.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
println!("wrote {d}");
|
||||
}
|
||||
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
|
||||
_ => {
|
||||
eprintln!("usage: wai_timeliness_vectors gen|verify <corpus-dir>");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verify(dir: &Path) -> i32 {
|
||||
let mut files = BTreeMap::new();
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("read {}: {e}", dir.display());
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if name == "README.md" {
|
||||
continue;
|
||||
}
|
||||
match std::fs::read_to_string(e.path()) {
|
||||
Ok(s) => {
|
||||
files.insert(name, s);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("read {name}: {err}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
|
||||
let same = files == generated;
|
||||
println!("{} every file regenerates byte for byte from its seeds, and no other file is there", if same { "PASS" } else { "FAIL" });
|
||||
let fails = corpus::verify(&files);
|
||||
for f in &fails {
|
||||
println!("FAIL {f}");
|
||||
}
|
||||
println!("{} every case gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
|
||||
(!same || !fails.is_empty()) as i32
|
||||
}
|
||||
1072
wai/wai-rs/src/cache_authority.rs
Normal file
1072
wai/wai-rs/src/cache_authority.rs
Normal file
File diff suppressed because it is too large
Load diff
2162
wai/wai-rs/src/cache_authority/corpus.rs
Normal file
2162
wai/wai-rs/src/cache_authority/corpus.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -336,6 +336,69 @@ impl Refusal {
|
|||
}
|
||||
}
|
||||
|
||||
/// §6.2 steps 3 and 4 over the claims accepted about one group, each at its position (`None`
|
||||
/// for one refused): the declarations by instance (bearer-receipts §6.2 step 4), then the
|
||||
/// schedules by instance. Returns the reason each claim set aside is set aside for, by its
|
||||
/// position, and the position of each standing schedule by its `bearer_id`. A timeliness plan
|
||||
/// (`crate::timeliness`) reads the schedules that stand here.
|
||||
pub(crate) fn standing(accepted: &[Option<ClaimReceipt>]) -> (BTreeMap<usize, &'static str>, BTreeMap<String, usize>) {
|
||||
let mut aside: BTreeMap<usize, &'static str> = BTreeMap::new();
|
||||
// Step 3: the declarations, by instance (bearer-receipts §6.2 step 4).
|
||||
let mut declared: BTreeMap<String, Vec<usize>> = BTreeMap::new();
|
||||
for (i, c) in accepted.iter().enumerate() {
|
||||
if let Some(t) = c.as_ref().and_then(ClaimReceipt::transmission) {
|
||||
declared.entry(t.bearer_id.clone()).or_default().push(i);
|
||||
}
|
||||
}
|
||||
let mut declaration: BTreeMap<String, Bearer> = BTreeMap::new();
|
||||
let mut contradicted: BTreeSet<String> = BTreeSet::new();
|
||||
for (id, idx) in &declared {
|
||||
let first = accepted[idx[0]].as_ref().and_then(ClaimReceipt::transmission);
|
||||
if idx.iter().all(|&i| accepted[i].as_ref().and_then(ClaimReceipt::transmission) == first) {
|
||||
declaration.insert(id.clone(), first.expect("a transmission").bearer);
|
||||
} else {
|
||||
contradicted.insert(id.clone());
|
||||
for &i in idx {
|
||||
aside.insert(i, BEARER_CONTRADICTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: the schedules, by instance.
|
||||
let mut scheduled: BTreeMap<String, Vec<usize>> = BTreeMap::new();
|
||||
for (i, c) in accepted.iter().enumerate() {
|
||||
if let Some(s) = c.as_ref().and_then(ClaimReceipt::schedule) {
|
||||
scheduled.entry(s.bearer_id.clone()).or_default().push(i);
|
||||
}
|
||||
}
|
||||
let mut standing: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for (id, idx) in &scheduled {
|
||||
let first = accepted[idx[0]].as_ref().and_then(ClaimReceipt::schedule);
|
||||
let reason = if idx.iter().any(|&i| accepted[i].as_ref().and_then(ClaimReceipt::schedule) != first) {
|
||||
Some(SCHEDULE_CONTRADICTED)
|
||||
} else if contradicted.contains(id) {
|
||||
Some(BEARER_CONTRADICTED)
|
||||
} else {
|
||||
match declaration.get(id) {
|
||||
None => Some(BEARER_NOT_DECLARED),
|
||||
Some(Bearer::Bidirectional) => Some(SCHEDULE_ON_BIDIRECTIONAL),
|
||||
Some(_) => None,
|
||||
}
|
||||
};
|
||||
match reason {
|
||||
Some(reason) => {
|
||||
for &i in idx {
|
||||
aside.insert(i, reason);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
standing.insert(id.clone(), idx[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
(aside, standing)
|
||||
}
|
||||
|
||||
/// §6.2: the verdict over one group.
|
||||
pub fn verdict(inp: &Inputs) -> Result<Verdict, Refusal> {
|
||||
check_bounds(inp.loss_ppm, inp.overhead).map_err(Refusal::Input)?;
|
||||
|
|
@ -390,58 +453,10 @@ pub fn verdict(inp: &Inputs) -> Result<Verdict, Refusal> {
|
|||
}
|
||||
}
|
||||
|
||||
// Step 3: the declarations, by instance (bearer-receipts §6.2 step 4).
|
||||
let mut declared: BTreeMap<String, Vec<usize>> = BTreeMap::new();
|
||||
for (i, c) in accepted.iter().enumerate() {
|
||||
if let Some(t) = c.as_ref().and_then(ClaimReceipt::transmission) {
|
||||
declared.entry(t.bearer_id.clone()).or_default().push(i);
|
||||
}
|
||||
}
|
||||
let mut declaration: BTreeMap<String, Bearer> = BTreeMap::new();
|
||||
let mut contradicted: BTreeSet<String> = BTreeSet::new();
|
||||
for (id, idx) in &declared {
|
||||
let first = accepted[idx[0]].as_ref().and_then(ClaimReceipt::transmission);
|
||||
if idx.iter().all(|&i| accepted[i].as_ref().and_then(ClaimReceipt::transmission) == first) {
|
||||
declaration.insert(id.clone(), first.expect("a transmission").bearer);
|
||||
} else {
|
||||
contradicted.insert(id.clone());
|
||||
for &i in idx {
|
||||
claims[i] = ClaimVerdict::SetAside(BEARER_CONTRADICTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: the schedules, by instance.
|
||||
let mut scheduled: BTreeMap<String, Vec<usize>> = BTreeMap::new();
|
||||
for (i, c) in accepted.iter().enumerate() {
|
||||
if let Some(s) = c.as_ref().and_then(ClaimReceipt::schedule) {
|
||||
scheduled.entry(s.bearer_id.clone()).or_default().push(i);
|
||||
}
|
||||
}
|
||||
let mut standing: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for (id, idx) in &scheduled {
|
||||
let first = accepted[idx[0]].as_ref().and_then(ClaimReceipt::schedule);
|
||||
let aside = if idx.iter().any(|&i| accepted[i].as_ref().and_then(ClaimReceipt::schedule) != first) {
|
||||
Some(SCHEDULE_CONTRADICTED)
|
||||
} else if contradicted.contains(id) {
|
||||
Some(BEARER_CONTRADICTED)
|
||||
} else {
|
||||
match declaration.get(id) {
|
||||
None => Some(BEARER_NOT_DECLARED),
|
||||
Some(Bearer::Bidirectional) => Some(SCHEDULE_ON_BIDIRECTIONAL),
|
||||
Some(_) => None,
|
||||
}
|
||||
};
|
||||
match aside {
|
||||
Some(reason) => {
|
||||
for &i in idx {
|
||||
claims[i] = ClaimVerdict::SetAside(reason);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
standing.insert(id.clone(), idx[0]);
|
||||
}
|
||||
}
|
||||
// Steps 3 and 4.
|
||||
let (aside, standing) = self::standing(&accepted);
|
||||
for (&i, &reason) in &aside {
|
||||
claims[i] = ClaimVerdict::SetAside(reason);
|
||||
}
|
||||
|
||||
// Step 5.
|
||||
|
|
|
|||
|
|
@ -133,11 +133,14 @@ pub use crate::jwp_hash::{content_hash as object_content_hash, DOMAIN_OBJECT};
|
|||
|
||||
/// cbindgen:ignore
|
||||
pub mod bearer;
|
||||
pub use bearer::{Bearer, ReconciliationClaim, TransmissionClaim};
|
||||
pub use bearer::{Bearer, CacheTransmissionClaim, ReconciliationClaim, TransmissionClaim};
|
||||
/// cbindgen:ignore
|
||||
pub mod schedule;
|
||||
pub use schedule::{BlockSize, Esi, Fec, Oti, ScheduleClaim, ScheduleEntry};
|
||||
/// cbindgen:ignore
|
||||
pub mod timeliness;
|
||||
pub use timeliness::{DecayPoint, Fallback, HeldGroup, TimelinessClaim, TimelinessEntry};
|
||||
/// cbindgen:ignore
|
||||
pub mod consumption;
|
||||
pub use consumption::{ConsumptionClaim, UsageEntry, Vocabulary};
|
||||
pub mod session;
|
||||
|
|
@ -494,6 +497,54 @@ pub enum Reason {
|
|||
ScheduleContentMismatch,
|
||||
/// A `schedule` entry whose transfer length is not its object's length.
|
||||
ScheduleLengthMismatch,
|
||||
/// A `timeliness` claim whose `attests` is not `valued`.
|
||||
TimelinessNotValued,
|
||||
/// A `timeliness` claim with no entry, or entries not in strictly increasing `leaf_index`.
|
||||
TimelinessObjectsMalformed,
|
||||
/// A `timeliness` entry whose `from_ms` is after its `by_ms`, or whose `by_ms` is not below
|
||||
/// the last instant.
|
||||
TimelinessWindowInvalid,
|
||||
/// A `timeliness` entry whose decay is not well formed.
|
||||
TimelinessDecayInvalid,
|
||||
/// A `timeliness` entry whose fallback is not well formed.
|
||||
TimelinessFallbackInvalid,
|
||||
/// A `timeliness` claim whose subject is not the link hash of the group it is checked
|
||||
/// against.
|
||||
TimelinessSubjectMismatch,
|
||||
/// A `timeliness` claim not signed by the group's key.
|
||||
TimelinessSignerMismatch,
|
||||
/// A `timeliness` entry naming an object past the group's leaf count.
|
||||
TimelinessLeafOutOfRange,
|
||||
/// A `timeliness` entry of use before the group begins.
|
||||
TimelinessBeforeGroup,
|
||||
/// A `timeliness` entry whose content hash is not that of the object receipt at its
|
||||
/// position.
|
||||
TimelinessContentMismatch,
|
||||
/// A `timeliness` entry, or its fallback, that is a staged layer.
|
||||
TimelinessOnLayer,
|
||||
/// A `timeliness` fallback whose position or content hash its group's receipts contradict.
|
||||
TimelinessFallbackMismatch,
|
||||
/// A `timeliness` fallback whose envelope does not derive from the entry's object.
|
||||
TimelinessFallbackNotDerived,
|
||||
/// A `cache-transmission` claim whose `attests` is not `transmitted`.
|
||||
CacheTransmissionNotTransmitted,
|
||||
/// A `cache-transmission` claim over a `bidirectional` bearer instance.
|
||||
CacheTransmissionOnBidirectional,
|
||||
/// A `cache-transmission` claim with no object, a bitmap of another length than its leaf
|
||||
/// count gives, a bit at or past its leaf count, or no bit set.
|
||||
CacheTransmissionBitmapMalformed,
|
||||
/// A `cache-transmission` claim whose subject is not the link hash of the group it is
|
||||
/// checked against.
|
||||
CacheTransmissionSubjectMismatch,
|
||||
/// A `cache-transmission` claim signed by the group's key.
|
||||
CacheTransmissionByPublisher,
|
||||
/// A `cache-transmission` claim whose leaf count is not the group's.
|
||||
CacheTransmissionLeafCountMismatch,
|
||||
/// A `cache-transmission` claim naming an object whose receipt the verifier does not hold.
|
||||
/// Recoverable by obtaining it; never accepted silently.
|
||||
CacheTransmissionObjectsNotHeld,
|
||||
/// A `cache-transmission` claim whose root is not the root over the leaves it names.
|
||||
CacheTransmissionRootMismatch,
|
||||
/// A reason of pq-signatures §8: a hybrid claim's members (step 1), its post-quantum
|
||||
/// half or the verifier's requirement of one (step 3).
|
||||
#[cfg(feature = "pq_signatures")]
|
||||
|
|
@ -640,6 +691,27 @@ impl Reason {
|
|||
Reason::ScheduleBeforeGroup => "schedule_before_group",
|
||||
Reason::ScheduleContentMismatch => "schedule_content_mismatch",
|
||||
Reason::ScheduleLengthMismatch => "schedule_length_mismatch",
|
||||
Reason::TimelinessNotValued => "timeliness_not_valued",
|
||||
Reason::TimelinessObjectsMalformed => "timeliness_objects_malformed",
|
||||
Reason::TimelinessWindowInvalid => "timeliness_window_invalid",
|
||||
Reason::TimelinessDecayInvalid => "timeliness_decay_invalid",
|
||||
Reason::TimelinessFallbackInvalid => "timeliness_fallback_invalid",
|
||||
Reason::TimelinessSubjectMismatch => "timeliness_subject_mismatch",
|
||||
Reason::TimelinessSignerMismatch => "timeliness_signer_mismatch",
|
||||
Reason::TimelinessLeafOutOfRange => "timeliness_leaf_out_of_range",
|
||||
Reason::TimelinessBeforeGroup => "timeliness_before_group",
|
||||
Reason::TimelinessContentMismatch => "timeliness_content_mismatch",
|
||||
Reason::TimelinessOnLayer => "timeliness_on_layer",
|
||||
Reason::TimelinessFallbackMismatch => "timeliness_fallback_mismatch",
|
||||
Reason::TimelinessFallbackNotDerived => "timeliness_fallback_not_derived",
|
||||
Reason::CacheTransmissionNotTransmitted => "cache_transmission_not_transmitted",
|
||||
Reason::CacheTransmissionOnBidirectional => "cache_transmission_on_bidirectional",
|
||||
Reason::CacheTransmissionBitmapMalformed => "cache_transmission_bitmap_malformed",
|
||||
Reason::CacheTransmissionSubjectMismatch => "cache_transmission_subject_mismatch",
|
||||
Reason::CacheTransmissionByPublisher => "cache_transmission_by_publisher",
|
||||
Reason::CacheTransmissionLeafCountMismatch => "cache_transmission_leaf_count_mismatch",
|
||||
Reason::CacheTransmissionObjectsNotHeld => "cache_transmission_objects_not_held",
|
||||
Reason::CacheTransmissionRootMismatch => "cache_transmission_root_mismatch",
|
||||
#[cfg(feature = "pq_signatures")]
|
||||
Reason::Pq(r) => r.label(),
|
||||
}
|
||||
|
|
@ -717,6 +789,8 @@ pub enum ClaimClass {
|
|||
Transmission,
|
||||
Reconciliation,
|
||||
Schedule,
|
||||
Timeliness,
|
||||
CacheTransmission,
|
||||
}
|
||||
|
||||
/// The class of `group` and `object` receipts. A claim naming it is rejected.
|
||||
|
|
@ -740,6 +814,8 @@ impl ClaimClass {
|
|||
ClaimClass::Transmission => "transmission",
|
||||
ClaimClass::Reconciliation => "reconciliation",
|
||||
ClaimClass::Schedule => "schedule",
|
||||
ClaimClass::Timeliness => "timeliness",
|
||||
ClaimClass::CacheTransmission => "cache-transmission",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -760,6 +836,8 @@ impl ClaimClass {
|
|||
"transmission" => Some(ClaimClass::Transmission),
|
||||
"reconciliation" => Some(ClaimClass::Reconciliation),
|
||||
"schedule" => Some(ClaimClass::Schedule),
|
||||
"timeliness" => Some(ClaimClass::Timeliness),
|
||||
"cache-transmission" => Some(ClaimClass::CacheTransmission),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -773,11 +851,15 @@ impl ClaimClass {
|
|||
/// object.
|
||||
pub fn admits(self, t: SubjectType) -> bool {
|
||||
match self {
|
||||
// A declaration of a transmission, a reconciliation and a schedule are about a
|
||||
// group: its link hash (bearer-receipts §4, §5; carousel §4).
|
||||
ClaimClass::LogInclusion | ClaimClass::Transmission | ClaimClass::Reconciliation | ClaimClass::Schedule => {
|
||||
t == SubjectType::Receipt
|
||||
}
|
||||
// A declaration of a transmission, a reconciliation, a schedule, a statement of
|
||||
// timeliness and a cache's declaration of its own instance are about a group: its link
|
||||
// hash (bearer-receipts §4, §5; carousel §4; timeliness §4; cache-authority §5).
|
||||
ClaimClass::LogInclusion
|
||||
| ClaimClass::Transmission
|
||||
| ClaimClass::Reconciliation
|
||||
| ClaimClass::Schedule
|
||||
| ClaimClass::Timeliness
|
||||
| ClaimClass::CacheTransmission => t == SubjectType::Receipt,
|
||||
ClaimClass::LawfulBasis | ClaimClass::Measurement | ClaimClass::OperatingPoint => true,
|
||||
ClaimClass::Energy
|
||||
| ClaimClass::SoftBinding
|
||||
|
|
@ -1945,6 +2027,8 @@ pub enum ClaimBody {
|
|||
Transmission(TransmissionClaim),
|
||||
Reconciliation(ReconciliationClaim),
|
||||
Schedule(ScheduleClaim),
|
||||
Timeliness(TimelinessClaim),
|
||||
CacheTransmission(CacheTransmissionClaim),
|
||||
}
|
||||
|
||||
/// The energy body's members, in the order step 2 reads them.
|
||||
|
|
@ -2107,6 +2191,8 @@ impl ClaimBody {
|
|||
ClaimBody::Transmission(_) => ClaimClass::Transmission,
|
||||
ClaimBody::Reconciliation(_) => ClaimClass::Reconciliation,
|
||||
ClaimBody::Schedule(_) => ClaimClass::Schedule,
|
||||
ClaimBody::Timeliness(_) => ClaimClass::Timeliness,
|
||||
ClaimBody::CacheTransmission(_) => ClaimClass::CacheTransmission,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2273,6 +2359,8 @@ impl ClaimBody {
|
|||
ClaimBody::Transmission(c) => return c.to_value(),
|
||||
ClaimBody::Reconciliation(c) => return c.to_value(),
|
||||
ClaimBody::Schedule(c) => return c.to_value(),
|
||||
ClaimBody::Timeliness(c) => return c.to_value(),
|
||||
ClaimBody::CacheTransmission(c) => return c.to_value(),
|
||||
}
|
||||
Value::Object(m)
|
||||
}
|
||||
|
|
@ -2508,6 +2596,8 @@ impl ClaimBody {
|
|||
ClaimClass::Transmission => ClaimBody::Transmission(TransmissionClaim::read(m)?),
|
||||
ClaimClass::Reconciliation => ClaimBody::Reconciliation(ReconciliationClaim::read(m)?),
|
||||
ClaimClass::Schedule => ClaimBody::Schedule(ScheduleClaim::read(m)?),
|
||||
ClaimClass::Timeliness => ClaimBody::Timeliness(TimelinessClaim::read(m)?),
|
||||
ClaimClass::CacheTransmission => ClaimBody::CacheTransmission(CacheTransmissionClaim::read(m)?),
|
||||
ClaimClass::BindingRecovery => {
|
||||
members(m, RECOVERY_MEMBERS)?;
|
||||
ClaimBody::BindingRecovery(BindingRecoveryClaim {
|
||||
|
|
@ -2665,6 +2755,8 @@ impl ClaimBody {
|
|||
ClaimBody::Transmission(c) => c.check_values(),
|
||||
ClaimBody::Reconciliation(c) => c.check_values(),
|
||||
ClaimBody::Schedule(c) => c.check_values(),
|
||||
ClaimBody::Timeliness(c) => c.check_values(),
|
||||
ClaimBody::CacheTransmission(c) => c.check_values(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2781,6 +2873,8 @@ impl ClaimBody {
|
|||
ClaimBody::Transmission(c) => c.rules(),
|
||||
ClaimBody::Reconciliation(c) => c.rules(),
|
||||
ClaimBody::Schedule(c) => c.rules(),
|
||||
ClaimBody::Timeliness(c) => c.rules(subject),
|
||||
ClaimBody::CacheTransmission(c) => c.rules(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3222,10 +3316,10 @@ impl ClaimReceipt {
|
|||
if e.joules_micro != object_joules_micro {
|
||||
return Err(Rejection::at(6, Reason::EnergyDisagreesWithDelivery));
|
||||
}
|
||||
if let Some(label) = object_class {
|
||||
if e.acquisition_class().ok().as_ref() != Some(label) {
|
||||
return Err(Rejection::at(6, Reason::EnergyClassDisagreesWithDelivery));
|
||||
}
|
||||
if let Some(label) = object_class
|
||||
&& e.acquisition_class().ok().as_ref() != Some(label)
|
||||
{
|
||||
return Err(Rejection::at(6, Reason::EnergyClassDisagreesWithDelivery));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -4140,7 +4234,7 @@ pub(crate) fn term_ok(s: &str) -> Result<(), Reason> {
|
|||
}
|
||||
|
||||
/// An absolute URI: `scheme ":" rest`, scheme per RFC 3986 §3.1.
|
||||
fn uri_ok(s: &str) -> Result<(), Reason> {
|
||||
pub(crate) fn uri_ok(s: &str) -> Result<(), Reason> {
|
||||
token_ok(s)?;
|
||||
let Some((scheme, rest)) = s.split_once(':') else {
|
||||
return Err(Reason::FieldType);
|
||||
|
|
@ -4261,7 +4355,7 @@ pub(crate) fn rfc3339(s: &str) -> bool {
|
|||
/// fails instead of passing on nothing. (A function rather than a public constant so
|
||||
/// cbindgen does not export it into the C header.)
|
||||
pub fn corpus_vector_count() -> usize {
|
||||
653
|
||||
654
|
||||
}
|
||||
|
||||
/// The conformance corpus (`claim-conformance/vectors.json`), regenerated. Ed25519
|
||||
|
|
@ -5505,6 +5599,8 @@ mod corpus {
|
|||
v.extend(fabric_vectors());
|
||||
// §2.4.12's decode path (rules 13 and 14, and a stage hash), appended the same way.
|
||||
v.extend(session::decode_path_vectors());
|
||||
// The switch reason timeliness §8.6 adds to delivery-telemetry §5, appended the same way.
|
||||
v.extend(session::timeliness_vectors());
|
||||
v
|
||||
}
|
||||
|
||||
|
|
@ -7529,6 +7625,18 @@ mod tests {
|
|||
assert_eq!(hex(digest.as_bytes()), "885e3ab74f416f9f8a0ef560dca930b31e430aa2b06a0d3bb126ab95fed841f0");
|
||||
}
|
||||
|
||||
/// The vectors the corpus held before the switch reason timeliness §8.6 adds (all 653),
|
||||
/// pinned as the tests above pin theirs. The digest was computed from the committed corpus
|
||||
/// before the reason's vector was added and must never be edited to make the test pass.
|
||||
#[test]
|
||||
fn the_vectors_before_the_timeliness_switch_reason_are_unchanged() {
|
||||
let doc: Value = crate::oer::load(conformance_vectors().as_bytes()).unwrap();
|
||||
let vs = doc["vectors"].as_array().unwrap();
|
||||
let first = Value::Array(vs[..653].to_vec());
|
||||
let digest = blake3::hash(serde_json::to_string(&first).unwrap().as_bytes());
|
||||
assert_eq!(hex(digest.as_bytes()), "6bdb6fbcdbef0af171aceab06c302318d11f396c2d657567665c5adfb9b8dc47");
|
||||
}
|
||||
|
||||
/// A session claim's grammars (§2.4.12): `media`, a decoder identifier, and the
|
||||
/// rounding of its worker count.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
//! `transmission` and `reconciliation` (jwp-receipts §2.4.14, §2.4.15;
|
||||
//! `extensions/bearer-receipts.md` §4, §5): a publisher's declaration of the bearer a group
|
||||
//! was sent over, and a sink's later statement of the objects of a group it received.
|
||||
//! `transmission`, `reconciliation` and `cache-transmission` (jwp-receipts §2.4.14, §2.4.15,
|
||||
//! §2.4.18; `extensions/bearer-receipts.md` §4, §5; `extensions/cache-authority.md` §5): a
|
||||
//! publisher's declaration of the bearer a group was sent over, a sink's later statement of the
|
||||
//! objects of a group it received, and a node's declaration of the objects of a group another key
|
||||
//! signed that it sent again, from what it held, over a bearer instance of its own.
|
||||
//!
|
||||
//! This file holds steps 2 and 4 of jwp-receipts §3.1 for both classes — their bodies'
|
||||
//! members and types, and bearer-receipts §4.2 and §5.2 — and step 6, their checks against
|
||||
//! the group receipt (§4.3) and the object receipts (§5.3). The verdict a verifier derives
|
||||
//! for a group from them is `crate::bearer_receipts`'s.
|
||||
//! This file holds steps 2 and 4 of jwp-receipts §3.1 for the three classes — their bodies'
|
||||
//! members and types, bearer-receipts §4.2 and §5.2 and cache-authority §5.2 — and step 6, their
|
||||
//! checks against the group receipt (§4.3) and the object receipts (§5.3; cache-authority §5.3).
|
||||
//! A reconciliation and a cache-transmission claim share one bitmap rule and one root check. The
|
||||
//! verdict a verifier derives for a group from them is `crate::bearer_receipts`'s, and with the
|
||||
//! caches' instances `crate::cache_authority`'s.
|
||||
|
||||
use super::*;
|
||||
use crate::jwp_delivery::{root as merkle_root, GroupReceipt, ObjectReceipt};
|
||||
|
|
@ -47,6 +51,8 @@ pub const ATTESTS_RECEIVED: &str = "received";
|
|||
const TRANSMISSION_MEMBERS: &[&str] = &["bearer", "bearer_id", "energy", "attests"];
|
||||
/// A reconciliation body's members (bearer-receipts §5.1), in the order step 2 reads them.
|
||||
const RECONCILIATION_MEMBERS: &[&str] = &["bearer", "bearer_id", "leaf_count", "received", "received_root", "attests"];
|
||||
/// A cache-transmission body's members (cache-authority §5.1), in the order step 2 reads them.
|
||||
const CACHE_TRANSMISSION_MEMBERS: &[&str] = &["bearer", "bearer_id", "leaf_count", "sent", "sent_root", "energy", "attests"];
|
||||
/// The members of a transmission's `energy`, as jwp-receipts §2.4.12 writes a session's.
|
||||
const ENERGY_MEMBERS: &[&str] =
|
||||
&["joules_micro", "energy_provenance", "energy_uncertainty", "energy_counter_filtering", "energy_calibration_ref"];
|
||||
|
|
@ -66,6 +72,26 @@ pub struct TransmissionClaim {
|
|||
pub attests: String,
|
||||
}
|
||||
|
||||
/// `cache-transmission` (cache-authority §5): a node's declaration that it sent, from what it
|
||||
/// held, the objects of the group its subject names whose bits are set, over a `multicast` or
|
||||
/// `unidirectional` bearer instance of its own. Signed by the node's key, never the group's.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CacheTransmissionClaim {
|
||||
pub bearer: Bearer,
|
||||
/// The node's own instance, an absolute URI compared byte for byte.
|
||||
pub bearer_id: String,
|
||||
/// The group's leaf count.
|
||||
pub leaf_count: u64,
|
||||
/// ⌈leaf_count / 8⌉ bytes, object *i* at bit `0x80 >> (i % 8)` of byte `i / 8`.
|
||||
pub sent: Vec<u8>,
|
||||
/// The jwp-receipts §2.2 root over the sent objects' leaves, in object order.
|
||||
pub sent_root: [u8; 32],
|
||||
/// What the instance spent sending these objects; `None` when it was not metered.
|
||||
pub energy: Option<SessionEnergy>,
|
||||
/// MUST be `"transmitted"`.
|
||||
pub attests: String,
|
||||
}
|
||||
|
||||
/// `reconciliation` (bearer-receipts §5): a sink's statement of the objects of the group its
|
||||
/// subject names that it received.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
|
@ -109,6 +135,62 @@ fn bitmap_len(leaf_count: u64) -> u64 {
|
|||
leaf_count.div_ceil(8)
|
||||
}
|
||||
|
||||
/// Whether bit `i` of a bitmap is set.
|
||||
fn bit_set(bits: &[u8], i: u64) -> bool {
|
||||
bits.get((i / 8) as usize).is_some_and(|b| b & (0x80 >> (i % 8)) != 0)
|
||||
}
|
||||
|
||||
/// The positions set in a bitmap, in order.
|
||||
fn bits_set(bits: &[u8]) -> Vec<u64> {
|
||||
(0..bits.len() as u64 * 8).filter(|&i| bit_set(bits, i)).collect()
|
||||
}
|
||||
|
||||
/// A bitmap's one form (bearer-receipts §5.2 rule 2; cache-authority §5.2 rule 3): at least one
|
||||
/// object, exactly ⌈`leaf_count` / 8⌉ bytes, no bit at or past the leaf count, and a bit set.
|
||||
fn bitmap_well_formed(leaf_count: u64, bits: &[u8]) -> bool {
|
||||
if leaf_count == 0 || bits.len() as u64 != bitmap_len(leaf_count) {
|
||||
return false;
|
||||
}
|
||||
// No bit at or past the leaf count: the last byte's unused low bits are clear.
|
||||
let used = (leaf_count % 8) as u32;
|
||||
if used != 0 && bits.last().is_some_and(|b| b & (0xffu8 >> used) != 0) {
|
||||
return false;
|
||||
}
|
||||
bits.iter().any(|b| *b != 0)
|
||||
}
|
||||
|
||||
/// Why a bitmap's root is not the claim's.
|
||||
enum RootFault {
|
||||
/// A set position whose object receipt the verifier does not hold.
|
||||
NotHeld,
|
||||
/// The root over the leaves is another.
|
||||
Mismatch,
|
||||
}
|
||||
|
||||
/// The root check of bearer-receipts §5.3 and cache-authority §5.3 step 4: with every bit set,
|
||||
/// the group's own root; otherwise the root over the leaves at the set positions, each of whose
|
||||
/// object receipts the verifier holds and verifies by inclusion.
|
||||
fn root_holds(group: &GroupReceipt, objects: &[&ObjectReceipt], bits: &[u8], leaf_count: u64, root: &[u8; 32]) -> Result<(), RootFault> {
|
||||
let whole = (0..leaf_count).all(|i| bit_set(bits, i));
|
||||
let computed = if whole {
|
||||
group.root_hash
|
||||
} else {
|
||||
let mut leaves = Vec::new();
|
||||
for i in bits_set(bits) {
|
||||
let held = objects.iter().find(|o| o.object_id == i && o.verify_inclusion(group).is_ok());
|
||||
match held {
|
||||
Some(o) => leaves.push(o.leaf()),
|
||||
None => return Err(RootFault::NotHeld),
|
||||
}
|
||||
}
|
||||
merkle_root(&leaves)
|
||||
};
|
||||
if computed != *root {
|
||||
return Err(RootFault::Mismatch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl TransmissionClaim {
|
||||
pub(super) fn to_value(&self) -> Value {
|
||||
let mut m = Map::new();
|
||||
|
|
@ -125,20 +207,7 @@ impl TransmissionClaim {
|
|||
members(m, TRANSMISSION_MEMBERS)?;
|
||||
let bearer = get_bearer(m)?;
|
||||
let bearer_id = get_bearer_id(m)?;
|
||||
let energy = match &m["energy"] {
|
||||
Value::Null => None,
|
||||
Value::Object(o) => {
|
||||
members(o, ENERGY_MEMBERS)?;
|
||||
Some(SessionEnergy {
|
||||
joules_micro: get_int(o, "joules_micro")?,
|
||||
energy_provenance: get_str(o, "energy_provenance")?,
|
||||
energy_uncertainty: get_uncertainty(o)?,
|
||||
energy_counter_filtering: get_opt_str(o, "energy_counter_filtering")?,
|
||||
energy_calibration_ref: get_opt_str(o, "energy_calibration_ref")?,
|
||||
})
|
||||
}
|
||||
_ => return Err(Reason::FieldType),
|
||||
};
|
||||
let energy = read_energy(&m["energy"])?;
|
||||
let attests = get_str(m, "attests")?;
|
||||
Ok(TransmissionClaim { bearer, bearer_id, energy, attests })
|
||||
}
|
||||
|
|
@ -146,14 +215,7 @@ impl TransmissionClaim {
|
|||
/// Step 2's checks for a body built field by field.
|
||||
pub(super) fn check_values(&self) -> Result<(), Reason> {
|
||||
uri_ok(&self.bearer_id)?;
|
||||
if let Some(e) = &self.energy {
|
||||
int_ok(e.joules_micro)?;
|
||||
if let Some(u) = e.energy_uncertainty {
|
||||
int_ok(u.relative_ppm)?;
|
||||
int_ok(u.window_us)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
energy_ints(&self.energy)
|
||||
}
|
||||
|
||||
/// Step 4: bearer-receipts §4.2, in its order.
|
||||
|
|
@ -161,16 +223,51 @@ impl TransmissionClaim {
|
|||
if self.attests != ATTESTS_TRANSMITTED {
|
||||
return Err(Reason::TransmissionNotTransmitted);
|
||||
}
|
||||
if let Some(e) = &self.energy {
|
||||
if e.joules_micro == 0 {
|
||||
return Err(Reason::EnergyZeroFigure);
|
||||
}
|
||||
e.acquisition_class()?;
|
||||
}
|
||||
Ok(())
|
||||
energy_rules(&self.energy)
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 2: an `energy` member, `null` or an object with a session's energy members and types.
|
||||
fn read_energy(v: &Value) -> Result<Option<SessionEnergy>, Reason> {
|
||||
match v {
|
||||
Value::Null => Ok(None),
|
||||
Value::Object(o) => {
|
||||
members(o, ENERGY_MEMBERS)?;
|
||||
Ok(Some(SessionEnergy {
|
||||
joules_micro: get_int(o, "joules_micro")?,
|
||||
energy_provenance: get_str(o, "energy_provenance")?,
|
||||
energy_uncertainty: get_uncertainty(o)?,
|
||||
energy_counter_filtering: get_opt_str(o, "energy_counter_filtering")?,
|
||||
energy_calibration_ref: get_opt_str(o, "energy_calibration_ref")?,
|
||||
}))
|
||||
}
|
||||
_ => Err(Reason::FieldType),
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 2's integer checks of an energy figure built field by field.
|
||||
fn energy_ints(e: &Option<SessionEnergy>) -> Result<(), Reason> {
|
||||
if let Some(e) = e {
|
||||
int_ok(e.joules_micro)?;
|
||||
if let Some(u) = e.energy_uncertainty {
|
||||
int_ok(u.relative_ppm)?;
|
||||
int_ok(u.window_us)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Step 4: bearer-receipts §4.2 rule 2 on an energy figure that is not `null`.
|
||||
fn energy_rules(e: &Option<SessionEnergy>) -> Result<(), Reason> {
|
||||
if let Some(e) = e {
|
||||
if e.joules_micro == 0 {
|
||||
return Err(Reason::EnergyZeroFigure);
|
||||
}
|
||||
e.acquisition_class()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl ReconciliationClaim {
|
||||
/// The bitmap of `leaf_count` objects with the objects `indices` set.
|
||||
pub fn bitmap(leaf_count: u64, indices: &[u64]) -> Vec<u8> {
|
||||
|
|
@ -183,13 +280,13 @@ impl ReconciliationClaim {
|
|||
|
||||
/// Whether object `i` is set.
|
||||
pub fn has(&self, i: u64) -> bool {
|
||||
self.received.get((i / 8) as usize).is_some_and(|b| b & (0x80 >> (i % 8)) != 0)
|
||||
bit_set(&self.received, i)
|
||||
}
|
||||
|
||||
/// The objects set, in order. Only for a body that passed step 4, whose bits lie below
|
||||
/// its leaf count.
|
||||
pub fn indices(&self) -> Vec<u64> {
|
||||
(0..self.received.len() as u64 * 8).filter(|&i| self.has(i)).collect()
|
||||
bits_set(&self.received)
|
||||
}
|
||||
|
||||
/// Whether every object of the group is set.
|
||||
|
|
@ -232,22 +329,73 @@ impl ReconciliationClaim {
|
|||
if self.attests != ATTESTS_RECEIVED {
|
||||
return Err(Reason::ReconciliationNotReceived);
|
||||
}
|
||||
let malformed = Err(Reason::ReconciliationBitmapMalformed);
|
||||
if self.leaf_count == 0 || self.received.len() as u64 != bitmap_len(self.leaf_count) {
|
||||
return malformed;
|
||||
}
|
||||
// No bit at or past the leaf count: the last byte's unused low bits are clear.
|
||||
let used = (self.leaf_count % 8) as u32;
|
||||
if used != 0 && self.received.last().is_some_and(|b| b & (0xffu8 >> used) != 0) {
|
||||
return malformed;
|
||||
}
|
||||
if self.received.iter().all(|b| *b == 0) {
|
||||
return malformed;
|
||||
if !bitmap_well_formed(self.leaf_count, &self.received) {
|
||||
return Err(Reason::ReconciliationBitmapMalformed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl CacheTransmissionClaim {
|
||||
/// Whether object `i` is set.
|
||||
pub fn has(&self, i: u64) -> bool {
|
||||
bit_set(&self.sent, i)
|
||||
}
|
||||
|
||||
/// The objects set, in order. Only for a body that passed step 4, whose bits lie below
|
||||
/// its leaf count.
|
||||
pub fn indices(&self) -> Vec<u64> {
|
||||
bits_set(&self.sent)
|
||||
}
|
||||
|
||||
pub(super) fn to_value(&self) -> Value {
|
||||
let mut m = Map::new();
|
||||
m.insert("bearer".into(), self.bearer.label().into());
|
||||
m.insert("bearer_id".into(), self.bearer_id.clone().into());
|
||||
m.insert("leaf_count".into(), self.leaf_count.into());
|
||||
m.insert("sent".into(), hex(&self.sent).into());
|
||||
m.insert("sent_root".into(), hex(&self.sent_root).into());
|
||||
m.insert("energy".into(), energy_value(&self.energy));
|
||||
m.insert("attests".into(), self.attests.clone().into());
|
||||
Value::Object(m)
|
||||
}
|
||||
|
||||
/// Step 2: the members, then each member's type in the table's order, `energy`'s members
|
||||
/// and types where the table reaches it. `sent` is a byte string of any length here; its
|
||||
/// length is a class rule (step 4).
|
||||
pub(super) fn read(m: &Map<String, Value>) -> Result<CacheTransmissionClaim, Reason> {
|
||||
members(m, CACHE_TRANSMISSION_MEMBERS)?;
|
||||
let bearer = get_bearer(m)?;
|
||||
let bearer_id = get_bearer_id(m)?;
|
||||
let leaf_count = get_int(m, "leaf_count")?;
|
||||
let sent = get_bytes(m, "sent")?;
|
||||
let sent_root = get_hash(m, "sent_root")?;
|
||||
let energy = read_energy(&m["energy"])?;
|
||||
let attests = get_str(m, "attests")?;
|
||||
Ok(CacheTransmissionClaim { bearer, bearer_id, leaf_count, sent, sent_root, energy, attests })
|
||||
}
|
||||
|
||||
pub(super) fn check_values(&self) -> Result<(), Reason> {
|
||||
uri_ok(&self.bearer_id)?;
|
||||
int_ok(self.leaf_count)?;
|
||||
energy_ints(&self.energy)
|
||||
}
|
||||
|
||||
/// Step 4: cache-authority §5.2, in its order.
|
||||
pub(super) fn rules(&self) -> Result<(), Reason> {
|
||||
if self.attests != ATTESTS_TRANSMITTED {
|
||||
return Err(Reason::CacheTransmissionNotTransmitted);
|
||||
}
|
||||
if self.bearer == Bearer::Bidirectional {
|
||||
return Err(Reason::CacheTransmissionOnBidirectional);
|
||||
}
|
||||
if !bitmap_well_formed(self.leaf_count, &self.sent) {
|
||||
return Err(Reason::CacheTransmissionBitmapMalformed);
|
||||
}
|
||||
energy_rules(&self.energy)
|
||||
}
|
||||
}
|
||||
|
||||
impl ClaimReceipt {
|
||||
/// Step 6 (bearer-receipts §4.3): a `transmission` claim against the group receipt its
|
||||
/// subject names. In order: the claim is a transmission (`context_mismatch`); the group
|
||||
|
|
@ -296,23 +444,45 @@ impl ClaimReceipt {
|
|||
if group.leaf_count != r.leaf_count {
|
||||
return at(Reason::ReconciliationLeafCountMismatch);
|
||||
}
|
||||
let root = if r.whole() {
|
||||
group.root_hash
|
||||
} else {
|
||||
let mut leaves = Vec::new();
|
||||
for i in r.indices() {
|
||||
let held = objects.iter().find(|o| o.object_id == i && o.verify_inclusion(group).is_ok());
|
||||
match held {
|
||||
Some(o) => leaves.push(o.leaf()),
|
||||
None => return at(Reason::ReconciliationObjectsNotHeld),
|
||||
}
|
||||
}
|
||||
merkle_root(&leaves)
|
||||
match root_holds(group, objects, &r.received, r.leaf_count, &r.received_root) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(RootFault::NotHeld) => at(Reason::ReconciliationObjectsNotHeld),
|
||||
Err(RootFault::Mismatch) => at(Reason::ReconciliationRootMismatch),
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 6 (cache-authority §5.3): a `cache-transmission` claim against the group receipt its
|
||||
/// subject names and the object receipts the verifier holds under it, of which only those
|
||||
/// that pass jwp-receipts §3 step 5 against the group are used. In order: the claim is a
|
||||
/// cache-transmission (`context_mismatch`); the group passes §3 steps 1 and 2
|
||||
/// (`context_invalid`); its link hash is the subject (`cache_transmission_subject_mismatch`);
|
||||
/// its key did not sign the claim (`cache_transmission_by_publisher`); its leaf count is the
|
||||
/// claim's (`cache_transmission_leaf_count_mismatch`); and the root — the group's own where
|
||||
/// every object is set, else the root over the leaves of the objects set, each of whose
|
||||
/// receipts the verifier holds (`cache_transmission_objects_not_held`) — is the claim's
|
||||
/// (`cache_transmission_root_mismatch`).
|
||||
pub fn check_cache_transmission_against(&self, group: &GroupReceipt, objects: &[&ObjectReceipt]) -> Result<(), Rejection> {
|
||||
let ClaimBody::CacheTransmission(t) = &self.body else {
|
||||
return Err(Rejection::at(6, Reason::ContextMismatch));
|
||||
};
|
||||
if root != r.received_root {
|
||||
return at(Reason::ReconciliationRootMismatch);
|
||||
let at = |reason| Err(Rejection::at(6, reason));
|
||||
if group.verify().is_err() {
|
||||
return at(Reason::ContextInvalid);
|
||||
}
|
||||
if group.link_hash() != self.subject {
|
||||
return at(Reason::CacheTransmissionSubjectMismatch);
|
||||
}
|
||||
if group.signer_pubkey == self.signer_pubkey {
|
||||
return at(Reason::CacheTransmissionByPublisher);
|
||||
}
|
||||
if group.leaf_count != t.leaf_count {
|
||||
return at(Reason::CacheTransmissionLeafCountMismatch);
|
||||
}
|
||||
match root_holds(group, objects, &t.sent, t.leaf_count, &t.sent_root) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(RootFault::NotHeld) => at(Reason::CacheTransmissionObjectsNotHeld),
|
||||
Err(RootFault::Mismatch) => at(Reason::CacheTransmissionRootMismatch),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The bearer body of a transmission claim, if this is one.
|
||||
|
|
@ -330,6 +500,14 @@ impl ClaimReceipt {
|
|||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The body of a cache-transmission claim, if this is one.
|
||||
pub fn cache_transmission(&self) -> Option<&CacheTransmissionClaim> {
|
||||
match &self.body {
|
||||
ClaimBody::CacheTransmission(t) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -371,6 +549,37 @@ mod tests {
|
|||
assert_eq!(r.rules(), Err(Reason::ReconciliationNotReceived));
|
||||
}
|
||||
|
||||
fn cache(bearer: Bearer, leaf_count: u64, sent: Vec<u8>) -> CacheTransmissionClaim {
|
||||
CacheTransmissionClaim {
|
||||
bearer,
|
||||
bearer_id: "urn:example:bearer".into(),
|
||||
leaf_count,
|
||||
sent,
|
||||
sent_root: [0; 32],
|
||||
energy: None,
|
||||
attests: ATTESTS_TRANSMITTED.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cache_transmission_is_held_to_its_rules_in_order() {
|
||||
assert_eq!(cache(Bearer::Multicast, 4, vec![0x50]).rules(), Ok(()));
|
||||
assert_eq!(cache(Bearer::Multicast, 4, vec![0x50]).indices(), vec![1, 3]);
|
||||
assert_eq!(cache(Bearer::Unidirectional, 9, vec![0x00, 0x80]).rules(), Ok(()), "the last of nine");
|
||||
let bad = Err(Reason::CacheTransmissionBitmapMalformed);
|
||||
assert_eq!(cache(Bearer::Multicast, 0, vec![]).rules(), bad, "no object");
|
||||
assert_eq!(cache(Bearer::Multicast, 9, vec![0x80]).rules(), bad, "too short");
|
||||
assert_eq!(cache(Bearer::Multicast, 9, vec![0x00, 0x40]).rules(), bad, "a bit past the leaf count");
|
||||
assert_eq!(cache(Bearer::Multicast, 4, vec![0x00]).rules(), bad, "nothing sent");
|
||||
let mut c = cache(Bearer::Bidirectional, 0, vec![]);
|
||||
assert_eq!(c.rules(), Err(Reason::CacheTransmissionOnBidirectional), "the bearer before the bitmap");
|
||||
c.attests = "served".into();
|
||||
assert_eq!(c.rules(), Err(Reason::CacheTransmissionNotTransmitted), "attests first");
|
||||
let mut e = cache(Bearer::Multicast, 4, vec![0x50]);
|
||||
e.energy = Some(SessionEnergy::new(0, &crate::energy_class::EnergyProvenance::Estimator));
|
||||
assert_eq!(e.rules(), Err(Reason::EnergyZeroFigure));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearers_read_their_labels() {
|
||||
for b in Bearer::ALL {
|
||||
|
|
|
|||
|
|
@ -1408,6 +1408,20 @@ pub(super) fn vectors() -> Vec<V> {
|
|||
v
|
||||
}
|
||||
|
||||
/// The switch reason timeliness §8.6 adds to delivery-telemetry §5: the model session switching
|
||||
/// to its fallback because its primary was not held by its deadline, against every context.
|
||||
/// Appended after every other vector, so the corpus before it is an unchanged prefix.
|
||||
pub(super) fn timeliness_vectors() -> Vec<V> {
|
||||
let mut s = model();
|
||||
s.switches[0].reason = "not-held-by-deadline".into();
|
||||
vec![with_model_contexts(claim(
|
||||
"session_switch_not_held_by_deadline_accept",
|
||||
"the model session switching to the fallback because the primary was not held by its deadline, the reason timeliness section 8.6 adds to delivery-telemetry section 5",
|
||||
sealed(s).to_json(),
|
||||
Ok(()),
|
||||
))]
|
||||
}
|
||||
|
||||
/// The `record_budget` case of `staged-conformance/` (staged-delivery §14): its first base,
|
||||
/// refined at stage 1 by its first layer, then forgotten under the record budget and
|
||||
/// decoded again with no layer sent again.
|
||||
|
|
|
|||
|
|
@ -791,8 +791,8 @@ pub fn classical_rendition(w: &crate::container::WaiMulti) -> Option<usize> {
|
|||
|
||||
/// An envelope a verifier holding the `envelopes` context holds (§2.4.12).
|
||||
enum HeldEnvelope {
|
||||
One(crate::container::Wai),
|
||||
Multi(crate::container::WaiMulti),
|
||||
One(Box<crate::container::Wai>),
|
||||
Multi(Box<crate::container::WaiMulti>),
|
||||
}
|
||||
|
||||
/// `bytes` as a held envelope (§2.4.12): one that reads as a `WAI1` or `WAI2` envelope
|
||||
|
|
@ -807,14 +807,14 @@ fn read_held(bytes: &[u8]) -> Option<(HeldEnvelope, bool)> {
|
|||
Some(b"WAI2") => {
|
||||
let w = WaiMulti::from_bytes(bytes).ok()?;
|
||||
match crate::container::manifest_v2_security(&w.manifest) {
|
||||
Ok(s) => Some((HeldEnvelope::Multi(w), s.as_ref().is_some_and(|x| !x.is_plaintext()))),
|
||||
Ok(s) => Some((HeldEnvelope::Multi(Box::new(w)), s.as_ref().is_some_and(|x| !x.is_plaintext()))),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
Some(b"WAI1") => {
|
||||
let w = Wai::from_bytes(bytes).ok()?;
|
||||
let sealed = w.manifest.security.as_ref().is_some_and(|x| !x.is_plaintext());
|
||||
Some((HeldEnvelope::One(w), sealed))
|
||||
Some((HeldEnvelope::One(Box::new(w)), sealed))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
|
|
@ -1217,7 +1217,8 @@ impl ClaimReceipt {
|
|||
};
|
||||
// The names of a base: its content hashes (a WAI1 base's, a rendition's exploded
|
||||
// envelope's) and its (envelope, rendition) pairs.
|
||||
let names_of = |hash_form: Option<[u8; 32]>, pair: Option<([u8; 32], u64)>| -> (Vec<[u8; 32]>, Vec<([u8; 32], u64)>) {
|
||||
type Names = (Vec<[u8; 32]>, Vec<([u8; 32], u64)>);
|
||||
let names_of = |hash_form: Option<[u8; 32]>, pair: Option<([u8; 32], u64)>| -> Names {
|
||||
let mut hashes: Vec<[u8; 32]> = hash_form.into_iter().collect();
|
||||
let mut pairs: Vec<([u8; 32], u64)> = pair.into_iter().collect();
|
||||
for &(x, w, j) in &exploded {
|
||||
|
|
@ -1238,7 +1239,7 @@ impl ClaimReceipt {
|
|||
}
|
||||
// A layer of capability `cap` over the base `b` (where held) named by `names`, with
|
||||
// `r` its refines and `payload` its WIR1 payload: what a sink applies, or nothing.
|
||||
let layer_of = |cap: &str, r: &Refines, b: Option<Base>, names: (Vec<[u8; 32]>, Vec<([u8; 32], u64)>), payload: Option<&[u8]>| -> Gives {
|
||||
let layer_of = |cap: &str, r: &Refines, b: Option<Base>, names: Names, payload: Option<&[u8]>| -> Gives {
|
||||
let n = match &b {
|
||||
Some(b) if !b.is_base || !crate::codecs::admits(cap, &b.capability) => return Gives::NotApplicable,
|
||||
Some(b) => b.units,
|
||||
|
|
@ -1495,14 +1496,15 @@ impl ClaimReceipt {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// The authorities the energy figure of a `reconstruct-session` or a `transmission`
|
||||
/// claim needs, as an item of its own (§7.5): the class's authority and
|
||||
/// The authorities the energy figure of a `reconstruct-session`, a `transmission` or a
|
||||
/// `cache-transmission` claim needs, as an item of its own (§7.5): the class's authority and
|
||||
/// `energy/<class>`. `None` for a claim of another class, or one whose `energy` is
|
||||
/// `null`.
|
||||
pub fn figure_authorities(&self) -> Option<Vec<String>> {
|
||||
let e = match &self.body {
|
||||
ClaimBody::ReconstructSession(s) => s.energy.as_ref()?,
|
||||
ClaimBody::Transmission(t) => t.energy.as_ref()?,
|
||||
ClaimBody::CacheTransmission(t) => t.energy.as_ref()?,
|
||||
_ => return None,
|
||||
};
|
||||
Some(vec![self.class().label().to_owned(), format!("energy/{}", e.energy_provenance)])
|
||||
|
|
|
|||
502
wai/wai-rs/src/claim_receipt/timeliness.rs
Normal file
502
wai/wai-rs/src/claim_receipt/timeliness.rs
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
//! `timeliness` (jwp-receipts §2.4.17; `extensions/timeliness.md` §4, §5): a publisher's
|
||||
//! statement of when each object of a group is of use — the instant it is useful from, the
|
||||
//! deadline by which a receiver must hold it for its full value, how that value falls after the
|
||||
//! deadline, and the rendition a receiver presents in its place.
|
||||
//!
|
||||
//! This file holds steps 2 and 4 of jwp-receipts §3.1 for the class — its body's members and
|
||||
//! types, and timeliness §4.2 — an entry's value at an instant (§5.1), and step 6, its checks
|
||||
//! against the group receipts, object receipts and envelopes a verifier holds (§4.3). What a
|
||||
//! verifier judges and an asker plans from it is `crate::timeliness`'s.
|
||||
|
||||
use super::schedule::LAST_INSTANT_MS;
|
||||
use super::*;
|
||||
use crate::container::{DerivedFrom, Wai};
|
||||
use crate::jwp_delivery::{GroupReceipt, ObjectReceipt};
|
||||
|
||||
/// What a timeliness claim attests, in-band (timeliness §4.2 rule 1).
|
||||
pub const ATTESTS_VALUED: &str = "valued";
|
||||
/// An object's full value, in parts per million (timeliness §2).
|
||||
pub const FULL_PPM: u64 = 1_000_000;
|
||||
/// The most points a decay has (timeliness §4.2 rule 3.2).
|
||||
pub const MAX_POINTS: usize = 16;
|
||||
|
||||
/// A timeliness body's members (timeliness §4.1), in the order step 2 reads them.
|
||||
const TIMELINESS_MEMBERS: &[&str] = &["objects", "attests"];
|
||||
/// An entry's members, in the order step 2 reads them.
|
||||
const ENTRY_MEMBERS: &[&str] = &["leaf_index", "content_hash", "from_ms", "by_ms", "decay", "fallback"];
|
||||
const POINT_MEMBERS: &[&str] = &["at_ms", "value_ppm"];
|
||||
const FALLBACK_MEMBERS: &[&str] = &["group", "leaf_index", "content_hash", "value_ppm"];
|
||||
|
||||
/// One point of a decay: the object's value at an instant after its deadline.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct DecayPoint {
|
||||
pub at_ms: u64,
|
||||
pub value_ppm: u64,
|
||||
}
|
||||
|
||||
/// The object a receiver presents in the primary's place (timeliness §4.1 `fallback`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Fallback {
|
||||
/// The link hash of its group receipt.
|
||||
pub group: [u8; 32],
|
||||
pub leaf_index: u64,
|
||||
pub content_hash: [u8; 32],
|
||||
/// The value of presenting it in the primary's place.
|
||||
pub value_ppm: u64,
|
||||
}
|
||||
|
||||
/// One object's statement (timeliness §4.1).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TimelinessEntry {
|
||||
pub leaf_index: u64,
|
||||
pub content_hash: [u8; 32],
|
||||
/// The instant from which the object is of use.
|
||||
pub from_ms: u64,
|
||||
/// The last instant at which a receiver that first holds the object has it at full value.
|
||||
pub by_ms: u64,
|
||||
pub decay: Vec<DecayPoint>,
|
||||
pub fallback: Option<Fallback>,
|
||||
}
|
||||
|
||||
impl TimelinessEntry {
|
||||
/// Timeliness §5.2: the `at_ms` of the last point, from which an object first held is of no
|
||||
/// value. For an entry with no point, its deadline's next instant.
|
||||
pub fn end_ms(&self) -> u64 {
|
||||
self.decay.last().map_or(self.by_ms.saturating_add(1), |p| p.at_ms)
|
||||
}
|
||||
|
||||
/// Timeliness §5.1: the value at instant `h`, in parts per million, the line between the
|
||||
/// two points about `h` floored, with (`by_ms`, 1,000,000) before the first point. The
|
||||
/// products are taken in 128 bits: they reach 2^68.
|
||||
pub fn value_at(&self, h: u64) -> u64 {
|
||||
if h <= self.by_ms {
|
||||
return FULL_PPM;
|
||||
}
|
||||
let (mut ta, mut va) = (self.by_ms, FULL_PPM);
|
||||
for p in &self.decay {
|
||||
if h <= p.at_ms {
|
||||
// ta < h ≤ tb, so tb − ta is at least 1.
|
||||
let (tb, vb) = (p.at_ms, p.value_ppm);
|
||||
let num = u128::from(va) * u128::from(tb - h) + u128::from(vb) * u128::from(h - ta);
|
||||
return (num / u128::from(tb - ta)) as u64;
|
||||
}
|
||||
(ta, va) = (p.at_ms, p.value_ppm);
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// Timeliness §4.2 rule 3 for this entry, in its order, `subject` being the claim's.
|
||||
pub fn check(&self, subject: &[u8; 32]) -> Result<(), Reason> {
|
||||
if self.from_ms > self.by_ms || self.by_ms >= LAST_INSTANT_MS {
|
||||
return Err(Reason::TimelinessWindowInvalid);
|
||||
}
|
||||
if !self.decay_formed() {
|
||||
return Err(Reason::TimelinessDecayInvalid);
|
||||
}
|
||||
if let Some(f) = &self.fallback
|
||||
&& (!(1..=FULL_PPM).contains(&f.value_ppm)
|
||||
|| f.content_hash == self.content_hash
|
||||
|| (f.group == *subject && f.leaf_index == self.leaf_index))
|
||||
{
|
||||
return Err(Reason::TimelinessFallbackInvalid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rule 3.2: 1 to 16 points, their instants strictly increasing from after `by_ms` to at
|
||||
/// most the last instant, their values below full, none above the one before it, the last 0
|
||||
/// and every other at least 1, and no point but the last on the line through its
|
||||
/// neighbours, with (`by_ms`, 1,000,000) before the first.
|
||||
fn decay_formed(&self) -> bool {
|
||||
let d = &self.decay;
|
||||
let (Some(first), Some(last)) = (d.first(), d.last()) else { return false };
|
||||
if d.len() > MAX_POINTS
|
||||
|| first.at_ms <= self.by_ms
|
||||
|| last.at_ms > LAST_INSTANT_MS
|
||||
|| d.windows(2).any(|w| w[0].at_ms >= w[1].at_ms)
|
||||
|| d.iter().any(|p| p.value_ppm >= FULL_PPM)
|
||||
|| d.windows(2).any(|w| w[1].value_ppm > w[0].value_ppm)
|
||||
|| last.value_ppm != 0
|
||||
|| d[..d.len() - 1].iter().any(|p| p.value_ppm == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let points: Vec<(u64, u64)> =
|
||||
std::iter::once((self.by_ms, FULL_PPM)).chain(d.iter().map(|p| (p.at_ms, p.value_ppm))).collect();
|
||||
// Times strictly increase and values do not, so every difference is non-negative.
|
||||
points.windows(3).all(|w| {
|
||||
let ((t0, v0), (t1, v1), (t2, v2)) = (w[0], w[1], w[2]);
|
||||
u128::from(v0 - v1) * u128::from(t2 - t1) != u128::from(v1 - v2) * u128::from(t1 - t0)
|
||||
})
|
||||
}
|
||||
|
||||
fn to_value(&self) -> Value {
|
||||
let mut m = Map::new();
|
||||
m.insert("leaf_index".into(), self.leaf_index.into());
|
||||
m.insert("content_hash".into(), hex(&self.content_hash).into());
|
||||
m.insert("from_ms".into(), self.from_ms.into());
|
||||
m.insert("by_ms".into(), self.by_ms.into());
|
||||
let points = self.decay.iter().map(|p| {
|
||||
let mut q = Map::new();
|
||||
q.insert("at_ms".into(), p.at_ms.into());
|
||||
q.insert("value_ppm".into(), p.value_ppm.into());
|
||||
Value::Object(q)
|
||||
});
|
||||
m.insert("decay".into(), Value::Array(points.collect()));
|
||||
let fallback = match &self.fallback {
|
||||
None => Value::Null,
|
||||
Some(f) => {
|
||||
let mut q = Map::new();
|
||||
q.insert("group".into(), hex(&f.group).into());
|
||||
q.insert("leaf_index".into(), f.leaf_index.into());
|
||||
q.insert("content_hash".into(), hex(&f.content_hash).into());
|
||||
q.insert("value_ppm".into(), f.value_ppm.into());
|
||||
Value::Object(q)
|
||||
}
|
||||
};
|
||||
m.insert("fallback".into(), fallback);
|
||||
Value::Object(m)
|
||||
}
|
||||
|
||||
/// The entry in its JSON form (timeliness §4.1).
|
||||
pub fn to_json(&self) -> Value {
|
||||
self.to_value()
|
||||
}
|
||||
|
||||
/// One entry as a reader holds it apart from its claim: step 2's reading of it, then §4.2
|
||||
/// rule 3 with `subject` the claim's.
|
||||
pub fn from_value(v: &Value, subject: &[u8; 32]) -> Result<TimelinessEntry, Reason> {
|
||||
let e = TimelinessEntry::read(v)?;
|
||||
e.check_values()?;
|
||||
e.check(subject)?;
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
/// Step 2: the members, then each one's type in the table's order, each point's and the
|
||||
/// fallback's members and types where the table reaches them.
|
||||
fn read(v: &Value) -> Result<TimelinessEntry, Reason> {
|
||||
let m = v.as_object().ok_or(Reason::FieldType)?;
|
||||
members(m, ENTRY_MEMBERS)?;
|
||||
let leaf_index = get_int(m, "leaf_index")?;
|
||||
let content_hash = get_hash(m, "content_hash")?;
|
||||
let from_ms = get_int(m, "from_ms")?;
|
||||
let by_ms = get_int(m, "by_ms")?;
|
||||
let decay = m["decay"]
|
||||
.as_array()
|
||||
.ok_or(Reason::FieldType)?
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let p = p.as_object().ok_or(Reason::FieldType)?;
|
||||
members(p, POINT_MEMBERS)?;
|
||||
Ok(DecayPoint { at_ms: get_int(p, "at_ms")?, value_ppm: get_int(p, "value_ppm")? })
|
||||
})
|
||||
.collect::<Result<_, Reason>>()?;
|
||||
let fallback = match &m["fallback"] {
|
||||
Value::Null => None,
|
||||
Value::Object(f) => {
|
||||
members(f, FALLBACK_MEMBERS)?;
|
||||
Some(Fallback {
|
||||
group: get_hash(f, "group")?,
|
||||
leaf_index: get_int(f, "leaf_index")?,
|
||||
content_hash: get_hash(f, "content_hash")?,
|
||||
value_ppm: get_int(f, "value_ppm")?,
|
||||
})
|
||||
}
|
||||
_ => return Err(Reason::FieldType),
|
||||
};
|
||||
Ok(TimelinessEntry { leaf_index, content_hash, from_ms, by_ms, decay, fallback })
|
||||
}
|
||||
|
||||
fn check_values(&self) -> Result<(), Reason> {
|
||||
for n in [self.leaf_index, self.from_ms, self.by_ms] {
|
||||
int_ok(n)?;
|
||||
}
|
||||
for p in &self.decay {
|
||||
int_ok(p.at_ms)?;
|
||||
int_ok(p.value_ppm)?;
|
||||
}
|
||||
if let Some(f) = &self.fallback {
|
||||
int_ok(f.leaf_index)?;
|
||||
int_ok(f.value_ppm)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `timeliness` (timeliness §4): the publisher's statement of when each object it names, of the
|
||||
/// group its subject names, is of use.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TimelinessClaim {
|
||||
/// One entry for each object stated, in increasing `leaf_index`.
|
||||
pub objects: Vec<TimelinessEntry>,
|
||||
/// MUST be `"valued"`.
|
||||
pub attests: String,
|
||||
}
|
||||
|
||||
impl TimelinessClaim {
|
||||
pub(super) fn to_value(&self) -> Value {
|
||||
let mut m = Map::new();
|
||||
m.insert("objects".into(), Value::Array(self.objects.iter().map(TimelinessEntry::to_value).collect()));
|
||||
m.insert("attests".into(), self.attests.clone().into());
|
||||
Value::Object(m)
|
||||
}
|
||||
|
||||
/// Step 2: the members — missing, then unknown — then each member's type in the table's
|
||||
/// order, each entry's in its own.
|
||||
pub(super) fn read(m: &Map<String, Value>) -> Result<TimelinessClaim, Reason> {
|
||||
members(m, TIMELINESS_MEMBERS)?;
|
||||
let objects =
|
||||
m["objects"].as_array().ok_or(Reason::FieldType)?.iter().map(TimelinessEntry::read).collect::<Result<_, _>>()?;
|
||||
Ok(TimelinessClaim { objects, attests: get_str(m, "attests")? })
|
||||
}
|
||||
|
||||
/// Step 2's checks for a body built field by field.
|
||||
pub(super) fn check_values(&self) -> Result<(), Reason> {
|
||||
self.objects.iter().try_for_each(TimelinessEntry::check_values)
|
||||
}
|
||||
|
||||
/// Step 4: timeliness §4.2, in its order, with the claim's `subject` for rule 3.3.
|
||||
pub(super) fn rules(&self, subject: &[u8; 32]) -> Result<(), Reason> {
|
||||
if self.attests != ATTESTS_VALUED {
|
||||
return Err(Reason::TimelinessNotValued);
|
||||
}
|
||||
if self.objects.is_empty() || self.objects.windows(2).any(|w| w[0].leaf_index >= w[1].leaf_index) {
|
||||
return Err(Reason::TimelinessObjectsMalformed);
|
||||
}
|
||||
self.objects.iter().try_for_each(|e| e.check(subject))
|
||||
}
|
||||
|
||||
/// The entry for object `i`, if the claim has one.
|
||||
pub fn entry(&self, i: u64) -> Option<&TimelinessEntry> {
|
||||
self.objects.iter().find(|e| e.leaf_index == i)
|
||||
}
|
||||
}
|
||||
|
||||
/// A group receipt a verifier holds, with the object receipts it holds under it and the
|
||||
/// envelope bytes of those whose bytes it holds (timeliness §4.3).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HeldGroup<'a> {
|
||||
pub group: &'a GroupReceipt,
|
||||
pub objects: Vec<(&'a ObjectReceipt, Option<&'a [u8]>)>,
|
||||
}
|
||||
|
||||
impl ClaimReceipt {
|
||||
/// Step 6 (timeliness §4.3): a `timeliness` claim against the group receipt its subject
|
||||
/// names and what else the verifier holds: the groups in `held` that pass jwp-receipts §3
|
||||
/// steps 1 and 2, each with the object receipts held under it that pass §3 step 5, and every
|
||||
/// envelope held, known by its content hash. In order: the claim is a statement of
|
||||
/// timeliness (`context_mismatch`); the group passes §3 steps 1 and 2 and its `group_start`
|
||||
/// reads as an RFC 3339 date-time (`context_invalid`); its link hash is the subject
|
||||
/// (`timeliness_subject_mismatch`); its key signed the claim (`timeliness_signer_mismatch`);
|
||||
/// every entry's object is in it (`timeliness_leaf_out_of_range`); none is of use before
|
||||
/// it begins (`timeliness_before_group`); each entry's object receipt, where held, has the
|
||||
/// entry's content hash (`timeliness_content_mismatch`); no envelope held of an entry or a
|
||||
/// fallback is a layer (`timeliness_on_layer`); each fallback is where it says, where its
|
||||
/// group and object receipts are held (`timeliness_fallback_mismatch`); and each fallback's
|
||||
/// envelope held derives from its primary (`timeliness_fallback_not_derived`).
|
||||
pub fn check_timeliness_against(&self, group: &GroupReceipt, held: &[HeldGroup<'_>]) -> Result<(), Rejection> {
|
||||
let ClaimBody::Timeliness(t) = &self.body else {
|
||||
return Err(Rejection::at(6, Reason::ContextMismatch));
|
||||
};
|
||||
let at = |reason| Err(Rejection::at(6, reason));
|
||||
let start = match group.verify() {
|
||||
Ok(()) => rfc3339_seconds(&group.group_start),
|
||||
Err(_) => None,
|
||||
};
|
||||
let Some(start) = start else { return at(Reason::ContextInvalid) };
|
||||
if group.link_hash() != self.subject {
|
||||
return at(Reason::TimelinessSubjectMismatch);
|
||||
}
|
||||
if group.signer_pubkey != self.signer_pubkey {
|
||||
return at(Reason::TimelinessSignerMismatch);
|
||||
}
|
||||
if t.objects.iter().any(|e| e.leaf_index >= group.leaf_count) {
|
||||
return at(Reason::TimelinessLeafOutOfRange);
|
||||
}
|
||||
if t.objects.iter().any(|e| ((e.from_ms / 1000) as i64) < start) {
|
||||
return at(Reason::TimelinessBeforeGroup);
|
||||
}
|
||||
// The object receipt held at position `i` of the group `g` whose link hash is `link`,
|
||||
// each passing §3 step 5 against it.
|
||||
let receipts = |link: &[u8; 32], g: &GroupReceipt, i: u64| -> Vec<[u8; 32]> {
|
||||
held.iter()
|
||||
.filter(|h| h.group.link_hash() == *link)
|
||||
.flat_map(|h| h.objects.iter().map(|(o, _)| *o))
|
||||
.filter(|o| o.object_id == i && o.verify_inclusion(g).is_ok())
|
||||
.map(|o| o.content_hash)
|
||||
.collect()
|
||||
};
|
||||
let link = group.link_hash();
|
||||
if t.objects.iter().any(|e| receipts(&link, group, e.leaf_index).iter().any(|h| *h != e.content_hash)) {
|
||||
return at(Reason::TimelinessContentMismatch);
|
||||
}
|
||||
// Every envelope held, by its content hash.
|
||||
let envelopes: Vec<([u8; 32], &[u8])> = held
|
||||
.iter()
|
||||
.flat_map(|h| h.objects.iter().filter_map(|(_, b)| *b))
|
||||
.map(|b| (crate::jwp_hash::content_hash(b), b))
|
||||
.collect();
|
||||
let bytes_of = |hash: &[u8; 32]| -> Vec<&[u8]> { envelopes.iter().filter(|(h, _)| h == hash).map(|(_, b)| *b).collect() };
|
||||
let is_layer = |b: &&[u8]| Wai::from_bytes(b).is_ok_and(|w| crate::codecs::is_refinement_only(w.capability()));
|
||||
for e in &t.objects {
|
||||
if bytes_of(&e.content_hash).iter().any(is_layer)
|
||||
|| e.fallback.as_ref().is_some_and(|f| bytes_of(&f.content_hash).iter().any(is_layer))
|
||||
{
|
||||
return at(Reason::TimelinessOnLayer);
|
||||
}
|
||||
}
|
||||
for f in t.objects.iter().filter_map(|e| e.fallback.as_ref()) {
|
||||
let Some(g) = held.iter().map(|h| h.group).find(|g| g.link_hash() == f.group && g.verify().is_ok()) else { continue };
|
||||
if f.leaf_index >= g.leaf_count || receipts(&f.group, g, f.leaf_index).iter().any(|h| *h != f.content_hash) {
|
||||
return at(Reason::TimelinessFallbackMismatch);
|
||||
}
|
||||
}
|
||||
for e in &t.objects {
|
||||
let Some(f) = &e.fallback else { continue };
|
||||
let motif = hex(&e.content_hash);
|
||||
let derived = |b: &&[u8]| {
|
||||
Wai::from_bytes(b).ok().and_then(|w| w.manifest.derived_from.as_ref().and_then(|v| DerivedFrom::from_value(v).ok())).is_some_and(|d| d.motif == motif)
|
||||
};
|
||||
if bytes_of(&f.content_hash).iter().any(|b| !derived(b)) {
|
||||
return at(Reason::TimelinessFallbackNotDerived);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The body of a timeliness claim, if this is one.
|
||||
pub fn timeliness(&self) -> Option<&TimelinessClaim> {
|
||||
match &self.body {
|
||||
ClaimBody::Timeliness(t) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const D: u64 = 1_793_664_060_000;
|
||||
|
||||
fn entry(by_ms: u64, decay: &[(u64, u64)]) -> TimelinessEntry {
|
||||
TimelinessEntry {
|
||||
leaf_index: 0,
|
||||
content_hash: [1; 32],
|
||||
from_ms: by_ms.min(1_000),
|
||||
by_ms,
|
||||
decay: decay.iter().map(|&(at_ms, value_ppm)| DecayPoint { at_ms, value_ppm }).collect(),
|
||||
fallback: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// §5.1 with exact rationals: ⌊(va·(tb − h) + vb·(h − ta)) / (tb − ta)⌋ by repeated
|
||||
/// subtraction of whole parts, not by the 128-bit product the reference takes.
|
||||
fn reference(e: &TimelinessEntry, h: u64) -> u64 {
|
||||
if h <= e.by_ms {
|
||||
return FULL_PPM;
|
||||
}
|
||||
let mut points = vec![(e.by_ms, FULL_PPM)];
|
||||
points.extend(e.decay.iter().map(|p| (p.at_ms, p.value_ppm)));
|
||||
for w in points.windows(2) {
|
||||
let ((ta, va), (tb, vb)) = (w[0], w[1]);
|
||||
if ta < h && h <= tb {
|
||||
// va − (va − vb)·(h − ta)/(tb − ta), the fraction's ceiling taken off.
|
||||
let (fall, span, gone) = (va - vb, tb - ta, h - ta);
|
||||
let whole = fall / span * gone + (fall % span) * (gone / span);
|
||||
let rest = (fall % span) as u128 * (gone % span) as u128;
|
||||
let lost = u128::from(whole) + rest.div_ceil(u128::from(span));
|
||||
return (u128::from(va) - lost) as u64;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_value_is_the_stated_line_floored() {
|
||||
let e = entry(D, &[(D + 2_000, 600_000), (D + 10_000, 0)]);
|
||||
for (h, v) in [(D, 1_000_000), (D + 1, 999_800), (D + 1_000, 800_000), (D + 2_000, 600_000), (D + 6_000, 300_000), (D + 9_999, 75), (D + 10_000, 0), (D + 10_001, 0)] {
|
||||
assert_eq!(e.value_at(h), v, "at D + {}", h - D);
|
||||
}
|
||||
assert_eq!(e.end_ms(), D + 10_000);
|
||||
let hard = entry(D, &[(D + 1, 0)]);
|
||||
assert_eq!((hard.value_at(D), hard.value_at(D + 1), hard.end_ms()), (1_000_000, 0, D + 1));
|
||||
let wide = entry(1, &[(LAST_INSTANT_MS, 0)]);
|
||||
assert_eq!(wide.value_at(LAST_INSTANT_MS / 2), 500_000, "products past 2^64");
|
||||
assert_eq!(wide.value_at(2), 999_999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_value_agrees_with_an_exact_reference_and_never_increases() {
|
||||
let entries = [
|
||||
entry(D, &[(D + 2_000, 600_000), (D + 10_000, 0)]),
|
||||
entry(D, &[(D + 1, 500_000), (D + 3_001, 0)]),
|
||||
entry(D, &[(D + 3, 2), (D + 13, 0)]),
|
||||
entry(D, &[(D + 1_000, 700_000), (D + 5_000, 700_000), (D + 6_000, 0)]),
|
||||
entry(1, &[(LAST_INSTANT_MS, 0)]),
|
||||
entry(1, &[(7, 999_999), (LAST_INSTANT_MS - 1, 1), (LAST_INSTANT_MS, 0)]),
|
||||
];
|
||||
// A fixed linear congruential sequence of instants: no randomness in a test.
|
||||
let mut x: u64 = 0x2545_f491_4f6c_dd1d;
|
||||
for e in &entries {
|
||||
let mut previous = FULL_PPM;
|
||||
let span = e.end_ms() + 2 - e.by_ms;
|
||||
let mut hs: Vec<u64> = (0..2_000)
|
||||
.map(|_| {
|
||||
x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
||||
e.by_ms - 1 + (x >> 11) % span
|
||||
})
|
||||
.collect();
|
||||
hs.extend(e.decay.iter().flat_map(|p| [p.at_ms - 1, p.at_ms, p.at_ms + 1]));
|
||||
hs.sort_unstable();
|
||||
for h in hs {
|
||||
let v = e.value_at(h);
|
||||
assert_eq!(v, reference(e, h), "at {h}");
|
||||
assert!(v <= previous, "rises at {h}");
|
||||
previous = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collinearity_is_exact() {
|
||||
let subject = [9; 32];
|
||||
let ok = |d: &[(u64, u64)]| entry(D, d).check(&subject);
|
||||
assert_eq!(ok(&[(D + 1_000, 500_000), (D + 2_000, 0)]), Err(Reason::TimelinessDecayInvalid), "on one line");
|
||||
assert_eq!(ok(&[(D + 1_000, 500_001), (D + 2_000, 0)]), Ok(()), "a part per million off it");
|
||||
assert_eq!(ok(&[(D + 1_000, 499_999), (D + 2_000, 0)]), Ok(()));
|
||||
assert_eq!(ok(&[(D + 1_000, 700_000), (D + 5_000, 700_000), (D + 6_000, 0)]), Ok(()), "a plateau");
|
||||
assert_eq!(ok(&[(D + 1_000, 700_000), (D + 3_000, 700_000), (D + 5_000, 700_000), (D + 6_000, 0)]), Err(Reason::TimelinessDecayInvalid));
|
||||
// Products past 2^64: half the value over 10^14 ms twice is one line, and a part per
|
||||
// million off it is not; a fall of 999,999 over nearly the widest span is no line.
|
||||
let s = 100_000_000_000_000;
|
||||
assert_eq!(entry(1, &[(1 + s, 500_000), (1 + 2 * s, 0)]).check(&subject), Err(Reason::TimelinessDecayInvalid));
|
||||
assert_eq!(entry(1, &[(1 + s, 500_001), (1 + 2 * s, 0)]).check(&subject), Ok(()));
|
||||
assert_eq!(entry(1, &[(LAST_INSTANT_MS - 1, 999_999), (LAST_INSTANT_MS, 0)]).check(&subject), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_rule_refuses() {
|
||||
let subject = [9; 32];
|
||||
let base = entry(D, &[(D + 1, 0)]);
|
||||
let with = |f: &dyn Fn(&mut TimelinessEntry)| {
|
||||
let mut e = base.clone();
|
||||
f(&mut e);
|
||||
e.check(&subject)
|
||||
};
|
||||
assert_eq!(with(&|_| ()), Ok(()));
|
||||
assert_eq!(with(&|e| e.from_ms = D + 1), Err(Reason::TimelinessWindowInvalid));
|
||||
assert_eq!(with(&|e| e.by_ms = LAST_INSTANT_MS), Err(Reason::TimelinessWindowInvalid));
|
||||
assert_eq!(with(&|e| e.decay.clear()), Err(Reason::TimelinessDecayInvalid));
|
||||
assert_eq!(with(&|e| e.decay[0].at_ms = D), Err(Reason::TimelinessDecayInvalid));
|
||||
assert_eq!(with(&|e| e.decay[0].value_ppm = 1), Err(Reason::TimelinessDecayInvalid));
|
||||
assert_eq!(with(&|e| e.decay[0].at_ms = LAST_INSTANT_MS + 1), Err(Reason::TimelinessDecayInvalid));
|
||||
let fallback = Fallback { group: [7; 32], leaf_index: 0, content_hash: [2; 32], value_ppm: 1 };
|
||||
assert_eq!(with(&|e| e.fallback = Some(fallback)), Ok(()));
|
||||
assert_eq!(with(&|e| e.fallback = Some(Fallback { value_ppm: 0, ..fallback })), Err(Reason::TimelinessFallbackInvalid));
|
||||
assert_eq!(with(&|e| e.fallback = Some(Fallback { value_ppm: FULL_PPM + 1, ..fallback })), Err(Reason::TimelinessFallbackInvalid));
|
||||
assert_eq!(with(&|e| e.fallback = Some(Fallback { content_hash: [1; 32], ..fallback })), Err(Reason::TimelinessFallbackInvalid));
|
||||
assert_eq!(with(&|e| e.fallback = Some(Fallback { group: subject, ..fallback })), Err(Reason::TimelinessFallbackInvalid));
|
||||
assert_eq!(with(&|e| e.fallback = Some(Fallback { group: subject, leaf_index: 1, ..fallback })), Ok(()));
|
||||
}
|
||||
}
|
||||
|
|
@ -250,6 +250,10 @@ pub const CAP_MEDIA_DIRECTION: &str = "wai.media.direction";
|
|||
pub const CAP_BEARER_RECEIPTS: &str = "wai.bearer.receipts";
|
||||
pub const CAP_BEARER_CAROUSEL: &str = "wai.bearer.carousel";
|
||||
pub const CAP_DELIVERY_HOLDINGS: &str = "wai.delivery.holdings";
|
||||
pub const CAP_DELIVERY_TIMELINESS: &str = "wai.delivery.timeliness";
|
||||
pub const CAP_DELIVERY_RUNG_PRICE: &str = "wai.delivery.rung_price";
|
||||
pub const CAP_DELIVERY_CACHE: &str = "wai.delivery.cache";
|
||||
pub const CAP_DELIVERY_IMPRESSIONS: &str = "wai.delivery.impressions";
|
||||
pub const CAP_ACCESS_SERVICES: &str = "wai.access.services";
|
||||
pub const CAP_AGE_ASSURANCE: &str = "wai.age.assurance";
|
||||
pub const CAP_MEDIA_EXPLORATION: &str = "wai.media.exploration";
|
||||
|
|
@ -382,6 +386,10 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[
|
|||
(CAP_BEARER_RECEIPTS, S_INTERACTIVE, None, "2026-10-21"),
|
||||
(CAP_BEARER_CAROUSEL, S_INTERACTIVE, None, "2026-11-02"),
|
||||
(CAP_DELIVERY_HOLDINGS, S_INTERACTIVE, None, "2026-11-03"),
|
||||
(CAP_DELIVERY_TIMELINESS, S_INTERACTIVE, None, "2026-11-03"),
|
||||
(CAP_DELIVERY_RUNG_PRICE, S_INTERACTIVE, None, "2026-11-03"),
|
||||
(CAP_DELIVERY_CACHE, S_INTERACTIVE, None, "2026-11-03"),
|
||||
(CAP_DELIVERY_IMPRESSIONS, S_INTERACTIVE, None, "2026-11-03"),
|
||||
(CAP_ACCESS_SERVICES, S_INTERACTIVE, None, "2026-10-21"),
|
||||
(CAP_AGE_ASSURANCE, S_INTERACTIVE, None, "2026-10-21"),
|
||||
(CAP_MEDIA_EXPLORATION, S_INTERACTIVE, None, "2026-10-21"),
|
||||
|
|
@ -481,6 +489,10 @@ const RECORDS: &[&str] = &[
|
|||
CAP_BEARER_RECEIPTS,
|
||||
CAP_BEARER_CAROUSEL,
|
||||
CAP_DELIVERY_HOLDINGS,
|
||||
CAP_DELIVERY_TIMELINESS,
|
||||
CAP_DELIVERY_RUNG_PRICE,
|
||||
CAP_DELIVERY_CACHE,
|
||||
CAP_DELIVERY_IMPRESSIONS,
|
||||
CAP_ACCESS_SERVICES,
|
||||
CAP_AGE_ASSURANCE,
|
||||
CAP_MEDIA_EXPLORATION,
|
||||
|
|
@ -567,16 +579,20 @@ pub enum WireKind {
|
|||
GltfExtension,
|
||||
/// A custom key of a CMCD (CTA-5004-B) or CMSD (CTA-5006) report.
|
||||
TelemetryKey,
|
||||
/// A report extension type of the Distributed Aggregation Protocol
|
||||
/// (`draft-ietf-ppm-dap-20` §4.4.2, its registry §9.2.4).
|
||||
DapReportExtension,
|
||||
}
|
||||
|
||||
impl WireKind {
|
||||
/// Every kind, in declaration order.
|
||||
pub const fn all() -> [Self; 17] {
|
||||
pub const fn all() -> [Self; 18] {
|
||||
[
|
||||
Self::MediaType, Self::CodecsPrefix, Self::FourCc, Self::Magic, Self::DataId,
|
||||
Self::Scheme, Self::Path, Self::WellKnown, Self::PlaylistTag,
|
||||
Self::PlaylistAttribute, Self::DocumentKind, Self::Catalog, Self::SetupOption,
|
||||
Self::HttpHeader, Self::FrameMetadata, Self::GltfExtension, Self::TelemetryKey,
|
||||
Self::DapReportExtension,
|
||||
]
|
||||
}
|
||||
/// The label the export uses.
|
||||
|
|
@ -599,6 +615,7 @@ impl WireKind {
|
|||
Self::FrameMetadata => "frame-metadata",
|
||||
Self::GltfExtension => "gltf-extension",
|
||||
Self::TelemetryKey => "telemetry-key",
|
||||
Self::DapReportExtension => "dap-report-extension",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -687,6 +704,7 @@ const AGE_ASSURANCE: &str = "extensions/age-assurance.md";
|
|||
const EXPLORATION: &str = "extensions/exploration.md";
|
||||
const EXACT_UPSCALE: &str = "extensions/exact-upscale.md";
|
||||
const TELEMETRY_AGGREGATION: &str = "extensions/telemetry-aggregation.md";
|
||||
const IMPRESSIONS: &str = "extensions/impression-aggregates.md";
|
||||
const EXACT_TONEMAP: &str = "extensions/exact-tonemap.md";
|
||||
const ROBUSTNESS: &str = "extensions/robustness-evidence.md";
|
||||
const SPLAT_EXACT: &str = "extensions/splat-exact.md";
|
||||
|
|
@ -699,6 +717,7 @@ const LOC_CARRIAGE: &str = "extensions/loc-carriage.md";
|
|||
const STREAM_TERMINATION: &str = "extensions/stream-termination.md";
|
||||
const KEY_RELEASE: &str = "extensions/key-release.md";
|
||||
const CONFORMANCE_RESULTS: &str = "extensions/conformance-results.md";
|
||||
const RUNG_PRICE: &str = "extensions/rung-price.md";
|
||||
const ROUTE_RECORD: &str = "extensions/route-record.md";
|
||||
|
||||
const fn wire(name: &'static str, kind: WireKind, document: &'static str, section: &'static str,
|
||||
|
|
@ -860,6 +879,8 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
|
|||
wire("WFR1", WireKind::Magic, FLEET, "§6.1", WireStatus::Defined),
|
||||
wire("wai-fleet-bundle", WireKind::DocumentKind, FLEET, "§7.1", WireStatus::Defined),
|
||||
wire(".wai-fleet.json", WireKind::Path, FLEET, "§7.1", WireStatus::Defined),
|
||||
wire("wai-rung-prices", WireKind::DocumentKind, RUNG_PRICE, "§4.1", WireStatus::Defined),
|
||||
wire("wai-rung-request", WireKind::DocumentKind, RUNG_PRICE, "§6.1", WireStatus::Defined),
|
||||
wire("wai-route-request", WireKind::DocumentKind, ROUTE_RECORD, "§3.1", WireStatus::Defined),
|
||||
wire("wai-route-pricebook", WireKind::DocumentKind, ROUTE_RECORD, "§3.2", WireStatus::Defined),
|
||||
wire("wai-route-policy", WireKind::DocumentKind, ROUTE_RECORD, "§3.3", WireStatus::Defined),
|
||||
|
|
@ -918,6 +939,10 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
|
|||
wire("wai-pick", WireKind::DocumentKind, EXPLORATION, "§10.2", WireStatus::Defined),
|
||||
wire("WQS1", WireKind::Magic, TELEMETRY_AGGREGATION, "§6.1", WireStatus::Defined),
|
||||
wire("WQA1", WireKind::Magic, TELEMETRY_AGGREGATION, "§7.1", WireStatus::Defined),
|
||||
wire("WIC1", WireKind::Magic, IMPRESSIONS, "§4.1", WireStatus::Defined),
|
||||
wire("WIT1", WireKind::Magic, IMPRESSIONS, "§5.4", WireStatus::Defined),
|
||||
wire("WIU1", WireKind::Magic, IMPRESSIONS, "§6.4", WireStatus::Defined),
|
||||
wire("wai_impression_token", WireKind::DapReportExtension, IMPRESSIONS, "§7.2", WireStatus::Proposed),
|
||||
wire("WGM1", WireKind::Magic, EXACT_TONEMAP, "§4.1", WireStatus::Defined),
|
||||
wire("WD50", WireKind::Magic, EXACT_TONEMAP, "§5.1", WireStatus::Defined),
|
||||
wire("wai-tonemap", WireKind::DocumentKind, EXACT_TONEMAP, "§7.1", WireStatus::Defined),
|
||||
|
|
|
|||
|
|
@ -25,5 +25,6 @@ pub const SWITCH_REASONS: &[&str] = &[
|
|||
"deadline-threshold",
|
||||
"unit-hash-mismatch",
|
||||
"budget-exhausted",
|
||||
"not-held-by-deadline",
|
||||
"recovered",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -255,6 +255,7 @@ pub fn claim_file(c: &ClaimReceipt) -> Vec<u8> {
|
|||
/// what was served remembered, at most a budget of fetches (never above
|
||||
/// [`FETCH_BUDGET`]) and of bytes ([`MAX_INDEX_FETCH_BYTES`]), and a file over
|
||||
/// [`MAX_RECEIPT_FILE_BYTES`] refused.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub struct ReceiptFetcher<'f> {
|
||||
fetch: &'f mut dyn FnMut(&[u8; 32], usize) -> Option<Vec<u8>>,
|
||||
memo: BTreeMap<[u8; 32], Served>,
|
||||
|
|
@ -275,6 +276,7 @@ impl<'f> ReceiptFetcher<'f> {
|
|||
/// A fetcher over `fetch` with `budget` fetches, at most [`FETCH_BUDGET`]. `fetch`
|
||||
/// gives what is served at a name, reading at most the number of bytes it is given;
|
||||
/// anything more it returns is taken as unread.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn new(fetch: &'f mut dyn FnMut(&[u8; 32], usize) -> Option<Vec<u8>>, budget: usize) -> Self {
|
||||
ReceiptFetcher { fetch, memo: BTreeMap::new(), fetched: 0, budget: budget.min(FETCH_BUDGET), bytes: 0 }
|
||||
}
|
||||
|
|
@ -725,6 +727,32 @@ fn step6(
|
|||
passed.iter().filter(|w| w.group.link_hash() == claim.subject).map(|w| (&w.object, None)).collect();
|
||||
claim.check_schedule_against(&v.group, &objects).map_err(c)
|
||||
}
|
||||
// §2.4.17 (timeliness §4.3): against that group, the object receipts of the items listed
|
||||
// under it here, and a fallback's group and object receipt where an item lists them; the
|
||||
// index carries no envelope bytes, so timeliness §4.3 steps 6 and 8 do not run here.
|
||||
ClaimBody::Timeliness(_) => {
|
||||
let v = passed.iter().find(|v| v.group.link_hash() == claim.subject).ok_or(ReceiptRefusal::ContextNotHeld)?;
|
||||
let mut held: Vec<crate::claim_receipt::HeldGroup<'_>> = Vec::new();
|
||||
for w in passed {
|
||||
match held.iter_mut().find(|h| h.group.link_hash() == w.group.link_hash()) {
|
||||
Some(h) => h.objects.push((&w.object, None)),
|
||||
None => held.push(crate::claim_receipt::HeldGroup { group: &w.group, objects: vec![(&w.object, None)] }),
|
||||
}
|
||||
}
|
||||
claim.check_timeliness_against(&v.group, &held).map_err(c)
|
||||
}
|
||||
// §2.4.18 (cache-authority §5.3): against that group and the object receipts of the items
|
||||
// listed under it here; one naming an object this index does not list is
|
||||
// `context_not_held`, as a reconciliation's is.
|
||||
ClaimBody::CacheTransmission(_) => {
|
||||
let v = passed.iter().find(|v| v.group.link_hash() == claim.subject).ok_or(ReceiptRefusal::ContextNotHeld)?;
|
||||
let objects: Vec<&ObjectReceipt> =
|
||||
passed.iter().filter(|w| w.group.link_hash() == claim.subject).map(|w| &w.object).collect();
|
||||
match claim.check_cache_transmission_against(&v.group, &objects) {
|
||||
Err(Rejection { reason: ClaimReason::CacheTransmissionObjectsNotHeld, .. }) => Err(ReceiptRefusal::ContextNotHeld),
|
||||
r => r.map_err(c),
|
||||
}
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
|
@ -849,7 +877,7 @@ impl Default for Limits {
|
|||
/// ([`check_claim`]) against the items that passed. Each name is fetched once, at most
|
||||
/// `limits.fetch_budget` files and [`MAX_INDEX_FETCH_BYTES`] bytes in all; `fetch` is
|
||||
/// given, with each name, the most bytes it may read of it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||||
pub fn check_index(
|
||||
cid: &[u8; 32],
|
||||
index: &[u8],
|
||||
|
|
|
|||
|
|
@ -357,7 +357,7 @@ fn claims_are_checked_by_subject_and_parent() {
|
|||
let index = index_file(&cid, &[item(&t.groups[0], &t.objects[0])], &names).unwrap();
|
||||
let mut fetch = |h: &[u8; 32], _: usize| files.get(h).cloned();
|
||||
let r = check_index(&cid, &index, &ObjectHeld::Nothing, &mut fetch, &mut ReceiptChains::new(), 0, &Limits::default(), None).unwrap();
|
||||
let got: Vec<Result<(Attests, Option<[u8; 32]>), &'static str>> =
|
||||
let got: Vec<_> =
|
||||
r.claims.iter().map(|c| c.as_ref().map(|v| (v.attests, v.parent)).map_err(ReceiptRefusal::code)).collect();
|
||||
// A soft binding of another object, with no recovery run, is about another object.
|
||||
assert_eq!(got, vec![
|
||||
|
|
@ -553,6 +553,53 @@ fn bearer_claims_are_checked_against_the_group_of_an_item() {
|
|||
]);
|
||||
}
|
||||
|
||||
/// §8.2, cache-authority §5.3: a cache-transmission claim about the group of an item that
|
||||
/// passed is checked against that group and the object receipts of the items listed under it
|
||||
/// here. One whose set bits name an object this index does not list is `context_not_held`, as a
|
||||
/// reconciliation's is; one the group's key signed is refused at step 6.
|
||||
#[test]
|
||||
fn cache_transmission_claims_are_checked_against_the_group_of_an_item() {
|
||||
use crate::claim_receipt::{Bearer, CacheTransmissionClaim, ReconciliationClaim};
|
||||
let envs = [envelope(72), envelope(73)];
|
||||
let mut b = GroupBuilder::default();
|
||||
for e in &envs {
|
||||
b.add(content_hash(e), 0, "origin", Some(ObjectLabel::Unmetered));
|
||||
}
|
||||
let (g, os) = b.finish(&key(0x61), SIGNER, 9, 1, "2026-10-06T00:00:00Z", "2026-10-06T00:00:04Z", None, true).unwrap();
|
||||
let mut files = BTreeMap::new();
|
||||
files.insert(g.link_hash(), group_file(&g).unwrap());
|
||||
let link = g.link_hash();
|
||||
let sent = |signer: u8, idx: &[u64], root: [u8; 32]| {
|
||||
let body = ClaimBody::CacheTransmission(CacheTransmissionClaim {
|
||||
bearer: Bearer::Multicast,
|
||||
bearer_id: "urn:example:wai-bearer:mesh".into(),
|
||||
leaf_count: 2,
|
||||
sent: ReconciliationClaim::bitmap(2, idx),
|
||||
sent_root: root,
|
||||
energy: None,
|
||||
attests: "transmitted".into(),
|
||||
});
|
||||
ClaimReceipt::seal(&key(signer), "did:example:cache", SubjectType::Receipt, link, 0, "2026-10-06T00:00:05Z", body, None).unwrap()
|
||||
};
|
||||
let claims = [
|
||||
sent(0x67, &[0], crate::jwp_delivery::root(&[os[0].leaf()])),
|
||||
sent(0x68, &[1], crate::jwp_delivery::root(&[os[1].leaf()])),
|
||||
sent(0x61, &[0, 1], g.root_hash),
|
||||
];
|
||||
for c in &claims {
|
||||
files.insert(c.link_hash(), claim_file(c));
|
||||
}
|
||||
let names: Vec<[u8; 32]> = claims.iter().map(ClaimReceipt::link_hash).collect();
|
||||
let cid = os[0].content_hash;
|
||||
let index = index_file(&cid, &[item(&g, &os[0])], &names).unwrap();
|
||||
let mut fetch = |h: &[u8; 32], _: usize| files.get(h).cloned();
|
||||
let r = check_index(&cid, &index, &whole(&envs[0]), &mut fetch, &mut ReceiptChains::new(), 0, &Limits::default(), None).unwrap();
|
||||
assert!(r.delivery[0].is_ok(), "{:?}", r.delivery[0]);
|
||||
let got: Vec<Result<(), (&str, Option<u8>)>> =
|
||||
r.claims.iter().map(|c| c.as_ref().map(|_| ()).map_err(|e| (e.code(), e.step()))).collect();
|
||||
assert_eq!(got, vec![Ok(()), Err(("context_not_held", Some(6))), Err(("cache_transmission_by_publisher", Some(6)))]);
|
||||
}
|
||||
|
||||
/// §8.2: a claim about a receipt attaches only through a delivery item that passed for
|
||||
/// this object: one about the group of an item refused, or of another object, is not
|
||||
/// about this object.
|
||||
|
|
@ -827,7 +874,7 @@ mod keys {
|
|||
let loc = key_log_url(ORIGIN);
|
||||
assert_eq!(loc, "https://origin.example/.well-known/wai-keys");
|
||||
let ok = Retrieval { requested: &loc, answered: &loc, status: 200 };
|
||||
let a = anchor_retrieved(ORIGIN, &ok, &log_bytes(&[r0.clone()]), "2026-10-03T00:00:00Z", None).unwrap();
|
||||
let a = anchor_retrieved(ORIGIN, &ok, &log_bytes(std::slice::from_ref(&r0)), "2026-10-03T00:00:00Z", None).unwrap();
|
||||
assert_eq!(a.revision, r0.revision_hash());
|
||||
let mirror = "https://mirror.example/.well-known/wai-keys";
|
||||
for (r, why) in [
|
||||
|
|
@ -835,12 +882,12 @@ mod keys {
|
|||
(Retrieval { requested: mirror, answered: mirror, status: 200 }, "another location"),
|
||||
(Retrieval { requested: &loc, answered: &loc, status: 304 }, "not a 200"),
|
||||
] {
|
||||
assert_eq!(anchor_retrieved(ORIGIN, &r, &log_bytes(&[r0.clone()]), "t", None).unwrap_err().code(), "key_log_not_from_origin", "{why}");
|
||||
assert_eq!(anchor_retrieved(ORIGIN, &r, &log_bytes(std::slice::from_ref(&r0)), "t", None).unwrap_err().code(), "key_log_not_from_origin", "{why}");
|
||||
}
|
||||
let plain = "http://origin.example";
|
||||
let pl = key_log_url(plain);
|
||||
let r = Retrieval { requested: &pl, answered: &pl, status: 200 };
|
||||
assert_eq!(anchor_retrieved(plain, &r, &log_bytes(&[r0.clone()]), "t", None).unwrap_err().code(), "key_log_not_from_origin");
|
||||
assert_eq!(anchor_retrieved(plain, &r, &log_bytes(std::slice::from_ref(&r0)), "t", None).unwrap_err().code(), "key_log_not_from_origin");
|
||||
// Through a cache: the newer copy adds r1; the anchor stays at r0.
|
||||
let mut held = vec![r0.clone()];
|
||||
assert_eq!(hold_cached_revisions(ORIGIN, &log_bytes(&[r0.clone(), r1.clone()]), &a, &mut held), Ok(1));
|
||||
|
|
@ -848,7 +895,7 @@ mod keys {
|
|||
// Anchored at r1, a cached copy ending at r0 is an older copy.
|
||||
let a1 = anchor_retrieved(ORIGIN, &ok, &log_bytes(&[r0.clone(), r1.clone()]), "2026-10-03T00:00:00Z", Some((&a, &held))).unwrap();
|
||||
assert_eq!(a1.revision, r1.revision_hash());
|
||||
assert_eq!(hold_cached_revisions(ORIGIN, &log_bytes(&[r0.clone()]), &a1, &mut held).unwrap_err().code(), "key_log_rolled_back");
|
||||
assert_eq!(hold_cached_revisions(ORIGIN, &log_bytes(std::slice::from_ref(&r0)), &a1, &mut held).unwrap_err().code(), "key_log_rolled_back");
|
||||
// A log of another origin is not this one's.
|
||||
assert_eq!(hold_cached_revisions("https://other.example", &log_bytes(&[r0]), &a1, &mut held).unwrap_err().code(), "origin_not_anchored");
|
||||
assert_eq!(status_max_age_s(&r1), 900);
|
||||
|
|
@ -861,7 +908,7 @@ mod keys {
|
|||
let r0 = revision(None, "2026-10-01T00:00:00Z", vec![]);
|
||||
let loc = key_log_url(ORIGIN);
|
||||
let ok = Retrieval { requested: &loc, answered: &loc, status: 200 };
|
||||
let mut at_cap = log_bytes(&[r0.clone()]);
|
||||
let mut at_cap = log_bytes(std::slice::from_ref(&r0));
|
||||
at_cap.resize(MAX_KEY_LOG_BYTES, b' ');
|
||||
let a = anchor_retrieved(ORIGIN, &ok, &at_cap, "t", None).expect("a log at the cap anchors");
|
||||
let mut past = at_cap.clone();
|
||||
|
|
@ -878,7 +925,7 @@ mod keys {
|
|||
let module = [7u8; 32];
|
||||
let g = ModuleGrant::seal(&key(0x63), "did:web:origin.example#g1", ORIGIN, module, ModuleForm::Decoder, &["wai.neural.int_mlicpp"],
|
||||
"2026-10-01T00:00:00Z", "2026-10-01T12:00:00Z").unwrap();
|
||||
let file = grants_file(&module, &[g.clone()]);
|
||||
let file = grants_file(&module, std::slice::from_ref(&g));
|
||||
assert_eq!(read_grants_file(&module, &file).unwrap(), vec![g.to_json()]);
|
||||
assert_eq!(read_grants_file(&[8; 32], &file).unwrap_err().code(), "grants_file_malformed");
|
||||
let text = String::from_utf8(file).unwrap();
|
||||
|
|
@ -901,7 +948,7 @@ mod keys {
|
|||
let r0 = revision(None, "2026-10-01T00:00:00Z", vec![]);
|
||||
let loc = key_log_url(ORIGIN);
|
||||
let ok = Retrieval { requested: &loc, answered: &loc, status: 200 };
|
||||
let a = anchor_retrieved(ORIGIN, &ok, &log_bytes(&[r0.clone()]), "2026-10-01T00:00:00Z", None).unwrap();
|
||||
let a = anchor_retrieved(ORIGIN, &ok, &log_bytes(std::slice::from_ref(&r0)), "2026-10-01T00:00:00Z", None).unwrap();
|
||||
let g = ModuleGrant::seal(&key(0x63), "did:web:origin.example#g1", ORIGIN, digest, ModuleForm::Decoder, &["wai.neural.int_mlicpp"],
|
||||
"2026-10-01T00:00:00Z", "2026-10-01T12:00:00Z").unwrap();
|
||||
let grants = read_grants_file(&digest, &grants_file(&digest, &[g])).unwrap();
|
||||
|
|
|
|||
1356
wai/wai-rs/src/impression_aggregate.rs
Normal file
1356
wai/wai-rs/src/impression_aggregate.rs
Normal file
File diff suppressed because it is too large
Load diff
2303
wai/wai-rs/src/impression_aggregate/corpus.rs
Normal file
2303
wai/wai-rs/src/impression_aggregate/corpus.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -85,14 +85,16 @@ pub fn status_ceiling_s() -> u64 {
|
|||
pub const HARDWARE_CLASSES: &[&str] = &["CalibratedInstrument", "OnChipCounter", "HwShunt"];
|
||||
|
||||
/// Authorities a verifier confirms only under a pinned anchor (§7.5): the hardware-class
|
||||
/// energy authorities and the lawful-basis authorities. An authority registered for
|
||||
/// pre-emption joins this set and [`PREEMPTION_AUTHORITIES`].
|
||||
/// energy authorities, the lawful-basis authorities and `aggregate/task`
|
||||
/// (impression-aggregates §5.4). An authority registered for pre-emption joins this set and
|
||||
/// [`PREEMPTION_AUTHORITIES`].
|
||||
pub const PINNED_ONLY: &[&str] = &[
|
||||
"energy/CalibratedInstrument",
|
||||
"energy/OnChipCounter",
|
||||
"energy/HwShunt",
|
||||
"lawful-basis/declaration",
|
||||
"lawful-basis/decision",
|
||||
"aggregate/task",
|
||||
];
|
||||
|
||||
/// Authorities registered for pre-emption (unidirectional-delivery §3). None is yet; one
|
||||
|
|
@ -1196,6 +1198,15 @@ impl Item {
|
|||
Some(Item { signer_pubkey: view.signer?, signer_id: signer_id.into(), authorities, time: time.map(str::to_owned), pq_key: None })
|
||||
}
|
||||
|
||||
/// A record of another WAI family whose signer must hold one named authority (§7.5),
|
||||
/// untimed: an impression task file needs `aggregate/task` (impression-aggregates §5.4).
|
||||
/// Crate-internal: the module that verified the record's signature builds it from the
|
||||
/// key and `signer_id` its signed bytes carry.
|
||||
#[cfg(feature = "impression_aggregate")]
|
||||
pub(crate) fn with_authority(signer_pubkey: [u8; 32], signer_id: &str, authority: &str) -> Item {
|
||||
Item { signer_pubkey, signer_id: signer_id.into(), authorities: vec![authority.into()], time: None, pq_key: None }
|
||||
}
|
||||
|
||||
/// A module grant whose signature verified (§7.9): `module/<form>`, at `not_before`.
|
||||
fn for_grant(g: &ModuleGrant) -> Item {
|
||||
Item {
|
||||
|
|
@ -1264,6 +1275,7 @@ pub fn subject_expectation(subject_confirmation: &Confirmation) -> Option<Expect
|
|||
|
||||
/// The result of §7.4, reported beside the item's own verdict.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum Confirmation {
|
||||
Confirmed {
|
||||
origin: String,
|
||||
|
|
@ -2083,6 +2095,7 @@ fn refusal_of(capability: &str, check: SetCheck) -> Result<(), crate::container:
|
|||
|
||||
/// §7.9 over a pinned parameter set at a sink ([`SinkKeyLog::check_set`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum SetCheck {
|
||||
/// The relying party does not hold the capability's pinned sets to §7.9.
|
||||
NotHeldTo,
|
||||
|
|
|
|||
|
|
@ -367,6 +367,13 @@ pub mod sig;
|
|||
// energy figures and packaging policies canonicalise through this one function.
|
||||
pub mod jcs;
|
||||
|
||||
// Region codes (extensions/rung-price.md §2): the WAI family's one definition of a region code
|
||||
// (ISO 3166-1 alpha-2 or ISO 3166-2, in capitals), its country and whether a list of codes holds
|
||||
// one. Three pure functions with no dependency, so it is ungated: every extension that states a
|
||||
// region uses it.
|
||||
/// cbindgen:ignore
|
||||
pub mod region;
|
||||
|
||||
// jwp-receipts §2.1: an object's content hash, BLAKE3 over a domain and the envelope.
|
||||
// One module, gated only on BLAKE3, so every receipt family computes it in one place.
|
||||
#[cfg(feature = "jwp_hash")]
|
||||
|
|
@ -861,6 +868,34 @@ pub mod carousel;
|
|||
/// cbindgen:ignore
|
||||
#[cfg(feature = "holdings")]
|
||||
pub mod holdings;
|
||||
// Timeliness (extensions/timeliness.md): `wai.delivery.timeliness` — when each object of a group is
|
||||
// of use, by when, how its value falls after, and what is presented in its place; whether each
|
||||
// holder held it in time, and which source an asker plans to take it from. Rust-only.
|
||||
/// cbindgen:ignore
|
||||
#[cfg(feature = "timeliness")]
|
||||
pub mod timeliness;
|
||||
// Rung price (extensions/rung-price.md): `wai.delivery.rung_price` — a seller's signed list of
|
||||
// delivery prices by direction, bearer class, region, rate ceiling and unit; which list stands at
|
||||
// an instant; and the comparison of delivery options per receiver. Rust-only.
|
||||
/// cbindgen:ignore
|
||||
#[cfg(feature = "rung_price")]
|
||||
pub mod rung_price;
|
||||
// Cache authority (extensions/cache-authority.md): `wai.delivery.cache` — the role `cache/serve` and
|
||||
// the class authority `cache-transmission` a node's operator gives the caches and peers it runs;
|
||||
// placing a server key by the heads a relying party names; the bearer verdict with a cache's own
|
||||
// instances beside the publisher's; and how many of a stated population held an object by a
|
||||
// deadline. Rust-only.
|
||||
/// cbindgen:ignore
|
||||
#[cfg(feature = "cache_authority")]
|
||||
pub mod cache_authority;
|
||||
// Impression aggregates (extensions/impression-aggregates.md): `wai.delivery.impressions` — ad
|
||||
// presentations counted over a bearer with no return path, each reported once as a Prio3Histogram
|
||||
// report of telemetry-aggregation's measure 5; the catalogue, the task file confirmed under a
|
||||
// pinned key log with `aggregate/task`, the upload file, the upload plan, settlement, the report
|
||||
// token, and the table a reader recomputes from the signed receipts. Rust-only.
|
||||
/// cbindgen:ignore
|
||||
#[cfg(feature = "impression_aggregate")]
|
||||
pub mod impression_aggregate;
|
||||
// Exploration (extensions/exploration.md): `wai.media.exploration` — the descriptors of a
|
||||
// picture, quality-diversity archives over the exact generator that replay bit for bit, a work's
|
||||
// default-distance, collection statements, and taste as a declared, owned objective: taste
|
||||
|
|
|
|||
92
wai/wai-rs/src/region.rs
Normal file
92
wai/wai-rs/src/region.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
//! Region codes — `extensions/rung-price.md` §2, the WAI family's one definition of a region
|
||||
//! code, its country and *holds*.
|
||||
//!
|
||||
//! A region code is an ISO 3166-1 alpha-2 code (two capitals `A`–`Z`) or an ISO 3166-2 code (an
|
||||
//! alpha-2 code, `-`, then 1 to 3 capitals or digits), written in capitals as WAI writes ISO 3166
|
||||
//! codes elsewhere. Its **country** is its first two characters. Whether ISO assigns a code is
|
||||
//! not checked, and codes are compared byte for byte. A list of codes **holds** a code *r* when it
|
||||
//! lists *r*, or when *r* is a subdivision code and the list names *r*'s country; it never holds a
|
||||
//! country because it lists a subdivision of it.
|
||||
//!
|
||||
//! CDNI writes the same codes in lowercase (RFC 8006 §4.3.8 `countrycode`, RFC 9388 §2.1.1.1
|
||||
//! `subdivisioncode`): [`to_cdni`] and [`from_cdni`] change the case of the letters, exactly.
|
||||
//!
|
||||
//! Three pure functions with no dependency, behind no feature, so that every extension that
|
||||
//! states a region uses this one module rather than a copy.
|
||||
|
||||
/// §2: whether `s` is a region code, in capitals.
|
||||
pub fn code(s: &[u8]) -> bool {
|
||||
let alpha2 = |c: &[u8]| c.len() == 2 && c.iter().all(u8::is_ascii_uppercase);
|
||||
match s.len() {
|
||||
2 => alpha2(s),
|
||||
4..=6 => alpha2(&s[..2]) && s[2] == b'-' && s[3..].iter().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `s` is a subdivision code (ISO 3166-2), given that it is a region code.
|
||||
pub fn subdivision(s: &[u8]) -> bool {
|
||||
s.len() > 2
|
||||
}
|
||||
|
||||
/// A code's country: its first two bytes (all of `s` when it is shorter).
|
||||
pub fn country(s: &[u8]) -> &[u8] {
|
||||
&s[..s.len().min(2)]
|
||||
}
|
||||
|
||||
/// §2 *Holds*: whether `listed`, in strictly ascending order of its bytes (as every footprint
|
||||
/// is), lists `r`, or lists `r`'s country when `r` is a subdivision code.
|
||||
pub fn holds<S: AsRef<[u8]>>(listed: &[S], r: &[u8]) -> bool {
|
||||
let has = |x: &[u8]| listed.binary_search_by(|l| l.as_ref().cmp(x)).is_ok();
|
||||
has(r) || (subdivision(r) && has(country(r)))
|
||||
}
|
||||
|
||||
/// A code in CDNI's lowercase form.
|
||||
pub fn to_cdni(s: &str) -> String {
|
||||
s.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// A CDNI code in WAI's capitals; `None` when the result is not a region code.
|
||||
pub fn from_cdni(s: &str) -> Option<String> {
|
||||
let c = s.to_ascii_uppercase();
|
||||
(code(c.as_bytes()) && to_cdni(&c) == s).then_some(c)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_grammar() {
|
||||
for ok in ["XA", "XB-1", "XB-ABC", "XB-1A2", "US", "US-NY"] {
|
||||
assert!(code(ok.as_bytes()), "{ok}");
|
||||
}
|
||||
for bad in ["", "X", "xb", "XBB", "XB-", "XB-ABCD", "XB_1", "XB-a", "X1", "XB-1-", "XB1"] {
|
||||
assert!(!code(bad.as_bytes()), "{bad}");
|
||||
}
|
||||
assert_eq!(country(b"XB-1"), b"XB");
|
||||
assert_eq!(country(b"XB"), b"XB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_footprint_holds_a_code_or_its_country_never_upward() {
|
||||
let f = ["XA", "XB"];
|
||||
assert!(holds(&f, b"XB"));
|
||||
assert!(holds(&f, b"XB-1"), "a subdivision, held by its country");
|
||||
assert!(!holds(&f, b"XC"));
|
||||
assert!(!holds(&f, b"XC-1"));
|
||||
let sub = vec![b"XB-1".to_vec()];
|
||||
assert!(holds(&sub, b"XB-1"));
|
||||
assert!(!holds(&sub, b"XB"), "a country is never held by its subdivision");
|
||||
assert!(!holds(&sub, b"XB-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cdni_case_mapping_is_exact() {
|
||||
assert_eq!(to_cdni("XB-1A"), "xb-1a");
|
||||
assert_eq!(from_cdni("xb-1a").as_deref(), Some("XB-1A"));
|
||||
assert_eq!(from_cdni("us-ny").as_deref(), Some("US-NY"));
|
||||
assert_eq!(from_cdni("XB"), None, "CDNI writes lowercase");
|
||||
assert_eq!(from_cdni("usa"), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -226,7 +226,7 @@ pub fn hex(b: &[u8]) -> String {
|
|||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
fn unhex<const N: usize>(s: &str) -> Option<[u8; N]> {
|
||||
pub(crate) fn unhex<const N: usize>(s: &str) -> Option<[u8; N]> {
|
||||
if s.len() != 2 * N || !s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -256,7 +256,7 @@ fn operation(s: &str) -> bool {
|
|||
matches!(s.split_once('.'), Some((n, v)) if word(n) && word(v))
|
||||
}
|
||||
|
||||
fn currency(s: &str) -> bool {
|
||||
pub(crate) fn currency(s: &str) -> bool {
|
||||
s.len() == 3 && s.bytes().all(|c| c.is_ascii_uppercase())
|
||||
}
|
||||
|
||||
|
|
@ -265,7 +265,7 @@ fn receipt_id(s: &str) -> bool {
|
|||
h.is_some_and(|h| h.len() == 64 && h.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)))
|
||||
}
|
||||
|
||||
fn exactly<'a>(v: &'a Value, members: &[&str]) -> Option<&'a Map<String, Value>> {
|
||||
pub(crate) fn exactly<'a>(v: &'a Value, members: &[&str]) -> Option<&'a Map<String, Value>> {
|
||||
let o = v.as_object()?;
|
||||
(o.len() == members.len() && members.iter().all(|m| o.contains_key(*m))).then_some(o)
|
||||
}
|
||||
|
|
@ -274,7 +274,7 @@ fn tok(v: &Value) -> Option<&str> {
|
|||
v.as_str().filter(|s| token(s))
|
||||
}
|
||||
|
||||
fn int(v: &Value) -> Option<u64> {
|
||||
pub(crate) fn int(v: &Value) -> Option<u64> {
|
||||
v.as_u64().filter(|n| *n <= MAX)
|
||||
}
|
||||
|
||||
|
|
@ -333,7 +333,7 @@ pub struct Request {
|
|||
pub digest: [u8; 32],
|
||||
}
|
||||
|
||||
fn quantities(v: &Value) -> Option<Vec<(String, u64)>> {
|
||||
pub(crate) fn quantities(v: &Value) -> Option<Vec<(String, u64)>> {
|
||||
let a = v.as_array()?;
|
||||
if a.is_empty() || a.len() > 16 {
|
||||
return None;
|
||||
|
|
@ -350,6 +350,23 @@ fn quantities(v: &Value) -> Option<Vec<(String, u64)>> {
|
|||
q.windows(2).all(|w| w[0].0.as_bytes() < w[1].0.as_bytes()).then_some(q)
|
||||
}
|
||||
|
||||
/// A request's `max_price` (§3.1): 0 to 16 objects of exactly `unit`, `price` and `divisor` (at
|
||||
/// least 1), in strictly ascending order of `unit`, as `(unit, price, divisor)`.
|
||||
pub(crate) fn max_price(v: &Value) -> Option<Vec<(String, u64, u64)>> {
|
||||
let mp = v.as_array()?;
|
||||
if mp.len() > 16 {
|
||||
return None;
|
||||
}
|
||||
let max_price: Vec<(String, u64, u64)> = mp
|
||||
.iter()
|
||||
.map(|x| {
|
||||
let m = exactly(x, &["unit", "price", "divisor"])?;
|
||||
Some((m["unit"].as_str().filter(|s| unit(s))?.to_string(), int(&m["price"])?, int(&m["divisor"]).filter(|d| *d >= 1)?))
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
max_price.windows(2).all(|w| w[0].0.as_bytes() < w[1].0.as_bytes()).then_some(max_price)
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// Read and check a request (§3.1): `malformed` or `request_invalid`.
|
||||
pub fn read(text: &str) -> Result<Request, Reason> {
|
||||
|
|
@ -369,20 +386,7 @@ impl Request {
|
|||
};
|
||||
let l = exactly(&o["limits"], &["regions", "residency", "no_training", "zero_retention", "ga_only", "consent_on_file", "max_price", "spend_ceiling"])?;
|
||||
let opt_tokens = |v: &Value| if v.is_null() { Some(None) } else { tokens(v, 64).map(Some) };
|
||||
let mp = l["max_price"].as_array()?;
|
||||
if mp.len() > 16 {
|
||||
return None;
|
||||
}
|
||||
let max_price: Vec<(String, u64, u64)> = mp
|
||||
.iter()
|
||||
.map(|x| {
|
||||
let m = exactly(x, &["unit", "price", "divisor"])?;
|
||||
Some((m["unit"].as_str().filter(|s| unit(s))?.to_string(), int(&m["price"])?, int(&m["divisor"]).filter(|d| *d >= 1)?))
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
if !max_price.windows(2).all(|w| w[0].0.as_bytes() < w[1].0.as_bytes()) {
|
||||
return None;
|
||||
}
|
||||
let max_price = max_price(&l["max_price"])?;
|
||||
let spend_ceiling = match &l["spend_ceiling"] {
|
||||
Value::Null => None,
|
||||
c => {
|
||||
|
|
|
|||
1475
wai/wai-rs/src/rung_price.rs
Normal file
1475
wai/wai-rs/src/rung_price.rs
Normal file
File diff suppressed because it is too large
Load diff
2095
wai/wai-rs/src/rung_price/corpus.rs
Normal file
2095
wai/wai-rs/src/rung_price/corpus.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -897,6 +897,14 @@ fn marker_in_manifest(c: &StitchedClaim, rec: &PodInput, manifest: &[u8], manife
|
|||
/// A verification reads at most [`MAX_CLAIMS`] claims (§7.1), so no spend it states passes
|
||||
/// `u64`; the tool refuses more before it calls this.
|
||||
pub fn verify(input: &Input) -> Verification {
|
||||
verify_through(input, true)
|
||||
}
|
||||
|
||||
/// [`verify`], with §7.3 step 11 run only when `attestation` is set: without it, a claim of a
|
||||
/// record that requires attestation is judged by steps 1 to 10 alone. A receiver with no
|
||||
/// path back judges its own claims so, and its report's token takes step 11's place
|
||||
/// (impression-aggregates §6.2).
|
||||
pub fn verify_through(input: &Input, attestation: bool) -> Verification {
|
||||
// §7.2: the records, in order.
|
||||
let mut accepted: Vec<Option<PodInput>> = Vec::new();
|
||||
let mut records: Vec<RecordFacts> = Vec::new();
|
||||
|
|
@ -1017,7 +1025,7 @@ pub fn verify(input: &Input) -> Verification {
|
|||
if run < ap::RUN_MS {
|
||||
return Err(Reason::ClaimNotViewable);
|
||||
}
|
||||
if rec.requirement.attest() {
|
||||
if attestation && rec.requirement.attest() {
|
||||
check_attestation(&c, &rec.requirement, input.issuers)?;
|
||||
}
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -819,6 +819,8 @@ pub struct Case {
|
|||
/// Acknowledgements and declarations (settlement §3), each its record's bytes and the
|
||||
/// item list of each of its entries, in order.
|
||||
pub acknowledgements: Vec<(Vec<u8>, Vec<Vec<u8>>)>,
|
||||
/// `cache-transmission` claim texts (cache-authority §5), in order.
|
||||
pub cache_transmissions: Vec<String>,
|
||||
/// The instant of the verification, a time's value (§2).
|
||||
pub as_of: u64,
|
||||
}
|
||||
|
|
@ -1055,6 +1057,15 @@ fn evaluate(case: &Case, as_of: u64) -> Result<Evaluated, Refusal> {
|
|||
})();
|
||||
acks.push(read.ok_or(()).or_else(|_| refuse(Reason::EvidenceInvalid, &place_i("acknowledgement", i)))?);
|
||||
}
|
||||
// The cache-transmission claims (cache-authority §5): each of that class, through
|
||||
// jwp-receipts §3.1 steps 1–4; what each shows, (signer, group, positions sent).
|
||||
let mut sent: Vec<([u8; 32], [u8; 32], Vec<u64>)> = Vec::new();
|
||||
for (i, t) in case.cache_transmissions.iter().enumerate() {
|
||||
let read = crate::claim_receipt::verify_claim_json(t)
|
||||
.ok()
|
||||
.and_then(|c| c.cache_transmission().map(|b| (c.signer_pubkey, c.subject, b.indices())));
|
||||
sent.push(read.ok_or(()).or_else(|_| refuse(Reason::EvidenceInvalid, &place_i("cache_transmission", i)))?);
|
||||
}
|
||||
// Step 6: the matches.
|
||||
let mut matches = Vec::new();
|
||||
for (i, t) in case.matches.iter().enumerate() {
|
||||
|
|
@ -1177,6 +1188,14 @@ fn evaluate(case: &Case, as_of: u64) -> Result<Evaluated, Refusal> {
|
|||
}
|
||||
}
|
||||
}
|
||||
// A node's own word that it sent objects of a group, read by position through the group's
|
||||
// object receipts (§6.3); a group not among the receipts names no object.
|
||||
for (signer, group, positions) in &sent {
|
||||
let Some((_, hashes)) = expand(&case.receipts, group) else { continue };
|
||||
for h in positions.iter().filter_map(|p| hashes.get(*p as usize)).filter(|h| objects.contains(*h)) {
|
||||
need(*signer, Subject::Object(*h), "served");
|
||||
}
|
||||
}
|
||||
let mut matched_required: BTreeMap<([u8; 32], String), Subject> = BTreeMap::new();
|
||||
let stated_candidates: BTreeSet<[u8; 32]> = td.matched.iter().map(|m| m.1).collect();
|
||||
for k in &pol.stores {
|
||||
|
|
|
|||
|
|
@ -308,6 +308,26 @@ fn acknowledgements(o: &Objects) -> Vec<Value> {
|
|||
vec![acknowledgement(role::SINK, CACHE, VIEWER, &[o.thumb, o.other_thumb]), acknowledgement(role::HOP, RELAY_STORE, CACHE, &[o.comp, o.rung])]
|
||||
}
|
||||
|
||||
/// The cache's `cache-transmission` claim (cache-authority §5): it sent the source, object 0 of
|
||||
/// the source group, over a multicast instance of its own.
|
||||
fn cache_transmission(w: &World) -> String {
|
||||
use crate::claim_receipt::{Bearer, CacheTransmissionClaim, ClaimBody, ClaimReceipt, ReconciliationClaim, SubjectType};
|
||||
let source = ObjectReceipt::from_json(&w.g.receipts[1]).expect("the source's receipt");
|
||||
assert_eq!(source.content_hash, w.o.src);
|
||||
let body = ClaimBody::CacheTransmission(CacheTransmissionClaim {
|
||||
bearer: Bearer::Multicast,
|
||||
bearer_id: "urn:example:wai-bearer:cache-mesh".into(),
|
||||
leaf_count: 2,
|
||||
sent: ReconciliationClaim::bitmap(2, &[0]),
|
||||
sent_root: crate::jwp_delivery::root(&[source.leaf()]),
|
||||
energy: None,
|
||||
attests: "transmitted".into(),
|
||||
});
|
||||
ClaimReceipt::seal(&key(CACHE), crate::signer_id::did_key(&pk(CACHE)), SubjectType::Receipt, w.g.g1, 0, ISSUED, body, None)
|
||||
.expect("a claim that passes steps 1-4")
|
||||
.to_json()
|
||||
}
|
||||
|
||||
fn registry_link() -> [u8; 32] {
|
||||
read_registry(REGISTRY).expect("the published registry reads").link
|
||||
}
|
||||
|
|
@ -476,6 +496,7 @@ pub fn case_of(world: &Map<String, Value>, over: &Map<String, Value>) -> Option<
|
|||
})
|
||||
.collect::<Option<Vec<_>>>()?,
|
||||
},
|
||||
cache_transmissions: texts("cache_transmissions")?,
|
||||
as_of: time(get("as_of")?.as_str()?)?,
|
||||
})
|
||||
}
|
||||
|
|
@ -604,6 +625,24 @@ fn vectors(w: &World) -> Vec<Vector> {
|
|||
want: Want::Missed(Reason::PurgeMissing),
|
||||
});
|
||||
|
||||
// 4c–4d. The request of case 1, with a node's cache-transmission claim that it sent the
|
||||
// source over an instance of its own: its own word makes it a holder of the source.
|
||||
let sent = cache_transmission(w);
|
||||
let mut p4d = p1.clone();
|
||||
p4d[6] = purge(CACHE, &td1s, &[obj(o.comp), obj(o.rung), obj(o.thumb), obj(o.src)], T0 + 14 * H);
|
||||
v.push(Vector {
|
||||
name: "cache-transmitted-purged",
|
||||
description: "The request of request-48h-met, with the cache's claim that it sent the source over a multicast instance of its own; the cache purges the source beside what it received and served: accept",
|
||||
over: over(vec![("notices", texts(std::slice::from_ref(&n1))), ("takedown", json!(td1s)), ("matches", texts(std::slice::from_ref(&m1))), ("purges", texts(&p4d)), ("cache_transmissions", texts(std::slice::from_ref(&sent))), ("as_of", json!(t(T0 + 72 * H)))]),
|
||||
want: Want::Accept,
|
||||
});
|
||||
v.push(Vector {
|
||||
name: "cache-transmitted-not-purged",
|
||||
description: "The same, verified after its deadline, with the cache's purge naming only what it received and served: the source it sent is not purged, purge_missing",
|
||||
over: over(vec![("notices", texts(std::slice::from_ref(&n1))), ("takedown", json!(td1s)), ("matches", texts(std::slice::from_ref(&m1))), ("purges", texts(&p1)), ("cache_transmissions", texts(std::slice::from_ref(&sent))), ("as_of", json!(t(T0 + 72 * H)))]),
|
||||
want: Want::Missed(Reason::PurgeMissing),
|
||||
});
|
||||
|
||||
// 5. The same, with a stranger's purge of what that edge held: it counts for nothing.
|
||||
let mut p5 = p4.clone();
|
||||
p5.push(purge(STRANGER, &td1s, &[obj(o.comp), obj(o.thumb), obj(o.rung)], T0 + 8 * H));
|
||||
|
|
@ -817,6 +856,14 @@ fn vectors(w: &World) -> Vec<Vector> {
|
|||
acks[0]["items"] = json!([hex(&crate::settlement::item_list(&[crate::settlement::Item { content_hash: o.thumb, start: 0, end: 95 }, crate::settlement::Item { content_hash: o.other_thumb, start: 0, end: 96 }]))]);
|
||||
bad_ack.insert("acknowledgements".into(), Value::Array(acks));
|
||||
v.push(Vector { name: "acknowledgement-invalid", description: "An acknowledgement whose item list is not the one its entry signs", over: bad_ack, want: Want::Refuse(Reason::EvidenceInvalid) });
|
||||
let forged = {
|
||||
let mut x: Value = serde_json::from_str(&sent).expect("json");
|
||||
x["issued_at"] = json!("2026-10-21T07:00:01Z");
|
||||
text(&x)
|
||||
};
|
||||
let mut bad_sent = base(&td1s);
|
||||
bad_sent.insert("cache_transmissions".into(), texts(std::slice::from_ref(&forged)));
|
||||
v.push(Vector { name: "cache-transmission-invalid", description: "A cache-transmission claim whose signature does not cover what it states", over: bad_sent, want: Want::Refuse(Reason::EvidenceInvalid) });
|
||||
|
||||
let mut short = base(&td1s);
|
||||
short.insert("receipts".into(), Value::Array(w.g.receipts_short.clone()));
|
||||
|
|
|
|||
|
|
@ -55,29 +55,62 @@ pub fn statement_info(role: u8) -> Vec<u8> {
|
|||
v
|
||||
}
|
||||
|
||||
/// What an aggregator accepts of a report's extensions (`draft-ietf-ppm-dap-20` §4.5.3.4), after
|
||||
/// its input share decrypts: `Ok`, or the report error it rejects the report with and, where a
|
||||
/// WAI text names one, the WAI reason it records beside it. A rule is `Sync` and
|
||||
/// `RefUnwindSafe`, so an aggregator that holds one is `Send`, `Sync` and unwind-safe.
|
||||
pub trait ReportRule: Sync + std::panic::RefUnwindSafe {
|
||||
fn extensions(&self, m: &ReportMetadata, private: &Extensions) -> Result<(), (u8, Option<&'static str>)>;
|
||||
}
|
||||
|
||||
/// The rule of §5.1: a report carries no extension, public or private; any is
|
||||
/// `invalid_message` (DAP §4.5.3.4 step 4).
|
||||
pub struct NoExtensions;
|
||||
|
||||
impl ReportRule for NoExtensions {
|
||||
fn extensions(&self, m: &ReportMetadata, private: &Extensions) -> Result<(), (u8, Option<&'static str>)> {
|
||||
if !private.is_empty() || !m.public_extensions.is_empty() {
|
||||
return Err((report_error::INVALID_MESSAGE, None));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The configuration a client seals an input share to, from its aggregator's HpkeConfigList:
|
||||
/// the first, in the list's order of preference (DAP §4.4.1), of DAP §7's suite —
|
||||
/// DHKEM(X25519, HKDF-SHA256), HKDF-SHA256 and AES-128-GCM — with a 32-byte key.
|
||||
pub fn choose(list: &[HpkeConfig]) -> Option<&HpkeConfig> {
|
||||
list.iter().find(|c| c.kem == hpke::KEM_X25519_HKDF_SHA256 && c.kdf == hpke::KDF_HKDF_SHA256 && c.aead == hpke::AEAD_AES_128_GCM && c.public_key.len() == 32)
|
||||
}
|
||||
|
||||
/// The client (§5.1): shard an encoded measurement and encrypt each input share to its
|
||||
/// aggregator. `rand` is Prio3's randomness; `ikm` the seeds of the two ephemeral keys.
|
||||
/// aggregator, under the configuration [`choose`] takes from its list. `rand` is Prio3's
|
||||
/// randomness; `ikm` the seeds of the two ephemeral keys; `public` the report's public
|
||||
/// extensions and `private` each input share's.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn client_report(
|
||||
task: &Task,
|
||||
leader: &HpkeConfig,
|
||||
helper: &HpkeConfig,
|
||||
leader: &[HpkeConfig],
|
||||
helper: &[HpkeConfig],
|
||||
id: [u8; 16],
|
||||
time: u64,
|
||||
encoded: &[u128],
|
||||
rand: &[u8],
|
||||
ikm: [&[u8]; 2],
|
||||
public: &Extensions,
|
||||
private: [&Extensions; 2],
|
||||
) -> Report {
|
||||
let (public_share, shares) = task.vdaf.shard(&task.ctx(), encoded, &id, rand).expect("shard");
|
||||
let metadata = ReportMetadata { id, time, public_extensions: vec![] };
|
||||
let metadata = ReportMetadata { id, time, public_extensions: public.clone() };
|
||||
let aad = input_share_aad(&task.id, &task.config, &metadata, &public_share);
|
||||
let seal = |cfg: &HpkeConfig, role: u8, share: &[u8], ikm: &[u8]| {
|
||||
let seal = |list: &[HpkeConfig], role: u8, share: &[u8], ikm: &[u8], ext: &Extensions| {
|
||||
let cfg = choose(list).expect("a configuration of DAP's suite");
|
||||
let pk: [u8; 32] = cfg.public_key.as_slice().try_into().expect("X25519 key");
|
||||
let (enc, payload) = hpke::seal_base(&pk, &input_share_info(role), &aad, &encode_plaintext_input_share(&vec![], share), ikm).expect("seal");
|
||||
let (enc, payload) = hpke::seal_base(&pk, &input_share_info(role), &aad, &encode_plaintext_input_share(ext, share), ikm).expect("seal");
|
||||
HpkeCiphertext { config_id: cfg.id, enc, payload }
|
||||
};
|
||||
let leader_share = seal(leader, ROLE_LEADER, &shares[0], ikm[0]);
|
||||
let helper_share = seal(helper, ROLE_HELPER, &shares[1], ikm[1]);
|
||||
let leader_share = seal(leader, ROLE_LEADER, &shares[0], ikm[0], private[0]);
|
||||
let helper_share = seal(helper, ROLE_HELPER, &shares[1], ikm[1], private[1]);
|
||||
Report { metadata, public_share, leader_share, helper_share }
|
||||
}
|
||||
|
||||
|
|
@ -94,15 +127,18 @@ pub struct Bucket {
|
|||
/// a report.
|
||||
type Merged = (Vec<u8>, u64, [u8; 32], Option<(u64, u64)>);
|
||||
|
||||
/// One aggregator's report state: its task, keys, verification key, buckets, the reports it
|
||||
/// has aggregated and the buckets it has collected.
|
||||
/// One aggregator's report state: its task, keys, verification key, the rule it holds a
|
||||
/// report's extensions to, buckets, the reports it has aggregated, the buckets it has
|
||||
/// collected, and the WAI reason it recorded beside a report error.
|
||||
pub struct Aggregator<'a> {
|
||||
pub task: &'a Task,
|
||||
pub keys: &'a Keys,
|
||||
pub verify_key: [u8; 32],
|
||||
pub rule: &'a dyn ReportRule,
|
||||
pub buckets: BTreeMap<u64, Bucket>,
|
||||
pub aggregated: BTreeSet<[u8; 16]>,
|
||||
pub collected: BTreeSet<u64>,
|
||||
pub reasons: BTreeMap<[u8; 16], &'static str>,
|
||||
}
|
||||
|
||||
/// A report's outcome in an aggregation job: committed, or rejected with a report error.
|
||||
|
|
@ -114,7 +150,29 @@ pub enum Outcome {
|
|||
|
||||
impl<'a> Aggregator<'a> {
|
||||
pub fn new(task: &'a Task, keys: &'a Keys, verify_key: [u8; 32]) -> Self {
|
||||
Aggregator { task, keys, verify_key, buckets: BTreeMap::new(), aggregated: BTreeSet::new(), collected: BTreeSet::new() }
|
||||
Aggregator::with_rule(task, keys, verify_key, &NoExtensions)
|
||||
}
|
||||
|
||||
/// An aggregator that holds a report's extensions to `rule`.
|
||||
pub fn with_rule(task: &'a Task, keys: &'a Keys, verify_key: [u8; 32], rule: &'a dyn ReportRule) -> Self {
|
||||
Aggregator {
|
||||
task,
|
||||
keys,
|
||||
verify_key,
|
||||
rule,
|
||||
buckets: BTreeMap::new(),
|
||||
aggregated: BTreeSet::new(),
|
||||
collected: BTreeSet::new(),
|
||||
reasons: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A report error, with the WAI reason recorded beside it where there is one.
|
||||
fn reject(&mut self, id: &[u8; 16], e: (u8, Option<&'static str>)) -> u8 {
|
||||
if let Some(r) = e.1 {
|
||||
self.reasons.insert(*id, r);
|
||||
}
|
||||
e.0
|
||||
}
|
||||
|
||||
/// Whether an output share may be committed (§4.5.4.3).
|
||||
|
|
@ -137,19 +195,18 @@ impl<'a> Aggregator<'a> {
|
|||
self.aggregated.insert(m.id);
|
||||
}
|
||||
|
||||
/// Decrypt and check this aggregator's input share of a report (§4.5.3.3, §4.5.3.4).
|
||||
fn open(&self, role: u8, m: &ReportMetadata, public_share: &[u8], ct: &HpkeCiphertext) -> Result<Vec<u8>, u8> {
|
||||
/// Decrypt and check this aggregator's input share of a report (§4.5.3.3, §4.5.3.4): its
|
||||
/// extensions are held to the aggregator's rule.
|
||||
fn open(&self, role: u8, m: &ReportMetadata, public_share: &[u8], ct: &HpkeCiphertext) -> Result<Vec<u8>, (u8, Option<&'static str>)> {
|
||||
if ct.config_id != self.keys.hpke.id {
|
||||
return Err(report_error::HPKE_UNKNOWN_CONFIG_ID);
|
||||
return Err((report_error::HPKE_UNKNOWN_CONFIG_ID, None));
|
||||
}
|
||||
let aad = input_share_aad(&self.task.id, &self.task.config, m, public_share);
|
||||
let pt = hpke::open_base(&ct.enc, &self.keys.hpke_sk, &input_share_info(role), &aad, &ct.payload).map_err(|_| report_error::HPKE_DECRYPT_ERROR)?;
|
||||
let (exts, payload) = decode_plaintext_input_share(&pt).map_err(|_| report_error::INVALID_MESSAGE)?;
|
||||
if !exts.is_empty() || !m.public_extensions.is_empty() {
|
||||
return Err(report_error::INVALID_MESSAGE);
|
||||
}
|
||||
let pt = hpke::open_base(&ct.enc, &self.keys.hpke_sk, &input_share_info(role), &aad, &ct.payload).map_err(|_| (report_error::HPKE_DECRYPT_ERROR, None))?;
|
||||
let (exts, payload) = decode_plaintext_input_share(&pt).map_err(|_| (report_error::INVALID_MESSAGE, None))?;
|
||||
self.rule.extensions(m, &exts)?;
|
||||
if self.task.config.task_interval().is_some_and(|i| !i.contains(m.time)) {
|
||||
return Err(report_error::REPORT_DROPPED);
|
||||
return Err((report_error::REPORT_DROPPED, None));
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
|
@ -232,7 +289,8 @@ impl<'a> Leader<'a> {
|
|||
let mut states = Vec::new();
|
||||
for r in &pending {
|
||||
let m = &r.metadata;
|
||||
let opened = self.agg.eligible(m).and_then(|_| self.agg.open(ROLE_LEADER, m, &r.public_share, &r.leader_share));
|
||||
let opened = self.agg.eligible(m).map_err(|e| (e, None)).and_then(|_| self.agg.open(ROLE_LEADER, m, &r.public_share, &r.leader_share));
|
||||
let opened = opened.map_err(|e| self.agg.reject(&m.id, e));
|
||||
let started = opened.and_then(|payload| {
|
||||
task.vdaf.leader_init(&self.agg.verify_key, &ctx, &m.id, &r.public_share, &payload).map_err(|_| report_error::VDAF_VERIFY_ERROR)
|
||||
});
|
||||
|
|
@ -269,8 +327,11 @@ impl<'a> Leader<'a> {
|
|||
}
|
||||
|
||||
/// Handle a collection job (§4.6.1) with the helper (§4.6.4): the response, the helper's
|
||||
/// sealed statement and the leader's own, and the AggregateShareReq it sent.
|
||||
pub fn collect(&mut self, body: &[u8], helper: &mut Helper, collector: &HpkeConfig, ikm: [&[u8]; 4]) -> Result<Collection, Problem> {
|
||||
/// sealed statement and the leader's own, and the AggregateShareReq it sent. At `now_s`, in
|
||||
/// seconds, a job whose batch interval has not ended `hold_s` seconds before is still
|
||||
/// processing (`Ok(None)`): a task that holds no collection passes a hold of 0.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn collect(&mut self, body: &[u8], helper: &mut Helper, collector: &HpkeConfig, ikm: [&[u8]; 4], now_s: u64, hold_s: u64) -> Result<Option<Collection>, Problem> {
|
||||
let task = self.agg.task;
|
||||
let coll = CollectionJobReq::decode(body).map_err(|_| Problem::InvalidMessage)?;
|
||||
if coll.batch_mode != task.config.batch_mode {
|
||||
|
|
@ -289,6 +350,10 @@ impl<'a> Leader<'a> {
|
|||
if !coll.extensions.is_empty() {
|
||||
return Err(Problem::UnsupportedExtension);
|
||||
}
|
||||
let due = query.end().and_then(|e| e.checked_mul(task.config.time_precision)).and_then(|e| e.checked_add(hold_s));
|
||||
if due.is_none_or(|d| now_s < d) {
|
||||
return Ok(None);
|
||||
}
|
||||
let (agg, count, checksum, span) = self.agg.merged(&query);
|
||||
if count < task.config.min_batch_size {
|
||||
return Err(Problem::InvalidBatchSize);
|
||||
|
|
@ -303,7 +368,7 @@ impl<'a> Leader<'a> {
|
|||
}
|
||||
let (a, b) = span.expect("a batch of at least the minimum holds a report");
|
||||
let resp = CollectionJobResp { report_count: count, interval: Interval { start: a, duration: b - a + 1 }, leader_share, helper_share: HpkeCiphertext::decode(&helper_share).map_err(|_| Problem::InvalidMessage)? };
|
||||
Ok(Collection { request: body.to_vec(), share_req, helper_share, resp: resp.encode(), leader_statement: leader_statement.encode(), helper_statement })
|
||||
Ok(Some(Collection { request: body.to_vec(), share_req, helper_share, resp: resp.encode(), leader_statement: leader_statement.encode(), helper_statement }))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -346,9 +411,9 @@ impl<'a> Helper<'a> {
|
|||
let r = if req.verification_key_id != 0 {
|
||||
Err(report_error::UNKNOWN_VERIFICATION_KEY_ID)
|
||||
} else {
|
||||
self.agg
|
||||
.open(ROLE_HELPER, m, &v.public_share, &v.encrypted)
|
||||
.and_then(|p| self.agg.eligible(m).map(|_| p))
|
||||
let opened = self.agg.open(ROLE_HELPER, m, &v.public_share, &v.encrypted).and_then(|p| self.agg.eligible(m).map(|_| p).map_err(|e| (e, None)));
|
||||
opened
|
||||
.map_err(|e| self.agg.reject(&m.id, e))
|
||||
.and_then(|p| task.vdaf.helper_init(&self.agg.verify_key, &ctx, &m.id, &v.public_share, &p, &v.payload).map_err(|_| report_error::VDAF_VERIFY_ERROR))
|
||||
};
|
||||
let kind = match r {
|
||||
|
|
@ -428,3 +493,18 @@ pub fn collector_open(task: &Task, sk: &[u8; 32], coll: &CollectionJobReq, resp:
|
|||
let result = task.vdaf.unshard(&[l_share, h_share]).map_err(|_| "unshard")?;
|
||||
Ok(Opened { result, leader: l, helper: h })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// An aggregator, and so a leader and a helper, is `Send`, `Sync` and unwind-safe whatever
|
||||
/// report rule it holds: the trait's bounds carry those of the rule.
|
||||
#[test]
|
||||
fn an_aggregator_is_send_sync_and_unwind_safe() {
|
||||
fn holds<T: Send + Sync + std::panic::UnwindSafe + std::panic::RefUnwindSafe>() {}
|
||||
holds::<Aggregator<'static>>();
|
||||
holds::<Leader<'static>>();
|
||||
holds::<Helper<'static>>();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -257,7 +257,19 @@ fn flow(w: &World, code: u8, scope: [u8; 32]) -> Flow {
|
|||
let mk = |idn: &str, time: u64, enc: &[u128]| -> Report {
|
||||
let id: [u8; 16] = seed(&[b"report", mname.as_bytes(), idn.as_bytes()])[..16].try_into().unwrap();
|
||||
let rand = stream(&[b"rand", mname.as_bytes(), idn.as_bytes()], task.vdaf.rand_size());
|
||||
client_report(&task, &w.leader.hpke, &w.helper.hpke, id, time, enc, &rand, [&ikm(mname, idn, "leader"), &ikm(mname, idn, "helper")])
|
||||
let none = Extensions::new();
|
||||
client_report(
|
||||
&task,
|
||||
std::slice::from_ref(&w.leader.hpke),
|
||||
std::slice::from_ref(&w.helper.hpke),
|
||||
id,
|
||||
time,
|
||||
enc,
|
||||
&rand,
|
||||
[&ikm(mname, idn, "leader"), &ikm(mname, idn, "helper")],
|
||||
&none,
|
||||
[&none, &none],
|
||||
)
|
||||
};
|
||||
for i in 0..SESSIONS {
|
||||
let m = measure::measurement(code, &session(i)).unwrap();
|
||||
|
|
@ -310,7 +322,7 @@ fn flow(w: &World, code: u8, scope: [u8; 32]) -> Flow {
|
|||
// Before the collection: a batch of the first hour alone, below the minimum, refused by
|
||||
// the leader and, asked directly, by the helper; a checksum that is not the batch's.
|
||||
let c1 = CollectionJobReq::time_interval(first).encode();
|
||||
let got = problem_or_ok(leader.collect(&c1, &mut helper, &collector, ikms));
|
||||
let got = problem_or_ok(leader.collect(&c1, &mut helper, &collector, ikms, u64::MAX, 0));
|
||||
assert_eq!(got, "invalidBatchSize");
|
||||
refusals.push(json!({"name": "leader_below_minimum", "phase": "before", "actor": "leader", "message": "collection_job_req", "request": hex(&c1), "outcome": got}));
|
||||
let (_, count, checksum) = bucket_sum(&helper, &first);
|
||||
|
|
@ -341,7 +353,7 @@ fn flow(w: &World, code: u8, scope: [u8; 32]) -> Flow {
|
|||
}
|
||||
v
|
||||
};
|
||||
let collection = leader.collect(&coll.encode(), &mut helper, &collector, ikms).expect("the collection");
|
||||
let collection = leader.collect(&coll.encode(), &mut helper, &collector, ikms, u64::MAX, 0).expect("the collection").expect("no hold");
|
||||
if code == OUTCOMES {
|
||||
let second = Interval { start: t + 1, duration: 1 };
|
||||
let (_, count, checksum) = bucket_sum(&helper, &second);
|
||||
|
|
@ -349,7 +361,7 @@ fn flow(w: &World, code: u8, scope: [u8; 32]) -> Flow {
|
|||
let got = problem_or_ok(helper.aggregate_share(&r3, &collector, [&k2, &k3]));
|
||||
assert_eq!(got, "batchOverlap");
|
||||
refusals.push(json!({"name": "helper_collected_again", "phase": "after", "actor": "helper", "message": "aggregate_share_req", "request": hex(&r3), "outcome": got}));
|
||||
let got = problem_or_ok(leader.collect(&coll.encode(), &mut helper, &collector, ikms));
|
||||
let got = problem_or_ok(leader.collect(&coll.encode(), &mut helper, &collector, ikms, u64::MAX, 0));
|
||||
assert_eq!(got, "batchOverlap");
|
||||
refusals.push(json!({"name": "leader_collected_again", "phase": "after", "actor": "leader", "message": "collection_job_req", "request": hex(&coll.encode()), "outcome": got}));
|
||||
}
|
||||
|
|
@ -427,7 +439,7 @@ fn outcome_json(r: &Result<Accepted, receipt::Refusal>) -> Value {
|
|||
"helper": hex(&a.helper),
|
||||
"collector": hex(&a.collector),
|
||||
}),
|
||||
Err((r, at)) => json!({"outcome": "refuse", "reason": r.code(), "at": at}),
|
||||
Err((r, at)) => json!({"outcome": "refuse", "reason": r, "at": at}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -474,7 +486,7 @@ fn cell_outcome(doc: &Value, v: &Value) -> Value {
|
|||
let b = unhex(r["receipt"].as_str().unwrap_or("")).unwrap_or_default();
|
||||
match receipt::verify(&b, given_of(r).as_deref()) {
|
||||
Ok(a) => acc.push(a),
|
||||
Err((r, _)) => return json!({"outcome": "refuse", "reason": r.code()}),
|
||||
Err((r, _)) => return json!({"outcome": "refuse", "reason": r}),
|
||||
}
|
||||
}
|
||||
match cell(&acc) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@ pub const OUTCOMES: u8 = 1;
|
|||
pub const STALLS: u8 = 2;
|
||||
pub const DURATIONS: u8 = 3;
|
||||
pub const START_TIME: u8 = 4;
|
||||
/// Ad presentations counted over a bearer with no return path (impression-aggregates §5.1):
|
||||
/// its VDAF is not fixed here but by the catalogue whose digest is the task's `scope`.
|
||||
pub const IMPRESSIONS: u8 = 5;
|
||||
|
||||
/// impression-aggregates §5.3's code, which a task of measure 5 is refused with when its
|
||||
/// `measure` or `scope` is not its catalogue's, and when no catalogue is given (§4.3 rule 3).
|
||||
pub const CATALOGUE_MISMATCH: &str = "catalogue_mismatch";
|
||||
|
||||
/// The largest stall count a session reports to `stalls`.
|
||||
pub const STALLS_MAX: u64 = 65_535;
|
||||
|
|
@ -28,13 +35,15 @@ pub const DURATION_MAX: u64 = u32::MAX as u64;
|
|||
pub const START_EDGES_MS: [u64; 6] = [250, 500, 1000, 2000, 4000, 8000];
|
||||
|
||||
/// The measures of §3: code, name.
|
||||
pub const MEASURES: [(u8, &str); 4] = [(OUTCOMES, "outcomes"), (STALLS, "stalls"), (DURATIONS, "durations"), (START_TIME, "start_time")];
|
||||
pub const MEASURES: [(u8, &str); 5] =
|
||||
[(OUTCOMES, "outcomes"), (STALLS, "stalls"), (DURATIONS, "durations"), (START_TIME, "start_time"), (IMPRESSIONS, "impressions")];
|
||||
|
||||
pub fn name(code: u8) -> Option<&'static str> {
|
||||
MEASURES.iter().find(|(c, _)| *c == code).map(|(_, n)| *n)
|
||||
}
|
||||
|
||||
/// The VDAF a measure is aggregated with (§3), two aggregators.
|
||||
/// The VDAF a measure is aggregated with (§3), two aggregators; `None` for measure 5, whose
|
||||
/// VDAF its catalogue fixes, and for a code of no measure.
|
||||
pub fn vdaf(code: u8) -> Option<Vdaf> {
|
||||
Some(match code {
|
||||
OUTCOMES => Vdaf::sumvec(2, 4, 1, 2),
|
||||
|
|
@ -114,30 +123,56 @@ pub struct Task {
|
|||
pub vdaf: Vdaf,
|
||||
}
|
||||
|
||||
/// A rule 3 for a measure whose VDAF §3.1 does not fix (measure 5): given the configuration
|
||||
/// and its `task_info`, the VDAF, or the code that refuses it.
|
||||
pub type MeasureRule<'a> = &'a dyn Fn(&TaskConfig, &TaskInfo) -> Result<Vdaf, &'static str>;
|
||||
|
||||
/// §4.3 rule 1: the configuration's `task_info`, or `task_invalid`.
|
||||
pub fn rule_1(config: &TaskConfig) -> Result<TaskInfo, &'static str> {
|
||||
let invalid = Reason::TaskInvalid.code();
|
||||
let info = TaskInfo::decode(&config.task_info).ok_or(invalid)?;
|
||||
if config.time_precision == 0 || config.batch_mode != BATCH_TIME_INTERVAL || !config.batch_config.is_empty() {
|
||||
return Err(invalid);
|
||||
}
|
||||
let ext_ok = config.extensions.iter().all(|(t, d)| *t == EXT_TASK_INTERVAL && super::dap::Interval::decode(d).is_ok());
|
||||
if !ext_ok || !super::dap::exts_sorted(&config.extensions) {
|
||||
return Err(invalid);
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
impl Task {
|
||||
/// Check a configuration against §4.3, in order.
|
||||
pub fn new(config: TaskConfig) -> Result<Task, Reason> {
|
||||
let info = TaskInfo::decode(&config.task_info).ok_or(Reason::TaskInvalid)?;
|
||||
if config.time_precision == 0 || config.batch_mode != BATCH_TIME_INTERVAL || !config.batch_config.is_empty() {
|
||||
return Err(Reason::TaskInvalid);
|
||||
}
|
||||
let ext_ok = config.extensions.iter().all(|(t, d)| *t == EXT_TASK_INTERVAL && super::dap::Interval::decode(d).is_ok());
|
||||
if !ext_ok || !super::dap::exts_sorted(&config.extensions) {
|
||||
return Err(Reason::TaskInvalid);
|
||||
}
|
||||
let vdaf = vdaf(info.measure).ok_or(Reason::MeasureUnknown)?;
|
||||
if config.vdaf_type != vdaf.id() || config.vdaf_config != vdaf.dap_config() {
|
||||
return Err(Reason::MeasureMismatch);
|
||||
/// Check a configuration against §4.3, in order, with `rule` as rule 3 for measure 5
|
||||
/// (impression-aggregates §5.3 steps 2 to 4). A refusal is its code.
|
||||
pub fn with(config: TaskConfig, rule: MeasureRule<'_>) -> Result<Task, &'static str> {
|
||||
let info = rule_1(&config)?;
|
||||
if name(info.measure).is_none() {
|
||||
return Err(Reason::MeasureUnknown.code());
|
||||
}
|
||||
let vdaf = match vdaf(info.measure) {
|
||||
Some(v) => {
|
||||
if config.vdaf_type != v.id() || config.vdaf_config != v.dap_config() {
|
||||
return Err(Reason::MeasureMismatch.code());
|
||||
}
|
||||
v
|
||||
}
|
||||
None => rule(&config, &info)?,
|
||||
};
|
||||
if config.min_batch_size < FLOOR {
|
||||
return Err(Reason::FloorTooLow);
|
||||
return Err(Reason::FloorTooLow.code());
|
||||
}
|
||||
if info.leader == info.helper {
|
||||
return Err(Reason::AggregatorAlone);
|
||||
return Err(Reason::AggregatorAlone.code());
|
||||
}
|
||||
Ok(Task { id: task_id(&config), config, info, vdaf })
|
||||
}
|
||||
|
||||
/// Check a configuration against §4.3 with no catalogue: measures 1 to 4 as §3.1 fixes
|
||||
/// them, and measure 5 refused `catalogue_mismatch`.
|
||||
pub fn new(config: TaskConfig) -> Result<Task, &'static str> {
|
||||
Task::with(config, &|_, _| Err(CATALOGUE_MISMATCH))
|
||||
}
|
||||
|
||||
/// The VDAF application context DAP shards and verifies under: `dap-20` and the task id.
|
||||
pub fn ctx(&self) -> Vec<u8> {
|
||||
let mut c = super::dap::DAP_TAG.to_vec();
|
||||
|
|
|
|||
|
|
@ -221,75 +221,82 @@ pub struct Accepted {
|
|||
pub collector: [u8; 32],
|
||||
}
|
||||
|
||||
/// Where a refusal is raised (§8.1).
|
||||
pub type Refusal = (Reason, &'static str);
|
||||
/// A refusal (§8.1): its code and where it is raised.
|
||||
pub type Refusal = (&'static str, &'static str);
|
||||
|
||||
/// Verify a receipt (§8.1), with the batch's reports where they are given.
|
||||
pub fn verify(bytes: &[u8], reports: Option<&[GivenReport]>) -> Result<Accepted, Refusal> {
|
||||
verify_with(bytes, reports, &|t| TaskConfig::decode(t).map_err(|_| Reason::TaskInvalid.code()).and_then(Task::new))
|
||||
}
|
||||
|
||||
/// §8.1 steps 1 to 11 with step 2 given as `task`: from the receipt's `task` bytes, the task
|
||||
/// that meets §4.3, or the code that refuses it. A task of measure 5 is checked against its
|
||||
/// catalogue this way (impression-aggregates §8.1).
|
||||
pub fn verify_with(bytes: &[u8], reports: Option<&[GivenReport]>, task: &dyn Fn(&[u8]) -> Result<Task, &'static str>) -> Result<Accepted, Refusal> {
|
||||
let at = |place: &'static str| move |r: Reason| (r.code(), place);
|
||||
// 1. The receipt reads.
|
||||
let rc = Receipt::decode(bytes).map_err(|r| (r, "receipt"))?;
|
||||
let rc = Receipt::decode(bytes).map_err(at("receipt"))?;
|
||||
// 2. The task meets §4.3.
|
||||
let cfg = TaskConfig::decode(&rc.task).map_err(|_| (Reason::TaskInvalid, "task"))?;
|
||||
let task = Task::new(cfg).map_err(|r| (r, "task"))?;
|
||||
let task = task(&rc.task).map_err(|r| (r, "task"))?;
|
||||
// 3. The collector signed the receipt.
|
||||
if rc.signer != task.info.collector {
|
||||
return Err((Reason::WrongSigner, "receipt"));
|
||||
return Err((Reason::WrongSigner.code(), "receipt"));
|
||||
}
|
||||
verify_sig(&rc.signer, RECEIPT_DOMAIN, &rc.body(), &rc.sig).map_err(|r| (r, "receipt"))?;
|
||||
verify_sig(&rc.signer, RECEIPT_DOMAIN, &rc.body(), &rc.sig).map_err(at("receipt"))?;
|
||||
// 4. The collection job reads and is one this extension collects.
|
||||
let coll = CollectionJobReq::decode(&rc.collection).map_err(|_| (Reason::CollectionInvalid, "collection"))?;
|
||||
let query = coll.interval().map_err(|_| (Reason::CollectionInvalid, "collection"))?;
|
||||
let coll = CollectionJobReq::decode(&rc.collection).map_err(|_| (Reason::CollectionInvalid.code(), "collection"))?;
|
||||
let query = coll.interval().map_err(|_| (Reason::CollectionInvalid.code(), "collection"))?;
|
||||
if coll.batch_mode != BATCH_TIME_INTERVAL || query.duration == 0 || query.end().is_none() || !coll.agg_param.is_empty() || !coll.extensions.is_empty() {
|
||||
return Err((Reason::CollectionInvalid, "collection"));
|
||||
return Err((Reason::CollectionInvalid.code(), "collection"));
|
||||
}
|
||||
// 5. Two statements, by the task's two aggregators.
|
||||
if rc.helper.is_empty() || rc.leader.is_empty() {
|
||||
return Err((Reason::AggregatorAlone, "statements"));
|
||||
return Err((Reason::AggregatorAlone.code(), "statements"));
|
||||
}
|
||||
let l = Statement::decode(&rc.leader).map_err(|r| (r, "leader"))?;
|
||||
let h = Statement::decode(&rc.helper).map_err(|r| (r, "helper"))?;
|
||||
let l = Statement::decode(&rc.leader).map_err(at("leader"))?;
|
||||
let h = Statement::decode(&rc.helper).map_err(at("helper"))?;
|
||||
if l.signer == h.signer || l.role == h.role {
|
||||
return Err((Reason::AggregatorAlone, "statements"));
|
||||
return Err((Reason::AggregatorAlone.code(), "statements"));
|
||||
}
|
||||
for (s, want_role, want_key, at) in [(&l, ROLE_LEADER, task.info.leader, "leader"), (&h, ROLE_HELPER, task.info.helper, "helper")] {
|
||||
if s.role != want_role || s.signer != want_key {
|
||||
return Err((Reason::WrongSigner, at));
|
||||
return Err((Reason::WrongSigner.code(), at));
|
||||
}
|
||||
verify_sig(&s.signer, STATEMENT_DOMAIN, &s.body(), &s.sig).map_err(|r| (r, at))?;
|
||||
verify_sig(&s.signer, STATEMENT_DOMAIN, &s.body(), &s.sig).map_err(|r| (r.code(), at))?;
|
||||
}
|
||||
// 6. Both statements are about this task and this collection.
|
||||
let coll_digest = sha256(&[&rc.collection]);
|
||||
for (s, at) in [(&l, "leader"), (&h, "helper")] {
|
||||
if s.task_id != task.id || s.collection != coll_digest {
|
||||
return Err((Reason::TaskMismatch, at));
|
||||
return Err((Reason::TaskMismatch.code(), at));
|
||||
}
|
||||
}
|
||||
// 7. The batch is within the query, and the reports' interval within the batch.
|
||||
for (s, at) in [(&l, "leader"), (&h, "helper")] {
|
||||
if s.batch.duration == 0 || !s.batch.within(&query) {
|
||||
return Err((Reason::BatchInvalid, at));
|
||||
return Err((Reason::BatchInvalid.code(), at));
|
||||
}
|
||||
}
|
||||
if !rc.interval.within(&l.batch) || rc.interval.duration == 0 {
|
||||
return Err((Reason::BatchInvalid, "interval"));
|
||||
return Err((Reason::BatchInvalid.code(), "interval"));
|
||||
}
|
||||
// 8. The statements agree.
|
||||
if l.batch != h.batch || l.report_count != h.report_count || l.checksum != h.checksum {
|
||||
return Err((Reason::StatementsDisagree, "statements"));
|
||||
return Err((Reason::StatementsDisagree.code(), "statements"));
|
||||
}
|
||||
// 9. The batch is at least the task's minimum.
|
||||
if l.report_count < task.config.min_batch_size {
|
||||
return Err((Reason::BatchTooSmall, "statements"));
|
||||
return Err((Reason::BatchTooSmall.code(), "statements"));
|
||||
}
|
||||
// 10. The result is the shares', unsharded.
|
||||
for (s, at) in [(&l, "leader"), (&h, "helper")] {
|
||||
if !task.vdaf.is_agg_share(&s.agg_share) {
|
||||
return Err((Reason::ShareInvalid, at));
|
||||
return Err((Reason::ShareInvalid.code(), at));
|
||||
}
|
||||
}
|
||||
let result = task.vdaf.unshard(&[l.agg_share.clone(), h.agg_share.clone()]).map_err(|_| (Reason::ShareInvalid, "helper"))?;
|
||||
let result = task.vdaf.unshard(&[l.agg_share.clone(), h.agg_share.clone()]).map_err(|_| (Reason::ShareInvalid.code(), "helper"))?;
|
||||
if result != rc.result {
|
||||
return Err((Reason::ResultMismatch, "result"));
|
||||
return Err((Reason::ResultMismatch.code(), "result"));
|
||||
}
|
||||
// 11. The batch's reports, where given.
|
||||
if let Some(reps) = reports {
|
||||
|
|
@ -297,12 +304,12 @@ pub fn verify(bytes: &[u8], reports: Option<&[GivenReport]>) -> Result<Accepted,
|
|||
let mut ids = std::collections::BTreeSet::new();
|
||||
for g in reps {
|
||||
if g.task_id != task.id || !rc.interval.contains(g.time) || !ids.insert(g.id) {
|
||||
return Err((Reason::BatchMismatch, "reports"));
|
||||
return Err((Reason::BatchMismatch.code(), "reports"));
|
||||
}
|
||||
xor32(&mut checksum, &report_checksum(&g.id));
|
||||
}
|
||||
if reps.len() as u64 != l.report_count || checksum != l.checksum {
|
||||
return Err((Reason::BatchMismatch, "reports"));
|
||||
return Err((Reason::BatchMismatch.code(), "reports"));
|
||||
}
|
||||
}
|
||||
Ok(Accepted {
|
||||
|
|
|
|||
759
wai/wai-rs/src/timeliness.rs
Normal file
759
wai/wai-rs/src/timeliness.rs
Normal file
|
|
@ -0,0 +1,759 @@
|
|||
//! Timeliness — `extensions/timeliness.md`, the record `wai.delivery.timeliness`: a publisher's
|
||||
//! signed statement of when each object of a group is of use.
|
||||
//!
|
||||
//! A publisher's `timeliness` claim, whose body, value function and context checks are
|
||||
//! `crate::claim_receipt::timeliness`'s, states for each object the instant it is of use from,
|
||||
//! its deadline, how its value falls after it and the rendition presented in its place. This
|
||||
//! module judges, from holdings records, whether each holder held each object in time
|
||||
//! ([`verdict`], §6), and plans which of an asker's sources delivers each object by its deadline
|
||||
//! ([`plan`], §7).
|
||||
//!
|
||||
//! # Rules enforced here rather than only documented
|
||||
//!
|
||||
//! - Every instant a held-at statement gives is the latest its word allows, so every value a line
|
||||
//! reports is the least the evidence shows; an object with no statement is never on time.
|
||||
//! - A sink is graded by what it held at the deadline (§5.3): a fallback held by then replaces a
|
||||
//! primary that arrives later, whatever that primary's value.
|
||||
//! - Two statements of one object that differ set both aside, as do two records of one holder at
|
||||
//! one instant that differ; a record dated before its group began is refused whole.
|
||||
//! - The held-at statement ([`HeldAt`]) is the family's one form: an extension that derives one
|
||||
//! from another signed record states it in this type and judges it by [`lines`].
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::bearer_receipts::HeldObject;
|
||||
use crate::carousel::{LOSS_BOUND_INVALID, MAX_LOSS_PPM, OVERHEAD_INVALID};
|
||||
use crate::claim_receipt::schedule::LAST_INSTANT_MS;
|
||||
use crate::claim_receipt::timeliness::FULL_PPM;
|
||||
use crate::claim_receipt::{ClaimBody, ClaimReceipt, HeldGroup, Reason as ClaimReason, Rejection, TimelinessEntry};
|
||||
use crate::holdings::{self, Holdings, RecordVerdict};
|
||||
use crate::jwp_delivery::{DeliveryError, GroupReceipt, ObjectReceipt};
|
||||
|
||||
pub mod corpus;
|
||||
|
||||
/// The codes timeliness §9 lists, in its order.
|
||||
pub const CODES: &[&str] = &[
|
||||
"timeliness_not_valued",
|
||||
"timeliness_objects_malformed",
|
||||
"timeliness_window_invalid",
|
||||
"timeliness_decay_invalid",
|
||||
"timeliness_fallback_invalid",
|
||||
"timeliness_subject_mismatch",
|
||||
"timeliness_signer_mismatch",
|
||||
"timeliness_leaf_out_of_range",
|
||||
"timeliness_before_group",
|
||||
"timeliness_content_mismatch",
|
||||
"timeliness_on_layer",
|
||||
"timeliness_fallback_mismatch",
|
||||
"timeliness_fallback_not_derived",
|
||||
CONTRADICTED,
|
||||
HELD_BEFORE_GROUP,
|
||||
DEADLINE_NOT_STATED,
|
||||
INSTANT_INVALID,
|
||||
SOURCES_INVALID,
|
||||
];
|
||||
|
||||
/// The outcomes timeliness §6.3 lists, in its order.
|
||||
pub const OUTCOMES: &[&str] = &["on_time", "replaced", "after_deadline", "not_shown", "no_source"];
|
||||
|
||||
pub const CONTRADICTED: &str = "timeliness_contradicted";
|
||||
pub const HELD_BEFORE_GROUP: &str = "held_before_group";
|
||||
pub const DEADLINE_NOT_STATED: &str = "deadline_not_stated";
|
||||
pub const INSTANT_INVALID: &str = "instant_invalid";
|
||||
pub const SOURCES_INVALID: &str = "sources_invalid";
|
||||
|
||||
/// 2^53 − 1: the greatest `fetch_ms` an asker states (§7.1).
|
||||
pub const MAX_FETCH_MS: u64 = (1 << 53) - 1;
|
||||
|
||||
/// A line's or a choice's outcome (§6.3).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
OnTime,
|
||||
Replaced,
|
||||
AfterDeadline,
|
||||
NotShown,
|
||||
NoSource,
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Outcome::OnTime => "on_time",
|
||||
Outcome::Replaced => "replaced",
|
||||
Outcome::AfterDeadline => "after_deadline",
|
||||
Outcome::NotShown => "not_shown",
|
||||
Outcome::NoSource => "no_source",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A held-at statement (timeliness §2): a key's signed word that it held object `index` of the
|
||||
/// group whose link hash is `group` no later than `instant_ms`. The family's one form: each
|
||||
/// kind of signed record that gives one states it here.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct HeldAt {
|
||||
pub key: [u8; 32],
|
||||
pub group: [u8; 32],
|
||||
pub index: u64,
|
||||
/// The latest instant the signed word allows, in milliseconds, rounded up where the word is
|
||||
/// finer.
|
||||
pub instant_ms: u64,
|
||||
/// The whole seconds of the time the word states, any fraction dropped.
|
||||
pub seconds: u64,
|
||||
}
|
||||
|
||||
/// Whether a held-at statement is before its group (timeliness §2): its whole seconds below the
|
||||
/// seconds read from the group's `group_start`. Such a statement is refused `held_before_group`.
|
||||
pub fn before_group(h: &HeldAt, group_start_s: i64) -> bool {
|
||||
seconds_before(h.seconds, group_start_s)
|
||||
}
|
||||
|
||||
fn seconds_before(seconds: u64, group_start_s: i64) -> bool {
|
||||
i64::try_from(seconds).is_ok_and(|s| s < group_start_s)
|
||||
}
|
||||
|
||||
/// §6.2 step 5: the held-at statements of the records that stand, for the objects asked about,
|
||||
/// each a group's link hash and a position — one for each record that holds each (holdings
|
||||
/// §5.4), at the record's `as_of_ms`. Asking by object keeps the work to records times objects
|
||||
/// whatever a record's leaf counts.
|
||||
pub fn held_from_records(standing: &[&Holdings], objects: &[([u8; 32], u64)]) -> Vec<HeldAt> {
|
||||
let mut out = Vec::new();
|
||||
for h in standing {
|
||||
for (group, index) in objects {
|
||||
if holdings::holds(h, group, *index).is_ok() {
|
||||
out.push(HeldAt { key: h.holder, group: *group, index: *index, instant_ms: h.as_of_ms, seconds: h.as_of_ms / 1000 });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One holder's line for an intent (§6.3).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Line {
|
||||
pub holder: [u8; 32],
|
||||
/// *p*: the least instant of the holder's held-at statements for the primary.
|
||||
pub held_ms: Option<u64>,
|
||||
/// *f*: the least for the fallback.
|
||||
pub fallback_ms: Option<u64>,
|
||||
pub outcome: Outcome,
|
||||
pub value_ppm: u64,
|
||||
}
|
||||
|
||||
/// §6.3: the lines of the entry of an intent of the group whose link hash is `group`, one for
|
||||
/// each of `holders` in increasing order of key bytes, from held-at statements of any kind.
|
||||
pub(crate) fn lines(group: &[u8; 32], entry: &TimelinessEntry, holders: &BTreeSet<[u8; 32]>, held: &[HeldAt]) -> Vec<Line> {
|
||||
let least = |key: &[u8; 32], g: &[u8; 32], i: u64| {
|
||||
held.iter().filter(|h| h.key == *key && h.group == *g && h.index == i).map(|h| h.instant_ms).min()
|
||||
};
|
||||
holders
|
||||
.iter()
|
||||
.map(|key| {
|
||||
let p = least(key, group, entry.leaf_index);
|
||||
let f = entry.fallback.as_ref().and_then(|fb| least(key, &fb.group, fb.leaf_index));
|
||||
let (outcome, value_ppm) = match (p, f, &entry.fallback) {
|
||||
(Some(p), _, _) if p <= entry.by_ms => (Outcome::OnTime, FULL_PPM),
|
||||
(_, Some(f), Some(fb)) if f <= entry.by_ms => (Outcome::Replaced, fb.value_ppm),
|
||||
(Some(p), _, _) => (Outcome::AfterDeadline, entry.value_at(p)),
|
||||
_ => (Outcome::NotShown, 0),
|
||||
};
|
||||
Line { holder: *key, held_ms: p, fallback_ms: f, outcome, value_ppm }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A group receipt given, in its JSON form (jwp-receipts §2.4.1), with the objects held under
|
||||
/// it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct GroupInput {
|
||||
pub group: Value,
|
||||
pub objects: Vec<HeldObject>,
|
||||
}
|
||||
|
||||
/// A statement offered (§6.4): object `index` of the group whose link hash is `group`, for a
|
||||
/// holder.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Statement {
|
||||
pub group: [u8; 32],
|
||||
pub index: u64,
|
||||
pub holder: [u8; 32],
|
||||
}
|
||||
|
||||
/// The inputs of §6.1.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Inputs {
|
||||
/// The groups given, in the verifier's order: a claim about none of them is checked against
|
||||
/// the first.
|
||||
pub groups: Vec<GroupInput>,
|
||||
/// The claims, each signer's in the order of its chain.
|
||||
pub claims: Vec<String>,
|
||||
/// The holdings records, as bytes.
|
||||
pub records: Vec<Vec<u8>>,
|
||||
pub statement: Option<Statement>,
|
||||
}
|
||||
|
||||
/// What became of one claim (§6.2 step 2; §7.2).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ClaimVerdict {
|
||||
/// Refused at a step of jwp-receipts §3.1.
|
||||
Refused(Rejection),
|
||||
/// A timeliness claim accepted.
|
||||
Accepted,
|
||||
/// A transmission claim that stands (a plan's).
|
||||
Declared,
|
||||
/// A schedule that stands (a plan's).
|
||||
Stands,
|
||||
/// A declaration or a schedule accepted and set aside for the reason given (a plan's).
|
||||
SetAside(&'static str),
|
||||
}
|
||||
|
||||
impl ClaimVerdict {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
ClaimVerdict::Refused(r) => format!("reject step={} reason={}", r.step, r.reason.label()),
|
||||
ClaimVerdict::Accepted => "accepted".into(),
|
||||
ClaimVerdict::Declared => "declared".into(),
|
||||
ClaimVerdict::Stands => "stands".into(),
|
||||
ClaimVerdict::SetAside(r) => format!("set-aside reason={r}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The entries of the accepted claims for one object (§6.2 step 3).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Intent {
|
||||
pub group: [u8; 32],
|
||||
pub leaf_index: u64,
|
||||
/// The entry that stands, or why none does (`timeliness_contradicted`).
|
||||
pub standing: Result<TimelinessEntry, &'static str>,
|
||||
/// §6.3; none for an intent set aside, and none in a plan.
|
||||
pub lines: Vec<Line>,
|
||||
}
|
||||
|
||||
/// §6's verdict.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Verdict {
|
||||
/// Each group's objects: `delivered-verified`, `inclusion-verified`, or `reject reason=…`.
|
||||
pub objects: Vec<Vec<String>>,
|
||||
pub claims: Vec<ClaimVerdict>,
|
||||
pub records: Vec<RecordVerdict>,
|
||||
/// By the order of the groups given, then of `leaf_index`.
|
||||
pub intents: Vec<Intent>,
|
||||
/// §6.4: the outcome and value, or why no intent answers it.
|
||||
pub statement: Option<Result<(Outcome, u64), &'static str>>,
|
||||
}
|
||||
|
||||
/// Why no verdict or plan was derived: a group fails jwp-receipts §3 steps 1–2, or an input of
|
||||
/// §7.1 is out of range.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
Group(DeliveryError),
|
||||
Input(&'static str),
|
||||
}
|
||||
|
||||
impl Refusal {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Refusal::Group(e) => e.reason.label(),
|
||||
Refusal::Input(r) => r,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// §6.2 steps 1 to 4, the same for a verdict and a plan.
|
||||
struct Judged {
|
||||
groups: Vec<GroupReceipt>,
|
||||
objects: Vec<Vec<String>>,
|
||||
claims: Vec<ClaimVerdict>,
|
||||
accepted: Vec<Option<ClaimReceipt>>,
|
||||
intents: Vec<Intent>,
|
||||
records: Vec<RecordVerdict>,
|
||||
/// Every record step 4 did not refuse, by its position: the records that stand and those
|
||||
/// set aside.
|
||||
read: BTreeMap<usize, Holdings>,
|
||||
}
|
||||
|
||||
fn judge(groups: &[GroupInput], claims: &[String], records: &[Vec<u8>], plan: bool) -> Result<Judged, Refusal> {
|
||||
// Step 1: the groups, and their objects as bearer-receipts §6.2 step 2 checks them.
|
||||
let mut gs: Vec<GroupReceipt> = Vec::with_capacity(groups.len());
|
||||
let mut receipts: Vec<Vec<(ObjectReceipt, Option<Vec<u8>>)>> = Vec::with_capacity(groups.len());
|
||||
let mut objects: Vec<Vec<String>> = Vec::with_capacity(groups.len());
|
||||
for gi in groups {
|
||||
let group = GroupReceipt::from_json(&gi.group).map_err(Refusal::Group)?;
|
||||
group.verify().map_err(Refusal::Group)?;
|
||||
let mut held = Vec::new();
|
||||
let labels = gi
|
||||
.objects
|
||||
.iter()
|
||||
.map(|o| {
|
||||
let r = match ObjectReceipt::from_json(&o.receipt) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return format!("reject reason={}", e.reason.label()),
|
||||
};
|
||||
let v = r.verify_against(&group, o.envelope.as_deref());
|
||||
held.push((r, o.envelope.clone()));
|
||||
match v {
|
||||
Ok(ev) => ev.label().to_owned(),
|
||||
Err(e) => format!("reject reason={}", e.reason.label()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
gs.push(group);
|
||||
receipts.push(held);
|
||||
objects.push(labels);
|
||||
}
|
||||
let held: Vec<HeldGroup<'_>> = gs
|
||||
.iter()
|
||||
.zip(&receipts)
|
||||
.map(|(g, rs)| HeldGroup { group: g, objects: rs.iter().map(|(r, b)| (r, b.as_deref())).collect() })
|
||||
.collect();
|
||||
|
||||
// Step 2: each claim against the group its subject names, or the first.
|
||||
let mut chain: BTreeMap<[u8; 32], ClaimReceipt> = BTreeMap::new();
|
||||
let mut verdicts: Vec<ClaimVerdict> = Vec::with_capacity(claims.len());
|
||||
let mut accepted: Vec<Option<ClaimReceipt>> = Vec::with_capacity(claims.len());
|
||||
for wire in claims {
|
||||
let checked = (|| {
|
||||
let c = ClaimReceipt::from_json(wire)?;
|
||||
c.verify()?;
|
||||
let previous = chain.insert(c.signer_pubkey, c.clone());
|
||||
c.check_parent(previous.as_ref())?;
|
||||
let at = gs.iter().position(|g| g.link_hash() == c.subject).or((!gs.is_empty()).then_some(0));
|
||||
let refuse = |reason| Err(Rejection { step: 6, reason });
|
||||
match (&c.body, at) {
|
||||
(ClaimBody::Timeliness(_), Some(i)) => c.check_timeliness_against(&gs[i], &held)?,
|
||||
(ClaimBody::Timeliness(_), None) => return refuse(ClaimReason::TimelinessSubjectMismatch),
|
||||
(ClaimBody::Transmission(_), Some(i)) if plan => c.check_transmission_against(&gs[i])?,
|
||||
(ClaimBody::Schedule(_), Some(i)) if plan => c.check_schedule_against(&gs[i], &held[i].objects)?,
|
||||
_ => return refuse(ClaimReason::ContextMismatch),
|
||||
}
|
||||
Ok(c)
|
||||
})();
|
||||
match checked {
|
||||
Ok(c) => {
|
||||
verdicts.push(match &c.body {
|
||||
ClaimBody::Transmission(_) => ClaimVerdict::Declared,
|
||||
ClaimBody::Schedule(_) => ClaimVerdict::Stands,
|
||||
_ => ClaimVerdict::Accepted,
|
||||
});
|
||||
accepted.push(Some(c));
|
||||
}
|
||||
Err(r) => {
|
||||
verdicts.push(ClaimVerdict::Refused(r));
|
||||
accepted.push(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: the intents, by group and position.
|
||||
let mut stated: BTreeMap<(usize, u64), Vec<&TimelinessEntry>> = BTreeMap::new();
|
||||
for c in accepted.iter().flatten() {
|
||||
let Some(t) = c.timeliness() else { continue };
|
||||
let gi = gs.iter().position(|g| g.link_hash() == c.subject).expect("an accepted statement names a group given");
|
||||
for e in &t.objects {
|
||||
stated.entry((gi, e.leaf_index)).or_default().push(e);
|
||||
}
|
||||
}
|
||||
let intents: Vec<Intent> = stated
|
||||
.into_iter()
|
||||
.map(|((gi, leaf_index), es)| Intent {
|
||||
group: gs[gi].link_hash(),
|
||||
leaf_index,
|
||||
standing: if es.iter().all(|e| *e == es[0]) { Ok(es[0].clone()) } else { Err(CONTRADICTED) },
|
||||
lines: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Step 4: the records, each refused whole where it misstates a group given.
|
||||
let given: BTreeMap<[u8; 32], (u64, Option<i64>)> = gs
|
||||
.iter()
|
||||
.map(|g| (g.link_hash(), (g.leaf_count, crate::claim_receipt::rfc3339_seconds(&g.group_start))))
|
||||
.collect();
|
||||
let mut out: Vec<RecordVerdict> = Vec::with_capacity(records.len());
|
||||
let mut read: BTreeMap<usize, Holdings> = BTreeMap::new();
|
||||
for (k, bytes) in records.iter().enumerate() {
|
||||
let v = Holdings::read(bytes).and_then(|h| {
|
||||
if h.entries.iter().any(|e| given.get(&e.group).is_some_and(|&(n, _)| n != e.leaf_count)) {
|
||||
return Err(holdings::LEAF_COUNT_MISMATCH);
|
||||
}
|
||||
let start = |e: &holdings::Entry| given.get(&e.group).and_then(|&(_, s)| s);
|
||||
if h.entries.iter().any(|e| start(e).is_some_and(|s| seconds_before(h.as_of_ms / 1000, s))) {
|
||||
return Err(HELD_BEFORE_GROUP);
|
||||
}
|
||||
Ok(h)
|
||||
});
|
||||
match v {
|
||||
Ok(h) => {
|
||||
read.insert(k, h);
|
||||
out.push(RecordVerdict::Stands);
|
||||
}
|
||||
Err(r) => out.push(RecordVerdict::Refused(r)),
|
||||
}
|
||||
}
|
||||
let mut at_instant: BTreeMap<([u8; 32], u64), Vec<usize>> = BTreeMap::new();
|
||||
for (k, h) in &read {
|
||||
at_instant.entry((h.holder, h.as_of_ms)).or_default().push(*k);
|
||||
}
|
||||
for ks in at_instant.values() {
|
||||
if ks.iter().any(|k| read[k].digest() != read[&ks[0]].digest()) {
|
||||
for k in ks {
|
||||
out[*k] = RecordVerdict::SetAside(holdings::CONTRADICTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Judged { groups: gs, objects, claims: verdicts, accepted, intents, records: out, read })
|
||||
}
|
||||
|
||||
/// §6.2: the verdict.
|
||||
pub fn verdict(inp: &Inputs) -> Result<Verdict, Refusal> {
|
||||
let mut j = judge(&inp.groups, &inp.claims, &inp.records, false)?;
|
||||
// Step 5: the held-at statements of the records that stand, for the objects the intents
|
||||
// name.
|
||||
let standing: Vec<&Holdings> =
|
||||
j.read.iter().filter(|(k, _)| j.records[**k] == RecordVerdict::Stands).map(|(_, h)| h).collect();
|
||||
let mut asked: Vec<([u8; 32], u64)> = Vec::new();
|
||||
for i in &j.intents {
|
||||
if let Ok(e) = &i.standing {
|
||||
asked.push((i.group, i.leaf_index));
|
||||
if let Some(f) = &e.fallback {
|
||||
asked.push((f.group, f.leaf_index));
|
||||
}
|
||||
}
|
||||
}
|
||||
let held = held_from_records(&standing, &asked);
|
||||
// Step 6: the lines, for each holder with a record that stands and an entry for the intent's
|
||||
// group or its fallback's.
|
||||
for i in &mut j.intents {
|
||||
let Ok(e) = &i.standing else { continue };
|
||||
let groups: Vec<[u8; 32]> = std::iter::once(i.group).chain(e.fallback.map(|f| f.group)).collect();
|
||||
let holders: BTreeSet<[u8; 32]> =
|
||||
standing.iter().filter(|h| groups.iter().any(|g| h.entry(g).is_some())).map(|h| h.holder).collect();
|
||||
i.lines = lines(&i.group, e, &holders, &held);
|
||||
}
|
||||
// §6.4.
|
||||
let statement = inp.statement.map(|s| -> Result<(Outcome, u64), &'static str> {
|
||||
let intent = j.intents.iter().find(|i| i.group == s.group && i.leaf_index == s.index && i.standing.is_ok());
|
||||
let intent = intent.ok_or(DEADLINE_NOT_STATED)?;
|
||||
Ok(intent.lines.iter().find(|l| l.holder == s.holder).map_or((Outcome::NotShown, 0), |l| (l.outcome, l.value_ppm)))
|
||||
});
|
||||
Ok(Verdict { objects: j.objects, claims: j.claims, records: j.records, intents: j.intents, statement })
|
||||
}
|
||||
|
||||
/// The instant a plan is made from (§7.1).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum At {
|
||||
/// One instant *t* for every object.
|
||||
Instant(u64),
|
||||
/// Each object from its own entry's `from_ms`.
|
||||
FromMs,
|
||||
}
|
||||
|
||||
/// One of an asker's sources (§7.1).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
/// A bearer instance, by its `bearer_id`.
|
||||
Bearer(String),
|
||||
/// A holder, by its key, with the asker's estimate of a fetch from it.
|
||||
Holder { key: [u8; 32], fetch_ms: u64 },
|
||||
/// The origin, with the asker's estimate of a fetch from it.
|
||||
Origin { fetch_ms: u64 },
|
||||
}
|
||||
|
||||
/// §7.1: the sources, each a JSON object of `kind` `bearer` with `bearer_id`, `holder` with
|
||||
/// `holder` and `fetch_ms`, or `origin` with `fetch_ms`, and no other member; `loss` whether *L*
|
||||
/// and *h* are given.
|
||||
pub fn sources(v: &[Value], loss: bool) -> Result<Vec<Source>, &'static str> {
|
||||
if v.is_empty() {
|
||||
return Err(SOURCES_INVALID);
|
||||
}
|
||||
let mut out: Vec<Source> = Vec::with_capacity(v.len());
|
||||
for s in v {
|
||||
let m = s.as_object().ok_or(SOURCES_INVALID)?;
|
||||
let exactly = |want: &[&str]| m.len() == want.len() && want.iter().all(|k| m.contains_key(*k));
|
||||
let fetch = || m["fetch_ms"].as_u64().filter(|n| (1..=MAX_FETCH_MS).contains(n)).ok_or(SOURCES_INVALID);
|
||||
let source = match m.get("kind").and_then(Value::as_str) {
|
||||
Some("bearer") if exactly(&["kind", "bearer_id"]) => {
|
||||
let id = m["bearer_id"].as_str().ok_or(SOURCES_INVALID)?;
|
||||
crate::claim_receipt::uri_ok(id).map_err(|_| SOURCES_INVALID)?;
|
||||
Source::Bearer(id.to_owned())
|
||||
}
|
||||
Some("holder") if exactly(&["kind", "holder", "fetch_ms"]) => {
|
||||
let key = m["holder"].as_str().filter(|k| k.len() == 64).and_then(crate::claim_receipt::unhex);
|
||||
let key: [u8; 32] = key.and_then(|k| k.try_into().ok()).ok_or(SOURCES_INVALID)?;
|
||||
Source::Holder { key, fetch_ms: fetch()? }
|
||||
}
|
||||
Some("origin") if exactly(&["kind", "fetch_ms"]) => Source::Origin { fetch_ms: fetch()? },
|
||||
_ => return Err(SOURCES_INVALID),
|
||||
};
|
||||
let twice = out.iter().any(|o| match (o, &source) {
|
||||
(Source::Bearer(a), Source::Bearer(b)) => a == b,
|
||||
(Source::Holder { key: a, .. }, Source::Holder { key: b, .. }) => a == b,
|
||||
(Source::Origin { .. }, Source::Origin { .. }) => true,
|
||||
_ => false,
|
||||
});
|
||||
if twice {
|
||||
return Err(SOURCES_INVALID);
|
||||
}
|
||||
out.push(source);
|
||||
}
|
||||
if !loss && out.iter().any(|s| matches!(s, Source::Bearer(_))) {
|
||||
return Err(SOURCES_INVALID);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The inputs of §7.1.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PlanInputs {
|
||||
pub groups: Vec<GroupInput>,
|
||||
/// The timeliness, transmission and schedule claims, each signer's in the order of its
|
||||
/// chain.
|
||||
pub claims: Vec<String>,
|
||||
pub records: Vec<Vec<u8>>,
|
||||
/// *L* and *h*, given together or not at all.
|
||||
pub loss: Option<(u64, u64)>,
|
||||
pub at: At,
|
||||
/// The asker's sources in its order, as [`sources`] reads them.
|
||||
pub sources: Vec<Value>,
|
||||
/// The wanted objects: a group's link hash and a position.
|
||||
pub wanted: Vec<([u8; 32], u64)>,
|
||||
}
|
||||
|
||||
/// Which object a choice takes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Which {
|
||||
Primary,
|
||||
Fallback,
|
||||
}
|
||||
|
||||
impl Which {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Which::Primary => "primary",
|
||||
Which::Fallback => "fallback",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// §7.3 for one wanted object.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Choice {
|
||||
pub outcome: Outcome,
|
||||
pub object: Option<Which>,
|
||||
/// The source's index in the asker's list.
|
||||
pub source: Option<usize>,
|
||||
pub arrival_ms: Option<u64>,
|
||||
pub value_ppm: u64,
|
||||
}
|
||||
|
||||
/// §7's plan.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PlanVerdict {
|
||||
pub claims: Vec<ClaimVerdict>,
|
||||
pub records: Vec<RecordVerdict>,
|
||||
/// Each wanted object's choice, or why it has none (`deadline_not_stated`).
|
||||
pub choices: Vec<Result<Choice, &'static str>>,
|
||||
}
|
||||
|
||||
/// Holdings §5.1 and §5.3 at `s` over the records §6.2 step 4 did not refuse: the record of
|
||||
/// `key` that stands at `s`, if one does.
|
||||
fn record_at<'a>(read: &'a BTreeMap<usize, Holdings>, key: &[u8; 32], s: u64) -> Option<&'a Holdings> {
|
||||
let live: Vec<&Holdings> = read.values().filter(|h| h.holder == *key && h.as_of_ms <= s && s < h.expires_ms).collect();
|
||||
let latest = live.iter().map(|h| h.as_of_ms).max()?;
|
||||
let at: Vec<&Holdings> = live.into_iter().filter(|h| h.as_of_ms == latest).collect();
|
||||
at.iter().all(|h| h.digest() == at[0].digest()).then_some(at[0])
|
||||
}
|
||||
|
||||
/// §7.
|
||||
pub fn plan(inp: &PlanInputs) -> Result<PlanVerdict, Refusal> {
|
||||
if let Some((l, h)) = inp.loss {
|
||||
if l > MAX_LOSS_PPM {
|
||||
return Err(Refusal::Input(LOSS_BOUND_INVALID));
|
||||
}
|
||||
if h > 2 {
|
||||
return Err(Refusal::Input(OVERHEAD_INVALID));
|
||||
}
|
||||
}
|
||||
if let At::Instant(t) = inp.at
|
||||
&& t > LAST_INSTANT_MS
|
||||
{
|
||||
return Err(Refusal::Input(INSTANT_INVALID));
|
||||
}
|
||||
let sources = sources(&inp.sources, inp.loss.is_some()).map_err(Refusal::Input)?;
|
||||
let j = judge(&inp.groups, &inp.claims, &inp.records, true)?;
|
||||
|
||||
// §7.2: the declarations and schedules of each group given (bearer-receipts §6.2 step 4;
|
||||
// carousel §6.2 step 4).
|
||||
let mut claims = j.claims.clone();
|
||||
let mut schedules: BTreeMap<[u8; 32], BTreeMap<String, usize>> = BTreeMap::new();
|
||||
for g in &j.groups {
|
||||
let link = g.link_hash();
|
||||
if schedules.contains_key(&link) {
|
||||
continue;
|
||||
}
|
||||
let of_group: Vec<Option<ClaimReceipt>> = j
|
||||
.accepted
|
||||
.iter()
|
||||
.map(|c| c.as_ref().filter(|c| c.subject == link && (c.transmission().is_some() || c.schedule().is_some())).cloned())
|
||||
.collect();
|
||||
let (aside, standing) = crate::carousel::standing(&of_group);
|
||||
for (i, reason) in aside {
|
||||
claims[i] = ClaimVerdict::SetAside(reason);
|
||||
}
|
||||
schedules.insert(link, standing);
|
||||
}
|
||||
|
||||
let arrival = |source: &Source, group: &[u8; 32], index: u64, s: u64| -> Option<u64> {
|
||||
match source {
|
||||
Source::Bearer(id) => {
|
||||
let &c = schedules.get(group)?.get(id)?;
|
||||
let e = j.accepted[c].as_ref()?.schedule()?.entry(index)?;
|
||||
let (l, h) = inp.loss?;
|
||||
let blocks: u64 = e.oti.blocks().iter().map(|b| b.blocks).sum();
|
||||
crate::carousel::plan(e, s, l, h, &vec![0; blocks as usize]).ok()?.complete_ms
|
||||
}
|
||||
Source::Holder { key, fetch_ms } => {
|
||||
let r = record_at(&j.read, key, s)?;
|
||||
holdings::holds(r, group, index).ok()?;
|
||||
Some(s + fetch_ms)
|
||||
}
|
||||
Source::Origin { fetch_ms } => Some(s + fetch_ms),
|
||||
}
|
||||
};
|
||||
|
||||
// §7.3.
|
||||
let mut choices = Vec::with_capacity(inp.wanted.len());
|
||||
for (group, index) in &inp.wanted {
|
||||
let intent = j.intents.iter().find(|i| i.group == *group && i.leaf_index == *index);
|
||||
let Some(Ok(e)) = intent.map(|i| &i.standing) else {
|
||||
choices.push(Err(DEADLINE_NOT_STATED));
|
||||
continue;
|
||||
};
|
||||
let s = match inp.at {
|
||||
At::Instant(t) => t,
|
||||
At::FromMs => e.from_ms,
|
||||
};
|
||||
let primary: Vec<Option<u64>> = sources.iter().map(|src| arrival(src, group, *index, s)).collect();
|
||||
let take = |outcome, object, source: usize, arrival: u64, value_ppm| Choice {
|
||||
outcome,
|
||||
object: Some(object),
|
||||
source: Some(source),
|
||||
arrival_ms: Some(arrival),
|
||||
value_ppm,
|
||||
};
|
||||
if let Some(k) = primary.iter().position(|a| a.is_some_and(|a| a <= e.by_ms)) {
|
||||
choices.push(Ok(take(Outcome::OnTime, Which::Primary, k, primary[k].expect("an arrival"), FULL_PPM)));
|
||||
continue;
|
||||
}
|
||||
let best = primary.iter().flatten().map(|&a| e.value_at(a)).max().unwrap_or(0);
|
||||
if let Some(f) = &e.fallback
|
||||
&& f.value_ppm >= best
|
||||
{
|
||||
let fallback: Vec<Option<u64>> = sources.iter().map(|src| arrival(src, &f.group, f.leaf_index, s)).collect();
|
||||
if let Some(k) = fallback.iter().position(|a| a.is_some_and(|a| a <= e.by_ms)) {
|
||||
choices.push(Ok(take(Outcome::Replaced, Which::Fallback, k, fallback[k].expect("an arrival"), f.value_ppm)));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if best >= 1 {
|
||||
let k = primary.iter().position(|a| a.is_some_and(|a| e.value_at(a) == best)).expect("the best value's source");
|
||||
choices.push(Ok(take(Outcome::AfterDeadline, Which::Primary, k, primary[k].expect("an arrival"), best)));
|
||||
continue;
|
||||
}
|
||||
choices.push(Ok(Choice { outcome: Outcome::NoSource, object: None, source: None, arrival_ms: None, value_ppm: 0 }));
|
||||
}
|
||||
Ok(PlanVerdict { claims, records: j.records, choices })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::claim_receipt::DecayPoint;
|
||||
|
||||
const D: u64 = 1_793_664_060_000;
|
||||
|
||||
fn entry(fallback: Option<crate::claim_receipt::Fallback>) -> TimelinessEntry {
|
||||
TimelinessEntry {
|
||||
leaf_index: 0,
|
||||
content_hash: [1; 32],
|
||||
from_ms: D - 60_000,
|
||||
by_ms: D,
|
||||
decay: vec![DecayPoint { at_ms: D + 2_000, value_ppm: 600_000 }, DecayPoint { at_ms: D + 10_000, value_ppm: 0 }],
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
|
||||
fn at(key: u8, group: [u8; 32], index: u64, instant_ms: u64) -> HeldAt {
|
||||
HeldAt { key: [key; 32], group, index, instant_ms, seconds: instant_ms / 1000 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_grades_what_was_held_at_the_deadline() {
|
||||
let (g, f) = ([7; 32], [8; 32]);
|
||||
let e = entry(Some(crate::claim_receipt::Fallback { group: f, leaf_index: 0, content_hash: [2; 32], value_ppm: 400_000 }));
|
||||
let holders: BTreeSet<[u8; 32]> = [[1; 32], [2; 32], [3; 32], [4; 32], [5; 32]].into_iter().collect();
|
||||
let held = [
|
||||
// 1: on time, its later statement ignored.
|
||||
at(1, g, 0, D + 5_000),
|
||||
at(1, g, 0, D),
|
||||
// 2: a fallback by the deadline before a late primary of higher value.
|
||||
at(2, g, 0, D + 1_000),
|
||||
at(2, f, 0, D),
|
||||
// 3: a fallback after the deadline: the late primary's value.
|
||||
at(3, f, 0, D + 1),
|
||||
at(3, g, 0, D + 2_000),
|
||||
// 4: a fallback after the deadline alone.
|
||||
at(4, f, 0, D + 1),
|
||||
// 5 holds nothing of either.
|
||||
at(5, g, 1, D),
|
||||
];
|
||||
let got: Vec<(Outcome, u64)> = lines(&g, &e, &holders, &held).iter().map(|l| (l.outcome, l.value_ppm)).collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
(Outcome::OnTime, 1_000_000),
|
||||
(Outcome::Replaced, 400_000),
|
||||
(Outcome::AfterDeadline, 600_000),
|
||||
(Outcome::NotShown, 0),
|
||||
(Outcome::NotShown, 0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_at_statement_before_its_group_is_by_whole_seconds() {
|
||||
let start = 1_793_664_000;
|
||||
assert!(!before_group(&at(1, [0; 32], 0, 1_793_664_000_000), start), "the group's own second");
|
||||
assert!(!before_group(&at(1, [0; 32], 0, 1_793_664_000_999), start));
|
||||
assert!(before_group(&at(1, [0; 32], 0, 1_793_663_999_999), start), "a millisecond before it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sources_are_held_to_section_7_1() {
|
||||
let key = "ab".repeat(32);
|
||||
let ok = |v: Value, loss: bool| sources(v.as_array().expect("a list"), loss);
|
||||
let good = serde_json::json!([
|
||||
{"kind": "bearer", "bearer_id": "urn:example:bearer:b1"},
|
||||
{"kind": "holder", "holder": key, "fetch_ms": 100},
|
||||
{"kind": "origin", "fetch_ms": MAX_FETCH_MS},
|
||||
]);
|
||||
assert_eq!(ok(good.clone(), true).map(|s| s.len()), Ok(3));
|
||||
assert_eq!(ok(good, false), Err(SOURCES_INVALID), "a bearer without L and h");
|
||||
for bad in [
|
||||
serde_json::json!([]),
|
||||
serde_json::json!([{"kind": "origin", "fetch_ms": 0}]),
|
||||
serde_json::json!([{"kind": "origin", "fetch_ms": MAX_FETCH_MS + 1}]),
|
||||
serde_json::json!([{"kind": "origin"}]),
|
||||
serde_json::json!([{"kind": "origin", "fetch_ms": 1, "note": 1}]),
|
||||
serde_json::json!([{"kind": "origin", "fetch_ms": 1}, {"kind": "origin", "fetch_ms": 2}]),
|
||||
serde_json::json!([{"kind": "holder", "holder": key.to_uppercase(), "fetch_ms": 1}]),
|
||||
serde_json::json!([{"kind": "holder", "holder": key, "fetch_ms": 1}, {"kind": "holder", "holder": key, "fetch_ms": 2}]),
|
||||
serde_json::json!([{"kind": "bearer", "bearer_id": "b1"}]),
|
||||
serde_json::json!([{"kind": "peer", "fetch_ms": 1}]),
|
||||
] {
|
||||
assert_eq!(ok(bad.clone(), true), Err(SOURCES_INVALID), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
1523
wai/wai-rs/src/timeliness/corpus.rs
Normal file
1523
wai/wai-rs/src/timeliness/corpus.rs
Normal file
File diff suppressed because it is too large
Load diff
65
wai/wai-rs/tests/cache_authority.rs
Normal file
65
wai/wai-rs/tests/cache_authority.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
//! `wai.delivery.cache` (extensions/cache-authority.md): the committed corpus
|
||||
//! `live-conformance/cache-authority/` regenerates byte for byte from its fixed seeds, holds no
|
||||
//! other file, every case in it gets the outcome it states, §11 lists exactly the reasons the
|
||||
//! reference reports, and jwp-receipts §3.1 lists the class codes at their steps.
|
||||
#![cfg(feature = "cache_authority")]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use wai::cache_authority::{corpus, CODES};
|
||||
|
||||
fn wai_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
|
||||
}
|
||||
|
||||
fn committed() -> BTreeMap<String, String> {
|
||||
let dir = wai_dir().join("live-conformance/cache-authority");
|
||||
std::fs::read_dir(&dir)
|
||||
.expect("the committed corpus")
|
||||
.flatten()
|
||||
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
|
||||
.filter(|(n, _)| n != "README.md")
|
||||
.map(|(n, p)| (n, std::fs::read_to_string(p).expect("a text file")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_committed_corpus_regenerates_from_its_seeds() {
|
||||
let files = committed();
|
||||
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
|
||||
let extra: Vec<&String> = files.keys().filter(|k| !generated.contains_key(*k)).collect();
|
||||
let missing: Vec<&String> = generated.keys().filter(|k| !files.contains_key(*k)).collect();
|
||||
let differ: Vec<&String> = generated.iter().filter(|(k, v)| files.get(*k).is_some_and(|f| f != *v)).map(|(k, _)| k).collect();
|
||||
assert!(extra.is_empty() && missing.is_empty() && differ.is_empty(), "extra {extra:?}, missing {missing:?}, differ {differ:?}: run `wai_cache_authority_vectors gen`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_case_gets_its_stated_outcome() {
|
||||
let fails = corpus::verify(&committed());
|
||||
assert!(fails.is_empty(), "{}", fails.join("\n"));
|
||||
}
|
||||
|
||||
/// §11 of the extension lists exactly the codes `CODES` has, each once, in order.
|
||||
#[test]
|
||||
fn the_extension_lists_the_reasons() {
|
||||
let text = std::fs::read_to_string(wai_dir().join("extensions/cache-authority.md")).expect("the extension");
|
||||
let at = text.find("\n## 11. Reasons").expect("a §11");
|
||||
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
|
||||
let listed: Vec<String> =
|
||||
text[at..end].lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect();
|
||||
let want: Vec<String> = CODES.iter().map(|c| c.to_string()).collect();
|
||||
assert_eq!(listed, want, "§11's table and CODES differ");
|
||||
}
|
||||
|
||||
/// jwp-receipts §3.1's table lists the class's codes: its rules at step 4, its checks against the
|
||||
/// group and its objects at step 6.
|
||||
#[test]
|
||||
fn jwp_receipts_lists_the_class_codes() {
|
||||
let text = std::fs::read_to_string(wai_dir().join("extensions/jwp-receipts.md")).expect("jwp-receipts");
|
||||
let class: Vec<&str> = CODES.iter().copied().filter(|c| c.starts_with("cache_transmission_")).collect();
|
||||
assert_eq!(class.len(), 8, "the class's codes");
|
||||
for (k, code) in class.iter().enumerate() {
|
||||
let step = if k < 3 { 4 } else { 6 };
|
||||
assert!(text.contains(&format!("| {step} | `{code}` |")), "jwp-receipts §3.1 does not list {code} at step {step}");
|
||||
}
|
||||
}
|
||||
95
wai/wai-rs/tests/impression_aggregate.rs
Normal file
95
wai/wai-rs/tests/impression_aggregate.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
//! `wai.delivery.impressions` (extensions/impression-aggregates.md): the committed corpus
|
||||
//! `live-conformance/impressions/` regenerates byte for byte from its fixed seeds, holds no other
|
||||
//! file, every case in it gets the outcome it states, §10 lists exactly the reasons the reference
|
||||
//! reports, each standing receipt's result is its contributions' tally and stitched presentation's
|
||||
//! own count of the flight's claims (§8.11), and the size bounds of §5.4 and §6.4 decide a
|
||||
//! file's outcome at their edge.
|
||||
#![cfg(feature = "impression_aggregate")]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use wai::impression_aggregate::{
|
||||
accept, corpus, encode_hpke_list, TaskFile, UploadFile, CATALOGUE_MALFORMED, CODES, MAX_TASK_FILE, MAX_UPLOAD, TASK_FILE_MALFORMED, UPLOAD_MALFORMED,
|
||||
};
|
||||
use wai::telemetry_aggregate::dap::{HpkeCiphertext, HpkeConfig, Report, ReportMetadata};
|
||||
|
||||
fn wai_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
|
||||
}
|
||||
|
||||
fn committed() -> BTreeMap<String, String> {
|
||||
let dir = wai_dir().join("live-conformance/impressions");
|
||||
std::fs::read_dir(&dir)
|
||||
.expect("the committed corpus")
|
||||
.flatten()
|
||||
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
|
||||
.filter(|(n, _)| n != "README.md")
|
||||
.map(|(n, p)| (n, std::fs::read_to_string(p).expect("a text file")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_committed_corpus_regenerates_from_its_seeds() {
|
||||
let files = committed();
|
||||
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
|
||||
let extra: Vec<&String> = files.keys().filter(|k| !generated.contains_key(*k)).collect();
|
||||
let missing: Vec<&String> = generated.keys().filter(|k| !files.contains_key(*k)).collect();
|
||||
let differ: Vec<&String> = generated.iter().filter(|(k, v)| files.get(*k).is_some_and(|f| f != *v)).map(|(k, _)| k).collect();
|
||||
assert!(extra.is_empty() && missing.is_empty() && differ.is_empty(), "extra {extra:?}, missing {missing:?}, differ {differ:?}: run `wai_impression_aggregate_vectors gen`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_case_gets_its_stated_outcome() {
|
||||
let fails = corpus::verify(&committed());
|
||||
assert!(fails.is_empty(), "{}", fails.join("\n"));
|
||||
}
|
||||
|
||||
/// §8.11: each standing receipt's result is the plaintext tally of its contributions, and the
|
||||
/// flight's table gives per creative what stitched presentation's verifier gives.
|
||||
#[test]
|
||||
fn aggregation_reproduces_the_plaintext_and_stitched_counts() {
|
||||
let fails = corpus::equivalence();
|
||||
assert!(fails.is_empty(), "{}", fails.join("\n"));
|
||||
}
|
||||
|
||||
/// §10 of the extension lists exactly the codes `CODES` has, each once, in order.
|
||||
#[test]
|
||||
fn the_extension_lists_the_reasons() {
|
||||
let text = std::fs::read_to_string(wai_dir().join("extensions/impression-aggregates.md")).expect("the extension");
|
||||
let at = text.find("\n## 10. Reasons").expect("a §10");
|
||||
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
|
||||
let listed: Vec<String> =
|
||||
text[at..end].lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect();
|
||||
let want: Vec<String> = CODES.iter().map(|c| c.to_string()).collect();
|
||||
assert_eq!(listed, want, "§10's table and CODES differ");
|
||||
}
|
||||
|
||||
/// The size bounds of §5.4 and §6.4, which no vector carries. A file of exactly the bound that
|
||||
/// reads in every other respect is read: a task file passes step 1 and is refused at step 2,
|
||||
/// where its catalogue of zeros does not read, and an upload file reads. The same file one byte
|
||||
/// longer is refused at step 1, so a reader without the bound, or with another, fails here.
|
||||
/// §4.3's bound decides no outcome: no catalogue that meets §4.3 comes near it.
|
||||
#[test]
|
||||
fn the_size_bounds_decide_at_their_edge() {
|
||||
let list = |id: u8| encode_hpke_list(&[HpkeConfig { id, kem: 0x0020, kdf: 0x0001, aead: 0x0001, public_key: vec![9; 32] }]);
|
||||
let task_file = |catalogue: usize| {
|
||||
let f = TaskFile { task: vec![], catalogue: vec![0; catalogue], leader_hpke: list(1), helper_hpke: list(2), signer: [0; 32], signer_id: "x".into(), sig: [0; 64] };
|
||||
f.to_bytes()
|
||||
};
|
||||
let at = MAX_TASK_FILE - task_file(0).len();
|
||||
let (inside, past) = (task_file(at), task_file(at + 1));
|
||||
assert_eq!((inside.len(), past.len()), (MAX_TASK_FILE, MAX_TASK_FILE + 1));
|
||||
assert_eq!(accept(&inside, &[], &[], None).err(), Some(CATALOGUE_MALFORMED), "a task file at the bound reaches step 2");
|
||||
assert_eq!(accept(&past, &[], &[], None).err(), Some(TASK_FILE_MALFORMED), "one byte past it is refused at step 1");
|
||||
let ct = HpkeCiphertext { config_id: 1, enc: vec![1], payload: vec![1] };
|
||||
let upload = |share: usize| {
|
||||
let metadata = ReportMetadata { id: [0; 16], time: 0, public_extensions: vec![] };
|
||||
let r = Report { metadata, public_share: vec![0; share], leader_share: ct.clone(), helper_share: ct.clone() };
|
||||
UploadFile { task_id: [0; 32], reports: vec![r] }.encode()
|
||||
};
|
||||
let at = MAX_UPLOAD - upload(0).len();
|
||||
let (inside, past) = (upload(at), upload(at + 1));
|
||||
assert_eq!((inside.len(), past.len()), (MAX_UPLOAD, MAX_UPLOAD + 1));
|
||||
assert_eq!(UploadFile::parse(&inside).map(|f| f.reports.len()).ok(), Some(1), "an upload file at the bound reads");
|
||||
assert_eq!(UploadFile::parse(&past).err(), Some(UPLOAD_MALFORMED), "one byte past it is refused");
|
||||
}
|
||||
70
wai/wai-rs/tests/rung_price.rs
Normal file
70
wai/wai-rs/tests/rung_price.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
//! `wai.delivery.rung_price` (extensions/rung-price.md): the committed corpus
|
||||
//! `live-conformance/prices/` regenerates byte for byte from its fixed seeds, holds no other file,
|
||||
//! every case in it gets the outcome it states, §8 lists exactly the reasons the reference reports
|
||||
//! and §6.2 exactly its limits, in order.
|
||||
#![cfg(feature = "rung_price")]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use wai::rung_price::{corpus, CODES, LIMITS};
|
||||
|
||||
fn wai_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
|
||||
}
|
||||
|
||||
fn committed() -> BTreeMap<String, String> {
|
||||
let dir = wai_dir().join("live-conformance/prices");
|
||||
std::fs::read_dir(&dir)
|
||||
.expect("the committed corpus")
|
||||
.flatten()
|
||||
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
|
||||
.filter(|(n, _)| n != "README.md")
|
||||
.map(|(n, p)| (n, std::fs::read_to_string(p).expect("a text file")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The text of the section of `text` that starts at the line `heading`, to the next heading of
|
||||
/// the same level.
|
||||
fn section<'a>(text: &'a str, heading: &str) -> &'a str {
|
||||
let at = text.find(&format!("\n{heading}")).unwrap_or_else(|| panic!("no {heading}"));
|
||||
let level = heading.split(' ').next().expect("a heading's hashes");
|
||||
let end = text[at + 1..].find(&format!("\n{level} ")).map_or(text.len(), |e| at + 1 + e);
|
||||
&text[at..end]
|
||||
}
|
||||
|
||||
/// The first code of each table row of a section: `| \`code\` | …`.
|
||||
fn listed(section: &str) -> Vec<String> {
|
||||
section.lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_committed_corpus_regenerates_from_its_seeds() {
|
||||
let files = committed();
|
||||
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
|
||||
let extra: Vec<&String> = files.keys().filter(|k| !generated.contains_key(*k)).collect();
|
||||
let missing: Vec<&String> = generated.keys().filter(|k| !files.contains_key(*k)).collect();
|
||||
let differ: Vec<&String> = generated.iter().filter(|(k, v)| files.get(*k).is_some_and(|f| f != *v)).map(|(k, _)| k).collect();
|
||||
assert!(extra.is_empty() && missing.is_empty() && differ.is_empty(), "extra {extra:?}, missing {missing:?}, differ {differ:?}: run `wai_rung_price_vectors gen`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_case_gets_its_stated_outcome() {
|
||||
let fails = corpus::verify(&committed());
|
||||
assert!(fails.is_empty(), "{}", fails.join("\n"));
|
||||
}
|
||||
|
||||
/// §8 of the extension lists exactly the codes `CODES` has, each once, in order.
|
||||
#[test]
|
||||
fn the_extension_lists_the_reasons() {
|
||||
let text = std::fs::read_to_string(wai_dir().join("extensions/rung-price.md")).expect("the extension");
|
||||
let want: Vec<String> = CODES.iter().map(|c| c.to_string()).collect();
|
||||
assert_eq!(listed(section(&text, "## 8. Reasons")), want, "§8's table and CODES differ");
|
||||
}
|
||||
|
||||
/// §6.2's table lists exactly the limits `LIMITS` has, in order.
|
||||
#[test]
|
||||
fn the_extension_lists_the_limits() {
|
||||
let text = std::fs::read_to_string(wai_dir().join("extensions/rung-price.md")).expect("the extension");
|
||||
let want: Vec<String> = LIMITS.iter().map(|c| c.to_string()).collect();
|
||||
assert_eq!(listed(section(&text, "### 6.2 Candidates and removals")), want, "§6.2's table and LIMITS differ");
|
||||
}
|
||||
98
wai/wai-rs/tests/timeliness.rs
Normal file
98
wai/wai-rs/tests/timeliness.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
//! `wai.delivery.timeliness` (extensions/timeliness.md): the committed corpus
|
||||
//! `live-conformance/timeliness/` regenerates byte for byte from its fixed seeds, holds no other
|
||||
//! file, every case in it gets the outcome it states, §9 lists exactly the reasons the reference
|
||||
//! reports and §6.3 exactly its outcomes, jwp-receipts §3.1 lists the class codes at their steps,
|
||||
//! and delivery-telemetry §5.1 lists the switch reason of §8.6.
|
||||
#![cfg(feature = "timeliness")]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use wai::timeliness::{corpus, CODES, OUTCOMES};
|
||||
|
||||
fn wai_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
|
||||
}
|
||||
|
||||
fn read(path: &str) -> String {
|
||||
std::fs::read_to_string(wai_dir().join(path)).unwrap_or_else(|e| panic!("{path}: {e}"))
|
||||
}
|
||||
|
||||
fn committed() -> BTreeMap<String, String> {
|
||||
let dir = wai_dir().join("live-conformance/timeliness");
|
||||
std::fs::read_dir(&dir)
|
||||
.expect("the committed corpus")
|
||||
.flatten()
|
||||
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
|
||||
.filter(|(n, _)| n != "README.md")
|
||||
.map(|(n, p)| (n, std::fs::read_to_string(p).expect("a text file")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The text of the section of `text` that starts at the line `heading`, to the next heading of
|
||||
/// the same level.
|
||||
fn section<'a>(text: &'a str, heading: &str) -> &'a str {
|
||||
let at = text.find(&format!("\n{heading}")).unwrap_or_else(|| panic!("no {heading}"));
|
||||
let level = heading.split(' ').next().expect("a heading's hashes");
|
||||
let end = text[at + 1..].find(&format!("\n{level} ")).map_or(text.len(), |e| at + 1 + e);
|
||||
&text[at..end]
|
||||
}
|
||||
|
||||
/// The first code of each table row of a section: `| \`code\` | …`.
|
||||
fn listed(section: &str) -> Vec<String> {
|
||||
section.lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_committed_corpus_regenerates_from_its_seeds() {
|
||||
let files = committed();
|
||||
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
|
||||
let extra: Vec<&String> = files.keys().filter(|k| !generated.contains_key(*k)).collect();
|
||||
let missing: Vec<&String> = generated.keys().filter(|k| !files.contains_key(*k)).collect();
|
||||
let differ: Vec<&String> = generated.iter().filter(|(k, v)| files.get(*k).is_some_and(|f| f != *v)).map(|(k, _)| k).collect();
|
||||
assert!(extra.is_empty() && missing.is_empty() && differ.is_empty(), "extra {extra:?}, missing {missing:?}, differ {differ:?}: run `wai_timeliness_vectors gen`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_case_gets_its_stated_outcome() {
|
||||
let fails = corpus::verify(&committed());
|
||||
assert!(fails.is_empty(), "{}", fails.join("\n"));
|
||||
}
|
||||
|
||||
/// §9 of the extension lists exactly the codes `CODES` has, each once, in order.
|
||||
#[test]
|
||||
fn the_extension_lists_the_reasons() {
|
||||
let text = read("extensions/timeliness.md");
|
||||
let want: Vec<String> = CODES.iter().map(|c| c.to_string()).collect();
|
||||
assert_eq!(listed(section(&text, "## 9. Reasons")), want, "§9's table and CODES differ");
|
||||
}
|
||||
|
||||
/// §6.3's table lists exactly the outcomes `OUTCOMES` has, in order.
|
||||
#[test]
|
||||
fn the_extension_lists_the_outcomes() {
|
||||
let text = read("extensions/timeliness.md");
|
||||
let want: Vec<String> = OUTCOMES.iter().map(|c| c.to_string()).collect();
|
||||
assert_eq!(listed(section(&text, "### 6.3 Lines")), want, "§6.3's table and OUTCOMES differ");
|
||||
}
|
||||
|
||||
/// jwp-receipts §3.1's table lists the class's codes: its rules at step 4, its checks against the
|
||||
/// group, its objects and their fallbacks at step 6.
|
||||
#[test]
|
||||
fn jwp_receipts_lists_the_class_codes() {
|
||||
let text = read("extensions/jwp-receipts.md");
|
||||
let class: Vec<&str> = CODES.iter().copied().filter(|c| c.starts_with("timeliness_") && *c != "timeliness_contradicted").collect();
|
||||
assert_eq!(class.len(), 13, "the class's codes");
|
||||
for (k, code) in class.iter().enumerate() {
|
||||
let step = if k < 5 { 4 } else { 6 };
|
||||
assert!(text.contains(&format!("| {step} | `{code}` |")), "jwp-receipts §3.1 does not list {code} at step {step}");
|
||||
}
|
||||
}
|
||||
|
||||
/// delivery-telemetry §5.1 lists the switch reason a sink records when it presents a fallback
|
||||
/// (§8.6), and the reference's list of switch reasons has it.
|
||||
#[test]
|
||||
fn delivery_telemetry_lists_the_switch_reason() {
|
||||
let text = read("extensions/delivery-telemetry.md");
|
||||
let reasons = section(&text, "## 5. Switch reasons");
|
||||
assert!(reasons.contains("`not-held-by-deadline`"), "delivery-telemetry §5.1 does not list not-held-by-deadline");
|
||||
assert!(wai::delivery_terms::SWITCH_REASONS.contains(&"not-held-by-deadline"));
|
||||
}
|
||||
Loading…
Reference in a new issue