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

kernel: el gate de no-regresión, por objetivo — el mismo plan pasa en QEMU y bloquea en metal

Paso 3 del §8 del SDD 22, y cierra el orden que fijaba: reversa, clausura, gate, diff-back.

La pieza que faltaba no era el gate sino el MAPA driver → símbolo. El kernel sabe qué
driver tiene bindeado cada dispositivo, pero no de qué CONFIG_* salió: esa relación sólo
existe en los Makefiles de kbuild. modmap.rs lee 15.789 reglas obj-$(CONFIG_X) += y.o en
3182 Makefiles. Dos trampas de nombres, cada una con su test:
  · el módulo cargado usa _ donde el fichero usa - (snd-hda-intel.o → snd_hda_intel)
  · un módulo puede salir de VARIOS símbolos, y sobrevive si sobrevive cualquiera
Sin resolverlas el gate no encontraría nada y diría que todo está bien, que es el peor
resultado posible para un portón.

POR OBJETIVO, no global. La regla es "todo dispositivo en uso debe seguir teniendo
driver"; aplicada global rechazaría recipes/linux.toml, que apaga USB, HID e INPUT A
PROPÓSITO por ser el kernel de QEMU con consola serie. El gate NO CORRE sin --objective, y
un allow_bundles con un id mal escrito es error de CARGA del catálogo (si no, autorizaría
nada y bloquearía sin que se entienda por qué).

Medido con el mismo plan (sin-usb + sin-entrada-humana + sin-graficos + sin-wifi) y el
hardware real de gioser:
  qemu-serial ........ PASA    — 5 pérdidas autorizadas
  metal-escritorio ... BLOQUEA — las mismas 5 como regresiones, con el bundle culpable
Ése es todo el punto del §5.

Y lo que el gate no puede comprobar, lo dice: de los 38 drivers bindeados, 15 no se
mapearon a ningún símbolo (pcieport, serial8250 — built-ins cuyo nombre de driver no
coincide con el del módulo). Quedan listados como SIN COMPROBAR, nunca como aprobados.

hammer kernel hw vuelca la huella y los drivers de la máquina DESTINO, que no tiene por
qué ser la de build — el SDD lo pedía explícitamente.

Cuatro objetivos en el catálogo (qemu-serial, servidor, metal-escritorio, portatil) y un
runbook nuevo: docs/runbooks/armador-de-kernel.md, con el ataque de punta a punta y una
lista honesta de lo que todavía NO está (sonda en VM, atestación por huella, bisección,
curación del delta con modelo, perillas side=recipe).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

fe382b0d268618542d7146430284ae24df8186f3 · por Sergio · (hace 2 meses) · padre 02991dd9d · ver el árbol en este commit

Delta semántico · 38

crates/hammer-cli/src/kernel_cmd.rs

  • cambió de firma enum KernelCmd pub
  • nueva fn gate
  • nueva fn hw
  • cambió por dentro fn run pub

crates/hammer-core/src/kernel/catalog.rs

crates/hammer-core/src/kernel/gate.rs

crates/hammer-core/src/kernel/mod.rs

8 archivos · +932 −1

diff --git a/crates/hammer-cli/src/kernel_cmd.rs b/crates/hammer-cli/src/kernel_cmd.rs
index 9b2651b8..eb9ad812 100644
--- a/crates/hammer-cli/src/kernel_cmd.rs
+++ b/crates/hammer-cli/src/kernel_cmd.rs
@@ -126,6 +126,36 @@ pub enum KernelCmd {
         #[arg(long)]
         json: bool,
     },
