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 1378Shell 129Rust 79Markdown 732463 Definitions

atuq: una descarga deja de ser un archivo con nombre y pasa a ser un objeto con identidad

La unidad 7 del SDD 26 (§6.2). Cuando una descarga TERMINA, la extensión
`descargas@atuq.tawasuyu` le pasa al host la ruta, el nombre y de dónde salió; el host la ingiere a
un CAS BLAKE3 y contesta el hash, el tamaño y **cuántas veces se bajó ese mismo contenido**. El mismo
contenido con otro nombre es UN objeto y dos nombres, y el navegador lo dice — que es exactamente lo
que ningún navegador sabe hacer: para todos una descarga es un blob con nombre, y por eso la bajás
dos veces y no te enterás.

No es un almacén nuevo: es `arje-cas`, el mismo formato y el mismo hash que usan arje, takana y
tejido. El `<hex>` del CAS ES el `expected_hash` de un `.swm`.

⚠ TRES DECISIONES QUE SALIERON DE MEDIR, NO DE DISEÑAR:

1. **La raíz del CAS de descargas no es la del sistema.** `arje_cas::gc` borra todo blob que no esté
   en el set `reachable` de su llamador, y el único que existe (`arje-brain`, `GcCas`) lo arma con la
   cadena de audit y las raíces vivas del grafo. Una descarga del usuario no está en ninguno: el
   primer GC se la llevaría, en silencio. Van a `<estado>/descargas-cas` — mismo formato (mover un
   objeto al CAS del sistema es un `rename`), fuera del alcance del GC de otro. El precio queda
   escrito: tejido sirve el CAS por defecto, así que compartir una descarga hoy exige apuntarlo ahí.
2. **El fichero del usuario no se toca**: se copia y se deja donde estaba. El guardián lo comprueba
   byte a byte. Borrar o mover lo que alguien acaba de bajar no es decisión de un navegador.
3. **La extensión observa, no intercepta.** Se entera cuando la descarga terminó. Meterse en el medio
   obligaría a decidir qué pasa si el CAS falla, y la respuesta correcta —que la descarga siga igual—
   es lo que se consigue no metiéndose.

Y EL HALLAZGO QUE COSTÓ LA TARDE, que no es de esta unidad: **en `--headless` el navegador se cae con
SIGSEGV en cuanto una descarga termina.** Se atribuyó con dos controles antes de tocar nada:

    --headless, con la extensión      → baja el fichero, TERMINADA, Segmentation fault (139)
    --headless, SIN la extensión      → baja el fichero, Segmentation fault (139)   ← no es nuestro
    --headless, panel de descargas apagado → Segmentation fault (139)
    sway headless (compositor REAL)   → baja, INGIERE al CAS, y no se cae          ← no es del producto

Sin el primer control esto se leía como «la extensión de descargas rompe el navegador» (perseguir un
bug que no existe); sin el último, como «atuq no puede descargar» (reportar un bug de producto que
tampoco existe). Por eso `scripts/test-atuq-descargas.py` corre sobre sway y no sobre `--headless`, y
por eso lo dice en su cabecera: un arnés distinto al de los demás guardianes, sin explicación, es una
invitación a «simplificarlo» de vuelta al que se cae. Queda en el §6.10.bis del SDD, que es donde vive
lo que atuq NO puede hacer.

El guardián baja de verdad (`Content-Disposition: attachment`, no una llamada a la API) dos veces
dentro de un mismo compositor, y trae control negativo: con OTRO contenido la segunda vez exige
`dedup=false` y dos objetos. Sin él, una sonda que dijera «ya lo tenías» siempre se vería idéntica a
una que funciona.

Del lado de tawasuyu (`ffa939c6`, pineado acá): `cas.ingest`/`cas.list` en el host y
`arje-cas::almacenar_fichero_en` — ingesta en streaming, porque `store` toma `&[u8]` y una ISO de
4 GiB serían 4 GiB de `Vec`. 5 tests nuevos en arje-cas y 5 en puriy-costura.

Y EL BUG QUE EL GUARDIÁN DESTAPÓ, que vale más que la función: **hay un host por PUERTO, no uno por
perfil.** Con `sct` y `descargas` hablando hay dos procesos vivos a la vez, cada uno con su copia en
memoria del estado, y cada uno escribía el fichero ENTERO al guardar: el de descargas borraba el
registro TOFU de `sct` al salir, y el de `sct` revertía el índice de descargas. Los dos contestaban
bien — el daño estaba sólo en el disco. Se vio porque el guardián lee el índice EN DISCO en vez de
creerle a la respuesta: decía `veces=2` y el fichero decía 1. Arreglado en tawasuyu (`55b918e8`,
pineado acá): cada proceso escribe sólo la parte que tocó, y el test que lo fija se comprobó
ROMPIENDO el arreglo a propósito — porque la primera versión de ese test abría los hosts en secuencia
y pasaba con el bug puesto.

897fad178a3b28360a0e8d252c3a0a728299bebd · by Sergio · (1 month ago) · parent 655a682aa · browse the tree at this commit

Semantic delta · 15

recipes/atuq/extensions/descargas/fondo.js

scripts/test-atuq-descargas.py

8 files · +601 −5

diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md
index fc5fbcb2..ff64d2b9 100644
--- a/docs/26-atuq-envoltorio-gecko.md
+++ b/docs/26-atuq-envoltorio-gecko.md
@@ -557,6 +557,69 @@ dos veces y no lo sabés.
 Ésa, y no «tener torrent», es la razón por la que 6.9 vale: Vivaldi ya tiene torrent, pero termina en
 una carpeta.
 
