Tracking Pesticide Lot Numbers for Traceability

Problem statement

A pesticide recall is a race against a question your records must already be able to answer: which fields did the suspect batch touch, and how much of it? If the only thing an application record carries is a product name, that question has no answer — you know you sprayed a product, but not which manufacturing lot of it, so a targeted recall becomes a whole-farm re-inspection. Federal FIFRA recordkeeping expects the tighter answer, and the pesticide recordkeeping program administered by the USDA, together with the EPA’s pesticide worker-safety rules, assumes a restricted-use application can be traced to the registered product actually used.

The gap this guide closes is the link between an application event and a received batch. The Input Inventory Ingestion service already validates receipts and normalizes their quantities into a lot-keyed ledger; what is missing is draw-down accounting that consumes that stock when an application is recorded and writes down which lot supplied it. Because a single application can span more than one received lot when the first runs out mid-season, the link cannot be a single foreign key — it has to be a set of allocations, each naming a lot, an EPA registration number, and an exact Decimal amount. This module produces exactly that: an immutable ApplicationLotLink per consumed lot, and a recall query that walks the links backward from a lot number to the fields and acres it reached.

Pesticide application to lot traceability flow Received lots stored as FIFO layers feed a draw-down engine, which also receives an application record and consumes stock oldest-lot-first, splitting the application across lots with exact Decimal amounts. Each consumed lot emits one immutable ApplicationLotLink joining the application id to a lot number, amount, and EPA registration number. A recall query reads the links to map a recalled lot back to the affected applications, fields, and acres. Received lots FIFO layers · lot number EPA reg · quantity Application record product · rate area · field applied_qty (Decimal) Draw-down engine FIFO oldest-first split across lots exact Decimal amounts ApplicationLotLink app_id ↔ lot_number amount · EPA reg immutable entry Recall query lot → applications scope affected fields + acres

Parameter reference table

Each value below changes whether a recall query can honestly scope the affected acreage. Recommended values assume canonical kilograms handed over by the ingestion service.

Parameter Type Recommended value Effect on behavior
amount_kg Decimal canonical kg from ingestion The quantity an application draws down; a Decimal so multi-lot splits sum back exactly to the applied total.
applied_at datetime tz-aware UTC Timestamp of the application; a naive value is rejected so ordering and reporting stay unambiguous.
over_draw_policy str "raise" Behavior when an application exceeds on-hand stock. Raising is correct: an application with no covering lot is untraceable and must be investigated, not booked against a phantom balance.
fifo bool True Consume the oldest received lot first. Matches how physical stock is actually drawn and keeps lot ages honest for shelf-life-sensitive chemistries.
epa_reg_no str copied from the lot Stamped onto every link from the received lot, never re-entered at application time, so the registration on record is the one that was actually received.
link_id str (uuid4) one per allocation Unique key per consumed lot, so an application spanning three lots yields three independently addressable links.

Runnable implementation

The module below is fully typed, targets Python 3.10+, and depends only on the standard library. It records receipts, draws applications down FIFO, emits an immutable ApplicationLotLink per consumed lot, and answers a recall query.

python
from __future__ import annotations

import logging
import uuid
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal

logger = logging.getLogger("inventory.lot_trace")


@dataclass(frozen=True)
class ReceivedLot:
    lot_number: str
    product_name: str
    epa_reg_no: str
    quantity_kg: Decimal          # canonical, from the ingestion service
    received_at: datetime


@dataclass(frozen=True)
class ApplicationLotLink:
    """Immutable evidence that one application drew a quantity from one lot."""
    link_id: str
    application_id: str
    lot_number: str
    product_name: str
    epa_reg_no: str
    amount_kg: Decimal
    field_id: str
    applied_at: datetime


