Automating Make-Good Routing for Preemptions
When a scheduled commercial is displaced by breaking news, a sports overrun, or an Emergency Alert System activation, the station has just incurred a contractual debt: a guaranteed spot did not air, and the advertiser is owed compensatory inventory. Resolve that debt by hand and three things break — pacing falls behind on time-sensitive campaigns, competitive-separation rules get violated in the scramble for open avails, and the audit trail needed for billing reconciliation never gets written. This page treats make-good routing as a deterministic, event-driven subsystem within Spot Scheduling Validation & Rule Engines: it ingests a preemption signal, gates it on eligibility, finds a compliant replacement window, and emits an idempotent assignment to playout — with every decision logged for audit. The material is written for two readers at once. Traffic managers get the plain-language reasoning behind each gate; Python automation builders get a deployable module with strict typing, Pydantic validation, and structured logging.
Make-good routing sits downstream of schedule generation and upstream of playout execution. It consumes the same normalized records that feed rule-based spot rotation and it must never contradict the collision findings produced by time slot conflict detection. Where a preempted spot’s under-delivery is measured against a ratings guarantee, the routing decision is armed by the thresholds defined in Configuring Make-Good Triggers Based on Ratings.
Concept & Data Model
A make-good is compensatory inventory automatically routed to fulfil a displaced contractual obligation. The routing engine operates on four entities, each with a stable schema so that validation stays decoupled from playout and billing.
| Entity | Purpose | Key fields | Constraints |
|---|---|---|---|
PreemptionEvent |
The normalized signal that a spot did not air | preemption_id, original_spot_id, reason, duration_ms, aired_at |
duration_ms > 0; aired_at is timezone-aware UTC |
InventoryWindow |
A candidate replacement avail | window_id, daypart, starts_at, remaining_units, category_lock |
remaining_units >= 1 to be routable |
MakeGoodAssignment |
The emitted routing decision | correlation_id, original_spot_id, target_window_id, routing_version |
correlation_id is deterministic and unique |
RoutingResult |
The outcome envelope for downstream consumers | status, assignment, error_code, decided_at |
exactly one of assignment / error_code is set |
The reason field draws from a fixed vocabulary — BREAKING_NEWS, SPORTS_OVERRUN, EAS_ACTIVATION, NETWORK_SHIFT, TECHNICAL_FAULT — because the reason drives the eligibility tier. An EAS activation is a non-negotiable federal preemption that is always make-good eligible; a technical fault at the station may fall under a different make-good clause, or none at all.
Every event moves through a small state machine. A signal is PENDING until it is validated against the active traffic log; it becomes ELIGIBLE once contractual and inventory gates pass; it is ROUTED when an assignment is dispatched, ESCALATED when no compliant window exists, or CLOSED when the underlying order is not make-good eligible.
Figure — Make-good routing: a detected preemption is gated on eligibility, then routed to a replacement window if one is found, otherwise escalated or closed out.
Implementation Approach
Three design decisions shape a production make-good router, and each is a trade-off worth stating explicitly.
Event-driven, not batch. A make-good that lands a day late has already failed the pacing goal for a same-day campaign. The router therefore consumes preemption signals as they arrive — from EAS/NWS decoder output, network SCTE-35 splice inserts, traffic-system override logs, and playout telemetry — rather than sweeping a log on a cron. The cost is that ordering is not guaranteed across sources, so every signal is normalized and de-duplicated on preemption_id before any routing logic runs.
Deterministic idempotency over locking. Two decoder paths can report the same preemption; a retry can replay a signal already routed. Instead of distributed locks, the engine derives a deterministic correlation_id from the preemption identity, so a replay produces a byte-identical assignment that downstream systems recognise and discard. This mirrors the idempotency contract used across the validation stack and keeps the router stateless enough to scale horizontally.
Scored selection, not first-fit. When several windows are open, first-fit tends to burn premium inventory on low-priority recoveries. The engine instead scores each candidate window on temporal proximity to the original airing, daypart parity, and remaining units, then routes to the highest-scoring compliant window. The weighting is configuration, owned by traffic managers, and it reuses the same declarative pattern that drives spot rotation rule engines so that rotation and make-good never diverge on separation logic.
Before any of this, the router must confirm the preempted spot actually existed and that its metadata is intact. That validation leans on the canonical spot schema for field definitions and on billing code normalization so the recovered airing bills against the same code as the spot it replaces.
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, a deterministic correlation key, a simple circuit breaker to protect downstream systems during inventory exhaustion, 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, timedelta, timezone
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field, field_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("makegood_router")
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 PreemptionReason(str, Enum):
BREAKING_NEWS = "breaking_news"
SPORTS_OVERRUN = "sports_overrun"
EAS_ACTIVATION = "eas_activation" # Federally mandated; always eligible
NETWORK_SHIFT = "network_shift"
TECHNICAL_FAULT = "technical_fault" # May not be make-good eligible
class RoutingStatus(str, Enum):
ROUTED = "routed"
ESCALATED = "escalated" # No compliant window found
CLOSED = "closed" # Order not make-good eligible
REJECTED = "rejected" # Failed a compliance gate
class PreemptionEvent(BaseModel):
preemption_id: str
original_spot_id: str
reason: PreemptionReason
advertiser_category: str # e.g. "auto", "qsr", "pharma"
duration_ms: int = Field(gt=0)
aired_at: datetime # Scheduled airing that was lost
make_good_eligible: bool # From the order's contractual terms
@field_validator("aired_at")
@classmethod
def _utc_aware(cls, v: datetime) -> datetime:
# Naive datetimes silently corrupt proximity scoring across DST.
if v.tzinfo is None:
raise ValueError("aired_at must be timezone-aware (UTC)")
return v.astimezone(timezone.utc)
class InventoryWindow(BaseModel):
window_id: str
daypart: str
starts_at: datetime
remaining_units: int = Field(ge=0)
category_lock: Optional[str] = None # Category already held in this break
fcc_cleared: bool = True # Sponsorship-ID clearance in place
@field_validator("starts_at")
@classmethod
def _utc_aware(cls, v: datetime) -> datetime:
if v.tzinfo is None:
raise ValueError("starts_at must be timezone-aware (UTC)")
return v.astimezone(timezone.utc)
class MakeGoodAssignment(BaseModel):
correlation_id: str
original_spot_id: str
target_window_id: str
routing_version: int = 1
class RoutingResult(BaseModel):
status: RoutingStatus
assignment: Optional[MakeGoodAssignment] = None
error_code: Optional[str] = None
decided_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc)
)
class MakeGoodRouter:
"""Deterministic make-good routing for preempted broadcast inventory."""
# Windows within this horizon preserve campaign pacing; beyond it we escalate.
PROXIMITY_HORIZON = timedelta(hours=24)
def __init__(self, failure_threshold: int = 5) -> None:
self._consecutive_failures = 0
self._failure_threshold = failure_threshold
self._breaker_open = False
# -- Idempotency ---------------------------------------------------------
@staticmethod
def _correlation_id(event: PreemptionEvent) -> str:
# Deterministic key: a replayed signal yields an identical assignment.
seed = f"{event.preemption_id}:{event.original_spot_id}"
return hashlib.sha256(seed.encode()).hexdigest()[:24]
# -- Eligibility gate ----------------------------------------------------
@staticmethod
def _is_eligible(event: PreemptionEvent) -> bool:
# EAS activations are always eligible; otherwise honour the order terms.
if event.reason is PreemptionReason.EAS_ACTIVATION:
return True
return event.make_good_eligible
# -- Compliance + scoring ------------------------------------------------
def _score_window(
self, event: PreemptionEvent, window: InventoryWindow
) -> Optional[float]:
# Return a score, or None if the window fails a hard compliance gate.
if not window.fcc_cleared:
return None
if window.remaining_units < 1:
return None
if window.category_lock == event.advertiser_category:
return None # Competitive separation: category already in the break
delta = abs(window.starts_at - event.aired_at)
if delta > self.PROXIMITY_HORIZON:
return None
# Closer to the original airing scores higher; ties broken by supply.
proximity = 1.0 - (delta / self.PROXIMITY_HORIZON)
return proximity + (window.remaining_units * 0.01)
def route(
self, event: PreemptionEvent, windows: list[InventoryWindow]
) -> RoutingResult:
sid = event.original_spot_id
if self._breaker_open:
_log(logging.ERROR, sid, "circuit_breaker_open | routing halted")
return RoutingResult(status=RoutingStatus.ESCALATED,
error_code="ERR_BREAKER_OPEN")
if not self._is_eligible(event):
_log(logging.INFO, sid, "not make-good eligible | closing out")
return RoutingResult(status=RoutingStatus.CLOSED)
scored = sorted(
((s, w) for w in windows if (s := self._score_window(event, w))),
key=lambda pair: pair[0],
reverse=True,
)
if not scored:
self._record_failure(sid)
_log(logging.WARNING, sid, "no compliant window | escalating")
return RoutingResult(status=RoutingStatus.ESCALATED,
error_code="ERR_WINDOW_EXHAUSTED")
best = scored[0][1]
self._consecutive_failures = 0 # A success resets the breaker
assignment = MakeGoodAssignment(
correlation_id=self._correlation_id(event),
original_spot_id=sid,
target_window_id=best.window_id,
)
_log(logging.INFO, sid,
f"routed to {best.window_id} | corr={assignment.correlation_id}")
return RoutingResult(status=RoutingStatus.ROUTED, assignment=assignment)
def _record_failure(self, spot_id: str) -> None:
self._consecutive_failures += 1
if self._consecutive_failures >= self._failure_threshold:
self._breaker_open = True
_log(logging.ERROR, spot_id,
f"circuit_breaker tripped after "
f"{self._consecutive_failures} failures")
The router never mutates playout state directly. It returns a RoutingResult; a separate dispatch adapter (see Integration Points) is responsible for delivering the assignment. That separation keeps the routing decision pure and testable, and it means a replayed signal can be re-decided safely without side effects.
Validation & Edge Cases
Broadcast operations produce boundary conditions that a naive router mishandles. Each of the following is exercised by the implementation above.
- Sports overrun with a moving target. An overrun does not just displace one spot; it slides an entire break. Route each preempted spot independently on its own
preemption_idso a single overrun becomes N routable events, not one oversized assignment that no window can absorb. - Timezone and DST offsets. Proximity scoring compares
starts_attoaired_at. A naive local datetime crossing a DST boundary distorts the delta by an hour and can push a valid window past the horizon. The Pydantic validators reject naive datetimes outright and normalize everything to UTC. - Competitive separation. A window that already holds the advertiser’s category (
category_lock) is disqualified before scoring — the same separation discipline enforced during time slot conflict detection, applied at recovery time so a make-good never creates a category collision. - Zero-duration or malformed signals.
duration_msis constrained to be positive; a zero-duration or negative signal fails validation and is routed to a dead-letter queue rather than silently producing a phantom make-good. - Preemption tiers. An EAS activation outranks a network shift, which outranks a station technical fault. The eligibility gate encodes the federal case explicitly; contractual tiers below it are honoured through the order’s
make_good_eligibleflag rather than hard-coded in the engine.
Integration Points
Upstream, the router consumes normalized records. Preemption signals from decoders and telemetry are mapped into the PreemptionEvent schema by ingestion adapters, and the original spot’s metadata is resolved through the pipelines described in Avion & Avstar Ingestion Pipelines so the router works against a single canonical shape rather than vendor-specific payloads.
Downstream, a dispatch adapter turns a ROUTED result into a playout instruction. The wire contract is deliberately small and carries the idempotency key so a duplicate delivery is a no-op at the receiver:
{
"message_type": "makegood.assignment.v1",
"correlation_id": "9f2c1ab4e77d0c5a2b6e91f0",
"original_spot_id": "SP-2026-0714-0031",
"target_window_id": "AV-PRIME-1930-04",
"routing_version": 1,
"issued_at": "2026-07-03T18:42:07Z"
}
The routing_version increments only when a prior assignment for the same correlation_id is superseded — for example, when a higher-rated recovery window opens after the first route. Playout controllers reconcile on the pair (correlation_id, routing_version), applying the highest version and discarding the rest, which prevents duplicate placement without a distributed lock.
Figure — Idempotent dispatch: the first delivery routes and applies an assignment; a replayed signal derives the same correlation_id, so playout deduplicates the duplicate and drops it as a no-op.
Compliance & Audit Considerations
Make-good routing is compliance-critical because a mis-routed recovery can violate federal rules or misstate revenue. Three obligations apply directly.
FCC sponsorship identification. Any window whose sponsorship-ID clearance is not in place (fcc_cleared = False) is disqualified before scoring — the engine cannot route an advertiser into a break that has not cleared. This gate is non-optional and cannot be overridden by a proximity score.
Immutable audit trail. Every decision — routed, escalated, closed, or rejected — is emitted on the timestamp | level | module | spot_id log line and should be shipped to append-only storage. The deterministic correlation_id ties the make-good back to the exact preemption that caused it, which is what a billing reconciliation or a regulatory review needs to reconstruct why compensatory inventory aired.
Billing lineage. The recovered airing must bill against the same normalized code as the spot it replaces. Keeping the router pure — decision in, result out, no hidden mutation — means the assignment is the single source of truth that billing joins on, consistent with the practices in billing code normalization.
Where recovery is driven by under-delivery against a ratings guarantee rather than a hard preemption, the activation threshold is a separate compliance surface covered in Configuring Make-Good Triggers Based on Ratings.
Troubleshooting & Common Errors
| Error code | Root cause | Remediation |
|---|---|---|
ERR_WINDOW_EXHAUSTED |
No candidate window survived the compliance and proximity gates | Widen the proximity horizon for low-priority orders, or escalate to the traffic desk for a manual placement outside the automated window pool |
ERR_FCC_CLEARANCE |
Best-scoring window lacks sponsorship-ID clearance | Route to the next compliant window; do not override — resolve clearance in the order-management system first |
ERR_COMPETITIVE_CONFLICT |
Every open window already holds the advertiser’s category | Relax separation only if the contract permits co-scheduling; otherwise defer to a later break |
ERR_BREAKER_OPEN |
Consecutive routing failures tripped the circuit breaker | Investigate the upstream inventory feed; a stale or empty window set is the usual cause. Reset the breaker only after supply is confirmed |
ERR_DUPLICATE_ROUTING |
A signal replayed after its assignment was already dispatched | Expected under at-least-once delivery — the matching correlation_id makes the second dispatch a no-op; verify the receiver is de-duplicating on that key |
The circuit breaker deserves particular attention. It exists to stop the router from hammering playout with escalations when the real problem is an empty inventory feed. When ERR_BREAKER_OPEN appears, the fault is almost always upstream — an ingestion adapter delivering zero windows, or a threshold miscalibration explored in Tuning Thresholds for Scheduling Accuracy — not the router itself.
Related
- Spot Scheduling Validation & Rule Engines — the parent architecture that defines the taxonomy, contracts, and idempotency guarantees this router inherits.
- Building Rule Engines for Spot Rotation — the declarative separation and weighting logic that make-good routing reuses so recovery never contradicts rotation.
- Detecting Time Slot Conflicts in Traffic Logs — the collision-detection gate that validates a preempted spot before it can be recovered.
- Configuring Make-Good Triggers Based on Ratings — how under-delivery thresholds arm the router for guarantee-driven recovery.
- Tuning Thresholds for Scheduling Accuracy — calibrating the proximity horizon and breaker thresholds that govern routing behaviour.