audit-verify, autopromote, histogramas y hot-reload caps

- audit::verify_chain_from_cas() recorre prev_sha desde head hasta genesis,
  valida sha256(blob)==sha del CAS para detectar tampering. Endpoint
  VerifyAudit + brainctl audit-verify.
- autopromote loop background: cada N segundos detecta cristales sobre
  threshold y los promueve sin operador. Anti-doble vía HashSet de pares
  (ant, con). Flag --autopromote-secs.
- GapHistogram con buckets exponenciales (1ms..1000s) capturado en
  observer.record(). top_gap_pairs(K) limita cardinalidad. Expuesto en
  Prometheus como ente_brain_pair_gap_seconds histogram per pair.
- BusRequest::UpdateCapabilities con auth obligatorio (sólo el dueño puede
  modificar sus caps). Incarnated.dynamic_provides separa runtime de la
  Card immutable. Graph mediator actualiza providers index + revoca grants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-03 23:32:54 +00:00
parent a4fa42c781
commit badf4257ec
12 changed files with 406 additions and 8 deletions
+11
View File
@@ -52,6 +52,7 @@ async fn main() -> anyhow::Result<()> {
IntrospectRequest::ListAudit { limit }
}
"flush-audit" => IntrospectRequest::FlushAudit,
"audit-verify" | "verify" => IntrospectRequest::VerifyAudit,
"reload" => {
let path = args.get(2).cloned();
IntrospectRequest::ReloadRules { path }
@@ -130,6 +131,16 @@ fn print_response(r: &IntrospectResponse) {
IntrospectResponse::Reloaded { count } => {
println!("reload OK: {count} reglas activas tras reload");
}
IntrospectResponse::AuditVerified(rep) => {
if let Some(seq) = rep.broken_at_seq {
println!("✗ verificación FALLÓ tras seq={seq}");
if let Some(e) = &rep.error { println!(" motivo: {e}"); }
println!(" entries verificadas: {}", rep.verified);
} else {
println!("✓ chain verificada — {} entries íntegras", rep.verified);
if let Some(g) = rep.genesis_sha { println!(" genesis: {}", hex_long(g)); }
}
}
IntrospectResponse::Error(e) => eprintln!("error: {e}"),
}
}
+77
View File
@@ -164,6 +164,83 @@ pub struct AuditHeadPointer {
pub timestamp_ms: u64,
}
/// Reporte de verificación de la cadena audit.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerificationReport {
/// Cuántas entries se recorrieron y verificaron exitosamente.
pub verified: u64,
/// Si hubo error, el seq donde se detectó.
pub broken_at_seq: Option<u64>,
/// Detalles del error si hubo.
pub error: Option<String>,
/// SHA del genesis (primer entry; prev_sha = None).
pub genesis_sha: Option<[u8; 32]>,
}
/// Recorre la cadena del audit log desde `start_sha` hacia atrás vía `prev_sha`
/// hasta el genesis. Para cada entry valida:
/// 1. CAS contiene un blob bajo ese SHA
/// 2. sha256(blob) == SHA esperado (defensa contra tampering del CAS)
/// 3. El blob deserializa a AuditEntry con sha=[0;32] (forma canónica)
///
/// Devuelve un VerificationReport con el conteo, posibles errores y
/// el SHA del genesis (útil para clientes que quieren cachearlo).
pub fn verify_chain_from_cas(start_sha: [u8; 32]) -> VerificationReport {
let mut current = Some(start_sha);
let mut verified = 0u64;
let mut last_seen: Option<AuditEntry> = None;
while let Some(sha) = current {
let path = ente_cas::cas_root().join(ente_cas::hex(&sha));
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => return VerificationReport {
verified,
broken_at_seq: last_seen.as_ref().map(|e| e.seq),
error: Some(format!("CAS read {}: {e}", path.display())),
genesis_sha: None,
},
};
// Verificación 1: el blob hashea a la SHA esperada (CAS contract).
let actual = ente_cas::sha256_of(&bytes);
if actual != sha {
return VerificationReport {
verified,
broken_at_seq: last_seen.as_ref().map(|e| e.seq),
error: Some(format!(
"CAS tamper en {}: expected {} got {}",
path.display(), ente_cas::hex(&sha), ente_cas::hex(&actual)
)),
genesis_sha: None,
};
}
// Verificación 2: deserialize. El blob canónico tiene sha=[0;32].
let mut entry: AuditEntry = match serde_json::from_slice(&bytes) {
Ok(e) => e,
Err(e) => return VerificationReport {
verified,
broken_at_seq: last_seen.as_ref().map(|e| e.seq),
error: Some(format!("deserialize: {e}")),
genesis_sha: None,
},
};
// Re-poblar el sha en el entry para reportar coherentemente.
entry.sha = sha;
verified += 1;
let prev = entry.prev_sha;
last_seen = Some(entry);
current = prev;
}
VerificationReport {
verified,
broken_at_seq: None,
error: None,
genesis_sha: last_seen.as_ref().map(|e| e.sha),
}
}
impl Default for AuditLog {
fn default() -> Self { Self::new() }
}
+102
View File
@@ -0,0 +1,102 @@
//! Autopromote loop. Background task que cada N segundos detecta cristales
//! con thresholds altos y los promueve sin intervención humana.
//!
//! Anti-doble-promote: tras promover, registramos en un set la pareja
//! (antecedent_kind, consequent_kind). Antes de promover, verificamos que
//! no exista ya una regla con el mismo trigger_kind (heurística simple —
//! evita ráfagas de duplicados de la misma estadística).
use crate::audit::AuditAction;
use crate::crystallize::{crystal_to_kcl, crystal_to_rule, detect_crystals, Crystal, CrystallizationParams};
use crate::introspect::{append_kcl_snippet, BrainState};
use crate::rules::EventKind;
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tracing::{info, warn};
#[derive(Debug, Clone, Copy)]
pub struct AutopromoteParams {
pub interval_secs: u64,
pub threshold: CrystallizationParams,
}
impl Default for AutopromoteParams {
fn default() -> Self {
Self {
interval_secs: 60,
// Más estrictos que el threshold default — evitar ruido.
threshold: CrystallizationParams {
min_support: 10,
min_conditional_prob: 0.85,
min_pmi: 2.0,
},
}
}
}
/// Spawn del bucle. El handle Mutex evita que dos pasadas concurrentes
/// promuevan el mismo cristal (el lock garantiza serialización por brain).
pub fn spawn_autopromote_loop(state: BrainState, params: AutopromoteParams) {
let promoted_keys: Arc<Mutex<HashSet<(EventKind, EventKind)>>> =
Arc::new(Mutex::new(HashSet::new()));
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(params.interval_secs));
tick.tick().await; // descartar primer tick inmediato
info!(?params, "autopromote loop activo");
loop {
tick.tick().await;
run_one_pass(&state, &params, &promoted_keys).await;
}
});
}
async fn run_one_pass(
state: &BrainState,
params: &AutopromoteParams,
promoted_keys: &Arc<Mutex<HashSet<(EventKind, EventKind)>>>,
) {
let crystals: Vec<Crystal> = {
let obs = state.observer.read().await;
detect_crystals(&obs, &params.threshold)
};
if crystals.is_empty() { return; }
let mut pk = promoted_keys.lock().await;
for c in crystals {
let key = (c.antecedent.clone(), c.consequent.clone());
if pk.contains(&key) {
// Ya promovido — el observer puede seguir reportando este
// cristal pero no necesitamos otra regla.
continue;
}
promote_one(state, &c).await;
pk.insert(key);
}
}
async fn promote_one(state: &BrainState, c: &Crystal) {
let rule = crystal_to_rule(c);
let snippet = crystal_to_kcl(c);
let rule_id = rule.id;
state.engine.write().await.insert(rule);
if let Some(path) = state.rules_out.as_ref() {
if let Err(e) = append_kcl_snippet(path, &snippet) {
warn!(?e, "autopromote: rules_out append falló");
}
}
state.audit.write().await.append(AuditAction::PromoteCrystal {
rule_id,
crystal: c.clone(),
});
info!(
%rule_id,
antecedent = ?c.antecedent,
consequent = ?c.consequent,
cp = c.conditional_prob,
pmi = c.pmi,
"autopromote: cristal → regla"
);
}
+16
View File
@@ -103,6 +103,9 @@ pub enum IntrospectRequest {
/// Recarga reglas desde el archivo configurado por --rules-out (o el
/// path provisto). Vacía el engine antes de cargar.
ReloadRules { path: Option<String> },
/// Verifica la cadena audit recorriendo prev_sha hasta el genesis,
/// validando integridad de cada entry contra el CAS.
VerifyAudit,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -124,6 +127,8 @@ pub enum IntrospectResponse {
Flushed { written: usize, head_sha: Option<[u8; 32]>, total_flushed: u64 },
/// Resultado de ReloadRules: número total de reglas tras el reload.
Reloaded { count: usize },
/// Resultado de VerifyAudit.
AuditVerified(crate::audit::VerificationReport),
Error(String),
}
@@ -317,6 +322,17 @@ impl IntrospectServer {
Err(e) => IntrospectResponse::Error(format!("flush_to_cas: {e}")),
}
}
IntrospectRequest::VerifyAudit => {
let head = self.state.audit.read().await.last_flushed_sha();
let head = match head {
Some(h) => h,
None => return IntrospectResponse::Error(
"audit log sin entries flushadas — nada que verificar".into()
),
};
let report = crate::audit::verify_chain_from_cas(head);
IntrospectResponse::AuditVerified(report)
}
IntrospectRequest::ReloadRules { path } => {
// Path explícito gana sobre el rules_out configurado.
let resolved = path.map(std::path::PathBuf::from)
+2
View File
@@ -17,6 +17,7 @@
//! - Observer mantiene contadores incrementales — sin recomputación.
pub mod audit;
pub mod autopromote;
pub mod crystallize;
pub mod dispatch;
pub mod engine;
@@ -26,6 +27,7 @@ pub mod metrics;
pub mod observer;
pub mod rules;
pub use autopromote::{spawn_autopromote_loop, AutopromoteParams};
pub use crystallize::{detect_crystals, Crystal, CrystallizationParams};
pub use dispatch::{dispatch_actions, ActionSink, NullSink};
pub use engine::{EventKindDiscriminant, RuleEngine, SubjectInfo};
+26
View File
@@ -102,6 +102,32 @@ async fn format_metrics(state: &BrainState) -> String {
out.push_str("# TYPE ente_brain_crystals_total gauge\n");
out.push_str(&format!("ente_brain_crystals_total {}\n", crystals.len()));
// ---- Histogramas de gaps temporales (top-32 pares más frecuentes) ----
out.push_str("# HELP ente_brain_pair_gap_seconds Time gap between correlated events.\n");
out.push_str("# TYPE ente_brain_pair_gap_seconds histogram\n");
let limits = crate::observer::GapHistogram::bucket_limits();
for ((a, b), hist) in obs.top_gap_pairs(32) {
let labels = format!(r#"a="{}",b="{}""#, kind_label(a), kind_label(b));
for (i, &limit) in limits.iter().enumerate() {
out.push_str(&format!(
"ente_brain_pair_gap_seconds_bucket{{{},le=\"{}\"}} {}\n",
labels, limit, hist.buckets[i]
));
}
out.push_str(&format!(
"ente_brain_pair_gap_seconds_bucket{{{},le=\"+Inf\"}} {}\n",
labels, hist.count
));
out.push_str(&format!(
"ente_brain_pair_gap_seconds_sum{{{}}} {:.6}\n",
labels, hist.sum_secs
));
out.push_str(&format!(
"ente_brain_pair_gap_seconds_count{{{}}} {}\n",
labels, hist.count
));
}
out
}
+55 -1
View File
@@ -20,6 +20,41 @@ pub struct TimedEvent {
pub at: Instant,
}
/// Histograma de gaps temporales con buckets exponenciales en segundos.
/// Cubre 10 órdenes de magnitud: 1ms hasta 1000s.
#[derive(Debug, Clone, Default)]
pub struct GapHistogram {
/// Buckets cumulativos (Prometheus-style): cada índice cuenta eventos
/// con gap ≤ ese límite. Limites: 1ms, 10ms, 100ms, 1s, 10s, 100s, 1000s.
pub buckets: [u64; 7],
pub count: u64,
pub sum_secs: f64,
pub max_secs: f64,
}
const GAP_BUCKET_LIMITS_SECS: [f64; 7] = [
0.001, 0.01, 0.1, 1.0, 10.0, 100.0, 1000.0,
];
impl GapHistogram {
pub fn observe(&mut self, gap_secs: f64) {
for (i, &limit) in GAP_BUCKET_LIMITS_SECS.iter().enumerate() {
if gap_secs <= limit {
self.buckets[i] += 1;
}
}
self.count += 1;
self.sum_secs += gap_secs;
if gap_secs > self.max_secs { self.max_secs = gap_secs; }
}
pub fn mean_secs(&self) -> f64 {
if self.count == 0 { 0.0 } else { self.sum_secs / self.count as f64 }
}
pub fn bucket_limits() -> &'static [f64; 7] { &GAP_BUCKET_LIMITS_SECS }
}
pub struct Observer {
window: VecDeque<TimedEvent>,
window_size: usize,
@@ -33,6 +68,8 @@ pub struct Observer {
/// Half-life del decay exponencial en segundos. None = sin decay
/// (las consultas devuelven los counts crudos).
half_life_secs: Option<f64>,
/// Histograma de gaps temporales por par (a, b). Capturado al `record()`.
gap_histograms: HashMap<(EventKind, EventKind), GapHistogram>,
}
impl Observer {
@@ -46,6 +83,7 @@ impl Observer {
last_seen_marginal: HashMap::new(),
last_seen_cooccur: HashMap::new(),
half_life_secs: None,
gap_histograms: HashMap::new(),
}
}
@@ -67,10 +105,13 @@ impl Observer {
let timed = TimedEvent { kind: kind.clone(), at: now };
// Co-ocurrencias: este evento con cada uno previo en ventana.
// Capturamos también el gap temporal (now - w.at) para histograma.
for w in &self.window {
let key = (w.kind.clone(), kind.clone());
*self.cooccur.entry(key.clone()).or_insert(0) += 1;
self.last_seen_cooccur.insert(key, now);
self.last_seen_cooccur.insert(key.clone(), now);
let gap_secs = now.duration_since(w.at).as_secs_f64();
self.gap_histograms.entry(key).or_default().observe(gap_secs);
}
self.window.push_back(timed);
@@ -176,6 +217,19 @@ impl Observer {
let start = self.window.len().saturating_sub(n);
self.window.range(start..)
}
pub fn gap_histograms(&self) -> &HashMap<(EventKind, EventKind), GapHistogram> {
&self.gap_histograms
}
/// Top-K pares por count del histograma (más frecuentes primero).
/// Útil para limitar cardinalidad de métricas exportadas.
pub fn top_gap_pairs(&self, k: usize) -> Vec<(&(EventKind, EventKind), &GapHistogram)> {
let mut pairs: Vec<_> = self.gap_histograms.iter().collect();
pairs.sort_by(|a, b| b.1.count.cmp(&a.1.count));
pairs.truncate(k);
pairs
}
}
#[cfg(test)]