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: un contrato de capacidades que hace RUIDOSA la falta de MEMCG

SDD 25 §4 dejó el hallazgo escrito y sin guardián: los kernels de hammer se
construyen sin CONFIG_MEMCG, `memory.max` no existe, y arje descarta el error
al escribirlo ⇒ una Card pide un tope de memoria, corre SIN tope, y la única
huella es un `warn!`. Nadie lo veía porque la máquina de desarrollo SÍ trae
MEMCG: el fallo sólo existe del lado del artefacto sellado.

`hammer kernel contract` declara qué pedazos de interfaz de kernel usa el
userland POR NOMBRE (con consumidor, fichero y CÓMO FALLA HOY si no está) y los
comprueba contra un `.config` YA PRODUCIDO — no contra la receta: entre el
`scripts/config -e X` y el `.config` hay un `olddefconfig` que puede tragarse el
símbolo en silencio.

Por perfil, no global — misma lección que el gate de hardware: `linux.toml` es
el kernel de QEMU del selfhost-verify, no hospeda Cards, y su hash es
load-bearing del baseline `of_tree`. Exigirle contabilidad de memoria sería
rechazar una receta sana.

Medido sobre el store: **2 de 11 configs sellados cumplen su perfil**; los 9
`anfitrion-cards` fallan por MEMCG (apagado A MANO: `# CONFIG_MEMCG is not
set`) y les falta PSI. El kernel vivo de esta máquina pasa las 11 exigidas —
que es exactamente por qué el bug sobrevivió.

Distingue apagado explícito de ausente (un símbolo que el .config ni nombra
puede ser un renombrado entre versiones), y un kernel sin perfil declarado
queda SIN COMPROBAR en vez de contar como aprobado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACUcwo9mZsE5ocYVE9npih

7433bcba8308b39d7c5e2565cf67dcb46d0d3940 · por Sergio · (hace 1 mes) · padre eec5d8db7 · ver el árbol en este commit

Delta semántico · 36

crates/hammer-cli/src/kernel_cmd.rs

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

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

4 archivos · +917 −0

diff --git a/crates/hammer-cli/src/kernel_cmd.rs b/crates/hammer-cli/src/kernel_cmd.rs
index 304f905e..75b8d3c0 100644
--- a/crates/hammer-cli/src/kernel_cmd.rs
+++ b/crates/hammer-cli/src/kernel_cmd.rs
@@ -16,11 +16,17 @@ use serde::Serialize;
 const KCONFIG_ENV: &str = "HAMMER_KCONFIG_ROOT";
 /// Ídem para el catálogo de bundles.
 const CATALOG_ENV: &str = "HAMMER_KERNEL_BUNDLES";
+/// Ídem para el contrato de capacidades (SDD 25 §4).
+const CONTRACT_ENV: &str = "HAMMER_KERNEL_CONTRACT";
 /// Sitios donde buscarlo sin que nadie diga nada: el del repo y el del sistema instalado.
 const CATALOG_DEFAULTS: &[&str] = &[
     "docs/state/kernel-bundles.toml",
     "/usr/share/hammer/kernel-bundles.toml",
 ];
