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

> Source: <https://agentbadge.xyz/blog/supply-chain-participant-roles>
> Published: 2026-10-29 00:00:00+00:00

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

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

*The lifecycle of a role claim: asserted at write time by the party doing the handoff, validated by a strict schema, stored in the append-only log, then lifted by a lenient replay into per-hop state — with rolesAssertedBy recording who made the claim. No guard ever consumes it: custody still answers only to counterparty and actor.*

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