Skip to Content
Numinance
Evidence

Offline verification

Verify an evidence/v1 export with numi evidence verify or the Go package, with every flag, the JSON report, the exit codes, and real runs of success, tampering and a wrong key.

View as .mdUpdated
Available in public spec
numi evidence verify and the Go package sdk/go/evidenceverify are in the open build and pass every conformance vector. numi evidence export is not in the open build.

The verifier is offline, read-only and size-bounded. It never defaults to trusting the directory: you pin the keys, or you get a weaker level.

When to use it

Use the verifier when you hold an export and want to know how strongly it can be believed: an auditor reading a change, a CI job gating on evidence, a second team re-checking a run, or a developer of another verifier comparing results. It needs no engine and opens no network connection.

Prerequisites

  • A checkout of the public repository and the Go toolchain declared by its go.mod.
  • An export directory.
  • The runner public key, the log public key and the log origin, each obtained out of band. A key file holds the 32-byte public key as 64 hexadecimal characters, with optional surrounding whitespace, in at most 1024 bytes.
Build the open numi
git clone https://github.com/dsha256/numinance-spec.git
cd numinance-spec
go build -o ./bin/numi ./cli/numi

go run ./cli/numi evidence verify ... works the same way without a build step. Installing numi gives you this verifier and the other open commands. It does not install an autonomous engineer, and it cannot produce exports.

Command reference

numi evidence verify [flags] DIR
FlagMeaning
DIRThe export directory. Exactly one
--runner-key FILEA pinned runner public key. Repeatable. Any pinned key may sign a record, the attestation and the manifest
--trust-bundle-keysTrust the keys in provenance/signers.json. Best level is self-consistent
--skip-signaturesCheck no signature. Best level is unauthenticated
--log-key FILE and --log-origin ORIGINThe pinned transparency log. Both or neither
--allow-unanchoredAccept a head not proved under a pinned log
--allow-incompleteAccept a chain with no terminal record
--allow-unsignedAccept the export of an Abandoned or RolledBack change
--completeFail when a part of the design list is absent
--expect-change CHG_IDFail unless the chain is this change
--expect-head DIGESTFail unless this is the last record's hash or the anchored hash
--jsonPrint one JSON object instead of the text report

Exactly one key mode is required: pinned --runner-key, --trust-bundle-keys or --skip-signatures. None, or two, is a usage error (exit 2). --trust-bundle-keys is refused together with a pinned key or --skip-signatures. A key file over 1024 bytes is an error and is never read as a truncated key.

A full pass, in text and in JSON

The fixture is conformance/evidence/v1/exports/closed-author, run from conformance/evidence/v1 of the spec checkout. It is a golden export generated with fixed test keys.

$ numi evidence verify --runner-key keys/runner.pub --log-key keys/log.pub --log-origin numinance.test/log --complete exports/closed-author
change: 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: the change closed from Merging (design section 6) and was never released, deployed or observed)
result: verified
✓ verifiednumi evidence verify, text report· numinance-spec @ 3c74d59 · 2026-11-09· 0

With --json the same run prints one object. The not_applicable array is shortened here for reading; the run printed the full strings.

{"ok":true,"change_id":"chg_01010101010101010101010101010101","records":12,
 "head_record_hash":"sha256:8dec4e3d5cf4bd7650ef6010671298793c05493cb20c3dd946bc3ef805689e48",
 "anchored_head_record_hash":"sha256:8dec4e3d5cf4bd7650ef6010671298793c05493cb20c3dd946bc3ef805689e48",
 "ended":true,"terminal_state":"Closed","sealed":false,
 "level":"verified","level_enum":"verified","weakened_by_flags":[],
 "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",
 "not_applicable":[{"part":"deployment.json","not_applicable":true,"...":"..."},{"part":"observation.json","not_applicable":true,"...":"..."}],
 "credential_scopes":"unsourced: no credential broker statement is in the attestation",
 "network_policy":"unsourced: no egress proxy statement is in the attestation"}
✓ verifiednumi evidence verify --json (not_applicable entries shortened)· numinance-spec @ 3c74d59 · 2026-11-09· 0

The JSON report

