Validating DGA 10-Hour Turnaround Rules in Python

The Directors Guild of America (DGA) Basic Agreement guarantees a director, unit production manager, or assistant director a minimum rest window — commonly ten hours — between wrap and the next scheduled call, and a shortfall in that window converts silently into penalty compensation on the daily cost report. The exact edge case this page solves is narrow and unforgiving: given a single wrap instant and the next call instant, each arriving in a shoot-local timezone and possibly straddling a Daylight Saving Time boundary, compute the turnaround shortfall to the minute, decide whether it breaches the required window, and emit an audit record whose penalty figure a completion guarantor can reproduce to the cent months later. Naive datetime subtraction and floating-point penalty math both fracture under real shooting conditions, so this walkthrough builds the check as a pure function over timezone-aware instants with Decimal money throughout.

Prerequisites and Context

This page extends the parent engine documented in DGA Overtime & Turnaround Rules; it assumes the wrap and call timestamps have already been cost-coded and typed upstream, and focuses entirely on the turnaround arithmetic and the audit trail around a single record. 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 (never 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 validated hour keys to exactly one budget line through Cost Code Standardization so any penalty accrual maps cleanly onto the ledger. The contract clause that matters is the turnaround guarantee of the applicable DGA agreement: because the precise threshold and rates vary by agreement, tier, and amendment, treat the ten-hour window and the premium as configurable rule parameters rather than hardcoded law.

Step-by-Step Implementation

Validation begins at the boundary. A Pydantic v2 model rejects any timestamp that is not timezone-aware, because a naive datetime is the single largest source of silent turnaround miscalculation. The shortfall is then a pure function of two aware instants — offset-invariant, so it can be computed on the normalized UTC values while display stays in the shoot-local zone — and the penalty is derived in Decimal from an agreement-governed rate table.

from __future__ import annotations

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

from pydantic import BaseModel, ConfigDict, field_validator

# Rule parameters — resolve from the applicable DGA agreement, not hardcoded law.
REQUIRED_TURNAROUND_HOURS = Decimal("10")
# Forced-call premium as a multiplier on the day rate, keyed to shoot-day class.
FORCED_CALL_PREMIUM = {"studio": Decimal("1.5"), "location": Decimal("2.0")}


class TurnaroundRecord(BaseModel):
    """A validated wrap / next-call pair for one guild member on one shoot day."""

    model_config = ConfigDict(frozen=True)

    crew_id: str
    shoot_class: str          # "studio" or "location"
    day_rate: Decimal         # contractual day rate for the member
    shoot_tz: str             # IANA identifier, e.g. "America/Los_Angeles"
    wrap: datetime            # timezone-aware wrap instant
    next_call: datetime       # timezone-aware next scheduled call

    @field_validator("wrap", "next_call")
    @classmethod
    def _must_be_aware(cls, v: datetime) -> datetime:
        if v.tzinfo is None or v.utcoffset() is None:
            raise ValueError("timestamp must be timezone-aware")
        return v

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


def _quantize(amount: Decimal) -> Decimal:
    return amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)


def validate_turnaround(record: TurnaroundRecord) -> dict:
    zone = ZoneInfo(record.shoot_tz)
    wrap_utc = record.wrap.astimezone(timezone.utc)
    call_utc = record.next_call.astimezone(timezone.utc)

    # Delta is offset-invariant; compute on aware instants, display in shoot zone.
    elapsed = Decimal(str((call_utc - wrap_utc).total_seconds())) / Decimal("3600")
    shortfall = REQUIRED_TURNAROUND_HOURS - elapsed
    violation = shortfall > 0

    if violation:
        multiplier = FORCED_CALL_PREMIUM.get(record.shoot_class, Decimal("1.5"))
        penalty = _quantize(record.day_rate * (multiplier - Decimal("1")))
    else:
        multiplier = Decimal("1")
        penalty = Decimal("0.00")

    payload = {
        "crew_id": record.crew_id,
        "shoot_class": record.shoot_class,
        "wrap_local": record.wrap.astimezone(zone).isoformat(),
        "call_local": record.next_call.astimezone(zone).isoformat(),
        "wrap_utc": wrap_utc.isoformat(),
        "call_utc": call_utc.isoformat(),
        "elapsed_hours": str(elapsed.quantize(Decimal("0.0001"))),
        "required_hours": str(REQUIRED_TURNAROUND_HOURS),
        "shortfall_hours": str(shortfall.quantize(Decimal("0.0001"))),
        "violation": violation,
        "premium_multiplier": str(multiplier),
        "penalty_amount": str(penalty),
    }
    canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
    payload["payload_sha256"] = hashlib.sha256(canonical).hexdigest()
    return payload

