Metadata-Version: 2.5
Name: sanning-anchor
Version: 0.2.0
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 — 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 returned, committing its EXACT BYTES** — one event per file. Anchoring the tool call proves the agent asked; it does not prove which bytes 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 from a constant, a configured list, or a prediction. |
| `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. |

### 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](https://github.com/sanning-io/knowledge-base/blob/main/decision-log.md)'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.

```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 before anchoring it.

MIT licensed.
