gioser

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 · by Sergio · (5 months ago) · parent a4fa42c78 · browse the tree at this commit

Semantic delta · 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

12 files · +406 −8

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, &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"
+    );
+}
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.