Skip to content

Enforcement modes

The same rule set does three jobs — verdicts in CI, telemetry while a run executes, and stopping a side effect before it happens. The only thing that changes between them is a rule’s mode.

Mode At the interception point Use it when
observe The call runs; the verdict is recorded. A rule is new and has to earn trust before it is switched on.
deny The call is refused and the violation is returned as the tool’s result. An agent could reasonably do something else instead.
suspend The workflow pauses through its own suspension primitive; a human’s answer resumes it. A person has to decide, and the decision may take longer than the process lives.
abort The run is cancelled and the guard throws where the side effect would have been. Nothing downstream should happen, and nobody is going to be asked.

Per rule, which is the usual way. Or globally with onViolation. Or with the enforce: true shorthand, which means abort everywhere.

createMonitor({
rules: [SEC_01, HITL_01],
checks: [Checks.callBudget({ max: 8 })],
onViolation: 'observe',
})

A rule’s own mode always wins over the global one. That ordering is what lets you drop a whole rule set into observe for a deploy while leaving the one rule you actually trust enforcing.

The strictest mode wins — abort over suspend over deny over observe — not the last one declared. A rule set is unordered, and a guardrail whose strength depended on the order of an array would be a bad guardrail.

The mode is the default decision. A policy overrides it, per call, with whatever context you have:

const policy: Policy = async (violations, ctx) => {
if (ctx.site === 'tool' && violations.every((v) => v.rule.id.startsWith('OPS-'))) return 'observe'
return policyFor(violations)
}
guardTools(monitor, tools, { policy })

A policy receives the violations and a context carrying the candidate event, the verdicts, whether this is a tool or a step, and the run’s abort signal. It may be async — calling out to a feature flag or an approvals service is the expected use, not a workaround.

A policy that throws fails closed. The guard reports the decision as an abort and rethrows. A policy backend that is unreachable is not evidence that a call is safe.

The one consequence worth internalising. peek asks what an event would mean; nothing is committed unless the call actually runs. So a denied call produces no tool.call event, no advance of the sequence counter, and no contribution to a budget or a repetition check.

That is right — the call never happened, and a budget that counted refused work would be counting nothing. It has two follow-on effects:

  • A rule that is enforcing erases the evidence a CI check would look for, which is why a corpus is captured while rules are only observing.
  • A repeat attempt is denied again rather than sliding through on a latched verdict.
const SEC_01 = Presets.requireBefore('restartService', 'queryMetrics', { mode: 'deny' })

The agent reaches for the restart. The guard refuses and returns this in place of the tool’s output:

{
"blocked": true,
"message": "blocked by SEC-01: restartService may not run before queryMetrics has returned",
"violations": [
{
"ruleId": "SEC-01",
"description": "restartService may not run before queryMetrics has returned",
"seq": 0,
"subformula": "O toolResult(queryMetrics)"
}
]
}

The model reads that as the tool’s result, does the missing work, and tries again — which is the point. deny turns a guardrail into feedback, and it is the mode that composes with an agent rather than fighting it. Stopping a call shows a run doing exactly that.

On a workflow step, deny bails the run instead of returning the object; see guarding workflows.