Handling late ISF filings and liquidated damages

Every Importer Security Filing carries a hard deadline: the ten importer elements must be on file with CBP no later than 24 hours before the cargo is laden aboard the vessel at the foreign port. Miss it and the filing is not merely late — it exposes the bond to a liquidated-damages claim of up to USD 5,000.00 per violation, and a single container can trigger several. The engineering problem is that “24 hours before lading” is a timezone-aware moment tied to the foreign load port, not to your server clock, and the vessel’s estimated lading time drifts as schedules change. This page builds a monitoring workflow that computes each shipment’s ISF deadline in the correct timezone, classifies every open filing as on-time, at-risk, or breached, emits alerts before the clock runs out, and records the timestamps you will need if a claim is ever mitigated. It leads with runnable code and anchors the timing rule in 19 CFR 149.2.

Prerequisites

The monitor is a pure function of a shipment’s timing facts; confirm each input before you rely on it:

  • Python 3.10+ with zoneinfo from the standard library for timezone-aware arithmetic, plus dataclass, Optional, and Decimal. Never represent the deadline as a naive datetime — a naive value silently assumes the host timezone and will misclassify any filing near the boundary.
  • A confirmed estimated time of lading (ETL) per shipment, expressed with its IANA timezone (for example Asia/Shanghai), sourced from the carrier booking. The load-port local time is what defines the deadline, not the vessel’s departure or the US arrival.
  • A filing status feed. Each shipment must expose whether its ISF has been accepted, and if so the acceptance timestamp. That status comes from the ISF 10+2 Data Assembly pipeline and its transmission acknowledgements.
  • An alert sink (a queue, a pager, or an email relay) already provisioned. This page decides when to alert and at what severity; it does not implement the transport.
  • Structured logging configured (stdlib logging with the %(asctime)s | %(levelname)s | %(name)s | %(message)s format) and a monotonic, auditable clock source so every classification is reproducible after the fact.

Implementation

The monitor models each shipment’s timing facts as a frozen dataclass, computes the deadline as etl - 24h in the load-port timezone, and classifies the filing against the current instant. All arithmetic happens in UTC internally; timezone objects exist only at the edges so comparisons are unambiguous.

import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from enum import Enum
from typing import Optional
from zoneinfo import ZoneInfo

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("isf_deadline_monitor")

ISF_LEAD = timedelta(hours=24)          # 19 CFR 149.2: 24h before lading
MAX_LIQUIDATED_DAMAGES = Decimal("5000.00")   # USD, per violation ceiling


class FilingState(Enum):
    ON_TIME = "ON_TIME"           # accepted at or before the deadline
    AT_RISK = "AT_RISK"           # open, deadline within the warning window
    BREACHED = "BREACHED"         # open past deadline, or accepted late
    CLEAR = "CLEAR"               # open, comfortably ahead of the deadline


@dataclass(frozen=True)
class Shipment:
    isf_ref: str
    load_port: str                       # UN/LOCODE, for logs
    etl_local: datetime                  # tz-aware estimated time of lading
    accepted_at: Optional[datetime] = None  # tz-aware; None while open

    def deadline_utc(self) -> datetime:
        if self.etl_local.tzinfo is None:
            raise ValueError(f"{self.isf_ref}: ETL must be timezone-aware")
        return (self.etl_local - ISF_LEAD).astimezone(timezone.utc)


@dataclass(frozen=True)
class Assessment:
    isf_ref: str
    state: FilingState
    deadline_utc: datetime
    slack: timedelta                     # positive = time remaining; negative = overdue
    exposure: Decimal                    # USD liquidated-damages exposure


class DeadlineMonitor:
    """Classifies open and closed ISF filings against the 24-hour rule."""

    def __init__(self, warn_before: timedelta = timedelta(hours=6)):
        self._warn_before = warn_before

    def assess(self, ship: Shipment, now: Optional[datetime] = None) -> Assessment:
        current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
        deadline = ship.deadline_utc()

        # Closed filing: on-time only if accepted at or before the deadline.
        if ship.accepted_at is not None:
            accepted = ship.accepted_at.astimezone(timezone.utc)
            slack = deadline - accepted
            if accepted <= deadline:
                return Assessment(ship.isf_ref, FilingState.ON_TIME,
                                  deadline, slack, Decimal("0.00"))
            logger.error(
                "LATE ISF %s: accepted %s, deadline %s (over by %s)",
                ship.isf_ref, accepted.isoformat(), deadline.isoformat(),
                accepted - deadline,
            )
            return Assessment(ship.isf_ref, FilingState.BREACHED,
                              deadline, slack, MAX_LIQUIDATED_DAMAGES)

        # Open filing: classify by remaining slack against the deadline.
        slack = deadline - current
        if slack <= timedelta(0):
            logger.error(
                "BREACHED open ISF %s at port %s: deadline %s passed %s ago",
                ship.isf_ref, ship.load_port, deadline.isoformat(), -slack,
            )
            return Assessment(ship.isf_ref, FilingState.BREACHED,
                              deadline, slack, MAX_LIQUIDATED_DAMAGES)
        if slack <= self._warn_before:
            logger.warning(
                "AT_RISK ISF %s: %s remaining before deadline %s",
                ship.isf_ref, slack, deadline.isoformat(),
            )
            return Assessment(ship.isf_ref, FilingState.AT_RISK,
                              deadline, slack, Decimal("0.00"))
        return Assessment(ship.isf_ref, FilingState.CLEAR,
                          deadline, slack, Decimal("0.00"))


