Chio/Docs
LOGIN · JOIN

PlatformSession & Approval Guards

Kernel

Session-Aware Guards

How DataFlowGuard, BehavioralSequenceGuard, and BehavioralProfileGuard use shared session state and handle journal failures.

The row next door, the reads here

These guards read state within a session. Session Lifecycle owns the prior question, which is whether the session exists at all, what state it is in, and what a restart does to it. The two do not share storage: the journal below is per-process memory and nothing persists or restores it.

The session journal

Source: crates/platform/chio-http-session/src/lib.rs. The journal is a thread-safe, hash-chained record of tool-call entries stored in capacity-bounded rings. The public handle is a mutex around the inner state:

crates/platform/chio-http-session/src/lib.rs293-300rust
/// Thread-safe, append-only, hash-chained session journal.
///
/// Create one per session and share it (via `Arc<SessionJournal>`) with all
/// guards that need session-aware context.
pub struct SessionJournal {
    inner: Mutex<JournalInner>,
    session_id: String,
}

The inner state carries the rings, and its field comments say which pieces are cumulative and therefore survive eviction:

crates/platform/chio-http-session/src/lib.rs194-233rust
/// Inner journal state (not thread-safe -- wrapped by `SessionJournal`).
#[derive(Debug)]
struct JournalInner {
    /// The capacity-bounded ring of retained entries.
    entries: chio_bounded::Ring<JournalEntry>,
    /// Running fold over the hashes of entries evicted from the ring, so the
    /// chain stays committed after the prefix is dropped. `None` until the first
    /// eviction.
    evicted_head_hash: Option<String>,
    /// Monotonic sequence counter, independent of the (bounded) ring length so
    /// sequence numbers never repeat after eviction.
    next_sequence: u64,
    /// Cumulative data flow stats.
    data_flow: CumulativeDataFlow,
    /// Tool invocation sequence (tool names in order), bounded to the entry cap.
    tool_sequence: chio_bounded::Ring<String>,
    /// Per-tool invocation counts (cumulative). The counts survive entry-ring
    /// eviction so the behavioral-sequence guard can answer "was this tool ever
    /// invoked", but the set of DISTINCT keys is bounded fail-closed by
    /// `tool_counts_cap`. See `record` for the overflow rule.
    tool_counts: HashMap<String, u64>,
    /// Maximum number of distinct tool names retained in `tool_counts`. Once
    /// reached, a previously-unseen tool name is dropped from the cumulative
    /// counts (fail-closed): a dependent required-predecessor check then treats
    /// it as never-invoked and denies. Already-seen tools keep
    /// counting so legitimate (registry-bounded) predecessor checks stay
    /// correct across ring eviction.
    tool_counts_cap: usize,
    /// The tool name of the current consecutive run, or `None` before the first
    /// record. Together with `current_streak_len` this is an O(1), bounded
    /// cumulative streak counter (last-tool plus consecutive-count) that survives
    /// entry-ring eviction, so a `max_consecutive` check can be enforced even when
    /// the streak is longer than `journal_entry_cap` and the older part of the
    /// streak has been evicted from `tool_sequence`.
    current_streak_tool: Option<String>,
    /// Length of the current consecutive run of `current_streak_tool`. Reset to 1
    /// when a different tool is recorded, so it never accumulates unboundedly as a
    /// collection: it is a single running scalar.
    current_streak_len: u64,
}

Byte and invocation totals live in their own struct, which the snapshot hands to the guards:

crates/platform/chio-http-session/src/lib.rs123-134rust
/// Cumulative data flow statistics for a session.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CumulativeDataFlow {
    /// Total bytes read across all invocations in the session.
    pub total_bytes_read: u64,
    /// Total bytes written across all invocations in the session.
    pub total_bytes_written: u64,
    /// Total number of tool invocations recorded.
    pub total_invocations: u64,
    /// Maximum delegation depth seen in the session.
    pub max_delegation_depth: u32,
}

