From 3c343bee9a3aef1c5c4b54ed1f6c65d7839ed78f Mon Sep 17 00:00:00 2001 From: sergio Date: Mon, 6 Jul 2026 20:36:04 -0400 Subject: [PATCH] =?UTF-8?q?arranque-grafo:=20paso=203=20del=20ADR=200010?= =?UTF-8?q?=20=E2=80=94=20grafo=20de=20estados=20+=20`hammer=20boot`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 [--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 --- crates/hammer-cli/src/main.rs | 83 +++++++ crates/hammer-upgrade/src/boot_graph.rs | 313 ++++++++++++++++++++++++ crates/hammer-upgrade/src/lib.rs | 61 +++++ docs/adr/0010-arranque-grafo-mirada.md | 9 +- 4 files changed, 464 insertions(+), 2 deletions(-) create mode 100644 crates/hammer-upgrade/src/boot_graph.rs 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 `` 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, + /// 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 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 `; [`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 `` 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 `). +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, + /// `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, +} + +/// 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, + /// `id` a arrancar si el usuario no elige. + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + pub nodes: Vec, +} + +/// 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 { + 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` (`-` ⇒ ``). +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 { + 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 = gens.iter().map(|m| (m.id, m)).collect(); + let hash_of = |gen: u64| -> Option { by_gen.get(&gen).map(|m| node_id(&m.tree_content)) }; + + let mut nodes: Vec = Vec::with_capacity(gens.len() + 1); + for m in &gens { + let parents = m.parent.and_then(hash_of).into_iter().collect::>(); + 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, +} + +/// 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 { + 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 = 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 ` (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