Observe and enforce
The two modes, the signed owner act without which no stage enforces, and what the agent and the record see when a call is stopped.
The two modes
| mode | what happens on block, error, a timeout or no answer |
|---|---|
observe (default) | the decision is recorded; the call goes through. Responses stream to the agent as they arrive |
enforce | the call is stopped. The whole response — including a streamed one — is held until the plugin has decided, then released or stopped |
Start in observe. Read the audit records until the plugin's decisions are the ones you want, then
arm enforcement.
Enforcement needs a signed act
Setting a stage to enforce is only a request. A stage actually enforces only when the file named
by guardrail.enforce_acts holds an act that:
- is signed with the installation's root key, and
- names this plugin (its id and its manifest's SHA-256), this stage, the actor and the time it was recorded.
The file is read where the stage runs: on the machine where the agent runs for the model stages, on
the dispatching machine for spawn.handoff, and on the gateway's machine for bridge.ingress. For the
model stages the path is the dispatching machine's setting, passed on unread: the acts file, the
plugin manifest and the plugin executable must exist at those absolute paths on the agent's machine.
Each of those machines must also hold the pinned root public key — ~/.saphan/trust/root.key.pub
on a per-user install — to verify the act.
Produce and sign the act with:
| the acts file | result |
|---|---|
| not configured, or holds no act for this plugin and stage | the stage observes, and the proxy says so when it starts: OBSERVES, and the configuration asked for ENFORCE … [guardrail-enforce-not-authorized] |
| configured but does not verify — a bad line, or no pinned root public key on that machine | the proxy does not start, and the handoff and ingress stages refuse. A broken acts file is never read as "observe" |
| holds a matching, verified act | the stage enforces |
Three consequences are deliberate:
- A configuration change alone cannot arm enforcement. Someone with the root key must sign it.
- Re-pinning the plugin disarms it. The manifest digest is inside the signed act, so a new plugin version observes until a new act is signed.
- A plugin cannot arm itself. A manifest that tries to carry a mode or an "enabled" switch is refused.
What the agent sees when a call is stopped
Every response the proxy writes itself carries the X-Saphan-Guardrail header (stop, or
upstream-unreachable).
| situation | response |
|---|---|
the plugin blocked the call (in enforce) | 403, guardrail_stop |
the plugin failed, timed out or gave no decision (in enforce) | 502, guardrail_stop |
the request could not be read, or its path is not one the proxy admits — in observe and enforce alike | 502, guardrail_stop |
| the model provider could not be reached | 502, guardrail_upstream_unreachable |
The body is a fixed error object — stage, request id, mode, decision, reason code and rule id, with a fixed message. The plugin's own message is never passed to the agent.
What the record shows
Each decision of the model stages is written to the run's guardrail audit file,
.saphan-guardrail/audit.jsonl in its working directory, one line per decision. A spawn.handoff
decision goes onto the run record's enforcement note instead, and bridge.ingress decisions are
written to .saphan/guardrail/bridge-ingress.jsonl in the workspace on the gateway's machine.
The audit line's fields:
| field | meaning |
|---|---|
| stage, plugin id and version, mode | where and by what the decision was taken |
| decision, action, would_stop | what the plugin said, what the proxy did, and whether enforce would have stopped it |
| rule id, reason code | the plugin's reason |
| plugin failed, latency | health of the plugin |
| items | for each judged item: its id, kind, digest and size — never its content |
would_stop is what makes observe useful: it shows, for every call that went through, whether
enforcement would have stopped it.
A run that is not recorded or judged says so on its run record, with the reason.
Refusals before a run starts
| refusal | meaning |
|---|---|
guardrail-unavailable | the run's backend is judged, but the guardrail cannot run for it — an agent it does not cover, a lane it cannot be carried into, or a remote saphan too old for it. See Coverage. When the lane is the cause, --egress-open runs that one run unwalled; for the other causes, change the agent or update the remote build |
guardrail-handoff-refused | the spawn.handoff stage blocked what the agent was about to be given — or, in any mode, the plugin could not be loaded or the hand-off could not be read |
| a refused push at the bridge | the bridge.ingress stage refused an arriving commit. The refusal carries the plugin's own rule id, or bridge-refusal-guardrail-plugin when it has none |