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…

TOML 1534Shell 199Rust 109Markdown 993713 Sutichaykuna

atuq: el diálogo de la bóveda no abre, y el muro no es de la bóveda — ninguna ventana llimphi pinta

`boveda` (b3:59ffd74b) y `shuma-pregunta` (b3:99763eca) sellaron en el worker. Ninguno de los dos abre
ventana, y el error no habla de contraseñas:

    panicked at llimphi-hal/src/lib.rs:1032: index out of bounds: the len is 0 but the index is 0

Esa línea es `caps.formats[0]`: la surface no tiene NI UN formato. La cadena, medida eslabón por
eslabón con RUST_LOG=wgpu_hal=debug:

    No (or unknown) windowing system ((None, Some(..))) present. Using surfaceless platform
    Trying native-render → No config found! · Trying presentation → No config found!

El `None` es `raw_display_handle`, y no es del arnés: es lo que llimphi hace A PROPÓSITO en su camino
de escritorio, con el comentario al lado («Sin display: este camino no tiene ventana todavía»). Sin
display handle wgpu abre el display EGL surfaceless, que no publica configs con WINDOW_BIT ⇒ la
surface no es presentable ⇒ cero formatos ⇒ panic.

No se nota en una máquina con Vulkan, porque ese camino elige Backends::PRIMARY y a Vulkan el display
no le hace falta. Y **ninguna imagen de takana tiene Vulkan**: las tres recetas de mesa se compilan
con `-Dvulkan-drivers=` vacío, y `vulkan-loader` sólo existe en incoming-kde sin que ningún perfil lo
declare (y un loader sin driver no es un driver). ⇒ toda app llimphi de escritorio cae al camino GL y
no puede abrir ventana. Reproducido en TRES binarios y dos versiones de wgpu: shuma-pregunta y boveda
(29) y llimphi-counter (27).

Los controles, porque la conclusión es fuerte: pedir Vulkan explícito da NoAdapter y no hay ICD ni
libvulkan en ninguna capa (la premisa está medida); con softpipe el fallo es OTRO y anterior —wgpu
pide compute shaders y softpipe se queda en GL 3.3—, así que llegar hasta acá ya exige llvmpipe; y el
compositor está levantado con su socket y su WAYLAND_DISPLAY.

Lo que esto dice del producto: el consentimiento de la bóveda no puede pedir permiso en ninguna imagen
de hoy, y como un lanzamiento fallido se traduce a «no» (Command::new falla ⇒ return false), el usuario
vería una bóveda que niega todo sin un solo error.

Se arregla en llimphi (que pase el display handle; la función `instancia_con` ya existe ahí al lado) o
con un driver Vulkan por software en el corpus. No se arregla en takana ni en el arnés.

Entra igual lo que sí se puede afirmar:
· `scripts/test-atuq-boveda-metal.py`, seis etapas declaradas, la A en VERDE — sin la app dueña el
  host contesta locked:true, que es la medición del §7.sexies ahora vigilada. Cada etapa dice qué
  afirma y la B nombra el muro en vez de dejar que se rediagnostique;
· `recipes/wtype.toml` — el corpus no tenía NINGUNA forma de meter una tecla en un compositor: todos
  los arneses de scripts/wlr miran, ninguno toca. Sellado y en ningún perfil: es instrumento.

⚠ Y una trampa del arnés, mía, que costó tres corridas: exportaba GALLIUM_DRIVER=softpipe, o sea que
yo mismo lo clavaba al driver débil. mesa-swrast y mesa-llvmpipe se ven como dos nombres de lo mismo
—«mesa por software»— y la diferencia decide si el arnés existe.

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

f611751f86b32fc6d16772900e6d71d45bd8b695 · ruwaq Sergio · (1 killa ñawpaq) · tayta 589c65626 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 15

scripts/test-atuq-boveda-metal.py

3 qillqakuna · +739 −1

diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md
index 2511c09d..4f3d1dce 100644
--- a/docs/26-atuq-envoltorio-gecko.md
+++ b/docs/26-atuq-envoltorio-gecko.md
@@ -3050,6 +3050,89 @@ dos y recién ahí escribirlo. Lo que sí queda escrito desde hoy es la pregunta
 tiene que empezar —`vault.status` contra el host— porque es la que separa «la bóveda dijo que no» de
 «no hay bóveda», que se ven idénticas desde la extensión.
 
