Metadata-Version: 2.4
Name: zanii-mandate-sdk
Version: 0.2.1
Summary: Python client for Zanii Mandate verification
Author-email: Zanii AI <info@zanii.agency>
License-Expression: Apache-2.0
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: fastapi
Requires-Dist: fastapi<1,>=0.115; extra == "fastapi"
Provides-Extra: django
Requires-Dist: Django<6,>=5.2; extra == "django"
Dynamic: license-file

# Zanii Mandate Python SDK

Python 3.12+; standard-library runtime only.

```sh
python -m pip install ./packages/sdk-python
```

```python
import os
from zanii_mandate import MandateClient

client = MandateClient(
    verify_base_url=os.environ["ZANII_VERIFY_URL"],
    api_key=os.environ["ZANII_API_KEY"],
    timeout=5.0,
    cache_capacity=128,
    cache_ttl=5.0,
)
decision = client.verify({
    "mandateId": os.environ["ZANII_MANDATE_ID"],
    "request": {
        "agentDid": os.environ["ZANII_AGENT_DID"],
        "action": "supplier.read",
        "resource": "supplier:acme",
    },
}, operation_id="checkout-attempt-2026-001")
assert decision["verdict"] == "allow"
```

Use `verify_batch(items, operation_id=...)` for 1–100 items. Reuse the same caller-owned operation ID only for an identical retry. It maps to `Idempotency-Key`; replay does not consume quota twice.

Verdicts are returned values. `MandateApiError`, `MandateTransportError`, `MandateTimeoutError` and `MandateResponseError` distinguish HTTP, network, deadline and malformed/oversized-response failures. Public `keys()` and `status()` metadata use independent bounded single-flight TTL/LRU caches; verdicts are never cached. An optional tracer exposes the standard `start_as_current_span(name, attributes=...)` OpenTelemetry shape.

## Typed verdicts and reason codes (0.2.0)

Verdicts stay plain dicts, so `decision["verdict"]`, `decision["checks"]` and fields a newer server adds keep working. `zanii_mandate.types` adds `TypedDict` hints for type checkers: `Verdict` (with `conditions: list[ConditionResult]`, each `index`, `type`, `ok`, `code`, `reason`, `evidence`), `Reason`, `VerifyInput`, `VerifyRequest` (`agentDid`, `action`, `resource`, `amount: {"value", "currency"}`, `dataClass`, `jurisdiction`, `approvalId(s)`, `dataSubject`, `counterpartyDid`, `dataRegion`, `destination`, `transferSafeguard`) and `Amount`. `verify` accepts a `VerifyInput` or any dict as before. `MandateApiError` carries `status`, `code`, `detail` and the server `details`.

`zanii_mandate.codes` defines one constant per verdict reason and API error code (`codes.ACTION_DENIED == "ACTION_DENIED"`), plus the `REASON_CODES` set, equal to the shared list `tests/fixtures/reason-codes.json` that the Go, Java and Node.js SDKs also follow. Codes are open strings: never treat an unlisted code as success.

## Unknown outcomes

`is_unknown_outcome(error)` is true when the server answered `DATABASE_COMMIT_UNKNOWN` (HTTP 503: the connection was lost during COMMIT, so the write may or may not have landed) or when a timeout or transport failure happened after the request may have reached the server. A refused connection or DNS failure is false. Every `MandateTimeoutError` counts as unknown, because `urllib` cannot tell a connect timeout from a slow answer. When it is true, mark the operation unknown (`mark_operation_unknown`) or read the state back with the same operation ID, and never redo the business action. Re-sending the same `verify` with the same operation ID only replays the stored result.

```python
import json

from zanii_mandate import MandateError, is_unknown_outcome

try:
    client.commit_operation(operation_id, outcome)
except MandateError as error:
    if is_unknown_outcome(error):
        client.mark_operation_unknown(operation_id, {"payload": json.dumps({"reason": "commit outcome unknown"})})  # text, not a dict
        raise  # reconcile later; do not repeat the action
    raise
```

