The launch journal

Model the pallet before it exists.

31 July 2026 · the launch journal

Every WMS integration eventually grows the table. parent_id on the item row, or a pallet_contents join table, updated whenever somebody packs, unpacks, or re-packs. It works in the demo. Then a case comes off pallet A at a cross-dock and onto pallet B, the update runs once instead of twice — or twice instead of once — and from that moment the table describes a pallet that does not exist. Nobody notices until a recall asks "what was on SSCC …6789 when it shipped on the 14th," and the honest answer is that the table only ever knew now, and now has been wrong since March.

The mistake is not sloppy code. It is modeling containment as state when the physical world delivers it as events. Somebody put these cases on that pallet at 06:40. Somebody took two off at 09:15. The EPCIS model records exactly those facts — AggregationEvent, a parent identified by SSCC, children as EPCs, and an explicit action — and lets every "what's on it" question be derived from the history instead of stored beside it.

Three events, one pallet

The worked sequence, in the corpus's identifier conventions — each document validates against the pinned official EPCIS 2.0.1 schema (npx epcis.dev validate, exit 0):

1. Commission the eaches. Serialized units come off the line as an ObjectEvent — action: ADD, bizStep: commissioning, four SGTINs born into the record:

{ "type": "ObjectEvent",
  "eventTime": "2026-07-31T06:00:00.000Z", "eventTimeZoneOffset": "-05:00",
  "epcList": ["urn:epc:id:sgtin:0614141.107346.3001",
              "urn:epc:id:sgtin:0614141.107346.3002",
              "urn:epc:id:sgtin:0614141.107346.3003",
              "urn:epc:id:sgtin:0614141.107346.3004"],
  "action": "ADD", "bizStep": "commissioning", "disposition": "active",
  "readPoint": { "id": "urn:epc:id:sgln:0614141.00888.line1" } }

2. Aggregate eaches to a case. The first containment fact — a case SSCC becomes parentID, units become childEPCs:

{ "type": "AggregationEvent",
  "eventTime": "2026-07-31T06:40:00.000Z", "eventTimeZoneOffset": "-05:00",
  "parentID": "urn:epc:id:sscc:0614141.0123456789",
  "childEPCs": ["urn:epc:id:sgtin:0614141.107346.3001",
                "urn:epc:id:sgtin:0614141.107346.3002"],
  "action": "ADD", "bizStep": "packing", "disposition": "in_progress",
  "readPoint": { "id": "urn:epc:id:sgln:0614141.00888.pack1" } }

3. Aggregate cases to the pallet. Same event type, one level up — the children are themselves SSCCs:

{ "type": "AggregationEvent",
  "eventTime": "2026-07-31T07:10:00.000Z", "eventTimeZoneOffset": "-05:00",
  "parentID": "urn:epc:id:sscc:0614141.0999999996",
  "childEPCs": ["urn:epc:id:sscc:0614141.0123456789",
                "urn:epc:id:sscc:0614141.0123456790"],
  "action": "ADD", "bizStep": "packing", "disposition": "in_progress",
  "readPoint": { "id": "urn:epc:id:sgln:0614141.00888.pal1" } }

Replay the sequence and fold ADD children under their parent; the containment tree after event 3 is a pure function of the log:

sscc:…0999999996            (pallet)
├── sscc:…0123456789        (case)
│   ├── sgtin:…3001
│   └── sgtin:…3002
└── sscc:…0123456790        (case)

What each action commits you to

AggregationEvent carries three actions, and choosing correctly is most of the discipline:

  • ADD — these children went onto this parent at this moment. The claim is the containment change itself.
  • DELETE — these children came off. Partial depalletization is just a DELETE naming the removed cases; the standard even allows a childless DELETE to disband a parent entirely. History is preserved: the tree as of any instant is the fold of ADDs and DELETEs up to that instant, so "what was on it when it shipped" and "what is on it now" are the same query with different time bounds.
  • OBSERVE — no change claimed; a checkpoint saw the pallet with children intact. Useful as corroboration, and a common modeling error when ADD was meant — an OBSERVE cannot put a case onto a pallet, and a derived view will rightly refuse to pretend it did.

Re-aggregation at the cross-dock is now unremarkable: DELETE from pallet A at 09:15, ADD to pallet B at 09:22, two events, both true forever. The failure mode the mutable table invited — current state silently diverging from history — is structurally impossible, because there is no current state to corrupt. There is only the log and views over it. (Wrong entries are handled the same way, by appending an errorDeclaration correction, never by editing — the corpus's sequence fixtures exercise exactly that law.)

Two practical notes from the corpus. Hierarchies nest by making a parent SSCC a child EPC in the next event up — the schema imposes no depth limit, and the layer-picking-then-palletize pattern falls out naturally. And when the mark on the case is lot-grain rather than serialized, childQuantityList carries LGTIN-plus-count instead of childEPCslot identity has its own entry in this series.

The payoff compounds downstream: the despatch advice your trading partner sends is itself a containment tree — shipment, order, pallet, case, item — and reconciling it against these events is set arithmetic on the business-transaction layer, not archaeology. A partner's system, or your own five years from now, rebuilds the same tree from the same events with no shared vendor and no shared table.

Validate the sequence yourself: the fixtures follow the packaged golden corpus (node_modules/epcis.dev/golden-corpus/), and npx epcis.dev validate answers in about twelve microseconds per document. Deriving the tree is a fold you can write in twenty lines — and never have to UPDATE again.

Sources: EPCIS 2.0.1 AggregationEvent model (pinned schema, digests at /conformance/); worked fixtures validated with the packaged CLI 2026-07-31, exit 0; corpus conventions from golden-corpus/valid-standard/aggregation-event-packing.json.

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.