IATSE Crew Rule Validation: Meal Penalties, Turnaround, and Golden Hours in Python

Below-the-line crew are where a shooting day turns expensive without anyone deciding to spend the money. A camera assistant who eats lunch eleven minutes late, a grip called back nine and a half hours after wrap, an electrician still on the clock in the sixteenth hour of a night exterior — each of those is a contractual penalty that accrues automatically under the International Alliance of Theatrical Stage Employees (IATSE) collective bargaining agreements, whether or not the production accountant noticed it on the wrap report. Unlike a negotiated rate, these penalties are not entered by a human; they are implied by the shoot-day timeline, and the only defensible way to compute them is to reconstruct that timeline from timezone-aware instants and apply the agreement’s arithmetic deterministically. This page specifies the crew-rule engine that does exactly that — its inputs, its meal-penalty, rest-invasion, and golden-hour subsystems, its production-grade Python, and the quarantine and verification mechanics a completion guarantor will insist on — as one subsystem of the broader Guild Compliance & Rule Validation Automation reference architecture.

The reader here is the production accountant reconciling a crew payroll against a fixed budget, the line producer who needs to know the penalty exposure of a decision before the day is shot, and the Python engineer who has to make those figures reproducible when the source data is a stack of hand-timed break slips. Every check below is built as a pure function over a validated timeline, because that property is what separates a penalty a guarantor can recompute from a number nobody can defend.

Prerequisites and Expected Inputs

The implementation targets Python 3.11+, both for the standard-library zoneinfo module and for modern union-type syntax. It uses a deliberately small stack: Pydantic v2 for boundary schema validation through model_validate and field_validator, documented in the Pydantic v2 reference; the standard-library decimal, hashlib, and zoneinfo modules for currency-safe arithmetic, deterministic audit hashing, and timezone-aware timestamps; and, at production scale, polars or SQLAlchemy to stream shoot-day records and persist an append-only penalty ledger. Money is never a float. As the Python decimal module documentation sets out, only fixed-point arithmetic under an explicit ROUND_HALF_UP context reproduces the rounding a payroll house and a union trust fund actually perform; a fractional-cent drift compounded across a hundred-name crew for a twelve-week shoot is precisely the reconciliation a bond auditor will hand back.

The engine assumes its inputs arrive already normalized. Break slips, time sheets, and call-sheet parameters do not originate here — they are cleaned upstream by the Cost Ingestion & Data Parsing Workflows subsystem, where Schema Validation & Error Handling rejects malformed payloads before they can reach a rule engine, and each crew hour keys to exactly one department, one fund code, and one budget line through Cost Code Standardization. What this engine consumes is a validated ShootDay — one crew member, a timezone-aware call and wrap, an ordered list of meal-break windows, a base hourly rate, and the ratified agreement version in force on the work date. What it emits is an immutable CrewPenaltyLedgerEntry carrying the resolved meal, turnaround, and golden-hour penalties, the pensionable base they contribute to, and a SHA-256 fingerprint of the record that produced them.

Architecture: Three Penalty Engines Over One Shoot-Day Timeline

The design mistake is to treat crew penalties as one calculation. They are three independent readings of the same timeline, each governed by its own clause and its own rate structure, and each must be resolvable on its own so that a dispute over meal penalties never forces a recompute of golden hours. The engine validates a ShootDay at the boundary, reconstructs a single canonical timeline of aware instants, then fans that timeline out to three pure evaluators — meal-penalty accrual, rest-invasion (turnaround) penalty, and golden-hour overtime — before aggregating their outputs into one hashed ledger entry. Any record the boundary cannot trust is quarantined rather than scored, so a missing break window never silently reads as zero penalties.

IATSE crew-rule validation architecture A validated shoot-day timeline for one crew member fans out to three independent penalty engines: meal-penalty accrual, rest-invasion turnaround, and golden-hour overtime. All three converge into one aggregator that writes a single SHA-256 stamped, write-once crew penalty ledger entry. A record the boundary cannot validate follows a dashed path to a separate quarantine store instead of being scored as zero. Shoot-day timeline validated · aware call · breaks · wrap Meal-penalty accrual stacked at set intervals Rest-invasion turnaround wrap-to-call shortfall Golden-hour overtime multiplied tier past threshold Crew penalty ledger SHA-256 · write-once one entry per shoot day Quarantine store payload · hash · reason scored every valid day

