Chio/Docs
LOGIN · JOIN

PlatformGuard Runtime

Node

External Guard Adapters

AsyncGuardAdapter applies circuit breaking, caching, rate limiting, and retries to external guard providers before the kernel evaluates their verdicts.

Two crates

The generic adapter and middleware live in chio-guards::external (workspace 0.1.0). HTTP-backed providers live in chio-external-guards, which re-exports the adapter so callers depend on a single crate. The sync bridge ScopedAsyncGuard lives inchio-external-guards.

Layering

A single call through the adapter passes four stages in a fixed order. The order matters because it determines which failure mode produces which verdict.

text
AsyncGuardAdapter::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 · HalfOpenTtlCache keyed by ExternalGuard::cache_key(ctx). Default cache_ttl is Duration::from_secs(60); a hit returns the cached verdict and skips rate limit + retry.CacheTTL 60s · capacity 1024Token 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 60s · 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
The four resilience layers AsyncGuardAdapter checks around one ExternalGuard::eval call, with each layer's default configuration and the verdict it returns when it short-circuits. Each layer answers on its own without reaching the provider.
sourcecrates/guards/chio-guards/src/external/mod.rs:194-208crates/guards/chio-guards/src/external/mod.rs:337-398crates/guards/chio-guards/src/external/circuit_breaker.rs:56-65crates/guards/chio-guards/src/external/retry.rs:49-59at fe56570

Adapter behavior defined in crates/guards/chio-guards/src/external/mod.rs:

  • Cache before rate limit. Cache hits do not consume the bucket.
  • Rate-limited calls do not fail the breaker. Only attempts sent to the external service count toward the breaker's failure threshold.
  • Permanent errors short-circuit retry. Only ExternalGuardError::Timeout and ::Transient retry;::Permanent returns immediately.
  • The fallback is deny. Any uncaught error path returns Verdict::Deny with a tracing::warn! record.

ExternalGuard trait

The async trait each provider implements:

crates/guards/chio-guards/src/external/mod.rs120-129rust
pub trait ExternalGuard: Send + Sync {
    /// Human-readable guard name (e.g. `"bedrock-guardrail"`).
    fn name(&self) -> &str;

    /// Return a cache key for this request, or `None` to skip caching.
    fn cache_key(&self, ctx: &GuardCallContext) -> Option<String>;

    /// Evaluate the request against the external service.
    async fn eval(&self, ctx: &GuardCallContext) -> Result<Verdict, ExternalGuardError>;
}

The context it reads is a flat projection of the request, with the arguments left as a JSON string so a cache key can hash them without committing to a schema.

crates/guards/chio-guards/src/external/mod.rs77-87rust
pub struct GuardCallContext {
    /// Tool name being invoked.
    pub tool_name: String,
    /// Calling agent identifier.
    pub agent_id: String,
    /// Target server identifier.
    pub server_id: String,
    /// Tool arguments serialized as JSON. Kept as a `String` so the cache
    /// key can hash it cheaply without committing to a fixed schema.
    pub arguments_json: String,
}

The error type separates what may be retried from what may not. is_retryable is true for Timeout and Transient only (external/mod.rs:109).

crates/guards/chio-guards/src/external/mod.rs91-104rust
pub enum ExternalGuardError {
    /// The downstream service timed out.
    #[error("external guard timeout")]
    Timeout,
    /// The downstream service returned a retryable failure (5xx, connection
    /// reset, etc.). Retryable errors are counted towards the circuit
    /// breaker and may trigger a retry.
    #[error("transient external error: {0}")]
    Transient(String),
    /// A permanent error that should not be retried (e.g. malformed request,
    /// 4xx auth failure).
    #[error("permanent external error: {0}")]
    Permanent(String),
}

eval describes a single attempt: one HTTP call and one decision. Providers do not implement retry, caching, or rate limiting. The adapter wraps that for them.


AsyncGuardAdapterConfig

Defaults from AsyncGuardAdapterConfig::default():

FieldTypeDefaultPurpose
circuitCircuitBreakerConfig5 failures / 60s, reset 30s, success 2Three-state breaker tuning.
retryRetryConfig3 retries, 100 ms base, 5 s max, 0.25 jitter, exponentialRetry / backoff inside the breaker.
cache_capacityNonZeroUsize1024TTL cache size.
cache_ttlDuration60sPer-entry expiration.
rate_per_secondf6420.0Token bucket refill rate.
rate_burstu3220Token bucket capacity.
circuit_open_verdictCircuitOpenVerdictDenyVerdict when the breaker is open.
rate_limited_verdictRateLimitedVerdictDenyVerdict when the bucket is empty.

Both CircuitOpenVerdict and RateLimitedVerdict are two-variant enums: Deny (default, fail-closed) and Allow (advisory only). There is no Escalate variant. A guard that needs human escalation uses the approval guard.

Do not enable Allow on last-line guards

The advisory Allow modes fit two shapes: a guard whose output feeds a human-review queue rather than gating an action, and a guard that is one of several independent layers where the rest of the synchronous chain still enforces the call. If this guard is the last line of defense on a capability, leave the defaults (Deny) in place. An open-circuit Allow on a sole enforcement guard turns the breaker into a single-point-of-failure exfil channel.