+    /// **Gate de no-regresión, por objetivo**: ¿el plan deja sin driver algún dispositivo en uso?
+    ///
+    /// No corre sin `--objective`: la regla aplicada global rechazaría recetas sanas (el kernel de
+    /// QEMU apaga USB, HID e INPUT a propósito). Sale distinto de cero si bloquea.
+    Gate {
+        /// El JSON que emitió `hammer kernel plan --out`.
+        #[arg(long)]
+        plan: PathBuf,
+        /// Id de objetivo del catálogo (`qemu-serial`, `servidor`, `metal-escritorio`, `portatil`).
+        #[arg(long)]
+        objective: String,
+        /// Hardware de la máquina DESTINO, capturado con `hammer kernel hw --out`. Sin esto se
+        /// mira el de esta máquina, que no siempre es la misma.
+        #[arg(long)]
+        devices: Option<PathBuf>,
+        #[arg(long)]
+        kconfig: Option<PathBuf>,
+        #[arg(long)]
+        catalog: Option<PathBuf>,
+        #[arg(long)]
+        json: bool,
+    },
+    /// Vuelca el hardware de esta máquina (huella + dispositivos + drivers bindeados).
+    ///
+    /// Se corre en la máquina DESTINO y el JSON viaja a la de build: el gate necesita saber qué
+    /// funciona allá, no acá.
+    Hw {
+        #[arg(long)]
+        out: Option<PathBuf>,
+    },
     /// Estadísticas del árbol de Kconfig parseado (control de salud del lector).
     Stats {
         #[arg(long)]
@@ -179,6 +209,22 @@ pub fn run(cmd: KernelCmd, store: &str) -> Result<()> {
             json,
         ),
         KernelCmd::DiffBack { plan, config, json } => diff_back(&plan, &config, json),
+        KernelCmd::Gate {
+            plan,
+            objective,
+            devices,
+            kconfig,
+            catalog,
+            json,
+        } => gate(
+            &plan,
+            &objective,
+            devices.as_deref(),
+            &kconfig_root(kconfig)?,
+            &catalog_path(catalog)?,
+            json,
+        ),
+        KernelCmd::Hw { out } => hw(out.as_deref()),
         KernelCmd::Closure {
             symbols,
             kconfig,
@@ -997,6 +1043,124 @@ fn diff_back(plan_path: &Path, config: &Path, json: bool) -> Result<()> {
     Ok(())
 }
 
+// ---------------------------------------------------------------------------------------------
+// gate / hw
+// ---------------------------------------------------------------------------------------------
+
+fn hw(out: Option<&Path>) -> Result<()> {
+    let h = hammer_core::kernel::Hardware::probe_local();
+    let texto = serde_json::to_string_pretty(&h)?;
+    match out {
+        Some(o) => {
+            if let Some(d) = o.parent() {
+                std::fs::create_dir_all(d).ok();
+            }
+            std::fs::write(o, &texto).with_context(|| format!("escribiendo {}", o.display()))?;
+            println!("huella   {}", h.fingerprint());
+            println!("hardware → {}", o.display());
+            println!("  {} PCI · {} USB · {} drivers bindeados", h.pci.len(), h.usb.len(), h.bound_drivers.len());
+            for g in &h.gaps {
+                println!("  ⚠ {g}");
+            }
+        }
+        None => println!("{texto}"),
+    }
+    Ok(())
+}
+
+fn gate(
+    plan_path: &Path,
+    objective: &str,
+    devices: Option<&Path>,
+    kroot: &Path,
+    catalog: &Path,
+    json: bool,
+) -> Result<()> {
+    let p: hammer_core::kernel::Plan = serde_json::from_str(
+        &std::fs::read_to_string(plan_path)
+            .with_context(|| format!("leyendo {}", plan_path.display()))?,
+    )
+    .with_context(|| format!("{} no es un plan de `hammer kernel plan`", plan_path.display()))?;
+    let cat = hammer_core::kernel::Catalog::load(catalog)?;
+    let tree = load_tree(kroot)?;
+    let modmap = hammer_core::kernel::ModMap::parse(kroot)
+        .with_context(|| format!("leyendo los Makefiles de {}", kroot.display()))?;
+    if modmap.is_empty() {
+        anyhow::bail!(
+            "no encontré una sola regla `obj-$(CONFIG_…)` en {}: sin los Makefiles del kernel el \
+             gate no puede mapear driver → símbolo, y un gate que no puede mirar no es un gate.",
+            kroot.display()
+        );
+    }
+
+    let hardware = match devices {
+        Some(d) => serde_json::from_str::<hammer_core::kernel::Hardware>(
+            &std::fs::read_to_string(d).with_context(|| format!("leyendo {}", d.display()))?,
+        )
+        .with_context(|| format!("{} no es un volcado de `hammer kernel hw`", d.display()))?,
+        None => hammer_core::kernel::Hardware::probe_local(),
+    };
+
+    let r = hammer_core::kernel::gate::run(
+        &p,
+        &cat,
+        &tree,
+        &modmap,
+        &hardware.bound_drivers,
+        objective,
+    )?;
+
+    if json {
+        println!("{}", serde_json::to_string_pretty(&r)?);
+    } else {
+        let obj = cat.objective(objective).map(|o| o.title.as_str()).unwrap_or("");
+        println!("plan        {}", r.plan);
+        println!("objetivo    {} — {obj}", r.objective);
+        println!(
+            "hardware    {} ({} drivers bindeados, {} reglas obj- en {} Makefiles)",
+            devices.map(|d| d.display().to_string()).unwrap_or_else(|| "esta máquina".into()),
+            r.drivers_in_use,
+            modmap.len(),
+            modmap.files
+        );
+        println!();
+        if r.regressions.is_empty() {
+            println!("✓ ningún dispositivo en uso se queda sin driver");
+        } else {
+            println!("✗ {} REGRESIÓN(ES) — dispositivos en uso que se quedarían sin driver:", r.regressions.len());
+            for l in &r.regressions {
+                println!(
+                    "  {:<20} lo apaga {}  (símbolos: {})",
+                    l.driver,
+                    if l.blamed_on.is_empty() { "una perilla".to_string() } else { l.blamed_on.join(", ") },
+                    l.symbols.join(", ")
+                );
+            }
+        }
+        if !r.allowed.is_empty() {
+            println!();
+            println!("{} pérdida(s) AUTORIZADAS por el objetivo:", r.allowed.len());
+            for l in &r.allowed {
+                println!("  {:<20} ← {}", l.driver, l.blamed_on.join(", "));
+            }
+        }
+        if !r.unmapped.is_empty() {
+            println!();
+            println!(
+                "⚠ {} driver(s) SIN COMPROBAR (no se pudieron mapear a ningún CONFIG_*):",
+                r.unmapped.len()
+            );
+            println!("  {}", r.unmapped.join(" "));
+            println!("  no cuentan como aprobados: el gate no los miró.");
+        }
+    }
+
+    if r.verdict == hammer_core::kernel::Verdict::Bloquea {
+        anyhow::bail!("el gate bloquea");
+    }
+    Ok(())
+}
+
 /// Lee un `.config`, descomprimiendo si es `.gz` (el caso de `/proc/config.gz`).
 ///
 /// Se delega a `gzip -dc` en vez de arrastrar un descompresor al core: es una entrada de
diff --git a/crates/hammer-core/src/kernel/catalog.rs b/crates/hammer-core/src/kernel/catalog.rs
index 524e390b..8f127749 100644
--- a/crates/hammer-core/src/kernel/catalog.rs
+++ b/crates/hammer-core/src/kernel/catalog.rs
@@ -36,6 +36,24 @@ pub struct Catalog {
     pub bundles: Vec<Bundle>,
     #[serde(default, rename = "knob")]
     pub knobs: Vec<Knob>,
+    #[serde(default, rename = "objective")]
+    pub objectives: Vec<Objective>,
+}
+
+/// Para qué es este kernel. **El gate de no-regresión no corre sin uno.**
+///
+/// `recipes/linux.toml` apaga USB, HID e INPUT a propósito — es el kernel de QEMU con consola
+/// serie. Un gate global «todo dispositivo en uso debe seguir teniendo driver» rechazaría esa
+/// receta, que está sana. El objetivo declara qué pérdidas son legítimas para este uso.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Objective {
+    pub id: String,
+    pub title: String,
+    #[serde(default)]
+    pub help: Option<String>,
+    /// Bundles cuyas pérdidas de driver este objetivo autoriza. Todo lo demás bloquea.
+    #[serde(default)]
+    pub allow_bundles: Vec<String>,
 }
 
 /// Un bundle de nivel 1: una aserción del dueño de la máquina («no necesito wifi»).
@@ -170,6 +188,23 @@ impl Catalog {
                 )));
             }
         }