class LotTraceLedger:
    """FIFO lot ledger that emits an immutable link for every application draw-down."""

    def __init__(self) -> None:
        self._layers: dict[str, deque[list]] = defaultdict(deque)  # product -> [lot, remaining]
        self._lots: dict[str, ReceivedLot] = {}
        self._links: list[ApplicationLotLink] = []

    def receive(self, lot: ReceivedLot) -> None:
        if lot.lot_number in self._lots:
            # Idempotent: a replayed receipt must not double the on-hand balance.
            logger.warning('{"event":"duplicate_lot","lot":"%s"}', lot.lot_number)
            return
        self._lots[lot.lot_number] = lot
        self._layers[lot.product_name].append([lot.lot_number, lot.quantity_kg])

    def on_hand(self, product_name: str) -> Decimal:
        return sum((layer[1] for layer in self._layers[product_name]), Decimal("0"))

    def record_application(
        self,
        application_id: str,
        product_name: str,
        amount_kg: Decimal,
        field_id: str,
        applied_at: datetime,
    ) -> list[ApplicationLotLink]:
        """Draw the application down FIFO and link each consumed lot to it."""
        if applied_at.tzinfo is None:
            raise ValueError("applied_at must be timezone-aware")
        if amount_kg <= 0:
            raise ValueError("application amount must be positive")
        available_total = self.on_hand(product_name)
        if amount_kg > available_total:
            # An application with no covering stock is untraceable: refuse, do not guess.
            raise ValueError(
                f"{product_name}: application {amount_kg} exceeds on-hand {available_total}"
            )
        layers = self._layers[product_name]
        remaining = amount_kg
        links: list[ApplicationLotLink] = []
        while remaining > 0:
            lot_number, available = layers[0]  # oldest lot first
            take = min(available, remaining)
            lot = self._lots[lot_number]
            link = ApplicationLotLink(
                link_id=str(uuid.uuid4()),
                application_id=application_id,
                lot_number=lot_number,
                product_name=product_name,
                epa_reg_no=lot.epa_reg_no,   # authoritative reg from the received lot
                amount_kg=take,
                field_id=field_id,
                applied_at=applied_at.astimezone(timezone.utc),
            )
            links.append(link)
            self._links.append(link)     # append-only: links are never edited in place
            layers[0][1] = available - take
            remaining -= take
            if layers[0][1] == 0:
                layers.popleft()          # lot fully consumed
            logger.info(
                '{"event":"lot_linked","app":"%s","lot":"%s","amount_kg":"%s","field":"%s"}',
                application_id, lot_number, take, field_id,
            )
        return links

    def trace_recall(self, lot_number: str) -> list[ApplicationLotLink]:
        """Return every application that drew from a recalled lot, for scoping."""
        hits = [ln for ln in self._links if ln.lot_number == lot_number]
        logger.warning(
            '{"event":"recall_trace","lot":"%s","applications":%d,"fields":%d}',
            lot_number,
            len({h.application_id for h in hits}),
            len({h.field_id for h in hits}),
        )
        return hits

The draw-down loop is the heart of traceability: because it splits an application across as many lots as it takes to cover the amount, an audit of application_id reconstructs the full set of batches that reached a field, and a recall of any one of those batches finds every application it touched. The epa_reg_no is copied from the received lot rather than re-entered at application time, so the registration on the record is provably the one that was received.

Log patterns and observable signals

Every draw-down and recall emits structured JSON so any application can be traced back to the exact lots it consumed.

Normal draw-down spanning two lots (the first ran out mid-application):

json
{"event": "lot_linked", "app": "APP-8841", "lot": "GLY-2291-A", "amount_kg": "40.0000", "field": "north-40"}
{"event": "lot_linked", "app": "APP-8841", "lot": "GLY-2291-B", "amount_kg": "8.5000", "field": "north-40"}

Idempotent receipt replay (ignored, not double-counted):

json
{"event": "duplicate_lot", "lot": "GLY-2291-A"}

Recall trace answering the scoping question:

json
{"event": "recall_trace", "lot": "GLY-2291-A", "applications": 3, "fields": 2}

When triaging, alert on any record_application that raises an over-draw error: it means an application was recorded with no covering stock, which is either a missed receipt or a data-entry error, and until it is resolved that application has no honest lot linkage. A rising rate of two-lot splits is benign — it simply means stock is turning over — but it is worth correlating against shelf-life for chemistries that degrade.

