The events a rule binds to
Every check and every match reads an event. This is the single reference for
what each event kind carries and how it is shaped on the wire — the other docs
point here rather than re-listing the fields. Ground truth is the engine
(internal/declaration/events.go, the module declarations under
internal/{filemod,commandmod,tooluse,tagmod,cyclemod}, internal/guardrail/scopes.go,
and the check payloads in internal/declaration/payload.go); this doc mirrors it,
and the load check is the copy to trust when the two disagree.
The event is read FLAT
Section titled “The event is read FLAT”An event’s own fields sit directly under event — .event.path,
.event.newContent, .event.kind, .event.resultKnown, .event.invocations,
.event.tags. There is no .event.fields.* nesting: that nested
{kind, fields} envelope is the OLD format’s. Historical events from
sr-session trajectory normalize are flat the same way — see “Past events”
below.
A field the kind declares but the event omits is filled with its type’s zero
value (an empty string, an empty list, false) — never dropped — so
newContent == "" fires rather than errors on an empty file, and
len(newMarkers) == 0 holds on unmarked content. The one place that bites is a
PreFileUpdate whose result is underivable: an absent newContent reads as "",
indistinguishable from an emptied file, which is what resultKnown exists to tell
apart (see below).
The kinds and their fields
Section titled “The kinds and their fields”The kind is the discriminator, carried alongside the fields as .event.kind.
Field types are the modules’ own.
File events
Section titled “File events”One file, one event. path is repository-relative and every file kind carries
it. PreFileWrite is an alias the engine expands to PreFileCreate +
PreFileUpdate (and PostFileWrite to the two Post kinds) — a shorthand in a
trigger’s on, not a kind the engine emits.
| kind | fields |
|---|---|
PreFileCreate |
path, newContent, resultKnown, newMarkers, citations |
PreFileUpdate |
path, oldContent, newContent, resultKnown, oldMarkers, newMarkers, citations |
PreFileDelete |
path, oldContent, oldContentKnown, oldMarkers, citations |
PostFileCreate |
path, newContent, newContentKnown, newMarkers, seen, citations |
PostFileUpdate |
path, oldContent, newContent, newContentKnown, oldMarkers, newMarkers, seen, citations |
PostFileDelete |
path, oldContent, oldMarkers, seen, citations |
-
seen— bool, Post kinds only.truewhen an earlier Stop was already handed this file with the same content: the event is a re-send, not a change since the previous Stop (see Re-sent events:seen). -
path— string, repository-relative. Prepend$SR_WORKSPACE(Environment) to reach the file on disk. -
oldContent— string, the file’s bytes before the change. On aPreupdate/delete it is the file on disk; on aPostupdate/delete it is the session baseline (the prior bytes are no longer on disk). A create has none — nothing preceded it, and the create kinds do not declare it. -
newContent— string, what the write would leave behind: the created body on a create, the post-edit bytes on an update. On aPreupdate it is meaningful only alongsideresultKnown. A delete has none — nothing remains. -
resultKnown— bool, onPreFileCreateandPreFileUpdateonly. Says whether the engine could computenewContent, or whether the value is a zero standing in for “the engine could not work it out”. See the discipline below. -
oldContentKnown— bool, onPreFileDeleteonly. The same gap on the delete side:falsewhen the delete is predicted without its bytes being read — the file is not a regular file (a link to a FIFO or a device), larger than a delete read takes (8 MiB), or does not fit in what is left of a recursive removal’s byte budget (see File guard, “Deleted files”).oldContentis then"". A rule that decides from what the file held readsoldContentKnownfirst; a rule about the path needs neither. ItsoldMarkersare then those of the file’s copy at HEAD when that is tracked and within the cap (so a marker-matched guard still selects it before the delete), and empty otherwise. -
newContentKnown— bool, onPostFileCreateandPostFileUpdateonly.falsewhen the settled file was not read: not a regular file once links are followed, or larger than one read takes (64 MiB).newContentis then"". -
newMarkers/oldMarkers— thesr:markers of the new / old text, each a list of{kind, fqn, line}(kindstring,fqnstring,lineint).newMarkersis set on the create and update kinds (the markersnewContentwould carry);oldMarkerson the update and delete kinds (the markers the file carries now). A create has nooldMarkers; a delete has nonewMarkers. -
citations— the citations the change was grounded in: a list of{quote, sourceTypes, path, line, message}(sourceTypesa list ofuser/tool_result,paththe absolute transcript,lineint,messagethe whole entry the quote came from,callthe tool call behind atool_result). Empty unless the change was made grounded — see Grounding.
Read a marker’s quote off .fqn, and test a list with a quantifier:
quote="$(printf '%s' "$payload" \ | jq -r '(.event.newMarkers // [])[] | select(.kind == "asked") | .fqn' | head -1)"Full treatment of the create/update/delete distinction, the pending-bytes reads,
and markers in a file-guard’s own match scope (where they appear as markers,
the file’s markers after the change, and oldMarkers, before it — not
newMarkers): File guard.
The resultKnown discipline
Section titled “The resultKnown discipline”On a PreFileUpdate (and an underivable PreFileCreate, e.g. a NotebookEdit
whose newContent is one cell rather than the JSON document), an absent
newContent reads as the empty string — the same observation as a write that
empties the file. resultKnown is the boolean beside the value that tells the two
apart. So guard on resultKnown before reading newContent:
resultKnown and not (newContent contains "---") refuse a strip, say nothing where the engine cannot seenot resultKnown catch the underivable cases deliberatelyIn a script, check presence first (.event | has("newContent")); in a judge,
exit 0 / defer when the result is not known and let the after-check judge the
settled file. A create bound only to PreFileCreate from an ordinary write always
carries newContent and can read it directly. Details and the dispatch-on-kind
skeleton are in File guard.
PreCommandInvoke — a shell command line about to run
Section titled “PreCommandInvoke — a shell command line about to run”| field | type |
|---|---|
raw |
string — the command line as written |
invocations |
list — every program the module parsed out of it, flattened |
citations |
list of {quote, sourceTypes, path, line, message, call} — see Citations |
One command line is rarely one program: a pipeline, an && chain, a subshell, a
sudo, an xargs each nest invocations. The module walks that once and emits
every invocation flattened, so a rule matches the list rather than the raw
string and nesting one level deeper does not defeat it. Each invocation carries:
-
.bin— string, the program name. -
.argv— list of strings, its argument vector. -
.flags— a map of parsed flags with open keys and typed values. A flag name belongs to the command, not the engine, so the keys are checked against nothing: a mistyped flag name is a flag the command never passed, and reads as absent. Cause the command and watch the rule fire before trusting a flags match. Each value is a list of every occurrence, in order —--tag=a --tag=bis["a", "b"], a flag given once is a one-element list, and a valueless flag carries"". Only the inline--flag=valueform carries a value; a separated--flag valueis[""]withvalueleft in.argv. A flag the command did not pass reads as an empty list, so every list idiom answers false without it rather than erroring. The values are declared, so a comparison a list can never satisfy (.flags.tag == "next",.flags.tag startsWith "n") is refused when the rule loads, with the error saying flag values are lists.question match was --accesspassed at all"access" in .flagsorlen(.flags.access) > 0was it given a non-empty value any(.flags.access, # != "")was --tag=nextamong them"next" in .flags.tagthe first value "tag" in .flags and .flags.tag[0] == "next"(indexing an empty list errors)was --tagNOT passednot ("tag" in .flags)—.flags.tag == nil,!= niland a??default are refused at load, since an absent flag reads as[], never nil -
.cwd— string, the directory the program runs in as far as the line says, threaded through everycdahead of it (a subshell’scdstays inside the subshell), through a wrapper that changes directory (env -C DIR/--chdir,sudo -D DIR/--chdir— what it wraps runs in DIR), and through a literalevalpayload (eval 'cd /x'moves what follows; the programs in the payload are reported too):"."is where the line started,"sub/dir"is relative to that,"/abs"is absolute, and""means the directory could not be known without running something (cd "$DIR",cd -,pushd,env -C "$D", or anything after anevalwhose payload is not literal, such aseval "$(ssh-agent -s)"). A file event’s path is resolved the same way, with one difference: after an unreadableeval, a relative write target is still reported where the line started — such an eval almost never moves the shell, and dropping the target would hide the write from every file rule. Where the line started is the harness’s working directory for that tool call — in a transcript, the record’s owncwd— so a script joins a relative.cwdonto that.
any(event.invocations, .bin == "curl")any(event.invocations, .bin == "rm" and any(.argv, # == "-rf"))any(event.invocations, .bin == "npm" and "next" in .flags.tag)len(event.invocations) > 1In a script: .flags.tag[0] for the first value, .flags.tag[-1] for the last,
(.flags.tag // []) | join(" ") for all of them.
.bin, .argv and .cwd have declared shapes, so a mistyped key inside a
predicate is refused at load; .flags is the one map whose keys are open. Only what the parser
can see without running the command is emitted — a program named by a variable, a
decoded-and-piped payload — is left alone rather than guessed, so this is a
correctness aid, never a security boundary. There is no Post counterpart: a
command that ran shows its consequences as the file events. See Gate.
Citations: a grounded action
Section titled “Citations: a grounded action”citations (on every file kind and on PreCommandInvoke) lists the citations
the action was made with, each {quote, sourceTypes, path, line, message, call}:
the quote, the pool(s) it resolved in (user, tool_result), the absolute
transcript, the line, the whole entry it came from (capped at 16KB), and — for a
tool_result — the tool call that produced it. The engine resolves every quote against the session’s record before
any rule runs, so an entry always names a real entry of the record. An action
that cited nothing carries an empty list. A Post file event carries the
citations recorded for its path at pre-tool. How an agent cites, and how a rule
requires or judges a citation: Grounding.
PreToolUse — a tool call about to run
Section titled “PreToolUse — a tool call about to run”| field | type |
|---|---|
tool |
string — the tool’s name as the harness reports it (tool == "WebFetch") |
input |
open map — the tool’s arguments, shape is the tool’s own |
The harness-native pre-action moment, for gating a tool no file or command event
covers (an MCP call, a web fetch, a bespoke tool) or activating a context from
“some tool ran” plus the trajectory. input is left open like .flags: a matcher
reading input.file_path reaches in unchecked. A tool with no arguments still
produces the event with an empty input, so a rule narrowing on tool alone
sees it. No Post counterpart — what a tool changed is the file module’s Post
events.
PostTagWrite — the #tags the agent wrote this cycle
Section titled “PostTagWrite — the #tags the agent wrote this cycle”| field | type |
|---|---|
tags |
list of {label, seen} — every distinct tag written this cycle, in order |
A bulk event, not one per tag: a message often holds #update #decision, and a
context deciding whether its tag showed up should see the whole set at once.
.label is the tag’s text without the leading # (#no-slop → no-slop).
Duplicates are dropped, first-occurrence order kept. An empty tags is a real
answer (“nothing tagged this cycle”) a context can react to. A Post fact only —
there is nothing to scan before the agent writes.
What counts as a tag: # at the start of the text or after whitespace, then a
letter or _, then letters, digits, _ and -. Markdown emphasis right before
the # is fine — **#research summary:**, *#research*, _#research_,
__#research__ all give research. Not a tag: #42 (an issue reference), #
(a heading), foo#bar or foo**#bar** (inside a word), ~~#research~~, and
anything in a code span, a fenced block or a > quoted line — text the agent
shows is not text it declares.
.seen is true when the tag is only in text an earlier Stop already read — a
refused reply’s text, re-sent with the retry. A tag the agent wrote again since
the previous Stop is seen: false.
any(event.tags, .label == "research") # said at any point this cycleany(event.tags, .label == "skip" and not .seen) # said in the reply being judgedRe-sent events: seen
Section titled “Re-sent events: seen”A cycle stays open until a Stop passes. When a Stop is refused, the retry’s Post
events deliver again what the refused reply already did — its tags, and every
file still differing from the session’s baseline (which stays in the difference,
and is delivered on every Stop, until committed). That is deliberate: a rule
whose obligation must survive a refusal (a context entered on #research) keeps
seeing it even if the retry does not repeat the tag.
A rule that judges only what happened since the previous Stop reads seen:
- on a tag,
seen: true= only in text an earlier Stop read; - on a Post file event,
seen: true= same content an earlier Stop was handed.
“Earlier Stop” means the previous Stop that ran the rules, whatever it decided. A
Stop let through un-judged at stop_hook_block_cap, or a turn interrupted before
any Stop, records nothing — what it covered is delivered unseen again, so it is
re-judged rather than skipped.
Stop — a work cycle ended
Section titled “Stop — a work cycle ended”No fields. The end of a cycle is about the cycle, not one file, so a rule
bound to Stop has nothing on the event to narrow on and establishes its subject
another way — usually by reading a context’s accumulated registry
(State management), or by asking sr-session query /
sr-session trajectory about the transcript. Stop marshals to {"kind":"Stop"}
— an object, never a null — so .event.<anything> is a clean miss, not an error.
Declaring the fields as empty is what makes a match naming path on Stop
refuse at load rather than evaluate against nothing.
Which kinds a nature admits
Section titled “Which kinds a nature admits”A nature’s on: may name only the kinds its nature admits; the loader refuses
at load otherwise, rather than binding to something that silently never fires.
pre-file / PreCommandInvoke / PreToolUse |
Stop |
PostFile* / PostTagWrite |
|
|---|---|---|---|
| gate | yes | yes | no |
| context | yes | no (its exit is always checked on Stop anyway) |
yes |
| file-guard | binds to the file lifecycle by nature — names no kind at all (deletions: decides whether the delete kinds reach it) |
Alias availability follows from the table: PreFileWrite is admitted on both a
gate and a context; PostFileWrite is context-only (a gate does not wake on
settled content).
The payload envelope
Section titled “The payload envelope”The event never arrives bare — it is one field of a payload the check reads on
stdin (or a judge template renders against). The envelope differs by nature and by
which script it feeds; all of them carry the event flat under event. The Go
shapes are in internal/declaration/payload.go.
CheckPayload — a file-guard’s script / prepare / (prepare-less) judge
Section titled “CheckPayload — a file-guard’s script / prepare / (prepare-less) judge”{"event":{"kind":"PreFileCreate","path":"memories/a.md","newContent":"…","newMarkers":[]}, "transcriptPath":"/abs/…session.jsonl", "context":{"some-context":{"active":true,"payload":{…}}}}event— the file event, flat. APre*only when the guard ispreventive, otherwise aPost*. A*FileDeleteonly when the guard’sdeletions:isincludeoronly; withonly, never a create or update (File guard).transcriptPath— the session record, for reading what the event does not carry (which human message grounds this write). Also on$SR_TRANSCRIPT.context— every declared context by name,{active, payload}, at parity with the file-guard’s match scope.
GateCheckPayload — a gate’s script / prepare / judge
Section titled “GateCheckPayload — a gate’s script / prepare / judge”The same three keys, but event is any gate kind — a gate wakes on command,
tool and Stop events too, never a Post variant. context is carried at top
level, at parity with the gate’s match scope, so a gate’s checks can read what an
upstream context left behind.
ContextEnterPayload — a context’s enter
Section titled “ContextEnterPayload — a context’s enter”{"event":{"kind":"PostTagWrite","tags":[{"label":"research","seen":false}]}, "transcriptPath":"/abs/…session.jsonl", "gates":{"depth-check":{"status":"pass"}}, "currentContext":{"active":false,"payload":{…}}}event— the trigger that fired, flat. A context kind (aPostvariant is possible here).transcriptPath— the session recordenterreads to recognise “now it’s the time” and extract its payload.gates— every declared gate’s most recent verdict by name,{status}where.statusis"pass"/"fail". Letsentergate its own activation on another gate’s verdict.currentContext— this context’s own last{active, payload}, becauseenterruns on every trigger whether or not it was already active.
ContextExitPayload — a context’s exit
Section titled “ContextExitPayload — a context’s exit”The same shape, but event is always the Stop that triggered the check
(exit is consulted on nothing else, so only .event.kind is meaningful).
currentContext is this context’s own entry — payload is what enter produced.
gates is again every gate’s verdict, which is how a thin exit mirrors a paired
gate:
status="$(printf '%s' "$input" | jq -r '.gates["depth-check"].status // "fail"')"[ "$status" = "pass" ] && exit 0exit 1See Context for enter/exit in full.
The judge inputs — FileJudgeInput / GateJudgeInput
Section titled “The judge inputs — FileJudgeInput / GateJudgeInput”A judge renders against the payload spread flat at the template’s top level
({{ event.newContent }}, {{ transcriptPath }}, {{ context }}), plus
additionalContext as one more top-level field, present only when the check’s
own prepare returned one:
## The rules it must follow<rules>{{ additionalContext.rules }}</rules>A file-guard’s judge also gets {{ change }}, the unified diff of the event’s
oldContent to its newContent (Judge checks).
additionalContext is additive — it never replaces the payload, and a prepare
key cannot collide with event or transcriptPath (it renders under the single
additionalContext field). See Judge checks.
Past events
Section titled “Past events”sr-session trajectory normalize re-derives each entry’s events and returns them
under the entry’s .events[], flat — kind beside the event’s own fields,
exactly the shape of the live .event. A check asking “did a real git clone
happen this run?” reads a past event the way it reads the one in front of it:
sr-session trajectory normalize --path "$tp" --events PreCommandInvoke \ | jq '[.[] | .events[] | .invocations[]? | select(.bin == "git")]'