Hand an auditor proof that a restore was correct
An applied restore produces a restore receipt: a per-record statement that what landed is what the archive said it should be. A dry run produces none, because it writes nothing and there is nothing to attest.
The receipt is the artefact to hand to an auditor asking “prove that restore worked”. The reason it is worth handing over, not summarising, is that it distinguishes six different strengths of proof and does not present them as one.
What one line says
Each restored record contributes a line carrying its name, its source type, the expected hash from the signed manifest, the hash actually computed, whether the two match, and which method produced the comparison.
That last field matters because not every resource can be re-read after a write, so not every line is the same claim.
The six proof strengths
A large R2 object: the persisted object is re-read and re-hashed. This proves the bytes that landed.
A small R2 object: written whole, then re-read and re-hashed. This also proves the landed bytes.
An image: the re-upload keeps its original id, so the live image is read back and re-hashed. This proves the landed bytes.
KV and D1: no readback. The engine does not read these resources back after a write. It writes bytes it has already verified and records that hash. This proves what was written, not what landed. It is a weaker claim than the three above, and the receipt labels it separately.
A video: the uid resolves. Stream transcodes an uploaded asset to a new object with a new id, so a byte-for-byte comparison is impossible in principle: the stored bytes are not the bytes that were sent. The engine instead proves the new object is live and queryable. There is no landed-byte hash on this line, and “verified” means the new asset resolved, which is the strongest proof available on a transcoded asset.
A Cloudflare configuration surface: every diffed item was accepted. A config snapshot is not written back as bytes at all. It is diffed against your live configuration and re-applied item by item through the Cloudflare API, so no landed-byte hash exists. This line has no landed-byte hash either.
Here “verified” means the API accepted every item the diff produced, the strongest proof available on a diff-driven apply. A surface the API refused an item on carries verified: false, except when every refusal is entitlement. The note further down covers this.
If you are assessing coverage, that is the table to read. Three of the six prove the landed bytes, the KV and D1 line proves the write, the video line proves the asset resolves, and the configuration line proves the apply was accepted. None of them claims to be one of the others.
Tamper evidence, and the two words that differ
The receipt is protected two ways, and which two you got is readable off the artefact itself.
It is anchored into the audit chain. The receipt’s canonical digest is written into the engine’s tamper-evident audit log as a restore-verified entry carrying that digest, the run id, the record count and whether everything verified. An altered receipt then no longer matches the digest in the chain. The anchor is best-effort: if the audit write fails, the restore still completes and the engine records a fault row.
When the engine’s signer key is reachable, it also carries a detached hybrid signature, Ed25519 with ML-DSA-87, the same scheme the archive manifest uses, over the canonicalised receipt core. You verify it against the signer’s public halves you already pinned.
When no signer is reachable, the receipt is audit-anchored rather than key-signed, and the way it says so is by omission: the signature and signatureAlg fields are simply absent. There is no field that spells the words out, so that absence is the thing to look for. The engine does not fall back to another signing scheme or mint a key; an unsigned receipt carries the audit anchor only.
So an auditor’s first question about a receipt is which of those two it is, and the answer is whether signature is present.
Which destination served the restore
If the restore was not served by its first-choice destination, the receipt carries a destFallback block naming its position in the walk (servedAt), which destination served it, and each destination that refused first with the reason. It is inside the hashed and signed core, so a receipt served from a replica cannot be made to hash like one served from the primary, and the walk cannot be stripped after the fact.
It is absent on a first-choice restore, which is every restore whose primary answered, so a first-choice receipt carries no fallback field in its hashed core. If you are recomputing a digest by hand, note that some fields are present on some receipts and not on others. They are this field, recordsSkipped, configSkipReasonCounts, metadataFieldsDropped, and the per-record restoredId and remapped. The presence of destFallback is also worth acting on in its own right: a customer restoring from a replica has learnt something about their primary.
Reading the summary
The summary’s all-verified flag is true only when every line verified. A single mismatch flips it false, and a record whose readback ran and disagreed appears in the receipt with its verdict as well as in the failures, so a bad landing is visible in the artefact rather than only in the error path.
A data or media record that failed to write at all is in the failures and not in the receipt, because the receipt is a statement about what was restored. A configuration surface whose write fails is in both, with verified: false.
The third outcome: records the apply deliberately did not write
Restored and failed are not the whole partition. Some records are skipped on purpose: the apply never attempts the write, so nothing failed, nothing landed, and the record is in neither the receipt lines nor the failures.
When any record is skipped this way, the summary carries a recordsSkipped count, and it sits inside the hashed and signed core rather than alongside it, so it is covered by the digest an auditor recomputes. The restore-verified audit entry carries the same count, read off the receipt rather than recomputed. The console shows it on the audit row as (N not written, still outstanding). The field is present only when it is non-zero, so a restore that skipped nothing carries no recordsSkipped field.
A record is skipped on purpose when:
- It is a Secrets Store value. Secrets Store bindings are read-only at runtime, so no write path exists for the engine to use.
- The captured value is an incompleteness marker rather than real bytes, because the object vanished mid-crawl or was over the capture ceiling. Writing the marker back would re-create the key with marker JSON as its live value, so the engine refuses.
- It is a Cloudflare config surface that cannot re-apply in-console, one you supplied no edit-scoped token for at all, or one outside the approved surface list for this restore. The reason on each skip line distinguishes those cases. A surface you did supply a token for is a different matter, covered immediately below.
- It is a Workers script or its settings. The engine never re-deploys a Worker from a backup, so the snapshot is verified and handed to you with re-deploy guidance instead.
- It is a media record that cannot re-upload in-account. This is an image or video with no edit-scoped token supplied, an inventory or metadata record, a caption, or an Artifact Registry record.
- It is a media file over the in-account re-upload limit. The backup captured it in full; the re-upload is the part that has to happen out of band. A media record whose captured value turned out to be a marker rather than file bytes is skipped on the same ground as the data-record marker above.
- You asked the apply to keep existing keys, and a live KV key or R2 object of the same name exists (from engine 0.3.6). The apply keeps the live one and writes nothing.
- It is the Cloudflare config backup identity record. That record states which account and zone the backup is for. It is informational, not a restorable surface, so there is nothing to write back.
- It is the Cloudflare config coverage record (from engine 0.3.6). That record names each surface the backup has no record for, and why. It is informational too, so there is nothing to write back.
A windowed restore adds one further entry, named (window), when maxRecords capped the apply below what the selector matched. That is a single entry however many records were left unrestored, so read the outOfWindow count on the restore result for the real number, not recordsSkipped.
Read the skipped count before you sign a recovery off. A receipt reading recordsRestored: 98, allVerified: true with an empty failure list is not a statement that the archive is fully back in the account. If the same summary carries recordsSkipped: 2, then two records the archive holds are still not in your account, and nothing failed in order to tell you so. Those records need the out-of-band path named on their skip line before the recovery is complete.
An under-scoped configuration token fails the restore, and the skipped count is still not where you read it
recordsSkipped counts records the apply deliberately never attempted. A Cloudflare config surface you supplied an edit-scoped token for was attempted, so it is not in that count and never will be. That is why the count above is not the field to check for this case.
The receipt states this case explicitly. If the token turns out to be too narrow for a surface, the Cloudflare API refuses each item individually and the apply stays fail-open per item, so the rest of the surface still applies. Each refusal is classified and carried rather than dropped. The surface goes on the receipt as a record with verified: false, which makes allVerified false, and it is raised as a named failure carrying the count per refusal class and the remedy for each, which makes both ok and complete false. The summary carries configSkipReasonCounts, a closed map of refusal class to count, inside the hashed and signed core, so a receipt from a refused apply cannot hash the same as a receipt from a clean one.
One class is exempt on purpose. An entitlement refusal means your account’s plan does not carry that surface, so there is no item to restore and the surface is as applied as it can be. Those skips are still counted in configSkipReasonCounts, but they do not fail the apply, because reddening a restore that is complete with respect to the account is how an operator learns to stop reading ok.
The apply’s per-surface outcomes are the finest-grained view. They report for each surface how many items it applied and how many it skipped, with the reasons grouped by class. See the credential model for how to build the token from a dry run so the refusal does not arise at all.
What it does not contain
Names, hashes and counts, plus your own destination ids and a closed set of reason codes when a fallback or a refusal is recorded. A remapped video also carries your own new Stream video id. No values, no keys, no credentials. The record name is your own key name, which you already know; nothing else about the content is in there.
That is what makes it safe to hand to an auditor, or to attach to a ticket, without a redaction pass first.
Where this fits
- The audit log is the chain the receipt digest is anchored into.
- Immutability and attestation covers the signing scheme and the pinned signer.
- Prove recoverability covers the other direction: proving you can restore, before you need to.
- Compliance evidence packs is where a receipt sits alongside the other artefacts an assessor asks for.
Last updated .