use super::*;
pub fn count_consultations(events: &[EnforcementEvent]) -> u64 {
let mut by_subject: HashMap<&str, Vec<u64>> = HashMap::new();
for e in events {
if matches!(e.event_type, EnforcementEventType::ReceiptMinted) {
by_subject
.entry(e.subject_key.as_str())
.or_default()
.push(e.recorded_at_ms);
}
}
let mut consultations = 0u64;
for times in by_subject.values_mut() {
times.sort_unstable();
let mut anchor: Option<u64> = None;
for &t in times.iter() {
let coalesces =
matches!(anchor, Some(a) if t.saturating_sub(a) <= CONSULTATION_COALESCE_MS);
if !coalesces {
anchor = Some(t);
consultations += 1;
}
}
}
consultations
}
pub fn aggregate_event_counts(events: &[EnforcementEvent]) -> EnforcementEventCounts {
let mut counts = EnforcementEventCounts {
total: events.len() as u64,
consultations: count_consultations(events),
..Default::default()
};
for e in events {
match &e.event_type {
EnforcementEventType::Deny => counts.denials += 1,
EnforcementEventType::AllowAfterReceipt => counts.allowed_after_receipt += 1,
EnforcementEventType::ReceiptMinted => counts.receipts_minted += 1,
EnforcementEventType::BypassDetected => counts.bypasses += 1,
EnforcementEventType::ControlChanged { change_kind } => {
counts.controls_changed += 1;
match change_kind {
ControlChangeKind::Created => counts.controls_created += 1,
ControlChangeKind::Confirmed => counts.controls_confirmed += 1,
ControlChangeKind::Updated => counts.controls_updated += 1,
ControlChangeKind::Deleted => counts.controls_removed += 1,
}
}
EnforcementEventType::EnforcementConfigChanged { .. } => counts.config_changes += 1,
EnforcementEventType::RecordingGap { .. } => counts.gaps += 1,
EnforcementEventType::RetentionPruned { .. } => counts.retention_prunes += 1,
EnforcementEventType::CleanShutdown { .. } => counts.clean_shutdowns += 1,
EnforcementEventType::SubagentSpawned => counts.subagent_spawns += 1,
EnforcementEventType::SubagentEdge => counts.subagent_edges += 1,
}
}
counts
}
#[derive(Debug, Default)]
pub struct EnforcementEventCounts {
pub total: u64,
pub denials: u64,
pub allowed_after_receipt: u64,
pub receipts_minted: u64,
pub consultations: u64,
pub bypasses: u64,
pub controls_changed: u64,
pub controls_created: u64,
pub controls_confirmed: u64,
pub controls_updated: u64,
pub controls_removed: u64,
pub config_changes: u64,
pub gaps: u64,
pub retention_prunes: u64,
pub clean_shutdowns: u64,
pub subagent_spawns: u64,
pub subagent_edges: u64,
}
#[derive(Debug, Default)]
pub struct DerivedEnforcementMetrics {
pub blocked_sessions: u64,
pub attributed_denials: u64,
pub blocks_per_session: Option<f64>,
pub median_time_to_consult_ms: Option<u64>,
pub consult_pairs: u64,
}
pub fn derive_enforcement_metrics(events: &[EnforcementEvent]) -> DerivedEnforcementMetrics {
use std::collections::{BTreeSet, HashMap};
let mut blocked_sessions: BTreeSet<&str> = BTreeSet::new();
let mut attributed_denials = 0u64;
for e in events {
if matches!(e.event_type, EnforcementEventType::Deny) {
if let Some(sid) = e.agent_session.as_deref() {
blocked_sessions.insert(sid);
attributed_denials += 1;
}
}
}
let blocks_per_session = if blocked_sessions.is_empty() {
None
} else {
Some(attributed_denials as f64 / blocked_sessions.len() as f64)
};
let mut receipts_by_subject: HashMap<&str, Vec<u64>> = HashMap::new();
for e in events {
if matches!(e.event_type, EnforcementEventType::ReceiptMinted) {
receipts_by_subject
.entry(e.subject_key.as_str())
.or_default()
.push(e.recorded_at_ms);
}
}
for times in receipts_by_subject.values_mut() {
times.sort_unstable();
}
let window_ms = crate::store::session::CONSULTED_RECENT_TTL_SECS * 1_000;
let mut deltas: Vec<u64> = Vec::new();
for e in events {
if matches!(e.event_type, EnforcementEventType::Deny) {
if let Some(times) = receipts_by_subject.get(e.subject_key.as_str()) {
if let Some(&t) = times.iter().find(|&&t| t >= e.recorded_at_ms) {
let delta = t - e.recorded_at_ms;
if delta <= window_ms {
deltas.push(delta);
}
}
}
}
}
let consult_pairs = deltas.len() as u64;
let median_time_to_consult_ms = median_u64(&mut deltas);
DerivedEnforcementMetrics {
blocked_sessions: blocked_sessions.len() as u64,
attributed_denials,
blocks_per_session,
median_time_to_consult_ms,
consult_pairs,
}
}
pub(crate) fn median_u64(values: &mut [u64]) -> Option<u64> {
if values.is_empty() {
return None;
}
values.sort_unstable();
let n = values.len();
let mid = n / 2;
if n % 2 == 1 {
Some(values[mid])
} else {
Some((values[mid - 1] + values[mid]) / 2)
}
}
pub fn event_type_label(event_type: &EnforcementEventType) -> &'static str {
match event_type {
EnforcementEventType::Deny => "deny",
EnforcementEventType::AllowAfterReceipt => "allow_receipt",
EnforcementEventType::ReceiptMinted => "receipt_minted",
EnforcementEventType::BypassDetected => "bypass",
EnforcementEventType::ControlChanged { .. } => "control_changed",
EnforcementEventType::EnforcementConfigChanged { .. } => "config_changed",
EnforcementEventType::RecordingGap { .. } => "gap",
EnforcementEventType::RetentionPruned { .. } => "retention_pruned",
EnforcementEventType::CleanShutdown { .. } => "clean_shutdown",
EnforcementEventType::SubagentSpawned => "subagent_spawned",
EnforcementEventType::SubagentEdge => "subagent_edge",
}
}