Brick #1: Any Node Can Hand Off — Typed Participants in a Verifiable Supply Chain
A handoff event says custody moved from one address to another — but not WHO those addresses are. Brick #1 adds typed participant roles (custodian/operator/device/sensor) to the handoff payload: orgs, agents and devices in a single primitive, replayed with provenance. Includes the adversarial AI audit story — 0 Critical, 4 High findings, all fixed and re-verified.
Participant roles are optional typed declarations of who participated in a hop — custodian, operator, device, sensor — carried in the handoff event and replayed into per-hop state with rolesAssertedBy provenance. Self-declared, never identity: no guard consults them. Shipped after an adversarial AI audit (4 High findings, all fixed).
Part of the verifiable supply-chain series — start here: The Bedrij Principle. The hypothesis under test: a complex economic system can be composed from verifiable state transitions.
Here is the smallest broken thing in a supply chain: a handoff event that says custody moved from 0xAA… to 0xBB… — and nothing else.
Which of those addresses is a company? Which is a forklift's telematics unit, a dock-scheduling agent, a cold-chain probe? The ledger knows custody moved. It does not know who held the box — org, agent, or device. If the answer to that question lives only in somebody's spreadsheet, the chain of custody is verifiable and meaningless at the same time.
This is Brick #1 — the first experimental proof inside the Bedrij-series hypothesis. It proves a narrow claim: transitions can be performed by nodes of any type, in a single primitive. Orgs, agents and devices now travel the same handoff event, and the read model replays them with provenance. No new endpoint, no new contract — four optional slots on a payload that already existed.
What are participant roles in a verifiable supply chain?
Short answer: participant roles are optional, typed declarations of who participated in a hop — { custodian, operator, device, sensor } — carried inside the handoff event payload and surfaced by the replay read model. Each role is an address, one of seven types (org | agent | device | robot | drone | service | human), and an optional display label. They are self-declared by the party doing the handoff — advisory metadata, not identity, and never consulted by any authorization or payment guard.
The distinction matters more than it looks. Our custody model is enforced by addresses: counterparty must equal the plan's receiver, actor must equal the current custody holder. Roles deliberately sit above that — they describe the real-world shape of the participants without pretending the chain verified it. A forklift can be listed as device and a scheduling service as agent; the ledger records who claimed it, not who approved it.
How do roles travel through a handoff event?
Short answer: roles enter at write time (handoffHop(…, roles)), are validated by a strict write schema, ride inside the handoff event's payload, and are lifted into ShipmentHop.roles during replay — so GET /api/ledger/shipments/:id returns them per hop with rolesAssertedBy naming who made the claim. Events written before roles existed replay unchanged: every field is optional.
This is the actual replay output from our test pack — two hops, one with a company custodian plus device and sensor, one with a custodian and an agent operator (addresses shortened for readability):
{
"shipmentId": "shp-brick1-demo",
"hops": [
{
"hopIndex": 0,
"status": "paid",
"receiver": "0xbbbb…bbbb",
"roles": {
"custodian": { "address": "0xbbbb…bbbb", "type": "org", "label": "TransMar Logistics" },
"device": { "address": "0xcccc…cccc", "type": "device", "label": "Forklift-7 telematics" },
"sensor": { "address": "0xdddd…dddd", "type": "device", "label": "Cold-chain probe" }
},
"rolesAssertedBy": "0xaaaa…aaaa"
},
{
"hopIndex": 1,
"status": "handed_off",
"receiver": "0xeeee…eeee",
"roles": {
"custodian": { "address": "0xeeee…eeee", "type": "org", "label": "Eindhoven Cold Hub" },
"operator": { "address": "0xcccc…cccc", "type": "agent", "label": "Dock-scheduler agent" }
},
"rolesAssertedBy": "0xbbbb…bbbb"
}
]
}
Three things worth reading in that JSON. The same address 0xcccc… plays device on hop 0 and agent on hop 1 — a type is a claim about a role in this hop, not a global identity. rolesAssertedBy moves: the client asserted hop 0's roles, the carrier asserted hop 1's. And hop 1's handoff works even though hop 0 already settled — roles are per-transition data, not per-shipment configuration.
Why do self-declared roles need provenance?
Short answer: because an unverified claim displayed as structured truth is indistinguishable from a verified one. A hander-off could label any third party "Maersk" and a consumer reading only hops[].roles would never know it was self-reported. We fixed that by writing the asserting party into the derived model — rolesAssertedBy — and by enforcing that custodian.address must equal the handoff counterparty, since custodian is the slot whose meaning the ledger already defines.
That fix came from an adversarial review, not foresight. We ran the changes through an AI audit before shipping: 0 Critical, 4 High, 6 Medium findings on the first pass. The Highs were exactly the trust-boundary bugs you'd want caught — roles presented as structured truth with no provenance (H-1), custodian able to contradict the custody model (H-2), a repeat handoff silently rewriting roles (H-3), and an epcis object able to smuggle keys that overrode guarded payload fields (H-4). All four are now guarded, and the re-audit came back APPROVE WITH NITS.
What did the audit actually change in the code?
Short answer: four behavioral changes and a schema split. Write-side parsing is now strict — unknown role slots or keys fail loudly instead of silently stripping; addresses normalize to lowercase; label rejects control, bidi and zero-width characters and must be non-blank; custodian must equal the counterparty; a hop already handed off rejects re-handoff; and the payload's guarded fields (shipmentId, hopIndex) are written last so a caller-supplied object can't overwrite them. The read schema stays lenient — stored events always replay.
The schema split is the part I'd highlight for anyone building on append-only logs: the write schema and the read schema are now different objects. ParticipantRolesWriteSchema is strict (z.strictObject, normalization, consistency refines). ParticipantRoleSchema — the one inside HandoffPayloadSchema and ShipmentHopSchema — is deliberately lenient, because tightening a rule tomorrow must never make a stored event unparseable. An append-only log means your read contract can only grow more permissive over time.
Current state: 76 package tests and 40 server tests green, zero open Critical/High findings, two audit reports committed alongside the code.
What roles are NOT — the honest boundary
Short answer: roles are not a registry, not a delegation mechanism, and not a signature scheme. Nothing checks that "Eindhoven Cold Hub" is who it says it is; rolesAssertedBy is only as strong as the authentication on the write path; label is display text and a determined attacker can still craft look-alike strings. If your application needs verified participant identity, roles are the wrong layer — they're the substrate such a verification layer would annotate.
That's deliberate. Brick #1 proves the shape of the claim — "transitions can name nodes of any type" — not the verification of the claim. The interesting follow-ups are exactly the missing layers: a participant registry, countersigned roles (where the named party must consent), and device attestations. Those are different bricks.
For AI Agents
{
"what": "Typed participant roles (custodian/operator/device/sensor) on handoff events in an append-only economic ledger, replayed into per-hop read state with provenance.",
"where": "packages/econ-events (ParticipantRoleSchema, ParticipantRolesSchema, ParticipantRolesWriteSchema); hackathon/server supply-chain pack (handoffHop, shipmentState); GET /api/ledger/shipments/:id",
"contract": "roles optional at every level; write path strict (unknown keys reject, addresses lowercase, custodian == counterparty, one type per address); read path lenient",
"verify": "76 package + 40 server tests green; adversarial AI audit REQUEST CHANGES → fixes → re-audit APPROVE WITH NITS; findings + fixes committed",
"limits": "roles are self-declared, not verified identity; single-process hop lock; labels are display-only"
}
If you're building agent-native supply chains: the schema package is @agentbadge/econ-events, the API is GET /api/ledger/shipments/:id, and the design docs for this brick live in docs/EPICS/212-supply-chain-participant-roles/. The anchor hypothesis this proves a piece of is in The Bedrij Principle.
Don't certify. Measure.