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 latest tag. 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.

Three versions that get called "the rule version"Pack version, individual rule content version and effective period each answer a different question about a verdict.AnswersChanges whenRule pack versionWhat was deployed for this runAny rule in the pack changesRule content versionWhich standard governed this verdictThis rule’s content changesEffective periodWhether it was in force at the timeAn amendment takes effect
Record only the pack version and you can answer what was deployed — the least interesting of the three questions.

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.

What makes a reference resolvableA stable unique identifier, a retrievable location, and content addressing so that reformatting a rule does not look like changing it.Stable, unique identifiera commit hash, or a content hashA location that resolves itrepository, or the archived packContent addressingidentical content, identical versionComments excluded from the hashso a clarifying note is not a rule change
Content addressing removes a recurring source of false drift in regression comparisons.

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.

Keeping references resolvable for as long as verdicts lastNo history rewriting, no deletion of retired rules, and an archived copy of the pack stored alongside each run.Never rewrite the rule repository’s historyevery verdict may point at a commit in itClose retired rules; never delete theman effective-to date, not a deletionArchive the pack with the runkilobytes, and it survives a reorganisationGive recipients the rule text directlysimpler than granting repository access
A reference that stops resolving is worse than none: 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.

Part of: Provenance and lineage tracking

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.