Safe override protocol

Occasionally a legitimate application must be linked to a lot the automated FIFO order would not have chosen — a technician confirms out of band that a specific drum was used, or a historical application predates the ledger and its lot is known from a paper record. The override must never fabricate a lot or silently pick the nearest one; it only pins an explicit lot so the same accounting still runs.

Guard conditions, all mandatory:

  1. Explicit lot, never inferred. The operator supplies a concrete lot_number that already exists in the ledger; the engine never guesses a lot from timing alone.
  2. Coverage check. The pinned lot must have enough remaining stock for the amount; if it does not, the override is rejected rather than spilling silently into another lot.
  3. Dry-run first. The override runs with persistence disabled and must produce a balanced set of links before it is allowed to write.
  4. Immutable audit trail. The application id, the pinned lot, the amount, and the operator identity are written to the append-only ledger and flagged for compliance review, consistent with FIFRA recordkeeping expectations.
python
def override_link_to_lot(
    ledger: LotTraceLedger,
    application_id: str,
    product_name: str,
    lot_number: str,
    amount_kg: Decimal,
    field_id: str,
    applied_at: datetime,
    approver: str,
    dry_run: bool = True,
) -> ApplicationLotLink:
    layers = ledger._layers[product_name]
    layer = next((l for l in layers if l[0] == lot_number), None)
    if layer is None:
        raise ValueError(f"{lot_number}: not a received lot; refusing to fabricate one")
    if amount_kg > layer[1]:
        raise ValueError(f"{lot_number}: {amount_kg} exceeds remaining {layer[1]}")
    lot = ledger._lots[lot_number]
    link = ApplicationLotLink(
        link_id=str(uuid.uuid4()),
        application_id=application_id,
        lot_number=lot_number,
        product_name=product_name,
        epa_reg_no=lot.epa_reg_no,
        amount_kg=amount_kg,
        field_id=field_id,
        applied_at=applied_at.astimezone(timezone.utc),
    )
    logger.warning(
        '{"event":"lot_override","app":"%s","lot":"%s","approver":"%s","dry_run":%s}',
        application_id, lot_number, approver, str(dry_run).lower(),
    )
    if not dry_run:
        layer[1] -= amount_kg
        ledger._links.append(link)
    return link  # caller persists only when dry_run is False and review passes

Requiring an explicit, already-received lot_number is what keeps a convenient override from becoming the untraceable free-text field it exists to replace.

Troubleshooting

  • record_application raises an over-draw error. Root cause: the application amount exceeds on-hand stock, usually a missed or unposted receipt. Remediation: reconcile the missing receipt through the inventory ingestion service first; never lower the amount to force the draw-down, which would understate what was applied.
  • A recall query returns no applications for a lot you know was sprayed. Root cause: the application was recorded against the product but drew from a different lot, or was entered before the lot existed in the ledger. Remediation: check for a paper application record and admit it through override_link_to_lot with the confirmed lot.
  • Multi-lot splits do not sum back to the applied total. Root cause: a float slipped into the quantity path upstream. Remediation: confirm every quantity is a Decimal from receipt through draw-down; the split amounts are exact only if the inputs are.
  • A replayed receipt inflates on-hand stock. Root cause: the idempotency key was bypassed. Remediation: ensure receipts always flow through receive, which ignores a lot_number it has already seen and logs duplicate_lot.

Frequently asked questions

Why does one application produce more than one lot link? Because an application can outlast a single lot; when the oldest lot runs out mid-application the engine continues FIFO into the next lot and records a separate link per lot, with amounts that sum back exactly to the applied total.

How does lot tracking support a recall? The recall query walks the immutable links from a lot number to the applications that drew from it and on to the affected fields and acres, turning a recall into a scoped list rather than a farm-wide re-inspection.

Why refuse an over-draw instead of allowing a negative balance? Because an application with no covering stock is untraceable; raising surfaces a missed receipt or entry error instead of hiding it behind a phantom number.

Up: Input Inventory Ingestion · Farm Data Ingestion & Field Boundary Synchronization