+#### 7.septies El diálogo NO ABRE, y el muro no es de la bóveda: **ninguna app llimphi de escritorio puede pintar en esta distro** (2026-09-18)
+
+Con `boveda` y `shuma-pregunta` sellados, la etapa B del guardián de metal falla, y falla en un sitio
+que no tiene nada que ver con las contraseñas:
+
+```
+thread 'main' panicked at 02_ruway/llimphi/llimphi-hal/src/lib.rs:1032:83:
+index out of bounds: the len is 0 but the index is 0
+```
+
+Esa línea es `caps.formats[0]` sobre las capacidades de la surface. O sea: **la surface no tiene NI UN
+formato**. La cadena entera, medida eslabón por eslabón con `RUST_LOG=wgpu_hal=debug`:
+
+```
+wgpu_hal::gles::egl: No (or unknown) windowing system ((None, Some(...))) present.
+                     Using surfaceless platform
+wgpu_hal::gles::egl: Trying native-render → No config found!
+wgpu_hal::gles::egl: Trying presentation  → No config found!
+wgpu_hal::gles::egl: Trying off-screen    → (ésta sí)
+```
+
+El `None` de ese par es `desc.raw_display_handle`, y **no es un accidente del arnés: es lo que el
+propio llimphi hace a propósito**, con su comentario al lado (`llimphi-hal/src/lib.rs`, camino de
+escritorio `new_inner`):
+
+> «Sin display: este camino no tiene ventana todavía (la surface se crea después, contra esta misma
+> instancia). Los caminos que SÍ la tienen —`new_for_raw_surface`, `recreate_for_window`— la pasan.»
+
+Sin display handle, wgpu abre el display EGL por la plataforma **surfaceless**, que por definición no
+publica configs con `WINDOW_BIT`; sin config de ventana, `presentable` es falso; sin `presentable`,
+`surface_capabilities` devuelve `None` y la lista de formatos sale vacía. Cada paso está en el log.
+
+**Por qué esto no se nota en la máquina de quien escribió llimphi, y sí acá.** Ese camino elige
+`Backends::PRIMARY` —Vulkan/Metal/DX12— y **a Vulkan el display handle no le hace falta**. Sólo cae al
+backend GL cuando no hay ninguno de los tres… que es exactamente el caso de esta distro:
+
+| | Vulkan | efecto |
+|---|---|---|
+| `recipes/mesa.toml` (iris, la de las imágenes) | `-Dvulkan-drivers=` **vacío** | no hay ICD |
+| `recipes/mesa-swrast.toml` · `recipes/mesa-llvmpipe.toml` | `-Dvulkan-drivers=` **vacío** | no hay ICD |
+| `vulkan-loader` | sólo en `incoming-kde`, y **ningún perfil lo declara** | y el loader sin driver no es un driver |
+
+⇒ **En ninguna imagen de takana hay un driver Vulkan.** Con lo cual toda app llimphi de escritorio
+—no sólo el diálogo de la bóveda— toma el camino GL, y el camino GL sin display handle no puede abrir
+ventana. Comprobado en TRES binarios distintos y con dos versiones de wgpu: `shuma-pregunta` (wgpu 29),
+`boveda` (wgpu 29) y `llimphi-counter` (wgpu 27, pineado a otro repo).
+
+**Los controles, porque la conclusión es fuerte:**
+
+· pedir Vulkan explícito (`LLIMPHI_WGPU_BACKEND=vulkan`) ⇒ **`NoAdapter`**, y `ls /usr/share/vulkan/icd.d`
+  y `ls /usr/lib/libvulkan*` no existen en ninguna capa. La premisa «no hay Vulkan» está medida, no
+  supuesta;
+· con **softpipe** el fallo es OTRO y anterior —`RequestDevice("Parent device is lost")`, con
+  `indirect-validation error: ComputePipeline(… COMPUTE_SHADER …)` una línea antes, porque softpipe se
+  queda en GL 3.3 y wgpu pide compute—. O sea que llegar hasta el panic de los formatos ya exige
+  `llvmpipe` (GL 4.5), que es lo que el arnés monta: `mesa-llvmpipe` + `llvm18`;
+· y el mismo arnés levanta `sway`, el socket wayland aparece y `WAYLAND_DISPLAY` está puesto — el
+  compositor no es el que falta.
+
+⚠ **Lo que esto dice del producto, y es lo que importa:** la bóveda de `atuq` no está «casi lista». Su
+consentimiento —`PorDialogo`, que lanza `shuma-pregunta`— **no puede pedir permiso en ninguna imagen
+de hoy**, y como el fallo del lanzamiento se traduce a «no» (`Command::new` falla ⇒ `return false`),
+lo que un usuario vería es una bóveda que niega todo, sin un error. Y no es sólo la bóveda: es
+cualquier ventana llimphi que la distro quiera abrir.
+
+**Dónde se arregla, y dónde NO.** No se arregla en takana: ni en la receta, ni en el arnés, ni
+poniendo otro mesa. Las dos salidas son de otro repo o de otra receta, y conviene decir cuál es cuál:
+
+1. **llimphi** (tawasuyu): que el camino de escritorio pase el display handle cuando lo tiene, o que
+   al caer al backend GL reconstruya la instancia con él. La función que hace falta **ya existe ahí
+   al lado** (`instancia_con`, que usan `new_for_raw_surface` y `recreate_for_window`);
+2. **un driver Vulkan por software** en el corpus (lavapipe: `-Dvulkan-drivers=swrast`), que además
+   destrabaría el muro de ScreenCast que el runbook de COSMIC documenta por otro camino. Es una
+   receta nueva y una decisión de tamaño, no un arreglo de paso.
+
+La 1 es la correcta: un navegador que pide permiso no debería depender de que la máquina tenga
+Vulkan. La 2 es la que, además, le sirve a otros frentes.
+
+**Lo que queda escrito y corriendo mientras tanto** es `scripts/test-atuq-boveda-metal.py`, con sus
+seis etapas declaradas y la A en verde: sin la app dueña, el host contesta `locked:true`. Es poco, y
+es exactamente lo que se puede afirmar hoy — que es mejor que un guardián que no existe y que uno que
+diera verde midiendo nada.
+
 ## 8. Plan, por unidades de trabajo
 
 Cada una cierra sola, se commitea y se pushea. El orden no es preferencia: cada una destraba a la
@@ -3077,7 +3160,7 @@ siguiente.
 | 9 | **Medios (6.6) ✅ y torrent (6.9) ✅ — 2026-09-10.** El medio lo abre `mpv`; el torrent lo toma `puriy-costura-torrent`, un daemon **propio, configurable y perezoso** que sobrevive al navegador y cosecha al CAS. Medido antes de decidir: la pila de librqbit compila para musl (226 crates), así que la decisión fue «no ahí» y no «no se puede». **Faltan** el foco (6.5) y la IA local (6.7), ésta bloqueada porque el corpus no tiene modelo ni embeddings | 6.5–6.7, 6.9 | 5 ✅ |
 | 10 | **Foco (6.5) — 2026-09-10.** Entra al corpus la cadena `nftables` (libmnl + libnftnl + nft 1.1.6), que el grafo pedía y nadie había puesto, con dos arreglos de fábrica: el sello de tiempo que rompía la reproducción y un bashismo. Medido con root: el cgroup en foco no sale y el de al lado sí. Y del lado del navegador, la extensión `foco` MUESTRA el estado y **no tiene verbo para apagarlo**. **Falta** quién pone a `atuq` en un cgroup (delegación, decisión de arje) y quién aplica la política (root) | 6.5 | 6 ✅ |
 | 11 | **Motor de inferencia local (6.7) — 2026-09-11.** Entra `recipes/llama-cpp.toml` (b10901, estática, 199 M, **REPRODUCE**), que resulta ser **un solo muro para dos pendientes**: el §6.7 y la mitad semántica del §6.3 no eran dos problemas, era que el corpus no tenía con qué correr un modelo (`pluma-llm` sólo tiene backends de nube; `rimay-verbo-fastembed` DESCARGA onnxruntime glibc + el modelo). ⚠ Y dejó medido lo que no se podía deducir: **`SOURCE_DATE_EPOCH` —la variable que nos da reproducibilidad— apagaba las SEIS perillas de ISA de ggml**, y el artefacto sellaba y corría con 0 `%ymm`. Ahora van declaradas y el `install` las comprueba. Guardián `scripts/test-llama-cpp.py` con control negativo vivo. **Faltan** el modelo (fuente pineada, no receta), los verbos del host y quién levanta el servidor | 6.7, y la mitad semántica de 6.3 | 5 ✅ |
