Versioning Rule References in Audit Trails
A compliance result is meaningless without knowing which rule decided it. Pinning every evaluation to an immutable rule version, resolved to a git commit SHA or a semantic version tag rather than a moving branch, is what lets you reproduce a two-year-old verdict and roll back cleanly when an ordinance amendment turns out to contain a drafting error. This guide shows how to resolve, record, and replay versioned rule references so each entry in your audit trail carries the exact logic that produced it.
Prerequisites
Step-by-step
Step 1: Resolve the rule set to an immutable reference
Never record a branch name like main; it points somewhere different tomorrow. Resolve the working rules to a concrete commit SHA at the moment of evaluation. This mirrors the discipline described in automating zoning code version control with git, where rule changes are committed and tagged rather than edited in place.
import subprocess, json, hashlib
from datetime import datetime, timezone
def resolve_rule_version(repo_dir: str) -> dict:
"""Pin the current rule set to an immutable commit SHA and tag."""
sha = subprocess.check_output(
["git", "-C", repo_dir, "rev-parse", "HEAD"], text=True).strip()
# describe gives the nearest semantic tag, e.g. v3.2.1-0-g9f2c1a7
tag = subprocess.check_output(
["git", "-C", repo_dir, "describe", "--tags", "--always"], text=True).strip()
dirty = subprocess.check_output(
["git", "-C", repo_dir, "status", "--porcelain"], text=True).strip()
return {"repo": repo_dir, "sha": sha, "tag": tag, "clean": dirty == ""}
Step 2: Hash the rule payload actually loaded
A commit SHA proves what the repository held, but a defensive audit also hashes the concrete rule bytes fed to the engine. If someone bypasses git and hand-edits a file, the payload digest will diverge from the commit, exposing the tampering.
def hash_rule_payload(rule_path: str) -> str:
with open(rule_path, "rb") as fh:
return hashlib.sha256(fh.read()).hexdigest()
def build_rule_reference(repo_dir: str, rule_path: str) -> dict:
ref = resolve_rule_version(repo_dir)
ref["payload_sha256"] = hash_rule_payload(rule_path)
ref["resolved_at"] = datetime.now(timezone.utc).isoformat()
return ref
Step 3: Attach the reference to each evaluation
When the engine scores a parcel, stamp the result with the resolved reference. Because the setback measurement itself must run in a metric CRS, the snippet projects before computing area so the recorded verdict and the recorded rule version describe the same, correct calculation.
import geopandas as gpd
def evaluate_with_reference(parcels: gpd.GeoDataFrame, rule_ref: dict,
min_area_m2: float, metric_crs="EPSG:2926") -> gpd.GeoDataFrame:
projected = parcels.to_crs(metric_crs) # project BEFORE measuring area
projected["area_m2"] = projected.geometry.area
projected["compliant"] = projected["area_m2"] >= min_area_m2
projected["rule_sha"] = rule_ref["sha"] # immutable reference on every row
projected["rule_tag"] = rule_ref["tag"]
projected["rule_payload_sha256"] = rule_ref["payload_sha256"]
return projected
Step 4: Write the versioned audit record
Persist the reference alongside the run so it can be queried later. A deterministic JSON dump keeps records diffable and tamper-evident on append-only storage.
def write_audit_record(run_id: str, rule_ref: dict, summary: dict, path: str) -> None:
record = {
"run_id": run_id,
"rule_reference": rule_ref, # sha + tag + payload digest
"result_summary": summary,
"written_at": datetime.now(timezone.utc).isoformat(),
}
with open(path, "w", encoding="utf-8") as fh:
json.dump(record, fh, indent=2, sort_keys=True)
Verification
To prove reproducibility, check out the recorded SHA into a clean workspace, re-run the evaluation, and confirm the payload digest and verdicts match the archived record. A mismatch means either the checkout was dirty or the rule file was altered outside git.
def verify_reproduction(archived: dict, repo_dir: str, rule_path: str) -> None:
current = build_rule_reference(repo_dir, rule_path)
assert current["sha"] == archived["rule_reference"]["sha"], "wrong commit checked out"
assert current["payload_sha256"] == archived["rule_reference"]["payload_sha256"], \
"rule payload differs from the archived audit record"
print("reproduction verified: rule reference matches archive")
Common Pitfalls
- Recording a branch or a
latesttag. These are mutable pointers. Only a full commit SHA guarantees the same logic on replay; capture the tag as a human-friendly label alongside it, not instead of it. - Trusting the SHA while ignoring a dirty tree. Uncommitted edits mean the code that ran is not the code the SHA describes. Record the clean or dirty state and refuse to certify results from a dirty tree.
- Storing the reference only in a summary, not per result. If one batch spans a mid-run rule update, a single top-level version misattributes some rows. Stamp the reference on each evaluated feature.
Three Versions, Not One
“The rule version” is usually three separate things, and conflating them is why version references so often fail to answer the question asked of them.
The rule pack version identifies the whole set — a tag, a commit, a semantic version — and is what a run manifest records once. The individual rule version identifies one rule’s content, which matters because a pack version changes whenever any rule in it changes, and a verdict needs to name the standard that governed it rather than the state of everything else. The effective period says when the rule was in force, which is what selects it for an application date and is entirely independent of when it was written.
A verdict that records all three can answer the three distinct questions that get asked: what was deployed, what standard applied here, and was that standard in force at the time. One that records only the pack version can answer the first, which is the least interesting of the three.
Making References Resolvable
A version string is only useful if someone can turn it back into the rule text.
That requires two things the reference itself must carry: an identifier that is stable and unique across the whole history, and a location where the content can be retrieved. A commit hash plus a repository is the simplest arrangement that satisfies both, and it has the useful property that the identifier verifies the content.
Content addressing goes one step further and is worth it for rules that matter: hash the rule’s canonical content and use that as its version. Two rules with identical content then have identical versions regardless of which file or pack they came from, and a rule that was reformatted but not changed does not appear to have changed. That last property removes a recurring source of false drift in regression comparisons.
def rule_ref(rule: dict, pack_version: str) -> dict:
"""A reference that resolves, and that only changes when the rule changes."""
body = {k: rule[k] for k in sorted(rule) if k not in ("comment", "_source_line")}
content = json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
return {
"rule_id": rule["id"],
"rule_content_sha256": hashlib.sha256(content).hexdigest()[:16],
"pack_version": pack_version,
"effective_from": rule["effective_from"],
"effective_to": rule.get("effective_to"),
}
Keeping Old References Resolvable Forever
A reference that stops resolving is worse than no reference, because it implies a traceability that no longer exists.
Three practices keep them working. Never force-push or rewrite history in the rule repository, since every verdict ever issued may reference a commit in it. Never delete a retired rule; close it with an effective-to date and keep it. And archive the rule pack itself alongside the run, so that a reference remains resolvable even if the repository becomes unavailable — a few kilobytes per run, and the only thing that survives an organisational change.
The archived copy is also what makes an external verifier possible. A recipient who wants to check a verdict needs the rule text, and giving it to them directly is considerably simpler than granting access to a repository.
Related
Part of: Provenance and lineage tracking
- Automating zoning code version control with Git — where these versions come from.
- Rule storage formats: JSON, YAML and databases — the store that produces resolvable references.
- Detecting rule drift between pipeline releases — comparing two versions’ effects.
- Tracking data lineage across geospatial ETL steps — the data half of the same record.
Frequently Asked Questions
Why pin to a commit SHA instead of a semantic version tag?
A semantic version communicates intent to humans, but tags can be moved or deleted, and several commits may share one release tag during development. A commit SHA is content-addressed and immutable, so it uniquely identifies the exact rule logic. The recommended practice is to store both: the SHA for reproducibility and the tag for readability.
How does this fit into the broader provenance record?
Rule versioning is one facet of end-to-end lineage. The parent module on provenance and lineage tracking assembles source hashes, transform metadata, and rule references into a single reconstructable graph, and the rule reference produced here is the node that answers which logic decided the verdict.
What should happen when a rule version is rolled back?
Rolling back means future evaluations resolve to an earlier commit SHA, while historical records keep the SHA that governed them at the time. Never rewrite past audit records to reflect a rollback; the whole point of an immutable reference is that a decision reflects the rules in force when it was made.
Can I version rules held in a database rather than git?
Yes. Give each rule row an immutable version column and never update in place; instead insert a new versioned row. Your reference then becomes the rule id plus version number plus a payload digest, which offers the same reproducibility guarantees as a commit SHA.
Summary
Versioning rule references turns an audit trail from a bare pass or fail into a reproducible, defensible record. By resolving rules to an immutable commit SHA, hashing the payload actually loaded, stamping each evaluation with that reference, and verifying reproduction against a clean checkout, teams can replay and roll back compliance decisions with confidence. This capability underpins the provenance and lineage tracking module, ensuring every automated verdict names the exact logic that produced it.