Generating FCC Political File Audit Exports
An FCC audit of a station’s political file is a reproducibility test: a reviewer asks the station to show, for every candidate and issue spot in a pre-election window, what was ordered, what class it ran in, what it was charged, what the lowest unit rate was at the time, and proof that none of those rows was altered after filing. If the station can only produce a spreadsheet that has been edited in place, it cannot demonstrate integrity, and the online public inspection file obligation under 47 CFR §73.1943 has effectively failed. This guide solves one exact task: assemble the disposition records for a window, render them into the public-file rows a reviewer expects, validate the set for completeness, and seal the whole thing into a signed, tamper-evident export bundle. It is the export procedure that sits under Automating FCC Political File Compliance, itself a subsystem of Spot Scheduling Validation & Rule Engines. Getting it right is what turns a pile of records into evidence: the bundle must be reproducible from the source data alone, so replaying it during an audit yields byte-identical rows and any post-hoc edit is provable as a hash mismatch.
The core requirement is immutability by construction. Rather than trusting a write-once database, the exporter derives a deterministic content hash over each disposition and a Merkle-style bundle digest over the whole set, so the export carries its own proof of integrity. Each disposition’s field names are resolved through the canonical traffic field data dictionary, and each aired row is confirmed against playout by as-run reconciliation before it is sealed, so the bundle attests to what actually aired, not merely what was scheduled.
Prerequisites
- Python 3.11+ — for the
X | Noneunion syntax and timezone-awaredatetime.now(timezone.utc)used in the models below. - Pydantic v2 — pin exactly (
pydantic==2.9.2); the disposition and bundle models rely on v2model_validatorsemantics. - Standard-library crypto only —
hashlibandhmaccover the content hash and the bundle signature; no third-party dependency is required for the seal itself. If you sign with an asymmetric key instead, pin your driver exactly (cryptography==43.0.1). - A shared signing secret or key — one stable HMAC secret (or private key) per traffic environment, held in a secret store, so
stagingandproductionbundles can never be confused and a forged bundle cannot be signed. - Read access to filed dispositions and the as-run log — a
SELECT-only view of the validated dispositions for the window and the as-run reconciliation results that confirm each spot aired; the exporter must never mutate either source.
Step-by-Step Implementation
The exporter runs as a pure pipeline over the window’s dispositions: seal each row with a content hash, render it into a public-file row, validate the set for completeness, and fold the rows into a signed bundle whose digest covers every hash. Nothing is edited in place; a correction is a new bundle version that supersedes the last.
Figure — Audit export pipeline: dispositions are sealed with a content hash, rendered into public-file rows, validated for completeness, then folded into a signed, tamper-evident bundle.
Step 1 — Model the disposition and seal it with a content hash
Goal: fix the typed model that carries a filed row into the export and derive a deterministic content_hash over its immutable fields. Emit audit lines in the traffic-ops timestamp | level | module | spot_id shape so the export itself is reconstructable from the log.
from __future__ import annotations
import hashlib
import hmac
import json
import logging
from datetime import datetime, timezone
from pydantic import BaseModel, Field, model_validator
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("traffic.political.export")
class Disposition(BaseModel):
"""One filed political-file row for a single aired spot."""
order_id: str
spot_id: str
sponsor_id: str
ad_type: str # candidate | issue | pac_independent
class_of_time: str
aired_at: datetime
charged_cents: int = Field(ge=0)
lur_cents: int = Field(gt=0)
airing_confirmed: bool # Set true only by as-run reconciliation
content_hash: str = ""
@model_validator(mode="after")
def _seal(self) -> "Disposition":
# Hash the immutable fields in a fixed order so the digest is stable
# across machines; any later edit to an amount or time breaks it.
seed = "|".join([
self.order_id, self.spot_id, self.sponsor_id, self.ad_type,
self.class_of_time, self.aired_at.astimezone(timezone.utc).isoformat(),
str(self.charged_cents), str(self.lur_cents),
])
object.__setattr__(
self, "content_hash", hashlib.sha256(seed.encode()).hexdigest()
)
return self
Step 2 — Render a disposition into a public-file row
Goal: project the sealed model into the flat, reviewer-facing shape the online public inspection file presents — order, sponsor, class, rate, lowest unit rate, airing — carrying the content hash so a reader can verify the row was not altered.
def render_public_row(d: Disposition) -> dict[str, str | int]:
# A flat, stable projection. Cents render as-is; format to dollars only
# at display time so the audited value stays exact.
row: dict[str, str | int] = {
"order_id": d.order_id,
"spot_id": d.spot_id,
"sponsor_id": d.sponsor_id,
"ad_type": d.ad_type,
"class_of_time": d.class_of_time,
"aired_at": d.aired_at.astimezone(timezone.utc).isoformat(),
"charged_cents": d.charged_cents,
"lur_cents": d.lur_cents,
"content_hash": d.content_hash,
}
logger.info("%s | rendered order=%s hash=%s",
d.spot_id, d.order_id, d.content_hash[:12])
return row
Step 3 — Validate the set for completeness
Goal: fail the whole export closed if any row is missing sponsorship identification, missing a lowest-unit-rate reference, or unconfirmed by as-run. A political file that ships with gaps is worse than late; it misrepresents the record, so completeness is a hard gate, not a warning.
class ExportIncompleteError(ValueError):
"""Raised when the disposition set cannot form a valid public file."""
def validate_completeness(dispositions: list[Disposition]) -> None:
problems: list[str] = []
for d in dispositions:
# §73.1212 sponsorship ID must be present on every filed row.
if not d.sponsor_id.strip():
problems.append(f"{d.spot_id}: missing sponsor_id")
# Every candidate row needs the LUR it was checked against on file.
if d.ad_type == "candidate" and d.lur_cents <= 0:
problems.append(f"{d.spot_id}: candidate row missing lur_cents")
# A row may only be filed once as-run confirms it actually aired.
if not d.airing_confirmed:
problems.append(f"{d.spot_id}: airing not confirmed by as-run")
if problems:
logger.error("export blocked | %d completeness problems", len(problems))
raise ExportIncompleteError("; ".join(problems))
logger.info("completeness OK | %d dispositions", len(dispositions))
A representative blocked run raises with a message like SP-2026-1004-0119: airing not confirmed by as-run, and the export never proceeds to signing.
Step 4 — Fold the rows into a signed, immutable bundle
Goal: compute a bundle digest over every row’s content hash and sign it with the environment secret, producing a tamper-evident artifact. Re-running the exporter on the same dispositions yields a byte-identical digest; changing any row changes the digest and invalidates the signature.
class ExportBundle(BaseModel):
window_label: str # e.g. "2026-general-wxyz"
generated_at: datetime
rows: list[dict[str, str | int]]
bundle_digest: str
signature: str
version: int = 1
def build_bundle(
dispositions: list[Disposition], window_label: str, signing_secret: bytes,
version: int = 1,
) -> ExportBundle:
validate_completeness(dispositions) # Hard gate before sealing.
rows = [render_public_row(d) for d in dispositions]
# Order-independent bundle digest: sort the per-row hashes so the digest
# depends on the SET of rows, not the order they were queried in.
leaf_hashes = sorted(d.content_hash for d in dispositions)
digest = hashlib.sha256("".join(leaf_hashes).encode()).hexdigest()
# HMAC binds the digest to the environment secret: a forged bundle from a
# different set of rows cannot carry a valid signature.
signature = hmac.new(signing_secret, digest.encode(), hashlib.sha256).hexdigest()
logger.info("bundle sealed | window=%s rows=%d digest=%s v%d",
window_label, len(rows), digest[:12], version)
return ExportBundle(
window_label=window_label,
generated_at=datetime.now(timezone.utc),
rows=rows, bundle_digest=digest, signature=signature, version=version,
)
def verify_bundle(bundle: ExportBundle, signing_secret: bytes) -> bool:
# Recompute the signature and compare in constant time; a mismatch means
# a row was edited or the bundle was signed with the wrong key.
expected = hmac.new(
signing_secret, bundle.bundle_digest.encode(), hashlib.sha256
).hexdigest()
ok = hmac.compare_digest(expected, bundle.signature)
logger.info("verify window=%s ok=%s", bundle.window_label, ok)
return ok
A sealed bundle serializes to JSON with json.dumps(bundle.model_dump(), default=str, sort_keys=True), and the sorted-key dump plus the order-independent digest guarantee two runs over the same window produce identical bytes.
Verification & Testing
Correct behaviour rests on two properties: immutability (editing any filed value breaks the signature) and completeness (a gap fails the export before anything is signed). Both are assertable against fixture data drawn from a real window.
SECRET = b"env-production-political-2026"
d = Disposition(order_id="PO-2026-0742", spot_id="SP-1", sponsor_id="SPN-4417",
ad_type="candidate", class_of_time="PRIME-ROT-P",
aired_at=datetime(2026, 10, 4, 1, 32, tzinfo=timezone.utc),
charged_cents=42000, lur_cents=42000, airing_confirmed=True)
# 1. Determinism + immutability: same rows seal to the same digest; an edit breaks it.
bundle = build_bundle([d], "2026-general-wxyz", SECRET)
assert verify_bundle(bundle, SECRET) is True
tampered = bundle.model_copy(update={"bundle_digest": "deadbeef"})
assert verify_bundle(tampered, SECRET) is False # signature no longer matches
# 2. Completeness: an unconfirmed airing fails the export closed, never ships.
unconfirmed = d.model_copy(update={"spot_id": "SP-2", "airing_confirmed": False})
try:
build_bundle([unconfirmed], "2026-general-wxyz", SECRET)
assert False, "expected ExportIncompleteError"
except ExportIncompleteError as exc:
assert "airing not confirmed" in str(exc)
Because the digest is computed over sorted per-row hashes, the bundle is invariant to the order the dispositions were queried in — replaying an archived window during an audit reproduces the same digest and signature. Run the suite in CI against a golden fixture of dispositions and their expected digest so a change to the sealing logic that would silently re-key filed rows fails the build instead of shipping. The confirmed-airing flag comes from detecting as-run versus scheduled discrepancies, so a spot that never aired can never enter the bundle.
Edge Cases & Failure Handling
- A row is corrected after filing. The public file is append-only by design, so a correction is never an in-place edit — it is a new bundle at
version + 1that supersedes the prior digest. Retain every version; an audit reviews the chain of bundles, and the ability to show that version 1 was superseded by version 2 (rather than overwritten) is itself part of the integrity proof. The rebate amendments produced by automating lowest unit rate calculations enter the file this way. - Signing secret rotation. If the environment secret rotates mid-window, previously signed bundles no longer verify against the new secret. Version the secret alongside the bundle and keep the retired secret available for verification of historical bundles; never re-sign an old bundle with a new secret, which would destroy the evidence that it was sealed under the original key.
- As-run confirmation lags the filing deadline. The online public inspection file must be updated within one business day, but as-run reconciliation may not have confirmed every spot by then. File the confirmed rows on time and hold the unconfirmed ones for the next bundle version rather than filing an unconfirmed airing; a late-but-accurate row is defensible, a filed row for a spot that did not air is not. The reconciliation that resolves this is covered in as-run reconciliation and discrepancy handling.
FAQ
Why sign the bundle instead of just writing to a write-once database?
A write-once table proves nothing to an outside reviewer — they have to trust your database permissions. A signed digest travels with the export: anyone holding the environment secret can recompute the signature and prove the rows are exactly as sealed. It also makes the integrity check reproducible offline, during an audit, from the exported bytes alone. The per-row content hash follows the same deterministic discipline the parent FCC political file compliance engine uses to seal each disposition at validation time.
Why is the bundle digest computed over sorted hashes?
So the digest depends on the set of rows, not the order they were queried in. If the digest were sensitive to row order, two exports of the same window from a differently-ordered query would produce different signatures and falsely look like tampering. Sorting the per-row content hashes before folding them makes the bundle order-independent and reproducible, which is exactly what a replay during an audit needs. The field names in each row are held stable by the canonical field data dictionary.
Do issue ads and PAC buys go in the same export as candidate ads?
Yes — §73.1943 requires the political file to hold records for candidate ads, issue ads about matters of national importance, and other political buys alike. They share the bundle but carry different ad_type values, and the completeness gate applies the rate-reference check only to candidate rows, since only candidate time is entitled to the lowest unit rate. The classification that stamps ad_type happens upstream in the parent FCC political file compliance engine.
How do I prove a filed row was never altered after the deadline?
Recompute the row’s content_hash from the source disposition and confirm it matches the hash in the bundle, then verify the bundle signature. If both hold, the row is byte-identical to what was sealed and the bundle was signed under the environment key. A mismatch localizes the tampering to the exact spot. Pair this with as-run reconciliation to show the filed airing matches what playout actually ran.
Related
- Automating FCC Political File Compliance — the parent engine that validates and classifies the orders whose dispositions this exporter seals.
- Automating Lowest Unit Rate Calculations in Python — the rebate amendments that enter the audit bundle as superseding versions.
- As-Run Reconciliation and Discrepancy Handling — the confirmation that each political spot actually aired before its row is sealed into the export.