Session-Aware Guards
A session's own history, from the bytes it has read to the order of its tools, can refuse its next call.
On this page
A session guard refuses a call for what the session has already done: the bytes it has moved and the order in which it ran its tools.
Three guards in chio-guards read that history. DataFlowGuard caps cumulative bytes and BehavioralSequenceGuard enforces tool order, both from one shared session journal. BehavioralProfileGuard scores each agent's call rate against its own baseline and always allows, so its signal reaches operators instead of the verdict.
The journal guards read one snapshot of a hash-chained journal
The host records each call into the session's SessionJournal. A guard reads it through snapshot(), which takes the lock once, so no guard sees a torn read across fields.
Eviction drops entries and keeps the chain
The ring holds the last 4 entries. Each entry carries the hash of the one before it, and an evicted entry's hash folds into evicted_head_hash, so integrity verification still commits to the dropped prefix.
Entries sit in capacity-bounded rings (chio_bounded::Ring) whose caps come from chio_kernel::MemoryBudgetConfig. When a ring evicts an entry, its hash folds into evicted_head_hash, so integrity verification still commits to the dropped prefix.
Two pieces of state outlive eviction, and the guards read both cumulatively: tool_counts, itself bounded by tool_counts_cap so a newly seen name is dropped fail-closed once it is full, and the current_streak_tool and current_streak_len pair. Byte and invocation totals use saturating_add on every record and clamp at u64::MAX.
The journal is per-process memory for one session, separate from the session state that Session Lifecycle manages. A restriction that must survive a restart belongs in persistent taint.
Sequence rules read counters that outlive the ring
BehavioralSequenceGuard, registered as behavioral-sequence, checks the requested tool against a SequencePolicy of four rules. Each rule reads a cumulative journal field:
required_first_tool. While nothing is recorded (current_streak_toolisNone), any other tool is refused.required_predecessors. A target tool is refused unless every named predecessor appears intool_counts, which still answers after the setup call has left the ring.forbidden_transitions. A(from, to)pair refusestowhencurrent_streak_tool, the last recorded tool, isfrom.max_consecutive. The O(1) countercurrent_streak_lenrefuses a run of the requested tool at the ceiling, including a run longer than the ring.
Rebuilding these from the ring's tail would undercount an evicted streak, which fails open, and would misreport the first call at an entry cap of 0, where the ring stores nothing. A required predecessor means a recorded occurrence; when the setup call must also have succeeded, check its result or the workflow explicitly.
Read more in Rules Across a Session.
Byte ceilings end a session once it has moved enough data
DataFlowGuard, registered as data-flow, reads cumulative bytes from the snapshot and refuses once any configured ceiling is reached: max_bytes_read, max_bytes_written, or max_bytes_total, the read and written totals added with saturating_add. Every ceiling defaults to None, so a default guard admits everything.
The comparison is inclusive. A session that has read exactly max_bytes_read is refused on its next call, because the guard compares what previous calls recorded and never pre-charges the call in flight. The action kind plays no part: a call that reports zero bytes still runs all three checks.
Once a total reaches its ceiling, every later call in the session is refused until the session is replaced. Saturation keeps a counter from wrapping to zero and re-admitting a session that has hit its limit.
The profile guard scores call rate and always allows
BehavioralProfileGuard, registered as behavioral-profile, counts each agent's receipts in the current window and compares the count with an exponentially weighted moving average (EMA) of past windows. A window is anomalous when the baseline holds at least baseline_min_windows samples (default 3) and the absolute z-score exceeds sigma_threshold (default 2.0).
Its verdict is GuardDecision::allow() with an empty evidence array, so the score, the flag, and the baseline stay out of the signed receipt. The call's lasting effect is one more sample folded into the agent's baseline. The operator report and dashboards call observe_sample directly and read the ObservationOutcome it returns.
A receipt feed error still propagates, and the kernel refuses the call. The advisory guard fails closed when it cannot read its input.
Read more in Advisory Guards.
The host wires the guards over one journal and one receipt feed
Each journal reader takes an Arc<SessionJournal> at construction and shares the state without owning it. The profile guard takes a receipt feed instead.
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(),
)));
// Install on the existing mutable kernel.
kernel.add_guard(Box::new(pipeline));The host records each call and its byte counts into the journal and supplies a receipt feed; InMemoryReceiptFeed serves tests and lightweight deployments, and production wiring backs the feed with the receipt store. A journal that fails to read, a feed that returns an error, and a poisoned mutex each return Err(KernelError::Internal(...)), which the kernel reads as a refusal.
Session guard fields and source
Search for snapshot, max_bytes_read, sigma_threshold, or current_streak_len.
Session journal structs
Source crates/platform/chio-http-session/src/lib.rs. The public handle is a mutex around the inner state; the inner state's field comments say which pieces are cumulative, and the byte and invocation totals live in their own struct that the snapshot hands to the guards.
/// 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,
}/// 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,
}/// 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,
}Snapshot and record
snapshot() -> Result<SessionJournalSnapshot, SessionJournalError>takes the lock once and returnssession_id,entry_count,head_hash,data_flow,tool_sequence,tool_counts,current_streak_tool, andcurrent_streak_lentogether.record(RecordParams) -> Result<u64, SessionJournalError>appends a hash-chained entry and returns its sequence number.RecordParamscarriestool_name,server_id,agent_id,bytes_read,bytes_written,delegation_depth, andallowed.data_flow()andtool_sequence()are public accessors;DataFlowGuardandBehavioralSequenceGuardread throughsnapshot()instead.
DataFlowGuard configuration
Source crates/guards/chio-guards/src/data_flow.rs; guard name data-flow (data_flow.rs:54). The guard holds the shared journal and its config, and Default on DataFlowConfig sets every ceiling to None.
/// 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>,
}/// 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,
}| Knob | Type | Default | Behavior |
|---|---|---|---|
max_bytes_read | Option<u64> | None | Cumulative read ceiling. Inclusive: flow.. |
max_ | Option<u64> | None | Cumulative write ceiling, with the same inclusive comparison. |
max_bytes_total | Option<u64> | None | Cumulative read plus write ceiling, computed as flow.. |
DataFlowGuard evaluate
The body reads cumulative data flow from one snapshot() and runs the three ceiling checks.
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())
}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 EiB, 18.4 quintillion bytes), a ceiling no web-scale session reaches.
DataFlowGuard failures
- A poisoned journal lock:
map_errturnsSessionJournalError::LockPoisonedintoKernelError::Internal("data-flow guard journal error (fail-closed): {e}"), and the kernel readsErr(_)from a guard as a refusal. - A saturated counter: a refusal stays a refusal. Once any total reaches its ceiling, every later call is refused until the session is replaced.
SequencePolicy
Source crates/guards/chio-guards/src/behavioral_sequence.rs; guard name behavioral-sequence (behavioral_sequence.rs:60). The guard pairs the policy with the shared journal.
/// 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>,
}/// Guard that enforces tool ordering policies using the session journal.
pub struct BehavioralSequenceGuard {
journal: Arc<SessionJournal>,
policy: SequencePolicy,
}Sequence rule configuration
| Knob | Type | Default | Check |
|---|---|---|---|
required_ | HashMap<String, HashSet<String>> | empty | For target tool_name, refuse if a required predecessor is absent from the cumulative tool_counts map (behavioral_). |
forbidden_ | Vec<(String, String)> | empty | If current_ is from and the requested tool is to, refuse (behavioral_). |
max_consecutive | Option<u32> | None | Read the O(1) counter current_; refuse when a run of the requested tool reaches the ceiling (behavioral_). |
required_ | Option<String> | None | While current_ is None, refuse any other tool (behavioral_). |
BehavioralSequenceGuard evaluate
evaluate takes one snapshot() and checks the four rules against cumulative journal fields. The body carries the source's own reasoning for each read.
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())
}Profile defaults
Source crates/guards/chio-guards/src/behavioral_profile.rs; guard name behavioral-profile (behavioral_profile.rs:213). The verdict path returns GuardDecision::allow() for anomalous samples too (behavioral_profile.rs:355-365). Every default constant is declared at the top of the module.
/// 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;| Knob | Type | Default | Source |
|---|---|---|---|
ema_alpha | f64 | 0.2 | behavioral_; clamped to (0.0, 1.0] on every update (operator_). |
sigma_threshold | f64 | 2.0 | behavioral_ |
window_secs | u64 | 60 | behavioral_ |
baseline_ | u64 | 3 | behavioral_; anomalies fire only after three windows have folded into the baseline. |
EmaBaselineState
The baseline state is shared with the operator-report module, a submodule directory. Its whole interface is three methods, one of which the guard replaces.
/// 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,
}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)
}
}- The first sample seeds the mean. With
sample_count == 0, the first call setsema_mean = sampleandema_variance = 0.0. The standard EMA updateprev_mean + alpha * (sample - prev_mean)runs from sample two onward. - The variance is an EWMA. The update
(1 - alpha) * (ema_variance + alpha * diff * diff)is the West/Welford incremental EWMA form.stddev()issqrt(max(ema_variance, 0.0)), with no Bessel correction and no separate sample-variance path.
Robust z-score with Poisson floor
The guard scores with a variant of EmaBaselineState::z_score that clamps the standard deviation away from zero.
/// 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)
}observe_sample calls robust_z_score before EmaBaselineState::update, so the early return on sample_count < 2 gives the first observation z_score = None and anomaly = false. The Poisson floor sqrt(max(mean, 1)) means a 50x spike over a steady 10-per-window baseline still flags when the EWMA variance is numerically zero.
Window-start quantization
The current window comes from an integer-division quantizer over unix seconds (behavioral_profile.rs:322-326); window_secs.max(1) guards a misconfigured zero.
fn current_window_start(&self, now: u64) -> u64 {
let window = self.config.window_secs.max(1);
(now / window) * window
}- Calls within one window-start bucket fold into one sample.
observe_sampleshort-circuits whenlast_window_start == window_start(behavioral_profile.rs:246-257) and returns the cached outcome without bumpingsample_count. - Timestamps are whole seconds. The clock 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.
The synchronous Guard::evaluate path (behavioral_profile.rs:355-365) samples CallRate alone: the count is receipts.len() as f64 from sample_for_window (behavioral_profile.rs:299-305). The other three metrics reach the baseline through observe_sample, for callers that feed values out of band, such as an offline batch or a dashboard.
BehavioralAnomalyScore
The dashboard-visible struct paired with EmaBaselineState. The operator-report path or a dashboard assembles it after calling observe_sample directly; the synchronous guard discards its observation outcome and returns a bare GuardDecision::allow().
/// 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 per-call return type is ObservationOutcome (behavioral_profile.rs:309-320), with the same z_score, anomaly, baseline, and sample fields.
Profile evaluate steps
window_start = (now / window_secs) * window_secs(behavioral_profile.rs:294-297).- Read receipts in
[window_start, window_end - 1], wherewindow_end = window_start + window_secs.max(1), through theReceiptFeedSource(behavioral_profile.rs:299-305). The inclusive upper bound is the last whole second beforewindow_end. - Sample =
receipts.len() as f64. - Compute
robust_z_score(&state, sample)against the pre-update baseline. Withsample_count >= baseline_min_windowsand|z| > sigma_threshold, setanomaly = true(behavioral_profile.rs:259-263). - Update the baseline with the new sample, once per window start; repeated calls in the same bucket short-circuit (
behavioral_profile.rs:246-257). - Return
GuardDecision::allow().let _ = self.observe_sample(...)?discards the outcome, andallow()carries an empty evidence array, so the z-score, anomaly flag, and baseline reach neither this guard'sGuardDecisionnor the signed receipt.
Profile failures
- A poisoned mutex returns
Err(KernelError::Internal("baseline lock poisoned"))(behavioral_profile.rs:240). - A receipt feed error propagates through
?(behavioral_profile.rs:359), and the kernel reads it as a refusal although the normal verdict is advisory. - A cold baseline, with fewer samples than
baseline_min_windows, never flags. The first observation also returnsz_score = Nonefrom thesample_count < 2early return.
Baseline storage
Baselines live in memory keyed by (agent_id, BehavioralMetric) behind a single Mutex<HashMap<...>> (behavioral_profile.rs:199). The receipt feed plugs in through ReceiptFeedSource (behavioral_profile.rs:84-95). InMemoryReceiptFeed (behavioral_profile.rs:104-158) ships with the crate, and production wiring backs the trait with ReceiptStore::query_receipts from chio-store-sqlite.