The traceability lot code has a home.
Ask five systems in one supply chain where the lot code lives and you get five answers: a label field, a WMS column, a spreadsheet tab, a free-text PO comment, and a QA binder. That scatter is why trace-backs take days — not because anyone lacks the data, but because "lot 7A-4412" means five different join keys that only a human phone call can unify. The fix is a discipline, not a product: the Traceability Lot Code gets exactly one home per record, assigned once, referenced everywhere. EPCIS gives that home a precise address.
TLC and TLC source, in engineering terms
Strip the regulatory prose and the FSMA 204 Traceability Lot Code is two facts: an identifier for a lot of food, and the party who assigned it (the TLC source — the entity at the point where the lot came into existence or was transformed). In data-model terms: a lot-grain identity plus an assignment provenance. Both have conformant homes:
- The identity is an LGTIN — GTIN plus lot — carried as epcClass in quantityList entries: urn:epc:class:lgtin:0614141.107346.7A-4412. Lot grain, not serial grain: the rule tracks lots, and class-level quantity elements are the standard's native way to say "96 units of this lot."
- The provenance is the commissioning event — the lot's birth record, where the assigning party's location is the readPoint and the lot's attributes are stamped in ILMD (instance/lot master data), using CBV's master-data attribute vocabulary rather than invented names: cbvmda:lotNumber, cbvmda:productionDate, cbvmda:itemExpirationDate.
ILMD's contract is what makes this trustworthy: writable at object creation, immutable after. The lot's facts are stated once, at birth, by the party who knows them — and they travel with the record instead of living in a side table that never crosses a company boundary.
One lot, three events, one story
The lifecycle, as committed fixtures — commission, ship, receive:
{ "type": "ObjectEvent", "action": "ADD",
"bizStep": "commissioning", "disposition": "active",
"eventTime": "2026-07-28T11:02:00.000Z",
"quantityList": [
{ "epcClass": "urn:epc:class:lgtin:0614141.107346.7A-4412",
"quantity": 480, "uom": "EA" } ],
"readPoint": { "id": "urn:epc:id:sgln:0614141.00777.0" },
"ilmd": {
"cbvmda:lotNumber": "7A-4412",
"cbvmda:productionDate": "2026-07-28",
"cbvmda:itemExpirationDate": "2026-08-11" } }
The shipping event references the same LGTIN (96 of the 480) with a desadv reference in bizTransactionList; the receiving event mirrors it at the receiver's SGLN and adds the recadv reference. The full document validates and hashes:
$ npx epcis.dev validate tlc-lifecycle.json
valid schema=epcis-json-schema.json version=2.0.1
$ npx epcis.dev hash tlc-lifecycle.json
hash 0 ni:///sha-256;466ec6279704116059a3fbc44c5b2802a8da0e6f361387df5553c4cdd5706a49?ver=CBV2.0
hash 1 ni:///sha-256;07dd3870f7eba9ee18915dcfc7744023e3eadd0f1e7afac40c213e8725babe2c?ver=CBV2.0
hash 2 ni:///sha-256;4b652d51a5c96b1c2433f27c87c0fbc54a9248cfd5fb5ddb6110ccaa7b41c65c?ver=CBV2.0
Three events, three content-derived identities, one lot key joining them. Downstream, the referencing rule is absolute: no event restates the lot's attributes. Ship and receive reference lgtin:…7A-4412; the expiry lives only where it was born. A system that copies expiry dates onto every downstream record has created N places for them to disagree.
The label is the same fact
The case label and the event record must agree, and GS1 already wrote the contract: the lot on the mark is Application Identifier (10) — batch/lot — beside AI (01), the GTIN, in a GS1-128 or 2D symbol. Parse the label and you hold (01) GTIN + (10) 7A-4412; that pair is the LGTIN in the events. One scan at the dock therefore reconstructs the join key with no lookup: label → AI map → lgtin class → every event in the lot's story. When the mark and the record are the same two facts in two syntaxes, "does the label match the system?" stops being an audit question and becomes a parser.
Lot grain vs serial grain
Choose grain by the question the reader will ask, and say so explicitly in your model:
- Lot grain (LGTIN + quantity) answers FSMA's questions — which lots, where, how much. It is the right default for this rule, and the cheap one: no per-unit serialization on the line.
- Serial grain (SGTIN in epcList) answers unit-level questions — this case, this saleable unit. Pharma-style mandates need it; FSMA does not.
The two compose: a serialized case can carry lot-grain contents, and the aggregation model holds both in one containment tree. What you must not do is drift — lot grain on some events and free-text lot fields on others, the scatter this post opened with.
The payoff compounds at trace time: because every event references one lot identity, "everything that touched lot 7A-4412" is a single query over the record, and the receiving diff that catches a substituted lot falls out of the same keys. The failure this prevents has a name in every trace-back post-mortem: the side table that didn't travel — the lot-attribute spreadsheet that stayed behind when the product crossed a company boundary. ILMD travels because it is in the record the parties already exchange.
Run the lifecycle now: the fixtures validate with npx epcis.dev validate — no account, exit code is the answer. The CTE-by-CTE mapping this slots into is the FSMA series opener.
We answer in writing. We take at most five conversations a month, only when you ask for one, and only after you already have the written read.