Circuit breaker

Three-state breaker from crates/guards/chio-guards/src/external/circuit_breaker.rs:

  • Closed. Calls flow. Failures accumulate inside a sliding failure_window. Once the count reaches failure_threshold, the breaker opens and records the timestamp.
  • Open. Calls short-circuit for at least reset_timeout. The adapter returns the configured CircuitOpenVerdict without touching the inner guard.
  • HalfOpen. A bounded set of trial calls is admitted. After success_threshold consecutive successes the breaker closes; any failure reopens it.
crates/guards/chio-guards/src/external/circuit_breaker.rs43-54rust
pub struct CircuitBreakerConfig {
    /// Failures observed within `failure_window` before the breaker opens.
    pub failure_threshold: u32,
    /// Rolling window for failure counting in the Closed state. Failures
    /// older than this are discarded from the sliding count.
    pub failure_window: Duration,
    /// Consecutive successes required in the HalfOpen state before the
    /// breaker transitions back to Closed.
    pub success_threshold: u32,
    /// Time the breaker remains Open before a HalfOpen probe is allowed.
    pub reset_timeout: Duration,
}
crates/guards/chio-guards/src/external/circuit_breaker.rs56-65rust
impl Default for CircuitBreakerConfig {
    fn default() -> Self {
        Self {
            failure_threshold: 5,
            failure_window: Duration::from_secs(60),
            success_threshold: 2,
            reset_timeout: Duration::from_secs(30),
        }
    }
}

Time uses a Clock abstraction; tests drive transitions through tokio::time::pause + advance without wall-clock sleeps. Nothing forces the breaker Closed from outside the process; recovery is covered in Failure & Recovery. For how an open breaker reaches a denied receipt, see Fail-Closed Semantics.


Retry strategies

From crates/guards/chio-guards/src/external/retry.rs. Three strategies ship in tree:

crates/guards/chio-guards/src/external/retry.rs22-29rust
pub enum BackoffStrategy {
    /// Each attempt sleeps `base_delay * 2^(attempt - 1)` before jitter.
    Exponential,
    /// Each attempt sleeps `base_delay` before jitter.
    Constant,
    /// Each attempt sleeps `base_delay * attempt` before jitter.
    Linear,
}

The curve is one field of the retry config:

crates/guards/chio-guards/src/external/retry.rs33-47rust
pub struct RetryConfig {
    /// Maximum number of retries after the initial attempt. A value of `0`
    /// means the operation is attempted exactly once.
    pub max_retries: u32,
    /// Base delay for the first retry.
    pub base_delay: Duration,
    /// Upper bound on the sleep between attempts (before jitter is added).
    pub max_delay: Duration,
    /// Fraction of the computed delay to use as bounded multiplicative
    /// jitter. Must be in `[0.0, 1.0]`; values outside that range are
    /// clamped.
    pub jitter_fraction: f64,
    /// Backoff curve.
    pub strategy: BackoffStrategy,
}
crates/guards/chio-guards/src/external/retry.rs49-59rust
impl Default for RetryConfig {
    fn default() -> Self {
        Self {
            max_retries: 3,
            base_delay: Duration::from_millis(100),
            max_delay: Duration::from_secs(5),
            jitter_fraction: 0.25,
            strategy: BackoffStrategy::Exponential,
        }
    }
}

Total attempts is max_retries + 1. Jitter is bounded multiplicative: the computed delay is scaled by 1 + uniform(-jitter_fraction, +jitter_fraction), so the defaults give a ±25% spread, enough to break up a thundering herd while keeping the worst case bounded. The default RNG seeds from max_retries + 0x9E37_79B9_7F4A_7C15 for deterministic test runs; retry_with_jitter_rng accepts a caller-supplied Rng when needed. The sleep between attempts uses tokio::time::sleep so it honours tokio::time::pause.

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 the delay overshoots the throttle. Constant is for tests and rare deterministic-cadence health checks.

No-retry equals max_retries: 0

The crate does not ship a NoRetry variant. To run a single attempt, set RetryConfig::max_retries = 0. Permanent errors already short-circuit retry. Use this setting for permanent-only providers that return each error immediately.

TTL cache

TtlCache<String, Verdict> with bounded capacity. Keys come from ExternalGuard::cache_key; a provider that returns None opts the call out of caching. Default TTL is 60 seconds; default capacity is 1024 entries. Eviction is recency-biased on the underlying map.

The cache is consulted before the rate limiter so steady-state hot traffic does not drain the QPS budget. A cached verdict attests to a decision made within the configured TTL, not a live one; operators that need per-call freshness either set cache_ttl to 0 or override cache_key to return None.


AsyncGuardAdapter shape

crates/guards/chio-guards/src/external/mod.rsrust
pub struct AsyncGuardAdapter<E: ExternalGuard + ?Sized> {
    inner: Arc<E>,
    config: AsyncGuardAdapterConfig,
    cache: TtlCache<String, Verdict>,
    circuit: CircuitBreaker,
    bucket: TokenBucket,
}

