Automating FCC Political File Compliance

When a candidate’s committee buys time in the weeks before an election, the station takes on a federal recordkeeping obligation that a spreadsheet cannot safely carry: every order, its rate, its class of time, and the disposition of every spot must appear in the online political file within one business day, and the price charged can never exceed the lowest unit rate the station charged any commercial advertiser for the same class in the same period. Miss a filing, misclassify an issue ad as a candidate ad, or overcharge against a rate that dropped later in the window, and the station is exposed to an FCC complaint, a make-good obligation it never budgeted for, and a public-file audit it cannot pass. This guide treats political-file upkeep as a deterministic, event-driven subsystem within Spot Scheduling Validation & Rule Engines: it ingests each political order, validates its rate against the live rate card, flags lowest-unit-rate and sponsorship-identification violations before they reach air, and writes an immutable disposition record for every spot. It is written for two readers at once — traffic managers who need the plain-language reasoning behind each rule under 47 CFR §73.1943, and Python automation builders who need a deployable module with strict typing, Pydantic v2 validation, and structured logging.

Political compliance sits alongside the rest of the validation stack rather than bolted onto billing after the fact. It reuses the same normalized identifiers that feed make-good routing, because a preempted political spot still owes a compliant disposition; it depends on billing code normalization so a candidate’s rate joins to the same class of time as the commercial spots it is compared against; and it resolves every field name through the canonical traffic field data dictionary so the file the FCC reads is built from the same shapes the scheduler validated. Two companion guides go deeper on the hardest mechanics: automating lowest unit rate calculations and generating political file audit exports.

Concept & Data Model

Political-file automation operates on four entities. Each carries a stable schema so that rate validation, classification, and file assembly stay decoupled from playout and billing.

Entity Purpose Key fields Constraints
PoliticalOrder A candidate or issue buy with its terms order_id, sponsor_id, ad_type, class_of_time, unit_rate_cents, window, spot_ids unit_rate_cents > 0; sponsor_id non-empty; ad_type in a fixed vocabulary
RateCard Rates charged to all advertisers by class card_id, effective_at, class_rates, is_political_window effective_at is timezone-aware UTC; one row per class
ClassOfTime A comparable pricing bucket class_id, daypart, preemptible, fixed class_id stable across systems; preemptibility drives comparability
DispositionRecord The immutable per-spot public-file row disposition_id, order_id, spot_id, aired_at, charged_cents, content_hash charged_cents <= lur_cents; content_hash deterministic

The ad_type field draws from a fixed vocabulary — CANDIDATE, ISSUE, PAC_INDEPENDENT — because the classification drives which federal rules attach. A CANDIDATE ad bought by a legally qualified candidate’s authorized committee is entitled to the lowest unit rate under §73.1942 during the pre-election windows and enjoys reasonable-access and equal-opportunity protections; an ISSUE ad about a “matter of national importance” triggers the political-file obligations of §73.1943 but is not entitled to the lowest unit rate; a PAC_INDEPENDENT buy is a non-candidate ad that still requires full sponsorship disclosure. Misfiling one as the other is the single most common political-file error, and it is a classification the engine must make explicitly rather than infer from who paid.

The pre-election window itself is a hard temporal boundary. The lowest-unit-rate obligation attaches during the 45 days before a primary and the 60 days before a general election; outside those windows a candidate may be charged the station’s “comparable” commercial rate instead. The engine therefore treats the window as a first-class attribute of both the order and the rate card, not a date arithmetic afterthought.

Every order moves through a small state machine. It is RECEIVED until its terms are validated against the active rate card; it becomes VALIDATED once the rate, class, and sponsorship gates pass; it is FILED when its disposition rows are written to the online public inspection file, FLAGGED when a lowest-unit-rate or sponsorship violation is detected, or REBATE_DUE when a later, lower rate obligates the station to refund the difference.

