Chio/Docs

PlatformOperations

Failure & Recovery

Reference for failure verdicts, circuit-breaker behavior, bounded retries, and recovery procedures for external guards.

Source

Verified against crates/guards/chio-guards/src/external/circuit_breaker.rs, crates/guards/chio-guards/src/external/retry.rs, and crates/guards/chio-guards/src/external/mod.rs.

Failure-Mode Matrix

FailureVerdictSide effects
Panic in guard evaluateDeny / process abortOffloaded: JoinErrorKernelError::Internal. Inline: panic = "abort" ends the process.
Poisoned mutexDenyMay invalidate session for session-aware guards
WASM fuel exhaustedDenyGuard quarantined until reset
WASM trapDenyGuard module marked unhealthy
Circuit open (default)DenyNo provider call attempted
Circuit open (advisory)AllowOpt-in via CircuitOpenVerdict::Allow
Parse error (request payload)DenyEvidence carries the parse failure
Regex compile failure (config)n/a (build-time)Guard fails to construct; kernel start-up fails. Exception: a ResponseSanitizationGuard custom pattern is silently skipped.
Network timeout (transient)retries; Deny on exhaustCounts as failure for the breaker
Permanent error (4xx, malformed)DenyNo retry; not configurable
Session journal unavailableDenyAffects only session-aware guards
Receipt store write failureDenyKernel cannot record evidence

Reason classification is narrower than this matrix. Only WASM guard denials carry a bounded reason_class label, on the chio_guard_deny_total{guard_id, reason_class} counter: chio-wasm-guards is the sole producer; the kernel /metrics endpoint only renders it. The label domain is fixed to nine values: policy, pii, secret, prompt_injection, oversize, fuel, trap, malformed, other, assigned by substring-matching the guard's free-form deny reason, with any unrecognized reason folded into other. Native-guard, circuit-breaker, mutex-poisoning, session-journal, and receipt-store denials in the table above carry no reason_class.

ResponseSanitizationGuard custom-pattern exception

For most guards a malformed regex is a construction-time error, not a runtime hole: BrowserAutomationGuard, MemoryGovernanceGuard, and ContentReviewGuard each return an InvalidPattern error when a configured pattern fails to compile, so the guard never builds and kernel start-up fails. The exception is ResponseSanitizationGuard: an invalid custom pattern (compiled through build_pattern) is silently dropped, leaving that single pattern unenforced. Validate the custom-pattern list before deployment.

Circuit Breaker

External-guard adapters wrap their inner provider call in a three-state breaker (crates/guards/chio-guards/src/external/circuit_breaker.rs):

crates/guards/chio-guards/src/external/circuit_breaker.rs
pub enum CircuitState {
    Closed,    // Normal operation. Failures count toward a sliding window.
    Open,      // Fail-fast. Calls short-circuit until reset_timeout elapses.
    HalfOpen,  // Probing. A bounded number of trial calls test recovery.
}

The transitions:

  • Closed to Open. When the number of failures inside the rolling failure_window reaches failure_threshold.
  • Open to HalfOpen. When reset_timeout has elapsed since the breaker opened. The next call admitted moves to HalfOpen.
  • HalfOpen to Closed. After success_threshold consecutive successes.
  • HalfOpen to Open. Any single failure during probing reopens the breaker.
crates/guards/chio-guards/src/external/circuit_breaker.rs
pub struct CircuitBreakerConfig {
    pub failure_threshold: u32,    // default 5
    pub failure_window: Duration,  // default 60s
    pub success_threshold: u32,    // default 2
    pub reset_timeout: Duration,   // default 30s
}

CircuitOpenVerdict

When the breaker is Open, the adapter does not call the provider. Instead it returns a configurable verdict:

crates/guards/chio-guards/src/external/circuit_breaker.rs
pub enum CircuitOpenVerdict {
    Deny,   // Fail-closed. Default.
    Allow,  // Fail-open. Advisory only.
}

Use Allow when unavailability is preferable to a denied request. Two appropriate cases are:

  • The guard's output feeds a human-review queue rather than gating an action. A missed signal is recoverable.
  • The guard is one of several independent layers and the others are still in the synchronous chain. The remaining synchronous guards continue to enforce the action.

Avoid fail-open for a sole enforcement guard

If a guard is the sole control between an agent and a privileged action, leave the default. An open-circuit Allow turns the breaker into a single-point-of-failure exfil channel.

