ZABTA DOCS

Credential Leasing Guide

Beta — 0.1.0b5

zabta.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.

python
import zabta

with zabta.credential("stripe", ["charges:create"]) as key:
    ...  # `key` is a real, usable secret — or the block never runs

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

ExceptionMeansCategory
AgentNotRegisteredErrorThe agent's DID isn't in the Broker's identity registryIdentity
GovernanceErrorA policy explicitly denied the requestPolicy
EscalatedErrorThe action requires human approval (raised immediately by default, or after approval_timeout elapses)Policy
AuthenticationErrorThe Broker session token is missing, unreadable, or rejectedConnectivity
ConnectionErrorThe Broker daemon isn't reachable at allConnectivity
ZabtaErrorThe Broker granted a lease but had nothing to issue — e.g. no secret vaulted for that providerVault

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:

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

python
# 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

python
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

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

python
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 early return, 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 with block 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