# The GTIN is the join key: attaching ASN lines to serial-level events

> transactions.dev · by visibility.cloud — 2026-07-31
> Written for the in-house supply-chain platform team — the staff engineer who owns both the event pipeline and the document intake, and wants one join to rule them.

One thesis: **documents speak class grain and events speak instance
grain, and the GTIN is the key that joins them — an ASN line's GTIN
matches the GTIN component inside each `sgtin` EPC and each lot-level
class URI, so line context attaches to every shipped serial
deterministically, not probabilistically.**

## Two grains, one key

A purchase order or ASN line says: 12 each of GTIN 00614141123452, lot
L42, against PO-8812 line 1. It does not, and cannot, name serial
numbers it has never seen. A captured EPCIS event says the opposite:
*this* serialized instance was commissioned, packed, shipped — instance
grain, with the class implicit inside the identifier. Every "attach the
paperwork to the trace" feature reduces to joining those two grains, and
teams that lack the key do it with SKU string-matching, description
fuzzy-matching, or a human with two spreadsheets.

The key exists. It is inside the EPC.

## The anatomy: where the GTIN lives in an EPC

A serialized instance identifier decomposes; the GTIN is a component,
not a lookup:

```text
EPC URN     urn:epc:id:sgtin:0614141.107346.2019
                             |CompanyPrefix.ItemRef.Serial|
                             recombine + check digit → GTIN-14

Digital Link  https://id.gs1.org/01/00614141765430/21/1001
                                  |AI 01 = GTIN-14  |AI 21 = serial
```

In the URN form, company prefix and item reference recombine (indicator
digit first, check digit computed) into the GTIN-14. In the canonical
Digital Link form the pipeline emits, the GTIN-14 sits in plain sight
behind AI `01`, with the serial behind AI `21` and — when the item is
serialized *and* lotted — the lot behind AI `10`, never dropped on
serialization. The identifier grammar in full is
[barcoding.dev](https://barcoding.dev)'s layer.

The join predicate is then one line: an ASN line attaches to an event
element when `gtin14(line) == gtin14(epc)` — and, when the line carries a
lot, when the lots agree too.

## The normalization trap the join must clear first

Documents rarely carry clean GTIN-14s. The 856's LIN qualifiers deliver
UPC-12 (`UP`), EAN-13 (`EN`), or GTIN-14 (`UK`), and equality only works
after normalization: `00` + UPC-12, `0` + EAN-13, GTIN-14 verbatim —
then the mod-10 check digit verified, with a failure producing a typed
warning rather than a silent drop. Skip this and 614141123452 never
equals 00614141123452, and your deterministic join quietly becomes a
partial one. The rule is pinned mapping data in the compile —
[the worked 856 run](/blog/from-856-to-epcis-document/) shows it firing.
The subtler cousin is the indicator digit: the case GTIN and the each
GTIN of the same product differ in position one, so a join that
"helpfully" zero-normalizes indicator digits away merges packaging
levels the business keeps distinct. Normalization means one canonical
fourteen-digit form — never digit surgery beyond the defined prefixes.

## The worked join

Three captured events and one ASN line. The events, compressed to their
operative fields:

```text
e1  ObjectEvent  commissioning  epcList: …/01/00614141123452/10/L42/21/9001
e2  ObjectEvent  packing        epcList: …/01/00614141123452/10/L42/21/9001
e3  ObjectEvent  shipping       quantityList:
                                  epcClass …/01/00614141123452/10/L42, qty 12
```

The document side, from the ASN:

```json
{ "line": 1, "gtin": "00614141123452", "lot": "L42",
  "quantity": 12, "poLine": 1 }
```

The join key `00614141123452` + lot `L42` matches all three — e1 and e2
at serial grain (the GTIN decomposed from each EPC), e3 at class grain
(the `epcClass` URI directly). The enriched output stamps the document
references onto each matched event's core field:

```json
"bizTransactionList": [
  { "type": "desadv",
    "bizTransaction": "urn:epcglobal:cbv:bt:0614141000012:SHIP20260730001" },
  { "type": "po",
    "bizTransaction": "urn:epcglobal:cbv:bt:0724142000017:PO-8812" }
]
```

Every serial of lot L42 now answers "which ASN, which PO" from the
record itself — [the field that rides every event
type](/blog/the-why-dimension-rides-on-every-event/). The enriched
events remain conformant — they validate against the pinned official
EPCIS 2.0 schema (sha256 `0f46ff69…`) — and are a superset only where
the document carried more than the standard models; the two properties
are never separated.

## Lot-level joins when serials are absent

Most of the world ships lots, not serials. The join does not degrade to
guesswork; it changes grain. A line with a lot and no serials joins to
`quantityList` entries whose `epcClass` is the LGTIN URI —
`…/01/{gtin14}/10/{lot}` — exactly e3 above. The verdict vocabulary of
[the reconcile post](/blog/the-asn-said-48-the-dock-saw-47/) is built on
the same two grains: serial set-difference where serials exist, quantity
comparison per lot class where they do not.

## What refuses to join, honestly

A free-text SKU with no GTIN behind it does not join, and the pipeline
says so with a typed warning instead of a fuzzy match. This is a
feature with a sharp edge: a deterministic join that silently falls back
to string similarity is a heuristic wearing a determinism costume, and
the first mis-attached lot in a recall scenario is the last time anyone
trusts the record. The honest path when your item master lacks GTINs
runs through the identifier layer —
[barcoding.dev](https://barcoding.dev) — because no compiler can join
what was never identified.

If your events and your documents are both real but the join between
them is still a person, [say so at the door](/get-access/): the
onboarding flow asks at what grain your last receiving shift actually
captured, and what last quarter's documents actually carried beyond
item and quantity.

---
Access: https://transactions.dev/get-access/ · All posts: https://transactions.dev/blog/ · Machine face: https://transactions.dev/llms.txt