+#### 6.2.bis Hecha (2026-09-10) — y el almacén NO es el del sistema, por una pérdida de datos medida
+
+`cas.ingest` en el host + la extensión `descargas@atuq.tawasuyu`, que observa las descargas
+TERMINADAS y le pasa la ruta. El objeto entra a un CAS BLAKE3 y el navegador contesta el hash, el
+tamaño, con qué nombre apareció la primera vez y **cuántas veces se bajó ese mismo contenido**.
+
+    1ª descarga  contenido A como `uno.bin`   ⇒ dedup=false, veces=1
+    2ª descarga  contenido A como `dos.bin`   ⇒ dedup=TRUE, veces=2, UN solo blob en disco
+
+**No es un almacén nuevo**: es `arje-cas`, el mismo formato y el mismo hash que usan arje, takana y
+`tejido` — lo dice el propio código de tejido («esto NO es un almacén nuevo»). Un objeto que entra por
+acá queda donde los otros lo van a buscar, y el `<hex>` del CAS **es** el `expected_hash` de un `.swm`.
+
+**Tres cosas que este trabajo dejó, y ninguna es la feature:**
+
+1. **`arje-cas` gana ingesta en streaming.** `store` toma `&[u8]`, así que ingerir un fichero obliga a
+   leerlo entero: para los binarios de arje da igual, para lo que baja un navegador no —una ISO de
+   4 GiB serían 4 GiB de `Vec`—. `almacenar_fichero_en` hace UNA pasada (lee, hashea y escribe a la
+   vez, a un temporal con el PID, y recién entonces renombra al `<hex>`) y devuelve
+   `(hash, bytes, ya_estaba)`: ese `ya_estaba` **es** la deduplicación vista desde afuera.
+2. ⚠ **La raíz del CAS de descargas no es la del sistema, y es por una pérdida de datos medida.**
+   `arje_cas::gc` borra todo blob que no esté en el set `reachable` de su llamador, y el único
+   llamador que existe hoy —`arje-brain::introspect`, `IntrospectRequest::GcCas`— lo arma con la
+   cadena de audit + las raíces vivas del grafo + lo que le pase el caller. **Una descarga del usuario
+   no está en ninguno de esos conjuntos: el primer `GcCas` se la llevaría, en silencio.** Por eso van
+   a `<estado>/descargas-cas`: mismo formato (mover un objeto al CAS del sistema es un `rename`),
+   fuera del alcance del GC de otro. Lo que se pierde, dicho: `tejido` sirve el CAS por defecto, así
+   que compartir una descarga hoy exige apuntarlo a esta raíz (`ENTE_CAS_ROOT`, la perilla que su
+   propio código documenta) hasta que el índice de descargas sea una raíz que el GC respete.
+3. **No se toca el fichero del usuario.** Se copia al CAS y se deja donde estaba. Borrar o mover lo
+   que alguien acaba de bajar —aunque «ya esté en el CAS»— no es una decisión que le toque a un
+   navegador tomar por su cuenta, y el guardián lo comprueba byte a byte.
+
+El índice va en **JSON** y no en postcard a propósito: es la lista de lo que el usuario bajó y tiene
+que poder leerse con `cat` el día que el binario no arranque. El estado de `sct` es interno; esto es
+suyo.
+
+**Y ahí apareció el bug que valía más que la función: hay UN HOST POR PUERTO, no uno por perfil.**
+Con `sct` y `descargas` hablando hay dos procesos vivos a la vez, cada uno con su copia en memoria
+del estado, y cada uno escribía el fichero ENTERO al guardar. El de descargas **borraba el registro
+TOFU de `sct`** al salir; el de `sct` **revertía el índice de descargas** al valor que tenía cuando
+arrancó. Los dos contestaban bien: el daño estaba sólo en el disco.
+
+Se encontró porque el guardián lee el índice **en disco** en vez de creerle a la respuesta del host —
+la respuesta decía `veces=2` y el fichero decía 1. Aseverar sobre lo que el proceso contestó habría
+dejado pasar una pérdida de datos silenciosa. El arreglo es que cada proceso escriba **sólo la parte
+que tocó**, y lo que eso NO cubre queda dicho: dos procesos que toquen la MISMA parte se seguirían
+pisando, y ese día hace falta un dueño único del estado, no más banderas.
+
+⚠ **Y el test de ese arreglo también costó, con una lección propia:** la primera versión abría los dos
+hosts en SECUENCIA y **pasaba con el bug puesto**, porque el segundo cargaba de disco lo que el
+primero acababa de escribir. El bug necesita que las vidas se SOLAPEN. Se comprobó rompiendo el
+arreglo a propósito —con la rotura el test falla nombrando la causa, sin ella pasa—, que es la única
+forma de saber si un guardián sirve.
+
+`scripts/test-atuq-descargas.py` baja de verdad —`Content-Disposition: attachment`, no una llamada a
+la API— dos veces, y trae control negativo: con OTRO contenido la segunda vez, exige `dedup=false` y
+dos objetos. Sin él, una sonda que dijera «ya lo tenías» siempre se vería idéntica a una que funciona.
+
+⚠ Y corre sobre **sway headless**, no sobre `--headless`, porque en `--headless` una descarga
+terminada mata al navegador con SIGSEGV — con y sin nuestra extensión. Está medido y atribuido en
+el §6.10.bis.
+
 ### 6.3 El archivo personal es la razón por la que alguien usaría `atuq`
 
 Cada página visitada se congela en el CAS y entra al DAG de `khipu`; después le preguntás a tu
@@ -778,6 +841,30 @@ detalle del build.
 sí. La pregunta que faltaba era «¿y qué NO puede hacer?», que ninguna métrica del repo respondía
 porque todas miran presencia y ésta mira ausencia declarada por el propio binario.
 
