Audit
ral records execution as a flat, lexically-scoped trail. The audit
operator runs its body and returns a report — how the body settled, beside
the trail of what happened: commands, redirect reads and writes, capability
denials, worker births — so a run can be inspected after the fact. It is one of
the seven 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/audit/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 search, a capability check, a worker’s birth, and a host
act are one shape apiece rather than seven private ones.
The report has two fields, and neither is a second copy of something else.
outcome is `ok of the body’s value or `err of exactly the error
record try hands its handler — so try, poll, and audit report failure
in one vocabulary, and a reader that can read one can read all three. trail
is the list of observations. Nothing else: no status field duplicating the
process exit status the host already owns and audit never changes, and no
hint appended to the message, because a hint is advice for a person and a
report is data. An ordinary failure settles into `err; an
exit escape and a stop are not failures and walk past the report entirely
(failure).
The kind is the tag. An observation is an envelope — an optional site,
start, end, principal — around one what, a variant over
`command | `write | `read | `grep | `check | `worker | `act. There is no
kind string beside it, so there is no second place for the kind to be
recorded and nowhere for the two to disagree; a reader dispatches with case
and the typechecker decides exhaustiveness. `grep and `act are
raised by host doors, never by a door in core.
The two questions almost every reader asks — did it work and what ran —
are prelude functions rather than idioms to be rediscovered: succeeded $report and commands $report, the latter handing back the `command
payloads in order. A case on what is for the reader who cares about the
other six tags.
A builtin application is not an observation. The trail records facts at
the doors to the world — an external or detached process, a redirect’s read
or write, a worker’s birth, a capability check, a host door — and a builtin
reaches the world only through one of those doors, which records the effect
there, typed as what it is: edit-hash lands as the `write its
atomic_write commits, spawn as its `worker, a == comparison as
nothing, because the pure fragment performs no effect
(syscalls-are-effects). The inventory of
observed doors is the inventory capability enforcement guards —
check_fs_op is asked at the same doors — so a builtin that could change the
world past both would be a missing door, not a reason to observe its frame.
The rule is what keeps the trail proportional to effects rather than to
β-reductions: observing every builtin application once retained a stamped,
teed, argv-rendered record per application, and a filter over a few
thousand grep hits became millions of them.
Retention is one rule for both byte streams: stdout and stderr each
retain an exact prefix of at most 1 MiB, and stdout-total and stderr-total
count every byte observed at the tee; truncation is exactly
total > length bytes. A detach birth, a direct-spawn stage, or byte
capture off yields empty bytes and total 0. No marker is mixed into the
command’s bytes. Neither cap is an I/O limit — every byte still reaches its
ordinary destination, since auditing is observational and a tee is not a
gate. Ordinary value capture and worker buffers retain their independent
16 MiB limit.
Collection is lexical, not temporal, and the trail itself is flat.
- Two sites delimit a trail, and only two: an
audit { }and the run door (ral --audit, or a dispatching host’s tool call). Each owns the trail its body produces — it opens or inherits collection, and everything the body observes lands in that same flat list. Neither builds an observation of its own; they are collection boundaries, not entries in the trail. - The other operators —
within/grant/guard/try— delimit nothing. They are transparent to collection, so their bodies’ observations land in the enclosing trail, and atryin particular costs no trail at all: nothing it does needs one. - Source nesting decides which collection an observation lands in, regardless of which process or thread produced it.
- A boundary — the OS-sandbox child a
grantre-execs into (grant), or a pipeline stage’s thread (stages-are-threads) — only transports its fragment back to the owning evaluator, which merges it into the surrounding trail; the boundary never decides structure. - A pipeline thread publishes each observation live. Its collector merges that observation into the enclosing trail without publishing it again; a direct external stage’s observation is first published by the collector.
- 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 nested
audit { }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.
Within a trail, position is settlement, and nesting is containment. An
observation is appended when the fact it records becomes true, not when the
construct that produced it began: a command’s own observation therefore lands
after those of everything it ran, and a redirect’s `write lands when the
write settles (a streaming write when its file opens). The order is a post-order traversal of what happened, and it is
the one order a flat list can carry without lying — an in-order list would have
to claim a command was finished before the work inside it was.
Nesting is then recovered from the timestamps: one observation’s start–end
interval contains another’s exactly when its extent contained the other’s.
That is why the flatness costs nothing. Containment needs no parent pointers,
so it survives a fragment’s trip back across a process boundary, and it holds
of a fragment merged out of order just as well as of one recorded in place —
where a parent-pointer tree would have had to be stitched back together by
whoever received 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,`nonein the projection, where noUSERis bound and there is nobody to name; - an observation carries only its own tag’s fields — a
`command’sargv, a`check’sresource/decision— never a handler frame or capability map, and never another tag’s fields. A`commandcarries no returned value either:argv, the status, and the bytes are what a later reader can act on, and a process-local or executable value has no honest projection to offer one; - an optional field is a variant,
`someor`none, never a missing key: “there was no before-image” is a fact the trail states, not one a reader infers from silence. A before-image is a read: under a grant that admits no read of the target, the write still lands andold-bytesis`none— decided at the door (Shell::admits_fs_exact), before the bytes could enter any observation, never hidden afterwards by a renderer; - 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; neither the REPL nor exarch’s tool call asks: the surface rail already hears every observation, and a trail would also retain each until the run ends; tryreads nothing. The failing command’s name is stamped on the error by the dispatch that failed, so the innermost failing dispatch wins the way the innermost source span does, andtrycosts no more than its own frame. It never had a reason to consult the trail: a name recovered by scanning observations is a guess about which of them was the cause, whereas the dispatch that raised the error knows. This also decouples the two — the error record is complete whether or not anyone is collecting;- a
granthas no say. Whenever a trail is open, a capability denial is recorded as a`checkobservation and an allowed check never is: the denial is a fact about authority nothing else in the trail attests, while everything an allowed check would attest is already attested by the command, read, or write it let through. There is no flag to set and none to clear, so no inner grant can hide a denial from the trail its caller opened. A denied head admission also reaches the surface rail, whether or not a trail is open; anfsor full-argv denial reaches the trail alone — the command it refused still surfaces in its own right, as a failed`commandobservation 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, LexicalPath::is_discard
(core/src/path/forms.rs), asked at both doors that have an opinion: the
in-process guard, which excuses such a target from an access
(path-judgment), and
Shell::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.
One module owns it all: core/src/types/audit.rs is the trail (Audit, with
TrailScope carrying the capture policy it displaced), types/audit/door.rs the
fan-out (impl Shell { observe, observe_stamped, listening, audit_start, record_check }, and Shell::observation, which stamps the call site and
principal), types/audit/observation.rs its constructors; the records themselves are
fact::observation, each a record!/variant!/label! so one declaration
yields the encoding, its decoder and its ral type. A Check is
{ resource, fields } and takes its decision from the resource, so a denied
deputy is not representable. evaluator/call.rs keeps only the call frame
(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.