Metadata-Version: 2.4
Name: ziffer
Version: 0.2.0
Summary: ZIFFER Python client: propose an action, wait for the decision, verify the receipt before you act
Author: code75 SASU
License: Proprietary
Project-URL: Homepage, https://ziffer.io
Project-URL: Documentation, https://ziffer.io/docs/developers/sdk
Project-URL: Support, https://ziffer.io/docs/support
Keywords: ziffer,agent,ai-agent,guardrail,approval,receipt
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: THIRD-PARTY-NOTICES
Requires-Dist: cryptography>=42
Requires-Dist: dilithium-py>=1.0
Dynamic: license-file

# ziffer

The ZIFFER client for Python. Propose an action, wait for the decision, and verify the signed
receipt in your own process before you act.

## Install

```bash
pip install ziffer
```

Python 3.10 or later.

## Quickstart

```python
import json, os
from ziffer import Client, TrustAnchor, verify

client = Client(base_url=os.environ["ZIFFER_API_URL"], api_key=os.environ["ZIFFER_API_KEY"])
anchor = TrustAnchor.from_file(
    os.environ["ZIFFER_TRUST_ANCHOR"], min_suite=os.environ["ZIFFER_SUITE_FLOOR"]
)

decision = client.wait(client.propose(proposal).decision_id, timeout=30.0)
if decision.outcome != "ALLOW":
    raise PermissionError(f"ziffer refused: {decision.refusal_category}")

verify(decision.receipt, json.dumps(proposal).encode(), anchor)
bank.transfer(amount, to_account)   # your line, unchanged
```

You pass the proposal twice on purpose. The verifier hashes the bytes you hand it and compares
them with the receipt's claim. The check is against your copy, not against ours. Key order and
spacing do not matter.

`verify` checks the answer. Your own `if` is what stops the action.

An action that needs human approval answers `ATTEST` and no receipt. Keep polling `client.wait`
for the same decision id until `decision.receipt` is present, then verify it.

## Checking the approvals yourself

Hand `verify` your own attester registry and it checks who approved, not just that we said so:

```python
from ziffer import AttesterRegistry

registry = AttesterRegistry.from_file(os.environ["ZIFFER_ATTESTER_REGISTRY"])

result = verify(decision.receipt, json.dumps(proposal).encode(), anchor, registry=registry)
if result.approvals:
    print("approved by", ", ".join(result.approvals))
```

`ZIFFER_ATTESTER_REGISTRY` is `policy/attesters/registry.json` in your own policy repository —
the file that says who may approve and how many of them it takes. Every approval travelling with
the receipt is checked against the keys in it. We hold none of those private keys, so a breach of
ZIFFER cannot produce an approval your verifier will accept.

Two things to know before you rely on it. `result.approvals` is who approved, and it is empty for
an action that needed nobody — we cannot tell you from the receipt alone whether an action
*should* have needed approval, so if a call must never run without two people, check
`len(result.approvals)` yourself. And if you leave `registry` out, a receipt that carries
approvals is refused (`AB-1`) rather than passed unchecked; a receipt that carries none comes
back with `result.quorum_verified` `False`, which says the approvals were not checked.

## Configuration

| Variable | What it is | Where the value comes from |
| --- | --- | --- |
| `ZIFFER_API_KEY` | Your API key. It carries your tenant, so no request names a tenant. | We issue it. It expires after 90 days unless you ask for another lifetime. |
| `ZIFFER_TRUST_ANCHOR` | Path to the public key file your receipts are signed under. | We give you the file. Take it from us, never from the API you are checking. |
| `ZIFFER_SUITE_FLOOR` | The weakest signature suite you will accept. | You choose it. There is no default. |
| `ZIFFER_ATTESTER_REGISTRY` | Path to `policy/attesters/registry.json` in your policy repository. Optional; without it a receipt that carries approvals is refused (`AB-1`), and one that carries none reports `quorum_verified` `False`. | Your own repository. Never take it from us. |
| `ZIFFER_API_URL` | The base URL of the ZIFFER deployment you call. | We give it to you with your key. |

`client.whoami()` returns an `Identity`: the tenant your key is bound to and the date it expires, so you can check which key your environment holds before the first refusal.

## When a request is refused

Every refusal is a raised `RefusedError` whose `name` names the rule that fired; the table of
every clause, what it means and what to do is at https://ziffer.io/docs/refusals. Catch
`RefusedError`, record the name, and do not retry it. An `ApiError` named `GatewayUnreachable`
is a different thing: you never got an answer at all.

## When a call does not get through

Some failures are worth another try and some are not, so the client decides for you.

**Tried again:** no answer at all — the connection was refused, DNS failed, or the round trip ran
out of time — and the answers 429, 502, 503 and 504.

**Not tried again:** 500, and every 4xx except 429. A 400 or a 403 is about your request, and
sending the same request twice changes nothing about it. A 500 is deliberately treated differently
from a 502 or a 503: we say 502 or 503 when it is *us* that is unavailable or shedding load, which
is an invitation to come back, so a 500 is a fault nobody classified and repeating it just repeats
it.

When an answer asks us to wait a set number of seconds, the client waits exactly that and no
longer. (A `Retry-After` we cannot read as a whole number of seconds — a date, a fraction — is
treated as if it were not there.) Otherwise the client waits a random time up to half a second,
then up to one, two, four. Random on purpose: clients that all back off by the same amount all
come back at the same moment. At most five attempts, and every attempt sends the same bytes, so a
retry can never reach us as a second, differently-spelled request.

One client will also only do so much of this. It has ten waits of its own to spend, and a
successful call puts one back. When they are gone, a failure comes straight back to you instead of
being retried — a bad afternoon stays a bad afternoon instead of becoming a flood.

### Putting a limit on the whole call

```python
client.propose(proposal, deadline=5.0)   # seconds from now
```

Before each wait the client checks whether it would still be waiting after your deadline. If it
would, it stops there and raises `DeadlineExceeded`, which carries the name and the status of the
last thing that went wrong — so you can tell a busy gateway from an unreachable one. Without a
deadline there is no such check, and the five attempts are the limit.

`client.wait(...)` polls, and each poll is retried the same way, inside the time you gave `wait`.
No poll will sleep past it.

### Seeing what it did

```python
client.retry_stats
# {'retries_directed': 2, 'retries_computed': 1, 'retry_bucket_level': 9}
```

`retries_directed` counts the waits we asked for; `retries_computed` counts the ones your client
chose on its own. They are separate numbers because they tell different stories — the first is
ZIFFER asking for room, the second is your client waiting out a silence. `retry_bucket_level` is
how much of the allowance above is left.

## Documentation

- Quickstart: https://ziffer.io/docs/quickstart
- Integrating the SDK: https://ziffer.io/docs/developers/sdk
- Sandbox tenants: https://ziffer.io/docs/developers/sandbox
- Every refusal: https://ziffer.io/docs/refusals
- Policy by example: https://ziffer.io/docs/policy/by-example
- Glossary: https://ziffer.io/docs/glossary

## Support

Write to hello@ziffer.io. Your API key, your trust anchor file and your suite floor come from us.
So does an answer about a refusal you cannot explain.

## License

Proprietary. Copyright (c) 2026 code75 SASU, Paris, France. ZIFFER is a registered trademark of
code75 SASU. This package is not open source. Its use is governed by your agreement with code75
and by `LICENSE` beside this file. The open-source components it redistributes are listed in
`THIRD-PARTY-NOTICES`, under their own licences.
