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.
What is worth emitting
Section titled “What is worth emitting”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.
What not to do
Section titled “What not to do”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.
Cleaning up
Section titled “Cleaning up”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.