Mandate enforcement¶
Your agent has an approved list of tools it is allowed to call. This guide covers how the runtime holds it to that list, and what happens when it tries something outside it.
The decision is made locally. The runtime keeps a cached copy of the agent's mandate and refreshes it in the background, so a tool call never waits on a network round trip to Coriqo, and Coriqo being slow or unreachable never stalls your agent.
Before you start¶
You need an enrolled device. Enrolment gives this host an Ed25519 key that signs every governance request, which is what ties a decision to a machine rather than to a shared secret in an environment variable:
pip install "byoai-runtime[recorder]"
byoai-recorder-enroll --coriqo-url https://coriqo.example.com --token cik_live_xxxxx
The static BYOAI_CORIQO_API_KEY credential still works for publishing records, but it
cannot enforce. If you try, you get EnforcementIdentityUnavailableError naming the
enrolment command rather than a request that fails later for an unclear reason.
Two places to enforce it¶
There are two seams, and which one you need depends on whether you own the agent's source.
Seam A — @governed_tool |
Seam B — the proxy | |
|---|---|---|
| What it needs from you | a decorator on your tool functions | BYOAI_PROXY_ENFORCEMENT=1, no code change |
| Where it stops the call | before the function body runs | before the tool_use block reaches the agent |
| What it covers | every call through that function, however it was triggered | every tool the model requests through the intercepted API |
| What it misses | a tool you did not decorate | a tool the agent calls directly in its own code |
They use the same MandateGate, the same denial latch and the same verdict
recorder, so a run is counted once no matter which seam refused it. Running both
is the intended configuration: neither is total on its own, and the gaps are not
the same gaps.
Decorating a tool¶
from byoai.recorder.governed_tool import governed_tool, set_default_gate
from byoai.recorder.mandate import mandate_gate
gate = mandate_gate("agt_7f3c") # the agent id Coriqo registered
set_default_gate(gate)
await gate.start() # begins the background refresh
@governed_tool
def wire_transfer(account: str, amount: int) -> str:
...
wire_transfer now checks the mandate before it runs. If the mandate does not cover it,
the function body never executes and the call raises MandateDeniedError.
The decorator works on sync and async functions, keeps the wrapped function's name,
signature and docstring, and takes name= when your framework prefixes tool names.
If no gate is bound, or no device is enrolled, the decorator logs one line and calls the function. You can add it to your codebase before anyone has enrolled anything.
Binding a gate per task¶
set_default_gate binds a ContextVar, so two agents running in one process do not share
a mandate. For a scoped binding, use the context manager:
with use_gate(other_agents_gate):
result = wire_transfer("ACC-1", 500)
What the gate decides¶
Two separate settings, controlled in Coriqo, answer two different questions.
mandate_enforcement answers does an out-of-scope call count as a violation:
enforce— deny itobserve— allow it, and record it as flagged
enforcement_posture answers what to do when the runtime cannot tell, which happens when
the cached snapshot has aged past its staleness budget or was never fetched at all:
fail_open— allow, and flagfail_closed— deny
Together:
| Situation | fail_open |
fail_closed |
|---|---|---|
| Snapshot fresh, tool in scope | Allow | Allow |
Snapshot fresh, out of scope, enforce |
Deny | Deny |
Snapshot fresh, out of scope, observe |
Allow + flag | Allow + flag |
| Snapshot stale past its budget | Allow + flag | Deny |
| Agent suspended | Deny | Deny |
| No snapshot ever fetched | Allow + flag | Deny |
Two rows are worth reading twice. A suspended agent is denied under both postures, because a suspension you know about outranks a staleness you are unsure about. And it is staleness that triggers the fail-closed branch, not network reachability: a failed refresh leaves a still-valid snapshot in place, so one blip does not take your agent offline. The failure is bounded by a number you configured rather than by whatever the network did.
Reading a verdict directly¶
Most code should use the decorator. When you want the decision without the raise:
verdict = gate.decide("wire_transfer")
if verdict.allowed:
...
Allow, Flag and Deny all carry reason, mandate_version_id and snapshot_age_s.
Flag subclasses Allow, so verdict.allowed and isinstance(verdict, Allow) agree: a
flagged call still runs, and only the record differs.
Enforcing at the proxy¶
The buyer often does not own the agent. It is a vendored binary, a framework whose tools are defined inside the framework, a team's own service nobody wants to fork. Seam A cannot reach any of that, and telling a bank "decorate your tool functions" is telling them to change code they do not control.
The proxy already sits at ANTHROPIC_BASE_URL and already reads tool_use
blocks out of model responses. Sitting there means it can refuse one.
export ANTHROPIC_BASE_URL=http://localhost:8787
export BYOAI_PROXY_ENFORCEMENT=1
export BYOAI_MANDATE_AGENT_ID=agt_7f3c
byoai-cache start
That is the whole integration. The agent is unmodified.
What the agent sees¶
The denied tool_use block is withheld. It does not reach the agent, so the
agent's dispatcher has nothing to execute — the enforcement does not depend on
the agent cooperating. In its place goes a synthesized tool_result:
{"type": "tool_result", "tool_use_id": "toolu_…", "is_error": true,
"content": [{"type": "text", "text": "This action is not permitted."}]}
That sentence is fixed and it is all there is. Not the tool name, not the
mandate, not "try search instead". The rule is the same one Seam A follows,
and it matters more here: at the proxy the text lands directly in the model's
next context window, with no framework in between to strip it.
If every tool call in the response was denied, stop_reason is rewritten from
tool_use to end_turn, because a message that claims to be waiting on a tool
while containing no tool call is a shape agent loops spin on.
Some SDKs will not parse a tool_result block inside an assistant message —
it is not a shape the provider emits. A strict client raises instead of
continuing, which stops the tool but as a crash rather than as a refusal. Set
BYOAI_PROXY_DENIAL_BLOCK=text and the replacement is a plain text block
carrying the same fixed sentence, which every client parses.
What a retrying loop does¶
Denial arrives as a tool failure and plenty of loops retry tool failures. Be clear about what actually happens:
- The tool does not run. Not on the first attempt and not on any retry — the block the dispatcher would have executed was never delivered.
- A loop that retries sends another turn, the model asks for the same tool again, and the second attempt is refused from the latch, without re-running the scope check.
- The third attempt halts the run. Every tool is refused after that, including ones that were in scope all along.
So a retry loop costs at most two extra round trips before the run is over. What
it does not do is stop the loop politely — the runtime has no way to reach into
an agent it did not modify and end its turn. Bounding it is what the latch is
for, and if you want the loop to exit cleanly rather than be starved, that is
Seam A: MandateRunHaltedError is a real exception in the agent's own process
and a supervisor can catch it.
Streaming¶
Nothing waits for the end of the response. Text frames go out in the same pass
they arrive; the only bytes held back are the frames of a tool_use block that
has not finished yet, plus at most one partial SSE frame. The decision lands on
content_block_stop — the earliest point the arguments are complete and the
last point before the agent could act on them.
A stream cut mid-tool_use drops the held block instead of releasing it. A
half-announced tool call the gate never got to see is exactly the thing this
seam exists to stop.
Which agent, and why it is not a header¶
Get this wrong and you either enforce one agent's mandate on another, or believe you are enforcing and enforce nothing.
Gates are registered at startup from configuration the operator controls —
BYOAI_MANDATE_AGENT_ID, or register_proxy_gate(gate) in code. A request
cannot introduce an agent identity that was not already configured. When one
proxy fronts several agents, X-BYOAI-Agent-Id picks among the registered ones;
it is a selector, not a credential. The agent is the thing being governed here,
and a header that could name any mandate would let it choose its own scope with
one line of code.
A request whose agent cannot be resolved — an unregistered id, or no id when
several gates are registered — is decided by posture. fail_closed withholds
the block; fail_open allows it and flags it. Both record a verdict with
reason: agent_unresolved and log a warning; neither silently allows. It is
never latched, because a misconfiguration is not a scope decision and should not
halt a run.
The latch bucket is the session, not the request. A model refused a tool and asking again next turn arrives as a fresh HTTP call, and a per-request bucket would make every repeat look like a first attempt forever.
What the proxy cannot see¶
Only tools the model requests through the intercepted provider API. A tool the agent's own code calls without asking the model — a helper in its control flow, an MCP client it drives itself, a subprocess — never appears in a response body and is invisible at this seam.
Traffic routed through the proxy's OpenAI-compat bridge is gated as well. That bridge translates the upstream response into Anthropic shape, so the same enforcer applies to it unchanged.
Do not read proxy coverage as total coverage. It covers the tools the model
chooses, which for a model-driven agent is most of them, and it covers them
without touching the agent's source. The rest is @governed_tool.
Denials do not come back as tool errors¶
A denied call raises MandateDeniedError, which is deliberately not shaped like a failure
worth retrying. It carries no retry_after, is not a ProviderError, and its message is
one fixed sentence that is identical whatever the reason was.
That last part matters more than it looks. If a denial reaches the model as an ordinary
tool error carrying detail, the model will read the detail, rephrase, try an adjacent tool,
and work its way around the control, because that is what a capable model does with a
failure it understands. So the model gets one opaque sentence, and everything an operator
needs sits on exc.verdict and exc.operator_detail for your logs.
Keep that separation when you handle the error. This one line undoes the design:
except MandateDeniedError as e:
return f"error: {e.verdict.detail}" # don't: hands the model the reason
The reprs of Verdict and ProposedAction redact detail and arguments for the same
reason, so an accidental f-string or a traceback does not leak them either.
Tool arguments are not captured by default¶
The verdict records which tool was attempted, not what it was called with. Turn capture on per tool when the arguments are safe to keep:
@governed_tool(capture_arguments=True)
def search(query: str, limit: int = 10) -> list[str]:
...
It is off by default because a governed tool's arguments tend to be exactly the things you would not want sitting in a record: account numbers, customer identifiers, credentials. Nothing redacts them yet.
An empty tool list is not the same as no tool list¶
On the wire, allowed_tools: null means the agent is unrestricted, and allowed_tools: []
means it may call nothing at all. Opposite meanings, and both are valid.
The runtime distinguishes them structurally: it branches on is None rather than on
truthiness, and rejects a bare string instead of iterating it into single characters. If
you write your own client against Coriqo's mandate endpoint, do the same. A falsy check
turns "permitted nothing" into "permitted everything".
Repeat attempts are latched¶
A denial stops one call. On its own that is not much of a control: an agent that ignores the message can attempt the same refused tool for as long as its loop keeps turning, and your logs fill with identical single denials that nothing ties together. The interesting fact — this agent went at a control it had already been refused, four times in ninety seconds — is the one a risk committee asks about, so the runtime keeps it.
The latch is keyed on the run, the agent asking, and the tool:
- the first denial is remembered;
- every later attempt at that tool in that run is refused from the latch, without re-running the scope check — the same tool against the same snapshot cannot answer differently, so re-evaluating would be theatre;
- at the third attempt (counting the first denial) the run is halted. Every
subsequent call in that run, including tools that were in scope all along,
raises
MandateRunHaltedError.
from byoai.errors import MandateDeniedError, MandateRunHaltedError
from byoai.recorder.denial_latch import run_scope
with run_scope(trajectory_id):
try:
result = wire_transfer("ACC-1", 500)
except MandateRunHaltedError as exc:
stop_the_run(exc.run_id, attempts=exc.attempts)
except MandateDeniedError:
try_something_else()
MandateRunHaltedError derives from MandateDeniedError, so a handler you have
already written keeps stopping the call. The distinction is for the loop above
it, not for the model: isinstance (or exc.halted) separates "this tool is
refused, try another" from "stop scheduling turns for this run".
The model's sentence does not change through any of it. First denial, fourth
repeat, halt — the same fixed sentence every time. Escalating the detail as the
agent tries harder would be handing it a hint sheet at exactly the moment it is
probing the control. The count goes to the operator instead, in the WARNING
logged on the raising path.
Set the threshold with BYOAI_MANDATE_HALT_THRESHOLD, or DenialLatch(threshold=…)
in code. The process-wide latch reads the variable at import, so set it before
importing the runtime.
What is not latched¶
Only denials about scope. A suspension is lifted, a stale snapshot refreshes, a
first snapshot arrives — all three deny under fail_closed and all three are
temporary. Remembering them would turn one refresh blip into a permanently halted
run, which is the failure the refresh path is built to avoid.
And a new mandate version clears that agent's buckets, its halt included. The latch's licence to refuse without looking is that the answer cannot have changed; a new version is exactly the case where somebody changed it. It is tracked per agent rather than per run, because a run holds a delegator and its delegated child at two different versions, and tracking it per run would have each of them wipe the other's count on every call — which would hand any agent with a sub-agent a halt it can never reach.
Which run, and which agent¶
The run is ProposedAction.trajectory_id when your tools carry one. Failing
that, run_scope() binds one for a block of calls. Failing that, the runtime
falls back to an id belonging to the MandateGate itself — one gate is built per
agent per run in every wiring this package encourages, and two gate objects are
two different mandates, so unrelated agents in one process never halt each other.
The imprecision runs the safe way round: an application that reuses one
long-lived gate across several sequential runs shares one bucket until it names
the runs, which latches too eagerly rather than too late.
Buckets carry the agent as well as the run, because a delegated sub-agent and its delegator share a run and do not share a scope. A child's denial must not latch a tool the parent may still legitimately call. The halt is the exception, and is deliberately run-wide.
The limit worth knowing¶
Latch state lives in process memory. A run that spans two processes, and a host that restarts mid-run, both start counting from zero. Nothing persists it yet, so an agent that can get itself restarted can reset its own count — treat the halt as a control against a model grinding away inside one run, not as one against an orchestrator that respawns workers.
A latch also holds a bounded number of runs (1024 by default) and drops the oldest first, so a long-lived process does not accumulate a bucket per run. A run that has been idle for that many runs and then comes back starts counting again.
Delegation narrows, it never widens¶
When agent A hands work to agent B, B has no mandate of its own for that run —
nobody approved B to act inside A's task. Its effective scope is the intersection
of B's own mandate with A's effective scope at the moment of delegation, pinned
to A's mandate_version_id.
from byoai.recorder.delegation import delegated_gate
from byoai.recorder.governed_tool import use_gate
child = delegated_gate(parent_gate, child_gate)
with use_gate(child):
await sub_agent.run(task)
This is not the same thing as a nested run. A sub-run of the same agent is still that agent, and its one mandate already scopes it; nothing attenuates. Attenuation is about two different agents, and it is one-directional on purpose. If delegation could widen, "ask another agent to do it" would be the cheapest way around a denial, and a model that has just been refused is one prompt away from trying it.
B's standing mandate is untouched. It is still B, with its own scope, the moment it runs its own work.
DelegatedGate also keeps A's gate and asks it first, so suspending A, or
narrowing A's mandate, stops B within one refresh interval. The pinned scope is a
photograph of the moment of delegation, and a photograph cannot notice a
revocation.
Two dials attenuate along with the tools. Whether a delegated breach blocks or
only flags is read off the delegator's mandate_enforcement, and any enforce
up the chain enforces — otherwise handing work to a sub-agent that happens to be
in observe rollout would be a working way around the parent's mandate. And
max_delegation_depth is the tightest limit anywhere up the chain, so a middle
agent cannot raise a bound the root set.
Two fields on the snapshot govern the hand-off. delegation_policy must be
attenuated; none — and anything absent or unrecognised, because a snapshot
that does not say delegation was approved has not approved it — refuses with
DelegationRefusedError. max_delegation_depth bounds the chain, where 0
forbids delegation outright and null leaves the policy as the only gate.
A delegation Coriqo was never told about gets the empty scope rather than a
refusal: it denies every tool under enforce, which is the same practical
outcome, but as a decision the record can explain instead of a crash in your
spawn path.
Intersecting is where the null-vs-empty rule earns its keep. allowed_tools: null
is unrestricted and contributes no restriction, so unrestricted ∩ X is X. []
permits nothing and survives every intersection, so anything ∩ [] is []. The
runtime branches on is None; if you write your own, do the same, because a
falsy check turns "permitted nothing" into "permitted everything".
Recording what the gate decided¶
Enforcement without a record is a claim. Bind a VerdictRecorder and every gate
decision — allowed, flagged and blocked alike — is appended to the local
hash-chained ledger and queued for Coriqo:
from byoai.recorder.ledger import Ledger
from byoai.recorder.verdicts import (
VerdictOutbox, VerdictRecorder, VerdictShipper, set_verdict_recorder,
)
ledger = Ledger("~/.byoai/recorder/ledger.db", device_id)
outbox = VerdictOutbox("~/.byoai/recorder/verdicts.db")
set_verdict_recorder(VerdictRecorder(ledger=ledger, outbox=outbox))
await VerdictShipper(client, outbox).drain() # batches of up to 200
Four things about it are worth stating plainly.
Allows are recorded too. The denominator is what makes the numerator mean anything: "4,120 tool calls, 9 outside the mandate" is a sentence a second-line reviewer can act on, and "9 denials" is not.
The record does not depend on Coriqo. The ledger write happens first and
shipping is downstream of it, so a denial during an outage is written down like
any other. A failed batch keeps its batch_key and is resent as the same batch,
so retrying cannot produce a second governance record; a repeat replays the
stored result instead.
Recording is not on the decide path. decide() still reads memory and
returns, with no I/O of any kind — the recorder runs at @governed_tool's
enforcement seam, after the verdict exists. Recording never raises into a tool
call either.
A repeat reads as a repeat. The first denial records out_of_scope, the
next attempt repeat_denied with attempts: 2, the one that trips the
threshold run_halted with halted: true. So the agent went at a control it
had already been refused, three times, then the run stopped is legible off the
record rather than being three rows that look the same.
If Coriqo's answer reports stale_mandate_version_count, this host's snapshot
has drifted from the version the batch was anchored on. The verdicts are still
recorded — the drift is the finding, not a reason to reject them — and the
shipper logs it so you learn from the reply rather than from the chain.
Not built yet¶
Two things you may expect are still missing, and it is better to know now than to discover them in a demo:
-
The local ledger has no destination yet. Verdicts reach Coriqo through the verdict endpoint, but the ledger underneath them — the hash-chained record the shipper posts to
/v1/ingest/batch— has no server-side implementation, so those uploads have nowhere to land. Your host keeps a complete, verifiable record; Coriqo currently sees the verdicts and not the events behind them. Practically:coriqo-verifyagainst a local export works, gap detection from the Coriqo console does not. -
Tool arguments are not part of the record.
capture_arguments=Truebinds them onto theProposedAction, but the verdict record keeps only how many were captured and never a key or a value, locally or on the wire, because nothing redacts them yet. Until it does, the record says which tool was attempted, not with what.