Files
takana/docs/22-configurador-kernel.md
T
SergioandClaude Opus 5 fe382b0d26 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>
2026-08-10 13:26:29 +00:00

22 KiB
Raw Blame History

SDD 22 — Configurador de kernel por niveles: respuesta al handoff de tawasuyu

Escrito 2026-08-07, en respuesta a tawasuyu/HANDOFF-KERNEL-CONFIG-A-HAMMER.md.

El handoff avisa de su propio método: «nada de lo que sigue está verificado contra el código de hammer — si un dato de hammer lo contradice, manda hammer». Este documento es esa verificación. No es una aprobación ni un plan de implementación: contesta las seis preguntas de su §9 con evidencia, corrige lo que estaba mal, y señala dos objeciones técnicas que cambian el diseño.

Veredicto corto: la idea se sostiene y el sustrato está más listo de lo que el handoff supone — el mecanismo de §6 («la app nunca escribe un .config») ya es exactamente como funciona hoy. Pero hay un hecho estructural que el handoff no podía conocer y que reordena todo, y una objeción técnica a §2.1 que hay que resolver antes de escribir una línea.


1. El hecho que reordena todo: el config ES la identidad del artefacto

En hammer, Recipe::hash_inputs es una lista blanca, y las fases de build entran en ella. El config del kernel vive dentro de la fase configure de recipes/linux*.toml. Por lo tanto:

Cambiar un solo símbolo de config cambia el ArtifactHash del kernel.

Esto no es un detalle de implementación: es la bisagra del diseño, y corta en los dos sentidos.

A favor, y fuerte: §2.2 (config atestado por huella de hardware) sale casi gratis. No hay que inventar un mecanismo para «atestar un config»: cada config ya es un artefacto direccionado por contenido, reproducible bit a bit y firmable con la maquinaria que existe. Un «config que booteó en esta huella» es, literalmente, un ArtifactHash + una firma. El handoff intuyó que hammer tenía el sustrato; lo tiene más de lo que creía.

En contra, y también fuerte: la explosión de builds que §2.2 quiere evitar no es hipotética, es aritmética visible en el disco. Cada variante de config es un artefacto nuevo en el store, y el store ya crece 24 G por ciclo de trabajo sólo con las variantes que producimos hoy sin querer. Con N1 (~4060 bundles) el espacio combinatorio es 2⁴⁰; con «perfiles publicados» —que es lo que el handoff propone en §8— es un puñado. La restricción de §8 no es una preferencia de diseño: es una condición de supervivencia del disco, y conviene decirlo así en la UI.

Consecuencia práctica que hay que aceptar desde el día uno: una «perilla» de la UI no es un parámetro de runtime, es una edición de receta. No hay forma de tener un config variable sin generar recetas. El diseño correcto es que hammer kernel plan emita una receta derivada (con su propio hash) en vez de fingir que el kernel es un binario parametrizable.


2. Las seis preguntas del §9, contestadas con evidencia

P1 — ¿Las recetas de kernel aceptan fragmentos o sólo defconfig completo?

Ya son fragmentos, y con la forma que §6 prescribe. recipes/linux.toml, fase configure:

make ARCH=x86_64 defconfig && \
scripts/config -d MODULE_SIG -d DEBUG_INFO_BTF -e SECURITY_LANDLOCK -e AUDIT \
  -d WLAN -d WIRELESS -d CFG80211 -d MAC80211 -d RFKILL  … && \
make ARCH=x86_64 olddefconfig

Base + deltas por símbolo + resolución por el olddefconfig del propio kernel. La app nunca escribe un .config. La única diferencia con el handoff es el vehículo: scripts/config -e/-d en vez de ficheros de fragmento y merge_config.sh. Son equivalentes y scripts/config es más fácil de generar programáticamente. Media implementación de §6 ya está en producción.

Falta la otra mitad, que es la que da la UI veraz: el diff-back. Hoy nada comprueba que los símbolos pedidos hayan sobrevivido a olddefconfig. Un -e FOO cuya dependencia no se cumple se pierde en silencio — el mismo patrón de fallo que el license que caía dentro de [deps].

P2 — ¿El build es bit-reproducible con config variable?

