Producing an Audit Trail for a Spatial Data Release
This guide, part of Compliance Mapping Frameworks for Spatial Epidemiology Pipelines, assembles the evidence package that lets an agency answer, months or years later, exactly how a published map or table was produced and why each of its cells looks the way it does.
Problem Context & Constraints
Audit questions about a spatial data release arrive late and specifically. A resident claims a published tract map identified their family. A partner agency finds a figure that disagrees with its own. A legislative inquiry asks why a county’s rate changed between two reports. In each case the agency needs to reconstruct a release that happened long ago, produced by people who may have moved on, from inputs that have since been updated.
Most agencies can produce the published file and a description of the method. Few can produce the evidence that actually answers the question: which vintage of the case data, which geocoder, which boundary file, which disclosure policy version, which cells were suppressed and why, who approved the release, and proof that the published file is the one that was approved.
Three constraints define a usable trail. It must be assembled at release time, from the pipeline’s own records, not reconstructed afterwards from memory. It must be tamper-evident, so that the agency can show the files in the trail have not changed since the release was approved. And it must not itself be a disclosure: the trail records decisions and hashes of protected inputs, not the protected inputs themselves, so it can be shown to an auditor without widening access to case data.
Prerequisites
python3.11pandas2.2.2,pyarrow16.1.0- The run provenance record from the pipeline, including input vintages and parameters
- The per-cell disclosure decisions with reasons, from the policy application step
- The disclosure policy file with its version and hash
- A record of the approval: who reviewed the release and when
- Write-once storage for the finished trail
Step-by-Step Solution
Assemble a manifest that hashes every input and output, embed the per-cell decisions and the approval, chain the manifest to the previous release, and write the whole package to write-once storage.
# Assemble a tamper-evident audit trail for one spatial data release.
# Pinned: pandas==2.2.2, pyarrow==16.1.0
import hashlib
import json
import logging
import os
from datetime import datetime, timezone
import pandas as pd
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("audit.release")
def sha256_file(path: str, chunk: int = 1 << 20) -> str:
h = hashlib.sha256()
with open(path, "rb") as fh:
for block in iter(lambda: fh.read(chunk), b""):
h.update(block)
return h.hexdigest()
def build_manifest(release_id: str, provenance: dict, policy_path: str,
decisions: pd.DataFrame, outputs: list[str],
approval: dict, previous_manifest_sha: str | None) -> dict:
"""One JSON document that answers every audit question about this release.
Protected inputs are referenced by identifier and hash, never embedded.
"""
decision_summary = (decisions.groupby(["level", "decision"]).size()
.rename("cells").reset_index().to_dict("records"))
manifest = {
"release_id": release_id,
"assembled_utc": datetime.now(timezone.utc).isoformat(),
"run_id": provenance["run_id"],
"code_commit": provenance["code_commit"],
"libraries": provenance["libraries"],
"inputs": [{"name": i["name"], "vintage": i["vintage"], "sha256": i["sha256"]}
for i in provenance["inputs"]], # hashes, not contents
"parameters": provenance["params"],
"seeds": provenance["seeds"],
"policy": {"path": os.path.basename(policy_path),
"sha256": sha256_file(policy_path)},
"disclosure_summary": decision_summary,
"outputs": [{"file": os.path.basename(o), "sha256": sha256_file(o),
"bytes": os.path.getsize(o)} for o in outputs],
"approval": {"approver_role": approval["role"],
"approved_utc": approval["when"],
"reference": approval["reference"]},
"previous_manifest_sha256": previous_manifest_sha,
}
return manifest
def write_trail(manifest: dict, decisions: pd.DataFrame, trail_dir: str) -> str:
"""Write per-cell decisions and the manifest; return the manifest's own hash."""
os.makedirs(trail_dir, exist_ok=True)
dpath = os.path.join(trail_dir, "cell_decisions.parquet")
decisions[["geoid", "level", "condition_group", "decision"]].to_parquet(dpath, index=False)
manifest["cell_decisions_sha256"] = sha256_file(dpath)
body = json.dumps(manifest, sort_keys=True, indent=2).encode()
mpath = os.path.join(trail_dir, "manifest.json")
with open(mpath, "wb") as fh:
fh.write(body)
msha = hashlib.sha256(body).hexdigest()
log.info("audit trail %s written: %d inputs, %d outputs, %d cell decisions, "
"manifest sha256=%s chained to %s", manifest["release_id"],
len(manifest["inputs"]), len(manifest["outputs"]), len(decisions),
msha[:16], (manifest["previous_manifest_sha256"] or "none")[:16])
return msha
def verify_release(trail_dir: str, published_dir: str) -> bool:
"""Prove the published files are byte-identical to what the trail recorded."""
m = json.load(open(os.path.join(trail_dir, "manifest.json")))
ok = True
for o in m["outputs"]:
actual = sha256_file(os.path.join(published_dir, o["file"]))
if actual != o["sha256"]:
log.error("MISMATCH %s: trail %s, published %s", o["file"],
o["sha256"][:16], actual[:16])
ok = False
log.info("release %s verification %s", m["release_id"], "PASSED" if ok else "FAILED")
return ok
The per-cell decision table records the geography, level, condition group and decision for every published cell, but not the underlying count. That is deliberate: it lets an auditor confirm that a specific tract was suppressed under a specific rule without the trail itself containing the small number that the rule was protecting. The count stays in the protected environment, retrievable through the run identifier by someone with access.
Chaining each manifest to the previous one by hash is what makes the history tamper-evident. Altering an old release’s manifest changes its hash, which no longer matches the reference in the next release’s manifest, so an edit anywhere in the chain is detectable from the chain itself.
Validation & Edge Cases
Approval recorded after publication. An approval timestamp later than the publication timestamp is a process failure the trail should surface, not hide. Record both and flag the inversion.
Outputs regenerated after approval. A table re-exported to fix a formatting issue has a different hash, and verification will fail against the trail. That is correct: the fix is a new release with its own manifest, even if the numbers did not change.
Trails that embed protected data. A manifest that includes a sample of input rows “for context” is a disclosure. Assert that the trail contains hashes and decisions only, and review the manifest schema whenever it changes.
Named individuals in approvals. Record the approving role and a reference to the internal approval record rather than a person’s name, so the trail can be shared with auditors without exposing staff details that the approval system already holds.
Releases assembled from several runs. A report that combines this week’s surveillance run with last month’s exposure surface and a quarterly denominator refresh has three provenance records, not one. The manifest must reference every contributing run by identifier, or the trail will explain part of the release and leave the rest to memory.
A trail nobody has ever tried to use. The first real audit request is a bad time to discover the trail omits the one thing it needed. Once a quarter, pick a past release at random and answer the four questions above using only its trail; any question that cannot be answered is a gap in the manifest schema, and it is far cheaper to find it in a drill than under a deadline.
Superseded releases. When a correction replaces a release, the original’s trail stays, and the correction’s manifest references it as superseded. Deleting or overwriting the original trail removes the evidence of what was published in between, which is often exactly what an inquiry is about.
Storage that allows overwrite. A trail on ordinary storage can be edited without breaking anything except the chain. Write-once storage makes the edit impossible rather than merely detectable, and the two together are the defensible configuration.
INFO audit.release audit trail REL-2026-37 written: 6 inputs, 3 outputs, 2184 cell decisions, manifest sha256=9e2b04c1a8f37d20 chained to 1a4f55e092bc3d71
INFO audit.release release REL-2026-37 verification PASSED
Compliance Notes
Retain audit trails for at least as long as the longest retention period that applies to any product in the release, and longer where the release informed a regulatory or legal decision. The trail is small — manifests and decision tables are measured in kilobytes — so indefinite retention is inexpensive and usually the right default.
Review the manifest schema with the disclosure officer whenever a field is added. The trail’s usefulness depends on it containing enough to answer the questions above, and its safety depends on it containing nothing that would itself need disclosure review. Those two requirements pull in opposite directions, and the schema is where the balance is set.
Related Topics
- Compliance Mapping Frameworks for Spatial Epidemiology Pipelines — the parent guide on compliance controls in the pipeline.
- Mapping State Small-Cell Rules Into a Policy File — the policy whose version and decisions the trail records.
- Documenting Residual Disclosure Risk for a Release — the risk statement that belongs alongside the trail.