What lands in the audit record
⚠ The record surface is BUILT, NOT WIRED (§8) — the row, the store and the table all ship; nothing constructs the store, so no judgement is ever written down. THIS BOX SAID "the record surface is NOT BUILT — nothing…
⚠ The record surface is BUILT, NOT WIRED (§8) — the row, the store and the table all ship;
nothing constructs the store, so no judgement is ever written down. **THIS BOX SAID "the
record surface is NOT BUILT — nothing assembles a per-judgement record and there is nowhere to
put one" ** the product assembles
one — Fact, with the seven fields listed below — and the product puts it
somewhere: the egress_fact table, created by migration 53 on both backends, with a reader
EgressFacts(runID) and a unique index that makes a permitted host/port idempotent per run. It
is the SECOND time this box has been wrong in the same direction: it also named four things
absent What actually ships: Verdict with
its VerdictAllow / VerdictDeny (the product), the Approved and Refusal
outcomes (core.go, refuse.go), the Claim{Source, Policy} digests that are a policy's
identity (document.go, §4.5), and — the piece this box denied longest — the record itself, a
timestamp included (Fact.At).
WHAT IS ACTUALLY MISSING IS ONE CALL, AND THE CONSEQUENCE IS EXACTLY AS BAD AS IT SOUNDS.
NewFactStore has no caller outside the product, so CoreOptions.Record arrives nil and
every door this engine opens judges without recording. ⇒ A governed run's decisions are
not on any surface you can query afterwards — the wall holds, and it leaves no trail. Read
everything below as the shape a record HAS in this build, not as a table you can go and read
today. ⛔ This document does not say when that call will be written; §8's row prices it.
The nearest shipped thing, named so you are not surprised by it: the per-run door keeps two aggregate counters — conversations and refusals (the product). Two integers are not the per-judgement record described below, and nothing in production reads them either.
Each judgement records: the host asked for · the port asked for · the verdict · the reason (the refusal class of §6, or the rule that admitted it) · a timestamp · the identity of the policy that decided.
Never a URL path — because secrets live in URLs
Pre-signed URLs, API keys pasted into query strings, session tokens in redirect parameters: URLs carry credentials constantly, and a log of full URLs is a credential store nobody meant to build.
No URL path can appear in this record, and that is structural rather than a setting. The proxy parses no HTTP (§3.3), so a URL path never exists in a form anything could record. There is no redaction rule here that could be misconfigured, disabled, or forgotten in a future release — the data is not present to be leaked.
Two smaller properties in the same family. The record carries the host and port because the operator needs them; the client is told neither, so the refusal returned across the wire cannot be used to confirm what a policy knows (§6). And where a refusal names a ceiling, the record carries the ceiling itself and not the observed value.
When the door itself dies — the egress-door-died runbook
Read this when a run row's status column reads egress-door-died. It is a terminal status
like any other and it means one specific thing: the per-run SOCKS5 door stopped serving while the
child was still running, and this engine ended the run rather than let it keep spending against a
boundary that was no longer there.
Why the status exists at all. The wall is fail-closed, which is correct — if the door dies the agent reaches nothing. But reaching nothing is not stopping: the leg keeps thinking, keeps burning wall-clock and keeps spending quota, and it looks exactly like work until it ends. Before this status, a run whose door had died and a run whose agent simply had no outbound work produced the same silence and the same green row, and one of those is a defect while the other is a policy working perfectly.
What the row tells you
| field | what it says |
|---|---|
status | egress-door-died — the token both a reader and a --json consumer key on |
enforcement_note | one line beginning egress-door-died: carrying class=…, detected=…, how long the door served, and how much of the mandate was left unused |
The class= is the part that decides your next act, and there are three:
| class | what happened | first thing to look at |
|---|---|---|
socket-unlinked | the socket path is gone while the listener was still healthy | whatever removes files under <home>/.saphan/egress/ — a cleanup script, a tmp reaper, a hand |
socket-replaced | the path exists and holds a different inode than the one bound | another process binding the same run id's path; this is the one a successful dial cannot reveal |
accept-loop-dead | the listener stopped accepting and nobody closed it | host descriptor exhaustion (EMFILE), or something closing the listener out of band |
⛔ The two remedies that are WRONG here
- A longer
--wall-clock. The run did not time out. The note prints the mandate it did not use precisely so you can see this without re-running anything. - A raised
--cap-usd. The run did not hit a cap either. Nothing about the budget was involved.
Reaching for either costs a whole second dispatch to discover that the first one had most of its wall left. The remedy is on the host, on the socket, at the path the note names.
What you do, in order
- Read the
class=and thedetected=off the note. They are on the run row; you do not need the host. - Look at the socket path the note names (
<home>/.saphan/egress/<run-id>/d.sock). It is already gone — the engine unlinks it when a run ends — so this step is about the directory and what else has been touching it. - Decide whether it can recur. A one-off (a reaper that ran once) and a standing condition (a
cleanup job on a timer, a host permanently short of descriptors) need different answers, and the
detected=timestamps across several rows are how you tell them apart. - Re-dispatch the leg unchanged. No flag needs to change: the leg was stopped by the engine,
not by its own limits, and its work — if it had any uncommitted — was rescued by the same
rescue-commitmechanism a wall-clock kill uses.
What it costs to have this
Nothing that is billed and nothing that is metered. The check is two atomic loads and one
lstat(2) per interval — no connection to the door, no bytes on the wire, no policy judgement, no
vendor call. Measured on darwin/arm64 under go1.26.5: 4252 ns and 3 allocations per reading,
against a bare os.Lstat floor of 3569 ns — 84% of a reading is the syscall it cannot avoid.
At the one-second default interval a 100-minute run pays about 25 ms of CPU in total.
remark: those figures are one host on one day and the ratio is the durable part of them, not the absolute ns — measure it on the host you care about rather than quoting this line.
Why one second, and how to change it
The interval bounds detection for socket-unlinked and socket-replaced only;
accept-loop-dead is reported by the accept loop itself at zero latency. One second makes the
worst-case unobserved slice 1/6000th of a 100-minute wall, which is below the noise on any cost
figure the record carries, while costing the 25 ms above. A caller may pass a different
LivenessInterval; the interval in force rides every reading, so a weakened bound is visible in the
record rather than something a reader has to go and look up.
There is no way to turn the guard off, deliberately. A door whose death can be made silent again by a caller is the defect this mechanism exists to remove.