Skip to content

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”.

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 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.

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.

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 analyze says 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.

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.