Metadata-Version: 2.4
Name: vavitien
Version: 0.13.0rc3
Summary: A cryptographic proof layer for AI agent actions: sign, verify, gate, and meter what agents do
Author: The vavitien authors
License-Expression: Apache-2.0
Keywords: ai-agents,cryptography,ed25519,authorization,audit,mcp,zero-trust
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Security :: Cryptography
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: cryptography>=50.0
Provides-Extra: server
Requires-Dist: fastapi>=0.110; extra == "server"
Requires-Dist: uvicorn>=0.29; extra == "server"
Provides-Extra: mcp
Requires-Dist: mcp>=1.10; extra == "mcp"
Provides-Extra: redis
Requires-Dist: redis>=5.0; extra == "redis"
Provides-Extra: postgres
Requires-Dist: psycopg[binary]>=3.1; extra == "postgres"
Provides-Extra: langchain
Requires-Dist: langchain-core>=0.3; extra == "langchain"
Provides-Extra: langgraph
Requires-Dist: langgraph>=0.2; extra == "langgraph"
Provides-Extra: openai
Requires-Dist: openai-agents>=0.1; extra == "openai"
Requires-Dist: openai<2.45,>=2.36; extra == "openai"
Provides-Extra: yaml
Requires-Dist: pyyaml>=6.0; extra == "yaml"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: httpx>=0.27; extra == "dev"
Requires-Dist: ruff>=0.4; extra == "dev"
Requires-Dist: build>=1.0; extra == "dev"
Requires-Dist: twine>=5.0; extra == "dev"
Requires-Dist: cyclonedx-bom>=4.0; extra == "dev"
Requires-Dist: pip-audit>=2.7; extra == "dev"
Requires-Dist: hypothesis>=6.0; extra == "dev"
Requires-Dist: mypy>=1.8; extra == "dev"
Requires-Dist: rfc8785==0.1.4; extra == "dev"
Dynamic: license-file

# Vavitien

> **Product sentence:** Vavitien authorizes or denies a proposed AI action and
> returns signed evidence; **your** infrastructure enforces that decision and
> retains credentials and execution. One metered Verified Action per rendered
> decision. See `docs/NARROW_PRODUCT.md`.

A cryptographic proof layer for AI agent actions: agents sign what they intend,
verifiers check signature, freshness, replay, tenant/audience binding, and
policy before an action is allowed to proceed.

```python
from vavitien import Agent, Registry, ReplayGuard, prove, verify

agent = Agent("refund-agent")
registry = Registry()
registry.register(agent)
guard = ReplayGuard()

proof = prove(agent, "issue_refund", {"amount": 200})
result = verify(proof, registry, replay_guard=guard, max_age_seconds=300)

if result.ok:
    issue_refund()
```

For consequential production actions, share one ``EnforcedProfile`` across the
verifier, gateway, and ``gate()`` so bindings cannot drift:

```python
from vavitien import EnforcedProfile, ProductionVerifier, ActionGateway, SQLiteReplayGuard

profile = EnforcedProfile(
    replay_guard=SQLiteReplayGuard("state.db"),
    max_age_seconds=300,
    expected_audience="payments-api",
    expected_tenant="tenant-1",
)
verifier = ProductionVerifier(registry, profile=profile, policy_checks=(under_limit,))
gateway = ActionGateway(registry, profile=profile)
```

`ProductionVerifier` always requires at least one policy check — omitting
policy is not a production profile. Each check may return ``(ok, reason)``
(preferred) or a bare ``bool`` (treated as ``(True, "ok")`` /
``(False, "denied")``).

`verify()` remains the low-level primitive for offline/signature-only use.
`ProductionVerifier` / `EnforcedProfile` always require freshness, replay
protection, tenant and audience binding. Use `capability_report()` to validate
deployment properties such as durable replay state before production.

> **Status:** Beta — the current release candidate is **v0.13.0rc3**, not yet
> tagged or published (install from source). The cryptographic core, storage,
> integrations, billing, and key handling are built and tested; release records
> must state the exact suite, environment, pass count, and skips rather than a
> hard-coded marketing count. It is a credible developer-preview SDK — not yet an
> externally-audited enterprise platform (see "What's still simplified",
> `RELEASING.md`, and **`DEPLOYMENT.md`** for the durability/topology
> assumptions that are load-bearing for the replay and billing guarantees).

## Quickstart (60 seconds)

```bash
git clone <this-repo> && cd vavitien
pip install -e ".[server,dev]"
```

> **Not on PyPI yet — install from source (above).** A published
> `pip install vavitien` lands at the public release; until then the
> `vavitien` name on PyPI is unclaimed, and any package by that name is not
> this project.

```python
from vavitien import (
    Agent, Registry, ReplayGuard, ActionGateway, EnforcedProfile, Mode, prove,
)

# 1. an agent identity (private key stays local, always)
agent = Agent("refund-agent")
registry = Registry(); registry.register(agent)

# 2. one shared enforcement profile — gateway and ProductionVerifier stay aligned
profile = EnforcedProfile(
    replay_guard=ReplayGuard(),
    max_age_seconds=60.0,
    expected_audience="billing-service",
    expected_tenant="tenant-1",
)
gateway = ActionGateway(registry, profile=profile)

def under_limit(p):                       # a verifier-controlled policy
    return p.payload["amount"] <= 500, "amount_exceeds_limit"

@gateway.protect("issue_refund", mode=Mode.STRICT, policy_checks=[under_limit])
def issue_refund(amount, customer_id):
    return f"refunded {amount} to {customer_id}"

# 3. the agent must produce a valid proof bound to that audience/tenant
ok  = prove(agent, "issue_refund", {"amount": 200},
            audience="billing-service", tenant_id="tenant-1")
bad = prove(agent, "issue_refund", {"amount": 9000},
            audience="billing-service", tenant_id="tenant-1")

print(gateway.invoke(ok,  200, "cust_1"))   # -> "refunded 200 to cust_1"
# STRICT denials raise ActionBlocked (use invoke_detailed / allow_silent_block
# only when you deliberately want the soft path)
```

