> 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…
feat(estado): nadie comparaba — el bucle del SDD 33 §4 ya se cierra
El §4 son tres pasos y ayer construí dos: el cierre de hoy (`cierre-perfiles.py`) y el que la
imagen declara (`hydrate-profile.py` → `/etc/os-release`). **Faltaba el que los junta.** Dos
mitades que no se encuentran son dos ficheros, no un disparador: sin este paso el §4 seguía siendo
prosa aunque cada pieza por separado funcionara y estuviera probada.
`imagen-rancia.py` lee el `TAKANA_CIERRE` de cada imagen y lo compara con el vigente de su perfil.
Dice CUÁLES rearmar; no rearma — eso son horas de worker y el §4 pone los tres arneses antes de
publicar.
**Cinco caminos, los cinco probados:**
```
AL DÍA hidratando metal-tigerlake DE VERDAD y comparando ⇒ sha256:5fed4348…
RANCIA perturbando el hash de intel-ucode en el grafo ⇒ lleva 5fed4348… / hoy d6fef70f…
RANCIA imagen que declara un digest viejo
INCOMPLETA imagen armada con `--allow-missing` ⇒ rearmar
MUDA imagen anterior al §4 ⇒ «no se puede saber», NO «al día»
```
⚠ Lo último no es un detalle: **tratar la ausencia de declaración como conformidad es la forma
exacta de que un medio rancio pase por bueno**, la misma trampa que un artefacto vacío en el store.
Y el control de rancidez destapó algo que no estaba buscando: **saltó la guarda de hashes
contradictorios** que `mapa_hashes()` trae, porque perturbé un solo grafo y los otros cuatro
conservaban el hash original. Dijo RANCIA *y* avisó de que la entrada era inconsistente — una
guarda que nunca había disparado haciendo su trabajo sobre una inconsistencia real.
**Puerta diaria, y no por coste** (son milisegundos): para no imprimir «0 imágenes» 48 veces al
día. El registro está vacío a propósito —no hay imágenes publicadas— y el fichero existe para que
el día que se arme una, vigilarla sea añadir una línea en vez de recordar que esto existe. Una
línea diaria mantiene la capacidad viva; 48 la vuelven invisible, que es el modo de fallo que este
mismo guion ya sufrió con el aviso del repo: una línea por hora, 18 días, en un log sin lector.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/docs/state/imagenes.txt b/docs/state/imagenes.txt
new file mode 100644
index 00000000..bd3b1646
--- /dev/null
+++ b/docs/state/imagenes.txt
@@ -0,0 +1,13 @@
+# imagenes.txt — las imágenes que el §4 del SDD 33 vigila, una ruta de rootfs por línea.
+#
+# `scripts/imagen-rancia.py` lee el `TAKANA_CIERRE` que cada una declara en su `etc/os-release` y
+# lo compara con el cierre vigente de su perfil. Dice cuáles rearmar; no rearma: eso son horas de
+# worker y pide los tres arneses antes de publicar.
+#
+# Está VACÍO a propósito, y no por olvido: al 2026-10-10 no hay ninguna imagen publicada (el sitio
+# no ofrece descargas; en `/srv/repo` hay paquetes `.tkn`). El fichero existe para que el día que se
+# arme una, vigilarla sea añadir una línea en vez de recordar que esto existe.
+#
+# Ejemplo:
+# /opt/takana/work/out/escritorio-kde
+# /srv/medios/takana-escritorio-sway
diff --git a/scripts/farm/cosecha-cron.sh b/scripts/farm/cosecha-cron.sh
index 9bac60ec..f833cc7d 100755
--- a/scripts/farm/cosecha-cron.sh
+++ b/scripts/farm/cosecha-cron.sh
@@ -403,6 +403,29 @@ if [ -x "$HAMMER" ]; then
fi
rm -f work/.cierres.tmp work/.cierres.salida
+ # ── ¿ALGUNA IMAGEN QUEDÓ RANCIA? (2026-10-10, SDD 33 §4) ─────────────────────────────────────
+ # Cierra el bucle del §4: `cierre-perfiles.py` dice cuál es el cierre de hoy, `hydrate-profile.py`
+ # hace que la imagen declare el suyo, y esto COMPARA. Sin este paso las otras dos piezas son dos
+ # ficheros que no se encuentran.
+ #
+ # ⚠ PUERTA DIARIA, y no por coste —son milisegundos— sino para no imprimir «0 imágenes» 48 veces
+ # al día. Hoy el registro está vacío a propósito: no hay imágenes publicadas. Una línea diaria
+ # mantiene la capacidad VIVA y visible; 48 la vuelven invisible, que es el modo de fallo que este
+ # guion ya sufrió con el aviso del repo (una línea por hora durante 18 días en un log sin lector).
+ IMG_SELLO="work/.imagen-rancia.sello"
+ if [ ! -f "$IMG_SELLO" ] || [ -n "$(find "$IMG_SELLO" -mtime +0 2>/dev/null)" ]; then
+ touch "$IMG_SELLO"
+ if python3 scripts/imagen-rancia.py --registro docs/state/imagenes.txt \
+ > work/.imagen-rancia.salida 2>&1; then
+ echo " imagen-rancia ✓ $(sed -n '1s/^== //;1s/ ==$//;1p' work/.imagen-rancia.salida)"
+ sed -n '/RANCIA\|a rearmar\|sin declarar/p' work/.imagen-rancia.salida | sed 's/^/ /' | head -6
+ else
+ echo " ⚠ imagen-rancia falló"
+ tail -2 work/.imagen-rancia.salida | sed 's/^/ /'
+ fi
+ rm -f work/.imagen-rancia.salida
+ fi
+
RIO_SELLO="work/.upstream-vigia.sello"
if [ ! -f "$RIO_SELLO" ] || [ -n "$(find "$RIO_SELLO" -mtime +0 2>/dev/null)" ]; then
echo " río arriba: puerta diaria abierta, preguntando a 660 fuentes git (~20 s)…"
diff --git a/scripts/imagen-rancia.py b/scripts/imagen-rancia.py
new file mode 100755
index 00000000..ad5079d2
--- /dev/null
+++ b/scripts/imagen-rancia.py
@@ -0,0 +1,170 @@
+#!/usr/bin/env python3
+# imagen-rancia.py — ¿esta imagen sigue siendo la clausura que dice ser?
+#
+# ══ LA PIEZA QUE HACÍA QUE LAS OTRAS DOS NO SIRVIERAN ═════════════════════════════════════════
+# El SDD 33 §4 es un bucle de tres pasos:
+#
+# h = hash de la clausura del perfil ← `cierre-perfiles.py`, publica `cierres.json`
+# el hash de la imagen publicada ← `hydrate-profile.py`, escribe `/etc/os-release`
+# si h ≠ ese ← ESTO
+#
+# Las dos primeras se construyeron el 2026-10-10 y **nadie comparaba**. Dos mitades que no se
+# encuentran son dos ficheros, no un disparador: sin este paso el §4 seguía siendo prosa, aunque
+# cada pieza por separado funcionara y estuviera probada.
+#
+# No construye, no rearma y no publica: dice CUÁLES rearmar. Rearmar es caro (horas de worker) y
+# publicar pide los tres arneses antes — 25 comprobaciones del banco, 13 del salto, 13 del USB—,
+# así que el §4 pone la decisión después del aviso, no antes.
+#
+# ══ QUÉ LEE Y QUÉ NO ══════════════════════════════════════════════════════════════════════════
+# Lee el `TAKANA_CIERRE` que la imagen declara y lo compara con el cierre de HOY para su perfil. No
+# recorre el rootfs ni verifica su contenido: eso es `vigia-imagen.py` (¿se puede USAR?) y
+# `vigia-sonames.py` (¿arranca?), que contestan otras preguntas. Acá la pregunta es una sola —
+# **¿lo que esta imagen lleva adentro sigue siendo lo vigente?**— y la contesta sin tocar un byte.
+#
+# ⚠ Una imagen **sin `TAKANA_CIERRE`** no es «al día»: es una imagen anterior a que las imágenes
+# declararan nada, y se dice así. Tratar la ausencia como conformidad es la forma exacta de que un
+# medio rancio pase por bueno — la misma trampa que un artefacto vacío en el store.
+#
+# Uso: scripts/imagen-rancia.py <rootfs>…
+# scripts/imagen-rancia.py --registro docs/state/imagenes.txt
+# La superficie va en INGLÉS (CLAUDE.md regla 4); lo que se lee, en castellano.
+import argparse, importlib.util, json, os, sys
+
+RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(RAIZ, 'scripts'))
+
+# Se importa por ruta porque el fichero lleva guion y no es importable como módulo. Se reusa en vez
+# de reimplementar por lo que ya costó una vez: dos digests «equivalentes» divergieron por el
+# prefijo `b3:`, con los hashes idénticos uno a uno.
+_spec = importlib.util.spec_from_file_location(
+ 'cierre_perfiles', os.path.join(RAIZ, 'scripts', 'cierre-perfiles.py'))
+_cp = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(_cp)
+
+
+def leer_os_release(ruta):
+ """{clave: valor} de un `os-release`, o None si no hay. Formato shell KEY=VALUE con comillas
+ opcionales; no se usa un parser de shell porque el fichero lo escribimos nosotros y meter un
+ intérprete para leer siete líneas es superficie de ataque gratis."""
+ f = os.path.join(ruta, 'etc', 'os-release')
+ if not os.path.isfile(f):
+ return None
+ d = {}
+ with open(f, encoding='utf-8', errors='replace') as fh:
+ for linea in fh:
+ linea = linea.strip()
+ if not linea or linea.startswith('#') or '=' not in linea:
+ continue
+ k, _, v = linea.partition('=')
+ d[k.strip()] = v.strip().strip('"').strip("'")
+ return d
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument('rootfs', nargs='*', help='directorios de imagen a comprobar')
+ ap.add_argument('--registro', help='fichero con una ruta de imagen por línea (# = comentario)')
+ ap.add_argument('--json', help='escribir el informe en este fichero')
+ a = ap.parse_args()
+
+ rutas = list(a.rootfs)
+ if a.registro and os.path.exists(a.registro):
+ with open(a.registro, encoding='utf-8') as fh:
+ for linea in fh:
+ linea = linea.split('#', 1)[0].strip()
+ if linea:
+ rutas.append(linea)
+
+ # El cierre de HOY, una vez para todas las imágenes.
+ filas_cierre, conflictos = _cp.cierres()
+ hoy = {f['perfil']: f for f in filas_cierre}
+
+ if not rutas:
+ # ⚠ «0 imágenes» es una RESPUESTA y el silencio no. Si este guion no dijera nada cuando no
+ # hay nada registrado, no se distinguiría de un guion roto — y el día que el registro se
+ # vacíe por accidente, nadie se enteraría de que dejó de vigilar.
+ print('== imagen rancia: 0 imágenes para comprobar ==')
+ print(' (pasá rutas de rootfs, o poné una por línea en --registro)')
+ print(' el cierre de hoy está calculado para %d perfil(es): %s'
+ % (len(hoy), ', '.join(sorted(hoy))))
+ return 0
+
+ informe, rancias, sin_declarar = [], [], []
+ print('== imagen rancia: %d imagen(es) ==' % len(rutas))
+ for ruta in rutas:
+ fila = {'rootfs': ruta}
+ osr = leer_os_release(ruta)
+ if osr is None:
+ fila['estado'] = 'sin-os-release'
+ print(' %-44s sin `etc/os-release` — ¿es un rootfs de perfil?' % ruta)
+ informe.append(fila)
+ continue
+ perfil = osr.get('TAKANA_PERFIL')
+ declarado = osr.get('TAKANA_CIERRE')
+ fila['perfil'], fila['declarado'] = perfil, declarado
+ if not perfil or not declarado:
+ fila['estado'] = 'no-declara'
+ sin_declarar.append(ruta)
+ print(' %-44s NO declara su clausura (imagen anterior al §4) ⇒ no se puede saber'
+ % ruta)
+ informe.append(fila)
+ continue
+ if declarado == 'incompleto':
+ fila['estado'] = 'declarada-incompleta'
+ print(' %-44s %s · se armó INCOMPLETA (faltan %s artefactos) ⇒ rearmar'
+ % (ruta, perfil, osr.get('TAKANA_CIERRE_FALTAN', '?')))
+ rancias.append(ruta)
+ informe.append(fila)
+ continue
+ actual = hoy.get(perfil)
+ if actual is None:
+ fila['estado'] = 'perfil-desconocido'
+ print(' %-44s declara perfil `%s`, que hoy no existe en targets.toml' % (ruta, perfil))
+ informe.append(fila)
+ continue
+ fila['vigente'] = actual['cierre']
+ if actual['estado'] != 'ok':
+ # No se puede afirmar nada: el cierre de hoy no se pudo resumir. Decir «al día» sería
+ # inventarlo y decir «rancia» también.
+ fila['estado'] = 'cierre-de-hoy-incompleto'
+ print(' %-44s %s · el cierre de HOY está incompleto ⇒ no se puede comparar'
+ % (ruta, perfil))
+ informe.append(fila)
+ continue
+ if declarado == actual['cierre']:
+ fila['estado'] = 'al-dia'
+ print(' %-44s %s · AL DÍA %s…' % (ruta, perfil, declarado[:22]))
+ else:
+ fila['estado'] = 'rancia'
+ rancias.append(ruta)
+ print(' %-44s %s · RANCIA' % (ruta, perfil))
+ print(' lleva %s…' % declarado[:30])
+ print(' hoy es %s…' % actual['cierre'][:30])
+ informe.append(fila)
+
+ if conflictos:
+ print('\n ⚠ %d par(es) (cola,nombre) con hash contradictorio entre grafos — el cierre de '
+ 'hoy puede no ser fiable' % len(conflictos))
+ if rancias:
+ print('\n %d imagen(es) a rearmar, probar con los tres arneses y publicar (SDD 33 §4):'
+ % len(rancias))
+ for r in rancias:
+ print(' %s' % r)
+ if sin_declarar:
+ print('\n %d imagen(es) sin declarar su clausura: rehidratarlas las deja comprobables'
+ % len(sin_declarar))
+
+ if a.json:
+ with open(a.json, 'w', encoding='utf-8') as fh:
+ json.dump({'schema': 'takana-imagen-rancia/1', 'imagenes': informe,
+ 'rancias': len(rancias), 'sin_declarar': len(sin_declarar)}, fh,
+ ensure_ascii=False, indent=1)
+ print('\n informe: %s' % a.json)
+ # Sale 0 siempre: esto INFORMA. Una imagen rancia es lo normal cuando se trabaja — lo que no
+ # debe ser normal es no saberlo.
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())