Where to put the gate: decorator, toolbox, or adapter

There are three shapes for wiring policy enforcement into a Python agent. They're usually presented as an ergonomics choice, which misses the thing that actually decides it: what should happen to your program when a call is refused.

By Louis Bryson · 7 min read · Updated

The three integration points look like a progression from simple to sophisticated, and that framing sends people to the wrong one. They're not tiers. They're answers to different questions about what your program should do when the policy says no.

ShapeOn denialReach for it when
@governedRaises GovernanceErrorA human or a script calls the function directly
GovernedToolboxRaises GovernanceErrorYou dispatch many tools by name from a custom loop
Framework adapterStructured tool result; loop continuesA model is driving, and should see the refusal

The decorator: one function, hard stop

@governed runs the gate before the function body. If the decision isn't allow, the body never executes.

pythonexamples/01_decorator.py
from kitelogik import OPAClient, PolicyGate, SessionContext, governed

gate = PolicyGate(opa_client=OPAClient())
context = SessionContext(
    session_id="sess_001",
    user_role="support_agent",
    session_scopes=["read_customer", "approve_refund_under_100"],
)

@governed(gate=gate, context=context)
async def approve_refund(customer_id: str, amount: float) -> str:
    return f"Refunded ${amount:.2f} to {customer_id}"

At call time the decorator binds positional and keyword arguments into a single dict via inspect.signature, and that dict becomes input.args for the policy. This is why argument-level rules work without you doing anything: the policy sees the real values the function was called with.

Sync and async both work — the decorator inspects the function and produces a matching wrapper, with the sync path bridging to the async gate in a way that's safe inside Jupyter and FastAPI event loops.

The toolbox: many tools, dispatched by name

An agent loop doesn't call approve_refund directly — it receives the string "approve_refund" and a dict of arguments from the model, and has to look up what to run. GovernedToolbox is that lookup with the gate built in.

pythonexamples/02_governed_toolbox.py
from kitelogik import GovernedToolbox, OPAClient, PolicyGate, SessionContext

toolbox = GovernedToolbox(gate=gate, context=context)
toolbox.register("get_customer_record", get_customer_record)
toolbox.register("approve_refund", approve_refund)

result = await toolbox.call(
    "approve_refund",
    {"customer_id": "cust_001", "amount": 50.0},
)

It's framework-agnostic — it dispatches (name, args) pairs and returns sanitised results, which is exactly the shape Anthropic's tool-use protocol and most custom loops want. tool_schemas() generates Anthropic-format tool definitions by inspecting each function's signature and first docstring line, so the registration is also the schema:

python
tools = toolbox.tool_schemas()

response = await client.messages.create(
    model="claude-opus-4-7",
    max_tokens=1024,
    tools=tools,
    messages=[{"role": "user", "content": "Refund $50 to cust_001"}],
)

There's also evaluate_plan(steps), which pre-flights a whole multi-step plan before any step fires — the control that catches a plan whose safe-looking prefix runs before its destructive tail.

Adapters: let the model see the refusal

Here's the distinction that should drive the choice. Both shapes above raise GovernanceError on any non-allow decision. Inside an agent loop, that's usually wrong.

An exception ends the run. But a model that tried to refund $5,000 and was refused hasn't failed — it's received information. It can ask the user, try $200, or explain the limit. Framework adapters convert denials into structured tool results so the loop continues and the model gets to respond to the constraint.

That's why "use @governed in a framework adapter agent loop" is explicitly the wrong call, even though it works.

Handling the three failure modes

When you are catching GovernanceError, it's raised on any non-allow decision, so the useful information is on exc.decision:

python
from kitelogik import GovernanceError

try:
    await approve_refund(customer_id="cust_001", amount=5000.0)
except GovernanceError as exc:
    if exc.decision.deny:
        ...  # hard block - security-critical, model cannot override
    elif exc.decision.requires_hitl:
        ...  # pending human approval - surface it, wait for the queue
    else:
        ...  # soft deny - the agent could adjust args and retry

Collapsing these three into one error path is the most common mistake with this API, and it produces a bad experience in both directions: users get "action failed" when the real answer is "waiting for approval," and genuinely security-critical blocks get retried as though they were soft.

The context immutability catch

SessionContext is treated as immutable for the duration of a session, and adapters capture it at construction. There's no supported way to mutate a running adapter's context — the path is to rebuild:

python
context = context.model_copy(update={
    "budget_used_tokens": context.budget_used_tokens + tokens_this_call,
    "budget_used_api_calls": (context.budget_used_api_calls or 0) + 1,
})

toolbox = GovernedToolbox(gate=gate, context=context)   # rebuild
# re-register tools on the rebuilt toolbox

This catches people out with budget counters, which change on every call. If you build the toolbox once at startup and never rebuild it, your budget counters stay at their initial values forever and the ceiling never fires — a failure that is completely silent, because everything keeps working exactly as though there were no budget.

Picking, briefly

If a human or a script calls it, use the decorator and let it raise. If a model calls it through a framework you have an adapter for, use the adapter and let the model see refusals. If a model calls it through something custom, use the toolbox and decide yourself how denials surface in your loop.

The security properties are identical across all three — same gate, same policies, same audit records. This is about what your program does next, which is a design question rather than a safety one.

Frequently asked questions

What is the actual difference between @governed and GovernedToolbox?

Shape, not capability. @governed wraps one specific function and raises GovernanceError on any non-allow decision. GovernedToolbox registers many tools and dispatches them by name, which is what an agent loop needs. Both run the same gate against the same policies. Reach for the decorator when a human calls the function and the toolbox when a model does.

Why do framework adapters not raise on denial?

Because an exception ends the agent loop, and usually you want the opposite. Adapters convert a denial into a structured tool result so the model sees "that was refused" and can choose another approach — asking the user, trying a smaller amount, explaining the limit. A raised exception is the right behaviour for plain Python callers and the wrong behaviour inside a loop that should keep running.

How do I tell a hard deny from a HITL escalation?

GovernanceError is raised on any non-allow decision, so inspect exc.decision. `deny=True` is a hard block the model cannot override. `requires_hitl=True` means the action is pending human approval. Neither flag set means a soft deny, where the agent could legitimately adjust its arguments and retry. Three distinct exception messages are produced internally, so logs stay readable without unpacking the decision.

Can I change the session context after construction?

Not in place. SessionContext is treated as immutable for a session, and adapters take it at construction time — the adapter's internal context is not part of its public API. The supported path is to build a new adapter or toolbox with the updated context and re-register the tools. This matters most for budget counters, which change constantly.

Does the policy action name have to match my function name?

No. Both @governed and GovernedToolbox.register take an action override. Use it when the Python name and the policy name differ — a partial_refund function matching policies written against approve_refund, for instance. Without an override the action defaults to the function or tool name.

Louis Bryson
Founder & maintainer, Kite Logik

Engineer focused on production AI agent infrastructure and policy-as-code. Maintains Kite Logik, the open-source OPA/Rego governance layer for Python agents.

Connect on LinkedIn