One identifier, two syntaxes, one hash.
31 July 2026 · the launch journal
Your warehouse system was serialized in 2016 and speaks EPC URNs: urn:epc:id:sgtin:0614141.107346.2018. Your newest trading partner scans 2D codes and speaks GS1 Digital Link: https://example.com/01/10614141073464/21/2018. Same physical item, same serial, two identifier syntaxes from two eras — and if your reconciliation logic compares them as strings, every cross-era match fails and your dedupe, your traces, and your partner joins silently fork into two worlds.
The CBV 2.0 §8.9 event hash was designed with exactly this seam in mind. Its normalization rules exist so that the content of an observation, not the syntax it arrived in, computes the identity. Here is the proof, run on this machine, not asserted.
Two fixtures, byte-different, hash-equal
Fixture one, the legacy shape — URN identifiers throughout:
{ "type": "ObjectEvent",
"eventTime": "2026-07-31T09:30:00.000Z",
"eventTimeZoneOffset": "+00:00",
"epcList": ["urn:epc:id:sgtin:0614141.107346.2018"],
"action": "OBSERVE",
"bizStep": "shipping",
"disposition": "in_transit",
"readPoint": { "id": "urn:epc:id:sgln:0614141.07346.1234" } }
Fixture two, the 2D-era shape — Digital Link URIs, on a non-canonical domain, different eventID:
{ "type": "ObjectEvent",
"eventTime": "2026-07-31T09:30:00.000Z",
"eventTimeZoneOffset": "+00:00",
"epcList": ["https://example.com/01/10614141073464/21/2018"],
"action": "OBSERVE",
"bizStep": "shipping",
"disposition": "in_transit",
"readPoint": { "id": "https://id.gs1.org/414/0614141073467/254/1234" } }
$ npx epcis.dev hash obs-urn-form.json
hash 0 ni:///sha-256;28339e054920ad294097cdf63a1e82fa28ac176b7952cfc6095999a45d3ef903?ver=CBV2.0
$ npx epcis.dev hash obs-dl-form.json
hash 0 ni:///sha-256;28339e054920ad294097cdf63a1e82fa28ac176b7952cfc6095999a45d3ef903?ver=CBV2.0
Identical, to the last hex digit. Two systems that have never met, holding byte-different records of the same observation, compute the same identity with zero coordination.
The normalization steps, line by line
The hash is SHA-256 over a canonical pre-hash string: fields visited in a prescribed order, each contributing name=value with no separator. For fixture one, the actual pre-hash string is:
eventType=ObjectEventeventTime=2026-07-31T09:30:00.000ZeventTimeZoneOffset=+00:00
epcListepc=https://id.gs1.org/01/10614141073464/21/2018action=OBSERVE
bizStep=https://ref.gs1.org/cbv/BizStep-shippingdisposition=https://ref.gs1.org/cbv/Disp-in_transit
readPointid=https://id.gs1.org/414/0614141073467/254/1234
(One string on the wire; wrapped here for reading.) Every rule that made the two fixtures converge is visible in it:
- EPC URNs → canonical GS1 Digital Link. urn:epc:id:sgtin:0614141.107346.2018 becomes https://id.gs1.org/01/10614141073464/21/2018 — the GTIN-14 assembled from company prefix and item reference, check digit computed, on the canonical id.gs1.org domain, most fine-granular level, no query string.
- Any-domain Digital Link → the same canonical form. Fixture two's example.com URI normalizes to the identical id.gs1.org form. The domain a scan happened to carry is transport, not identity.
- CBV bare words → CBV web URIs. shipping → https://ref.gs1.org/cbv/BizStep-shipping. The 1.x-era URN spellings normalize to the same URIs — and note the pinned 2.0.1 JSON schema rejects urn:epcglobal:cbv:… spellings in 2.0 JSON documents outright, so the seam is policed at validation time too.
- Timestamps → UTC, millisecond precision, Z. A +02:00 local offset spelling of the same instant contributes the same bytes.
- eventID and recordTime are excluded. The two fixtures carry different eventIDs; the hash does not care. Identity is the observation, not the label a system stuck on it — the same exclusion that makes append-only corrections work.
- Lists sort, numbers strip, extensions namespace-qualify. Case-sensitive lexical ordering for list members; no leading zeros or trailing .0; user extensions render as {namespace}name=value after the ordered standard members.
None of this is our interpretation. The algorithm is CBV 2.0 §8.9, the canonicalization is the openepcis-published reference implementation, and this implementation is gated in CI against the published reference test vectors — vendored, digest-pinned, alongside the schema pins on the conformance page. If our hash and the reference vectors ever disagree, the build fails, not the reconciliation.
What this buys at the migration seam
Cross-era dedupe. During a 1D→2D transition, the same case gets scanned by an old lane (URN pipeline) and a new one (Digital Link pipeline). Content-derived identity makes the double-capture converge at the capture door — one stored event, no reconciliation job.
Partner joins without a syntax treaty. Partner A archives URNs; partner B mints Digital Links. Neither migrates their history; both compute the same chain. The alternative — agreeing on a canonical syntax across companies and backfilling a decade of records — is a project everyone schedules and no one finishes.
A free canonical-form policy for your own store. Store whichever syntax your systems prefer; the hash is your join key regardless. Canonicalizing stored identifiers becomes a projection choice — a read-time rendering — rather than a migration. If you do canonicalize, the hash's own rules are the right target, for the same reason: they are pinned, published, and shared.
The translation seam this post serves — a decade of EPCIS 1.1/1.2 XML becoming 2.0 JSON-LD with a per-job fidelity report — is /translate/. The wire formats a 2D scanner actually emits, and how both parse to one AI map before any of this begins, is the element-strings entry.
Run the proof yourself, on your own identifiers — no account, entirely local:
npx epcis.dev hash your-event-urn-form.json
npx epcis.dev hash your-event-dl-form.json
Two files, one hash — or a discrepancy you now know how to read.
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.