sanning-anchor
Produce verifiable evidence from your Python agents: hash locally, anchor a signed commitment, and hand an auditor a pack they verify offline. Nothing in this package needs a wallet, a chain identity, or a Sanning account to check.
Node is not required at any point. The bundle command that assembles a
hand-over pack is a Python console script, and so is everything before it.
- The path from signup to a verified event is at docs.sanning.io.
- The wire format, the envelope profile and the pack body type are specified
in documents that ship inside
sanning-proof, beside the kernel that implements them.
Install
pip install sanning-anchor
An API key comes from console.sanning.io/signup:
email, password, organisation name, no card. Give the key producer:enroll and
anchor:write to anchor, and anchor:read as well if the same key will later
build a hand-over pack. The console pre-checks the first two. A key holding
only anchor:write anchors nothing, because the plane refuses an envelope
whose signing key the organisation has not enrolled; a key holding no
anchor:read anchors correctly and then fails at hand-over.
Anchor an event
import json
import os
from nacl.signing import SigningKey
from sanning_anchor import Anchorer, FsLogStore
anchorer = Anchorer(
# Writes to a permanent public record. Use "dev" until you mean it.
environment="production",
# Your Ed25519 identity key. Keep the seed: a different seed is a different agent.
signing_key=SigningKey(bytes.fromhex(os.environ["SANNING_SIGNING_SEED"])),
subject={"type": "producer", "producer_id": "claims-triage"},
api_key=os.environ["SANNING_API_KEY"],
log_store=FsLogStore("./logstore"),
)
result = anchorer.anchor(
event_type="claims.decision",
# Hashed in this process. These bytes are never sent to Sanning.
content=json.dumps(decision).encode(),
metadata={"reviewer": "queue-3"},
)
anchorer.close()
result.event_id # the id this process minted
result.record_bytes # retain these: the bytes payload_hash commits to
Replace the following:
SANNING_SIGNING_SEEDwith a 32-byte Ed25519 seed, 64 hex characters, that your service keeps across restarts. An agent's identity is this key, not its name.bytes.fromhexaccepts either case; the TypeScript SDK'sfromSeedHextakes lowercase only.SANNING_API_KEYwith the key from the console.claims-triagewith the identity you want the evidence attributed to.
@sanning/anchor takes the same arguments under TypeScript naming and writes
the same bytes, so a mixed fleet hands over one pack.
Enrolment is automatic
The example writes no enrolment step, and that is the whole of it: on the first
anchor(), the SDK proves possession of your signing key to the control plane,
and your agent appears on Fleet under producer_id. One organisation key
covers the whole fleet: no per-agent credential, no enrol script. It is not
optional politeness, because the plane refuses an envelope signed by a key your
organisation has not enrolled (SIGNING_KEY_NOT_ENROLLED, HTTP 403).
Three properties are load-bearing:
- The challenge is shape-checked before it is signed. Enrolment is the one moment your identity key signs bytes the server chose, and that key also seals your envelopes, so the SDK refuses anything that is not a nonce rather than trusting Sanning not to send an envelope pre-image. Sanning is never in the trust path, including here.
- A different key for a
producer_idyour organisation has enrolled is a rotation, and it needs the retiring key's counter-signature (previous_signing_key=). It is never inferred from a refusal: that is what stops a leaked organisation key silently taking over an identity that is already producing evidence. - Verification never depends on the roster. Enrolment is the plane's door
policy, not the trust path. Every signed record verifies offline against
sanning-proof, enrolled or not.
Enrolling out of band at deploy time instead? auto_register=False turns the
call off, and ensure_registered() is exported for you to drive:
from sanning_anchor import ensure_registered
ensure_registered(
api_key=os.environ["SANNING_API_KEY"],
producer_id="claims-triage",
signing_key=key,
)
Behaviour is identical to the TypeScript SDK, pinned by a shared conformance oracle that calls neither of them.
What never leaves your process
Your content does not. It is hashed locally and only the hash goes into the signed envelope. The control plane is content-blind by construction rather than by policy: it never receives the bytes, so it cannot disclose them.
record_bytes is your retention obligation. It is what payload_hash
commits to. Sanning never holds it, so losing it leaves you holding a
commitment to something you can no longer produce.
No Arweave wallet, no chain identity, no data item. Placement is Sanning's act, which is why this package holds no blockchain code at all, and why it is a few hundred lines rather than a chain client.
Retain what you anchored
Pass log_store= and the SDK writes both halves an auditor needs before the
event is anchored: the content you handed it, and the canonical event record
the envelope commits to. If the store write fails, nothing is anchored. There
is deliberately no best-effort mode, because under this write path nothing else
holds those bytes.
FsLogStore is the development destination. For production, sanning_anchor.s3
supplies an S3-compatible object store behind the same seam, and the on-disk
shape is a versioned contract, specs/log-store.md in the sanning-proof
package, so an auditor holding nothing but the directory can resolve it without a
connector and without Sanning.
--logs takes a local store root or an s3:// URL. Reading a bucket needs
SANNING_S3_ENDPOINT, SANNING_S3_ACCESS_KEY_ID and
SANNING_S3_SECRET_ACCESS_KEY in the environment, and a missing one is named
individually. There is no flag for them: a credential in argv lands in shell
history. The reader credential is enough, s3:GetObject and s3:ListBucket.
Trace a LangChain agent
pip install "sanning-anchor[langchain]"
from sanning_anchor import AnchorCallbackHandler
with AnchorCallbackHandler(anchorer) as handler:
agent.invoke(inputs, config={"callbacks": [handler]})
Every chain, model, tool and retriever step is anchored, with LangChain's
run_id and parent_run_id tree committed alongside a per-run seq and
prev_event_id. A missing event leaves a hole in seq and a moved one breaks
the chain, so the trail is deletion-evident and reorder-evident.
Four things decide whether your integration is correct, so they sit here rather than in a guide:
- The
withblock raises on a gap. Leaving it out anchors what it can and walks past what it cannot, and you find out when an auditor asks. Useraise_on_gap=Trueto stop the agent instead. - The whole step is committed, prompts, outputs, tool input and output. It
stays with you.
on_eventwatches what is committed and cannot change it. - A file a tool returns gets its own record, name, size and hash, with no
setup. The file's bytes are never stored, not in the log store and not in
the bucket. A file the SDK cannot read becomes a
langchain.artifact_unreadableevent with its reason, because silence is how files go unrecorded on a run that reports success. - Transient failures are retried; a 4xx is not. The plane has said the envelope is wrong, and repeating it is a slower failure rather than a recovery.
For the event vocabulary, the promoted fields, and worked examples, see Trace a LangChain agent.
Hand over a pack
When a compliance person asks for one agent's evidence over one period, the SDK assembles it in one command, with no Node installed:
export SANNING_API_KEY=ANCHOR_READ_KEY # the key needs the `anchor:read` scope
sanning-anchor bundle \
--agent claims-triage \
--from 2026-06-01 --to 2026-06-30 \
--logs ./logstore \
--out claims-triage-2026-06.zip
Replace ANCHOR_READ_KEY with a key from the console carrying anchor:read.
There is no flag for it: a credential in argv lands in shell history, and this
is a command people paste into tickets. A key missing the scope is reported as
a scope to widen, never as an absence of evidence.
| Flag | What it names |
|---|---|
--agent |
The agent's producer_id, as committed in each record's subject. --producer is an accepted alias. |
--from |
Period start, inclusive. A date or an ISO instant. |
--to |
Period end. A YYYY-MM-DD names a whole day and is included; an ISO instant is exclusive. |
--logs |
Your local sanning.logstore/v1 store root, the directory holding content/ and records/. |
--out |
Where to write the pack. A .zip suffix is appended when absent. |
--base-url |
The control plane. Defaults to the hosted plane, or $SANNING_BASE_URL. |
--allow-unstamped |
Assemble even when records in the period are anchored but not yet stamped. Without it the verb refuses. Takes no value. |
sanning-anchor --help prints that reference, the exit codes, and the offline
command that checks what the verb produced.
That writes one zip: a signed sanning.stamp.evidence/v1 bundle.json
beside logs/<event id>.json. Proofs come from the Sanning
index; the raw bytes come from your log store and never left it. The verb
verifies the whole pack offline before it writes anything, so a pack that
would fail at the auditor is never produced (exit 1, no file).
It also refuses to assemble a short pack, in two cases, because a recipient cannot tell a short pack from a complete one:
- Records whose record object is missing from your store make
subject.producer_idunknowable, and one of them may be this agent's. - Records that are anchored but not yet stamped have no inclusion proof, so
a pack built now omits them and says so nowhere inside itself. The stamper
seals on an interval, and running again once it has is usually the whole fix.
--allow-unstampedassembles anyway, and then the summary tells you the pack is short so you can say so in the cover note.
No signing key is required or accepted. The container is sealed with a one-time assembly key, and every event inside already carries its producer's own signature. Fulfilling an evidence request is not a key-custody event.
Verify a pack
Verification lives in sanning-proof and needs no account, no key and no
network. In Python the kernel is a library and ships no console script:
import json, pathlib
from sanning_proof import verify_evidence_bundle
pack = pathlib.Path("pack")
verdict = verify_evidence_bundle(
json.loads((pack / "bundle.json").read_bytes()),
content={p.stem: p.read_bytes() for p in (pack / "logs").glob("*.json")},
)
verdict.status # "verified"
Whoever receives your pack does not need this SDK. For what a verdict proves, and the four things it does not claim, see Verify.
Parity with the TypeScript SDK
sanning-anchor and @sanning/anchor produce the same signed bytes for the
same event, and the same signed pack body, byte for byte, for the same
pack, so a mixed fleet hands over one pack. Both are gated against the same
pinned corpus for the envelope, and against an oracle that imports neither SDK
for the hand-over pack, so neither language can pass by agreeing with the
other.
The LangChain adapters commit JSON.stringify(payload), which json.dumps is
not, so this package reimplements it against a cross-language corpus generated
from the real JSON.stringify.
One case refuses rather than diverges: an integer outside ±(253−1), a set, a reference cycle. JavaScript would round the first one, and two different 64-bit trace ids can round to the same double, which is a false integrity verdict rather than a formatting difference. So it fails at anchor time, while you can still fix it, rather than at verification, when the record is already sealed. Convert the value before anchoring it.
Development
pip install -e ".[dev]"
black --check src tests
pytest -q
MIT licensed.
Metadata
Release files for sanning-anchor 0.4.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_anchor-0.4.0.tar.gz | 119.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sanning_anchor-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 249.0 kB
Release files / sanning_anchor-0.4.0.tar.gz
| Download URL | sanning_anchor-0.4.0.tar.gz |
|---|---|
| Size | 119.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
66e654bdf6d44a357a19ad92736afb6145a007a22cdfce4e3abc80236a3418e1
|
|
BLAKE2b-256 checksum How to use checksums |
511c78e6b7e6e3d9073112e43762aee3edc76f0122dfb00d4b50b19ace44db48
|
| 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 Sep 10, 2026.
Transparency logRelease files / sanning_anchor-0.4.0-py3-none-any.whl
| Download URL | sanning_anchor-0.4.0-py3-none-any.whl |
|---|---|
| Size | 129.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d4436252592b77822ac4e61056efc25ffdef39ec9997a70d6b67cd3176f504ee
|
|
BLAKE2b-256 checksum How to use checksums |
60b161cc78b7f9fcac76a03969adb70c92acaae44ab622a605e342e49d87ff9a
|
| 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 Sep 10, 2026.
Transparency log