-| 12 | **La bóveda (SDD-BOVEDA §8) — a medias, 2026-09-15.** La décima extensión: ofrece la credencial del sitio abierto y no puede sacar una contraseña por su cuenta —`vault.match` contesta títulos y usuarios; la contraseña sale por `vault.fill`, que **pregunta en el escritorio**—. La dirección la pone el chrome, nunca la página. El gestor de Gecko se aparta como VALOR DE ARRANQUE y no como política. **Destrabada el 2026-09-16** (§7.quinquies.bis): el `Cargo.lock` de tawasuyu publicado —y resultó ser byte a byte el que la otra sesión ya tenía sin commitear—, el pin subido a `23a292863` ⇒ `b3:7d63655a`, construido en el worker (el vendoreo son 2,4 G y el hub estaba al 99%) y medido con el mismo `strings` que había diagnosticado el hueco: `vault` de **0 a 10**, con `cas`/`sct` de control. **Falta**, y no era lo que este renglón decía (§7.sexies, 2026-09-18): el host sellado contesta `vault.status → locked:true` porque **el dueño de la bóveda y el diálogo de consentimiento no estaban en el corpus** — la función está apagada de fábrica en las cuatro imágenes. Entran `recipes/boveda.toml` y `recipes/shuma-pregunta.toml`; después de eso, el guardián de METAL | el gestor de contraseñas de la suite dentro del navegador | 5 ✅ |
+| 12 | **La bóveda (SDD-BOVEDA §8) — a medias, 2026-09-15.** La décima extensión: ofrece la credencial del sitio abierto y no puede sacar una contraseña por su cuenta —`vault.match` contesta títulos y usuarios; la contraseña sale por `vault.fill`, que **pregunta en el escritorio**—. La dirección la pone el chrome, nunca la página. El gestor de Gecko se aparta como VALOR DE ARRANQUE y no como política. **Destrabada el 2026-09-16** (§7.quinquies.bis): el `Cargo.lock` de tawasuyu publicado —y resultó ser byte a byte el que la otra sesión ya tenía sin commitear—, el pin subido a `23a292863` ⇒ `b3:7d63655a`, construido en el worker (el vendoreo son 2,4 G y el hub estaba al 99%) y medido con el mismo `strings` que había diagnosticado el hueco: `vault` de **0 a 10**, con `cas`/`sct` de control. **Falta**, y no era lo que este renglón decía (§7.sexies, 2026-09-18): el host sellado contesta `vault.status → locked:true` porque **el dueño de la bóveda y el diálogo de consentimiento no estaban en el corpus** — la función está apagada de fábrica en las cuatro imágenes. Entran `recipes/boveda.toml` y `recipes/shuma-pregunta.toml`, **las dos SELLADAS el 2026-09-18** (`b3:59ffd74b` y `b3:99763eca`, construidas en el worker). ⚠ Y con eso apareció el muro de verdad, que no es de la bóveda: **ninguna app llimphi de escritorio abre ventana en esta distro** porque no hay Vulkan en ninguna imagen y el camino GL de llimphi arma la instancia sin display handle (§7.septies). El guardián de metal existe —`scripts/test-atuq-boveda-metal.py`, seis etapas— y hoy llega hasta la A. La B se destraba en llimphi, no acá | el gestor de contraseñas de la suite dentro del navegador | 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 | — |
 
 Las unidades 2 y 4 son **paralelizables**: la toolchain no toca el chrome y el chrome no toca la
