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 | None union syntax and timezone-aware datetime.now(timezone.utc) used in the models below.
  • Pydantic v2 — pin exactly (pydantic==2.9.2); the disposition and bundle models rely on v2 model_validator semantics.
  • Standard-library crypto onlyhashlib and hmac cover 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 staging and production bundles 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.

Political file audit export bundle pipeline Filed disposition records enter a sealing step that derives a deterministic content hash over each row's immutable fields. The sealed rows are rendered into public-file rows, then validated for completeness: any missing sponsorship identification, missing lowest-unit-rate reference, or unconfirmed airing fails the export closed rather than shipping an incomplete file. A complete set is folded into a bundle whose digest covers every content hash, and the digest is signed with the environment key to produce a tamper-evident export bundle. Dispositions filed rows Seal: content_hash Render public-file rows Complete? ID + LUR + aired Fail closed no partial file shipped no Signed bundle digest + signature yes

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.

python
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.

python
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.

python
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.

python
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.

python
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 + 1 that 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.