Fail-Closed Semantics
The invariant: when in doubt, deny. A guard that crashes, times out, loses a lock, or hits a parse error denies the call. The kernel signs the deny receipt the same way it would sign an allow. There is no fallback path that bypasses guards on failure.
The Invariant
The synchronous GuardPipeline encodes the invariant in one short match:
match guard.evaluate(ctx) {
Ok(Verdict::Allow) => continue,
Ok(Verdict::PendingApproval) => { /* sticky, keep iterating */ }
Ok(Verdict::Deny) => {
return Err(KernelError::GuardDenied(format!(
"guard \"{}\" denied the request",
guard.name()
)));
}
Err(e) => {
// Fail closed: guard errors are treated as denials.
return Err(KernelError::GuardDenied(format!(
"guard \"{}\" error (fail-closed): {e}",
guard.name()
)));
}
}Two consequences:
- A returned
Errand a returnedOk(Deny)both short-circuit. The receipt distinguishes the two by the message (the error path includes(fail-closed)). - The pipeline never silently downgrades to
Allowon failure. There is no best-effort mode for the synchronous catalog.
Failure-Mode Matrix
Every failure class collapses onto one of two outcomes. Either the pipeline turns it into a deny, or the wrapping kernel does.
| Failure | Verdict | Reason | Side Effects |
|---|---|---|---|
Panic in evaluate | Deny | Kernel catches the unwind and converts to GuardDenied. | Receipt records guard name; tracing logs the panic payload. |
| Mutex poisoned | Deny | Guard returns KernelError::Internal; pipeline maps to GuardDenied. | Lock state stays poisoned; subsequent calls also deny until the guard is restarted or its state is cleared. |
| WASM fuel exhausted | Deny | Wasmtime aborts execution; the WASM-host guard returns an error. | Per-call deadline metric increments; module instance is reaped. |
| Circuit breaker open | Deny by default | CircuitOpenVerdict::Deny is the adapter default. | No external call. Operators can opt into Allow per-adapter for advisory deployments. |
| Parse error on input | Deny | Guard returns KernelError::Internal. | Receipt records the guard name and a fail-closed marker; raw input is never echoed. |
| Regex compile failure (load time) | N/A | Caught at construction; the guard never reaches the pipeline. | Kernel start-up fails. Misconfiguration is a load-time error, not a runtime denial storm. |
| Network timeout (external guard) | Deny after retries exhausted | retry_with_jitter retries until RetryConfig.max_retries; final failure becomes Verdict::Deny. | Failure recorded on the breaker; cumulative failures may open it. |
| Journal unavailable (session-aware guard) | Deny | Guard returns KernelError::Internal. | tracing event with guard name; receipt records the deny. |
| Rate limiter empty | Deny by default | RateLimitedVerdict::Deny is the adapter default. | No external call; no breaker increment. |
| Ambiguous input (cannot decide allow vs deny) | Deny | Author convention: when the guard cannot prove allow, return deny. | Receipt records the deny with the guard's reason. |
| Internal exception (any other) | Deny | KernelError mapped to GuardDenied with (fail-closed) tag. | tracing event; receipt persisted. |
Why the Kernel Catches Panics
A panic inside a single guard should not unwind the kernel process. The kernel wraps guard evaluation in a panic-catching boundary and converts the unwind into KernelError::GuardDenied. That preserves three invariants at once:
- The kernel keeps serving other requests after a buggy guard panics.
- The receipt for the panicked request is still signed and persisted, so the audit log is gap-free.
- The agent receives a clean error, not a silently dropped call or a hung connection.
Operators should still treat a panic as a bug. The tracing event carries the panic payload, the guard name, and the request id, which is enough to reproduce.
The Advisory Exception
The AdvisoryPipeline does not deny on success. That is the whole point of advisory mode: signals are recorded, the request proceeds. There are two narrow carve-outs:
- Promotion to deny. If a
PromotionRulematches a signal at or above itsmin_severity, the advisory pipeline returnsVerdict::Denyfor that call. The signal is markedpromoted = true. - Failure of an advisory guard. If an
AdvisoryGuard::evaluateitself returnsErr, the advisory pipeline propagates it. The wrappingGuardPipelinethen maps the error toGuardDenied. Advisory means non-blocking on success, not non-blocking on failure.
Advisory Allow modes are deliberate
CircuitOpenVerdict::Allow and RateLimitedVerdict::Allow exist for guards whose outputs feed a review queue rather than gate an action. If an external guard is the last line of defense on a capability, leave both at the Deny default.Example: Err Becomes a Denied Receipt
Concrete walk-through. A guard that needs to read a journal lock returns an internal error when the lock is poisoned:
impl Guard for SessionVelocityGuard {
fn name(&self) -> &str {
"session-velocity"
}
fn evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError> {
let counts = self.journal
.tool_counts()
.map_err(|e| KernelError::Internal(
format!("session-velocity journal error: {e}"),
))?;
match counts.get(&ctx.request.tool_name) {
Some(n) if *n >= self.limit => Ok(Verdict::Deny),
_ => Ok(Verdict::Allow),
}
}
}When the journal lock is poisoned, the guard returns Err(KernelError::Internal(...)). The pipeline turns that into:
KernelError::GuardDenied(
"guard \"session-velocity\" error (fail-closed): session-velocity journal error: poisoned lock"
)The kernel signs a receipt with verdict Deny and a reason field that contains that message. The agent receives a denial; the audit log has a gap-free record of why.
Operator Guidance
- Treat denial spikes as both signal and noise. A spike of fail-closed denials may mean a guard is misbehaving (regex bug, journal corruption) rather than the agent attempting something forbidden. The receipt message tells you which.
- Validate at load time. Move regex compilation, JSON-schema validation, and config parse into the guard's
new()path. A configuration error should fail kernel start-up, not generate a denial storm at traffic time. - Do not catch panics inside a guard. Let panics bubble. The kernel's panic boundary turns them into fail-closed denies and emits the right tracing event. Catching inside the guard hides the bug.
Where to Go Next
- Pipelines & Composition · the full
evaluateloop and how verdicts combine - Advisory Signals · the only pipeline that does not deny on success
- Failure Recovery · operator runbook for fail-closed events
- Receipts · what a denied receipt looks like