Example:

```sh
PYTHONPATH=packages/sdk-python/src python packages/sdk-python/examples/verify.py checkout-attempt-2026-001
```

Conformance and package commands:

```sh
python -m unittest discover -s packages/sdk-python/tests -v
python -m pip wheel --no-deps --no-build-isolation packages/sdk-python
```

## Operation-bound FastAPI and Django middleware

Install the framework extra (`zanii-mandate-sdk[fastapi]` or `zanii-mandate-sdk[django]`). Import `mandate_operation` from `zanii_mandate.fastapi_middleware` or `zanii_mandate.django_middleware` and decorate a route/view with `client` and a server-owned `operation` mapping. The asynchronous FastAPI decorator runs the synchronous SDK transport in worker threads; the Django decorator supports synchronous views.

The mapping uses the operation API field names: `mandateId`, `agentDid`, `action`, `audience`, `enforcementPoint`, `policyRevision`, `authorityEpoch`, `argumentSchema`, and either static `resource` or `resources` plus `resourceArgument`. Optional fields are `mandateVersionId`, `amount`/`amountArgument` + `currency`, and `count`. String, decimal-string, boolean, and non-negative safe-integer arguments are allowlisted. Python decimal strings are required instead of non-integer JSON floats so canonical argument bytes remain interoperable with the Node JCS profile.

Request JSON may contain only `args`, stable `expiresAt`, `approvalId`, and `approvalIds`; caller claims for action/resource/identity/policy are rejected. Supply the stable operation ID in `Idempotency-Key` or `X-Zanii-Operation-Id`. Both adapters reserve and dispatch through `MandateClient` before calling the endpoint/view. Only fresh `allow` + `dispatched` executes. Deny/review return reason-bearing 403 responses; `OPERATION_MISMATCH` stays 409; timeouts return 504; transport/verification errors never execute.

A 2xx result is committed with the reserved amount/count and an SDK-observed outcome payload. Non-2xx results and endpoint/view exceptions are marked unknown. This is usage accounting, not a signed external execution receipt. An operation replay returns the stored operation result with `Idempotency-Replayed: true`, without executing again or consuming usage; it does not reconstruct the arbitrary original endpoint/view response. Commit failure cannot undo an external effect.

Executed locally: **17/17 Python tests passed** at T7 acceptance (27 October 2026): the original 7 SDK tests over all 12 shared real-PostgreSQL/HTTP vectors plus 10 real FastAPI/Django middleware tests. Both frameworks ran actual loopback HTTP servers against the real verify-api and isolated PostgreSQL 17.11. Tests cover allow/exact accounting, replay without re-execution, argument tampering, deny/review reason propagation, verification error, timeout, and trusted mapping. Django 5.2.8 was installed into the ignored `.local/t7-python-deps` test directory; no global package was installed. A wheel built successfully. The T5-3 offline evaluator is provided by the Node package; no Python offline cryptographic verifier, package-index publication, production deployment, or exactly-once remote-effect guarantee is claimed.

## Changelog

### 0.2.1 (30 October 2026)

- Documentation fix only, no code change: the unknown-outcome example now sends the operation payload as
  text (`json.dumps({"reason": ...})`). The server refuses an object payload with `INVALID_OUTCOME`, so the 0.2.0 example
  would have failed when used.

### 0.2.0 (30 October 2026)

- `zanii_mandate.codes` constants, `REASON_CODES` and `UNKNOWN_OUTCOME_CODES`, equal to the shared list `tests/fixtures/reason-codes.json` (130 codes) used by all four SDKs.
- `is_unknown_outcome(error)`, with the Go and Java semantics.
- `TypedDict` hints (`Verdict`, `ConditionResult`, `Reason`, `VerifyInput`, `VerifyRequest`, `Amount`); verdicts remain plain dicts. No change for 0.1.0 callers.

### 0.1.0

- First release: verify and batch, operation lifecycle, key and status caches, telemetry, FastAPI and Django middleware.