def sweep(monitor: DeadlineMonitor, shipments: list[Shipment]) -> Decimal:
    """Assess a batch, alert on non-CLEAR states, return total exposure."""
    total = Decimal("0.00")
    for ship in shipments:
        a = monitor.assess(ship)
        total += a.exposure
        if a.state in (FilingState.AT_RISK, FilingState.BREACHED):
            logger.info(
                "ALERT %s state=%s slack=%s exposure=USD %s",
                a.isf_ref, a.state.value, a.slack, a.exposure,
            )
    return total

The design decision that matters: deadline_utc refuses a naive etl_local. The 24-hour rule is meaningless without a timezone, so the monitor makes the missing-timezone case a loud failure rather than a silent misclassification. Every downstream comparison then happens in UTC, which removes daylight-saving and cross-date ambiguity from the arithmetic entirely.

ISF deadline timeline showing the 24-hour rule, warning window, and breach zone A horizontal timeline runs left to right toward the estimated time of lading at the far right. Twenty-four hours before lading sits the ISF deadline marker. Everything to the left of the warning window is the clear zone, where an open filing is comfortably ahead. Six hours before the deadline the at-risk window begins, coloured by a warning band, where the monitor emits warnings. At the deadline the breach zone begins: any filing still open, or accepted after this line, is late and carries up to USD 5,000.00 of liquidated-damages exposure. The vessel lading event is marked 24 hours to the right of the deadline. time CLEAR open, ahead of deadline AT_RISK 6h window warn BREACHED up to USD 5,000.00 per violation ISF deadline lading − 24h deadline − 6h vessel laden 24 hours (tz of load port)
The deadline is the load-port-local lading time minus 24 hours. Open filings move from CLEAR through a six-hour AT_RISK warning window into the BREACHED zone, where each violation exposes the bond to up to USD 5,000.00.

Verification steps

Run these checks before you let the monitor drive real alerts:

  1. Timezone integrity. Feed a shipment whose etl_local is naive and assert deadline_utc raises. Then feed the same wall-clock lading time tagged Asia/Shanghai versus America/Los_Angeles and confirm the two deadlines differ by the expected offset. A monitor that treats both identically will misfile near the boundary.
  2. Boundary classification. Construct a filing accepted exactly at the deadline instant and assert ON_TIME; move acceptance one second later and assert BREACHED. The comparison is inclusive of the deadline, matching CBP’s “no later than” language.
  3. Warning-window trigger. Set now so the remaining slack is just inside the six-hour window and assert AT_RISK; step it back one minute and assert CLEAR. Confirm the warning fires exactly once per crossing, not on every sweep, if your alert sink is not idempotent.
  4. Exposure accounting. Assert every BREACHED assessment reports exposure of exactly USD 5,000.00 and every other state reports USD 0.00. Sum a mixed batch through sweep and confirm the total equals the breach count times the ceiling.
  5. Daylight-saving crossing. Choose a load port and a lading date that straddle a DST transition and confirm the deadline lands on the correct absolute UTC instant. Doing the subtraction in local time before converting to UTC, as the code does, is what makes this correct.
  6. Clock injection. Pass an explicit now into assess in tests rather than relying on datetime.now. A monitor that reads the wall clock internally cannot be tested deterministically, and non-determinism here directly undermines the audit trail.

Edge cases & gotchas

  • ETL drift. The estimated time of lading moves as the vessel schedule changes. A deadline computed at booking can be hours off by sailing. Re-assess on every ETL update, and treat a pull-forward that moves the deadline into the past as an immediate breach, not a warning.
  • Rolled cargo. If cargo is rolled to a later vessel, the ISF may need to reference the new voyage even though the filing itself was timely. A timely original does not excuse a stale bill or voyage reference; reconcile the manifest match through the field mapping in Mapping bill of lading data to ISF 10+2 fields.
  • Accepted is not transmitted. A transmission timestamp is when you sent the filing; the acceptance timestamp is when CBP acknowledged it. Only acceptance stops the clock. If your feed conflates the two, a filing that transmitted on time but was rejected and re-sent late will look compliant when it is not.
  • Rejection restarts exposure. A rejected ISF is not an accepted ISF. When ACE returns an application-advice error, the clock keeps running while you correct it, so route rejections through ACE and ABI Rejection Handling immediately rather than treating the original transmission as protective.
  • Mitigation depends on records. CBP may mitigate a liquidated-damages claim for a first offense or a good compliance history, but only if you can show the timeline. Persist the deadline, the acceptance timestamp, and the slack for every filing; the exposure figure the monitor records is your evidence, and a missing timestamp forfeits the argument.
  • Bulk and break-bulk exemptions. The 24-hour lead applies to containerized ocean cargo; bulk and certain break-bulk shipments have different timing. Do not apply this monitor blindly to a bulk voyage — gate it on the shipment’s mode so you neither over-alert nor miss a genuinely different deadline.

Up: ISF 10+2 Data Assembly

Authoritative references: 19 CFR 149.2 (ISF timing); 19 CFR 113 (customs bonds and liquidated damages); CBP ACE ABI/ISF technical guidance; ISO 8601 timestamps; IANA time zone database.