ISF 10+2 Data Assembly
ISF 10+2 data assembly collects the ten importer-supplied and two carrier-supplied data elements required under 19 CFR 149 and files them with CBP at least 24 hours before ocean cargo is laden aboard a vessel at the foreign port. Part of the Compliance Reporting & ACE Transmission area, ISF assembly is where parsed trade documents stop being an internal record and become a legally-timed transmission to a customs authority. The Importer Security Filing — universally called “10+2” for its split of ten importer elements and two carrier elements — is the earliest structured obligation in the ocean import lifecycle: it must be complete and accurate before the goods even leave the origin port, well ahead of the entry summary that follows arrival. For the trade-compliance officer, the filing is a bonded declaration whose lateness or inaccuracy carries liquidated damages of up to USD 5,000 per violation. For the Python ETL team that builds the assembler, that same filing is a deterministic function of a bill of lading, a purchase order, and the commercial documents already parsed upstream — no manual re-keying, no guessing at a missing party, no clock that starts late.
Problem Framing: The Filing Is Due Before You Have Everything
The structural difficulty of ISF is that its deadline is anchored to an event — vessel lading at the foreign port — that happens before the importer typically holds a finalized entry. You are assembling a legal declaration from documents that arrive at different times, in different formats, from different parties, and you must close the filing before the vessel is loaded. Three failure modes recur in hand-built ISF workflows.
First, the missing party. The ten importer elements name six distinct parties — manufacturer, seller, buyer, ship-to, container stuffing location, and consolidator — and a booking confirmation frequently omits one or conflates two (a seller who is also the manufacturer, a ship-to that is blank because the goods are sold in transit). An assembler that silently substitutes a default party files a false declaration.
Second, the late clock. The 24-hour rule is measured against the vessel’s lading time abroad, not against your booking or your data-readiness. Teams that trigger assembly off document arrival rather than off the sailing schedule discover the deadline has already passed when the last element lands. The companion guide on late filings and liquidated damages covers the recovery path, but the assembler’s job is to make lateness detectable early rather than discovered late.
Third, the unvalidated element. An importer of record number in the wrong format, a country of origin that disagrees with the HTSUS chapter, a consignee number that is not on file — each passes a naive “field is present” check and is rejected downstream by ACE, consuming a resubmission cycle against a deadline that does not pause. The assembler below closes these gaps by treating every element as a typed, validated, sourced value, and by binding the whole filing to the sailing clock from the first pass.
Schema / Data Contract
The ISF data contract has to do two things at once: enforce the CBP field-level rules for each of the twelve elements, and record where each value came from so a filing can be defended and a discrepancy traced back to its source document. Every element is therefore a typed value carrying provenance — the source document, the field it was read from, and a confidence flag — rather than a bare string. The Pydantic model below is the input contract for one filing; a booking that cannot satisfy it is rejected at the boundary rather than transmitted and bounced.
from __future__ import annotations
import logging
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field, field_validator
logger = logging.getLogger(__name__)
class SourceDoc(str, Enum):
BILL_OF_LADING = "BILL_OF_LADING"
PURCHASE_ORDER = "PURCHASE_ORDER"
COMMERCIAL_INVOICE = "COMMERCIAL_INVOICE"
PACKING_LIST = "PACKING_LIST"
class SourcedValue(BaseModel):
"""A single ISF field value with the provenance needed to defend it."""
value: str
source: SourceDoc
source_field: str
verified: bool = False
class Party(BaseModel):
name: SourcedValue
address: SourcedValue
country: SourcedValue # ISO 3166-1 alpha-2
class ImporterSecurityFiling(BaseModel):
# --- identity + timing ---
isf_control_number: str = Field(..., pattern=r"^[A-Z0-9]{10,15}$")
bill_of_lading_number: str = Field(..., min_length=6)
estimated_lading_utc: datetime # vessel lading at foreign port
# --- 10 importer elements (19 CFR 149.3(a)) ---
manufacturer: Party
seller: Party
buyer: Party
ship_to: Party
stuffing_location: SourcedValue
consolidator: Party
importer_of_record_no: SourcedValue # EIN / CBP-assigned / SSN
consignee_numbers: list[SourcedValue] = Field(..., min_length=1)
country_of_origin: SourcedValue # ISO 3166-1 alpha-2
htsus_number: SourcedValue # 6 to 10 digits
# --- 2 carrier elements (19 CFR 149.4) ---
vessel_stow_plan_ref: Optional[str] = None
container_status_ref: Optional[str] = None
@field_validator("htsus_number")
@classmethod
def _hts_digits(cls, v: SourcedValue) -> SourcedValue:
digits = v.value.replace(".", "")
if not (digits.isdigit() and 6 <= len(digits) <= 10):
raise ValueError(f"HTSUS number must be 6-10 digits, got {v.value!r}")
return v
@field_validator("country_of_origin")
@classmethod
def _iso_country(cls, v: SourcedValue) -> SourcedValue:
if len(v.value) != 2 or not v.value.isalpha():
raise ValueError(f"country_of_origin must be ISO alpha-2, got {v.value!r}")
return v.model_copy(update={"value": v.value.upper()})
The two carrier elements — the vessel stow plan and the container status messages — are stored as references rather than embedded values because the ocean carrier, not the importer, files them directly with CBP under 19 CFR 149.4. The importer’s assembler holds the linkage so the filing can be reconciled, but it never fabricates the carrier’s data. That split is the whole meaning of “10+2”: ten elements the importer owns, two the carrier owns.
Persisted, the twelve elements live in a normalized table keyed on the ISF control number. The DDL below targets PostgreSQL and stores each element alongside its source document so provenance survives to the audit record.
CREATE TABLE isf_filing (
isf_control_number TEXT PRIMARY KEY,
bill_of_lading_no TEXT NOT NULL,
estimated_lading_utc TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'DRAFT', -- DRAFT|VALIDATED|FILED|ACCEPTED|REJECTED
filed_at_utc TIMESTAMPTZ,
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT status_chk CHECK (status IN
('DRAFT','VALIDATED','FILED','ACCEPTED','REJECTED'))
);
CREATE TABLE isf_element (
isf_control_number TEXT NOT NULL REFERENCES isf_filing(isf_control_number),
element_no SMALLINT NOT NULL CHECK (element_no BETWEEN 1 AND 12),
element_name TEXT NOT NULL, -- 'manufacturer','htsus_number',...
element_value TEXT NOT NULL,
source_doc TEXT NOT NULL, -- BILL_OF_LADING|PURCHASE_ORDER|...
source_field TEXT NOT NULL,
verified BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (isf_control_number, element_no)
);
-- one partial index to find filings whose deadline is approaching
CREATE INDEX isf_due_soon
ON isf_filing (estimated_lading_utc)
WHERE status IN ('DRAFT','VALIDATED');
The element_no check constraint pins the table to exactly the twelve-element universe; a row numbered outside 1–12 is a coding error, not data. The partial index on estimated_lading_utc is what a scheduler queries to find filings whose 24-hour window is closing, so the deadline is enforced by the database rather than by hope.
Step-by-Step Implementation
Assembly runs in ordered stages. Each stage reads from already-parsed upstream documents, populates a slice of the twelve elements, and records provenance. The order matters: parties first (they anchor the filing), then the classification and origin elements that depend on the commercial invoice, then the timing computation, and validation last.
Stage 1 — Resolve the six parties from the bill of lading and purchase order. The bill of lading carries the shipper, consignee, and notify party; the purchase order carries buyer and seller; the manufacturer is frequently on neither and must be pulled from the commercial invoice. Because the same party can appear in more than one document, the resolver prefers the most authoritative source per element and records which one it used. The detailed field-by-field crosswalk lives in Mapping bill of lading data to ISF 10+2 fields.
def resolve_party(name: str, bol: dict, po: dict, invoice: dict,
preference: list[SourceDoc]) -> Party:
"""Pull one party from the first source that carries it, in preference order."""
lookup = {
SourceDoc.BILL_OF_LADING: bol,
SourceDoc.PURCHASE_ORDER: po,
SourceDoc.COMMERCIAL_INVOICE: invoice,
}
for src in preference:
doc = lookup[src]
block = doc.get(name)
if block and block.get("name") and block.get("address"):
return Party(
name=SourcedValue(value=block["name"], source=src, source_field=name),
address=SourcedValue(value=block["address"], source=src, source_field=name),
country=SourcedValue(value=block["country"], source=src, source_field=name),
)
raise ValueError(f"ISF party '{name}' not found in any source document")
Stage 2 — Carry the classification and origin elements from the commercial documents. The HTSUS number and the country of origin are not on the bill of lading; they come from the classified commercial invoice, which is produced upstream by Commercial Invoice PDF Extraction and cross-checked against normalized quantities from Packing List Data Normalization. Reusing those parsed values — rather than re-reading the PDF here — is what keeps assembly deterministic.
def carry_classification(invoice: dict) -> tuple[SourcedValue, SourcedValue]:
hts = SourcedValue(
value=invoice["hts_code"],
source=SourceDoc.COMMERCIAL_INVOICE,
source_field="hts_code",
)
origin = SourcedValue(
value=invoice["country_of_origin"],
source=SourceDoc.COMMERCIAL_INVOICE,
source_field="country_of_origin",
)
return hts, origin
Stage 3 — Compute the filing deadline from the sailing schedule. The deadline is 24 hours before the vessel is laden at the foreign port, so the assembler subtracts the window from the estimated lading time and stores both. Anchoring to lading — not to booking or data-readiness — is the single most important line in the whole pipeline.
from datetime import timedelta
ISF_LEAD = timedelta(hours=24)
def compute_deadline(estimated_lading_utc: datetime) -> datetime:
"""The ISF must be on file no later than 24 h before lading abroad."""
if estimated_lading_utc.tzinfo is None:
raise ValueError("estimated_lading_utc must be timezone-aware (UTC)")
return estimated_lading_utc - ISF_LEAD
Stage 4 — Assemble and validate the full filing. With parties, classification, origin, and timing in hand, the assembler constructs the ImporterSecurityFiling model. Construction is validation: Pydantic runs every field validator, so a malformed HTSUS number or a non-ISO country raises before the record can be marked ready to file.
def assemble_isf(control_no: str, bol: dict, po: dict, invoice: dict) -> ImporterSecurityFiling:
party_pref = [SourceDoc.BILL_OF_LADING, SourceDoc.PURCHASE_ORDER,
SourceDoc.COMMERCIAL_INVOICE]
hts, origin = carry_classification(invoice)
filing = ImporterSecurityFiling(
isf_control_number=control_no,
bill_of_lading_number=bol["bol_number"],
estimated_lading_utc=bol["estimated_lading_utc"],
manufacturer=resolve_party("manufacturer", bol, po, invoice,
[SourceDoc.COMMERCIAL_INVOICE, SourceDoc.PURCHASE_ORDER]),
seller=resolve_party("seller", bol, po, invoice, party_pref),
buyer=resolve_party("buyer", bol, po, invoice, party_pref),
ship_to=resolve_party("ship_to", bol, po, invoice, party_pref),
stuffing_location=SourcedValue(value=bol["stuffing_location"],
source=SourceDoc.BILL_OF_LADING,
source_field="stuffing_location"),
consolidator=resolve_party("consolidator", bol, po, invoice, party_pref),
importer_of_record_no=SourcedValue(value=po["ior_number"],
source=SourceDoc.PURCHASE_ORDER,
source_field="ior_number"),
consignee_numbers=[SourcedValue(value=po["consignee_no"],
source=SourceDoc.PURCHASE_ORDER,
source_field="consignee_no")],
country_of_origin=origin,
htsus_number=hts,
)
logger.info("ISF assembled control=%s deadline=%s",
control_no, compute_deadline(filing.estimated_lading_utc).isoformat())
return filing
The assembler never emits a partial filing. If any of the twelve elements cannot be resolved, resolve_party raises, the model does not construct, and the record stays in DRAFT — visible on the due-soon index but explicitly not ready to transmit. That is the correct behavior: a missing element that surfaces now, days before lading, is recoverable; the same gap discovered after the deadline is a liquidated-damages claim.
Validation & Determinism
Validation happens in two layers, and both must pass before a filing leaves VALIDATED. The first layer is structural, enforced by the Pydantic model: the HTSUS number is six to ten digits, the country of origin is an ISO alpha-2 code, the importer of record number matches an accepted format, and at least one consignee number is present. The second layer is cross-element consistency, which no single field validator can catch.
def cross_validate(filing: ImporterSecurityFiling) -> list[str]:
"""Return a list of consistency errors; empty means the filing is coherent."""
errors: list[str] = []
# Country of origin should be plausible against the HTSUS chapter's scope.
if filing.country_of_origin.value == filing.ship_to.country.value:
logger.info("origin equals ship-to country for %s", filing.isf_control_number)
# Manufacturer country and country of origin must not silently disagree.
if filing.manufacturer.country.value != filing.country_of_origin.value:
errors.append(
f"manufacturer country {filing.manufacturer.country.value} "
f"!= country_of_origin {filing.country_of_origin.value}")
# Deadline must still be in the future at validation time.
deadline = compute_deadline(filing.estimated_lading_utc)
if deadline <= datetime.now(timezone.utc):
errors.append(f"ISF deadline {deadline.isoformat()} already passed")
return errors
Determinism is the property an auditor tests: the same three source documents must assemble to the same twelve elements on every replay, with the same provenance recorded. The assembler guarantees this by never introducing time-varying or heuristic behavior into the value derivation — the only clock it reads is the stored estimated lading time, not the wall clock, so re-running assembly a week later reproduces the identical filing. The one intentionally time-sensitive check, the passed-deadline test, lives in cross_validate and is labeled as such, keeping the pure assembly path replayable. Every element carries its source and source_field, so a discrepancy is always traceable to a specific line of a specific document rather than to the assembler’s judgment.
Downstream Integration
A validated ISF is not the end of the record’s life — it is the opening move of a shipment’s compliance history, and several downstream systems read it. The assembled filing transmits through the same ACE/ABI channel that carries the entry, so a rejection returns as an X12 824 application advice; decoding and recovering from those rejections is handled by ACE and ABI Rejection Handling, and an ISF rejected for a bad element re-enters this assembler at the failing stage rather than being re-keyed by hand.
The twelve elements also seed the entry that follows arrival. The importer of record number, consignee number, country of origin, and HTSUS number captured here flow forward into Entry Summary Filing Schemas, where the same classification is reconciled against the CBP 7501 line items. Because the ISF was assembled from the same parsed commercial documents the entry will use, a divergence between the ISF HTSUS number and the entered classification is a signal worth flagging — it usually means a reclassification happened between filing and entry, which is exactly the kind of change the post-entry workflows exist to reconcile. When lateness cannot be avoided — a vessel sailing schedule that compresses faster than the data arrives — the record is routed to Handling late ISF filings and liquidated damages for mitigation rather than dropped.
Scaling & Resilience
A single broker files ISFs for thousands of ocean bookings a week, each on its own sailing clock, and the scaling problem is not throughput so much as timeliness under fan-out: every filing has an independent deadline, and none of them wait. The controls that keep the assembler inside its window:
- Deadline-driven scheduling rather than arrival-driven. A worker polls the
isf_due_soonpartial index on a short interval, pulling filings ordered byestimated_lading_utcso the most urgent assemble first. The clock, not the inbox, sets priority. - Idempotent assembly keyed on the ISF control number. Re-running assembly for a control number that is already
FILEDis a no-op; a booking whose documents were revised produces an amendment against the same control number rather than a duplicate transmission. - Bounded concurrency on the ACE channel. A semaphore caps simultaneous transmissions so a surge of due filings cannot overwhelm the ABI connection, while the deadline ordering guarantees the most urgent go first when the channel is saturated.
- Circuit breaking on upstream document feeds. If commercial-invoice parsing stalls, the assembler holds affected filings in
DRAFTand alerts on those whose deadline is approaching, rather than transmitting incomplete records against the clock.
Transient failures — a momentary ACE outage, a database timeout — are retried with backoff, but a filing is never transmitted from a partially assembled state. The database status column is the single source of truth for where each filing sits, so a worker that crashes mid-transmission resumes from VALIDATED rather than re-deriving values or double-filing.
Compliance Obligations
The ISF is a bonded declaration, and every filing becomes part of the importer’s compliance record for the CBP five-year recordkeeping horizon. The assembler treats the stored filing as evidence: each of the twelve elements is retained with its source document, source field, the assembly timestamp, and the computed deadline, so a Focused Assessment or an ISF liquidated-damages inquiry can be answered from primary records rather than reconstructed. The isf_element rows are written append-style — an amendment supersedes rather than overwrites, preserving the exact values that were on file at each point in time.
The core obligation the assembler exists to protect is timeliness. Under 19 CFR 149, the ten importer elements must be transmitted no later than 24 hours before the cargo is laden aboard the vessel at the foreign port; the two carrier elements — the vessel stow plan and the container status messages — are the carrier’s responsibility on their own schedules. A late or inaccurate ISF exposes the importer to liquidated damages of up to USD 5,000 per violation and, on repeated findings, to increased cargo examinations and loss of trusted-trader standing. By anchoring assembly to the sailing clock, validating every element structurally and cross-sectionally before transmission, and recording provenance for each of the twelve, the assembler turns a filing that is due before the data is complete into infrastructure that files on time, files correctly, and survives audit rather than merely clearing the vessel.
Authoritative references: 19 CFR Part 149 (Importer Security Filing); CBP Importer Security Filing “10+2” program; SAFE Port Act of 2006; HTSUS (USITC); WCO Harmonized System 2022.
Related
- Mapping bill of lading data to ISF 10+2 fields — the field-by-field crosswalk from bill-of-lading and purchase-order structures to the ten importer elements
- Handling late ISF filings and liquidated damages — mitigation and recovery when the 24-hour window is missed
- Entry Summary Filing Schemas — consumes the importer of record, consignee, origin, and HTSUS elements captured at ISF into the CBP 7501
- ACE and ABI Rejection Handling — decodes the X12 824 advice returned when an ISF element is rejected
- Commercial Invoice PDF Extraction — upstream source of the HTSUS number and country of origin the assembler carries