gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 604Rust 56Shell 36Markdown 281227 Definiciones

arranque-grafo: paso 3 del ADR 0010 — grafo de estados + `hammer boot`

El menú de arranque como navegación del grafo content-addressed de estados
(no una lista de kernels). Sube el modelo de generaciones in-place a un grafo
navegable y define el contrato de datos con mirada.

- hammer_upgrade::boot_graph: BootGraph/BootNode (formato exacto del contrato),
  build() arma el DAG desde las generaciones (id = of_tree sin b3:, parents =
  of_tree del padre, Base para la raíz del linaje), nodo Recovery sintético
  colgando de la viva, emit() atómico a /run/hammer/boot-graph.json.
- activate(): resuelve el id content-addressed a una generación y deja el
  sistema en ese nodo — no-op si ya viva, rollback paso a paso a un ancestro
  (reusa el rollback E4), recovery = un rollback, error honesto ante un "redo"
  hacia una generación huérfana (pide re-aplicar el árbol).
- CLI `hammer boot graph [--out|--stdout]` y `hammer boot activate <id>
  [--from-select]` (lee el id que mirada deja en /run/hammer/boot-select).
- Tests: DAG + activate a ancestro + recovery + redo-falla; rfc3339 sin deps.
  Validado e2e por el binario (3 generaciones → grafo → activar → recovery).

Cierra el lado `proceso` de SDD 15 §H4 (arrancar = activar un nodo del DAG).
mirada ya puede maquetar contra el grafo real (HANDOFF-arranque-grafo.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

3c343bee9a3aef1c5c4b54ed1f6c65d7839ed78f · por sergio · (hace 3 meses) · padre 7e21b0184 · ver el árbol en este commit

Delta semántico · 23

crates/hammer-cli/src/main.rs

  • nueva enum BootCmd
  • cambió de firma enum Cmd
  • cambió por dentro fn main

crates/hammer-upgrade/src/boot_graph.rs

crates/hammer-upgrade/src/lib.rs

4 archivos · +464 −2

diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index ba79e4cd..4a8eaef0 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -404,6 +404,45 @@ enum Cmd {
         #[command(subcommand)]
         sub: UpgradeCmd,
     },
+
+    /// [ADR 0010] Arranque por grafo: el menú de boot es la navegación del grafo de estados del
+    /// sistema. `graph` publica el grafo para mirada; `activate` deja el sistema en un nodo.
+    Boot {
+        /// Root del FHS vivo sobre el que se activa un nodo (default `/`).
+        #[arg(long, default_value = "/", global = true)]
+        root: String,
+        /// Directorio de estado de generaciones (default `/var/lib/hammer/upgrades`).
+        #[arg(long, default_value = hammer_upgrade::DEFAULT_STATE_ROOT, global = true)]
+        state_root: String,
+        /// Diario donde registrar cada cambio al activar (default `/var/lib/hammer/journal`).
+        #[arg(long, default_value = "/var/lib/hammer/journal", global = true)]
+        journal: String,
+        #[command(subcommand)]
+        sub: BootCmd,
+    },
+}
+
+#[derive(Subcommand)]
+enum BootCmd {
+    /// Emite el grafo de estados a `--out` (default `/run/hammer/boot-graph.json`) para que mirada
+    /// dibuje el menú. Regenerable, world-readable.
+    Graph {
+        /// Path de salida del grafo.
+        #[arg(long, default_value = hammer_upgrade::boot_graph::BOOT_GRAPH_PATH)]
+        out: PathBuf,
+        /// En vez de escribir el fichero, imprime el grafo por stdout (para inspección/tests).
+        #[arg(long)]
+        stdout: bool,
+    },
+    /// Activa el nodo `<id>` del grafo (deja el sistema en ese estado). Con `--from-select` lee el id
+    /// que mirada dejó en `/run/hammer/boot-select` en vez de pasarlo por argumento.
+    Activate {
+        /// Id (BLAKE3, con o sin `b3:`) del nodo a activar. Omitir con `--from-select`.
+        id: Option<String>,
+        /// Lee el id desde `/run/hammer/boot-select` (canal de vuelta de mirada).
+        #[arg(long)]
+        from_select: bool,
+    },
 }
 
 #[derive(Subcommand)]