Two properties make the model defensible rather than merely convenient. First, the three engines are orthogonal: a meal penalty and a golden hour can both fall in the sixteenth hour of a day without either compounding on the other, because each reads a different quantity off the same timeline and each posts a distinct ledger code. Second, every evaluator is a pure function — same timeline in, same penalty out — which is what lets a production accountant replay a contested day deterministically instead of re-timing it from slips. When a break slip is missing or a wrap instant never arrives, that day does not resolve to zero and slide through payroll; it takes the conservative, over-accruing provisional branch defined in Compliance Fallback Chains, flagged for reconciliation once the real timeline lands. The rest-invasion engine here is the crew-side sibling of the director-and-unit-production-manager turnaround logic in DGA Overtime & Turnaround Rules; both compute a wrap-to-call shortfall, but the IATSE rate schedule and grace bands differ, which is exactly why they are separate calculators rather than one shared branch.

Core Implementation

The reference engine models the shoot day as a frozen Pydantic v2 object validated at the boundary, resolves the agreement’s rate parameters by work date, and runs three independent evaluators whose outputs aggregate into one hashed ledger entry. Monetary fields are Decimal; every instant is timezone-aware; the agreement parameters are an explicit versioned input, never hardcoded law.

import hashlib
import json
import logging
from datetime import datetime, timedelta
from decimal import Decimal, ROUND_HALF_UP
from typing import Any
from zoneinfo import ZoneInfo

from pydantic import BaseModel, ConfigDict, Field, field_validator

logger = logging.getLogger("iatse_crew_rules")

CENTS = Decimal("0.01")


class MealBreak(BaseModel):
    """One bona fide meal break window on the shoot day."""
    model_config = ConfigDict(frozen=True)

    start: datetime           # timezone-aware
    end: datetime             # timezone-aware

    @field_validator("start", "end")
    @classmethod
    def _aware(cls, v: datetime) -> datetime:
        if v.tzinfo is None or v.utcoffset() is None:
            raise ValueError("meal-break instants must be timezone-aware")
        return v


class AgreementParams(BaseModel):
    """Rate parameters resolved from the ratified IATSE agreement in force
    on the work date. Every threshold and multiplier is a versioned input."""
    model_config = ConfigDict(frozen=True)

    version: str                          # e.g. "2024.BASIC"
    meal_threshold_hours: Decimal         # hours worked before a meal is owed
    meal_penalty_interval_min: int        # each stacked penalty interval
    meal_penalty_schedule: list[Decimal]  # flat penalty per stacked interval
    required_turnaround_hours: Decimal    # minimum rest between wrap and call
    turnaround_penalty_hours: Decimal     # hours paid per rest-invasion breach
    golden_after_hours: Decimal           # worked hours before golden time
    straight_multiplier: Decimal          # 1.0
    time_and_half_after: Decimal          # worked hours before 1.5x
    double_after: Decimal                 # worked hours before 2.0x


class ShootDay(BaseModel):
    """Boundary schema for one crew member's single shoot day."""
    model_config = ConfigDict(frozen=True)

    crew_id: str
    department: str
    hourly_rate: Decimal = Field(gt=Decimal("0"))
    shoot_tz: str                         # IANA id, e.g. "America/Los_Angeles"
    call: datetime                        # timezone-aware call
    wrap: datetime                        # timezone-aware wrap
    meal_breaks: list[MealBreak] = Field(default_factory=list)
    prior_wrap: datetime | None = None    # previous day's wrap, for turnaround

    @field_validator("hourly_rate", mode="before")
    @classmethod
    def _reject_float(cls, v: Any) -> Decimal:
        if isinstance(v, float):
            raise ValueError("monetary fields must be str/Decimal, never float")
        return Decimal(str(v))

    @field_validator("call", "wrap", "prior_wrap")
    @classmethod
    def _aware(cls, v: datetime | None) -> datetime | None:
        if v is not None and (v.tzinfo is None or v.utcoffset() is None):
            raise ValueError("call/wrap instants must be timezone-aware")
        return v

    @field_validator("shoot_tz")
    @classmethod
    def _tz_resolves(cls, v: str) -> str:
        ZoneInfo(v)  # raises on an unknown IANA id
        return v


