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, andDEPLOYMENT.mdfor 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 vavitienlands at the public release; until then thevavitienname 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.v1for a proof,vavitien.ticket.v1for 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 ticketversionis signed and reject-unknown (fail closed). - RFC 8785 JCS canonical serialization for the signable bytes
(
vavitien/canonical.py, zero runtime deps), replacingjson.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 therfc8785reference 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 registeredsandbox_fnonly. Neither consumes a nonce or spends a ticket. Full diagnostics are privileged (the gateway requiresallow_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 (atomicReplayGuard.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
Mappingpayload/ticket is frozen at the Proof boundary.
Cloud metering (reporting only — payments are OFF and Cloud is FREE):
- The Cloud ledger now derives a
meterablesignal alongside the existingbillable(ALLOW) flag and theloggedevidentiary 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 byusage()— nothing is charged; turning on billing is a separate deliberate action. Full contract and the per-response-class table:cloud/docs/METERING.md, pinned incloud/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.dumpsrecurse untilRecursionError. 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 theProofboundary —prove()andProof.from_dict()raise a clean, catchablePayloadTooDeep(aValueError), so no over-deep proof can ever exist andverify()/serialization stay safe. The ASGI server already returned a clean 4xx for deep JSON bodies; this closes the in-process path too. Pinned intests/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 pinsopenai>=2.36,<2.45.openai-agents(≤0.18.0, the latest) buildsInputTokensDetailswithoutcache_write_tokens, whichopenai2.45 made a required field — so an unconstrained install pulledopenai>=2.45and broke tool invocation. The cap makes a freshpip install "vavitien[openai]"work with no manual pin; it will be relaxed onceopenai-agentssupports 2.45. A resolver/compat test (test_openai_dependency_resolution_is_compatible) fails loudly if the pin is ever dropped against an incompatibleopenai. -
Type checking is clean and enforced.
mypy vavitiennow passes with zero errors and runs as a blocking CI job; the few irreducibly-dynamic spots (dataclass**-splat from a validated dict,functools.wrapssignature override,LocalSigner-only export) carry narrow, justified per-line ignores, so a new type error fails the build.mypywas added to thedevextra. -
CI actions are SHA-pinned. Every third-party GitHub Action in
ci.ymlis 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-inRedisReplayGuard(..., warn_on_weak_persistence=True)) surfaces a Redis node whoseappendonly/appendfsyncconfig is too weak for durable replay. It is a deployment hint, not a verdict: a blockedCONFIG GET(common on managed Redis) or an unreachable server returnsdurable=Noneand it never raises. Adurable=Trueresult 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, useSQLiteReplayGuard/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 againstmax_age, ignoring the verifier's own future-skew allowance. A proof accepted up toclock_skew_secondsin 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 — raisettl_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'sretention_secondspreviously failed open —NaN >= xis False in Python, so the fail-closed>=evaluated the wrong way and accepted.verify()now returnsinvalid_max_age_seconds/invalid_clock_skew_seconds/invalid_retention_secondsfor NaN, ±Inf, negatives, booleans, and non-numeric types.RedisReplayGuardrejects a non-finite / non-positivettl_secondsat 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 buggyamountcan 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_idstarting with=/+/-/@gets OWASP's quote prefix). - A corrupted anchor sink is a failed verdict, not a crash —
verify_anchoredreturnsok=False, sink_unreadableon garbage, and anchor records are type-validated (a forgedlength < 1is 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
.headersproperty 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_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
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)raisesTicketRequiredwithout 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.pricingthat 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):
- Keep it out of the process —
ExternalSignerholds 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. - Remove the leak vectors otherwise — the key is never a CLI argument
(no shell history /
psexposure), 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. - Make any leak survivable —
TicketAuthority.rotate()plusTicketVerifier.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/andenterprise/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 withNOTICE,cloud/LICENSE, andenterprise/LICENSE.
What's still simplified (the honest remaining list)
- 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 whatverify()does. This is the one item that cannot be "finished" — it's the nature of the problem, not a gap. - 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.
- 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.)
- Anchoring is only as strong as the sink's isolation.
FileAnchorSinkwriting 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. - 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); seevavitien/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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cc465f46581f3f1949923e58a4322aa319cb735c126028f662c214e745e02708
|
|
| MD5 |
eff13340fdf20b085a5fc13160ebf164
|
|
| BLAKE2b-256 |
128fbca1bbb17a47e1b588bf35f40ca0544307b5db38aaa72ec6c506c8f82ec0
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6970f51583202f0e70b6c4b4dbba1d0ac0052e897aecb582e249d5160cc66af9
|
|
| MD5 |
2383a1f9025c168f358f584133e183ff
|
|
| BLAKE2b-256 |
ff426834f033e1220624f777c3c61d76483cb807139b26b872fa46190d758d5a
|