entries and tool_sequence are chio_bounded::Rings, defaulting to caps sourced from chio_kernel::MemoryBudgetConfig. When the ring evicts an entry its hash folds into evicted_head_hash, so integrity verification still commits to the dropped prefix. Two pieces of state survive that eviction, and the session-aware guards read both cumulatively: tool_counts and the current_streak_tool / current_streak_len pair. tool_counts is itself distinct-key bounded (tool_counts_cap), dropping a newly-seen name fail-closed once full.

The guards read the journal through one snapshot instead of separate per-field accessors:

  • snapshot() -> Result<SessionJournalSnapshot, SessionJournalError> takes the lock once and returns data flow, tool sequence, tool counts, the streak pair, and the head hash together, so no guard samples a torn read across fields. SessionJournalSnapshot carries session_id, entry_count, head_hash, data_flow, tool_sequence, tool_counts, current_streak_tool, and current_streak_len.
  • record(RecordParams) -> Result<u64, SessionJournalError> appends a hash-chained entry and returns its sequence number. RecordParams carries tool_name, server_id, agent_id, bytes_read, bytes_written, delegation_depth, and allowed.
  • data_flow() and tool_sequence() still exist as public accessors, but neither DataFlowGuard nor BehavioralSequenceGuard calls them; both read through snapshot().

The cumulative counters use saturating_add on every record, so each running total clamps at u64::MAX rather than wrapping. The guards take Arc<SessionJournal> at construction so they share the underlying state without owning it.


DataFlowGuard

Source: crates/guards/chio-guards/src/data_flow.rs. Guard name: data-flow (data_flow.rs:54). Reads cumulative bytes from the journal and denies once any configured ceiling is reached.

Struct

crates/guards/chio-guards/src/data_flow.rs21-30rust
/// Configuration for cumulative data flow limits.
#[derive(Clone, Debug, Default)]
pub struct DataFlowConfig {
    /// Maximum cumulative bytes read per session. None means unlimited.
    pub max_bytes_read: Option<u64>,
    /// Maximum cumulative bytes written per session. None means unlimited.
    pub max_bytes_written: Option<u64>,
    /// Maximum cumulative bytes (read + written) per session. None means unlimited.
    pub max_bytes_total: Option<u64>,
}

The guard itself holds the shared journal and that config:

crates/guards/chio-guards/src/data_flow.rs36-43rust
/// Guard that enforces cumulative data flow limits using the session journal.
///
/// Reads the journal's cumulative data flow statistics and denies requests
/// if any configured limit has been reached.
pub struct DataFlowGuard {
    journal: Arc<SessionJournal>,
    config: DataFlowConfig,
}

Default on DataFlowConfig sets every ceiling to None: a default guard never denies. Per-knob defaults:

KnobTypeDefaultBehavior
max_bytes_readOption<u64>NoneCumulative read ceiling. Inclusive comparison: flow.total_bytes_read >= max_read.
max_bytes_writtenOption<u64>NoneCumulative write ceiling. Same inclusive comparison.
max_bytes_totalOption<u64>NoneCumulative read + write ceiling. The total is computed via flow.total_bytes_read.saturating_add(flow.total_bytes_written).

Algorithm

The evaluate body reads cumulative data flow from a snapshot(), not the older data_flow() accessor:

crates/guards/chio-guards/src/data_flow.rs57-88rust
fn evaluate(&self, _ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
    let snapshot = self.journal.snapshot().map_err(|e| {
        KernelError::Internal(format!("data-flow guard journal error (fail-closed): {e}"))
    })?;
    let flow = snapshot.data_flow;

    // Check bytes read limit.
    if let Some(max_read) = self.config.max_bytes_read {
        if flow.total_bytes_read >= max_read {
            return Ok(GuardDecision::deny(Vec::new()));
        }
    }

    // Check bytes written limit.
    if let Some(max_written) = self.config.max_bytes_written {
        if flow.total_bytes_written >= max_written {
            return Ok(GuardDecision::deny(Vec::new()));
        }
    }

    // Check total I/O limit.
    if let Some(max_total) = self.config.max_bytes_total {
        let total = flow
            .total_bytes_read
            .saturating_add(flow.total_bytes_written);
        if total >= max_total {
            return Ok(GuardDecision::deny(Vec::new()));
        }
    }

    Ok(GuardDecision::allow())
}

