Skip to content

Verdicts as telemetry

A running system wants to know what its guardrails did, and it wants to know now. onDecision fires once per interception, including for the calls that were refused and therefore never happened — which are precisely the ones nothing else can tell you about.

function report(decision: { action: Action; event: TraceEvent; verdicts: readonly VerdictEvent[] }): void {
for (const verdict of decision.verdicts) {
if (verdict.status === 'satisfied') continue
logger.warn('ltl', {
rule: verdict.rule.id,
status: verdict.status,
action: decision.action,
at: verdict.seq,
subformula: verdict.cause?.subformula,
})
}
}
guardTools(monitor, tools, { source, onDecision: (decision) => report(decision) })

Anything shaped like a span event, a log line or a counter works — the verdict is already a plain object, and cause names the event and the subterm that failed. observeRun’s onFinish gives you the same thing for end-of-run resolution, which is where unresolved shows up.

action, not just status. The status says a rule was broken; the action says what happened about it. A violated verdict with action: 'observe' is a measurement; the same verdict with action: 'deny' is a side effect that did not happen. Those are different events in a dashboard.

cause.subformula. It is always a substring of the rule’s printed form, so a log line carrying it can be read against the rule without guessing which clause failed.

The rule id as a dimension, not the message. Rule ids are stable and low-cardinality by construction, which is what makes “SEC-01 fired 40 times today, up from 2” a query rather than a grep.

Do not write JSONL to disk in a deployment. The recorder exists, it is good, and it belongs in tests — it lives behind @rhysbalevicius/ltl-mastra/testing so it never enters the path a running system loads. The reason is not performance; it is that a file of production traces answers a question nobody asked. Your telemetry already knows what your agent did. What a rule set needs is a corpus of fixtures, which is a curated thing, not an exhaust stream.

release(runId) drops a finished run’s state. A long-lived guard in front of many runs stays bounded because you call it, not because it guesses.