Logging EAS Interrupts for As-Run Reconciliation
This guide solves one exact task: emit an immutable, tamper-evident log entry for every Emergency Alert System interrupt and the spots it displaced, in a shape that reconciles cleanly against the station’s as-run log. It is the audit-trail procedure that sits under Handling EAS Emergency Interrupts in Ad Scheduling, itself a subsystem of Spot Scheduling Validation & Rule Engines. It matters because the interrupt log is the only record that explains a gap between what was scheduled and what the as-run log says aired. Without it, an FCC Part 11 review cannot confirm a required test fired, a billing auditor cannot justify a make-good credit, and a SOC 2 reviewer cannot reproduce why compensatory inventory ran. The entries must therefore be append-only, deterministically keyed, and joinable to the as-run record spot by spot.
The reconciliation itself — resolving a mismatch once one is found — belongs to as-run reconciliation and discrepancy handling. This guide produces the authoritative EAS side of that comparison: a write-once ledger the reconciler treats as ground truth for what an alert displaced, carrying the normalized identity from billing code normalization so a corroborated make-good bills against the same account the original spot debited.
Prerequisites
- Python 3.11+ — required for the
X | Noneunion syntax and timezone-awaredatetime.now(timezone.utc)used throughout. - Pydantic v2 — pin it exactly (
pydantic==2.9.2); the frozen-model and validator semantics below are v2-specific. - An append-only sink — an object-store prefix with write-once (WORM) semantics, an append-only database table, or a hash-chained file. Pin the driver exactly if you use one (
psycopg[binary]==3.2.1). - Displacement signals — the
eas.displacement.v1records produced upstream when spots are marked displaced, each carryingactivation_id,spot_id,overlap_ms, anddisposition. - Read access to the as-run log — the playout confirmation feed that records what actually aired, keyed by
spot_idandaired_at, mounted read-only so the reconciler never mutates the source of truth.
Step-by-Step Implementation
The logger runs as a pure append: build a frozen entry from a displacement signal, hash it against the previous entry to make tampering detectable, persist it write-once, then join the ledger against the as-run log to classify each entry as corroborated or discrepant. Nothing is ever updated in place; a correction is a new entry that supersedes, never an edit.
Step 1 — Model the immutable interrupt log entry
Goal: define a frozen entry that captures the interrupt, the displaced spot, and a content hash chained to the prior entry. Immutability is enforced at the type level so a downstream bug cannot silently rewrite history.
from __future__ import annotations
import hashlib
import json
import logging
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel, ConfigDict, Field, field_validator
# Traffic-ops structured logging: timestamp | level | module | spot_id
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("traffic.eas.ledger")
GENESIS_HASH = "0" * 64 # prev_hash of the very first entry in a chain
class ReconStatus(str, Enum):
CORROBORATED = "corroborated" # as-run confirms the displacement
MISSING_AS_RUN = "missing_as_run" # ledger says displaced, as-run silent
UNEXPECTED_AIR = "unexpected_air" # as-run says aired despite displacement
def _require_utc(v: datetime) -> datetime:
if v.tzinfo is None:
raise ValueError("timestamp must be timezone-aware (UTC)")
return v.astimezone(timezone.utc)
class InterruptLogEntry(BaseModel):
# frozen=True makes an instance hashable and read-only after construction.
model_config = ConfigDict(frozen=True)
entry_id: str # Deterministic hash of (activation_id, spot_id)
activation_id: str
event_code: str # EAN, TOR, SVR, RWT, ...
spot_id: str
house_number: str
isci: str
overlap_ms: int = Field(gt=0)
disposition: str
makegood_eligible: bool
interrupted_at: datetime # window start that displaced this spot
recorded_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
prev_hash: str = GENESIS_HASH # links this entry to the one before it
content_hash: str = "" # filled in at Step 2; excluded from itself
_v_int = field_validator("interrupted_at")(classmethod(lambda cls, v: _require_utc(v)))
Step 2 — Compute a chained content hash
Goal: make the ledger tamper-evident. Each entry hashes its own content plus the previous entry’s hash, so altering any historical entry invalidates every hash after it — the property an auditor checks to trust the record.
def seal_entry(entry: InterruptLogEntry, prev_hash: str) -> InterruptLogEntry:
# Serialize the immutable fields deterministically (sorted keys), excluding
# the hash field itself, then chain onto the previous entry's hash.
payload = entry.model_dump(exclude={"content_hash", "prev_hash"}, mode="json")
material = json.dumps(payload, sort_keys=True, separators=(",", ":")) + prev_hash
digest = hashlib.sha256(material.encode()).hexdigest()
sealed = entry.model_copy(update={"prev_hash": prev_hash, "content_hash": digest})
logger.info(
"%s | sealed ledger entry | activation=%s hash=%s",
sealed.spot_id, sealed.activation_id, digest[:12],
)
return sealed
Sealing an entry for spot SP-2026-0714-0192 emits 2026-07-14T21:05:02+00:00 | INFO | traffic.eas.ledger | SP-2026-0714-0192 | sealed ledger entry | activation=EAS-2026-0714-TOR-0007 hash=9f2c1ab4e77d. The 64-character content_hash becomes the prev_hash of the next entry appended to the chain.
Step 3 — Append to a write-once ledger
Goal: persist each sealed entry without ever overwriting a prior one, and refuse a duplicate entry_id so a replayed displacement signal cannot double-write. The in-memory ledger below models the contract; swap the sink for a WORM bucket or append-only table in production.
class ImmutableLedger:
"""Append-only, hash-chained store of EAS interrupt log entries."""
def __init__(self) -> None:
self._entries: list[InterruptLogEntry] = []
self._seen: set[str] = set()
@property
def head_hash(self) -> str:
return self._entries[-1].content_hash if self._entries else GENESIS_HASH
def append(self, entry: InterruptLogEntry) -> InterruptLogEntry:
if entry.entry_id in self._seen:
# Idempotent: a replayed signal is a no-op, not a second record.
logger.info("%s | duplicate entry_id, skipped | %s",
entry.spot_id, entry.entry_id)
return next(e for e in self._entries if e.entry_id == entry.entry_id)
sealed = seal_entry(entry, self.head_hash)
self._entries.append(sealed)
self._seen.add(sealed.entry_id)
return sealed
def verify_chain(self) -> bool:
# Re-derive every hash; any tampered entry breaks the chain from there on.
prev = GENESIS_HASH
for e in self._entries:
expected = seal_entry(e, prev).content_hash
if expected != e.content_hash:
logger.error("%s | chain broken at entry %s", e.spot_id, e.entry_id)
return False
prev = e.content_hash
return True
Step 4 — Reconcile the ledger against the as-run log
Goal: join each ledger entry to the as-run record for the same spot and classify the outcome. A displaced spot that the as-run log also shows as not aired is corroborated; one the as-run log shows as fully aired is an unexpected_air discrepancy that must be investigated before any make-good is credited.
class AsRunRecord(BaseModel):
spot_id: str
aired_at: datetime | None # None if the spot did not air
aired_ms: int = Field(ge=0) # duration actually broadcast
_v_air = field_validator("aired_at")(
classmethod(lambda cls, v: _require_utc(v) if v is not None else None)
)
def reconcile(
ledger: list[InterruptLogEntry], as_run: dict[str, AsRunRecord]
) -> dict[str, ReconStatus]:
results: dict[str, ReconStatus] = {}
for entry in ledger:
record = as_run.get(entry.spot_id)
if record is None or record.aired_ms == 0:
# Ledger says displaced and as-run shows no airing → they agree.
status = ReconStatus.CORROBORATED
elif entry.disposition == "fully_displaced" and record.aired_ms > 0:
# Ledger says fully displaced but as-run shows it aired → conflict.
status = ReconStatus.UNEXPECTED_AIR
else:
# Partial displacement with a partial airing is internally consistent.
status = ReconStatus.CORROBORATED
results[entry.spot_id] = status
level = logging.WARNING if status is ReconStatus.UNEXPECTED_AIR else logging.INFO
logger.log(level, "%s | reconcile %s | activation=%s",
entry.spot_id, status.value, entry.activation_id)
return results
A clean run logs ... | SP-2026-0714-0192 | reconcile corroborated | activation=EAS-2026-0714-TOR-0007; a conflict escalates to WARNING with reconcile unexpected_air, which is the signal the discrepancy handler acts on.
Step 5 — Emit discrepancy records for billing
Goal: turn every non-corroborated outcome into a discrepancy record for the reconciliation subsystem, so a make-good is never credited on a spot the as-run log says actually aired. Corroborated entries pass through; conflicts are held for review.
def emit_discrepancies(
ledger: list[InterruptLogEntry], statuses: dict[str, ReconStatus]
) -> list[dict[str, str]]:
discrepancies: list[dict[str, str]] = []
for entry in ledger:
status = statuses[entry.spot_id]
if status is ReconStatus.CORROBORATED:
continue
discrepancies.append({
"entry_id": entry.entry_id,
"spot_id": entry.spot_id,
"house_number": entry.house_number,
"activation_id": entry.activation_id,
"status": status.value,
})
logger.warning("%s | discrepancy queued for review | %s",
entry.spot_id, status.value)
return discrepancies
Figure — Reconciliation flow: sealed, hash-chained ledger entries are joined by spot to the as-run log; agreements are corroborated for billing while conflicts are queued as discrepancies for review.
Verification & Testing
Correct behaviour rests on two properties: the chain detects any tampering, and a spot the as-run log shows as aired is never silently corroborated. Both are assertable against fixture data.
led = ImmutableLedger()
base = dict(activation_id="EAS-2026-0714-TOR-0007", event_code="TOR",
house_number="HN-1", isci="AAAA1111", overlap_ms=30000,
makegood_eligible=True,
interrupted_at=datetime(2026, 7, 14, 21, 3, 0, tzinfo=timezone.utc))
e1 = led.append(InterruptLogEntry(entry_id="k1", spot_id="SP-A",
disposition="fully_displaced", **base))
e2 = led.append(InterruptLogEntry(entry_id="k2", spot_id="SP-B",
disposition="fully_displaced", **base))
# 1. Chaining: entry two links to entry one, and the chain verifies.
assert e2.prev_hash == e1.content_hash
assert led.verify_chain() is True
# 2. Idempotency: re-appending an existing entry_id is a no-op.
dup = led.append(InterruptLogEntry(entry_id="k1", spot_id="SP-A",
disposition="fully_displaced", **base))
assert dup.content_hash == e1.content_hash
assert len(led._entries) == 2
# 3. Reconciliation: SP-A did not air (corroborated); SP-B aired (conflict).
as_run = {
"SP-A": AsRunRecord(spot_id="SP-A", aired_at=None, aired_ms=0),
"SP-B": AsRunRecord(spot_id="SP-B",
aired_at=datetime(2026, 7, 14, 21, 3, 30, tzinfo=timezone.utc),
aired_ms=30000),
}
statuses = reconcile(led._entries, as_run)
assert statuses["SP-A"] is ReconStatus.CORROBORATED
assert statuses["SP-B"] is ReconStatus.UNEXPECTED_AIR
assert len(emit_discrepancies(led._entries, statuses)) == 1
Because seal_entry is a pure function of the entry content and the prior hash, replaying an archived ledger during an audit reproduces byte-identical hashes. Run verify_chain in CI against a golden fixture so any change that would re-key or reorder entries fails the build rather than silently invalidating the audit trail.
Edge Cases & Failure Handling
- A tampered or reordered entry. If any historical entry is edited, its re-derived hash no longer matches and
verify_chainreturns false from that point on, so the corruption is detectable rather than silent. Never repair a broken chain by rewriting hashes; append a new correcting entry and let the reconciliation subsystem resolve the supersession, preserving the evidence that a change occurred. - As-run log arrives late. Playout confirmation can lag the interrupt by minutes on a busy night. Reconcile against a snapshot with an explicit cut-off rather than assuming completeness; an entry with no matching as-run record yet is
MISSING_AS_RUN, held open — not closed as corroborated — until the as-run feed catches up. Resolving the held entries is the job of as-run reconciliation and discrepancy handling. - Conflicting displacement and airing. An
unexpected_air— the ledger says fully displaced but the as-run log says the spot ran — usually means the ENDEC released the air path mid-break and playout resumed the spot, or the window’s end-of-message was misdated. Queue it for review and block the make-good credit until resolved, because crediting a spot that actually aired double-pays the advertiser and is exactly the error a billing audit surfaces. Where the recovery had already been proposed, coordinate the reversal with make-good routing.
FAQ
Why hash-chain the entries instead of a plain append-only table?
An append-only table stops accidental overwrites, but it cannot prove that no one edited a row out of band. Chaining each entry’s hash onto the previous one makes any after-the-fact edit detectable: change one entry and every hash after it stops verifying. That is what lets an auditor trust the ledger as evidence, and it is why the parent guide, Handling EAS Emergency Interrupts in Ad Scheduling, treats the ledger as the immutable source of truth for what an alert displaced.
What is the difference between this log and the as-run log?
The as-run log is playout’s record of what actually broadcast; this interrupt ledger is the traffic system’s record of what an EAS activation displaced. Reconciliation is the join of the two — they should agree, and a conflict is a discrepancy. The resolution of that conflict is owned by as-run reconciliation and discrepancy handling, which consumes this ledger as its authoritative EAS input.
How does this ledger satisfy FCC and SOC 2 requirements?
Every interrupt — including Required Weekly and Monthly Tests — is recorded with its event code, originator, and the exact spots it touched, so the ledger doubles as evidence that Part 11 tests fired and were accounted for. Because sealing is deterministic and the chain is verifiable, replaying an archived ledger reproduces byte-identical hashes, which is the reproducibility a SOC 2 review expects. The normalized billing identity from billing code normalization ties each entry to the account it affects.
Can I safely re-run the logger after a crash mid-batch?
Yes. Each entry’s entry_id is a deterministic hash of the activation and spot, and append skips any id already present, so re-running after a crash re-processes the same signals without duplicating entries or breaking the chain. Gate re-runs on the deterministic key, never on arrival order — the same discipline the upstream detection step in Handling EAS Emergency Interrupts in Ad Scheduling applies.
Related
- Handling EAS Emergency Interrupts in Ad Scheduling — the parent guide that produces the displacement signals this ledger seals and stores.
- As-Run Reconciliation and Discrepancy Handling — the subsystem that joins this ledger against the as-run log and resolves the discrepancies it surfaces.
- Standardizing Billing Codes Across Traffic Systems — the normalization that ties each ledger entry to the account a corroborated make-good bills against.