The comparison is inclusive: a session that has already read exactly max_bytes_read denies the next call. The guard does not pre-charge the in-flight request, so the arithmetic only sees what prior callers wrote into the journal. The action enum is ignored: even invocations with zero reported bytes execute the three checks before returning Allow.

u64 ceiling

Both CumulativeDataFlow counters and max_bytes_total are u64. The journal's saturating_add updates clamp at u64::MAX = 18_446_744_073_709_551_615 bytes (about 16 EB, 18.4 quintillion bytes). At any realistic web scale this ceiling is unreachable. Saturation prevents a misconfigured journal from wrapping a counter to zero and re-allowing a terminated session.

Failure modes

  • Journal lock poisoned :: SessionJournalError::LockPoisoned is mapped by map_err as KernelError::Internal("data-flow guard journal error (fail-closed): {e}"). The kernel reads Err(_) from a guard as a denial.
  • Saturated counter :: deny stays deny. Once any total reaches its ceiling, every subsequent call denies until the session is replaced.

BehavioralSequenceGuard

Source: crates/guards/chio-guards/src/behavioral_sequence.rs. Guard name: behavioral-sequence (behavioral_sequence.rs:60). Enforces tool-ordering rules over the journal's tool sequence.

Struct

crates/guards/chio-guards/src/behavioral_sequence.rs25-39rust
/// Policy configuration for the behavioral sequence guard.
#[derive(Clone, Debug, Default)]
pub struct SequencePolicy {
    /// Tools that must have been invoked before a given tool can run.
    /// Map from tool_name to set of required predecessor tools.
    pub required_predecessors: HashMap<String, HashSet<String>>,
    /// Forbidden immediate transitions: (from_tool, to_tool) pairs.
    /// If the last invoked tool is `from_tool`, then `to_tool` is denied.
    pub forbidden_transitions: Vec<(String, String)>,
    /// Maximum consecutive invocations of the same tool.
    /// None means unlimited.
    pub max_consecutive: Option<u32>,
    /// If set, the first tool in the session must match this name.
    pub required_first_tool: Option<String>,
}

The guard pairs that policy with the shared journal:

crates/guards/chio-guards/src/behavioral_sequence.rs45-49rust
/// Guard that enforces tool ordering policies using the session journal.
pub struct BehavioralSequenceGuard {
    journal: Arc<SessionJournal>,
    policy: SequencePolicy,
}

Configuration

KnobTypeDefaultCheck
required_predecessorsHashMap<String, HashSet<String>>emptyFor target tool_name: deny if any required predecessor is absent from the cumulative tool_counts map, which survives ring eviction (behavioral_sequence.rs:100-106).
forbidden_transitionsVec<(String, String)>emptyIf the cumulative last tool (current_streak_tool) is from and the requested tool is to, deny (behavioral_sequence.rs:116-122).
max_consecutiveOption<u32>NoneRead the O(1) cumulative streak counter current_streak_len; deny when a run of the requested tool reaches the ceiling (behavioral_sequence.rs:134-144).
required_first_toolOption<String>NoneIf nothing has been recorded yet (current_streak_tool is None), deny anything other than this tool (behavioral_sequence.rs:79-85).

Algorithm

evaluate takes one snapshot() and checks four rules against cumulative journal fields, not against a streak re-derived from the bounded tool_sequence ring tail. The body, carrying the source's own reasoning for each read:

crates/guards/chio-guards/src/behavioral_sequence.rs63-147rust
fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
    let tool_name = &ctx.request.tool_name;

    let snapshot = self.journal.snapshot().map_err(|e| {
        KernelError::Internal(format!(
            "behavioral-sequence guard journal error (fail-closed): {e}"
        ))
    })?;

    // Check required first tool. "Have any tools run yet" is a cumulative
    // property, so consult the journal's cumulative O(1) last-tool field
    // (`current_streak_tool`), which is `None` only before the first record,
    // NOT the bounded `tool_sequence` ring. When `journal_entry_cap` is 0 the
    // ring stores no tool names (capacity 0 = disabled), so `tool_sequence`
    // would report EVERY call as the first and mis-fire this check; the
    // cumulative field is correct at any entry cap.
    if snapshot.current_streak_tool.is_none() {
        if let Some(ref required_first) = self.policy.required_first_tool {
            if tool_name != required_first {
                return Ok(GuardDecision::deny(Vec::new()));
            }
        }
    }

    // Check required predecessors. "Has this tool ever been invoked" is a
    // cumulative property, so consult the journal's cumulative `tool_counts`
    // (which survives ring eviction) rather than the
    // bounded `tool_sequence` tail. A predecessor invoked once and then
    // pushed out of the retained window is still known to have run, so a
    // workflow that runs setup once and then more than `journal_entry_cap`
    // other calls is no longer falsely denied when a dependent tool needs
    // that evicted predecessor. `tool_counts` cannot grow without bound: the
    // journal caps its distinct-key set fail-closed
    // (`journal_tool_counts_cap`). Legitimate registry-bounded predecessors
    // stay recorded, but a predecessor that overflowed the cap is absent here
    // and therefore denies (fail-closed) rather than being falsely treated as
    // invoked.
    if let Some(required) = self.policy.required_predecessors.get(tool_name) {
        for req in required {
            if !snapshot.tool_counts.contains_key(req) {
                return Ok(GuardDecision::deny(Vec::new()));
            }
        }
    }

    // Check forbidden transitions. The "last invoked tool" comes from the
    // journal's cumulative O(1) last-tool field (`current_streak_tool`), NOT
    // the bounded `tool_sequence` tail: when `journal_entry_cap` is 0 the ring
    // stores no tool names (capacity 0 = disabled), so `tool_sequence.last()`
    // is always None and a forbidden transition would silently never fire
    // (fail-OPEN), letting a memory-budget setting disable a transition-deny
    // policy. The cumulative field tracks the most recent recorded tool at any
    // entry cap, so the check holds fail-closed.
    if let Some(last_tool) = snapshot.current_streak_tool.as_deref() {
        for (from, to) in &self.policy.forbidden_transitions {
            if last_tool == from && tool_name == to {
                return Ok(GuardDecision::deny(Vec::new()));
            }
        }
    }

    // Check max consecutive. The count of prior consecutive same-tool
    // invocations comes from the journal's cumulative O(1) streak counter
    // (`current_streak_tool` + `current_streak_len`), NOT the bounded
    // `tool_sequence` tail. When `journal_entry_cap` is smaller than
    // `max_consecutive`, the ring evicts the older part of a same-tool streak,
    // so counting the retained tail would undercount and ALLOW a call that
    // must be DENIED. The cumulative counter survives ring eviction, so the
    // streak limit holds regardless of the entry cap. If the
    // request tool differs from the current-streak tool, no prior consecutive
    // run exists for it (it would start a fresh streak).
    if let Some(max_consec) = self.policy.max_consecutive {
        let prior_streak =
            if snapshot.current_streak_tool.as_deref() == Some(tool_name.as_str()) {
                snapshot.current_streak_len
            } else {
                0
            };
        if prior_streak >= u64::from(max_consec) {
            return Ok(GuardDecision::deny(Vec::new()));
        }
    }

    Ok(GuardDecision::allow())
}

Cumulative fields survive ring eviction

Each check reads a cumulative field instead of deriving state from the bounded tool_sequence ring. tool_counts still answers "was this tool ever invoked" after the setup call has been evicted; current_streak_len counts a same-tool run longer than the entry cap; and current_streak_tool reports the last recorded tool even at entry-cap 0, where the ring stores nothing. Deriving these from the retained ring tail would undercount an evicted streak (fail-open) or misreport the first call.

BehavioralProfileGuard