+const CONTRACT_DEFAULTS: &[&str] = &[
+    "docs/state/kernel-contract.toml",
+    "/usr/share/hammer/kernel-contract.toml",
+];
 
 #[derive(Subcommand)]
 pub enum KernelCmd {
@@ -165,6 +171,33 @@ pub enum KernelCmd {
         #[arg(long)]
         out: Option<PathBuf>,
     },
+    /// **Contrato de capacidades**: ¿este kernel trae lo que el userland usa por nombre?
+    ///
+    /// El punto ciego que cierra (SDD 25 §4): sin `CONFIG_MEMCG` el fichero `memory.max` no
+    /// existe, el escritor de arje descarta el error y una Card corre SIN tope de memoria dejando
+    /// sólo un `warn!`. La máquina de desarrollo no lo ve porque su kernel sí lo trae. Se comprueba
+    /// contra un `.config` YA PRODUCIDO, no contra la receta: entre el `scripts/config -e X` y el
+    /// `.config` hay un `olddefconfig` que puede tragarse el símbolo en silencio.
+    ///
+    /// Sale distinto de cero si falta una capacidad EXIGIDA por el perfil.
+    Contract {
+        /// `.config` a comprobar. Repetible. Por defecto, el del kernel vivo.
+        #[arg(long = "config")]
+        configs: Vec<PathBuf>,
+        /// Barre todos los kernels sellados del store (`<hash>-<nombre>/boot/config-*`).
+        #[arg(long)]
+        sealed: bool,
+        /// Perfil del contrato. Obligatorio salvo que el nombre del artefacto ya lo declare.
+        #[arg(long)]
+        profile: Option<String>,
+        #[arg(long)]
+        contract: Option<PathBuf>,
+        /// Lista el contrato (capacidades, perfiles y objetivos) y no comprueba nada.
+        #[arg(long)]
+        list: bool,
+        #[arg(long)]
+        json: bool,
+    },
     /// Estadísticas del árbol de Kconfig parseado (control de salud del lector).
     Stats {
         #[arg(long)]
@@ -238,6 +271,22 @@ pub fn run(cmd: KernelCmd, store: &str) -> Result<()> {
             json,
         ),
         KernelCmd::Hw { out } => hw(out.as_deref()),
+        KernelCmd::Contract {
+            configs,
+            sealed,
+            profile,
+            contract,
+            list,
+            json,
+        } => contract_cmd(
+            &configs,
+            sealed,
+            profile.as_deref(),
+            &contract_path(contract)?,
+            store,
+            list,
+            json,
+        ),
         KernelCmd::Closure {
             symbols,
             kconfig,
@@ -288,6 +337,23 @@ fn catalog_path(flag: Option<PathBuf>) -> Result<PathBuf> {
     )
 }
 
+/// Resuelve el contrato de capacidades: `--contract`, si no la variable de entorno, si no el repo.
+fn contract_path(flag: Option<PathBuf>) -> Result<PathBuf> {
+    if let Some(p) = flag {
+        return Ok(p);
+    }
+    if let Some(v) = std::env::var_os(CONTRACT_ENV) {
+        return Ok(PathBuf::from(v));
+    }
+    for c in CONTRACT_DEFAULTS {
+        let p = PathBuf::from(c);
+        if p.is_file() {
+            return Ok(p);
+        }
+    }
+    anyhow::bail!("no encontré el contrato de kernel: pasá --contract <path> o exportá {CONTRACT_ENV}")
+}
+
 /// Dónde está el `.config` del kernel que corre. `/proc/config.gz` es el camino soberano (lo emite
 /// el propio kernel); el de `/boot` es el respaldo para kernels compilados sin `IKCONFIG_PROC`.
 fn running_config_path() -> Result<PathBuf> {
@@ -1246,6 +1312,215 @@ fn gate(
     Ok(())
 }
 
+// ---------------------------------------------------------------------------------------------
+// contract — el contrato de capacidades (SDD 25 §4)
+// ---------------------------------------------------------------------------------------------
+
+/// Nombre del artefacto si la ruta cae dentro de un directorio `<64 hex>-<nombre>` del store.
+///
+/// Es lo que permite que `--sealed` sepa qué perfil le toca a cada kernel sin que nadie lo escriba
+/// dos veces. Fuera del store devuelve `None` y el perfil hay que pasarlo a mano.
+fn artifact_of(path: &Path) -> Option<String> {
+    for a in path.ancestors() {
+        let name = a.file_name()?.to_string_lossy().to_string();
+        if let Some((hash, art)) = name.split_once('-') {
+            if hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) {
+                return Some(art.to_string());
+            }
+        }
+    }
+    None
+}
+
+#[derive(Serialize)]
+struct ContractRun {
+    contract: String,
+    reports: Vec<hammer_core::kernel::ContractReport>,
+    /// Configs que NO se comprobaron por no tener perfil declarado. No cuentan como aprobados.
+    sin_perfil: Vec<String>,
+}
+
+fn contract_cmd(
+    configs: &[PathBuf],
+    sealed: bool,
+    profile: Option<&str>,
+    contract: &Path,
+    store: &str,
+    list: bool,
+    json: bool,
+) -> Result<()> {
+    use hammer_core::kernel::contract::sealed_kernel_configs;
+    use hammer_core::kernel::Contract;
+
+    let c = Contract::load(contract)?;
+
+    if list {
+        if json {
+            println!("{}", serde_json::to_string_pretty(&c)?);
+            return Ok(());
+        }
+        println!("contrato    {}  (v{}", contract.display(), c.version);
+        if let Some(r) = &c.reviewed_against {
+            println!("revisado    contra kernel {r}");
+        }
+        println!();
+        let req = hammer_core::kernel::contract::required_by(&c);
+        for cap in &c.capabilities {
+            let quien = req.get(cap.id.as_str()).map(|v| v.join(", ")).unwrap_or_default();
+            let quien = if quien.is_empty() { "—".to_string() } else { quien };
+            println!("{:<24} {}", cap.id, cap.title);
+            println!("{:<24} símbolos: {}", "", cap.symbols.join(" "));
+            println!("{:<24} consumidor: {}", "", cap.consumer.trim().replace('\n', " "));
+            println!("{:<24} exigida por: {quien}", "");
+            println!();
+        }
+        for p in &c.profiles {
+            println!("perfil {:<17} {}", p.id, p.title);
+            println!("{:<24} exige: {}", "", p.requires.join(" "));
+            println!("{:<24} quiere: {}", "", p.wants.join(" "));
+        }
+        println!();
+        for t in &c.targets {
+            println!("{:<24} → perfil {}", t.artifact, t.profile);
+        }
+        return Ok(());
+    }
+
+    // Qué configs se miran, en orden: --sealed, --config, y si no hay nada, el kernel vivo.
+    let mut objetivos: Vec<(String, Option<String>, PathBuf)> = Vec::new();
+    if sealed {
+        let s = sealed_kernel_configs(Path::new(store));
+        if s.is_empty() {
+            anyhow::bail!(
+                "no hay ningún kernel sellado con `boot/config-*` en {store}.\n\
+                 Un kernel que no instale su config no se puede comprobar — y fingir que pasó \
+                 sería el mismo fallo que este guardián viene a arreglar."
+            );
+        }
+        for (art, p) in s {
+            objetivos.push((format!("{art} · {}", p.display()), Some(art), p));
+        }
+    }
+    for p in configs {
+        objetivos.push((p.display().to_string(), artifact_of(p), p.clone()));
+    }
+    if objetivos.is_empty() {
+        let p = running_config_path()?;
+        objetivos.push((format!("kernel vivo · {}", p.display()), None, p));
+    }
+
+    let mut run = ContractRun {
+        contract: contract.display().to_string(),
+        reports: Vec::new(),
+        sin_perfil: Vec::new(),
+    };
+
+    for (label, artifact, path) in &objetivos {
+        let cfg = hammer_core::kernel::KernelConfig::parse(&read_maybe_gz(path)?);
+        if cfg.is_empty() {
+            anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", path.display());
+        }
+        let prof = match profile {
+            Some(p) => Some(p.to_string()),
+            None => artifact
+                .as_deref()
+                .and_then(|a| c.profile_for(a))
+                .map(str::to_string),
+        };
+        let Some(prof) = prof else {
+            run.sin_perfil.push(label.clone());
+            continue;
+        };
+        run.reports.push(c.check(&cfg, &prof, label)?);
+    }
+
+    if json {
+        println!("{}", serde_json::to_string_pretty(&run)?);
+    } else {
+        imprimir_contrato(&run);
+    }
+
+    let bloquean = run
+        .reports
+        .iter()
+        .filter(|r| r.verdict == hammer_core::kernel::Verdict::Bloquea)
+        .count();
+    if bloquean > 0 {
+        anyhow::bail!("el contrato NO se cumple en {bloquean} kernel(es)");
+    }
+    Ok(())
+}
+
+fn imprimir_contrato(run: &ContractRun) {
+    println!("contrato    {}", run.contract);
+    println!();
+    for r in &run.reports {
+        let marca = if r.verdict == hammer_core::kernel::Verdict::Bloquea { "✗" } else { "✓" };
+        println!("{marca} {}", r.config);
+        println!("  perfil    {}   ({} capacidad(es) miradas)", r.profile, r.checked);
+        if let Some(k) = &r.kernel {
+            println!("  kernel    {k}");
+        }
+        for m in &r.missing_required {
+            println!("  ✗ FALTA   {} — {}", m.id, m.title);
+            if !m.explicit_off.is_empty() {
+                println!("            apagado a mano: {}", m.explicit_off.join(" "));
+            }
+            if !m.absent.is_empty() {
+                println!(
+                    "            ni nombrado en el .config: {} (¿renombrado entre versiones?)",
+                    m.absent.join(" ")
+                );
+            }
+            println!("            lo usa: {}", m.consumer.trim().replace('\n', " "));
+            if let Some(s) = &m.silent {
+                println!("            cómo falla hoy: {}", s.trim().replace('\n', " "));
+            }
+        }
+        for m in &r.missing_wanted {
+            println!(
+                "  · falta (no bloquea) {} — {} [{}]",
+                m.id,
+                m.title,
+                [m.explicit_off.clone(), m.absent.clone()].concat().join(" ")
+            );
+        }
+        for u in &r.unknown {
+            println!(
+                "  ⚠ el perfil nombra «{}», que el contrato no define: NO se comprobó",
+                u.id
+            );
+        }
+        if r.missing_required.is_empty() {
+            println!("  ✓ las {} capacidades exigidas están", r.ok.iter().filter(|o| o.required).count());
+        }
+        println!();
+    }
+    let malos = run
+        .reports
+        .iter()
+        .filter(|r| r.verdict == hammer_core::kernel::Verdict::Bloquea)
+        .count();
+    println!(
+        "resumen     {} de {} config(s) cumplen su perfil{}",
+        run.reports.len() - malos,
+        run.reports.len(),
+        if run.sin_perfil.is_empty() {
+            String::new()
+        } else {
+            format!(" · {} sin comprobar", run.sin_perfil.len())
+        }
+    );
+    println!();
+    if !run.sin_perfil.is_empty() {
+        println!("⚠ {} config(s) SIN COMPROBAR (ningún perfil declarado):", run.sin_perfil.len());
+        for s in &run.sin_perfil {
+            println!("  {s}");
+        }
+        println!("  no cuentan como aprobados. Declaralos en [[target]] o pasá --profile.");
+    }
+}
+
 /// 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/contract.rs b/crates/hammer-core/src/kernel/contract.rs
new file mode 100644
index 00000000..2fdb3e4d
--- /dev/null
+++ b/crates/hammer-core/src/kernel/contract.rs
@@ -0,0 +1,429 @@
+//! **El contrato de capacidades**: qué símbolos del kernel exige el userland, y quién se rompe
+//! callado si faltan (SDD 25 §4 y §8-W6).
+//!
+//! ## Por qué existe este módulo
+//! SDD 25 encontró, midiendo otra cosa, que los kernels de hammer se construyen **sin
+//! `CONFIG_MEMCG`**: `memory.max` no existe, y el escritor del otro lado (`arje-incarnate::cgroup`)
+//! descarta el error. Una Card que pide un tope de memoria arranca **sin tope** y la única huella
+//! es una línea de log. Es la forma de fallo que `CLAUDE.md` §3 nombra: *un ausente falla
+//! ruidosamente; un vacío llega hasta el final diciendo que todo fue bien*.
+//!
+//! El arreglo de fondo es de receta (`-e MEMCG`), y cuesta un re-hasheo del kernel. Lo que este
+//! módulo arregla es lo otro, que es peor: **que nadie se entere**. El contrato se declara una vez,
+//! se comprueba contra un `.config` YA PRODUCIDO —el que la receta instala en
+//! `/out/boot/config-<versión>`— y sale distinto de cero cuando una capacidad prometida no está.
+//!
+//! ## Por qué POR PERFIL y no global
+//! Misma lección que el gate de hardware ([`super::gate`]): `recipes/linux.toml` es el kernel de
+//! QEMU con consola serie del selfhost-verify, y no hospeda Cards. Exigirle contabilidad de memoria
+//! sería rechazar una receta sana. El perfil declara **para qué es este kernel**; el contrato sólo
+//! obliga donde el consumidor existe.
+//!
+//! ## Lo que este contrato NO comprueba, lo dice
+//! Un símbolo **ausente** del `.config` no es lo mismo que uno apagado a mano: puede no existir en
+//! esa versión del kernel. Los dos cuentan como capacidad ausente —porque el fichero de cgroup no
+//! va a aparecer por buena voluntad— pero se informan **por separado**, y una capacidad sin perfil
+//! declarado queda en [`ContractReport::unprofiled`] sin contar como aprobada.
+
+use std::collections::BTreeMap;
+use std::path::Path;
+
+use serde::{Deserialize, Serialize};
+
+use super::config::{ConfigValue, KernelConfig};
+use super::gate::Verdict;
+
+/// Formato del fichero de contrato. Se versiona para poder migrarlo sin adivinar.
+pub const CONTRACT_VERSION: u32 = 1;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Contract {
+    pub version: u32,
+    /// Versión de kernel contra la que se revisaron los nombres de símbolo. No invalida nada:
+    /// avisa de que un `ausente` puede ser un renombrado.
+    #[serde(default)]
+    pub reviewed_against: Option<String>,
+    #[serde(default, rename = "capability")]
+    pub capabilities: Vec<Capability>,
+    #[serde(default, rename = "profile")]
+    pub profiles: Vec<Profile>,
+    #[serde(default, rename = "target")]
+    pub targets: Vec<Target>,
+}
+
+/// Una capacidad: un pedazo de interfaz de kernel que alguien del userland USA por nombre.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Capability {
+    /// Identificador estable, en kebab-case.
+    pub id: String,
+    pub title: String,
+    /// Los símbolos que la construyen. **Todos** deben estar en `y`/`m`: son conjunción, no lista
+    /// de candidatos — si con uno bastara, sería otra capacidad.
+    pub symbols: Vec<String>,
+    /// Qué se puede tocar cuando está: ficheros de cgroup, rutas de `/proc`, syscalls.
+    #[serde(default)]
+    pub interface: Vec<String>,
+    /// Quién la usa, con ruta y símbolo. Sin consumidor una capacidad no entra al contrato.
+    pub consumer: String,
+    /// **Cómo falla HOY si no está.** Es el campo que justifica el guardián: si el fallo fuera
+    /// ruidoso, bastaría con correr el programa.
+    #[serde(default)]
+    pub silent: Option<String>,
+    #[serde(default)]
+    pub notes: Option<String>,
+}
+
+/// Para qué es este kernel. El contrato no corre sin uno.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Profile {
+    pub id: String,
+    pub title: String,
+    #[serde(default)]
+    pub help: Option<String>,
+    /// Capacidades cuya ausencia **bloquea**.
+    #[serde(default)]
+    pub requires: Vec<String>,
+    /// Capacidades que se quieren pero cuya ausencia sólo se informa.
+    #[serde(default)]
+    pub wants: Vec<String>,
+}
+
+/// Qué perfil le toca a cada artefacto de kernel del store.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Target {
+    /// Nombre del artefacto tal como lo nombra la receta (campo `name`), p.ej. `linux-generic`.
+    pub artifact: String,
+    pub profile: String,
+    #[serde(default)]
+    pub notes: Option<String>,
+}
+
+/// Estado de UNA capacidad contra UN config.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CapabilityState {
+    pub id: String,
+    pub title: String,
+    /// `true` si el perfil la exige; `false` si sólo la quiere.
+    pub required: bool,
+    pub present: bool,
+    /// Símbolos apagados a mano (`# CONFIG_X is not set`): prueba dura de que la decisión se tomó.
+    #[serde(default)]
+    pub explicit_off: Vec<String>,
+    /// Símbolos que el `.config` ni nombra. Puede ser un renombrado entre versiones ⇒ se informa
+    /// aparte, aunque para la capacidad cuente igual de ausente.
+    #[serde(default)]
+    pub absent: Vec<String>,
+    pub consumer: String,
+    #[serde(default)]
+    pub silent: Option<String>,
+}
+
+/// Una capacidad nombrada por un perfil que no está declarada: el contrato envejeció.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct UnknownCapability {
+    pub id: String,
+    pub profile: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ContractReport {
+    /// Etiqueta del config mirado (ruta, o nombre del artefacto).
+    pub config: String,
+    pub profile: String,
+    #[serde(default)]
+    pub kernel: Option<String>,
+    pub checked: usize,
+    /// Exigidas y ausentes. Éstas bloquean.
+    pub missing_required: Vec<CapabilityState>,
+    /// Queridas y ausentes: se informan y se dejan pasar.
+    pub missing_wanted: Vec<CapabilityState>,
+    pub ok: Vec<CapabilityState>,
+    /// Ids que el perfil nombra y el contrato no define: NO se comprobaron.
+    #[serde(default)]
+    pub unknown: Vec<UnknownCapability>,
+    pub verdict: Verdict,
+}
+
+impl Contract {
+    pub fn load(path: &Path) -> crate::Result<Contract> {
+        let text = std::fs::read_to_string(path)
+            .map_err(|e| crate::Error::Recipe(format!("leyendo {}: {e}", path.display())))?;
+        Contract::parse(&text)
+    }
+
+    pub fn parse(text: &str) -> crate::Result<Contract> {
+        let c: Contract = toml::from_str(text)
+            .map_err(|e| crate::Error::Recipe(format!("parseando el contrato de kernel: {e}")))?;
+        if c.version != CONTRACT_VERSION {
+            return Err(crate::Error::Recipe(format!(
+                "contrato de kernel versión {} (esperaba {CONTRACT_VERSION})",
+                c.version
+            )));
+        }
+        Ok(c)
+    }
+
+    pub fn capability(&self, id: &str) -> Option<&Capability> {
+        self.capabilities.iter().find(|c| c.id == id)
+    }
+
+    pub fn profile(&self, id: &str) -> Option<&Profile> {
+        self.profiles.iter().find(|p| p.id == id)
+    }
+
+    /// Qué perfil le toca a un artefacto. `None` = no declarado ⇒ **no se comprueba**, se informa.
+    pub fn profile_for(&self, artifact: &str) -> Option<&str> {
+        self.targets
+            .iter()
+            .find(|t| t.artifact == artifact)
+            .map(|t| t.profile.as_str())
+    }
+
+    /// Corre el contrato de un perfil contra un `.config` ya producido.
+    pub fn check(&self, cfg: &KernelConfig, profile_id: &str, label: &str) -> crate::Result<ContractReport> {
+        let prof = self.profile(profile_id).ok_or_else(|| {
+            crate::Error::Recipe(format!(
+                "no existe el perfil «{profile_id}» en el contrato. El contrato NO corre sin \
+                 perfil declarado: uno global rechazaría kernels sanos (el de QEMU no hospeda Cards)."
+            ))
+        })?;
+
+        let mut rep = ContractReport {
+            config: label.to_string(),
+            profile: profile_id.to_string(),
+            kernel: cfg.banner.clone(),
+            checked: 0,
+            missing_required: Vec::new(),
+            missing_wanted: Vec::new(),
+            ok: Vec::new(),
+            unknown: Vec::new(),
+            verdict: Verdict::Pasa,
+        };
+
+        for (ids, required) in [(&prof.requires, true), (&prof.wants, false)] {
+            for id in ids {
+                let Some(cap) = self.capability(id) else {
+                    rep.unknown.push(UnknownCapability {
+                        id: id.clone(),
+                        profile: profile_id.to_string(),
+                    });
+                    continue;
+                };
+                rep.checked += 1;
+                let st = evaluate(cap, cfg, required);
+                match (st.present, required) {
+                    (true, _) => rep.ok.push(st),
+                    (false, true) => rep.missing_required.push(st),
+                    (false, false) => rep.missing_wanted.push(st),
+                }
+            }
+        }
+
+        if !rep.missing_required.is_empty() {
+            rep.verdict = Verdict::Bloquea;
+        }
+        Ok(rep)
+    }
+}
+
+fn evaluate(cap: &Capability, cfg: &KernelConfig, required: bool) -> CapabilityState {
+    let mut explicit_off = Vec::new();
+    let mut absent = Vec::new();
+    for s in &cap.symbols {
+        match cfg.get(s) {
+            Some(v) if v.is_on() => {}
+            Some(ConfigValue::No) => explicit_off.push(s.clone()),
+            // Un `=n` literal es rarísimo en un `.config` generado, pero se lee igual que el
+            // apagado explícito: la decisión está escrita.
+            Some(_) => explicit_off.push(s.clone()),
+            None => absent.push(s.clone()),
+        }
+    }
+    CapabilityState {
+        id: cap.id.clone(),
+        title: cap.title.clone(),
+        required,
+        present: explicit_off.is_empty() && absent.is_empty(),
+        explicit_off,
+        absent,
+        consumer: cap.consumer.clone(),
+        silent: cap.silent.clone(),
+    }
+}
+
+/// Barrido de un store: los `.config` que instaló cada kernel sellado.
+///
+/// Devuelve `(nombre del artefacto, ruta del config)`. Un kernel que no instale su config **no
+/// aparece**: sin config no hay nada que comprobar, y fingir que pasó sería el mismo pecado que
+/// el guardián viene a arreglar.
+pub fn sealed_kernel_configs(store: &Path) -> Vec<(String, std::path::PathBuf)> {
+    let mut out: Vec<(String, std::path::PathBuf)> = Vec::new();
+    let Ok(rd) = std::fs::read_dir(store) else {
+        return out;
+    };
+    for e in rd.flatten() {
+        let name = e.file_name().to_string_lossy().to_string();
+        // `<64 hex>-<nombre>`: el nombre del artefacto es lo que va detrás del primer guion.
+        let Some((hash, artifact)) = name.split_once('-') else {
+            continue;
+        };
+        if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
+            continue;
+        }
+        let boot = e.path().join("boot");
+        let Ok(bd) = std::fs::read_dir(&boot) else {
+            continue;
+        };
+        for b in bd.flatten() {
+            let f = b.file_name().to_string_lossy().to_string();
+            if f.starts_with("config-") {
+                out.push((artifact.to_string(), b.path()));
+            }
+        }
+    }
+    out.sort();
+    out
+}
+
+/// Índice `capacidad → perfiles que la exigen`, para el informe legible.
+pub fn required_by(contract: &Contract) -> BTreeMap<&str, Vec<&str>> {
+    let mut m: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
+    for p in &contract.profiles {
+        for id in &p.requires {
+            m.entry(id.as_str()).or_default().push(p.id.as_str());
+        }
+    }
+    m
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const CONTRATO: &str = r#"
+version = 1
+reviewed_against = "7.1.2"
+
+[[capability]]
+id = "limite-memoria"
+title = "Tope de memoria por unidad"
+symbols = ["MEMCG"]
+interface = ["memory.max", "memory.current"]
+consumer = "arje-incarnate::cgroup"
+silent = "el escritor descarta el error"
+
+[[capability]]
+id = "peso-cpu"
+title = "Reparto de CPU por unidad"
+symbols = ["CGROUPS", "CGROUP_SCHED"]
+consumer = "arje-incarnate::cgroup"
+
+[profile.anfitrion]
+id = "anfitrion"
+title = "Hospeda Cards"
+requires = ["limite-memoria", "peso-cpu"]
+wants = ["no-declarada"]
+
+[[target]]
+artifact = "linux-generic"
+profile = "anfitrion"
+"#;
+
+    fn contrato() -> Contract {
+        // `[profile.x]` con `id` dentro no es lo que usa el fichero real (usa `[[profile]]`);
+        // se arma a mano para no depender del formato en el test de lógica.
+        let mut c = Contract::parse(&CONTRATO.replace("[profile.anfitrion]", "[[profile]]")).unwrap();
+        c.targets.push(Target {
+            artifact: "linux".into(),
+            profile: "sin-perfil".into(),
+            notes: None,
+        });
+        c
+    }
+
+    #[test]
+    fn el_apagado_explicito_bloquea_y_se_distingue_del_ausente() {
+        let c = contrato();
+        let cfg = KernelConfig::parse(
+            "# CONFIG_MEMCG is not set\nCONFIG_CGROUPS=y\nCONFIG_CGROUP_SCHED=y\n",
+        );
+        let r = c.check(&cfg, "anfitrion", "test").unwrap();
+        assert_eq!(r.verdict, Verdict::Bloquea);
+        assert_eq!(r.missing_required.len(), 1);
+        assert_eq!(r.missing_required[0].explicit_off, vec!["MEMCG"]);
+        assert!(r.missing_required[0].absent.is_empty());
+        assert_eq!(r.ok.len(), 1);
+    }
+
+    #[test]
+    fn el_ausente_cuenta_pero_se_informa_aparte() {
+        let c = contrato();
+        let cfg = KernelConfig::parse("CONFIG_CGROUPS=y\nCONFIG_CGROUP_SCHED=y\n");
+        let r = c.check(&cfg, "anfitrion", "test").unwrap();
+        assert_eq!(r.verdict, Verdict::Bloquea);
+        assert_eq!(r.missing_required[0].absent, vec!["MEMCG"]);
+        assert!(r.missing_required[0].explicit_off.is_empty());
+    }
+
+    #[test]
+    fn cumplir_todo_pasa() {
+        let c = contrato();
+        let cfg = KernelConfig::parse("CONFIG_MEMCG=y\nCONFIG_CGROUPS=y\nCONFIG_CGROUP_SCHED=y\n");
+        let r = c.check(&cfg, "anfitrion", "test").unwrap();
+        assert_eq!(r.verdict, Verdict::Pasa);
+        assert_eq!(r.ok.len(), 2);
+    }
+
+    #[test]
+    fn una_capacidad_no_declarada_no_cuenta_como_aprobada() {
+        let c = contrato();
+        let cfg = KernelConfig::parse("CONFIG_MEMCG=y\nCONFIG_CGROUPS=y\nCONFIG_CGROUP_SCHED=y\n");
+        let r = c.check(&cfg, "anfitrion", "test").unwrap();
+        assert_eq!(r.unknown.len(), 1);
+        assert_eq!(r.unknown[0].id, "no-declarada");
+        assert_eq!(r.checked, 2);
+    }
+
+    /// Control de frescura del fichero real: los perfiles no pueden nombrar capacidades que no
+    /// existen, ni los objetivos perfiles que no existen. Un contrato que se desincroniza de sí
+    /// mismo miente igual que el kernel al que vigila.
+    #[test]
+    fn el_contrato_del_repo_cierra_sobre_si_mismo() {
+        let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+            .join("../../docs/state/kernel-contract.toml");
+        if !p.is_file() {
+            return; // instalado fuera del repo: no hay nada que comprobar
+        }
+        let c = Contract::load(&p).expect("el contrato del repo debe parsear");
+        for prof in &c.profiles {
+            for id in prof.requires.iter().chain(prof.wants.iter()) {
+                assert!(
+                    c.capability(id).is_some(),
+                    "el perfil {} nombra la capacidad inexistente {id}",
+                    prof.id
+                );
+            }
+        }
+        for t in &c.targets {
+            assert!(
+                c.profile(&t.profile).is_some(),
+                "el artefacto {} apunta al perfil inexistente {}",
+                t.artifact,
+                t.profile
+            );
+        }
+        for cap in &c.capabilities {
+            assert!(!cap.symbols.is_empty(), "{} no declara símbolos", cap.id);
+            assert!(!cap.consumer.trim().is_empty(), "{} no declara consumidor", cap.id);
+        }
+    }
+
+    #[test]
+    fn sin_perfil_declarado_no_corre() {
+        let c = contrato();
+        let cfg = KernelConfig::parse("CONFIG_MEMCG=y\n");
+        assert!(c.check(&cfg, "no-existe", "test").is_err());
+        assert_eq!(c.profile_for("linux-generic"), Some("anfitrion"));
+        assert_eq!(c.profile_for("linux-metal"), None);
+    }
+}
diff --git a/crates/hammer-core/src/kernel/mod.rs b/crates/hammer-core/src/kernel/mod.rs
index 02afcb45..f80c59c0 100644
--- a/crates/hammer-core/src/kernel/mod.rs
+++ b/crates/hammer-core/src/kernel/mod.rs
@@ -18,6 +18,7 @@
 
 pub mod catalog;
 pub mod config;
