The event model
Everything the engine reasons about is a sequence of these. Get this right and every rule, verdict, artifact and analysis follows.
type TraceEvent = { runId: string seq: number ts: number kind: 'tool.call' | 'tool.result' | 'step.start' | 'step.finish' | 'run.suspend' | 'run.resume' | 'run.finish' | 'agent.message' name?: string payload?: unknown meta?: Record<string, unknown>}| Kind | Means | name |
|---|---|---|
tool.call |
A tool is about to run. | The tool’s id. |
tool.result |
It returned. | The tool’s id. |
step.start |
A workflow step is about to run. | The step’s id. |
step.finish |
It returned. | The step’s id. |
run.suspend |
The run paused. | — |
run.resume |
The run continued. | — |
run.finish |
The run ended. payload.status carries how. |
— |
agent.message |
The model said something. | — |
The contract
Section titled “The contract”seq is the clock. It starts at zero, advances by exactly one per committed event, and never restarts within a run.
Every temporal operator counts in seq, so a hole or a repeat changes what rules mean. This is why the counter advances
on commit rather than on construction: an event that is drafted and then refused never happened, and must not consume a
number.
ts is for humans. Nothing in the engine reads it. It is what the demo shows as elapsed time.
A call and its result are separate events. tool.call is emitted before the tool runs, tool.result after it
returns. A tool that throws produces a call with no result — which is exactly what a response rule is watching for, so do
not synthesise one.
Order is causal, not wall-clock. Concurrent work still gets a total order, and the guard serialises the drafting of events for exactly that reason. What matters is that a result never precedes its call.
payload is whatever the tool was given or returned. It must be JSON-serializable if the trace is going to be
recorded. Field matchers read into it with dotted paths, and a missing field, a non-object payload, or a value of the
wrong type all read as “does not match” rather than throwing.
meta is for the adapter. The one key the engine cares about is meta.payloadHash, attached to tool.call and
step.start: a stable digest of the arguments, which is what repetition checks key on. Compute it where
the validated arguments are in hand.
Refused calls leave no event
Section titled “Refused calls leave no event”The most important consequence of the model. A guard drafts a candidate, asks the monitor what it would mean, and commits
only if the call runs. So a denied or suspended call contributes nothing: no event, no seq, no budget, no repetition.
That is right, and an adapter must preserve it. A trace is a record of what happened, and a call that was refused did not happen.
The demo draws that decision even though the trace cannot: a refused call turns the path it reached for red. That is the one thing a graph can show you which no trace can, and it is why the picture exists.
Adding a kind
Section titled “Adding a kind”The kind list is closed in the engine’s types but open in a matcher: Matcher['kind'] is string, not the union. 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.
So an adapter may emit a kind the engine has not heard of. Nothing will match it, and nothing will break.