Skip to content

What it costs to prove the evidence is still what was recorded

id: evidence-integrity-cost
kind: measured-tripwire
measured_on: 2026-09-02
stale_when: >
the per-invocation subrequest cap moves from 1,000; evidence stops being one R2 object per sealed body,
since the two subrequests per object below assume exactly that; the frame size in evidence-frame-size.md
changes enough to move the decrypt cost materially; a fifth prefix is added, since the walk-forward cost
below is bounded by the number of tables; or the vault key cache stops being held for a whole batch, which
is what keeps the key RPCs off the per-object cost
values:
evidence.verify_objects: 200
evidence.verify_subrequests_per_object: 2
evidence.verify_tables: 5

Issue #92, re-measured for #131. The restore drill’s step 5, “prove a sampled set of raw messages decrypt and hash-verify against the manifests”, is the step the ticket says makes the rest true. It needs a verifier, and the number that bounds one is how much fits in an invocation.

Why this was re-measured rather than adjusted

Section titled “Why this was re-measured rather than adjusted”

The previous version of this receipt bounded receipts, and its own stale_when named the condition that has now happened: “a verification begins reading rows other than ingress_receipts. #131 is that change. The verifier swept inbound mail only while the inventory covered all four prefixes, so a Node whose evidence is drafts or staged sends verified nothing and said it was clean.

A row bound cannot survive that widening, because rows stopped costing the same. One send_manifests row stages three objects; one ingress_receipts row stages one. The cost this number exists to bound is the R2 get and the decrypt, and both happen per object. So the figure is now an object bound, and the rows per page are derived from it: floor(200 / columns): 200 rows of receipts, drafts or exports, and 66 rows of sends.

That is the same underlying measurement, expressed in the unit that stays constant across tables.

Two subrequests, and only two:

OperationSubrequestsWhy not more
EVIDENCE.get(key)1one R2 object per sealed body, header and frames together
vault opening key0 amortisedone Durable Object RPC per generation per batch, from runKeyCache
SHA-256 of the plaintext0crypto.subtle, in the isolate
D1 page of rows1 per batchkeyset pagination, one query for the whole page

So a batch of n objects costs n + 2 subrequests. The key cache is what makes the second row zero rather than n. Without it a batch of 200 would spend 400, and the reason it is a per-run parameter rather than an isolate-wide map is argued in evidence-store.ts.

Plus at most three more, and this is new. A batch begins by walking forward past tables that are empty, because an empty table must not end a sweep. Each skipped table costs one D1 query, and there are four tables, so the walk-forward adds at most three. A Node with no drafts, no exports and no sends spends four queries to discover it has only receipts, once per batch, not once per row.

Worst case per invocation: 200 + 1 + 3 + 1 = 205 subrequests.

17 September 2026 (0060): a fifth table, and the worst case is 206. send_attachments joined INVENTORY_REFERENTS. An authored send’s attachments are evidence with a hash each, under the sent/ prefix, so it is a fifth table and not a fifth prefix. The walk-forward can now skip four tables rather than three: 200 + 1 + 4 + 1 = 206. evidence.verify_tables moves to 5; the objects and per-object figures do not move, because an attachment is one R2 object with one hash like every other row.

The per-invocation cap is 1,000 subrequests, so arithmetic alone would allow ~990. The batch is 200, and the headroom is deliberate for three reasons:

  1. The route is not alone in its invocation. Authorization, the audit append and the cost meter all spend subrequests before the first get. A bound set at the arithmetic maximum is a bound that fails whenever anything else in the request grows, and the failure mode is a partial sweep reported as a clean one.
  2. Wall clock, not just subrequests. 200 objects at the measured frame size is the interval an operator will actually wait for a response, and a resumable verifier that times out is not resumable.
  3. A sweep is resumable by design, so a smaller batch costs nothing but more round trips. The direction the number can be wrong in matters: too small wastes requests, too large loses coverage silently.

The figure did not move when the unit changed, and that is worth stating rather than leaving to look like an oversight: 200 receipts cost 200 gets before, and 200 objects cost 200 gets now. What changed is that the number is no longer wrong for three of the four tables.

evidence.verify_tables is the count this sweep walks, and it is derived. The verifier groups INVENTORY_REFERENTS by table rather than listing prefixes again. The number is recorded here so the walk-forward arithmetic above has a stated basis, and so a fifth prefix stales this receipt instead of quietly making the worst case five.

A single UNION ALL over all six referent columns was not an option: D1 answered “too many terms in compound SELECT” when the inventory tried it, which is why that file groups by prefix and why this one groups by table.

Wall-clock seconds per batch on a live Node. It has not been run against one. The Node whose evidence this would sweep is carrying real mail, and this figure is a bound on work per invocation rather than a latency promise. bytesRead is returned in every verdict precisely so an operator sizing a full sweep reads a measured number rather than this document.

Wall-clock seconds for a full sweep. The CLI pages until the Node stops returning a cursor and prints the megabytes it read, so the figure an operator needs comes from their own run rather than from here.

A full-database sweep as one figure (RPO/RTO). #92 asks for both, and both belong to the restore drill in a clean account rather than to the verifier. Recording an RTO estimated from a batch bound would be exactly the kind of number this repository refuses.

Whether a mixed-generation batch costs more. The key cache is per generation, so a Node holding evidence under several content keys spends one extra RPC each. Bounded by the number of generations, which is small and not measured here.