KeyTypeMeaning
okbooleanTrue exactly when the exit code is 0, which includes passes that carry an allowance
change_id, records, head_record_hashstring, integer, stringThe verified chain
anchored_head_record_hashstringThe hash the log's head leaf holds. Differs from head_record_hash only on a sealed chain
ended, terminal_state, sealedboolean, string, booleanWhat the records state
levelstringThe display level, suffixes included, such as verified, unanchored
level_enumstringThe level without suffix: verified, failed, unauthenticated, self-consistent, incomplete, unsigned or unanchored
weakened_by_flagsarray of stringThe allowance suffixes, in fixed order: unanchored, incomplete, unsigned export
weaknessesarray of stringSentences for people, one per weakness not covered by a flag
chain, signatures, anchor, attestationstringWhat was proved of each
absent, not_applicablearray of part objects{part, reason, owner, not_applicable?}
credential_scopes, network_policystringunsourced... when the attestation holds no broker or proxy statement
trajectorystringPresent only when the chain holds a trajectory statement; a finding to read, never a failure
findingsarray of {code, message}Absent when empty. The codes are normative; the messages are not

A report is printed only when the verifier got as far as verifying a directory. Exit 2, a refused path (3) and a missing path (4) print none.

Automation: read three things, not one

Exit 0 and ok: true are also produced by verified, unanchored, verified, incomplete and verified, unsigned export. Do not match a prefix of level. A full pass is exit 0, level_enum equal to verified, and an empty weakened_by_flags. If you need every part, also read absent or pass --complete. A pipeline that accepts an allowance must say which one it accepts.

Exit codes

ExitNameWhen
0OKNo finding, and a level that starts with verified, with or without suffixes
1internalAn internal error, a cancelled context, a report that cannot be encoded
2usageUnknown flag, not exactly one directory, an unreadable or malformed key file, a half-given log pin, no key mode or more than one, a malformed --expect-* value, an empty path
3invalidAt least one finding, or the directory path itself is refused (not a directory, a link, changed while opened)
4not foundThe directory does not exist
5weakNo finding, but the level does not start with verified: unauthenticated, self-consistent, incomplete, unsigned or unanchored
7unavailableAn I/O error that is not a refusal
8timeoutThe deadline was exceeded

Exit 6 is never produced by verify. Exit 5 is "a conflict" elsewhere in the numi table, but verify never produces a conflict: for verify, 5 means the directory is consistent and the check was weaker than verified. Exits 1, 7 and 8 depend on the environment and have no data vector.

Tampering is a named finding

Copy the fixture, change one byte of one record (the risk_class of record 6 from READ to HIGH), and run the same command.

level: failed
FAIL digest_mismatch: a file differs from its index entry
FAIL chain_broken: a record does not match its record_hash
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
✓ verifiedverify a copy of closed-author with one edited record (filtered to level, FAIL and result lines)· numinance-spec @ 3c74d59 · 2026-11-09· 3

The edit is caught three ways at once: the index digest no longer matches, the record no longer hashes to its record_hash, and, because the chain is no longer verified, the claim that deployment and observation are not applicable can no longer be borne out. All of these are findings and the exit is 3. Appending a line to diff.digest instead gives digest_mismatch, diff_mismatch and manifest_mismatch, also exit 3.

Pinning the wrong key

Pin a key that did not sign the export (keys/other-runner.pub) and the verifier does not fall back to the key the directory claims.

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
✓ verifiedsame export, wrong runner key· numinance-spec @ 3c74d59 · 2026-11-09· 3

A wrong log pin fails the same way. Pinning the right key with the wrong origin, or the wrong log key with the right origin, gives anchor_invalid, attestation_proof and manifest_mismatch, with messages that say "the checkpoint origin is not the pinned origin" or "the checkpoint signature does not verify under the pinned log key".

Binding the result to a change or a head

A verifier cannot tell an export from another genuine export of the same change signed by the same keys. If you know the change or the head from another source, say so.

Fail unless this is the change and head you expected
numi evidence verify \
  --runner-key keys/runner.pub --log-key keys/log.pub --log-origin numinance.test/log \
  --expect-change chg_01010101010101010101010101010101 \
  --expect-head sha256:8dec4e3d5cf4bd7650ef6010671298793c05493cb20c3dd946bc3ef805689e48 \
  exports/closed-author
result: verified

FAIL expectation_mismatch: the change is not the one expected
result: failed
✓ verifiedthe command above, and the same with another change id· numinance-spec @ 3c74d59 · 2026-11-09· 0 for the command above, 3 with --expect-change chg_02020202020202020202020202020202

--expect-head accepts either the last record's hash or the anchored hash. They differ on a sealed chain, where the last record is the seal and the log holds the terminal record before it.

Calling the package from Go

numi evidence verify is a thin wrapper over evidenceverify.Verify. A Go program can call it directly. The package is in the module github.com/numinance/numinance-spec and has no dependency outside the standard library. No tagged release of the module is published, so until one is, point a replace at your checkout.

