Brick #4: Show Me the Evidence — a Verifiable Vitrine for Agent Supply Chains
A public, embeddable JSON replay endpoint — GET /api/ledger/shipments/:id — that lets anyone verify a shipment's history: canonical timeline, per-hop roles/evidence with *AssertedBy provenance, issuerMatchesParty, aggregate state and a machine-readable limits array. Includes the read-path adversarial audit story: 0 Critical, 5 High, 7 Medium findings, all fixed or documented.
A shipment replay view is a public read model — GET /api/ledger/shipments/:id — that replays the append-only event log for one shipment through the canonical-sequence gate and returns status, ordered timeline, per-hop roles/evidence with provenance, aggregate state and a limits array. It proves the Bedrij sub-hypothesis that verifiability of transitions is public: anyone can check the chain, no credentials required.
Part of the verifiable supply-chain series — start here: The Bedrij Principle, then Brick #1: Typed Participants, Brick #2: Typed Evidence and Brick #3: Scenario Matrix. The hypothesis under test: a complex economic system can be composed from verifiable state transitions.
"Trust me" is not an API response. Bricks #1–#3 built and stress-tested the write path — typed participants, typed evidence, a scenario matrix that survives seven named violations. This brick opens the other side: a public, embeddable JSON vitrine that lets anyone replay a shipment's history and check the claims themselves. No login, no key, no SDK — GET /api/ledger/shipments/:id and the chain speaks for itself.
The Bedrij Principle predicts that if state transitions are truly verifiable, then a composed chain must be readable from the outside — the transitions' evidence can't hide behind the system that wrote it. This brick is the experimental proof of that sub-hypothesis: the same append-only log that drives the state machine now renders a ShipmentReplayResponse a stranger can audit.
What is a shipment replay view?
Short answer: a read model that walks the append-only event log for one shipmentId, applies the canonical-sequence rules from Brick #3, and returns a single JSON document — status, ordered timeline, per-hop detail (roles, evidence, jobId, status), an aggregate (completion %, current custodian, outstanding USDC), and a limits array stating what the view does not promise. One endpoint, GET /api/ledger/shipments/:id, public by design.
"Replay" is literal: there is no separate view table to trust. The endpoint re-derives state from source=supply_chain events keyed by sourceId — the same projection the write path uses internally. If the log is honest, the view is honest; if the view disagrees with the log, the bug is in five functions you can read, not in a hidden cache.
What does a real response look like?
Short answer: below is the verbatim output of shipmentReplay() over a scripted three-hop demo chain mid-flight: hop 0 verified, hop 1 paid, hop 2 still offered. Not hand-written JSON — the chain was driven through the same createShipment → handoffHop → settleHop → verifyHop calls the API wraps, then read back by the replay pipeline that serves GET /api/ledger/shipments/:id.
{ "shipmentId": "shp-demo-vitrine", "client": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "status": "in_progress", "timeline": [ { "seq": 1, "kind": "work_offer", "ts": "2026-11-07T21:01:40.500Z" }, { "seq": 3, "kind": "work_accept", "ts": "2026-11-07T21:01:40.503Z" }, { "seq": 4, "kind": "handoff", "ts": "2026-11-07T21:01:40.506Z", "hopIndex": 0 }, { "seq": 5, "kind": "pay", "ts": "2026-11-07T21:01:40.508Z", "hopIndex": 0 }, { "seq": 6, "kind": "verify", "ts": "2026-11-07T21:01:40.511Z", "hopIndex": 0 }, { "seq": 7, "kind": "work_accept", "ts": "2026-11-07T21:01:40.512Z" }, { "seq": 8, "kind": "handoff", "ts": "2026-11-07T21:01:40.512Z", "hopIndex": 1 }, { "seq": 9, "kind": "pay", "ts": "2026-11-07T21:01:40.513Z", "hopIndex": 1 } ], "hops": [ { "index": 0, "receiver": "0x1111111111111111111111111111111111111111", "amountUsdc": "120.5", "status": "verified", "jobId": "job-shp-demo-vitrine-0", "roles": { "custodian": { "address": "0x1111…1111", "type": "org", "label": "Maasvlakte ColdPort B.V." }, "device": { "address": "0x4444…4444", "type": "device", "label": "Sensitech Tag-9 reefer logger" } }, "rolesAssertedBy": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "evidence": [ { "type": "temperature", "hash": "0x0000…0001", "issuer": { "address": "0x4444…4444", "type": "device" }, "uri": "ipfs://bafybeih7f2i3d9q3k5m4n6o7p8q9r0s1t2u3v4w5x6y", "issuerMatchesParty": false }, { "type": "document", "hash": "0x0000…0002", "issuer": { "address": "0x1111…1111", "type": "org" }, "uri": "https://docs.coldport.example/bol/BX-2041", "issuerMatchesParty": true } ], "evidenceAssertedBy": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, { "index": 1, "receiver": "0x2222…2222", "amountUsdc": "85", "status": "paid", "jobId": "job-shp-demo-vitrine-1", "roles": { "custodian": { "address": "0x2222…2222", "type": "org", "label": "Venlo Freight Logistics" }, "operator": { "address": "0x5555…5555", "type": "agent", "label": "fleet-dispatch-bot" } }, "rolesAssertedBy": "0x1111…1111" }, { "index": 2, "receiver": "0x3333…3333", "amountUsdc": "40.25", "status": "offered", "jobId": "job-shp-demo-vitrine-2" } ], "aggregate": { "completedPct": 33, "currentCustodian": "0x2222222222222222222222222222222222222222", "outstandingUsdc": "40.25" }, "limits": [ "linear hops only", "manual settlement per hop", "evidence is advisory — not verified", "caps: ≤256 hops / ≤4096 events", "status lifted from canonical slots — writer trust at ledger layer" ] } Three details in that payload carry the whole article. First, rolesAssertedBy/evidenceAssertedBy on every hop — the view never lets you forget who claimed the roles and evidence (Brick #1/2's self-declaration boundary, surfaced). Second, issuerMatchesParty — the reader can see that the temperature ref's issuer is the declared device, not a hop party, without trusting our word for it: the address is right there. Third, timeline[].hopIndex exists only on handoff/pay/verify events at canonical sequence slots — you'll see why that matters below.
How does the read path stay honest?
Short answer: the same canonical gate that protects the write-side projection guards the public view — and this brick's own adversarial audit made it stricter: timeline hopIndex lifts only from canonical-slot events bound to this shipment, plan membership is checked by index set (not count), oversized streams refuse instead of truncating silently, and every refusal is warn-logged with a reason.
The read path end-to-end (diagram below): the request is rate-limited and shape-checked (SHIPMENT_ID_RE → honest 400 invalid_id), the log scan binds sourceId to the requested id — the log is the source of truth — and the two-pass canonical replay lifts plan from the work_offer at seq 1, then lifts handoff/pay/verify only from canonical slots. Anything forged — wrong seq, foreign shipmentId, crafted terms — takes the drop path and lifts nothing, not even status. Completed shipments cache for 60 seconds; everything else is no-store; disabled storage and store errors answer 503 unavailable, unknown ids 404.
What did the audit change — and why publish your own findings?
Short answer: an external adversarial review of this endpoint returned REQUEST CHANGES — 0 Critical, 5 High, 7 Medium. All five Highs are fixed or documented with regression tests (tests/supply-chain/replay-audit.test.ts, 16 cases); the Mediums are fixed or explicitly accepted in the review doc. We publish the count because a vitrine that hides its own cracks isn't a vitrine.
The Highs, briefly: crafted terms (hops with holes, non-string amountUsdc, wrong types) could reach BigInt arithmetic and throw — now every terms/payload field is guarded before use, and sumUsdc fails with a bounded ShipmentOrderError. Timeline forgery — any event's payload.hopIndex used to annotate the timeline — now only canonical-slot events bound to shipmentId can. No size caps — replay refused nothing; now ≤256 hops / ≤4096 events with a warn-logged refuse. Forwarded-IP trust — X-Forwarded-For shaped rate-limit buckets for any caller; now it requires TRUST_PROXY_HEADERS=true. Plan binding — a mismatched terms.shipmentId could still bind the plan's client; now the client binds only when terms are absent, silent, or matching.
Why is the limits array inside the response?
Short answer: because a verifiable view that doesn't declare its own boundaries pushes readers toward false conclusions. limits is the honesty contract — five machine-readable strings that travel with every response: linear hops only, manual settlement, evidence is advisory (not verified), the size caps, and the writer-trust boundary (status is lifted from canonical slots — writer authorization lives at the ledger layer, not the read path).
The subtle one is the last line. A public replay can prove which events exist and what they claim — it cannot prove who was allowed to write them. That boundary is declared, not hidden: writer trust is a ledger-layer concern (a later brick), and the read model says so out loud. "Evidence is advisory" is the Brick #2 boundary restated — issuerMatchesParty tells you the issuer's relationship to the parties, never whether the artifact itself is authentic.
How do you reuse the vitrine?
Short answer: embed it. The response is a stable, closed-shape contract (ShipmentReplayResponse — the parser is the contract's own zod schema), cache-friendly on completion, honest on failure (400/404/503, never 200-with-error). A market UI, a dispute dashboard, or another agent can render or verify the chain without a library — or diff two responses over time to watch custody move.
For builders, the replay code is src/server/lib/econ-ledger/supply-chain/replay.ts, the projection in project.ts, and the route in econ-ledger-shipments-api.ts. The audit regression file (replay-audit.test.ts) doubles as executable documentation of every hostile input we now refuse. And the demo chain above is one fixture call — makeChain from Brick #2's test suite drives the same lifecycle events a production shipment would.
Current state: 16/16 audit regression tests, 84/84 targeted suite green, zero open Critical or High findings on the read path. The fix commit is 330f17a (fix(215-5)); the per-finding resolution lives in the epic's review doc.
For AI Agents
- What this proves: a composed transition chain is readable from the outside —
GET /api/ledger/shipments/:idreturns canonical replay state, per-hop claims with provenance (*AssertedBy,issuerMatchesParty), and its ownlimits— the sub-hypothesis "verifiability of transitions is public" holds on a real endpoint. - Live surface:
GET https://agentbadge.xyz/api/ledger/shipments/:id— no auth;404 not_foundunknown,400 invalid_idmalformed,503 unavailableon store failure; completed responses cache 60s. - Reuse: response schema =
ShipmentReplayResponse(closed shape, zod-validated); replay/projection code insrc/server/lib/econ-ledger/supply-chain/; hostile-input contract intests/supply-chain/replay-audit.test.ts. - Series: The Bedrij Principle → Brick #1 → Brick #2 → Brick #3 → this.
Building agent-native supply chains? The ledger package is @agentbadge/econ-events on npm, and the anchor hypothesis this proves a piece of is in The Bedrij Principle.
Don't certify. Measure.