+        let conocidos: std::collections::BTreeSet<&str> =
+            self.bundles.iter().map(|b| b.id.as_str()).collect();
+        for o in &self.objectives {
+            if !ids.insert(("objective", o.id.as_str())) {
+                return Err(crate::Error::Recipe(format!("{p}: objetivo duplicado «{}»", o.id)));
+            }
+            // Un `allow_bundles` con un id mal escrito autorizaría NADA y el gate bloquearía sin
+            // que se entienda por qué. Se rechaza al cargar, no en el portón.
+            for b in &o.allow_bundles {
+                if !conocidos.contains(b.as_str()) {
+                    return Err(crate::Error::Recipe(format!(
+                        "{p}: el objetivo «{}» autoriza el bundle «{b}», que no existe",
+                        o.id
+                    )));
+                }
+            }
+        }
         Ok(())
     }
 
@@ -180,6 +215,10 @@ impl Catalog {
     pub fn knob(&self, id: &str) -> Option<&Knob> {
         self.knobs.iter().find(|k| k.id == id)
     }
+
+    pub fn objective(&self, id: &str) -> Option<&Objective> {
+        self.objectives.iter().find(|o| o.id == id)
+    }
 }
 
 #[cfg(test)]
diff --git a/crates/hammer-core/src/kernel/gate.rs b/crates/hammer-core/src/kernel/gate.rs
new file mode 100644
index 00000000..b85051e4
--- /dev/null
+++ b/crates/hammer-core/src/kernel/gate.rs
@@ -0,0 +1,297 @@
+//! **Gate de no-regresión de hardware, por objetivo** (#6 del handoff, §5 del SDD 22).
+//!
+//! La regla, en una línea: *todo dispositivo en uso ahora mismo debe seguir teniendo driver en el
+//! config nuevo*. No es un consejo, es un portón. Corre en un segundo, sin IA y sin VM, y cubre la
+//! mayoría de los desastres de «me quedé sin red / sin teclado».
+//!
+//! ## Por qué POR OBJETIVO y no global
+//! `recipes/linux.toml` apaga `USB_SUPPORT`, `HID`, `INPUT_MOUSE` **a propósito**: es el kernel de
+//! QEMU con consola serie, donde nadie va a tocar la máquina. Un gate global «todo dispositivo en
+//! uso debe seguir teniendo driver» **rechazaría una receta sana**. El objetivo declara qué
+//! pérdidas son legítimas; el portón se queda, sólo que en el sitio correcto.
+//!
+//! ## Y la máquina destino no es la de build
+//! Los drivers en uso se pasan como dato ([`Hardware::bound_drivers`]), así que pueden venir de la
+//! máquina donde el kernel va a correr y no de la que lo compila.
+//!
+//! ## Lo que el gate NO puede comprobar, lo dice
+//! Un driver que no se pueda mapear a ningún símbolo queda en [`GateReport::unmapped`] y **no**
+//! cuenta como aprobado. Un portón que calla lo que no pudo mirar no es un portón.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use serde::{Deserialize, Serialize};
+
+use super::catalog::Catalog;
+use super::kconfig::KconfigTree;
+use super::modmap::ModMap;
+use super::plan::{Action, Plan};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum Verdict {
+    Pasa,
+    Bloquea,
+}
+
+/// Un driver en uso que el plan dejaría sin símbolo.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DriverLoss {
+    pub driver: String,
+    /// Los símbolos que lo construyen. Se pierde sólo si caen TODOS.
+    pub symbols: Vec<String>,
+    /// Qué bundle(s) del plan se lo llevan por delante.
+    pub blamed_on: Vec<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct GateReport {
+    pub objective: String,
+    pub plan: String,
+    /// Cuántos drivers en uso se miraron.
+    pub drivers_in_use: usize,
+    /// Pérdidas que el objetivo **no** autoriza. Éstas bloquean.
+    pub regressions: Vec<DriverLoss>,
+    /// Pérdidas autorizadas por el objetivo: se informan y se dejan pasar.
+    pub allowed: Vec<DriverLoss>,
+    /// Drivers que no se pudieron mapear a ningún `CONFIG_*`: el gate NO los comprobó.
+    pub unmapped: Vec<String>,
+    pub verdict: Verdict,
+}
+
+/// Corre el gate.
+///
+/// `drivers_in_use` son los nombres tal como salen de `/sys` (con `_`).
+pub fn run(
+    plan: &Plan,
+    cat: &Catalog,
+    tree: &KconfigTree,
+    modmap: &ModMap,
+    drivers_in_use: &BTreeSet<String>,
+    objective_id: &str,
+) -> crate::Result<GateReport> {
+    let obj = cat.objective(objective_id).ok_or_else(|| {
+        crate::Error::Recipe(format!(
+            "no existe el objetivo «{objective_id}» en el catálogo. \
+             El gate NO corre sin objetivo declarado: uno global rechazaría recetas sanas."
+        ))
+    })?;
+
+    // Lo que el plan apaga, con su clausura. Se recalcula desde las raíces del plan: es la misma
+    // cuenta que hará `olddefconfig`, no una copia que pueda desincronizarse.
+    let roots: Vec<String> = plan
+        .symbols
+        .iter()
+        .filter(|s| s.action == Action::Disable)
+        .map(|s| s.symbol.clone())
+        .collect();
+    let apagados = tree.closure_off(&roots);
+
+    // Atribución: qué bundle se lleva cada símbolo. Un símbolo puede caer por varios.
+    let mut culpa: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
+    for id in &plan.bundles {
+        let Some(b) = cat.bundle(id) else { continue };
+        let mut r = b.disable.clone();
+        r.extend(b.close_leaks.iter().cloned());
+        for s in tree.closure_off(&r) {
+            if let Some(k) = apagados.get(&s) {
+                culpa.entry(k.as_str()).or_default().push(id.as_str());
+            }
+        }
+    }
+
+    let permitidos: BTreeSet<&str> = obj.allow_bundles.iter().map(|s| s.as_str()).collect();
+    let mut rep = GateReport {
+        objective: objective_id.to_string(),
+        plan: plan.derived_name.clone(),
+        drivers_in_use: drivers_in_use.len(),
+        regressions: Vec::new(),
+        allowed: Vec::new(),
+        unmapped: Vec::new(),
+        verdict: Verdict::Pasa,
+    };
+
+    for drv in drivers_in_use {
+        let Some(syms) = modmap.symbols_for(drv) else {
+            rep.unmapped.push(drv.clone());
+            continue;
+        };
+        // Sobrevive si le queda AL MENOS UN símbolo en pie: un módulo puede construirse desde
+        // varias opciones y basta una.
+        if syms.iter().any(|s| !apagados.contains(s)) {
+            continue;
+        }
+        let mut culpables: Vec<String> = syms
+            .iter()
+            .filter_map(|s| culpa.get(s.as_str()))
+            .flatten()
+            .map(|s| s.to_string())
+            .collect();
+        culpables.sort();
+        culpables.dedup();
+
+        let loss = DriverLoss {
+            driver: drv.clone(),
+            symbols: syms.iter().cloned().collect(),
+            blamed_on: culpables.clone(),
+        };
+        // Autorizado sólo si TODO lo que se lo lleva está permitido por el objetivo. Si algún
+        // culpable no lo está —o si no se pudo atribuir a ninguno— es regresión.
+        let autorizado = !culpables.is_empty()
+            && culpables.iter().all(|c| permitidos.contains(c.as_str()));
+        if autorizado {
+            rep.allowed.push(loss);
+        } else {
+            rep.regressions.push(loss);
+        }
+    }
+
+    if !rep.regressions.is_empty() {
+        rep.verdict = Verdict::Bloquea;
+    }
+    Ok(rep)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::kernel::plan::make_plan;
+    use crate::recipe::Recipe;
+
+    fn escenario() -> (tempfile::TempDir, KconfigTree, Catalog, Recipe, ModMap) {
+        let d = tempfile::tempdir().unwrap();
+        std::fs::write(
+            d.path().join("Kconfig"),
+            r#"
+config WIRELESS
+	bool "Wireless"
+
+config IWLWIFI
+	tristate "iwlwifi"
+	depends on WIRELESS
+
+config USB_SUPPORT
+	bool "USB"
+
+config USB_HID
+	tristate "usbhid"
+	depends on USB_SUPPORT
+
+config E1000
+	tristate "e1000"
+"#,
+        )
+        .unwrap();
+        let tree = KconfigTree::parse(d.path(), &std::collections::BTreeMap::new()).unwrap();
+
+        let mut modmap = ModMap::default();
+        std::fs::write(
+            d.path().join("Makefile"),
+            "obj-$(CONFIG_IWLWIFI) += iwlwifi.o\n\
+             obj-$(CONFIG_USB_HID) += usbhid.o\n\
+             obj-$(CONFIG_E1000) += e1000.o\n",
+        )
+        .unwrap();
+        modmap = ModMap::parse(d.path()).unwrap_or(modmap);
+
+        let cat: Catalog = toml::from_str(
+            r#"
+version = 1
+[[bundle]]
+id = "sin-wifi"
+title = "Sin wifi"
+disable = ["WIRELESS"]
+
+[[bundle]]
+id = "sin-usb"
+title = "Sin USB"
+disable = ["USB_SUPPORT"]
+
+[[objective]]
+id = "qemu-serial"
+title = "VM con consola serie"
+allow_bundles = ["sin-usb"]
+
+[[objective]]
+id = "metal"
+title = "Metal"
+allow_bundles = []
+"#,
+        )
+        .unwrap();
+
+        let recipe: Recipe = toml::from_str(
+            r#"
+name = "linux"
+version = "6.16.12"
+[source]
+tarball = "https://x/l.tar.gz"
+sha256 = "aa"
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "dynamic"
+[build.phases]
+configure = "make ARCH=x86_64 defconfig && make ARCH=x86_64 olddefconfig"
+"#,
+        )
+        .unwrap();
+        (d, tree, cat, recipe, modmap)
+    }
+
+    fn drivers(v: &[&str]) -> BTreeSet<String> {
+        v.iter().map(|s| s.to_string()).collect()
+    }
+
+    #[test]
+    fn perder_un_driver_en_uso_bloquea() {
+        let (_d, tree, cat, base, mm) = escenario();
+        let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
+        let r = run(&p, &cat, &tree, &mm, &drivers(&["iwlwifi", "e1000"]), "metal").unwrap();
+        assert_eq!(r.verdict, Verdict::Bloquea);
+        assert_eq!(r.regressions.len(), 1);
+        assert_eq!(r.regressions[0].driver, "iwlwifi");
+        assert_eq!(r.regressions[0].blamed_on, vec!["sin-wifi"]);
+    }
+
+    #[test]
+    fn el_objetivo_autoriza_la_perdida_que_declara() {
+        let (_d, tree, cat, base, mm) = escenario();
+        let p = make_plan(&base, "r", &cat, &tree, &["sin-usb".into()], &[]).unwrap();
+        // El MISMO plan: bloquea en metal y pasa en qemu-serial. Ése es todo el punto del §5.
+        let metal = run(&p, &cat, &tree, &mm, &drivers(&["usbhid"]), "metal").unwrap();
+        assert_eq!(metal.verdict, Verdict::Bloquea);
+        let qemu = run(&p, &cat, &tree, &mm, &drivers(&["usbhid"]), "qemu-serial").unwrap();
+        assert_eq!(qemu.verdict, Verdict::Pasa);
+        assert_eq!(qemu.allowed.len(), 1);
+    }
+
+    #[test]
+    fn el_driver_que_no_se_pudo_mapear_no_cuenta_como_aprobado() {
+        let (_d, tree, cat, base, mm) = escenario();
+        let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
+        let r = run(&p, &cat, &tree, &mm, &drivers(&["misterio"]), "metal").unwrap();
+        assert_eq!(r.unmapped, vec!["misterio"]);
+        // No está ni en pérdidas ni en aprobados: está declarado como no comprobado.
+        assert!(r.regressions.is_empty() && r.allowed.is_empty());
+    }
+
+    #[test]
+    fn sin_objetivo_declarado_el_gate_no_corre() {
+        let (_d, tree, cat, base, mm) = escenario();
+        let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
+        let e = run(&p, &cat, &tree, &mm, &drivers(&["iwlwifi"]), "inventado")
+            .unwrap_err()
+            .to_string();
+        assert!(e.contains("inventado"), "{e}");
+    }
+
+    #[test]
+    fn un_driver_intacto_no_molesta() {
+        let (_d, tree, cat, base, mm) = escenario();
+        let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
+        let r = run(&p, &cat, &tree, &mm, &drivers(&["e1000"]), "metal").unwrap();
+        assert_eq!(r.verdict, Verdict::Pasa);
+        assert!(r.regressions.is_empty() && r.allowed.is_empty() && r.unmapped.is_empty());
+    }
+}
diff --git a/crates/hammer-core/src/kernel/mod.rs b/crates/hammer-core/src/kernel/mod.rs
index 31bbeae3..02afcb45 100644
--- a/crates/hammer-core/src/kernel/mod.rs
+++ b/crates/hammer-core/src/kernel/mod.rs
@@ -18,12 +18,16 @@
 
 pub mod catalog;
 pub mod config;
