Credential Leasing Guide
Beta — 0.1.0b5zabta.credential() and zabta.acredential() are context managers that lease a scoped credential from the local Broker for the duration of a block. This page is the contract: what you get, what can go wrong, and how to adopt it without introducing a race.
The invariant: yields or raises, never a placeholder
A credential() block yields a real, usable secret or it raises. It never yields None, an empty string, or a placeholder. A denial, a connectivity failure, an unregistered agent, or a vault with nothing stored for that provider are all exceptions — never a value your code could accidentally pass to a live API call.
import zabta
with zabta.credential("stripe", ["charges:create"]) as key:
... # `key` is a real, usable secret — or the block never runsException taxonomy
Four different problems can stop a lease, and they raise four different exceptions — identity, policy, approval, and connectivity are not the same failure and should not be handled as one:
| Exception | Means | Category |
|---|---|---|
| AgentNotRegisteredError | The agent's DID isn't in the Broker's identity registry | Identity |
| GovernanceError | A policy explicitly denied the request | Policy |
| EscalatedError | The action requires human approval (raised immediately by default, or after approval_timeout elapses) | Policy |
| AuthenticationError | The Broker session token is missing, unreadable, or rejected | Connectivity |
| ConnectionError | The Broker daemon isn't reachable at all | Connectivity |
| ZabtaError | The Broker granted a lease but had nothing to issue — e.g. no secret vaulted for that provider | Vault |
Identity and policy denials both arrive from the same Broker endpoint, but the SDK maps them to different exceptions using the Broker's machine-readable error code — you don't need to pattern-match a reason string:
from zabta.exceptions import (
AgentNotRegisteredError, # your agent's DID isn't registered with the Broker
GovernanceError, # policy denied the request
EscalatedError, # requires human approval (immediate, or after timeout)
AuthenticationError, # missing/invalid Broker session token
ConnectionError, # can't reach the Broker daemon at all
)
try:
with zabta.credential("stripe", ["charges:create"]) as key:
charge(key)
except AgentNotRegisteredError:
# Identity problem — the Broker doesn't know this agent yet.
# Check ZABTA_AGENT_ID and whether cloud sync has run.
...
except GovernanceError as e:
# Policy said no. e.reason and e.matched_policy_id (via the
# underlying detail) explain why.
...
except EscalatedError:
# Needs a human. Nothing was leased.
...
except ConnectionError:
# The Broker daemon isn't reachable. Is it running?
...Human approval
By default, a request that needs human approval raises EscalatedError immediately — a blocking wait with nobody watching for it is a worse default than a loud, instant failure. Pass approval_timeout to opt into a bounded poll instead:
# Default: raise immediately if the action needs human approval —
# a blocking wait with nobody watching is a bad default.
with zabta.credential("stripe", ["charges:create"]) as key:
...
# Opt in to a bounded poll instead, e.g. up to 60 seconds:
with zabta.credential("stripe", ["charges:create"], approval_timeout=60) as key:
...Adoption: use a per-call client, not a global
Most provider SDKs default to a module-global credential (stripe.api_key = key). That pattern is the one to avoid here: setting global mutable state inside a leased block is racy under any concurrency — two coroutines or threads can see (or lose) each other's key at the wrong moment. Always construct a per-call client from the leased key instead:
Do this
import stripe
import zabta
def charge_customer(customer_id: str, amount: int):
with zabta.credential("stripe", ["charges:create"]) as key:
# Per-call client — safe under concurrency. The key never
# touches shared, mutable module state.
client = stripe.StripeClient(api_key=key)
return client.charges.create(
customer=customer_id, amount=amount, currency="usd"
)Not this
import stripe
import zabta
def charge_customer(customer_id: str, amount: int):
with zabta.credential("stripe", ["charges:create"]) as key:
# DON'T DO THIS: stripe.api_key is module-global mutable state.
# Two concurrent calls race on which key is active when either
# actually fires its request — one coroutine can see (or lose)
# the other's key. Safe-looking, unsafe under any concurrency.
stripe.api_key = key
return stripe.Charge.create(
customer=customer_id, amount=amount, currency="usd"
)The async form follows the same rule:
import zabta
async def charge_customer(customer_id: str, amount: int):
async with zabta.acredential("stripe", ["charges:create"]) as key:
client = stripe.StripeClient(api_key=key)
return await client.charges.create_async(
customer=customer_id, amount=amount, currency="usd"
)Release semantics
- The lease is released in a
finally— on normal exit, an earlyreturn, or an exception raised inside the block. - Release is best-effort: a release failure is logged and never masks an exception your own code raised inside the block.
- Each
withblock is a request-then-release round trip to the Broker. Scope blocks deliberately — fine around a batch of work, wrong wrapped around each item in a hot loop.
What release does not do — revoke the secret mid-use, or protect you if a process dies holding the lease — is covered in the Enforcement Boundary page.
Related