Building Fallback Chains for Missing Guild Rate Tables in Python

A missing guild rate table is a narrower failure than a down endpoint, and it needs a narrower answer. The primary feed responds cleanly, but it has no row for the guild, jurisdiction, contract tier, and work date you asked for — a freshly ratified amendment the vendor has not published yet, a new filming zone added mid-schedule, or a crew classification that never existed in the table. A KeyError here is not a bug to swallow; it is the exact moment a payroll batch either stalls or invents an indefensible number. This page builds the missing-rate-table case that the Compliance Fallback Chains architecture frames: how to synthesize a replacement rate from cached and rulebook sources, attach a confidence score, and write a provenance record precise enough that a completion guarantor can reconstruct exactly how the number was derived when no authoritative row existed.

Prerequisites and Context

This page extends the Compliance Fallback Chains reference, which specifies the tiered state machine as a whole; here we focus solely on the tier logic that fires when a lookup returns no matching row. The implementation targets Python 3.11+ for zoneinfo in the standard library. It leans on Pydantic v2 for boundary validation via model_validate and field_validator, and on the standard-library decimal, hashlib, sqlite3, and zoneinfo modules for currency-safe arithmetic, deterministic audit hashing, a versioned local cache, and timezone-aware timestamps. Monetary values are always Decimal, never float — a fractional-cent drift compounded across thousands of Pension & Health (P&H) contribution lines is precisely the variance a guarantor will make you explain.

The contract clause that governs everything below is effective_from: a collective bargaining agreement (CBA) is a succession of dated amendments, so a rate is valid only if the agreement version in force brackets the date the work was performed. A replacement table assembled from a stale season is not a fallback — it is a repricing error waiting to be flagged. The chain assumes its inputs are already normalized upstream: heterogeneous timecards are cleaned by the Cost Ingestion & Data Parsing Workflows subsystem, and each hour is keyed to a single fund code through Cost Code Standardization before it ever reaches this resolver.

The Resolution Hierarchy for a Missing Row

When the authoritative lookup returns nothing, the resolver must not guess and must not retry blindly against a source that has no data to give. It advances through four tiers in strict order — live API, versioned local cache, rulebook-grounded interpolation, and a hardcoded contract floor — writing an append-only audit entry at each tier before either auto-posting or quarantining the record based on its confidence score. A circuit breaker guards Tier 1 so that a genuinely unavailable source is skipped rather than hammered, but a present-yet-incomplete source still routes forward on the missing key.

The cascade below shows each tier activating only when the prior tier cannot supply a valid row, logging the transition before advancing, and gating the final rate on a confidence threshold.

Four-tier resolution for a missing guild rate row with a confidence gate A resolve request keyed by crew_id, jurisdiction, and work_date passes a circuit-breaker check. When the circuit is open, Tier 1 is skipped and control jumps straight to the versioned cache. Otherwise the request enters Tier 1, the live guild-rate API; an absent row or a feed failure advances to Tier 2, the versioned local cache, which is used only if its effective dates bracket the work date; a cache miss or stale version advances to Tier 3, rulebook interpolation toward the nearest valid baseline; no baseline mapping advances to Tier 4, the contract floor, which always resolves. Whichever tier answers writes one entry — tier, confidence score, SHA-256 payload hash, audit flag, and UTC timestamp — to an append-only JSON Lines ledger, carrying a confidence of 1.00, 0.90, 0.78, or 0.50 by tier. The ledger feeds a confidence gate: a score below 0.75 quarantines the record for manual reconciliation, while a score at or above 0.75 auto-posts to the payroll ledger. Circuit open? no yes · skip to cache no row · API failure cache miss / stale no baseline mapping conf 1.00 conf 0.90 conf 0.78 conf 0.50 Resolve rate crew_id · jurisdiction · work_date Tier 1 — Live guild-rate API queried row absent, not the feed Tier 2 — Versioned local cache hit only if dates bracket work_date Tier 3 — Rulebook interpolation toward nearest valid baseline Tier 4 — Contract floor always resolves · audit flag Append-only JSONL ledger one entry per resolution: tier · confidence SHA-256 payload hash audit_flag · UTC timestamp Confidence < 0.75 ? no yes Auto-post to payroll ledger Quarantine manual reconciliation resolves → ledger no valid row → next tier
Each tier fires only when the prior one has no valid row; whichever answers writes one hashed, confidence-scored entry to the append-only ledger, and the confidence gate then routes the record to auto-post or quarantine.

Two properties make this defensible rather than merely functional. Failure is classified — a missing row routes differently from a network timeout, and both are distinguishable in the log from a stale-version rejection. And every tier is idempotent: re-running the resolver against the same inputs yields the same rate and the same payload hash, which is what lets a production accountant replay a disputed week without duplicating a single contribution.

Step-by-Step: Assembling a Replacement Rate Table

