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.

Make-good routing decision flow A detected preemption is gated on make-good eligibility. Ineligible orders are closed out with no debt owed. Eligible ones search for a replacement inventory window: if a compliant window is found the make-good is routed, otherwise it is escalated for manual review. Preemption detected Make-good eligible? yes Find replacement inventory window Window found? yes Route make-good no Close out no debt owed no Escalate manual review

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.

python
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_id so a single overrun becomes N routable events, not one oversized assignment that no window can absorb.
  • Timezone and DST offsets. Proximity scoring compares starts_at to aired_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_ms is 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_eligible flag 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:

json
{
  "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.

Idempotent make-good dispatch sequence A decoder or telemetry source sends a PreemptionEvent to the MakeGoodRouter, which returns a ROUTED RoutingResult to the dispatch adapter. The adapter emits a makegood.assignment.v1 message carrying a deterministic correlation_id to the playout controller, which applies it on the pair correlation_id and routing_version. When the same signal is replayed under at-least-once delivery, the router derives an identical correlation_id, so the duplicate assignment is deduplicated and dropped by playout as a no-op. Decoder / Telemetry MakeGoodRouter Dispatch Adapter Playout PreemptionEvent RoutingResult · ROUTED makegood.assignment.v1 · corr=9f2c1ab4… apply (corr_id, v1) replayed signal · at-least-once delivery PreemptionEvent (replay) RoutingResult · identical corr_id makegood.assignment.v1 · corr=9f2c1ab4… dedupe → drop (no-op)

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.