From badf4257ecd5a4ef06f6f477279834893a24f87b Mon Sep 17 00:00:00 2001 From: Sergio Date: Sun, 3 May 2026 23:32:54 +0000 Subject: [PATCH] audit-verify, autopromote, histogramas y hot-reload caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- crates/ente-brain/examples/brainctl.rs | 11 +++ crates/ente-brain/src/audit.rs | 77 ++++++++++++++++ crates/ente-brain/src/autopromote.rs | 102 +++++++++++++++++++++ crates/ente-brain/src/introspect.rs | 16 ++++ crates/ente-brain/src/lib.rs | 2 + crates/ente-brain/src/metrics.rs | 26 ++++++ crates/ente-brain/src/observer.rs | 56 ++++++++++- crates/ente-bus/src/lib.rs | 9 ++ crates/ente-zero/src/graph/bus_mediator.rs | 69 +++++++++++++- crates/ente-zero/src/graph/lifecycle.rs | 5 +- crates/ente-zero/src/graph/mod.rs | 22 ++++- crates/ente-zero/src/main.rs | 19 +++- 12 files changed, 406 insertions(+), 8 deletions(-) create mode 100644 crates/ente-brain/src/autopromote.rs diff --git a/crates/ente-brain/examples/brainctl.rs b/crates/ente-brain/examples/brainctl.rs index 3e1fb52..6330564 100644 --- a/crates/ente-brain/examples/brainctl.rs +++ b/crates/ente-brain/examples/brainctl.rs @@ -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}"), } } diff --git a/crates/ente-brain/src/audit.rs b/crates/ente-brain/src/audit.rs index 2686fe6..f32d1b2 100644 --- a/crates/ente-brain/src/audit.rs +++ b/crates/ente-brain/src/audit.rs @@ -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, + /// Detalles del error si hubo. + pub error: Option, + /// 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 = 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() } } diff --git a/crates/ente-brain/src/autopromote.rs b/crates/ente-brain/src/autopromote.rs new file mode 100644 index 0000000..bd028fb --- /dev/null +++ b/crates/ente-brain/src/autopromote.rs @@ -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>> = + 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, ¶ms, &promoted_keys).await; + } + }); +} + +async fn run_one_pass( + state: &BrainState, + params: &AutopromoteParams, + promoted_keys: &Arc>>, +) { + let crystals: Vec = { + let obs = state.observer.read().await; + detect_crystals(&obs, ¶ms.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" + ); +} diff --git a/crates/ente-brain/src/introspect.rs b/crates/ente-brain/src/introspect.rs index e1c54f7..bdc821e 100644 --- a/crates/ente-brain/src/introspect.rs +++ b/crates/ente-brain/src/introspect.rs @@ -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 }, + /// 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) diff --git a/crates/ente-brain/src/lib.rs b/crates/ente-brain/src/lib.rs index 4aa661e..3723e1b 100644 --- a/crates/ente-brain/src/lib.rs +++ b/crates/ente-brain/src/lib.rs @@ -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}; diff --git a/crates/ente-brain/src/metrics.rs b/crates/ente-brain/src/metrics.rs index 8f0e0ba..10b9a29 100644 --- a/crates/ente-brain/src/metrics.rs +++ b/crates/ente-brain/src/metrics.rs @@ -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 } diff --git a/crates/ente-brain/src/observer.rs b/crates/ente-brain/src/observer.rs index 21cf7b7..f46527b 100644 --- a/crates/ente-brain/src/observer.rs +++ b/crates/ente-brain/src/observer.rs @@ -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, 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, + /// 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)] diff --git a/crates/ente-bus/src/lib.rs b/crates/ente-bus/src/lib.rs index 8d6a71f..2ce90a4 100644 --- a/crates/ente-bus/src/lib.rs +++ b/crates/ente-bus/src/lib.rs @@ -61,6 +61,15 @@ pub enum BusRequest { /// Invocación genérica de capacidad. `cap` debe estar provista por algún /// Ente del grafo; el blob es el argumento opaco que el proveedor parsea. Invoke { cap: Capability, blob: Vec }, + + /// Actualización dinámica del set de capacidades del Ente que llama. + /// Sólo aplicable al `from_authenticated` — un Ente sólo puede modificar + /// sus propias caps. La Card original (immutable) no se toca; la mutación + /// va al `dynamic_provides` del Incarnated. + UpdateCapabilities { + adds: Vec, + removes: Vec, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/ente-zero/src/graph/bus_mediator.rs b/crates/ente-zero/src/graph/bus_mediator.rs index c5d8a0d..51d3f1a 100644 --- a/crates/ente-zero/src/graph/bus_mediator.rs +++ b/crates/ente-zero/src/graph/bus_mediator.rs @@ -16,11 +16,16 @@ use ulid::Ulid; /// Operaciones que requieren identidad verificada en el bus. /// -/// Sólo `Announce`: establece la entrada en `bus_connections` para forwarding -/// y debe ser no-falsificable. Invoke, ListEntes y power-mgmt se aceptan -/// anonymous — políticas por capacidad se aplican aguas abajo, no aquí. +/// - `Announce`: establece bus_connections para forwarding. +/// - `UpdateCapabilities`: muta dynamic_provides del Ente — sólo el dueño. +/// +/// Invoke, ListEntes y power-mgmt se aceptan anonymous — políticas por +/// capacidad se aplican aguas abajo, no aquí. fn requires_auth(req: &BusRequest) -> bool { - matches!(req, BusRequest::Announce { .. }) + matches!( + req, + BusRequest::Announce { .. } | BusRequest::UpdateCapabilities { .. } + ) } impl EnteGraph { @@ -101,9 +106,65 @@ impl EnteGraph { BusRequest::Invoke { cap, blob } => { self.forward_invoke(from_authenticated, cap, blob, reply).await; } + BusRequest::UpdateCapabilities { adds, removes } => { + let id = from_authenticated.expect("auth-required guarantees Some"); + self.apply_capability_update(id, adds, removes); + let _ = reply.send(BusResponse::Ok); + } } } + /// Muta `dynamic_provides` del Ente y actualiza el índice global de + /// providers. La Card original (immutable) no se toca. + fn apply_capability_update( + &mut self, + ente_id: Ulid, + adds: Vec, + removes: Vec, + ) { + // Adiciones: dedupe contra Card.provides + dynamic_provides existentes. + let mut added = Vec::new(); + let mut removed = Vec::new(); + if let Some(inc) = self.incarnated.get_mut(&ente_id) { + for cap in adds { + if inc.card.provides.contains(&cap) || inc.dynamic_provides.contains(&cap) { + continue; // ya provista, no-op + } + inc.dynamic_provides.insert(cap.clone()); + added.push(cap); + } + for cap in removes { + if inc.dynamic_provides.remove(&cap) { + removed.push(cap); + } + // Caps de la Card original no se pueden quitar — silenciosamente + // ignoradas. Una Card es contrato; sólo el dynamic es mutable. + } + } + // Actualizar índice global. Hacemos esto fuera del scope `inc` para + // evitar el doble-borrow de self. + for cap in &added { + self.register_dynamic_cap(ente_id, cap.clone()); + } + for cap in &removed { + self.unregister_dynamic_cap(ente_id, cap); + // Revocar grants emitidos contra esta cap por este Ente. + let revoked: Vec = self.grants.iter() + .filter(|(_, g)| g.provider == ente_id && &g.cap == cap) + .map(|(t, _)| *t) + .collect(); + for t in revoked { + self.grants.remove(&t); + } + } + info!( + %ente_id, + added_count = added.len(), + removed_count = removed.len(), + "capabilities actualizadas en runtime" + ); + } + /// Enruta un Invoke al proveedor real de la capacidad. Aloca un seq /// server-side, registra el reply oneshot en `pending_invokes`, y empuja /// el request por la conexión del proveedor. diff --git a/crates/ente-zero/src/graph/lifecycle.rs b/crates/ente-zero/src/graph/lifecycle.rs index 867190e..2613878 100644 --- a/crates/ente-zero/src/graph/lifecycle.rs +++ b/crates/ente-zero/src/graph/lifecycle.rs @@ -84,7 +84,10 @@ impl EnteGraph { self.children.entry(parent).or_default().push(card.id); } info!(label = %card.label, ?pid, lineage = ?card.lineage, "Ente encarnado"); - self.incarnated.insert(card.id, Incarnated { card, pid }); + self.incarnated.insert(card.id, Incarnated { + card, pid, + dynamic_provides: std::collections::BTreeSet::new(), + }); Ok(()) } diff --git a/crates/ente-zero/src/graph/mod.rs b/crates/ente-zero/src/graph/mod.rs index 016a640..2b99a75 100644 --- a/crates/ente-zero/src/graph/mod.rs +++ b/crates/ente-zero/src/graph/mod.rs @@ -56,6 +56,10 @@ pub struct EnteGraph { pub(in crate::graph) struct Incarnated { pub card: EntityCard, pub pid: Option, + /// Capacidades añadidas en runtime vía BusRequest::UpdateCapabilities. + /// La Card original es immutable; la "vista efectiva" del Ente es + /// `card.provides ∪ dynamic_provides`. + pub dynamic_provides: BTreeSet, } pub(in crate::graph) struct GrantedCapability { @@ -86,7 +90,10 @@ impl EnteGraph { // El Ente #0 se inscribe a sí mismo como proveedor de las capacidades // que su Card declara — sólo así los hijos pueden requerirlas. g.register_provider(&seed); - g.incarnated.insert(seed.id, Incarnated { card: seed, pid: None }); + g.incarnated.insert(seed.id, Incarnated { + card: seed, pid: None, + dynamic_provides: BTreeSet::new(), + }); g } @@ -144,6 +151,19 @@ impl EnteGraph { } } + /// Quita una capacidad dinámica del índice de providers para un Ente + /// específico. Usado al recibir UpdateCapabilities con `removes`. + pub(in crate::graph) fn unregister_dynamic_cap(&mut self, ente_id: Ulid, cap: &Capability) { + if let Some(set) = self.providers.get_mut(cap) { + set.remove(&ente_id); + } + } + + /// Inserta una capacidad dinámica al índice de providers para un Ente. + pub(in crate::graph) fn register_dynamic_cap(&mut self, ente_id: Ulid, cap: Capability) { + self.providers.entry(cap).or_default().insert(ente_id); + } + /// El Ente #0 (semilla) tiene todas sus capacidades declaradas. Otros /// las tienen si su Card las declara o si poseen un grant vivo. pub(in crate::graph) fn holder_has(&self, holder: Ulid, cap: &Capability) -> bool { diff --git a/crates/ente-zero/src/main.rs b/crates/ente-zero/src/main.rs index eb8f677..17dbf2e 100644 --- a/crates/ente-zero/src/main.rs +++ b/crates/ente-zero/src/main.rs @@ -42,6 +42,7 @@ struct CliArgs { audit_head: Option, metrics_addr: Option, brain_half_life: Option, + autopromote_secs: Option, } fn parse_args() -> CliArgs { @@ -53,6 +54,7 @@ fn parse_args() -> CliArgs { let mut audit_head = None; let mut metrics_addr = None; let mut brain_half_life = None; + let mut autopromote_secs = None; while let Some(a) = args.next() { match a.as_str() { "--checkpoint" => checkpoint = args.next().map(PathBuf::from), @@ -62,10 +64,14 @@ fn parse_args() -> CliArgs { "--audit-head" => audit_head = args.next().map(PathBuf::from), "--metrics-addr" => metrics_addr = args.next(), "--brain-half-life" => brain_half_life = args.next().and_then(|s| s.parse().ok()), + "--autopromote-secs" => autopromote_secs = args.next().and_then(|s| s.parse().ok()), other => warn!(arg = %other, "argumento desconocido, ignorado"), } } - CliArgs { checkpoint, restore, rules, rules_out, audit_head, metrics_addr, brain_half_life } + CliArgs { + checkpoint, restore, rules, rules_out, audit_head, + metrics_addr, brain_half_life, autopromote_secs, + } } fn main() -> anyhow::Result<()> { @@ -94,6 +100,7 @@ fn main() -> anyhow::Result<()> { card, dev_mode, cli.checkpoint, cli.rules, cli.rules_out, cli.audit_head, cli.metrics_addr, cli.brain_half_life, + cli.autopromote_secs, )) } @@ -106,6 +113,7 @@ async fn primordial_loop( audit_head: Option, metrics_addr: Option, brain_half_life: Option, + autopromote_secs: Option, ) -> anyhow::Result<()> { info!(seed_id = %seed_card.id, label = %seed_card.label, "Ente #0 entra al bucle primordial"); @@ -158,6 +166,15 @@ async fn primordial_loop( *obs = ente_brain::Observer::new(1024).with_half_life(hl); info!(hl_secs = hl, "observer con time-decay activo"); } + if let Some(secs) = autopromote_secs { + ente_brain::spawn_autopromote_loop( + brain.clone(), + ente_brain::AutopromoteParams { + interval_secs: secs, + threshold: brain.params, // mismo threshold que crystals manuales + }, + ); + } // Si --audit-head, configuramos el head pointer y arrancamos auto-flush. if let Some(head_path) = audit_head { // Re-creamos el AuditLog con head pointer.