gioser

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…

feat(estado): la imagen declara de qué clausura salió — cierra la otra mitad del SDD 33 §4

El §4 compara «h ≠ el hash de la imagen publicada», y esa segunda mitad **no se podía hacer:
ninguna imagen declaraba nada**. Medido: ni un `/etc/os-release` se escribía, y los aciertos de
`grep os-release` en el repo eran todos LECTURAS para detectar el anfitrión. Sin esto el cron del
§4 vuelve a ser un calendario.

`hydrate-profile.py` lo escribe ahora, y va ahí —no en cada script de imagen— por el mismo motivo
que el bit de privilegio: por ese guion pasan TODAS las imágenes de perfil, y un paso que hay que
acordarse de invocar se olvida.

**El digest se calcula con la MISMA función que la vigía** (`digest_de`, importada por ruta porque
el fichero lleva guion), sobre lo que el script acaba de proyectar. Dos implementaciones
«equivalentes» divergirían sin que nada lo dijera, y entonces el §4 diría «rearmá» por una
diferencia de código y no de contenido.

⚠ **Nada derivado del reloj entra en el fichero.** Un rootfs hidratado tiene que ser reproducible:
una fecha —o un nombre tipo `takana 2026.10`— cambiaría los bytes de la imagen cada mes sin que la
clausura se mueva, que es exactamente la mentira que el §1 señala («publicar sólo el nombre es la
forma barata de que el sitio empiece a mentir»). El nombre legible es una ETIQUETA y entra sólo con
`--version`.

⚠ **Un rootfs con artefactos faltantes NO declara digest**: pone `TAKANA_CIERRE=incompleto` y
cuántos faltan. Declararlo sería lo peor de los dos mundos — un número con pinta de identidad sobre
una imagen que no es la clausura que dice ser, y el §4 comparando contra una mentira.

**Y la tercera corrección a mi propia vigía en dos días, que esta vez la encontró el otro lado:**
mi digest incluía los `wanted` por nombre, y **`hydrate` los excluye explícitamente**
(`n[0] != "wanted"`), así que no van en la imagen y el número NUNCA habría coincidido con el que la
imagen declara — que es justo para lo que existe. Ahora quedan fuera del digest y se informan al
lado («declara sin receta: wanted/xwayland»): la información no se pierde y la comparación no se
rompe. Alinear con quien proyecta, en vez de razonar el caso por separado, es lo que lo cerró.

Medido de paso, y simplifica: en los diez perfiles el ÚNICO miembro no construible de cualquier
cierre de imagen es `wanted/xwayland`. Ni un `ajeno`, ni un desconocido — así que cualquier otro
caso que no se pueda hashear es clausura incompleta y sin digest, que es también lo que `hydrate`
hace (abortar sin `--allow-missing`).

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

8d4746bba976b963f0d2ebd5aba3c57cfd1ee5da · por Sergio · (hace 11 d) · padre 78e863b2c · ver el árbol en este commit

Delta semántico · 4

scripts/cierre-perfiles.py

scripts/hydrate-profile.py

  • cambió por dentro fn main

2 archivos · +109 −45

diff --git a/scripts/cierre-perfiles.py b/scripts/cierre-perfiles.py
index 40262b3f..aa6840bd 100755
--- a/scripts/cierre-perfiles.py
+++ b/scripts/cierre-perfiles.py
@@ -79,6 +79,26 @@ def mapa_hashes():
     return m, conflictos
 
 
+# ══ LA FUNCIÓN COMPARTIDA, Y POR QUÉ ES TAN CORTA ═════════════════════════════════════════════
+# `hydrate-profile.py` la importa para escribir el digest DENTRO de la imagen que acaba de armar.
+# Si hubiera dos implementaciones, el número de la imagen y el que esta vigía predice divergirían
+# sin que nada lo dijera — y entonces la comparación del §4 («h ≠ el de la imagen publicada»)
+# diría «rearmá» por una diferencia de código y no de contenido. Por eso es UNA función, de tres
+# líneas, en vez de dos «equivalentes».
+def digest_de(items):
+    """sha256 sobre `cola/nombre hash`, ORDENADO. `items` = iterable de (cola, nombre, hash).
+
+    Ordenado porque el orden de un dict no es contrato y sin ordenar el mismo cierre daría dos
+    números. Por (cola, nombre) y no por nombre porque `openssl` es una receta distinta en `corpus`
+    y en `incoming-kde`. Y `sha256:` y no `b3:` porque esto resume un conjunto y no direcciona nada
+    en el store: reusar el prefijo del ArtifactHash invitaría a buscarlo ahí.
+    """
+    h = hashlib.sha256()
+    for cola, nombre, hh in sorted(items):
+        h.update(('%s/%s %s\n' % (cola, nombre, hh)).encode())
+    return 'sha256:' + h.hexdigest()
+
+
 def cierres():
     por_perfil = collections.defaultdict(set)
     for clave, perfiles in yupana.membresia_imagen().items():