Sí, y por construcción. El config no es un parámetro externo: es parte de la entrada hasheada (§1). Cada config es su propia receta con su propio hash y su propia garantía de reproducción. No hay un «kernel» cuya reproducibilidad dependa de congelar el config; hay N kernels, cada uno congelado. ⇒ §2.2 puede prefirmar artefactos, no sólo configs.

P3 — ¿Cuánto tarda un kernel completo? 🚨

Declarado en las propias recetas: linux ~35 min, linux-metal ~45 min, linux-generic ~60 min en la máquina de referencia. En la más lenta del parque, más.

Esto mata la bisección ingenua de §5. ~6 rebuilds × 45 min = 4,5 horas de la máquina del usuario para encontrar un bundle culpable. Nadie espera eso, y menos con la máquina que acaba de no arrancar. El handoff ya preveía el riesgo («si es caro, tope duro de iteraciones»); la respuesta es que es caro. Ver §4, donde propongo una bisección que no recompila.

P4 — ¿harkaq puede prestar su motor de clausura?

El patrón sí; el código no. crates/hammer-build/src/harkaq.rs deriva su política enumerando fichero a fichero los artefactos de las deps declaradas — es una clausura sobre el grafo de paquetes materializado en el store, con semántica de rutas de filesystem. Kconfig es otro grafo, con otra semántica y ~15 000 símbolos. Reusar la idea («política = clausura, no lista») es correcto y está validado; reusar la implementación no. Hace falta un lector de Kconfig — y ahí está la objeción del §3.

P5 — ¿El índice firmado admite un eje más (la huella)?

Sí, y sin romper nada. PackageEntry (crates/hammer-core/src/repo.rs) es un struct serde cuyos campos opcionales llevan #[serde(default, skip_serializing_if = ...)]. Añadir fingerprint: Option<String> es compatible hacia atrás: los índices viejos siguen parseando y los clientes viejos ignoran el campo. Es exactamente el truco que hizo pagable el campo license hoy mismo.

P6 — El prerrequisito de PLAN-KIKIN §4.bis

Estaba dado por pendiente y está hecho en dos de tres. Medido:

receta SECURITY_LANDLOCK AUDIT IO_URING BPF_SYSCALL
linux-metal -e -e -e -e
linux-generic -e -e -e -e
linux -e -e sólo en un comentario sólo en un comentario

⚠️ Y la forma de medirlo importa: grep IO_URING recipes/linux.toml da positivo porque la palabra aparece en un comentario. Hay que mirar los -e/-d dentro del bloque configure. Es el mismo error que hoy infló el conteo de licencias (grep -l license contaba comentarios y nombres de paquete) y el que hace mentir a la regla del .a no-PIC. En este repo, grep sobre ficheros con comentarios densos no es una medición.

⇒ Queda pendiente sólo linux, que es el kernel de QEMU/serial.


3. Objeción técnica a §2.1: «clausura» sobre Kconfig no está bien definida todavía

La propuesta —«todo lo alcanzable desde CONFIG_WIRELESS menos lo alcanzable desde lo que sí quiero»— asume un grafo con aristas de un solo tipo. Kconfig tiene al menos tres, con semánticas incompatibles:

  • depends on — arista hacia arriba: no puedo existir sin X.
  • select — arista hacia abajo y forzada: si yo entro, X entra, ignorando los depends on de X. Es la fuente clásica de configs inválidos.
  • imply — sugerencia blanda.

«Alcanzable» por depends on y «alcanzable» por select dan conjuntos distintos y ninguno de los dos es la respuesta. Un driver de otro subsistema puede select un helper de wireless sin ser wireless: cae dentro de la clausura ingenua y apagarlo rompe algo que el usuario sí quería. Y al revés, select puede meter símbolos wireless desde fuera de la clausura.

Esto no invalida §2.1 — lo convierte en la parte con contenido técnico real, tal como el propio handoff dice en su §10. Pero hay que definir la semántica de arista antes de escribir el predicado, y la definición hay que validarla contra algo.

Y tenemos contra qué validarla, hoy y gratis

El config actual de linux.toml ya es un juego de bundles N1 escrito a mano. Literalmente:

