Propositions
P builds the matcher for a single event. Every rule bottoms out in these.
P.toolCall('restartService')P.toolResult('queryMetrics')P.stepStart('runDiagnostics')P.stepFinish('runDiagnostics')P.runSuspend()P.runResume()P.runFinish()P.agentMessage()The name is optional. P.toolCall() matches a call to anything, which is what makes a rule say “every call” rather than
“every call to one tool”.
Name the tool, or pass the tool
Section titled “Name the tool, or pass the tool”Anywhere a name is taken, the tool itself is taken too:
P.toolCall(restartService)P.toolResult(queryMetrics)Prefer this where the tool is in scope. A string is a copy of the tool’s id, and copies drift: rename the tool and the rule keeps matching a name nothing emits any more. It does not fail — it silently stops firing, which is the worst way for a guardrail to break, because the rule set still looks green.
Passing the tool makes the compiler responsible for the correspondence. A typo will not compile, a deleted tool will not compile, and a renamed id propagates into the rule instead of rotting.
The engine reads one field — a string id — and nothing else, so it never learns what a Mastra tool is. That is
Identified, and anything with an id satisfies it: a tool, a step, or a plain { id } in a test.
const SEC_01 = Presets.requireBefore(restartService, queryMetrics, { id: 'SEC-01', mode: 'deny' })The rule that comes out is identical either way — the same formula, the same generated id, the same JSON. This is an authoring-time guarantee, not a different runtime.
When a string is still the answer
Section titled “When a string is still the answer”When there is no symbol to point at: tools built per run by a factory, a rule set loaded from JSON, a rule about a tool that lives in another service. The string form is not deprecated and never will be — the event model is names, and a rule set has to be expressible as data. It is just weaker, and worth knowing that it is.
Payload matchers
Section titled “Payload matchers”P.toolCall('restartService', { amount: P.gt(1000) })// toolCall(restartService, amount > 1000)| Operator | Matches |
|---|---|
P.eq(v) / P.ne(v) |
Deep equality against a JSON value. ne also requires the field to be present. |
P.gt(n) / P.gte(n) / P.lt(n) / P.lte(n) |
The field is a number and compares that way. |
P.oneOf([...]) |
Deep equality against any of the values. |
P.matches(pattern, flags) |
The field is a string the expression matches. g and y are refused: they carry state between calls. |
P.exists() |
The field is present, whatever it holds. |
Field paths are dotted, so { 'service.id': P.eq('checkout-api') } reads into a nested payload.
A missing field, a payload that is not an object, and a value of the wrong type all read as does not match rather than throwing. A rule is a question about an event, and a question about a field that is not there has an answer.
When a matcher is not enough
Section titled “When a matcher is not enough”P.custom('overLimit', (event) => event.kind === 'tool.call' && (event.payload as { amount?: number })?.amount === 999_999)A custom predicate is arbitrary code, and it works everywhere a matcher does. Two things follow from that, and both are reported rather than silent:
- The rule cannot be serialized to JSON. An artifact manifest carries its printed form without the serialized one.
- Static analysis treats it as opaque.
ltl analyzesays the rule is out of scope rather than reasoning about it wrongly.
A predicate that throws reads as “does not match”, exactly as a matcher reads a payload it cannot interpret — so a broken
rule degrades its own proposition instead of crashing the run it was meant to guard. Pass onPredicateError to
createMonitor to hear about it: the hook receives the error, the rule id and the event, and it is the difference
between a rule that is quiet and one you know is broken.
Kinds the engine has not heard of
Section titled “Kinds the engine has not heard of”Matcher['kind'] is string, not the closed union of event kinds. That is deliberate: a rule set written against a
newer adapter must still load on an older engine, matching nothing rather than failing to parse. Loading such a rule
produces an unknown-event-kind lint warning, never an error.