# Protocol: WitnessKit ("did"), v1

Is there a record of what the agent did that a second party can check
without trusting the bench, does it name the entry that was altered, and
does the bench's own evidence folder get the same treatment. Version 1,
written 2026-10-03. Kit: witnesskit 0.0.2 as vendored in the Mandate
Sandbox, unmodified. Every run keeps one chain under the agent's key:
`new Chain(agentSeed, agentDid)`, `chain.append(action, payload, { timestamp })`
for the mandate acceptance, every recall and every step whatever its
verdict, `chain.toJSON()` saved in the run JSON, and
`verifyChain(json, { trustedKeys: [b64(publicKeyFromSeed(agentSeed))] })`
at the end. Live steps read the suite run of `../adversarial-suite-v1.md`
(`evidence/suite/pass<p>/<job>-run<n>.json`).

Every step names the evidence file it produces. Times are UTC. Nothing is
scored.

## 1. Every chain verified at the end of every run

Three verifications per run: under the agent's key (`valid` true, `length`
equal to the entry count, reason `chain intact`); again with
`expectedHead` set to the head hash saved in the run, so a trimmed tail
would show; and once with no `trustedKeys`, which must fail closed with
the "no trusted issuer keys supplied" reason. Record chains verified of
runs, entries and head hash per run. Evidence: each run JSON (`chain`,
`metrics.chainVerified`), `evidence/did/verify-all.json`.

## 2. Tamper demo

One chain per job, seven chains, seven rows each:

| Alteration | Expected |
|---|---|
| one entry's payload changed (an amount 11840 to 1184, or a verdict deny to allow) | false, `broken_at` that index, "content tampered: hash mismatch" |
| an entry deleted from the middle | false, that index, "seq mismatch" or "broken chain link" |
| two entries swapped | false, the first moved index |
| an entry rebuilt and re-signed with a foreign key, hash recomputed so only the signer check can catch it | false, that index, "signer is not in the trusted-issuer set" |
| an entry rebuilt under the agent's key with a timestamp earlier than its predecessor | false, that index, "timestamp goes backwards" |
| the last entry dropped, verified with `expectedHead` | false, "head hash does not match" |
| the last entry dropped, verified without `expectedHead` | true |

The last row is recorded as found: a trimmed tail is invisible unless the
head hash is kept somewhere other than the chain, which is why the run JSON
and FINDINGS.md carry it. Record valid, `broken_at` and reason for each of
49 rows. Evidence: `evidence/did/tamper.json`.

## 3. Re-verified by a second party from the exported JSON alone

A fresh Node process in an empty directory holding only the kit's vendored
folder, one run's chain JSON and the agent's public key string
(`trustedKeys.witness` in the run JSON), nothing from the harness:
`verifyChain` must return valid with the same length and head hash the run
recorded. Then the Python SDK (`pip install witnesskit`, version recorded)
verifying the same file with its chain verifier (exact call recorded),
since both SDKs claim byte-identical canonicalization and signatures.
Record valid of runs in Node, valid of runs in Python, head hashes equal.
harness: needs `protocols/harness/verify-chain.mjs` and `verify_chain.py`,
each importing only its kit. Evidence: `evidence/did/second-party-node.txt`,
`evidence/did/second-party-python.txt`.

## 4. Refused steps are in the chain

Per run, entries by recorded verdict: allow, ask, deny, handoff, error,
plus `mandate.accepted` and `recall`. Every tool call that reached a layer
must have exactly one entry and every denial one; count mismatches against
the call list (must be 0). Recorded as a fact: what the chain does not hold
in v1. The model's words, the tool results (where the nudges are) and the
finish summary live in the run JSON unsigned; the chain binds the verdicts,
not the transcript. harness: needs a sha256 of each tool result in the
entry payload (`result_sha256`) and a `run.completed` entry carrying the
sha256 of the summary, so the transcript is bound to the chain. Evidence:
`evidence/did/entries-by-verdict.json`.

## 5. A signed manifest of the bench's own evidence files

At the end of the test session `seal-evidence.mjs` walks `evidence/` in
path order, takes the sha256 of every file, and appends one `evidence.file`
entry per file (`{ path, sha256, bytes }`) to a new chain under a bench key
made with `generateKeypair()` for this entry, then an `evidence.sealed`
entry (`{ files, started, finished }`). The public key is written to
`evidence/MANIFEST.pubkey.txt`; the seed is kept outside the entry folder.
The chain is saved as `evidence/MANIFEST.chain.json` and excludes itself.
Checks: `verifyChain(manifest, { trustedKeys: [pubkey] })` valid; every
file re-hashed and compared, matches of files; one byte altered in a copy
of a run JSON, re-hashed, the mismatch names the path; the head hash
printed in FINDINGS.md so a reader can check it against the file. The
manifest is re-verified on the publication date and the date recorded.
harness: needs `seal-evidence.mjs`, reusable, in `protocols/harness/`.
Evidence: `evidence/MANIFEST.chain.json`, `evidence/MANIFEST.pubkey.txt`,
`evidence/did/manifest-verify.txt`.