Political order compliance decision flow A received political order is first classified as a candidate, issue, or independent-PAC ad. It then passes a sponsorship-identification gate; a missing or malformed sponsor disclosure flags the order for manual correction. Cleared orders are checked against the lowest unit rate for their class of time within the pre-election window: an order priced at or below the lowest unit rate is validated and its disposition rows are filed to the online public inspection file, while an order priced above it is flagged as an overcharge and marked rebate-due so the station can refund the difference. Political order Classify ad type Sponsorship ID §73.1212 gate ok Rate ≤ LUR? yes File to public file missing Flag · hold correct disclosure over Rebate due refund difference

Figure — Political order compliance flow: an order is classified, gated on sponsorship identification, then checked against the lowest unit rate for its class before its dispositions are filed to the online public inspection file.

Implementation Approach

Three design decisions shape a production political-file engine, and each is a trade-off worth stating explicitly.

Validate at order time, re-validate at rate change. The naive approach checks a political rate once, when the order is booked. But the lowest unit rate is not a fixed number — it is the lowest rate charged to any advertiser for the same class during the window, so it falls every time a commercial advertiser buys the same class cheaper. A political order that was compliant on Monday can become an overcharge on Thursday. The engine therefore treats every rate-card change during a pre-election window as an event that re-runs lowest-unit-rate validation across all open political orders in the affected class, emitting a REBATE_DUE result when a later, lower rate appears. That rebate mechanics is the core of automating lowest unit rate calculations.

Classification is explicit, never inferred from price or buyer. It is tempting to infer ad type from the sponsor name, but a station cannot lawfully guess. The engine requires ad_type as a declared attribute of the order and validates the consequences of that declaration — a CANDIDATE ad must carry a legally-qualified-candidate reference and is entitled to the lowest unit rate; an ISSUE ad must carry the §73.1943(a) disclosures (the issue to which it refers, and the list of the sponsoring organization’s chief executive officers or board of directors) but is not rate-protected. Misdeclaration is caught by cross-field validation, not by a heuristic that could quietly overcharge a candidate.

Immutability by content hash, not by database permission. The public inspection file is an audit artifact; its rows must be reconstructable and tamper-evident. Rather than relying on a write-once table, the engine derives a deterministic content_hash over each disposition’s stable fields, so a replayed export produces byte-identical rows and any post-hoc edit is detectable as a hash mismatch. This mirrors the idempotency contract used across the validation stack and the as-run reconciliation that later confirms each political spot actually aired as filed.

Before any of this, the engine must confirm the order’s class of time is comparable to the classes on the rate card. That comparability leans on billing code normalization so a candidate’s “prime rotator, preemptible” maps to the same class the commercial advertisers were charged under.

Production Python Implementation

The module below is deployable as a library or a worker. It uses Pydantic v2 for schema enforcement, strict type hints throughout, cross-field validators for classification and rate gates, a deterministic disposition hash for immutability, and structured logging in the traffic-ops format timestamp | level | module | spot_id.

python
from __future__ import annotations

import hashlib
import logging
from datetime import datetime, timezone
from enum import Enum
from typing import Optional

from pydantic import BaseModel, Field, field_validator, model_validator

# --- Structured logging: timestamp | level | module | spot_id ---------------
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("political_file")


def _log(level: int, spot_id: str, msg: str) -> None:
    # Prepend the spot_id so every audit line is greppable by placement.
    logger.log(level, "%s | %s", spot_id, msg)


class AdType(str, Enum):
    CANDIDATE = "candidate"          # Authorized committee; LUR-entitled in window
    ISSUE = "issue"                  # Matter of national importance; not LUR-entitled
    PAC_INDEPENDENT = "pac_independent"  # Non-candidate buy; full disclosure required


class OrderStatus(str, Enum):
    VALIDATED = "validated"
    FILED = "filed"
    FLAGGED = "flagged"              # Sponsorship or classification defect
    REBATE_DUE = "rebate_due"        # A later, lower rate obligates a refund


