Metadata-Version: 2.5
Name: sanning-anchor
Version: 0.8.0
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.10.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 a first conversation with Sanning 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
```

Access to the console comes through a conversation with Sanning; there is no
self-service sign-up. Once your organization has a console login, create an
API key at [console.sanning.io](https://console.sanning.io). Give the key `agent: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 organization 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": "agent", "agent_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.event_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.

## Enrollment is automatic

The example writes no enrollment 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 `agent_id`. One organization key
covers the whole fleet: no per-agent credential, no enroll script. It is not
optional politeness, because the plane refuses an envelope signed by a key your
organization has not enrolled (`SIGNING_KEY_NOT_ENROLLED`, HTTP 403).

Three properties are load-bearing:

- **The challenge is shape-checked before it is signed.** Enrollment 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 an `agent_id` your organization 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 organization key silently taking over an identity that is
  already producing evidence.
- **Verification never depends on the roster.** Enrollment 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"],
    agent_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.

**`event_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

```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 `agent_id`, as committed in each event's subject. There is no `--producer` 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 `sanning.logstore/v1` store root: the local directory holding `content/` and `records/`, or `s3://bucket` or `s3://bucket/prefix`. |
| `--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.

**The window is the plane's clock, not yours.** `--from` and `--to` are matched
against each record's `witnessed_at`, which the plane sets when it accepts the
envelope. That instant can be *earlier* than the local one you captured just
before calling `anchor()`: 433 ms earlier on a measured production run. A record
at the edge of your window then falls outside it, and the pack is short by that
record while the command exits `0` and warns about nothing, which is the one
outcome a pack's reader cannot detect. Widen the window by a few seconds on each
side, and compare the pack's event count against what you anchored.

**Pass `prefix` to one of `S3ObjectStore` and the `ObjectLogStore` wrapper,
never both.** Each of them prepends it, so the same value on both writes
`ts/ts/content/...` while every reader looks under `ts/content/...`, and the
writes and the reads disagree. `bundle` refuses correctly, with `no record
object in your log store`, and that message does not name the cause.

That writes **one zip**: a signed `sanning.stamp.evidence/v2` `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).

**A stamp settles after it seals.** For the first minutes of its life an
interval stamp has not reached the public record, and a pack resting on one
cannot be verified offline by whoever receives it. The verb says so and exits
`1`, the same way it does for records that are not stamped yet, and the message
names the interval it is waiting for: run the command again in a few minutes.
Your records are anchored, retained and stamped throughout, and nothing about
them is wrong.

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.agent_id` unknowable, 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-unstamped` assembles 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 agent'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.