diff --git a/recipes/wtype.toml b/recipes/wtype.toml
new file mode 100644
index 00000000..4716ac38
--- /dev/null
+++ b/recipes/wtype.toml
@@ -0,0 +1,57 @@
+# wtype 0.4 — teclear en Wayland desde un script: `wtype "hola"`, `wtype -k Return`. Es xdotool para
+# Wayland, por el protocolo `zwp_virtual_keyboard_v1` (que el XML vendorea en `protocol/`).
+#
+# ── POR QUÉ ENTRA (2026-09-18): sin esto no se puede CONTESTAR un diálogo ───────────────────────
+# El corpus no tiene **ninguna** forma de meter una tecla en un compositor: ni `wtype`, ni `ydotool`,
+# ni nada que hable el protocolo de teclado virtual (comprobado sobre `recipes/` y sobre
+# `scripts/`). Mientras lo único que había que medir era «¿la ventana pinta?», daba igual: las
+# capturas y el log del protocolo contestan eso sin tocar nada. La bóveda de `atuq` rompe esa
+# comodidad, porque su promesa **es** una pregunta: `vault.fill` no entrega la contraseña hasta que
+# una persona diga que sí en `shuma-pregunta` (SDD 26 §7.sexies). Un guardián que no puede decir que
+# sí sólo puede medir la mitad de la función — que la puerta existe—, y nunca que la puerta se abre.
+#
+# No es sólo para la bóveda: es la pieza que le faltaba a **cualquier** arnés de escritorio de este
+# repo. Todos los de `scripts/wlr/` miran; ninguno toca.
+#
+# ⚠ Y NO va declarado en ningún perfil, a propósito: es un INSTRUMENTO, no parte del producto. Mismo
+# criterio que `llama-cpp` cuando entró sin imagen que la declarara — una imagen no crece por algo
+# que sólo usa el que mide. Los arneses lo montan como capa overlay desde el store.
+#
+# ── LA REPRODUCIBILIDAD, QUE ACÁ TIENE UNA TRAMPA A LA VISTA ───────────────────────────────────
+# `meson.build` arma la cadena de versión con `__DATE__` cuando encuentra `git`. Un macro de fecha
+# de compilación es exactamente lo que rompe un binario reproducible… salvo que el compilador
+# respete `SOURCE_DATE_EPOCH`, que es lo que hacen gcc y clang (y por lo tanto `zig cc`) desde hace
+# años: con la variable puesta —y takana la pone siempre— `__DATE__` deja de ser el reloj de pared.
+# Si algún día esta receta apareciera en `why-differs`, éste es el primer sospechoso y está escrito
+# acá para no volver a buscarlo.
+#
+# ⚠ El tag `v0.4` es ANOTADO: `git ls-remote --tags` devuelve DOS shas y el bueno es el `^{}`
+# (d71be3a7…), que es el commit. Pinear el sha del objeto tag valida y después no encuentra el árbol.
+name    = "wtype"
+version = "0.4"
+license = "MIT"
+
+[source]
+repo   = "https://github.com/atx/wtype.git"
+commit = "d71be3a7b3f93b534a2823fd68cabd7ac2a02359"  # v0.4 PELADO (^{commit})
+
+[build]
+compiler    = "zig-cc"
+target      = "x86_64-linux-musl"
+link        = "dynamic"
+zig_version = "0.13.0"
+
+[build.phases]
+configure = "meson setup output --prefix=/usr --buildtype=release --wrap-mode=nodownload"
+compile   = "ninja -C output"
+install   = '''
+set -e
+DESTDIR=/out ninja -C output install
+test -x /out/usr/bin/wtype || { echo "no quedó /out/usr/bin/wtype — ¿cambió el layout de meson?" >&2; exit 1; }
+'''
+
+[deps]
+# `wayland-cursor` y `wayland-client` salen los dos del paquete `wayland`; `xkbcommon` es
+# `libxkbcommon`. `rt` lo resuelve musl dentro de la libc y no hace falta declarar nada.
+build = ["meson", "samurai", "python3", "pkgconf", "wayland", "libxkbcommon", "expat", "libffi", "zlib"]
+run   = ["wayland", "libxkbcommon"]
diff --git a/scripts/test-atuq-boveda-metal.py b/scripts/test-atuq-boveda-metal.py
new file mode 100644
index 00000000..91b82c2a
--- /dev/null
+++ b/scripts/test-atuq-boveda-metal.py
@@ -0,0 +1,598 @@
+#!/usr/bin/env python3
+"""¿La bóveda de `atuq` entrega una contraseña, y sólo si una persona dice que sí? (SDD 26 §7.sexies)
+
+    python3 scripts/test-atuq-boveda-metal.py                 # las seis etapas, en orden
+    python3 scripts/test-atuq-boveda-metal.py --hasta C       # sólo hasta la etapa C (el diálogo)
+    python3 scripts/test-atuq-boveda-metal.py --list          # qué afirma cada etapa, sin correr nada
+
+── POR QUÉ ESTE GUARDIÁN, Y POR QUÉ NO ALCANZA CON EL DE COHERENCIA ──────────────────────────
+`test-atuq-boveda-coherente.py` mira los CINCO lugares donde una extensión se enchufa y tarda un
+segundo. Es lo que hay que correr primero y no reemplaza a nada. Lo que no puede contestar es si del
+otro lado del cable hay alguien: el 2026-09-18 los cinco lugares estaban en orden —manifiesto,
+política, permiso, prefs y el commit del host con sus verbos— y la bóveda contestaba **cerrada** en
+las cuatro imágenes, porque el DUEÑO de la base y el DIÁLOGO de consentimiento no estaban en el
+corpus. Ningún guardián de ficheros podía verlo; éste empieza justamente por ahí.
+
+── LAS SEIS ETAPAS, Y QUÉ AFIRMA CADA UNA ────────────────────────────────────────────────────
+    A  sin la app `boveda`, el host contesta `locked:true`   ← el control, y es el bug del §7.sexies
+    B  con la app corriendo, `vault.status` dice ABIERTA     ← el dueño existe y atiende el socket
+    C  `vault.save` abre el DIÁLOGO y se lo puede contestar  ← el consentimiento es real y se ve
+    D  el navegador, sobre una página servida por HTTP, encuentra la credencial (`vault.match`)
+    E  `vault.fill` contestado que SÍ ⇒ la contraseña llega al campo del formulario
+    F  `vault.fill` contestado que NO ⇒ `denied`, y el campo queda VACÍO   ← el control de E
+
+La etapa A no es decoración: **«cerrada» es una respuesta exitosa** (`ok:true`), así que sin ese
+control un guardián que midiera sólo B no distinguiría «la bóveda abrió» de «nadie preguntó bien».
+Y la F es la que hace que la E signifique algo: una bóveda que entrega siempre se ve idéntica a una
+que entrega con permiso, y la diferencia es la única razón por la que esto existe.
+
+── LO QUE HIZO FALTA PARA PODER CONTESTAR UNA PREGUNTA ───────────────────────────────────────
+El corpus no tenía **ninguna** forma de meter una tecla en un compositor. Todos los arneses de
+`scripts/wlr/` miran; ninguno toca. Entra `wtype` (protocolo `zwp_virtual_keyboard_v1`) como
+INSTRUMENTO —no va en ninguna imagen— y con él se contesta el diálogo igual que lo haría una
+persona: flechas y `Enter`.
+
+⚠ **Y el render es por SOFTWARE a propósito, con `llvmpipe` y no con `softpipe`.** `shuma-pregunta`
+y `boveda` son llimphi/wgpu, o sea clientes de GL; el `mesa` de las imágenes es **iris-only** (Intel)
+y esta máquina tiene una virtio-gpu, así que EGL no encontraría driver. Se montan `mesa-llvmpipe` +
+`llvm18` como últimas capas. Con `mesa-swrast` (softpipe, GL 3.3) el diálogo **no abre**: wgpu pide
+compute shaders y muere con `RequestDevice("Parent device is lost")`, un mensaje que no nombra la
+causa. Es el entorno de prueba, no el artefacto: en la imagen de verdad el driver lo pone el metal.
+
+⚠⚠ **DÓNDE ESTÁ HOY (2026-09-18): la etapa A pasa y la B choca contra un muro que no es de la
+bóveda.** `boveda` y `shuma-pregunta` están sellados, y ninguno de los dos abre ventana: llimphi
+construye la instancia de wgpu **sin display handle** —su propio comentario lo dice: «este camino no
+tiene ventana todavía»—, wgpu cae entonces a la plataforma EGL **surfaceless**, que por definición no
+tiene configs de ventana, y `surface.get_capabilities()` devuelve **cero formatos**; llimphi hace
+`caps.formats[0]` y panica. En una máquina con Vulkan no se nota, porque el backend PRIMARY no
+necesita el display — y **ninguna imagen de takana tiene Vulkan**: las tres recetas de mesa se
+compilan con `-Dvulkan-drivers=` vacío. Ver SDD 26 §7.septies. Este guardián queda escrito y
+CORRIENDO hasta donde se puede: la A en verde es la medición del §7.sexies, vigilada.
+
+REQUISITOS: el rootfs de sway (`scripts/hydrate-profile.py escritorio-sway --into …`) y, sellados,
+`atuq`, `puriy-costura`, `boveda`, `shuma-pregunta`, `wtype`, `llvm18` y `mesa-llvmpipe`.
+"""
+import argparse
+import http.server
+import json
+import os
+import shutil
+import socket
+import struct
+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"))
+
+# La raíz de la bóveda DE PRUEBA. Con esta variable puesta, la app deriva sus claves de esta cadena
+# en vez de la seed de identidad del llavero del kernel —que en ninguna máquina de desarrollo está
+# desbloqueada— y abre `…/tawasuyu/boveda-de-prueba`, que es OTRA bóveda, vacía, y lo dice en
+# pantalla. Sin esto este guardián no se podría encender en ningún lado.
+RAIZ_DE_PRUEBA = "metal-de-atuq"
+
+USUARIO = "sergio"
+CLAVE = "Zx7#qLm2pVnR"
+TITULO = "Banco de prueba"
+
+ETAPAS = [
+    ("A", "sin la app `boveda`, el host contesta locked:true (el bug del §7.sexies, vigilado)"),
+    ("B", "con la app corriendo, vault.status dice ABIERTA: el dueño atiende el socket"),
+    ("C", "el DIÁLOGO abre en el compositor y se lo puede CONTESTAR (el instrumento: wtype)"),
+    ("D", "vault.save contestado que SÍ guarda, y vault.match la encuentra después"),
+    ("E", "el navegador real: vault.fill contestado que SÍ ⇒ la contraseña llega al campo"),
+    ("F", "vault.fill contestado que NO ⇒ denied y el campo queda VACÍO (el control de E)"),
+]
+
+# El atajo del diálogo: el dígito contesta con esa opción (`atajo_de_digito` en shuma-pregunta),
+# así que «1» es la primera opción y «2» la segunda. Se teclea con `wtype`, que es la única forma
+# que tiene este corpus de meter una tecla en un compositor.
+TECLA_SI = "1"
+TECLA_NO = "2"
+
+PAGINA = """<!doctype html><html><head><meta charset="utf-8"><title>Banco de prueba</title></head>
+<body><h1>Banco de prueba</h1>
+<form id="f" method="POST" action="/entrar">
+  <input id="u" name="username" type="text" autocomplete="username">
+  <input id="p" name="password" type="password" autocomplete="current-password">
+  <button id="b" type="submit">Entrar</button>
+</form></body></html>
+"""
+
+
+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 al usarse: el corpus es compartido y otro frente puede re-hashear una dep
+    dejando lo de acá sin sellar durante las horas de su build. Un resultado obtenido con la
+    escotilla NO se cita como «el de hoy pasa».
+    """
+    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:\n"
+              f"    flock -o work/.farm-build.lock {HAMMER} --store {STORE} build recipes/{receta}.toml")
+    return d
+
+
+class Servidor(http.server.SimpleHTTPRequestHandler):
+    """La página de login, servida de verdad. `atuq` la carga por la red como cualquier otra."""
+
+    def log_message(self, *a):
+        pass
+
+    def do_GET(self):
+        datos = PAGINA.encode()
+        self.send_response(200)
+        self.send_header("Content-Type", "text/html; charset=utf-8")
+        self.send_header("Content-Length", str(len(datos)))
+        self.send_header("Cache-Control", "no-store")
+        self.end_headers()
+        self.wfile.write(datos)
+
+    def do_POST(self):
+        n = int(self.headers.get("Content-Length") or 0)
+        self.rfile.read(n)
+        datos = b"<!doctype html><html><body>adentro</body></html>"
+        self.send_response(200)
+        self.send_header("Content-Type", "text/html; charset=utf-8")
+        self.send_header("Content-Length", str(len(datos)))
+        self.end_headers()
+        self.wfile.write(datos)
+
+
+def puerto_libre():
+    s = socket.socket()
+    s.bind(("0.0.0.0", 0))
+    p = s.getsockname()[1]
+    s.close()
+    return p
+
+
+# ── El cliente del cable, en Python ───────────────────────────────────────────────────────────
+# Marco de 4 bytes little-endian + JSON, que es lo que habla native messaging y lo que
+# `shared/foreign-webext` implementa del otro lado. Se escribe acá —y no se llama a la extensión—
+# porque las etapas A/B/C miden la CADENA host↔dueño↔diálogo, donde el navegador no participa:
+# meterlo sólo agregaría diez minutos y un sospechoso más a cada fallo.
+CLIENTE = r'''
+import json, os, struct, subprocess, sys
+def hablar(binario, estado, mensajes):
+    p = subprocess.Popen([binario, "--state", estado],
+                         stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+    entrada = b""
+    for m in mensajes:
+        b = json.dumps(m).encode()
+        entrada += struct.pack("<I", len(b)) + b
+    fuera, err = p.communicate(entrada, timeout=180)
+    salida, i = [], 0
+    while i + 4 <= len(fuera):
+        n = struct.unpack("<I", fuera[i:i+4])[0]; i += 4
+        salida.append(json.loads(fuera[i:i+n])); i += n
+    return salida, err.decode(errors="replace")
+'''
+
+
+
+# ── El contestador: quien dice que sí (o que no) donde diría una persona ──────────────────────
+# ⚠ Espera a que la ventana esté en el ÁRBOL DEL COMPOSITOR y con el foco, **no** a que el proceso
+# exista. Que `shuma-pregunta` esté corriendo no dice que su superficie esté mapeada ni enfocada, y
+# una tecla mandada antes no la recibe nadie: el diálogo se queda abierto y el resultado se lee como
+# «el consentimiento no contesta», que es la conclusión contraria a la verdadera. El compositor es
+# el único que sabe quién tiene el foco, así que se le pregunta a él.
+CONTESTADOR = r"""
+import json, subprocess, sys, time
+
+TECLA = sys.argv[1]
+ESPERA = int(sys.argv[2]) if len(sys.argv) > 2 else 120
+
+
+def arbol():
+    r = subprocess.run(["/usr/bin/swaymsg", "-t", "get_tree", "-r"],
+                       capture_output=True, text=True)
+    if r.returncode != 0 or not r.stdout.strip():
+        return None
+    try:
+        return json.loads(r.stdout)
+    except Exception:
+        return None
+
+
+def ventanas(n, acc=None):
+    acc = [] if acc is None else acc
+    if n.get("pid") and n.get("type") in ("con", "floating_con"):
+        acc.append(n)
+    for hijo in (n.get("nodes") or []) + (n.get("floating_nodes") or []):
+        ventanas(hijo, acc)
+    return acc
+
+
+t0 = time.time()
+visto = None
+while time.time() - t0 < ESPERA:
+    a = arbol()
+    if a:
+        vs = ventanas(a)
+        if vs:
+            visto = vs
+            enfocada = [v for v in vs if v.get("focused")]
+            print("VENTANAS " + json.dumps(
+                [{"app_id": v.get("app_id"), "name": v.get("name"),
+                  "focused": bool(v.get("focused")),
+                  "rect": v.get("rect")} for v in vs], ensure_ascii=False))
+            if enfocada:
+                # Un respiro después del foco: el primer frame puede no estar pintado todavía y
+                # la tecla llegaría a una ventana que aún no instaló su manejador.
+                time.sleep(2)
+                r = subprocess.run(["/usr/bin/wtype", TECLA], capture_output=True, text=True)
+                print("WTYPE %s rc=%s %s" % (TECLA, r.returncode, r.stderr.strip()))
+                raise SystemExit(0)
+    time.sleep(1)
+print("SIN VENTANA tras %ss; lo último que vi: %s" % (ESPERA, json.dumps(visto, ensure_ascii=False)[:2000]))
+raise SystemExit(1)
+"""
+
+
+def guion_dentro(cuerpo, con_boveda, log="pasos.log", salida_rel="/salida"):
+    """El guión que corre DENTRO de la jaula, con sway headless levantado.
+
+    El compositor se levanta siempre —aunque la etapa no lo necesite— porque el diálogo es un
+    cliente wayland y `boveda` también: sin compositor no se distingue «la app no abrió la bóveda»
+    de «la app no encontró dónde pintar», que son dos causas muy distintas con el mismo síntoma.
+    """
+    arranque_boveda = ""
+    if con_boveda:
+        arranque_boveda = f"""
+  # El DUEÑO de la bóveda. Se lanza con la raíz de prueba puesta, así que abre
+  # `…/tawasuyu/boveda-de-prueba` y no la de nadie. Levanta el socket del navegador en un hilo.
+  BOVEDA_TEST_ROOT={RAIZ_DE_PRUEBA} /usr/bin/boveda >> {salida_rel}/boveda.log 2>&1 &
+  echo "@@ boveda lanzada pid $!" >> {salida_rel}/{log}
+  # Se espera al SOCKET, no a un `sleep`: un sleep que alcanza hoy no alcanza en una máquina
+  # cargada, y el fallo aparece como «la bóveda estaba cerrada» — la conclusión contraria.
+  i=0
+  while [ ! -S "$XDG_RUNTIME_DIR/boveda.sock" ] && [ $i -lt 60 ]; do sleep 1; i=$((i+1)); done
+  [ -S "$XDG_RUNTIME_DIR/boveda.sock" ] \\
+    && echo "@@ socket de la boveda LISTO en ${{i}}s" >> {salida_rel}/{log} \\
+    || echo "@@ SIN socket de la boveda tras ${{i}}s" >> {salida_rel}/{log}
+"""
+    return f"""#!/bin/sh
+export XDG_RUNTIME_DIR=/run/user/0 HOME={salida_rel}/hogar LANG=C XDG_DATA_DIRS=/usr/share
+export XDG_STATE_HOME={salida_rel}/hogar/.local/state
+export WLR_BACKENDS=headless WLR_HEADLESS_OUTPUTS=1 WLR_RENDERER=pixman
+# El cliente de GL va por software: ver la cabecera. `LIBGL_ALWAYS_SOFTWARE` hace que EGL elija
+# `swrast` en vez de buscar un driver de la GPU que esta máquina no tiene, y `GALLIUM_DRIVER`
+# elige CUÁL de los dos software.
+#
+# ⚠ **Y tiene que ser `llvmpipe`, no `softpipe`, y no es una preferencia.** Medido el 2026-09-18:
+# con softpipe el diálogo no abre nunca y muere con `RequestDevice("Parent device is lost")`, que no
+# nombra la causa. La causa está una línea más arriba en el log, y sólo aparece con
+# `RUST_LOG=wgpu_core=info`:
+#
+#     Renderer: softpipe · Version: 3.3 (Core Profile)
+#     indirect-validation error: ComputePipeline(Internal("The selected version doesn't support
+#         Features(BUFFER_STORAGE | COMPUTE_SHADER | DYNAMIC_ARRAY_SIZE)"))
+#
+# O sea: **wgpu exige compute shaders** y softpipe se queda en GL 3.3. `llvmpipe` da GL 4.5 y pasa.
+# Las dos son «mesa por software» y en el store son dos recetas hermanas (`mesa-swrast` es softpipe,
+# `mesa-llvmpipe` es llvmpipe + `llvm18`), así que la diferencia se ve como un detalle de nombre y
+# decide si el arnés existe o no.
+export LIBGL_ALWAYS_SOFTWARE=1 LIBGL_DRIVERS_PATH=/usr/lib/dri GALLIUM_DRIVER=llvmpipe
+mkdir -p "$XDG_RUNTIME_DIR" {salida_rel}/hogar "$XDG_STATE_HOME"; 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" >> {salida_rel}/{log}; exit 1; }}
+  export WAYLAND_DISPLAY=wayland-1
+  export SWAYSOCK=$(ls /run/user/0/sway-ipc.* 2>/dev/null | head -1)
+  echo "@@ wayland listo" >> {salida_rel}/{log}
+{arranque_boveda}
+{cuerpo}
+  sleep 1
+  /usr/bin/swaymsg exit 2>/dev/null
+) &
+exec /usr/bin/sway -d > {salida_rel}/sway.log 2>&1
+"""
+
+
+def correr_jaula(capas, salida, guion, segundos=420):
+    """Una corrida de bwrap con las capas dadas. La ÚLTIMA capa gana."""
+    ruta = os.path.join(salida, "dentro.sh")
+    with open(ruta, "w") as fh:
+        fh.write(guion)
+    orden = ["bwrap"]
+    for c in capas:
+        orden += ["--overlay-src", c]
+    orden += [
+        "--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=segundos)
+        return True
+    except subprocess.TimeoutExpired:
+        print(f"   ⚠ la jaula no se cerró sola; se la cortó a los {segundos} s")
+        return False
+
+
+def leer(salida, nombre):
+    r = os.path.join(salida, nombre)
+    return open(r, errors="replace").read() if os.path.exists(r) else ""
+
+
+def main():
+    ap = argparse.ArgumentParser(add_help=False)
+    ap.add_argument("--hasta", default="F")
+    ap.add_argument("--list", action="store_true")
+    ap.add_argument("-h", "--help", action="store_true")
+    args, _ = ap.parse_known_args()
+    if args.help:
+        print(__doc__)
+        return
+    if args.list:
+        for clave, que in ETAPAS:
+            print(f"  {clave}  {que}")
+        return
+
+    hasta = args.hasta.upper()
+    if hasta not in [c for c, _ in ETAPAS]:
+        fatal(f"--hasta {hasta}: las etapas son " + ", ".join(c for c, _ in ETAPAS))
+
+    if not os.path.isdir(RFS):
+        fatal(f"no está el rootfs de sway en {RFS} — hidratalo con "
+              "`scripts/hydrate-profile.py escritorio-sway --into …`")
+
+    # Los artefactos se exigen POR ETAPA y no todos de entrada. No es comodidad: pedir los seis para
+    # correr la A haría que este guardián no se pudiera encender mientras se construye una de las
+    # piezas que él mismo destrabó —y la A es justamente la que mide el estado SIN esas piezas—.
+    necesita = {"A": [("puriy-costura", "puriy-costura", None)]}
+    necesita["B"] = necesita["A"] + [("boveda", "boveda", None),
+                                 ("llvm18", "llvm18", None),
+                                 ("mesa-llvmpipe", "mesa-llvmpipe", None)]
+    necesita["C"] = necesita["B"] + [("shuma-pregunta", "shuma-pregunta", None), ("wtype", "wtype", None)]
+    necesita["D"] = necesita["C"] + [("atuq", "atuq", "ATUQ_DIR")]
+    necesita["E"] = necesita["D"]
+    necesita["F"] = necesita["D"]
+
+    dirs = {}
+    for receta, nombre, escotilla in necesita[hasta]:
+        dirs[nombre] = artefacto(receta, nombre, escotilla)
+        print(f"artefacto: {os.path.basename(dirs[nombre])}")
+
+    # El orden de las capas ES la semántica: **la última gana**. El mesa por software va al final
+    # para pisar el `iris`-only del rootfs; si fuera antes, el rootfs le devolvería el driver de una
+    # GPU Intel que esta máquina no tiene. `llvm18` va antes porque `swrast_dri.so` lo NECESITA:
+    # sin él, el loader dice «failed to open swrast: libLLVM.so.18.1» y wgpu reporta «NoAdapter»,
+    # que es el mismo síntoma que no tener driver.
+    capas_base = [ALPINE, RFS]
+    for n in ("atuq", "puriy-costura", "boveda", "shuma-pregunta", "wtype", "llvm18", "mesa-llvmpipe"):
+        if n in dirs:
+            capas_base.append(dirs[n])
+
+    # El servidor de la página de login. Se levanta aunque la etapa no lo use: su PUERTO es lo que
+    # define la URL con la que se guarda la credencial, y esa URL tiene que ser la MISMA en la
+    # etapa que guarda y en la que el navegador visita — si no, `vault.match` no encontraría nada
+    # y el fallo hablaría de la bóveda cuando el que cambió fue el puerto.
+    puerto = puerto_libre()
+    servidor = http.server.ThreadingHTTPServer(("0.0.0.0", puerto), Servidor)
+    threading.Thread(target=servidor.serve_forever, daemon=True).start()
+    url = f"http://127.0.0.1:{puerto}/"
+    print(f"la página de login se sirve en {url}")
+
+    tmp = tempfile.mkdtemp(prefix="atuq-boveda-metal-")
+    salida = os.path.join(tmp, "salida")
+    os.makedirs(salida)
+    fallos = []
+    try:
+        correr_etapas(capas_base, salida, hasta, url, fallos)
+    finally:
+        guardado = os.path.join(ROOT, "work", "atuq-boveda-metal")
+        os.makedirs(guardado, exist_ok=True)
+        for n in os.listdir(salida):
+            o = os.path.join(salida, n)
+            if os.path.isfile(o):
+                shutil.copy(o, os.path.join(guardado, n))
+        print(f"\nlogs en {guardado}")
+        shutil.rmtree(tmp, ignore_errors=True)
+
+    if fallos:
+        for f in fallos:
+            print(f"✗ {f}", file=sys.stderr)
+        raise SystemExit(1)
+    print("\n✓ todo lo que se corrió, en verde")
+
+
+def respuesta(salida, fichero, verbo):
+    """La respuesta a un verbo, leída del JSON que dejó el preguntador dentro de la jaula."""
+    crudo = leer(salida, fichero)
+    if not crudo.strip():
+        return None
+    for r in json.loads(crudo):
+        if r.get("verb") == verbo:
+            return r
+    return None
+
+
+def correr_etapas(capas, salida, hasta, url, fallos):
+    """Las etapas, en orden y cada una en su propia jaula.
+
+    Cada etapa levanta su propio compositor. Es más lento que reusar uno y es a propósito: el
+    estado que importa —la bóveda de prueba, el registro del host— vive en `/salida`, que se
+    comparte, y así una etapa no hereda procesos de la anterior. Dos corridas sobre el mismo
+    log se MEZCLAN y el resultado parece un pase; eso ya pasó en este frente (§6.1.bis).
+    """
+    orden = [c for c, _ in ETAPAS]
+    tope = orden.index(hasta)
+    escribir_preguntador(salida, url)
+    with open(os.path.join(salida, "contestar.py"), "w") as fh:
+        fh.write(CONTESTADOR)
+
+    # ── A: SIN la app. Es el estado en que estuvo el corpus entero hasta el 2026-09-18. ────────
+    print("\n== A · sin la app `boveda`, ¿qué contesta el host?")
+    cuerpo = "  python3 /salida/preguntar.py sin-boveda >> /salida/pasos-A.log 2>&1\n"
+    correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=False, log="pasos-A.log"), segundos=240)
+    print(indentar(leer(salida, "pasos-A.log")))
+    estado = respuesta(salida, "sin-boveda.json", "vault.status")
+    if estado is None:
+        fallos.append("A: el preguntador no dejó respuesta — mirá pasos-A.log")
+    elif estado.get("locked") is not True:
+        fallos.append(f"A: sin la app, vault.status tenía que decir locked:true y dijo {estado}")
+    else:
+        print("   ✓ A: sin dueño, la bóveda contesta CERRADA (y con ok:true, que es la trampa)")
+    if tope < orden.index("B"):
+        return
+
+    # ── B: CON la app. El dueño abre la base y atiende el socket. ──────────────────────────────
+    print("\n== B · con la app `boveda` corriendo, ¿la bóveda está abierta?")
+    cuerpo = "  python3 /salida/preguntar.py con-boveda >> /salida/pasos-B.log 2>&1\n"
+    correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=True, log="pasos-B.log"), segundos=300)
+    print(indentar(leer(salida, "pasos-B.log")))
+    estado = respuesta(salida, "con-boveda.json", "vault.status")
+    if estado is None:
+        fallos.append("B: el preguntador no dejó respuesta — mirá pasos-B.log y boveda.log")
+    elif estado.get("locked") is not False:
+        fallos.append(f"B: con la app corriendo, vault.status tenía que decir locked:false y dijo "
+                      f"{estado}.\n"
+                      "     Mirá `boveda.log`. Si dice «index out of bounds: the len is 0 but the index "
+                      "is 0» en `llimphi-hal/src/lib.rs`, NO es de la bóveda: es el muro del §7.septies "
+                      "—llimphi construye la instancia de wgpu SIN display handle, wgpu cae a la "
+                      "plataforma EGL surfaceless, esa plataforma no tiene configs de ventana, y la "
+                      "lista de formatos de la surface sale vacía—. Se destraba en llimphi, no acá, y "
+                      "hasta entonces la app no abre ventana ni levanta el socket del navegador.")
+    else:
+        print("   ✓ B: el dueño atiende — la bóveda de prueba está ABIERTA")
+    if tope < orden.index("C"):
+        return
+
+    # ── C: el instrumento. Antes de pedirle nada a la bóveda, ¿se puede contestar un diálogo? ──
+    # Va ANTES de D a propósito: si el diálogo no se puede contestar, `vault.save` va a decir
+    # `denied` — que es la respuesta CORRECTA a un consentimiento que no llegó— y sin esta etapa
+    # eso se leería como «la bóveda no guarda». Separar el instrumento del producto es lo que
+    # hace que el fallo diga cuál de los dos se rompió.
+    print("\n== C · ¿abre el diálogo en el compositor, y se lo puede contestar?")
+    cuerpo = """
+  for par in si:%s:1 no:%s:2; do
+    quien=${par%%%%:*}; resto=${par#*:}; tecla=${resto%%%%:*}; n=${resto#*:}
+    ( python3 /salida/contestar.py "$tecla" > /salida/contestador-C-$quien.log 2>&1 ) &
+    /usr/bin/shuma-pregunta --title "Prueba del instrumento" --detail "esto no toca la boveda" \
+        --option "yes=Si, contesta" --option "no=No" \
+        > /salida/dialogo-C-$quien.json 2> /salida/dialogo-C-$quien.err
+    echo "@@ dialogo $quien salio con $? y dijo: $(cat /salida/dialogo-C-$quien.json)" >> /salida/pasos-C.log
+    wait
+  done
+""" % (TECLA_SI, TECLA_NO)
+    correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=False, log="pasos-C.log"), segundos=420)
+    print(indentar(leer(salida, "pasos-C.log")))
+    for quien in ("si", "no"):
+        print(indentar(leer(salida, f"contestador-C-{quien}.log")))
+    dijo_si = leer(salida, "dialogo-C-si.json").strip()
+    dijo_no = leer(salida, "dialogo-C-no.json").strip()
+    if '"choice":"yes"' not in dijo_si.replace(" ", ""):
+        fallos.append(f"C: al teclear «{TECLA_SI}» el diálogo tenía que contestar yes y dijo {dijo_si!r} "
+                      "— si no hay VENTANAS en el log del contestador, el cliente de GL no pintó "
+                      "(mirá dialogo-C-si.err: es donde llimphi dice por qué)")
+    elif '"choice":"no"' not in dijo_no.replace(" ", ""):
+        # El control: sin él, un diálogo que contestara «yes» pase lo que pase se vería igual.
+        fallos.append(f"C: el control falló — al teclear «{TECLA_NO}» tenía que contestar no y dijo {dijo_no!r}")
+    else:
+        print("   ✓ C: el diálogo abre, la tecla llega, y contesta lo que se le tecleó (sí Y no)")
+    if tope < orden.index("D"):
+        return
+
+    # ── D: la bóveda guarda, y sólo con permiso. ───────────────────────────────────────────────
+    print("\n== D · vault.save con el consentimiento dado, ¿guarda? ¿y después la encuentra?")
+    cuerpo = """
+  ( python3 /salida/contestar.py "%s" > /salida/contestador-D.log 2>&1 ) &
+  python3 /salida/preguntar.py guardar >> /salida/pasos-D.log 2>&1
+  wait
+""" % TECLA_SI
+    correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=True, log="pasos-D.log"), segundos=420)
+    print(indentar(leer(salida, "pasos-D.log")))
+    print(indentar(leer(salida, "contestador-D.log")))
+    guardo = respuesta(salida, "guardar.json", "vault.save")
+    encontro = respuesta(salida, "guardar.json", "vault.match")
+    if guardo is None:
+        fallos.append("D: el preguntador no dejó respuesta de vault.save — mirá pasos-D.log")
+    elif guardo.get("denied") is True:
+        fallos.append("D: vault.save salió DENEGADO. Si la etapa C está en verde, el diálogo se puede "
+                      "contestar y esto es del producto; si C falló, es el instrumento y no la bóveda")
+    elif not guardo.get("item"):
+        fallos.append(f"D: vault.save no devolvió el identificador de lo guardado: {guardo}")
+    elif not (encontro or {}).get("items"):
+        fallos.append(f"D: guardó pero vault.match no la encuentra para {url}: {encontro}")
+    else:
+        item = encontro["items"][0]
+        if item.get("username") != USUARIO:
+            fallos.append(f"D: la credencial encontrada no es la guardada: {item}")
+        else:
+            print(f"   ✓ D: guardada con permiso y encontrada de nuevo ({item.get('title')!r}, "
+                  f"usuario {item.get('username')!r})")
+            # Y la mitad que importa: `vault.match` NO puede traer la contraseña. El tipo del
+            # otro lado no tiene dónde ponerla, y esto lo comprueba desde afuera.
+            if any(k in item for k in ("password", "clave")):
+                fallos.append(f"D: ⚠ vault.match trajo una CONTRASEÑA, y no debe: {item}")
+
+
+def indentar(t):
+    return "\n".join("   " + l for l in t.splitlines() if l.strip())
+
+
+def escribir_preguntador(salida, url):
+    """El cliente del cable que corre DENTRO de la jaula.
+
+    Habla con el host EXACTAMENTE como lo hace la extensión —marco de 4 bytes + JSON— y no con la
+    bóveda directamente: lo que este guardián tiene que medir es la cadena entera, y el host es el
+    único que sabe encontrar al dueño.
+    """
+    with open(os.path.join(salida, "preguntar.py"), "w") as fh:
+        fh.write(CLIENTE)
+        fh.write(f'''
+QUE = sys.argv[1]
+URL = {url!r}
+BIN = "/usr/bin/puriy-costura"
+ESTADO = "/salida/hogar/estado-costura"
+os.makedirs(ESTADO, exist_ok=True)
+# `ping` va SIEMPRE y es el control de la corrida: separa «el host no contestó» de «el host
+# contestó que la bóveda está cerrada», que es la distinción que costó tres semanas.
+mensajes = [
+    {{"id": 1, "verb": "ping"}},
+    {{"id": 2, "verb": "vault.status"}},
+]
+if QUE == "guardar":
+    mensajes.append({{"id": 3, "verb": "vault.save", "url": URL,
+                      "username": {USUARIO!r}, "password": {CLAVE!r}, "title": {TITULO!r}}})
+if QUE in ("match", "guardar"):
+    mensajes.append({{"id": 4, "verb": "vault.match", "url": URL}})
+respuestas, err = hablar(BIN, ESTADO, mensajes)
+open("/salida/%s.json" % QUE, "w").write(json.dumps(respuestas, ensure_ascii=False, indent=1))
+open("/salida/%s.err" % QUE, "w").write(err)
+for r in respuestas:
+    print("   <-", json.dumps(r, ensure_ascii=False))
+''')
+
+
+if __name__ == "__main__":
+    main()