> 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…
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>
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) = ¤t_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