Packaging an Evidence Bundle for an External Auditor

Assemble the bundle as a self-verifying archive: the records for the period, the hashes of the data they refer to, the verification script, and a manifest naming the root hash — so a recipient can establish that nothing was altered without trusting the sender or having access to your systems.

Most evidence requests are answered with an export and an explanation. That works when the auditor trusts the export, and it collapses the moment they ask how they would know it was complete. A bundle that verifies itself removes that conversation entirely and takes about a day to build once. This guide sits under audit trail and evidence retention, within Spatial Data Audit Reporting & Compliance Governance.

What goes in the bundle, and why each layer is thereManifest, verification tooling, references and records, each serving a different part of the recipient's verification.Manifestsignedperiod coveredrecord countroot hashVerificationrunnable by the recipientverify scriptcanonicalisation ruleexpected rootReferencesdataset hashespolicy versionsexception recordsRecordsunmodifiedchained eventsin orderfor the period
Remove the top layer and the recipient has to trust you; remove the second and they have to write their own tooling.

Automated Python Implementation

#!/usr/bin/env python3
"""Assemble a self-verifying evidence bundle for a reporting period."""
import hashlib
import json
import zipfile


def canonical(record):
    """One serialisation rule, stated once, used by writer and verifier alike."""
    return json.dumps(record, sort_keys=True, separators=(",", ":"),
                      ensure_ascii=False).encode("utf-8")


def chain_root(records):
    """Fold the records into a single root hash, in order."""
    digest = hashlib.sha256(b"").hexdigest()
    for record in records:
        digest = hashlib.sha256(
            digest.encode("ascii") + canonical(record)).hexdigest()
    return digest


def build_manifest(records, period_start, period_end, scope):
    return {
        "period_start": period_start,
        "period_end": period_end,
        "scope": scope,
        "record_count": len(records),
        "root_hash": chain_root(records),
        "canonicalisation": "json, sorted keys, no whitespace, utf-8",
        "hash_algorithm": "sha256",
    }


VERIFY_SCRIPT = r"""#!/usr/bin/env python3
"Verify this bundle. Run: python verify.py"
import hashlib, json, pathlib, sys

def canonical(record):
    return json.dumps(record, sort_keys=True, separators=(",", ":"),
                      ensure_ascii=False).encode("utf-8")

records = json.loads(pathlib.Path("records.json").read_text(encoding="utf-8"))
manifest = json.loads(pathlib.Path("manifest.json").read_text(encoding="utf-8"))

digest = hashlib.sha256(b"").hexdigest()
for record in records:
    digest = hashlib.sha256(digest.encode("ascii") + canonical(record)).hexdigest()

if len(records) != manifest["record_count"]:
    sys.exit(f"record count differs: {len(records)} vs {manifest['record_count']}")
if digest != manifest["root_hash"]:
    sys.exit("root hash does not match; the records have been altered")
print(f"verified {len(records)} record(s); root {digest[:16]}...")
"""


def build_bundle(out_path, records, period_start, period_end, scope, references):
    manifest = build_manifest(records, period_start, period_end, scope)
    with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as archive:
        archive.writestr("manifest.json", json.dumps(manifest, indent=2, sort_keys=True))
        archive.writestr("records.json", json.dumps(records, indent=2, sort_keys=True))
        archive.writestr("references.json",
                         json.dumps(references, indent=2, sort_keys=True))
        archive.writestr("verify.py", VERIFY_SCRIPT)
        archive.writestr("README.txt",
                         "Run: python verify.py\n"
                         "It recomputes the root hash from records.json and compares\n"
                         "it with manifest.json. No network access is required.\n")
    return manifest

Three properties make this bundle answer the questions an auditor actually asks.

The verification runs without you. A script inside the archive, depending only on the standard library and reading only files in the archive, means the recipient verifies rather than being told. That is a different kind of statement, and it is the one that ends the conversation about trust.

The canonicalisation rule is stated, not assumed. Two implementations that disagree about whitespace compute different hashes and the bundle fails to verify for reasons unrelated to tampering. Writing the rule into the manifest, and using the same function on both sides, removes that class of false alarm.

The record count is checked separately from the hash. A truncated bundle would otherwise fail with a hash mismatch, which reads like alteration; comparing counts first produces the accurate message.

