Skip to main content

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_id is 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:

  1. LangChain's own artifact field on a tool message (response_format="content_and_artifact") — the first-class channel, and what a real agent hits.
  2. Raw bytes returned from a tool.
  3. 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:

  1. 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.
  2. One name in every SDK. run.seq means the same thing here, in @sanning/anchor-langchain, @sanning/anchor-vercel and @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.
  3. Collection reads these names. DL-46's standard level 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 seq slot, so the survivors cannot close ranks over it: an auditor sees a hole in seq and a dangling prev_event_id, offline, with no cooperation from us;
  • its record_bytes are 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 with block raises IncompleteTrailError, 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)

Source distribution for sanning-anchor 0.3.0
File Size Uploaded
sanning_anchor-0.3.0.tar.gz 188.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for sanning-anchor 0.3.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

0.19.1

2 release files

0.18.0

2 release files

0.16.2

2 release files

0.16.1

2 release files

0.16.0

2 release files

0.15.0

2 release files

0.8.0

2 release files

0.7.1

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.2

2 release files

This release

0.3.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page