Metadata-Version: 2.5
Name: sanning-anchor
Version: 0.3.2
Summary: Sanning write SDK for Python — produce verifiable evidence from your agents
Project-URL: Homepage, https://sanning.io
Project-URL: Documentation, https://docs.sanning.io
Author: Sanning
License: MIT
Keywords: agents,audit,evidence,provenance,verification
Requires-Python: >=3.10
Requires-Dist: sanning-proof>=0.8.0
Provides-Extra: dev
Requires-Dist: black>=24.0; extra == 'dev'
Requires-Dist: langchain-core>=0.3; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Provides-Extra: langchain
Requires-Dist: langchain-core>=0.3; extra == 'langchain'
Provides-Extra: s3
Requires-Dist: boto3>=1.34; extra == 's3'
Description-Content-Type: text/markdown

# `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](https://docs.sanning.io).
- The wire format, the envelope profile and the pack body type are specified
  in documents that ship inside
  [`sanning-proof`](https://pypi.org/project/sanning-proof/), beside the kernel
  that implements them.

## Install

```bash
pip install sanning-anchor
```

An API key comes from [console.sanning.io/signup](https://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

```python
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_SEED` with 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.fromhex` accepts either case; the TypeScript SDK's
  `fromSeedHex` takes lowercase only.
- `SANNING_API_KEY` with the key from the console.
- `claims-triage` with 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_id` your 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:

```python
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.

> **Caution:** the `bundle` command reads a **local** store root. It refuses an
> `s3://` argument rather than pretending to read one. Sync the bucket down
> before you build a pack.

## Trace a LangChain agent

```bash
pip install "sanning-anchor[langchain]"
```

```python
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 `with` block 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. Use
  `raise_on_gap=True` to stop the agent instead.
- **The whole step is committed**, prompts, outputs, tool input and output. It
  stays with you. `on_event` watches 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_unreadable` event 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](https://docs.sanning.io/guides/langchain).

## 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:

```bash
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`. |

`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: records in the period whose record object is missing
from your store make `subject.producer_id` unknowable, and under-reporting is
reported rather than assembled around.

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:

```python
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](https://docs.sanning.io/verify/what-a-verdict-means).

## 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
±(2<sup>53</sup>−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

```bash
pip install -e ".[dev]"
black --check src tests
pytest -q
```

MIT licensed.
