tawasuyu

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 1572Shell 203Rust 109Markdown 993728 Definiciones

hammer-upgrade: upgrades atómicos con generaciones y rollback (Etapa E4, primer corte)

Crate hammer-upgrade + CLI 'hammer upgrade apply|rollback|status'. Aplica un
árbol Stage1/producto del store sobre el FHS vivo dejando una generación
(manifest + backup de bytes previos), atómico fichero-a-fichero con 'current'
como commit-point. Rollback restaura EXACTO al árbol anterior (o pre-upgrades).
Cada cambio al journal (HammerHydrate, replay-able). Verificación opcional del
of_tree esperado (índice de mirror E3 / release firmada). Maneja ficheros +
symlinks. 7 tests unitarios + scripts/upgrade-e2e-test.sh (apply v1->v2->
rollback->rollback contra el binario real). SDD 13 actualizado.

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

d23946fa3d45e29f42e0ea707d612d5477ab04f3 · por sergio · (hace 4 meses) · padre 30daab35e · ver el árbol en este commit

Delta semántico · 44

crates/hammer-cli/src/main.rs

crates/hammer-upgrade/src/lib.rs

8 archivos · +953 −2

diff --git a/Cargo.lock b/Cargo.lock
index 754eee14..b2110b4b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -545,6 +545,7 @@ dependencies = [
  "hammer-journal",
  "hammer-mirror",
  "hammer-overlay",
+ "hammer-upgrade",
  "hex",
  "serde_json",
  "sha2",
@@ -613,6 +614,20 @@ dependencies = [
  "tracing-subscriber",
 ]
 
+[[package]]
+name = "hammer-upgrade"
+version = "0.0.1"
+dependencies = [
+ "anyhow",
+ "hammer-core",
+ "hammer-journal",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "thiserror",
+ "tracing",
+]
+
 [[package]]
 name = "hammerd"
 version = "0.0.1"
diff --git a/Cargo.toml b/Cargo.toml
index 5f6f8ccb..4b995faf 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,6 +7,7 @@ members = [
     "crates/hammer-overlay",
     "crates/hammer-journal",
     "crates/hammer-mirror",
+    "crates/hammer-upgrade",
     "crates/hammer-agent",
     "crates/hammer-cli",
     "crates/hammerd",
@@ -27,6 +28,7 @@ hammer-bootstrap = { path = "crates/hammer-bootstrap" }
 hammer-overlay = { path = "crates/hammer-overlay" }
 hammer-journal = { path = "crates/hammer-journal" }
 hammer-mirror = { path = "crates/hammer-mirror" }
+hammer-upgrade = { path = "crates/hammer-upgrade" }
 hammer-agent = { path = "crates/hammer-agent" }
 
 # shared third-party deps, pinned at the workspace level
diff --git a/crates/hammer-cli/Cargo.toml b/crates/hammer-cli/Cargo.toml
index ca789a34..4fd8b428 100644
--- a/crates/hammer-cli/Cargo.toml
+++ b/crates/hammer-cli/Cargo.toml
@@ -22,6 +22,7 @@ hammer-core.workspace = true
 hammer-build.workspace = true
 hammer-bootstrap.workspace = true
 hammer-mirror.workspace = true
+hammer-upgrade.workspace = true
 hammer-overlay.workspace = true
 hammer-journal.workspace = true
 hammer-agent.workspace = true
diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index ebf14d3a..03f93ddd 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -245,6 +245,38 @@ enum Cmd {
         #[command(subcommand)]
         sub: MirrorCmd,
     },
+
+    /// [Etapa E4] Upgrades atómicos con rollback: aplica un árbol Stage1/producto del store al root
+    /// vivo dejando una generación nueva; `rollback` revierte exactamente al árbol anterior.
+    Upgrade {
+        /// Root del FHS vivo sobre el que se proyecta el árbol (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 (default `/var/lib/hammer/journal`).
+        #[arg(long, default_value = "/var/lib/hammer/journal", global = true)]
+        journal: String,
+        #[command(subcommand)]
+        sub: UpgradeCmd,
+    },
+}
+
+#[derive(Subcommand)]
+enum UpgradeCmd {
+    /// Aplica el árbol `<tree-dir>` (`<hash>-<name>` del store) al root vivo, dejando una generación.
+    Apply {
+        /// Directorio del artefacto en el store (p.ej. `b3hex…-product-rootfs`).
+        tree_dir: String,
+        /// `of_tree` esperado (`b3:…`) — de un índice de mirror o release firmada; si se da, se exige.
+        #[arg(long)]
+        expected: Option<String>,
+    },
+    /// Revierte la generación viva al árbol anterior (o al estado pre-upgrades).
+    Rollback,
+    /// Muestra la generación viva y el historial.
+    Status,
 }
 
 #[derive(Subcommand)]
@@ -798,6 +830,49 @@ fn main() -> anyhow::Result<()> {
                 }
             }
         }
+        Cmd::Upgrade { root, state_root, journal, sub } => {
+            let store = std::path::PathBuf::from(&cli.store);
+            let root = std::path::PathBuf::from(&root);
+            let state_root = std::path::PathBuf::from(&state_root);
+            match sub {
+                UpgradeCmd::Apply { tree_dir, expected } => {
+                    let j = hammer_journal::Journal::open(&journal)?;
+                    let r = hammer_upgrade::apply(
+                        &store, &tree_dir, expected.as_deref(), &root, &state_root, Some(&j),
+                    )?;
+                    if r.already_current {
+                        println!("generación {} ya viva — árbol idéntico, no-op", r.generation);
+                    } else {
+                        println!("✓ generación {} aplicada", r.generation);
+                        println!("  + {} añadidos, ~ {} pisados, - {} retirados",
+                            r.added.len(), r.replaced.len(), r.removed.len());
+                    }
+                }
+                UpgradeCmd::Rollback => {
+                    let j = hammer_journal::Journal::open(&journal)?;
+                    let r = hammer_upgrade::rollback(&root, &state_root, Some(&j))?;
+                    println!("✓ generación {} revertida", r.reverted);
+                    match r.now_current {
+                        Some(id) => println!("  ahora viva: generación {id}"),
+                        None => println!("  ahora viva: estado pre-upgrades"),
+                    }
+                    println!("  {} restaurados, {} borrados", r.restored.len(), r.deleted.len());
+                }
+                UpgradeCmd::Status => {
+                    let cur = hammer_upgrade::current(&state_root)?;
+                    match cur {
+                        Some(id) => println!("generación viva: {id}"),
+                        None => println!("generación viva: (ninguna — sistema sin upgrades aplicados)"),
+                    }
+                    let gens = hammer_upgrade::list(&state_root)?;
+                    println!("historial: {} generación(es)", gens.len());
+                    for g in &gens {
+                        let mark = if Some(g.id) == cur { "*" } else { " " };
+                        println!("  {mark} {} → {} ({} ficheros)", g.id, g.tree_dir, g.files.len());
+                    }
+                }
+            }
+        }
     }
     Ok(())
 }
