Skip to content

How it works

packages/core zero runtime dependencies. The whole engine.
packages/mastra the only published package that imports Mastra.
packages/cli the binary. Depends on core; reaches analyze by dynamic import only.
packages/analyze optional. Owns the solver, dynamically imported.
apps/demo React Flow and Tailwind live here and in apps/site, nowhere else.
apps/site this documentation.
examples the demo's agent and workflow. The one other place Mastra belongs.

pnpm run check:deps enforces this, and it enforces more than the manifests: it reads every import in every file of every unit, catches a package reached by walking out of its own directory with a relative path, and distinguishes static from dynamic imports so “reachable only by dynamic import” is a real constraint rather than an intention.

The reason core has no dependencies is not minimalism. It is that the engine has to run identically in three places — a Node process guarding a live run, a CI job folding over a file, and a browser replaying one — and anything it imports is something one of those three cannot have.

Everything is one function:

(state, event) => { state, verdicts }

peek runs it and throws the state away. step runs it and keeps the state. finish resolves what is still open. The load-bearing invariant is that step after peek equals step, and it is checked by a property test over generated traces rather than by inspection — enforcement, replay, timeline scrubbing and batch checking are all the same fold, and they stay the same fold only because that holds.

State is immutable and the transition is pure. That is what lets a guard ask “what would this mean?” before deciding whether to let it happen.

Committed state is partitioned by runId. Every event advances the state of its own run and no other, so one monitor can stand in front of interleaved runs without an approval in one satisfying a prerequisite in another. peek judges a candidate against its own run’s history, finish(outcome, runId) resolves one run while finish(outcome) resolves every run the monitor has seen, and release(runId) drops a finished run’s state so a long-lived guard stays bounded. The one obligation this places on adapters: events that belong together must carry the same runId, which is exactly what sharing one trace source between a guard and an observer achieves.

A proposition predicate that throws does not take the run down. The throwing proposition reads as “does not match” — the same answer a matcher gives a payload it cannot read — and the throw is reported through the monitor’s onPredicateError hook so telemetry can say the rule is broken rather than merely quiet. The hook is telemetry: it may fire during peek as well as step, and its own errors are swallowed.

A formula compiles into a flat array of nodes, and each node is one of two things.

Past-definite nodes are decidable from what has already happened. prev, once, historically, since, and any boolean combination of them fold over the trace with a fixed amount of state — one boolean per node, updated per event. once latches on, historically starts true and latches off, since needs its right side to have fired and its left to have held since. No history is retained: the recurrence is the history.

Bounded-future nodes cannot be decided when they are opened. next and eventually(within: k) create an obligation — a countdown carried forward until it is discharged or expires. An obligation holds a residual: the part of the formula still outstanding, simplified after every event. When the residual collapses to a constant the obligation is discharged; when the countdown reaches zero without collapsing, the rule is violated.

The state size is therefore one boolean per subformula plus at most k open obligations per bounded operator, where k is that operator’s window. Not k+1 — the obligation opened at the current event is the one the deadline event discharges. Both figures are asserted by tests, exactly rather than as bounds, because a loose assertion would hide a regression.

A violation names the subterm that failed. Getting that right is harder than it looks, and two things make it correct.

Blame walks to a subterm that is definitely false, and stops if none is. Naming the first conjunct would mean a rule broken by one clause could be reported against a liveness obligation beside it that was merely still open. A subterm is only accused once its residual has collapsed to false, and a past-definite node is read directly. That also makes the choice independent of the order the conjuncts were written in.

Labels come from the printer’s own spans. Re-describing a node in isolation produces text that may not appear in the printed rule — a parenthesised disjunction prints one way at the top level and another inside its parent. Compilation labels each node with the span the printer placed, keyed by path, so cause.subformula is always a substring of rule.text and can be highlighted without searching.

packages/mastra never imports Mastra’s types into its logic; it is written against duck-typed shapes on purpose, so a version bump cannot break the engine. What it does is wrap execute, and the wrapping is where the care is.

An interception is atomic per monitor: drafting the candidate, peeking, deciding and committing happen inside a critical section, because two concurrent tool calls would otherwise each judge state the other had already changed. Tool and step bodies run outside it.

Nothing is committed unless the call actually runs. The sequence counter advances on commit, not on construction, so a refused call leaves no hole in the numbering — and no evidence in the trace, because it never happened.

The three things a change usually has to keep true

Section titled “The three things a change usually has to keep true”
  1. step ∘ peek ≡ step. If a change makes peeking observable, enforcement and replay have diverged.
  2. The blamed subformula is a span of the printed rule. If it stops being one, the viewer cannot highlight it and a reader cannot find it.
  3. Core imports nothing. Including node builtins, including dynamically.

All three are tests. Run pnpm run ci.

If you are changing the engine, read in this order: event.ts (what a trace is), matcher.ts (what a proposition is), formula.ts and ltl.ts (what a formula is and what construction refuses), automaton.ts (the transition), monitor.ts (properties, modes and the batch fold). describe.ts is the printer and matters more than it looks — attribution depends on it.