Events, Credentials and Representations
What happens after a record is published — how a movement in your source system becomes an EPCIS event, where UNTP answers the same question differently, and how a passport gains a signed, selectively disclosed representation.
Two questions, two systems
Everything the connectors have done so far answers one question: what is this thing? A GTIN is registered, a brand and a net content and a set of images are attached to it, and a resolver makes that answer reachable from a scan. That is master data, and it lives in the catalog behind the GS1 Digital Link resolver.
There is a second question that master data cannot answer, because it is not about the article at all: what became of this particular one? A lot came into being on a Tuesday. A pallet was packed, shipped, received, sold. Those are statements about a moment, not about a product model, and they belong in a different system — an EPCIS 2.0 repository, with its own address, its own permissions and its own retention. A deployment may well run both on one host; a connector is never allowed to assume it, and the two are configured separately.
The permissions are separate too, on the same token. Writing events requires the capture permission, reading them requires query, and a token that carries one says nothing about the other. On the platform, the tenant-scoped realm role <tenant>/capture expands to the capture permission, which is why the connector's diagnosis ladder carries a rung of its own for it: an authenticated GET /capture that proves reachability and entitlement without capturing anything.
From a movement to an event
An EPCIS event answers four questions — what, when, where and why — and the useful discovery when wiring an ERP is that a single warehouse movement line already holds all four. The Odoo connector reads them at the one moment where they are all settled: the hook that runs when a transfer is validated. What is the lot or serial on the line; when is the completion of the transfer; where is the GLN of the location; why is the operation type.
That last one is the interesting one, because why is the part a source system does not know. Odoo knows that a picking is "incoming"; it does not know that GS1's Core Business Vocabulary calls that receiving with disposition in_progress. So the mapping is not a rule buried in code but two fields on the operation type, pre-filled from Odoo's own codes — incoming to receiving, outgoing to shipping, internal to storing, a manufacturing order to commissioning — and refined further by the sequence code, so a PICK, PACK, QC or STOR step lands on its own business step. A choice a human has made is never overwritten. A point-of-sale terminal looks to the stock module exactly like a delivery, so a small bridge addon recognises the till and reports retail_selling with disposition retail_sold instead. It has to hook the moment a till is pointed at an operation type, because that changes none of the codes the pre-fill is computed from — and it gives the plain shipment meaning back when the till is pointed elsewhere.
Reporting must never be the reason a transfer fails, so the event is not sent at that moment. The picking writes rows into an outbox table, and a scheduled job delivers them. A warehouse operator pressing Validate cannot do anything about an unreachable repository, and should not be told about it.
Delivery itself is two answers, not one, and the outbox keeps them apart. accepted means the repository has taken the document into custody; captured means it is stored. A queue that deletes its row on the 202 reports success for events that were rejected minutes later. A capture job the repository does not recognise is explicitly not counted as success — a rejected document and a forgotten one answer alike — and after five attempts the row stops asking and says it cannot be confirmed.
Why the eventID is the event hash
Every event in this stack carries an eventID that was computed, not drawn — and it is not a name anyone chose for the event. It is the canonical CBV event hash, ni:///sha-256;<hex>?ver=CBV2.0, computed over everything the event asserts, with only a literal exclusion list left out: the eventID itself, recordTime, errorDeclaration and its sub-fields, certificationInfo, and the JSON-LD @context. Anyone holding the event can recompute it. That is what makes it an identity rather than an allocation: two companies describing the same observation arrive at the same identifier without having agreed on anything beforehand.
eventTime and the declared eventTimeZoneOffset go into it, and that is deliberate — the time is part of what the event says. So is where it happened and under which business step: the read point's GLN, the operation type's bizStep and disposition mapping, the form of the business transaction reference. Changing any of those changes the identifiers minted from that moment on. That is not a defect, but it is worth knowing before rather than after: an event identifier describes the statement, and if the statement changes so does its name.
The reason for computing rather than drawing is retry. Derive the identifier from the facts and a repeated send becomes a no-op rather than a second event; draw it randomly, and every retry after a half-completed publish invents a new occurrence of something that happened once. The repository meets this with two independent checks: it de-duplicates on the canonical hash — a hard rejection when the same sender repeats an event, a quiet metadata update when a different tenant sends one it already holds — and it separately refuses an eventID it has already seen. What it does not do is de-duplicate in storage: events are indexed without a document id of their own.
Because the time is part of the identity, the time has to come from the source and never from the clock at reporting time. A made-up event time is a false statement to begin with, and it would give the same movement a different name on every attempt. So where the source cannot say when something happened, no event is made at all: Odoo posts the reason on the transfer, the Java connector publishes the instance to the catalog and logs that it was not commissioned. That is the honest failure, and it is loud enough to fix.
One identifier does not carry all of this. The connectors keep a second, private key for their own bookkeeping — in Odoo an idempotency key derived from the database, the transfer and the identifiers, with a unique index per company, so the same movement cannot enter the outbox twice. It is not the event's name and never leaves the database. The event's name is assigned by the repository, which computes the hash when it stores the event; there is exactly one canonicalisation, and it is the one on the side that keeps the event. The Java connector, which mints commissioning events, computes the same hash itself and sends it — and the repository keeps what a sender supplies verbatim. It places the capture call deliberately between the catalog PUT and the write of its own state: if capture fails, the state stays unwritten, the next tick publishes again (a merge, harmless) and captures again under the same identifier, which the repository recognises as the repetition it is.
Class level and instance level
GS1 addresses three different things with one grammar, and getting them mixed up produces events that validate and mean the wrong thing — the library says so in as many words, because putting an LGTIN in an epcList is the most common way to do it.
A bare GTIN is the model: the article, /01/{gtin}. A GTIN with a lot number (AI 10) is an LGTIN — still class level, because a lot is a set of things, not a thing — and it belongs in the quantityList as an epcClass. A GTIN with a serial (AI 21) is an SGTIN, a single item, and only that belongs in the epcList. A SSCC (AI 00) identifies a logistic unit and becomes the parentID of an aggregation.
Which qualifier applies is decided by the product's tracking setting rather than by the lot record: serial tracking yields AI 21, everything else falls back to AI 10, because the lot level is the weaker and therefore safer claim. Quantities are summed per class, since two lines of the same lot are one statement about that lot; a quantity with no unit is read by EPCIS as a piece count, so the unit code for pieces is left off rather than stated.
All of these identifiers are written as canonical GS1 Digital Link URIs on https://id.gs1.org, never on the resolver of the installation. In an event an identifier is an identity, not an address: two companies describing the same pallet have to produce byte-identical strings, and the qualifier order is fixed by GS1 (22, 10, 21) rather than by the order of the call. Read points and business locations follow the same logic and become SGLN URIs under AI 414 — a raw GLN is accepted by the repository and then found by no query.
Instances are published as their own catalog documents too, under the model's GTIN: /01/{gtin}/10/{lot} and /01/{gtin}/21/{serial}. The sync loop walks the kinds in a fixed order — products and organizations first, lots and serials after them — so that a fresh GTIN document exists before the first instance hangs off it; an instance document under a GTIN the catalog does not hold would point at a model nobody can resolve. And an event with no read point counts as half an answer: the Java connector says so loudly at startup and declines to commission for that connection, while Odoo skips the report and writes the reason into the document's chatter.
What the connectors capture today
The two strands deliberately do different amounts.
The Java connector does exactly one thing: when a lot or a serial is published for the first time, it mints a commissioning event — an ObjectEvent with action=ADD, bizStep=commissioning, disposition=active. It carries no ilmd, on purpose: a production date copied into the event is a second copy that can go stale, and ILMD is defined for the lot level only, so it has no answer at all for a serial. Instead the instance path carries a gs1:masterData link, and the data is reached by resolution.
Three conditions have to hold before anything is captured: it is a first publish of an instance, the connection has epcis.enabled=true and an epcis.url, and the article itself consents. That consent is master data — the field commissionCaptureEnabled on the product. What is stated explicitly always wins; where nothing is stated, the licence decides, and only a key on a prefix the tenant is licensed for is commissioned. In dry run nothing is ever captured, only logged, because events are append-only and that is the hardest category of irreversibility there is.
The Odoo strand goes further, and does so on both maintained branches — 18.0 and 19.0 carry the same five addons, differing only where Odoo itself does. Four of the five EPCIS event types are produced. One distinction decides which: a movement is a fact about a moment and can simply be stated, but an aggregation and a transaction association are standing statements — scan the SSCC and the repository answers what is underneath it, read the despatch advice and it answers which goods went out on it — and a standing statement that is never withdrawn keeps answering after it has stopped being true. So those two are reported at both ends, not only when they begin.
ObjectEvents come out of validated transfers. AggregationEvents carry packing as action=ADD and emptying as action=DELETE, the parentID being the SSCC, emitted before the movement event because that is the order of reality: goods leave one unit, enter another, and the unit itself moves last. A line counts as packed or unpacked only where the unit actually changed, so a pallet that merely travels is a movement and nothing more, and a unit with no SSCC is skipped on both sides because nothing was ever claimed about it. Emptying arrives by two routes that have to be caught separately, since a warehouse takes a pallet apart either through a transfer or through the Unpack button on the package — and the button destroys the contents and the location it needs to describe, so both are read before it runs. SSCCs, for their part, are minted when a package is created rather than when it is first reported.
On Odoo 19 an aggregation gained a second kind of child, because that release lets logistic units nest inside one another. A pallet of cases is the oldest hierarchy GS1 describes, and the vocabulary never needed extending for it — an AggregationEvent's childEPCs may be SSCCs rather than trade items. What was missing was the fact, and only the newer Odoo records it. The rule is the same one that governs a packed pallet: it is a standing statement, so a unit entering a container and a unit leaving one are both reported, and a unit that changes container states both halves — otherwise the first pallet keeps answering with a case that is no longer on it.
One hook carries all of it, and finding it was the point: every route to nesting in Odoo passes through the parent field on the package. The put-in-pack flows write it, the field on the package form writes it, and Unpack clears it on the children. What that hook cannot see is a unit created already inside another, so creation is caught separately. The grouping is by container rather than by the unit that moved, because an aggregation is a statement about a container: two cases put on one pallet are one statement about that pallet. The planned container — where a unit is going during an open transfer — is deliberately ignored, because a plan is not a fact.
Two of this connector's rules meet here and pull in different directions. An event's time has to come from the source rather than from the clock, but a nesting is an act rather than a completion: nothing in Odoo records when it happened, and the moment it is written is the moment it becomes true. So that is the time it carries, which is the same rule the Unpack button already followed. The read point is the container's own location, because the statement is about the container; a child on its way elsewhere has already left.
A TransactionEvent is emitted in exactly one situation, and the restraint is the point. Every event this connector sends already names the paperwork it belongs to, so associating goods with an order needs no event of its own; stating it again as a TransactionEvent with action=ADD would assert the same fact twice. Ending the association is the thing no other event says. Goods coming back on a return stop belonging to the shipment they went out on — a receipt says they arrived somewhere, not that they left a despatch advice, and nobody downstream can derive the second from the first — so a return releases the transaction the original transfer named, action=DELETE with disposition returned. A return of goods that named no paperwork releases nothing.
The TransformationEvent lives in a bridge addon of its own and hangs on the manufacturing order rather than on a transfer, because a production is the one movement where the identifiers going in are not the identifiers coming out. One event carries the whole order: what was consumed as the input list, what came out as the output list, and no action at all — it is the one event type that has none. A half transformation is refused rather than sent, since an event with inputs and no outputs asserts that goods vanished. The business step comes from the same two fields on the operation type as everywhere else.
AssociationEvent, the fifth type, is deliberately not produced. It states that one thing is installed in another, and nothing a warehouse or a production order in Odoo records means that.
Events also come back. An inbox reads them with GET /events on a recordTime watermark and shows them on the lot or the package. It reads class level as well as instance level, so an event that names a lot only as an epcClass in a quantityList still finds it, and it recognises one of our identifiers inside a document that mixes ours with a partner's. Events the connector sent itself return recognisable, because the outbox derives its identifiers exactly as the repository holds them, and are marked as ignored.
Booking stock from that inbox is possible but deliberately narrow, and the guard list is the honest part of it. A transfer is completed only by an incoming event, reported by the partner the transfer names, against paperwork we named, on a transfer that is reserved and waiting, in the right company, with a business step that actually attests completion. The first of those checks is the event type: an AggregationEvent or a TransformationEvent may be perfectly true and still says nothing about whether a transfer completed, so it is shown and never booked. A partner's event remains an observation, not a document — posting one as if it were would make your period close depend on somebody else's data quality.
And a reported event can be withdrawn, which is the one operation append-only storage looks like it forbids. It is done by saying so rather than by removing anything: EPCIS answers a movement that turns out not to have happened with an errorDeclaration, and because the declaration fields are excluded from the canonical hash, the correction carries the same identity as the event it corrects. The repository recognises which event is meant without this side ever having learned what it was called. Only did_not_occur is offered. The other CBV reason, incorrect_data, keeps the occurrence and disputes the description, which means minting the corrected events as well — and this connector mints none.
Status: on both maintained branches the Odoo connector produces ObjectEvent, AggregationEvent in both directions, TransactionEvent with action=DELETE and TransformationEvent, reads events back, and can withdraw one through an error declaration. Aggregation over nested units is 19.0 only, because Odoo 18 has no nesting to report. AssociationEvent is produced nowhere in this stack, and neither is a TransactionEvent with action=ADD. The Java connector's event surface is commissioning only — no movement, aggregation, transaction or transformation event and no read-back there.
UNTP, and where it answers differently
The UN Transparency Protocol is a UN/CEFACT specification for supply-chain transparency: a set of JSON-LD document types with their own vocabulary, aimed at the same problem GS1 and EN 182xx aim at, from a different starting point. Where the GS1 stack begins with an identifier that already exists in the physical world and hangs everything off its resolution, UNTP begins with the document — a passport, a conformity attestation — as a credential that travels.
Both answers are useful, and OpenEPCIS treats the difference as a translation problem rather than a choice. The alignment is a graded SKOS mapping in the module ontologies plus a bridge context: 41 mappings onto untp: terms, spread over the core, battery, textile and electronics ontologies — 33 of them skos:broadMatch, four closeMatch and four exactMatch — graded, because claiming equivalence where only a broader match holds is how two vocabularies come to disagree quietly. The bridge context resolves UNTP property names onto oec: targets while adopting genuinely UNTP-owned conformity classes unchanged, and value conventions match by design: both sides use the same 0-1 decimal scale for ratios, so no arithmetic happens at the boundary. A command-line tool audits those mappings against the upstream vocabulary rather than trusting that they still hold.
What "UNTP-aligned" means here: a vocabulary mapping, and the choice of Data Integrity as the securing mechanism UNTP is native to. It does not mean UNTP document types are issued — the credential type this platform issues is oec:DigitalProductPassport, not untp:ProductPassport or a UNTP conformity credential.
A passport, signed
Above the master data, the passport and the event history sits a cryptographically checkable wrapper: W3C Verifiable Credentials, data model 2.0. The platform's VC layer is a Quarkus service with a library core, and it does not invent content — the credential subject is the same oec: graph the rest of the stack publishes.
Three securing mechanisms are implemented, and the choice is made in exactly one place, per credential configuration. JSON-LD Data Integrity with the ecdsa-rdfc-2019 cryptosuite is served as application/vc+ld+json; the W3C VC-JOSE compact envelope as application/vc+jwt; SD-JWT VC, the format the EUDI wallet world speaks, as application/dc+sd-jwt. A configuration that names no format falls back to the JOSE envelope, which is the cheaper choice for a large payload; Data Integrity is the one the GS1 and UNTP ecosystems are converging on, and it is the path the interoperability evidence below rests on.
The signing path is guarded in two ways worth naming. A safe-mode gate refuses to sign a credential that still contains relative @id or @type references — signing one would mean signing something the ecosystem cannot verify, and the endpoint answers 422 rather than producing it. And the credential type is written as a CURIE, because the standard contexts define prefixes only and a bare type would expand relatively, which strict verifiers reject.
Trust is anchored in did:web. The issuer publishes its own DID document at /.well-known/did.json, key material lives in Kubernetes secrets and never in the search index, and a rule library models the GS1 licence chain — topology, delegation, prefix containment, with did:web:id.gs1.org as the root — carrying an off / log / enforce switch so that a deployment can observe before it enforces. That library checks structure only: it performs no IO and no cryptography, signatures being the proof engine's job. It is not yet wired into the verification endpoint, which today resolves the DID and checks the proof and stops there.
Revocation is opt-in per credential configuration: without a status purpose a credential carries no credentialStatus at all and can never be revoked, which is the honest default for a passport nobody intends to withdraw. Where it is enabled, two mechanisms are implemented — the W3C Bitstring Status List and the IETF Token Status List — and the status URL dereferences anonymously by design, because checking whether a credential is still valid must never require an account. Revocation is tenant-bound, idempotent and permanent; a credential belonging to another tenant answers 404 rather than admitting it exists.
That interoperability is measured rather than asserted, and unevenly so. A real GS1 licence credential, fetched live and checked against GS1's own DID document, verifies in this engine and stops verifying when a byte changes; a credential issued here is accepted by a third-party verifier on an unrelated library stack. Getting there uncovered a genuine defect: the bundled RDF canonicaliser was not RDFC-1.0 conformant, producing six wrong outputs on the 33 conclusive cases of the W3C suite, which made every signature over a blank-node-rich passport unreconstructable by anyone else. It was replaced. The vc+jwt path and the W3C bitstring list, by contrast, are so far only round-tripped against this implementation's own output.
Status: issuance today is an authenticated POST to the issuer. OID4VCI — the wallet-facing issuance protocol, with its credential offer and issuer metadata — is not built, and neither is OID4VP for presentation. Until they are, "interoperable" describes the artifact, not the interaction. There is also no bridge that mints a credential automatically when a passport is saved or an event is captured; issuance is always an explicit request.
Representations
The word that ties this page together is representation: the same statement, packaged differently and disclosed to a different extent.
The DPP API already works this way for the passport itself. The GS1 Web Vocabulary master data is the single source of truth; the EN 18223 passport is derived from it on read, never stored a second time. From that one working document, three JSON representations are projected — compressed (the default, with operational as a silent alias), which echoes the stored body verbatim so that a GET returns the bytes that were PUT; full (or expanded), the Annex A form with every data element spelled out with its elementId, objectType, dictionaryReference and valueDataType; and gs1, the master-data body on its own. The Annex B XML form and the RDF serialisations come out of the same computation, chosen through the Accept header.
A verifiable credential joins that set as one more representation, and the vocabulary decision behind it is deliberate: no new link type was minted. The credential is registered in the linkset under the existing gs1:dpp link type and distinguished from the HTML rendering only by its media type. That is ordinary GS1 content negotiation, and it is exactly what a pull consumer matches on. For an EPCIS event the mirror-image rule applies — the event stays in the repository, the credential references it, and the canonical CBV event hash travels along as a relatedResource to anchor that reference. Embedding the event would redefine terms the EPCIS context protects, and the hash is reproducible by anyone who has the event.
Nothing in this stack takes a representation down again. If a product disappears at the source, the connector keeps the catalog document standing — a deletion in the catalog is treated as a deliberate act, not as a side effect of a webhook that arrived late. If a published passport page is withdrawn again in the PIM, its gs1:pip entry stays in the linkset and keeps pointing at a page that now answers with a tombstone rather than a 404, because an empty link list makes the registration step return without removing anything. And the resolver never checks whether a link target is still alive; an entry disappears when somebody removes it through the linkset API and not before. Withdrawing a record, a passport or a link is therefore a manual act today, on each of the three levels separately. An event is the exception, and only because EPCIS built the exception in: it is taken back by declaring the error rather than by deleting anything, so even the retraction is an addition.
Status: linkset auto-registration — the step that makes a credential discoverable as a representation — is switched off by default. Where it is enabled it runs as a read-merge-update through the resolver's PATCH interface on the caller's own token, and a failure is logged rather than turned into an issuance error.
Access tiers on a representation
Which parts of a passport a given reader may see is not a connector decision, nor a deployment preference. It is the three-tier scale of ESPR Article 9, and it is carried by the vocabulary itself: Public, for every user including market surveillance and customs; AuthorizedOnly, for market surveillance authorities and customs; Restricted, for specified authorised economic operators. That is what the annotation on a property means, and it is written into the ontology in those words.
What the resolver actually enforces is narrower, because at runtime it knows tenant membership and roles rather than actors. A caller who holds the authority role dpp-restricted or dpp-admin and belongs to the owning tenant is treated as Restricted; any other authenticated member of the owning tenant is AuthorizedOnly; everybody else is Public — anonymous readers and authenticated strangers alike. There is no notion of "customs" in that computation, so an authority outside the tenant is, technically, a member of the public. Who receives those two roles is a Keycloak decision taken per deployment and is nowhere fixed in code, which is why the equation of Restricted with Article 9(3) operators is the intent of the vocabulary rather than a guarantee of the implementation.
The tier hangs on the property definition, with a stated rationale, and where a regulation dictates it the annotation locks the value in both directions — it can be neither loosened nor tightened at runtime; an override on a mandated term is rejected before its direction is even examined, and the signing path ignores overrides on such terms outright. For the terms no regulation mandates, deployment and tenant policy may only ever tighten, never loosen, resolved tenant over deployment over semantic default, with AuthorizedOnly as the fail-closed floor — which is also where a property with no annotation at all lands. The issuer loads the same vocabulary artefacts the resolver's read-path enforcement is built from, so what gets signed and what gets filtered share one source of truth.
Two mechanisms turn a tier into disclosure, and they are genuinely different. An SD-JWT disclosure frame keeps only the Public claims in the clear and makes everything else selectively disclosable, so the holder decides at presentation time. Tier slicing instead splits one subject into up to three independently signed credentials, one per tier, with the JSON-LD structural keys repeated in each slice and the non-public slices hash-chained to the public anchor through relatedResource.digestMultibase. Only the anchor is ever advertised in the linkset: higher tiers are found by authorisation, not by public listing.
Enforcement on the read path is layered, and only the lower layer is switched on today. Document-level access is enforced by OpenSearch document-level security against the stored accessLevel — a record either answers or it does not, so an anonymous reader sees Public documents and gets a 404 for the rest. Field-level tiering is implemented but still behind the rollout gate dlr.field-access.enforce, which defaults to false; until it is switched on, no field is filtered on any read path, and the policy API that renders the tier matrix computes the tiers without enforcing them. With the gate on, the platform's own enforcer performs the field projection — not native field-level security, because the stored shape and the served shape differ — applying on read paths only, leaving writes, patches and reconstruction untouched, and failing closed where a term is unclassified or the policy store is unreachable. Selective disclosure at issuance is a separate mechanism and is not affected by this gate. Public retrieval of a credential carries no authorisation in the application at all — it goes through the unauthenticated search client so that document-level security is the only filter, and the stored signed bytes are served verbatim, never re-serialised.
Status: today the semantic vocabulary defaults are the policy in the issuer — the deployment and tenant override maps are wired but empty. Unlocking a higher tier by presenting a credential belongs to the planned verifier application; at present access to a non-public slice is decided by the search role, not by a presentation. The DPP API decides access per passport rather than per element, so redaction within a document is likewise still open there. The relevant CEN/CENELEC standards, prEN 18239 for access rights and prEN 18246 for data authentication, are unpublished, so no conformance to either is claimed here.