qorpa D8: sniper sellado al store, con la marca que evita que la cifra mienta

D8 decía que sniper «entra al store por `file_drop`». Dos correcciones, y la
primera es de vocabulario: **`file_drop` en hammer es otra cosa** — una
operación de `hammer apply` que coloca un fichero en el sistema instalado
verificando su hash. No tenía nada que ver con sellar. Lo que sella es lo de
siempre, una receta. Queda escrito en el ADR: un término inventado que suena a
mecanismo existente manda a buscar el código donde no está.

`recipes/steam-runtime-sniper.toml` sella el árbol del runtime (11196 ficheros)
pineado por el sha256 que ya estaba verificado. Entra donde Arch y Ubuntu no
pueden por una propiedad, no por simpatía: **no muta** —nadie le instala nada
adentro— así que el mismo tarball da siempre el mismo árbol y sellarlo es una
afirmación verdadera.

**La marca: `foreign = true`.** No cambia el build en un byte y **no entra en
`hash_inputs`** (describe procedencia, no identidad — hay test). Lo que cambia
es contable: `build-state.py` la clasifica `ajeno`, la resta del denominador de
las imágenes y la deja fuera del recuento de recetas. Sin eso, sellar un
prebuilt habría subido la cifra que todo el mundo lee como «cuánto
construimos» — el riesgo que el ADR escribió antes de que existiera la primera
instancia. Verificado: sigue diciendo 821 recetas, y aparte
`de las ajenas, 1 selladas al store (prebuilt pineado, sin procedencia de fuente)`.

Y la diferencia con el otro ajeno: `xwayland` no se hashea (no hay receta, y un
hash afirmaría que lo reproducimos); el sellado **sí conserva su hash**, porque
está en el store y que un artefacto exista mientras el grafo lo niega sería otra
forma de mentir. Comparten el estado, que es lo que protege la cifra.

**`hammer qorpa import --from-store <hash>`** lo consume, y ahí está el detalle
que hace que valga: la imagen se registra bajo el **sha256 del archivo de
upstream**, no bajo el ArtifactHash. Al revés, la imagen del store y la traída
con `pull` serían dos imágenes distintas con los mismos bytes y las instancias
de dos máquinas dejarían de coincidir — justo lo que el pin existe para evitar.
El árbol se **enlaza**: una imagen nunca se escribe (lo que escribe la instancia
va a su `upper`), así que compartir inodos con un artefacto sellado y de sólo
lectura es correcto por construcción y la imagen cuesta ~0 bytes. La contracara
conocida de `.dmerge`: mientras el artefacto siga en el store, borrar la imagen
no libera disco; `--copy` lo evita.

Licencia `LicenseRef-qorpa-ajena-no-enumerable` a propósito: adentro hay cientos
de paquetes Debian y no podemos enumerarlos; vacío se leería como «todavía no la
poblamos». SDD 20 lo recoge y afila la distinción: replicarla a nuestras
máquinas es lo que ya hace ADR 0013 con las fuentes; publicarla a terceros sigue
pidiendo licencia y marca.

29 tests verdes. El sellado en sí corre aparte, esperando el lock de la granja.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZjGRqvjWij9dew7NVFu4Q
This commit is contained in:
Sergio
2026-09-04 00:09:20 +00:00
co-authored by Claude Opus 5
parent 3329317737
commit fdce080d96
7 changed files with 378 additions and 5 deletions
+30 -1
View File
@@ -127,6 +127,12 @@ def load_recipes():
deps=d.get("deps", {}).get("build", []),
cls=classify(n, d),
queue=queue,
# `foreign = true`: la receta SELLA bytes ajenos ya compilados, no construye desde
# fuente (ADR 0015 D8; hoy sólo `steam-runtime-sniper`). Se lee acá para que la
# clasificación de abajo la saque del corpus: un prebuilt sellado no puede sumar a
# «cuántas recetas construimos» — es la cifra que el ADR pidió proteger antes de
# que existiera la primera instancia.
foreign=bool(d.get("foreign")),
)
return recs
@@ -306,6 +312,15 @@ def main():
if dep not in recs:
orphans.add(dep)
# Una receta `foreign` es AJENA aunque tenga fichero: hay bytes que sellamos y no construimos.
# Se marca ANTES del recuento para que no entre en el corpus por la puerta de atrás. Y no es lo
# mismo que un ajeno declarado en `qorpa-ajenos.toml`: aquél NO está en el store y éste SÍ, así
# que conserva su hash. Que un artefacto exista y el grafo lo niegue sería otra forma de mentir.
for r in recs.values():
if r.get("foreign"):
r["ajeno"] = True
r["cls"] = "ajeno"
n_recetas = sum(1 for r in recs.values() if not r.get("wanted") and not r.get("ajeno"))
print(f"== {n_recetas} recetas; computando hash vigente (~{n_recetas*18//1000}s)…", file=sys.stderr)
for n, r in recs.items():
@@ -315,7 +330,13 @@ def main():
# `ajeno` tampoco, y por una razón más fuerte: no tiene procedencia de fuente. Un hash
# afirmaría que lo reproducimos, y no lo reproducimos.
if r.get("ajeno"):
r["hash"] = None; r["state"] = "ajeno"; continue
# El ajeno DECLARADO no se hashea (no hay receta que hashear, y un hash afirmaría que
# lo reproducimos). El ajeno SELLADO sí: es un puntero al contenido exacto, y sin él no
# se lo podría encontrar en el store ni pedir por `qorpa import`. Lo que no cambia en
# ninguno de los dos casos es el estado: `ajeno`, fuera del corpus.
r["hash"] = vigente_hash(r["path"]) if r.get("foreign") else None
r["state"] = "ajeno"
continue
if "parse_error" in r:
r["hash"] = None; r["state"] = "unhashable"; continue
h = vigente_hash(r["path"])
@@ -397,6 +418,8 @@ def main():
sin_perfil=sin_perfil,
orphan_deps=sorted(orphans),
topo_ok=order is not None,
# `foreign` SÍ viaja al nodo (a diferencia de `ajeno`, que es derivado): es lo que le dice
# a quien lee el grafo que ese `sealed` de ahí son bytes de otro, no un build nuestro.
nodes={n: {k: v for k, v in r.items() if k not in ("path", "wanted", "ajeno")}
for n, r in sorted(recs.items())},
)
@@ -414,6 +437,12 @@ def main():
# sembrado, es la señal de que un manifiesto afirma algo que ya no existe en ningún lado.
print(f" de los sellados, {len(REMOTO_ONLY)} NO están en el disco de esta máquina "
f"(los avalan los manifiestos)")
sellados_ajenos = [n for n, r in recs.items() if r.get("foreign") and r.get("hash")]
if sellados_ajenos:
# Se dice aparte y con nombre: un prebuilt ajeno en el store es una excepción que hay que
# poder auditar de un vistazo, no un renglón más del corpus.
print(f" de las ajenas, {len(sellados_ajenos)} selladas al store (prebuilt pineado, "
f"sin procedencia de fuente): {', '.join(sorted(sellados_ajenos))}")
if debt_by_cls:
print(" deuda por clase:", " ".join(f"{k}={v}" for k, v in sorted(debt_by_cls.items())))
if len(by_queue) > 1: