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. |
Setting it
Section titled “Setting it”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.
When one event breaks several rules
Section titled “When one event breaks several rules”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.
Policies
Section titled “Policies”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.
A refused call leaves no trace
Section titled “A refused call leaves no trace”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.
deny, end to end
Section titled “deny, end to end”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.