RateLimitedVerdict

Same shape, different trigger. When the adapter's token bucket is empty:

crates/guards/chio-guards/src/external/mod.rs
pub enum RateLimitedVerdict {
    Deny,   // Default. The QPS budget is exhausted; deny the request.
    Allow,  // Advisory: allow the request even though the budget is exhausted.
}

Retry Strategies

Adapters retry transient and timeout errors only. 4xx and malformed-request errors are permanent; they short-circuit the retry loop and become Verdict::Deny.

crates/guards/chio-guards/src/external/retry.rs
pub enum BackoffStrategy {
    Exponential,  // base_delay * 2^(attempt - 1)
    Constant,     // base_delay
    Linear,       // base_delay * attempt
}

pub struct RetryConfig {
    pub max_retries: u32,         // default 3 (so 4 total attempts)
    pub base_delay: Duration,     // default 100ms
    pub max_delay: Duration,      // default 5s
    pub jitter_fraction: f64,     // default 0.25 (clamped to [0.0, 1.0])
    pub strategy: BackoffStrategy,// default Exponential
}

Three things to know:

  • Total attempts is max_retries + 1. A value of 0 means "run exactly once, no retries".
  • Jitter is bounded multiplicative. The configured delay is multiplied by 1 + uniform(-jitter_fraction, +jitter_fraction). Defaults give a ±25% spread, which is enough to avoid thundering herd while keeping the worst-case bounded.
  • Jitter is deterministic by default. The retry RNG is seeded from max_retries so tests reproduce. Production callers wanting non-deterministic jitter can use retry_with_jitter_rng.

Backoff selection

Exponential is right for most providers: a temporarily-degraded service recovers faster when retries thin out. Linear suits providers that throttle on a fixed window, where doubling delay overshoots the throttle. Constant is for tests and rare deterministic-cadence health checks.

Composed Adapter Flow

Inside AsyncGuardAdapter, the failure-handling pieces compose in a fixed order:

text
evaluate(ctx):
    1. CircuitBreaker.allow_call()          -> CircuitOpenVerdict on deny
    2. TtlCache.get(cache_key)              -> cached verdict on hit
    3. TokenBucket.try_acquire()            -> RateLimitedVerdict on empty
    4. retry_with_jitter(inner.eval)        -> Verdict::Deny on permanent failure
                                             -> Verdict on success (also cached)
AsyncGuardAdapter layered compositionCircuit breaker: tracks recent failures; opens on threshold; short-circuits while open; probes on reset_timeout.Circuit breakerClosed · Open · HalfOpenTTL cache keyed by ExternalGuard::cache_key(ctx). Hit returns the cached verdict and skips rate limit + retry.CacheTTL ~30 minToken bucket. On empty, returns the configured rate-limited verdict (Deny by default) without contacting the provider.Rate limittoken bucketretry_with_jitter: up to max_retries+1 attempts. Exponential, Constant, or Linear backoff with jitter_fraction=0.25 by default.Retryjittered backoffOne provider attempt: one outbound call and one Verdict or ExternalGuardError.ExternalGuard::eval()one HTTP call · one verdictVerdict on Open is Deny by default; reconfigurable via circuit_open_verdict.open ⇒ Denythreshold 5 / window 60s · reset 30sCache check sits before the token bucket so steady-state hits do not spend rate-limit budget.hit ⇒ return cachedTTL ~30 min · skips rate limit + retryRate-limited calls return rate_limited_verdict (Deny by default) and never reach the provider, so they do not count against the breaker's failure window.empty ⇒ Denytoken bucket · does not feed breakerOnly Timeout and Transient errors retry. Permanent (4xx, malformed) errors short-circuit immediately.permanent ⇒ Deny · transient + timeout retrymax 3 retries · base 100ms · cap 5s · jitter 0.25request flows inbreaker → cache → rate limit→ retry → core evalresponse unwinds outverdict bubbles back througheach layer in reverse orderAsyncGuardAdapter<E>: outer layers add resilience around inner corefailure modes short-circuit at the layer that detects them · final fallback is Deny
AsyncGuardAdapter wraps the provider call with circuit breaking, caching, rate limiting, and retries. A layer returns its configured result when it detects a failure.

Two invariants matter:

  • Cache hits do not spend rate-limit budget. The cache check precedes the token bucket on purpose: a steady-state hot cache is free.
  • Rate-limited calls do not count as breaker failures. Only attempted calls to the external service feed the failure window. A bursty client cannot trip the breaker by exceeding the rate limit.

