> 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: 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>
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"]