class CrewPenaltyLedgerEntry(BaseModel):
    model_config = ConfigDict(frozen=True)

    crew_id: str
    department: str
    agreement_version: str
    worked_hours: Decimal
    meal_penalty: Decimal
    turnaround_penalty: Decimal
    golden_hour_pay: Decimal
    total_premium: Decimal
    payload_hash: str
    computed_at: str
    operator_id: str = "SYS_AUTO"


def _q(amount: Decimal) -> Decimal:
    return amount.quantize(CENTS, rounding=ROUND_HALF_UP)


def _hours_between(a: datetime, b: datetime) -> Decimal:
    """Elapsed hours between two aware instants, offset-invariant."""
    seconds = (b.astimezone(ZoneInfo("UTC")) - a.astimezone(ZoneInfo("UTC"))).total_seconds()
    return Decimal(str(seconds)) / Decimal("3600")


def meal_penalty(day: ShootDay, p: AgreementParams) -> Decimal:
    """Stacked penalty for each interval a meal runs past the threshold."""
    threshold = timedelta(hours=float(p.meal_threshold_hours))
    interval = timedelta(minutes=p.meal_penalty_interval_min)
    total = Decimal("0.00")
    last_meal_end = day.call
    for br in sorted(day.meal_breaks, key=lambda m: m.start):
        due_by = last_meal_end + threshold
        if br.start > due_by:
            late = br.start - due_by
            # Each started interval stacks the next scheduled penalty.
            steps = int(late / interval) + 1
            for i in range(steps):
                idx = min(i, len(p.meal_penalty_schedule) - 1)
                total += p.meal_penalty_schedule[idx]
        last_meal_end = br.end
    return _q(total)


def turnaround_penalty(day: ShootDay, p: AgreementParams) -> Decimal:
    """Rest-invasion penalty when wrap-to-call rest is short."""
    if day.prior_wrap is None:
        return Decimal("0.00")
    rest = _hours_between(day.prior_wrap, day.call)
    if rest >= p.required_turnaround_hours:
        return Decimal("0.00")
    return _q(day.hourly_rate * p.turnaround_penalty_hours)


def golden_hour_pay(day: ShootDay, p: AgreementParams) -> tuple[Decimal, Decimal]:
    """Tiered overtime with a golden-hour band past golden_after_hours.
    Returns (worked_hours, golden_pay_premium)."""
    worked = _hours_between(day.call, day.wrap)
    # Premium *above* straight time for the golden band only.
    golden_hours = max(Decimal("0"), worked - p.golden_after_hours)
    # Golden time is double-and-a-half here; the multiplier is a versioned input.
    golden_premium_rate = day.hourly_rate * (Decimal("2.5") - p.straight_multiplier)
    return worked.quantize(Decimal("0.0001")), _q(golden_hours * golden_premium_rate)