+pub mod contract;
 pub mod gate;
 pub mod hw;
 pub mod kconfig;
@@ -29,6 +30,7 @@ pub use catalog::{Bundle, Catalog, Knob, Objective, Side};
 pub use gate::{GateReport, Verdict};
 pub use modmap::ModMap;
 pub use config::{ConfigValue, KernelConfig};
+pub use contract::{Capability, Contract, ContractReport, Profile as ContractProfile};
 pub use hw::Hardware;
 pub use kconfig::{Expr, KconfigTree, SelectLeak, SymKind, Symbol};
 pub use plan::{diff_back, make_plan, DiffBack, Plan};
diff --git a/docs/state/kernel-contract.toml b/docs/state/kernel-contract.toml
new file mode 100644
index 00000000..504916cd
--- /dev/null
+++ b/docs/state/kernel-contract.toml
@@ -0,0 +1,211 @@
+# Contrato de capacidades del kernel (SDD 25 §4, §7-H1 y §8-W6).
+#
+# QUÉ ES. La lista de pedazos de interfaz de kernel que el userland de hammer/tawasuyu usa POR
+# NOMBRE, con su consumidor y con **cómo falla hoy si no están**. Se comprueba con
+# `hammer kernel contract` contra un `.config` YA PRODUCIDO (el que la receta instala en
+# `/out/boot/config-<versión>`), nunca contra la receta: entre el `scripts/config -e X` y el
+# `.config` hay un `olddefconfig` que puede tragarse el símbolo en silencio.
+#
+# POR QUÉ EXISTE. SDD 25 encontró que los kernels de hammer se construyen sin `CONFIG_MEMCG`, y
+# que el escritor del otro lado descarta el error: una Card que pide un tope de memoria arranca
+# SIN tope y la única huella es un `warn!`. La máquina de desarrollo no lo ve porque su kernel
+# (Artix) sí trae MEMCG. Este fichero es el guardián de ese punto ciego.
+#
+# POR QUÉ PERFILES. Misma lección que el gate de hardware: `recipes/linux.toml` es el kernel de
+# QEMU con consola serie del selfhost-verify y no hospeda Cards. Un contrato global rechazaría
+# una receta sana.
+#
+# REGLA DE ALTA. Una capacidad entra acá sólo si hay un consumidor con ruta y símbolo. Lo que
+# «estaría bueno tener» no es un contrato.
+
+version = 1
+reviewed_against = "7.1.2"
+
+# ---------------------------------------------------------------------------------------------
+# Capacidades
+# ---------------------------------------------------------------------------------------------
+
+[[capability]]
+id = "limite-memoria"
+title = "Tope de memoria por unidad"
+symbols = ["MEMCG"]
+interface = ["memory.max", "memory.high", "memory.current"]
+consumer = """
+arje-incarnate::cgroup::{apply_rlimits_to_cgroup:78, set_memory_max_at:204, set_memory_high_at:210} \
+← sandokan-core::Engine::{set_memory_max,set_memory_high} ← sandokan-monitor-llimphi (govierna memory.high)"""
+silent = """
+Sin MEMCG los ficheros NO EXISTEN. `apply_rlimits_to_cgroup` sólo emite `warn!(memory.max write \
+failed)` y sigue: la Card corre sin tope y el resto del sistema se entera cuando el OOM killer \
+elige a otro. Es el fallo de CLAUDE.md §3 — un ausente falla ruidosamente, un vacío llega hasta \
+el final diciendo que todo fue bien."""
+notes = "Arreglo: `-e MEMCG` en la fase configure. Re-hashea el kernel (SDD 22 §1) ⇒ va en tanda."
+
+[[capability]]
+id = "peso-cpu"
+title = "Reparto proporcional de CPU por unidad"
+symbols = ["CGROUPS", "CGROUP_SCHED", "FAIR_GROUP_SCHED"]
+interface = ["cpu.weight", "cpu.stat"]
+consumer = "arje-incarnate::cgroup::set_cpu_weight_at:170 (y :60, que descarta el error)"
+silent = "`let _ = write(cpu.weight)` en :60: el peso no se aplica y no queda ni un warn."
+
+[[capability]]
+id = "tope-procesos"
+title = "Tope de procesos por unidad"
+symbols = ["CGROUPS", "CGROUP_PIDS"]
+interface = ["pids.max", "pids.current"]
+consumer = "arje-incarnate::cgroup::apply_rlimits_to_cgroup:85"
+silent = "Mismo patrón que memory.max: sólo un `warn!` y la unidad forkea sin límite."
+
+[[capability]]
+id = "peso-io"
+title = "Reparto de E/S por unidad"
+symbols = ["BLK_CGROUP", "BLK_CGROUP_IOCOST"]
+interface = ["io.weight", "io.cost.qos", "io.cost.model"]
+consumer = "arje-incarnate::cgroup::set_io_weight_at:217 (y :64, que descarta el error)"
+silent = "`let _ = write(io.weight)`: silencio total."
+notes = """
+`io.weight` con sintaxis «default N» lo publica iocost, no BFQ — por eso el contrato pide \
+BLK_CGROUP_IOCOST y no IOSCHED_BFQ (que está apagado en las recetas y no hace falta)."""
+
+[[capability]]
+id = "cpus-por-unidad"
+title = "Pinneo de CPUs por unidad"
+symbols = ["CPUSETS"]
+interface = ["cpuset.cpus", "cpuset.mems"]
+consumer = "arje-incarnate::cgroup::set_cpuset_at:247"
+
+[[capability]]
+id = "jaula-de-rutas"
+title = "Landlock: la jaula de rutas de harkaq"
+symbols = ["SECURITY", "SECURITY_LANDLOCK"]
+interface = ["landlock_create_ruleset(2)", "landlock_restrict_self(2)"]
+consumer = "hammer-build::harkaq (SDD 16) y hammer-build::sandbox"
+silent = """
+Landlock ausente no rompe el build: la jaula simplemente no se aplica y el veredicto por fase \
+sale igual de verde. Evidencia negativa que no prueba nada."""
+
+[[capability]]
+id = "jaula-de-syscalls"
+title = "seccomp: el filtro de syscalls de harkaq"
+symbols = ["SECCOMP", "SECCOMP_FILTER"]
+interface = ["seccomp(2) SECCOMP_SET_MODE_FILTER"]
+consumer = "hammer-build::harkaq (fase 1 cerrada, barrido en granja)"
+
+[[capability]]
+id = "namespaces-de-build"
+title = "Namespaces para el sandbox de build"
+symbols = ["NAMESPACES", "USER_NS", "PID_NS", "UTS_NS", "IPC_NS"]
+interface = ["unshare(2)", "bwrap"]
+consumer = "hammer-build::sandbox (bwrap sin pre_exec — ver SDD 25 §7-H4)"
+silent = "Sin USER_NS bwrap falla ruidosamente; entra al contrato porque es la base de todo build."
+
+[[capability]]
+id = "hidratacion-overlay"
+title = "Overlay con metacopy para hidratar árboles"
+symbols = ["OVERLAY_FS", "OVERLAY_FS_REDIRECT_DIR", "OVERLAY_FS_INDEX", "OVERLAY_FS_XINO_AUTO", "OVERLAY_FS_METACOPY"]
+interface = ["mount -t overlay"]
+consumer = "hammer-overlay / `hammer hydrate` (docs/04-overlay.md)"
+
+[[capability]]
+id = "journal-fanotify"
+title = "Journal de accesos por fanotify"
+symbols = ["FANOTIFY", "FANOTIFY_ACCESS_PERMISSIONS"]
+interface = ["fanotify_init(2) FAN_CLASS_CONTENT"]
+consumer = "hammerd::watcher / hammer-journal (handoff arje→hammer, frente 2)"
+
+[[capability]]
+id = "io-uring"
+title = "io_uring"
+symbols = ["IO_URING"]
+interface = ["io_uring_setup(2)"]
+consumer = "kikin J4 (PLAN-KIKIN §4.bis) — el sustrato se declara explícito, no «al azar del defconfig»"
+
+[[capability]]
+id = "presion-de-recursos"
+title = "PSI: presión de CPU/memoria/E/S"
+symbols = ["PSI"]
+interface = ["/proc/pressure/{cpu,memory,io}", "cpu.pressure", "memory.pressure"]
+consumer = """
+sandokan-monitor-llimphi — PREVISTO, no actual: hoy gobierna por muestreo. Se declara como \
+`wants` para que el día que se use, el kernel ya lo traiga."""
+silent = "Los ficheros no existen y el lector cae al muestreo sin decir que degradó."
+
+[[capability]]
+id = "contabilidad-por-tarea"
+title = "taskstats + proc connector (el reemplazo de sondear /proc)"
+symbols = ["TASKSTATS", "TASK_DELAY_ACCT", "CONNECTOR", "PROC_EVENTS"]
+interface = ["netlink NETLINK_CONNECTOR", "TASKSTATS_CMD_GET"]
+consumer = """
+sandokan-local — PREVISTO (SDD 25 T3/W2): hoy sondea `/proc/<pid>` a 3,4 µs por proceso. \
+Se declara `wants` porque el kernel ya lo trae por defconfig y perderlo sería una regresión silenciosa."""
+
+# ---------------------------------------------------------------------------------------------
+# Perfiles: PARA QUÉ es cada kernel
+# ---------------------------------------------------------------------------------------------
+
+[[profile]]
+id = "anfitrion-cards"
+title = "Hospeda Cards de sandokan/arje con límites"
+help = """
+El kernel de un sistema hammer real. Acá vive el contrato entero: si una Card pide un tope, el \
+tope tiene que existir."""
+requires = [
+  "limite-memoria",
+  "peso-cpu",
+  "tope-procesos",
+  "peso-io",
+  "cpus-por-unidad",
+  "jaula-de-rutas",
+  "jaula-de-syscalls",
+  "namespaces-de-build",
+  "hidratacion-overlay",
+  "journal-fanotify",
+  "io-uring",
+]
+wants = ["presion-de-recursos", "contabilidad-por-tarea"]
+
+[[profile]]
+id = "qemu-serial"
+title = "Kernel de QEMU con consola serie (selfhost-verify)"
+help = """
+`recipes/linux.toml`. No hospeda Cards: construye hammer dentro de una VM y su ArtifactHash es \
+LOAD-BEARING del baseline `of_tree 9adefb82` del selfhost-verify. Por eso los límites por unidad \
+son `wants` y no `requires`: subirlos acá cuesta rehacer el baseline, y el consumidor no existe."""
+requires = [
+  "namespaces-de-build",
+  "hidratacion-overlay",
+  "jaula-de-rutas",
+  "jaula-de-syscalls",
+  "io-uring",
+]
+wants = ["limite-memoria", "peso-cpu", "tope-procesos", "journal-fanotify"]
+
+# ---------------------------------------------------------------------------------------------
+# Qué perfil le toca a cada artefacto. Un kernel sin fila acá NO se comprueba y se informa:
+# un portón que calla lo que no pudo mirar no es un portón.
+# ---------------------------------------------------------------------------------------------
+
+[[target]]
+artifact = "linux"
+profile = "qemu-serial"
+notes = "6.16.12, QEMU-tuned, INTOCADO (baseline del selfhost-verify)."
+
+[[target]]
+artifact = "linux-generic"
+profile = "anfitrion-cards"
+notes = "7.1.2, hardware variado de testers."
+
+[[target]]
+artifact = "linux-metal"
+profile = "anfitrion-cards"
+notes = "6.16.12, clavado al TigerLake/Iris-Xe/AX201."
+
+[[target]]
+artifact = "linux-metal-dual"
+profile = "anfitrion-cards"
+notes = "7.1.2, USB de escritorio dual."
+
+[[target]]
+artifact = "linux-gioser"
+profile = "anfitrion-cards"
+notes = "Derivado de `hammer kernel plan` para gioser (SDD 22, bzImage 11,1 MB)."