PlatformFoundations
Fail-Closed Semantics
Guard errors, timeouts, lock failures, and parse failures deny the call. The kernel signs deny and allow receipts.
The Invariant
The kernel's pre-dispatch guard loop maps each guard result to the invariant. Each guard returns a GuardDecision (a Verdict plus evidence) or an Err; the loop turns any deny, any unsupported approval verdict, and any error into KernelError::GuardDenied:
for guard in guards {
match guard.evaluate(ctx) {
Ok(decision) => {
evidence.extend(decision.evidence);
match decision.verdict {
Verdict::Allow => { /* passed; keep iterating */ }
Verdict::Deny => {
return Err(GuardRunError::new(
KernelError::GuardDenied(format!(
"guard \"{}\" denied the request",
guard.name()
)),
evidence,
));
}
Verdict::PendingApproval => {
// The Guard trait does not carry the HITL approval flow;
// a bare guard returning PendingApproval is unsupported,
// so fail closed.
return Err(GuardRunError::new(
KernelError::GuardDenied(format!(
"guard \"{}\" returned an unsupported approval verdict",
guard.name()
)),
evidence,
));
}
}
}
Err(e) => {
// Fail closed: guard errors are treated as denials.
return Err(GuardRunError::new(
KernelError::GuardDenied(format!(
"guard \"{}\" error (fail-closed): {e}",
guard.name()
)),
evidence,
));
}
}
}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 loop never silently downgrades to
Allowon failure. There is no best-effort mode for the synchronous catalog.
Load-Time Cryptographic Floor
The kernel also enforces a process-wide cryptographic floor on each signed record: receipts, capability tokens, compliance certificates. It is loaded once at start and threaded through the receipt signer, the capability validator, and the compliance-certificate issuer. The floor is a CryptoFloor enum in chio-policy:
pub enum CryptoFloor {
AllowClassical, // accept classical-only envelopes (Ed25519, P-256, P-384). Default.
AllowHybrid, // accept classical-only or hybrid classical-plus-ML-DSA-65. Needs a PQ key.
PqRequired, // reject classical-only; every artifact must be hybrid. Needs a PQ key.
}The variants are strictly ordered, AllowClassical < AllowHybrid < PqRequired, and serialize as allow_classical, allow_hybrid, and pq_required. The kernel validates the floor at policy load. CryptoFloor::validate_with_pq_key runs at policy load: selecting AllowHybrid or PqRequired without a provisioned ML-DSA-65 key returns CryptoFloorLoadError::HybridFloorRequiresPqKey and the kernel refuses to start before its first signing call. A misconfigured deployment therefore fails at boot.
A sibling WeightsCardRequired enum (Disabled, Required, RequiredWithPin) applies the same load-time requirement to model provenance: set above Disabled, it makes a signed weights/model-card binding mandatory on every provider bind and rejects a bind that arrives without one.
Failure-Mode Matrix
The table records how each failure reaches a deny: through the pipeline or through the kernel that wraps it.
| Failure | Verdict | Reason | Side Effects |
|---|---|---|---|
Panic in evaluate | Deny / process abort | No blanket catch_unwind. An offloaded guard's panic surfaces as a JoinError mapped to KernelError::Internal; an inline panic aborts the process under panic = "abort". | Guards must not panic; the workspace denies unwrap/expect on guard and receipt paths. |
| 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. One narrow exception: a ResponseSanitizationGuard custom pattern that fails to compile through build_pattern is silently dropped: the guard still builds, that single pattern goes unenforced. See Failure Recovery. |
| 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. |
How Panics Are Handled
There is no blanket panic-catching boundary around inline guard evaluation. In the default path guards run inline in evaluate_guards_sequential, and release builds compile with panic = "abort", so a panic inside a guard aborts the process rather than unwinding into a verdict. Guards should not panic: the workspace sets clippy::unwrap_used = "deny" and clippy::expect_used = "deny", because an unhandled Option/Result panic on a guard or receipt path is an availability fault, not an acceptable shortcut.
A panic is isolated only when guards are offloaded onto tokio::task::spawn_blocking, which happens under a configured guard budget or always_offload_guards. There Tokio's blocking-pool handling catches the unwind and surfaces it as a JoinError, which the kernel maps to KernelError::Internal("guard task join failed: ..."). The request fails closed and the async worker pool keeps serving.
Emergency Stop
Two process-wide kill switches force deny-all independent of any single guard, for the case where an operator needs to freeze a running kernel without tearing it down.
At the kernel level, ChioKernel::emergency_stop(reason) sets a flag that every evaluate_tool_call* path checks first. While it is engaged, each call returns a signed deny receipt carrying EMERGENCY_STOP_DENY_REASON ("kernel emergency stop active") before capability validation or the guard pipeline runs. emergency_resume() disengages it and is_emergency_stopped() reports the current state. The kernel stays live so orchestrators and health probes see a running process while all evaluated calls receive a deny.
HushSpec carries a second, independent switch. activate_panic(), deactivate_panic(), and is_panic_active() in chio-policy set a global flag consulted by each policy evaluate() call. The built-in chio:panic ruleset: one of the seven embedded rulesets a policy can extends. Both mechanisms deny requests, but they operate at different layers and can be activated independently.
The Advisory Exception
The AdvisoryPipeline records signals and lets successful requests proceed. Two cases can still deny a request:
- 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.
When advisory Allow modes apply
CircuitOpenVerdict::Allow and RateLimitedVerdict::Allow apply to guards whose outputs feed a review queue instead of gating 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
Example: a guard that reads 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<GuardDecision, 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(GuardDecision::deny(vec![])),
_ => Ok(GuardDecision::allow()),
}
}
}When the journal lock is poisoned, the guard returns Err(KernelError::Internal(...)). The kernel's guard loop 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 records the reason.
Operator Guidance
- Investigate denial spikes. A spike of fail-closed denials can indicate a guard failure (such as a regex bug or journal corruption) or a rejected agent request. The receipt message identifies the guard and reason.
- 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 panic in a guard. There is no inline panic boundary to fall back on: in the default path a panic aborts the process (release builds use
panic = "abort"). Handle everyOption/Result: the workspace deniesunwrap/expecton guard and receipt paths for exactly this reason.
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