Skip to content

Anchor verification with receipts

Verification proves an archive matches its own manifest, but the manifest lives inside the same file it vouches for, so nothing inside the file can prove this archive is the one you recorded months ago.

sample.verify consumes receipts and reports the same separate facts; creating and transporting a receipt stays a core operation, shown in this guide.

This guide assumes: you have finished the tutorial path, including Save, load, and verify, and you know that verification checks an archive against its own integrity manifest.

The way past that limit is a receipt: a small piece of JSON text you export once, keep somewhere separate from the archive, and check the archive against later. A receipt binds three things: the archive identity, the exact revision identity, and the revision root, a digest over the complete state of that revision. If someone swapped in a different archive — data, manifest, and all — the revision roots disagree and the anchor check fails.

The example below records the polymer film spectrum, saves the archive, exports a receipt, and then loads the archive with that receipt as the anchor, the way you would months later.

from pathlib import Path
from tempfile import TemporaryDirectory
import numpy as np
from mtrl import (
InstrumentRun,
MaterialData,
Mtrl,
load_receipt,
receipt_to_text,
)
application = Mtrl(sample_id="polymer-film-01")
run = InstrumentRun(run_name="ftir-replicate-01")
run.add_checkpoint(
MaterialData(
content=np.array([0.15, 0.21, 0.19]),
type="raw",
checkpoint_name="spectrum",
)
)
application.add(run)
with TemporaryDirectory() as directory:
archive_path = application.save(Path(directory) / "polymer-film-01.mtrl")
receipt_path = Path(directory) / "polymer-film-01-receipt.json"
receipt_path.write_text(
receipt_to_text(application.export_receipt()),
encoding="utf-8",
)
loaded = Mtrl.load(archive_path, receipt=load_receipt(receipt_path))
print(f"anchor status: {loaded.verification.anchor_status.value}")
anchor status: match

The print shows anchor status: match: the loaded archive’s revision root agrees with the receipt, so this is the archive the receipt was exported from. In real work the receipt file leaves the temporary directory with you — you keep it in a notebook, an email, or a register of record, and supply it when you next verify.

application.export_receipt() produced a receipt for the current committed revision. MTRL refuses to export one for a dirty, unsaved, or corrupted copy, so a receipt can never describe work in progress. receipt_to_text(...) rendered it as canonical JSON, and you wrote that text to a file of your own. Nothing about the receipt lives inside the archive.

load_receipt(receipt_path) parsed the bounded receipt file back, and Mtrl.load(archive_path, receipt=...) verified the archive against it as part of loading. The result surfaced in loaded.verification.anchor_status, one of the separate verification statuses: match when the revision roots agree, no-receipt when you supply no anchor, and scope-insufficient when the receipt covers less than the archive asks of it. The anchor check sits beside the structural and hash statuses; it never replaces them.

When the roots do not agree, you never see a mismatch status from Mtrl.load: the default policy raises AnchorMismatchError and refuses the archive, because loading an archive that fails its anchor is exactly what the receipt exists to prevent. To read a mismatch as a report instead, verify without loading: verify_archive(archive_path, receipt, policy=VerifyPolicy(on_mismatch="forensic")). A forensic report stays readable: result.format_summary() prints one line per status, and result.explain_issues() turns each issue into one sentence.

Notice what the receipt cannot do. It holds no signature and no path. It is only as trustworthy as the channel you kept it in, which is why it must travel separately from the archive. Its strength is independence: the archive no longer has to be its own only witness.

Caution — Keep the receipt outside the archive and send it through a separate trusted channel. A receipt stored next to the archive, or inside it, can be replaced along with everything else, and the anchor is gone. A receipt has no signature.

Tip — A receipt normally covers one exact revision. A kind="source-baseline" receipt instead anchors the retained instrument sources and their interpretation metadata, not the complete revision, and it requires at least one retained source; this synthetic example has none, so the call would fail there. Keep a revision receipt to anchor derived results.

  • Trust over time — how receipts, disclosure, and assertions each extend trust past the archive boundary.
  • Inspect a damaged archive — read the separate findings when verification, with or without a receipt, reports a problem.
  • Reference — exact signatures for Mtrl.export_receipt(), load_receipt(), and receipt_to_text().