diff --git a/crates/hammer-upgrade/Cargo.toml b/crates/hammer-upgrade/Cargo.toml
new file mode 100644
index 00000000..4b75227a
--- /dev/null
+++ b/crates/hammer-upgrade/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "hammer-upgrade"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+repository.workspace = true
+description = "Upgrades atómicos de hammer (E4): aplica un árbol Stage1/producto del store al root vivo con generaciones y rollback."
+
+[dependencies]
+hammer-core.workspace = true
+hammer-journal.workspace = true
+anyhow.workspace = true
+thiserror.workspace = true
+tracing.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+
+[dev-dependencies]
+tempfile.workspace = true
diff --git a/crates/hammer-upgrade/src/lib.rs b/crates/hammer-upgrade/src/lib.rs
new file mode 100644
index 00000000..030096a3
--- /dev/null
+++ b/crates/hammer-upgrade/src/lib.rs
@@ -0,0 +1,718 @@
+//! **E4 — upgrades atómicos con rollback** ([SDD 13 §E4](../../docs/13-release-engineering.md)).
+//!
+//! Aplicar un árbol Stage1/producto **nuevo** al root vivo sin reinstalar, de forma atómica y con
+//! **rollback** al árbol anterior. El árbol nuevo es un artefacto sellado del store (CAS BLAKE3); el
+//! "apply" lo proyecta sobre el FHS vivo (`target_root`, normalmente `/`), registra cada cambio en el
+//! [diario](hammer_journal) y deja una **generación** nueva. Si algo sale mal, `rollback` revierte
+//! exactamente al árbol anterior.
+//!
+//! ## Modelo de generaciones (inspirado en NixOS, aplicado in-place al FHS)
+//!
+//! El sistema booteado lee el userland directamente del root (vda2), no de un symlink indirecto. Así
+//! que el "switch" no es un único symlink: es la proyección de los ficheros del árbol nuevo sobre el
+//! root, fichero a fichero (escritura a temporal + `rename` ⇒ cada fichero conmuta atómicamente), más
+//! un **registro de generación** que es la fuente de verdad de "qué árbol está vivo" y guarda lo
+//! necesario para deshacer.
+//!
+//! ```text
+//! <state_root>/                       (default /var/lib/hammer/upgrades)
+//!   generations/
+//!     <N>/manifest.json               id, tree_dir (store), tree_content (of_tree), parent, cambios
+//!     <N>/backup/<rel...>             bytes PREVIOS de cada fichero que el apply pisó o borró
+//!   current                           fichero con el id de la generación viva (escritura atómica)
+//! ```
+//!
+//! **Atomicidad y rollback.** El apply respalda el estado previo de todo path que toca *antes* de
+//! tocarlo; el commit-point es el avance de `current`. Un corte a mitad deja `current` en la
+//! generación vieja y los backups intactos ⇒ `rollback` (o un re-apply) restaura. El rollback deshace
+//! en orden inverso: borra lo que el apply creó, restaura desde `backup/` lo que pisó o borró, y
+//! retrocede `current` al padre.
+//!
+//! **Alcance del primer corte.** Maneja ficheros regulares y symlinks (los dos tipos de contenido que
+//! `of_tree` hashea); crea los directorios intermedios que haga falta. El cableado del init de boot
+//! para que el rollback sobreviva un kernel-panic a media escritura (journal de intención + replay) es
+//! endurecimiento posterior, ortogonal a esta lógica.
+
+use std::path::{Path, PathBuf};
+
+use hammer_core::{ArtifactHash, Store};
+use hammer_journal::{Actor, Journal, MutationEvent, MutationOp, Source};
+use serde::{Deserialize, Serialize};
+
+pub const DEFAULT_STATE_ROOT: &str = "/var/lib/hammer/upgrades";
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+    #[error("io: {0}")]
+    Io(#[from] std::io::Error),
+    #[error("core: {0}")]
+    Core(#[from] hammer_core::Error),
+    #[error("journal: {0}")]
+    Journal(#[from] hammer_journal::Error),
+    #[error("json: {0}")]
+    Json(#[from] serde_json::Error),
+    #[error(
+        "integridad: el árbol '{dir}' del store hashea a {got} pero se esperaba {want} \
+         — store corrupto o árbol equivocado; NO se aplica"
+    )]
+    Verify { dir: String, want: String, got: String },
+    #[error("upgrade: no hay generación viva que revertir (sistema sin upgrades aplicados)")]
+    NothingToRollback,
+    #[error("upgrade: {0}")]
+    Other(String),
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+/// Qué le pasó a un path concreto al aplicar una generación. Determina cómo se deshace.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum ChangeOp {
+    /// El path no existía antes ⇒ rollback lo **borra**.
+    Added,
+    /// El path existía y fue pisado ⇒ `backup/` guarda los bytes previos ⇒ rollback los **restaura**.
+    Replaced,
+    /// El path existía en el árbol previo y el nuevo no lo tiene ⇒ apply lo borra; `backup/` guarda
+    /// los bytes previos ⇒ rollback los **restaura**.
+    Removed,
+}
+
+/// Un cambio atómico sobre un path del FHS, relativo a `target_root`.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Change {
+    pub path: PathBuf,
+    pub op: ChangeOp,
+}
+
+/// Manifiesto persistente de una generación — la unidad de upgrade/rollback.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct GenerationManifest {
+    pub id: u64,
+    /// Directorio del artefacto en el store (`<hash>-<name>`) cuyo árbol esta generación proyectó.
+    pub tree_dir: String,
+    /// `of_tree` (`b3:…`) del árbol aplicado: ancla de integridad y desempate vs el store.
+    pub tree_content: String,
+    /// Generación anterior (a la que el rollback vuelve). `None` en la primera.
+    pub parent: Option<u64>,
+    /// Segundos desde UNIX epoch al apply.
+    pub created_at: u64,
+    /// Lista relativa (ordenada) de los ficheros que el árbol aporta — sirve para que el SIGUIENTE
+    /// apply sepa qué retirar (paths del árbol previo ausentes en el nuevo).
+    pub files: Vec<PathBuf>,
+    /// Cada path tocado y cómo, para deshacer.
+    pub changes: Vec<Change>,
+}
+
+/// Reporte de un [`apply`].
+#[derive(Debug, Clone, Default)]
+pub struct ApplyReport {
+    pub generation: u64,
+    pub added: Vec<PathBuf>,
+    pub replaced: Vec<PathBuf>,
+    pub removed: Vec<PathBuf>,
+    /// `true` si el árbol pedido ya era el vivo (apply idempotente, no se creó generación nueva).
+    pub already_current: bool,
+}
+
+/// Reporte de un [`rollback`].
+#[derive(Debug, Clone, Default)]
+pub struct RollbackReport {
+    /// Generación que se revirtió (la que estaba viva).
+    pub reverted: u64,
+    /// Generación que quedó viva tras revertir (`None` si se volvió al estado pre-upgrades).
+    pub now_current: Option<u64>,
+    pub restored: Vec<PathBuf>,
+    pub deleted: Vec<PathBuf>,
+}
+
+// ---------------------------------------------------------------------------------------------------
+// Registro de generaciones (lectura del estado).
+// ---------------------------------------------------------------------------------------------------
+
+fn generations_dir(state_root: &Path) -> PathBuf {
+    state_root.join("generations")
+}
+
+fn current_file(state_root: &Path) -> PathBuf {
+    state_root.join("current")
+}
+
+fn manifest_path(state_root: &Path, id: u64) -> PathBuf {
+    generations_dir(state_root).join(id.to_string()).join("manifest.json")
+}
+
+/// Id de la generación viva, o `None` si nunca se aplicó un upgrade.
+pub fn current(state_root: &Path) -> Result<Option<u64>> {
+    let f = current_file(state_root);
+    if !f.is_file() {
+        return Ok(None);
+    }
+    let s = std::fs::read_to_string(&f)?;
+    let s = s.trim();
+    if s.is_empty() {
+        return Ok(None);
+    }
+    s.parse::<u64>()
+        .map(Some)
+        .map_err(|_| Error::Other(format!("`current` ilegible: {s:?}")))
+}
+
+/// Lee el manifiesto de una generación concreta.
+pub fn get(state_root: &Path, id: u64) -> Result<Option<GenerationManifest>> {
+    let p = manifest_path(state_root, id);
+    if !p.is_file() {
+        return Ok(None);
+    }
+    Ok(Some(serde_json::from_slice(&std::fs::read(&p)?)?))
+}
+
+/// Lista todas las generaciones registradas, ordenadas por id ascendente.
+pub fn list(state_root: &Path) -> Result<Vec<GenerationManifest>> {
+    let dir = generations_dir(state_root);
+    if !dir.is_dir() {
+        return Ok(Vec::new());
+    }
+    let mut out: Vec<GenerationManifest> = Vec::new();
+    for entry in std::fs::read_dir(&dir)? {
+        let entry = entry?;
+        let mp = entry.path().join("manifest.json");
+        if mp.is_file() {
+            out.push(serde_json::from_slice(&std::fs::read(&mp)?)?);
+        }
+    }
+    out.sort_by_key(|m| m.id);
+    Ok(out)
+}
+
+fn next_id(state_root: &Path) -> Result<u64> {
+    Ok(list(state_root)?.iter().map(|m| m.id).max().map_or(1, |m| m + 1))
+}
+
+// ---------------------------------------------------------------------------------------------------
+// Enumeración de árboles.
+// ---------------------------------------------------------------------------------------------------
+
+/// Un fichero de un árbol: su path relativo y su tipo de contenido (lo que `of_tree` distingue).
+#[derive(Debug, Clone)]
+struct TreeEntry {
+    rel: PathBuf,
+    kind: EntryKind,
+}
+
+#[derive(Debug, Clone)]
+enum EntryKind {
+    /// Fichero regular. `exec` = bit de ejecución (de_tree lo mira).
+    File { exec: bool },
+    /// Symlink → target literal (sin resolver).
+    Symlink(PathBuf),
+}
+
+/// Camina `root` y devuelve sus ficheros (regulares + symlinks) con path relativo, ordenados. Los
+/// directorios no se listan: se recrean al proyectar según haga falta.
+fn enumerate_tree(root: &Path) -> Result<Vec<TreeEntry>> {
+    let mut out = Vec::new();
+    fn walk(root: &Path, dir: &Path, out: &mut Vec<TreeEntry>) -> Result<()> {
+        use std::os::unix::fs::PermissionsExt;
+        for entry in std::fs::read_dir(dir)? {
+            let entry = entry?;
+            let p = entry.path();
+            let meta = std::fs::symlink_metadata(&p)?;
+            let ft = meta.file_type();
+            let rel = p.strip_prefix(root).expect("p ⊂ root").to_path_buf();
+            if ft.is_symlink() {
+                out.push(TreeEntry { rel, kind: EntryKind::Symlink(std::fs::read_link(&p)?) });
+            } else if ft.is_dir() {
+                walk(root, &p, out)?;
+            } else if ft.is_file() {
+                let exec = meta.permissions().mode() & 0o111 != 0;
+                out.push(TreeEntry { rel, kind: EntryKind::File { exec } });
+            }
+        }
+        Ok(())
+    }
+    walk(root, root, &mut out)?;
+    out.sort_by(|a, b| a.rel.cmp(&b.rel));
+    Ok(out)
+}
+
+// ---------------------------------------------------------------------------------------------------
+// Apply.
+// ---------------------------------------------------------------------------------------------------
+
+/// Aplica el árbol `tree_dir` del store sobre `target_root`, dejando una generación nueva.
+///
+/// Pasos: (1) resuelve y **verifica** el árbol del store (su `of_tree` debe casar el del índice);
+/// (2) diferencia contra el árbol de la generación viva ⇒ qué se añade/pisa/retira; (3) **respalda**
+/// el estado previo de cada path tocado; (4) proyecta el árbol nuevo (escritura a temporal + rename)
+/// y retira los ficheros sobrantes, registrando cada cambio en el diario; (5) escribe el manifiesto y
+/// **avanza `current`** (commit-point).
+pub fn apply(
+    store_root: &Path,
+    tree_dir: &str,
+    expected: Option<&str>,
+    target_root: &Path,
+    state_root: &Path,
+    journal: Option<&Journal>,
+) -> Result<ApplyReport> {
+    let store = Store::open(store_root)?;
+    let tree_path = store.root().join(tree_dir);
+    if !tree_path.is_dir() {
+        return Err(Error::Other(format!(
+            "el árbol '{tree_dir}' no existe en el store {}",
+            store.root().display()
+        )));
+    }
+    // El of_tree real ancla la identidad del árbol. Si el llamador anuncia uno esperado (de un índice
+    // de mirror o una release firmada), lo exigimos: defiende de un store corrupto/manipulado entre el
+    // sellado y el apply. Sin `expected`, el store local se confía (sellado read-only) y el of_tree
+    // sólo se registra en el manifiesto.
+    let got = ArtifactHash::of_tree(&tree_path)?;
+    let tree_content = got.as_str().to_string();
+    if let Some(want) = expected {
+        if tree_content != want {
+            return Err(Error::Verify {
+                dir: tree_dir.to_string(),
+                want: want.to_string(),
+                got: tree_content,
+            });
+        }
+    }
+
+    // Idempotencia: si el árbol vivo ya es éste, no hacemos nada.
+    if let Some(cur) = current(state_root)? {
+        if let Some(m) = get(state_root, cur)? {
+            if m.tree_content == tree_content {
+                return Ok(ApplyReport { generation: cur, already_current: true, ..Default::default() });
+            }
+        }
+    }
+
+    let new_entries = enumerate_tree(&tree_path)?;
+    let new_files: Vec<PathBuf> = new_entries.iter().map(|e| e.rel.clone()).collect();
+    let new_set: std::collections::BTreeSet<&PathBuf> = new_files.iter().collect();
+
+    // Ficheros que la generación viva aportaba y el árbol nuevo NO ⇒ se retiran.
+    let prev_files: Vec<PathBuf> = match current(state_root)? {
+        Some(cur) => get(state_root, cur)?.map(|m| m.files).unwrap_or_default(),
+        None => Vec::new(),
+    };
+    let removed_files: Vec<PathBuf> =
+        prev_files.into_iter().filter(|p| !new_set.contains(p)).collect();
+
+    let id = next_id(state_root)?;
+    let gen_base = generations_dir(state_root).join(id.to_string());
+    let backup_root = gen_base.join("backup");
+    std::fs::create_dir_all(&backup_root)?;
+
+    let mut report = ApplyReport { generation: id, ..Default::default() };
+    let mut changes: Vec<Change> = Vec::new();
+
+    // (3)+(4) proyectar el árbol nuevo.
+    for e in &new_entries {
+        let dst = target_root.join(&e.rel);
+        let existed = std::fs::symlink_metadata(&dst).is_ok();
+        if existed {
+            backup_existing(&dst, &backup_root.join(&e.rel))?;
+        }
+        project_entry(&tree_path, e, &dst)?;
+        let op = if existed { ChangeOp::Replaced } else { ChangeOp::Added };
+        match op {
+            ChangeOp::Added => report.added.push(e.rel.clone()),
+            ChangeOp::Replaced => report.replaced.push(e.rel.clone()),
+            ChangeOp::Removed => unreachable!(),
+        }
+        changes.push(Change { path: e.rel.clone(), op });
+        journal_change(journal, tree_dir, &dst, op);
+    }
+
+    // Retirar los sobrantes del árbol previo.
+    for rel in &removed_files {
+        let dst = target_root.join(rel);
+        if std::fs::symlink_metadata(&dst).is_ok() {
+            backup_existing(&dst, &backup_root.join(rel))?;
+            remove_path(&dst)?;
+            report.removed.push(rel.clone());
+            changes.push(Change { path: rel.clone(), op: ChangeOp::Removed });
+            journal_change(journal, tree_dir, &dst, ChangeOp::Removed);
+        }
+    }
+
+    // (5) manifiesto + avance de `current` (commit-point).
+    let manifest = GenerationManifest {
+        id,
+        tree_dir: tree_dir.to_string(),
+        tree_content,
+        parent: current(state_root)?,
+        created_at: now_secs(),
+        files: new_files,
+        changes,
+    };
+    write_atomic(&manifest_path(state_root, id), &serde_json::to_vec_pretty(&manifest)?)?;
+    set_current(state_root, Some(id))?;
+    Ok(report)
+}
+
+// ---------------------------------------------------------------------------------------------------
+// Rollback.
+// ---------------------------------------------------------------------------------------------------
+
+/// Revierte la generación viva: deshace sus cambios (borra lo añadido, restaura desde `backup/` lo
+/// pisado/retirado) y retrocede `current` al padre. Idempotente respecto a paths ya restaurados.
+pub fn rollback(
+    target_root: &Path,
+    state_root: &Path,
+    journal: Option<&Journal>,
+) -> Result<RollbackReport> {
+    let cur = current(state_root)?.ok_or(Error::NothingToRollback)?;
+    let m = get(state_root, cur)?
+        .ok_or_else(|| Error::Other(format!("manifiesto de la generación {cur} ausente")))?;
+    let backup_root = generations_dir(state_root).join(cur.to_string()).join("backup");
+
+    let mut report = RollbackReport { reverted: cur, now_current: m.parent, ..Default::default() };
+
+    // Deshacer en orden inverso al apply (los Added al final, por si un Replaced creó su dir padre).
+    for ch in m.changes.iter().rev() {
+        let dst = target_root.join(&ch.path);
+        match ch.op {
+            ChangeOp::Added => {
+                if std::fs::symlink_metadata(&dst).is_ok() {
+                    remove_path(&dst)?;
+                    report.deleted.push(ch.path.clone());
+                    journal_change(journal, &m.tree_dir, &dst, ChangeOp::Removed);
+                }
+            }
+            ChangeOp::Replaced | ChangeOp::Removed => {
+                let bak = backup_root.join(&ch.path);
+                restore_from_backup(&bak, &dst)?;
+                report.restored.push(ch.path.clone());
+                journal_change(journal, &m.tree_dir, &dst, ChangeOp::Replaced);
+            }
+        }
+    }
+
+    set_current(state_root, m.parent)?;
+    Ok(report)
+}
+
+// ---------------------------------------------------------------------------------------------------
+// Primitivas de filesystem.
+// ---------------------------------------------------------------------------------------------------
+
+/// Proyecta una entrada del árbol sobre `dst` (escritura a temporal hermano + `rename` atómico).
+fn project_entry(tree_root: &Path, e: &TreeEntry, dst: &Path) -> Result<()> {
+    if let Some(parent) = dst.parent() {
+        std::fs::create_dir_all(parent)?;
+    }
+    let tmp = tmp_sibling(dst);
+    let _ = remove_path(&tmp);
+    match &e.kind {
+        EntryKind::File { exec } => {
+            let src = tree_root.join(&e.rel);
+            std::fs::copy(&src, &tmp)?;
+            if *exec {
+                use std::os::unix::fs::PermissionsExt;
+                let mode = std::fs::metadata(&src)?.permissions().mode();
+                std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))?;
+            }
+        }
+        EntryKind::Symlink(target) => {
+            std::os::unix::fs::symlink(target, &tmp)?;
+        }
+    }
+    // rename pisa el destino atómicamente (mismo dir ⇒ mismo FS).
+    std::fs::rename(&tmp, dst)?;
+    Ok(())
+}
+
+/// Respalda el estado PREVIO de `src` (fichero o symlink) en `bak`, preservando el tipo.
+fn backup_existing(src: &Path, bak: &Path) -> Result<()> {
+    if let Some(parent) = bak.parent() {
+        std::fs::create_dir_all(parent)?;
+    }
+    let meta = std::fs::symlink_metadata(src)?;
+    if meta.file_type().is_symlink() {
+        let _ = remove_path(bak);
+        std::os::unix::fs::symlink(std::fs::read_link(src)?, bak)?;
+    } else if meta.is_file() {
+        let _ = remove_path(bak);
+        std::fs::copy(src, bak)?;
+        std::fs::set_permissions(bak, meta.permissions())?;
+    } else {
+        return Err(Error::Other(format!(
+            "no sé respaldar {} (no es fichero ni symlink)",
+            src.display()
+        )));
+    }
+    Ok(())
+}
+
+/// Restaura `dst` desde su backup (atómico vía temporal). Si el backup no existe, no hace nada
+/// (idempotencia: ya restaurado o nunca existió).
+fn restore_from_backup(bak: &Path, dst: &Path) -> Result<()> {
+    let meta = match std::fs::symlink_metadata(bak) {
+        Ok(m) => m,
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
+        Err(e) => return Err(e.into()),
+    };
+    if let Some(parent) = dst.parent() {
+        std::fs::create_dir_all(parent)?;
+    }
+    let tmp = tmp_sibling(dst);
+    let _ = remove_path(&tmp);
+    if meta.file_type().is_symlink() {
+        std::os::unix::fs::symlink(std::fs::read_link(bak)?, &tmp)?;
+    } else {
+        std::fs::copy(bak, &tmp)?;
+        std::fs::set_permissions(&tmp, meta.permissions())?;
+    }
+    std::fs::rename(&tmp, dst)?;
+    Ok(())
+}
+
+fn remove_path(p: &Path) -> Result<()> {
+    match std::fs::symlink_metadata(p) {
+        Ok(m) if m.is_dir() && !m.file_type().is_symlink() => std::fs::remove_dir_all(p)?,
+        Ok(_) => std::fs::remove_file(p)?,
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
+        Err(e) => return Err(e.into()),
+    }
+    Ok(())
+}
+
+fn tmp_sibling(dst: &Path) -> PathBuf {
+    let name = dst.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
+    dst.with_file_name(format!(".hammer-upgrade-tmp-{name}"))
+}
+
+/// Escribe `bytes` en `path` de forma atómica (temporal + rename).
+fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
+    if let Some(parent) = path.parent() {
+        std::fs::create_dir_all(parent)?;
+    }
+    let tmp = tmp_sibling(path);
+    std::fs::write(&tmp, bytes)?;
+    std::fs::rename(&tmp, path)?;
+    Ok(())
+}
+
+/// Avanza/retrocede `current` de forma atómica. `None` ⇒ borra el puntero (estado pre-upgrades).
+fn set_current(state_root: &Path, id: Option<u64>) -> Result<()> {
+    let f = current_file(state_root);
+    match id {
+        Some(id) => write_atomic(&f, id.to_string().as_bytes())?,
+        None => {
+            let _ = std::fs::remove_file(&f);
+        }
+    }
+    Ok(())
+}
+
+fn journal_change(journal: Option<&Journal>, tree_dir: &str, dst: &Path, op: ChangeOp) {
+    let Some(j) = journal else { return };
+    let mop = match op {
+        ChangeOp::Added => MutationOp::Create,
+        ChangeOp::Replaced => MutationOp::Replace,
+        ChangeOp::Removed => MutationOp::Delete,
+    };
+    let ev = MutationEvent {
+        ts: hammer_journal::now_rfc3339(),
+        op: mop,
+        path: dst.to_path_buf(),
+        by: Actor {
+            // El upgrade es replay-able: el contenido viene del árbol `tree_dir` del store.
+            source: Source::HammerHydrate { artifact: tree_dir.to_string() },
+            pid: Some(std::process::id()),
+            uid: None,
+        },
+        content_hash: None,
+        note: Some("hammer upgrade".to_string()),
+    };
+    if let Err(e) = j.record(&ev) {
+        tracing::warn!(error = %e, path = %dst.display(), "upgrade: fallo al registrar en el diario");
+    }
+}
+
+fn now_secs() -> u64 {
+    std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .map(|d| d.as_secs())
+        .unwrap_or(0)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Sella un árbol sintético en `store_root` y devuelve su `<hash>-<name>`.
+    fn seal_tree(store_root: &Path, hex: &str, name: &str, build: impl Fn(&Path)) -> String {
+        let store = Store::open(store_root).unwrap();
+        let h = ArtifactHash::from_hex(hex);
+        let staging = store_root.join(".bootstrap-tmp").join(h.store_dir_name(name));
+        let _ = std::fs::remove_dir_all(&staging);
+        std::fs::create_dir_all(&staging).unwrap();
+        build(&staging);
+        store.seal(&staging, &h, name).unwrap();
+        h.store_dir_name(name)
+    }
+
+    fn h64(prefix: &str) -> String {
+        format!("{prefix}{}", "0".repeat(64 - prefix.len()))
+    }
+
+    fn write(p: &Path, bytes: &[u8]) {
+        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
+        std::fs::write(p, bytes).unwrap();
+    }
+
+    #[test]
+    fn apply_projects_tree_and_records_generation() {
+        let tmp = tempfile::tempdir().unwrap();
+        let store = tmp.path().join("store");
+        let root = tmp.path().join("root");
+        let state = tmp.path().join("state");
+        std::fs::create_dir_all(&root).unwrap();
+        // root ya tiene un /usr/bin/ls "viejo" que el upgrade pisará.
+        write(&root.join("usr/bin/ls"), b"OLD-ls");
+
+        let dir = seal_tree(&store, &h64("aa"), "v1", |w| {
+            write(&w.join("usr/bin/ls"), b"NEW-ls");
+            write(&w.join("usr/bin/cat"), b"NEW-cat");
+            std::fs::create_dir_all(w.join("bin")).unwrap();
+            std::os::unix::fs::symlink("busybox", w.join("bin/sh")).unwrap();
+        });
+
+        let r = apply(&store, &dir, None, &root, &state, None).unwrap();
+        assert_eq!(r.generation, 1);
+        assert!(!r.already_current);
+        assert_eq!(r.replaced, vec![PathBuf::from("usr/bin/ls")]);
+        assert!(r.added.contains(&PathBuf::from("usr/bin/cat")));
+        assert!(r.added.contains(&PathBuf::from("bin/sh")));
+
+        // los bytes nuevos están en el root vivo.
+        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"NEW-ls");
+        assert_eq!(std::fs::read(root.join("usr/bin/cat")).unwrap(), b"NEW-cat");
+        assert_eq!(std::fs::read_link(root.join("bin/sh")).unwrap(), PathBuf::from("busybox"));
+
+        assert_eq!(current(&state).unwrap(), Some(1));
+        assert_eq!(list(&state).unwrap().len(), 1);
+    }
+
+    #[test]
+    fn apply_is_idempotent_for_same_tree() {
+        let tmp = tempfile::tempdir().unwrap();
+        let store = tmp.path().join("store");
+        let root = tmp.path().join("root");
+        let state = tmp.path().join("state");
+        let dir = seal_tree(&store, &h64("bb"), "v1", |w| write(&w.join("a"), b"A"));
+        let r1 = apply(&store, &dir, None, &root, &state, None).unwrap();
+        assert!(!r1.already_current && r1.generation == 1);
+        let r2 = apply(&store, &dir, None, &root, &state, None).unwrap();
+        assert!(r2.already_current, "mismo árbol ⇒ no-op");
+        assert_eq!(list(&state).unwrap().len(), 1, "no se creó generación nueva");
+    }
+
+    #[test]
+    fn rollback_restores_previous_tree_exactly() {
+        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("usr/bin/ls"), b"LS-v1");
+            write(&w.join("usr/bin/old"), b"OLD-only-in-v1");
+        });
+        let v2 = seal_tree(&store, &h64("c2"), "v2", |w| {
+            write(&w.join("usr/bin/ls"), b"LS-v2");
+            write(&w.join("usr/bin/new"), b"NEW-only-in-v2");
+            // `old` desaparece ⇒ apply de v2 debe retirarlo.
+        });
+
+        apply(&store, &v1, None, &root, &state, None).unwrap();
+        let r2 = apply(&store, &v2, None, &root, &state, None).unwrap();
+        assert_eq!(r2.generation, 2);
+        assert_eq!(r2.removed, vec![PathBuf::from("usr/bin/old")]);
+        // estado tras v2.
+        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"LS-v2");
+        assert!(root.join("usr/bin/new").exists());
+        assert!(!root.join("usr/bin/old").exists());
+
+        // rollback ⇒ vuelve EXACTAMENTE a v1.
+        let rb = rollback(&root, &state, None).unwrap();
+        assert_eq!(rb.reverted, 2);
+        assert_eq!(rb.now_current, Some(1));
+        assert_eq!(current(&state).unwrap(), Some(1));
+        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"LS-v1", "ls revertido");
+        assert!(!root.join("usr/bin/new").exists(), "lo añadido por v2 se borró");
+        assert_eq!(std::fs::read(root.join("usr/bin/old")).unwrap(), b"OLD-only-in-v1", "lo retirado por v2 se restauró");
+    }
+
+    #[test]
+    fn rollback_to_pre_upgrade_state() {
+        let tmp = tempfile::tempdir().unwrap();
+        let store = tmp.path().join("store");
+        let root = tmp.path().join("root");
+        let state = tmp.path().join("state");
+        std::fs::create_dir_all(&root).unwrap();
+        write(&root.join("etc/motd"), b"PRISTINE");
+
+        let v1 = seal_tree(&store, &h64("dd"), "v1", |w| {
+            write(&w.join("etc/motd"), b"UPGRADED");
+            write(&w.join("usr/bin/brand-new"), b"X");
+        });
+        apply(&store, &v1, None, &root, &state, None).unwrap();
+        assert_eq!(std::fs::read(root.join("etc/motd")).unwrap(), b"UPGRADED");
+
+        let rb = rollback(&root, &state, None).unwrap();
+        assert_eq!(rb.now_current, None, "se volvió al estado pre-upgrades");
+        assert_eq!(current(&state).unwrap(), None);
+        assert_eq!(std::fs::read(root.join("etc/motd")).unwrap(), b"PRISTINE", "config original restaurada");
+        assert!(!root.join("usr/bin/brand-new").exists());
+    }
+
+    #[test]
+    fn rollback_without_generation_errors() {
+        let tmp = tempfile::tempdir().unwrap();
+        let err = rollback(&tmp.path().join("root"), &tmp.path().join("state"), None).unwrap_err();
+        assert!(matches!(err, Error::NothingToRollback), "vino {err}");
+    }
+
+    #[test]
+    fn apply_rejects_tree_that_does_not_match_expected() {
+        // Si el llamador anuncia un of_tree esperado (de un índice de mirror / release firmada) y el
+        // árbol del store no casa, apply rechaza ANTES de tocar el root.
+        let tmp = tempfile::tempdir().unwrap();
+        let store_root = tmp.path().join("store");
+        let root = tmp.path().join("root");
+        let state = tmp.path().join("state");
+        let dir = seal_tree(&store_root, &h64("ee"), "v1", |w| write(&w.join("a"), b"A"));
+
+        // expected MENTIROSO ⇒ Verify, y nada se aplicó.
+        let err = apply(&store_root, &dir, Some("b3:deadbeef"), &root, &state, None).unwrap_err();
+        assert!(matches!(err, Error::Verify { .. }), "vino {err}");
+        assert_eq!(current(&state).unwrap(), None, "no se creó generación");
+        assert!(!root.join("a").exists(), "el root quedó intacto");
+
+        // expected CORRECTO (el of_tree real) ⇒ aplica.
+        let want = ArtifactHash::of_tree(&store_root.join(&dir)).unwrap().as_str().to_string();
+        let r = apply(&store_root, &dir, Some(&want), &root, &state, None).unwrap();
+        assert_eq!(r.generation, 1);
+    }
+
+    #[test]
+    fn journal_records_each_change() {
+        let tmp = tempfile::tempdir().unwrap();
+        let store = tmp.path().join("store");
+        let root = tmp.path().join("root");
+        let state = tmp.path().join("state");
+        let j = Journal::open(tmp.path().join("journal")).unwrap();
+        let dir = seal_tree(&store, &h64("ff"), "v1", |w| {
+            write(&w.join("usr/bin/a"), b"A");
+            write(&w.join("usr/bin/b"), b"B");
+        });
+        apply(&store, &dir, None, &root, &state, Some(&j)).unwrap();
+        let evs = j.read_all().unwrap();
+        assert_eq!(evs.len(), 2);
+        assert!(evs.iter().all(|e| matches!(&e.by.source, Source::HammerHydrate { artifact } if artifact == &dir)));
+    }
+}
diff --git a/docs/13-release-engineering.md b/docs/13-release-engineering.md
index 14fd397d..c2252e92 100644
--- a/docs/13-release-engineering.md
+++ b/docs/13-release-engineering.md
@@ -63,7 +63,24 @@ kernel en `/boot`, los módulos GRUB en `/boot/grub/i386-pc` y los pasos `grub-m
   Endurecimiento pendiente: anclar el `of_tree` a una raíz firmada (log de transparencia `bootstrap.json` /
   atestación de Etapa D) para defenderse de un origen plenamente malicioso (hoy el invariante forzado es
   "el contenido recibido hashea a lo que el índice anuncia", que ataja corrupción de transporte).
-- **E4 — upgrades:** aplicar un nuevo árbol Stage 1 sin reinstalar — atómico vía el modelo overlay +
-  el journal (D), con rollback al árbol anterior.
+- **E4 — upgrades: ✅ primer corte** (crate `hammer-upgrade` + CLI `hammer upgrade apply|rollback|status`).
+  Aplica un árbol Stage1/producto **nuevo** (artefacto sellado del store, CAS BLAKE3) sobre el FHS vivo
+  sin reinstalar, dejando una **generación**, y revierte exactamente al árbol anterior con `rollback`.
+  **Modelo de generaciones** (inspirado en NixOS, in-place al FHS porque el boot lee el root directo, no
+  un symlink indirecto): cada generación graba bajo `<state>/generations/<N>/` su `manifest.json`
+  (tree_dir del store, `of_tree`, parent, lista de ficheros, cambios) y un `backup/` con los bytes
+  **previos** de todo path que pisó o retiró. **Atomicidad:** proyección fichero-a-fichero (escritura a
+  temporal + `rename` ⇒ cada fichero conmuta atómicamente), con `current` (id de la generación viva) como
+  commit-point; un corte a mitad deja `current` en la vieja y los backups intactos ⇒ re-apply/rollback
+  recuperan. **Diff vs el árbol previo:** el apply añade/pisa los ficheros del árbol nuevo y **retira** los
+  que la generación viva aportaba y el nuevo no tiene. **Rollback:** deshace en orden inverso (borra lo
+  añadido, restaura desde `backup/` lo pisado/retirado) y retrocede `current` al padre — restauración
+  *exacta*, incluso al estado pre-upgrades. Cada cambio se registra en el [journal](05-journal.md) como
+  `HammerHydrate{artifact=tree_dir}` (replay-able). Verificación opcional de integridad: si el llamador
+  anuncia el `of_tree` esperado (de un índice de **mirror E3** o release firmada), el apply lo exige antes
+  de tocar el root. Maneja ficheros regulares + symlinks; valida E2E en host con `scripts/upgrade-e2e-test.sh`
+  (apply v1→v2→rollback→rollback contra el binario real). **Endurecimiento pendiente:** journal de
+  *intención* + replay para sobrevivir un kernel-panic a media escritura; GC de generaciones viejas
+  (prune); cableado del init de boot para que el rollback sea seleccionable al arranque.
 - **E5 — ISO/medio de arranque:** medio live para correr el instalador (xorriso/grub-mkrescue;
   herramental a construir).
diff --git a/scripts/upgrade-e2e-test.sh b/scripts/upgrade-e2e-test.sh
new file mode 100755
index 00000000..7512b94a
--- /dev/null
+++ b/scripts/upgrade-e2e-test.sh
@@ -0,0 +1,103 @@
+#!/bin/sh
+# E2E de la Etapa E4 (upgrades atómicos con rollback) contra el binario `hammer` REAL.
+#
+# Monta un store sintético con dos árboles "producto" (v1, v2), un root FHS vivo, y ejercita:
+#   apply v1 → apply v2 → rollback → rollback, verificando el estado del root en cada paso.
+# Todo en host, sin VM: valida el cableado del CLI + la semántica generación/rollback de hammer-upgrade.
+#
+# Uso: scripts/upgrade-e2e-test.sh
+set -eu
+
+ROOT_DIR="$(CDPATH= cd "$(dirname "$0")/.." && pwd)"
+WORK="$(mktemp -d)"
+trap 'chmod -R u+w "$WORK" 2>/dev/null || true; rm -rf "$WORK"' EXIT
+
+STORE="$WORK/store"
+ROOT="$WORK/root"
+STATE="$WORK/state"
+JOURNAL="$WORK/journal"
+mkdir -p "$STORE" "$ROOT/usr/bin" "$ROOT/etc"
+
+echo "==> compilando hammer"
+cargo build -q -p hammer-cli --manifest-path "$ROOT_DIR/Cargo.toml"
+HAMMER="$ROOT_DIR/target/debug/hammer"
+
+up() { "$HAMMER" --store "$STORE" upgrade --root "$ROOT" --state-root "$STATE" --journal "$JOURNAL" "$@"; }
+
+# --- estado pristino del root (lo que el rollback final debe restaurar) ---
+printf 'PRISTINE-ls' > "$ROOT/usr/bin/ls"
+printf 'motd original\n' > "$ROOT/etc/motd"
+
+# --- sella dos árboles producto sintéticos en el store (vía `hammer build`? no: usamos un mini-store
+#     a mano replicando la forma <64hex>-<name>; el of_tree lo computa hammer al aplicar) ---
+seal_tree() {
+    # $1=hex64 $2=name $3=builder-fn
+    dir="$STORE/$1-$2"
+    rm -rf "$dir"; mkdir -p "$dir"
+    "$3" "$dir"
+    chmod -R a-w "$dir"   # el store sella read-only; replicamos para fidelidad
+}
+hex() { printf '%s' "$1"; i=${#1}; while [ "$i" -lt 64 ]; do printf '0'; i=$((i+1)); done; }
+
+build_v1() {
+    mkdir -p "$1/usr/bin" "$1/bin"
+    printf 'LS-v1'  > "$1/usr/bin/ls"
+    printf 'OLDtool'> "$1/usr/bin/oldtool"
+    ln -s busybox "$1/bin/sh"
+}
+build_v2() {
+    mkdir -p "$1/usr/bin" "$1/bin"
+    printf 'LS-v2'  > "$1/usr/bin/ls"
+    printf 'NEWtool'> "$1/usr/bin/newtool"   # oldtool desaparece
+    ln -s busybox "$1/bin/sh"
+}
+seal_tree "$(hex aa11)" product-rootfs build_v1
+seal_tree "$(hex bb22)" product-rootfs build_v2
+V1="$(hex aa11)-product-rootfs"
+V2="$(hex bb22)-product-rootfs"
+
+assert() { # $1=path $2=expected-content $3=label
+    got="$(cat "$1" 2>/dev/null || echo '<ausente>')"
+    if [ "$got" != "$2" ]; then echo "FALLO [$3]: $1 = '$got', esperaba '$2'" >&2; exit 1; fi
+    echo "  ok [$3] $1 = $2"
+}
+assert_absent() { [ ! -e "$1" ] || { echo "FALLO [$2]: $1 debería estar ausente" >&2; exit 1; }; echo "  ok [$2] $1 ausente"; }
+
+echo "==> apply v1"
+up apply "$V1"
+assert "$ROOT/usr/bin/ls" "LS-v1" "v1"
+assert "$ROOT/usr/bin/oldtool" "OLDtool" "v1"
+[ -L "$ROOT/bin/sh" ] || { echo "FALLO: bin/sh no es symlink" >&2; exit 1; }
+
+echo "==> apply v2 (pisa ls, añade newtool, RETIRA oldtool)"
+up apply "$V2"
+assert "$ROOT/usr/bin/ls" "LS-v2" "v2"
+assert "$ROOT/usr/bin/newtool" "NEWtool" "v2"
+assert_absent "$ROOT/usr/bin/oldtool" "v2"
+
+echo "==> status"
+up status
+
+echo "==> rollback (v2 → v1: ls vuelve, newtool se borra, oldtool se restaura)"
+up rollback
+assert "$ROOT/usr/bin/ls" "LS-v1" "rb→v1"
+assert "$ROOT/usr/bin/oldtool" "OLDtool" "rb→v1"
+assert_absent "$ROOT/usr/bin/newtool" "rb→v1"
+
+echo "==> rollback (v1 → pristino: el root vuelve EXACTO al estado pre-upgrades)"
+up rollback
+assert "$ROOT/usr/bin/ls" "PRISTINE-ls" "rb→pristino"
+assert "$ROOT/etc/motd" "$(printf 'motd original\n')" "rb→pristino"
+assert_absent "$ROOT/usr/bin/oldtool" "rb→pristino"
+
+echo "==> idempotencia: re-apply v1 sobre pristino vuelve a generar, status coherente"
+up apply "$V1" >/dev/null
+up apply "$V1"   # mismo árbol ⇒ no-op
+
+echo "==> diario: registró cambios"
+LINES="$(wc -l < "$JOURNAL/mutations.jsonl")"
+[ "$LINES" -gt 0 ] || { echo "FALLO: diario vacío" >&2; exit 1; }
+echo "  ok: $LINES eventos en el diario"
+
+echo
+echo "✓✓ E4 upgrade E2E VERDE: apply/rollback atómicos + restauración exacta + idempotencia + diario"