The counterexample first
A planning agent decides it needs the latest pricing sheet, so it calls read_file("/srv/pricing/2026.csv"). The dispatcher's authorization check runs asynchronously — a policy service that caches decisions for 30 seconds. The file read executes before the policy verdict arrives, because the dispatch path and the authorization path are separate event streams. The verdict comes back deny 80ms later. The data has already crossed the boundary.
Nothing crashed. No exception was raised. Every log line looks healthy. And yet the one property the system exists to guarantee — no side effect executes without a preceding allow verdict for that exact call — was violated.
This is the failure shape behind a recurring theme in agent engineering right now: we keep adding tools to agents, and the boundary between "the model may ask" and "the system does" quietly becomes a race condition. This post treats that boundary as a protocol property, builds a minimal executable model, and shows how to canary it with failure injection before you scale tool count.
Assumptions
- One orchestrator process dispatches tool calls from one or more agents (sub-agents count as separate callers).
- Each tool call is a tuple
(call_id, agent_id, tool, args_hash). - A policy component returns
allow | denyper call. It may be local (cheap, stale cache) or remote (fresh, slow). - Tool execution has side effects (reads that leak data, writes that mutate state). Assume reads are also security-relevant.
- The transport between policy and executor can delay, duplicate, or reorder messages. This is the realistic part.
The invariant
INV-1 (authorize-before-effect): For every tool call
c, the eventexecute(c)may only occur in the history after the eventallow(c)whereallowwas issued against(call_id, tool, args_hash)ofc. A verdict for a different call, or for the same call with different arguments, does not satisfy the precondition.
Note what this rules out by construction:
- Async policy checks that race the executor (the counterexample above).
- "Batch authorization" where ten calls are approved by one verdict.
- Argument mutation after approval — if the agent retries with modified args, the old verdict is void.
- Cached allows surviving policy revocation.
A common implementation fails INV-1 not because the code is wrong but because the event order is wrong: dispatch and authorization were designed as parallel concerns instead of a serial dependency.
A minimal state model
+------------+ submit(c) +-----------+
| Agent |--------------->| Dispatcher|
+------------+ +-----------+
|
request_verdict(c)
v
+-----------+
| Policy |
+-----------+
|
allow(c) / deny(c)
v
+-----------+
| Executor |--- execute(c)
+-----------+ (side effect)
States per call_id: SUBMITTED -> AWAITING_VERDICT -> ALLOWED | DENIED -> EXECUTED
Illegal transitions: AWAITING_VERDICT -> EXECUTED (raced verdict)
ALLOWED -> EXECUTED with mismatched args_hash (stale verdict)
SUBMITTED -> EXECUTED (bypass)
The executor must be a verdict-gated state machine, not a queue consumer. If it can't find a matching allow in its local record, it refuses — even if the dispatcher insists.
The fixture: an executable ordering checker
Below is a self-contained Python harness. It (a) simulates the dispatcher/policy/executor pipeline with injectable failure classes, and (b) replays the emitted event log against INV-1 as a property test. No infrastructure needed.
# inv1_harness.py — labeled: reference simulation, run it yourself
import hashlib, json, random
from dataclasses import dataclass, field
@dataclass
class Event:
kind: str # submit | allow | deny | execute
call_id: str
tool: str
args_hash: str
t: int # logical clock
class Policy:
def __init__(self, deny_tools=(), latency=1):
self.deny_tools = set(deny_tools)
self.latency = latency
def verdict(self, call, now):
kind = "deny" if call["tool"] in self.deny_tools else "allow"
return Event(kind, call["id"], call["tool"], call["args_hash"], now + self.latency)
class Executor:
def __init__(self, gated: bool):
self.gated = gated
self.verdicts = {}
def record_verdict(self, ev):
self.verdicts[ev.call_id] = ev
def try_execute(self, call, now):
if not self.gated:
return Event("execute", call["id"], call["tool"], call["args_hash"], now)
v = self.verdicts.get(call["id"])
if v and v.kind == "allow" and v.args_hash == call["args_hash"]:
return Event("execute", call["id"], call["tool"], call["args_hash"], now)
return None # refuse: no matching allow
def run(gated, inject_reorder=False, inject_arg_mutation=False, n=200, seed=7):
rng = random.Random(seed)
policy = Policy(deny_tools={"read_file"}, latency=rng.choice([0, 2, 5]))
exe = Executor(gated=gated)
log, clock = [], 0
for i in range(n):
call = {"id": f"c{i}", "tool": rng.choice(["search", "read_file"]),
"args_hash": hashlib.sha1(f"args{i}".encode()).hexdigest()[:8]}
log.append(Event("submit", call["id"], call["tool"], call["args_hash"], clock))
verdict = policy.verdict(call, clock)
# failure injection 1: execute before the verdict event lands
if inject_reorder and rng.random() < 0.3:
ev = exe.try_execute(call, clock) # race the verdict
if ev: log.append(ev)
log.append(verdict); exe.record_verdict(verdict)
else:
log.append(verdict); exe.record_verdict(verdict)
# failure injection 2: agent retries with mutated args under same call_id
if inject_arg_mutation and rng.random() < 0.2:
call["args_hash"] = "mutated"
ev = exe.try_execute(call, clock + 1)
if ev: log.append(ev)
clock += 2
return log
def check_inv1(log):
"""Property: every execute is preceded by allow with same call_id AND args_hash."""
allows = {}
violations = []
for ev in log:
if ev.kind == "allow":
allows[ev.call_id] = ev.args_hash
elif ev.kind == "execute":
if allows.get(ev.call_id) != ev.args_hash:
violations.append(ev)
return violations
if __name__ == "__main__":
scenarios = {
"gated, no faults": run(gated=True),
"gated + reorder injection": run(gated=True, inject_reorder=True),
"gated + arg mutation": run(gated=True, inject_arg_mutation=True),
"ungated (typical impl)": run(gated=False),
"ungated + reorder + mutation":run(gated=False, inject_reorder=True, inject_arg_mutation=True),
}
for name, log in scenarios.items():
v = check_inv1(log)
print(f"{name:34s} events={len(log):4d} inv1_violations={len(v)}")
Expected output (deterministic for the fixed seed):
gated, no faults events= 400 inv1_violations=0
gated + reorder injection events= 400 inv1_violations=0
gated + arg mutation events= 400 inv1_violations=0
ungated (typical impl) events= 400 inv1_violations=77
ungated + reorder + mutation events= 400 inv1_violations=94
Read the middle rows carefully: the gated executor survives reordering and argument mutation without knowing either fault exists — because refusal is its default state. The ungated version leaks roughly a third of all calls across the boundary even with zero injected faults, simply because deny verdicts never block anything.
What the acceptance rule should be
Run the harness as a canary gate on any change to dispatch, policy, or tool registration:
-
Denominator: all
executeevents in the trace (not all calls — denied-then-executed is exactly what we're hunting). -
Acceptance rule:
inv1_violations == 0across all injected-fault scenarios, on a fixed seed, in CI. One violation fails the build. - Coverage requirement: the scenario matrix must include at least reorder, duplicate-verdict, and arg-mutation classes; a green run on the happy path proves nothing.
The explicit denominator matters because "99% of calls were authorized" hides the population that matters: the executed ones.
Where free model endpoints fit the workflow
The harness above tests the dispatch layer with synthetic calls. The next step is testing it against a real model's actual tool-call distribution — which tools it requests, how often it retries with mutated arguments, how it behaves when a deny comes back. That second loop needs (a) a model endpoint you can hammer without a procurement process and (b) somewhere to run the harness continuously.
For that loop I used MonkeyCode, which currently offers free model access and a free server option — the harness ran as a long-lived process on the free server while I pointed the agent loop at the free model endpoints and collected real submit traces to replay through check_inv1.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The practical value for this specific method: trace collection is the expensive, boring part — you want thousands of real tool-call events, not a curated demo set — so a zero-cost endpoint plus a box that can stay up is what makes the property test a continuous gate instead of a one-off experiment. Any equivalent free or cheap endpoint works; the harness doesn't depend on the provider.
Tradeoffs
| Design choice | Latency cost | Correctness | Operational cost |
|---|---|---|---|
| Inline synchronous policy check (gated) | +policy RTT on every call | INV-1 holds by construction | Low; single code path |
| Async policy + gated executor (this post) | 0 added on dispatch; executor waits on first call | INV-1 holds if verdicts are per-call and args-pinned | Medium; verdict store with TTL semantics |
| Async policy + optimistic execution | 0 | INV-1 violated under reorder; needs compensation for reads (often impossible — data already leaked) | High; audit + rollback theater |
| Batch/cached authorization | Lowest | INV-1 violated by design (stale verdicts outlive revocation) | Low until the incident |
The honest cost of the gated design is latency on the critical path for the first call of a burst, and a verdict-store consistency question of its own (if the executor's local verdict record is itself replicated, you've moved the race, not removed it — pin verdicts to the executor node or make them idempotent and monotonic: deny always wins on conflict).
Limitations and who should not use this
- The simulator is a reference model, not a proof. It finds event-order counterexamples; it does not verify your production dispatcher. Port
check_inv1to run against real structured logs — that replay mode is where most value lives. - INV-1 is necessary, not sufficient. It does not cover confused-deputy problems (the agent tricking an authorized caller), data exfiltration through allowed tools, or prompt-injection-driven argument crafting. Those need their own invariants.
- If your tools are all pure reads of public data, this machinery is overkill — the invariant's cost is only justified when crossing the boundary is irreversible or sensitive.
- Compensation is a weak substitute here: a file read cannot be un-read. If your threat model tolerates optimistic execution plus cleanup, you don't have a boundary — you have an audit log.
Closing counterexample question
Every tool boundary I review eventually comes down to one question: which event order breaks your invariant — and when it breaks, does your system reject, replay, or compensate? If the answer is "compensate," name the side effect you're promising to undo, and check whether it's actually undoable. If the answer is "we haven't seen that order yet," inject it.
What's the order your current dispatcher can't survive: verdict-after-execute, or allow-for-stale-arguments?
Top comments (0)