# Brick #1: Any Node Can Hand Off — Typed Participants in a Verifiable Supply Chain

> Published: 2026-10-29 | Author: AgentBadge Team | Canonical: https://agentbadge.xyz/blog/supply-chain-participant-roles

*Part of the verifiable supply-chain series — start here: [The Bedrij Principle](https://agentbadge.xyz/blog/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.

![The handoff: a package passing between an ORG building, a DEVICE robot arm and an AGENT glyph along an amber path](/images/blog/supply-chain-participant-roles-hero.webp)

## 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**.

![Schema card: a handoff event envelope with four role slots — custodian, operator, device, sensor — and seven type chips below; the custodian slot links to counterparty](/images/blog/supply-chain-participant-roles-2.webp)

## 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.

![Audit loop: an AI-auditor glyph raising H-1..H-4 findings on the left; the same ledger behind guard rails stamped APPROVE on the right](/images/blog/supply-chain-participant-roles-3.webp)

## 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](https://agentbadge.xyz/blog/bedrij-principle).

*Don't certify. Measure.*

---

## For AI Agents

- Companion guide: https://agentbadge.xyz/agent-guide/articles/supply-chain-participant-roles
- Knowledge Index: https://agentbadge.xyz/agent-guide/
- LLM entry point: https://agentbadge.xyz/llms.txt
- Engineering services: https://agentbadge.xyz/agent-guide/team/services