class CrewRuleEngine:
    def __init__(self, params_by_version: dict[str, AgreementParams]) -> None:
        self.params = params_by_version
        self.ledger: list[CrewPenaltyLedgerEntry] = []
        self.quarantine: list[dict] = []

    def _hash(self, day: ShootDay) -> str:
        payload = json.dumps(
            day.model_dump(mode="json"), sort_keys=True, separators=(",", ":")
        ).encode("utf-8")
        return hashlib.sha256(payload).hexdigest()

    def process(self, day: ShootDay, version: str) -> CrewPenaltyLedgerEntry | None:
        payload_hash = self._hash(day)
        p = self.params.get(version)
        if p is None:
            self._quarantine(day, payload_hash, "no agreement params for version")
            return None
        if day.wrap <= day.call:
            self._quarantine(day, payload_hash, "wrap not after call")
            return None

        meal = meal_penalty(day, p)
        turn = turnaround_penalty(day, p)
        worked, golden = golden_hour_pay(day, p)
        total = _q(meal + turn + golden)

        entry = CrewPenaltyLedgerEntry(
            crew_id=day.crew_id,
            department=day.department,
            agreement_version=p.version,
            worked_hours=worked,
            meal_penalty=meal,
            turnaround_penalty=turn,
            golden_hour_pay=golden,
            total_premium=total,
            payload_hash=payload_hash,
            computed_at=datetime.now(tz=ZoneInfo("UTC")).isoformat(),
        )
        self.ledger.append(entry)
        logger.info(
            "crew=%s dept=%s meal=%s turn=%s golden=%s hash=%s",
            entry.crew_id, entry.department, meal, turn, golden, payload_hash,
        )
        return entry

    def _quarantine(self, day: ShootDay, payload_hash: str, reason: str) -> None:
        self.quarantine.append({
            "payload": day.model_dump(mode="json"),
            "payload_hash": payload_hash,
            "reason_code": reason,
            "quarantined_at": datetime.now(tz=ZoneInfo("UTC")).isoformat(),
            "requires_manual_override": True,
        })
        logger.warning("Quarantined %s: %s", day.crew_id, reason)

The frozen=True config makes both the incoming shoot day and the resolved ledger entry immutable once validated, so no downstream caller can nudge a rate after it has been hashed. Serialization is canonical — sorted keys, no incidental whitespace — so the SHA-256 fingerprint is stable across runs and any tampering with the source timeline changes it. Because the three evaluators read the same immutable ShootDay, the pensionable base carried into Pension & Health Fund Calculations is derived from one record rather than a re-keyed copy, so a golden-hour premium and its fringe contribution can never disagree about how many hours were worked. The meal-penalty stacking and the golden-hour tiers each get their own focused walkthrough in Validating IATSE Meal Penalties in Python and Computing IATSE Golden-Hours Overtime in Python.

Agreement Specifics and Bond Exposure

The rate parameters above are only correct relative to the ratified agreement in force on the day the work was performed, and the engine models the moving parts explicitly rather than folding them into constants:

  • Meal penalties. Under the IATSE Basic Agreement and its Area Standards counterparts, a bona fide meal break is owed within a set window of the call or the end of the prior break — commonly six hours. Once that window lapses, penalties accrue not as a single charge but as a stack: a flat amount for the first interval past the deadline and an escalating amount for each interval thereafter, until the crew actually breaks. Modeling the schedule as an ordered list indexed by interval, rather than a single number, is what keeps a fifty-minute-late lunch from being priced as though it were ten minutes late.
  • Rest invasion (turnaround). Crew are guaranteed a minimum rest between wrap and the next call. An invasion of that window converts to penalty hours at the member’s rate. The required window and the number of penalty hours both vary by agreement and by whether the following day is a distant-location or studio call, so both are versioned inputs, not literals.
  • Golden hours. After a threshold number of worked hours — frequently the fifteenth on a five-day week — every additional hour is paid at a multiplied rate well above straight time. Because golden time is the most expensive hour on the call sheet, resolving the tier boundary to the minute on a timezone-aware timeline is where the money is won or lost.

Two realities shape all three. First, the parameter table is versioned by ratified agreement: a snapshot from last cycle must fail to resolve against a current-period work date rather than silently repricing it, which is why version and the work date are first-class rather than incidental. Second — and this is where the completion guarantor’s interest becomes concrete — crew penalties are the single most volatile line in a shooting budget, because they are generated by the schedule, not by a rate card. A guarantor does not release a reserve against a producer’s assurance that the crew “wrapped on time”; it releases against evidence that every meal, turnaround, and golden-hour penalty implied by the day’s timeline was computed correctly and posted. An unbooked golden-hour tail or an unaccrued meal-penalty stack is not a rounding matter — it is an understated liability that resurfaces as a payroll-house adjustment or a union grievance, and it reopens the reserve calculation a line producer was counting on to close the picture. Deterministic, agreement-versioned validation turns that exposure into a figure the guarantor can recompute on demand.

Error Handling and Quarantine

