Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

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.

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:

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)

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.

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.

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 openNaN >= 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 crashverify_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 LineItems (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_redisRedisReplayGuard (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.storageSQLiteRegistry, 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

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

{
  "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}}
    }
  }
}
from vavitien import PolicyEngine
engine = PolicyEngine.from_file("policies.json")
result = verify(proof, registry, policy_checks=[engine.check])

Proof-gated MCP tools

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.

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

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 processExternalSigner 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 survivableTicketAuthority.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

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

vavitien-0.13.0rc3.tar.gz (268.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

vavitien-0.13.0rc3-py3-none-any.whl (153.1 kB view details)

Uploaded Python 3

File details

Details for the file vavitien-0.13.0rc3.tar.gz.

File metadata

  • Download URL: vavitien-0.13.0rc3.tar.gz
  • Upload date:
  • Size: 268.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.11.15

File hashes

Hashes for vavitien-0.13.0rc3.tar.gz
Algorithm Hash digest
SHA256 cc465f46581f3f1949923e58a4322aa319cb735c126028f662c214e745e02708
MD5 eff13340fdf20b085a5fc13160ebf164
BLAKE2b-256 128fbca1bbb17a47e1b588bf35f40ca0544307b5db38aaa72ec6c506c8f82ec0

See more details on using hashes here.

File details

Details for the file vavitien-0.13.0rc3-py3-none-any.whl.

File metadata

  • Download URL: vavitien-0.13.0rc3-py3-none-any.whl
  • Upload date:
  • Size: 153.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.11.15

File hashes

Hashes for vavitien-0.13.0rc3-py3-none-any.whl
Algorithm Hash digest
SHA256 6970f51583202f0e70b6c4b4dbba1d0ac0052e897aecb582e249d5160cc66af9
MD5 2383a1f9025c168f358f584133e183ff
BLAKE2b-256 ff426834f033e1220624f777c3c61d76483cb807139b26b872fa46190d758d5a

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page