The resolver models each candidate rate as a Pydantic v2 object validated at the boundary, resolves through the tiers as an explicit sequence, and writes a hashed provenance entry at every hop. Interpolation is deliberately conservative: it over-accrues toward the nearest valid historical baseline and never emits a value below the ratified minimum, because an over-accrual is reconcilable whereas a short P&H remittance surfaces as a delinquency.

import hashlib
import json
import logging
import sqlite3
import time
from datetime import date, datetime
from decimal import Decimal
from enum import Enum
from typing import Any
from zoneinfo import ZoneInfo

from pydantic import BaseModel, ConfigDict, Field, field_validator

logging.basicConfig(
    level=logging.INFO,
    format="%(message)s",
    handlers=[logging.FileHandler("rate_fallback_audit.jsonl", mode="a")],
)
logger = logging.getLogger("missing_rate_fallback")

# Audit instants are stamped in UTC; render against an IANA zone only for
# human-facing reports — never with a fixed offset.
AUDIT_TZ = ZoneInfo("America/Los_Angeles")
MIN_CONFIDENCE = Decimal("0.75")


class FallbackTier(str, Enum):
    LIVE_API = "live_api"
    VERSIONED_CACHE = "versioned_cache"
    RULEBOOK_INTERPOLATION = "rulebook_interpolation"
    CONTRACT_FLOOR = "contract_floor"


class RateResolution(BaseModel):
    """A resolved rate plus the provenance that makes it auditable."""
    model_config = ConfigDict(frozen=True)

    crew_id: str
    jurisdiction: str
    work_date: date
    derived_rate: Decimal = Field(gt=Decimal("0"))
    tier: FallbackTier
    confidence: Decimal
    contract_version: str
    resolved_at: str

    @field_validator("derived_rate", "confidence", mode="before")
    @classmethod
    def reject_float(cls, v: Any) -> Decimal:
        if isinstance(v, float):
            raise ValueError("monetary/score fields must be str or Decimal, not float")
        return Decimal(str(v))


class MissingRateFallbackChain:
    def __init__(self, db_path: str, min_confidence: Decimal = MIN_CONFIDENCE):
        self.db_path = db_path
        self.min_confidence = min_confidence
        self.circuit = {"failures": 0, "open": False}
        self._init_cache()

    def _init_cache(self) -> None:
        with sqlite3.connect(self.db_path) as conn:
            conn.execute("PRAGMA foreign_keys=ON")
            conn.execute(
                """
                CREATE TABLE IF NOT EXISTS rate_cache (
                    jurisdiction     TEXT NOT NULL,
                    contract_version TEXT NOT NULL,
                    rate             TEXT NOT NULL,
                    effective_from   TEXT NOT NULL,
                    effective_to     TEXT NOT NULL,
                    PRIMARY KEY (jurisdiction, contract_version)
                )
                """
            )

    def resolve(self, crew_id: str, jurisdiction: str, work_date: date) -> RateResolution:
        # Tier 1 — Live API, guarded by the circuit breaker.
        if not self.circuit["open"]:
            try:
                rate = self._fetch_live(jurisdiction, work_date)  # raises KeyError on missing row
                self.circuit["failures"] = 0
                return self._log(crew_id, jurisdiction, work_date, rate,
                                 FallbackTier.LIVE_API, "1.0", "current")
            except KeyError:
                logger.info(json.dumps({"event": "missing_row", "jurisdiction": jurisdiction}))
            except Exception as exc:
                self.circuit["failures"] += 1
                if self.circuit["failures"] >= 3:
                    self.circuit["open"] = True
                logger.info(json.dumps({"event": "api_failure", "error": str(exc)}))

        # Tier 2 — Versioned cache, valid only if its dates bracket the work date.
        cached = self._cache_lookup(jurisdiction, work_date)
        if cached is not None:
            rate, version = cached
            return self._log(crew_id, jurisdiction, work_date, rate,
                             FallbackTier.VERSIONED_CACHE, "0.9", version)

        # Tier 3 — Rulebook interpolation toward the nearest valid baseline.
        interpolated = self._interpolate(jurisdiction)
        if interpolated is not None:
            return self._log(crew_id, jurisdiction, work_date, interpolated,
                             FallbackTier.RULEBOOK_INTERPOLATION, "0.78", "historical_baseline")

        # Tier 4 — Contract floor; the chain always resolves, always flags.
        floor = self._contract_floor(jurisdiction)
        return self._log(crew_id, jurisdiction, work_date, floor,
                         FallbackTier.CONTRACT_FLOOR, "0.50", "ratified_floor")

    def _log(self, crew_id: str, jurisdiction: str, work_date: date, rate: Decimal,
             tier: FallbackTier, confidence: str, version: str) -> RateResolution:
        res = RateResolution(
            crew_id=crew_id, jurisdiction=jurisdiction, work_date=work_date,
            derived_rate=rate, tier=tier, confidence=confidence,
            contract_version=version,
            resolved_at=datetime.now(tz=ZoneInfo("UTC")).isoformat(),
        )
        # Canonical serialization → a stable SHA-256 fingerprint of the decision.
        canonical = json.dumps(res.model_dump(mode="json"), sort_keys=True,
                               separators=(",", ":")).encode("utf-8")
        entry = {
            "payload_hash": hashlib.sha256(canonical).hexdigest(),
            "audit_flag": res.confidence < self.min_confidence,
            **res.model_dump(mode="json"),
        }
        logger.info(json.dumps(entry, sort_keys=True))
        return res

    def _fetch_live(self, jurisdiction: str, work_date: date) -> Decimal:
        raise KeyError(jurisdiction)  # simulate a present feed with no matching row

    def _cache_lookup(self, jurisdiction: str, work_date: date) -> tuple[Decimal, str] | None:
        with sqlite3.connect(self.db_path) as conn:
            row = conn.execute(
                """SELECT rate, contract_version FROM rate_cache
                   WHERE jurisdiction = ? AND ? BETWEEN effective_from AND effective_to
                   ORDER BY effective_from DESC LIMIT 1""",
                (jurisdiction, work_date.isoformat()),
            ).fetchone()
        return (Decimal(row[0]), row[1]) if row else None

    def _interpolate(self, jurisdiction: str) -> Decimal | None:
        # Nearest valid baseline per CBA; never below the ratified minimum.
        baseline = {"Zone_A": Decimal("450.00"), "Zone_B": Decimal("435.00")}
        return baseline.get(jurisdiction)

    def _contract_floor(self, jurisdiction: str) -> Decimal:
        return {"Zone_A": Decimal("400.00"), "Zone_B": Decimal("385.00")}.get(
            jurisdiction, Decimal("400.00"))

