Developer documentation
Python SDK
Last reviewed 31 August 2026
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
| option | default | meaning |
action | function name | what the agent wants to do |
resource | None | what it wants to do it to |
include_args | False | send 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)