What the recipient does with the bundleThe auditor extracts the archive, runs the verification script, and compares the root hash against the independently published value.AuditorBundlePublished rootextract, run verify.pyrecomputed root hashlook up the root for this periodindependently held value
The last exchange is the one that matters: the root came from somewhere the sender does not control.

Validation and Pipeline Integration

def test_bundle_verifies_itself(tmp_path):
    records = [{"event": "check", "dataset": "roads", "result": "pass"}]
    path = tmp_path / "bundle.zip"
    manifest = build_bundle(path, records, "2026-01-01", "2026-03-31", "catalogue", {})
    assert manifest["root_hash"] == chain_root(records)


def test_altering_a_record_changes_the_root():
    records = [{"event": "check", "result": "pass"}]
    altered = [{"event": "check", "result": "fail"}]
    assert chain_root(records) != chain_root(altered)


def test_reordering_records_changes_the_root():
    a = [{"n": 1}, {"n": 2}]
    assert chain_root(a) != chain_root(list(reversed(a)))

The third assertion is worth keeping because order is part of what the chain attests. A bundle whose records were sorted differently on export would verify against a root computed the same way and not against the one published at the time, and knowing that the ordering is load-bearing prevents a well-meaning tidy-up from invalidating a year of bundles.

Publish the root hash somewhere you do not control — a mailing list, a separate repository, a monitoring system — on the schedule described in audit trail and evidence retention. Without that, the bundle proves internal consistency and nothing about time.

What belongs in the bundle and what stays behindRecords, references and payloads assessed for inclusion by size and by whether the recipient needs them to verify.Is it needed to verify the chain?Include itrecords and manifestyesnoIs it a reference the records pointat?hashes, policy versionsInclude the referencenot the payloadyesnoLeave it outavailable on request, listed in themanifest
Anything the recipient cannot verify or does not need makes the bundle larger and no more convincing.

Scoping the Bundle Before Building It

The temptation is to include everything, on the reasoning that more evidence is more convincing. It is not: an auditor handed forty gigabytes asks for a subset, and the subset they ask for is the one you should have sent.

Three questions settle the scope quickly.

What period? Almost always a reporting period the auditor named. Bundling more is not generosity; it invites questions about material outside the engagement and makes the record count harder to reconcile against anything.

What scope of datasets? The engagement will name a system, a programme or a data category. Include the records for exactly that, and state the scope in the manifest, so a reader can see what was excluded and why. An unstated scope is the single most common reason a bundle is returned.

What depth of reference? Records refer to datasets by hash, to policies by version, and to exceptions by identifier. Including those references costs kilobytes and lets the auditor ask precise follow-up questions. Including the referenced payloads costs gigabytes and answers questions nobody asked.

A bundle scoped this way is typically a few megabytes, which has a practical benefit beyond tidiness: it can be sent by ordinary means, verified on a laptop, and archived by the recipient without a conversation about storage. Bundles that require a file transfer service tend to be verified once, if at all.

Answering the Follow-Up Questions

A verified bundle usually produces two follow-ups, and both are easier to answer if the bundle was built with them in mind.

“Show me the record for this dataset.” The auditor picks one dataset from the reference list and asks for everything the records say about it. If the records carry a dataset identifier — the stable one, not a path — this is a filter over records.json and takes a minute. If they carry paths that have since changed, it becomes an exercise in reconstruction that undermines the confidence the verification just established.

“Why is there nothing here for March?” Gaps are read as omissions unless they are explained. A period during which no checks ran, because a pipeline was paused or a system was being migrated, should appear in the manifest as a stated gap with a reason rather than as an absence the auditor discovers. Emitting a heartbeat record on a schedule, even when nothing else happened, converts an ambiguous silence into a positive statement that the system was running and had nothing to report.

Long-Term Compliance Best Practices

  • Ship the verifier with the bundle. Evidence a recipient cannot check independently is a claim.
  • State the canonicalisation rule in the manifest. Disagreement about whitespace should never look like tampering.
  • Keep the bundle small enough to email. Convenience determines whether verification actually happens.
  • State the scope explicitly, including what was excluded. An unstated scope is the most common cause of a returned bundle.
  • Retain the bundle you sent, byte for byte. The next question is usually about what you provided last time.