Verify a run
Check an evidence export offline with numi evidence verify, read the four ways it fails, gate CI on the right JSON fields, and pin your keys independently of the export.
numi evidence verify and the sdk/go/evidenceverify package are in the open build and pass every conformance vector. Producing an export (evidence export) is not in the open build: the exports in this guide are the conformance fixtures shipped in the spec.
When to use this
Run verification whenever a reviewer, an auditor or a release gate must decide whether a change's record can be believed, without access to the system that produced it. The verifier is offline, read-only, bounded in what it reads, and open source, so the party doing the checking does not have to trust the party that made the export.
Prerequisites
- The open CLI built from a spec checkout:
go build -o ./bin/numi ./cli/numi, orgo run ./cli/numi .... See Install. - An export directory. In this guide, the fixtures under
conformance/evidence/v1/exports/. - The pinned public keys: the runner key (64 hex characters), the transparency log key and the log origin. You obtain these from your own records, not from the export.
All commands below were run from conformance/evidence/v1 inside the spec checkout, using the prebuilt numi at revision 3c74d59.
What verified means
The verifier reads the records of one change, recomputes each hash, checks each signature against the keys you pinned, checks that the chain head is proved under the log you pinned, and re-derives the attestation from the chain. The result is one of several levels. verified is the top one, and it is the only one that rests entirely on keys you chose.
| Level | Signatures | Anchor | Exit |
|---|---|---|---|
verified | Under pinned runner keys | Head proved under the pinned log | 0 |
self-consistent | Under keys the export states only | Not checked | 5 |
unauthenticated | Not checked (--skip-signatures) | Not checked | 5 |
failed | Any finding | n/a | 3 |
What verified does not say: that the code is correct, that the tests were thorough, that the model reasoned well, or that a compromised host logged every event. It says the provenance and chain integrity hold under the keys you pinned. Read Threat model and limits before relying on it.
Evidence chain inspector
What does verification actually establish, and which inputs does it depend on?
Hash-linked records (drawing 6 of 12)
- seq 1link
previous_record_hash empty (sequence 1)
signature verified - seq 2link
links to previous
signature verified - seq 3link
links to previous
signature verified - seq 4link
links to previous
signature verified - seq 5link
links to previous
signature verified - seq 12 (head)link
terminal state: Closed
signature verified
attestation.json
signed summary, re-derived from the chain
verifiedhead-checkpoint + inclusion proof
checked under the pinned log key and origin
verifiedVerifier inputs (what you bring, not what the export says)
- ✓ pinned runner key(s)
- ✓ pinned log key + origin
- provenance/signers.json and any checkpoint in the export are claims, not trust.
numi evidence verify --runner-key keys/runner.pub --log-key keys/log.pub --log-origin numinance.test/log --complete exports/closed-author
simulated output, shape as in the real CLI
change: chg_01010101010101010101010101010101 records: 12, head sha256:8dec4e3d… state: ended Closed level: verified chain: records link, hash and end as stated signatures: verified under the pinned runner keys anchor: head proved under the pinned log attestation: signed, re-derived from the chain result: verified
exit 0
level: verifiedexit 0
Provenance and chain integrity hold under keys and a log you pinned.
What this does NOT prove, at any level
- The code is correct.
- The tests are thorough.
- The model reasoned well.
- Completeness against a compromised host: events it never logged are not in the chain.
Level verified, exit 0. No findings or weaknesses.
Text description
Illustrative and simulated: no export is read. The rules are those of docs/evidence/verification.md; the baseline report is the real output of the open verifier on the conformance export exports/closed-author.
- You must choose exactly one key mode. Pinned runner keys (
--runner-key) are the only way to reachverified.--trust-bundle-keystakes the keys the export claims and tops out atself-consistent.--skip-signaturestops out atunauthenticated. - A pinned log (
--log-keywith--log-origin) is needed to prove the head. Without it the result isunanchored(exit 5) unless--allow-unanchoredis given. - Any finding makes the level
failed(exit 3). Edit or remove a record:chain_broken. A chain signed by a key you did not pin:signer_untrusted. With keys from the export or signatures skipped, the same forged-but-consistent chain is not caught, which is why those levels are weaker. - Exit codes: 0 verified, 3 failed, 5 consistent but weaker than verified.
Verified does not prove the code is correct, the tests are thorough, the model reasoned well, or that a compromised host logged every event.
Verify a good export
numi evidence verify \
--runner-key keys/runner.pub \
--log-key keys/log.pub --log-origin numinance.test/log \
--complete exports/closed-authorchange: chg_01010101010101010101010101010101
records: 12, head sha256:8dec4e3d5cf4bd7650ef6010671298793c05493cb20c3dd946bc3ef805689e48
state: ended Closed
level: verified
chain: records link, hash and end as stated
signatures: verified under the pinned runner keys
anchor: head proved under the pinned log
attestation: signed, re-derived from the chain
credential_scopes: unsourced: no credential broker statement is in the attestation
network_policy: unsourced: no egress proxy statement is in the attestation
not applicable: deployment.json (not applicable at PR level: the change closed from Merging (design section 6) and was never released, deployed or observed)
not applicable: observation.json (not applicable at PR level: ... same reason)
result: verifiedTwo lines are not green and should not be skimmed. credential_scopes and network_policy are unsourced: the attestation carries no statement from a credential broker or an egress proxy, so the export proves nothing about those. And deployment.json and observation.json are marked not applicable because this change closed at PR level, and the chain bears that out. A part declared not applicable that the chain contradicts is a failure (part_contradiction).
Four ways it fails
The conformance suite ships a failing vector for each tampering case. Each of these runs below used the same pinned keys as the success above, against an export that the vector names. The absent: lines that every failed report also prints are omitted for brevity.
1. A record edited in place: chain_broken
The vector forged-record edits a record and leaves its record_hash, so the hash no longer matches the content.
change: chg_01010101010101010101010101010101
records: 0, head
state: open (no terminal record)
level: failed
chain: failed
signatures: not verified
anchor: not proved
attestation: none
FAIL chain_broken: a record does not match its record_hash
result: failedNotice records: 0 and no head: a report for a broken chain does not claim a head, signatures or an anchor.
2. A signature changed: signature_invalid
The vector signature-edited-in-place changes one base64 character of a record's signature. The key is trusted and the signature fails.
level: failed
chain: failed
signatures: not verified
FAIL signature_invalid: a record signature does not verify
result: failed3. A signer you did not pin: signer_untrusted
An attacker who builds a complete, self-consistent chain with their own key gets a chain that is internally valid. It is rejected because their key is not one you pinned. This run points --runner-key at the wrong key for the closed-author export:
level: failed
FAIL signer_untrusted: a record names a key that is not trusted
FAIL part_contradiction: a part is declared not applicable and the chain does not bear it out
FAIL part_contradiction: a part is declared not applicable and the chain does not bear it out
result: failed(The two extra findings follow from the signer failure: the not-applicable declarations cannot be borne out by a chain whose signatures are not trusted.)
4. Another genuine chain: expectation_mismatch
A different but internally consistent export of the same change, signed by the pinned keys and anchored, verifies. A verifier cannot tell it from the original on its own. That is what --expect-head and --expect-change are for: you record the head hash when you approve a change, and the check later fails if the export presented is not that chain.
numi evidence verify \
--runner-key keys/runner.pub \
--log-key keys/log.pub --log-origin numinance.test/log \
--expect-head sha256:cfe3d79b4663e5a178e3720a0f44c5503a79c7fe5c2230192fc3bdadb8e89393 \
exports/closed-basicchange: chg_01010101010101010101010101010101
records: 4, head sha256:f660cbc6ee23d051bd40f405bc60f2947b10577324e15172e5f1464b624b0d8c
state: ended Closed
level: failed
chain: records link, hash and end as stated
signatures: verified under the pinned runner keys
anchor: head proved under the pinned log
attestation: signed, re-derived from the chain
FAIL expectation_mismatch: the head is not the one expected
result: failedHere the chain and signatures are fine, which is the point: only the caller's own expectation catches a swap for another valid chain. --expect-change takes a chg_ id and exits 2 with a usage error for anything else.
The full set of vectors is under conformance/evidence/v1/vectors/*/expected.json, each naming its export, flags, exit code and finding codes. See Conformance vectors.
Exit codes
| Exit | Meaning |
|---|---|
| 0 | A pass, with or without an allowance flag |
| 2 | Usage error: none or two key modes, bad flag value, a malformed --expect-change |
| 3 | A report with a finding, or a refused directory path |
| 4 | The directory does not exist |
| 5 | A consistent export weaker than verified |
| 7, 8 | I/O error, timeout |
Exit 0 is not the same as verified. An allowance flag such as --allow-unanchored makes a weaker pass exit 0 and suffixes the level (verified, unanchored). That is why a CI gate must read the JSON, not the exit code alone.
Wire it into CI
The rule from the CLI documentation: require level_enum == "verified" and an empty weakened_by_flags, never a prefix of the level string. A verified result can still carry declared-absent parts, so also read the absent list, or pass --complete.
#!/bin/sh
set -u
report=$(numi evidence verify --json \
--runner-key keys/runner.pub --log-key keys/log.pub --log-origin numinance.test/log \
--complete "$1")
code=$?
echo "$report" | jq -r '"level_enum=\(.level_enum) weakened_by_flags=\(.weakened_by_flags|length) absent=\((.absent // [])|length)"'
[ "$code" -eq 0 ] || { echo "verify exit $code"; exit 1; }
echo "$report" | jq -e '.level_enum == "verified" and (.weakened_by_flags | length == 0)' >/dev/null || { echo "weaker than verified"; exit 1; }
echo "gate passed"level_enum=verified weakened_by_flags=0 absent=0
gate passed
level_enum=failed weakened_by_flags=0 absent=5
verify exit 3Read the absent list
--complete turns a declared-absent part into a failure. Without it, the same closed-basic export is verified, with five parts listed in absent:
{"level_enum":"verified","absent":["change.json","verification/","review/","deployment.json","observation.json"]}With --complete the same export exits 3 and prints FAIL parts_absent: a part is absent and --complete was given. Decide per pipeline: a gate that must know a verifier report and a reviewer decision exist should demand --complete, or inspect absent for the parts it needs. Treat each absent part as a statement that this export does not say anything about that area.
Pin keys independently
The flags give you three modes, and exactly one is required:
--runner-key FILE(repeatable), with--log-keyand--log-origin: the only mode that can reachverified. Any pinned runner key may sign any record, the attestation or the manifest.--trust-bundle-keys: trusts the keys the export states. The best level isself-consistent.--skip-signatures: checks no signature. The best level isunauthenticated.
level: self-consistent
signatures: verified under keys the export states only
anchor: not checked (no log pinned)
result: self-consistentA key that arrives inside the export can only ever tell you the export agrees with itself. The attacker who forges a whole export brings their own key and their own bundle. The pin must therefore come through a channel the exporter does not control: a file in your own repository, a secrets store, a record your security team signed. There is no built-in trust root in the verifier and none in the spec. Rotating a key means updating your pin and, for old exports, keeping the old pin alongside the new one, since --runner-key is repeatable.
Log pinning has two parts
--log-key and --log-origin go together. The origin names which log's checkpoints you accept. A correct key with the wrong origin, or the reverse, fails the anchor check (see the vectors wrong-log-key and wrong-log-origin).
Failure modes and troubleshooting
| What you see | Meaning | Fix |
|---|---|---|
exactly one key mode is required, exit 2 | No key mode or two modes given | Pick --runner-key with the log flags |
| Usage error about the log flags, exit 2 | --log-key and --log-origin must be given together | Provide both |
level: self-consistent, exit 5 | Keys come from the export | Pin your own runner key |
anchor_invalid | Head not proved under the pinned log, or wrong log key | Check the log key, origin and that the export has a head proof |
signer_untrusted | A record is signed by a key you did not pin | Do not accept: investigate the signer |
Exit 0 and a verified, unanchored level | An allowance flag was given | Gate on level_enum and weakened_by_flags |
parts_absent | --complete and a part is missing | Decide whether the missing part matters for your gate |
Next steps
Build a custom plugin
Walk the whole path of a capability provider, from picking a port and reading its protobuf to the handshake, Invoke, HostService calls and conformance, using the real sample-knowledge provider.
Extend a coding harness
How an adapter maps a native coding CLI onto the authorexecutor port, what it must report, what it must never do, and why residency is derived from live configuration rather than a cloud's name.