Validating IATSE Meal Penalties in Python

Under the International Alliance of Theatrical Stage Employees (IATSE) collective bargaining agreements, a crew member is owed a bona fide meal break within a set window of the call or of the end of the prior break, and once that window lapses the production begins accruing a stacked penalty — a flat charge for the first interval past the deadline and an escalating charge for each interval after that, until the crew actually breaks. The edge case this page solves is narrow and unforgiving: given a crew member’s call time and an ordered list of meal-break windows, each arriving in a shoot-local timezone and possibly straddling a Daylight Saving Time boundary, decide exactly how many penalty intervals each late meal earns, price them in Decimal from an agreement-governed schedule, and emit an audit record a completion guarantor can reproduce to the cent. Naive datetime subtraction and floating-point money both fracture under real shooting conditions, so this walkthrough builds the check as a pure function over timezone-aware instants.

Prerequisites and Context

This page extends the parent engine documented in IATSE Crew Rule Validation: Meal Penalties, Turnaround, and Golden Hours in Python; it assumes the call and meal-break instants have already been cost-coded and typed upstream, and focuses entirely on the meal-penalty arithmetic and the audit trail around a single crew day. It targets Python 3.11+ for the standard-library zoneinfo module, and uses the same deliberate dependency set as the rest of Guild Compliance & Rule Validation Automation: decimal for every monetary value, zoneinfo for IANA timezone resolution rather than a bare UTC offset, hashlib for the payload fingerprint, and Pydantic v2 (model_validate, field_validator) for the boundary schema. Records reach this function already parsed by the upstream Cost Ingestion & Data Parsing Workflows subsystem, and each penalty keys to one budget line through Cost Code Standardization. The clause that matters is the meal-period guarantee of the applicable IATSE agreement: because the exact window, interval length, and per-interval amounts vary by agreement, tier, and amendment, treat the six-hour threshold and the penalty schedule as configurable rule parameters, never hardcoded law.

Step-by-Step Implementation

Validation begins at the boundary. A Pydantic v2 model rejects any instant that is not timezone-aware, because a naive datetime is the single largest source of a mis-timed meal deadline. The deadline for each meal is the later of the call or the prior break’s end, plus the threshold; a break that starts after its deadline earns one penalty interval for the deadline itself and one more for each full interval of lateness beyond it. The per-interval charge is read from an agreement-governed schedule in Decimal.

from __future__ import annotations

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

from pydantic import BaseModel, ConfigDict, Field, field_validator

# Rule parameters — resolve from the applicable IATSE agreement, not hardcoded law.
MEAL_THRESHOLD = timedelta(hours=6)          # meal owed within 6h of call/prior break
PENALTY_INTERVAL = timedelta(minutes=30)     # each stacked penalty covers 30 minutes
# Flat charge per stacked interval; the last value repeats for further intervals.
PENALTY_SCHEDULE = [Decimal("7.50"), Decimal("10.00"), Decimal("12.50"), Decimal("15.00")]


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

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

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


class MealPenaltyRecord(BaseModel):
    """One crew member's meal timeline for a single shoot day."""
    model_config = ConfigDict(frozen=True)

    crew_id: str
    shoot_tz: str                        # IANA id, e.g. "America/Los_Angeles"
    call: datetime                       # timezone-aware call
    meal_breaks: list[MealWindow] = Field(default_factory=list)

    @field_validator("call")
    @classmethod
    def _aware(cls, v: datetime) -> datetime:
        if v.tzinfo is None or v.utcoffset() is None:
            raise ValueError("call 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


def _intervals_late(late: timedelta) -> int:
    """Number of stacked penalty intervals for a break this late.
    The deadline itself is interval 1; each further interval adds one."""
    if late <= timedelta(0):
        return 0
    seconds = Decimal(str(late.total_seconds()))
    step = Decimal(str(PENALTY_INTERVAL.total_seconds()))
    return int(seconds / step) + 1


def validate_meal_penalties(rec: MealPenaltyRecord) -> dict:
    zone = ZoneInfo(rec.shoot_tz)
    utc = ZoneInfo("UTC")
    deadline_anchor = rec.call.astimezone(utc)
    total = Decimal("0.00")
    lines: list[dict] = []

    for br in sorted(rec.meal_breaks, key=lambda m: m.start):
        start_utc = br.start.astimezone(utc)
        due_by = deadline_anchor + MEAL_THRESHOLD
        late = start_utc - due_by
        steps = _intervals_late(late)
        charge = Decimal("0.00")
        for i in range(steps):
            idx = min(i, len(PENALTY_SCHEDULE) - 1)
            charge += PENALTY_SCHEDULE[idx]
        charge = charge.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
        total += charge
        lines.append({
            "break_start_local": br.start.astimezone(zone).isoformat(),
            "due_by_local": due_by.astimezone(zone).isoformat(),
            "minutes_late": str((Decimal(str(late.total_seconds())) / 60)
                                .quantize(Decimal("0.01"))) if late.total_seconds() > 0 else "0",
            "penalty_intervals": steps,
            "charge": str(charge),
        })
        # Next meal's clock restarts from the end of this break.
        deadline_anchor = br.end.astimezone(utc)

    payload = {
        "crew_id": rec.crew_id,
        "shoot_tz": rec.shoot_tz,
        "call_utc": rec.call.astimezone(utc).isoformat(),
        "lines": lines,
        "total_penalty": str(total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)),
    }
    canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
    payload["payload_sha256"] = hashlib.sha256(canonical).hexdigest()
    return payload