@@ -1170,6 +1209,50 @@ fn main() -> anyhow::Result<()> {
                 }
             }
         }
+        Cmd::Boot { root, state_root, journal, sub } => {
+            use hammer_upgrade::boot_graph;
+            let root = std::path::PathBuf::from(&root);
+            let state_root = std::path::PathBuf::from(&state_root);
+            match sub {
+                BootCmd::Graph { out, stdout } => {
+                    if stdout {
+                        let g = boot_graph::build(&state_root)?;
+                        println!("{}", serde_json::to_string_pretty(&g)?);
+                    } else {
+                        boot_graph::emit(&state_root, &out)?;
+                        let g = boot_graph::build(&state_root)?;
+                        println!("✓ grafo de arranque escrito en {}", out.display());
+                        println!("  {} nodo(s); vivo: {}", g.nodes.len(),
+                            g.current.as_deref().unwrap_or("(ninguno)"));
+                    }
+                }
+                BootCmd::Activate { id, from_select } => {
+                    let id = match (id, from_select) {
+                        (Some(id), _) => id,
+                        (None, true) => std::fs::read_to_string(boot_graph::BOOT_SELECT_PATH)
+                            .map_err(|e| anyhow::anyhow!(
+                                "no pude leer {}: {e}", boot_graph::BOOT_SELECT_PATH))?
+                            .trim().to_string(),
+                        (None, false) => anyhow::bail!(
+                            "pasá un <id> o usá --from-select para leerlo de {}",
+                            boot_graph::BOOT_SELECT_PATH),
+                    };
+                    let j = hammer_journal::Journal::open(&journal)?;
+                    let r = boot_graph::activate(&root, &state_root, Some(&j), &id)?;
+                    if r.already_current {
+                        println!("✓ nodo {} ya era el vivo — no-op", r.target);
+                    } else if r.kind == boot_graph::NodeKind::Recovery {
+                        println!("✓ recuperación: generación {:?} revertida", r.rolled_back);
+                    } else {
+                        println!("✓ nodo {} activado", r.target);
+                        println!("  {} generación(es) revertida(s): {:?}",
+                            r.rolled_back.len(), r.rolled_back);
+                    }
+                    // Republica el grafo: la vista de mirada debe reflejar el nuevo nodo vivo.
+                    boot_graph::emit(&state_root, std::path::Path::new(boot_graph::BOOT_GRAPH_PATH)).ok();
+                }
+            }
+        }
     }
     Ok(())
 }