class ClassOfTime(BaseModel):
    class_id: str                    # Stable across systems, e.g. "PRIME-ROT-P"
    daypart: str                     # e.g. "prime", "early_news", "rotator"
    preemptible: bool                # Preemptible and fixed are NOT comparable
    fixed: bool = False

    @model_validator(mode="after")
    def _preemptibility_is_exclusive(self) -> "ClassOfTime":
        # A class cannot be both fully preemptible and fixed-position; the two
        # are different products and must not be compared for LUR purposes.
        if self.preemptible and self.fixed:
            raise ValueError("class cannot be both preemptible and fixed")
        return self


class SponsorDisclosure(BaseModel):
    sponsor_id: str
    sponsor_name: str                # As it must appear on-air per §73.1212
    # For ISSUE / PAC ads, §73.1943(a) requires the chief officers on file.
    chief_officers: list[str] = Field(default_factory=list)

    @field_validator("sponsor_name")
    @classmethod
    def _non_empty(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("sponsor_name is required for sponsorship ID")
        return v.strip()


class PoliticalWindow(BaseModel):
    kind: str                        # "primary" or "general"
    election_at: datetime
    opens_at: datetime               # 45 days pre-primary / 60 days pre-general
    closes_at: datetime

    @field_validator("election_at", "opens_at", "closes_at")
    @classmethod
    def _utc_aware(cls, v: datetime) -> datetime:
        # Naive datetimes silently misplace the window boundary across DST.
        if v.tzinfo is None:
            raise ValueError("window datetimes must be timezone-aware (UTC)")
        return v.astimezone(timezone.utc)

    def covers(self, moment: datetime) -> bool:
        return self.opens_at <= moment <= self.closes_at


class PoliticalOrder(BaseModel):
    order_id: str
    sponsor: SponsorDisclosure
    ad_type: AdType
    class_of_time: ClassOfTime
    unit_rate_cents: int = Field(gt=0)   # Rate charged per spot, in cents
    window: PoliticalWindow
    spot_ids: list[str]
    booked_at: datetime

    @field_validator("booked_at")
    @classmethod
    def _utc_aware(cls, v: datetime) -> datetime:
        if v.tzinfo is None:
            raise ValueError("booked_at must be timezone-aware (UTC)")
        return v.astimezone(timezone.utc)

    @model_validator(mode="after")
    def _classification_consequences(self) -> "PoliticalOrder":
        # Cross-field validation: the declared ad_type carries obligations.
        # ISSUE and PAC ads must list the sponsoring org's chief officers on
        # file; a CANDIDATE ad is filed under its authorized committee instead.
        if self.ad_type in (AdType.ISSUE, AdType.PAC_INDEPENDENT):
            if not self.sponsor.chief_officers:
                raise ValueError(
                    f"{self.ad_type.value} ad requires chief_officers on file "
                    "per 47 CFR 73.1943(a)"
                )
        return self


class RateCard(BaseModel):
    card_id: str
    effective_at: datetime
    is_political_window: bool            # True during a 45/60-day pre-election window
    # Lowest rate charged to ANY advertiser per class, in cents.
    class_rates: dict[str, int]

    @field_validator("effective_at")
    @classmethod
    def _utc_aware(cls, v: datetime) -> datetime:
        if v.tzinfo is None:
            raise ValueError("effective_at must be timezone-aware (UTC)")
        return v.astimezone(timezone.utc)


class DispositionRecord(BaseModel):
    disposition_id: str
    order_id: str
    spot_id: str
    aired_at: datetime
    charged_cents: int = Field(ge=0)
    lur_cents: int = Field(gt=0)
    content_hash: str = ""

    @model_validator(mode="after")
    def _seal(self) -> "DispositionRecord":
        # Derive a deterministic hash over the immutable fields so a replayed
        # export is byte-identical and any later edit is detectable.
        seed = (
            f"{self.order_id}|{self.spot_id}|"
            f"{self.aired_at.isoformat()}|{self.charged_cents}"
        )
        object.__setattr__(
            self, "content_hash", hashlib.sha256(seed.encode()).hexdigest()[:32]
        )
        return self


class ComplianceResult(BaseModel):
    status: OrderStatus
    order_id: str
    lur_cents: Optional[int] = None
    overcharge_cents: int = 0
    error_code: Optional[str] = None
    decided_at: datetime = Field(
        default_factory=lambda: datetime.now(timezone.utc)
    )


class PoliticalFileEngine:
    """Validates political orders for LUR and sponsorship-ID compliance."""

    def _lowest_unit_rate(
        self, order: PoliticalOrder, card: RateCard
    ) -> Optional[int]:
        # The LUR is the lowest rate charged to any advertiser for the SAME
        # class during the window. Outside the window the rule does not apply.
        if not order.window.covers(order.booked_at):
            return None
        return card.class_rates.get(order.class_of_time.class_id)

    def validate(
        self, order: PoliticalOrder, card: RateCard
    ) -> ComplianceResult:
        oid = order.order_id
        anchor = order.spot_ids[0] if order.spot_ids else oid

        # Gate 1: sponsorship identification must be present (§73.1212).
        if not order.sponsor.sponsor_name:
            _log(logging.WARNING, anchor, f"order={oid} missing sponsorship ID")
            return ComplianceResult(status=OrderStatus.FLAGGED, order_id=oid,
                                    error_code="ERR_SPONSOR_MISSING")

        lur = self._lowest_unit_rate(order, card)

        # CANDIDATE ads inside the window are entitled to the LUR; ISSUE and
        # PAC ads are filed but pay the comparable commercial rate.
        if order.ad_type is AdType.CANDIDATE and lur is not None:
            if order.unit_rate_cents > lur:
                overcharge = order.unit_rate_cents - lur
                _log(logging.WARNING, anchor,
                     f"order={oid} rate {order.unit_rate_cents} exceeds LUR "
                     f"{lur} by {overcharge} | rebate_due")
                return ComplianceResult(status=OrderStatus.REBATE_DUE,
                                        order_id=oid, lur_cents=lur,
                                        overcharge_cents=overcharge,
                                        error_code="ERR_LUR_EXCEEDED")

        _log(logging.INFO, anchor,
             f"order={oid} type={order.ad_type.value} validated | lur={lur}")
        return ComplianceResult(status=OrderStatus.VALIDATED, order_id=oid,
                                lur_cents=lur)

The engine never writes to the public file directly. It returns a ComplianceResult; a separate export builder (see generating political file audit exports) assembles the DispositionRecord rows once validation passes. That separation keeps validation pure and testable, and it means a re-validation triggered by a rate change can be re-run safely without touching filed rows.

Validation & Edge Cases

Political scheduling produces boundary conditions that a naive engine mishandles. Each of the following is exercised by the implementation above.

  • The window boundary crosses a DST change. The 45-day and 60-day windows are counted in calendar days, but a station comparing booked_at to opens_at in local time can misjudge the boundary by an hour around a DST transition and admit or exclude an order incorrectly. The Pydantic validators reject naive datetimes and normalize every timestamp to UTC, so window membership is unambiguous.
  • Preemptible and fixed classes are not comparable. A candidate cannot demand the lowest fixed-position rate against a preemptible buy — they are different products. The ClassOfTime model forbids a class that is both, and the lowest-unit-rate lookup keys strictly on class_id, so a preemptible political spot is only ever compared to preemptible commercial spots.
  • Issue ad misclassified as candidate. An ISSUE or PAC_INDEPENDENT order that omits its chief-officer disclosures fails cross-field validation outright rather than being filed as a rate-protected candidate ad. This is the classification error most likely to surface in an FCC complaint, so the engine fails closed.
  • Rate drops after the spot has already aired. If a commercial advertiser buys the same class cheaper after a political spot ran at the old rate, the station still owes the difference. The engine emits REBATE_DUE on the later rate change; the aired disposition is not rewritten — a new adjustment references it, preserving the immutable trail confirmed by as-run reconciliation.
  • Zero-spot or malformed order. An order with an empty spot_ids list still validates its rate but anchors its log line on the order_id, so a booking defect is visible in the audit stream rather than silently skipped.

Integration Points

Upstream, the engine consumes normalized orders. Political buys arriving from the sales and order-management systems are mapped into the PoliticalOrder schema by ingestion adapters, and their class of time is resolved through billing code normalization so the engine works against a single canonical shape rather than vendor-specific payloads.

Downstream, the export builder turns a VALIDATED or REBATE_DUE result into public-file rows. The wire contract carries the classification, the class, the rate, and the lowest unit rate it was checked against, so a reviewer can reconstruct the decision from the message alone:

json
{
  "message_type": "political.disposition.v1",
  "order_id": "PO-2026-0742",
  "sponsor_id": "SPN-4417",
  "ad_type": "candidate",
  "class_of_time": "PRIME-ROT-P",
  "window": "general",
  "unit_rate_cents": 42000,
  "lur_cents": 42000,
  "spot_id": "SP-2026-1004-0112",
  "aired_at": "2026-10-04T01:32:11Z",
  "charged_cents": 42000,
  "content_hash": "a19f5c0e77d0c5a2b6e91f0c4d8b3e21",
  "filed_at": "2026-10-04T14:07:00Z"
}

The content_hash is the join key between the filed row, the billing posting, and the as-run record. Because it is derived deterministically from the order, spot, airing time, and amount charged, a duplicate delivery is a no-op at the receiver and any downstream edit to the amount breaks the hash — which is exactly what a §73.1943 audit needs to prove a filed row was never altered.

Compliance & Audit Considerations

Political-file automation is compliance-critical because a single missed obligation is directly actionable under federal rule. Three obligations apply directly.

Online public inspection file timeliness. Under §73.1943, records of political-time requests and their disposition must be placed in the online public inspection file “as soon as possible,” which the FCC treats as within one business day. The engine timestamps each ComplianceResult and each DispositionRecord in UTC so a late filing is visible in the audit stream, and it files on validation rather than on billing close so the file is never gated on a slow revenue process.

Lowest unit rate integrity. During the 45-day pre-primary and 60-day pre-general windows, a candidate cannot be charged more than the lowest unit rate for the class under §73.1942. The engine re-runs the rate check on every rate-card change and emits REBATE_DUE with the exact overcharge_cents when a later, lower rate appears, so the refund obligation is computed automatically rather than discovered in an audit. The full rebate mechanics live in automating lowest unit rate calculations.

Sponsorship identification and immutable lineage. Every ad must disclose its sponsor under §73.1212, and issue and PAC ads must additionally list the sponsoring organization’s chief officers under §73.1943(a). The engine gates on that disclosure before any row is filed, and the deterministic content_hash ties each filed disposition back to the exact order and airing that produced it — the tamper-evident trail a regulatory review or a billing reconciliation needs to reconstruct why a political spot aired at the price it did. Assembling that tamper-evident bundle is covered in generating political file audit exports.

Troubleshooting & Common Errors

Error code Root cause Remediation
ERR_SPONSOR_MISSING Order reached validation with an empty sponsor_name Resolve the sponsorship disclosure in the order-management system before filing; §73.1212 forbids airing an unidentified sponsor — do not override
ERR_LUR_EXCEEDED A candidate rate is above the lowest unit rate for its class in the window Issue the computed overcharge_cents as a rebate and re-file; verify the class comparison keyed on the correct class_id and not a mismatched preemptibility
ERR_CLASS_INCOMPARABLE The order’s class of time has no matching row on the rate card Normalize the class through billing code normalization so the candidate buy maps to the same class the commercial spots were charged under
ERR_WINDOW_MISMATCH booked_at falls outside the declared pre-election window Confirm the primary/general window dates; outside the window the comparable commercial rate applies and the LUR gate is intentionally skipped
ERR_HASH_CONFLICT A re-derived content_hash does not match a previously filed row Treat as tampering or a field drift; reconcile against the as-run record before re-filing and never overwrite the original filed row

The lowest-unit-rate gate deserves particular attention. ERR_LUR_EXCEEDED rarely means the original booking was wrong — far more often a commercial advertiser bought the same class cheaper later in the window and dropped the lowest unit rate beneath a political rate that was compliant when booked. The fix is a rebate, not a re-book, and the threshold calibration that decides how aggressively to re-scan open orders is explored in Tuning Thresholds for Scheduling Accuracy.