FSMA 204 is an event schema if you squint correctly.
Read the FSMA 204 rule as a compliance document and you get a records-request checklist. Read it as an engineer and you notice something more useful: the rule's whole vocabulary — Critical Tracking Events that happen to food, Key Data Elements that must be recorded about each — is an event schema wearing regulatory prose. Every CTE is an event type; every KDE is a field. And the event standard your supply chain already has a door for carries all of it conformantly. This post is the mapping table, mechanical enough to hand to the engineer who asked "so what do we actually store?"
One framing rule before the table, because it decides whether your project survives review: the argument for doing this is operational — records that answer trace questions in minutes instead of days — never the deadline. The deadline is softer than the vendors selling it admit, and the honest date record is its own post.
CTEs are event shapes
| FSMA 204 CTE | EPCIS event shape | why this shape |
|---|---|---|
| Harvesting | ObjectEvent, action: ADD, bizStep: commissioning, lot born in ILMD | first record of the lot's existence |
| Cooling | ObjectEvent, action: OBSERVE, the cooling step and location | an observation of an existing lot |
| Initial packing | ObjectEvent (ADD, packing) + AggregationEvent for case/pallet containment | identity plus containment, asserted separately |
| Shipping | ObjectEvent, action: OBSERVE, bizStep: shipping, sourceList/destinationList | custody asserted org-grain, both ends |
| Receiving | ObjectEvent, action: OBSERVE, bizStep: receiving, sourceList/destinationList | the mirror record, at the receiver's point |
| Transformation | TransformationEvent, input lots → output lots | inputs ≠ outputs is exactly what this type exists for |
Nothing exotic: two event types and one aggregation pattern cover the rule. The transformation row deserves its own note — input/output lot genealogy is the standard's answer to "which finished lots contain that ingredient," with no side genealogy database.
KDEs are field placements
| KDE | conformant home | notes |
|---|---|---|
| Traceability lot code (TLC) | lot-grain identity: LGTIN in quantityList / epcClass; stamped in ilmd at commissioning | one lot, one identifier, one story — the TLC post works this in full |
| TLC source / source reference | sourceList party (PGLN) + location master data | organisation-grain, per CBV §7.4 |
| Location description | readPoint / bizLocation as SGLN; attributes in location master data (CBV §9.3) | a GLN is a description that joins |
| Date of the event | eventTime (+ eventTimeZoneOffset) | the rule's date, with its timezone honest |
| Quantity and unit of measure | quantityList: quantity + uom | the same grain the diff and the trace use |
| Reference document type & number | bizTransactionList — po, desadv, recadv types | the document join, designed into CBV |
| Product description | trade-item master data (CBV §9.2), shipped in the document header | travels with the record, no side lookup |
The receiving CTE, as one conformant event
The row most estates fail first is receiving, so here it is whole — TLC at lot grain, quantity and UoM, both reference documents, both parties, the receiving location as an SGLN:
{
"type": "ObjectEvent",
"eventTime": "2026-07-31T13:42:00.000Z",
"eventTimeZoneOffset": "-05:00",
"quantityList": [
{ "epcClass": "urn:epc:class:lgtin:0614141.107346.7A-4412",
"quantity": 96, "uom": "EA" }
],
"action": "OBSERVE",
"bizStep": "receiving",
"disposition": "in_progress",
"readPoint": { "id": "urn:epc:id:sgln:0614141.00888.0" },
"bizTransactionList": [
{ "type": "po", "bizTransaction": "urn:epc:id:gdti:0614141.06012.1234" },
{ "type": "desadv", "bizTransaction": "urn:epc:id:gdti:0614142.11711.5501" }
],
"sourceList": [
{ "type": "owning_party", "source": "urn:epc:id:pgln:0614142.00001" },
{ "type": "location", "source": "urn:epc:id:sgln:0614142.00777.0" }
],
"destinationList": [
{ "type": "owning_party", "destination": "urn:epc:id:pgln:0614141.00001" },
{ "type": "location", "destination": "urn:epc:id:sgln:0614141.00888.0" }
]
}
$ npx epcis.dev validate fsma-receiving-cte.json
valid schema=epcis-json-schema.json version=2.0.1
$ npx epcis.dev hash fsma-receiving-cte.json
hash 0 ni:///sha-256;6e82b882880ed2501c5f83bce669a4fe34ccc2c8a2e2d1b6084d433592e6a8aa?ver=CBV2.0
Validated against GS1's own pinned 2.0.1 schema, identified by its CBV §8.9 content hash. No extension fields were needed for any KDE — the rule fits inside the standard's core.
The surplus is the point
Map the tables both directions and notice the asymmetry: a conformant EPCIS record carries more than the rule asks. The rule wants a date; the event carries a timezone-honest instant. The rule wants a location description; the event carries a GLN that joins across parties. The rule wants reference documents; the event carries typed references your trading partners' systems can match on. Build to the rule and you have records. Build to the standard and you have records that also run your receiving diff, your recall scope query, and your partner reconciliation — the operational return that survives any deadline movement.
And the famous ask — trace data as a sortable spreadsheet within 24 hours — stops being a fire drill. It is a query over events: filter by lot, join the aggregation history, project the KDE columns. One EPC, one call, the whole chain is the same machinery pointed at a recall instead of an audit.
Validate the mapping today: the fixture above runs through npx epcis.dev validate with no account — exit code is the verdict. Keep the records queryable: stored traces ride the capture spine — get a capture key and say what you will capture.
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.