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.
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.
Related Guides
- IATSE Crew Rule Validation: Meal Penalties, Turnaround, and Golden Hours in Python — the parent engine that runs this meal check alongside turnaround and golden-hour evaluation over one shoot-day timeline.
- Computing IATSE Golden-Hours Overtime in Python — the sibling overtime walkthrough that reads the same timeline for its tier boundaries.
- Validating DGA 10-Hour Turnaround Rules in Python — a cousin walkthrough applying the same timezone-aware, Decimal-first, hash-audited discipline to rest windows.
- Compliance Fallback Chains — the controlled path a shoot day takes when a break slip is missing or delayed.
- Guild Compliance & Rule Validation Automation — the reference architecture these crew checks sit inside.
Up one level: IATSE Crew Rule Validation, part of Guild Compliance & Rule Validation Automation.