impl<E: ExternalGuard + ?Sized> AsyncGuardAdapter<E> {
    pub fn builder(inner: Arc<E>) -> AsyncGuardAdapterBuilder<E>;
    pub fn name(&self) -> &str;
    pub fn config(&self) -> &AsyncGuardAdapterConfig;
    pub fn circuit_state(&self) -> CircuitState;
    pub async fn evaluate(&self, ctx: &GuardCallContext) -> Verdict;
}

Construction goes through the fluent builder. Tests pass a custom Clock via .clock(...) to drive breaker and cache transitions deterministically.


Sync bridge: ScopedAsyncGuard

The kernel guard pipeline is sync. The bridge in chio-external-guards wraps an AsyncGuardAdapter in a sync Guard and scopes it to a wildcard tool-name pattern set:

crates/guards/chio-external-guards/src/lib.rs36-39rust
pub struct ScopedAsyncGuard<E: ExternalGuard> {
    adapter: AsyncGuardAdapter<E>,
    tool_patterns: Vec<String>,
}

An empty pattern list matches every tool (chio-external-guards/src/lib.rs:51).

crates/guards/chio-external-guards/src/lib.rs137-162rust
impl<E: ExternalGuard> Guard for ScopedAsyncGuard<E> {
    fn name(&self) -> &str {
        self.adapter.name()
    }

    fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
        // By design: a tool-scoped guard returns Allow for traffic outside
        // its scope. The deny-by-default contract is enforced by the
        // composing authority layer, not by this single scoped guard.
        if !self.matches_tool(&ctx.request.tool_name) {
            return Ok(GuardDecision::allow());
        }

        let call_ctx = self.call_context(ctx);
        self.block_on(self.adapter.evaluate(&call_ctx))
            .map(GuardDecision::from_verdict)
    }

    fn revalidate_before_dispatch(&self, _ctx: &GuardContext) -> Result<(), KernelError> {
        // External evaluation can consume rate-limit, cache, retry, and
        // circuit-breaker state. The admission verdict is authoritative for
        // this dispatch, so readiness revalidation must not call the adapter a
        // second time.
        Ok(())
    }
}

A non-matching tool returns Verdict::Allow without calling the external service and without consuming rate-limit or cache budget. The bridge detects the current Tokio runtime flavor and dispatches:

  • MultiThread: tokio::task::block_in_place + the current handle.
  • CurrentThread: spawn a fresh current-thread runtime on a scoped thread to avoid deadlocking the caller's executor.
  • No runtime in scope: build a transient current-thread runtime and run to completion.
  • Unknown future Tokio flavor: return KernelError::GuardDenied with a diagnostic name.

Provider catalog

ProviderGuard structClassEvidence struct
AWS Bedrock GuardrailsBedrockGuardrailGuardContent safetyBedrockDecisionDetails
Azure AI Content SafetyAzureContentSafetyGuardContent safetyAzureDecisionDetails
Google Vertex AI SafetyVertexSafetyGuardContent safetyVertexDecisionDetails
SnykSnykGuardVulnerabilitySnykEvidence
VirusTotalVirusTotalGuardURL threat intelVirusTotalEvidence
Google Safe BrowsingSafeBrowsingGuardURL threat intelSafeBrowsingEvidence

For credentials, endpoints, and per-provider thresholds, read External Guards. That page covers the chio-control-plane cloud_guardrails and threat_intel policy-loader blocks (fields on its guard-config struct, not part of HushSpec's rules schema) and the SSRF-safe URL validator that gates every adapter.


Cache-key design

Each provider implements cache_key(ctx). The key must capture every input that influences the verdict and nothing else. Typical recipes:

  • Content-safety providers hash the prompt or tool argument body so a benign prompt hits cache across agents.
  • URL-threat providers hash the canonicalised URL host + path; the fragment is dropped because it does not change reputation.
  • Vulnerability providers hash the package coordinate (name@version) plus the ecosystem.

Returning None opts out of caching for that specific call, useful when the input is sensitive or the verdict is too contextual to share.


Receipt evidence

The adapter writes its decision to the receipt's guard-evidence block. The evidence struct preserves the guard name and provider identity, the provider decision label (Azure severity, Vertex probability, VirusTotal detection count, and so on), the upstream request correlation id when one is returned, and a cache-or-live flag so auditors can tell a cached decision from a fresh call.

Receipts do not embed raw provider responses

The receipt records the structured decision, not the verbatim API body. Operators who need raw provider telemetry route that through SIEM export, not the receipt log.

Performance class

A cache hit performs a breaker check and a cache read. A cache miss adds bucket acquisition, one provider HTTP call, and a cache insert. A circuit-open call returns after the breaker check without making a provider request.


Next steps

  • External Guards for provider configuration, SSRF controls, and the fail-mode matrix.
  • Fail-Closed Semantics for the kernel-wide invariants this adapter inherits and the full failure-mode matrix.
  • Failure & Recovery for breaker recovery, WASM hot-reload, and a worked adapter tuning example.
  • Rate Limit Guards for in-process limits that complement provider-side QPS budgets.