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.
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.
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_attoopens_atin 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
ClassOfTimemodel forbids a class that is both, and the lowest-unit-rate lookup keys strictly onclass_id, so a preemptible political spot is only ever compared to preemptible commercial spots. - Issue ad misclassified as candidate. An
ISSUEorPAC_INDEPENDENTorder 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_DUEon 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_idslist still validates its rate but anchors its log line on theorder_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:
{
"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.
Related
- Spot Scheduling Validation & Rule Engines — the parent architecture that defines the taxonomy, contracts, and idempotency guarantees this compliance engine inherits.
- Automating Lowest Unit Rate Calculations in Python — the step-by-step LUR computation and rebate-detection procedure that arms the rate gate above.
- Generating FCC Political File Audit Exports — assembling the immutable, signed disposition bundle that satisfies an online-public-file audit.
- Automating Make-Good Routing for Preemptions — how a preempted political spot still earns a compliant disposition and audit trail.