The frozen=True config makes every resolution immutable once validated, so nothing downstream can mutate a rate after it has been hashed into the log. Because serialization is canonical — sorted keys, no incidental whitespace — the hash is a stable fingerprint: two runs that resolve the same inputs produce the same hash, and any tampering with a cached snapshot changes it. That fingerprint is the spine of the whole audit story.

Audit Trail Requirements

Bond lenders and union auditors require complete traceability for a synthesized rate — more so than for a live one, because no authoritative source stands behind it. Every resolution must emit exactly one append-only JSON Lines record capturing, at minimum:

  • resolved_at — UTC instant of the routing decision
  • crew_id and jurisdiction
  • work_date — the date whose CBA amendment governs the rate
  • tier — which source supplied the number (live_apicontract_floor)
  • derived_rate — serialized as a string to preserve Decimal precision
  • confidence and audit_flag — the score and whether it fell below the 0.75 threshold
  • contract_version — the ratified agreement or historical_baseline / ratified_floor
  • payload_hash — the SHA-256 of the canonical payload

Write this log to write-once storage: an append-only file on a WORM-backed volume, or an object store with an object-lock retention policy, so the compliance archive cannot be silently rewritten. Disable log rotation on the archive stream — a rotated-away line is an audit gap. Production accountants replay these records to reconstruct a batch, verify P&H contributions, and prove no crew member was paid below a union minimum even on a day when the rate table had no row for them.

Gotchas and Production Edge Cases

Contract-ratification drift is the whole reason this page exists. The missing row is usually not corruption — it is a newly ratified amendment the vendor has not published. Do not let a Tier 2 cache paper over it silently: a cached snapshot whose effective_to predates the work date must fail the date-bracket check and route to interpolation, and the resulting audit_flag is the signal that a human should confirm the new agreement before the weekly run.

Multi-location shoots split one crew member across jurisdictions in a single week. Resolve per work date and per jurisdiction, never per person — a grip who works Zone A on Monday and Zone B on Thursday needs two independent resolutions, and collapsing them to one rate is a reconciliation failure waiting to surface in a cost report.

DST boundaries corrupt the work_date key if you derive it from a naive timestamp. Always attach the shoot location’s IANA zone at ingestion and take the calendar date in that zone; a wrap logged at 2026-11-01T00:30 in America/Los_Angeles belongs to the prior work date on a fall-back night, and mis-dating it can select the wrong CBA amendment entirely.

Idempotency protects the P&H funds. Because the resolver is deterministic and every entry is keyed by its payload hash, re-running a batch after a partial failure must not create a second contribution. Deduplicate downstream on (crew_id, work_date, jurisdiction, payload_hash) before posting, so a retried run is a no-op rather than a double remittance.

The circuit breaker must distinguish “down” from “incomplete.” A 5xx or timeout increments the failure counter and can open the circuit; a clean response missing one row must not, or a single new jurisdiction would trip the breaker and needlessly downgrade every other lookup to cache. The reference resolver above catches KeyError separately from every other exception for exactly this reason. The penalty and premium math that a resolved base rate later feeds — for example the turnaround premiums in DGA Overtime & Turnaround Rules — assumes a defensible base; the fallback’s only job is to guarantee that base while the authoritative row is pending.

Up: Compliance Fallback Chains