Developer documentation

Python SDK

Last reviewed 31 August 2026

QuickstartCLI referencePython SDKTypeScript SDKMCPIntegrationsPoliciesActionProofEvidenceCAIN TraceConformanceTroubleshootingDeveloper portalMarketplaceFree tierBenchmarksArchitectureCAIN IdentityCAIN ControlCAIN BudgetCAIN GovernanceCAIN MemorySelf-Hosted MCPGateCAIN PrivateCAIN TrajectoryCAIN Agent SecurityCAIN Drift7-Moat ArchitectureChangelog

Python SDK

pip install cain-trust

Two shapes, because agents come in two shapes. Neither requires rewriting your

agent -- you wrap the calls that do something consequential and leave the rest.

Declarative: @trust

from cain import trust

@trust(action="send_email", resource="customer_inbox")
def send_email(to, subject, body):
    ...

The decision happens before the body runs. Not authorized, not executed: it

raises NotAuthorized rather than returning a falsy value, because an

if not send_email(...) that nobody wrote is how an unauthorized action proceeds

anyway.

action defaults to the function name:

@trust
def delete_customer(customer_id): ...      # action = "delete_customer"

Works on async functions, and preserves the wrapped signature via

functools.wraps -- so LangChain and friends can still introspect it to build

tool schemas.

Options

optiondefaultmeaning
actionfunction namewhat the agent wants to do
resourceNonewhat it wants to do it to
include_argsFalsesend call arguments to the fabric as the payload
on_unauthorized"raise""raise" or "return_none"

include_args is off by default on purpose: tool arguments routinely contain

customer data, and shipping it to a decision service should be a choice someone

made deliberately. When on, values are truncated at 2000 characters.

There is no on_unauthorized="proceed". Passing it raises ValueError.

Imperative: verify

import cain

decision = cain.verify(action="delete_customer", resource=customer_id)
if decision.allowed:
    delete_customer(customer_id)
else:
    log.warning("refused: %s", decision.explain())

verify() never raises. Transport failure returns a decision with verdict

ERROR, because an exception is easier to swallow with a broad except and

continue past than a verdict you have to look at.

cain.guard(...) is the same call but raises NotAuthorized unless the verdict

is ALLOW.

The Decision

decision.verdict        # ALLOW | DENY | REQUIRE_APPROVAL | UNKNOWN | ERROR
decision.allowed        # True only for ALLOW
decision.determinate    # False for UNKNOWN and ERROR
decision.denied_by      # stages that refused AND were enforcing
decision.shadow_denied_by   # stages that refused but were NOT enforcing
decision.policy_version # the policy that produced this, captured at decision time
decision.explain()      # one-line human summary
decision.to_dict()      # JSON-safe

The one thing to get right

decision.allowed is true only for ALLOW. UNKNOWN, ERROR and

REQUIRE_APPROVAL are all "not authorized".

There is deliberately no decision.denied attribute, because if not

decision.denied` is the shape of the bug: it treats "we could not decide" as

permission. The only easy thing to write is the correct thing.

if decision.allowed:        # correct
if decision.verdict != "DENY":   # WRONG -- lets UNKNOWN and ERROR through

Strict mode

With enforcement.strict: true (the default), a server verdict of

ALLOWED_DEGRADED -- allowed while some check could not answer -- is reported to

you as UNKNOWN, so a fail-closed caller refuses. Turning strict off reports the

server's looser behaviour, and the fact that you opted out is recorded on the

decision.

Configuration in code

Rarely needed; cain.yaml is the norm.

cain.configure(endpoint="https://fabric.internal", api_key=..., strict=True)