-d WLAN -d WIRELESS -d CFG80211 -d MAC80211 -d RFKILL      ← el bundle «no necesito wifi»
-d SOUND -d SND                                            ← «no necesito audio»
-d DRM -d DRM_I915 -d DRM_AMDGPU -d DRM_NOUVEAU -d FB      ← «sin gráficos»
-d XFS_FS -d BTRFS_FS -d F2FS_FS -d JFS_FS -d REISERFS_FS  ← «sólo ext4»

Es un corpus de verdad-de-campo: alguien decidió a mano qué símbolos componen «no necesito wifi», y ese kernel arranca. La prueba de §2.1 es directa y barata: ¿la clausura calculada de CONFIG_WIRELESS reproduce ese conjunto? Si sobra o falta mucho, la semántica de arista está mal y nos enteramos en una tarde, sin compilar nada. Refuerza el §10 del handoff: eso se hace antes que nada, junto al modo reversa.


4. Lo que agrego: bisección sin recompilar

Dado P3 (3560 min por kernel), la bisección de §5 cuesta horas. Pero la mayoría de los bundles N1 son drivers, y un driver puede ser =m en vez de =y.

Propuesta: un kernel «superset» con todo lo bisecable como módulo. Los bundles N1 de tipo hardware no se resuelven entonces recompilando, sino con una lista de bloqueo de módulos en la línea de arranque. La bisección deja de ser 6 rebuilds (4,5 h) y pasa a ser 6 reinicios (minutos), sobre un kernel que ya está en el árbol de generaciones.

Consecuencias:

  • El «kernel a medida» pasa a ser una optimización de tamaño/arranque, no un requisito de funcionalidad ⇒ el usuario puede probar bundles antes de pagar un build.
  • Encaja con el árbol de generaciones y el rollback E4, que ya existen.
  • Limitación honesta: no cubre lo que debe ser =y (LSM, preempción, unwinder, -march, LTO, o sea buena parte de N2). Para esos sí hay rebuild y sí hay que topar iteraciones.
  • Y un coste que hay que decir: el superset es exactamente el kernel gordo que el nivel 1 quiere evitar. Es una herramienta de diagnóstico, no el kernel de todos los días.

5. Lo que agrego: el gate de no-regresión (§6) debe ser por objetivo, no global

El handoff lo llama la mejor relación valor/esfuerzo y estoy de acuerdo. Pero el config actual de linux.toml apaga USB_SUPPORT, HID, HID_SUPPORT, INPUT_MOUSE, INPUT_TOUCHSCREEN. Ese kernel no puede recibir teclado en metal — y es correcto, porque es el kernel de QEMU con consola serie. Un gate global «todo dispositivo en uso debe seguir teniendo driver» rechazaría una receta sana.

⇒ El gate necesita el objetivo declarado (qemu-serial / metal / servidor) y una noción de «dispositivos en uso de la máquina destino», que no siempre es la máquina donde se construye. Sigue siendo la propuesta más barata de las nueve; sólo que el portón se pone en el sitio correcto.

6. Dos huecos del handoff que conviene cerrar antes

«Booteó» no es una aserción suficiente para atestar (§2.2). Un kernel puede arrancar y dejarte sin red, sin wifi o sin teclado — que es justo el desastre que §6 quiere evitar. Si la atestación dice sólo «booteó en esta huella», estamos firmando la clase de fallo que más duele. La atestación debería llevar qué se comprobó: qué dispositivos de la huella quedaron con driver bindeado después de arrancar. Es el mismo dato que ya calcula el gate de §6 ⇒ una implementación, dos usos.

El catálogo de bundles necesita las tres etiquetas de nivel, no dos. El handoff avisa en §5 que «la mitad de N2 no es Kconfig sino variables de receta». Con §1 en la mano eso es más grave de lo que parece: las perillas de receta (LTO, -march, zig_version) cambian el hash igual que los símbolos, pero se aplican en otra fase y fallan de otra forma. La UI tiene que saber de qué lado cae cada perilla o prometerá diffs que no puede explicar.


7. Qué contesto al reparto (§7)

