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.
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.
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):
{"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):
{"event": "duplicate_lot", "lot": "GLY-2291-A"}
Recall trace answering the scoping question:
{"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:
- Explicit lot, never inferred. The operator supplies a concrete
lot_numberthat already exists in the ledger; the engine never guesses a lot from timing alone. - 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.
- Dry-run first. The override runs with persistence disabled and must produce a balanced set of links before it is allowed to write.
- 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.
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_applicationraises 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_lotwith 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
Decimalfrom 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 alot_numberit has already seen and logsduplicate_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.
Related
- Reconciling Tank-Mix Batches Against Inventory — decomposes a spray batch into the per-product draw-downs this ledger links to lots.
- EPA/USDA Rule Mapping — the versioned rule set that defines which recordkeeping fields each link must carry.
Up: Input Inventory Ingestion · Farm Data Ingestion & Field Boundary Synchronization