02 · Policy engine

Stop it before it happens, and record that you did.

A rule that runs after the fact is a report. Auditant evaluates before the action executes — allow it, refuse it, or hold it for a named human — and writes the decision into the same chain as the action it governed.

Why it lives in the same product

The check and the record have to be one thing.

A policy engine that logs somewhere else can tell you a rule existed. It cannot prove the rule was in force at the moment that specific action ran, in its version from that day, and that nobody rewrote the verdict afterwards.

So the decision is an event: the rule id, its version, the effect, the reasons, and whether it was enforcing or only watching — hashed and chained beside the action itself. Reconstructing that later from two systems is exactly the thing nobody can do when asked.

the decision, as it lands in the chain
{
  "actionType": "tool_call",
  "action":     "wire_transfer",
  "outcome":    "pending_approval",
  "policy": {
    "decision":      "pending_approval",
    "policyId":      "human-signoff-above-threshold",
    "policyVersion": "1",
    "engine":        "cedar-4.7",
    "reasons":       ["amount 50000 exceeds threshold 10000"],
    "monitorMode":   false
  }
}
Three outcomes
allow
The action proceeds.

Recorded anyway. “Nothing stopped it” is a finding, and a log that only contains refusals cannot show that a control was actually running.

deny
The action never executes.

The agent gets a structured refusal naming the policy and the reasons — not a generic error it will retry into.

pending_approval
The action holds for a named human.

The approval, when it comes, is its own event carrying approvedBy. Years later, “who signed off” is answerable without an inbox search.

How a rule gets turned on

Every rule starts watching, not blocking.

In monitor mode a rule evaluates and records what it would have done, and the action proceeds. You get a week of evidence about how often it would have fired, against real traffic, before it can break anything.

This is not a nicety. A compliance control that halts production on the day it ships gets switched off the day after, and a switched-off control protects nobody.

A bug we shipped, found, and now test for

In an early version each rule hardcoded monitorMode: falseinto the verdict it built, so flipping the flag changed the label and nothing else. A rule marked “monitoring” was still blocking live traffic.

That is precisely the failure the feature exists to prevent, and it was invisible from the outside. The rule definition now owns the flag, and a test flips it and asserts the action goes through.

What ships in the box

Five starter rules, each tied to a real obligation.

Deny-by-default, forbid-wins — the same evaluation model AWS chose for Bedrock AgentCore Policy. These are the defaults; the interesting rules are the ones specific to your business.

  • kill-switch
    Halt this tenant. Nothing proceeds.

    Forbid-wins over every other rule, including allows. The control you need at 2am, which is the worst possible time to discover it is subtle.

  • human-signoff-above-threshold
    Actions above a value wait for a named human.

    EU AI Act Article 14 human oversight; financial supervision duties. The approval becomes an event carrying who signed off.

  • adverse-decision-requires-reason
    A decision against someone must carry a reason.

    Colorado SB 26-189. A declined application with no recorded reason is not defensible, and the gap is invisible until someone asks.

  • tool-allowlist
    Deny by default; an agent may use only what it was scoped to.

    Bounded by the invoking user's own rights, so an agent cannot become a privilege-escalation path for the person who ran it.

  • cost-velocity
    Halt an agent burning faster than its baseline.

    The FinOps rule that is also a compliance control. A ceiling reports the damage afterwards; burn rate stops it in the first minute.

Writing your own

A rule is data, not code.

A condition tree over a fixed set of fields — no loops, no recursion, no expression evaluator. Partly because a policy engine that runs customer-supplied code inside the process holding the audit chain is a bad trade whatever the sandbox story is, and partly because rules being data is what gives you the next three things.

  • It reads back in English

    The person who signs off on a policy is usually not the person who wrote the JSON, and “trust me, that’s what it says” is not a review.

  • Its version is its content

    A version identifier is the hash of the rule’s own bytes, so it can only ever refer to one rule. Nobody can edit a rule and leave old decisions pointing at the new text.

  • Nothing is silently coerced

    A threshold compared against a string is refused at save time. In JavaScript “5000” > 400 is true, and a limit that quietly means something other than it says is the worst bug available here.

the rule
{
  "id":          "refund-cap",
  "description": "Refunds above 500 need sign-off.",
  "effect":      "pending_approval",
  "monitorMode": true,
  "condition": {
    "all": [
      { "field": "action", "op": "eq",  "value": "refund" },
      { "field": "amount", "op": "gt",  "value": 500 }
    ]
  }
}
what the compliance officer reads
When the action is "refund" and the amount is
above 500, hold the action for a named human
(monitoring only — nothing is blocked).
Changes take effect immediately

Rules are read on every decision, not loaded at boot. A compliance officer who arms a rule and is told to wait for a deploy will simply not arm it.

“What was that rule, on that day?”

Rules are append-only too.

A change writes a new version; nothing is ever overwritten and nothing is ever deleted. A rule that governed a real decision is part of that decision's evidence, so retiring it removes it from evaluation and from nothing else.

Which means a decision recorded nine months ago, naming a policy id and a version, resolves back to the exact rule that made it — in the words it was written in, with who wrote them and when.

And every change is itself an event in the chain. Arming a rule is the change most likely to be made quietly, and a policy table nobody audits is the obvious place to hide one.

the change, as it lands in the chain
seq  22  policy_created   armed=false  augusta@…
         refund-cap: When the action is "refund"
         and the amount is above 500, hold the
         action for a named human (monitoring only).

seq  23  policy_updated   armed=true   augusta@…
         refund-cap: "…(monitoring only)"
                  → "…hold the action for a named human."
What the agent is allowed to say

An agent states what it wants to do. Never what it is measured against.

The agent is the only thing that knows what it is about to do, so it supplies that. Everything the decision is checked against — spend so far, loop count, whether a human actually approved, whether the tenant is halted — is computed by the server from the chain and from stored configuration, and overwrites anything the caller sent.

This is not an adversarial edge case. An agent under a budget it wants to exceed is the ordinary case, and an agent that has been prompt-injected will send whatever it is told to send.

Asserted by the caller
  • agentId
  • action
  • amount
  • model
  • endUser
  • reason
  • sessionId
Derived by the server
  • dailyTotal
  • ceilingUsd
  • sessionIterations
  • approvalId
  • tenantHalted

A caller that sends these has them replaced, silently and every time.

A bug we shipped, found, and now test for

The first version of the decision endpoint read the entire context out of the request body. So an agent could send tenantHalted: false and walk through the kill switch, or approvalId: "x" and satisfy the human-signoff rule with no human anywhere near it. The control you reach for at 2am was switchable by the thing you were trying to stop.

Where enforcement actually reaches

What we can block

  • Anything traversing an instrumented tool dispatcher.
  • Anything traversing a gateway we sit in front of.
  • An agent's whole plan, authorised before step one.

What we cannot

  • A raw cloud SDK call that never passes through us.
  • Anything at a chokepoint we don't occupy — that needs your own service control policies or admission control.

We record those rather than pretending to stop them. Claiming to block a cloud API we do not sit in front of would be the kind of thing that survives exactly until the first incident review.

A spend ceiling is a compliance control wearing a different hat. Same engine, same record.