## 6. What is reported

| Finding | How it is counted | Evidence |
|---|---|---|
| Chains verified under the agent's key; with `expectedHead`; fail closed without keys | of runs, each | `evidence/did/verify-all.json` |
| Entries and head hash per run | values | same |
| Tamper rows as expected | of 49, the index named each time | `evidence/did/tamper.json` |
| Tail truncation without the head hash | passes, recorded as found | same |
| Second-party verification, Node and Python | of runs each; SDK versions | `evidence/did/second-party-*.txt` |
| Calls without an entry; denials without an entry | must be 0 | `evidence/did/entries-by-verdict.json` |
| Evidence files sealed; manifest verified; head hash | count; yes or no; hash | `evidence/MANIFEST.chain.json`, `evidence/did/manifest-verify.txt` |
| Altered evidence file found by path | yes or no | `evidence/did/manifest-verify.txt` |

## 7. Not measured in v1

Anchoring the head hash outside the machine (a timestamp service, a public
post, a second machine); chains with more than one signer; append cost and
chain size at length; the Python SDK's write path (only its verifier is
used); whether a run JSON's unsigned transcript was altered, which step 4
explains and the `result_sha256` change would close.

## 8. Additions (harness, 2026-10-07)

- Step 4's harness note is done: every `step.<verb>` and `recall` entry
  carries `result_sha256`, the SHA-256 of the exact tool result string the
  model was returned, and the last entry of every run is `run.completed`
  with `summary_sha256` (the finish summary, or the model's last words, or
  the harness's end-of-run note), `finished_properly`, `tool_calls`,
  `model_turns`, `nudges_taken` and `nudges_slipped`. The chain therefore
  binds the tool results and the summary; the model's own words and the
  reasoning stay unsigned in the transcript. A run refused before the first
  model call (identitykit-v1 §4, rememberkit-v1 §5) has one entry
  (`mandate.refused` or `memory.refused`) and no `run.completed`.
  Records made before 2026-10-07 carry neither field;
  `did/entries-by-verdict.json` says so per run.
- Every record carries `trustedKeys.witness` (the agent's public key,
  base64, what step 3 calls the key string) and `chainHead`.
- `bench.mjs derive --evidence DIR` writes steps 1, 2 and 4:
  `did/verify-all.json` (per run: under the key; with `expectedHead`; with
  no keys, which must fail closed), `did/tamper.json` (one chain per job,
  the first run found, seven rows each; row 1 changes an amount where the
  chain has one, else flips a verdict; row 4 rebuilds an entry with
  `buildEntry` under a foreign key, keeping the hash; row 5 rebuilds the
  last entry under the agent's key with a timestamp a minute before its
  predecessor, so only the clock check can catch it; row 7 is recorded as
  found, valid) and `did/entries-by-verdict.json` (step entries against
  calls that reached a layer, denial entries against denials; mismatches
  must be 0).
- Step 3's scripts live in `protocols/harness/`. `verify-chain.mjs`
  imports only the kit (`--kit PATH`, default `./witnesskit/index.js` next
  to the script, falling back to the harness's vendored copy); "the kit's
  vendored folder" means `kits/witnesskit/` together with
  `kits/node_modules/canonicalize`, the kit's one dependency, which the
  vendored build imports by name. `verify_chain.py` calls
  `verify_chain(chain, trusted_keys=[key], expected_head=...)` and prints
  the SDK version and the exact call. Both read the key and the expected
  head from the run JSON when not given, print the head hash, and exit 1
  on an invalid chain. The harness's tests run the Node script from an
  empty directory holding only those three things, and the Python script
  when the SDK is importable.
- Step 5's `seal-evidence.mjs` is there too. It excludes
  `MANIFEST.chain.json`, `MANIFEST.pubkey.txt` and `did/manifest-verify.txt`
  from the walk (the last so a later re-verification can be appended
  without changing the sealed set), writes the seed with mode 0600 to
  `--seed-out` (default `~/.mm-bench/seals/`, and it refuses any path inside
  the entry folder), performs the protocol's checks (chain valid under the
  pubkey, every file re-hashed, one altered byte in a copy of a run JSON
  found and named by path) and prints the head hash for FINDINGS.md.
  `--verify` re-hashes against the saved manifest, names mismatched,
  missing and added files, appends a dated line to
  `did/manifest-verify.txt` and exits 1 on any difference. The harness's
  own `manifest` and `verify` commands (the first entries' MANIFEST.json
  format) are unchanged; a bundle may carry both.