Recovery Patterns

Hot-Reload a WASM Guard

A WASM guard that hits its fuel ceiling or traps repeatedly is quarantined: the bundle store marks it unhealthy and pipeline evaluations short-circuit to Verdict::Deny. Recovery is a corrected bundle pushed through the hot-reload path. The path validates the replacement before an atomic swap.

Before a reload is accepted, a canary harness replays a frozen 32-fixture corpus against the new module; a failure leaves the prior epoch untouched. Publish is an atomic epoch swap: in-flight evaluations keep their original module snapshot while new calls use the new epoch. The reload metric chio_guard_reload_total labels by outcome: applied, canary_failed, rolled_back.

A post-publish rollback watchdog attaches to every accepted reload and trips after 5 consecutive error-class verdicts within 60 seconds: traps, fuel exhaustion, serialization failures, and other fail-closed backend errors. On trip it restores the prior module, emits chio.guard.reload.rolled_back, and writes an incident directory under ${XDG_STATE_HOME}/chio/incidents/<utc-iso8601>-<guard_id>-<reload_seq>/ holding incident.json and last_5_eval_traces.ndjson (redacted trace summaries only; request payloads are never persisted).

Digest Blocklist

A known-bad guard build can be pinned out of rotation by digest. The local blocklist lives at ${XDG_STATE_HOME}/chio/guards/blocklist.json. Engine::reload refuses a replacement module whose sha256:<digest> is listed and returns E_GUARD_DIGEST_BLOCKLISTED, and chio guard pull runs the same check against the pinned OCI manifest digest before it fetches or writes cache. Clear an entry with:

bash
chio guard blocklist remove sha256:<digest>

Circuit-Breaker Recovery

The breaker transitions automatically. Open transitions to HalfOpen once reset_timeout has elapsed; HalfOpen transitions to Closed after success_threshold consecutive successes, and any failure while probing reopens it. The running service exposes no reset API: no admin endpoint or CLI subcommand forces the breaker Closed. Deployment recovery therefore follows provider recovery instead of a manual override. Callers that embed chio-guards as a library have one library API: CircuitBreaker::reset() is a public method that clears the failure window and returns the breaker to Closed for operator intervention.

Session Journal Loss

A SessionJournal is in-process state: a Mutex-guarded, capacity-bounded ring built via new, from_memory_budget, or with_caps. It has no persistence and no replay-from-receipts path, so it does not survive a kernel restart: a restarted kernel starts every session journal empty, and session-aware guards rebuild their view from new traffic. Signed receipts remain the durable audit record; the journal is the fast in-memory index guards read during a session, not a store to recover.


Worked Example: Tuned for a Flaky Provider

policy.yaml
# The adapter block nests under a provider, not a standalone file.
# Path here: guards.threat_intel.safe_browsing.adapter
guards:
  threat_intel:
    safe_browsing:
      api_key: "sb-provider-key"        # required provider credential
      adapter:
        # Cache aggressively because the provider is unreliable.
        cache_ttl_seconds: 120          # default 60

        # Provider QPS is 50; leave 20% headroom.
        rate_per_second: 40             # default 20
        rate_burst: 40                  # default 20

        # Trip the breaker after five failures in the rolling window.
        circuit_failure_threshold: 5    # default 5 (unchanged)

        # Retry transient failures up to twice (three total attempts).
        retry_max_retries: 2            # default 3

The adapter block deserializes as ExternalAdapterPolicyConfig, which is deny_unknown_fields and exposes exactly five knobs: keys outside that set fail policy validation. Four move off their defaults here: cache_ttl_seconds 60 → 120 to absorb more of the provider's flakiness in cache; rate_per_second and rate_burst both 20 → 40 to track the provider's stated 50 QPS with headroom; and retry_max_retries 3 → 2 so a degraded service is not amplified. circuit_failure_threshold stays at its default 5.

Everything else the breaker and retry loop use: failure_window (60s), success_threshold (2), reset_timeout (30s), base_delay (100ms), max_delay (5s), jitter_fraction (0.25), the 1024-entry cache capacity, and the deny-on-open and deny-on-rate-limit verdicts, comes from the compiled adapter's built-in defaults and has no policy-YAML mapping. The backoff strategy is fixed to Exponential.


Next Steps