Compliance as an Agent Task: Automating Policy Checks with Verifier Agents
Build verifier agents that check every proposed agent action against codified policy, with fail-closed defaults, guardrail placement, and eval-backed tests.
Published on • October 16, 2026
AI Assistant

An agent proposes a $4,000 refund to a customer who has never complained. The model that generated that action is the same model you would ask “is this allowed?”, and it answers yes, because it wrote the question. That is the structural flaw in most agent compliance work: the generator grades its own homework. The fix is boring and effective. You split the run in two. One agent proposes an action, and a second, deliberately narrow verifier decides whether that proposal satisfies policy you wrote down in code before the action touches anything real.
Separate generation from verification
Generation is fuzzy work. It summarizes, plans, and improvises. Verification is the opposite: given a proposal and a policy, produce a decision you can defend to an auditor. When you blur the two roles you get an agent that argues with itself in one context window and calls the result a control.
Concretely, you want three properties in your verifier:
- It fails closed. If the verifier errors, times out, or cannot parse the proposal, the default answer is deny, not allow. A control that silently passes on failure is worse than no control, because people trust it.
- It sees structured data, not prose. The proposal arrives as a typed object with an amount, an action, a customer tier, and a policy version. Anything the verifier has to regex out of a paragraph is a bug in the upstream schema.
- It cannot act. The verifier holds no tool that mutates state. Its only output is a verdict.
Pydantic models are the natural way to write this policy, because the same model that documents the rule becomes the machine-checkable version of it.
Policy as Pydantic models
Start by typing the proposal, then type the verdict:
from typing import Literal
from pydantic import BaseModel, Field
class RefundProposal(BaseModel):
action: Literal['issue_refund']
customer_id: int
amount_usd: float = Field(ge=0)
order_id: str
reason_code: Literal[
'damaged_item', 'not_delivered', 'duplicate_charge', 'goodwill'
]
policy_version: str
class PolicyVerdict(BaseModel):
allowed: bool
decision: Literal['allow', 'deny', 'escalate']
rule_id: str
rationale: str
requires_human_approval: bool = False
The constraints do real work here. Field(ge=0) rejects a negative refund before any rule runs. The Literal reason codes mean the model cannot invent a category your finance team has never seen. And because Pydantic builds the JSON schema the model fills in, a malformed proposal never reaches your rule engine in the first place: in Pydantic AI, when you pass an output_type, validation runs at the end of the run and the agent is prompted to try again if the data does not validate.
Then write the rule engine as ordinary Python. It should be readable by a compliance officer:
THRESHOLDS = {
'goodwill': (50.0, 'R-REFUND-004'),
'damaged_item': (250.0, 'R-REFUND-011'),
'not_delivered': (500.0, 'R-REFUND-011'),
'duplicate_charge': (1_000.0, 'R-REFUND-002'),
}
CURRENT_POLICY_VERSION = '2026-10-01'
def verify_refund(p: RefundProposal) -> PolicyVerdict:
"""Fail closed: any exception path below means deny."""
limit, rule_id = THRESHOLDS[p.reason_code]
if p.policy_version != CURRENT_POLICY_VERSION:
return PolicyVerdict(
allowed=False, decision='deny', rule_id='R-VERSION-STALE',
rationale='Proposal cites a superseded policy version.',
)
if p.amount_usd > limit:
return PolicyVerdict(
allowed=False, decision='escalate', rule_id=rule_id,
rationale=f'{p.amount_usd:.2f} exceeds the {limit:.2f} limit.',
requires_human_approval=True,
)
return PolicyVerdict(
allowed=True, decision='allow', rule_id=rule_id,
rationale='Within limit for the given reason code.',
)
There is no LLM in that function, and that is the point. Threshold comparisons, version checks, and allowlists are deterministic, cheap, and testable with plain unit tests.
Where to put the verifier: the three edges of a run
Deciding where the check runs matters as much as the check itself. Pydantic AI Harness models this with guardrails on three edges of a run: input on the way to the model, tool calls in the middle, and output on the way back to your caller. Each wraps a guard callable you own, and each guard returns one of five outcomes: allow, block, replace, retry, and, for tool arguments, approve.
That gives you a placement map:
- Before execution:
ToolGuardrail. Itsguardinspects validated arguments before the tool runs. This is where refund, payment, and deletion actions belong, because a blocked tool call never happened. - Before exposure:
OutputGuardrail. Catches PII, missing citations, or unsupported claims in the answer you were about to show a user.blockraisesOutputBlockedso your caller decides what to do;retrysends the output back to the model with an instruction. - Before spend:
InputGuardrail. Blocks a prompt injection attempt without spending a single token on a model call.
A concrete verifier with an approval gate:
from pydantic_ai import Agent
from pydantic_ai_harness import GuardrailResult, OutputGuardrail, ToolGuardrail
from pydantic_ai_harness.guardrails import ToolCallInfo
POLICY = {
'auto_approve_usd': 250.0,
}
def refund_policy(call: ToolCallInfo) -> GuardrailResult:
if call.name != 'issue_refund':
return GuardrailResult.allow()
amount = float(call.args['amount_usd'])
if amount > POLICY['auto_approve_usd']:
return GuardrailResult.approve() # defer to a human, run ends here
return GuardrailResult.allow()
def deny_pii(output: object) -> GuardrailResult:
text = output.model_dump_json() if hasattr(output, 'model_dump_json') else str(output)
if 'SSN' in text:
return GuardrailResult.block('Response contained personal data.')
return GuardrailResult.allow()
agent = Agent(
'openai:gpt-5.4',
capabilities=[
ToolGuardrail(guard=refund_policy),
OutputGuardrail(guard=deny_pii),
],
)
approve is the interesting outcome. The call is held back, the run finishes with deferred tool requests, and you resume it with the human’s answer. When the human clears it, the guard is evaluated again on resume, so a policy that changed its mind in the meantime can still block an approved call. Note the caveat that follows from placing checks on edges: OutputGuardrail inspects the final output only. If you stream partial chunks to a browser, content sent before the guard runs cannot be un-sent. Use run() instead of run_stream() when output must be screened before any of it is exposed.
Two practical rules for chains. First, order is meaningful: guards run in sequence, and replace (a redaction) threads forward so later guards inspect the cleaned value. Put the redactor first. Second, parallel input guards trade tokens for latency: with parallel=True the model call starts alongside a slow classifier and is cancelled when the guard trips, so fast local checks should stay sequential.
Rule-based checks versus LLM-as-judge
Split the work along a sharp line.
Rules for what has a shape. Thresholds, allowlists, schema constraints, and pattern detectors. The Pydantic AI harness ships detectors that rewrite vendor API keys, tokens, private key blocks, emails, card numbers, IBANs, and SSNs out of text, and they are plain functions returning a verdict, so they chain beside checks you wrote.
An LLM judge only for what has no shape. Tone, “does this refusal sound like a legal admission”, “is this summary faithful to the transcript” are legitimately fuzzy. Keep them out of the execution path: Pydantic Evals has a built-in LLM-as-a-judge evaluator for exactly this, and that is where a probabilistic opinion belongs, in testing, not in the gate that protects a payment.
And be honest about what rules cannot do. As the guardrails docs put it, a regex finds a credential because credentials have a shape; it does not find a prompt injection, which is ordinary language, and a pattern list for injection catches the examples while missing the attack. A check that reads as protection without being it is worse than none.
What verifiers should never be trusted with
- Vetting calls they never see. Output tools, external and deferred tools handed back to your application, and provider-side built-in tools never reach execution hooks, so no tool guard inspects them. The check belongs in the code that actually executes them.
- Being your only control. Verifiers guard application logic. Network egress, IAM, and database permissions still need to hold when the verifier is bypassed or buggy.
- Reasoning about novel attacks. Fuzzy semantic judgment is advisory. If a decision is irreversible, the deterministic rule decides.
- Holding tools. A verifier that can call
issue_refundhas merged the two roles again. - Silence on failure. Wrap the verifier call, catch the exception, and return deny. Fail closed, always.
Test the verifier like production code
Your rule engine is ordinary Python, so unit-test it. For the parts that involve the model, use Pydantic Evals: define a Dataset of Cases with known inputs and expected outputs, attach evaluators, and run dataset.evaluate_sync(task). Deterministic evaluators (exact match on claim_type, a check that requires_human_approval flipped for a $900 refund) are cheaper and easier than model review, and an IsInstance-style evaluator on your verdict model catches schema regressions immediately. Because everything is code, the same dataset runs in CI on every prompt change.
Key Takeaways
- Split generation from verification: the model that proposes an action must never be the one that approves it.
- Write policy as Pydantic models and plain Python rules; fail closed on every error path.
- Place checks on the three edges: input guards before spend, tool guards before execution, output guards before exposure.
- Use deterministic rules for shaped data (thresholds, PII, allowlists) and LLM-as-judge only in evals for unshaped judgment.
- Verifiers cannot see deferred, output, or provider built-in tools, and they never replace network or database permissions.
- Stream only after the output is verified, and unit-test plus eval your verifier on every policy change.