diff --git a/crates/hammer-upgrade/src/boot_graph.rs b/crates/hammer-upgrade/src/boot_graph.rs
new file mode 100644
index 00000000..01b47e5f
--- /dev/null
+++ b/crates/hammer-upgrade/src/boot_graph.rs
@@ -0,0 +1,313 @@
+//! **Arranque por grafo** ([ADR 0010](../../docs/adr/0010-arranque-grafo-mirada.md)).
+//!
+//! El menú de arranque de hammer no es una *lista* de kernels (GRUB) sino la **navegación del grafo
+//! content-addressed de estados** del sistema. Este módulo sube el modelo de generaciones in-place
+//! ([`crate`]) a ese grafo navegable y define el **contrato de datos** con `mirada` (el compositor de
+//! tawasuyu que dibuja el menú sobre KMS):
+//!
+//! - **hammer → mirada**: [`build`] arma el grafo y [`emit`] lo escribe en
+//!   [`BOOT_GRAPH_PATH`] (`/run/hammer/boot-graph.json`), world-readable y regenerable.
+//! - **mirada → hammer**: mirada escribe el `id` elegido en [`BOOT_SELECT_PATH`] **o** invoca
+//!   `hammer boot activate <id>`; [`activate`] valida el id contra el grafo y **activa** el nodo
+//!   (pivota la generación vía el rollback existente).
+//!
+//! La frontera es *un fichero + un comando*, no una API viva: mirada puede maquetar el menú contra un
+//! `boot-graph.json` de ejemplo sin esperar a hammer, y hammer emite el grafo sin esperar a mirada.
+//!
+//! **Content-addressing.** El `id` de cada nodo es el `of_tree` (BLAKE3) del árbol que la generación
+//! proyectó — sin el prefijo `b3:`, para casar con el `<blake3>` del contrato. `parents` forma un DAG
+//! (no una lista lineal): el padre de una generación es el `of_tree` de su generación anterior. Activar
+//! un nodo es *reproducir/hidratar* un árbol sellado del store, no ejecutar algo nuevo (ADR 0007/0009).
+
+use std::collections::HashMap;
+use std::path::Path;
+
+use hammer_core::ArtifactHash;
+use hammer_journal::Journal;
+use serde::{Deserialize, Serialize};
+
+use crate::{
+    current, list, live_chain, rollback, Error, GenerationManifest, Result, RollbackReport,
+};
+
+/// Path bien conocido donde hammer publica el grafo para mirada. Regenerable, world-readable.
+pub const BOOT_GRAPH_PATH: &str = "/run/hammer/boot-graph.json";
+
+/// Path bien conocido donde mirada deja el `id` del nodo a activar (canal de vuelta alternativo a
+/// `hammer boot activate <id>`).
+pub const BOOT_SELECT_PATH: &str = "/run/hammer/boot-select";
+
+/// Qué representa un nodo del grafo de arranque. Guía cómo lo dibuja mirada y cómo lo activa hammer.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum NodeKind {
+    /// Una generación de upgrade normal (tiene padre).
+    Generation,
+    /// Un punto de captura explícito (reservado; hoy no se emite).
+    Snapshot,
+    /// La primera generación / raíz del linaje (sin padre).
+    Base,
+    /// Nodo sintético "deshacer la generación viva" — mapea al rollback.
+    Recovery,
+}
+
+/// Un nodo del grafo de arranque: un estado del sistema al que se puede arrancar.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct BootNode {
+    /// `of_tree` (BLAKE3 hex, sin `b3:`) del árbol de la generación. Content-addressed y estable.
+    pub id: String,
+    /// Texto corto para el usuario.
+    pub label: String,
+    pub kind: NodeKind,
+    /// Instante del apply en RFC 3339 (UTC).
+    pub created: String,
+    /// Ids de los padres (DAG). Hoy 0 o 1 por nodo; el formato admite varios.
+    pub parents: Vec<String>,
+    /// `true` si el nodo se puede arrancar (siempre hoy; el flag existe para estados futuros parciales).
+    pub bootable: bool,
+    /// Qué cambió respecto del padre (opcional).
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub summary: Option<String>,
+}
+
+/// El grafo entero — exactamente el JSON del contrato del ADR 0010.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct BootGraph {
+    pub version: u32,
+    /// `id` del nodo vivo (la generación actual), o `None` si el sistema nunca aplicó un upgrade.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub current: Option<String>,
+    /// `id` a arrancar si el usuario no elige.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub default: Option<String>,
+    pub nodes: Vec<BootNode>,
+}
+
+/// El `id` content-addressed de un nodo: el `of_tree` sin el prefijo `b3:`.
+fn node_id(tree_content: &str) -> String {
+    tree_content.trim_start_matches("b3:").to_string()
+}
+
+/// Id estable del nodo sintético de recuperación. Content-addressed (BLAKE3 de una etiqueta fija) para
+/// respetar el contrato "cada id es un hash", pero reservado: [`activate`] lo reconoce y hace rollback.
+pub fn recovery_id() -> String {
+    node_id(ArtifactHash::of_bytes(b"hammer:boot:recovery").as_str())
+}
+
+/// Resume los cambios de una generación para el `summary` (+añadidos ~reemplazados -retirados).
+fn summarize(m: &GenerationManifest) -> Option<String> {
+    if m.changes.is_empty() {
+        return None;
+    }
+    let (mut a, mut r, mut d) = (0u32, 0u32, 0u32);
+    for c in &m.changes {
+        match c.op {
+            crate::ChangeOp::Added => a += 1,
+            crate::ChangeOp::Replaced => r += 1,
+            crate::ChangeOp::Removed => d += 1,
+        }
+    }
+    Some(format!("+{a} ~{r} -{d}"))
+}
+
+/// El nombre legible del árbol: la cola de `tree_dir` (`<hash>-<name>` ⇒ `<name>`).
+fn tree_name(tree_dir: &str) -> &str {
+    tree_dir.split_once('-').map_or(tree_dir, |(_, name)| name)
+}
+
+/// Convierte segundos-desde-epoch a RFC 3339 (UTC) sin dependencias externas
+/// (algoritmo `civil_from_days` de Howard Hinnant). Determinista y testeable.
+fn rfc3339(secs: u64) -> String {
+    let days = (secs / 86_400) as i64;
+    let rem = secs % 86_400;
+    let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
+
+    let z = days + 719_468;
+    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
+    let doe = z - era * 146_097; // [0, 146096]
+    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
+    let y = yoe + era * 400;
+    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
+    let mp = (5 * doy + 2) / 153; // [0, 11]
+    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
+    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
+    let year = if m <= 2 { y + 1 } else { y };
+    format!("{year:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
+}
+
+/// Arma el [`BootGraph`] a partir del registro de generaciones en `state_root`.
+///
+/// Cada generación es un nodo (id = `of_tree`); su padre es el `of_tree` de la generación anterior.
+/// La primera generación del linaje (sin padre) es [`NodeKind::Base`]. Si hay una generación viva, se
+/// añade un nodo sintético [`NodeKind::Recovery`] colgando de ella ("deshacer la generación viva").
+pub fn build(state_root: &Path) -> Result<BootGraph> {
+    let gens = list(state_root)?;
+    let cur = current(state_root)?;
+
+    // Índice u64 → of_tree, para traducir `parent` (u64) al id del padre (hash).
+    let by_gen: HashMap<u64, &GenerationManifest> = gens.iter().map(|m| (m.id, m)).collect();
+    let hash_of = |gen: u64| -> Option<String> { by_gen.get(&gen).map(|m| node_id(&m.tree_content)) };
+
+    let mut nodes: Vec<BootNode> = Vec::with_capacity(gens.len() + 1);
+    for m in &gens {
+        let parents = m.parent.and_then(hash_of).into_iter().collect::<Vec<_>>();
+        let kind = if m.parent.is_none() { NodeKind::Base } else { NodeKind::Generation };
+        nodes.push(BootNode {
+            id: node_id(&m.tree_content),
+            label: format!("gen {} · {}", m.id, tree_name(&m.tree_dir)),
+            kind,
+            created: rfc3339(m.created_at),
+            parents,
+            bootable: true,
+            summary: summarize(m),
+        });
+    }
+
+    let current_id = cur.and_then(hash_of);
+
+    // Nodo de recuperación: sólo tiene sentido si hay algo vivo que deshacer.
+    if let Some(cur_hash) = &current_id {
+        let created = cur.and_then(|g| by_gen.get(&g)).map_or(0, |m| m.created_at);
+        nodes.push(BootNode {
+            id: recovery_id(),
+            label: "recuperación (deshacer la generación viva)".to_string(),
+            kind: NodeKind::Recovery,
+            created: rfc3339(created),
+            parents: vec![cur_hash.clone()],
+            bootable: true,
+            summary: Some("rollback a la generación anterior".to_string()),
+        });
+    }
+
+    Ok(BootGraph { version: 1, current: current_id.clone(), default: current_id, nodes })
+}
+
+/// Escribe el grafo en `out` (default [`BOOT_GRAPH_PATH`]) de forma atómica (temporal + rename).
+/// Crea el directorio padre (`/run/hammer`) si falta.
+pub fn emit(state_root: &Path, out: &Path) -> Result<()> {
+    let graph = build(state_root)?;
+    let bytes = serde_json::to_vec_pretty(&graph)?;
+    if let Some(dir) = out.parent() {
+        std::fs::create_dir_all(dir)?;
+    }
+    let tmp = out.with_extension("json.tmp");
+    std::fs::write(&tmp, &bytes)?;
+    std::fs::rename(&tmp, out)?;
+    Ok(())
+}
+
+/// Reporte de una [`activate`].
+#[derive(Debug, Clone)]
+pub struct ActivateReport {
+    /// Id (hash) que se pidió activar.
+    pub target: String,
+    pub kind: NodeKind,
+    /// `true` si el nodo pedido ya era el vivo (no se hizo nada).
+    pub already_current: bool,
+    /// Generaciones revertidas, de la más nueva a la más vieja (vacío si no hubo rollback).
+    pub rolled_back: Vec<u64>,
+}
+
+/// Activa el nodo `id` del grafo: deja el sistema en ese estado.
+///
+/// - Si `id` es el vivo ⇒ no-op ([`ActivateReport::already_current`]).
+/// - Si `id` es un **ancestro** de la generación viva ⇒ rollback paso a paso hasta él (cada paso es un
+///   [`rollback`] atómico del modelo E4).
+/// - Si `id` es el nodo de **recuperación** ⇒ un rollback de la generación viva.
+/// - Si `id` existe pero **no** está en la cadena viva (un "redo" hacia una generación huérfana) ⇒
+///   error honesto: eso requiere re-aplicar el árbol con `hammer upgrade apply`.
+pub fn activate(
+    target_root: &Path,
+    state_root: &Path,
+    journal: Option<&Journal>,
+    id: &str,
+) -> Result<ActivateReport> {
+    let want = node_id(id); // tolera que llegue con o sin `b3:`
+
+    // Recuperación: un rollback de la generación viva.
+    if want == recovery_id() {
+        let RollbackReport { reverted, .. } = rollback(target_root, state_root, journal)?;
+        return Ok(ActivateReport {
+            target: want,
+            kind: NodeKind::Recovery,
+            already_current: false,
+            rolled_back: vec![reverted],
+        });
+    }
+
+    let cur = current(state_root)?
+        .ok_or_else(|| Error::Other("no hay generación viva a la que anclar la activación".into()))?;
+    let cur_manifest = crate::get(state_root, cur)?
+        .ok_or_else(|| Error::Other(format!("manifiesto de la generación viva {cur} ausente")))?;
+
+    // ¿Ya es la viva?
+    if node_id(&cur_manifest.tree_content) == want {
+        return Ok(ActivateReport {
+            target: want,
+            kind: NodeKind::Generation,
+            already_current: true,
+            rolled_back: Vec::new(),
+        });
+    }
+
+    // Buscar el nodo pedido dentro de la cadena viva (ancestros de la generación actual). El índice en
+    // la cadena (0 = actual) es exactamente cuántos rollbacks hay que hacer para llegar a él.
+    let chain = live_chain(state_root)?;
+    let mut steps: Option<usize> = None;
+    for (i, gen) in chain.iter().enumerate() {
+        let m = crate::get(state_root, *gen)?
+            .ok_or_else(|| Error::Other(format!("manifiesto de la generación {gen} ausente")))?;
+        if node_id(&m.tree_content) == want {
+            steps = Some(i);
+            break;
+        }
+    }
+
+    let steps = steps.ok_or_else(|| {
+        Error::Other(format!(
+            "el nodo {want} no es un ancestro de la generación viva; \
+             activar hacia adelante (redo) aún no se soporta — re-aplicá el árbol con `hammer upgrade apply`"
+        ))
+    })?;
+
+    let mut rolled_back = Vec::with_capacity(steps);
+    for _ in 0..steps {
+        let RollbackReport { reverted, .. } = rollback(target_root, state_root, journal)?;
+        rolled_back.push(reverted);
+    }
+
+    Ok(ActivateReport {
+        target: want,
+        kind: NodeKind::Generation,
+        already_current: false,
+        rolled_back,
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn rfc3339_epoch_and_known_dates() {
+        assert_eq!(rfc3339(0), "1970-01-01T00:00:00Z");
+        // 2026-07-06T12:00:00Z
+        assert_eq!(rfc3339(1_783_339_200), "2026-07-06T12:00:00Z");
+        // 2000-01-01T00:00:00Z (post-leap-year boundary)
+        assert_eq!(rfc3339(946_684_800), "2000-01-01T00:00:00Z");
+    }
+
+    #[test]
+    fn recovery_id_is_stable_and_hex() {
+        let a = recovery_id();
+        assert_eq!(a, recovery_id());
+        assert_eq!(a.len(), 64); // BLAKE3 hex, sin `b3:`
+        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
+    }
+
+    #[test]
+    fn tree_name_strips_hash_prefix() {
+        assert_eq!(tree_name("abc123-product-rootfs"), "product-rootfs");
+        assert_eq!(tree_name("noseparator"), "noseparator");
+    }
+}
diff --git a/crates/hammer-upgrade/src/lib.rs b/crates/hammer-upgrade/src/lib.rs
index 7a7d13cc..e2eee522 100644
--- a/crates/hammer-upgrade/src/lib.rs
+++ b/crates/hammer-upgrade/src/lib.rs
@@ -39,6 +39,8 @@ use hammer_core::{ArtifactHash, Store};
 use hammer_journal::{Actor, Journal, MutationEvent, MutationOp, Source};
 use serde::{Deserialize, Serialize};
 
+pub mod boot_graph;
+
 pub const DEFAULT_STATE_ROOT: &str = "/var/lib/hammer/upgrades";
 
 #[derive(Debug, thiserror::Error)]
@@ -1089,6 +1091,65 @@ mod tests {
         assert!(prune(&state, None).unwrap().removed.is_empty());
     }
 
+    #[test]
+    fn boot_graph_builds_dag_and_activate_rolls_back_to_ancestor() {
+        use crate::boot_graph::{activate, build, recovery_id, NodeKind};
+
+        let tmp = tempfile::tempdir().unwrap();
+        let store = tmp.path().join("store");
+        let root = tmp.path().join("root");
+        let state = tmp.path().join("state");
+        let v1 = seal_tree(&store, &h64("c1"), "v1", |w| write(&w.join("f"), b"1"));
+        let v2 = seal_tree(&store, &h64("c2"), "v2", |w| write(&w.join("f"), b"2"));
+        let v3 = seal_tree(&store, &h64("c3"), "v3", |w| write(&w.join("f"), b"3"));
+        apply(&store, &v1, None, &root, &state, None).unwrap(); // gen 1 (Base)
+        apply(&store, &v2, None, &root, &state, None).unwrap(); // gen 2
+        apply(&store, &v3, None, &root, &state, None).unwrap(); // gen 3 (viva)
+
+        let g = build(&state).unwrap();
+        assert_eq!(g.version, 1);
+        // 3 generaciones + el nodo de recuperación sintético.
+        assert_eq!(g.nodes.len(), 4);
+        let gen1 = &g.nodes[0];
+        assert_eq!(gen1.kind, NodeKind::Base, "la primera generación es la raíz del linaje");
+        assert!(gen1.parents.is_empty());
+        let gen2 = &g.nodes[1];
+        assert_eq!(gen2.kind, NodeKind::Generation);
+        assert_eq!(gen2.parents, vec![gen1.id.clone()], "el DAG cuelga del of_tree del padre");
+        // current/default = la generación viva (gen 3).
+        let gen3 = &g.nodes[2];
+        assert_eq!(g.current.as_deref(), Some(gen3.id.as_str()));
+        assert_eq!(g.default, g.current);
+        // el nodo de recuperación cuelga de la viva.
+        let rec = g.nodes.iter().find(|n| n.kind == NodeKind::Recovery).unwrap();
+        assert_eq!(rec.id, recovery_id());
+        assert_eq!(rec.parents, vec![gen3.id.clone()]);
+
+        // activar la viva ⇒ no-op.
+        let r = activate(&root, &state, None, &gen3.id).unwrap();
+        assert!(r.already_current);
+        assert_eq!(current(&state).unwrap(), Some(3));
+
+        // activar la gen 1 (ancestro) ⇒ rollback paso a paso hasta ella.
+        let r = activate(&root, &state, None, &gen1.id).unwrap();
+        assert!(!r.already_current);
+        assert_eq!(r.rolled_back, vec![3, 2], "revierte de la más nueva a la más vieja");
+        assert_eq!(current(&state).unwrap(), Some(1));
+        assert_eq!(std::fs::read(root.join("f")).unwrap(), b"1", "el FHS vivo es el de la gen 1");
+
+        // ahora las gens 2 y 3 son huérfanas (redo no soportado) ⇒ error honesto.
+        let g2 = build(&state).unwrap();
+        assert_eq!(g2.current.as_deref(), Some(gen1.id.as_str()));
+        let err = activate(&root, &state, None, &gen3.id).unwrap_err();
+        assert!(err.to_string().contains("redo"), "activar hacia adelante debe fallar claro: {err}");
+
+        // recuperación desde la gen 1 ⇒ rollback al estado pre-upgrades (sin current).
+        let r = activate(&root, &state, None, &recovery_id()).unwrap();
+        assert_eq!(r.kind, NodeKind::Recovery);
+        assert_eq!(r.rolled_back, vec![1]);
+        assert_eq!(current(&state).unwrap(), None);
+    }
+
     #[test]
     fn prune_with_keep_trims_chain_depth() {
         let tmp = tempfile::tempdir().unwrap();
diff --git a/docs/adr/0010-arranque-grafo-mirada.md b/docs/adr/0010-arranque-grafo-mirada.md
index 47b3aceb..78c3cc34 100644
--- a/docs/adr/0010-arranque-grafo-mirada.md
+++ b/docs/adr/0010-arranque-grafo-mirada.md
@@ -96,8 +96,13 @@ muerto antes del takeover).
 1. **Este ADR + el handoff a tawasuyu** con el contrato (hecho en este commit).
 2. **squashfs+overlay** en el medio/instalador → initrd chico → **reprobar EFI-stub directo** en
    OVMF, dejarlo listo para el metal. (Refinamiento #2 de [post-etapa-e].)
-3. **Contrato de generaciones-grafo**: subir el modelo in-place a un **grafo de estados** navegable +
-   la operación "activar nodo", y emitir `/run/hammer/boot-graph.json`.
+3. **Contrato de generaciones-grafo** ✅ (módulo `hammer_upgrade::boot_graph` + subcomando `hammer
+   boot`): el modelo in-place de generaciones se lee como un **grafo de estados** navegable (id =
+   `of_tree`, DAG por `parent`), `hammer boot graph` emite `/run/hammer/boot-graph.json` con el
+   formato del contrato, y `hammer boot activate <id>` (o `--from-select`) deja el sistema en un nodo
+   (no-op si ya vivo · rollback paso a paso a un ancestro · nodo `recovery` sintético · error honesto
+   ante un "redo" hacia una generación huérfana). Validado e2e por el CLI. **mirada ya puede maquetar
+   contra el grafo real.**
 4. **Afinar kernel/cmdline** para el handoff sin costura (punto cero-parpadeo).
 5. **install-to-disk EFI** (`hammer-live-install` rama EFI): GPT+ESP con el bzImage como
    `\EFI\BOOT\BOOTX64.EFI` (EFI-stub), root/store/var en particiones; validar el disco instalado en