Sanning Proof
Offline verification for the Sanning evidence plane. If someone handed you an evidence pack and asked you to check it, this package is the whole answer: no account, no API key, no network call, and no relationship with Sanning. Sanning is never in the trust path.
Two kernels, one contract. This page covers the Python one.
- Python:
sanning-proofon PyPI. A library, with no command of its own. - TypeScript:
@sanning/proofon npm, browser and Node ≥ 20. Ships theproofcommand.
If instead you are producing evidence, the write SDK is
sanning-anchor and the hosted
plane is console.sanning.io. For what the
evidence plane is and how anchoring, reading and verifying fit together, see
the Sanning documentation.
Install
pip install sanning-proof
Check a pack you were handed
A pack is one zip holding bundle.json beside a logs/ directory. Unzip it
and check it from your terminal:
sanning-proof verify pack/bundle.json --logs pack/logs
--logs reads a regular file named <event_id>.json as that record's
disclosed bytes, and nothing else in the folder: a link is never followed, a
subdirectory is never entered, and a file of any other name (<event_id>.txt
included) is not a disclosure. Each of those is named on stderr, so no file is
left out without a line saying so. Both kernels read a folder by this one rule
(evidence-bundle §5.1.4).
Exit codes are the contract, so a script can read them, and every verb shares
one table: 0 verified, or the export was written, 1 failed, 2 usage
or malformed, 3 undetermined (partial, pending, incomplete or
not_found), 4 mixed. 3 is deliberately not 1: undetermined means a
check could not be run, and reporting that as failed would say the evidence is
bad when it is not. Since 0.13.0 partial exits 3 whatever caused it; it exited
0 unless a gateway was unreachable.
Several things leave a run undetermined. An unreachable gateway leaves the
on-chain half unanswered, which is the network being down rather than the
evidence being bad. A stamp no witness has published is partial. And a record
nobody hashed leaves the PACK incomplete: the headline is
the verdict on the pack, the signed bundle plus whatever disclosed bytes
travelled beside it, which is the same verdict the verify page prints. A pack
can be incomplete while the bundle it carries is impeccable, so both verdicts
are printed whenever they differ, along with every reason the pack is not
verified.
Add --gateways <url,url> to check the on-chain half as well; omit
them for a fully offline verification, which needs no account, no API key and
no network. Only arweave witnesses are re-fetched. A witness of any other
kind is not checked, and with gateways passed the run answers exit 3.
The same command exists in the TypeScript kernel as
npx @sanning/proof verify. A cross-kernel leg runs both over the same
bundles and compares their exit codes, including the --logs lanes, so a
verdict cannot drift between them unnoticed. Their printed output is not
identical: the TypeScript command reports more per-event detail.
Or from Python, if you would rather have the result as an object than as an exit code:
import json
import pathlib
from sanning_proof import verify_evidence_bundle
pack = pathlib.Path("pack")
bundle = json.loads((pack / "bundle.json").read_text())
content = {p.stem: p.read_bytes() for p in (pack / "logs").glob("*.json")}
result = verify_evidence_bundle(bundle, content=content)
print(result.status) # "verified"
print(sum(1 for e in result.events if e.ok), "verified")
content= binds each disclosed raw log to the commitment inside its own signed
record. Omit it and every event's content check is undetermined rather than
failed: absence of a disclosure is not evidence of tampering.
Nothing leaves your machine. The kernel does no I/O of its own.
Check the files that came with a pack
A pack proves what its records signed. To ask whether it vouches for the files
you were handed beside it, such as a screenshot, a report or an export, name
them after --files. Each file is hashed with SHA-256 and looked up among what
the pack's signed records commit to. A folder stands for every regular file
inside it, so one argument covers a whole download on any operating system,
the Windows command prompt and PowerShell included (they do not expand a
wildcard such as evidence/*, and this command does not either):
sanning-proof verify pack/bundle.json --logs pack/logs --files evidence/
The report lists the files the pack vouches for, the files that match no record and the commitments no file was offered for. A match is by hash alone: file and folder names are never compared, and the verdict and the exit code never move.
- A folder is read recursively, and hidden files count. Its files are listed as
<folder>/<path inside>in UTF-16 code unit order of that path, so both kernels print one list in one order. Files and folders can be mixed in one command. - A symbolic link inside a folder is never followed. It is listed as skipped, with the reason, as is anything that is not a regular file (a pipe, a socket) and a file or folder the command cannot read. Nothing is dropped without being named, and a skipped file is not a verdict about it.
- Two files with one hash are both listed, each under its own name. An empty
file is an ordinary file with the empty digest. A file is read in
pieces and hashed, so it is never held in memory. A file you name yourself is
read at any size; the files found inside folders share 1 GiB per run, the cap
the
.zipreader uses, and one that would push past it is listed as skipped (too_large) and not read, so a sparse file of a terabyte cannot hold the command.casebounds its other reads of files on disk as well; the README section oncaselists them. - A folder nested more than 32 levels deep, one holding more than 50,000 files
and folders, or one with a name that is not valid UTF-8, exits 2, as
casedoes. A path that is not a readable file or folder exits 2.
Read several packs together: a case reading
When a case spans more than one pack (two agents, two organizations, one hand-off between them), check the packs together:
sanning-proof case audit-file.zip # a whole download, one command
sanning-proof case assessor-pack/ challenger-pack/
sanning-proof case case/ --json # the full reading, for a script
Each argument is a folder, or a case file named as a .zip. A folder is one
pack (bundle.json plus logs/), or a folder that holds several packs, such as
an unzipped folder of packs. A .zip is read as the folder
unzip <name>.zip -d <name> would make, so the archive and its unpacked folder
give one reading, with no unzip step and on any operating system. A .zip
inside a folder is a file like any other: it is hashed, never unzipped.
Packs are found by the verify page's splitting rule, so the page and this
command apply the same rule to the same unpacked tree: every bundle.json is a
pack, a file belongs to the deepest pack whose bundle.json or logs/ it sits
in, and every other file is listed as outside every pack and is never read as part
of a pack. Several folders are read as
one folder holding them, each under its own name. Each bundle.json is read
with verify's own reader, so the two commands classify one bundle the same
way.
The files outside every pack are checked too, with no flag. A download
usually holds more than packs: a screenshot, a report, an export. case hashes
each file that sits outside every pack, with SHA-256 over its exact bytes, and
looks the digest up among what every pack's signed records commit to, the same
check verify --files makes. The report lists each as vouched for, naming the
record and its pack, or as matching no record:
sanning-proof case audit-file.zip
This is information beside the case reading and never inside it: the verdict,
the exit code and the summary are the same whether a file matches or not, and a
changed file reads as no match, nothing more. A match is by hash alone, so no
name or folder is compared. Files after --files are offered as well. The first
line of the section counts the files; each file that cannot be hashed, such as a
link, a pipe, a zip entry the reader refused (with the reader's own words) or a
file that cannot be read, is listed as skipped with the reason, so every file
outside a pack is matched, unmatched or skipped. Two files with one hash are
both listed; an empty file is an ordinary file; a file is read in pieces. The
files share 1 GiB in all, the cap the .zip reader uses: one that would push
past it is listed as skipped (too_large) and not read, so a sparse file of a
terabyte in a download cannot hold case at that step. Every other read case makes of a file on disk is bounded too, so a sparse file of a terabyte can neither stall case nor exhaust its memory. The index comparison hashes every file under packs/ and report/ in pieces, within its own 1 GiB, the files the index lists first, and a listed file it has no room left for is reported as case_index_file_too_large, never as a match and never as absent. A pack's own files (bundle.json, logs/), contents.json and witness-cache.json are read whole within one 1 GiB total across the run: a pack's own file with no room left exits 2 at once, naming the file (the words say the run's total is used up, not that the file is itself over 1 GiB), a contents.json with no room left reads as an unreadable index (case_index_unreadable), and a witness-cache.json with no room left reads as no cache, with a note line saying so. A .zip larger than 1.25 GiB on disk exits 2 before it is read. A file outside every pack, contents.json and witness-cache.json included, therefore never keeps case from its verdict, and one a pack owns, or an archive that large, is refused promptly instead. Without
--files the report lists each file read and then one line, N recorded commitment(s) matched no loose file and no log in their own pack, without a
line for each record; --files and --json list every commitment no file
matched, those the count leaves out included. The section is printed
when a file sits outside every pack or --files is given, so a case with
neither prints what it printed before. The files member of --json follows
the same rule.
The walk is bounded and follows no link:
- A symbolic link is never followed. It is listed as outside every pack and not
read, even under a pack's
logs/, and a link named on the command line exits 2. - A folder nested more than 32 levels deep, more than 50,000 files and folders in all, or a name that is not valid UTF-8 exits 2. A bad name is refused rather than escaped, because two different names would then read as one.
- A
bundle.jsonnested more than 512 levels deep exits 2, fromcaseand fromverifyalike.
Known differences from the verify page: the page opens a dropped .zip; the
page drops a leading byte-order mark from bundle.json, which verify's reader
refuses; the page reads whatever the browser hands it for a link, a bad name or
a very deep tree; and the page loses a record named __proto__, which this
command keeps.
The first line is the case verdict. Then come the kernel's one-line summary and every exception the reading names, in the kernel's own words, followed by the packs, the files outside them, and the on-chain half.
| Verdict | Exit |
|---|---|
verified |
0 |
failed |
1 |
malformed |
2 (also usage, an input that cannot be read as a case, or an unexpected error in the command) |
incomplete |
3 |
partial |
3 |
mixed |
4 |
A proven failure keeps the headline: if anything in the case failed a check,
the case reads failed and exits 1, and everything else the reading found is
still listed beside it (evidence-bundle §5.5.4, v1.16). mixed means the case
has more than one outcome and none of them failed, so read the exceptions. It
has its own code because each of the others would claim something a mixed case
has not established: 0 would soften a case with an unsettled or unreadable
outcome in it, 3 would hide the outcomes that did settle, and 1 would accuse a
case in which nothing was proven wrong.
What a case reading establishes. Every pack is verified offline. Every record every pack supplied is read in one pile under the hand-off rules of envelope-spec §4.1, never partitioned by pack, issuer or organization. A hand-off record with no counterpart among the case's records is always named, and when a pack signed by the key it names is in the case, the reading says that pack holds no counterpart, unless every candidate it had was accounted for by another record (A20), which reads as no counterpart and names no pack.
What it does not establish. That the case is complete, since a party can
decline to hand over a pack. That a party which presented no pack under the
named key recorded nothing. That two organizations are independent of each
other. That any witness was re-fetched on chain: the reading makes no network
call, and the output says on-chain NOT CHECKED unless you pass
--gateways <url,url>. With gateways, each pack's witnesses are re-fetched
pack by pack and reported beside the reading, which they never change. A
witness that does not match exits 1, and an on-chain half that could not be
settled turns 0 into 3.
An unexpected error inside the command exits 2, never 1, so a defect in the command is never reported as failed evidence.
The same command exists as npx @sanning/proof case, and a cross-kernel leg
runs both over every case/ corpus vector and every tree in
ts/vectors/case-split.json, and requires identical output, identical --json
and identical exit codes.
Compose an attested export
An attested export (sanning.evidence.export/v2, specs/evidence-export.md)
carries operator attestations over a source bundle, signed with your own
Ed25519 key:
sanning-proof export source.json --attestations attestations.json --key exporter.key --out export.json
sanning-proof verify export.json
export exits 0 when it wrote the export, 1 when the recomputed export is
failed (it still writes it, so you can inspect it), and 2 on a bad flag or
malformed input. The TypeScript kernel ships the same command, and
cross-kernel/run_export_cli.sh requires the two to write the same bytes.
What a verdict claims
A verdict is recomputed from the artifact, never read off it. A bundle whose own asserted verdict disagrees with the recomputed one is reported as the recomputed one.
A verified verdict is offline, and it does not mean the chain was
consulted. A bundle names the witness that holds its bytes; asking that
witness whether it does is a separate, online act.
What a verifier can prove, and what it must not claim, is specified in
specs/evidence-bundle.md §5.3.4, which ships inside this package, under
"The standard". It draws four boundaries, covering
completeness, the chain, identity and per-agent slices. Read it before you
write down what a pack proved. A verifier that overclaims is worth less than no
verifier. The plain reading is at
what a verdict means.
The rest of the family
To check one signed envelope on its own, rather than a whole bundle:
from sanning_proof import verify_envelope
result = verify_envelope(envelope)
result.ok # spec_version accepted + payload binding + Ed25519 signature
Three arguments cover the rest:
verify_envelope(envelope, expected_content_hash=...)binds an artifact you already hold to the provenance an envelope commits to, and reports which role it matched.verify_envelope(envelope, payload_bytes=...)checks an external-commitment envelope against the committed bytes.verify_agent_proof(bundle)proves a leaf event was in a signed checkpoint.
The TypeScript kernel has parity, including the RFC 9162 Merkle primitives.
Sign an envelope
Most producers never call this kernel to sign: the write SDK, the agent daemon
and the MLflow plugin all sign through it, and reaching for
sanning-anchor is the shorter
path. For a custom producer:
from sanning_proof import jcs, sha256_hex, sign_envelope, signing_key_from_seed_hex
key = signing_key_from_seed_hex(SEED_HEX)
envelope = sign_envelope({
"spec_version": "sanning.agent/v1",
"event_id": EVENT_ID,
"signed_at": "2026-01-01T00:00:00Z",
"payload": payload,
"payload_hash": sha256_hex(jcs(payload)),
}, key)
Replace SEED_HEX with a 32-byte hex Ed25519 seed and EVENT_ID with the
event's UUID. sign_envelope takes the envelope minus signature and returns
it with public_key and signature filled in, per the spec.
The standard
The specifications ship inside this package, beside the kernel that implements them, so the contract and the implementation claiming to satisfy it arrive together and can be read against each other:
pip download --no-deps --no-binary :all: sanning-proof
tar xzf sanning_proof-*.tar.gz && ls sanning_proof-*/specs/
| Specification | Covers |
|---|---|
envelope-spec.md |
The producer-neutral Verifiable Event Envelope family contract |
evidence-bundle.md |
The sanning.evidence/v1 report wrapper, its body types, and the verdict boundaries in §5.3.4 |
evidence-export.md |
The sanning.evidence.export/v2 wire format |
log-store.md |
The sanning.logstore/v1 store a pack's logs/ is materialized from |
architecture.md |
The kernel, producer, connector and transport factoring standard |
governance.md |
Who decides, and how |
Three envelope profiles are registered against the family contract:
sanning.agent/v1, the agent daemon's inline-payload profile;
sanning.mlflow/v1, the MLflow plugin's external-commitment profile; and
sanning.events/v2, the anchor SDK's minimal-disclosure profile (sanning.events/v1
until the producer-to-agent cut, and no longer accepted). This Python kernel
accepts all three. Additive minors are accepted within a major, while unknown
majors and malformed versions fail closed.
Conformance discipline: both kernels reproduce one pinned corpus byte for byte, across JCS-canonical bytes, payload hashes, envelope-for-signature bytes, deterministic signatures, Merkle roots and audit paths. Neither passes by agreeing with the other. If this package disagrees with a vector, the package is wrong and the vector is not.
Kernel scope
Deliberately small: canonicalization (RFC 8785), SHA-256 hashing, Ed25519 sign and verify, RFC 9162 binary Merkle inclusion proofs, and the profile registry. No I/O, no networking, no key lifecycle. Gateway fetching, attestation polling and key storage belong to the products that import it.
Three dependencies, and adding to them is a design decision rather than a
convenience: PyNaCl and
jcs for the kernel primitives, and
cryptography for RSA-PSS
attestation verification.
A malformed envelope returns a failed result rather than raising. A verifier that crashes on a hostile artifact has handed that artifact a way to avoid being checked, so both kernels treat adversarial input as something to report on, not something to fall over on.
Security
Report vulnerabilities to security@sanning.io. We support responsible disclosure and ask for a reasonable opportunity to investigate before public disclosure.
License
MIT. The verifier is deliberately open-licensed so anyone can audit it and verify evidence independently of Sanning.
Metadata
Release files for sanning-proof 0.17.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| sanning_proof-0.17.0.tar.gz | 2.4 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sanning_proof-0.17.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 2.7 MB
Release files / sanning_proof-0.17.0.tar.gz
| Download URL | sanning_proof-0.17.0.tar.gz |
|---|---|
| Size | 2.4 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5d3e673c356f1736339ad6bcf433317cd423c361a58e523b634c9f3bc3217786
|
|
BLAKE2b-256 checksum How to use checksums |
03a927bb0c43342d382b460dab2bbc115d27ae308dce19d29051d8e14a5de444
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 11, 2026.
Transparency logRelease files / sanning_proof-0.17.0-py3-none-any.whl
| Download URL | sanning_proof-0.17.0-py3-none-any.whl |
|---|---|
| Size | 262.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3cc61ad81fc7e54cabf47377fe33d5934afaef8fed70ae9aa1af663622294f93
|
|
BLAKE2b-256 checksum How to use checksums |
e7234fc19b672a27e3ec2c90e73032299d792cbae6dd13ee648d105579756cf5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 11, 2026.
Transparency log