Not every shoot day should resolve to a penalty. The engine separates two failure classes: agreement resolution — a work date with no parameter version — and record validity — a wrap that precedes its call, a non-positive rate, a naive timestamp, or a float monetary field slipping past the boundary. Neither is a candidate for scoring; both are quarantined.

Every quarantine event serializes the original payload verbatim, attaches the SHA-256 hash of that payload, records a machine-readable reason_code, and pushes the record to a reconciliation queue for human triage. The failing day never enters the penalty ledger as a resolved zero — it enters a separate exception store, so the ledger stays clean while the discrepancy is preserved for review. This mirrors the boundary contract enforced in Schema Validation & Error Handling: a rejected record carries a reason code, its original bytes, and its hash, so a production accountant can triage without re-ingesting the batch. When a break slip is merely delayed rather than wrong, the engine does not quarantine — it books a conservative, over-accruing provisional through the tiered pathway in Compliance Fallback Chains and flags it for reconciliation once the verified timeline arrives.

Scoring versus quarantine for an incoming shoot day An incoming shoot-day record hits a validity gate. A valid record whose agreement version resolves is scored by the three penalty engines and appended to the SHA-256 stamped, append-only crew penalty ledger. A record-validity failure — no agreement params, a wrap before the call, a non-positive rate, or a float monetary field — is diverted to a separate quarantine store that keeps the payload verbatim with its hash, a reason code, and a manual-override flag. A barred link shows the quarantined payload never enters the ledger. SCORING QUARANTINE Incoming ShootDay Valid? version resolves? valid invalid Score: meal · turnaround · golden Crew penalty ledger append-only · SHA-256 stamped • no agreement params for version • wrap not after call • non-positive rate / float field Quarantine store payload · hash · reason_code never enters ledger

Verification

A crew-rule engine is trustworthy only if you can prove, after the fact, which agreement version and which timeline produced every penalty. Verification checks three artifacts, not one.

First, the ledger entry. Every scored shoot day must produce exactly one CrewPenaltyLedgerEntry whose agreement_version pins the parameters, whose meal_penalty, turnaround_penalty, and golden_hour_pay are individually recoverable, whose payload_hash matches an independent re-hash of the source ShootDay, and whose monetary fields are Decimal. Re-processing the same record must yield an identical hash and identical component penalties — the idempotency property that lets you replay a disputed week deterministically.

Second, the audit log fields. Each computation must log, at minimum: crew_id, department, agreement_version, worked_hours, each of the three penalty components, the total_premium, payload_hash, a UTC computed_at, and an operator_idSYS_AUTO for automated runs, a real identifier for manual overrides. A completion-bond auditor reads this sequence as the provenance of the number, so no field is optional.

Third, the reconciliation report shape. A period report should surface, per department: the aggregate meal, turnaround, and golden-hour premium; the count of provisional accruals still awaiting a verified timeline; and every quarantined day awaiting triage. A healthy run is fully scored with a short, explained provisional tail; a run heavy with provisionals or quarantines signals that break slips or call sheets need attention before they become a payroll problem. A minimal harness confirms the invariants:

def verify(entry: CrewPenaltyLedgerEntry, engine: CrewRuleEngine,
           day: ShootDay, version: str) -> None:
    replay = engine.process(day, version)
    assert replay is not None, "valid day must resolve to a ledger entry"
    assert replay.payload_hash == entry.payload_hash, "non-deterministic hash"
    assert replay.total_premium == entry.total_premium, "non-deterministic total"
    assert isinstance(entry.golden_hour_pay, Decimal), "money must be Decimal"
    assert entry.operator_id, "every entry needs an accountable operator id"

Built with orthogonal per-clause evaluators, Decimal-exact arithmetic, agreement-versioned rate parameters, and conservative provisional defaults, an IATSE crew-rule engine converts the most schedule-driven line in a production budget into a controlled, auditable workflow: meal penalties do not slip, turnaround invasions are priced, golden hours are booked to the minute, and every premium remains traceable to a signed, version-stamped timeline the completion guarantor can recompute.

Up: Guild Compliance & Rule Validation Automation