**No valid proof → no action.** A replayed proof, a tampered payload, an
over-limit amount, or a missing billing ticket all fail closed. `Mode.STRICT`
is the only mode that ever runs the real action; the testing modes below let
you roll out safely without ever executing the production action on a failed
check.

## Install

```
pip install -e .                # core: only real dependency is `cryptography`
pip install -e ".[server]"      # + FastAPI/uvicorn server (also fastapi_deps integration)
pip install -e ".[mcp]"         # + MCP tool-call integration (Python >= 3.10)
pip install -e ".[redis]"       # + Redis multi-host storage/balances/rate-limit
pip install -e ".[postgres]"    # + Postgres (psycopg 3) multi-host storage backend
pip install -e ".[langchain]"   # + LangChain proof-gated tools
pip install -e ".[langgraph]"   # + LangGraph proof-gated graph nodes
pip install -e ".[openai]"      # + OpenAI Agents SDK proof-gated tools
pip install -e ".[dev]"         # + pytest/httpx for the test suite
```

**Important:** the package must actually be installed (`pip install -e .`)
for imports like `from vavitien import Agent` to resolve. If you'd rather
not install it, run everything with `PYTHONPATH=.` from the repo root
instead, e.g. `PYTHONPATH=. python3 examples/demo.py`. Either works —
just pick one; running `python3 examples/demo.py` with neither will fail
with `ModuleNotFoundError: No module named 'vavitien'`.

## The three modes

Only **STRICT** ever runs the real consequential action, and only when *every*
check passes. The other two are **testing** modes that never touch the production
action — so you can validate against live traffic without risk.

```python
from vavitien import Mode, gate, diagnose

# PRODUCTION — block on ANY failure; run the real action only when everything passes
gate(proof, registry, Mode.STRICT, issue_refund, [under_limit], replay_guard=guard)

# AUDIT DIAGNOSTICS — verify and show EVERY gate result; never execute, never spend a
# nonce. The full per-gate report is an information oracle, so it is PRIVILEGED: the
# gateway path requires allow_diagnostics=True, and diagnose() is a trusted-caller API.
report = diagnose(proof, registry, policy_checks=[under_limit], replay_guard=guard)
for g in report.failures():
    print(g.check, g.detail)     # e.g. signature / expiry / tenant / replay / policy[0]

# AUDIT SIMULATION — run a sandbox/mock action only, never the production one. The
# sandbox handler is REQUIRED (registration fails closed without it) and never falls
# back to the real action.
gateway.register("issue_refund", issue_refund, mode=Mode.AUDIT_SIMULATION,
                 sandbox_fn=mock_refund)
```

Roll out gradually: **AUDIT_DIAGNOSTICS** to see what would fail without executing
anything, **AUDIT_SIMULATION** to exercise a mock end-to-end, then **STRICT** once
you're ready for the verifier to actually block. **STRICT** records every
decision on the audit log when one is configured; diagnostics and simulation
intentionally do **not** write production audit rows (they touch no production
stores). Only STRICT can run the real action, and it never runs past any failed
check. The removed legacy `Mode.AUDIT`/`Mode.ALERT` now raise a clear migration
error rather than failing silently. Pinned invariants live in `tests/test_modes.py`.

**Isolation boundary:** the SDK does not mutate its own production stores (replay
guard, ticket balances, audit log, anchors) during diagnostics or simulation. But a
sandbox handler is arbitrary customer code and a diagnostic policy check is an
arbitrary callback — keep both isolated from production resources (no real payment
API, no production DB writes); the SDK can't guarantee side-effect freedom for code
it doesn't control.

## What's new in v0.13.0rc2

The first release candidate for **0.13.0**. This line makes deliberate,
pre-1.0 **breaking** changes to the signed bytes and to the enforcement mode
model. A proof or ticket signed by 0.13.x does **not** verify against 0.12.x
and vice-versa — this was an explicit decision, acceptable only because no
0.12.x build was ever published.

Signed-format hardening (breaking vs 0.12.x):

- **Domain separation.** The ed25519 signable bytes are prefixed with a
  context tag — `vavitien.proof.v1` for a proof, `vavitien.ticket.v1` for a
  ticket — so a signature in one context can never be reinterpreted in the
  other.
- **Mandatory ticket → agent binding.** A ticket now carries a signed
  `subject`; `verify()` refuses a ticket whose agent doesn't match, and refuses
  an unbound ticket entirely, so a leaked ticket can't be spent by another
  agent. The ticket `version` is signed and reject-unknown (fail closed).
- **RFC 8785 JCS canonical serialization** for the signable bytes
  (`vavitien/canonical.py`, zero runtime deps), replacing `json.dumps`, so
  proofs/tickets are cross-language verifiable. The Vavitien JCS profile is
  **frozen for protocol v1** (`docs/CROSS_LANGUAGE_VERIFICATION.md`): integers
  and floats are bounded to ±(2^53−1) — larger ids must be strings — and
  NaN/Inf and lone surrogates are rejected fail closed
  (`noncanonical_content`). Cross-checked byte-for-byte against the `rfc8785`
  reference lib and an independent non-Python (JavaScript) reference.

Enforcement model (breaking vs earlier 0.13 dev):

- **STRICT is the only mode that ever runs the real consequential action**, and
  only when every check passes. The old `Mode.AUDIT` / `Mode.ALERT` — which
  executed the real action *past a failed verification* — are **removed** (they
  were a genuine footgun) and now raise a clear migration error. The two
  testing modes never touch the production action: **AUDIT_DIAGNOSTICS** returns
  a full per-gate report and executes nothing, and **AUDIT_SIMULATION** runs a
  registered `sandbox_fn` only. Neither consumes a nonce or spends a ticket.
  Full diagnostics are privileged (the gateway requires `allow_diagnostics`).

