PlatformFoundations
Pipelines & Composition
How Chio combines synchronous, advisory, and post-invocation guards, plus adapters for external guards.
GuardPipeline (synchronous, fail-closed)
The primary integration point runs guards sequentially with conjunctive, short-circuit evaluation. Defined in crates/guards/chio-guards/src/pipeline.rs.
impl Guard for GuardPipeline {
fn name(&self) -> &str {
"guard-pipeline"
}
fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
let mut final_verdict = Verdict::Allow;
let mut evidence = Vec::new();
for guard in &self.guards {
match guard.evaluate(ctx) {
Ok(decision) => {
evidence.extend(decision.evidence);
match decision.verdict {
Verdict::Allow => continue,
Verdict::PendingApproval => {
// Sticky escalation: keep iterating so a later guard
// can still short-circuit to Deny, otherwise propagate
// the pending verdict up.
final_verdict = Verdict::PendingApproval;
}
Verdict::Deny => {
evidence.push(GuardEvidence {
guard_name: guard.name().to_string(),
verdict: false,
details: Some("action=deny; reason=guard denied request".into()),
});
return Ok(GuardDecision::deny(evidence));
}
}
}
Err(e) => {
// Fail closed: a guard error becomes a deny decision.
evidence.push(GuardEvidence {
guard_name: guard.name().to_string(),
verdict: false,
details: Some(format!("action=error; reason=fail-closed; error={e}")),
});
return Ok(GuardDecision::deny(evidence));
}
}
}
Ok(GuardDecision { verdict: final_verdict, evidence })
}
}Combination Rules
| Per-guard outcome | Pipeline behavior |
|---|---|
Ok, verdict Allow | Fold in the guard's evidence and continue to the next guard. |
Ok, verdict PendingApproval | Record it as the running verdict and continue, so a later guard can still short-circuit to Deny. |
Ok, verdict Deny | Stop. Return Ok(GuardDecision::deny(evidence)): the accumulated evidence plus a row naming the guard. |
Err(_) | Stop, fail-closed. Return Ok(GuardDecision::deny(evidence)) with an error row naming the guard, a false verdict, and a formatted reason: action=error; reason=fail-closed; error={e}, where {e} is the guard's error. |
The pipeline never returns an Err itself: a denied or errored child produces an Ok(GuardDecision::deny(...)). The kernel's own top-level guard loop (evaluate_guards_sequential in crates/kernel/chio-kernel/src/kernel/dispatch.rs) is what turns a top-level Deny (from a registered guard, which may itself be a GuardPipeline) into a KernelError::GuardDenied.
PendingApproval is sticky, not short-circuit
PendingApproval still run. That is deliberate: an approval-waiting verdict should not mask a downstream guard that would otherwise have denied the call. The final pipeline verdict is PendingApproval only when every later guard returned Allow.Ordering and Cost
The pipeline runs guards in registration order. Ordering does not change correctness (conjunctive), but it changes cost on the hot path. Place cheap stateless guards first; place stateful or regex-heavy guards last. The default pipeline orders guards by cost. See Default Pipeline.
AdvisoryPipeline
Non-blocking signals. Defined in crates/guards/chio-guards/src/advisory.rs. Wraps multiple AdvisoryGuard implementations and a PromotionPolicy. Implements the standard Guard trait so it slots into a regular GuardPipeline like any other guard.
pub trait AdvisoryGuard: Send + Sync {
fn name(&self) -> &str;
fn evaluate(&self, ctx: &GuardContext) -> Result<Vec<AdvisorySignal>, KernelError>;
}
pub struct AdvisorySignal {
pub guard_name: String,
pub description: String,
pub severity: AdvisorySeverity,
pub metadata: Option<serde_json::Value>,
pub promoted: bool,
}
pub enum AdvisorySeverity { Info, Low, Medium, High, Critical }
pub struct PromotionRule {
pub guard_name: String,
pub min_severity: AdvisorySeverity,
}
pub struct PromotionPolicy {
pub rules: Vec<PromotionRule>,
}Behavior
- Each
AdvisoryGuardreturns zero or moreAdvisorySignalvalues per call. - The pipeline accumulates every signal regardless of severity. Signals are stored on the pipeline for evidence export via
last_signals()andlast_outputs(). - Without a promotion rule, the pipeline always returns
Verdict::Allow. - With a matching
PromotionRule, a signal at or abovemin_severityis markedpromoted = trueand the pipeline returnsVerdict::Deny.
Severity ordering is Info < Low < Medium < High < Critical.
Promotion Example
let mut policy = PromotionPolicy::new();
policy.add_rule(PromotionRule {
guard_name: "anomaly-advisory".to_string(),
min_severity: AdvisorySeverity::High,
});
let mut advisory = AdvisoryPipeline::new(policy);
advisory.add(Box::new(AnomalyAdvisoryGuard::new(journal, 100, 5)));
advisory.add(Box::new(DataTransferAdvisoryGuard::new(journal, 10_000_000)));
// Plug it into the regular guard pipeline.
pipeline.add(Box::new(advisory));Advisory pipelines are not exempt from fail-closed
AdvisoryGuard::evaluate returns Err, the advisory pipeline propagates the error. The wrapping GuardPipeline then treats it as a fail-closed deny. Advisory means non-blocking on success, not non-blocking on failure.PostInvocationPipeline
Inspects tool responses before they reach the agent. The types are defined in crates/kernel/chio-kernel/src/post_invocation.rs and re-exported from chio-guards for convenience:
pub use chio_kernel::{
PipelineOutcome, PostInvocationContext, PostInvocationHook,
PostInvocationPipeline, PostInvocationVerdict,
};A hook returns one of four verdicts:
| Verdict | Effect |
|---|---|
Allow | Pass the response through unmodified. |
Redact(Value) | Replace the response with the modified JSON value (structure preserved). Subsequent hooks see the redacted value. |
Block(reason) | Stop the pipeline. The kernel returns an error to the agent and records the block on the receipt. |
Escalate(message) | Non-blocking signal collected for operator review. Other hooks keep running. A subsequent Block still wins. |
The ready-made SanitizerHook wraps the output sanitizer: when sensitive data is found, it returns Redact(sanitized) and emits GuardEvidence for the receipt with detector IDs and counts (never raw secrets).
AsyncGuardAdapter (External Guards)
External guards call out to third-party services. The kernel's guard pipeline is synchronous, so each external provider is wrapped in an AsyncGuardAdapter that applies caching, rate limiting, retries, and circuit breaking to an async eval() call. Defined in crates/guards/chio-guards/src/external/mod.rs.
Evaluation order
Evaluation order from AsyncGuardAdapter::evaluate:
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)Invariants
- Cache hits do not consume rate-limit budget.
- Rate-limited calls do not count as circuit-breaker failures. Only calls sent to the external service do.
- Permanent errors (4xx, malformed) short-circuit the retry loop and return
Verdict::Deny. - Transient errors and timeouts retry and count against the breaker.
- The default for both
CircuitOpenVerdictandRateLimitedVerdictisDeny. Operators can flip either toAllowfor advisory deployments where operators review the guard's output without using it to gate the call.
Bridging to the Sync Pipeline
An AsyncGuardAdapter is async; the kernel's Guard trait is sync. chio_external_guards::ScopedAsyncGuard<E> wraps the adapter as a Guard, scopes it to wildcard tool-name patterns, and bridges async to sync by detecting the current tokio runtime flavor. See External Guards for the external-guard bridge contract.
Public re-exports
chio-guards re-exports the pipeline-composition types from one place: GuardPipeline, PostInvocationPipeline, PostInvocationHook, PostInvocationVerdict, SanitizerHook, sanitize_json, PipelineOutcome, AdvisoryPipeline, PromotionPolicy, PromotionRule, and GuardOutput.
GuardOutput is the enum that stores deterministic and advisory findings in one receipt field. It has two variants and a snake_case type discriminator:
pub enum GuardOutput {
/// tag: "deterministic"
Deterministic {
guard_name: String,
verdict: bool,
details: Option<String>,
},
/// tag: "advisory" - carries all AdvisorySignal fields
Advisory(AdvisorySignal),
}Composing Multiple Pipelines
A typical kernel registers one GuardPipeline as its single sync guard. That pipeline contains the catalog guards plus, optionally, an AdvisoryPipeline and any number of ScopedAsyncGuard wrappers. The PostInvocationPipeline is registered separately on the kernel for the response-side phase.
use chio_guards::{
GuardPipeline, AdvisoryPipeline, PromotionPolicy, PromotionRule,
AdvisorySeverity,
};
let mut pipeline = GuardPipeline::default_pipeline();
// Layer in advisory signals from session journal.
let mut policy = PromotionPolicy::new();
policy.add_rule(PromotionRule {
guard_name: "anomaly-advisory".to_string(),
min_severity: AdvisorySeverity::Critical,
});
let mut advisory = AdvisoryPipeline::new(policy);
advisory.add(Box::new(AnomalyAdvisoryGuard::new(journal.clone(), 100, 5)));
pipeline.add(Box::new(advisory));
// Layer in an external guard. The Arc wraps the concrete ExternalGuard
// impl passed to the adapter builder, not the adapter itself.
let adapter = AsyncGuardAdapter::builder(Arc::new(SomeExternalGuardImpl)).build();
pipeline.add(Box::new(ScopedAsyncGuard::new(
adapter,
vec!["fetch_url".into()],
)));
kernel.add_guard(Box::new(pipeline));Where to Go Next
- Fail-Closed Semantics · failure-mode matrix and the advisory exception
- Advisory Signals · authoring advisory guards and writing promotion rules
- External Adapters · the full external-guard bridge contract
- Default Pipeline · what runs when you call
default_pipeline()