+#### 6.10.bis Una descarga terminada MATA a `atuq --headless` (2026-09-10)
+
+Medido al escribir el guardián del §6.2, y vale la pena por cómo se atribuyó, no por el bug:
+
+| corrida | qué pasa |
+|---|---|
+| `--headless`, con la extensión de descargas | baja el fichero, la extensión dice `TERMINADA`, y el proceso muere con **SIGSEGV (139)** |
+| `--headless`, **sin** la extensión (control) | baja el fichero, y muere igual |
+| `--headless`, con el panel de descargas apagado (`browser.download.alwaysOpenPanel=false`) | muere igual |
+| **sway headless (compositor REAL, salida offscreen)** | baja, ingiere al CAS y **no se cae** |
+
+Las dos primeras filas son las que importan: **el control sin la extensión descarta que sea nuestro**,
+y la última descarta que sea del producto. Es el modo `--headless` de Gecko, en esta jaula, cuando una
+descarga completa. El fichero se baja bien en los cuatro casos — lo que muere es el proceso después.
+
+Sin el control, esto se lee de dos maneras y las dos son caras: «la extensión de descargas rompe el
+navegador» (perseguir un bug que no existe) o «atuq no puede descargar» (reportar un bug de producto
+que tampoco existe). Por eso `scripts/test-atuq-descargas.py` corre sobre sway y no sobre `--headless`,
+y por eso dice en su cabecera POR QUÉ — un arnés distinto al de los demás guardianes, sin explicación,
+es una invitación a «simplificarlo» de vuelta al que se cae.
+
+⚠ Lo que NO se midió: si el crash existe también en el `--headless` de una máquina sin jaula. No hace
+falta para lo que se decidió acá, y afirmarlo sin medirlo sería justo lo que este documento no hace.
+
 ### 6.11 Y ahora la pregunta que faltaba: ¿REPRODUCE? (2026-09-07)
 
 El §6.10 preguntó «¿qué NO puede hacer?» y contestó leyendo cadenas. Media pregunta. La otra mitad
@@ -1167,7 +1254,7 @@ siguiente.
 | 4.h | **Quién instala las extensiones, medido** ✅ 2026-09-09 — tres escenarios en `scripts/test-atuq-instalacion.py`: instala el ESCANEO de `distribution/extensions/`, `install_url` con `file://` no instala nada. El README de la receta afirmaba lo contrario y quedó corregido | el mecanismo del que cuelga la extensión de `sct` deja de ser una nota al pie y pasa a estar vigilado | — |
 | 5 | **Host de native messaging — HECHO 2026-09-10.** `puriy-costura` en tawasuyu (`ebe41e96`) + el cable `shared/foreign-webext`, 14 tests sin navegador; y del lado de acá `recipes/puriy-costura.toml` ⇒ `b3:3b633431`, ELF estático de 952 K **verificado como artefacto**: contesta un marco de 4 bytes y hace la promesa del §6.1 completa (4 visitas aprendiendo → `stable` → un script cambiado = un evento). El camino ya estaba comprobado en el §7.bis con sonda y control negativo | los verbos del §6 | 4 ✅ |
 | 6 | **`sct` v1 — HECHA 2026-09-10.** Extensión `sct@atuq.tawasuyu` (`filterResponseData` → native messaging) + el manifiesto en `/usr/lib/mozilla/native-messaging-hosts/` + el lanzador que le pone el `--state` que el manifiesto no puede llevar. Guardián `scripts/test-atuq-sct.py`: servidor HTTP real, seis cargas, **una carga = una visita** vigilado, la insignia releída, y dos controles (la 5ª repite el mismo script y no alerta; sin manifiesto no hay veredicto) | el diferenciador que nadie tiene, andando | 5 ✅ |
-| 7 | Descargas al CAS | 6.2, y alimenta 6.9 | 5 |
+| 7 | **Descargas al CAS — HECHA 2026-09-10.** `cas.ingest`/`cas.list` en el host + la extensión `descargas@atuq.tawasuyu`; ingesta en STREAMING (`arje-cas::almacenar_fichero_en`, nueva) para no cargar una ISO entera en RAM; raíz propia porque el `GcCas` de arje borraría las descargas en silencio; el fichero del usuario no se toca. Guardián `scripts/test-atuq-descargas.py` con descarga real y control negativo | 6.2, y alimenta 6.9 | 5 ✅ |
 | 8 | Archivo + RAG | 6.3 | 5, 7 |
 | 9 | Torrent, medios, foco | 6.5–6.7, 6.9 | 5 |
 | 9.a | **Proxy por contenedor ✅ v0.5** — contenedores por política + extensión con `proxy.onRequest` | 6.8, y NO dependía de 5: es API de Firefox | — |
diff --git a/recipes/atuq/README.md b/recipes/atuq/README.md
index da2e10af..a2887124 100644
--- a/recipes/atuq/README.md
+++ b/recipes/atuq/README.md
@@ -25,6 +25,7 @@ el vigente (por eso el ciclo es: editar todo → resellar una vez → medir), y
 | `extensions/inicio/` | `<appdir>/distribution/extensions/inicio@atuq.tawasuyu.xpi` | Página de inicio y pestaña nueva. |
 | `extensions/proxy/` | `<appdir>/distribution/extensions/proxy@atuq.tawasuyu.xpi` | **Proxy por contenedor** (SDD 26 §6.8). |
 | `extensions/sct/` | `<appdir>/distribution/extensions/sct@atuq.tawasuyu.xpi` | **Transparencia de scripts** (SDD 26 §6.1). |
+| `extensions/descargas/` | `<appdir>/distribution/extensions/descargas@atuq.tawasuyu.xpi` | **Descargas con identidad** (SDD 26 §6.2). |
 | `native-messaging/*.json` | `/usr/lib/mozilla/native-messaging-hosts/` | El manifiesto del host nativo. **Fuera del appdir** — ver abajo. |
 | `bin/puriy-costura-host` | `<appdir>/puriy-costura-host` | El lanzador del host: le pone el `--state` que el manifiesto no puede. |
 
@@ -160,6 +161,34 @@ trae dos controles: la quinta carga repite el MISMO script ya estable y **no** d
 `--negative-control` borra el manifiesto y exige que no haya veredicto — o sea que el veredicto de la
 corrida positiva viene del host y no de la extensión inventándolo.
 