De acuerdo con el reparto tal como está. Dos precisiones desde este lado:

  • El contrato JSON: de acuerdo, y el precedente que cita (/run/hammer/boot-graph.json) es el correcto. Añadiría que el JSON de hammer kernel plan debe incluir el ArtifactHash resultante, porque por §1 el plan determina el artefacto: la UI puede decir «esto ya está construido y firmado» sin construir nada.
  • Los verbos propuestos me parecen bien salvo hammer kernel build --plan, que esconde dos cosas muy distintas (construir, y dar de alta en el árbol de generaciones). Separarlos deja que la sonda en VM de §4 se meta entre medio como un paso propio y auditable.

8. Orden que propongo (modifica el §10 del handoff)

  1. Modo reversa (#9) — tal cual lo propone: cero riesgo, valida el catálogo contra un kernel real.
  2. Validar la clausura contra el config actual (§3 de este documento) — sale gratis, usa un corpus que ya existe, y decide si §2.1 es viable. Va antes que el gate, porque si la clausura no reproduce los bundles hechos a mano, el diseño de N1 cambia.
  3. Gate de no-regresión (#6), por objetivo (§5 de este documento).
  4. Diff-back (la mitad que falta de §6 del handoff): barato, y sin él la UI miente.
  5. Recién después, #1 (clausuras) con la semántica ya decidida.

No implementar todavía. Este documento existe para que la decisión se tome con los seis datos verificados, no con las suposiciones del handoff — que su autor, correctamente, marcó como tales.


ADENDA — 2026-08-10: implementación en marcha

El usuario decidió. Lo que sigue ya no es análisis: es lo medido contra el código escrito. Se sigue el orden del §8.

9. Paso 2 hecho: la clausura SÍ reproduce los bundles a mano

hammer kernel (crates/hammer-core/src/kernel/, crates/hammer-cli/src/kernel_cmd.rs) trae un lector de Kconfig de sólo análisis — la regla dura del §6 del handoff se respeta literalmente: no hay solver, el .config lo sigue produciendo olddefconfig.

Salud del lector, sobre linux-6.16.12 (hammer kernel stats):

ficheros símbolos visibles con ayuda aristas select aristas imply avisos de parseo
1646 18 212 15 169 14 866 15 165 445 0

La semántica de arista, resuelta

El §3 pedía definirla antes de escribir el predicado. Quedó así, y las dos mitades importan:

  • depends on en posición conjuntiva — sólo cuenta como dependencia dura el símbolo que, puesto a n, apaga la expresión entera. En A && (B || C) sólo A es duro; !X, las ramas de un || y los dos lados de una comparación no aportan nada. Es deliberadamente conservador: preferimos un bundle que apague de menos (se nota) a uno que apague de más (hace un ladrillo).
  • Un símbolo con varias definiciones muere sólo si mueren todas ⇒ las dependencias duras son la intersección entre definiciones, no la unión. El error natural (la unión) diría que apagar Y mata a X cuando X tiene otra definición que ni menciona a Y.
  • select no es una arista más: es el portillo. Fuerza el destino ignorando sus depends on, así que un símbolo de fuera del bundle reenciende lo que el bundle apagó. select_leaks las enumera, y closure_off_fixpoint cierra el bundle contra ellas — reportando el precio, nunca aplicándolo solo.

La medición que decide §2.1

Corpus: el juego de bundles N1 escrito a mano en la fase configure de recipes/linux.toml, sobre un kernel que arranca. Pregunta: ¿la clausura calculada desde la raíz mínima reproduce lo que escribió el humano?

hammer kernel closure --kconfig <árbol> WIRELESS --fixpoint
símbolos
clausura estricta de WIRELESS (sólo depends on) 350
punto fijo (cierra las 3 fugas select: WLAN, IWLEGACY, GELIC_WIRELESS) 406
bundle -d WLAN -d WIRELESS -d CFG80211 -d MAC80211 -d RFKILL hecho a mano 421
sobra (apagaría algo que el humano dejó vivo) 0
falta 15

Sobra cero. La clausura no toca un solo símbolo que el humano quisiera conservar — que era el riesgo real, porque sobrar es hacer un ladrillo. Cierra en una ronda.

Los 15 que faltan son todos de RFKILL (RFKILL{,_GPIO,_INPUT,_LEDS} y los drivers de laptop que dependen de él: DELL_RBTN, IDEAPAD_LAPTOP, MSI_LAPTOP, AMILO_RFKILL…). Y RFKILL no es wifi: es el interruptor de radio compartido, que bluetooth y NFC declaran con el mismo idioma (depends on RFKILL || !RFKILL en net/{bluetooth,nfc,wireless}/Kconfig). Que quede fuera no es un fallo de la clausura — es que el humano apagó dos bundles a la vez («no necesito wifi» y «no necesito radios») y los escribió en la misma línea. ⇒ el catálogo N1 necesita «sin radios» como entrada propia. Nótese que ese idioma es un ||: la regla conservadora lo excluye de los duros, que es exactamente lo correcto.

§2.1 es viable y la objeción del §3 queda resuelta, con una corrección al diseño: un bundle no es clausura(raíz) sino clausura(raíz) + el punto fijo sobre select, y las fugas se aprueban a mano una vez, no en cada release.

Y el punto fijo también dice cuándo NO

No siempre cierra barato, y eso es información, no un fallo. Medido sobre los otros tres bundles a mano de linux.toml:

bundle a mano clausura fugas select qué las causa
WLAN WIRELESS CFG80211 MAC80211 RFKILL 421 0 cerrado
SOUND SND 1323 19 sobre 7 destinos DRM_I915/DRM_NOUVEAU/DRM_AMD_DC hacen select del códec HDMI
DRM DRM_I915 … FB AGP 770 15 sobre 6 destinos drivers USB-C (TYPEC_*) hacen select DRM_AUX_BRIDGE
XFS_FS BTRFS_FS … NTFS3_FS 46 1 NTFS3_FS ← NTFS_FS

Para cerrar «no necesito audio» hay que tragarse drivers gráficos. En linux.toml sale gratis porque los gráficos también están apagados, pero en un perfil de escritorio no: ahí la fuga es una decisión, y por eso el punto fijo la muestra en vez de aplicarla.

10. Pasos 1 y 4 hechos: modo reversa, plan y diff-back

Modo reversa (hammer kernel probe) sobre gioser, con el catálogo docs/state/kernel-bundles.toml (15 bundles N1, 8 perillas N2): 10 551 símbolos declarados, 20 dispositivos PCI, 38 drivers bindeados, 7 bundles con capacidad que este hardware no usa. Y avisa de lo que el propio caso destapó: el config vivo es de la serie 7.1 y el catálogo se revisó contra la 6.16 ⇒ las clausuras son aproximadas. Se dice en vez de callarlo.

Plan (hammer kernel plan): emite una receta derivada, como manda §1. Medido sobre recipes/linux.toml con sin-wifi + sin-audio + solo-ext4 + jaula-y-eio-moderna:

banderas emitidas 16
símbolos que caen con su clausura 1775
ArtifactHash de la base b3:cb926743…
ArtifactHash de la derivada b3:47a52b2e…

Dos hashes distintos para el mismo código fuente: el config es la identidad, y ahora se ve. El plan lleva su hash dentro ⇒ la UI puede decir «esto ya está construido y firmado» sin construir.

Tres detalles del plan que no eran obvios:

  • Se emiten raíces, no clausuras. 16 banderas, no 1775 líneas. La clausura la calcula el olddefconfig del propio kernel; hammer la sabe sólo para poder explicarla.
  • La fase derivada AÑADE una segunda ronda (… && scripts/config … && make olddefconfig) en vez de reescribir la base. No hay que parsear el shell de nadie y la base sigue siendo literalmente la de siempre en el diff.
  • Los conflictos se rechazan, no se ordenan. Dos selecciones que se contradicen sobre un símbolo darían una respuesta plausible y arbitraria según el orden de aparición. Y hay un segundo conflicto que el símbolo solo no delata: encender algo que cae dentro de la clausura de lo que otro bundle apaga — olddefconfig lo descartaría sin decir nada.

Diff-back (hammer kernel diff-back --plan … --config …): la mitad que faltaba del §6 del handoff. 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ó, 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.osnd_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-back (paso 4 del §8) existe para atrapar, encontrado antes de implementarlo: hoy un -e/-d sobre un símbolo inexistente se pierde en silencio.