Because validate_meal_penalties reads its threshold, interval, and schedule from rule parameters and never touches the network, identical inputs always produce an identical payload and an identical hash. That determinism is what lets a guarantor’s auditor recompute a disputed meal penalty from the archived record alone, and it is why the deadline for each successive meal is anchored to the prior break’s end rather than a running wall clock — a second lunch owed after a late first lunch has its own, later deadline.

The flow below shows how each break resolves its own deadline, measures lateness, and maps that lateness onto a stack of penalty intervals before summing the day’s charges.

IATSE meal-penalty accrual flow for one break For each meal break in order, the deadline is computed as the later of the call or the prior break's end plus the six-hour threshold, normalized through zoneinfo. A decision asks whether the break start is after its deadline. If not, no penalty is charged and the loop advances. If it is, the lateness is divided into thirty-minute intervals, the deadline interval plus each further interval is charged from the escalating schedule, and the charge is added to the day total. Both branches converge on serializing the payload with a SHA-256 audit hash. Next meal break timezone-aware start / end Deadline = anchor + 6h anchor = call or prior break end start after deadline? no No penalty advance to next break yes Split lateness into 30-min intervals Charge stacked schedule deadline + each interval Serialize payload + SHA-256 audit hash

Audit Trail Requirements

Every evaluation — including the no-penalty branch — must be serialized to a write-once store so an examiner sees an unbroken record rather than only the charges. At minimum the persisted payload must carry: the crew_id and shoot_tz; the normalized-UTC call; and, per break, the shoot-local break start, the resolved local deadline, the minutes_late, the number of penalty_intervals, and the per-break charge as a string; plus the total_penalty and the payload_sha256 fingerprint of the canonical payload. Persist the record to append-only, write-once storage — object storage with an immutability lock, or an event-sourced table with no in-place update — before any downstream ledger transaction commits, so a crash mid-run leaves a replayable record of intent rather than a silent gap. A later correction is a new compensating record, never an edit of the posted one, so the figure a report was built on stays recoverable. This is the same audit discipline the sibling engines apply, including DGA Overtime & Turnaround Rules and Pension & Health Fund Calculations, which is why the normalization and hashing belong in one shared module rather than duplicated per rule.

Gotchas and Production Edge Cases

Stacking intervals versus one flat charge. The most expensive mistake is pricing a badly late meal as a single penalty. A lunch pushed fifty minutes past its deadline is not one interval — under a thirty-minute schedule it is the deadline interval plus one further interval, and the escalating schedule means the second interval usually costs more than the first. Treat the schedule as an ordered list, index into it by interval number, and let the last value repeat for very late breaks rather than falling off the end of the array.

Daylight Saving Time boundaries. A night shoot whose meal window brackets a spring-forward or fall-back transition is exactly where naive arithmetic fails: subtracting wall-clock times across the change mis-measures lateness by a full hour, either inventing a penalty or erasing one. Anchoring the call, each deadline, and each break start to an IANA zone through zoneinfo and computing the deltas on normalized UTC values makes the transition invisible to the math while the audit record still shows the correct local times. This is the same per-instant zone discipline the turnaround check relies on, worked through in Validating DGA 10-Hour Turnaround Rules in Python.

The prior-break anchor. Each successive meal’s deadline restarts from the end of the previous break, not from the call. Anchoring every deadline to the original call double-charges a crew that took a late first lunch and an on-time second meal, because the second window would be measured from a clock that never reset. The reference loop advances deadline_anchor to br.end on each iteration precisely to avoid that.

Idempotency on replay. Because the payload hash is deterministic and the store is append-only, re-running a batch after a partial failure is safe only if the consumer deduplicates on (crew_id, call_utc, payload_sha256); without that guard a retried chunk double-posts the whole day’s meal penalties. When a break slip is missing or a call sheet is late rather than final, route the day through the controlled path in Compliance Fallback Chains — a conservative provisional and a quarantine entry — rather than assuming zero late meals inline.

Float contamination. Never build a Decimal from a float literal — Decimal(0.1) imports the binary rounding error. Read every schedule amount and elapsed value through its string form, as the code does, so a fractional cent compounded across a hundred-name crew never becomes the variance a guarantor asks you to explain.

Up one level: IATSE Crew Rule Validation, part of Guild Compliance & Rule Validation Automation.