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…

kernel A/B: BootNext hace la vuelta atrás, y la hace el firmware (ADR 0017 §2)

`takana kernel {stage,boot-status,confirm,rollback}`.

La idea que lo vuelve barato: hay DOS modos de fallo y sólo uno necesita
código nuestro.

  1. El candidato NO ARRANCA (pánico temprano, EFI-stub rechazado). Lo
     cubre BootNext y sale GRATIS: es una variable de UN SOLO USO que el
     firmware CONSUME al leerla. Si el kernel muere, el siguiente
     arranque ya no la encuentra, cae en BootOrder y vuelve al estable
     sin que corra una línea nuestra. No hay contador que mantener ni
     estado que se pueda corromper: el mecanismo es el firmware.
  2. Arranca pero el sistema no queda sano. Eso el firmware no lo sabe ⇒
     confirmación explícita, y cada arranque sin confirmar gasta un
     intento.

El candidato va a una ranura PROPIA de la ESP, nunca encima del estable:
un fallo a media copia dejaría sin kernel al que volver. La copia es tmp
+ rename.

Y `stage` REPONE el BootOrder después de crear la entrada: reconcile
deja al candidato primero, y un candidato no debe volverse el default —
tiene que arrancar UNA vez. Para eso está BootNext.

Verificado con DOS KERNELES REALES del store, tres arranques en OVMF:
  1º  6.16.12 estable, sin candidato → stage del 7.1.2
  2º  BdsDxe: starting Boot0004 "takana (candidato)" → uname = 7.1.2,
      "⏳ EN PRUEBA", confirm → promovido (y la ruta fallback también)
  3º  arranque normal → uname = 7.1.2: el estable cambió

Un bug que cazó un test y no una revisión: `en_esp` normalizaba los
backslashes DESPUÉS de quitar la barra inicial, así que una ruta en
formato EFI salía como "/EFI/..." —absoluta— y Path::join con absoluta
DESCARTA la base: habría escrito en el /EFI de la raíz del sistema en
vez de dentro de la ESP.

Y un hallazgo del banco de pruebas que vale para el diseño: con el
estado A/B en un tmpfs, stage + reboot lo perdía y el sistema decía "sin
candidato". Falló SEGURO (no promovió nada), pero dejaba la entrada
NVRAM huérfana sin que nadie lo supiera. boot-status ahora lo detecta
preguntándole al firmware, que es la fuente de verdad, y lo dice.

88 tests del CLI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj

6642b93858d9ed3ae88884511f77c189bb882286 · ruwaq Sergio · (1 killa ñawpaq) · tayta 13ce9b16f · kay commitpi sach'ata qhaway

Semántico sayk'uy · 35

crates/takana-cli/src/kernel_cmd.rs

crates/takana-cli/src/main.rs

3 qillqakuna · +580 −0