main.go
package main

import (
	"context"
	"crypto/ed25519"
	"fmt"
	"os"
	"time"

	"github.com/numinance/numinance-spec/sdk/go/evidenceverify"
)

func main() {
	dir, runnerFile, logFile := os.Args[1], os.Args[2], os.Args[3]

	runnerKey, err := evidenceverify.ReadKeyFile(runnerFile)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(2)
	}
	logKey, err := evidenceverify.ReadKeyFile(logFile)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(2)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	rep, err := evidenceverify.Verify(ctx, dir, evidenceverify.Options{
		RunnerKeys: []ed25519.PublicKey{runnerKey}, // pinned out of band
		LogKey:     logKey,                         // pinned out of band
		LogOrigin:  "numinance.test/log",
	})
	if err != nil {
		fmt.Fprintln(os.Stderr, "no report:", err)
		os.Exit(evidenceverify.ExitCode(nil, err))
	}

	fullPass := rep.ExitCode() == 0 && rep.LevelEnum == "verified" && len(rep.WeakenedByFlags) == 0
	fmt.Printf("level=%q level_enum=%q weakened_by_flags=%v findings=%v full_pass=%v\n",
		rep.Level, rep.LevelEnum, rep.WeakenedByFlags, rep.FindingCodes(), fullPass)
	os.Exit(rep.ExitCode())
}
go.mod
module example.com/check

go 1.27

require github.com/numinance/numinance-spec v0.0.0

replace github.com/numinance/numinance-spec => /path/to/numinance-spec

This program was built with the Go toolchain against the spec checkout through exactly such a replace, with the module cache offline. It passed go vet and ran three times:

$ ./check exports/closed-author keys/runner.pub keys/log.pub
level="verified" level_enum="verified" weakened_by_flags=[] findings=[] full_pass=true

$ ./check exports/closed-author keys/other-runner.pub keys/log.pub
level="failed" level_enum="failed" weakened_by_flags=[] findings=[part_contradiction signer_untrusted] full_pass=false

$ ./check exports/nope keys/runner.pub keys/log.pub
no report: the directory does not exist
✓ verifiedgo build, then ./check with the closed-author export (three runs)· numinance-spec @ 3c74d59 · 2026-11-09· 0, 3 and 4 respectively

The exported surface you need is small. Options carries RunnerKeys, TrustBundleKeys, SkipSignatures, LogKey, LogOrigin, AllowUnanchored, AllowIncomplete, AllowUnsigned, Complete, ExpectChange and ExpectHead. Verify returns a *Report whose fields mirror the JSON keys above (LevelEnum, WeakenedByFlags, Findings, Absent). When there is no report, Verify returns an *Error whose Kind (KindUsage, KindInvalid, KindNotFound, KindUnavailable, KindTimeout, KindInternal) maps to exit codes 2, 3, 4, 7, 8 and 1 through ExitCode(rep, err). ParseKey and ReadKeyFile read key files the way the command does. Read the exact definitions in the package before you depend on a field.

What the verifier checks, in order

  1. Read the directory and refuse what cannot be read.
  2. Name check, then the index against every file.
  3. Decode the summary.
  4. Walk the chain: decode, links, signatures, bounds; then compare the summary and diff.digest with what the records give.
  5. Anchor: decode signers.json, verify the head proof under the pinned log.
  6. Derived parts: the three views, tool-events.ndjson, change.json.
  7. Attestation: signature, re-derivation, per-change checkpoint and proof.
  8. Ticket cross-check, then the manifest.
  9. Parts present or declared absent, then the level.

Steps that need a verified chain do not run when the chain did not verify, and are reported as not checked. The directory checks, the index, the presence of the attestation and the manifest, and the parts always run, so one failure can come with others. The set of finding codes is normative; their order in a report is not.

Troubleshooting

ProblemCause and fix
Exit 2 with "choose exactly one key mode"You gave no key mode, or more than one. Pin a runner key
Exit 2 with "--log-key and --log-origin go together"Give both or neither
Exit 5, level unanchoredNo log pinned. Pin the log, or pass --allow-unanchored and accept a verified, unanchored result that you must handle explicitly
Exit 5, level incompleteThe chain has no terminal record, or the attestation of a Closed change was not made
Exit 5, level unsignedThe change ended Abandoned or RolledBack and has no signed manifest
Exit 3, anchor_missingA log is pinned and the directory holds no head proof
Exit 0 but PARTIAL in the result lineThe export declares absent parts. Read absent, or use --complete

On this page