+pub mod gate;
 pub mod hw;
 pub mod kconfig;
+pub mod modmap;
 pub mod plan;
 pub mod reverse;
 
-pub use catalog::{Bundle, Catalog, Knob, Side};
+pub use catalog::{Bundle, Catalog, Knob, Objective, Side};
+pub use gate::{GateReport, Verdict};
+pub use modmap::ModMap;
 pub use config::{ConfigValue, KernelConfig};
 pub use hw::Hardware;
 pub use kconfig::{Expr, KconfigTree, SelectLeak, SymKind, Symbol};
diff --git a/crates/hammer-core/src/kernel/modmap.rs b/crates/hammer-core/src/kernel/modmap.rs
new file mode 100644
index 00000000..851c8e88
--- /dev/null
+++ b/crates/hammer-core/src/kernel/modmap.rs
@@ -0,0 +1,199 @@
+//! Mapa **driver → símbolo de Kconfig**, leído de los Makefiles de kbuild.
+//!
+//! Es la pieza que le faltaba al gate de no-regresión (#6 del handoff). El kernel sabe qué driver
+//! tiene bindeado cada dispositivo (`/sys/bus/*/devices/*/driver`), pero **no** dice de qué
+//! `CONFIG_*` salió: esa relación sólo existe en los Makefiles, en la forma
+//!
+//! ```make
+//! obj-$(CONFIG_SND_HDA_INTEL) += snd-hda-intel.o
+//! ```
+//!
+//! ## Dos trampas de nombres
+//! 1. **El módulo cargado usa `_` donde el fichero usa `-`**: `snd-hda-intel.o` se ve en `/sys` como
+//!    `snd_hda_intel`. Se indexa normalizado, o el gate no encontraría nada y diría que todo está
+//!    bien — que es el peor resultado posible para un portón.
+//! 2. **Un módulo puede salir de más de un símbolo** (se declara en varios Makefiles o bajo varias
+//!    condiciones). Se guardan todos: el driver sobrevive si sobrevive **cualquiera** de ellos.
+//!
+//! ## Lo que este mapa NO sabe
+//! `obj-y += foo.o` (built-in incondicional) no lleva símbolo: ese driver está siempre y no hay
+//! nada que proteger. Y hay drivers que ningún `obj-$(CONFIG_…)` nombra — los que el mapa no
+//! cubre se reportan como **hueco**, nunca como «está bien». Un gate que calla lo que no pudo
+//! comprobar no es un gate.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::path::Path;
+
+/// `driver normalizado → símbolos que lo construyen`.
+#[derive(Debug, Clone, Default)]
+pub struct ModMap {
+    pub by_module: BTreeMap<String, BTreeSet<String>>,
+    /// Cuántos Makefiles se leyeron.
+    pub files: usize,
+}
+
+impl ModMap {
+    /// Recorre el árbol buscando `Makefile*` y extrae las reglas `obj-$(CONFIG_X) += y.o`.
+    pub fn parse(root: &Path) -> std::io::Result<ModMap> {
+        let mut m = ModMap::default();
+        walk(root, &mut m)?;
+        Ok(m)
+    }
+
+    /// Símbolos que construyen este driver. Acepta el nombre tal cual sale de `/sys` (con `_`).
+    pub fn symbols_for(&self, driver: &str) -> Option<&BTreeSet<String>> {
+        self.by_module.get(&normalize(driver))
+    }
+
+    pub fn len(&self) -> usize {
+        self.by_module.len()
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.by_module.is_empty()
+    }
+}
+
+fn walk(dir: &Path, m: &mut ModMap) -> std::io::Result<()> {
+    let Ok(entries) = std::fs::read_dir(dir) else {
+        return Ok(());
+    };
+    for e in entries.flatten() {
+        let p = e.path();
+        if p.is_dir() {
+            walk(&p, m)?;
+            continue;
+        }
+        let name = e.file_name();
+        let name = name.to_string_lossy();
+        if !name.starts_with("Makefile") && !name.starts_with("Kbuild") {
+            continue;
+        }
+        if let Ok(text) = std::fs::read_to_string(&p) {
+            m.files += 1;
+            parse_makefile(&text, m);
+        }
+    }
+    Ok(())
+}
+
+fn parse_makefile(text: &str, m: &mut ModMap) {
+    for line in joined_lines(text) {
+        let t = line.trim();
+        let Some(rest) = t.strip_prefix("obj-$(CONFIG_") else {
+            continue;
+        };
+        let Some(close) = rest.find(')') else { continue };
+        let symbol = &rest[..close];
+        if symbol.is_empty() || !symbol.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') {
+            continue;
+        }
+        // `+=`, `:=` o `=`.
+        let after = &rest[close + 1..];
+        let Some(eq) = after.find('=') else { continue };
+        let targets = &after[eq + 1..];
+        for tok in targets.split_whitespace() {
+            // Los directorios (`foo/`) los cubre el Makefile de dentro.
+            let Some(obj) = tok.strip_suffix(".o") else {
+                continue;
+            };
+            // `subdir/mod.o` — el nombre del módulo es el último componente.
+            let obj = obj.rsplit('/').next().unwrap_or(obj);
+            if obj.is_empty() || obj.contains('$') {
+                continue;
+            }
+            m.by_module
+                .entry(normalize(obj))
+                .or_default()
+                .insert(symbol.to_string());
+        }
+    }
+}
+
+/// Une las continuaciones `\` de make.
+fn joined_lines(text: &str) -> Vec<String> {
+    let mut out = Vec::new();
+    let mut acc = String::new();
+    for line in text.lines() {
+        match line.strip_suffix('\\') {
+            Some(head) => {
+                acc.push_str(head);
+                acc.push(' ');
+            }
+            None => {
+                acc.push_str(line);
+                out.push(std::mem::take(&mut acc));
+            }
+        }
+    }
+    if !acc.is_empty() {
+        out.push(acc);
+    }
+    out
+}
+
+/// El nombre con el que el módulo aparece en `/sys`: guiones a guiones bajos.
+fn normalize(s: &str) -> String {
+    s.replace('-', "_")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn extrae_las_tres_formas_de_asignacion() {
+        let mut m = ModMap::default();
+        parse_makefile(
+            "obj-$(CONFIG_SND_HDA) := snd-hda-codec.o\n\
+             obj-$(CONFIG_E1000) += e1000.o\n\
+             obj-$(CONFIG_FOO)=foo.o\n",
+            &mut m,
+        );
+        assert!(m.symbols_for("snd_hda_codec").unwrap().contains("SND_HDA"));
+        assert!(m.symbols_for("e1000").unwrap().contains("E1000"));
+        assert!(m.symbols_for("foo").unwrap().contains("FOO"));
+    }
+
+    #[test]
+    fn el_guion_bajo_de_sys_encuentra_al_guion_del_fichero() {
+        let mut m = ModMap::default();
+        parse_makefile("obj-$(CONFIG_SND_HDA_INTEL) += snd-hda-intel.o\n", &mut m);
+        // Así es como sale de /sys/bus/pci/devices/*/driver.
+        assert!(m.symbols_for("snd_hda_intel").is_some());
+    }
+
+    #[test]
+    fn un_modulo_puede_venir_de_varios_simbolos() {
+        let mut m = ModMap::default();
+        parse_makefile("obj-$(CONFIG_A) += x.o\n", &mut m);
+        parse_makefile("obj-$(CONFIG_B) += x.o\n", &mut m);
+        let s = m.symbols_for("x").unwrap();
+        assert_eq!(s.len(), 2);
+    }
+
+    #[test]
+    fn los_directorios_y_los_obj_y_no_entran() {
+        let mut m = ModMap::default();
+        parse_makefile(
+            "obj-$(CONFIG_NET) += core/\nobj-y += siempre.o\nobj-$(CONFIG_X) += $(VAR).o\n",
+            &mut m,
+        );
+        assert!(m.is_empty(), "{:?}", m.by_module);
+    }
+
+    #[test]
+    fn la_continuacion_de_barra_se_une() {
+        let mut m = ModMap::default();
+        parse_makefile("obj-$(CONFIG_X) += a.o \\\n\tb.o\n", &mut m);
+        assert!(m.symbols_for("a").is_some());
+        assert!(m.symbols_for("b").is_some());
+    }
+
+    #[test]
+    fn el_modulo_en_subdirectorio_se_nombra_por_el_ultimo_componente() {
+        let mut m = ModMap::default();
+        parse_makefile("obj-$(CONFIG_X) += sub/dir/mod.o\n", &mut m);
+        assert!(m.symbols_for("mod").is_some());
+    }
+}
diff --git a/docs/22-configurador-kernel.md b/docs/22-configurador-kernel.md
index 2fdfffa7..0344784d 100644
--- a/docs/22-configurador-kernel.md
+++ b/docs/22-configurador-kernel.md
@@ -341,6 +341,32 @@ handoff. Clasifica cada símbolo pedido en cumplido / **incumplido** (el `.confi
 ausente (el kernel ni lo menciona: la bandera fue un no-op), con la procedencia de quién lo pidió, y
 sale distinto de cero si el config no honra el plan.
 
+## 11. Paso 3 hecho: el gate de no-regresión, por objetivo
+
+La pieza que faltaba no era el gate sino el **mapa driver → símbolo**: el kernel sabe qué driver
+tiene bindeado cada dispositivo, pero no de qué `CONFIG_*` salió. Esa relación sólo existe en los
+Makefiles de kbuild (`obj-$(CONFIG_SND_HDA_INTEL) += snd-hda-intel.o`). Leídas **15 789 reglas en
+3182 Makefiles**.
+
+Dos trampas de nombres que, sin resolver, harían que el gate no encontrara nada y dijera que todo
+está bien — el peor resultado posible para un portón:
+- el módulo cargado usa `_` donde el fichero usa `-` (`snd-hda-intel.o` → `snd_hda_intel`);
+- un módulo puede salir de **varios** símbolos, y sobrevive si sobrevive cualquiera.
+
+**La prueba de que el portón está en el sitio correcto**, con el mismo plan (`sin-usb` +
+`sin-entrada-humana` + `sin-graficos` + `sin-wifi`) y el hardware real de gioser:
+
+| objetivo | veredicto | detalle |
+|---|---|---|
+| `qemu-serial` | ✓ pasa | 5 pérdidas AUTORIZADAS (`hid-generic`, `psmouse`, `usbhid`, `virtio_gpu`, `xhci_hcd`) |
+| `metal-escritorio` | ✗ bloquea | las mismas 5, como REGRESIONES, cada una con el bundle culpable |
+
+Un gate global habría rechazado `recipes/linux.toml`, que está sana.
+
+**Y lo que el gate no puede comprobar, lo dice.** De los 38 drivers bindeados en gioser, 15 no se
+pudieron mapear a ningún símbolo (built-ins de núcleo como `pcieport` o `serial8250`, cuyo nombre de
+driver no coincide con el del módulo). Quedan listados como **sin comprobar**, no como aprobados.
+
 ### Un hallazgo de regalo
 `recipes/linux.toml` apaga **`REISERFS_FS`, que no existe en 6.16.12** (upstream lo retiró). El
 `scripts/config -d REISERFS_FS` es un no-op que nadie vio. Es exactamente el modo de fallo que el
diff --git a/docs/runbooks/armador-de-kernel.md b/docs/runbooks/armador-de-kernel.md
new file mode 100644
index 00000000..2d80e887
--- /dev/null
+++ b/docs/runbooks/armador-de-kernel.md
@@ -0,0 +1,153 @@
+# Runbook — el armador de kernel (`hammer kernel`)
+
+Implementa `docs/22-configurador-kernel.md` (SDD 22), que contesta
+`tawasuyu/HANDOFF-KERNEL-CONFIG-A-HAMMER.md`.
+
+**La regla que no se cruza:** hammer **lee** el grafo de Kconfig, no lo resuelve. El `.config` lo
+sigue produciendo el `olddefconfig` del propio kernel. Lo que la app emite son **fragmentos**
+(`scripts/config -e/-d`) y una **receta derivada**.
+
+**El hecho que ordena todo:** las fases de build entran en `Recipe::hash_inputs` y el config vive en
+la fase `configure` ⇒ **el config ES la identidad del artefacto**. Cambiar un símbolo cambia el
+`ArtifactHash`. Por eso una perilla de UI no es un parámetro de runtime: es una edición de receta.
+
+---
+
+## 0. Preparar el árbol de fuentes
+
+Los comandos necesitan un árbol de fuentes del kernel, del que sólo se leen los `Kconfig*` y los
+`Makefile*`. No hace falta extraer el tarball entero (1,5 G): con estos dos juegos bastan 38 M.
+
+```sh
+curl -sSLo work/tarballs/linux-6.16.12.tar.gz \
+  https://mirrors.edge.kernel.org/pub/linux/kernel/v6.x/linux-6.16.12.tar.gz
+mkdir -p work/kconfig-6.16.12
+tar -xzf work/tarballs/linux-6.16.12.tar.gz -C work/kconfig-6.16.12 --strip-components=1 \
+  --wildcards '*/Kconfig*' '*/Makefile' '*/Makefile.*' 'linux-6.16.12/arch/x86/configs/*'
+export HAMMER_KCONFIG_ROOT=work/kconfig-6.16.12
+```
+
+`work/` está en `.gitignore`. El `sha256` del tarball es el mismo que pinea `recipes/linux.toml`.
+
+Control de salud del lector — **si esto no da 0 avisos, no confíes en lo que sigue**:
+
+```sh
+hammer kernel stats
+# ficheros 1646 · símbolos 18212 (15169 visibles) · select 15165 · imply 445 · avisos 0
+```
+
+## 1. Modo reversa — mirar el kernel que ya corre
+
+Cero build, cero riesgo. Lee `/proc/config.gz` (o `/boot/config-<release>`).
+
+```sh
+hammer kernel probe
+```
+
+Contesta tres cosas: cuánto de cada bundle ya rige, qué capacidad carga este kernel que esta máquina
+no usa, y dónde el hardware **contradice** a un bundle aplicado. La detección va en un solo sentido:
+sirve para contradecir, **nunca** para podar sola — no se puede detectar el dock que se enchufa el
+mes que viene ni el fs del USB de rescate.
+
+Si el config vivo y el catálogo son de series distintas, lo avisa: las clausuras salen aproximadas.
+
+## 2. El catálogo
+
+`docs/state/kernel-bundles.toml`. Un bundle **no es una lista de símbolos**: es una raíz y su
+clausura. `disable = ["WIRELESS"]` son ~400 `CONFIG_*` que hammer calcula.
+
+```sh
+hammer kernel bundles          # el catálogo con las clausuras resueltas
+hammer kernel bundles --check  # falla si envejeció respecto de este árbol
+```
+
+`--check` es el control de frescura y hace dos cosas:
+- **Símbolos idos**: un `-d` sobre un símbolo que ya no existe es un no-op silencioso. Así se
+  descubrió que las cuatro recetas de kernel del repo apagan `THUNDERBOLT` (hoy `USB4`) y
+  `REISERFS_FS` (retirado), o sea que no apagan nada.
+- **Fugas `select` sin declarar**: cada `select` nuevo que entra a un bundle es un símbolo que
+  upstream agregó y nadie revisó. Es la mitad barata de la curación del delta entre versiones, y
+  sale de comparar el grafo con el catálogo — sin IA.
+
+Para decidir qué hacer con una fuga:
+
+```sh
+hammer kernel closure WIRELESS --fixpoint
+```
+
+El punto fijo **reporta el precio, no lo aplica**. Cerrar «sin audio» exige apagar
+`DRM_I915`/`NOUVEAU`/`AMD_DC`, que hacen `select` del códec HDMI: en un escritorio eso es una
+decisión, no una limpieza. Se resuelve una vez, con `close_leaks` o `accept_leaks`.
+
+## 3. Planear
+
+```sh
+hammer kernel plan --recipe recipes/linux.toml \
+  --bundle sin-wifi --bundle sin-audio --bundle solo-ext4 \
+  --knob jaula-y-eio-moderna \
+  --out work/kernel-plans/plan.json \
+  --recipe-out work/kernel-plans/linux-derivada.toml
+```
+
+Emite **raíces**, no clausuras: 16 banderas, no 1775 líneas. Y lleva el `ArtifactHash` de la receta
+derivada dentro del JSON, así que la UI puede decir «esto ya está construido y firmado» **sin
+construir**.
+
+⚠ Para construir la receta derivada hay que dejarla **junto a la receta base**, o sus `deps.build`
+no resuelven. `plan` no escribe en `recipes/` por su cuenta: ese directorio es el corpus compartido.
+
+## 4. El gate de no-regresión — **por objetivo**
+
+La regla es «todo dispositivo en uso debe seguir teniendo driver». Aplicada global rechazaría
+`recipes/linux.toml`, que apaga USB, HID e INPUT **a propósito** por ser el kernel de QEMU con
+consola serie. Por eso el gate **no corre sin `--objective`**.
+
+En la máquina **destino** (que no tiene por qué ser la de build):
+
+```sh
+hammer kernel hw --out work/kernel-plans/hw.json
+```
+
+En la de build:
+
+```sh
+hammer kernel gate --plan work/kernel-plans/plan.json \
+  --objective qemu-serial --devices work/kernel-plans/hw.json
+```
+
+El mismo plan **pasa** en `qemu-serial` y **bloquea** en `metal-escritorio`. Ése es todo el punto.
+
+El gate mapea driver → símbolo leyendo las reglas `obj-$(CONFIG_X) += y.o` de los Makefiles (15 789
+reglas en 3182 ficheros). Lo que no pueda mapear lo lista como **sin comprobar** y **no** lo cuenta
+como aprobado: un portón que calla lo que no miró no es un portón. Los que suelen quedar fuera son
+built-ins de núcleo (`pcieport`, `serial8250`) cuyo nombre de driver no coincide con el del módulo.
+
+## 5. Diff-back — después de construir
+
+Sin esto la UI miente: un `-e FOO` cuya dependencia no se cumple se pierde en silencio.
+
+```sh
+hammer kernel diff-back --plan work/kernel-plans/plan.json \
+  --config <artefacto>/boot/config-6.16.12
+```
+
+Clasifica cada símbolo pedido en cumplido / **incumplido** (el `.config` dice otra cosa) / **ausente**
+(el kernel ni lo menciona: la bandera fue un no-op), con la procedencia de quién lo pidió. Sale
+distinto de cero si el config no honra el plan.
+
+---
+
+## Lo que todavía NO está
+
+- **Sonda de arranque en VM** (#4 del handoff): construir → bootear en QEMU con el perfil de la
+  máquina destino → recién ahí dar de alta en el árbol de generaciones.
+- **Atestación por huella** (#3): `hammer kernel hw` ya da la huella y el plan ya da el
+  `ArtifactHash`; falta el eje `fingerprint` en `PackageEntry` y el quórum de `minga atestar`. Y
+  ojo: «booteó» no basta para atestar — la atestación tiene que llevar **qué se comprobó**, que es
+  el mismo dato que calcula el gate.
+- **Bisección** (#5): un kernel completo tarda 35–60 min ⇒ la bisección ingenua son ~4,5 h. La salida
+  propuesta (SDD 22 §4) es un kernel «superset» con lo bisecable como módulo: 6 **reinicios** en vez
+  de 6 rebuilds.
+- **Curación del delta con modelo** (#2): hoy `--check` marca lo no clasificado; falta que alguien
+  proponga a qué bundle va cada símbolo nuevo.
+- Aplicar las perillas `side = "recipe"`: hoy se **declaran** y no se aplican.
diff --git a/docs/state/kernel-bundles.toml b/docs/state/kernel-bundles.toml
index 191a021c..cf47f8c1 100644
--- a/docs/state/kernel-bundles.toml
+++ b/docs/state/kernel-bundles.toml
@@ -253,3 +253,52 @@ title = "Separar los símbolos de depuración del artefacto"
 side = "recipe"
 recipe_field = "build.strip_debug"
 notes = "SDD 23. Entra en hash_inputs sólo si está fijado, así que se puede activar receta a receta."
+
+# =================================================================================================
+# OBJETIVOS — para qué es este kernel. EL GATE DE NO-REGRESIÓN NO CORRE SIN UNO.
+#
+# La regla del gate es «todo dispositivo en uso debe seguir teniendo driver». Aplicada global,
+# rechazaría recipes/linux.toml, que apaga USB, HID e INPUT A PROPÓSITO por ser el kernel de QEMU
+# con consola serie. El objetivo declara qué pérdidas son legítimas para este uso; todo lo demás
+# bloquea. El portón se queda — sólo que en el sitio correcto.
+# =================================================================================================
+
+[[objective]]
+id = "qemu-serial"
+title = "VM con consola serie"
+help = """
+Nadie va a tocar esta máquina: entra por ttyS0 y sale por ttyS0. Es exactamente lo que hace
+recipes/linux.toml, y es una receta sana."""
+allow_bundles = [
+    "sin-wifi", "sin-bluetooth", "sin-nfc", "sin-radios",
+    "sin-audio", "sin-graficos", "sin-usb", "sin-entrada-humana",
+    "sin-camaras-tv", "sin-infiniband", "sin-bus-industrial",
+    "sin-thunderbolt-firewire", "sin-lector-de-tarjetas", "solo-ext4",
+]
+
+[[objective]]
+id = "servidor"
+title = "Servidor sin pantalla"
+help = """
+Sin monitor ni sonido, pero CON teclado y USB: alguien va a enchufar un rescate el día que la red
+falle. Ésa es justo la clase de pérdida que el gate existe para no dejar pasar en silencio."""
+allow_bundles = [
+    "sin-audio", "sin-graficos", "sin-camaras-tv",
+    "sin-bluetooth", "sin-nfc", "sin-lector-de-tarjetas",
+    "sin-thunderbolt-firewire", "sin-bus-industrial",
+]
+
+[[objective]]
+id = "metal-escritorio"
+title = "Escritorio en metal"
+help = """
+El objetivo más estricto del catálogo, y a propósito: en un escritorio casi nada de lo que hoy
+funciona puede desaparecer sin que el dueño se entere. Sólo se autoriza lo que un escritorio
+demostrablemente no usa."""
+allow_bundles = ["sin-infiniband", "sin-bus-industrial"]
+
+[[objective]]
+id = "portatil"
+title = "Portátil"
+help = "Como el escritorio pero además con radios y lector de tarjetas: en un portátil eso se usa."
+allow_bundles = ["sin-infiniband", "sin-bus-industrial", "sin-nfc"]