Metadata-Version: 2.5
Name: sanning-anchor
Version: 0.1.1
Summary: Sanning write SDK for Python — produce verifiable evidence from your agents
Project-URL: Homepage, https://sanning.io
Author: Sanning
License: MIT
Keywords: agents,audit,evidence,provenance,verification
Requires-Python: >=3.10
Requires-Dist: sanning-proof>=0.6.0
Provides-Extra: dev
Requires-Dist: black>=24.0; 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, verify offline — with no Sanning account needed to check it.

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

## Anchor an event

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

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

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

```python
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. Pass `map_payload` to redact before hashing; returning `None` skips an
event without leaving a gap. What you receive there is a copy, so redacting
cannot touch your agent's live state.

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

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

## Verifying

Use the open kernel, which needs no account and no network:

```bash
pip install sanning-proof
```

```python
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. Both are gated against the same pinned conformance corpus, so a
pack produced by one verifies identically under 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
±(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, when you can still fix it, rather than at verification, when
the record is already sealed. Convert the value in `map_payload`.

MIT licensed.