Source: crates/guards/chio-guards/src/behavioral_profile.rs. Guard name: behavioral-profile (behavioral_profile.rs:213). Computes anomaly signals against a per-agent EMA baseline. The verdict path is advisory: even when the sample is anomalous, evaluate returns GuardDecision::allow() (behavioral_profile.rs:355-365).

Defaults

Every default constant is declared at the top of the module:

crates/guards/chio-guards/src/behavioral_profile.rs45-54rust
/// Default EMA smoothing factor. Equivalent to a ~10-sample window.
pub const DEFAULT_EMA_ALPHA: f64 = 0.2;
/// Default sigma threshold above which a window is flagged.
pub const DEFAULT_SIGMA_THRESHOLD: f64 = 2.0;
/// Default rolling window length in seconds.
pub const DEFAULT_WINDOW_SECS: u64 = 60;
/// Default number of historical windows used to prime the baseline
/// before the guard starts emitting signals. Guarantees the z-score
/// has enough history to be meaningful.
pub const DEFAULT_BASELINE_MIN_WINDOWS: u64 = 3;
KnobTypeDefaultSource
ema_alphaf640.2behavioral_profile.rs:46 (clamped to (0.0, 1.0] on every update at operator_report/behavioral_analysis.rs:179).
sigma_thresholdf642.0behavioral_profile.rs:48.
window_secsu6460behavioral_profile.rs:50.
baseline_min_windowsu643behavioral_profile.rs:54. Anomalies cannot fire until at least three windows have folded into the baseline.

EmaBaselineState

The baseline state is shared with the operator-report module, now a submodule directory:

crates/kernel/chio-kernel/src/operator_report/behavioral_analysis.rs151-169rust
/// EMA (exponentially-weighted moving average) baseline state for a
/// single (agent, metric) pair. Used by behavioral profiling to detect
/// z-score anomalies without storing every historical sample.
///
/// The baseline uses Welford-style incremental tracking of mean and
/// variance so callers can compute a z-score for any new sample
/// without re-reading history.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EmaBaselineState {
    /// Number of samples folded into the baseline.
    pub sample_count: u64,
    /// Exponentially-weighted mean.
    pub ema_mean: f64,
    /// Exponentially-weighted variance.
    pub ema_variance: f64,
    /// Last update timestamp (unix seconds).
    pub last_update: u64,
}

Its whole surface is three methods, one of which the guard replaces:

crates/kernel/chio-kernel/src/operator_report/behavioral_analysis.rs171-213rust
impl EmaBaselineState {
    /// Fold a new sample into the baseline with the provided smoothing
    /// factor `alpha` (0.0..=1.0). Higher alpha weighs recent samples
    /// more heavily.
    ///
    /// `alpha` is clamped to `(0.0, 1.0]`. `now` is recorded as
    /// `last_update`.
    pub fn update(&mut self, sample: f64, alpha: f64, now: u64) {
        let alpha = alpha.clamp(f64::MIN_POSITIVE, 1.0);
        if self.sample_count == 0 {
            self.ema_mean = sample;
            self.ema_variance = 0.0;
        } else {
            let prev_mean = self.ema_mean;
            self.ema_mean = prev_mean + alpha * (sample - prev_mean);
            // Incremental EWMA variance, following West (1979) / Welford.
            let diff = sample - prev_mean;
            self.ema_variance = (1.0 - alpha) * (self.ema_variance + alpha * diff * diff);
        }
        self.sample_count = self.sample_count.saturating_add(1);
        self.last_update = now;
    }

    /// Standard deviation (sqrt of EWMA variance).
    #[must_use]
    pub fn stddev(&self) -> f64 {
        self.ema_variance.max(0.0).sqrt()
    }

    /// Z-score for a new sample. Returns `None` when the baseline has
    /// fewer than two samples or zero variance (no meaningful signal).
    #[must_use]
    pub fn z_score(&self, sample: f64) -> Option<f64> {
        if self.sample_count < 2 {
            return None;
        }
        let stddev = self.stddev();
        if stddev <= f64::EPSILON {
            return None;
        }
        Some((sample - self.ema_mean) / stddev)
    }
}