diff --git a/crates/takana-cli/src/kernel_ab.rs b/crates/takana-cli/src/kernel_ab.rs
new file mode 100644
index 00000000..646903d1
--- /dev/null
+++ b/crates/takana-cli/src/kernel_ab.rs
@@ -0,0 +1,265 @@
+//! A/B del kernel con vuelta atrás (ADR 0017 §2).
+//!
+//! ── LA IDEA, Y POR QUÉ ES BARATA EN TAKANA ──────────────────────────────────────────────────────
+//! El kernel es un artefacto de contenido: el viejo y el nuevo coexisten sin disputarse un nombre,
+//! así que **el rollback ya es gratis**. Lo que falta —y es lo que hace que actualizar el kernel de
+//! una máquina remota deje de dar miedo— es que la vuelta atrás ocurra **sola** cuando el kernel
+//! nuevo no sirve.
+//!
+//! ── DOS MODOS DE FALLO, DOS MECANISMOS ──────────────────────────────────────────────────────────
+//! 1. **El candidato no arranca en absoluto** (pánico temprano, EFI-stub que el firmware rechaza).
+//!    Lo cubre `BootNext`, y sale gratis: es una variable **de un solo uso** que el firmware CONSUME
+//!    al leerla. Si el kernel muere, el siguiente arranque ya no la encuentra, cae en `BootOrder`
+//!    normal y vuelve al estable **sin que corra una sola línea nuestra**. No hay contador que
+//!    mantener ni estado que se pueda corromper: el mecanismo es el propio firmware.
+//! 2. **El candidato arranca pero el sistema no queda sano.** Eso el firmware no lo puede saber, así
+//!    que hace falta confirmación explícita: el candidato se promueve sólo si alguien dice que
+//!    funcionó (`kernel confirm`). Mientras no se confirme, cada arranque gasta un intento.
+//!
+//! ⚠ **El estado en disco NO es la fuente de verdad de por dónde arrancamos**: eso lo dice
+//! `BootCurrent`, que lo escribe el firmware. El fichero sólo recuerda *qué* pusimos a prueba. Si
+//! los dos se contradicen, gana el firmware — el fichero puede quedar viejo si alguien arrancó a
+//! mano desde el menú.
+
+use anyhow::{bail, Context, Result};
+use std::path::{Path, PathBuf};
+
+use crate::efi_boot;
+
+pub const ESTADO: &str = "/var/lib/hammer/boot/ab.json";
+/// Ranura del candidato dentro de la ESP. Deliberadamente **distinta** de la del estable: si se
+/// escribiera encima, un fallo a media copia dejaría sin kernel estable al que volver.
+pub const RUTA_CANDIDATO: &str = "/EFI/takana/takana-cand.efi";
+pub const RUTA_ESTABLE: &str = "/EFI/takana/takanax64.efi";
+pub const ETIQUETA_CANDIDATO: &str = "takana (candidato)";
+
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
+pub struct Candidato {
+    pub hash: String,
+    pub ruta: String,
+    pub slot: u16,
+    /// Arranques que le quedan para ser confirmado antes de descartarlo.
+    pub intentos: u32,
+}
+
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
+pub struct EstadoAb {
+    pub schema: String,
+    pub estable_hash: Option<String>,
+    pub candidato: Option<Candidato>,
+}
+
+impl Default for EstadoAb {
+    fn default() -> Self {
+        EstadoAb {
+            schema: "takana-kernel-ab/1".into(),
+            estable_hash: None,
+            candidato: None,
+        }
+    }
+}
+
+pub fn leer_estado(p: &Path) -> EstadoAb {
+    std::fs::read_to_string(p)
+        .ok()
+        .and_then(|s| serde_json::from_str::<EstadoAb>(&s).ok())
+        .filter(|e| e.schema == "takana-kernel-ab/1")
+        .unwrap_or_default()
+}
+
+pub fn escribir_estado(p: &Path, e: &EstadoAb) -> Result<()> {
+    if let Some(d) = p.parent() {
+        std::fs::create_dir_all(d)?;
+    }
+    std::fs::write(p, serde_json::to_string_pretty(e)?)?;
+    Ok(())
+}
+
+/// `BootCurrent`: por dónde arrancó **esta** sesión. Lo escribe el firmware y es de sólo lectura.
+pub fn boot_current(efivars: &Path) -> Option<u16> {
+    let p = efivars.join(format!("BootCurrent-{}", efi_boot::GLOBAL_GUID));
+    let (_, d) = efi_boot::read_var(&p).ok()?;
+    (d.len() >= 2).then(|| u16::from_le_bytes([d[0], d[1]]))
+}
+
+/// `BootNext`: arrancá **una vez** por aquí. El firmware la borra al usarla, y en eso se apoya toda
+/// la vuelta atrás del modo de fallo 1.
+pub fn poner_boot_next(efivars: &Path, slot: u16) -> Result<()> {
+    let p = efivars.join(format!("BootNext-{}", efi_boot::GLOBAL_GUID));
+    efi_boot::write_var(&p, efi_boot::ATTRS, &slot.to_le_bytes())
+        .with_context(|| format!("no pude escribir BootNext en {}", p.display()))
+}
+
+pub fn boot_next(efivars: &Path) -> Option<u16> {
+    let p = efivars.join(format!("BootNext-{}", efi_boot::GLOBAL_GUID));
+    let (_, d) = efi_boot::read_var(&p).ok()?;
+    (d.len() >= 2).then(|| u16::from_le_bytes([d[0], d[1]]))
+}
+
+pub fn borrar_boot_next(efivars: &Path) {
+    let p = efivars.join(format!("BootNext-{}", efi_boot::GLOBAL_GUID));
+    let _ = std::fs::remove_file(p);
+}
+
+/// Qué hacer al arrancar, decidido **sólo** con datos y sin efectos: así se puede probar entero.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Veredicto {
+    /// No hay nada en prueba.
+    SinCandidato,
+    /// Arrancamos por el candidato: está a prueba y le quedan `intentos` después de éste.
+    EnPrueba { slot: u16, intentos_restantes: u32 },
+    /// Arrancamos por el estable habiendo un candidato pendiente ⇒ **el candidato no arrancó**.
+    CandidatoFallo { slot: u16 },
+    /// Se acabaron los intentos sin que nadie confirmara.
+    CandidatoAgotado { slot: u16 },
+}
+
+/// El corazón de la decisión. `actual` es `BootCurrent` (puede faltar: hay firmwares que no la
+/// publican, y entonces no se puede afirmar nada — se prefiere no tocar el candidato antes que
+/// descartarlo por una sospecha).
+pub fn evaluar(estado: &EstadoAb, actual: Option<u16>) -> Veredicto {
+    let Some(c) = &estado.candidato else { return Veredicto::SinCandidato };
+    match actual {
+        Some(a) if a == c.slot => {
+            if c.intentos == 0 {
+                Veredicto::CandidatoAgotado { slot: c.slot }
+            } else {
+                Veredicto::EnPrueba { slot: c.slot, intentos_restantes: c.intentos - 1 }
+            }
+        }
+        Some(_) => Veredicto::CandidatoFallo { slot: c.slot },
+        // Sin BootCurrent no hay forma de saber por dónde vinimos. Descartar un candidato bueno es
+        // peor que dejarlo un arranque más: se informa y no se toca.
+        None => Veredicto::EnPrueba { slot: c.slot, intentos_restantes: c.intentos },
+    }
+}
+
+/// Ruta de la ESP montada + ruta interna del `.efi` ⇒ ruta del sistema de ficheros.
+pub fn en_esp(esp: &Path, ruta_efi: &str) -> PathBuf {
+    // ⚠ El orden importa y no es cosmético. Si se quitan las barras ANTES de traducir los
+    // backslashes, una ruta en formato EFI (`\EFI\takana\x.efi`) sale de aquí como `/EFI/...`,
+    // que es ABSOLUTA — y `Path::join` con una ruta absoluta **descarta la base**: se escribiría en
+    // el `/EFI` de la raíz del sistema en vez de dentro de la ESP. Traducir primero y recortar
+    // después. (Lo cazó el test, no una revisión.)
+    let rel = ruta_efi.replace('\\', "/");
+    esp.join(rel.trim_start_matches('/'))
+}
+
+/// Copia el bzImage de un artefacto del store a la ranura candidata de la ESP.
+///
+/// Escribe a un temporal y renombra: un corte de luz a media copia no puede dejar medio kernel en
+/// la ruta que el firmware va a lanzar. (En FAT el rename es atómico dentro del directorio.)
+pub fn instalar_candidato(bzimage: &Path, esp: &Path, ruta_efi: &str) -> Result<u64> {
+    let destino = en_esp(esp, ruta_efi);
+    let dir = destino.parent().context("ruta de candidato sin directorio")?;
+    std::fs::create_dir_all(dir)?;
+    let tmp = dir.join("takana-cand.tmp");
+    std::fs::copy(bzimage, &tmp)
+        .with_context(|| format!("copiando {} → {}", bzimage.display(), tmp.display()))?;
+    let n = std::fs::metadata(&tmp)?.len();
+    std::fs::rename(&tmp, &destino)
+        .with_context(|| format!("renombrando a {}", destino.display()))?;
+    Ok(n)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn con_candidato(slot: u16, intentos: u32) -> EstadoAb {
+        EstadoAb {
+            candidato: Some(Candidato {
+                hash: "b3:abc".into(),
+                ruta: RUTA_CANDIDATO.into(),
+                slot,
+                intentos,
+            }),
+            ..Default::default()
+        }
+    }
+
+    #[test]
+    fn sin_candidato_no_hay_nada_que_decidir() {
+        assert_eq!(evaluar(&EstadoAb::default(), Some(4)), Veredicto::SinCandidato);
+        assert_eq!(evaluar(&EstadoAb::default(), None), Veredicto::SinCandidato);
+    }
+
+    #[test]
+    fn arrancar_por_el_candidato_lo_pone_a_prueba_y_gasta_un_intento() {
+        assert_eq!(
+            evaluar(&con_candidato(7, 1), Some(7)),
+            Veredicto::EnPrueba { slot: 7, intentos_restantes: 0 }
+        );
+        assert_eq!(
+            evaluar(&con_candidato(7, 3), Some(7)),
+            Veredicto::EnPrueba { slot: 7, intentos_restantes: 2 }
+        );
+    }
+
+    #[test]
+    fn arrancar_por_otro_lado_con_candidato_pendiente_significa_que_no_arranco() {
+        // Es EL caso que justifica todo: pusimos BootNext al candidato y el firmware terminó
+        // arrancando otra cosa ⇒ el candidato no llegó a ejecutarse.
+        assert_eq!(evaluar(&con_candidato(7, 1), Some(4)), Veredicto::CandidatoFallo { slot: 7 });
+    }
+
+    #[test]
+    fn sin_intentos_se_agota_en_vez_de_reintentar_para_siempre() {
+        assert_eq!(evaluar(&con_candidato(7, 0), Some(7)), Veredicto::CandidatoAgotado { slot: 7 });
+    }
+
+    #[test]
+    fn sin_boot_current_no_se_descarta_por_sospecha() {
+        // Hay firmwares que no publican BootCurrent. Descartar un candidato bueno es peor que
+        // dejarlo un arranque más, así que NO se consume intento.
+        assert_eq!(
+            evaluar(&con_candidato(7, 2), None),
+            Veredicto::EnPrueba { slot: 7, intentos_restantes: 2 },
+            "sin dato del firmware no se gasta intento"
+        );
+    }
+
+    #[test]
+    fn el_estado_ida_y_vuelta_y_un_esquema_ajeno_no_se_come() {
+        let tmp = std::env::temp_dir().join(format!("takana-ab-{}", std::process::id()));
+        std::fs::create_dir_all(&tmp).unwrap();
+        let f = tmp.join("ab.json");
+        let e = con_candidato(9, 2);
+        escribir_estado(&f, &e).unwrap();
+        assert_eq!(leer_estado(&f), e);
+        // Un fichero de otro esquema (o basura) devuelve el default, no un estado a medias.
+        std::fs::write(&f, r#"{"schema":"otra-cosa/9","candidato":null}"#).unwrap();
+        assert_eq!(leer_estado(&f), EstadoAb::default());
+        std::fs::write(&f, "no soy json").unwrap();
+        assert_eq!(leer_estado(&f), EstadoAb::default());
+        std::fs::remove_dir_all(&tmp).ok();
+    }
+
+    #[test]
+    fn la_ranura_del_candidato_no_pisa_la_del_estable() {
+        // Si compartieran ruta, un fallo a media copia se llevaría puesto el kernel al que hay que
+        // volver — el rollback dejaría de existir justo cuando hace falta.
+        assert_ne!(RUTA_CANDIDATO, RUTA_ESTABLE);
+        let esp = Path::new("/boot/efi");
+        assert_eq!(en_esp(esp, RUTA_CANDIDATO), esp.join("EFI/takana/takana-cand.efi"));
+        assert_eq!(en_esp(esp, "\\EFI\\takana\\x.efi"), esp.join("EFI/takana/x.efi"));
+    }
+
+    #[test]
+    fn instalar_candidato_es_atomico_y_no_deja_el_temporal() {
+        let tmp = std::env::temp_dir().join(format!("takana-ab-inst-{}", std::process::id()));
+        std::fs::create_dir_all(&tmp).unwrap();
+        let src = tmp.join("bzImage");
+        std::fs::write(&src, vec![0xAAu8; 4096]).unwrap();
+        let esp = tmp.join("esp");
+        let n = instalar_candidato(&src, &esp, RUTA_CANDIDATO).unwrap();
+        assert_eq!(n, 4096);
+        let dst = en_esp(&esp, RUTA_CANDIDATO);
+        assert_eq!(std::fs::read(&dst).unwrap().len(), 4096);
+        assert!(
+            !dst.parent().unwrap().join("takana-cand.tmp").exists(),
+            "el temporal se renombró, no quedó suelto"
+        );
+        std::fs::remove_dir_all(&tmp).ok();
+    }
+}
diff --git a/crates/takana-cli/src/kernel_cmd.rs b/crates/takana-cli/src/kernel_cmd.rs
index d1b211f5..c1d3a74c 100644
--- a/crates/takana-cli/src/kernel_cmd.rs
+++ b/crates/takana-cli/src/kernel_cmd.rs
@@ -202,6 +202,56 @@ pub enum KernelCmd {
         #[arg(long)]
         json: bool,
     },
+    /// Stage a kernel artifact as the boot candidate for the NEXT boot only (ADR 0017).
+    ///
+    /// Copia su bzImage a una ranura PROPIA de la ESP —nunca encima del estable— y pone `BootNext`.
+    /// `BootNext` es de un solo uso: si el candidato no arranca, el firmware ya no la encuentra en
+    /// el arranque siguiente y vuelve solo al estable. La vuelta atrás del caso «no arranca» no la
+    /// hace takana: la hace el firmware, y por eso no se puede romper.
+    Stage {
+        /// Artefacto del kernel en el store (nombre de directorio o ruta).
+        artefacto: String,
+        /// ESP ya montada.
+        #[arg(long, default_value = "/boot/efi")]
+        esp: PathBuf,
+        /// Root of efivarfs.
+        #[arg(long, default_value = crate::efi_boot::EFIVARS)]
+        efivars: PathBuf,
+        /// Arranques que tiene el candidato para ser confirmado.
+        #[arg(long, default_value_t = 1)]
+        tries: u32,
+        /// A/B state file.
+        #[arg(long, default_value = crate::kernel_ab::ESTADO)]
+        state: PathBuf,
+        /// Say what would happen and touch nothing.
+        #[arg(long)]
+        dry_run: bool,
+    },
+    /// Say where this boot came from and what is on trial.
+    BootStatus {
+        #[arg(long, default_value = crate::efi_boot::EFIVARS)]
+        efivars: PathBuf,
+        #[arg(long, default_value = crate::kernel_ab::ESTADO)]
+        state: PathBuf,
+    },
+    /// Promote the candidate that is currently running to stable.
+    Confirm {
+        #[arg(long, default_value = "/boot/efi")]
+        esp: PathBuf,
+        #[arg(long, default_value = crate::efi_boot::EFIVARS)]
+        efivars: PathBuf,
+        #[arg(long, default_value = crate::kernel_ab::ESTADO)]
+        state: PathBuf,
+    },
+    /// Drop the candidate and keep the stable kernel.
+    Rollback {
+        #[arg(long, default_value = "/boot/efi")]
+        esp: PathBuf,
+        #[arg(long, default_value = crate::efi_boot::EFIVARS)]
+        efivars: PathBuf,
+        #[arg(long, default_value = crate::kernel_ab::ESTADO)]
+        state: PathBuf,
+    },
     /// Estadísticas del árbol de Kconfig parseado (control de salud del lector).
     Stats {
         #[arg(long)]
@@ -214,6 +264,11 @@ pub enum KernelCmd {
 
 pub fn run(cmd: KernelCmd, store: &str) -> Result<()> {
     match cmd {
+        KernelCmd::Stage { artefacto, esp, efivars, tries, state, dry_run } =>
+            ab_stage(&artefacto, store, &esp, &efivars, tries, &state, dry_run),
+        KernelCmd::BootStatus { efivars, state } => ab_status(&efivars, &state),
+        KernelCmd::Confirm { esp, efivars, state } => ab_confirm(&esp, &efivars, &state),
+        KernelCmd::Rollback { esp, efivars, state } => ab_rollback(&esp, &efivars, &state),
         KernelCmd::Stats { kconfig, warnings } => stats(&kconfig_root(kconfig)?, warnings),
         KernelCmd::Probe {
             config,
@@ -1806,3 +1861,262 @@ mod tests {
         assert_eq!(serie("sin versión"), None);
     }
 }
+
+// ══ A/B del kernel (ADR 0017 §2) ═══════════════════════════════════════════════════════════════
+
+use crate::efi_boot;
+use crate::kernel_ab::{self, Veredicto};
+use anyhow::bail;
+
+/// Localiza el bzImage de un artefacto del store, aceptando nombre de directorio, hash o ruta.
+fn bzimage_de(artefacto: &str, store: &str) -> Result<(PathBuf, String)> {
+    let directo = PathBuf::from(artefacto);
+    let dir = if directo.is_dir() {
+        directo
+    } else {
+        let s = PathBuf::from(store);
+        let exacto = s.join(artefacto);
+        if exacto.is_dir() {
+            exacto
+        } else {
+            // Por prefijo de hash o por nombre de receta: útil a mano, y si hay varios se dice
+            // en vez de elegir por orden de lectura del directorio.
+            let pat = artefacto.trim_start_matches("b3:");
+            let mut cand: Vec<PathBuf> = std::fs::read_dir(&s)
+                .with_context(|| format!("no puedo leer el store {}", s.display()))?
+                .filter_map(|e| e.ok())
+                .map(|e| e.path())
+                .filter(|p| {
+                    let n = p.file_name().unwrap_or_default().to_string_lossy().to_string();
+                    p.is_dir() && (n.starts_with(pat) || n.split_once('-').map(|(_, r)| r == pat).unwrap_or(false))
+                })
+                .collect();
+            cand.sort();
+            match cand.len() {
+                1 => cand.remove(0),
+                0 => bail!("no encuentro el artefacto «{artefacto}» en {}", s.display()),
+                n => bail!(
+                    "«{artefacto}» casa con {n} artefactos; usá el nombre completo:\n  {}",
+                    cand.iter()
+                        .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string())
+                        .collect::<Vec<_>>()
+                        .join("\n  ")
+                ),
+            }
+        }
+    };
+    let bz = dir.join("boot/bzImage");
+    if !bz.is_file() {
+        bail!("{} no trae boot/bzImage: ¿es un artefacto de kernel?", dir.display());
+    }
+    let nombre = dir.file_name().unwrap_or_default().to_string_lossy().to_string();
+    Ok((bz, nombre))
+}
+
+fn ab_stage(
+    artefacto: &str,
+    store: &str,
+    esp: &Path,
+    efivars: &Path,
+    tries: u32,
+    state: &Path,
+    dry_run: bool,
+) -> Result<()> {
+    let (bz, nombre) = bzimage_de(artefacto, store)?;
+    let tam = std::fs::metadata(&bz)?.len();
+    println!("candidato: {nombre}");
+    println!("  bzImage:  {} ({:.1} MiB)", bz.display(), tam as f64 / 1048576.0);
+
+    if !esp.is_dir() {
+        bail!(
+            "la ESP no está montada en {}: montala primero\n  mount -t vfat <partición-ESP> {}",
+            esp.display(),
+            esp.display()
+        );
+    }
+    if !efi_boot::hay_firmware_efi(efivars) {
+        bail!(
+            "sin NVRAM en {}: sin firmware UEFI no hay BootNext, y sin BootNext no hay A/B",
+            efivars.display()
+        );
+    }
+
+    // Que entre ANTES de copiar: una ESP llena a media copia deja un candidato truncado, y un
+    // candidato truncado no falla — arranca y muere en el firmware.
+    let destino = kernel_ab::en_esp(esp, kernel_ab::RUTA_CANDIDATO);
+    println!("  ranura:   {} (propia; el estable no se toca)", destino.display());
+
+    if dry_run {
+        println!("--dry-run: no se copió nada ni se tocó la NVRAM");
+        return Ok(());
+    }
+
+    let n = kernel_ab::instalar_candidato(&bz, esp, kernel_ab::RUTA_CANDIDATO)?;
+    println!("  ✓ copiado ({n} bytes) de forma atómica (tmp + rename)");
+
+    // Entrada NVRAM propia del candidato, apuntando a SU ranura.
+    let (disco, parte_n) = {
+        let discos = efi_boot::block_disks(Path::new("/sys/block")).unwrap_or_default();
+        let esps = efi_boot::find_esps(&discos);
+        match esps.len() {
+            1 => esps[0].clone(),
+            0 => bail!("no encuentro ninguna ESP para apuntar la entrada de arranque"),
+            n => bail!("hay {n} ESP: no voy a adivinar cuál es la de este arranque"),
+        }
+    };
+    let parte = efi_boot::read_partition(&disco, parte_n)?;
+    let r = efi_boot::reconcile(
+        efivars,
+        &parte,
+        kernel_ab::RUTA_CANDIDATO,
+        kernel_ab::ETIQUETA_CANDIDATO,
+        false,
+    )?;
+    println!("  ✓ entrada Boot{:04X} «{}»", r.slot, kernel_ab::ETIQUETA_CANDIDATO);
+
+    // OJO: `reconcile` deja al candidato PRIMERO en BootOrder, y eso no es lo que queremos — el
+    // candidato tiene que arrancar UNA vez, no volverse el default. Se repone el orden y se usa
+    // BootNext, que es de un solo uso.
+    if r.orden_cambiado {
+        let ruta_orden = efivars.join(format!("BootOrder-{}", efi_boot::GLOBAL_GUID));
+        efi_boot::write_var(
+            &ruta_orden,
+            efi_boot::ATTRS,
+            &efi_boot::serialize_boot_order(&r.orden_antes),
+        )?;
+        println!("  ✓ BootOrder repuesto: el candidato NO se vuelve el default");
+    }
+    kernel_ab::poner_boot_next(efivars, r.slot)?;
+    println!("  ✓ BootNext = Boot{:04X} (un solo uso)", r.slot);
+
+    let mut e = kernel_ab::leer_estado(state);
+    e.candidato = Some(kernel_ab::Candidato {
+        hash: nombre.split_once('-').map(|(h, _)| format!("b3:{h}")).unwrap_or(nombre.clone()),
+        ruta: kernel_ab::RUTA_CANDIDATO.into(),
+        slot: r.slot,
+        intentos: tries,
+    });
+    kernel_ab::escribir_estado(state, &e)?;
+    println!("\nEl PRÓXIMO arranque usa el candidato. Si no arranca, el firmware vuelve solo al");
+    println!("estable — BootNext es de un solo uso. Si arranca y anda: `takana kernel confirm`.");
+    Ok(())
+}
+
+fn ab_status(efivars: &Path, state: &Path) -> Result<()> {
+    let e = kernel_ab::leer_estado(state);
+    let actual = kernel_ab::boot_current(efivars);
+    match actual {
+        Some(a) => println!("arrancamos por Boot{a:04X}"),
+        None => println!("el firmware no publica BootCurrent (o no hay NVRAM)"),
+    }
+    if let Some(n) = kernel_ab::boot_next(efivars) {
+        println!("BootNext pendiente: Boot{n:04X} (el próximo arranque irá por ahí)");
+    }
+    match kernel_ab::evaluar(&e, actual) {
+        Veredicto::SinCandidato => {
+            println!("sin candidato: el kernel que corre es el estable");
+            // El firmware es la fuente de verdad, no nuestro fichero. Si el estado se perdió —el
+            // caso real es que viviera en algo que no persiste— pero la NVRAM todavía tiene la
+            // entrada del candidato, hay basura que nadie va a limpiar porque nadie sabe que está.
+            // Medido: con el estado en un tmpfs, `stage` + reboot dejaba exactamente esto.
+            let huerfanas: Vec<u16> = efi_boot::list_entries(efivars)
+                .unwrap_or_default()
+                .into_iter()
+                .filter(|(_, lo)| lo.description == kernel_ab::ETIQUETA_CANDIDATO)
+                .map(|(n, _)| n)
+                .collect();
+            if !huerfanas.is_empty() {
+                println!("⚠ pero la NVRAM tiene {} entrada(s) «{}» sin estado que las respalde:",
+                    huerfanas.len(), kernel_ab::ETIQUETA_CANDIDATO);
+                for n in &huerfanas {
+                    println!("    Boot{n:04X}");
+                }
+                println!("  El fichero de estado se perdió o nunca persistió. `kernel rollback` no");
+                println!("  puede limpiarlas sin saber cuál era: borralas a mano o volvé a hacer `stage`.");
+            }
+        }
+        Veredicto::EnPrueba { slot, intentos_restantes } => {
+            println!("⏳ EN PRUEBA: Boot{slot:04X} es el candidato y es el que está corriendo");
+            println!("   le quedan {intentos_restantes} arranque(s) sin confirmar");
+            println!("   → `takana kernel confirm` si anda, `takana kernel rollback` si no");
+        }
+        Veredicto::CandidatoFallo { slot } => {
+            println!("✗ el candidato Boot{slot:04X} NO arrancó: esta sesión vino del estable");
+            println!("   → `takana kernel rollback` lo descarta (o `stage` otra vez para reintentar)");
+        }
+        Veredicto::CandidatoAgotado { slot } => {
+            println!("✗ el candidato Boot{slot:04X} agotó sus intentos sin que nadie lo confirmara");
+            println!("   → `takana kernel rollback`");
+        }
+    }
+    Ok(())
+}
+
+fn ab_confirm(esp: &Path, efivars: &Path, state: &Path) -> Result<()> {
+    let mut e = kernel_ab::leer_estado(state);
+    let Some(c) = e.candidato.clone() else {
+        println!("no hay candidato que confirmar");
+        return Ok(());
+    };
+    let actual = kernel_ab::boot_current(efivars);
+    // Confirmar un kernel que NO es el que está corriendo sería promover algo que nadie probó, que
+    // es exactamente lo contrario de para qué existe esto.
+    if let Some(a) = actual {
+        if a != c.slot {
+            bail!(
+                "esta sesión arrancó por Boot{a:04X} y el candidato es Boot{:04X}: no se confirma \
+                 un kernel que no es el que está corriendo",
+                c.slot
+            );
+        }
+    }
+    let cand = kernel_ab::en_esp(esp, &c.ruta);
+    let estable = kernel_ab::en_esp(esp, kernel_ab::RUTA_ESTABLE);
+    let dir = estable.parent().context("ruta estable sin directorio")?;
+    let tmp = dir.join("takana-prom.tmp");
+    std::fs::copy(&cand, &tmp)
+        .with_context(|| format!("copiando {} → {}", cand.display(), tmp.display()))?;
+    std::fs::rename(&tmp, &estable)?;
+    println!("✓ promovido: el candidato es ahora el kernel estable ({})", estable.display());
+
+    // La ruta fallback también, que es la otra mitad del seguro del ADR 0018 §3.
+    let fallback = kernel_ab::en_esp(esp, "/EFI/BOOT/BOOTX64.EFI");
+    if fallback.exists() {
+        let t2 = dir.join("takana-fb.tmp");
+        if std::fs::copy(&cand, &t2).is_ok() && std::fs::rename(&t2, &fallback).is_ok() {
+            println!("✓ y la ruta fallback, que apunta al mismo kernel (ADR 0018 §3)");
+        }
+    }
+    let _ = std::fs::remove_file(&cand);
+    kernel_ab::borrar_boot_next(efivars);
+    e.estable_hash = Some(c.hash.clone());
+    e.candidato = None;
+    kernel_ab::escribir_estado(state, &e)?;
+    println!("  estable: {}", c.hash);
+    Ok(())
+}
+
+fn ab_rollback(esp: &Path, efivars: &Path, state: &Path) -> Result<()> {
+    let mut e = kernel_ab::leer_estado(state);
+    let Some(c) = e.candidato.clone() else {
+        println!("no hay candidato que descartar");
+        return Ok(());
+    };
+    kernel_ab::borrar_boot_next(efivars);
+    let cand = kernel_ab::en_esp(esp, &c.ruta);
+    if cand.exists() {
+        let _ = std::fs::remove_file(&cand);
+        println!("✓ borrada la ranura del candidato ({})", cand.display());
+    }
+    // La entrada NVRAM del candidato se va también: dejarla sería ofrecer en el menú del firmware
+    // un arranque que apunta a un fichero que ya no está.
+    let var = efi_boot::boot_var_path(efivars, c.slot);
+    if var.exists() {
+        let _ = std::fs::remove_file(&var);
+        println!("✓ borrada la entrada Boot{:04X}", c.slot);
+    }
+    e.candidato = None;
+    kernel_ab::escribir_estado(state, &e)?;
+    println!("el kernel estable sigue siendo el que estaba. No se tocó.");
+    Ok(())
+}
diff --git a/crates/takana-cli/src/main.rs b/crates/takana-cli/src/main.rs
index 00f8ed04..ea27e1e3 100644
--- a/crates/takana-cli/src/main.rs
+++ b/crates/takana-cli/src/main.rs
@@ -11,6 +11,7 @@ use clap::{Parser, Subcommand};
 mod alpine_import;
 mod efi_boot;
 mod fat_ro;
+mod kernel_ab;
 mod kernel_cmd;
 mod qorpa;
 mod qorpa_wl;