Gate
A gate is a checkpoint on an event. It wakes on the events its on: names,
optionally requires some precondition, runs its checks, and blocks if they
refuse. Unlike a file-guard it is one-shot — it fires on the event, decides,
and is done; it does not re-fire until a file settles.
.sloprail/gate/<name>/gate.yaml# require-skill-topics — writing under memories/topics/ is blocked until# document-topic was loaded this session. `require` is the whole rule; no checks.on: - event: PreFileWrite match: event.path startsWith "memories/topics/"require: - skill: document-topic# verify-artifact-produced — on Stop, if a tag was declared this turn, the# matching artifact must have landed. Reads a sibling context's registry.on: - event: Stop match: context["tag-declared"].activerequire: - context: tag-declaredchecks: - script: ./verify-tag-and-artifact.shThree keys. on is the list of triggers — each an event kind and an optional
match. require is a list of preconditions that must already hold. checks is
the list of checks — each a script (Script checks) or a
judge (Judge checks). A gate needs on; the other two are
optional, but a gate with neither require nor checks decides nothing.
The gate scope: event.*, nested
Section titled “The gate scope: event.*, nested”A gate’s match — and its checks’ stdin — nest the event under event:
event.path, event.invocations, event.tags, plus the context map. This is
the asymmetry with a file-guard, whose match reads path bare. A gate is not “a
file at a path” by default, so there is no glob shorthand — a gate narrowing
on a path writes it out: event.path startsWith "memories/decisions/".
match is checked against the fields of the kind the trigger fires on, so
event.invocations type-checks on a PreCommandInvoke trigger and
event.path on a PreFileWrite one. A name the kind does not declare
(event.paht) is refused at load.
What a gate triggers on
Section titled “What a gate triggers on”Any pre-action event kind, plus Stop — never a Post variant (a gate
that already happened is too late to gate). Events has the full
per-nature admission table and every kind’s fields; the ones a gate is written on:
An event about to happen — the pre-action gate
Section titled “An event about to happen — the pre-action gate”The gate refuses before the action, preventing it. Examples:
PreFileWrite— the alias the engine expands toPreFileCreate+PreFileUpdate, so one trigger covers both.event.path startsWith "memories/decisions/". (A gate cannot usePostFileWrite— that alias is context-only.)PreCommandInvoke— a shell command line about to run. It carries the flattenedinvocationsit parsed (below).PreToolUse— a tool call about to run.
The turn as a whole — the Stop gate
Section titled “The turn as a whole — the Stop gate”Stop fires once when a work cycle ends, last and unconditionally — whether
or not anything changed. This is the right trigger for a rule about the result
of a turn (“the turn promised an artifact; did it produce one?”).
A Stop refusal is reported to the agent as a blocking error on the cycle, and the cycle’s read mark does not advance — so the next Stop judges the same span again, and a rule that stays unsatisfied stays reported rather than scrolling away.
The agent’s corrected reply fires Stop again, and that retry is judged like
any other Stop: replying twice does not get the agent past a rule. A reply that
still breaks the rule is refused again, so the loop runs until a reply passes.
The project caps how many refusals in a row it will take, in .sloprail/config.yaml:
stop_hook_block_cap: 8 # the default, matching Claude Code's own cap# 1 — refuse once, then let the retry end un-judged# 0 — no engine cap (Claude Code's CLAUDE_CODE_STOP_HOOK_BLOCK_CAP still applies)When the cap is reached the turn ends with the refusal still standing, and the engine says so on stderr. Nothing is marked judged, so the next cycle sees the same work again.
Stop carries no fields — the end of a cycle is about the cycle, not one
file. So a Stop gate has nothing on the event to narrow on; it establishes its
subject another way:
- By reading a context’s accumulated state — the usual pattern. A context
logs what it saw into
sr-session state; the Stop gate reads that registry back (below, and State management). This is why a Stop gate’smatchso often readscontext["…"].active. - Or by asking
sr-session query/sr-session trajectoryabout the transcript (via.transcriptPath).
Because a Stop gate fires every cycle, it can wedge a session wholesale rather than for one file. Refuse on the rule’s own logic, and permit when the rule’s plumbing fails (State management, “fail closed on the logic, open on the plumbing”).
Inside PreCommandInvoke: the flattened invocations
Section titled “Inside PreCommandInvoke: the flattened invocations”One command line is rarely one program. A pipeline, an && chain, a subshell, a
sudo or an xargs each nest invocations inside a single string. The module
walks that structure once and emits every invocation it finds, flattened, so
no rule has to recurse through shell syntax — and nesting an invocation one level
deeper does not defeat a rule written against it.
So a gate narrows on the invocations list, not the raw line:
any(event.invocations, .bin == "curl")not any(event.invocations, .bin == "npm")len(event.invocations) > 1any(event.invocations, .bin == "rm" and any(.argv, # == "-rf"))Each invocation’s fields — .bin, .argv (both with declared element shapes, so
a mistyped key inside a predicate is refused at load) and the open .flags
map (verified against nothing, so a mistyped flag evaluates false forever — cause
the command and watch it fire before trusting one) — are set out in full in
Events. The gate-specific point is only that you match against the
flattened list.
The resolution floor
Section titled “The resolution floor”A program named by a variable, a payload decoded and piped to a shell, splitting
that depends on the runtime IFS: none of these can be known without running
them, and running them is exactly what a guardrail must not do. What can be seen
is emitted; what cannot is left alone rather than guessed at. This is a
correctness aid, never a security boundary — a rule that assumes every way of
invoking a program is visible here believes more than the parser promises.
There is no Post counterpart to PreCommandInvoke. A file has a settled state
a diff establishes afterwards; a command that already ran has no equivalent —
what it changed shows up as the file events, which is where a rule about
consequences belongs.
require: a precondition that must already hold
Section titled “require: a precondition that must already hold”require is a list of things that must be true before the gate’s checks even
run. If a requirement is unmet, the gate refuses on that alone — require can be
the whole rule, with no checks at all.
require: - skill: document-topic # this skill was loaded this session - skill: authoring-guardrails # AND this specific page inside it was read files: [file-guard.md] - context: tag-declared # this context is currently active - citation: {source_types: [user]} # the action cites the user's wordsThree forms in use:
skill: <name>— the named skill was loaded this session. “Writing undermemories/decisions/is blocked untildocument-strategywas loaded” is a gate whoserequireis exactly that, and nothing else. A separate gate per prefix→skill pairing, rather than one gate branching internally, becauserequirebinds to the whole gate.files: [<name>, …], alongsideskill, optional — page(s) INSIDE that skill (relative to its own directory) that must ALSO have been read (a Read tool_use, or a file-reading Bash command). Loading a skill only guarantees itsSKILL.mdwas read, not any page it merely links to.
citation: {source_types: [...]}— the action carries a citation that resolved in one of the named pools:user(the user’s own words) ortool_result(a tool’s output). The pools are always named. For a command, the agent chains a cite in front of it:sr-session trajectory cite '<exact quote>' && git push. Only a gate onPreCommandInvokeor aPreFile*kind may require one; onStoporPreToolUseit is a load error. See Grounding.context: <name>— the named context is active. This is also what makes a Stop gate’s cross-context read safe:require: [{context: tag-declared}]guarantees that context entered this cycle before this gate’s check runs, so the registry the check reads back is current.matchreadscontext["…"].active, but that read is only meaningful once the context has actually run its enter this cycle — whichrequire’s ordering guarantees.
A gate that reads a context’s sr-session state registry via --owner without
a matching require reads stale, prior-cycle state — a footgun. The --owner
read supplies the entries; require supplies the ordering. See
State management.
Note the two roles the context plays in the artifact example above: match: context["tag-declared"].active skips the gate declaratively when the context
never activated, and require: [{context: tag-declared}] orders the context’s
enter before the check. A sibling gate can catch the opposite case — no tag at
all — with match: not context["tag-declared"].active and no require (there is
nothing for the context to have run first).
Reading a context’s registry from a gate’s check
Section titled “Reading a context’s registry from a gate’s check”A Stop gate’s check reads back what a paired context accumulated. The context
logs each subject under its own name into sr-session state; the gate reads the
group with the cross-guardrail --owner read (list only, read-only):
# state list emits JSON-LINES, so SLURP with `jq -s` before treating it as one.entries="$(sr-session state list --owner tag-declared 2>/dev/null)"tags="$(printf '%s' "$entries" | jq -s -r '[.[] | select(.key | startswith("tag:"))] | .[].key | ltrimstr("tag:")')"The gate’s own require: [{context: tag-declared}] is what makes those entries
current. Full treatment — the JSON-lines shape, --owner, and why require is
load-bearing — in State management.
Turning one off
Section titled “Turning one off”Keep the folder; disable the gate from .sloprail/config.yaml by its qualified
name (the same mechanism that disables a plugin’s gate):
disabled: - <plugin-or-project>/gate/<name>The nature is part of the key — .../gate/<name> — because a gate and a context
may share a bare name. Keep the sibling prose that records why the gate exists.