sanning-anchor
Produce verifiable evidence from your Python agents. Hash locally, anchor a signed commitment, verify offline — with no Sanning account needed to check it.
pip install sanning-anchor
Anchor an event
from nacl.signing import SigningKey
from sanning_anchor import Anchorer
anchorer = Anchorer(
api_key="sanning_...",
subject={"type": "producer", "producer_id": "my-agent"},
signing_key=SigningKey.generate(),
environment="production",
)
result = anchorer.anchor(
content=b'{"step": "tool_call", "tool": "search"}',
event_type="myapp.tool_call",
)
result.record_bytes # ← RETAIN THESE
Enrolment is automatic, and the plane requires it
The example above 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, attributed to its
own key. One org API key covers the whole fleet — no per-agent credential,
no enrol script.
It is not optional politeness. The control plane refuses an envelope signed
by a key your org has not enrolled (SIGNING_KEY_NOT_ENROLLED, HTTP 403), so
without this the first anchor of every new agent would fail. Your API key needs
the producer:enroll scope alongside anchor:write.
Three properties worth knowing, each of them 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 us not to send an envelope pre-image. Sanning is never in the trust path, including here.
- A different key for an existing
producer_idis a rotation, and it needs the retiring key's counter-signature (previous_signing_key=). Never inferred from a refusal: that is what stops a leaked org key silently taking over an identity that is already producing evidence. - Verification never depends on the roster. This is the plane's door
policy, not the trust path — every signed record still 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="sanning_...",
producer_id="my-agent",
signing_key=key,
)
Identical behaviour to the TypeScript SDK, pinned by a shared conformance
oracle that calls neither of them
(tools/enrolment-conformance/).
What leaves your process, and what does not
Your content never does. It is hashed locally; only the hash goes into the signed envelope. The control plane is content-blind by construction, not by policy — it never receives the bytes.
record_bytes is your retention obligation. It is what payload_hash
commits to. Sanning never holds it, so if you lose it you hold a commitment to
something you can no longer produce.
What you do not need
No Arweave wallet. No chain identity. No data item. Placement is Sanning's act, which is why this package has no blockchain code in it at all — and why it is a few hundred lines rather than a chain client.
LangChain
pip install "sanning-anchor[langchain]"
from sanning_anchor import AnchorCallbackHandler
with AnchorCallbackHandler(anchorer) as handler:
agent.invoke(inputs, config={"callbacks": [handler]})
for outcome in handler.results:
my_store.put(outcome.event_id, outcome.record_bytes) # ← RETAIN THESE
Every chain, model, tool and retriever step is anchored, with LangChain's
run_id/parent_run_id tree committed alongside a per-run seq and
prev_event_id. That makes the trail deletion-evident and reorder-evident:
a missing event leaves a gap in seq, a moved one breaks the chain.
Prompts, outputs and tool I/O go into the committed record, which stays with
you — the whole step, nothing curated. Pass on_event to watch what is
committed; it is void-returning and cannot change it.
Event vocabulary
Fifteen types, one per anchored callback (exported as EVENT_TYPES).
| Event | What it records |
|---|---|
langchain.chain_start |
A chain or agent step began: the chain, its inputs, tags, metadata, run type and run name. |
langchain.chain_end |
It finished: the outputs. |
langchain.chain_error |
It failed: the error message. |
langchain.chat_model_start |
A chat model was called: the serialised model, the messages, and the invocation params. |
langchain.llm_start |
A completion model was called: the serialised model and the prompts. |
langchain.llm_end |
The provider answered: the generations, token usage, and whatever llm_output carried. |
langchain.llm_error |
The provider call failed. |
langchain.tool_start |
A tool was invoked: the tool, its input, and the model-emitted tool_call_id. |
langchain.tool_end |
The tool returned: its output. |
langchain.tool_error |
The tool raised. |
langchain.retriever_start |
A retrieval began: the retriever and the query. |
langchain.retriever_end |
The documents it returned. |
langchain.artifact |
A file a tool produced: its name, size and hash, recorded the moment it is produced — one record per file, linked to the tool step. Anchoring the tool call proves the agent asked; it does not prove which file came back, and "is this the report the inspector filed?" is the second question. Emitted only when the tool's own result says the file came back — never scraped from prose, never from a constant, a configured list, or a folder we watched. The file's bytes are never stored — the bucket holds records; you keep your files where you keep your work. |
langchain.artifact_unreadable |
A file the tool named and the SDK could not hash, with the reason (not_found, outside_root, not_a_regular_file, too_large, read_failed, path_reading_disabled). A trail that says "I could not hash this" is evidence; a trail that says nothing is a lie by omission. |
langchain.agent_action |
The agent's decision to act: the tool it chose and the reasoning it stated. Without it, an agent's log shows the call but never the choice. |
langchain.agent_finish |
The agent stopped: its return values. |
Files a tool produced
No setup. A tool that writes a file gets a record per file — name, size and hash — with nothing passed and no resolver written. Three shapes are recognised in a tool's return value:
- LangChain's own
artifactfield on a tool message (response_format="content_and_artifact") — the first-class channel, and what a real agent hits. - Raw bytes returned from a tool.
- A path — the whole return value, which is what a tool that saves a file returns.
@tool
def screenshot(label: str) -> str:
"""Screenshot the dashboard and return where it was saved."""
return str(save_png(label)) # that is the entire integration
The file's bytes are never stored — not in the log store, not in the bucket,
not anywhere. The record names the file and commits its hash; you keep the file
where you keep your work. An auditor with the file in hand runs sha256sum and
compares it to metadata.artifact.sha256, with no Sanning code in that step.
A file the SDK cannot read becomes its own event — langchain.artifact_unreadable,
carrying the reason — rather than a warning nobody sees. Silence is how three
files went unrecorded on a run that reported success.
Reading is scoped to a folder, and the folder is a limit on reading, never a place anything is stored. It defaults to the process's working directory, because a tool result is model-influenced text and following a path out of one is an untrusted read:
AnchorCallbackHandler(anchorer, artifacts={
"root": "/srv/agent/workspace", # narrow it, widen it, or None to read no paths at all
"max_bytes": 50 * 1024 * 1024, # above this a file is recorded unreadable, never truncated
})
A file is recorded only when the tool result says it came back — never scraped from prose, never discovered by watching a folder, never from a constant or a configured list. A bare string is treated as a path only if a file actually exists there; an ordinary tool returning ordinary text records nothing and says nothing.
The promoted fields
The whole step goes in the record body. On top of that, a closed list is copied into named metadata. Three reasons, and each field earns its place against all three:
- Findable without reading the body. The body is yours — it can be large, and at hand-over it may be withheld. A promoted field is legible from the record alone.
- One name in every SDK.
run.seqmeans the same thing here, in@sanning/anchor-langchain,@sanning/anchor-verceland@sanning/anchor-interchange. The shape is shared; only the values are framework-native, and the two languages produce identical promoted metadata for the same inputs. - Collection reads these names.
DL-46's
standardlevel collects this list, so the fact is defined once rather than re-derived per integration.
| Field | Why it is promoted |
|---|---|
run.run_id |
Names the step itself, so its start, end and error events join without inferring anything from emission order. |
run.parent_run_id |
Its parent, so the tree is reconstructable from the records alone. |
run.root_run_id |
The invocation the whole set belongs to — how you select one run out of a store holding many. |
run.seq |
Its ordinal within that root. A hole in seq is a missing event, visible offline with no cooperation from us. |
run.prev_event_id |
The previous event's id. Delete an event and the next one's pointer dangles; reorder and the chain disagrees. |
run.tool_call_id |
The model-emitted id of the tool call, joining a tool step to the message that asked for it. |
otel.trace_id, otel.span_id |
The ids of the real OpenTelemetry span this step ran inside, so an evidence record joins the tracing you already have. Read from opentelemetry if your process has it; this package does not depend on it. |
model.id |
The model as you named it. Its absence is what prompted this list: a record of an LLM call that does not say which model is evidence of very little. |
model.id_returned |
The model the provider says answered, where it reports one. A different fact from what was asked for, which is why there are two fields and not one. |
model.provider |
The provider/integration class the call went through. |
Absent, never empty. A field we do not know does not appear — no "", no
"unknown". The two exceptions are run.parent_run_id and
run.prev_event_id, which carry an explicit None: there, null is a fact
(this step is a root; this is the chain's first event), and a reader must not
have to guess which of "absent" and "no parent" they are looking at. otel
appears only when a real span exists — a LangChain run id is a UUID the
framework invented and an OTel span id is 16 bytes from an actual span, so
promoting one as the other would assert an identity we never obtained.
The list is closed. Adding to it takes a change with a one-line justification per field; nothing joins it quietly. A field the model reported that is not on the list is still in the body — nothing is dropped, it just is not promoted.
These are names a customer authored (a model name can encode a project), so they are in scope for your own leak review exactly as the metadata PRD says. And per P-1 they record what the integration reported, never proof of what actually served the request.
There is deliberately no pre-hash edit hook. map_payload was removed under
register BC: a dropped event consumed no sequence number, so the chain
stayed gapless and nothing recorded that a step had been withheld. Withholding
belongs at hand-over, in the bundle's disclosure map, over an evidence set that
is already complete. Credential-shaped values are still scrubbed automatically.
When a step cannot be anchored
The trail never claims completeness it does not have. If an event cannot be anchored — a network blip, a bad key, a value that has no faithful JSON form — three things happen, and none of them is a log line you have to be watching for:
- the event still burns its
seqslot, so the survivors cannot close ranks over it: an auditor sees a hole inseqand a danglingprev_event_id, offline, with no cooperation from us; - its
record_bytesare still retained where the record got as far as being signed — after a failure they are the only copy, and a timeout tells you nothing about whether the plane accepted it; - leaving the
withblock raisesIncompleteTrailError, naming every gap.
A gap is reported as undelivered, never as a bad record. An anchoring failure
is ours, not yours — but it is never reported as fine either.
handler.is_complete # False if anything was dropped
handler.gaps # the outcomes that are not in the anchored trail
handler.close() # raises IncompleteTrailError; returns the outcomes
AnchorCallbackHandler(anchorer, raise_on_gap=True) # stop the agent instead
AnchorCallbackHandler(anchorer, on_event=alert) # stream every outcome
Transient failures (a 5xx, a 429, a transport error) are retried before any of that. A 4xx is not: the plane has told us the envelope is wrong, and repeating it is a slower failure, not a recovery.
Hand over a pack
When a compliance person asks for one agent's evidence over one period, the SDK assembles it — one command, and no Node installed:
export SANNING_API_KEY=sanning_... # needs the `anchor:read` scope
sanning-anchor bundle \
--agent my-agent \
--from 2026-06-01 --to 2026-06-30 \
--logs ./my-log-store \
--out acme-2026-06.zip
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).
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.
sanning-anchor bundle --help prints the flags, the exit codes, and the
offline command to check what it produced.
Verifying
Use the open kernel, which needs no account and no network:
pip install sanning-proof
from sanning_proof import verify_envelope
verify_envelope(result.envelope, payload_bytes=result.record_bytes)
Byte-identical to the TypeScript SDK
sanning-anchor and @sanning/anchor produce the same signed bytes for the
same event, and the same bundle.json, byte for byte, for the same pack.
Both are gated against the same pinned conformance corpus for the envelope, and
against tools/pack-conformance
for the hand-over pack — an oracle that imports neither SDK, so neither language
can pass by agreeing with the other. This matters more than it sounds: two SDKs
disagreeing by one byte would produce evidence that cross-verifies as tampered.
The LangChain adapters commit JSON.stringify(payload), which json.dumps is
not (1.0 → 1.0 where JavaScript gives 1; NaN and Infinity, which are
not JSON at all; integer-like keys unsorted). That serializer has its own
cross-language corpus, generated from the real JSON.stringify and asserted
from both sides.
Where it deliberately 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, when you can still fix it, rather than at verification, when the record is already sealed. Convert the value before anchoring it.
MIT licensed.
Metadata
Release files for sanning-anchor 0.3.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.3.0.tar.gz | 188.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sanning_anchor-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 302.1 kB
Release files / sanning_anchor-0.3.0.tar.gz
| Download URL | sanning_anchor-0.3.0.tar.gz |
|---|---|
| Size | 188.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9e61560a03e340846ed19e417ee7178c3eb875a4c83f189f1a5bc2a492c1799e
|
|
BLAKE2b-256 checksum How to use checksums |
8835610403eed6d34659f693f6d3cac1034655e4df36c3b8d3bbfc212c016b8d
|
| 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.3.0-py3-none-any.whl
| Download URL | sanning_anchor-0.3.0-py3-none-any.whl |
|---|---|
| Size | 114.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6764c28a01c397faa08c2b1443139f03af3eb9cdf92b375d9fdce857c0f764d1
|
|
BLAKE2b-256 checksum How to use checksums |
c7ed5d10f8fb01d596b2923a0a99abb187d7ab0cbdb7a88843aec20b7c5ce32a
|
| 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