Audit
ral records execution as a flat, lexically-scoped trail. The audit
operator runs its body and returns, alongside the body’s value, the trail of
what happened — commands, redirect reads and writes, capability checks,
worker births — so a run can be inspected after the fact. It is one of the
five control operators for the same reason as
the others: the trail it threads lives in Shell state, not in any value the
body could construct. Every fact is one vocabulary, Observation
(core/src/types/observation.rs) — the same one the surface rail and
--audit’s JSON project, and the same one a host author speaks (exarch’s
desk records its committed acts as Observed::Act, host-side, into a
per-call fragment of its own rather than the engine’s trail) — so a command,
a write, a read, a capability check, and a worker’s birth are one shape
apiece rather than five private ones.
Collection is lexical, not temporal, and the trail itself is flat.
- Each scope-producing site —
audit, and the other operatorswithin/grant/guard/try— owns the trail its body produces: it opens (or inherits) collection, and everything the body observes lands in that same flat list. None of them builds an observation of its own; they are collection boundaries, not entries in the trail. - Source nesting decides which collection an observation lands in, regardless of which process or thread produced it.
- A process boundary — the OS-sandbox child a
grantre-execs into (grant), a pipeline-stage helper — only transports its fragment back to the owning evaluator, which merges it into the surrounding trail; the boundary never decides structure. - A delimiter’s lifecycle is scope-shaped, not one-shot: opening either
installs a trail or finds one already open, and whichever happened decides
what closing does. The delimiter that installed the trail drains and closes
it on every exit, panics included, so the next dispatch starts from nothing;
a delimiter that found one already open reads back only its own suffix and
leaves the trail intact for its opener to close later. Nobody but the
opener closes — that discipline is what keeps a
tryor anaudit { }from leaving the trail standing open for the rest of the session.
So a sandboxed grant { … } merges its body’s commands into the nearest open
trail rather than losing them at the process boundary, and a transported
fragment stays self-describing without needing a parent to interpret it.
Each observation is self-describing about who and how it happened:
- every observation carries the
principalin force where it was recorded, so the trail records who as well as what, and a transported fragment still names its actor —Nonein Rust, the empty string in the projection, where no$USERis bound and there is nobody to name; - an observation carries only its own kind’s fields — a command’s
argv, a capability check’sresource/decision— never a handler frame or capability map, and never another observation’s fields; - tail-recursive iteration adds no wrapper: a loop contributes one flat run of observations, not a chain as deep as the iteration count, matching how dynamic scope persists across tail calls.
What builds up the trail is itself scoped:
- plain execution records nothing unless a host asks: a dispatching host
(exarch’s tool call) may open its own delimiter over the whole run —
Run.trail: Somein the transport protocol — and get the extent’s trail back on theReport, each observation in the sharedObservationmap shape, with an`opaqueplaceholder wherever a value has no wire form; the REPL never asks, and asking costs nothing beyond what the surface rail already builds per command; trykeeps only its flat error record;- a
grantcontributes capability-check observations only when it setsaudit: true; of those, a denied head admission also reaches the surface rail whether or not a trail is open, while an allowed check never does. Anfsor full-argv denial reaches the trail alone — the command it refused still surfaces in its own right, as a failed command observation carrying the denial message; auditcollects the full trail its body produces.
A write that changed nothing in the world is not a fact. A redirect onto
the discard device — /dev/null, or NUL on Windows — records nothing: no
card, no rail barrier, and no line in an agent’s trail claiming it wrote a
file. One predicate says so, ResolvedPath::is_discard
(core/src/path/resolved.rs), asked at both doors that have an opinion: the
capability gate, which excuses such a target from an access
(capability-enforcement), and
observe_stamped itself, the one fan-out door, which excuses it from a
mutation. Every redirect seam already passes through that door, so the rule
is stated once and no seam can forget it.
See also syscalls-are-effects — an audit trail is a trace of the operations performed and the scopes that framed them.
Recording lives in core/src/evaluator/audit.rs (evaluator);
the dispatch delimiter is the run door’s own scope, held at Shell::enter in
core/src/run.rs, outside the catch_unwind a panicking run rolls back
through — a panic still drains and closes the scope, but reports Static
rather than carrying a trail; docs/SPEC.md §13.3 gives the formal
account.