Skip to main content
This page takes the hand-offs two agents recorded to one verdict that a third party can reproduce without you and without Sanning. It is for the developer whose agents already record hand-offs as Record a hand-off between two agents describes. A case is the exchange a set of records belongs to: the opaque id you write into each record as context.case_id. Each side hands over a pack of its own records, and a case reading drops every party’s pack together and returns one case verdict, with every exception named.
This page uses @sanning/anchor 0.16.0 and sanning-anchor 0.16.0 to build packs, and @sanning/proof and sanning-proof 0.14.0 to read a case. Where a behavior is new at one of those versions, this page says so.

The path

Each side does the first three steps on its own records, and the reader does the last two:
  1. Name the case on every record in it.
  2. Close each run.
  3. Bundle one pack per agent, with a declaration.
  4. Hand every pack to the reader.
  5. Read the case at the command line, and read the verdict.

Name the case on every record

createHandoff writes the case id into the two hand-off records for you. Any other record that belongs to the case needs it passed by hand:
bundle --case selects records by the case id signed into each one, so a record that names no case cannot be placed. When the pack carries a declaration, bundle refuses a window that holds such a record rather than guess, and names how many it found.
The LangChain and Vercel adapters write no context.case_id unless you name one. Pass caseId (case_id in Python) when you build the handler, as Putting the records in a case in the adapter’s own README shows, or bundle --case refuses the adapter’s records rather than guess which case they belong to.

Close each run

A closing record says how a run ended. It lists the records the run delivered, what was observed (completed, failed or unknown), and, separately, what your code states. The two are never merged, so a run that ended cleanly inside a case that failed says both. With the LangChain adapter, name the case when you build the handler and state the outcome when you close it:
The adapter writes one closing record per root run, langchain.run_closed, with closing.outcome.observed from the run’s own end and closing.outcome.stated from your close(). The Vercel middleware takes the same caseId and close({ outcome, reason }); it cannot see how a run ended, so its observed status is always unknown. Without an adapter, anchor a closing record yourself. promotedClosing builds the same closing field the adapters write:
Replace deliveredEventIds (delivered_event_ids) with the event ids this run anchored. Pass unknown as the observed status unless something watched the run end, and put what your code knows in stated. The case rides in caseId on the anchor() call itself, the same option every other record in the case uses; a closing record carries no case field of its own. No kernel reads a closing record’s metadata. A case reading names it as a step record, as Read the verdict shows.

Bundle one pack per agent

Wait until the interval holding the case’s records is sealed, which takes minutes. Then build each agent’s pack where that agent’s log store and seed are:
Replace the following:
  • API_KEY with the organization’s API key.
  • AGENT_SIGNING_SEED with the seed the assessor agent signs its records with. bundle signs the declaration and the pack with it.
  • assessor, the dates and ./logstore with the agent, the days and the log store root that hold the case.
--from and --to select on the time Sanning received each record, which can sit a moment before the time your code captured. Widen the window rather than cut it close. Every declared pack signs sanning.declaration/v2: each record it holds carries a role, handoff or step. A hand-off record’s message travels as logs/<event id>.json, exactly JCS(message), the bytes its message_hash commits to; a step record, such as the closing record above, is declared and signed as a step, and a reader names it as one rather than treating it as an unclaimed record. A full pack reads cleanly as a case, closing records and all: you do not need to leave anything out to get a clean verdict.
Minimum disclosure across organizations. --handoffs-only (with --case and SANNING_SIGNING_SEED) packs only the case’s hand-off records and claims each one in the declaration’s handoffs[], leaving step records out and counted. Reach for it when your step records are not the other organization’s to read; a full pack does not need it to read verified.
Two agents in one organization can build a case file in one command. Give --agent more than once, with --case, and bundle writes one pack per agent, each signed by that agent’s own key, into one zip:
Replace SEED_A and SEED_B with each agent’s 64-hex signing seed. SANNING_SIGNING_SEEDS holds one <agent>=<seed> entry per --agent (SANNING_SIGNING_SEED is refused beside it), and --logs is given once for every agent, or once per --agent in the same order. This is the case file: one zip holding packs/<agent>/bundle.json and its logs/, per agent, beside contents.json (an unsigned index of every file and its SHA-256) and HOW-TO-VERIFY.txt. Nobody signs the case file itself; each pack inside it is still checked on its own, and the case reading below never reads the index as evidence. Two agents in two organizations cannot share one bundle call, because each organization holds its own API key and log store: build each side’s pack separately, as the single-agent command above shows, and hand both packs to the reader.

Hand every pack to the reader

Send each pack, or the case file, to the reader by whatever channel you use for evidence. Each organization sends its own: the reader needs every party’s pack, because a pack holds only one side of each hand-off. Nothing about this step touches Sanning.

Read the case at the command line

Each pack verifies on its own, from inside the unzipped pack:
Add --logs logs/ when the pack has a logs/ directory. A pack of hand-off records has none, and pointing --logs at a directory that does not exist is an input error that exits 2. None of a hand-off record’s content is disclosed, so its content binding is undetermined rather than proven. A pack built with SANNING_SIGNING_SEED, as above, says so in its own declaration, and reads verified (exit 0) even though nothing in it discloses content. A pack built with no signing seed carries no declaration to say the absence was on purpose, and the same pack then reads incomplete (exit 3) instead. Either way the signatures and the inclusion proofs still pass, and the output says so on its own lines. A single-pack check does not pair anything. To read the case, pass every pack, or the case file, to case:
case reads a folder holding one pack, a folder holding several packs, or a case file named <name>.zip, unzipped as it reads it. It does not open a .zip that is not a case file: given one, it exits 2 and asks you to unzip it first and pass the folder. Pass --json for the full reading as data, and --gateways <url,url> to also look up each pack’s witnesses on the public record; the lookup is reported on its own line and never moves the verdict. The following table lists the exit codes every verb of @sanning/proof and sanning-proof uses, verify, export and case alike:

Read the verdict

A case reading reaches six of the kernel’s eight verdict words, and each one carries a fixed reliance line, printed with the headline: Under the headline, the scope sentence names how many packs and signing keys are in the reading, how many hand-off records, how many exceptions, and that the reading was made offline, plus how many records’ content is withheld by their holder and how many packs do not say what they hold, when either is true:
Withheld content, and a full pack that includes step records, are not exceptions: a case with either still reads verified, and the sentence says so alongside it, never instead of it. Below the scope sentence, the witness line says how far a record travelled on the public record, independent of the headline: confirmed, not_checked (no lookup ran, or one did not settle), or differs. It never moves the headline. --gateways is what makes the lookup run; without it the line always reads not_checked. A one-sided hand-off, or several messages in one direction, is a named exception, never a failure by itself. An honest exchange of several messages in one direction, or a message that is fanning out to more than one receiver, reads verified once every candidate is accounted for; only a genuine mismatch reads failed, naming the record on each side:
A verified case carries the limits a verified pack carries, and more. What a hand-off record does not prove states them.

What a verdict means

Every verdict word, and reading a case from your own code.

What a hand-off record does not prove

Integrity, not accuracy, and the other limits to state to a reader.