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>
badf4257ecd5a4ef06f6f477279834893a24f87b · por Sergio · 2026-05-03T23:32:54Z (hace 5 meses) · padre a4fa42c78 · ver el árbol en este commit
Delta semántico · 37
crates/ente-brain/examples/brainctl.rs
crates/ente-brain/src/audit.rs
crates/ente-brain/src/autopromote.rs
crates/ente-brain/src/introspect.rs
crates/ente-brain/src/lib.rs
crates/ente-brain/src/metrics.rs
crates/ente-brain/src/observer.rs
crates/ente-bus/src/lib.rs
crates/ente-zero/src/graph/bus_mediator.rs
crates/ente-zero/src/graph/lifecycle.rs
crates/ente-zero/src/graph/mod.rs
crates/ente-zero/src/main.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<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() }
}
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<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, ¶ms, &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, ¶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<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)
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<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)]
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<u8> },
+
+ /// 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<Capability>,
+ removes: Vec<Capability>,
+ },
}
#[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,7 +106,63 @@ 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<Capability>,
+ removes: Vec<Capability>,
+ ) {
+ // 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<u64> = 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
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<Pid>,
+ /// 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<Capability>,
}
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<PathBuf>,
metrics_addr: Option<String>,
brain_half_life: Option<f64>,
+ autopromote_secs: Option<u64>,
}
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<PathBuf>,
metrics_addr: Option<String>,
brain_half_life: Option<f64>,
+ autopromote_secs: Option<u64>,
) -> 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.