+## `descargas` — una descarga con identidad (§6.2)
+
+Cuando una descarga TERMINA, la extensión le pasa al host la ruta, el nombre y de dónde salió; el host
+la ingiere a un CAS BLAKE3 y contesta el hash, el tamaño y **cuántas veces se bajó ese mismo
+contenido**. El mismo contenido con otro nombre es **un objeto y dos nombres**, y el navegador lo dice.
+Ningún navegador trata una descarga como algo con identidad: para todos es un blob con nombre, y por
+eso lo bajás dos veces y no lo sabés.
+
+No es un almacén nuevo: es `arje-cas`, el mismo formato y el mismo hash que usan arje, takana y
+`tejido`.
+
+⚠ **Tres cosas que hay que saber, y las tres están medidas:**
+
+1. **La raíz del CAS de descargas NO es la del sistema.** `arje_cas::gc` borra todo blob que no esté
+   en el set `reachable` de su llamador, y el único que existe hoy (`arje-brain`, `GcCas`) lo arma con
+   la cadena de audit y las raíces vivas del grafo. Una descarga no está en ninguno: **el primer
+   `GcCas` se la llevaría, en silencio.** Por eso van a `<estado>/descargas-cas`. El precio, dicho:
+   `tejido` sirve el CAS por defecto, así que compartir una descarga hoy exige apuntarlo a esa raíz.
+2. **El fichero que bajaste no se toca.** Se copia al CAS y se deja donde estaba. El guardián lo
+   comprueba byte a byte.
+3. **La extensión observa, no intercepta.** Se entera cuando la descarga terminó. Meterse en el medio
+   obligaría a decidir qué pasa si el CAS falla, y la respuesta correcta —que la descarga siga igual—
+   es exactamente lo que se consigue no metiéndose.
+
+`scripts/test-atuq-descargas.py` baja de verdad (`Content-Disposition: attachment`) dos veces y exige
+`dedup=true` la segunda; con `--negative-control` la segunda descarga es otro contenido y exige lo
+contrario.
+
 ## Por qué el CSS no es `userChrome.css`
 
 `userChrome.css` vive en el PERFIL del usuario y exige que el usuario prenda