@@ -91,54 +111,37 @@ def cierres():
         miembros = sorted(por_perfil[perfil])
         det = {'perfil': perfil, 'nodos': len(miembros), 'vista': 'imagen'}
 
-        # ── Tres clases de miembro, y las tres se nombran ────────────────────────────────────
-        # ⚠ `wanted` = **declarado por el perfil y sin receta que lo provea** (`escritorio-kde`
-        # declara `xwayland`, que no existe como receta en ningún sitio; `yupana` lo llava con cola
-        # `wanted`, build-state lo llama `ajeno` en `incoming-kde` — el mismo nodo con dos
-        # etiquetas). No se le puede pedir hash y **no aporta bytes a la imagen**: no hay nada que
-        # instalar. ⇒ Entra por NOMBRE con su marca, igual que un `ajeno`, y el día que alguien le
-        # escriba la receta la marca pasa a ser un hash y el digest se mueve — que es exactamente
-        # lo que se quiere.
+        # ── `wanted` FUERA DEL DIGEST, y esto es alineación con quien manda ────────────────────
+        # `wanted` = declarado por el perfil y sin receta que lo provea (`escritorio-kde` declara
+        # `xwayland`). **`hydrate-profile.py` los excluye explícitamente** (`n[0] != "wanted"`), así
+        # que no van en la imagen — y un digest que los incluyera NUNCA coincidiría con el que la
+        # imagen declara, que es justo para lo que existe este número. Se informan al lado, no
+        # adentro: la información no se pierde y la comparación no se rompe.
         #
-        # Negarle el digest por esto sería repetir el error de la primera versión con los `ajeno`:
-        # dejar a `escritorio-kde` —el escritorio más grande— eternamente «incompleto» por un nodo,
-        # o sea volver inútil un informe correcto.
+        # Medido el 2026-10-10: en los diez perfiles, el ÚNICO miembro no construible de cualquier
+        # cierre de imagen es `wanted/xwayland`. Ni un `ajeno`, ni un desconocido. Por eso cualquier
+        # otro caso que no se pueda hashear se trata como clausura INCOMPLETA y sin digest — que es
+        # también lo que `hydrate` hace: aborta sin `--allow-missing`.
         wanted = sorted('%s/%s' % k for k in miembros if k[0] == 'wanted')
         resto = [k for k in miembros if k[0] != 'wanted']
-        desconocidos = sorted('%s/%s' % k for k in resto if k not in hashes)
-        ajenos = sorted('%s/%s' % k for k in resto
-                        if k in hashes and hashes[k][1] == 'ajeno')
-        propios = [k for k in resto if k in hashes and hashes[k][1] != 'ajeno']
         det['wanted'] = wanted
-        sin_hash = sorted('%s/%s' % k for k in propios if not hashes[k][0])
-        sin_sellar = sorted('%s/%s' % k for k in propios if hashes[k][1] != 'sealed')
-        det['ajenos'] = ajenos
-
-        if desconocidos or sin_hash or sin_sellar:
+        det['nodos_en_digest'] = len(resto)
+
+        impedidos = sorted('%s/%s (%s)' % (k[0], k[1], 'sin entrada en ningún grafo' if k not in hashes
+                                           else 'sin hash' if not hashes[k][0]
+                                           else 'estado=%s' % hashes[k][1])
+                           for k in resto
+                           if k not in hashes or not hashes[k][0] or hashes[k][1] != 'sealed')
+        if impedidos:
             det['estado'] = 'incompleto'
-            det['desconocidos'] = desconocidos[:10]
-            det['sin_hash'] = sin_hash[:10]
-            det['sin_sellar'] = sin_sellar[:10]
-            det['n_desconocidos'] = len(desconocidos)
-            det['n_sin_hash'], det['n_sin_sellar'] = len(sin_hash), len(sin_sellar)
+            det['impedidos'] = impedidos[:10]
+            det['n_impedidos'] = len(impedidos)
             det['cierre'] = None
             filas.append(det)
             continue
 
-        # Pares `cola/nombre hash`, ORDENADOS: el orden de un dict no es contrato, y sin ordenar el
-        # mismo cierre daría dos números distintos.
-        h = hashlib.sha256()
-        for k in sorted(propios):
-            h.update(('%s/%s %s\n' % (k[0], k[1], hashes[k][0])).encode())
-        for n in ajenos + ['%s (wanted)' % w for w in wanted]:
-            # Sin hash que poner: se marca la PRESENCIA. Un `ajeno` es un artefacto que no
-            # construimos nosotros (xwayland, rust-toolchain-bin): está EN la clausura y su
-            # contenido queda FUERA de lo que este número garantiza. Si la clausura suma o pierde
-            # un ajeno, el digest se mueve; su contenido no lo cubre, y el informe lo nombra.
-            h.update(('%s ajeno\n' % n).encode())
-        # `sha256:` y no `b3:` a propósito: esto resume un conjunto y no direcciona nada en el store.
         det['estado'] = 'ok'
-        det['cierre'] = 'sha256:' + h.hexdigest()
+        det['cierre'] = digest_de((k[0], k[1], hashes[k][0]) for k in resto)
         filas.append(det)
     return filas, conflictos
 
@@ -187,12 +190,10 @@ def main():
                 # provee es un dato, y esconderlo detrás de un digest «completo» lo perdería.
                 extra = '  · declara sin receta: %s' % ', '.join(f['wanted'])
             print('   %-20s %5d nodos  %s…%s%s'
-                  % (f['perfil'], f['nodos'], f['cierre'][:22], marca, extra))
+                  % (f['perfil'], f['nodos_en_digest'], f['cierre'][:22], marca, extra))
         else:
-            print('   %-20s %5d        incompleto (%d desconocidos, %d sin hash, %d sin sellar; '
-                  'p.ej. %s)' % (f['perfil'], f['nodos'], f['n_desconocidos'], f['n_sin_hash'],
-                                 f['n_sin_sellar'],
-                                 (f['desconocidos'] or f['sin_hash'] or f['sin_sellar'] or ['—'])[0]))
+            print('   %-20s %5d        incompleto: %d nodo(s) sin hashear; p.ej. %s'
+                  % (f['perfil'], f['nodos'], f['n_impedidos'], f['impedidos'][0]))
 
     if movidos:
         print('\n   %d perfil(es) con la clausura movida ⇒ rearmar su imagen, probar con los tres '
diff --git a/scripts/hydrate-profile.py b/scripts/hydrate-profile.py
index af6f7d50..acfc507a 100755
--- a/scripts/hydrate-profile.py
+++ b/scripts/hydrate-profile.py
@@ -224,7 +224,7 @@ def main():
         sys.exit(auditar_raices(opcion("--store",
                                        os.environ.get("HAMMER_STORE", str(ROOT / "store")))))
     if not libres:
-        sys.exit(__doc__ or "uso: scripts/hydrate-profile.py <perfil> [--into DIR]")
+        sys.exit(__doc__ or "uso: scripts/hydrate-profile.py <perfil> [--into DIR] [--version ETIQUETA]")
     perfil = libres[0]
     store = opcion("--store", os.environ.get("HAMMER_STORE", str(ROOT / "store")))
     # Dónde cae el rootfs por defecto. Se puede fijar por entorno porque el sitio CORRECTO depende
@@ -356,6 +356,69 @@ def main():
             muestra = ", ".join(extra[:3]) + (", …" if len(extra) > 3 else "")
             print("     %-28s %4d  (%s)" % (nombre, len(extra), muestra))
 
+    # ── LA IMAGEN DECLARA DE QUÉ CLAUSURA SALIÓ (SDD 33 §4, 2026-10-10) ────────────────────────
+    # El §4 decide rearmar los medios por el CIERRE y no por el calendario, y su comparación es
+    # «h ≠ el hash de la imagen publicada». **Esa segunda mitad no se podía hacer: ninguna imagen
+    # declaraba nada** — medido, ni un `/etc/os-release` se escribía; los aciertos de `grep
+    # os-release` en el repo eran todos LECTURAS para detectar el anfitrión. Sin esto, el cron del
+    # §4 vuelve a ser un calendario.
+    #
+    # Va ACÁ, y no en cada script de imagen, por el mismo motivo que el bit de privilegio de abajo:
+    # por este guion pasan TODAS las imágenes de perfil, y un paso que hay que acordarse de invocar
+    # se olvida.
+    #
+    # ⚠ **El digest se calcula con la MISMA función que `cierre-perfiles.py`** (`digest_de`), sobre
+    # lo que ESTE script acaba de proyectar. Dos implementaciones «equivalentes» divergirían sin que
+    # nada lo dijera, y entonces el §4 diría «rearmá» por una diferencia de código y no de
+    # contenido. Se importa por ruta porque el fichero lleva guion en el nombre y no es importable
+    # como módulo.
+    #
+    # ⚠ **NADA DERIVADO DEL RELOJ entra en este fichero.** Un rootfs hidratado tiene que ser
+    # reproducible: una fecha, o un nombre tipo `takana 2026.10`, cambiaría los bytes de la imagen
+    # cada mes sin que la clausura se mueva — justo la clase de mentira que el SDD 33 §1 señala
+    # («publicar sólo el nombre es la forma barata de que el sitio empiece a mentir»). El nombre
+    # legible es una ETIQUETA y entra sólo si alguien la pasa con `--version`.
+    import importlib.util as _ilu
+    _spec = _ilu.spec_from_file_location("cierre_perfiles", str(ROOT / "scripts/cierre-perfiles.py"))
+    _cp = _ilu.module_from_spec(_spec)
+    _spec.loader.exec_module(_cp)
+
+    # `artefactos` trae (nombre, hash, dir) y `nodos` las parejas (cola, nombre) en el mismo orden
+    # de construcción; se recupera la cola para que la clave sea la misma que usa la vigía.
+    cola_de = {n: q for q, n in nodos}
+    items = [(cola_de[nombre], nombre, h) for nombre, h, _d in artefactos]
+    version = opcion("--version", "")
+
+    lineas = ['NAME="takana"', 'ID=takana',
+              'PRETTY_NAME="takana · %s"' % perfil,
+              'HOME_URL="https://takana-os.net"',
+              'TAKANA_PERFIL=%s' % perfil,
+              'TAKANA_NODOS=%d' % len(items)]
+    if version:
+        # Etiqueta, no identidad: va sólo si se pide, para no meter el reloj en el rootfs.
+        lineas.insert(2, 'VERSION="%s"' % version)
+        lineas.insert(3, 'VERSION_ID=%s' % version)
+    if faltan:
+        # ⚠ Un rootfs al que le faltan artefactos NO declara digest. Declararlo sería lo peor de
+        # los dos mundos: un número con pinta de identidad sobre una imagen que no es la clausura
+        # que dice ser, y el §4 compararía contra una mentira. Se declara el HUECO.
+        lineas.append('TAKANA_CIERRE=incompleto')
+        lineas.append('TAKANA_CIERRE_FALTAN=%d' % len(faltan))
+    else:
+        lineas.append('TAKANA_CIERRE=%s' % _cp.digest_de(items))
+    if cierre != "image":
+        # El cierre de build es el control del experimento, no una imagen publicable: que el
+        # fichero lo diga, o alguien comparará un digest de peras con uno de manzanas.
+        lineas.append('TAKANA_CIERRE_VISTA=build')
+
+    os.makedirs(os.path.join(into, "etc"), exist_ok=True)
+    with open(os.path.join(into, "etc", "os-release"), "w", encoding="utf-8") as fh:
+        fh.write("\n".join(lineas) + "\n")
+    print("\n== identidad → %s/etc/os-release" % into)
+    for l in lineas:
+        if l.startswith("TAKANA_") or l.startswith("VERSION"):
+            print("   %s" % l)
+
     # ── EL BIT DE PRIVILEGIO (SDD 28 §5.11, respondido el 2026-09-22) ──────────────────────────
     # Se aplica ACÁ y no en la receta porque no puede vivir en el artefacto: `of_tree` sólo hashea el
     # bit de EJECUCIÓN (⇒ el hash no lo cubriría) y la hidratación es por HARDLINK (⇒ el `chmod`
@@ -387,7 +450,7 @@ def main():
     sys.exit(1 if (fallidos or salida_setuid or (faltan and not permitir_faltantes)) else 0)
 
 
-FLAGS_CON_VALOR = {"--into", "--store", "--link", "--closure"}
+FLAGS_CON_VALOR = {"--into", "--store", "--link", "--closure", "--version"}
 
 # Lo que un artefacto PUEDE poner en la raíz. Todo lo demás es una fase `install` que se equivocó de
 # destino, y en un rootfs fundido eso se lee como si fuera parte del sistema.