Two things to read out of the update body:

  • First sample seeds the mean. When sample_count == 0 the very first call sets ema_mean = sample and ema_variance = 0.0. The arithmetic prev_mean + alpha * (sample - prev_mean) (which is just the standard EMA update) does not run on sample one; it runs from sample two onward.
  • EWMA variance, not sample variance. The variance update (1 - alpha) * (ema_variance + alpha * diff * diff) is the West/Welford incremental EWMA form. stddev() is sqrt(max(ema_variance, 0.0)); there is no Bessel correction and no separate sample-variance path.

Robust z-score with Poisson floor

The guard does not call EmaBaselineState::z_score directly; it uses a variant that clamps stddev away from zero:

crates/guards/chio-guards/src/behavioral_profile.rs329-348rust
/// Z-score with a Poisson-style stddev floor.
///
/// For count metrics (call rate, deny count, unique tools) a zero
/// measured variance is an artifact of a short baseline rather than a
/// true zero-noise process. We floor the effective stddev at
/// `sqrt(max(mean, 1))` so that a 50x spike over a steady 10/window
/// baseline is detected as an anomaly even when the EWMA variance
/// happens to be numerically zero.
fn robust_z_score(state: &EmaBaselineState, sample: f64) -> Option<f64> {
    if state.sample_count < 2 {
        return None;
    }
    let measured = state.stddev();
    let floor = state.ema_mean.max(1.0).sqrt();
    let effective = measured.max(floor);
    if effective <= f64::EPSILON {
        return None;
    }
    Some((sample - state.ema_mean) / effective)
}

The early return on sample_count < 2 is the documented reason the first sample never updates a usable baseline: observe_sample calls robust_z_score before EmaBaselineState::update, so the very first observation always returns z_score = None and anomaly = false. The Poisson floor sqrt(max(mean, 1)) means a 50x spike over a steady 10/window baseline still flags even when EWMA variance is numerically zero.

Window-start quantization

The current-window calculation is a plain integer-division quantizer:

crates/guards/chio-guards/src/behavioral_profile.rs294-297rust
fn current_window_start(&self, now: u64) -> u64 {
    let window = self.config.window_secs.max(1);
    (now / window) * window
}

now is in unix seconds (behavioral_profile.rs:322-326), and the window_secs.max(1) guards a misconfigured zero. Two consequences:

  • Calls within the same window-start bucket fold into one sample. observe_sample short-circuits when last_window_start == window_start (behavioral_profile.rs:246-257) and returns the cached outcome without bumping sample_count.
  • There are no sub-second timestamps. The clock source is SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()) (behavioral_profile.rs:323-326), so anything finer than 1 s is discarded before the divisor sees it.

Metrics

From BehavioralMetric (behavioral_profile.rs:57-79):

  • CallRate :: "call_rate", receipts per window.
  • DenyRate :: "deny_rate", denies per window.
  • UniqueTools :: "unique_tools", distinct tool names per window.
  • AvgParameterEntropy :: "avg_parameter_entropy", Shannon entropy of parameters.

On the synchronous Guard::evaluate path (behavioral_profile.rs:355-365), only CallRate is sampled: the count is receipts.len() as f64 from sample_for_window (behavioral_profile.rs:299-305). The other three metrics are reachable through observe_sample for callers that want to feed values out-of-band (an offline batch or a dashboard).

BehavioralAnomalyScore

The dashboard-visible struct paired with EmaBaselineState. The synchronous guard never fills it: evaluate discards its observation outcome and returns a bare GuardDecision::allow(). The struct is instead assembled by a caller, either the operator-report path or a dashboard, that invokes observe_sample directly and reads the ObservationOutcome it returns:

crates/kernel/chio-kernel/src/operator_report/behavioral_analysis.rs215-235rust
/// Summary of behavioral-anomaly signals derived from receipts over a
/// window. Used by `BehavioralProfileGuard` and surfaced in operator
/// UIs.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BehavioralAnomalyScore {
    /// Agent subject this anomaly score applies to.
    pub agent_id: String,
    /// Baseline statistic the z-score is computed against.
    pub baseline: EmaBaselineState,
    /// Current-window sample value (e.g. call count per window).
    pub current_sample: f64,
    /// Computed z-score, or `None` when baseline is too small.
    pub z_score: Option<f64>,
    /// Threshold above which an advisory signal is raised.
    pub sigma_threshold: f64,
    /// Whether the current sample crossed the threshold.
    pub anomaly: bool,
    /// Unix timestamp (seconds) at which the score was computed.
    pub generated_at: u64,
}

The matching per-call return type is ObservationOutcome (behavioral_profile.rs:309-320), which pairs the same z_score, anomaly, baseline, and sample fields.

Algorithm

  1. window_start = (now / window_secs) * window_secs (behavioral_profile.rs:294-297).
  2. Read receipts in [window_start, window_end - 1] where window_end = window_start + window_secs.max(1) via the ReceiptFeedSource (behavioral_profile.rs:299-305). The upper bound is exclusive after thesaturating_sub(1).
  3. Sample = receipts.len() as f64.
  4. Compute robust_z_score(&state, sample) against the pre-update baseline. If sample_count >= baseline_min_windows and |z| > sigma_threshold, mark anomaly = true (behavioral_profile.rs:259-263).
  5. Update the baseline with the new sample (one update per window-start; repeated calls in the same bucket short-circuit at behavioral_profile.rs:246-257).
  6. Return GuardDecision::allow(). The observation outcome is discarded at let _ = self.observe_sample(...)?, and allow() carries an empty evidence array, so the computed z-score, anomaly flag, and baseline never reach this guard's GuardDecision and never land in the signed receipt through the synchronous evaluate path. The one durable effect of the call is folding the new sample into the per-agent EMA baseline for the next window.

Failure modes

  • Mutex poisoning :: Err(KernelError::Internal("baseline lock poisoned")) (behavioral_profile.rs:240).
  • Receipt feed error :: propagated through ? (behavioral_profile.rs:359). The kernel reads it as deny even though the normal verdict is advisory.
  • Cold baseline (sample count below baseline_min_windows) :: never flags. The first observation also returns z_score = None from the sample_count < 2 early return.

Storage

Baselines live in memory keyed by (agent_id, BehavioralMetric) behind a single Mutex<HashMap<...>> (behavioral_profile.rs:199). The receipt feed is pluggable through ReceiptFeedSource (behavioral_profile.rs:84-95); InMemoryReceiptFeed (behavioral_profile.rs:104-158) ships with the crate for tests and lightweight deployments. Production wiring backs the trait with ReceiptStore::query_receipts from chio-store-sqlite.


Composition

rust
use std::sync::Arc;
use chio_guards::{
    DataFlowGuard, DataFlowConfig,
    BehavioralSequenceGuard, SequencePolicy,
    BehavioralProfileGuard, BehavioralProfileConfig,
    InMemoryReceiptFeed,
};
use chio_http_session::SessionJournal;

let journal = Arc::new(SessionJournal::new("sess-1".to_string()));
let feed = InMemoryReceiptFeed::new();

let mut pipeline = chio_guards::GuardPipeline::new();

pipeline.add(Box::new(DataFlowGuard::new(
    journal.clone(),
    DataFlowConfig {
        max_bytes_read: Some(50 * 1024 * 1024),
        max_bytes_written: Some(10 * 1024 * 1024),
        max_bytes_total: None,
    },
)));

let mut policy = SequencePolicy::default();
policy.required_first_tool = Some("init".to_string());
pipeline.add(Box::new(BehavioralSequenceGuard::new(journal.clone(), policy)));

pipeline.add(Box::new(BehavioralProfileGuard::with_config(
    Box::new(feed),
    BehavioralProfileConfig::default(),
)));

Journal-unavailable means deny

All three guards are fail-closed. A journal that fails to read, a receipt feed that returns an error, and a poisoned mutex all returnErr(KernelError::Internal(...)) from the guard. The kernel reads every Err as a denial. A session-aware guard that cannot read session state cannot make a safe allow decision.

Next steps

Session-Aware Guards · Chio Docs