Fail-closed robustness (pinned by regression tests):

- `verify()` fails closed on any structurally-invalid Proof field
  (`malformed_proof`) via a central validator and never raises.
- `verify_proof_chain()` commits nonces all-or-nothing (atomic
  `ReplayGuard.claim_many()`): a chain that loses a concurrent race on any
  nonce burns none and stays retryable.
- A directly-constructed Proof can't retain mutable signed content — any
  `Mapping` payload/ticket is frozen at the Proof boundary.

Cloud metering (reporting only — **payments are OFF and Cloud is FREE**):

- The Cloud ledger now derives a **`meterable`** signal alongside the existing
  `billable` (ALLOW) flag and the `logged` evidentiary count. The intended
  future meter is **one unit per fresh, authenticated, completed ALLOW or DENY
  decision**; malformed/unauthenticated requests, service errors, idempotent
  retries, and replays are **not** metered. This is a reconciliation/reporting
  counter surfaced by `usage()` — **nothing is charged**; turning on billing is
  a separate deliberate action. Full contract and the per-response-class table:
  `cloud/docs/METERING.md`, pinned in `cloud/tests/test_metering_contract.py`.

## What's new in v0.12.8

Defensive hardening from an adversarial re-audit (no change to verify/billing
semantics or defaults):

- **Pathologically nested payloads/tickets fail closed, not with a crash.** A
  proof carrying a payload (or ticket) nested thousands of levels deep would
  make `copy.deepcopy` / `json.dumps` recurse until `RecursionError`. The depth
  is now bounded (`_MAX_JSON_DEPTH = 64`, far beyond any real payload) and
  checked **iteratively** (so the check itself can't recurse) at the `Proof`
  boundary — `prove()` and `Proof.from_dict()` raise a clean, catchable
  `PayloadTooDeep` (a `ValueError`), so no over-deep proof can ever exist and
  `verify()`/serialization stay safe. The ASGI server already returned a clean
  4xx for deep JSON bodies; this closes the in-process path too. Pinned in
  `tests/test_hardening.py`. Found by the v0.12.8 crash-test campaign, whose
  other ~200k adversarial cases (signature tampering, replay/ticket races,
  webhook forgery, chain/audit tamper, money-math) surfaced no other issues.

## What's new in v0.12.7

Follow-up hardening and tooling (no change to the verify/billing security path):

- **`vavitien[openai]` resolves to a working combo out of the box.** The extra
  now pins `openai>=2.36,<2.45`. `openai-agents` (≤0.18.0, the latest) builds
  `InputTokensDetails` without `cache_write_tokens`, which `openai` 2.45 made a
  required field — so an unconstrained install pulled `openai>=2.45` and broke
  tool invocation. The cap makes a fresh `pip install "vavitien[openai]"` work
  with **no manual pin**; it will be relaxed once `openai-agents` supports 2.45.
  A resolver/compat test
  (`test_openai_dependency_resolution_is_compatible`) fails loudly if the pin
  is ever dropped against an incompatible `openai`.

- **Type checking is clean and enforced.** `mypy vavitien` now passes with
  zero errors and runs as a **blocking** CI job; the few irreducibly-dynamic
  spots (dataclass `**`-splat from a validated dict, `functools.wraps`
  signature override, `LocalSigner`-only export) carry narrow, justified
  per-line ignores, so a *new* type error fails the build. `mypy` was added to
  the `dev` extra.
- **CI actions are SHA-pinned.** Every third-party GitHub Action in `ci.yml`
  is pinned to a full commit SHA (supply-chain hardening) — a prerequisite for
  any future automated publish workflow.
- **Optional Redis persistence probe (advisory, fail-open).** New
  `check_replay_durability(client)` (and an opt-in
  `RedisReplayGuard(..., warn_on_weak_persistence=True)`) surfaces a Redis node
  whose `appendonly`/`appendfsync` config is too weak for durable replay. It is
  a **deployment hint, not a verdict**: a blocked `CONFIG GET` (common on
  managed Redis) or an unreachable server returns `durable=None` and it never
  raises. A `durable=True` result means only that this single node's
  persistence config matches the recommendation — it does **not** prove
  end-to-end replay durability, failover/replication safety, or managed-topology
  correctness, all of which remain operator responsibilities (`DEPLOYMENT.md`
  §1). For a replay guarantee this repo actually tests, use
  `SQLiteReplayGuard`/`PostgresReplayGuard`.

## What's new in v0.12.6

Security fix to the TTL-backed replay-guard invariant (`RedisReplayGuard`
and any guard exposing `retention_seconds`):

- **TTL guards now require `retention_seconds > max_age_seconds +
  clock_skew_seconds`.** The earlier check only compared retention against
  `max_age`, ignoring the verifier's own future-skew allowance. A proof
  accepted up to `clock_skew_seconds` in the future stays fresh past its
  nonce's TTL expiry, so a shorter retention could silently reopen replay
  with **no clock drift at all** (e.g. `retention=60, max_age=59.999,
  clock_skew=5`). That config is now rejected fail-closed
  (`replay_window_exceeds_retention`). **This is a behavior change:
  previously accepted configurations may now fail closed** — raise
  `ttl_seconds` (see DEPLOYMENT.md "Freshness window vs. TTL").
- **Non-finite / negative / non-numeric bounds are rejected before any
  security comparison.** A NaN in `max_age_seconds`, `clock_skew_seconds`,
  or a guard's `retention_seconds` previously **failed open** — `NaN >= x`
  is False in Python, so the fail-closed `>=` evaluated the wrong way and
  accepted. `verify()` now returns `invalid_max_age_seconds` /
  `invalid_clock_skew_seconds` / `invalid_retention_seconds` for NaN, ±Inf,
  negatives, booleans, and non-numeric types. `RedisReplayGuard` rejects a
  non-finite / non-positive `ttl_seconds` at construction.

**Unchanged deployment limitations (this fix does not touch them).** Two
standing deployment responsibilities are *not* addressed by v0.12.6 and remain
the operator's to satisfy: **Redis replay durability stays conditional** on
your Redis persistence/replication configuration (an acked nonce claim can
still be lost under RDB-only / async-failover topologies — use AOF
`appendfsync=always`, or a SQLite/Postgres guard, for a hard guarantee); and
**`/verify` stays at-most-once, not exactly-once** — a response lost after the
commit is unrecoverable (lost-response idempotency) unless the deployment adds
a durable decision receipt / idempotency key. See `DEPLOYMENT.md` §1
(replay-guard durability) and §2 (lost-response idempotency).

Pinned in `tests/test_review_fixes.py` and `tests/test_redis.py`.

## What's new in v0.10.1

Two adversarial review passes over the whole codebase (webhook header fuzz,
hostile event corpora, corrupted-sink probes, race re-runs, property tests
on the pricing math). The heavily-stress-tested core came back clean both
times; 12 findings in the newer modules were fixed, every one fail-closed,
and pinned by regression tests (`tests/test_webhooks.py`,
`tests/test_hardening.py`):

- **Webhook credits are bounded on every path.** One signed event can
  credit at most `max_units_per_event` (default 1e9) — an absurd or buggy
  `amount` can no longer mint a quasi-infinite balance; negative units are
  a 400, not a 500. Applies to both the StripeAdapter path and the legacy
  raw-HMAC path.
- **The finance CSV export neutralizes formula injection** (an
  `account_id` starting with `=`/`+`/`-`/`@` gets OWASP's quote prefix).
- **A corrupted anchor sink is a failed verdict, not a crash** —
  `verify_anchored` returns `ok=False, sink_unreadable` on garbage, and
  anchor records are type-validated (a forged `length < 1` is rejected).
- **`verify_proof_chain([])` now fails closed** (`empty_chain`) instead of
  vacuously succeeding.
- **Tested malformed inputs return clean 4xx, not 500**: non-object webhook
  JSON, non-numeric amounts, non-string event types, unhashable API-key
  scopes, deeply-nested bodies — all clean denials (over the tested cases,
  not a proof over all possible inputs).
- **generic_http honors its "never raises" contract** even against a
  request whose `.headers` property itself raises.
- **Postgres table prefixes are validated as SQL identifiers** at
  construction (config, not user input — but the foot-gun is gone).

## What's new in v0.10.0

The remaining launch whiteboard, cleared — every item built AND tested
against the real dependency (the OpenAI Agents SDK, LangGraph, a real
`redis-server`, a real `postgres` spun up via `initdb`/`pg_ctl`), never
mocked. 80 new tests (236 → **316**).

| Gap | What was built |
|---|---|
| Only three framework integrations | Two more: `integrations.langgraph_tools.protect_node` (a proof-gated **LangGraph** graph node — a rejected proof routes to `proof_error` instead of crashing the run) and `integrations.openai_agents.protect_tool` (a proof-gated **OpenAI Agents SDK** `FunctionTool`). Five integrations total, each tested against the real package. |
| Rate limiting was per-process | `vavitien.ratelimit.RedisTokenBucket` — a shared token bucket (refill-check-consume as one atomic Lua script) so N server instances enforce ONE limit; injectable via `create_app(rate_limiter=…)`. Proven no-oversell under a 32-thread race. |
| Only single-host + Redis storage | `vavitien.storage_postgres` — a SQL multi-host backend on psycopg 3 (Registry/ReplayGuard/AuditLog). Replay claims are `INSERT … ON CONFLICT DO NOTHING`; audit appends take a transaction-scoped `pg_advisory_xact_lock` so the chain can't fork under 16 concurrent writers. |
| Webhook auth was a bare shared secret | `vavitien.webhooks.WebhookVerifier` implements Stripe's real `t=..,v1=..` scheme — multi-secret rotation, constant-time compare, and a replay-bounding timestamp tolerance. `StripeAdapter` maps a Stripe event to a credit, so nothing imports the `stripe` package. Wired into `/billing/webhook`. |
| Billing balances were single-host | `vavitien.billing_redis.RedisBalanceStore` shares balances + the monthly free allowance across hosts (atomic-Lua debit/credit/consume_free). 32 authorities racing a 10-unit balance → exactly 10 issue. |
| No path from metering to an invoice | `vavitien.usage.build_usage_report(store, period, plan)` prices each account's metered usage with a `PricingPlan` into invoice `LineItem`s (JSON/CSV export) — the reconcile stage between metering and your payment processor. |

## What's new in v0.6

The v0.5 "still simplified" list, closed. Every item is built AND tested
against the real dependency (Redis, FastAPI, LangChain), not mocked.

| v0.5 limitation | What was built |
|---|---|
| Single-host storage only | `vavitien.storage_redis` — `RedisReplayGuard` (claim = `SET NX`), `RedisRegistry` (WATCH/MULTI mutations), `RedisAuditLog` (Lua-atomic append). Multi-host safe; tested against a real `redis-server` (32-thread claim race → exactly one winner; 60 concurrent audit writers → chain never forks). |
| Audit log tamper-detectable but not tamper-evident | `vavitien.anchoring` — periodic `{length, head_hash}` checkpoints to an append-only sink outside the log. Catches the full self-consistent chain rewrite that `verify_chain()` alone cannot (proven in `tests/test_anchoring.py` and the stress campaign). |
| Only one framework integration | Three at the time — since grown to five (see v0.10.0). |
| Static plaintext API keys | The ASGI server now accepts `sha256:<hex>` key entries, so a deployment stores key *hashes*, never the secrets; comparison is constant-time. |
| Nonces grow forever | `SQLiteReplayGuard.prune(older_than_seconds)` and a Redis TTL option, both documented with the safety invariant (only prune past the enforced `max_age_seconds`). |

Stress-tested before shipping: ~19,300 adversarial cases across 4 seeds
(signature bit-flips, field tampering, malformed input, chain splicing,
rotation boundaries, policy evasion) with **zero** correctness failures;
20/20 SIGKILL-mid-write trials left the SQLite DB and audit chain intact;
and a live server siege held **1000/1000** concurrent requests, elected
exactly one winner among 200 racing duplicate verifies, and returned only
4xx (never 5xx) under a 500-request malformed-payload flood.

## What's new in v0.5

Every item below was on v0.4's honest to-do list. Each was built AND
verified by running it in this environment — the acceptance criteria are
in the test suite, not just prose.

| v0.4 gap | What was built |
|---|---|
| Demo server dropped connections under load (stdlib `HTTPServer` handles one connection at a time; reproduced: 23/400 connection resets) | `vavitien.asgi` — a FastAPI app served by uvicorn. Re-ran the same burst: **400/400 succeed**; the 100-concurrent acceptance test from the original brief runs in CI (`tests/test_asgi.py`). The stdlib `vavitien.server` remains for zero-dependency demos. |
| File-backed persistence unsafe for concurrent writers | `vavitien.storage` — `SQLiteRegistry`, `SQLiteReplayGuard`, `SQLiteAuditLog`. SQLite's PRIMARY KEY on `nonce` makes replay claims atomic across threads AND processes (tested with 8 processes racing one nonce: exactly one wins). Audit writes serialize in a transaction, so the hash chain can't fork. All three share one `.db` file. |
| Replay check had a race under true concurrency | `ReplayGuard.claim()` — atomic check-and-mark used by `verify()` to commit nonces. 16 threads verifying the same proof at the same instant: exactly one succeeds (tested in `test_core.py`). |
| No auth, TLS, or rate limiting | The ASGI server takes `--api-key` (or `VAVITIEN_API_KEYS`), enforces it via `X-API-Key`; `--rate-limit N` gives a per-caller fixed-window limit; `--ssl-keyfile/--ssl-certfile` pass through to uvicorn for TLS. |
| No framework integration tested against the real package | `vavitien.integrations.mcp_tools.protect_tool()` — every MCP tool invocation must carry a Proof, verified through `ActionGateway` before the tool runs. Tested end-to-end against the real `mcp` SDK over a real client/server session (replay, policy failure, cross-tool proof spending, malformed proofs — all exercised through the actual MCP protocol, no mocks). |
| `tenant_id` carried but not enforced | `verify(..., expected_tenant=...)` — strict: a tenant-scoped verifier rejects wrong-tenant AND tenant-less proofs. |
| No policy configuration files | `vavitien.policy.PolicyEngine` — a proof's `policy_id` maps to a named, declarative JSON/YAML rule set (allowed actions/agents, tenant, resource patterns, payload bounds). Fails closed: unknown `policy_id` denies, config typos are rejected at load time, and `engine.check` composes with hand-written policy functions. |

## Running the server

```
python -m vavitien.asgi --port 8765 --allow-ephemeral        # local-only in-memory state
python -m vavitien.asgi --port 8765 --allow-ephemeral \
    --enable-demo-endpoints                                  # local signing demo only
python -m vavitien.asgi --db state.db                        # durable SQLite state
python -m vavitien.asgi --db state.db --api-key s3cret \
    --rate-limit 600 --policy-file policies.json \
    --ssl-keyfile key.pem --ssl-certfile cert.pem            # the full production shape
```

Endpoints: `GET /healthz`, `POST /register_key`, and `POST /verify`. The
server-side signing endpoints `POST /register` and `POST /prove` are disabled
by default and exist only with `--enable-demo-endpoints`; never enable them in
production. Request-size enforcement counts the actual ASGI stream, including
chunked bodies without `Content-Length`. The zero-dependency demo server remains
available via `python -m vavitien.server`.

## Using the local-signing client against a running server

```python
from vavitien import Agent
from vavitien.client import LocalSigningClient

agent = Agent("refund-agent")           # private key stays here
client = LocalSigningClient("http://localhost:8765")

client.register(agent)                   # sends only the public key
proof = client.prove(agent, "issue_refund", {"amount": 200})  # signs locally
result = client.verify(proof)            # sends only the (public) proof
```

## Declarative policies

```json
{
  "default": "deny",
  "policies": {
    "refund-policy": {
      "allowed_actions": ["issue_refund"],
      "allowed_agents": ["refund-agent"],
      "required_tenant": "tenant-1",
      "allowed_resources": ["invoice:*"],
      "payload": {"amount": {"min": 0, "max": 500}}
    }
  }
}
```

```python
from vavitien import PolicyEngine
engine = PolicyEngine.from_file("policies.json")
result = verify(proof, registry, policy_checks=[engine.check])
```

## Proof-gated MCP tools

```python
from mcp.server.fastmcp import FastMCP
from vavitien import ActionGateway, Registry, ReplayGuard
from vavitien.integrations.mcp_tools import protect_tool

server = FastMCP("billing")
gateway = ActionGateway(registry, replay_guard=ReplayGuard(),
                        max_age_seconds=60.0, expected_audience="billing")

@protect_tool(server, gateway)
def issue_refund(amount: int, customer_id: str) -> str:
    return f"refunded {amount} to {customer_id}"

# MCP clients now see `proof` as a required tool argument:
#   session.call_tool("issue_refund",
#       {"proof": proof.to_json(), "amount": 200, "customer_id": "c1"})
```

A proof minted for one tool cannot be spent on another, a fully-verified
proof cannot be replayed, and a rejected call surfaces the exact
verification reason as an MCP tool error.

## Mandatory per-action billing (optional)

`vavitien.billing` adds metered billing without touching the security
model: **signing stays free and local; certifying is the paid step.**
Before a client can produce a *usable* proof it must buy a **ticket** — a
short, authority-signed, content-blind token meaning "one paid action was
authorized." One ticket issued = one billable unit.

```python
from vavitien import Agent, Registry, ReplayGuard, TicketAuthority, prove, verify

# server side: the billing authority holds a private key + account balances
authority = TicketAuthority()
authority.credit("acme", 1000)          # a payment cleared → 1000 units
verifier = authority.verifier()         # PUBLIC key only — hand this to verifiers

# client side: buy a ticket, then sign locally as usual (key never leaves)
ticket = authority.issue("acme")        # meters one unit; raises if balance is 0
proof  = prove(agent, "issue_refund", {"amount": 200}, ticket=ticket)

# verifier side: the real, unbypassable gate
result = verify(proof, registry, ticket_verifier=verifier,
                ticket_guard=ReplayGuard(), require_ticket=True)
```

Two enforcement points, mirroring TLS:

- **Client-side (soft):** `prove(require_ticket=True)` raises `TicketRequired`
  without a ticket. Soft because it's open-source — someone can edit it out.
- **Verifier-side (hard):** `verify(require_ticket=True, ticket_verifier=…)`
  rejects any proof whose ticket is missing, forged, expired, or already
  spent — *however the proof was produced*. This is the true gate, exactly
  like a browser rejecting a certificate not signed by a trusted CA.

Guarantees (all tested in `tests/test_billing.py`): the ticket is covered
by the agent's own signature (can't be swapped after signing), a ticket
from any other authority is rejected, double-spend is caught by the same
atomic `claim()` as nonce replay (16 threads racing one ticket → exactly
one action authorized), and `issue()` can't oversell a balance under
concurrency. The authority is **content-blind** — it never sees the action
or payload, only that a funded account asked for a ticket (which is what
makes a later privacy-preserving upgrade to blind signatures possible).

Over HTTP, enable it on the server:

```
python -m vavitien.asgi --billing --require-ticket \
    --db state.db --ticket-authority-key-file authority.key --api-key secret
#   POST /issue_ticket   {"account_id":"acme"}   -> ticket (402 when out of balance)
#                        Idempotency-Key header  -> safe to retry, no double-charge
#   GET  /ticket_authority_key                   -> the authority's public key
#   POST /billing/webhook {account_id,units,event_id} -> credit balance (Stripe lands here)
#   POST /verify                                 -> now requires a valid ticket
```

### Free tier + metered pricing

> **These are billing primitives for _your_ product, not Vavitien's price
> list.** Every rate, tier and allowance below is a configurable default in
> `vavitien.pricing` that you set for the customers of whatever you build on
> this SDK. Nothing here describes what Vavitien charges. The SDK itself is
> Apache-2.0 and free; hosted Vavitien Cloud pricing is published separately.

`vavitien.pricing` makes the meter configurable: a monthly **free allowance**,
then **graduated tiers** (each bracket's rate applies only to actions inside
it — no cliffs). First N actions/account/month are free (no funded balance
needed); usage beyond N is billed by the plan.

```
python -m vavitien.asgi --billing --db state.db \
    --free-allowance 1000 --pricing volume
#   first 1,000 actions/account/month are free, then metered
#   GET /pricing/quote?units=1000000  -> itemized cost breakdown
```

```python
from vavitien import TicketAuthority, FREE_THEN_VOLUME_DISCOUNT as plan
authority = TicketAuthority(free_allowance=1_000)   # first 1k/month free
# ... on each action: authority.issue(account)  → free until 1k, then needs balance
plan.quote(1_000_000)     # {"total_cost_usd": ..., "lines": [...]}
```

Two preset shapes ship — both low-churn: `FREE_THEN_FLAT` (one published
price) and `FREE_THEN_VOLUME_DISCOUNT` (rate falls with volume, so your
biggest customers have the least reason to leave). Both default to a
**1,000 actions/month** free tier. The volume plan keeps stepping the rate
down past 10M actions/month ($0.0005 → $0.00015 → $0.00005 → a $0.00002
floor) — published brackets do the job an enterprise contract's discount
schedule exists for, with no sales cycle, and the meter is deliberately
**uncapped**: revenue scales with usage forever. **See
`PRICING.md`** for the full curve and why an *increasing* marginal rate
was evaluated and deliberately removed (it penalizes your biggest,
most-able-to-self-host customers). Volume-discount is the recommended
default.

### Identity gating (the anti-Sybil layer)

The free tier's abuse vector is account farming: N fake signups, each
staying under the monthly allowance. `vavitien.accounts` closes it the way
OpenAI/Anthropic gate free usage — all optional, off by default:

```python
from vavitien import TicketAuthority, SQLiteAccountStore
accounts = SQLiteAccountStore("accounts.db")
authority = TicketAuthority(free_allowance=1_000, account_store=accounts)

accounts.register("acct_1")                        # signup — ALL usage now requires this
accounts.verify("acct_1", billing_entity="card_fp") # identity ceremony done (card on file)
```

With an account store configured: unknown accounts can't get tickets at
all (fail closed); **unverified** accounts get no free tier but may pay
(payment is identity); the free allowance is keyed by **billing entity**
(card fingerprint / org domain), so a hundred accounts farmed onto one
card share ONE monthly allowance; `suspend()` kills issuance instantly;
and `flag_shared_entities()` is the nightly Sybil report. The server
exposes the lifecycle at `/admin/accounts[...]` (admin:write scope).
Without a store, nothing changes — self-hosters aren't gated.

### Money in (Stripe) vs. authorization out (tickets)

Payment and ticketing are two layers, not alternatives. A payment processor
(Stripe, etc.) moves money — cards, subscriptions, invoices, tax,
chargebacks — and you never rebuild that. When a payment clears, its webhook
credits a balance; the ticket authority meters and cryptographically
*enforces* that balance at the moment of action, per-action,
offline-verifiable by a third party. **Stripe fills the wallet; tickets are
the turnstile.**

```
Stripe Checkout  →  webhook  →  POST /billing/webhook  →  authority.credit(account, units)
     agent buys a ticket   →  balance drawn down, ticket signed   (metering)
     verifier checks ticket cryptographically, offline, per action   (enforcement)
```

`/billing/webhook` is idempotent on `event_id` (the processor's at-least-once
retries can't double-credit) and, when `VAVITIEN_WEBHOOK_SECRET` is set,
requires an HMAC-SHA256 of the raw body in `X-Signature` — so only your
processor can move balances. Balances persist in SQLite (`--db`), so a
restart never wipes what customers paid for.

### Protecting the authority key

The ticket-authority private key is the trust root of a billed deployment.
vavitien materially reduces the blast radius of key leakage — it does not
eliminate it — in the three ways that actually matter (`vavitien.signing`):

1. **Keep it out of the process** — `ExternalSigner` holds only the public
   key and delegates signing to a callable you wire to AWS/GCP KMS, an HSM,
   or Vault Transit. The private key never enters application memory, so
   there's nothing to steal from a compromised app. Recommended for
   production.
2. **Remove the leak vectors otherwise** — the key is never a CLI argument
   (no shell history / `ps` exposure), never logged, never in a repr, and
   refuses to be pickled. On disk it's stored **encrypted at rest**
   (`generate_encrypted_key_file`), unlocked by a passphrase read from the
   environment/secret manager — a stolen disk alone is useless.
3. **Make any leak survivable** — `TicketAuthority.rotate()` plus
   `TicketVerifier.revoke(key_id)` reject every ticket a compromised key
   ever signed, instantly, while the new key keeps the business running.

(No software can make leakage *literally* impossible — anything the process
can sign with, a full compromise of that process can too. The KMS path is
as close as it gets: the key isn't in the process at all.)

## Scoped API keys

Beyond one shared secret, the server supports **per-customer, scope-limited
keys** (`vavitien.apikeys`). A key looks like `vvt_<prefix>_<secret>`: the
`prefix` identifies it (safe to log/show), only a SHA-256 **hash** is stored,
and the secret is shown once. Each key carries a subset of four scopes,
enforced per endpoint:

| scope | grants |
|---|---|
| `ticket:issue` | `POST /issue_ticket` |
| `proof:verify` | `POST /verify`, register agents |
| `audit:read` | `GET /stats`, `GET /metrics` |
| `admin:write` | `POST /admin/api_keys`, `.../revoke` |

```
python -m vavitien.asgi --db state.db --scoped-keys \
    --create-key acme ticket:issue,proof:verify   # prints the key once, exits
```

Keys are created/revoked over HTTP (admin:write) or via the CLI, and persist
in the `--db` file (`SQLiteApiKeyStore`). Enforcement is opt-in: with no key
store configured, the server behaves exactly as before (a mid-migration
deployment can keep a legacy flat `--api-key` that acts as an admin key).
Missing scope → 403, revoked key → 401 — both tested.

## Monitoring

The server emits dependency-free counters (`vavitien.metrics`), exposed at
`GET /metrics` (Prometheus text) and `GET /stats` (JSON):

| counter | what it tells you |
|---|---|
| `proofs_verified` | total verification volume |
| `actions_allowed` / `actions_blocked` | the allow/deny split |
| `policy_failures` | blocked by a policy rule |
| `replay_attempts` | reused nonces caught |
| `expired_proofs` | stale / future-dated proofs |
| `audience_mismatch` / `tenant_mismatch` | proof aimed at the wrong place |
| `tickets_issued` / `tickets_spent` | billing volume (revenue + usage) |
| `billing_failures` | missing/forged/expired/spent ticket, or out of balance |

Alert on a sustained rise in `actions_blocked`, `replay_attempts`, or
`policy_failures` — that's an agent misbehaving or an attack in progress. All
series exist at `0` from startup, so a scraper sees them immediately. If you
already run `prometheus_client`, mirror these counters into it; nothing here
requires it.

## Open-core / licensing

The **open-source SDK** — the `vavitien/` package and its single-node reference
server — is **Apache-2.0** (see `LICENSE` and `NOTICE`): the protocol,
verification, proof schema, gateway, integrations, and the billing *primitives* —
everything a developer must be able to inspect before putting it near real AI
actions. The Apache-2.0 license covers **only** `vavitien/`.

The **hosted trust infrastructure** is the commercial product. Its source lives
in this repository (this repo is private) under `cloud/` (`vavitien_cloud`) and
`enterprise/`, each governed by its **own proprietary `LICENSE`** (all rights
reserved) — the root Apache-2.0 does **not** grant any license to those
directories. This is the managed multi-tenant authority/verifier service with
KMS-backed keys and SLAs, the hosted billing backend and dashboard, the managed
externally-anchored audit ledger, and enterprise deployment tooling. What is
genuinely *not* here is OPERATING it as a live service. Stripe-style: the public
SDK makes integration easy; the paid backend is the product.

> Note: `cloud/` and `enterprise/` must be moved to a separate private repo
> before this repository is ever made public. The exact license text is subject
> to legal review; this section is reconciled with `NOTICE`, `cloud/LICENSE`, and
> `enterprise/LICENSE`.

## What's still simplified (the honest remaining list)

1. **Self-attestation.** An agent still signs its own claim about what it
   did. Policy checks run in `verify()`, controlled by the verifier — but
   the signature itself only proves *this agent said this*, not that it's true.
   This is architectural; the mitigation is running policy checks in a
   system the agent doesn't control, which is exactly what `verify()` does.
   This is the one item that cannot be "finished" — it's the nature of the
   problem, not a gap.
2. **Rate limiting is per-process.** The ASGI limiter is an in-memory
   fixed window — honest backstop, not a distributed limiter. Put a real
   one at the proxy (or a Redis token bucket) for multi-instance
   deployments. The Redis storage backend is the natural place to add it.
3. **ActionGateway bypass is a convention, not a guarantee** — see its
   docstring. Code in the same process can still call a protected function
   directly; real bypass-resistance is an infrastructure boundary, not a
   library feature. (The MCP, FastAPI, and LangChain integrations are each
   such a boundary for calls that arrive through them.)
4. **Anchoring is only as strong as the sink's isolation.** `FileAnchorSink`
   writing to the same host as the log is a paper seatbelt; the protection
   is real only when anchors land somewhere the log's writer can't reach
   (another host, a WORM bucket, a different owner). The code can't enforce
   that placement — the threat model has to.
5. **Remaining integrations.** OpenAI Agents SDK and LangGraph nodes aren't
   shipped yet — same pattern as the three that are (`mcp_tools`,
   `langchain_tools`, `fastapi_deps`); see `vavitien/integrations/README.md`.

## Structure

```
vavitien/
  core.py             Agent, Registry, Proof (schema v1), prove(), verify()
                      (now with expected_tenant + atomic replay claims),
                      verify_proof_chain() (atomic), Mode, gate(),
                      ReplayGuard (claim()), FileBackedReplayGuard, AuditLog
  gateway.py          ActionGateway — central enforcement point;
                      invoke_detailed() reports WHY a call was blocked
  storage.py          SQLiteRegistry / SQLiteReplayGuard / SQLiteAuditLog —
                      concurrent-writer-safe persistence, one .db file;
                      SQLiteReplayGuard.prune() for nonce retention
  storage_redis.py    RedisRegistry / RedisReplayGuard / RedisAuditLog —
                      multi-host backend (SET NX, WATCH/MULTI, Lua append)
  storage_postgres.py PostgresRegistry / PostgresReplayGuard / PostgresAuditLog —
                      SQL multi-host backend (psycopg 3; ON CONFLICT claims,
                      advisory-locked audit append)
  ratelimit.py        InMemoryTokenBucket + RedisTokenBucket (atomic-Lua) —
                      distributed rate limiting; injectable into create_app
  anchoring.py        external audit-log anchoring — catches full chain
                      rewrites that verify_chain() alone cannot
  policy.py           PolicyEngine — declarative JSON/YAML policies keyed
                      by the proof's policy_id; fails closed
  billing.py          TicketAuthority / Ticket / TicketVerifier — mandatory
                      per-action billing; persistent+idempotent balances,
                      key rotation + revocation, Stripe-webhook credit hook
  billing_redis.py    RedisBalanceStore — multi-host balances/free-allowance
                      (atomic debit/credit/consume_free via Lua)
  webhooks.py         WebhookVerifier (Stripe t=..,v1=.. scheme, rotation,
                      replay tolerance) + StripeAdapter (event JSON → credit)
  usage.py            build_usage_report() — price metered usage into invoice
                      LineItems (JSON/CSV); the reconcile stage before invoicing
  signing.py          Signer abstraction: ExternalSigner (KMS/HSM — key never
                      in-process), hardened LocalSigner, encrypted-at-rest keys
  apikeys.py          per-customer, scope-limited API keys (hashed, prefixed,
                      create/revoke; InMemory + SQLite stores)
  metrics.py          dependency-free counters + Prometheus/JSON exposition
  asgi.py             production server: FastAPI + uvicorn, API-key auth
                      (plain or sha256: hashed), rate limiting, TLS, --db,
                      --billing/--require-ticket
  server.py           stdlib-only demo server (kept: zero dependencies)
  client.py           LocalSigningClient (product path) and DemoServerSigningClient
  integrations/
    generic_http.py   framework-agnostic require_proof() HTTP decorator
    mcp_tools.py      protect_tool() — proof-gated MCP tools (real mcp SDK)
    fastapi_deps.py   require_proof() — FastAPI Depends (real FastAPI)
    langchain_tools.py protect_tool() — proof-gated LangChain StructuredTools
    langgraph_tools.py protect_node() — proof-gated LangGraph graph nodes
    openai_agents.py  protect_tool() — proof-gated OpenAI Agents FunctionTools
    README.md         honest scoping of what's built vs. documented-only
examples/
  demo.py             4-agent handoff chain, atomic chain verification,
                      and ActionGateway usage, all exercised end to end
tests/
  test_core.py           38 protocol + gateway tests — runs as a real
                          standalone script (`python3 tests/test_core.py`)
  test_client_server.py  stdlib HTTP client/server tests
  test_storage.py        SQLite backends, incl. cross-process atomicity
  test_policy.py         declarative policy engine
  test_asgi.py           production server: auth, rate limit, policy wiring,
                          SQLite wiring, and the 100-concurrent stress test
  test_mcp.py            end-to-end MCP protocol tests (real SDK, no mocks)
```

## Run it

```
pip install -e ".[server,mcp,dev]"
python3 examples/demo.py                  # local, no server — core + gateway
python3 tests/test_core.py                # standalone test run, no pytest needed
python3 -m pytest tests/                  # full suite; record the exact pass/skip count
python3 -m vavitien.asgi                  # production server on :8765
python3 -m vavitien.server                # zero-dependency demo server on :8765
```