@@ -219,8 +248,11 @@ pestañas están fijadas borra el wrapper y devuelve `null`. `--negative-control
 
 - **La v2 de `sct`**: el gancho en el script loader de Gecko, que es lo único que ve los scripts
   `inline` y lo único que puede BLOQUEAR. Eso sí es parche del árbol (§2.bis).
-- **Los otros verbos del §6** sobre el host que ya está puesto: descargas al CAS (6.2), archivo con
-  RAG (6.3), torrent (6.9), IA local en la barra lateral (6.7).
+- **Los otros verbos del §6** sobre el host que ya está puesto: archivo con RAG (6.3), torrent (6.9),
+  IA local en la barra lateral (6.7).
+- **Que una descarga se pueda COMPARTIR con `tejido`**: hoy el objeto está en el CAS de descargas y
+  tejido sirve el del sistema. Falta que el índice de descargas sea una raíz que el GC respete, y ahí
+  las dos raíces pueden volver a ser una.
 *(El §6.8 ya no tiene pendientes: que una petición hecha en «Banco» sale por el proxy de «Banco»
 está probado por `scripts/test-atuq-ruteo.py`, que abre las dos pestañas por sessionstore y mira a
 qué puerto llama el navegador. Trae control negativo.)*
diff --git a/recipes/atuq/distribution/policies.json b/recipes/atuq/distribution/policies.json
index f1139f25..2c5b7129 100644
--- a/recipes/atuq/distribution/policies.json
+++ b/recipes/atuq/distribution/policies.json
@@ -39,6 +39,10 @@
       "sct@atuq.tawasuyu": {
         "installation_mode": "normal_installed",
         "install_url": "file:///usr/lib/atuq/distribution/extensions/sct@atuq.tawasuyu.xpi"
+      },
+      "descargas@atuq.tawasuyu": {
+        "installation_mode": "normal_installed",
+        "install_url": "file:///usr/lib/atuq/distribution/extensions/descargas@atuq.tawasuyu.xpi"
       }
     }
   }
diff --git a/recipes/atuq/extensions/descargas/fondo.js b/recipes/atuq/extensions/descargas/fondo.js
new file mode 100644
index 00000000..7d9d9aeb
--- /dev/null
+++ b/recipes/atuq/extensions/descargas/fondo.js
@@ -0,0 +1,136 @@
+"use strict";
+// descargas — una descarga con identidad (SDD 26 §6.2), v1.
+//
+// Cuando una descarga TERMINA, esta extensión le pasa al host nativo la ruta del
+// fichero, su nombre y de dónde salió. El host lo ingiere a un CAS BLAKE3 —el de
+// `arje-cas`, el mismo formato y el mismo hash que usan arje, takana y tejido— y
+// contesta el hash, el tamaño, con qué nombre apareció la PRIMERA vez y cuántas
+// veces se bajó ese mismo contenido.
+//
+// Para qué: ningún navegador trata una descarga como algo con identidad. Para
+// todos es un blob con nombre, y por eso lo bajás dos veces y no lo sabés. Acá el
+// mismo contenido con otro nombre es UN objeto y dos nombres, y se dice.
+//
+// ── LO QUE NO HACE, Y ES DELIBERADO ──────────────────────────────────────────
+// No toca el fichero que bajaste. El host lo COPIA al CAS y lo deja donde estaba.
+// Mover o borrar lo que alguien acaba de bajar —aunque «ya esté en el CAS»— no es
+// una decisión que le toque a un navegador tomar por su cuenta.
+//
+// Y no intercepta la descarga: la observa cuando terminó. Meterse en el medio
+// obligaría a decidir qué hacer cuando el CAS falla, y la respuesta correcta a eso
+// («que la descarga siga igual») es exactamente lo que se consigue no metiéndose.
+
+const HOST = "puriy_costura";
+
+let puerto = null;
+// La descarga que motivó cada mensaje, para poder pintar la insignia en su
+// respuesta: el host devuelve el `id` que le mandamos, y el `id` de una descarga
+// es justo un entero.
+const pendientes = new Map();
+
+function di(s) {
+  try { dump("DESCARGAS " + s + "\n"); } catch (e) {}
+}
+
+function conectar() {
+  try {
+    puerto = browser.runtime.connectNative(HOST);
+    puerto.onMessage.addListener(alRecibir);
+    puerto.onDisconnect.addListener(p => {
+      di("DESCONECTADO " + (p.error ? p.error.message : "sin error"));
+      puerto = null;
+    });
+    di("CONECTADO " + HOST);
+  } catch (e) {
+    di("SIN HOST " + e.message);
+    puerto = null;
+  }
+}
+
+function enviar(msg) {
+  if (!puerto) { conectar(); }
+  if (!puerto) { return false; }
+  try {
+    puerto.postMessage(msg);
+    return true;
+  } catch (e) {
+    di("ENVÍO FALLÓ " + e.message);
+    puerto = null;
+    return false;
+  }
+}
+
+function alRecibir(r) {
+  if (!r || r.ok !== true) {
+    di("RECHAZO " + (r && r.error ? r.error : "sin error"));
+    return;
+  }
+  if (r.verb !== "cas.ingest" || !r.object) { return; }
+  const o = r.object;
+  di("INGERIDO " + o.hash + " nombre=" + o.name + " bytes=" + o.bytes +
+     " veces=" + o.times + " dedup=" + (r.deduped === true));
+  pendientes.delete(r.id);
+  pintar(o, r.deduped === true);
+}
+
+// El aviso es la insignia del botón, no un modal: es información, no una alarma.
+// Sólo marca cuando hay algo que decir —que ese contenido ya lo tenías—, porque
+// una insignia que está siempre encendida deja de leerse.
+function pintar(o, dedup) {
+  try {
+    if (dedup) {
+      browser.browserAction.setBadgeText({ text: String(o.times) });
+      browser.browserAction.setBadgeBackgroundColor({ color: "#1b6470" });
+      browser.browserAction.setTitle({
+        title: "Esto ya lo tenías: «" + o.name + "»\n" +
+               "es la vez " + o.times + " que bajás el mismo contenido\n" +
+               "blake3:" + o.hash.slice(0, 16) + "…",
+      });
+    } else {
+      browser.browserAction.setBadgeText({ text: "" });
+      browser.browserAction.setTitle({
+        title: "«" + o.name + "» — " + o.bytes + " bytes\nblake3:" +
+               o.hash.slice(0, 16) + "…",
+      });
+    }
+    browser.browserAction.getBadgeText({}).then(
+      t => di("INSIGNIA texto=" + JSON.stringify(t)),
+      e => di("INSIGNIA NO SE PUDO LEER " + e.message));
+  } catch (e) { di("INSIGNIA FALLÓ " + e.message); }
+}
+
+async function terminada(id) {
+  let item;
+  try {
+    const r = await browser.downloads.search({ id });
+    item = r && r[0];
+  } catch (e) {
+    di("BUSCAR FALLÓ " + e.message);
+    return;
+  }
+  if (!item || !item.filename) {
+    di("SIN RUTA para la descarga " + id);
+    return;
+  }
+  // `filename` de la API de descargas es la ruta ABSOLUTA en disco, no el nombre
+  // (el nombre visible es el último segmento). El host además la exige absoluta.
+  const nombre = item.filename.split("/").pop();
+  di("TERMINADA " + id + " " + item.filename + " (" + item.fileSize + " bytes)");
+  pendientes.set(id, nombre);
+  enviar({
+    id,
+    verb: "cas.ingest",
+    path: item.filename,
+    name: nombre,
+    origin: item.url || "",
+  });
+}
+
+browser.downloads.onChanged.addListener(delta => {
+  if (delta.state && delta.state.current === "complete") {
+    terminada(delta.id);
+  }
+});
+
+conectar();
+di("LISTA v1 observa descargas terminadas; no toca el fichero");
diff --git a/recipes/atuq/extensions/descargas/manifest.json b/recipes/atuq/extensions/descargas/manifest.json
new file mode 100644
index 00000000..eaa85084
--- /dev/null
+++ b/recipes/atuq/extensions/descargas/manifest.json
@@ -0,0 +1,26 @@
+{
+  "manifest_version": 2,
+  "name": "atuq — descargas con identidad",
+  "description": "Lo que bajás no es un archivo con nombre: es un objeto BLAKE3 en el CAS, deduplicado y verificable (SDD 26 §6.2).",
+  "version": "1.0",
+  "browser_specific_settings": {
+    "gecko": {
+      "id": "descargas@atuq.tawasuyu",
+      "strict_min_version": "115.0"
+    }
+  },
+  "permissions": [
+    "downloads",
+    "nativeMessaging"
+  ],
+  "background": {
+    "scripts": ["fondo.js"]
+  },
+  "browser_action": {
+    "default_title": "Descargas con identidad",
+    "default_icon": "atuq128.png"
+  },
+  "icons": {
+    "128": "atuq128.png"
+  }
+}
diff --git a/recipes/atuq/native-messaging/puriy_costura.json b/recipes/atuq/native-messaging/puriy_costura.json
index ea2081ff..1b296fec 100644
--- a/recipes/atuq/native-messaging/puriy_costura.json
+++ b/recipes/atuq/native-messaging/puriy_costura.json
@@ -3,5 +3,8 @@
   "description": "La costura: el host nativo de atuq (SDD 26 §7). Le habla a puriy-sct.",
   "path": "/usr/lib/atuq/puriy-costura-host",
   "type": "stdio",
-  "allowed_extensions": ["sct@atuq.tawasuyu"]
+  "allowed_extensions": [
+    "sct@atuq.tawasuyu",
+    "descargas@atuq.tawasuyu"
+  ]
 }
diff --git a/recipes/puriy-costura.toml b/recipes/puriy-costura.toml
index 6eefb5b6..00f54123 100644
--- a/recipes/puriy-costura.toml
+++ b/recipes/puriy-costura.toml
@@ -43,7 +43,7 @@ license = "MIT OR Apache-2.0"
 
 [source]
 repo   = "gitea@git.tawasuyu.net:tawasuyu/tawasuyu.git"
-commit = "ebe41e9683f8d53c988061dd8e46eb74c2057490"
+commit = "55b918e89970650a9160fb7d532ef4f9b0648400"
 # ⚠ SIN ESTO EL BUILD MUERE EN UN SITIO QUE NO NOMBRA LA CAUSA — medido acá el 2026-09-10.
 # tawasuyu COMMITEA su propio `vendor/`: adentro tiene un `smithay` parcheado a mano (tearing/flip
 # async para el compositor), y lo enchufa con `[patch.crates-io] smithay = { path = "vendor/smithay" }`.
diff --git a/scripts/test-atuq-descargas.py b/scripts/test-atuq-descargas.py
new file mode 100755
index 00000000..edab148f
--- /dev/null
+++ b/scripts/test-atuq-descargas.py
@@ -0,0 +1,308 @@
+#!/usr/bin/env python3
+"""¿Lo que baja `atuq` tiene identidad, y sabe que ya lo tenías? (SDD 26 §6.2)
+
+    python3 scripts/test-atuq-descargas.py                    # dos descargas del MISMO contenido
+    python3 scripts/test-atuq-descargas.py --negative-control  # la segunda es OTRO contenido
+
+── QUÉ SE EJERCITA ───────────────────────────────────────────────────────────────────────────
+Un servidor HTTP sirve un fichero como adjunto; el navegador lo baja de verdad (no es una llamada a
+la API: es la descarga completa, con su `Content-Disposition`); la extensión `descargas@atuq` ve que
+terminó, le pasa al host la ruta absoluta, y el host lo ingiere a un CAS BLAKE3 y contesta el hash,
+el tamaño y **cuántas veces se bajó ese mismo contenido**.
+
+    servidor HTTP → descarga real → downloads.onChanged → connectNative → puriy-costura
+                  → arje-cas (BLAKE3, deduplicado)
+
+── LAS TRES AFIRMACIONES ─────────────────────────────────────────────────────────────────────
+    1ª descarga  contenido A, nombre `uno.bin`   ⇒ dedup=false, veces=1
+    2ª descarga  contenido A, nombre `dos.bin`   ⇒ **dedup=true, veces=2** y UN solo blob en disco
+    el fichero que bajó el usuario sigue donde estaba, byte a byte
+
+La segunda es la del §6.2: «lo bajás dos veces y no lo sabés». El nombre cambia y el objeto es el
+mismo, porque la identidad es el contenido.
+
+`--negative-control` baja OTRO contenido la segunda vez y exige `dedup=false` y DOS objetos. Sin él,
+una sonda rota que dijera «ya lo tenías» siempre se vería idéntica a una que funciona.
+
+── ⚠ POR QUÉ ESTO CORRE EN UN SWAY DE VERDAD Y NO EN `--headless` ────────────────────────────
+Porque en `--headless` **el navegador se cae con SIGSEGV en cuanto una descarga termina**, y eso se
+midió acá antes de escribir una línea de este guardián:
+
+    con la extensión de descargas    → baja el fichero, TERMINADA, Segmentation fault (139)
+    SIN la extensión (control)       → baja el fichero, Segmentation fault (139)
+    con el panel de descargas apagado→ Segmentation fault (139)
+
+O sea que **no es nuestro**: es el modo headless de Gecko. Y no es del producto tampoco: con un
+compositor real —sway headless, salida offscreen— la misma descarga completa la cadena entera sin
+caerse. La conclusión importa más que el arnés: si esto se hubiera dado por «la función no anda», se
+habría perseguido un bug que no existe; y si se hubiera dado por «el navegador no puede descargar»,
+se habría reportado un bug de producto que tampoco existe. Queda anotado en el §6.10 del SDD, que es
+donde vive lo que atuq NO puede hacer.
+
+REQUISITOS: el rootfs de sway (`scripts/hydrate-profile.py escritorio-sway --into …`), y `atuq` +
+`puriy-costura` sellados.
+"""
+import http.server
+import json
+import os
+import shutil
+import socket
+import subprocess
+import sys
+import tempfile
+import threading
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/sway-rootfs")
+ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
+HAMMER = os.path.join(ROOT, "target/release/takana")
+STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
+
+CONTENIDO_A = b"un objeto con identidad, no un archivo con nombre\n" * 64
+CONTENIDO_B = b"otro contenido cualquiera, para el control negativo\n" * 64
+
+
+def fatal(msg):
+    print(f"✗ {msg}", file=sys.stderr)
+    raise SystemExit(1)
+
+
+def artefacto(receta, nombre, escotilla=None):
+    """El artefacto VIGENTE, por `takana hash` y nunca por glob. La escotilla grita."""
+    forzado = os.environ.get(escotilla) if escotilla else None
+    if forzado:
+        if not os.path.isdir(forzado):
+            fatal(f"{escotilla}={forzado} no existe")
+        print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
+        return forzado
+    r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
+                       cwd=ROOT, capture_output=True, text=True)
+    if r.returncode != 0:
+        fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
+    h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
+    d = os.path.join(STORE, f"{h}-{nombre}")
+    if not os.path.isdir(d):
+        fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes")
+    return d
+
+
+class Servidor(http.server.SimpleHTTPRequestHandler):
+    """Sirve cada ruta como ADJUNTO. El `Content-Disposition` es lo que hace que el navegador lo
+    baje en vez de pintarlo: sin él esto mediría otra cosa."""
+
+    contenidos = {}
+
+    def log_message(self, *a):
+        pass
+
+    def do_GET(self):
+        nombre = self.path.lstrip("/").split("?")[0]
+        datos = Servidor.contenidos.get(nombre)
+        if datos is None:
+            self.send_error(404)
+            return
+        self.send_response(200)
+        self.send_header("Content-Type", "application/octet-stream")
+        self.send_header("Content-Disposition", f'attachment; filename="{nombre}"')
+        self.send_header("Content-Length", str(len(datos)))
+        self.send_header("Cache-Control", "no-store")
+        self.end_headers()
+        self.wfile.write(datos)
+
+
+def puerto_libre():
+    s = socket.socket()
+    s.bind(("127.0.0.1", 0))
+    p = s.getsockname()[1]
+    s.close()
+    return p
+
+
+def correr(atuq, costura, salida, urls):
+    """Levanta UN sway headless y baja, dentro de él, las URLs en orden (un atuq por descarga).
+
+    Un solo compositor para las dos descargas: levantarlo dos veces costaría el doble y no mediría
+    nada más. El perfil y el `$HOME` se reusan, así que la segunda descarga encuentra el estado que
+    dejó la primera — que es justo lo que hace falta para que la deduplicación signifique algo.
+    """
+    bajadas = " ".join(f"'{u}'" for u in urls)
+    dentro = f"""#!/bin/sh
+export XDG_RUNTIME_DIR=/run/user/0 HOME=/salida/hogar LANG=C XDG_DATA_DIRS=/usr/share
+export WLR_BACKENDS=headless WLR_HEADLESS_OUTPUTS=1 WLR_RENDERER=pixman
+mkdir -p "$XDG_RUNTIME_DIR" /salida/hogar /salida/bajadas; chmod 700 "$XDG_RUNTIME_DIR"
+(
+  i=0
+  while [ ! -S /run/user/0/wayland-1 ] && [ $i -lt 40 ]; do sleep 1; i=$((i+1)); done
+  [ -S /run/user/0/wayland-1 ] || {{ echo "@@ sin socket wayland"; exit 1; }}
+  export WAYLAND_DISPLAY=wayland-1
+  /usr/bin/swaybg -c '#1a4b8c' & sleep 3
+  export MOZ_ENABLE_WAYLAND=1 GDK_BACKEND=wayland LIBGL_DRIVERS_PATH=/usr/lib/dri
+  # Los sandboxes de Gecko se apagan porque esto YA corre dentro de una jaula sin userns anidado:
+  # con ellos puestos los procesos hijos mueren en bucle. Es el entorno de prueba, no el artefacto.
+  export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1
+  for u in {bajadas}; do
+    # La marca va AL MISMO log que lee el guardián: si sale por la consola, el
+    # parser no tiene con qué separar una descarga de la otra.
+    echo "@@ bajando $u" >> /salida/atuq.log
+    timeout 60 /usr/bin/atuq --no-remote --profile /salida/perfil "$u" >> /salida/atuq.log 2>&1
+    echo "@@ atuq salió con $?" >> /salida/atuq.log
+  done
+  sleep 2
+  # El socket de sway se busca explícitamente: esta subshell arrancó ANTES que sway
+  # (es hija del shell que después lo `exec`a), así que no hereda `SWAYSOCK` y sin él
+  # `swaymsg exit` no hace nada — la jaula se quedaría viva hasta el timeout externo.
+  SWAYSOCK=$(ls /run/user/0/sway-ipc.* 2>/dev/null | head -1)
+  export SWAYSOCK
+  /usr/bin/swaymsg exit 2>/dev/null
+) &
+exec /usr/bin/sway -d > /salida/sway.log 2>&1
+"""
+    guion = os.path.join(salida, "dentro.sh")
+    with open(guion, "w") as fh:
+        fh.write(dentro)
+    orden = [
+        "bwrap",
+        "--overlay-src", ALPINE, "--overlay-src", RFS,
+        "--overlay-src", atuq, "--overlay-src", costura,
+        "--tmp-overlay", "/",
+        "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
+        "--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
+        "--bind", salida, "/salida",
+        "--uid", "0", "--gid", "0",
+        "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
+        "/bin/sh", "/salida/dentro.sh",
+    ]
+    try:
+        subprocess.run(orden, check=False, timeout=300)
+    except subprocess.TimeoutExpired:
+        # El compositor no se cerró solo. No es motivo para perder la corrida: las
+        # descargas y el log ya están en disco, y las aserciones son sobre eso.
+        print("   ⚠ el compositor no se cerró solo; se lo cortó a los 300 s")
+    ruta = os.path.join(salida, "atuq.log")
+    log = open(ruta, errors="replace").read() if os.path.exists(ruta) else ""
+    # Las líneas de cada descarga, separadas por el `@@ bajando` que las precede.
+    tandas, actual = [], None
+    for l in log.splitlines():
+        if l.startswith("@@ bajando"):
+            actual = []
+            tandas.append(actual)
+        elif l.startswith("DESCARGAS ") and actual is not None:
+            actual.append(l[len("DESCARGAS "):])
+    return tandas, log
+
+
+def main():
+    negativo = "--negative-control" in sys.argv[1:]
+    if negativo:
+        print("== MODO CONTROL NEGATIVO: la segunda descarga es OTRO contenido; NO debe deduplicar")
+    if not os.path.isdir(RFS):
+        fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`, que lo hidrata")
+    atuq = artefacto("atuq", "atuq", "ATUQ_DIR")
+    costura = artefacto("puriy-costura", "puriy-costura")
+    print(f"artefactos: {os.path.basename(atuq)}\n            {os.path.basename(costura)}")
+
+    segundo = CONTENIDO_B if negativo else CONTENIDO_A
+    Servidor.contenidos = {"uno.bin": CONTENIDO_A, "dos.bin": segundo}
+    puerto = puerto_libre()
+    servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Servidor)
+    threading.Thread(target=servidor.serve_forever, daemon=True).start()
+
+    tmp = tempfile.mkdtemp(prefix="atuq-desc-")
+    salida = os.path.join(tmp, "salida")
+    perfil = os.path.join(salida, "perfil")
+    os.makedirs(perfil)
+    try:
+        with open(os.path.join(perfil, "user.js"), "w") as fh:
+            fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
+            fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
+            # Bajar SIN preguntar y a un directorio conocido. Sin esto el navegador
+            # headless se queda esperando un diálogo que nadie va a contestar, y la
+            # prueba mediría un timeout en vez de una descarga.
+            fh.write('user_pref("browser.download.folderList", 2);\n')
+            fh.write('user_pref("browser.download.dir", "/salida/bajadas");\n')
+            fh.write('user_pref("browser.download.useDownloadDir", true);\n')
+            fh.write('user_pref("browser.download.always_ask_before_handling_new_types", false);\n')
+            fh.write('user_pref("browser.helperApps.neverAsk.saveToDisk", "application/octet-stream");\n')
+            fh.write('user_pref("browser.download.start_downloads_in_tmp_dir", false);\n')
+
+        urls = [f"http://127.0.0.1:{puerto}/uno.bin", f"http://127.0.0.1:{puerto}/dos.bin"]
+        bajadas, log = correr(atuq, costura, salida, urls)
+        for i, lineas in enumerate(bajadas):
+            print(f"\n-- descarga {('uno.bin', 'dos.bin')[i] if i < 2 else i}")
+            for l in lineas:
+                print("   " + l)
+        if len(bajadas) != 2 or not all(any(l.startswith("INGERIDO ") for l in t) for t in bajadas):
+            destino = os.path.join(ROOT, "work", "atuq-descargas.log")
+            os.makedirs(os.path.dirname(destino), exist_ok=True)
+            open(destino, "w").write(log)
+            fatal(f"alguna descarga no llegó al CAS — log en {destino}")
+
+        def ingerido(lineas):
+            for l in lineas:
+                if l.startswith("INGERIDO "):
+                    partes = l.split()
+                    campos = dict(p.split("=", 1) for p in partes if "=" in p)
+                    campos["hash"] = partes[1]
+                    return campos
+            return None
+
+        uno, dos = ingerido(bajadas[0]), ingerido(bajadas[1])
+        cas = os.path.join(salida, "hogar", ".local", "state", "puriy-costura", "descargas-cas")
+        indice = os.path.join(salida, "hogar", ".local", "state", "puriy-costura", "descargas.json")
+
+        if uno["dedup"] != "false" or uno["veces"] != "1":
+            fatal(f"la PRIMERA descarga no debía estar en el CAS: {uno}")
+
+        # El fichero del usuario sigue donde estaba, y con sus bytes. Es la promesa
+        # explícita del §6.2: el CAS es una COPIA, no una mudanza.
+        for nombre, contenido in (("uno.bin", CONTENIDO_A), ("dos.bin", segundo)):
+            f = os.path.join(salida, "bajadas", nombre)
+            if not os.path.isfile(f):
+                fatal(f"el fichero bajado {nombre} ya no está donde el usuario lo dejó")
+            if open(f, "rb").read() != contenido:
+                fatal(f"el fichero bajado {nombre} no es byte a byte lo que se sirvió")
+
+        blobs = sorted(os.listdir(cas)) if os.path.isdir(cas) else []
+        objetos = json.load(open(indice)) if os.path.isfile(indice) else {}
+
+        if negativo:
+            if dos["dedup"] != "false":
+                fatal(f"con OTRO contenido dijo dedup={dos['dedup']} — esta prueba no distingue nada")
+            if dos["hash"] == uno["hash"]:
+                fatal("dos contenidos distintos dieron el MISMO hash")
+            if len(blobs) != 2 or len(objetos) != 2:
+                fatal(f"se esperaban DOS objetos y hay blobs={blobs} índice={list(objetos)}")
+            print("\n✓ control negativo: otro contenido es otro objeto, y no dice que ya lo tenías")
+            return 0
+
+        if dos["dedup"] != "true":
+            fatal(f"la SEGUNDA descarga del mismo contenido dijo dedup={dos['dedup']}"
+                  " — que es justo lo que ningún navegador sabe hacer")
+        if dos["hash"] != uno["hash"]:
+            fatal(f"el mismo contenido dio dos hashes: {uno['hash']} y {dos['hash']}")
+        if dos["veces"] != "2":
+            fatal(f"no contó las dos veces: {dos}")
+        if dos["nombre"] != "uno.bin":
+            fatal(f"el nombre tenía que ser el de la PRIMERA vez y es {dos['nombre']}")
+        if blobs != [uno["hash"]]:
+            fatal(f"el CAS tiene {blobs} y tenía que tener UN blob llamado {uno['hash']}")
+        if list(objetos) != [uno["hash"]] or objetos[uno["hash"]]["times"] != 2:
+            fatal(f"el índice no cuenta un objeto bajado dos veces: {objetos}")
+
+        # Y el blob es el contenido servido, byte a byte, bajo un nombre que es su hash.
+        if open(os.path.join(cas, uno["hash"]), "rb").read() != CONTENIDO_A:
+            fatal("el blob del CAS no es el contenido que sirvió el servidor")
+
+        print(f"\nCAS: {blobs[0][:16]}… ({len(CONTENIDO_A)} bytes) · índice: {list(objetos)[0][:16]}…"
+              f" veces={objetos[uno['hash']]['times']}")
+        print("\n✓ la descarga entra al CAS con su BLAKE3, y el fichero del usuario no se toca")
+        print("✓ el MISMO contenido con otro nombre es UN objeto, y el navegador lo dice"
+              " («esto ya lo tenías»)")
+        print("✓ el índice queda legible en disco: la lista de lo que bajaste sobrevive al binario")
+        return 0
+    finally:
+        servidor.shutdown()
+        shutil.rmtree(tmp, ignore_errors=True)
+
+
+sys.exit(main())