Because validate_turnaround reads its threshold and premium 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 penalty from the archived record alone.

The validation flow normalizes the wrap and next-call timestamps, computes the rest delta, and compares it against the agreement-governed turnaround threshold before resolving any premium.

DGA turnaround validation and penalty resolution flow A timezone-aware wrap and next-call pair is normalized to UTC through zoneinfo, then the elapsed rest is computed as call minus wrap in Decimal hours. A single decision asks whether elapsed is less than the required window. If not, the record passes with a multiplier of 1.0 and a penalty of 0.00. If it is, the record is flagged a turnaround violation and a forced-call premium is resolved by shoot-day class. Both the pass branch and the violation branch converge on one terminal step that serializes the payload and stamps it with a SHA-256 audit hash. Wrap + next call timezone-aware instants Normalize both to UTC astimezone · zoneinfo elapsed = call − wrap Decimal hours elapsed < required window? no Pass · multiplier 1.0 penalty 0.00 yes Turnaround violation Resolve forced-call premium by shoot-day class Serialize payload + SHA-256 audit hash

Audit Trail Requirements

Every evaluation — including the no-violation branch — must be serialized to a write-once store so an examiner sees an unbroken record rather than only the exceptions. At minimum the persisted payload must carry: the crew_id and shoot_class; both the shoot-local and normalized-UTC forms of the wrap and call instants; the computed elapsed_hours, the governing required_hours, and the resulting shortfall_hours; the violation flag; the premium_multiplier and derived penalty_amount as strings; 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 deterministic audit discipline required across sibling jurisdictions, including SAG-AFTRA Residuals Logic and Pension & Health Fund Calculations, which is why the normalization and hashing belong in one shared module rather than duplicated per union.

Gotchas and Production Edge Cases

Daylight Saving Time boundaries. A wrap on the night the clocks change is exactly where naive arithmetic fails: subtracting wall-clock times across a spring-forward or fall-back transition mis-measures the rest window by a full hour. Anchoring both instants to an IANA zone through zoneinfo and computing the delta on the normalized UTC values makes the transition invisible to the math while the audit record still shows the correct local times.

Turnaround window across a fall-back DST night: wall clock versus zoneinfo UTC delta A director wraps Saturday at 21:30 local and is called back Sunday at 07:00 local in America/Los_Angeles, with the clocks falling back from 02:00 to 01:00 during the gap. On the physical timeline the rest window is 10 hours 30 minutes, clearing the 10-hour required threshold. But naive wall-clock subtraction, 07:00 minus 21:30, reads only 9 hours 30 minutes because the repeated hour is lived but never counted, which would falsely flag a turnaround violation. Anchoring both instants to the IANA zone and differencing the normalized UTC values recovers the true 10 hours 30 minutes and passes the record. One turnaround window across a fall-back DST night Wrap Sat 21:30 → Next call Sun 07:00 · America/Los_Angeles Wrap 21:30 Next call 07:00 02:00 ↻01:00 required 10h 0h 2h 4h 6h 8h the repeated hour lived once, 0 on the wall clock Naive wall clock: 07:00 − 21:30 = 9h 30m → below 10h · false violation, would post a penalty zoneinfo UTC delta: 10h 30m → 10-hour window met · no penalty, audit shows correct local times

Multi-location shoots. A unit that wraps in America/New_York and calls the next morning in America/Los_Angeles must stamp each instant in its own production zone; a single hard-coded zone silently shifts the shortfall by three hours. Store the shoot zone on the record, as the model above does, and never infer it from the server clock. The same per-unit zone discipline is spelled out for cross-jurisdiction cost data in Async Batch Processing for Multi-Currency Shoots.

Overlapping penalty triggers. A shortened turnaround often coincides with a meal-penalty window, and the two must not double-count the same minutes. Resolve turnaround as its own line item keyed to the rest delta, keep the meal penalty on its own trigger, and let the ledger sum distinct codes rather than compounding one premium on top of another.

Float contamination. Never build a Decimal from a float literal — Decimal(0.1) imports the binary rounding error. Read rates and elapsed seconds through their string form, as Decimal(str(...)) above, so a single fractional cent compounded across a week of timecards never becomes the variance a guarantor asks you to explain.

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, wrap_utc, payload_sha256); without that guard a retried chunk double-posts the penalty. When an upstream rate table is missing or a guild API is slow, route the record through the controlled path defined in Compliance Fallback Chains — a conservative cached scale and a quarantine entry — rather than guessing a multiplier inline.

Up one level: DGA Overtime & Turnaround Rules, part of Guild Compliance & Rule Validation Automation.