tawasuyu

tawasuyu/agora

> Public square. Federated identity and a trust graph that no one runs.

Rust 76Markdown 49TOML 211529 Definiciones

feat: agora standalone — front-door de identidad federada y grafo de confianza

Extraído del monorepo con scripts/actualizar-standalone.py: 13 crates propios,
todo lo fundacional (Llimphi incluido) por git-dep a tawasuyu.git v0.2.0, con
[patch] al source git para agora-{core,graph,petnames} (una sola fuente).
cargo check --workspace verde.

d0638350bf39c350b2d1d1858a8c8eb962240fa5 · por Sergio · (hace 2 meses) · padre ad38d2d18 · ver el árbol en este commit

Delta semántico · 1099

03_ukupacha/agora/agora-app/examples/pantallazo_agora.rs

03_ukupacha/agora/agora-app/src/chrome.rs

03_ukupacha/agora/agora-app/src/main.rs

03_ukupacha/agora/agora-app/src/model.rs

03_ukupacha/agora/agora-app/src/modulos/agora.rs

03_ukupacha/agora/agora-app/src/modulos/mod.rs

03_ukupacha/agora/agora-app/src/modulos/qillqa/autoridad.rs

03_ukupacha/agora/agora-app/src/modulos/qillqa/compras.rs

03_ukupacha/agora/agora-app/src/modulos/qillqa/mesa.rs

03_ukupacha/agora/agora-app/src/modulos/qillqa/mod.rs

03_ukupacha/agora/agora-app/src/modulos/qillqa/registro.rs

03_ukupacha/agora/agora-app/src/modulos/qillqa/veeduria.rs

03_ukupacha/agora/agora-app/src/modulos/qillqa/verificar.rs

03_ukupacha/agora/agora-app/src/perfil.rs

03_ukupacha/agora/agora-app/src/tiles/actas.rs

03_ukupacha/agora/agora-app/src/tiles/capacidad.rs

03_ukupacha/agora/agora-app/src/tiles/compositor.rs

03_ukupacha/agora/agora-app/src/tiles/mod.rs

03_ukupacha/agora/agora-app/src/tiles/multifirma.rs

03_ukupacha/agora/agora-app/src/tiles/politica.rs

03_ukupacha/agora/agora-app/src/tiles/release.rs

03_ukupacha/agora/agora-app/src/ui.rs

03_ukupacha/agora/agora-ayllu/src/lib.rs

03_ukupacha/agora/agora-ayllu/src/mallki.rs

03_ukupacha/agora/agora-channel/examples/forjar_propuesta_mudanza_demo.rs

03_ukupacha/agora/agora-channel/tests/e2e_mudanza_demo.rs

03_ukupacha/agora/agora-cli/examples/verificar_concesiones.rs

03_ukupacha/agora/agora-cli/src/atestacion.rs

03_ukupacha/agora/agora-cli/src/canal.rs

03_ukupacha/agora/agora-cli/src/cmd.rs

03_ukupacha/agora/agora-cli/src/dispositivo.rs

03_ukupacha/agora/agora-cli/src/identidad.rs

03_ukupacha/agora/agora-cli/src/sesion.rs

03_ukupacha/agora/agora-cli/src/wawa.rs

03_ukupacha/agora/agora-confianza/src/lib.rs

03_ukupacha/agora/agora-core/src/attest.rs

03_ukupacha/agora/agora-core/src/claim.rs

03_ukupacha/agora/agora-core/src/identity.rs

03_ukupacha/agora/agora-core/src/lib.rs

03_ukupacha/agora/agora-core/src/lifecycle.rs

03_ukupacha/agora/agora-core/src/multisig.rs

03_ukupacha/agora/agora-dispositivo/src/lib.rs

03_ukupacha/agora/agora-gossip/examples/two_node_sync.rs

03_ukupacha/agora/agora-graph/src/forkproof.rs

03_ukupacha/agora/agora-graph/src/lib.rs

03_ukupacha/agora/agora-keystore/src/lib.rs

03_ukupacha/agora/agora-net-brahman/examples/convergencia_minga.rs

03_ukupacha/agora/agora-net-brahman/src/lib.rs

03_ukupacha/agora/agora-net-brahman/src/rate_limit.rs

03_ukupacha/agora/agora-petnames/src/lib.rs

108 archivos · +35014 −0

diff --git a/03_ukupacha/agora/APLICACIONES.md b/03_ukupacha/agora/APLICACIONES.md
new file mode 100644
index 0000000..f1686b3
--- /dev/null
+++ b/03_ukupacha/agora/APLICACIONES.md
@@ -0,0 +1,150 @@
+# Aplicaciones de agora — orden de prioridad
+
+Catálogo de aplicaciones **útiles ahora** sobre las primitivas que `agora` ya
+expone (no roadmap especulativo). El criterio de orden combina dos ejes:
+
+1. **Utilidad presente** — resuelve un problema real hoy, no "cuando se publique".
+2. **Apalancamiento** — cuánto del código necesario ya existe y compila.
+
+Cada entrada marca su pista: **[T]** técnica/infraestructura, **[S]** social/organizativa.
+
+## Primitivas disponibles hoy
+
+- **Identidad Ed25519 + atestaciones firmadas + grafo de confianza sin servidor**
+  (`agora-core`, `agora-graph`, `agora-keystore` cifrado con Argon2id+ChaCha20).
+- **Identidad fractal**: `IdentityKind::{Person, Community, Alliance, Institution}`
+  — la organización anida por diseño, no por convención.
+- **Multifirma M-of-N con whitelist**: `MultiSignature::verify_threshold_in`.
+- **Canal append-only firmado** con timestamps estrictamente monótonos:
+  `verificar_canal` (libro mayor autoverificable).
+- **Manifiestos firmados + concesión de capacidad atada a `BLAKE3(bytecode)`**:
+  `firmar_capacidad` / `verificar_capacidad`.
+- **Distribución content-addressed por LAN sin IP/TCP** (AoE: anuncio broadcast +
+  `SolicitarObjeto`/`ProveedorObjeto` + descarga recursiva del DAG).
+- **Gossip anti-entropía transport-agnóstico** + transporte libp2p compartido con
+  minga (`agora-net-brahman`, mismo `PeerId`).
+
+## Gaps transversales (afectan a varias entradas)
+
+- **No hay revocación** de identidades/atestaciones todavía. Un claim de
+  revocación atestado cubriría el 80%.
+- **§14.1.3 (enforcement de capacidad en kernel)** está hecho en host pero
+  pendiente de validar en QEMU.
+
+---
+
+## Prioridad 1 — útil ya, casi todo el código existe
+
+### P1. Store/repo de apps wawa firmado, offline, por LAN  [T]
+Reusa: `wawa publicar` + `wawa anunciar` (ya difunden el release por Ethernet
+crudo) + `verificar_canal`. Falta: índice navegable (qué canales/versiones hay en
+la red) + botón "instalar" en `mudanza`. Caso: parque de máquinas sin internet
+(fábrica, aula, barco, sitio remoto).
+
+### P2. Distribución de datasets/árboles POSIX firmados por LAN  [T]
+Reusa: `wawa importar` / `importar-imagen` / `exportar` (direccionan árboles por
+contenido con dedup) envueltos en canal firmado. Es Syncthing/IPFS-lite **pero
+autenticado**: N nodos descargan verificando cada objeto contra su hash. Caso:
+repartir un corpus o assets a varias máquinas sin servidor central.
+
+### P3. Notarización y firma de documentos  [T]
+Reusa: `Attestation::create` firma cualquier claim; `firmar_manifiesto` firma
+cualquier hash BLAKE3. Conectar a **pluma/khipu**: sellar un documento y atestar
+"identidad X aprobó este blob el día Y". Verificación offline, sin CA externa.
+
+### P4. Credenciales y membresía verificables de una organización  [S]
+Reusa: `IdentityKind` + atestaciones. Una institución atesta "persona X es socia /
+tiene rol Y / completó Z". El portador guarda la atestación; cualquiera la
+verifica offline contra la pubkey de la institución, sin que esta esté online.
+Casos: carnet de socio, certificación profesional, diploma, acreditación de
+prensa, pase de evento.
+
+---
+
+## Prioridad 2 — alto valor, base sólida, falta UI/flujo
+
+### P5. OTA delta para flotas de wawa  [T]
+Reusa: canal append-only + descarga recursiva. Una nueva raíz transmite **sólo
+los objetos nuevos** (dedup por contenido). Rollback = apuntar a una raíz anterior
+del historial (ya trivial). Caso: upgrade incremental verificado de fábrica.
+
+### P6. Web of trust / reputación sin autoridad central  [S]
+Reusa: `agora-graph` (corroboración) + `TrustPolicy` negociada por lector (no hay
+veredicto central). "¿A esta persona la avalan otros que yo ya respeto?" Cada quien
+fija su umbral. Casos: marketplaces P2P, reputación de proveedores, vetting de
+colaboradores, anti-sybil ligero.
+
+### P7. Gobernanza por quórum (decisión colectiva M-of-N)  [S]
+Reusa: `MultiSignature::verify_threshold_in` + whitelist. Una decisión sólo es
+válida si K de N miembros firmaron. Gobernanza sin smart-contract ni blockchain.
+Casos: junta que aprueba un gasto, comité que ratifica una propuesta, multifirma
+para una acción crítica.
+
+### P8. Libro de actas / registro de decisiones inmutable  [S]
+Reusa: canal append-only firmado con orden temporal garantizado. Cada raíz = un
+acta firmada y fechada, encadenada y auditable por cualquier miembro con
+`verificar_canal`. Casos: actas de asamblea, historial de acuerdos de una
+cooperativa, bitácora de gobernanza.
+
+### P9. Log de auditoría / compliance verificable  [T]
+Misma primitiva que P8 (el canal es un libro mayor), enfocada a trazabilidad
+regulatoria, cadena de custodia, historial de aprobaciones. Re-verificación total
+de la cadena por cualquiera.
+
+---
+
+## Prioridad 3 — ambicioso: requiere componer y/o cerrar gaps
+
+### P10. Cooperativa / colectivo / DAO ligera sin cadena de bloques  [S]
+Compone P4 + P7 + P8: identidad fractal anidada (persona ∈ comunidad ∈ alianza) +
+quórum + libro de actas. Autogobierno con identidad soberana, decisiones por
+quórum y registro inmutable, **offline-first y sin gas/fees**. Es el caso emblema
+de la identidad fractal de agora.
+
+### P11. Coordinación entre organizaciones que no se confían entre sí  [S]
+Reusa: gossip + política por lector. Dos orgs intercambian atestaciones **sin
+ceder su grafo** ni adoptar una autoridad común; cada una mantiene soberanía sobre
+a quién cree. Casos: alianzas, consorcios, redes de cooperativas, federaciones
+gremiales.
+
+### P12. Mini-PKI con delegación de autoridad encadenada + revocación  [T]
+Reusa: multifirma M-of-N + `AGORA_AUTH_RING` + cadena de atestaciones
+("institución → delega en comunidad → delega en persona, ámbito X"). **Bloqueante:
+falta revocación** (ver gaps). Casos: poderes verificables, apoderados, jerarquías
+de aprobación.
+
+### P13. Licenciamiento / entitlements por bytecode hash  [T]
+Reusa: `firmar_capacidad(kp, bytecode_hash, permisos)` — "este binario exacto puede
+usar estos permisos exactos, no transplantable". **Depende de §14.1.3 enforcement
+en kernel** (ver gaps). Casos: gating de features, autorización de apps de terceros.
+
+### P14. Identidad soberana (self-sovereign) anti-plataforma  [S]
+Reusa: keystore cifrado local; tú emites tus claims, nadie te borra. Casos:
+identidad que sobrevive a la muerte de una plataforma, perfil portable, "tu
+reputación es tuya".
+
+### P15. Padrón / censo verificable sin doble conteo  [S]
+Reusa: `IdentityId = BLAKE3(pubkey)` estable + atestaciones cruzadas. Registro de
+miembros con identidad única y verificable. Casos: padrón de un sindicato, censo de
+una comunidad, lista de socios.
+
+### P16. Mensajería / alertas autenticadas broadcast en LAN  [T]
+Reusa: `MensajeAkasha` ya viaja firmado por L2. Extender el enum para anuncios
+operativos (alertas, comandos a la flota) = bus de eventos autenticado sin broker.
+
+### P17. Federación de confianza con minga (grafo de la verdad)  [T/S]
+Reusa: `agora-net-brahman` comparte `PeerId` con minga
+(`/agora/gossip/1.0.0` + `/minga/sync/1.0.0`). Las atestaciones agora son fuentes
+firmadas con reputación para el "grafo de la verdad" de minga.
+
+---
+
+## Empieza por aquí
+
+Ruta de menor fricción y máximo aprendizaje, encadenando artefactos demostrables:
+
+1. **P1** (store LAN firmado) — monta sobre `wawa publicar`/`anunciar` ya operativos.
+2. **P4** (credenciales de organización) — abre toda la pista social con sólo
+   atestaciones + una UI mínima en `agora-app`.
+3. **P7 + P8** (quórum + libro de actas) — la base de gobernanza, que luego
+   compone en P10 (cooperativa/DAO ligera), el destino más distintivo de agora.
diff --git a/03_ukupacha/agora/ARQUITECTURA.md b/03_ukupacha/agora/ARQUITECTURA.md
new file mode 100644
index 0000000..0c64391
--- /dev/null
+++ b/03_ukupacha/agora/ARQUITECTURA.md
@@ -0,0 +1,189 @@
+# ARQUITECTURA.md — agora
+
+> Descripción técnico-arquitectónica densa, optimizada para consumo por IA.
+> Snapshot: 2026-05-30. Fuente autoritativa cuando difiera con prosa de READMEs.
+
+```yaml
+DOMINIO: agora
+CUADRANTE: 03_ukupacha (RAÍZ)
+TESIS: raíz de confianza EJECUTABLE de un SO soberano. Responde dos preguntas del kernel wawa:
+       (1) ¿quién puede empujar una imagen del sistema?  (2) ¿qué bytecode puede tocar el hardware?
+       NO es una red social ni una "plaza pública" a escala humanidad — eso es el sustrato, no el producto.
+PARADIGMA: firma Ed25519 que viaja CON el dato (release / concesión), verificada idéntica en host (dalek)
+           y en bare-metal (compact). El veredicto se computa por-LECTOR vía TrustPolicy; en el plano de
+           control el "lector" es el kernel y el set de firmantes es CURADO (anillo), no abierto.
+TAMAÑO: 9 crates, ~8.6 KLoC puro Rust
+```
+
+## Encuadre honesto (límites duros — leer antes de soñar escala)
+
+```
+LÍMITE-1  IdentityId = BLAKE3(pubkey). Crear identidad = generar un keypair. COSTO CERO.
+          No hay PoW, stake, ni puzzle (verificado: grep vacío en agora-core/agora-graph).
+LÍMITE-2  third_party() = atestadores_distintos − self. Luego `min_third_party = N` se DERROTA
+          generando N claves. En un grafo ABIERTO no es defensa anti-Sybil; es decoración —
+          a menos que los atestadores estén ellos mismos vetados (el problema que la WoT nunca resolvió).
+LÍMITE-3  La WoT a escala masiva tiene 30 años de cementerio (PGP). La causa no fue la cripto:
+          fue el bootstrap de confianza + la carga cognitiva del veredicto-por-lector. agora hereda
+          ESE fracaso si se vende como red social. No se "arregla" — se vuelve IRRELEVANTE en el
+          encuadre de abajo.
+CONSECUENCIA  El uso PORTANTE no es humano y no es abierto: es el plano de control de wawa, donde el
+              set de firmantes lo controlas vos (AGORA_AUTH_RING) y el "consenso" es la intersección
+              que toma el kernel. Ahí Sybil no existe porque no hay registro abierto que sybilear.
+              La maquinaria por-lector (TrustPolicy, identidad fractal, gossip) queda intacta en el
+              código y sirve igual; lo que cambia es a QUÉ apuntamos con ella.
+```
+
+## Modelo conceptual (la idea-fuerza)
+
+```
+PRINCIPIO_CENTRAL:
+  El grafo almacena EVIDENCIA (atestaciones firmadas), no VERDAD.
+  La verdad se computa por-lector vía TrustPolicy negociada.
+  Dos lectores honestos pueden discrepar legítimamente sobre la misma evidencia.
+  => No hay "fuente autoritativa" de quién-es-quién. Hay corroboración medible.
+
+IDENTIDAD_FRACTAL:
+  Person | Community | Alliance | Institution comparten ESTRUCTURA IDÉNTICA (1:1).
+  Una comunidad atestigua igual que una persona. Recursión sin casos especiales.
+  IdentityId = BLAKE3(pubkey_ed25519)  // la identidad ES su clave, no un registro
+```
+
+## Cadena de tipos (núcleo)
+
+```rust
+// agora-core (810 LOC) — cripto pura, sin red, sin estado global
+IdentityId = BLAKE3(pubkey: [u8;32])
+Claim      = { subject: IdentityId, predicate: String, value: String, issued_at: u64 }
+             canonical_bytes = "agora_app-claim\x01" || subject || issued_at_LE
+                               || len(pred)_LE || pred || len(val)_LE || val   // prefix-len, sin ambigüedad
+Attestation= { claim, attester: IdentityId, attester_key: [u8;32], signature: [u8;64] }
+             verify() := attester == BLAKE3(attester_key) ∧ ed25519_verify(key, canonical, sig)
+             stable_hash() := BLAKE3(canonical || attester_key || signature)  // DETERMINISTA → gossip converge
+MultiSignature = { M: usize, firmas: Vec<SingleSig> }  // umbral M-of-N
+```
+
+## Invariantes duras (un verificador puede asumirlas)
+
+```
+INV-1  add_attestation() RE-VERIFICA la firma; firma rota ⇒ Err. El grafo nunca contiene basura no-comprobable.
+INV-2  load() reconstruye llamando add_attestation() 1×1 ⇒ re-verifica TODO al cargar de disco.
+INV-3  ed25519-dalek determinista (sin RNG feature) ⇒ misma (seed,claim) ⇒ misma firma ⇒ mismo stable_hash en toda máquina.
+INV-4  nombre_de_canal ENTRA en mensaje_a_firmar() ⇒ firma válida en canal "dev" NO valida en "estable" (anti-replay cross-canal).
+INV-5  seed Ed25519 jamás se serializa en claro: Argon2id(m=19456,t=2,p=1) → ChaCha20-Poly1305, blob de 88B exactos.
+INV-6  veredicto = TrustPolicy.evaluate(corroboration) — función del LECTOR, no del dato. is_accepted ≠ propiedad global.
+```
+
+## Capas (orden de dependencia, no de runtime)
+
+```
+L0 cripto pura ......... agora-core      identidad fractal · claim · attestation · multisig
+L1 grafo + política .... agora-graph     TrustGraph (solo verificado) · Corroboration · TrustPolicy(min_third_party,
+                                          accept_self, min_attesters_of_kind, max_age_secs)
+L2 transporte .......... agora-gossip    anti-entropy SIN IO: Announce(Digest)→Request(Δhash)→Bundle(att)→[verify]; +Pull
+L3 persistencia ........ agora-keystore  seeds cifradas (Argon2id+ChaCha20)  | agora-store  JSON|postcard + append-log + replay
+L4 red ................. agora-net-brahman  /agora/gossip/1.0.0 sobre BrahmanNet libp2p; comparte PeerId+Kademlia con minga
+L5 interop wawa ........ agora-channel   firma/verifica RaizFirmada · ManifiestoFirmado · ConcesionCapacidad(bytecode,permisos)
+L6 interfaz ............ agora-cli (1637) · agora-app (1923, 4 tiles Llimphi draggables + watcher disco)
+```
+
+## Protocolo de propagación (gossip = anti-entropía, no flood)
+
+```
+Digest = BTreeSet<stable_hash>          // ordenado, sin duplicados, idempotente
+PUSH:  A→B Announce(haves_A) ; B→A Request(haves_A∖haves_B) ; A→B Bundle(those) ; B integra+verifica
+PULL:  B→A Pull ; A→B Announce(...) ; sigue flujo PUSH
+PROPIEDAD: convergente, sin estado de sesión, sin firma nueva en tránsito (la firma viaja DENTRO de la atestación).
+WIRE (brahman): u32_LE len || postcard(Message), cap 16MB; rate-limit token-bucket opt-in por peer.
+```
+
+## Puente hacia wawa (donde agora deja de ser "social" y se vuelve "sistema")
+
+```
+agora-channel produce el sobre que el KERNEL wawa valida:
+  firmar_manifiesto(kp, hash) → ManifiestoFirmado
+  → AoE broadcast: MensajeAkasha::AnunciarCanal{canal,raiz,autor,timestamp,firma} (anuncio.bin = 168B)
+  → app WASM `mudanza` en wawa recibe, verifica, llama sys_manifiesto_proponer (gateado por AGORA_AUTH_RING en .rodata)
+  → kernel re-ancla SuperBloque.manifiesto
+
+ESPEJO KERNEL (zero-alloc, ed25519-compact en wawa-kernel/src/claves.rs):
+  userspace agora-channel::verificar_{raiz,canal,manifiesto}
+    ⟷ kernel verificar_{manifiesto_firmado,anuncio_canal,cuaderno_firmado}
+  La MISMA firma se valida en host (dalek) y en bare-metal (compact). format/* cruza la frontera no_std.
+
+=> agora es el plano de control de IDENTIDAD y RELEASE del SO wawa: quién puede empujar una nueva imagen del sistema.
+```
+
+## CLI host-side (`agora-cli wawa`)
+
+```
+wawa forjar-clave   → genera/guarda seed Ed25519 en keystore
+wawa publicar       → firma manifiesto + emite anuncio.bin (168B) + <hash>.obj del DAG
+wawa anunciar       → emisión AoE L2: broadcast periódico de AnunciarCanal por --iface,
+                       sirve SolicitarObjeto(hash) desde <hash>.obj (fragmenta >1024B), durante --segundos.
+                       Reusa wawa-explorer-aoe::ClienteAoE (raw socket, requiere CAP_NET_RAW).
+```
+
+## Estado vs aspiración (vector de evolución)
+
+```
+HECHO (A→T cerrado 2026-05-28..29):
+  ✓ identidad+keystore+grafo+store(append-log+postcard) · TrustPolicy multi-eje · gossip bidireccional+rate-limit
+  ✓ bridge canal wawa + mudanza con verify real · UI Llimphi tiles+watcher · CLI prefix-match · multisig M-of-N
+  ✓ wawa host-side: agora-cli wawa {forjar-clave, publicar, anunciar(AoE L2)}
+
+YA HECHO que el snapshot viejo daba por pendiente (verificado en disco 2026-05-30):
+  ✓ PRIMITIVOS de capacidad por hash-de-bytecode: agora-channel::{firmar_capacidad, verificar_capacidad}
+    firman/verifican la firma Ed25519 sobre format::mensaje_capacidad(bytecode, permisos)=[u8;36].
+  ✓ ESPEJO KERNEL de la concesión: wawa-kernel/src/claves.rs::verificar_concesion_capacidad (zero-alloc).
+  ✓ format::ConcesionCapacidad + permisos_efectivos (intersección manifiesto ∩ concesión).
+  => §14.1.3 NO es "tabla por construir": el sobre y los dos verificadores existen. Lo que falta es el CABLEADO.
+
+ASPIRA_A (pendientes, REORDENADO por valor desbloqueado — no por riesgo teórico):
+  #1  CABLEAR §14.1.3 — HECHO (2026-05-30, commits 98bc98bc + e27fade9). El kernel deriva permisos
+      efectivos por intersección manifiesto ∩ ConcesionCapacidad verificada en el punto de carga; boot
+      ancla las concesiones offline (*.cap.obj) del génesis. Rollout escalonado: None⇒declarados hasta
+      que el operador provisione. Convierte agora de "mensajería de confianza" en "cargador de capacidades".
+  #2  DAEMON mudanza completo — HECHO. El snapshot era stale: recepción AoE + pull DAG + aceptación
+      soberana ya existían (Fase 64/65: sys_net_recibir, sys_red_solicitar, sys_canal_{anuncio,aceptar}).
+      Cerrado el bucle aceptar/rechazar (commit 330f5a05): sys_canal_descartar (ESC en mudanza).
+  #3  BRIDGE Akasha-over-Ether ↔ host — EN GRAN PARTE HECHO. El "crate nuevo" era redundante: emit+serve
+      sobre raw socket YA viven en wawa-explorer-aoe::ClienteAoE (anunciar_canal/servir/solicitar,
+      AF_PACKET, fragmenta >1024B) y `agora-cli wawa anunciar` los orquesta en loop. El hueco real era el
+      TRANSPORTE host↔guest: el NAT user-mode de QEMU solo reenvía IP, NO el EtherType 0x88B5. CERRADO
+      (2026-05-30): boot::lanzar_qemu honra WAWA_TAP=<iface> y bridgea la NIC del guest a un TAP de
+      capa-2. scripts/aoe-tap-setup.sh forja el tap; scripts/test-mudanza-aoe-qemu.sh corre el E2E vivo.
+      Dos máquinas con un cable se instalan SO mutuamente sin IP/DHCP/DNS — el demo que se ve y se entiende.
+  #4  ROTACIÓN / REVOCACIÓN de clave (juntas): atestación "cambio-de-clave: vieja→nueva" firmada por
+      AMBAS pubkeys + vector de revocación con caducidad estricta. Necesarias ANTES de que exista un set
+      de releasers > 1, no antes. Hoy eres vos con una seed; si se compromete, regeneras.
+
+NORTE_ARQUITECTÓNICO:
+  agora NO es "red de confianza entre personas". Es la raíz de confianza ejecutable de un SO soberano:
+  identidad → release firmado → capacidad derivada de firma sobre (hash_bytecode, permisos) → ejecución
+  gateada en kernel por intersección. El mismo Ed25519 que podría decir "esta persona es quién dice"
+  aquí dice algo más duro y verificable: "este bytecode exacto puede tocar la red". El grafo social y la
+  TrustPolicy por-lector son el sustrato de bootstrap; el producto es el plano de control de wawa.
+```
+
+## Relaciones (mapa de dependencias inter-dominio)
+
+```
+chasqui  : transporte P2P de mensajes (agora puede viajar encima)
+minga    : VFS P2P content-addressed; COMPARTE el nodo libp2p (BrahmanNet) y la Kademlia DHT con agora
+akasha   : protocolo L2 EtherType-propio sin TCP/IP; transporta AnunciarCanal hacia wawa
+format   : tipos no_std compartidos host↔kernel (Canal, *Firmado, AgoraId, Firma, Permisos, MensajeAkasha)
+wawa     : consumidor final — agora es su plano de control de identidad/release/capacidad
+```
+
+---
+
+**Síntesis de una línea para otra IA:** agora es la **raíz de confianza ejecutable** del SO bare-metal
+wawa — su plano de control de identidad/release/capacidad. La maquinaria es una WoT Ed25519 fractal,
+transport-agnóstica y por-lector (`TrustPolicy`, no consenso global), construida en capas puras
+(cripto → grafo → gossip anti-entropía → persistencia → libp2p); pero su uso PORTANTE no es social a
+escala humanidad (eso es WoT abierta y Sybil-vulnerable: identidad gratis ⇒ `min_third_party` no defiende):
+es firmar releases (propagados por AoE) y conceder capacidades por `(hash_bytecode, permisos)` que el
+kernel honra por intersección contra un anillo CURADO de firmantes. Los primitivos host+kernel ya existen
+(`firmar/verificar_capacidad` ⟷ `verificar_concesion_capacidad`); lo pendiente es cablear la derivación
+de permisos efectivos en el enlace de capacidades del kernel.
diff --git a/03_ukupacha/agora/LEEME.md b/03_ukupacha/agora/LEEME.md
new file mode 100644
index 0000000..3575562
--- /dev/null
+++ b/03_ukupacha/agora/LEEME.md
@@ -0,0 +1,46 @@
+# agora
+
+> Plaza pública. Identidad federada y un grafo de confianza que no administra nadie.
+
+![agora-app: siete tiles sobre el mismo TrustGraph — identidades con claves Ed25519 reales, atestaciones firmadas y verificadas, una multifirma 2-de-2 y los sobres del plano de control de wawa](https://tawasuyu.net/03_ukupacha/agora/pantallazo.png)
+
+`agora` es la capa de identidad del monorepo. Cada identidad — persona, comunidad, alianza, institución — es una clave pública ed25519. Cada afirmación sobre ella es un `Claim`. Cada respaldo es una `Attestation`: un claim firmado por un atestador. La verdad del ágora no la dicta un servidor: emerge de quién atestigua qué, pesado por una `TrustPolicy` que cada lector negocia para sí.
+
+No hay registro maestro, no hay algoritmo de moderación, no hay feed. La misma forma — pubkey + atestaciones firmadas — cubre desde una persona hasta una institución, pasando por comunidades y alianzas.
+
+## Instalación
+
+```sh
+cargo run --release -p agora-app
+```
+
+## Compatibilidad
+
+- **Linux / macOS / Windows / Wawa** — todos los crates son puro Rust.
+- Persistencia local; convergencia opcional con peers de la malla `minga`.
+
+## Crates
+
+| Crate | Rol |
+|---|---|
+| [`agora-core`](agora-core/LEEME.md) | Identidades, claims, atestaciones firmadas ed25519. |
+| [`agora-graph`](agora-graph/LEEME.md) | TrustGraph: atestaciones verificadas + corroboración + política negociada. |
+| [`agora-store`](agora-store/LEEME.md) | Persistencia JSON atómica con re-verificación al cargar. |
+| [`agora-keystore`](agora-keystore/LEEME.md) | Almacén cifrado de seeds privadas (Argon2 + ChaCha20-Poly1305). |
+| [`agora-gossip`](agora-gossip/LEEME.md) | Protocolo anti-entropy transport-agnóstico sobre atestaciones firmadas. |
+| [`agora-net-brahman`](agora-net-brahman/LEEME.md) | Puente libp2p: registra `/agora/gossip/1.0.0` sobre `BrahmanNet` (compartido con minga). |
+| [`agora-channel`](agora-channel/LEEME.md) | Adapter de firma al contrato `format::Canal` de wawa: raíces/releases firmados, verificación del historial de un canal, concesiones de capacidad por hash de bytecode (§14.1.3). |
+| [`agora-cli`](agora-cli/LEEME.md) | CLI de shell: identidades (rotar/revocar), atestaciones, exportar/importar, canales y las ceremonias `wawa` (publicar / concesion / anunciar / revocar). |
+| [`agora-app`](agora-app/LEEME.md) | UI Llimphi: identidades, atestaciones, compositor, política, tiles de control wawa (capacidad/release). |
+| [`agora-dispositivo`](agora-dispositivo/) | Binding device→persona por atestación firmada: una persona, una identidad, muchas máquinas. |
+| [`agora-petnames`](agora-petnames/) | Nombres memorables locales más presentaciones firmadas — petnames en vez de un espacio de nombres global. |
+| [`agora-confianza`](agora-confianza/) | Reconcilia los dos planos de confianza de agora en **una sola opinión por-lector**. |
+| [`agora-ayllu`](agora-ayllu/) | La comunidad como autora: el mundo andino nunca tuvo al individuo como unidad — la unidad era el **ayllu**. Este crate le da identidad firmante. |
+
+## Consideraciones
+
+- **Identidad por clave pública**, nunca email ni teléfono.
+- El grafo guarda sólo **evidencia comprobable**: una atestación con firma rota se rechaza al ingresar.
+- El veredicto sobre un claim no es una propiedad del grafo — la `TrustPolicy` se **negocia** por lector, y dos lectores pueden discrepar legítimamente sobre la misma evidencia.
+- La auto-atestación se preserva pero queda marcada aparte del respaldo de terceros.
+- Compatible con `minga`: cuando ambos están activos, ágora viaja sobre el mismo nodo `BrahmanNet` (un PeerId, una tabla Kademlia, dos sub-protocolos de stream).
diff --git a/03_ukupacha/agora/README.md b/03_ukupacha/agora/README.md
new file mode 100644
index 0000000..3ab547e
--- /dev/null
+++ b/03_ukupacha/agora/README.md
@@ -0,0 +1,60 @@
+# agora
+
+> Public square. Federated identity and a trust graph that no one runs.
+
+![agora-app: seven tiles over the same TrustGraph — identities with real Ed25519 keys, signed and verified attestations, a 2-of-2 multisig and wawa's control-plane envelopes](https://tawasuyu.net/03_ukupacha/agora/pantallazo.png)
+
+`agora` is the identity layer of the monorepo. Each identity — person, community, alliance, institution — is an ed25519 public key. Each assertion about it is a `Claim`. Each endorsement is an `Attestation`: a claim signed by an attester. The truth of the agora is not dictated by a server: it emerges from who attests to what, weighted by a `TrustPolicy` that every reader negotiates for themselves.
+
+There is no central registry, no moderation algorithm, no feed. The same shape — pubkey + signed attestations — covers a single person, a neighborhood community, a federation, or an institution.
+
+## Install
+
+```sh
+cargo run --release -p agora-app
+```
+
+## Compatibility
+
+- **Linux / macOS / Windows / Wawa** — all crates are pure Rust.
+- Local persistence; optional convergence with peers from the `minga` mesh.
+
+## Crates
+
+| Crate | Role |
+|---|---|
+| [`agora-core`](agora-core/README.md) | Identities, claims, ed25519-signed attestations. |
+| [`agora-graph`](agora-graph/README.md) | TrustGraph: verified attestations + corroboration + negotiated policy. |
+| [`agora-store`](agora-store/README.md) | Atomic JSON persistence with re-verification on load. |
+| [`agora-keystore`](agora-keystore/README.md) | Encrypted private-seed storage (Argon2 + ChaCha20-Poly1305). |
+| [`agora-gossip`](agora-gossip/README.md) | Transport-agnostic anti-entropy protocol over signed attestations. |
+| [`agora-net-brahman`](agora-net-brahman/README.md) | libp2p bridge: registers `/agora/gossip/1.0.0` over `BrahmanNet` (shared with minga). |
+| [`agora-channel`](agora-channel/README.md) | Signing adapter to wawa's `format::Canal` contract: signed roots/releases, channel-history verification, capability grants by bytecode hash (§14.1.3). |
+| [`agora-cli`](agora-cli/README.md) | Shell CLI: identities (rotate/revoke), attestations, export/import, channels, and the `wawa` ceremonies (publicar / concesion / anunciar / revocar). |
+| [`agora-app`](agora-app/README.md) | Llimphi UI: identities, attestations, composer, policy, wawa control tiles (capability/release). |
+| [`agora-dispositivo`](agora-dispositivo/) | device→person binding by signed attestation: one person, one identity, many machines. |
+| [`agora-petnames`](agora-petnames/) | Memorable local names plus signed introductions — petnames instead of a global namespace. |
+| [`agora-confianza`](agora-confianza/) | Reconciles agora's two planes of trust into a **single per-reader opinion**. |
+| [`agora-ayllu`](agora-ayllu/) | The community as author: the Andean world never took the individual as the unit — the unit was the *ayllu*. This crate gives it a signing identity. |
+
+## Considerations
+
+- **Pubkey identity**, never email or phone.
+- The graph stores only **verifiable** evidence: any attestation with a broken signature is rejected at ingest.
+- The verdict on a claim is not a property of the graph — `TrustPolicy` is **negotiated** per reader, and two readers may disagree legitimately on the same evidence.
+- Self-attestation is preserved but flagged separately from third-party endorsement.
+- Plays well with `minga`: when both are active, agora rides the same `BrahmanNet` node (one PeerId, one Kademlia, two stream protocols).
+
+## Status (2026-06-10)
+
+### Done
+- Complete identity core: `agora-core` (identities, claims, Ed25519 attestations, multisig, lifecycle) + `agora-graph` (TrustGraph with corroboration and negotiated policy) + `agora-store` (atomic JSON persistence with re-verification on load) + `agora-keystore` (encrypted seeds Argon2 + ChaCha20-Poly1305).
+- End-to-end key rotation/revocation (SDD #4, phases 1–5): primitives in `agora-core`, tombstones in the TrustGraph, persistence in the snapshot, CLI `identidad rotar/revocar`, and mirror in the Wawa kernel (`verificar_revocacion`, canonicalized to `format`).
+- Control plane over Wawa: revocation overlay wired to the kernel + boot seam that anchors the offline grants (§14.1.3), `wawa concesion` ceremony, and host↔guest TAP transport that closes the Akasha-over-Ether bridge.
+- P2P transport: `agora-gossip` (transport-agnostic anti-entropy) + `agora-net-brahman` (libp2p bridge `/agora/gossip/1.0.0` convergent with minga over a single `BrahmanNet`). Person discovery by `DhtKey::Persona` (Phase 2b).
+- Llimphi UI `agora-app` with tiles (identities, attestations, multisig, policy, capability, release) + main and context menus; `agora-channel` (proposal/release forge) with demo and e2e migration test.
+
+### Pending
+- Capability table by bytecode hash (WAWA.md §14.1.3): **code-complete** — primitives (`firmar/verificar_capacidad`), kernel mirror (`verificar_concesion_capacidad`), intersection wired at load (`permisos_efectivos_de`), operator tool (`agora-cli wawa concesion`), boot anchoring (`sembrar_concesion`) and automated ceremony (`scripts/wawa-conceder-genesis.sh`) already exist. Only the **operator step** remains: run the ceremony with the slot-0 seed (seeds `assets/concesiones/`) and then flip `MODO_CAPACIDAD_ESTRICTO_GLOBAL = true`.
+- Network convergence beyond the agora/minga pair: still no mass discovery nor rate-limit hardening in real adversarial scenarios.
+- The 17 applications prioritized in `APLICACIONES.md` are design/roadmap; only the substrate (identity + trust graph) is implemented.
diff --git a/03_ukupacha/agora/README.qu.md b/03_ukupacha/agora/README.qu.md
new file mode 100644
index 0000000..a0bd8be
--- /dev/null
+++ b/03_ukupacha/agora/README.qu.md
@@ -0,0 +1,40 @@
+<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
+
+# agora
+
+> Rikukuq llaqta. Federasqa identidad, mana piqpa kamachisqan iniy-sach'a.
+
+`agora` monorepu identidad ladu kan. Sapanka identidad — runa, ayllu, alianza, kamachiq — huk ed25519 puyay clave kan. Sapanka rimay payqa `Claim` kan. Sapanka yanapay `Attestation` kan: huk claim atestiguador firmasqa. Ágorapa cheqaq mana servidorpa: piqkuna ima atestiguanku, sapanka rikuq `TrustPolicy`-nta wak munasqa-rayku.
+
+Hatun registro mana kan, moderar algoritmu mana kan, feed mana kan. Misma simi — pubkey + firmasqa atestaciones — runamantapacha kamachi institucionkamapacha llapanchu chaska.
+
+## Churay
+
+```sh
+cargo run --release -p agora-app
+```
+
+## Tinkuy
+
+- **Linux / macOS / Windows / Wawa** — Rust ch'uya tukuy crates.
+- Lokal waqaychay; opcional convergencia `minga`-pa peers-wan.
+
+## Crateskuna
+
+| Crate | Ima ruwan |
+|---|---|
+| [`agora-core`](agora-core/LEEME.md) | Identidades, claims, ed25519 firmasqa atestaciones. |
+| [`agora-graph`](agora-graph/LEEME.md) | TrustGraph: verificasqa atestaciones + corroboración + negociasqa política. |
+| [`agora-store`](agora-store/LEEME.md) | JSON waqaychay, kargaspa firmata kutichi-verificay. |
+| [`agora-keystore`](agora-keystore/LEEME.md) | Cifrasqa seed waqaychay (Argon2 + ChaCha20-Poly1305). |
+| [`agora-gossip`](agora-gossip/LEEME.md) | Transport-mana-akllaq anti-entropy protocolu. |
+| [`agora-net-brahman`](agora-net-brahman/LEEME.md) | libp2p chaka: `/agora/gossip/1.0.0` `BrahmanNet` patanpi (minga-wan compartisqa). |
+| [`agora-app`](agora-app/LEEME.md) | Llimphi UI: identidades, atestaciones, compositor, política. |
+
+## Yuyaykunaq
+
+- **Puyay clave identidad**, manan email manan teléfono.
+- Grafu apaq **verificay atinata** llapan: firmaynin p'akisqa atestación, manapuni chaskina.
+- Veredicto mana grafupa, `TrustPolicy` sapanka rikuqpaq **negociasqa** — iskay rikuqkuna kasqallaq evidenciata mana hukniyninta munankuman.
+- Auto-atestación waqaychasqa kashan, ichaqa wakichaq runakunamanta t'aqasqa.
+- `minga`-wan tinkun: iskayninku kawsaqtin, ágora kasqa `BrahmanNet` nodo patanpi puriy (huk PeerId, huk Kademlia, iskay sub-protocolo).
diff --git a/03_ukupacha/agora/SDD-rotacion-revocacion.md b/03_ukupacha/agora/SDD-rotacion-revocacion.md
new file mode 100644
index 0000000..0288f6a
--- /dev/null
+++ b/03_ukupacha/agora/SDD-rotacion-revocacion.md
@@ -0,0 +1,173 @@
+# SDD — Rotación y revocación de claves (agora #4)
+
+> Estado: **IMPLEMENTADO end-to-end (2026-05-31)** — ver §6 "Estado global". Resuelve
+> el item #4 del norte de `ARQUITECTURA.md`. Fuente autoritativa del modelo.
+
+## 0. La distinción que define todo
+
+agora tiene **dos planos de confianza**, y rotación/revocación significan cosas
+distintas en cada uno:
+
+- **Plano social** (userspace, `agora-graph::TrustGraph`): atestaciones
+  `Claim{subject,predicate,value}` de firma única. La autoridad de revocación de
+  una identidad es un **set de guardianes** que ella pre-declara.
+- **Plano de control** (kernel, `claves.rs::AGORA_AUTH_RING`): un `const` de N
+  pubkeys compilado en el kernel; compuerta de manifiesto/canal/concesión. Es el
+  norte ("raíz de confianza ejecutable"). La autoridad de revocación es el
+  **propio anillo** por quórum M-of-N.
+
+**Decisión (3 forks, 2026-05-30):** atacar **AMBOS** planos con **primitivos
+compartidos** en `agora-core` cuya autoridad es *pluggable* — el set autorizador
+es el `AGORA_AUTH_RING` (control) o el set de guardianes (social), pero la
+maquinaria de verificación M-of-N es la misma (`MultiSignature::verify_threshold_in`).
+
+## 1. Tres mecanismos, no uno
+
+| Mecanismo | Caso | Autoridad | Firma |
+|---|---|---|---|
+| **Rotación** | handoff voluntario vieja→nueva, SIN compromiso | posesión de ambas claves | doble: `sig_old` + `sig_new` |
+| **Revocación** | clave comprometida o retirada | **M-of-N** del set autorizador | `MultiSignature` |
+| **Caducidad** | confianza que se pudre sola | — (TTL en la atestación) | la firma original |
+
+La clave del modelo: **una clave comprometida no puede revocarse a sí misma**
+(el atacante la tiene). Por eso la revocación por compromiso es M-of-N de OTROS
+(el anillo o los guardianes), nunca self-signed. Self-signed solo vale para
+retiro voluntario (`RevReason::Retired`).
+
+## 2. Primitivos compartidos (`agora-core::lifecycle`)
+
+### 2.1 `KeyRotation` — rotación doble-firmada
+
+```rust
+struct KeyRotation { old_key: [u8;32], new_key: [u8;32], issued_at: u64,
+                     sig_old: [u8;64], sig_new: [u8;64] }
+```
+
+Mensaje canónico (domain-separated, tamaños fijos):
+`b"agora-key-rotation\x01" || old_key || new_key || issued_at.to_le_bytes()`.
+
+`verify()`: ambas firmas cierran bajo SU respectiva clave. `sig_old` prueba que
+la vieja AUTORIZA el handoff; `sig_new` prueba POSESIÓN de la nueva (nadie ata la
+clave de otro como sucesora sin su consentimiento). NO es M-of-N: la rotación
+voluntaria se auto-autoriza con la clave vieja viva.
+
+### 2.2 `Revocation` — revocación M-of-N
+
+```rust
+enum RevReason { Compromised, Retired, Superseded }   // discriminante en canónico
+struct Revocation { target_key: [u8;32], reason: RevReason, issued_at: u64,
+                    expires_at: Option<u64>, authorizers: MultiSignature }
+```
+
+Mensaje canónico:
+`b"agora-revocation\x01" || target_key || [reason_byte] || issued_at.to_le_bytes()
+|| [tag_expires] (|| expires.to_le_bytes())`.
+
+`verify(min, allowed)`: `authorizers.verify_threshold_in(canónico, min, allowed)`.
+`expires_at = None` ⇒ revocación PERMANENTE (compromiso); `Some(t)` ⇒ suspensión
+temporal que vence en `t` (la "caducidad estricta" del roadmap, generalizada).
+
+## 3. Consumo en el plano social (`agora-graph::TrustGraph`)
+
+- El grafo guarda `Vec<Revocation>` y `Vec<KeyRotation>` como **tombstones de
+  primera clase**, separados de las atestaciones.
+- `evidence_for`/`corroboration` FILTRAN: una atestación cuyo `attester` esté
+  revocado (activo a `now`) NO cuenta. Aplicado en tiempo de consulta ⇒ un
+  re-gossip de lo revocado NO lo resucita (sobrevive al replay).
+- `current_key(identity, now)`: sigue la cadena de `KeyRotation` válidas hasta la
+  punta. Una revocación de un eslabón corta la cadena ahí.
+- **Precedencia: la revocación SIEMPRE gana** sobre una rotación que involucre la
+  clave revocada, sin importar timestamps (un atacante no rota para escapar una
+  revocación M-of-N).
+- Autoridad social: el set autorizador de una identidad son sus guardianes
+  declarados (atestación reservada `predicate="guardian"`, una por guardián).
+
+## 4. Consumo en el plano de control (kernel)
+
+- `agora-core` es `std` + `ed25519-dalek`: el kernel NO lo enlaza. Espeja la
+  verificación en `wawa-kernel/src/claves.rs` con `ed25519-compact` zero-alloc
+  (mismo patrón que `verificar_anuncio_canal`), sobre los MISMOS bytes canónicos.
+- El anillo sigue siendo `const` (ancla de compile-time, "frontera física"):
+  rotar el ancla = **reflash deliberado** del kernel, no hay rotación online del
+  root (sería recursión: quién ancla al que ancla).
+- Lo que SÍ se agrega: un **overlay de revocación** — un objeto del grafo que
+  lista `Revocation`s firmadas M-of-N por el anillo. `autor_en_anillo(k)` pasa a
+  exigir además que `k` NO esté en el overlay (activo a `now`). Así una clave del
+  anillo filtrada se deniega ENTRE reflasheos sin esperar el reflash. El overlay
+  se ancla como el manifiesto (superbloque) y se verifica FRESH en cada carga.
+
+## 5. Modelo de amenaza
+
+| Ataque | Defensa |
+|---|---|
+| Clave filtrada sigue firstmando releases | Revocación M-of-N del resto del anillo; overlay la deniega en kernel |
+| Atacante rota la clave filtrada a la suya | Precedencia: revocación M-of-N gana sobre cualquier `KeyRotation` |
+| Re-gossip de atestaciones de una clave revocada | Filtro en tiempo de consulta contra el set revocado (no resucita) |
+| Atar la clave de otro como sucesora | `sig_new` exige posesión de la nueva |
+| Revocar con M-1 claves | `verify_threshold_in` cuenta firmantes DISTINTOS del set |
+
+No protege contra: quórum del anillo comprometido (es la raíz por diseño) ni
+contra reflash malicioso del kernel (frontera física fuera de alcance).
+
+## 6. Plan de implementación
+
+1. ✅ `agora-core::lifecycle` — `KeyRotation` + `Revocation` + `RevReason` + tests.
+2. ✅ `agora-graph` — tombstones, filtrar evidencia (`corroboration_at`),
+   `current_key_at`, `guardians_of`, `is_revoked_at`, `ingest_revocation`.
+3. ✅ `agora-store` — rotaciones/revocaciones en el SNAPSHOT (camino frío; el
+   append-log queda para las atestaciones, camino caliente), `serde(default)` ⇒
+   sin corte de formato (SCHEMA 1). `load` re-verifica firma (doble en rotación,
+   integridad en revocación; el umbral M-of-N es del consumidor, no del store).
+3.bis ✅ Bytes canónicos a `format` (fuente única): `mensaje_rotacion_clave` +
+   `mensaje_revocacion_clave` viven en `shared/format` (no_std + alloc, junto a
+   `mensaje_a_firmar`/`mensaje_capacidad`). `agora-core::{KeyRotation,Revocation}
+   ::canonical_bytes` DELEGAN ahí ⇒ kernel y userspace firman/verifican EXACTAMENTE
+   los mismos bytes por construcción, no por convención.
+4. `wawa-kernel/src/claves.rs`:
+   - ✅ 4a. Espejo `verificar_revocacion(objetivo, motivo, emitida_en, vence_en,
+     firmantes, min)` — M-of-N contra `AGORA_AUTH_RING` (slots distintos por
+     bitmask, anti-inflado del conteo), `Compromised` excluye al `objetivo` (no se
+     revoca a sí misma), zero-alloc salvo el canónico (camino frío).
+   - ✅ 4b. Overlay en carga (commit a6128110). Anclaje: `Manifiesto.overlay_
+     revocacion: Option<Hash>` (corte de formato v5→v6, NO superbloque — más simple
+     y sigue el patrón ya usado para concesiones). `claves::SLOTS_REVOCADOS`
+     (`AtomicU32`, un bit por slot) lo enciende `aplicar_overlay_revocacion` tras
+     verificar quórum (2-of-3); `autor_en_anillo` lo consulta y deniega.
+     `manifiesto::aplicar_overlay` lo lee FRESH en el arranque (fail-safe en
+     disponibilidad: overlay corrupto ⇒ "sin revocaciones", el gate de autoridad
+     sigue intacto); `main` lo invoca tras `instalar`, antes de aceptar propuesta.
+     DECISIÓN de tiempo: el kernel no tiene wall-clock (ticks PIT), así que aplica
+     la revocación FAIL-CLOSED mientras el overlay siga anclado; la auto-caducidad
+     temporal (`vence_en`) espera un RTC. Des-revocar = anclar overlay recortado.
+     boot siembra `overlay_revocacion: None` salvo que exista el asset (4b seam).
+5. `agora-cli`:
+   - ✅ 5a. `identidad rotar <id>` — forja la sucesora, `KeyRotation` doble-firmada,
+     registra la clave nueva en grafo + keystore (handoff voluntario; la cadena
+     queda viva en `current_key_at`).
+   - ✅ 5b. `identidad revocar <id> [--motivo] [--umbral] [--vence-en-seg]` — plano
+     SOCIAL: autoridad = `guardians_of(target)`, firma con cada guardián cuya seed
+     viva en el keystore local, el grafo gatea el umbral M-of-N (anti-DoS de
+     tombstones). Persisten ambos vía `agora-store` (snapshot, fase 3). Falta la
+     combinación multi-parte offline de firmas parciales (un guardián por máquina).
+   - ✅ 5c. `wawa revocar --objetivo <pubkey> --como <id,id,...> [--motivo]
+     [--vence-en-seg] --salida <obj>` — productor del OVERLAY del plano de CONTROL.
+     Firma M-of-N con los miembros del anillo de seed local sobre el canónico
+     compartido (`mensaje_revocacion_clave`), aplana la multifirma al wire
+     (`format::OverlayRevocacion`/`RevocacionFirmada`/`FirmaRevocacion`) y la emite
+     como objeto del grafo. Rechaza que el `objetivo` firme su propia revocación
+     por compromiso. El operador deja el `.obj` en `wawa-kernel/assets/overlay-
+     revocacion.obj` y `wawa-boot::sembrar_overlay` lo ancla (gemelo de
+     `sembrar_concesion`; ausencia ⇒ None ⇒ cero cambio). Test de contrato
+     cross-frontera: el wire round-trippea y cada firma verifica bajo el MISMO
+     canónico que el kernel reconstruye (quórum 2-of-3 alcanzado).
+
+## Estado global (2026-05-31)
+
+**SDD #4 COMPLETO end-to-end.** Las 5 fases (1, 2, 3, 3.bis, 4a, 4b, 5a, 5b, 5c)
+están en `main`. El lazo del plano de control cierra: `wawa revocar` forja el
+overlay → operador lo deja en assets → `boot` lo ancla en el manifiesto v6 → el
+kernel lo aplica al arrancar y deniega la clave del anillo revocada por quórum.
+PENDIENTES menores, no bloqueantes: (a) auto-caducidad temporal en el kernel
+(necesita RTC; hoy fail-closed); (b) combinación multi-parte offline de firmas
+parciales para `identidad revocar` social (hoy sólo firma con guardianes de seed
+local); (c) validación en QEMU del seam de boot (la sandbox no compila `boot`).
diff --git a/03_ukupacha/agora/SDD.md b/03_ukupacha/agora/SDD.md
new file mode 100644
index 0000000..e35edb4
--- /dev/null
+++ b/03_ukupacha/agora/SDD.md
@@ -0,0 +1,449 @@
+# SDD.md — agora como **capa humana** (la plaza) · host de plugins
+
+> Documento de diseño. Estado: **APROBADO** (2026-07-06). Marca cada sección como
+> `HECHO` / `PROPUESTO` en el texto. Fuente autoritativa para el rediseño de la
+> interfaz de agora; **no** deroga a `ARQUITECTURA.md` (que sigue siendo la verdad
+> sobre el núcleo cripto y el plano de control de wawa) — lo **extiende en la capa
+> L6 (interfaz)**.
+>
+> **Decisiones tomadas (2026-07-06):**
+> 1. **Unificar** la raíz de confianza de qillqa con la de agora — **F3b entra en
+>    alcance** (no sólo hospedar: reemplazar el M-de-N casero de `qillqa-core`).
+> 2. ~~**Puentes (F4: minga/pluma) se difieren** a un ciclo posterior.~~
+>    **Revisado 2026-07-17:** F4 se reescribió tras verificar el código (§4.2, §5).
+>    "Conversar → minga" era falso (es `ayni`, que ya existía cuando esto se escribió);
+>    "Archivos → minga-vfs" se canceló (es un FUSE de código); sólo sobrevive
+>    "Documentos → pluma". El trabajo de identidad de F4 resultó ser otro y ya se hizo:
+>    cerrar la brecha de revocaciones ayni↔agora.
+> 3. Orden de ejecución arrancado: **F0 → F1 → F2 → F3**.
+>
+> **Realidad al arrancar (verificado en disco 2026-07-06):** el host de plugins
+> **ya existe** — `agora-app` fue refactorizado (commit `b5b3b06b0`) a un **shell
+> modular por dientes** (`src/chrome.rs` + `src/modulos/`): rail `llimphi-widget-
+> dock-rail` + índice + canvas + sidebar de perfil, con 11 módulos (6 agora + 5
+> qillqa) despachados por `ModuloId` y `sidebar()`/`canvas()`. Los módulos qillqa
+> ya usan el patrón `State + Msg + update + view(state, theme, lift)` (estilo
+> `media-module`). **Por lo tanto: F0 (§8) está en gran parte HECHO, y "sala" ≡
+> el `ModuloId` existente.** Este SDD NO introduce un `trait Sala` paralelo (sería
+> el pecado capital del repo): el contrato `Sala`/`Accion` de §3.1 se lee como la
+> *formalización* del despacho-por-enum que ya vive en `modulos`, y F1+ se
+> construye DENTRO de ese sistema.
+
+---
+
+## §0 · La tensión que este SDD resuelve (leer primero)
+
+`ARQUITECTURA.md` es tajante:
+
+> *"NO es una red social ni una 'plaza pública' a escala humanidad — eso es el
+> sustrato, no el producto."* (LÍMITE-3: la WoT abierta es Sybil-vulnerable porque
+> crear identidad cuesta cero.)
+
+Este SDD **no contradice** eso. Lo que propone es distinto de una red social abierta:
+
+- **NO** una plaza pública a escala humanidad (identidad gratis ⇒ `min_third_party`
+  no defiende ⇒ prohibido, sigue prohibido).
+- **SÍ** la **cara humana de un grupo CURADO y acotado**: un municipio, una
+  cooperativa, un consejo comunal, una familia, una DAO chica. Un anillo de
+  firmantes que *vos* controlas — exactamente el mismo encuadre que el anillo
+  curado del plano de control de wawa (`AGORA_AUTH_RING`), pero con **un humano
+  como "lector"** en vez del kernel.
+
+El propio `ARQUITECTURA.md` lo habilita:
+
+> *"La maquinaria por-lector (TrustPolicy, identidad fractal, gossip) queda intacta
+> en el código y sirve igual; lo que cambia es a QUÉ apuntamos con ella."*
+
+**A qué apuntamos aquí:** a que una persona común, dentro de su comunidad curada,
+pueda *ver quién es quién, dar fe, decidir en grupo, llevar actas, y usar las
+herramientas cívicas/colaborativas de la suite* — **todo firmado bajo la misma raíz
+de confianza**, sin ver un solo hash. El núcleo cripto (L0–L5) **no se toca**.
+
+Regla que gobierna todo el documento: **el `core` se mantiene pulcro y enfocado en
+criptografía. La "plaza" es UI + composición de plugins sobre cores agnósticos**
+(Reglas duras del repo #1 y #2).
+
+---
+
+## §1 · Tesis
+
+**agora-la-app** deja de ser *"7 tiles criptográficos hardcodeados para expertos"* y
+pasa a ser **un host de plugins**: la interfaz centralizada de la plaza, donde cada
+capacidad es una **sala** (un plugin). Una sala puede ser:
+
+1. **Nativa** — habla directo con `agora-core`/`agora-graph`/`agora-channel`
+   (identidad, avales, confianza, decisiones, actas).
+2. **Puente** — delega el trabajo pesado en otro dominio de la suite
+   (`minga` para archivos/mensajes, `pluma` para documentos, `qillqa` para
+   transparencia cívica), pero **firma y verifica bajo agora-core**.
+3. **Experto** — capacidades del plano de control de wawa (release, capacidad),
+   ocultas del usuario común detrás de una puerta de "Sistema".
+
+Invariante rectora (§5): **nada entra a la plaza sin pasar por la raíz de confianza.**
+
+---
+
+## §2 · Qué NO cambia (el núcleo intacto)
+
+`ARQUITECTURA.md §Capas` sigue siendo la verdad. Este SDD sólo actúa en **L6**:
+
+```
+L0 cripto pura ......... agora-core       ← INTACTO
+L1 grafo + política .... agora-graph      ← INTACTO
+L2 transporte .......... agora-gossip     ← INTACTO
+L3 persistencia ........ agora-keystore/store ← INTACTO
+L4 red ................. agora-net-brahman ← INTACTO
+L5 interop wawa ........ agora-channel    ← INTACTO
+L6 interfaz ............ agora-app  ← AQUÍ: de enum-de-tiles a HOST DE PLUGINS
+                         agora-cli  ← intacto
+```
+
+Ningún tipo de dominio nuevo baja al núcleo. Las salas puente **no reimplementan**
+minga/pluma/qillqa: los consumen.
+
+---
+
+## §3 · Arquitectura del host de plugins (el contrato)
+
+No se inventa runtime nuevo. Se combinan **dos patrones que ya existen** en el repo:
+
+- **Contrato de "módulo Llimphi"** (`02_ruway/llimphi/MANUAL.md §14`): una unidad
+  con estado propio y forma `State + Msg + Action + apply + view + on_key`, que
+  **emite intención** (`Action`) y el host la materializa.
+- **Catálogo abierto por string estilo `pata`** (`02_ruway/pata/pata-core/src/config.rs`:
+  `WidgetSpec{ kind: String, props }` + `widget_catalog()`): el conjunto de salas es
+  **abierto**; el frontend despacha por `kind` y cae a un placeholder si no lo
+  conoce. Precedente vivo: **`pata` ya hospeda a `shuma` "como widget"** — el caso
+  canónico de *"capacidad de otro dominio hospedada como plugin"*.
+
+### §3.1 · El trait/convención `Sala` (PROPUESTO)
+
+Cada sala es un crate `agora-plugin-<nombre>` (Regla dura #1: subcrates plugin de un
+dominio). Convención (no polimorfismo forzado; espeja el contrato de módulo):
+
+```rust
+/// Estado propio de la sala. La plaza guarda un `Box<dyn SalaState>` por sala montada.
+pub trait Sala {
+    type Msg;                       // eventos internos de la sala
+    fn id(&self) -> &'static str;   // "carne", "gente", "dar-fe", "decisiones", ...
+    fn etiqueta(&self) -> &str;     // nombre humano, localizable (rimay-localize)
+
+    /// Aplica un Msg interno y devuelve INTENCIÓN hacia la plaza, no efectos.
+    fn apply(&mut self, msg: Self::Msg, ctx: &SalaCtx) -> Accion;
+
+    /// Vista de la sala. `to_host` mapea Self::Msg → Msg de la plaza.
+    fn view(&self, ctx: &SalaCtx, to_host: &dyn Fn(Self::Msg) -> HostMsg) -> View<HostMsg>;
+
+    fn on_key(&mut self, ev: &KeyEvent) -> Option<Self::Msg> { None }
+}
+
+/// La INTENCIÓN que una sala pide y la plaza materializa BAJO LA RAÍZ DE CONFIANZA.
+/// Ninguna sala firma por su cuenta: pide, la plaza firma con la identidad activa.
+pub enum Accion {
+    Nada,
+    /// Firmar una atestación (Dar fe, petname, dispositivo, guardián…).
+    Atestar { claim: Claim },
+    /// Componer/contar una multifirma (Decisiones).
+    Multifirmar { mensaje: Vec<u8>, firmantes: Vec<IdentityId>, umbral: usize },
+    /// Asentar en un canal/acta append-only firmada (Actas, qillqa).
+    Asentar { canal: CanalId, carga: Vec<u8> },
+    /// Delegar a un dominio puente, pasando un token de identidad verificable.
+    Puente { dominio: &'static str, intent: PuenteIntent, credencial: CredencialFirmada },
+    /// Cambiar el foco/estado de la plaza (navegar, seleccionar identidad, …).
+    Plaza(EventoPlaza),
+    /// Reportar al usuario en lenguaje humano (no hex, no jerga).
+    Aviso(Aviso),
+}
+```
+
+### §3.2 · El catálogo (PROPUESTO)
+
+Espeja `pata-core::widget_catalog()`:
+
+```rust
+pub struct SalaEntry {
+    pub kind: &'static str,       // "gente", "documentos", "qillqa-veeduria", …
+    pub etiqueta: &'static str,   // "Mi gente", "Documentos", "Veeduría", …
+    pub icono: Icono,
+    pub familia: Familia,         // Nativa | Puente | Civica | Experta
+    pub visible_pueblo: bool,     // false ⇒ oculta salvo modo experto
+}
+pub fn sala_catalog() -> &'static [SalaEntry];
+```
+
+El layout reusa `llimphi-widget-tiled::tiled_view_reorderable` (drag-to-swap **ya
+soportado**, hoy en `agora-app`), pero las tiles salen del catálogo, no de un enum
+cerrado. Añadir una sala **no toca el host** (placeholder de fallback como en pata).
+
+### §3.3 · Comunicación entre salas / entre apps
+
+Se reusa **`shared/app-bus`** (`Bus`/`BusEvent`), que `agora-app` ya linkea, para
+foco, comandos y lanzamiento — **no** para el estado interno de las salas (eso vive
+en la plaza). Una sala puente que necesite lanzar otra app (p. ej. abrir un doc de
+pluma) emite `Launch::{Exec|Action|Wasm}` por app-bus.
+
+---
+
+## §4 · El catálogo de salas (el corazón del diseño)
+
+Mapea la lista de *"funcionalidades de una ágora completa"* (chat, docs, votación,
+gobernanza, actas…) a **TU** arquitectura: cada cosa es una sala que o es cripto
+nativa, o delega en el dominio dueño — nunca metida en el core.
+
+### §4.1 · Familia **Nativa** (habla con agora-core/graph/channel)
+
+| Sala (`kind`) | Qué ve el usuario (humano) | Motor real | Estado motor |
+|---|---|---|---|
+| **Mi carné** (`carne`) | Tu identidad: nombre, inicial/foto, tipo (persona/comunidad). Editar tu `display_name`, ver tu círculo. Cero hash a la vista. | `agora-core::Identity` + petnames | HECHO (falta UI) |
+| **Mi gente** (`gente`) | Libreta de personas con **nombres memorables y caras**, no IDs. Buscador. "Presentar" a alguien (compartir un petname). | `agora-petnames::{Libreta, Presentacion}` | **HECHO, SIN CABLEAR** ← gana medio problema gratis |
+| **Dar fe** (`dar-fe`) | *"Yo doy fe de que **Yumaira** es **soldadora**"* — prosa, plantillas de predicados comunes (oficio, miembro-de, vecino-de). | `agora-core::Attestation` (`Accion::Atestar`) | HECHO (falta UI en prosa) |
+| **¿Le creo?** (`confianza`) | Una **pregunta con respuesta** ✅/❌ y el *por qué en palabras* ("la avalan 3 personas de tu círculo, ninguna es ella misma"), **no** sliders de `min_third_party`. Modo avanzado opcional expone la política cruda. | `agora-graph::{TrustPolicy, Corroboration}` | HECHO (falta traducción humana) |
+| **Decisiones** (`decisiones`) | Votación de grupo: *"Se aprueba cuando firmen **3 de 5**"*. Barra de progreso de firmas, quién falta. | `agora-core::MultiSignature` (`Accion::Multifirmar`) | HECHO (motor); UI cruda hoy |
+| **Actas** (`actas`) | Libro de acuerdos **firmado e inmutable**: cada decisión/acto queda encadenado y con fecha innegable. Exportable. | `agora-channel::{Canal, RaizFirmada}` (append-only) | HECHO (motor); sin UI |
+
+### §4.2 · Familia **Puente** (delega en otro dominio, firma bajo agora)
+
+> **CORREGIDO 2026-07-17 tras verificar el código.** La versión original de esta
+> sección planificaba tres salas sobre supuestos falsos: dos de los tres crates
+> nombrados no hacen lo que decía, y el dominio de chat que ya existía desde hacía
+> cinco semanas ni se mencionaba. Lo que sigue es lo que hay en disco.
+
+| Sala | Qué ve el usuario | Delega en | Estado |
+|---|---|---|---|
+| **Conversar** | Mensajes, gente, confianza, adjuntos, hilo. | **`ayni`** (`02_ruway/ayni`, 10 crates) | **YA EXISTE COMO APP.** Falta decidir si se hospeda. |
+| **Documentos** | Editar documentos (haz de cuerpos, versiones). | `pluma` | **PENDIENTE — lo único de F4 que sigue intacto.** |
+| ~~Archivos~~ | ~~Repositorio compartido~~ | ~~`minga-vfs`~~ | **CANCELADA** (ver abajo). |
+
+**Conversar = `ayni`, no "minga para mensajes".** `ayni` existe desde el
+**2026-05-29** (`86e4ded0e`), cinco semanas antes de que se escribiera este SDD, y
+sigue en desarrollo activo. Es un DAG CRDT de mensajes firmados, content-addressed,
+`no_std+alloc` (viaja a wawa), con E2EE 1:1, membresía firmada derivada del grafo,
+recibos simétricos, UI de 4 dientes (`ayni-llimphi`, ~2.200 LOC), CLI e índice
+semántico. **minga no es su almacén: es uno de sus transportes** — `ayni-minga` es
+sólo un impl del trait `Transporte` sobre `card_net::BrahmanNet`, intercambiable con
+`EnlaceTcp`, y no cruza identidad ni credenciales. Construir "Conversar → delega en
+minga" sería reimplementar peor algo que ya existe mejor.
+
+**Archivos: cancelada.** `minga-vfs` **no** es un repositorio de archivos de usuario:
+es un **FUSE de sólo lectura que proyecta código fuente** desde el CAS semántico de
+minga (`roots/<hash>` reconstruidos desde el AST, `cas/<hash>` de subárboles). Su
+unidad es un `SemanticNode`, no un blob. Y los adjuntos content-addressed de una
+conversación **ya existen**: `ayni_core::Carga::Adjunto` cita un hash y los bytes
+viajan aparte, con el diente ADJUNTOS pintándolos. Si algún día hace falta un
+repositorio de archivos, se replantea desde cero contra `minga-object`/`minga-store`
+(el CAS crudo) — no contra `minga-vfs`.
+
+**Lo que SÍ falta, y no estaba en este SDD:** ayni no veía las revocaciones de agora.
+Ningún crate suyo dependía de `agora-graph`, así que una clave revocada por sus
+guardianes seguía firmando mensajes que entraban a la conversación. **Cerrado el
+2026-07-17** (`b964d785f` + `bd8682ba1`): el núcleo sigue `no_std` y puro, y el borde
+inyecta `ayni_crypto::verificador_con_revocaciones`; `ayni-llimphi`/`ayni-cli` cargan
+el **mismo** `graph.json` que escribe agora. Ése era el trabajo de identidad real de
+F4 — no inventar una credencial para salas nuevas.
+
+**Queda abierto (sin decidir):** los **dos grafos de confianza paralelos**. El de
+agora (`TrustGraph`: revocaciones M-de-N, rotación de claves) y el social de la
+conversación (`ayni_core::confianza`, derivado del DAG, `nivel = 0` borra la arista)
+no se reconcilian. Son dos preguntas distintas —"¿esta clave sigue siendo suya?" vs
+"¿le creo a esta persona?"— y ambas hacen falta; nadie decidió si se cruzan y cómo.
+
+### §4.3 · Familia **Cívica**: `qillqa` (transparencia del acto administrativo)
+
+`qillqa` = *"no puedo impedirlo, pero no puedes ocultarlo"*: registro append-only
+hash-encadenado de actos administrativos, con veeduría ciudadana. Hoy **reimplementa
+su propio M-de-N** en `qillqa-core::gestion` *"para no arrastrar agora al núcleo"*, y
+comparte el **mismo backend cripto** (Ed25519 + BLAKE3 vía `shared/fork-proof`).
+Su propio SDD (§8, M4) declara la unificación con los guardianes de agora como el
+camino previsto. Salas:
+
+| Sala | Qué ve el usuario | Crate | Estado |
+|---|---|---|---|
+| **Verificar** (`qillqa-verificar`) | La cara pública: pegar una `Constancia` → **VERDE/ROJO** con evidencia legible. Offline, sin cuenta. | `qillqa-verificar-llimphi` sobre `qillqa-core::verificar_constancia` | HECHO (app aparte) |
+| **Mesa de partes** (`qillqa-mesa`) | Ventanilla: cola del día, expediente como hilo, constancia con QR. | `qillqa-mesa` | HECHO (app aparte) |
+| **Compras** (`qillqa-compras`) | Línea de tiempo de una licitación con saldo corriente; rechazo visible del sobregiro. | `qillqa-compras` + `sheafsync` | HECHO (app aparte) |
+| **Veeduría** (`qillqa-veeduria`) | Vigilar portales públicos; alertas de "edición silenciosa" con credibilidad por lector. | `qillqa-veeduria(-capture)` + `iniy` | HECHO (app aparte) |
+| **Autoridad** (`qillqa-autoridad`) | Ceremonia de traspaso de gestión M-de-N (guardianes). | `qillqa-autoridad-llimphi` sobre `qillqa-core::gestion` | HECHO (app aparte) |
+
+**Integración (PROPUESTA, escalonada):**
+- **F3a — hospedar**: montar las cinco UIs `qillqa-*-llimphi` como salas (son apps
+  Llimphi del mismo workspace, mismo tema/widgets). Vía app-bus `Launch::Action` o
+  embebido como `View`. **No** reimplementar nada.
+- **F3b — unificar la raíz** (opcional, alineado con qillqa M4): reemplazar el
+  M-de-N casero de `qillqa-core::gestion` por `agora-core::MultiSignature` +
+  `KeyRotation`/`Revocation` y la identidad de la plaza como "autoridad". Sin
+  conflicto de backend (ambos Ed25519+BLAKE3). Es un **cambio de firma compartida**
+  ⇒ exige barrido de workspace + re-check (memoria `firma-compartida-barrido-workspace`).
+
+### §4.4 · Familia **Experta**: Sistema wawa (oculto)
+
+Los tiles actuales `Release` y `Capacidad` **no desaparecen** — se reubican tras una
+puerta **"⚙️ Sistema"** con `visible_pueblo = false`. Mantienen su forma actual
+(hex, `AGORA_AUTH_RING`, permisos `0b…`) porque su lector real es un operador, no el
+pueblo. Nada de esto se muestra por defecto.
+
+| Sala | Motor | Estado |
+|---|---|---|
+| **Release wawa** (`sys-release`) | `agora-channel::ManifiestoFirmado` | HECHO (es el tile actual) |
+| **Capacidad wawa** (`sys-capacidad`) | `agora-channel::ConcesionCapacidad` | HECHO (es el tile actual) |
+
+---
+
+## §5 · El principio rector: **todo bajo la raíz de confianza**
+
+La propiedad que hace de esto "un agora" y no un Slack cualquiera: **ninguna sala
+firma por su cuenta**. El flujo es siempre:
+
+```
+Sala emite Accion  ─►  la Plaza la materializa con la IDENTIDAD ACTIVA (keystore)
+                       firmando con agora-core  ─►  el resultado se verifica
+                       (INV-1: firma rota ⇒ rechazo) antes de entrar al grafo/canal.
+```
+
+Para las salas **puente**, el objeto que viaja al otro dominio lleva una
+`CredencialFirmada` que prueba *quién* actúa y *con qué permiso*.
+
+> **CORREGIDO 2026-07-17 tras leer el código.** Tres cosas que esta sección decía mal:
+>
+> 1. **`CredencialFirmada` no existe.** Aparece sólo en este documento. Cero código.
+> 2. **`Presentacion` NO sirve como tal.** `agora_petnames::Presentacion` prueba
+>    exactamente una cosa —"el presentador llama al sujeto *nombre*"—: tiene tres
+>    campos (`presentador`, `sujeto`, `nombre`), su constructor **rechaza** toda
+>    atestación cuyo predicado no sea `PREDICATE_PETNAME`, y el propio doc la llama
+>    "una SUGERENCIA". **No tiene dónde poner el permiso.** El molde correcto es
+>    `agora-dispositivo` (un `Claim` con predicado propio + un tipo verificado cuyo
+>    `de_atestacion` reusa `Attestation::verify`) para la *estructura*, y el bitfield
+>    `format::Permisos` —que `agora-channel::firmar_capacidad` ya firma, y `minga-cli`
+>    ya consume— para el *contenido*.
+> 3. **"Sin acoplar su núcleo" es más ceremonia de la necesaria, y hay prueba por
+>    existencia:** `minga` ya verifica contra el grafo de agora **hoy**, sin ninguna
+>    credencial — `minga-store` simplemente depende de `agora-graph` y usa
+>    `TrustGraph` (`trust_store.rs`) y `Libreta` (`petname_store.rs`).
+>
+> **Regla que se deriva de esto:** antes de inventar una credencial, mirar si el
+> dominio puede **linkear `agora-graph` y preguntar**. Sólo hace falta un objeto
+> firmado que viaje cuando el verificador NO puede tener el grafo — el caso real es
+> `ayni-core`, que es `no_std` y va a wawa. Y ahí la solución que funcionó tampoco
+> fue una credencial: fue que el núcleo pida un **closure** y el borde inyecte la
+> política (`b964d785f`). El núcleo puro, la confianza afuera.
+
+Invariantes heredadas que la plaza **debe** preservar: INV-1 (re-verificar al
+insertar), INV-2 (re-verificar al cargar), INV-4 (nombre de canal en la firma),
+INV-5 (seed cifrada, nunca en claro), INV-6 (veredicto = función del lector).
+
+---
+
+## §6 · Traducción humana (la regla anti-jerga)
+
+El defecto de raíz de la app hoy no es "le faltan features": es que **habla en
+criptógrafo**. Contrato de UX para toda sala visible al pueblo:
+
+| Prohibido a la vista | Se muestra en su lugar |
+|---|---|
+| `IdentityId` en hex | Nombre del petname + inicial/color/foto |
+| Hash BLAKE3 de 64 hex | "documento «Acta del 6 de julio»" + ✔ verificado |
+| Firma/clave en hex, `postcard` | (nada; es plomería — a lo sumo un ícono ✔) |
+| `permisos: 0b0000111` | Lista de permisos en palabras, con toggles |
+| `min_third_party`, `accept_self`, `kind` | "¿Cuántas personas de tu círculo deben avalar?" |
+| Veredicto `ACEPTA/rechaza` a secas | "✅ Puedes confiar — la avalan 3 de tu gente" + **qué hacer** |
+| Errores `✗ hex inválido (longitud impar)` | "No pude leer eso — ¿lo pegaste completo?" |
+| Nombres autogenerados "yo 1", "yo 2" | Pedir el nombre en el onboarding |
+
+Onboarding (hoy inexistente), estados vacíos amables (hoy captions técnicos),
+buscador en las listas (hoy inexistente) y avatares/iniciales de color son parte del
+contrato, no extras.
+
+Localización: los rótulos salen de `shared/rimay-localize` (nada hardcodeado en
+español/spanglish), para que "pueblo" incluya idioma nativo del usuario.
+
+---
+
+## §7 · Reuso — qué YA existe (no reconstruir)
+
+> **Esta tabla es la que falló.** Omitía `ayni` —que llevaba cinco semanas en
+> disco— y por eso §4.2 planificó reconstruir el chat sobre minga. Antes de
+> planificar una sala, `ls` del cuadrante y `grep` del nombre: un dominio que ya
+> existe se usa, no se reinventa (regla del `CLAUDE.md`).
+
+| Necesidad | Ya existe | Ruta |
+|---|---|---|
+| **Conversación P2P firmada** | **`ayni`** (DAG CRDT, E2EE, membresía, UI, CLI) | `02_ruway/ayni` |
+| **Verificar contra el grafo de agora desde otro dominio** | linkear `agora-graph` y preguntar — `minga-store` YA lo hace | `03_ukupacha/minga/minga-store/src/trust_store.rs` |
+| **Firmar PERMISOS** | `agora-channel::firmar_capacidad` + `format::Permisos` | `agora-channel/src/lib.rs` |
+| **Molde de credencial firmada** | `agora-dispositivo` (Claim + predicado propio + `de_atestacion` verificada) | `03_ukupacha/agora/agora-dispositivo` |
+| Lanzar otra app de la suite | app-bus `Launch{Exec,Action,Wasm}` (en producción) | `shared/app-bus/src/lib.rs` |
+| Nombres en vez de hashes | `agora-petnames` (sin cablear) | `03_ukupacha/agora/agora-petnames` |
+| Libro de actas firmado | `agora-channel::Canal` append-only | `agora-channel/src` |
+| Motor de votación M-de-N | `agora-core::MultiSignature` | `agora-core/src/multisig.rs` |
+| Layout de salas draggable | `llimphi-widget-tiled` (ya usado) | `02_ruway/llimphi/widgets/tiled` |
+| Contrato de plugin in-process | módulos Llimphi (`MANUAL.md §14`) | `02_ruway/llimphi/modules` |
+| Catálogo abierto por string | `pata-core::widget_catalog()` | `02_ruway/pata/pata-core/src/config.rs` |
+| Plugin cargable WASM (a futuro) | `llimphi/modules/plugin-host` (Tier 2) | idem |
+| Bus foco/lanzamiento | `app-bus` (ya linkeado) | `shared/app-bus` |
+| Archivos/mensajes P2P | `minga` (comparte nodo con agora) | `03_ukupacha/minga` |
+| Documentos | `pluma` | `00_unanchay/pluma` |
+| Transparencia cívica | `qillqa` (5 UIs Llimphi) | `02_ruway/qillqa` |
+
+---
+
+## §8 · Fases de implementación (PROPUESTO)
+
+Cada fase termina verde en `cargo check --workspace` + tests + commit (convención del
+repo). Evidencia por texto/tests salvo lo genuinamente visual (Regla dura #8).
+
+- **F0 — Andamiaje del host.** ✅ **HECHO** (commit `b5b3b06b0`, sesión paralela):
+  el shell modular por dientes (`chrome.rs` + `modulos/`) ya despacha 11 módulos.
+  `ModuloId` cumple el rol de `SalaEntry`; `sidebar()`/`canvas()` el de `view`.
+  *Resta de F0:* mover release/capacidad a la familia **Experta** (ocultos del
+  pueblo) — se hace en F1/F5.
+- **F1 — Núcleo humano.** Salas **Mi carné · Mi gente (cablear petnames) · Dar fe ·
+  ¿Le creo?**. Onboarding + estados vacíos + buscador + traducción §6. *Aquí se cura
+  la fealdad de raíz.*
+- **F2 — Gobernanza.** Salas **Decisiones** (multifirma como votación) y **Actas**
+  (agora-channel como libro).
+- **F3 — Cívica (qillqa).** F3a hospedar las 5 UIs como salas; F3b (opcional)
+  unificar M-de-N con agora.
+- **F3c/d/e/f — el dominio a los cores + las 6 salas a paridad.** ✅ **HECHO**
+  (2026-07-17). F3a **no** estaba hecho pese a decir "no reimplementar nada": había
+  cinco reimplementaciones degradadas con el dominio copiado y divergiendo. Se bajó a
+  los cores (`MotivoRojo::texto`, `Registro::gestion_vigente`, `verdicto_umbral`,
+  `autorizantes_de`, `cola_de`, `money`, `resumen_por_fuente`, `banco`, demos), se
+  barrieron los consumidores (−493 LOC) y se cerró paridad. Se agregó la sala
+  **Verificar**, que faltaba: era la única cara pública, y el ROJO era **inalcanzable**
+  (en `registro.rs` autoridad y testigo son siempre correctos ⇒ siempre VERDE). Y el
+  puente de confianza: la credibilidad de veeduría la pone la red del lector
+  (`TrustPolicy::grado` por CONTEO, no el bool de `accepts`).
+- **F4 — Puentes.** ⚠️ **REESCRITA 2026-07-17** (ver §4.2 y §5; el plan original
+  partía de supuestos falsos).
+  - ~~Conversar (minga)~~ → es **`ayni`**, que ya existe. Falta decidir si se hospeda.
+  - ~~Archivos (minga-vfs)~~ → **cancelada**: `minga-vfs` es un FUSE de código; los
+    adjuntos ya existen en `ayni_core::Carga::Adjunto`.
+  - **Documentos (pluma)** → **lo único intacto**: pluma no tiene ni una dep de agora.
+  - ✅ **HECHO (y no estaba en el plan): la brecha de revocaciones ayni↔agora**
+    (`b964d785f` + `bd8682ba1`). Era el trabajo de identidad real de esta fase.
+  - **Abierto:** los dos grafos de confianza paralelos (agora vs `ayni_core::confianza`).
+- **F5 — Pulido.** Localización completa (rimay-localize), avatares, tema no fijo,
+  cerrar sesión, refinar el modo experto.
+
+Dependencias: F0 bloquea todo. F1 es independiente del resto. F3/F4 pueden ir en
+paralelo tras F0. F3b toca firma compartida ⇒ barrido de workspace.
+
+---
+
+## §9 · No-objetivos (para no repetir el error de la IA que originó esto)
+
+- **NO** una red social abierta a escala humanidad (ARQUITECTURA `LÍMITE-1/2/3`).
+- **NO** chat/docs/archivos **reimplementados dentro de agora**: son puentes a
+  minga/pluma. Meterlos al core desnaturaliza el dominio (Reglas duras #1/#2).
+- **NO** videollamada (no hay dominio dueño hoy; fuera de alcance).
+- **NO** tocar L0–L5. El core queda cripto puro.
+
+---
+
+**Síntesis para otra IA:** este SDD extiende la capa L6 de agora. Convierte
+`agora-app` de un enum cerrado de 7 tiles cripto en un **host de plugins** (patrón
+módulo-Llimphi + catálogo-abierto-estilo-pata) que presenta la **cara humana de una
+comunidad curada**: salas nativas (identidad/petnames/avales/confianza/decisiones/
+actas sobre agora-core), salas puente (minga/pluma) y salas cívicas (qillqa), más un
+modo experto oculto (release/capacidad de wawa). Principio rector: **ninguna sala
+firma sola — la plaza materializa cada `Accion` bajo la raíz de confianza**. No es
+una WoT abierta (sigue prohibida); es la interfaz humana del anillo curado.
diff --git a/03_ukupacha/agora/agora-app/Cargo.toml b/03_ukupacha/agora/agora-app/Cargo.toml
new file mode 100644
index 0000000..c453596
--- /dev/null
+++ b/03_ukupacha/agora/agora-app/Cargo.toml
@@ -0,0 +1,59 @@
+[package]
+name = "agora-app"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "agora-app — UI Llimphi del ágora: identidades, atestaciones, compositor in-situ y política negociada sobre el TrustGraph local."
+
+[[bin]]
+name = "agora-app"
+path = "src/main.rs"
+
+[dependencies]
+agora-core = { path = "../agora-core" }
+agora-graph = { path = "../agora-graph" }
+agora-confianza = { path = "../agora-confianza" }
+agora-store = { path = "../agora-store" }
+agora-keystore = { path = "../agora-keystore" }
+agora-channel = { path = "../agora-channel" }
+agora-petnames = { path = "../agora-petnames" }
+format = { workspace = true }
+# Módulos qillqa (registros a prueba de manipulación) adoptados por el shell:
+# se reusan sus *-core, no sus binarios (UIs = frontends intercambiables).
+qillqa-core = { workspace = true }
+qillqa-mesa = { workspace = true }
+qillqa-veeduria = { workspace = true }
+qillqa-compras = { workspace = true }
+llimphi-ui = { workspace = true }
+llimphi-theme = { workspace = true }
+llimphi-widget-tiled = { workspace = true }
+llimphi-widget-dock-rail = { workspace = true }
+llimphi-widget-rag-sidebar = { workspace = true }
+llimphi-icons = { workspace = true }
+llimphi-widget-text-input = { workspace = true }
+llimphi-widget-list = { workspace = true }
+llimphi-widget-slider = { workspace = true }
+llimphi-widget-progress = { workspace = true }
+llimphi-widget-button = { workspace = true }
+llimphi-widget-card = { workspace = true }
+llimphi-widget-stat-card = { workspace = true }
+llimphi-widget-banner = { workspace = true }
+llimphi-widget-empty = { workspace = true }
+llimphi-widget-menubar = { workspace = true }
+llimphi-widget-edit-menu = { workspace = true }
+llimphi-widget-context-menu = { workspace = true }
+llimphi-motion = { workspace = true }
+llimphi-clipboard = { workspace = true }
+app-bus = { workspace = true }
+directories = { workspace = true }
+rand = { workspace = true }
+notify = { workspace = true }
+postcard = { workspace = true }
+
+[dev-dependencies]
+# Sólo para el example `pantallazo_agora` (volcado headless a PNG).
+pollster = { workspace = true }
+png = { workspace = true }
diff --git a/03_ukupacha/agora/agora-app/LEEME.md b/03_ukupacha/agora/agora-app/LEEME.md
new file mode 100644
index 0000000..67dc21a
--- /dev/null
+++ b/03_ukupacha/agora/agora-app/LEEME.md
@@ -0,0 +1,25 @@
+# agora-app
+
+*Read this in English: [README.md](README.md).*
+
+> UI Llimphi de [agora](../LEEME.md): identidades, atestaciones, compositor, política.
+
+Cuatro tiles draggables sobre el mismo `TrustGraph`, intercambiables por arrastre de la barra de título:
+
+- **Identidades** — conocidas + mías. El botón nueva-identidad genera una seed CSPRNG, la encierra en [`agora-keystore`](../agora-keystore/LEEME.md) y registra la cara pública en el grafo.
+- **Atestaciones** — la pila de evidencia verificada. Filtrable por sujeto / atestador / predicado. Auto-atestaciones marcadas distinto.
+- **Compositor** — edición in-situ de `sujeto · predicado = valor`, firmado como la identidad local activa, agregado al grafo y persistido al confirmar.
+- **Política** — slider de `min_third_party`, checkbox de `accept_self`, veredicto en vivo sobre el claim seleccionado.
+
+La persistencia es automática: cada cambio pasa por [`agora-store`](../agora-store/LEEME.md); las claves privadas viven en `agora-keystore`, desbloqueadas al arrancar con una passphrase.
+
+## Uso
+
+```sh
+cargo run --release -p agora-app
+```
+
+## Deps
+
+- Todos los `agora-*` (core, graph, store, keystore)
+- [`llimphi-ui`](../../../02_ruway/llimphi/llimphi-ui/) + widgets de Llimphi
diff --git a/03_ukupacha/agora/agora-app/README.md b/03_ukupacha/agora/agora-app/README.md
new file mode 100644
index 0000000..42fe3e4
--- /dev/null
+++ b/03_ukupacha/agora/agora-app/README.md
@@ -0,0 +1,23 @@
+# agora-app
+
+> Llimphi UI of [agora](../README.md): identities, attestations, composer, policy.
+
+Four draggable tiles over the same `TrustGraph`, swappable by title-bar drag:
+
+- **Identities** — known + mine. New-identity button generates a CSPRNG seed, wraps it in [`agora-keystore`](../agora-keystore/README.md), and registers the public side in the graph.
+- **Attestations** — the verified evidence pile. Filterable by subject / attester / predicate. Self-attestations marked distinctly.
+- **Composer** — in-situ editor for `subject · predicate = value`, signed as the active local identity, added to the graph and persisted on commit.
+- **Policy** — slider for `min_third_party`, toggle for `accept_self`, live verdict on the selected claim.
+
+Persistence is automatic: every change goes through [`agora-store`](../agora-store/README.md); private keys live in `agora-keystore`, unlocked at startup with a passphrase.
+
+## Usage
+
+```sh
+cargo run --release -p agora-app
+```
+
+## Deps
+
+- All `agora-*` (core, graph, store, keystore)
+- [`llimphi-ui`](../../../02_ruway/llimphi/llimphi-ui/) + Llimphi widgets
diff --git a/03_ukupacha/agora/agora-app/examples/pantallazo_agora.rs b/03_ukupacha/agora/agora-app/examples/pantallazo_agora.rs
new file mode 100644
index 0000000..3ab4c9c
--- /dev/null
+++ b/03_ukupacha/agora/agora-app/examples/pantallazo_agora.rs
@@ -0,0 +1,453 @@
+//! Pantallazo headless de `agora-app` — identidad digital soberana.
+//!
+//! Monta la **view real** de la app (los siete tiles sobre el mismo
+//! `TrustGraph`: identidades · compositor · atestaciones · política ·
+//! multifirma · release · capacidad) con un `Model` sembrado con datos demo
+//! creíbles: tres identidades con claves Ed25519 reales, atestaciones
+//! firmadas y verificadas, una multifirma 2-de-2 y los sobres del plano de
+//! control de wawa (`ManifiestoFirmado` + `ConcesionCapacidad`) firmados de
+//! verdad con `agora-channel` — el mismo código que re-verifica el kernel.
+//!
+//! Pinta a una textura wgpu sin ventana y vuelca PNG (mismo patrón que
+//! `llimphi-compositor/examples/primitivas_demo.rs`).
+//!
+//! `cargo run -p agora-app --example pantallazo_agora --release -- [out.png]`
+#![allow(dead_code)]
+
+// La app es un crate binario sin lib: incluimos sus módulos reales por
+// `#[path]` para pintar exactamente el mismo shell que la app. Los submódulos
+// (modulos/agora, modulos/qillqa/*) se resuelven relativos a cada archivo
+// incluido, así que basta declarar los raíces.
+#[path = "../src/chrome.rs"]
+mod chrome;
+#[path = "../src/model.rs"]
+mod model;
+#[path = "../src/modulos/mod.rs"]
+mod modulos;
+#[path = "../src/perfil.rs"]
+mod perfil;
+#[path = "../src/tiles/mod.rs"]
+mod tiles;
+#[path = "../src/ui.rs"]
+mod ui;
+
+use std::fs::File;
+use std::io::BufWriter;
+
+use agora_core::{Attestation, Claim, IdentityKind, Keypair, MultiSignature};
+use agora_graph::{TrustGraph, TrustPolicy};
+use agora_keystore::Keystore;
+use format::{PERMISO_ALTAVOZ, PERMISO_CONFIG, PERMISO_RED};
+use llimphi_motion::Tween;
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_hal::{wgpu, Hal};
+use llimphi_ui::llimphi_layout::taffy;
+use llimphi_ui::llimphi_layout::taffy::prelude::{percent, FlexDirection, Size, Style};
+use llimphi_ui::llimphi_layout::LayoutTree;
+use llimphi_ui::llimphi_raster::peniko::Color;
+use llimphi_ui::llimphi_raster::{vello, Renderer};
+use llimphi_ui::llimphi_text::Typesetter;
+use llimphi_ui::{measure_text_node, mount, paint, View};
+use llimphi_widget_menubar::{menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
+use llimphi_widget_text_input::TextInputState;
+use llimphi_widget_tiled::{tiled_view_cols, tiled_view_reorderable, TileSpec, TiledPalette};
+
+use crate::model::{
+    ComposeField, FocusedInput, Model, Msg, Screen, StatusBanner, StatusLevel, Tile,
+};
+use crate::ui::bytes_to_hex;
+
+const W: u32 = 1280;
+const H: u32 = 800;
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+
+/// Módulo activo elegido por la env `AGORA_MODULO` (default: identidades).
+fn modulo_env() -> modulos::ModuloId {
+    use modulos::ModuloId::*;
+    match std::env::var("AGORA_MODULO").unwrap_or_default().as_str() {
+        "atestaciones" => Atestaciones,
+        "politica" => Politica,
+        "multifirma" => Multifirma,
+        "actas" => Actas,
+        "release" => Release,
+        "capacidad" => Capacidad,
+        "registro" => Registro,
+        "mesa" => Mesa,
+        "veeduria" => Veeduria,
+        "compras" => Compras,
+        "autoridad" => Autoridad,
+        _ => Identidades,
+    }
+}
+
+/// Segundos UNIX actuales.
+fn ahora() -> u64 {
+    std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .map(|d| d.as_secs())
+        .unwrap_or(0)
+}
+
+/// Construye el `Model` demo: el mismo estado que tendría la app tras una
+/// sesión corta de uso (identidades creadas, atestaciones firmadas, sobres
+/// de wawa emitidos). Todas las firmas son Ed25519 reales.
+fn modelo_demo() -> Model {
+    let now = ahora();
+
+    // --- Tres identidades con claves reales (seeds fijas → pantallazo estable).
+    let kp_amaru = Keypair::from_seed(*b"amaru-semilla-demo-pantallazo-01");
+    let kp_killa = Keypair::from_seed(*b"killa-semilla-demo-pantallazo-02");
+    let kp_minga = Keypair::from_seed(*b"minga-semilla-demo-pantallazo-03");
+    let id_amaru = kp_amaru.identity_id();
+    let id_killa = kp_killa.identity_id();
+
+    let mut graph = TrustGraph::new();
+    graph.register(kp_amaru.identity(IdentityKind::Person, "amaru"));
+    graph.register(kp_killa.identity(IdentityKind::Person, "killa"));
+    graph.register(kp_minga.identity(IdentityKind::Community, "minga del valle"));
+
+    // --- Atestaciones firmadas y verificadas por el grafo. Dos terceros
+    //     corroboran el mismo claim sobre amaru → la política lo ACEPTA.
+    let atestar = |kp: &Keypair, sujeto, pred: &str, val: &str, t| {
+        Attestation::create(kp, Claim::new(sujeto, pred, val, t))
+    };
+    for att in [
+        atestar(&kp_killa, id_amaru, "miembro", "minga del valle", now - 3_600),
+        atestar(&kp_minga, id_amaru, "miembro", "minga del valle", now - 7_200),
+        atestar(&kp_minga, id_killa, "miembro", "minga del valle", now - 86_000),
+        atestar(&kp_amaru, id_amaru, "rol", "guardián de releases", now - 1_800),
+    ] {
+        graph
+            .add_attestation(att)
+            .expect("atestación demo válida");
+    }
+
+    // --- Seeds "mías" en RAM (amaru y killa); la comunidad es ajena.
+    let mut seeds = std::collections::HashMap::new();
+    seeds.insert(id_amaru, *b"amaru-semilla-demo-pantallazo-01");
+    seeds.insert(id_killa, *b"killa-semilla-demo-pantallazo-02");
+
+    // Keystore efímero: la view no lo lee, pero el Model lo exige.
+    let ks_dir = std::env::temp_dir().join("agora-pantallazo-keys");
+    std::fs::create_dir_all(&ks_dir).ok();
+    let keystore = Keystore::open(&ks_dir).expect("keystore efímero");
+
+    // --- Compositor a medio escribir (como en una sesión real).
+    let mut compose_predicate = TextInputState::new();
+    compose_predicate.set_text("habilidad");
+    let mut compose_value = TextInputState::new();
+    compose_value.set_text("soldadura de cobre");
+
+    // --- Multifirma 2-de-2 sobre una raíz canónica.
+    let raiz_hex = bytes_to_hex(b"raiz-canonica-del-grafo-demo-001");
+    let mut multi_message = TextInputState::new();
+    multi_message.set_text(&raiz_hex);
+    let multi_current = Some(MultiSignature::create(
+        &[&kp_amaru, &kp_killa],
+        raiz_hex.as_bytes(),
+    ));
+    let mut multi_selected = std::collections::BTreeSet::new();
+    multi_selected.insert(id_amaru);
+    multi_selected.insert(id_killa);
+
+    // --- Release de wawa: hash de imagen firmado con la clave activa.
+    let hash_release: [u8; 32] = *b"hash-imagen-wawa-0.9-demo-000001";
+    let mut release_hash = TextInputState::new();
+    release_hash.set_text(bytes_to_hex(&hash_release));
+    let release_current = Some(agora_channel::firmar_manifiesto(&kp_amaru, &hash_release));
+
+    // --- Concesión de capacidad §14.1.3: (hash_bytecode, permisos) firmados.
+    let hash_bytecode: [u8; 32] = *b"hash-bytecode-pluma-wasm-demo-01";
+    let mut cap_bytecode = TextInputState::new();
+    cap_bytecode.set_text(bytes_to_hex(&hash_bytecode));
+    let cap_permisos = PERMISO_RED | PERMISO_ALTAVOZ | PERMISO_CONFIG;
+    let cap_current = Some(agora_channel::firmar_capacidad(
+        &kp_amaru,
+        &hash_bytecode,
+        cap_permisos,
+    ));
+
+    // --- Libro de actas: dos acuerdos firmados por amaru, encadenados por fecha.
+    let actas_textos = vec![
+        "Se aprueba el presupuesto de la minga.".to_string(),
+        "Amaru queda designado guardián de releases.".to_string(),
+    ];
+    let mut actas = Model::libro_actas_vacio();
+    actas.autor = kp_amaru.public_key();
+    for (i, t) in actas_textos.iter().enumerate() {
+        let ts = now - 7_200 + i as u64 * 60;
+        let h = format::hash(t.as_bytes());
+        actas.raices.push(agora_channel::firmar_raiz(&kp_amaru, &actas.nombre, &h, ts));
+    }
+
+    // --- Banner: el postcard exportado del release (recortado para que quepa).
+    let postcard_hex = release_current
+        .as_ref()
+        .and_then(|mf| mf.serializar().ok())
+        .map(|b| (b.len(), bytes_to_hex(&b)))
+        .map(|(n, hex)| format!("ManifiestoFirmado postcard ({n} bytes): {}…", &hex[..96]))
+        .unwrap_or_default();
+
+    Model {
+        graph,
+        keystore,
+        seeds,
+        passphrase: "agora-dev".into(),
+        store_path: std::env::temp_dir().join("agora-pantallazo-graph.json"),
+        libreta: {
+            // Un petname puesto: el pantallazo muestra "Amaru" en vez del hash.
+            let mut l = agora_petnames::Libreta::nueva();
+            l.nombrar(id_amaru, "Amaru").ok();
+            l
+        },
+        libreta_path: std::env::temp_dir().join("agora-pantallazo-libreta.postcard"),
+        petname_input: TextInputState::new(),
+        nueva_identidad_nombre: TextInputState::new(),
+        modo_experto: false,
+        screen: Screen::Main,
+        // Seis tiles (sin el compositor) → grilla 3×2 llena, sin celdas
+        // vacías: sustrato social arriba, plano de control de wawa abajo.
+        tiles_order: vec![
+            Tile::Identidades,
+            Tile::Atestaciones,
+            Tile::Politica,
+            Tile::Multifirma,
+            Tile::Release,
+            Tile::Capacidad,
+        ],
+        active_modulo: modulo_env(),
+        sidebar: llimphi_widget_rag_sidebar::RagSidebarState {
+            open: Some(modulo_env().to_u64()),
+            selected: Some(modulo_env().to_u64()),
+            panel_w: 258.0,
+            ..Default::default()
+        },
+        perfiles: {
+            let mut amaru = model::Perfil::nuevo("amaru");
+            amaru.identidad = Some(id_amaru);
+            vec![amaru, model::Perfil::nuevo("killa")]
+        },
+        perfil_activo: 0,
+        perfil_nombre: {
+            let mut t = TextInputState::new();
+            t.set_text("amaru");
+            t
+        },
+        // Sin data_dir: el pantallazo corre el demo EN MEMORIA (determinista y
+        // sin escribir un registro en el disco de quien lo saca).
+        qillqa: modulos::qillqa::State::nueva(None),
+        focused_subject: Some(id_amaru),
+        active_signer: Some(id_amaru),
+        selected_attestation: Some(0),
+        compose_predicate,
+        compose_value,
+        focused_input: FocusedInput::Compose(ComposeField::Value),
+        compose_status: "atestación agregada y persistida".into(),
+        policy: TrustPolicy {
+            min_third_party: 2,
+            accept_self: false,
+            min_attesters_of_kind: Some((IdentityKind::Community, 1)),
+            max_age_secs: Some(604_800),
+        },
+        multi_message,
+        multi_selected,
+        multi_threshold: 2,
+        multi_current,
+        release_hash,
+        release_paste: TextInputState::new(),
+        verificar_paste: TextInputState::new(),
+        mesa_tipo: TextInputState::new(),
+        mesa_partes: TextInputState::new(),
+        mesa_refs: TextInputState::new(),
+        release_current,
+        release_status: "✓ release firmado (exporta el postcard para mudanza)".into(),
+        cap_bytecode,
+        cap_permisos,
+        cap_paste: TextInputState::new(),
+        cap_current,
+        cap_status: "✓ concesión firmada (viaja con el bytecode)".into(),
+        actas,
+        actas_textos,
+        actas_path: std::env::temp_dir().join("agora-pantallazo-actas.postcard"),
+        acta_input: TextInputState::new(),
+        acta_selected: Some(0),
+        actas_status: "✓ acta asentada y firmada".into(),
+        status: Some(StatusBanner {
+            level: StatusLevel::Info,
+            text: postcard_hex,
+        }),
+        menu_open: None,
+        edit_menu: None,
+        clipboard: llimphi_clipboard::SystemClipboard::new(),
+        menu_active: usize::MAX,
+        menu_anim: Tween::idle(1.0),
+        edit_active: usize::MAX,
+        edit_anim: Tween::idle(1.0),
+    }
+}
+
+/// Barra de menú con los mismos menús raíz que la app (cerrados en el
+/// pantallazo, así que sólo se ven los rótulos).
+fn menu_demo() -> app_bus::AppMenu {
+    use app_bus::{AppMenu, Menu, MenuItem};
+    AppMenu::new()
+        .menu(Menu::new("Archivo").item(MenuItem::new("Nueva identidad", "file.nueva_identidad")))
+        .menu(Menu::new("Editar").item(MenuItem::new("Atestar", "edit.atestar")))
+        .menu(Menu::new("Ver").item(MenuItem::new("Limpiar release", "view.limpiar_release")))
+        .menu(Menu::new("Ayuda").item(MenuItem::new("Recargar grafo", "help.recargar")))
+}
+
+/// Misma composición que el `view()` de `AgoraApp` (menubar arriba, tiles
+/// draggables al centro, banner de estado al pie), con la grilla acomodada
+/// para el pantallazo: el sustrato social en 2×2 a la izquierda y el tile
+/// **capacidad · wawa** a columna completa a la derecha — los sobres firmados
+/// muestran hashes de 64 hex que necesitan ese ancho/alto para no envolver.
+fn view_demo(model: &Model, menu: &app_bus::AppMenu, theme: &Theme) -> View<Msg> {
+    // El shell modular real: rail de dientes · índice · canvas · perfil.
+    let shell = chrome::shell_view(model, theme);
+
+    let body = match &model.status {
+        None => shell,
+        Some(banner) => ui::status_layout(theme, shell, banner),
+    };
+
+    let menubar = menubar_view(&MenuBarSpec {
+        menu,
+        open: model.menu_open,
+        theme,
+        viewport: (W as f32, H as f32),
+        height: MENU_H,
+        on_open: std::sync::Arc::new(Msg::MenuOpen),
+        on_command: std::sync::Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+    });
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_app)
+    .children(vec![menubar, ui::grow(body)])
+}
+
+fn main() {
+    let out = std::env::args()
+        .nth(1)
+        .unwrap_or_else(|| "/tmp/shots/agora.png".to_string());
+    if let Some(dir) = std::path::Path::new(&out).parent() {
+        std::fs::create_dir_all(dir).ok();
+    }
+
+    let theme = Theme::dark();
+    let model = modelo_demo();
+    let menu = menu_demo();
+    let root = view_demo(&model, &menu, &theme);
+
+    // view → layout → scene (misma secuencia que el eventloop real).
+    let mut layout = LayoutTree::new();
+    let mounted = mount(&mut layout, root);
+    let mut ts = Typesetter::new();
+    let computed = {
+        let tmap = &mounted.text_measures;
+        layout
+            .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| {
+                match tmap.get(&nid) {
+                    Some(tm) => measure_text_node(&mut ts, tm, known, avail),
+                    None => taffy::Size::ZERO,
+                }
+            })
+            .expect("layout")
+    };
+    let mut scene = vello::Scene::new();
+    paint(&mut scene, &mounted, &computed, &mut ts, None, None);
+
+    let hal = pollster::block_on(Hal::new(None)).expect("hal");
+    let mut renderer = Renderer::new(&hal).expect("renderer");
+    let target = hal.device.create_texture(&wgpu::TextureDescriptor {
+        label: Some("pantallazo-agora"),
+        size: wgpu::Extent3d {
+            width: W,
+            height: H,
+            depth_or_array_layers: 1,
+        },
+        mip_level_count: 1,
+        sample_count: 1,
+        dimension: wgpu::TextureDimension::D2,
+        format: FMT,
+        usage: wgpu::TextureUsages::STORAGE_BINDING
+            | wgpu::TextureUsages::RENDER_ATTACHMENT
+            | wgpu::TextureUsages::COPY_SRC,
+        view_formats: &[],
+    });
+    let view = target.create_view(&wgpu::TextureViewDescriptor::default());
+    let [r, g, b, _] = theme.bg_app.components;
+    let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
+    renderer
+        .render_to_view(&hal, &scene, &view, W, H, bg)
+        .expect("render_to_view");
+
+    write_png(&hal, &target, &out);
+    eprintln!("pantallazo_agora: escrito {out} ({W}x{H})");
+}
+
+/// Lee la textura a CPU y la vuelca como PNG RGBA8.
+fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
+    let unpadded = (W * 4) as usize;
+    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
+    let padded = unpadded.div_ceil(align) * align;
+    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
+        label: Some("readback"),
+        size: (padded * H as usize) as u64,
+        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
+        mapped_at_creation: false,
+    });
+    let mut enc = hal
+        .device
+        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
+    enc.copy_texture_to_buffer(
+        wgpu::TexelCopyTextureInfo {
+            texture: target,
+            mip_level: 0,
+            origin: wgpu::Origin3d::ZERO,
+            aspect: wgpu::TextureAspect::All,
+        },
+        wgpu::TexelCopyBufferInfo {
+            buffer: &buf,
+            layout: wgpu::TexelCopyBufferLayout {
+                offset: 0,
+                bytes_per_row: Some(padded as u32),
+                rows_per_image: Some(H),
+            },
+        },
+        wgpu::Extent3d {
+            width: W,
+            height: H,
+            depth_or_array_layers: 1,
+        },
+    );
+    hal.queue.submit(std::iter::once(enc.finish()));
+    let slice = buf.slice(..);
+    let (tx, rx) = std::sync::mpsc::channel();
+    slice.map_async(wgpu::MapMode::Read, move |r| {
+        let _ = tx.send(r);
+    });
+    hal.device.poll(wgpu::PollType::wait_indefinitely());
+    rx.recv().unwrap().unwrap();
+    let data = slice.get_mapped_range();
+    let mut pixels = Vec::with_capacity((W * H * 4) as usize);
+    for row in 0..H as usize {
+        let s = row * padded;
+        pixels.extend_from_slice(&data[s..s + unpadded]);
+    }
+    drop(data);
+    buf.unmap();
+    let file = File::create(path).expect("png");
+    let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
+    enc.set_color(png::ColorType::Rgba);
+    enc.set_depth(png::BitDepth::Eight);
+    let mut w = enc.write_header().unwrap();
+    w.write_image_data(&pixels).unwrap();
+}
diff --git a/03_ukupacha/agora/agora-app/src/chrome.rs b/03_ukupacha/agora/agora-app/src/chrome.rs
new file mode 100644
index 0000000..cc49110
--- /dev/null
+++ b/03_ukupacha/agora/agora-app/src/chrome.rs
@@ -0,0 +1,258 @@
+//! El **chrome** del shell modular: el **sidebar unificado** (`rag-sidebar`) de
+//! índice + canvas + sidebar de perfil (derecha).
+//!
+//! ```text
+//!  ┌────┬──────────────┬───────────────────────┬──────────────┐
+//!  │ 🦷 │  ÍNDICE      │        CANVAS         │   PERFIL     │
+//!  │ 🦷 │  listas /    │  contenido / form /   │  identidad   │
+//!  │ 🦷 │  metadatos   │  grafo del ítem       │  activa +    │
+//!  │ …  │  del módulo  │  seleccionado         │  config      │
+//!  └────┴──────────────┴───────────────────────┴──────────────┘
+//!    rag-sidebar (rail + panel)   canvas          perfil
+//! ```
+//!
+//! El **rail de dientes + el panel de índice** los pinta un solo dueño: el widget
+//! compartido `llimphi-widget-rag-sidebar` (mismo chrome que cosmos/nakui/media/pata).
+//! Un diente por módulo; el índice es el **panel del diente activo**. El header
+//! uniforme, el buscador y el control de disposición salen del widget — agora no
+//! re-deriva ninguno. El canvas y el sidebar de perfil quedan de la app.
+
+use std::sync::Arc;
+
+use llimphi_icons::icon_view;
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::{
+    prelude::{auto, length, percent, FlexDirection, Size, Style},
+    style::Position,
+    AlignItems, Rect,
+};
+use llimphi_ui::View;
+use llimphi_widget_rag_sidebar::{
+    rag_multiselect_view, rag_sidebar_view, RagOptions, RagSidebarMsg, RagSidebarPalette,
+    RagSidebarView, RagSide, RagTooth,
+};
+
+use crate::model::{Model, Msg};
+use crate::modulos::{self, Dominio, ModuloId, MODULOS};
+
+/// Ancho del rail de dientes (px) — mismo que cosmos/nahual/media.
+const RAIL_W: f32 = 44.0;
+/// Ancho del sidebar de perfil (derecha).
+const PERFIL_W: f32 = 312.0;
+
+/// El shell completo: (rail · índice) · canvas · perfil.
+pub(crate) fn shell_view(model: &Model, theme: &Theme) -> View<Msg> {
+    let sidebar = sidebar_widget(model, theme);
+    let canvas = canvas(model.active_modulo, model, theme);
+    let perfil = crate::perfil::sidebar_view(model, theme, PERFIL_W);
+
+    if model.sidebar.reserves_space() {
+        // FIJO: el sidebar de índice se planta como columna y redelimita el canvas.
+        View::new(Style {
+            flex_direction: FlexDirection::Row,
+            size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+            ..Default::default()
+        })
+        .children(vec![sidebar, canvas, perfil])
+    } else {
+        // FLOTA / AUTOHIDE: el índice flota como overlay a la izquierda sobre el
+        // canvas; un backdrop de click-away lo guarda/oculta. El perfil sigue fijo
+        // a la derecha.
+        let base = View::new(Style {
+            flex_direction: FlexDirection::Row,
+            size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+            ..Default::default()
+        })
+        .children(vec![canvas, perfil]);
+        let mut kids = vec![base];
+        let expandido =
+            model.sidebar.panel_visible() || (model.sidebar.autohide && !model.sidebar.hidden());
+        if expandido {
+            kids.push(
+                View::new(Style {
+                    position: Position::Absolute,
+                    inset: Rect {
+                        left: length(0.0_f32),
+                        right: length(0.0_f32),
+                        top: length(0.0_f32),
+                        bottom: length(0.0_f32),
+                    },
+                    size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+                    ..Default::default()
+                })
+                .on_click(Msg::Sidebar(RagSidebarMsg::Reveal(false))),
+            );
+        }
+        kids.push(
+            View::new(Style {
+                position: Position::Absolute,
+                inset: Rect {
+                    left: length(0.0_f32),
+                    right: auto(),
+                    top: length(0.0_f32),
+                    bottom: length(0.0_f32),
+                },
+                size: Size { width: auto(), height: percent(1.0_f32) },
+                ..Default::default()
+            })
+            .children(vec![sidebar]),
+        );
+        View::new(Style {
+            position: Position::Relative,
+            size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+            ..Default::default()
+        })
+        .children(kids)
+    }
+}
+
+/// El **sidebar unificado** de agora: un diente por módulo (rail del widget) y el
+/// índice del módulo activo como panel abierto (header uniforme + buscador +
+/// control, todo del widget). Los módulos de Sistema wawa (release/capacidad) sólo
+/// aparecen en modo experto; los de qillqa quedan tras un separador de grupo.
+fn sidebar_widget(model: &Model, theme: &Theme) -> View<Msg> {
+    let activo = model.active_modulo;
+    let visibles: Vec<ModuloId> = MODULOS
+        .iter()
+        .copied()
+        .filter(|m| model.modo_experto || !m.es_experto())
+        .collect();
+
+    let teeth: Vec<RagTooth<Msg>> = visibles
+        .iter()
+        .enumerate()
+        .map(|(i, &m)| {
+            let ic = m.icono();
+            let mut t = RagTooth::new(
+                m.to_u64(),
+                m.titulo(),
+                Arc::new(move |size, color| {
+                    View::new(Style {
+                        size: Size { width: length(size), height: length(size) },
+                        align_items: Some(AlignItems::Center),
+                        ..Default::default()
+                    })
+                    .children(vec![icon_view(ic, color, 1.6)])
+                }),
+            );
+            // Separador tenue antes del primer diente de qillqa (agrupa dominios).
+            if i > 0
+                && m.dominio() == Dominio::Qillqa
+                && visibles[i - 1].dominio() == Dominio::Agora
+            {
+                t = t.separator_before();
+            }
+            t
+        })
+        .collect();
+
+    // Cuerpo del diente abierto = subtítulo del módulo + índice filtrado por el
+    // buscador (listas/metadatos despachados por `modulos::sidebar`).
+    let dom = match activo.dominio() {
+        Dominio::Agora => "ágora",
+        Dominio::Qillqa => "qillqa",
+    };
+    let subtitulo = View::new(Style {
+        size: Size { width: percent(1.0_f32), height: length(18.0_f32) },
+        align_items: Some(AlignItems::Center),
+        flex_shrink: 0.0,
+        padding: Rect {
+            left: length(10.0_f32),
+            right: length(8.0_f32),
+            top: length(0.0_f32),
+            bottom: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .text(format!("{dom} · {}", activo.subtitulo()), 11.0, theme.fg_muted);
+    let contenido = modulos::sidebar(activo, model, theme, &model.sidebar.search);
+    let body = View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+        gap: Size { width: length(0.0_f32), height: length(4.0_f32) },
+        padding: Rect {
+            left: length(0.0_f32),
+            right: length(0.0_f32),
+            top: length(6.0_f32),
+            bottom: length(6.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![subtitulo, crate::ui::grow(contenido)]);
+
+    let spec = RagSidebarView {
+        teeth,
+        body,
+        accessory: None,
+        search_hits: !model.sidebar.search.is_empty(),
+        t: 0.0,
+        rail_w: RAIL_W,
+        // El click en un diente selecciona el módulo (nunca colapsa el índice); el
+        // resto de mensajes (ejes de disposición, buscador, control) van al estado.
+        map: Arc::new(|rm| match rm {
+            RagSidebarMsg::Activate(id) => Msg::SelectModulo(ModuloId::from_u64(id)),
+            other => Msg::Sidebar(other),
+        }),
+        on_drop: None,
+        on_reorder: None,
+        grow: None,
+    };
+    rag_sidebar_view(&model.sidebar, spec, RagSide::Left, &RagSidebarPalette::from_theme(theme))
+}
+
+/// El **multiselect de disposición** del sidebar de índice, como ventana propia
+/// (overlay a nivel raíz, no clipeado por el panel) con backdrop de click-away.
+/// `None` si el control está cerrado. Lo pinta [`crate::main`] sobre el shell.
+pub(crate) fn multiselect_overlay(model: &Model, theme: &Theme) -> Option<View<Msg>> {
+    if !model.sidebar.control_open {
+        return None;
+    }
+    let card = rag_multiselect_view(
+        &model.sidebar,
+        RagOptions::default(),
+        Arc::new(Msg::Sidebar),
+        &RagSidebarPalette::from_theme(theme),
+    );
+    // Arrimada al borde derecho del panel (donde está el control ⚙), bajo la barra.
+    let left = (RAIL_W + model.sidebar.panel_w - 238.0).max(RAIL_W + 4.0);
+    let card_pos = View::new(Style {
+        position: Position::Absolute,
+        inset: Rect { left: length(left), right: auto(), top: length(40.0_f32), bottom: auto() },
+        ..Default::default()
+    })
+    .children(vec![card]);
+    let backdrop = View::new(Style {
+        position: Position::Absolute,
+        inset: Rect {
+            left: length(0.0_f32),
+            right: length(0.0_f32),
+            top: length(0.0_f32),
+            bottom: length(0.0_f32),
+        },
+        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+        ..Default::default()
+    })
+    .on_click(Msg::Sidebar(RagSidebarMsg::ControlToggle));
+    Some(
+        View::new(Style {
+            position: Position::Relative,
+            size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+            ..Default::default()
+        })
+        .children(vec![backdrop, card_pos]),
+    )
+}
+
+/// El canvas: el contenido/formulario/grafo del ítem activo.
+fn canvas(activo: ModuloId, model: &Model, theme: &Theme) -> View<Msg> {
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+        flex_grow: 1.0,
+        flex_basis: length(0.0_f32),
+        min_size: Size { width: length(0.0_f32), height: length(0.0_f32) },
+        ..Default::default()
+    })
+    .fill(theme.bg_app)
+    .children(vec![modulos::canvas(activo, model, theme)])
+}
diff --git a/03_ukupacha/agora/agora-app/src/main.rs b/03_ukupacha/agora/agora-app/src/main.rs
new file mode 100644
index 0000000..d7286d1
--- /dev/null
+++ b/03_ukupacha/agora/agora-app/src/main.rs
@@ -0,0 +1,1245 @@
+//! `agora-app` — UI Llimphi del ágora.
+//!
+//! Una ventana con tiles draggables sobre el mismo `TrustGraph`. Drag de la
+//! title bar de un tile sobre otro los intercambia. Dos familias de tiles:
+//!
+//! - **Sustrato social** — identidades · compositor · atestaciones · política
+//!   · multifirma: construyen y evalúan la web-of-trust por-lector.
+//! - **Plano de control de wawa** (el norte, ver `ARQUITECTURA.md`) — release ·
+//!   capacidad: firman y verifican los sobres Ed25519 que el kernel honra
+//!   (`ManifiestoFirmado`, `ConcesionCapacidad`), reusando `agora-channel`.
+//!
+//! ## Cómo arranca
+//!
+//! - Lee `~/.local/share/agora/graph.json` si existe; si no, parte vacío.
+//! - Abre el `Keystore` en `~/.local/share/agora/keys/`.
+//! - La passphrase sale de `AGORA_PASSPHRASE`; sin esa env y con keystore no
+//!   vacío, muestra la pantalla de unlock. Keystore vacío ⇒ entra directo.
+//!
+//! El código vive partido en módulos: [`model`] (estado + mensajes), [`ui`]
+//! (helpers/paletas/hex + pantallas transversales) y [`tiles`] (un archivo por
+//! tile). Este archivo deja sólo el `App` impl, el `update` y el watcher.
+
+mod chrome;
+mod model;
+mod modulos;
+mod perfil;
+mod tiles;
+mod ui;
+
+use std::path::PathBuf;
+
+use agora_core::{Attestation, Claim, IdentityKind, Keypair, MultiSignature, SingleSig};
+use agora_graph::{TrustGraph, TrustPolicy};
+use agora_keystore::Keystore;
+use format::{Canal, ConcesionCapacidad, ManifiestoFirmado};
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::prelude::{percent, FlexDirection, Size, Style};
+use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
+use llimphi_motion::{animate, motion, Tween};
+use llimphi_widget_context_menu::{context_menu_view_ex, ContextMenuExtras};
+use llimphi_widget_edit_menu::{self as editmenu, EditAction, EditFlags};
+use llimphi_widget_menubar::{
+    menubar_command_at, menubar_nav, menubar_overlay_animated, menubar_view, MenuBarSpec,
+    DEFAULT_HEIGHT as MENU_H,
+};
+use llimphi_widget_text_input::{TextInputEvent, TextInputState};
+use rand::RngCore;
+
+use crate::model::{
+    ComposeField, FocusedInput, Model, Msg, Perfil, Screen, StatusLevel, TILES_INICIALES,
+};
+use crate::modulos::ModuloId;
+use crate::ui::{bytes_to_hex, hex_to_bytes, parse_hash32, status_layout, unlock_view};
+
+struct AgoraApp;
+
+impl App for AgoraApp {
+    type Model = Model;
+    type Msg = Msg;
+
+    fn title() -> &'static str {
+        "ágora · raíz de confianza ejecutable"
+    }
+
+    fn initial_size() -> (u32, u32) {
+        (1280, 820)
+    }
+
+    fn init(handle: &Handle<Msg>) -> Model {
+        let data_dir = directories::ProjectDirs::from("net", "tawasuyu", "agora")
+            .map(|p| p.data_dir().to_path_buf())
+            .unwrap_or_else(|| PathBuf::from("."));
+        std::fs::create_dir_all(&data_dir).ok();
+        let store_path = data_dir.join("graph.json");
+
+        // Libreta de petnames: nombres memorables locales. Se carga de disco
+        // (postcard) si existe; si el archivo está corrupto, parte vacía sin
+        // ladrillar — una libreta es local y re-poblable.
+        let libreta_path = data_dir.join("libreta.postcard");
+        let libreta = std::fs::read(&libreta_path)
+            .ok()
+            .and_then(|b| postcard::from_bytes(&b).ok())
+            .unwrap_or_default();
+
+        // Libro de actas (canal firmado + textos paralelos). Igual que la libreta:
+        // si el archivo está corrupto, parte de un libro vacío sin ladrillar.
+        let actas_path = data_dir.join("actas.postcard");
+        let (actas, actas_textos): (Canal, Vec<String>) = std::fs::read(&actas_path)
+            .ok()
+            .and_then(|b| postcard::from_bytes(&b).ok())
+            .unwrap_or_else(|| (Model::libro_actas_vacio(), Vec::new()));
+
+        // Watcher del directorio padre — vigila renames y crear/borrar del
+        // archivo aunque aún no exista (agora-store::save es tmp+rename).
+        arranca_watcher(handle.clone(), data_dir.clone());
+
+        let env_pass = std::env::var("AGORA_PASSPHRASE").ok();
+        let keystore = Keystore::open_default()
+            .unwrap_or_else(|e| panic!("agora-app: no pude abrir el keystore: {e}"));
+        let graph = if store_path.exists() {
+            agora_store::load(&store_path).unwrap_or_else(|e| {
+                eprintln!("agora-app: no pude cargar el grafo ({e}); empiezo vacío.");
+                TrustGraph::new()
+            })
+        } else {
+            TrustGraph::new()
+        };
+
+        // Passphrase del keystore: si el usuario fijó `AGORA_PASSPHRASE`, esa; si
+        // no, probamos la de desarrollo por defecto (`agora-dev`) — el caso de
+        // quien nunca fijó una passphrase: agora-app cifra las llaves nuevas con
+        // ella, así que abre el keystore y entra directo. Sólo mostramos la
+        // pantalla de unlock si esa default NO abre la primera llave, señal de
+        // que el usuario SÍ puso una passphrase real y hay que pedírsela.
+        const PASS_DEV: &str = "agora-dev";
+        let ids_keystore = keystore.list().unwrap_or_default();
+        let (passphrase, necesita_unlock) = match env_pass {
+            Some(p) => (p, false),
+            None if ids_keystore.is_empty() => (PASS_DEV.to_string(), false),
+            None => {
+                let abre_default = keystore.load(ids_keystore[0], PASS_DEV).is_ok();
+                (PASS_DEV.to_string(), !abre_default)
+            }
+        };
+
+        let mut model = Model {
+            graph,
+            keystore,
+            seeds: std::collections::HashMap::new(),
+            passphrase,
+            store_path,
+            libreta,
+            libreta_path,
+            petname_input: TextInputState::new(),
+            nueva_identidad_nombre: TextInputState::new(),
+            screen: if necesita_unlock {
+                Screen::Unlock {
+                    input: TextInputState::masked(),
+                    status: String::new(),
+                }
+            } else {
+                Screen::Main
+            },
+            tiles_order: TILES_INICIALES.to_vec(),
+            active_modulo: ModuloId::Identidades,
+            sidebar: llimphi_widget_rag_sidebar::RagSidebarState {
+                // El índice del módulo arranca desplegado (fijo). El diente abierto lo
+                // fija `active_modulo` en cada render; aquí sólo el estado inicial.
+                open: Some(ModuloId::Identidades.to_u64()),
+                selected: Some(ModuloId::Identidades.to_u64()),
+                panel_w: 258.0,
+                ..Default::default()
+            },
+            modo_experto: false,
+            perfiles: vec![Perfil::nuevo("principal")],
+            perfil_activo: 0,
+            perfil_nombre: TextInputState::new(),
+            // Las salas qillqa que persisten cuelgan de `data_dir` (hoy Mesa de
+            // partes, en `<data_dir>/mesa/`), igual que el libro de actas.
+            qillqa: modulos::qillqa::State::nueva(Some(&data_dir)),
+            focused_subject: None,
+            active_signer: None,
+            selected_attestation: None,
+            compose_predicate: TextInputState::new(),
+            compose_value: TextInputState::new(),
+            focused_input: FocusedInput::Compose(ComposeField::Predicate),
+            compose_status: String::new(),
+            policy: TrustPolicy::default(),
+            multi_message: TextInputState::new(),
+            multi_selected: std::collections::BTreeSet::new(),
+            multi_threshold: 1,
+            multi_current: None,
+            release_hash: TextInputState::new(),
+            release_paste: TextInputState::new(),
+            verificar_paste: TextInputState::new(),
+            mesa_tipo: TextInputState::new(),
+            mesa_partes: TextInputState::new(),
+            mesa_refs: TextInputState::new(),
+            release_current: None,
+            release_status: String::new(),
+            cap_bytecode: TextInputState::new(),
+            cap_permisos: 0,
+            cap_paste: TextInputState::new(),
+            cap_current: None,
+            cap_status: String::new(),
+            actas,
+            actas_textos,
+            actas_path,
+            acta_input: TextInputState::new(),
+            acta_selected: None,
+            actas_status: String::new(),
+            status: None,
+            menu_open: None,
+            edit_menu: None,
+            clipboard: llimphi_clipboard::SystemClipboard::new(),
+            menu_active: usize::MAX,
+            menu_anim: Tween::idle(1.0),
+            edit_active: usize::MAX,
+            edit_anim: Tween::idle(1.0),
+        };
+
+        if !necesita_unlock {
+            model.desbloquear_seeds_silencioso();
+            model.active_signer = model.seeds.keys().next().copied();
+            model.registrar_identidades_huerfanas();
+            model.perfil_mut().identidad = model.active_signer;
+        }
+        model.sincronizar_perfil();
+        model
+    }
+
+    fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+        match msg {
+            Msg::SwapTile(from, to) => {
+                if from != to && from < model.tiles_order.len() && to < model.tiles_order.len() {
+                    model.tiles_order.swap(from, to);
+                }
+            }
+
+            // ---- Shell modular -------------------------------------------
+            Msg::SelectModulo(m) => {
+                model.active_modulo = m;
+                // El índice del módulo activo ES el panel abierto del sidebar
+                // unificado: mantenerlos en sincronía y limpiar el filtro previo.
+                model.sidebar.open = Some(m.to_u64());
+                model.sidebar.selected = Some(m.to_u64());
+                model.sidebar.search.clear();
+            }
+            Msg::Sidebar(sm) => {
+                model.sidebar.update(sm);
+                // El índice nunca colapsa: tras reducir los ejes/buscador, el diente
+                // del módulo activo sigue abierto.
+                model.sidebar.open = Some(model.active_modulo.to_u64());
+                model.sidebar.selected = Some(model.active_modulo.to_u64());
+            }
+            Msg::SwitchPerfil(i) => {
+                if i < model.perfiles.len() {
+                    model.perfil_activo = i;
+                    model.sincronizar_perfil();
+                }
+            }
+            Msg::NuevoPerfil => {
+                let n = model.perfiles.len() + 1;
+                model.perfiles.push(Perfil::nuevo(format!("perfil {n}")));
+                model.perfil_activo = model.perfiles.len() - 1;
+                model.sincronizar_perfil();
+            }
+            Msg::TogglePerfilTema => {
+                let v = model.perfil().tema_oscuro;
+                model.perfil_mut().tema_oscuro = !v;
+            }
+            Msg::ToggleModoExperto => {
+                model.modo_experto = !model.modo_experto;
+                // Si al apagarlo estábamos parados en un diente de Sistema,
+                // volvemos a Mi gente para no quedar en un módulo invisible.
+                if !model.modo_experto && model.active_modulo.es_experto() {
+                    model.active_modulo = ModuloId::Identidades;
+                }
+            }
+            Msg::VincularIdentidad(id) => {
+                model.perfil_mut().identidad = Some(id);
+                if model.seeds.contains_key(&id) {
+                    model.active_signer = Some(id);
+                }
+            }
+            Msg::Qillqa(m) => {
+                // El alta de Mesa es el único mensaje qillqa que consume campos
+                // del shell: si el acto quedó asentado, los campos se limpian
+                // (si falló, el texto se queda para corregirlo).
+                let era_alta = matches!(
+                    m,
+                    modulos::qillqa::Msg::Mesa(modulos::qillqa::mesa::Msg::Asentar { .. })
+                );
+                modulos::qillqa::update(&mut model.qillqa, m);
+                if era_alta && modulos::qillqa::mesa::sin_error(&model.qillqa.mesa) {
+                    model.limpiar_form_mesa();
+                }
+            }
+
+            Msg::NuevaIdentidad => {
+                let mut seed = [0u8; 32];
+                rand::thread_rng().fill_bytes(&mut seed);
+                let kp = Keypair::from_seed(seed);
+                let id = kp.identity_id();
+                // El nombre que el usuario tipeó en el onboarding. Vacío ⇒ caemos
+                // a "yo N" (mejor un default feo que una identidad sin nombre),
+                // pero el flujo normal es que venga con nombre real.
+                let nombre = model.nueva_identidad_nombre.text().trim().to_string();
+                let display = if nombre.is_empty() {
+                    format!("yo {}", model.graph.identity_count() + 1)
+                } else {
+                    nombre.clone()
+                };
+                match model.keystore.save(id, &seed, &model.passphrase) {
+                    Ok(()) => {
+                        model.seeds.insert(id, seed);
+                        model.graph.register(kp.identity(IdentityKind::Person, display.clone()));
+                        if model.active_signer.is_none() {
+                            model.active_signer = Some(id);
+                        }
+                        model.nueva_identidad_nombre.clear();
+                        model.save_graph();
+                        model.compose_status = if nombre.is_empty() {
+                            format!("identidad nueva: {display}")
+                        } else {
+                            format!("¡bienvenida, {display}!")
+                        };
+                    }
+                    Err(e) => {
+                        model.compose_status = format!("no pude guardar la seed: {e}");
+                    }
+                }
+            }
+
+            Msg::FocoSujeto(id) => {
+                model.focused_subject = Some(id);
+                // Precargá el campo de nombre con el petname vigente (si hay),
+                // para que "poner nombre" se sienta como editar, no empezar de cero.
+                model.petname_input = TextInputState::new();
+                if let Some(n) = model.libreta.nombre_de(id) {
+                    model.petname_input.set_text(n.to_string());
+                }
+            }
+
+            Msg::PonerNombre => {
+                let id = match model.focused_subject {
+                    Some(id) => id,
+                    None => {
+                        model.compose_status = "elige una identidad primero".into();
+                        return model;
+                    }
+                };
+                let nombre = model.petname_input.text().trim().to_string();
+                if nombre.is_empty() {
+                    // Nombre vacío = olvidar el petname (volver al nombre del grafo).
+                    if let Some(viejo) = model.libreta.nombre_de(id).map(str::to_owned) {
+                        model.libreta.olvidar(&viejo);
+                        model.guardar_libreta();
+                    }
+                    return model;
+                }
+                match model.libreta.nombrar(id, nombre.clone()) {
+                    Ok(()) => {
+                        model.guardar_libreta();
+                        model.compose_status = format!("la llamas «{nombre}»");
+                    }
+                    Err(ocupado) => {
+                        let quien = model.nombre_humano(ocupado.por);
+                        model.compose_status =
+                            format!("«{nombre}» ya es el nombre de {quien} — elige otro");
+                    }
+                }
+            }
+
+            Msg::OlvidarNombre => {
+                if let Some(id) = model.focused_subject {
+                    if let Some(viejo) = model.libreta.nombre_de(id).map(str::to_owned) {
+                        model.libreta.olvidar(&viejo);
+                        model.petname_input = TextInputState::new();
+                        model.guardar_libreta();
+                    }
+                }
+            }
+
+            Msg::ActuarComo(id) => {
+                if model.seeds.contains_key(&id) {
+                    model.active_signer = Some(id);
+                    // Las firmas vigentes eran del firmante anterior.
+                    model.release_current = None;
+                    model.cap_current = None;
+                }
+            }
+
+            Msg::SeleccionarAtestacion(idx) => {
+                if idx < model.graph.attestations().len() {
+                    model.selected_attestation = Some(idx);
+                }
+            }
+
+            Msg::Foco(f) => model.focused_input = f,
+
+            Msg::Campo(f, ev) => model.campo_evento(f, ev),
+
+            Msg::EditFocused(ev) => model.edit_focused(&ev),
+
+            Msg::Atestar => {
+                let signer = model.signer_keypair();
+                let subject = model.focused_subject;
+                let predicate = model.compose_predicate.text();
+                let value = model.compose_value.text();
+                let predicate = predicate.trim();
+                let value = value.trim();
+                match (signer, subject) {
+                    (None, _) => {
+                        model.compose_status =
+                            "no hay firmante activo (crea una identidad o selecciona \"actuar como\")".into();
+                    }
+                    (_, None) => {
+                        model.compose_status = "elige un sujeto en el tile de identidades".into();
+                    }
+                    _ if predicate.is_empty() || value.is_empty() => {
+                        model.compose_status = "predicate y value son obligatorios".into();
+                    }
+                    (Some(kp), Some(subject)) => {
+                        let now = ahora();
+                        let claim = Claim::new(subject, predicate, value, now);
+                        let att = Attestation::create(&kp, claim);
+                        match model.graph.add_attestation(att.clone()) {
+                            Ok(()) => {
+                                model.compose_predicate.clear();
+                                model.compose_value.clear();
+                                model.compose_status = "atestación agregada y persistida".into();
+                                if let Err(e) =
+                                    agora_store::append_attestation(&model.store_path, &att)
+                                {
+                                    model.set_status(
+                                        StatusLevel::Error,
+                                        format!("no pude appendear la atestación: {e}"),
+                                    );
+                                }
+                            }
+                            Err(e) => model.compose_status = format!("rechazada: {e}"),
+                        }
+                    }
+                }
+            }
+
+            Msg::SliderMinThird(_phase, dv) => {
+                let cur = model.policy.min_third_party as f32 + dv;
+                model.policy.min_third_party = cur.clamp(0.0, 5.0).round() as usize;
+            }
+            Msg::ToggleAcceptSelf => model.policy.accept_self = !model.policy.accept_self,
+            Msg::CycleKind => {
+                model.policy.min_attesters_of_kind = match model.policy.min_attesters_of_kind {
+                    None => Some((IdentityKind::Person, 1)),
+                    Some((IdentityKind::Person, n)) => Some((IdentityKind::Community, n)),
+                    Some((IdentityKind::Community, n)) => Some((IdentityKind::Alliance, n)),
+                    Some((IdentityKind::Alliance, n)) => Some((IdentityKind::Institution, n)),
+                    Some((IdentityKind::Institution, _)) => None,
+                };
+            }
+            Msg::SliderMinKind(_phase, dv) => {
+                if let Some((kind, n)) = model.policy.min_attesters_of_kind {
+                    let cur = n as f32 + dv;
+                    model.policy.min_attesters_of_kind = Some((kind, cur.clamp(1.0, 5.0).round() as usize));
+                }
+            }
+            Msg::CycleMaxAge => {
+                model.policy.max_age_secs = match model.policy.max_age_secs {
+                    None => Some(60),
+                    Some(60) => Some(300),
+                    Some(300) => Some(3_600),
+                    Some(3_600) => Some(86_400),
+                    Some(86_400) => Some(604_800),
+                    _ => None,
+                };
+            }
+
+            Msg::UnlockKey(ev) => {
+                if let Screen::Unlock { input, .. } = &mut model.screen {
+                    input.handle(TextInputEvent::Key(ev.clone()), &mut model.clipboard);
+                }
+            }
+            Msg::UnlockCampo(ev) => {
+                if let Screen::Unlock { input, .. } = &mut model.screen {
+                    input.handle(ev, &mut model.clipboard);
+                }
+            }
+            Msg::UnlockSubmit => {
+                let pass_intentada = if let Screen::Unlock { input, .. } = &model.screen {
+                    Some(input.text())
+                } else {
+                    None
+                };
+                if let Some(pass) = pass_intentada {
+                    if model.intentar_unlock(&pass) {
+                        model.registrar_identidades_huerfanas();
+                        model.active_signer = model.seeds.keys().next().copied();
+                        model.perfil_mut().identidad = model.active_signer;
+                        model.sincronizar_perfil();
+                        model.screen = Screen::Main;
+                    } else if let Screen::Unlock { input, status } = &mut model.screen {
+                        *status = "passphrase incorrecta".into();
+                        input.clear();
+                    }
+                }
+            }
+
+            Msg::ArchivoCambio => recargar_grafo(&mut model),
+
+            Msg::DescartarStatus => model.status = None,
+
+            // ---- Multifirma ----------------------------------------------
+            Msg::ToggleMultiFirmante(id) => {
+                if !model.seeds.contains_key(&id) {
+                    return model; // sólo identidades propias
+                }
+                if model.multi_selected.insert(id) {
+                    // Se sumó a la decisión: no toca las firmas ya reunidas.
+                } else {
+                    // Se quitó de la decisión: su firma (si la había) ya no cuenta.
+                    model.multi_selected.remove(&id);
+                    if let Some(m) = model.multi_current.as_mut() {
+                        m.signers.retain(|s| s.signer != id);
+                        if m.signers.is_empty() {
+                            model.multi_current = None;
+                        }
+                    }
+                }
+                let n = model.multi_selected.len().max(1);
+                model.multi_threshold = model.multi_threshold.clamp(1, n);
+            }
+            Msg::SliderMultiUmbral(_phase, dv) => {
+                let n = model.multi_selected.len().max(1);
+                let cur = model.multi_threshold as f32 + dv;
+                model.multi_threshold = cur.clamp(1.0, n as f32).round() as usize;
+            }
+            Msg::FirmarComo(id) => {
+                // Un participante firma la decisión vigente. Sólo cuenta si está
+                // elegido, es una identidad propia (tenemos su seed) y no firmó ya.
+                if !model.multi_selected.contains(&id) || !model.seeds.contains_key(&id) {
+                    return model;
+                }
+                let mensaje = model.multi_message.text();
+                if mensaje.trim().is_empty() {
+                    model.set_status(StatusLevel::Error, "escribe qué se decide antes de firmar");
+                    return model;
+                }
+                if model.multi_firmo(id) {
+                    return model;
+                }
+                let kp = Keypair::from_seed(model.seeds[&id]);
+                let sig = SingleSig {
+                    signer: kp.identity_id(),
+                    public_key: kp.public_key(),
+                    signature: kp.sign(mensaje.as_bytes()),
+                };
+                model
+                    .multi_current
+                    .get_or_insert_with(MultiSignature::default)
+                    .signers
+                    .push(sig);
+            }
+            Msg::FirmarMulti => {
+                if model.multi_selected.is_empty() {
+                    model.set_status(StatusLevel::Error, "elige al menos una identidad propia como firmante");
+                    return model;
+                }
+                let mensaje_txt = model.multi_message.text();
+                if mensaje_txt.trim().is_empty() {
+                    model.set_status(StatusLevel::Error, "el mensaje de la multifirma no puede estar vacío");
+                    return model;
+                }
+                let pares: Vec<Keypair> = model
+                    .multi_selected
+                    .iter()
+                    .filter_map(|id| model.seeds.get(id).copied().map(Keypair::from_seed))
+                    .collect();
+                let refs: Vec<&Keypair> = pares.iter().collect();
+                model.multi_current = Some(MultiSignature::create(&refs, mensaje_txt.as_bytes()));
+            }
+            Msg::LimpiarMulti => {
+                model.multi_selected.clear();
+                model.multi_threshold = 1;
+                model.multi_current = None;
+                model.multi_message.clear();
+            }
+            Msg::ExportarMulti => match model.multi_current.as_ref() {
+                None => model.set_status(StatusLevel::Error, "no hay multifirma vigente — firma primero"),
+                Some(multi) => match postcard::to_allocvec(multi) {
+                    Ok(bytes) => {
+                        let hex = bytes_to_hex(&bytes);
+                        model.set_status(
+                            StatusLevel::Info,
+                            format!("multifirma postcard ({} bytes): {hex}", bytes.len()),
+                        );
+                    }
+                    Err(e) => model.set_status(StatusLevel::Error, format!("no pude serializar la multifirma: {e}")),
+                },
+            },
+
+            // ---- Release (plano de control wawa) -------------------------
+            Msg::FirmarRelease => {
+                let kp = match model.signer_keypair() {
+                    Some(kp) => kp,
+                    None => {
+                        model.release_status = "no hay firmante activo — crea una identidad".into();
+                        return model;
+                    }
+                };
+                match parse_hash32(&model.release_hash.text()) {
+                    Some(hash) => {
+                        model.release_current = Some(agora_channel::firmar_manifiesto(&kp, &hash));
+                        model.release_status = "✓ release firmado (exporta el postcard para mudanza)".into();
+                    }
+                    None => {
+                        model.release_status = "hash inválido — esperaba 64 dígitos hex".into();
+                    }
+                }
+            }
+            Msg::VerificarRelease => {
+                let bytes = match hex_to_bytes(&model.release_paste.text()) {
+                    Some(b) => b,
+                    None => {
+                        model.release_status = "✗ hex inválido (longitud impar o dígito no-hex)".into();
+                        return model;
+                    }
+                };
+                model.release_status = match ManifiestoFirmado::deserializar(&bytes) {
+                    Ok(mf) => match agora_channel::verificar_manifiesto(&mf) {
+                        Ok(()) => format!("✓ firma válida · autor {}…", &bytes_to_hex(&mf.autor)[..16]),
+                        Err(_) => "✗ firma rota — el autor no firmó este hash".into(),
+                    },
+                    Err(e) => format!("✗ no parsea como ManifiestoFirmado: {e}"),
+                };
+            }
+            Msg::ExportarRelease => match model.release_current.as_ref() {
+                None => model.set_status(StatusLevel::Error, "no hay release vigente — firma primero"),
+                Some(mf) => match mf.serializar() {
+                    Ok(bytes) => {
+                        let hex = bytes_to_hex(&bytes);
+                        model.set_status(
+                            StatusLevel::Info,
+                            format!("ManifiestoFirmado postcard ({} bytes): {hex}", bytes.len()),
+                        );
+                    }
+                    Err(e) => model.set_status(StatusLevel::Error, format!("no pude serializar el release: {e}")),
+                },
+            },
+            Msg::LimpiarRelease => {
+                model.release_hash.clear();
+                model.release_paste.clear();
+                model.release_current = None;
+                model.release_status.clear();
+            }
+
+            // ---- Capacidad (§14.1.3) -------------------------------------
+            Msg::ToggleCapPermiso(bit) => {
+                model.cap_permisos ^= bit;
+                model.cap_current = None;
+            }
+            Msg::FirmarCapacidad => {
+                let kp = match model.signer_keypair() {
+                    Some(kp) => kp,
+                    None => {
+                        model.cap_status = "no hay firmante activo — crea una identidad".into();
+                        return model;
+                    }
+                };
+                match parse_hash32(&model.cap_bytecode.text()) {
+                    Some(hash) => {
+                        model.cap_current =
+                            Some(agora_channel::firmar_capacidad(&kp, &hash, model.cap_permisos));
+                        model.cap_status = "✓ concesión firmada (viaja con el bytecode)".into();
+                    }
+                    None => {
+                        model.cap_status = "hash inválido — esperaba 64 dígitos hex".into();
+                    }
+                }
+            }
+            Msg::VerificarCapacidad => {
+                let bytes = match hex_to_bytes(&model.cap_paste.text()) {
+                    Some(b) => b,
+                    None => {
+                        model.cap_status = "✗ hex inválido (longitud impar o dígito no-hex)".into();
+                        return model;
+                    }
+                };
+                model.cap_status = match ConcesionCapacidad::deserializar(&bytes) {
+                    Ok(c) => match agora_channel::verificar_capacidad(&c) {
+                        Ok(()) => format!("✓ firma válida · autor {}…", &bytes_to_hex(&c.autor)[..16]),
+                        Err(_) => "✗ firma rota — no cubre (bytecode, permisos)".into(),
+                    },
+                    Err(e) => format!("✗ no parsea como ConcesionCapacidad: {e}"),
+                };
+            }
+            Msg::ExportarCapacidad => match model.cap_current.as_ref() {
+                None => model.set_status(StatusLevel::Error, "no hay concesión vigente — concede primero"),
+                Some(c) => match c.serializar() {
+                    Ok(bytes) => {
+                        let hex = bytes_to_hex(&bytes);
+                        model.set_status(
+                            StatusLevel::Info,
+                            format!("ConcesionCapacidad postcard ({} bytes): {hex}", bytes.len()),
+                        );
+                    }
+                    Err(e) => model.set_status(StatusLevel::Error, format!("no pude serializar la concesión: {e}")),
+                },
+            },
+            Msg::LimpiarCapacidad => {
+                model.cap_bytecode.clear();
+                model.cap_paste.clear();
+                model.cap_permisos = 0;
+                model.cap_current = None;
+                model.cap_status.clear();
+            }
+
+            // ---- Actas (libro firmado e inmutable) -----------------------
+            Msg::AsentarActa => {
+                let kp = match model.signer_keypair() {
+                    Some(kp) => kp,
+                    None => {
+                        model.actas_status = "no hay firmante activo — crea una identidad".into();
+                        return model;
+                    }
+                };
+                let texto = model.acta_input.text().trim().to_string();
+                if texto.is_empty() {
+                    model.actas_status = "escribe el texto del acta primero".into();
+                    return model;
+                }
+                // El libro lo lleva UN autor. La primera acta lo liga; después,
+                // sólo esa identidad puede seguir asentando (si no, la verificación
+                // del canal fallaría). Cambiar de firmante ⇒ mensaje claro.
+                let firmante = kp.public_key();
+                if model.actas_tiene_autor() && model.actas.autor != firmante {
+                    let dueno = model.nombre_humano(
+                        agora_core::IdentityId::from_public_key(&model.actas.autor),
+                    );
+                    model.actas_status =
+                        format!("este libro lo lleva {dueno} — cambia a esa identidad para asentar");
+                    return model;
+                }
+                if !model.actas_tiene_autor() {
+                    model.actas.autor = firmante;
+                }
+                // Fecha estrictamente creciente: el canal rechaza timestamps
+                // iguales o menores, así que empujamos al menos un segundo sobre
+                // la última acta cuando el reloj no avanzó.
+                let ultima_ts = model.actas.raices.last().map(|r| r.timestamp).unwrap_or(0);
+                let ts = ahora().max(ultima_ts + 1);
+                let hash = format::hash(texto.as_bytes());
+                let raiz = agora_channel::firmar_raiz(&kp, &model.actas.nombre, &hash, ts);
+                model.actas.raices.push(raiz);
+                model.actas_textos.push(texto);
+                model.acta_input.clear();
+                model.acta_selected = Some(model.actas.raices.len() - 1);
+                model.guardar_actas();
+                model.actas_status = "✓ acta asentada y firmada".into();
+            }
+            Msg::SeleccionarActa(idx) => {
+                if idx < model.actas.raices.len() {
+                    model.acta_selected = Some(idx);
+                }
+            }
+            Msg::VerificarActas => {
+                if model.actas.raices.is_empty() {
+                    model.actas_status = "el libro está vacío — no hay nada que verificar".into();
+                    return model;
+                }
+                // Dos capas: el canal entero (firmas + monotonía de fechas) y que
+                // ningún texto en disco haya sido alterado por fuera de la firma.
+                let canal_ok = agora_channel::verificar_canal(&model.actas).is_ok();
+                let alterada = (0..model.actas.raices.len()).find(|&i| !model.acta_verificada(i));
+                model.actas_status = match (canal_ok, alterada) {
+                    (true, None) => format!(
+                        "✅ libro íntegro — {} actas firmadas, en orden y sin alterar",
+                        model.actas.raices.len()
+                    ),
+                    (false, _) => "❌ el historial firmado no valida (firma o fecha rota)".into(),
+                    (true, Some(i)) => format!("❌ el acta #{} fue alterada en disco", i + 1),
+                };
+            }
+            Msg::ExportarActas => {
+                let payload = (&model.actas, &model.actas_textos);
+                match postcard::to_allocvec(&payload) {
+                    Ok(bytes) => {
+                        let hex = bytes_to_hex(&bytes);
+                        model.set_status(
+                            StatusLevel::Info,
+                            format!(
+                                "libro de actas postcard ({} actas, {} bytes): {hex}",
+                                model.actas.raices.len(),
+                                bytes.len()
+                            ),
+                        );
+                    }
+                    Err(e) => model
+                        .set_status(StatusLevel::Error, format!("no pude serializar el libro: {e}")),
+                }
+            }
+
+            // ---- Menús ---------------------------------------------------
+            Msg::MenuOpen(idx) => {
+                model.menu_open = idx;
+                model.edit_menu = None;
+                model.menu_active = usize::MAX;
+                if idx.is_some() {
+                    model.menu_anim =
+                        Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
+                    animate(handle, motion::FAST, || Msg::MenuTick);
+                }
+            }
+            Msg::CloseMenus => {
+                model.menu_open = None;
+                model.edit_menu = None;
+                model.menu_active = usize::MAX;
+                model.edit_active = usize::MAX;
+            }
+            Msg::MenuCommand(cmd) => {
+                model.menu_open = None;
+                if let Some(real) = menu_command_to_msg(&model, &cmd) {
+                    return AgoraApp::update(model, real, handle);
+                }
+            }
+            Msg::MenuNav(dir) => {
+                if let Some(mi) = model.menu_open {
+                    let menu = app_menu(&model);
+                    model.menu_active = menubar_nav(&menu, mi, model.menu_active, dir);
+                }
+            }
+            Msg::MenuActivate => {
+                if let Some(mi) = model.menu_open {
+                    let menu = app_menu(&model);
+                    if let Some(cmd) = menubar_command_at(&menu, mi, model.menu_active) {
+                        model.menu_open = None;
+                        if let Some(real) = menu_command_to_msg(&model, &cmd) {
+                            return AgoraApp::update(model, real, handle);
+                        }
+                    }
+                }
+            }
+            Msg::MenuTick => {}
+            Msg::EditNav(dir) => {
+                let editor = model.focused_input_ref().editor();
+                let masked = model.focused_input_ref().is_masked();
+                let flags = EditFlags::from_editor(editor, masked);
+                model.edit_active = editmenu::edit_menu_step(flags, model.edit_active, dir);
+            }
+            Msg::EditActivate => {
+                let editor = model.focused_input_ref().editor();
+                let masked = model.focused_input_ref().is_masked();
+                let flags = EditFlags::from_editor(editor, masked);
+                if let Some(a) = editmenu::edit_menu_action_at(flags, model.edit_active) {
+                    model.edit_menu = None;
+                    model.apply_edit_menu_action(a);
+                }
+            }
+            Msg::EditMenuOpen(x, y) => {
+                model.edit_menu = Some((x, y));
+                model.menu_open = None;
+                model.edit_active = usize::MAX;
+                model.edit_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
+                animate(handle, motion::FAST, || Msg::MenuTick);
+            }
+            Msg::EditMenuAction(action) => {
+                model.edit_menu = None;
+                model.apply_edit_menu_action(action);
+            }
+        }
+        model
+    }
+
+    fn on_key(model: &Model, event: &KeyEvent) -> Option<Msg> {
+        if event.state != KeyState::Pressed {
+            return None;
+        }
+        // Menú principal abierto: las flechas navegan. ←/→ cambian de menú
+        // raíz (con wrap), ↑/↓ mueven la fila activa, Enter ejecuta, Esc
+        // cierra. Tiene prioridad sobre todo lo demás.
+        if let Some(mi) = model.menu_open {
+            let n = app_menu(model).menus.len().max(1);
+            match &event.key {
+                Key::Named(NamedKey::Escape) => return Some(Msg::CloseMenus),
+                Key::Named(NamedKey::ArrowLeft) => {
+                    return Some(Msg::MenuOpen(Some((mi + n - 1) % n)));
+                }
+                Key::Named(NamedKey::ArrowRight) => {
+                    return Some(Msg::MenuOpen(Some((mi + 1) % n)));
+                }
+                Key::Named(NamedKey::ArrowDown) => return Some(Msg::MenuNav(1)),
+                Key::Named(NamedKey::ArrowUp) => return Some(Msg::MenuNav(-1)),
+                Key::Named(NamedKey::Enter) => return Some(Msg::MenuActivate),
+                _ => return None,
+            }
+        }
+        // Menú de edición abierto: ↑/↓ navegan, Enter ejecuta, Esc cierra.
+        if model.edit_menu.is_some() {
+            match &event.key {
+                Key::Named(NamedKey::Escape) => return Some(Msg::CloseMenus),
+                Key::Named(NamedKey::ArrowDown) => return Some(Msg::EditNav(1)),
+                Key::Named(NamedKey::ArrowUp) => return Some(Msg::EditNav(-1)),
+                Key::Named(NamedKey::Enter) => return Some(Msg::EditActivate),
+                _ => return None,
+            }
+        }
+        // Unlock: todas las teclas al input; Enter desbloquea.
+        if matches!(model.screen, Screen::Unlock { .. }) {
+            if let Key::Named(NamedKey::Enter) = &event.key {
+                return Some(Msg::UnlockSubmit);
+            }
+            return Some(Msg::UnlockKey(event.clone()));
+        }
+        // Buscador del sidebar unificado con foco: las teclas van al filtro del
+        // índice (no a los campos del compositor). Esc/Enter lo sueltan; Backspace
+        // borra; el resto inserta el texto tipeado.
+        if model.sidebar.search_focused {
+            use llimphi_widget_rag_sidebar::RagSidebarMsg;
+            match &event.key {
+                Key::Named(NamedKey::Escape) | Key::Named(NamedKey::Enter) => {
+                    return Some(Msg::Sidebar(RagSidebarMsg::SearchFocus(false)));
+                }
+                Key::Named(NamedKey::Backspace) => {
+                    let mut s = model.sidebar.search.clone();
+                    s.pop();
+                    return Some(Msg::Sidebar(RagSidebarMsg::SearchSet(s)));
+                }
+                _ => {
+                    if let Some(t) = &event.text {
+                        if !t.is_empty() && !t.chars().any(char::is_control) {
+                            let mut s = model.sidebar.search.clone();
+                            s.push_str(t);
+                            return Some(Msg::Sidebar(RagSidebarMsg::SearchSet(s)));
+                        }
+                    }
+                    return None;
+                }
+            }
+        }
+        // Tab cicla los campos del compositor; desde cualquier otro foco,
+        // lleva al predicado.
+        if let Key::Named(NamedKey::Tab) = &event.key {
+            let destino = match model.focused_input {
+                FocusedInput::Compose(ComposeField::Predicate) => {
+                    FocusedInput::Compose(ComposeField::Value)
+                }
+                _ => FocusedInput::Compose(ComposeField::Predicate),
+            };
+            return Some(Msg::Foco(destino));
+        }
+        // Enter dispara la acción primaria del input focado.
+        if let Key::Named(NamedKey::Enter) = &event.key {
+            return Some(match model.focused_input {
+                FocusedInput::Compose(_) => Msg::Atestar,
+                FocusedInput::MultiMessage => Msg::FirmarMulti,
+                FocusedInput::ReleaseHash => Msg::FirmarRelease,
+                FocusedInput::ReleasePaste => Msg::VerificarRelease,
+                FocusedInput::VerificarPaste => Msg::Qillqa(crate::modulos::qillqa::Msg::Verificar(
+                    crate::modulos::qillqa::verificar::Msg::Pegar(
+                        model.verificar_paste.text().to_string(),
+                    ),
+                )),
+                // Los tres campos del alta de la mesa disparan el mismo alta: se
+                // asienta desde cualquiera, sin tener que ir al botón.
+                FocusedInput::MesaTipo
+                | FocusedInput::MesaPartes
+                | FocusedInput::MesaRefs => model.msg_asentar_mesa(),
+                FocusedInput::CapBytecode => Msg::FirmarCapacidad,
+                FocusedInput::CapPaste => Msg::VerificarCapacidad,
+                // El nombre del perfil no tiene acción de Enter (edición viva).
+                FocusedInput::PerfilNombre => Msg::MenuTick,
+                FocusedInput::Petname => Msg::PonerNombre,
+                FocusedInput::ActaTexto => Msg::AsentarActa,
+                FocusedInput::NuevaIdentidadNombre => Msg::NuevaIdentidad,
+            });
+        }
+        Some(Msg::EditFocused(event.clone()))
+    }
+
+    fn view(model: &Model) -> View<Msg> {
+        let theme = tema(model);
+        if matches!(model.screen, Screen::Unlock { .. }) {
+            return unlock_view(&model.screen, &theme);
+        }
+
+        let shell_base = chrome::shell_view(model, &theme);
+        // El multiselect de disposición del sidebar de índice, como overlay raíz
+        // (no clipeado por el panel) cuando el control ⚙ está abierto.
+        let shell = match chrome::multiselect_overlay(model, &theme) {
+            None => shell_base,
+            Some(overlay) => View::new(Style {
+                position: llimphi_ui::llimphi_layout::taffy::style::Position::Relative,
+                size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+                flex_grow: 1.0,
+                ..Default::default()
+            })
+            .children(vec![shell_base, overlay]),
+        };
+
+        let body = match &model.status {
+            None => shell,
+            Some(banner) => status_layout(&theme, shell, banner),
+        };
+
+        let menu = app_menu(model);
+        let menubar = menubar_view(&menubar_spec(&menu, model, &theme));
+
+        // Right-click en la raíz (origen 0,0 → coords locales == coords de
+        // ventana) abre el menú de edición sobre el input focuseado.
+        View::new(Style {
+            flex_direction: FlexDirection::Column,
+            size: Size {
+                width: percent(1.0_f32),
+                height: percent(1.0_f32),
+            },
+            ..Default::default()
+        })
+        .fill(theme.bg_app)
+        .on_right_click_at(|x, y, _w, _h| Some(Msg::EditMenuOpen(x, y)))
+        .children(vec![menubar, ui::grow(body)])
+    }
+
+    fn view_overlay(model: &Model) -> Option<View<Msg>> {
+        if matches!(model.screen, Screen::Unlock { .. }) {
+            return None;
+        }
+        let theme = tema(model);
+        // El menú de edición tiene prioridad sobre el dropdown principal.
+        if let Some((x, y)) = model.edit_menu {
+            let editor = model.focused_input_ref().editor();
+            let masked = model.focused_input_ref().is_masked();
+            let flags = EditFlags::from_editor(editor, masked);
+            let (w, h) = AgoraApp::initial_size();
+            let mut spec = editmenu::edit_context_menu(
+                (x, y),
+                (w as f32, h as f32),
+                &theme,
+                flags,
+                Msg::EditMenuAction,
+                Msg::CloseMenus,
+            );
+            spec.active = model.edit_active;
+            return Some(context_menu_view_ex(
+                spec,
+                ContextMenuExtras { appear: model.edit_anim.value(), ..Default::default() },
+            ));
+        }
+        let menu = app_menu(model);
+        menubar_overlay_animated(
+            &menubar_spec(&menu, model, &theme),
+            model.menu_active,
+            model.menu_anim.value(),
+        )
+    }
+}
+
+/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
+fn menubar_spec<'a>(
+    menu: &'a app_bus::AppMenu,
+    model: &Model,
+    theme: &'a Theme,
+) -> MenuBarSpec<'a, Msg> {
+    let (w, h) = AgoraApp::initial_size();
+    MenuBarSpec {
+        menu,
+        open: model.menu_open,
+        theme,
+        viewport: (w as f32, h as f32),
+        height: MENU_H,
+        on_open: std::sync::Arc::new(Msg::MenuOpen),
+        on_command: std::sync::Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+    }
+}
+
+/// Construye el menú principal reflejando el estado real: el submenú
+/// Editar se grisa según el input focuseado; Archivo/Ver exponen firmar,
+/// verificar, exportar, limpiar y los toggles de tile. Sólo comandos que
+/// mapean a `Msg` reales (ver [`menu_command_to_msg`]).
+fn app_menu(model: &Model) -> app_bus::AppMenu {
+    use app_bus::{AppMenu, Menu, MenuItem};
+
+    let editor = model.focused_input_ref().editor();
+    let masked = model.focused_input_ref().is_masked();
+    let has_sel = editor.has_selection();
+    let can_undo = editor.can_undo();
+    let can_redo = editor.can_redo();
+    let has_text = !editor.is_empty();
+    let can_copy = has_sel && !masked;
+
+    let mut undo = MenuItem::new("Deshacer", "edit.undo").shortcut("Ctrl+Z");
+    if !can_undo {
+        undo = undo.disabled();
+    }
+    let mut redo = MenuItem::new("Rehacer", "edit.redo").shortcut("Ctrl+Y");
+    if !can_redo {
+        redo = redo.disabled();
+    }
+    let mut cut = MenuItem::new("Cortar", "edit.cut").shortcut("Ctrl+X").separated();
+    let mut copy = MenuItem::new("Copiar", "edit.copy").shortcut("Ctrl+C");
+    if !can_copy {
+        cut = cut.disabled();
+        copy = copy.disabled();
+    }
+    let paste = MenuItem::new("Pegar", "edit.paste").shortcut("Ctrl+V");
+    let mut sel_all = MenuItem::new("Seleccionar todo", "edit.selectall")
+        .shortcut("Ctrl+A")
+        .separated();
+    if !has_text {
+        sel_all = sel_all.disabled();
+    }
+
+    AppMenu::new()
+        .menu(
+            Menu::new("Archivo")
+                .item(MenuItem::new("Nueva identidad", "file.nueva_identidad"))
+                .item(MenuItem::new("Firmar release", "file.firmar_release").separated())
+                .item(MenuItem::new("Verificar release", "file.verificar_release"))
+                .item(MenuItem::new("Exportar release", "file.exportar_release"))
+                .item(MenuItem::new("Firmar capacidad", "file.firmar_capacidad").separated())
+                .item(MenuItem::new("Verificar capacidad", "file.verificar_capacidad"))
+                .item(MenuItem::new("Exportar capacidad", "file.exportar_capacidad"))
+                .item(MenuItem::new("Firmar decisión con todas mis elegidas", "file.firmar_multi").separated())
+                .item(MenuItem::new("Exportar decisión", "file.exportar_multi"))
+                .item(MenuItem::new("Asentar acta", "file.asentar_acta").separated())
+                .item(MenuItem::new("Exportar libro de actas", "file.exportar_actas")),
+        )
+        .menu(
+            Menu::new("Editar")
+                .item(undo)
+                .item(redo)
+                .item(cut)
+                .item(copy)
+                .item(paste)
+                .item(sel_all)
+                .item(MenuItem::new("Atestar", "edit.atestar").separated()),
+        )
+        .menu(
+            Menu::new("Ver")
+                .item(MenuItem::new("Limpiar release", "view.limpiar_release"))
+                .item(MenuItem::new("Limpiar capacidad", "view.limpiar_capacidad"))
+                .item(MenuItem::new("Empezar la decisión de nuevo", "view.limpiar_multi").separated())
+                .item(MenuItem::new("Verificar libro de actas", "view.verificar_actas"))
+                .item(MenuItem::new("Cerrar aviso de estado", "view.descartar_status")),
+        )
+        .menu(
+            Menu::new("Ayuda")
+                .item(MenuItem::new("Recargar grafo desde disco", "help.recargar")),
+        )
+}
+
+/// Traduce el `command` del menú principal al `Msg` real de la app. `None`
+/// si el comando no aplica (p. ej. Atestar sin sujeto se resuelve en el
+/// propio `update`). Mantener en sync con [`app_menu`].
+fn menu_command_to_msg(_model: &Model, command: &str) -> Option<Msg> {
+    Some(match command {
+        "file.nueva_identidad" => Msg::NuevaIdentidad,
+        "file.firmar_release" => Msg::FirmarRelease,
+        "file.verificar_release" => Msg::VerificarRelease,
+        "file.exportar_release" => Msg::ExportarRelease,
+        "file.firmar_capacidad" => Msg::FirmarCapacidad,
+        "file.verificar_capacidad" => Msg::VerificarCapacidad,
+        "file.exportar_capacidad" => Msg::ExportarCapacidad,
+        "file.firmar_multi" => Msg::FirmarMulti,
+        "file.exportar_multi" => Msg::ExportarMulti,
+        "file.asentar_acta" => Msg::AsentarActa,
+        "file.exportar_actas" => Msg::ExportarActas,
+        "view.verificar_actas" => Msg::VerificarActas,
+        "edit.undo" => Msg::EditMenuAction(EditAction::Undo),
+        "edit.redo" => Msg::EditMenuAction(EditAction::Redo),
+        "edit.cut" => Msg::EditMenuAction(EditAction::Cut),
+        "edit.copy" => Msg::EditMenuAction(EditAction::Copy),
+        "edit.paste" => Msg::EditMenuAction(EditAction::Paste),
+        "edit.selectall" => Msg::EditMenuAction(EditAction::SelectAll),
+        "edit.atestar" => Msg::Atestar,
+        "view.limpiar_release" => Msg::LimpiarRelease,
+        "view.limpiar_capacidad" => Msg::LimpiarCapacidad,
+        "view.limpiar_multi" => Msg::LimpiarMulti,
+        "view.descartar_status" => Msg::DescartarStatus,
+        "help.recargar" => Msg::ArchivoCambio,
+        _ => return None,
+    })
+}
+
+/// El tema del perfil activo (oscuro/claro).
+fn tema(model: &Model) -> Theme {
+    if model.perfil().tema_oscuro {
+        Theme::dark()
+    } else {
+        Theme::light()
+    }
+}
+
+/// Segundos UNIX actuales (0 si el reloj está antes de la época).
+fn ahora() -> u64 {
+    std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .map(|d| d.as_secs())
+        .unwrap_or(0)
+}
+
+/// Recarga `graph.json` tras un cambio externo (típicamente `agora-cli`).
+/// `agora-store::save` es atómico (tmp+rename); si falla, deja el grafo en
+/// memoria intacto y reporta por el banner.
+fn recargar_grafo(model: &mut Model) {
+    match agora_store::load(&model.store_path) {
+        Ok(g) => {
+            if let Some(id) = model.focused_subject {
+                if g.identity(id).is_none() {
+                    model.focused_subject = None;
+                }
+            }
+            if let Some(idx) = model.selected_attestation {
+                if idx >= g.attestations().len() {
+                    model.selected_attestation = None;
+                }
+            }
+            let antes_atts = model.graph.attestation_count();
+            let antes_idents = model.graph.identity_count();
+            model.graph = g;
+            let delta_atts = model.graph.attestation_count() as isize - antes_atts as isize;
+            let delta_idents = model.graph.identity_count() as isize - antes_idents as isize;
+            if delta_atts != 0 || delta_idents != 0 {
+                model.set_status(
+                    StatusLevel::Info,
+                    format!(
+                        "grafo recargado desde disco · {delta_atts:+} atestaciones · {delta_idents:+} identidades"
+                    ),
+                );
+            }
+        }
+        Err(e) => model.set_status(
+            StatusLevel::Error,
+            format!("no pude recargar graph.json ({e}); sigo con el grafo en memoria"),
+        ),
+    }
+}
+
+/// Lanza un watcher de filesystem sobre `dir` y dispatcha `Msg::ArchivoCambio`
+/// en cada evento OK. El callback es coarse — el `update` decide si el cambio
+/// es relevante reintentando el `load`. El `Watcher` se "leakea" con
+/// `mem::forget`: vive lo que vive el proceso y es `!Send` en algunos backends.
+fn arranca_watcher(handle: Handle<Msg>, dir: PathBuf) {
+    use notify::{RecursiveMode, Watcher};
+    let watcher_res = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
+        if res.is_ok() {
+            handle.dispatch(Msg::ArchivoCambio);
+        }
+    });
+    let mut watcher = match watcher_res {
+        Ok(w) => w,
+        Err(e) => {
+            eprintln!("agora-app: no pude arrancar el watcher ({e}); cambios externos al grafo no se reflejarán hasta reiniciar.");
+            return;
+        }
+    };
+    if let Err(e) = watcher.watch(&dir, RecursiveMode::NonRecursive) {
+        eprintln!("agora-app: no pude vigilar {} ({e}); cambios externos al grafo no se reflejarán.", dir.display());
+        return;
+    }
+    std::mem::forget(watcher);
+}
+
+fn main() {
+    llimphi_ui::run::<AgoraApp>();
+}
diff --git a/03_ukupacha/agora/agora-app/src/model.rs b/03_ukupacha/agora/agora-app/src/model.rs
new file mode 100644
index 0000000..8793cb5
--- /dev/null
+++ b/03_ukupacha/agora/agora-app/src/model.rs
@@ -0,0 +1,837 @@
+//! Estado de la app y el catálogo de mensajes.
+//!
+//! `agora-app` es una sola ventana Llimphi con tiles draggables sobre el
+//! mismo `TrustGraph`. Hay dos familias de tiles:
+//!
+//! - **Sociales** (sustrato): identidades · compositor · atestaciones ·
+//!   política · multifirma. Construyen y evalúan la web-of-trust.
+//! - **Plano de control de wawa** (el norte): release · capacidad. Firman
+//!   y verifican los sobres Ed25519 que el kernel honra — `ManifiestoFirmado`
+//!   (quién empuja una imagen) y `ConcesionCapacidad` (qué bytecode toca el
+//!   hardware). Reusan `agora-channel`, el mismo código que valida el kernel.
+
+use std::collections::{BTreeSet, HashMap};
+use std::path::PathBuf;
+
+use agora_core::{IdentityId, IdentityKind, Keypair, MultiSignature};
+use agora_graph::{TrustGraph, TrustPolicy};
+use agora_petnames::Libreta;
+use agora_keystore::Keystore;
+use format::{Canal, ConcesionCapacidad, ManifiestoFirmado, Permisos, VERSION_CANAL};
+use llimphi_motion::Tween;
+use llimphi_ui::KeyEvent;
+use llimphi_widget_text_input::{TextInputEvent, TextInputState};
+
+use crate::modulos::{self, ModuloId};
+
+// =============================================================================
+//  Tiles
+// =============================================================================
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Tile {
+    Identidades,
+    Compositor,
+    Atestaciones,
+    Politica,
+    /// Compositor de [`MultiSignature`]: elige firmantes "míos", escribe
+    /// el mensaje (típicamente una raíz canónica), elige umbral M, firma
+    /// y exporta postcard en hex.
+    Multifirma,
+    /// **Plano de control wawa.** Firma un `ManifiestoFirmado` (hash de una
+    /// imagen del sistema) con el firmante activo y verifica sobres pegados.
+    /// Es el sobre que `apps/mudanza` empuja al kernel.
+    Release,
+    /// **Plano de control wawa (§14.1.3).** Concede capacidades por
+    /// `(hash_bytecode, permisos)`: firma una `ConcesionCapacidad`. El kernel
+    /// honra la intersección contra el `AGORA_AUTH_RING`.
+    Capacidad,
+}
+
+/// Orden inicial de tiles en la grilla draggable.
+pub(crate) const TILES_INICIALES: [Tile; 7] = [
+    Tile::Identidades,
+    Tile::Compositor,
+    Tile::Atestaciones,
+    Tile::Politica,
+    Tile::Multifirma,
+    Tile::Release,
+    Tile::Capacidad,
+];
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(crate) enum ComposeField {
+    Predicate,
+    Value,
+}
+
+/// Qué input de texto recibe las teclas en la pantalla principal. Un solo
+/// `on_key` rutea cada evento al `TextInputState` correcto vía
+/// [`Model::campo_evento`].
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(crate) enum FocusedInput {
+    Compose(ComposeField),
+    MultiMessage,
+    /// Hash hex del manifiesto a firmar (tile Release).
+    ReleaseHash,
+    /// Postcard hex de un `ManifiestoFirmado` a verificar (tile Release).
+    ReleasePaste,
+    /// Hash hex del bytecode a conceder (tile Capacidad).
+    CapBytecode,
+    /// Postcard hex de una `ConcesionCapacidad` a verificar (tile Capacidad).
+    CapPaste,
+    /// Nombre del perfil activo (sidebar derecho de perfil).
+    PerfilNombre,
+    /// Texto del acta nueva a asentar en el libro (módulo Actas).
+    ActaTexto,
+    /// La constancia ajena que trae el ciudadano, más la autoridad esperada
+    /// (`<constancia-hex> <autoridad-hex>`) — sala Verificar, la cara pública.
+    VerificarPaste,
+    /// Tipo del acto a asentar en la mesa de partes ("ingreso", "informe"…).
+    /// Los tres campos del alta viven aquí y no en la sala: los inputs son del
+    /// shell (`Msg` global), y la sala —genérica sobre `H`— recibe su texto ya
+    /// tipeado en `mesa::Msg::Asentar`. Mismo reparto que `VerificarPaste`.
+    MesaTipo,
+    /// Partes / descripción del acto (su texto va al CAS del registro).
+    MesaPartes,
+    /// Enlaces del expediente del acto: seqs separados por coma ("1,3").
+    MesaRefs,
+    /// Nombre memorable (petname) para la identidad enfocada — el campo "¿cómo la
+    /// llamas?" del módulo Identidades. Lo consume [`crate::model::Model`] al
+    /// nombrar en la [`Libreta`].
+    Petname,
+    /// Nombre a estampar en una identidad **nueva** al crearla (onboarding). Sin
+    /// esto la identidad nace como "yo N" — jerga que el SDD §6 prohíbe.
+    NuevaIdentidadNombre,
+}
+
+/// Un **perfil** del shell: un envoltorio que ata una identidad de agora con
+/// las preferencias y los datos que ese usuario ve. Cambiar de perfil cambia el
+/// firmante activo (la identidad que firma todo) y la config que pinta el
+/// sidebar derecho.
+pub(crate) struct Perfil {
+    /// Nombre legible del perfil (editable en el sidebar derecho).
+    pub nombre: String,
+    /// Identidad de agora que firma bajo este perfil (`None` = sin vincular).
+    pub identidad: Option<IdentityId>,
+    /// Preferencia de tema (oscuro/claro) del perfil.
+    pub tema_oscuro: bool,
+    /// Etiquetas de los registros qillqa que este perfil ve (MVP: nombres).
+    pub registros: Vec<String>,
+}
+
+impl Perfil {
+    /// Un perfil nuevo sin identidad vinculada.
+    pub fn nuevo(nombre: impl Into<String>) -> Self {
+        Self {
+            nombre: nombre.into(),
+            identidad: None,
+            tema_oscuro: true,
+            registros: vec!["mesa-de-partes".into(), "resoluciones".into()],
+        }
+    }
+}
+
+/// Severidad de un mensaje de estado de servicio (persistencia, red,
+/// keystore). Determina el color del banner.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(crate) enum StatusLevel {
+    Info,
+    Error,
+}
+
+/// Banner visible al pie de la ventana cuando hay un error o info que vale
+/// la pena destacar (típicamente I/O, red, o un export que conviene poder
+/// copiar). `None` lo oculta.
+pub(crate) struct StatusBanner {
+    pub level: StatusLevel,
+    pub text: String,
+}
+
+/// Pantalla activa. `Unlock` pide la passphrase; `Main` muestra los tiles.
+pub(crate) enum Screen {
+    Unlock {
+        input: TextInputState,
+        /// Vacío hasta el primer intento; al fallar, "passphrase incorrecta".
+        status: String,
+    },
+    Main,
+}
+
+pub(crate) struct Model {
+    pub graph: TrustGraph,
+    pub keystore: Keystore,
+    /// Seeds en RAM de las identidades "mías" (con archivo en el keystore).
+    /// Se desbloquean al arrancar y viven aquí mientras corre el proceso; no
+    /// persisten al salir — siguen cifradas en el keystore.
+    pub seeds: HashMap<IdentityId, [u8; 32]>,
+    pub passphrase: String,
+    pub store_path: PathBuf,
+
+    /// **Libreta de petnames** — mi mapeo privado y local de nombres memorables a
+    /// identidades (el nombre que YO le pongo a cada quien). No es autoritativa ni
+    /// global; vive en `libreta.postcard` junto al grafo. Es lo que deja que la UI
+    /// muestre "Yumaira" en vez de un hash. Ver `agora-petnames`.
+    pub libreta: Libreta,
+    /// Ruta de la libreta en disco (`<data_dir>/libreta.postcard`).
+    pub libreta_path: PathBuf,
+    /// Editor del petname para la identidad enfocada (módulo Identidades).
+    pub petname_input: TextInputState,
+    /// Editor del nombre para una identidad nueva (onboarding / "sumar a
+    /// alguien"). Se estampa como `display_name` al crearla, en vez de "yo N".
+    pub nueva_identidad_nombre: TextInputState,
+
+    pub screen: Screen,
+    pub tiles_order: Vec<Tile>,
+
+    /// Módulo (categoría) activo — su diente está marcado en el rail, su índice
+    /// en el sidebar izquierdo y su contenido en el canvas.
+    pub active_modulo: ModuloId,
+
+    /// Estado del **sidebar unificado** (widget `rag-sidebar`): ejes de disposición
+    /// (rail adentro/afuera, flota/fijo, autoesconde), buscador jerárquico y control.
+    /// El diente abierto lo fija el módulo activo ([`Model::active_modulo`]); el resto
+    /// lo reduce `RagSidebarState::update` vía [`Msg::Sidebar`].
+    pub sidebar: llimphi_widget_rag_sidebar::RagSidebarState,
+
+    /// **Modo avanzado (Sistema wawa).** `false` = plaza para el pueblo: los
+    /// dientes del plano de control (release/capacidad) quedan ocultos del rail.
+    /// `true` = los muestra. Se alterna desde el sidebar de perfil (SDD §4.4).
+    pub modo_experto: bool,
+
+    /// Perfiles del shell y cuál está activo. El firmante activo se deriva del
+    /// perfil vigente (ver [`Model::sincronizar_perfil`]).
+    pub perfiles: Vec<Perfil>,
+    pub perfil_activo: usize,
+    /// Editor del nombre del perfil activo (sidebar derecho).
+    pub perfil_nombre: TextInputState,
+
+    /// Estado de los módulos qillqa adoptados (registro, mesa, veeduría,
+    /// compras, autoridad).
+    pub qillqa: modulos::qillqa::State,
+
+    /// Identidad seleccionada como sujeto (objetivo del próximo claim).
+    pub focused_subject: Option<IdentityId>,
+    /// Identidad firmante activa (debe estar en `seeds`). También es la que
+    /// firma releases y concesiones.
+    pub active_signer: Option<IdentityId>,
+    /// Atestación seleccionada en el tile de atestaciones, por índice.
+    pub selected_attestation: Option<usize>,
+
+    pub compose_predicate: TextInputState,
+    pub compose_value: TextInputState,
+    /// Input que recibe las teclas (ver [`FocusedInput`]).
+    pub focused_input: FocusedInput,
+    /// Último mensaje al pie del compositor (éxito, error, hint).
+    pub compose_status: String,
+
+    pub policy: TrustPolicy,
+
+    // ---- Multifirma --------------------------------------------------------
+    /// Mensaje sobre el que se compone la multifirma (típicamente una raíz).
+    pub multi_message: TextInputState,
+    /// Identidades "mías" elegidas como firmantes. Sólo ids en `seeds`.
+    pub multi_selected: BTreeSet<IdentityId>,
+    /// Umbral M, clampado a `[1, max(1, N)]` con N = `multi_selected.len()`.
+    pub multi_threshold: usize,
+    /// Última multifirma producida. Se descarta al cambiar selección o mensaje.
+    pub multi_current: Option<MultiSignature>,
+
+    // ---- Release (plano de control wawa) -----------------------------------
+    /// Hash hex (64 chars) del manifiesto a firmar.
+    pub release_hash: TextInputState,
+    /// Postcard hex de un `ManifiestoFirmado` ajeno a verificar.
+    pub release_paste: TextInputState,
+    /// Lo que el ciudadano pega en la sala Verificar: su constancia y la
+    /// autoridad que espera. La sala lo parsea con `qillqa_core::banco`.
+    pub verificar_paste: TextInputState,
+
+    // ---- Mesa de partes (el form de alta) ----------------------------------
+    /// Tipo del acto a asentar. Los envía `mesa::Msg::Asentar` al hacer click en
+    /// el botón del índice o al apretar Enter sobre cualquiera de los tres.
+    pub mesa_tipo: TextInputState,
+    /// Partes / descripción del acto a asentar.
+    pub mesa_partes: TextInputState,
+    /// Enlaces del expediente ("1,3") del acto a asentar.
+    pub mesa_refs: TextInputState,
+    /// Último release firmado por el firmante activo. Se descarta al editar
+    /// el hash o cambiar de firmante.
+    pub release_current: Option<ManifiestoFirmado>,
+    /// Feedback in-situ del tile Release (verificación, errores de parseo).
+    pub release_status: String,
+
+    // ---- Capacidad (§14.1.3) -----------------------------------------------
+    /// Hash hex (64 chars) del bytecode al que aplica la concesión.
+    pub cap_bytecode: TextInputState,
+    /// Bitfield de permisos a conceder (toggles de los `PERMISO_*`).
+    pub cap_permisos: Permisos,
+    /// Postcard hex de una `ConcesionCapacidad` ajena a verificar.
+    pub cap_paste: TextInputState,
+    /// Última concesión firmada. Se descarta al editar bytecode/permisos.
+    pub cap_current: Option<ConcesionCapacidad>,
+    /// Feedback in-situ del tile Capacidad.
+    pub cap_status: String,
+
+    // ---- Actas (libro firmado e inmutable) ---------------------------------
+    /// El **libro de actas**: un [`Canal`] append-only firmado por su autor.
+    /// Cada raíz es el hash BLAKE3 de un acta; el texto legible vive en paralelo
+    /// en [`Self::actas_textos`]. `autor` en ceros hasta la primera acta —ahí se
+    /// liga a la identidad que la asienta y el libro pasa a ser "de esa persona".
+    pub actas: Canal,
+    /// Texto humano de cada acta, paralelo a `actas.raices`. El `Canal` sólo
+    /// guarda hashes; esto es lo que se muestra y lo que se re-hashea para
+    /// detectar que alguien alteró el archivo por fuera de la firma.
+    pub actas_textos: Vec<String>,
+    /// Ruta del libro en disco (`<data_dir>/actas.postcard`).
+    pub actas_path: PathBuf,
+    /// Editor del acta nueva a asentar.
+    pub acta_input: TextInputState,
+    /// Acta seleccionada en el índice, por posición.
+    pub acta_selected: Option<usize>,
+    /// Feedback in-situ del módulo Actas.
+    pub actas_status: String,
+
+    /// Banner de estado de servicio al pie. Lo pinta el `view`.
+    pub status: Option<StatusBanner>,
+
+    /// Barra de menú principal: índice del menú raíz abierto (`None` cerrado).
+    pub menu_open: Option<usize>,
+    /// Menú de edición contextual: ancla `(x, y)` en ventana (`None` cerrado).
+    /// Opera sobre el `TextInputState` focuseado (`focused_input`).
+    pub edit_menu: Option<(f32, f32)>,
+    /// Portapapeles del sistema, compartido por copiar/cortar/pegar.
+    pub clipboard: llimphi_clipboard::SystemClipboard,
+    /// Fila resaltada por teclado en el menú principal (`usize::MAX` = ninguna).
+    pub menu_active: usize,
+    /// Animación de aparición/swap del dropdown principal.
+    pub menu_anim: Tween<f32>,
+    /// Fila resaltada por teclado en el menú de edición (`usize::MAX` = ninguna).
+    pub edit_active: usize,
+    /// Animación de aparición del menú de edición.
+    pub edit_anim: Tween<f32>,
+}
+
+impl Model {
+    /// El mensaje de **alta** de la mesa de partes con el texto que hay HOY en
+    /// los tres campos. Lo usan el botón del índice (que lo hornea al pintar) y
+    /// el Enter sobre cualquiera de los campos — las dos rutas que asientan.
+    ///
+    /// Vive aquí y no en la sala porque los `TextInputState` son del shell: la
+    /// sala `mesa` es genérica sobre `H` y sólo recibe `String`s.
+    pub fn msg_asentar_mesa(&self) -> Msg {
+        Msg::Qillqa(modulos::qillqa::Msg::Mesa(
+            modulos::qillqa::mesa::Msg::Asentar {
+                tipo: self.mesa_tipo.text(),
+                partes: self.mesa_partes.text(),
+                refs: self.mesa_refs.text(),
+            },
+        ))
+    }
+
+    /// Limpia los tres campos del alta de la mesa (tras un acto asentado).
+    pub fn limpiar_form_mesa(&mut self) {
+        self.mesa_tipo = TextInputState::new();
+        self.mesa_partes = TextInputState::new();
+        self.mesa_refs = TextInputState::new();
+    }
+
+    pub fn set_status(&mut self, level: StatusLevel, text: impl Into<String>) {
+        self.status = Some(StatusBanner {
+            level,
+            text: text.into(),
+        });
+    }
+
+    pub fn save_graph(&mut self) {
+        if let Err(e) = agora_store::save(&self.store_path, &self.graph) {
+            self.set_status(StatusLevel::Error, format!("no pude persistir el grafo: {e}"));
+        }
+    }
+
+    pub fn is_mine(&self, id: IdentityId) -> bool {
+        self.seeds.contains_key(&id)
+    }
+
+    /// **Cuánto vale para MÍ el testimonio de quien firma con `clave`**, en
+    /// `0.0..=1.0`. El puente entre la red de confianza de agora y los dominios
+    /// que pesan testimonio (hoy: la credibilidad de `qillqa-veeduria`).
+    ///
+    /// Cómo se calcula: cuántas personas de MI grafo dan fe de esa identidad
+    /// (`corroboration_of_at`, que excluye atestadores revocados), pasado por la
+    /// curva de MI política (`TrustPolicy::grado`, que satura en
+    /// `min_third_party`). Es una lectura **por lector**: otro con otro grafo
+    /// obtiene otro número del mismo observador, y eso es deliberado.
+    ///
+    /// Delega en `agora_confianza::Confianza`, que **reconcilia los dos planos**:
+    /// además del grado social, si la clave del propio observador está revocada
+    /// el peso cae a `0.0`. Antes no: `corroboration_of_at` sólo excluye
+    /// atestadores revocados, no al sujeto, así que un observador comprometido y
+    /// revocado por sus guardianes seguía pesando a full por sus avales vivos —
+    /// el espejo social del agujero que ayni cerró para las firmas.
+    ///
+    /// **Por qué no se usa `policy.accepts()`:** ése es un bool, una compuerta de
+    /// admisión. Quien pesa testimonio necesita el GRADO: `credibilidad` descuenta
+    /// la opinión del observador con este peso (discounting de Jøsang) y `0.0`
+    /// NO significa "no le creo" sino **opinión vacua** — "no sé quién es, no
+    /// aporta". Eso es exactamente lo correcto para un desconocido, y es
+    /// distinto de la descreencia, que haría contar su testimonio EN CONTRA.
+    /// Pasar por el bool tiraría la información dos veces: colapsar el conteo
+    /// contra el umbral y después intentar recuperar un grado del sí/no.
+    ///
+    /// **Ojo con `clave`:** es la clave pública del firmante (lo que trae un
+    /// `Snapshot`), no un `IdentityId`. El id se DERIVA
+    /// (`IdentityId::from_public_key` = BLAKE3 de la clave); usar los bytes
+    /// crudos daría un id que no le corresponde a nadie y todo el mundo
+    /// resultaría desconocido — un 0.0 que parece funcionar y miente.
+    pub fn confianza_en(&self, clave: &[u8; 32], ahora: u64) -> f32 {
+        agora_confianza::Confianza::nueva(&self.graph, self.policy).peso(clave, ahora)
+    }
+
+    /// El nombre que se le muestra al usuario común para `id`, en orden de
+    /// preferencia: (1) su petname en mi libreta, (2) el `display_name` del grafo,
+    /// (3) la huella corta como último recurso. **Nunca** el hash completo — esa es
+    /// la regla anti-jerga del SDD §6.
+    pub fn nombre_humano(&self, id: IdentityId) -> String {
+        if let Some(n) = self.libreta.nombre_de(id) {
+            return n.to_string();
+        }
+        if let Some(ident) = self.graph.identity(id) {
+            if !ident.display_name.trim().is_empty() {
+                return ident.display_name.clone();
+            }
+        }
+        Self::huella_corta(id)
+    }
+
+    /// La "huella" corta y legible de una identidad: 8 hex con separador. Sirve
+    /// como desambiguador discreto bajo el nombre humano, no como identificador
+    /// primario a la vista.
+    pub fn huella_corta(id: IdentityId) -> String {
+        let b = id.as_bytes();
+        format!("{:02x}{:02x}·{:02x}{:02x}", b[0], b[1], b[2], b[3])
+    }
+
+    /// ¿Tengo un petname puesto para `id`?
+    pub fn tiene_petname(&self, id: IdentityId) -> bool {
+        self.libreta.nombre_de(id).is_some()
+    }
+
+    /// Persiste la libreta a disco (postcard). Reporta por el banner si falla; una
+    /// libreta es local y recuperable, así que no es fatal.
+    pub fn guardar_libreta(&mut self) {
+        match postcard::to_allocvec(&self.libreta) {
+            Ok(bytes) => {
+                if let Err(e) = std::fs::write(&self.libreta_path, &bytes) {
+                    self.set_status(StatusLevel::Error, format!("no pude guardar la libreta: {e}"));
+                }
+            }
+            Err(e) => self.set_status(StatusLevel::Error, format!("no pude serializar la libreta: {e}")),
+        }
+    }
+
+    /// Un libro de actas vacío, sin autor todavía (`autor` en ceros). La primera
+    /// acta lo liga a la identidad que la asienta.
+    pub fn libro_actas_vacio() -> Canal {
+        Canal {
+            version: VERSION_CANAL,
+            nombre: "actas".into(),
+            autor: [0u8; 32],
+            raices: Vec::new(),
+        }
+    }
+
+    /// ¿El libro ya tiene autor (o sigue en ceros = recién nacido)?
+    pub fn actas_tiene_autor(&self) -> bool {
+        self.actas.autor != [0u8; 32]
+    }
+
+    /// ¿El acta en la posición `i` verifica? Dos condiciones, y ambas deben
+    /// cumplirse: (1) el texto guardado re-hashea al hash firmado —nadie editó el
+    /// archivo por fuera de la firma— y (2) la firma del autor del libro cubre esa
+    /// raíz. Es la misma verificación que hace el kernel sobre un canal de release.
+    pub fn acta_verificada(&self, i: usize) -> bool {
+        let (Some(raiz), Some(texto)) = (self.actas.raices.get(i), self.actas_textos.get(i)) else {
+            return false;
+        };
+        format::hash(texto.as_bytes()) == raiz.raiz_manifiesto
+            && agora_channel::verificar_raiz(&self.actas.autor, &self.actas.nombre, raiz).is_ok()
+    }
+
+    /// Persiste el libro de actas (canal + textos) a disco como una tupla
+    /// postcard. Local y recuperable ⇒ no es fatal si falla; reporta por el banner.
+    pub fn guardar_actas(&mut self) {
+        let payload = (&self.actas, &self.actas_textos);
+        match postcard::to_allocvec(&payload) {
+            Ok(bytes) => {
+                if let Err(e) = std::fs::write(&self.actas_path, &bytes) {
+                    self.set_status(StatusLevel::Error, format!("no pude guardar el libro de actas: {e}"));
+                }
+            }
+            Err(e) => {
+                self.set_status(StatusLevel::Error, format!("no pude serializar las actas: {e}"))
+            }
+        }
+    }
+
+    /// ¿Ya firmó `id` la decisión vigente (sobre el mensaje actual)? Se usa para
+    /// pintar el ✅ del participante y saber quién falta.
+    pub fn multi_firmo(&self, id: IdentityId) -> bool {
+        self.multi_current
+            .as_ref()
+            .is_some_and(|m| m.signers.iter().any(|s| s.signer == id))
+    }
+
+    /// Cuántas firmas VÁLIDAS y distintas hay sobre el mensaje actual. Editar el
+    /// texto de la decisión invalida las firmas viejas (no cubren el mensaje
+    /// nuevo), así que este conteo es siempre respecto de lo que se decide hoy.
+    pub fn multi_firmas_validas(&self) -> usize {
+        let msg = self.multi_message.text();
+        self.multi_current
+            .as_ref()
+            .map_or(0, |m| m.verdict(msg.as_bytes()).firmantes_distintos)
+    }
+
+    pub fn signer_keypair(&self) -> Option<Keypair> {
+        self.active_signer
+            .and_then(|id| self.seeds.get(&id).copied())
+            .map(Keypair::from_seed)
+    }
+
+    /// El perfil activo.
+    pub fn perfil(&self) -> &Perfil {
+        &self.perfiles[self.perfil_activo]
+    }
+
+    /// El perfil activo, mutable.
+    pub fn perfil_mut(&mut self) -> &mut Perfil {
+        &mut self.perfiles[self.perfil_activo]
+    }
+
+    /// Refleja el perfil activo en el resto del estado: el firmante activo pasa
+    /// a ser la identidad del perfil (si tiene una y su seed está en RAM), y el
+    /// editor del nombre se recarga. Se llama al cambiar/crear perfil.
+    pub fn sincronizar_perfil(&mut self) {
+        let identidad = self.perfil().identidad;
+        if let Some(id) = identidad {
+            if self.seeds.contains_key(&id) {
+                self.active_signer = Some(id);
+            }
+        }
+        let nombre = self.perfil().nombre.clone();
+        self.perfil_nombre = TextInputState::new();
+        self.perfil_nombre.set_text(nombre);
+    }
+
+    /// Vista de sólo lectura del `TextInputState` focuseado. Lo usan el menú de
+    /// edición y el submenú Editar para reflejar el estado real del campo. El
+    /// ruteo mutable de teclas/mouse va por [`Self::campo_evento`].
+    pub fn focused_input_ref(&self) -> &TextInputState {
+        match self.focused_input {
+            FocusedInput::Compose(ComposeField::Predicate) => &self.compose_predicate,
+            FocusedInput::Compose(ComposeField::Value) => &self.compose_value,
+            FocusedInput::MultiMessage => &self.multi_message,
+            FocusedInput::ReleaseHash => &self.release_hash,
+            FocusedInput::ReleasePaste => &self.release_paste,
+            FocusedInput::VerificarPaste => &self.verificar_paste,
+            FocusedInput::MesaTipo => &self.mesa_tipo,
+            FocusedInput::MesaPartes => &self.mesa_partes,
+            FocusedInput::MesaRefs => &self.mesa_refs,
+            FocusedInput::CapBytecode => &self.cap_bytecode,
+            FocusedInput::CapPaste => &self.cap_paste,
+            FocusedInput::PerfilNombre => &self.perfil_nombre,
+            FocusedInput::Petname => &self.petname_input,
+            FocusedInput::ActaTexto => &self.acta_input,
+            FocusedInput::NuevaIdentidadNombre => &self.nueva_identidad_nombre,
+        }
+    }
+
+    /// Aplica una tecla al input focado e invalida el artefacto derivado del
+    /// input editado (una firma vieja deja de corresponder al texto nuevo). El
+    /// tecleo va por [`Self::campo_evento`], así Ctrl+C/X/V operan contra el
+    /// portapapeles del sistema sin pasar por el menú de edición.
+    pub fn edit_focused(&mut self, ev: &KeyEvent) {
+        let f = self.focused_input;
+        self.campo_evento(f, TextInputEvent::Key(ev.clone()));
+    }
+
+    /// Enruta un [`TextInputEvent`] (teclado o mouse) al campo `f`: el `Press`
+    /// lo enfoca; el resto (arrastre, teclas) lo procesa `handle` contra el
+    /// clipboard del sistema (copiar/cortar/pegar, selección, acentos). Tras
+    /// editar, invalida el artefacto derivado del campo (ver
+    /// [`Self::invalidar_derivado`]).
+    pub fn campo_evento(&mut self, f: FocusedInput, ev: TextInputEvent) {
+        if matches!(ev, TextInputEvent::Press(_)) {
+            self.focused_input = f;
+        }
+        // Borrow disjunto: el campo y el clipboard son campos distintos.
+        let input = match f {
+            FocusedInput::Compose(ComposeField::Predicate) => &mut self.compose_predicate,
+            FocusedInput::Compose(ComposeField::Value) => &mut self.compose_value,
+            FocusedInput::MultiMessage => &mut self.multi_message,
+            FocusedInput::ReleaseHash => &mut self.release_hash,
+            FocusedInput::ReleasePaste => &mut self.release_paste,
+            FocusedInput::VerificarPaste => &mut self.verificar_paste,
+            FocusedInput::MesaTipo => &mut self.mesa_tipo,
+            FocusedInput::MesaPartes => &mut self.mesa_partes,
+            FocusedInput::MesaRefs => &mut self.mesa_refs,
+            FocusedInput::CapBytecode => &mut self.cap_bytecode,
+            FocusedInput::CapPaste => &mut self.cap_paste,
+            FocusedInput::PerfilNombre => &mut self.perfil_nombre,
+            FocusedInput::Petname => &mut self.petname_input,
+            FocusedInput::ActaTexto => &mut self.acta_input,
+            FocusedInput::NuevaIdentidadNombre => &mut self.nueva_identidad_nombre,
+        };
+        input.handle(ev, &mut self.clipboard);
+        self.invalidar_derivado(f);
+    }
+
+    /// Invalida el artefacto derivado del campo `f` tras una edición (una firma
+    /// o el nombre del perfil dejan de corresponder al texto viejo).
+    fn invalidar_derivado(&mut self, f: FocusedInput) {
+        match f {
+            FocusedInput::MultiMessage => self.multi_current = None,
+            FocusedInput::ReleaseHash => self.release_current = None,
+            FocusedInput::CapBytecode => self.cap_current = None,
+            // El nombre del perfil se edita en vivo: cada tecla lo refleja.
+            FocusedInput::PerfilNombre => {
+                let t = self.perfil_nombre.text();
+                self.perfil_mut().nombre = t;
+            }
+            _ => {}
+        }
+    }
+
+    /// Aplica una acción del menú de edición al `EditorState` del input
+    /// focuseado, reusando el clipboard del sistema. Invalida el artefacto
+    /// derivado del campo editado igual que [`Self::edit_focused`].
+    pub fn apply_edit_menu_action(&mut self, action: llimphi_widget_edit_menu::EditAction) {
+        let focus = self.focused_input;
+        // Borrow disjunto: el campo focuseado y el clipboard son campos
+        // distintos del Model, así que los tomamos por separado.
+        let input = match focus {
+            FocusedInput::Compose(ComposeField::Predicate) => &mut self.compose_predicate,
+            FocusedInput::Compose(ComposeField::Value) => &mut self.compose_value,
+            FocusedInput::MultiMessage => &mut self.multi_message,
+            FocusedInput::ReleaseHash => &mut self.release_hash,
+            FocusedInput::ReleasePaste => &mut self.release_paste,
+            FocusedInput::VerificarPaste => &mut self.verificar_paste,
+            FocusedInput::MesaTipo => &mut self.mesa_tipo,
+            FocusedInput::MesaPartes => &mut self.mesa_partes,
+            FocusedInput::MesaRefs => &mut self.mesa_refs,
+            FocusedInput::CapBytecode => &mut self.cap_bytecode,
+            FocusedInput::CapPaste => &mut self.cap_paste,
+            FocusedInput::PerfilNombre => &mut self.perfil_nombre,
+            FocusedInput::Petname => &mut self.petname_input,
+            FocusedInput::ActaTexto => &mut self.acta_input,
+            FocusedInput::NuevaIdentidadNombre => &mut self.nueva_identidad_nombre,
+        };
+        llimphi_widget_edit_menu::apply(input.editor_mut(), action, &mut self.clipboard);
+        match focus {
+            FocusedInput::MultiMessage => self.multi_current = None,
+            FocusedInput::ReleaseHash => self.release_current = None,
+            FocusedInput::CapBytecode => self.cap_current = None,
+            FocusedInput::PerfilNombre => {
+                let t = self.perfil_nombre.text();
+                self.perfil_mut().nombre = t;
+            }
+            _ => {}
+        }
+    }
+
+    /// Intenta desbloquear todas las seeds del keystore con `self.passphrase`.
+    /// Sólo guarda las que descifran; el resto se loguea a stderr y se omite.
+    pub fn desbloquear_seeds_silencioso(&mut self) {
+        let ids = self.keystore.list().unwrap_or_default();
+        for id in ids {
+            match self.keystore.load(id, &self.passphrase) {
+                Ok(seed) => {
+                    self.seeds.insert(id, seed);
+                }
+                Err(e) => eprintln!("agora-app: no pude desbloquear {id}: {e}"),
+            }
+        }
+    }
+
+    /// Versión estricta para la pantalla de unlock: requiere que **todas** las
+    /// seeds del keystore descifren contra `passphrase`. `true` si pasó (y deja
+    /// `self.seeds` poblada).
+    pub fn intentar_unlock(&mut self, passphrase: &str) -> bool {
+        let ids = self.keystore.list().unwrap_or_default();
+        let mut nuevas = HashMap::new();
+        for id in &ids {
+            match self.keystore.load(*id, passphrase) {
+                Ok(seed) => {
+                    nuevas.insert(*id, seed);
+                }
+                Err(_) => return false,
+            }
+        }
+        self.seeds = nuevas;
+        self.passphrase = passphrase.to_string();
+        true
+    }
+
+    /// Si el grafo no registra una identidad mía conocida (p. ej. el archivo
+    /// se borró pero el keystore sobrevivió), la registra de nuevo como Person.
+    pub fn registrar_identidades_huerfanas(&mut self) {
+        let huerfanas: Vec<_> = self
+            .seeds
+            .iter()
+            .filter(|(id, _)| self.graph.identity(**id).is_none())
+            .map(|(_id, seed)| {
+                let kp = Keypair::from_seed(*seed);
+                let n = self.graph.identity_count();
+                kp.identity(IdentityKind::Person, format!("yo {}", n + 1))
+            })
+            .collect();
+        for ident in huerfanas {
+            self.graph.register(ident);
+        }
+    }
+}
+
+// =============================================================================
+//  Mensajes
+// =============================================================================
+
+#[derive(Clone)]
+pub(crate) enum Msg {
+    /// Reordenar tiles por drag.
+    SwapTile(usize, usize),
+
+    // ---- Shell modular -----------------------------------------------------
+    /// Activa un módulo (click en su diente del rail).
+    SelectModulo(ModuloId),
+    /// Mensaje del **sidebar unificado** (ejes de disposición, buscador, control).
+    /// Se reduce con `RagSidebarState::update`.
+    Sidebar(llimphi_widget_rag_sidebar::RagSidebarMsg),
+    /// Cambia el perfil activo (switcher del sidebar derecho).
+    SwitchPerfil(usize),
+    /// Crea un perfil nuevo y lo activa.
+    NuevoPerfil,
+    /// Alterna el tema (oscuro/claro) del perfil activo.
+    TogglePerfilTema,
+    /// Alterna el modo avanzado (muestra/oculta los dientes de Sistema wawa).
+    ToggleModoExperto,
+    /// Vincula la identidad enfocada como la identidad del perfil activo.
+    VincularIdentidad(IdentityId),
+    /// Mensaje de un módulo qillqa (registro/mesa/veeduría/compras/autoridad).
+    Qillqa(modulos::qillqa::Msg),
+
+    /// Genera una identidad nueva (seed CSPRNG), la guarda y la registra.
+    NuevaIdentidad,
+    /// Selecciona el sujeto enfocado (objetivo del próximo claim).
+    FocoSujeto(IdentityId),
+    /// Le pone el nombre de `petname_input` a la identidad enfocada en la
+    /// [`Libreta`] y persiste. Es el gesto "así la llamo yo".
+    PonerNombre,
+    /// Olvida el petname de la identidad enfocada (vuelve al display_name/huella).
+    OlvidarNombre,
+    /// Cambia el firmante activo (entre identidades mías).
+    ActuarComo(IdentityId),
+    /// Selecciona una atestación para que la política evalúe su claim.
+    SeleccionarAtestacion(usize),
+
+    /// Cambia el input focado (cualquiera de la pantalla principal). Lo usa el
+    /// Tab (ciclo de campos); el click ahora enfoca vía [`Msg::Campo`].
+    Foco(FocusedInput),
+    /// Evento de mouse de un campo de la pantalla principal (click/arrastre). El
+    /// [`FocusedInput`] dice cuál; el `Press` además lo enfoca. Ver
+    /// [`crate::ui::input_row`] / [`text_input_view_full`](llimphi_widget_text_input::text_input_view_full).
+    Campo(FocusedInput, TextInputEvent),
+    /// Tecla aplicada al input focado.
+    EditFocused(KeyEvent),
+    /// Firma + agrega la atestación con los valores actuales y persiste.
+    Atestar,
+
+    /// Drag del slider de `min_third_party`. Acumula el delta.
+    SliderMinThird(llimphi_ui::DragPhase, f32),
+    /// Toggle de `accept_self`.
+    ToggleAcceptSelf,
+    /// Cicla `min_attesters_of_kind`: off → Person → … → Institution → off.
+    CycleKind,
+    /// Drag del slider del N requerido para el kind activo.
+    SliderMinKind(llimphi_ui::DragPhase, f32),
+    /// Cicla `max_age_secs` por presets: off → 60 → 300 → 3600 → 86400 → 604800 → off.
+    CycleMaxAge,
+
+    /// `graph.json` cambió en disco (otro proceso). Recarga el snapshot.
+    ArchivoCambio,
+
+    /// Tecla aplicada al input de passphrase en la pantalla de unlock.
+    UnlockKey(KeyEvent),
+    /// Evento de mouse del campo de passphrase (click/arrastre → caret/selección).
+    UnlockCampo(TextInputEvent),
+    /// Intenta desbloquear el keystore con la passphrase actual.
+    UnlockSubmit,
+
+    /// Cierra el banner de estado de servicio.
+    DescartarStatus,
+
+    // ---- Multifirma / Decisiones -------------------------------------------
+    ToggleMultiFirmante(IdentityId),
+    SliderMultiUmbral(llimphi_ui::DragPhase, f32),
+    /// Agrega la firma de UN participante a la decisión vigente (firma
+    /// incremental, para que la barra de progreso se llene de a una).
+    FirmarComo(IdentityId),
+    FirmarMulti,
+    LimpiarMulti,
+    ExportarMulti,
+
+    // ---- Release -----------------------------------------------------------
+    /// Firma `release_hash` (parseado de hex) con el firmante activo.
+    FirmarRelease,
+    /// Verifica el `ManifiestoFirmado` pegado en `release_paste` (postcard hex).
+    VerificarRelease,
+    /// Serializa `release_current` a postcard y lo presenta en hex en el banner.
+    ExportarRelease,
+    /// Limpia el estado del tile Release.
+    LimpiarRelease,
+
+    // ---- Capacidad ---------------------------------------------------------
+    /// Toggle de un bit de `cap_permisos`.
+    ToggleCapPermiso(Permisos),
+    /// Firma una `ConcesionCapacidad` sobre `(cap_bytecode, cap_permisos)`.
+    FirmarCapacidad,
+    /// Verifica la `ConcesionCapacidad` pegada en `cap_paste` (postcard hex).
+    VerificarCapacidad,
+    /// Serializa `cap_current` a postcard y lo presenta en hex en el banner.
+    ExportarCapacidad,
+    /// Limpia el estado del tile Capacidad.
+    LimpiarCapacidad,
+
+    // ---- Actas (libro firmado e inmutable) ---------------------------------
+    /// Firma el texto de `acta_input` con el firmante activo y lo asienta en el
+    /// libro (append-only, con fecha). Persiste.
+    AsentarActa,
+    /// Selecciona un acta del índice para verla en el canvas.
+    SeleccionarActa(usize),
+    /// Re-verifica el libro entero: firmas del autor, monotonía de fechas y que
+    /// ningún texto haya sido alterado por fuera de la firma.
+    VerificarActas,
+    /// Serializa el libro a postcard y lo vuelca en hex al banner (exportable).
+    ExportarActas,
+
+    // ---- Menús (barra principal + edición contextual) ---------------------
+    /// Barra de menú principal: abrir/cerrar un menú raíz (`None` = cerrar).
+    MenuOpen(Option<usize>),
+    /// Comando elegido en el menú principal — se traduce al `Msg` real.
+    MenuCommand(String),
+    /// Right-click sobre la ventana → abre el menú de edición en `(x, y)`,
+    /// operando sobre el input focuseado.
+    EditMenuOpen(f32, f32),
+    /// Acción elegida en el menú de edición contextual.
+    EditMenuAction(llimphi_widget_edit_menu::EditAction),
+    /// Navegación ↑/↓ por la fila activa del menú principal.
+    MenuNav(i32),
+    /// Enter sobre la fila activa del menú principal.
+    MenuActivate,
+    /// Tick de animación de aparición/swap (re-render).
+    MenuTick,
+    /// Navegación ↑/↓ por la fila activa del menú de edición.
+    EditNav(i32),
+    /// Enter sobre la fila activa del menú de edición.
+    EditActivate,
+    /// Cierra cualquier menú abierto (click-fuera / Esc).
+    CloseMenus,
+}
diff --git a/03_ukupacha/agora/agora-app/src/modulos/agora.rs b/03_ukupacha/agora/agora-app/src/modulos/agora.rs
new file mode 100644
index 0000000..f58de11
--- /dev/null
+++ b/03_ukupacha/agora/agora-app/src/modulos/agora.rs
@@ -0,0 +1,422 @@
+//! Vistas de los módulos **agora** — el sustrato de confianza (identidades,
+//! atestaciones, política, multifirma) y el plano de control de wawa (release,
+//! capacidad). El canvas reusa las vistas de [`crate::tiles`]; el sidebar arma
+//! el índice (listas/metadatos) propio de cada módulo.
+
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::{
+    prelude::{length, percent, FlexDirection, Size, Style},
+    AlignItems,
+};
+use llimphi_ui::View;
+use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec};
+use llimphi_widget_text_input::TextInputPalette;
+
+use super::ModuloId;
+use crate::model::{FocusedInput, Model, Msg};
+use crate::tiles::{compositor_view, kind_str};
+use crate::ui::{
+    boton_frac, button_palette_primary, column, edge_padding, grow, input_row, label_line, spacer,
+};
+
+// =============================================================================
+//  Sidebars (el índice de cada módulo)
+// =============================================================================
+
+/// Índice de **Identidades**: la lista del grafo; click enfoca el sujeto.
+pub(super) fn sidebar_identidades(model: &Model, theme: &Theme, filtro: &str) -> View<Msg> {
+    let mut idents: Vec<_> = model.graph.identities().collect();
+    idents.sort_by(|a, b| a.id().as_bytes().cmp(b.id().as_bytes()));
+
+    let rows: Vec<ListRow<Msg>> = idents
+        .iter()
+        .map(|ident| {
+            let id = ident.id();
+            let mio = if model.is_mine(id) { "★ " } else { "  " };
+            let activa = if Some(id) == model.active_signer { " ←" } else { "" };
+            ListRow {
+                label: format!("{mio}{}  {}{activa}", model.nombre_humano(id), kind_str(ident.kind)),
+                selected: model.focused_subject == Some(id),
+                on_click: Msg::FocoSujeto(id),
+            }
+        })
+        .collect();
+
+    let caption = format!("{} en el grafo · {} mías", idents.len(), model.seeds.len());
+    let rows = super::filtrar_rows(rows, filtro);
+    let total = rows.len();
+    lista(rows, total, caption, theme)
+}
+
+/// Índice de **Atestaciones**: los avales verificados; click selecciona.
+pub(super) fn sidebar_atestaciones(model: &Model, theme: &Theme, filtro: &str) -> View<Msg> {
+    let rows: Vec<ListRow<Msg>> = model
+        .graph
+        .attestations()
+        .iter()
+        .enumerate()
+        .map(|(idx, att)| {
+            let mark = if att.is_self_attested() { "◦ " } else { "  " };
+            let attester = model.nombre_humano(att.attester);
+            ListRow {
+                label: format!("{mark}{attester}: {} = {}", att.claim.predicate, att.claim.value),
+                selected: model.selected_attestation == Some(idx),
+                on_click: Msg::SeleccionarAtestacion(idx),
+            }
+        })
+        .collect();
+
+    let rows = super::filtrar_rows(rows, filtro);
+    let total = rows.len();
+    lista(rows, total, format!("{total} verificadas"), theme)
+}
+
+/// Índice de **Política**: qué se está evaluando + resumen de la política.
+pub(super) fn sidebar_politica(model: &Model, theme: &Theme) -> View<Msg> {
+    let evaluando = match model.selected_attestation.and_then(|i| model.graph.attestations().get(i)) {
+        Some(att) => format!("{} = {}", att.claim.predicate, att.claim.value),
+        None => "— elige una atestación —".into(),
+    };
+    column(vec![
+        label_line("evaluando", 11.0, theme.fg_muted),
+        label_line(&evaluando, 13.0, theme.fg_text),
+        spacer(10.0),
+        label_line("umbral", 11.0, theme.fg_muted),
+        label_line(&format!("terceros ≥ {}", model.policy.min_third_party), 13.0, theme.fg_text),
+        label_line(
+            &format!("auto-aval: {}", if model.policy.accept_self { "sí" } else { "no" }),
+            13.0,
+            theme.fg_text,
+        ),
+    ])
+}
+
+/// Índice de **Decisiones**: quiénes deciden. Cada identidad propia con su
+/// estado — ✅ firmó, ☑ elegida (falta firmar), ☐ no participa. Click la
+/// (des)elige.
+pub(super) fn sidebar_multifirma(model: &Model, theme: &Theme, filtro: &str) -> View<Msg> {
+    let rows: Vec<ListRow<Msg>> = model
+        .graph
+        .identities()
+        .filter(|i| model.is_mine(i.id()))
+        .map(|ident| {
+            let id = ident.id();
+            let elegida = model.multi_selected.contains(&id);
+            let marca = if elegida && model.multi_firmo(id) {
+                "✅"
+            } else if elegida {
+                "☑"
+            } else {
+                "☐"
+            };
+            ListRow {
+                label: format!("{marca}  {}", model.nombre_humano(id)),
+                selected: elegida,
+                on_click: Msg::ToggleMultiFirmante(id),
+            }
+        })
+        .collect();
+
+    let rows = super::filtrar_rows(rows, filtro);
+    let total = rows.len();
+    let firmas = model.multi_firmas_validas();
+    let umbral = model.multi_threshold.max(1);
+    lista(
+        rows,
+        total,
+        format!("firmaron {firmas} de {umbral} necesarias"),
+        theme,
+    )
+}
+
+/// Índice de **Actas**: el libro, un acta por fila con su fecha, las primeras
+/// palabras y un ✔/✗ de verificación. Click abre el acta en el canvas.
+pub(super) fn sidebar_actas(model: &Model, theme: &Theme, filtro: &str) -> View<Msg> {
+    let rows: Vec<ListRow<Msg>> = model
+        .actas
+        .raices
+        .iter()
+        .enumerate()
+        .map(|(idx, raiz)| {
+            let ok = if model.acta_verificada(idx) { "✔" } else { "✗" };
+            let texto = model.actas_textos.get(idx).map(String::as_str).unwrap_or("");
+            let resumen: String = texto.chars().take(28).collect();
+            let elipsis = if texto.chars().count() > 28 { "…" } else { "" };
+            let fecha = &crate::tiles::fecha_humana(raiz.timestamp)[..10]; // AAAA-MM-DD
+            ListRow {
+                label: format!("{ok} {fecha}  {resumen}{elipsis}"),
+                selected: model.acta_selected == Some(idx),
+                on_click: Msg::SeleccionarActa(idx),
+            }
+        })
+        .collect();
+
+    let rows = super::filtrar_rows(rows, filtro);
+    let total = rows.len();
+    let caption = if total == 0 {
+        "libro vacío — escribe la primera".to_string()
+    } else {
+        format!("{total} actas en el libro")
+    };
+    lista(rows, total, caption, theme)
+}
+
+/// Índice genérico (Release / Capacidad): metadatos + firmante activo.
+pub(super) fn sidebar_meta(model: &Model, theme: &Theme, id: ModuloId) -> View<Msg> {
+    let firmante = model
+        .active_signer
+        .map(|s| model.nombre_humano(s))
+        .unwrap_or_else(|| "— sin firmante —".into());
+    let que = match id {
+        ModuloId::Release => "Firma el hash de una imagen del sistema. El sobre \
+            (ManifiestoFirmado) es lo que mudanza empuja al kernel.",
+        _ => "Concede permisos a un bytecode por su hash BLAKE3. El kernel honra \
+            la intersección contra el AGORA_AUTH_RING.",
+    };
+    column(vec![
+        label_line("firmante activo", 11.0, theme.fg_muted),
+        label_line(&firmante, 13.0, theme.accent),
+        spacer(12.0),
+        label_line(que, 12.0, theme.fg_muted),
+    ])
+}
+
+// =============================================================================
+//  Canvas (contenido / formulario del ítem)
+// =============================================================================
+
+/// Canvas de **Identidades**: detalle de la enfocada + el compositor (atestar
+/// sobre ella). El compositor es el formulario del ítem.
+pub(super) fn canvas_identidades(model: &Model, theme: &Theme) -> View<Msg> {
+    // Primer arranque: grafo vacío ⇒ bienvenida amable, no una lista pelada.
+    if model.graph.identity_count() == 0 {
+        return bienvenida_view(model, theme);
+    }
+
+    let detalle = match model.focused_subject.and_then(|id| model.graph.identity(id)) {
+        None => column(vec![label_line(
+            "Elige una identidad en el índice para verla y atestar sobre ella.",
+            13.0,
+            theme.fg_muted,
+        )]),
+        Some(ident) => {
+            let id = ident.id();
+            let n_avales = model
+                .graph
+                .attestations()
+                .iter()
+                .filter(|a| a.claim.subject == id && !a.is_self_attested())
+                .count();
+            column(vec![
+                label_line(&model.nombre_humano(id), 18.0, theme.fg_text),
+                label_line(kind_str(ident.kind), 12.0, theme.fg_muted),
+                spacer(6.0),
+                // Huella corta como desambiguador discreto — NO el hash completo.
+                label_line(&format!("huella {}", Model::huella_corta(id)), 11.0, theme.fg_muted),
+                spacer(6.0),
+                label_line(
+                    &format!(
+                        "{n_avales} avales de terceros · {}",
+                        if model.is_mine(id) { "★ es mía" } else { "ajena" }
+                    ),
+                    12.0,
+                    theme.fg_muted,
+                ),
+            ])
+        }
+    };
+
+    let nombrar = nombrar_view(model, theme);
+
+    column(vec![
+        seccion("Detalle", theme, detalle),
+        seccion("¿Cómo la llamas?", theme, nombrar),
+        seccion("Sumar a alguien", theme, crear_identidad_view(model, theme)),
+        seccion("Atestar sobre la enfocada", theme, grow(compositor_view(model, theme))),
+    ])
+}
+
+/// Formulario para crear una identidad nueva **con nombre desde el arranque**
+/// (no "yo N"). El mismo campo que el onboarding, disponible siempre que ya haya
+/// gente en el grafo.
+fn crear_identidad_view(model: &Model, theme: &Theme) -> View<Msg> {
+    let input = input_row(
+        &model.nueva_identidad_nombre,
+        "el nombre de quien se suma…",
+        model.focused_input == FocusedInput::NuevaIdentidadNombre,
+        &TextInputPalette::from_theme(theme),
+        FocusedInput::NuevaIdentidadNombre,
+    );
+    column(vec![
+        label_line(
+            "Crea otra identidad tuya y ponele nombre desde el arranque.",
+            11.0,
+            theme.fg_muted,
+        ),
+        spacer(6.0),
+        input,
+        spacer(6.0),
+        boton_frac(
+            "+ crear con ese nombre (Enter)",
+            1.0,
+            32.0,
+            &button_palette_primary(theme),
+            Msg::NuevaIdentidad,
+        ),
+    ])
+}
+
+/// Bienvenida de primer arranque, cuando todavía no hay ninguna identidad. En
+/// vez de una lista vacía y jerga, saluda y ofrece el único paso que importa:
+/// crear tu identidad.
+fn bienvenida_view(model: &Model, theme: &Theme) -> View<Msg> {
+    let nombre_input = input_row(
+        &model.nueva_identidad_nombre,
+        "tu nombre (p. ej. Yumaira, don Pedro, la cooperativa) …",
+        model.focused_input == FocusedInput::NuevaIdentidadNombre,
+        &TextInputPalette::from_theme(theme),
+        FocusedInput::NuevaIdentidadNombre,
+    );
+    let cuerpo = column(vec![
+        label_line("Bienvenida a tu plaza", 22.0, theme.fg_text),
+        spacer(10.0),
+        label_line(
+            "Aquí vas a ver a tu gente, dar fe de quién es quién y decidir en grupo.",
+            13.0,
+            theme.fg_muted,
+        ),
+        label_line(
+            "Todo queda firmado y es tuyo — nadie manda esta plaza.",
+            13.0,
+            theme.fg_muted,
+        ),
+        spacer(16.0),
+        label_line("¿Cómo te llamas?", 13.0, theme.fg_text),
+        spacer(6.0),
+        nombre_input,
+        spacer(10.0),
+        boton_frac(
+            "+ crear mi identidad (Enter)",
+            1.0,
+            36.0,
+            &button_palette_primary(theme),
+            Msg::NuevaIdentidad,
+        ),
+    ]);
+    seccion("Empieza aquí", theme, cuerpo)
+}
+
+/// El formulario de **petname**: input "así la llamo yo" + guardar/olvidar. Es lo
+/// que deja que un hash se vuelva "Yumaira" en toda la app. Sin sujeto enfocado,
+/// invita a elegir uno.
+fn nombrar_view(model: &Model, theme: &Theme) -> View<Msg> {
+    let Some(id) = model.focused_subject else {
+        return label_line("Elige una identidad en el índice para nombrarla.", 12.0, theme.fg_muted);
+    };
+
+    let input = input_row(
+        &model.petname_input,
+        "un nombre memorable (p. ej. Yumaira, la comadre, don Pedro) …",
+        model.focused_input == FocusedInput::Petname,
+        &TextInputPalette::from_theme(theme),
+        FocusedInput::Petname,
+    );
+
+    let guardar = boton_frac(
+        "guardar nombre (Enter)",
+        1.0,
+        32.0,
+        &button_palette_primary(theme),
+        Msg::PonerNombre,
+    );
+
+    let mut hijos = vec![
+        label_line(
+            "El nombre es tuyo y privado: sólo cambia cómo VOS ves esta identidad.",
+            11.0,
+            theme.fg_muted,
+        ),
+        spacer(6.0),
+        input,
+        spacer(6.0),
+        guardar,
+    ];
+    if model.tiene_petname(id) {
+        hijos.push(spacer(4.0));
+        hijos.push(boton_frac(
+            "olvidar el nombre",
+            1.0,
+            28.0,
+            &button_palette_primary(theme),
+            Msg::OlvidarNombre,
+        ));
+    }
+    column(hijos)
+}
+
+/// Canvas de **Atestaciones**: detalle firmado de la seleccionada.
+pub(super) fn canvas_atestaciones(model: &Model, theme: &Theme) -> View<Msg> {
+    let cuerpo = match model.selected_attestation.and_then(|i| model.graph.attestations().get(i)) {
+        None => column(vec![label_line(
+            "Elige una atestación en el índice para ver su firma y su claim.",
+            13.0,
+            theme.fg_muted,
+        )]),
+        Some(att) => {
+            let attester = model.nombre_humano(att.attester);
+            let subject = model.nombre_humano(att.claim.subject);
+            column(vec![
+                label_line(&format!("{} = {}", att.claim.predicate, att.claim.value), 18.0, theme.fg_text),
+                spacer(8.0),
+                label_line("atestada por", 11.0, theme.fg_muted),
+                label_line(&attester, 13.0, theme.accent),
+                spacer(6.0),
+                label_line("sobre", 11.0, theme.fg_muted),
+                label_line(&subject, 13.0, theme.fg_text),
+                spacer(6.0),
+                label_line(
+                    if att.is_self_attested() {
+                        "◦ auto-atestación (la propia identidad se avala a sí misma)"
+                    } else {
+                        "✓ aval de tercero — firma Ed25519 verificada al ingresar al grafo"
+                    },
+                    12.0,
+                    theme.fg_muted,
+                ),
+            ])
+        }
+    };
+    seccion("Atestación", theme, cuerpo)
+}
+
+// =============================================================================
+//  Helpers
+// =============================================================================
+
+/// Envuelve una lista de filas en el `list_view` estándar.
+fn lista(rows: Vec<ListRow<Msg>>, total: usize, caption: String, theme: &Theme) -> View<Msg> {
+    list_view(ListSpec {
+        rows,
+        total,
+        caption: Some(caption),
+        truncated_hint: None,
+        row_height: 24.0,
+        palette: ListPalette::from_theme(theme),
+    })
+}
+
+/// Un bloque con rótulo tenue y su contenido, con padding interno.
+fn seccion(titulo: &str, theme: &Theme, contenido: View<Msg>) -> View<Msg> {
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size { width: percent(1.0_f32), height: length(0.0_f32) },
+        flex_grow: 1.0,
+        flex_basis: length(0.0_f32),
+        min_size: Size { width: length(0.0_f32), height: length(0.0_f32) },
+        padding: edge_padding(14.0, 10.0),
+        gap: Size { width: length(0.0_f32), height: length(6.0_f32) },
+        align_items: Some(AlignItems::Stretch),
+        ..Default::default()
+    })
+    .children(vec![label_line(titulo, 11.0, theme.fg_muted), grow(contenido)])
+}
diff --git a/03_ukupacha/agora/agora-app/src/modulos/mod.rs b/03_ukupacha/agora/agora-app/src/modulos/mod.rs
new file mode 100644
index 0000000..1f938c7
--- /dev/null
+++ b/03_ukupacha/agora/agora-app/src/modulos/mod.rs
@@ -0,0 +1,404 @@
+//! Sistema de **módulos** del shell de agora.
+//!
+//! Cada categoría es un módulo: un **diente** en el rail (`llimphi-widget-dock-
+//! rail`, el patrón canónico de cosmos/media), un **índice** en el sidebar
+//! izquierdo (listas / metadatos) y el **contenido/formulario/grafo** del ítem
+//! seleccionado en el canvas. El contrato es Elm por despacho de enum, como el
+//! viejo `Tile`: [`ModuloId`] nombra la categoría y [`sidebar`]/[`canvas`]
+//! despachan la vista.
+//!
+//! Dos familias:
+//! - **agora** (sustrato de confianza + plano de control wawa) — reusa las
+//!   vistas de [`crate::tiles`] como canvas.
+//! - **qillqa** (registros a prueba de manipulación) — [`qillqa`], módulos que
+//!   reusan los `qillqa-*-core` con el patrón `media-module` (`State + Msg +
+//!   update + view(state, theme, lift)`), agnósticos del `Msg` global.
+
+pub(crate) mod qillqa;
+
+use llimphi_icons::Icon;
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::prelude::{
+    length, percent, FlexDirection, Rect, Size, Style,
+};
+use llimphi_ui::View;
+
+use crate::model::{Model, Msg};
+
+/// Una categoría del shell. El orden del array es el orden de los dientes.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(crate) enum ModuloId {
+    // --- agora: sustrato social -------------------------------------------
+    Identidades,
+    Atestaciones,
+    Politica,
+    Multifirma,
+    Actas,
+    // --- agora: plano de control wawa -------------------------------------
+    Release,
+    Capacidad,
+    // --- qillqa: registros a prueba de manipulación -----------------------
+    Verificar,
+    Registro,
+    Mesa,
+    Veeduria,
+    Compras,
+    Autoridad,
+}
+
+/// Orden canónico de los dientes en el rail.
+pub(crate) const MODULOS: [ModuloId; 13] = [
+    ModuloId::Identidades,
+    ModuloId::Atestaciones,
+    ModuloId::Politica,
+    ModuloId::Multifirma,
+    ModuloId::Actas,
+    ModuloId::Release,
+    ModuloId::Capacidad,
+    ModuloId::Verificar,
+    ModuloId::Registro,
+    ModuloId::Mesa,
+    ModuloId::Veeduria,
+    ModuloId::Compras,
+    ModuloId::Autoridad,
+];
+
+impl ModuloId {
+    pub(crate) fn to_u64(self) -> u64 {
+        match self {
+            ModuloId::Identidades => 0,
+            ModuloId::Atestaciones => 1,
+            ModuloId::Politica => 2,
+            ModuloId::Multifirma => 3,
+            ModuloId::Actas => 11,
+            ModuloId::Release => 4,
+            ModuloId::Capacidad => 5,
+            ModuloId::Verificar => 12,
+            ModuloId::Registro => 6,
+            ModuloId::Mesa => 7,
+            ModuloId::Veeduria => 8,
+            ModuloId::Compras => 9,
+            ModuloId::Autoridad => 10,
+        }
+    }
+
+    pub(crate) fn from_u64(v: u64) -> ModuloId {
+        MODULOS
+            .iter()
+            .copied()
+            .find(|m| m.to_u64() == v)
+            .unwrap_or(ModuloId::Identidades)
+    }
+
+    /// Icono del diente.
+    pub(crate) fn icono(self) -> Icon {
+        match self {
+            ModuloId::Identidades => Icon::User,
+            ModuloId::Atestaciones => Icon::Check,
+            ModuloId::Politica => Icon::Gauge,
+            ModuloId::Multifirma => Icon::Key,
+            ModuloId::Actas => Icon::FileText,
+            ModuloId::Release => Icon::Power,
+            ModuloId::Capacidad => Icon::Lock,
+            // Globe = la cara PÚBLICA: la única sala abierta a cualquiera, sin
+            // cuenta ni pertenencia al grupo curado. El set no tiene escudo y no
+            // se agrega uno por esto (se reusa lo que hay, regla del repo).
+            ModuloId::Verificar => Icon::Globe,
+            ModuloId::Registro => Icon::Archive,
+            ModuloId::Mesa => Icon::Rows,
+            ModuloId::Veeduria => Icon::Search,
+            ModuloId::Compras => Icon::Columns,
+            ModuloId::Autoridad => Icon::Sparkle,
+        }
+    }
+
+    /// Título del módulo (cabecera del sidebar) — en lenguaje humano. La jerga
+    /// cripto vive en el subtítulo o en el modo experto, no en el rótulo.
+    pub(crate) fn titulo(self) -> &'static str {
+        match self {
+            ModuloId::Identidades => "Mi gente",
+            ModuloId::Atestaciones => "Avales",
+            ModuloId::Politica => "¿Le creo?",
+            ModuloId::Multifirma => "Decisiones",
+            ModuloId::Actas => "Actas",
+            ModuloId::Release => "Release · wawa",
+            ModuloId::Capacidad => "Capacidad · wawa",
+            ModuloId::Verificar => "Verificar",
+            ModuloId::Registro => "Registro",
+            ModuloId::Mesa => "Mesa de partes",
+            ModuloId::Veeduria => "Veeduría",
+            ModuloId::Compras => "Compras",
+            ModuloId::Autoridad => "Autoridad",
+        }
+    }
+
+    /// Subtítulo (una línea de contexto bajo el título del sidebar).
+    pub(crate) fn subtitulo(self) -> &'static str {
+        match self {
+            ModuloId::Identidades => "quién es quién",
+            ModuloId::Atestaciones => "quién da fe de quién",
+            ModuloId::Politica => "cuánto confías",
+            ModuloId::Multifirma => "acuerdos del grupo",
+            ModuloId::Actas => "libro firmado e inmutable",
+            ModuloId::Release => "sobres que el kernel honra",
+            ModuloId::Capacidad => "permisos por bytecode",
+            ModuloId::Verificar => "¿este papel es de verdad?",
+            ModuloId::Registro => "log append-only firmado",
+            ModuloId::Mesa => "cargo de recepción",
+            ModuloId::Veeduria => "el archivo que no olvida",
+            ModuloId::Compras => "presupuesto certificado",
+            ModuloId::Autoridad => "traspaso de gestión M-de-N",
+        }
+    }
+
+    /// ¿Es un módulo del **plano de control de wawa** (experto)? Estos se ocultan
+    /// del rail salvo que el usuario encienda el modo avanzado (SDD §4.4). Su
+    /// lector real es un operador, no el pueblo.
+    pub(crate) fn es_experto(self) -> bool {
+        matches!(self, ModuloId::Release | ModuloId::Capacidad)
+    }
+
+    /// Dominio al que pertenece (para agrupar dientes y teñir acentos).
+    pub(crate) fn dominio(self) -> Dominio {
+        match self {
+            ModuloId::Identidades
+            | ModuloId::Atestaciones
+            | ModuloId::Politica
+            | ModuloId::Multifirma
+            | ModuloId::Actas
+            | ModuloId::Release
+            | ModuloId::Capacidad => Dominio::Agora,
+            _ => Dominio::Qillqa,
+        }
+    }
+}
+
+/// De qué suite viene el módulo.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Dominio {
+    Agora,
+    Qillqa,
+}
+
+/// El **índice** del módulo: listas / metadatos en el sidebar izquierdo. `filtro`
+/// es el texto del buscador del sidebar unificado — las listas lo aplican a sus
+/// filas (los índices que son formulario/metadatos lo ignoran, no hay lista que
+/// filtrar).
+pub(crate) fn sidebar(id: ModuloId, model: &Model, theme: &Theme, filtro: &str) -> View<Msg> {
+    match id {
+        ModuloId::Identidades => agora::sidebar_identidades(model, theme, filtro),
+        ModuloId::Atestaciones => agora::sidebar_atestaciones(model, theme, filtro),
+        ModuloId::Politica => agora::sidebar_politica(model, theme),
+        ModuloId::Multifirma => agora::sidebar_multifirma(model, theme, filtro),
+        ModuloId::Actas => agora::sidebar_actas(model, theme, filtro),
+        ModuloId::Release => agora::sidebar_meta(model, theme, id),
+        ModuloId::Capacidad => agora::sidebar_meta(model, theme, id),
+        // Verificar compone: el input de pegado lo aporta el SHELL (los inputs
+        // viven sobre el `Msg` global y el `FocusedInput`), y los interruptores
+        // del banco los aporta la sala, que es genérica sobre `H`. Ninguno de
+        // los dos contratos se rompe: la sala recibe el texto ya pegado como
+        // `String` y lo parsea con `qillqa_core::banco`. Mismo reparto que
+        // `ReleasePaste`.
+        ModuloId::Verificar => {
+            let pegado = crate::ui::input_row(
+                &model.verificar_paste,
+                "pega tu constancia y la autoridad esperada, separadas por un espacio…",
+                model.focused_input == crate::model::FocusedInput::VerificarPaste,
+                &llimphi_widget_text_input::TextInputPalette::from_theme(theme),
+                crate::model::FocusedInput::VerificarPaste,
+            );
+            let banco = qillqa::verificar::sidebar(&model.qillqa.verificar, theme, filtro, |m| {
+                Msg::Qillqa(qillqa::Msg::Verificar(m))
+            });
+            View::new(Style {
+                flex_direction: FlexDirection::Column,
+                size: Size {
+                    width: percent(1.0_f32),
+                    height: percent(1.0_f32),
+                },
+                gap: Size {
+                    width: length(0.0_f32),
+                    height: length(6.0_f32),
+                },
+                padding: Rect {
+                    left: length(10.0_f32),
+                    right: length(10.0_f32),
+                    top: length(8.0_f32),
+                    bottom: length(0.0_f32),
+                },
+                ..Default::default()
+            })
+            .children(vec![pegado, banco])
+        }
+        ModuloId::Registro => {
+            qillqa::registro::sidebar(&model.qillqa.registro, theme, filtro, |m| Msg::Qillqa(qillqa::Msg::Registro(m)))
+        }
+        // Mesa compone igual que Verificar: el **form de alta** reparte sus
+        // piezas entre los dos contratos. Los tres campos de texto los aporta el
+        // SHELL (viven sobre el `Msg` global y el `FocusedInput`), y el botón que
+        // manda su contenido; el selector de funcionario y el error del alta los
+        // aporta la SALA, que es genérica sobre `H` y dueña de ese estado. La
+        // sala nunca ve un `TextInputState`: recibe `String`s y las parsea con
+        // `qillqa-mesa`.
+        ModuloId::Mesa => {
+            let tpal = llimphi_widget_text_input::TextInputPalette::from_theme(theme);
+            let lift = |m| Msg::Qillqa(qillqa::Msg::Mesa(m));
+            let campo = |st, ph: &str, foco: crate::model::FocusedInput| {
+                crate::ui::input_row(st, ph, model.focused_input == foco, &tpal, foco)
+            };
+
+            let mut form: Vec<View<Msg>> = vec![
+                qillqa::mesa::picker(&model.qillqa.mesa, theme, lift),
+                etiqueta_form("TIPO", theme),
+                campo(
+                    &model.mesa_tipo,
+                    "ingreso / informe / resolución…",
+                    crate::model::FocusedInput::MesaTipo,
+                ),
+                etiqueta_form("PARTES / DESCRIPCIÓN (va al contenido)", theme),
+                campo(
+                    &model.mesa_partes,
+                    "Juana Poma — solicitud de licencia",
+                    crate::model::FocusedInput::MesaPartes,
+                ),
+                etiqueta_form("ENLACES DE EXPEDIENTE (seqs, opcional)", theme),
+                campo(
+                    &model.mesa_refs,
+                    "ej: 1,3",
+                    crate::model::FocusedInput::MesaRefs,
+                ),
+                crate::ui::boton_frac(
+                    // "+" ASCII a propósito: el "+" fullwidth que usa el
+                    // standalone no está en la fuente de agora y sale tofu.
+                    "+ asentar en la cola",
+                    1.0,
+                    30.0,
+                    &crate::ui::button_palette_primary(theme),
+                    // El `Msg` se hornea al pintar, así que lleva el texto tal
+                    // como está en el momento del click (igual que el Enter).
+                    model.msg_asentar_mesa(),
+                ),
+            ];
+            if let Some(e) = qillqa::mesa::error_view(&model.qillqa.mesa, theme) {
+                form.push(e);
+            }
+
+            let bloque_form = View::new(Style {
+                flex_direction: FlexDirection::Column,
+                size: Size {
+                    width: percent(1.0_f32),
+                    height: llimphi_ui::llimphi_layout::taffy::prelude::Dimension::auto(),
+                },
+                flex_shrink: 0.0,
+                gap: Size {
+                    width: length(0.0_f32),
+                    height: length(4.0_f32),
+                },
+                ..Default::default()
+            })
+            .children(form);
+
+            let cola = qillqa::mesa::sidebar(&model.qillqa.mesa, theme, filtro, lift);
+
+            View::new(Style {
+                flex_direction: FlexDirection::Column,
+                size: Size {
+                    width: percent(1.0_f32),
+                    height: percent(1.0_f32),
+                },
+                gap: Size {
+                    width: length(0.0_f32),
+                    height: length(8.0_f32),
+                },
+                padding: Rect {
+                    left: length(10.0_f32),
+                    right: length(10.0_f32),
+                    top: length(8.0_f32),
+                    bottom: length(0.0_f32),
+                },
+                ..Default::default()
+            })
+            // El form ocupa lo suyo; la cola se queda con el resto (`grow` le
+            // pone flex_grow/basis-0/min-0 — sin eso el `height: 100%` de la
+            // lista desbordaría el panel).
+            .children(vec![bloque_form, crate::ui::grow(cola)])
+        }
+        ModuloId::Veeduria => {
+            qillqa::veeduria::sidebar(&model.qillqa.veeduria, theme, filtro, |m| Msg::Qillqa(qillqa::Msg::Veeduria(m)))
+        }
+        ModuloId::Compras => {
+            qillqa::compras::sidebar(&model.qillqa.compras, theme, filtro, |m| Msg::Qillqa(qillqa::Msg::Compras(m)))
+        }
+        ModuloId::Autoridad => {
+            qillqa::autoridad::sidebar(&model.qillqa.autoridad, theme, filtro, |m| Msg::Qillqa(qillqa::Msg::Autoridad(m)))
+        }
+    }
+}
+
+/// Etiqueta de un campo del formulario del índice (rótulo tenue sobre el input).
+fn etiqueta_form(t: &str, theme: &Theme) -> View<Msg> {
+    View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(15.0_f32),
+        },
+        flex_shrink: 0.0,
+        ..Default::default()
+    })
+    .text(t.to_string(), 10.5, theme.accent)
+}
+
+/// Filtro de filas por substring case-insensitive del label. Vacío = pasa todo.
+/// Compartido por los índices con lista (buscador del sidebar unificado).
+pub(crate) fn filtrar_rows<H>(
+    rows: Vec<llimphi_widget_list::ListRow<H>>,
+    filtro: &str,
+) -> Vec<llimphi_widget_list::ListRow<H>> {
+    if filtro.is_empty() {
+        return rows;
+    }
+    let f = filtro.to_lowercase();
+    rows.into_iter().filter(|r| r.label.to_lowercase().contains(&f)).collect()
+}
+
+/// El **canvas** del módulo: contenido/formulario/grafo del ítem activo.
+pub(crate) fn canvas(id: ModuloId, model: &Model, theme: &Theme) -> View<Msg> {
+    match id {
+        ModuloId::Identidades => agora::canvas_identidades(model, theme),
+        ModuloId::Atestaciones => agora::canvas_atestaciones(model, theme),
+        ModuloId::Politica => crate::tiles::politica_view(model, theme),
+        ModuloId::Multifirma => crate::tiles::multifirma_view(model, theme),
+        ModuloId::Actas => crate::tiles::actas_view(model, theme),
+        ModuloId::Release => crate::tiles::release_view(model, theme),
+        ModuloId::Capacidad => crate::tiles::capacidad_view(model, theme),
+        ModuloId::Verificar => {
+            qillqa::verificar::canvas(&model.qillqa.verificar, theme, |m| Msg::Qillqa(qillqa::Msg::Verificar(m)))
+        }
+        ModuloId::Registro => {
+            qillqa::registro::canvas(&model.qillqa.registro, theme, |m| Msg::Qillqa(qillqa::Msg::Registro(m)))
+        }
+        ModuloId::Mesa => {
+            qillqa::mesa::canvas(&model.qillqa.mesa, theme, |m| Msg::Qillqa(qillqa::Msg::Mesa(m)))
+        }
+        ModuloId::Veeduria => {
+            // El shell aporta la confianza: la sabe la red de agora, y el módulo
+            // qillqa no depende de agora. `ahora()` porque un aval de clave
+            // revocada no debe seguir dando confianza.
+            qillqa::veeduria::canvas(
+                &model.qillqa.veeduria,
+                theme,
+                &|pk| model.confianza_en(pk, crate::ahora()),
+                |m| Msg::Qillqa(qillqa::Msg::Veeduria(m)),
+            )
+        }
+        ModuloId::Compras => {
+            qillqa::compras::canvas(&model.qillqa.compras, theme, |m| Msg::Qillqa(qillqa::Msg::Compras(m)))
+        }
+        ModuloId::Autoridad => {
+            qillqa::autoridad::canvas(&model.qillqa.autoridad, theme, |m| Msg::Qillqa(qillqa::Msg::Autoridad(m)))
+        }
+    }
+}
+
+/// Vistas de los módulos agora (sustrato de confianza + control wawa).
+mod agora;
diff --git a/03_ukupacha/agora/agora-app/src/modulos/qillqa/autoridad.rs b/03_ukupacha/agora/agora-app/src/modulos/qillqa/autoridad.rs
new file mode 100644
index 0000000..c672acd
--- /dev/null
+++ b/03_ukupacha/agora/agora-app/src/modulos/qillqa/autoridad.rs
@@ -0,0 +1,1114 @@
+//! Módulo **Autoridad** (`qillqa-core::gestion`): la ceremonia de traspaso de
+//! gestión M-de-N. La identidad de la organización no es una llave sino un
+//! conjunto de guardianes con umbral; el traspaso —las elecciones— lo firman,
+//! con su umbral, los guardianes SALIENTES autorizando a los entrantes. En el
+//! **GÉNESIS** no hay salientes: los propios entrantes se autofirman, y ése es
+//! el acto que FUNDA la autoridad.
+//!
+//! - **Sidebar** = la gestión vigente (época, guardianes, umbral M-de-N) —o
+//!   «autoridad de llave única» si el registro nunca traspasó— más el linaje
+//!   REAL de la llave de cadena leído del registro.
+//! - **Canvas** = la ceremonia: proponer la autoridad entrante (toggles sobre
+//!   las llaves conocidas + umbral ±), elegir si se entrega también la **llave
+//!   de cadena** (rotación M4), recoger las firmas de los autorizantes con una
+//!   barra de progreso M-de-N, y **Asentar** GATEADO hasta alcanzar el umbral
+//!   (`Traspaso::autorizado_por`, que reusa `gestion::umbral_alcanzado`).
+//!
+//! El traspaso se **encadena de verdad** en un `Registro` real (`traspasar` /
+//! `traspasar_rotando`) y se verifica de cortesía con `verificar_registro`: el
+//! linaje que el sidebar pinta es el del log, no una nota de texto.
+//!
+//! La lógica cripto vive en `qillqa-core` (regla dura #2): esto sólo pinta y
+//! orquesta clicks. Espeja `qillqa-autoridad-llimphi` sin la persistencia a
+//! disco (la sala opera un registro en memoria).
+
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::prelude::{
+    length, percent, AlignItems, FlexDirection, JustifyContent, Rect as TaffyRect, Size, Style,
+};
+use llimphi_ui::llimphi_raster::peniko::Color;
+use llimphi_ui::View;
+
+use llimphi_widget_banner::{banner_view, BannerKind};
+use llimphi_widget_button::{button_view, ButtonPalette};
+use llimphi_widget_progress::linear_progress_view;
+
+use qillqa_core::gestion::autorizantes_de;
+use qillqa_core::{
+    par_desde_semilla, verificar_registro, FirmaGuardian, Gestion, KeyPair, Registro, Traspaso,
+};
+
+use super::hex_corto;
+
+/// Clave pública = 32 bytes (igual que `qillqa_core::ClavePublica`).
+type Pk = [u8; 32];
+
+/// Inicio (segundos UNIX) de la gestión génesis que la sala propone. La sala es
+/// una demo determinista: el reloj no entra en el canónico por sorpresa.
+const INICIO_GENESIS: u64 = 1_783_000_000;
+/// Cuánto dura una época en la demo (lo que se le suma al inicio de la vigente).
+const DURACION_EPOCA: u64 = 100_000;
+
+// ---------------------------------------------------------------------------
+//  Estado
+// ---------------------------------------------------------------------------
+
+/// Las dos fases guiadas de la ceremonia.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Fase {
+    /// Elegir guardianes entrantes + umbral; congela la propuesta al iniciar.
+    Editar,
+    /// Los autorizantes firman la propuesta congelada, uno a uno.
+    Firmar,
+}
+
+pub(crate) struct State {
+    /// El registro real sobre el que se encadenan los traspasos. La sala no
+    /// simula el linaje: lo escribe.
+    registro: Registro,
+    /// La semilla de la llave de cadena VIGENTE del registro (la que asienta).
+    /// Tras una rotación pasa a ser la entrante.
+    semilla: Pk,
+    /// La gestión vigente, leída del registro (`Registro::gestion_vigente`).
+    ///
+    /// `None` = **génesis**: el registro nunca traspasó, la autoridad es de
+    /// llave única (M0). Es el caso que FUNDA la autoridad, y la regla de quién
+    /// autoriza en él la dicta el core (`autorizantes_de`: se autofirman los
+    /// entrantes).
+    vigente: Option<Gestion>,
+    /// Llaves conocidas (nombre, semilla) — candidatas a guardián y a firmante.
+    /// La semilla queda del lado demo; en producción sólo se tendría la privada
+    /// de quien firma (los salientes).
+    disponibles: Vec<(String, Pk)>,
+    /// Paralelo a `disponibles`: elegido como guardián entrante.
+    sel: Vec<bool>,
+    /// Umbral propuesto (M) para la gestión entrante.
+    umbral: u32,
+    fase: Fase,
+    /// La gestión propuesta, congelada al iniciar la ceremonia (su canónico no
+    /// puede moverse mientras se recogen firmas).
+    propuesta: Option<Gestion>,
+    /// Firmas de los autorizantes recogidas hasta ahora.
+    firmas: Vec<FirmaGuardian>,
+    /// ¿La ceremonia entrega también la LLAVE DE CADENA (rotación M4)? Se elige
+    /// en la fase Editar; al iniciar, la llave entrante queda DENTRO del
+    /// canónico que firman los autorizantes.
+    rotar: bool,
+    /// La semilla de la llave entrante, generada al iniciar si `rotar`. Tras
+    /// asentar pasa a ser la semilla vigente del registro.
+    semilla_entrante: Option<Pk>,
+    /// Mensaje puntual: `(es_error, texto)`.
+    mensaje: Option<(bool, String)>,
+}
+
+/// La sala arranca en el **génesis**: un registro recién fundado, sin ninguna
+/// gestión asentada. La primera ceremonia FUNDA la autoridad (época 0,
+/// autofirmada por los entrantes); de ahí en adelante rige la rotación con los
+/// salientes autorizando.
+pub(crate) fn nueva() -> State {
+    let disponibles: Vec<(String, Pk)> = vec![
+        ("alcalde".into(), [10u8; 32]),
+        ("secretario".into(), [11u8; 32]),
+        ("regidor".into(), [12u8; 32]),
+        ("concejal".into(), [13u8; 32]),
+    ];
+    let semilla: Pk = [1u8; 32];
+    // Se funda una gestión 2-de-3 para que la sala ABRA en su función estrella:
+    // el traspaso M-de-N, que es lo que el módulo se llama. Arrancar en el
+    // génesis dejaba la rotación invisible hasta que alguien la armara a clicks.
+    //
+    // El génesis NO queda como rama muerta: `Msg::Refundar` vuelve a un registro
+    // sin traspasos y la ceremonia fundacional se ejercita de verdad, con un
+    // control real (no un estado que sólo alcanzan los tests).
+    let mut registro = Registro::nuevo("mesa-de-partes", semilla);
+    let fundacional = Gestion::nueva(
+        0,
+        disponibles[..3].iter().map(|(_, pk)| pk_de(pk)).collect(),
+        2,
+        INICIO_GENESIS,
+    );
+    let pares: Vec<KeyPair> = disponibles[..3]
+        .iter()
+        .map(|(_, s)| par_desde_semilla(*s))
+        .collect();
+    let refs: Vec<&KeyPair> = pares.iter().collect();
+    // Génesis: autorizan los propios entrantes (regla del §4, `autorizantes_de`).
+    registro.traspasar(Traspaso::firmar(fundacional, &refs));
+    // La regla de lectura la dicta el core: registro sin traspasos = `None`.
+    let vigente = registro.gestion_vigente();
+
+    let n = disponibles.len();
+    State {
+        registro,
+        semilla,
+        umbral: vigente.as_ref().map(|g| g.umbral).unwrap_or(1),
+        vigente,
+        disponibles,
+        sel: vec![false; n],
+        fase: Fase::Editar,
+        propuesta: None,
+        firmas: Vec::new(),
+        rotar: false,
+        semilla_entrante: None,
+        mensaje: None,
+    }
+}
+
+// ---------------------------------------------------------------------------
+//  Mensajes
+// ---------------------------------------------------------------------------
+
+#[derive(Clone)]
+pub(crate) enum Msg {
+    /// Alterna un candidato como guardián entrante.
+    ToggleGuardian(usize),
+    /// Alterna la rotación de la llave de cadena (M4).
+    ToggleRotar,
+    /// Umbral propuesto ±.
+    UmbralMas,
+    UmbralMenos,
+    /// Congela la propuesta y pasa a la fase de firmas.
+    Iniciar,
+    /// Descarta la propuesta y vuelve a editar.
+    Cancelar,
+    /// El autorizante en la posición `idx` firma.
+    Firmar(usize),
+    /// Encadena el traspaso si se alcanzó el umbral.
+    Asentar,
+    /// Vuelve a un registro SIN traspasos: la autoridad de llave única (M0), el
+    /// caso génesis. Sin este control la rama `vigente: None` sería UI muerta.
+    Refundar,
+}
+
+// ---------------------------------------------------------------------------
+//  Cómputo derivado
+// ---------------------------------------------------------------------------
+
+fn pk_de(seed: &Pk) -> Pk {
+    *par_desde_semilla(*seed).pk
+}
+
+/// Los guardianes entrantes elegidos (pubkeys).
+fn seleccionados(st: &State) -> Vec<Pk> {
+    st.disponibles
+        .iter()
+        .zip(&st.sel)
+        .filter(|(_, s)| **s)
+        .map(|((_, seed), _)| pk_de(seed))
+        .collect()
+}
+
+/// La época que le toca a la gestión entrante: la siguiente a la vigente, o
+/// **0** en el génesis (no hay `vigente.epoca` del que derivar).
+fn epoca_entrante(st: &State) -> u64 {
+    st.vigente.as_ref().map(|g| g.epoca + 1).unwrap_or(0)
+}
+
+/// El inicio de la gestión entrante.
+fn inicio_entrante(st: &State) -> u64 {
+    st.vigente
+        .as_ref()
+        .map(|g| g.inicio + DURACION_EPOCA)
+        .unwrap_or(INICIO_GENESIS)
+}
+
+/// La gestión que la selección actual propondría con `umbral`. Sirve para
+/// preguntarle al core —y no a una copia a mano— si la propuesta es coherente.
+fn candidata(st: &State, umbral: u32) -> Gestion {
+    Gestion::nueva(
+        epoca_entrante(st),
+        seleccionados(st),
+        umbral,
+        inicio_entrante(st),
+    )
+}
+
+/// ¿La propuesta en edición es válida? La regla (`1 <= umbral <= N`, sin vacío
+/// ni duplicados) la codifica `Gestion::valida()` y es la MISMA que aplica
+/// `verificar_registro` — copiarla a mano aquí era arriesgar asentar algo que el
+/// verificador después rechaza (y perderse el chequeo de duplicados).
+fn propuesta_valida(st: &State) -> bool {
+    candidata(st, st.umbral).valida()
+}
+
+/// Quiénes autorizan el traspaso y con qué umbral. La regla la dicta el core
+/// (`autorizantes_de`): autorizan los SALIENTES, y en el génesis —sin gestión
+/// previa— los propios entrantes. Aquí sólo se decide qué gestión juega de
+/// entrante.
+fn autorizantes(st: &State) -> (Vec<Pk>, u32) {
+    match &st.propuesta {
+        Some(p) => autorizantes_de(st.vigente.as_ref(), p),
+        // Sin propuesta todavía no hay entrante: rige la vigente, si la hay.
+        None => match &st.vigente {
+            Some(g) => (g.guardianes.clone(), g.umbral),
+            None => (Vec::new(), 0),
+        },
+    }
+}
+
+/// Firmantes DISTINTOS válidos recogidos hasta ahora (la barra M-de-N). El conteo
+/// lo trae `Traspaso::verdicto` exacto y de una pasada; antes se deducía llamando
+/// a `autorizado_por` una vez por cada k en `(0..=n).rev()` — O(N) verificaciones
+/// Ed25519 del set entero EN CADA FRAME de render.
+fn firmas_validas(st: &State) -> u32 {
+    let Some(p) = &st.propuesta else { return 0 };
+    let (auts, _) = autorizantes(st);
+    let t = Traspaso {
+        gestion: p.clone(),
+        firmas: st.firmas.clone(),
+    };
+    t.verdicto(&auts).distintos as u32
+}
+
+/// El nombre de una llave conocida, o su hex corto.
+fn nombre_de(st: &State, pk: &Pk) -> String {
+    st.disponibles
+        .iter()
+        .find(|(_, s)| pk_de(s) == *pk)
+        .map(|(n, _)| n.clone())
+        .unwrap_or_else(|| hex_corto(pk))
+}
+
+/// Semilla nueva del SO para la llave de cadena entrante.
+fn semilla_aleatoria() -> Pk {
+    use rand::RngCore;
+    let mut s: Pk = [0u8; 32];
+    rand::thread_rng().fill_bytes(&mut s);
+    s
+}
+
+// ---------------------------------------------------------------------------
+//  Transición
+// ---------------------------------------------------------------------------
+
+pub(crate) fn update(st: &mut State, msg: Msg) {
+    match msg {
+        Msg::Refundar => {
+            // Registro nuevo, sin traspasos → `gestion_vigente()` da `None`: la
+            // autoridad de llave única, antes de que exista gestión alguna.
+            let n = st.disponibles.len();
+            st.registro = Registro::nuevo("mesa-de-partes", st.semilla);
+            st.vigente = st.registro.gestion_vigente();
+            st.umbral = 1;
+            st.sel = vec![false; n];
+            st.fase = Fase::Editar;
+            st.propuesta = None;
+            st.firmas = Vec::new();
+            st.rotar = false;
+            st.semilla_entrante = None;
+            st.mensaje = None;
+        }
+        Msg::ToggleGuardian(i) => {
+            if let Some(s) = st.sel.get_mut(i) {
+                *s = !*s;
+            }
+            // El umbral no puede exceder los guardianes elegidos: bajarlo hasta
+            // que el core acepte la propuesta (o hasta el piso, 1).
+            while st.umbral > 1 && !propuesta_valida(st) {
+                st.umbral -= 1;
+            }
+        }
+        Msg::ToggleRotar => st.rotar = !st.rotar,
+        Msg::UmbralMas => {
+            if candidata(st, st.umbral + 1).valida() {
+                st.umbral += 1;
+            }
+        }
+        Msg::UmbralMenos => {
+            // `umbral - 1 == 0` no es válida para el core: el piso sale solo.
+            if candidata(st, st.umbral.saturating_sub(1)).valida() {
+                st.umbral -= 1;
+            }
+        }
+        Msg::Iniciar => {
+            let mut g = candidata(st, st.umbral);
+            if !g.valida() {
+                st.mensaje = Some((true, "elige guardianes y un umbral válido".into()));
+                return;
+            }
+            // Rotación: generar la semilla entrante ANTES de congelar la
+            // propuesta — la llave va dentro del canónico que se firmará, así
+            // nadie instala una llave que los autorizantes no autorizaron.
+            if st.rotar {
+                let s = semilla_aleatoria();
+                g = g.con_llave_cadena(pk_de(&s));
+                st.semilla_entrante = Some(s);
+            } else {
+                st.semilla_entrante = None;
+            }
+            st.propuesta = Some(g);
+            st.firmas.clear();
+            st.mensaje = None;
+            st.fase = Fase::Firmar;
+        }
+        Msg::Cancelar => {
+            st.propuesta = None;
+            st.firmas.clear();
+            st.semilla_entrante = None;
+            st.mensaje = None;
+            st.fase = Fase::Editar;
+        }
+        Msg::Firmar(i) => {
+            let Some(p) = st.propuesta.clone() else { return };
+            let (auts, _) = autorizantes(st);
+            let Some(pk) = auts.get(i).copied() else {
+                return;
+            };
+            // ¿Tenemos la llave de ese autorizante? (demo: está en `disponibles`).
+            let seed = st
+                .disponibles
+                .iter()
+                .find(|(_, s)| pk_de(s) == pk)
+                .map(|(_, s)| *s);
+            match seed {
+                Some(seed) => {
+                    let kp = par_desde_semilla(seed);
+                    let t = Traspaso::firmar(p, &[&kp]);
+                    if let Some(f) = t.firmas.into_iter().next() {
+                        if !st.firmas.iter().any(|x| x.guardian == f.guardian) {
+                            st.firmas.push(f);
+                        }
+                    }
+                }
+                None => {
+                    let nom = nombre_de(st, &pk);
+                    st.mensaje = Some((true, format!("no tengo la llave de {nom}")));
+                }
+            }
+        }
+        Msg::Asentar => {
+            let Some(p) = st.propuesta.clone() else { return };
+            let (auts, um) = autorizantes(st);
+            let t = Traspaso {
+                gestion: p.clone(),
+                firmas: st.firmas.clone(),
+            };
+            // El umbral es la frontera: sin él, no se asienta (el golpe no entra
+            // por la UI).
+            if !t.autorizado_por(&auts, um) {
+                st.mensaje = Some((true, "faltan firmas para alcanzar el umbral".into()));
+                return;
+            }
+            let fundacion = st.vigente.is_none();
+            let rotado = st.semilla_entrante.is_some();
+            // Con rotación: la saliente asienta la entrega y desde ahí firma la
+            // entrante; la semilla vigente pasa a ser la nueva.
+            let seq = match st.semilla_entrante {
+                Some(s) => match st.registro.traspasar_rotando(t, s) {
+                    Ok(seq) => {
+                        st.semilla = s;
+                        seq
+                    }
+                    Err(e) => {
+                        st.mensaje = Some((true, format!("no se pudo rotar: {e}")));
+                        return;
+                    }
+                },
+                None => st.registro.traspasar(t),
+            };
+            // Verificación de cortesía: lo que se acaba de encadenar, ¿verifica?
+            let serie = st.registro.serie.clone();
+            let ok = verificar_registro(st.registro.log(), &serie, &[]).is_ok();
+            st.vigente = st.registro.gestion_vigente();
+            let epoca = p.epoca;
+            st.mensaje = Some((
+                !ok,
+                if !ok {
+                    "traspaso asentado pero el registro no verifica".into()
+                } else if rotado {
+                    format!(
+                        "{} en seq {seq} (época {epoca}) — LLAVE DE CADENA ROTADA: la saliente ya no puede asentar",
+                        if fundacion {
+                            "autoridad FUNDADA"
+                        } else {
+                            "traspaso asentado"
+                        }
+                    )
+                } else if fundacion {
+                    format!("autoridad FUNDADA en seq {seq} — gestión génesis (época {epoca}) autofirmada por sus entrantes")
+                } else {
+                    format!("traspaso asentado en seq {seq} — nueva gestión vigente (época {epoca})")
+                },
+            ));
+            // Ceremonia limpia para la próxima.
+            st.propuesta = None;
+            st.firmas.clear();
+            st.semilla_entrante = None;
+            st.rotar = false;
+            st.sel = vec![false; st.disponibles.len()];
+            st.umbral = 1;
+            st.fase = Fase::Editar;
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------
+//  Vistas — helpers
+// ---------------------------------------------------------------------------
+
+fn fila(h: f32) -> Style {
+    Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(h),
+        },
+        flex_shrink: 0.0,
+        ..Default::default()
+    }
+}
+
+/// Una línea de texto de altura fija.
+fn linea<H: Clone + Send + Sync + 'static>(t: String, size: f32, color: Color) -> View<H> {
+    View::new(fila(size + 6.0)).text(t, size, color)
+}
+
+/// Botón chico cuadrado (± del stepper de umbral).
+fn boton_chico<H: Clone + Send + Sync + 'static>(label: &str, msg: H, theme: &Theme) -> View<H> {
+    View::new(Style {
+        size: Size {
+            width: length(30.0_f32),
+            height: length(26.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        justify_content: Some(JustifyContent::Center),
+        flex_shrink: 0.0,
+        ..Default::default()
+    })
+    .fill(theme.bg_button)
+    .radius(5.0)
+    .text(label.to_string(), 15.0, theme.fg_text)
+    .on_click(msg)
+}
+
+/// Contenedor columna con padding.
+fn columna<H: Clone + Send + Sync + 'static>(hijos: Vec<View<H>>) -> View<H> {
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(6.0_f32),
+        },
+        padding: TaffyRect {
+            left: length(14.0_f32),
+            right: length(14.0_f32),
+            top: length(12.0_f32),
+            bottom: length(12.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(hijos)
+}
+
+// ---------------------------------------------------------------------------
+//  Sidebar — la gestión vigente y el linaje
+// ---------------------------------------------------------------------------
+
+pub(crate) fn sidebar<H: Clone + Send + Sync + 'static>(
+    state: &State,
+    theme: &Theme,
+    // Índice de puro metadato (sin lista): el buscador no tiene filas que filtrar.
+    _filtro: &str,
+    lift: impl Fn(Msg) -> H + Clone + Send + Sync + 'static,
+) -> View<H> {
+    let mut hijos: Vec<View<H>> = vec![linea("GESTIÓN VIGENTE".into(), 12.0, theme.accent)];
+    match &state.vigente {
+        // El génesis: hay registro, pero todavía no hay autoridad M-de-N.
+        None => hijos.push(linea(
+            "autoridad de llave única (sin traspaso)".into(),
+            12.0,
+            theme.fg_muted,
+        )),
+        Some(g) => {
+            hijos.push(linea(
+                format!(
+                    "época {} · umbral {}/{}",
+                    g.epoca,
+                    g.umbral,
+                    g.guardianes.len()
+                ),
+                12.0,
+                theme.fg_muted,
+            ));
+            for gu in &g.guardianes {
+                hijos.push(linea(
+                    format!("• {}", nombre_de(state, gu)),
+                    13.0,
+                    theme.fg_text,
+                ));
+            }
+        }
+    }
+
+    // Linaje REAL de la llave de cadena, leído del registro: si hubo rotación,
+    // la vigente ≠ la génesis.
+    hijos.push(View::new(fila(10.0)));
+    hijos.push(linea("LINAJE".into(), 11.0, theme.accent));
+    let r = &state.registro;
+    if r.llave_vigente() != r.autoridad() {
+        hijos.push(linea(
+            "llave de cadena ROTADA".into(),
+            11.5,
+            theme.fg_destructive,
+        ));
+        hijos.push(linea(
+            format!("vigente: {}", hex_corto(&r.llave_vigente())),
+            12.0,
+            theme.fg_text,
+        ));
+        hijos.push(linea(
+            format!("génesis: {}", hex_corto(&r.autoridad())),
+            12.0,
+            theme.fg_muted,
+        ));
+    } else {
+        hijos.push(linea(
+            format!("llave de cadena: {} (sin rotar)", hex_corto(&r.autoridad())),
+            11.5,
+            theme.fg_muted,
+        ));
+    }
+    hijos.push(linea(
+        format!("altura del registro: {}", r.altura()),
+        11.5,
+        theme.fg_muted,
+    ));
+
+    // Volver al génesis. La sala abre con una gestión ya fundada (su función
+    // estrella es el TRASPASO, que es como se llama el módulo), así que sin este
+    // control la rama `vigente: None` —la autoridad de llave única, el caso que
+    // FUNDA— sería inalcanzable desde la UI: código muerto, como lo era el ROJO
+    // de la sala Registro.
+    if state.vigente.is_some() {
+        hijos.push(View::new(fila(10.0)));
+        hijos.push(
+            View::new(fila(30.0))
+                .children(vec![button_view("Volver al génesis", &ButtonPalette::from_theme(theme), lift(Msg::Refundar))]),
+        );
+        hijos.push(linea(
+            "descarta la gestión y vuelve a la autoridad de llave única".into(),
+            10.5,
+            theme.fg_muted,
+        ));
+    }
+
+    columna(hijos).fill(theme.bg_panel)
+}
+
+// ---------------------------------------------------------------------------
+//  Canvas — la ceremonia
+// ---------------------------------------------------------------------------
+
+pub(crate) fn canvas<H: Clone + Send + Sync + 'static>(
+    state: &State,
+    theme: &Theme,
+    lift: impl Fn(Msg) -> H + Clone + Send + Sync + 'static,
+) -> View<H> {
+    let bpal = ButtonPalette::from_theme(theme);
+    let epoca_nueva = epoca_entrante(state);
+    let titulo = if state.vigente.is_none() {
+        format!("CEREMONIA FUNDACIONAL (GÉNESIS) → época {epoca_nueva}")
+    } else {
+        format!("CEREMONIA DE TRASPASO → época {epoca_nueva}")
+    };
+    let mut hijos: Vec<View<H>> = vec![linea(titulo, 13.0, theme.accent)];
+
+    match state.fase {
+        Fase::Editar => {
+            hijos.push(linea(
+                "Elige los guardianes entrantes:".into(),
+                12.0,
+                theme.fg_muted,
+            ));
+            for (i, (nombre, _)) in state.disponibles.iter().enumerate() {
+                let on = state.sel.get(i).copied().unwrap_or(false);
+                let marca = if on { "☑" } else { "☐" };
+                let col = if on { theme.accent } else { theme.fg_text };
+                hijos.push(
+                    View::new(fila(26.0))
+                        .text(format!("{marca}  {nombre}"), 13.0, col)
+                        .on_click(lift(Msg::ToggleGuardian(i))),
+                );
+            }
+
+            // Stepper de umbral.
+            let n_sel = seleccionados(state).len();
+            let stepper = View::new(Style {
+                flex_direction: FlexDirection::Row,
+                size: Size {
+                    width: percent(1.0_f32),
+                    height: length(30.0_f32),
+                },
+                gap: Size {
+                    width: length(8.0_f32),
+                    height: length(0.0_f32),
+                },
+                align_items: Some(AlignItems::Center),
+                flex_shrink: 0.0,
+                ..Default::default()
+            })
+            .children(vec![
+                View::new(Style {
+                    size: Size {
+                        width: length(150.0_f32),
+                        height: length(20.0_f32),
+                    },
+                    ..Default::default()
+                })
+                .text(
+                    format!("umbral: {} de {}", state.umbral, n_sel.max(1)),
+                    13.0,
+                    theme.fg_text,
+                ),
+                boton_chico("−", lift(Msg::UmbralMenos), theme),
+                boton_chico("+", lift(Msg::UmbralMas), theme),
+            ]);
+            hijos.push(stepper);
+
+            // Rotación de la llave de cadena (M4): la entrega física del poder
+            // de asentar. La llave entrante queda dentro del canónico firmado.
+            let marca_rotar = if state.rotar { "☑" } else { "☐" };
+            let col_rotar = if state.rotar {
+                theme.accent
+            } else {
+                theme.fg_text
+            };
+            hijos.push(
+                View::new(fila(26.0))
+                    .text(
+                        format!("{marca_rotar}  entregar también la llave de cadena (la saliente ya no podrá asentar)"),
+                        12.5,
+                        col_rotar,
+                    )
+                    .on_click(lift(Msg::ToggleRotar)),
+            );
+
+            // Iniciar — gateado por la validez que dicta el core.
+            hijos.push(View::new(fila(40.0)).children(vec![if propuesta_valida(state) {
+                button_view("Iniciar ceremonia", &bpal, lift(Msg::Iniciar))
+            } else {
+                linea(
+                    "(elige guardianes y un umbral válido)".into(),
+                    12.0,
+                    theme.fg_muted,
+                )
+            }]));
+        }
+        Fase::Firmar => {
+            let (auts, um) = autorizantes(state);
+            let tengo = firmas_validas(state);
+            // En el génesis no hay salientes: firman los propios entrantes.
+            let quien = if state.vigente.is_some() {
+                "salientes"
+            } else {
+                "entrantes (génesis)"
+            };
+            hijos.push(linea(
+                format!("Firmas de los guardianes {quien}: {tengo} de {um}"),
+                12.0,
+                theme.fg_muted,
+            ));
+            if state
+                .propuesta
+                .as_ref()
+                .is_some_and(|p| p.llave_cadena.is_some())
+            {
+                hijos.push(linea(
+                    "con ROTACIÓN de la llave de cadena — lo que firman incluye la llave entrante"
+                        .into(),
+                    12.0,
+                    theme.accent,
+                ));
+            }
+            hijos.push(
+                View::new(Style {
+                    size: Size {
+                        width: percent(1.0_f32),
+                        height: length(10.0_f32),
+                    },
+                    flex_shrink: 0.0,
+                    ..Default::default()
+                })
+                .children(vec![linear_progress_view(
+                    if um == 0 {
+                        0.0
+                    } else {
+                        (tengo as f32 / um as f32).min(1.0)
+                    },
+                    theme.bg_input,
+                    theme.accent,
+                    8.0,
+                )]),
+            );
+
+            for (i, a) in auts.iter().enumerate() {
+                let firmo = state.firmas.iter().any(|f| &f.guardian == a);
+                let (marca, col): (&str, Color) = if firmo {
+                    ("✓ firmó", theme.accent)
+                } else {
+                    ("firmar", theme.fg_text)
+                };
+                let mut row = View::new(Style {
+                    flex_direction: FlexDirection::Row,
+                    size: Size {
+                        width: percent(1.0_f32),
+                        height: length(28.0_f32),
+                    },
+                    align_items: Some(AlignItems::Center),
+                    justify_content: Some(JustifyContent::SpaceBetween),
+                    flex_shrink: 0.0,
+                    ..Default::default()
+                })
+                .children(vec![
+                    View::new(Style {
+                        size: Size {
+                            width: percent(0.6_f32),
+                            height: length(20.0_f32),
+                        },
+                        ..Default::default()
+                    })
+                    .text(nombre_de(state, a), 13.0, theme.fg_text),
+                    View::new(Style {
+                        size: Size {
+                            width: length(90.0_f32),
+                            height: length(22.0_f32),
+                        },
+                        align_items: Some(AlignItems::Center),
+                        justify_content: Some(JustifyContent::Center),
+                        ..Default::default()
+                    })
+                    .text(marca.to_string(), 12.0, col),
+                ]);
+                if !firmo {
+                    row = row.on_click(lift(Msg::Firmar(i)));
+                }
+                hijos.push(row);
+            }
+
+            // Acciones: Cancelar + Asentar (gateado por el umbral).
+            let puede = tengo >= um && um > 0;
+            let acciones = View::new(Style {
+                flex_direction: FlexDirection::Row,
+                size: Size {
+                    width: percent(1.0_f32),
+                    height: length(40.0_f32),
+                },
+                gap: Size {
+                    width: length(10.0_f32),
+                    height: length(0.0_f32),
+                },
+                flex_shrink: 0.0,
+                ..Default::default()
+            })
+            .children(vec![
+                View::new(Style {
+                    size: Size {
+                        width: length(120.0_f32),
+                        height: length(34.0_f32),
+                    },
+                    flex_shrink: 0.0,
+                    ..Default::default()
+                })
+                .children(vec![button_view("Cancelar", &bpal, lift(Msg::Cancelar))]),
+                View::new(Style {
+                    size: Size {
+                        width: length(180.0_f32),
+                        height: length(34.0_f32),
+                    },
+                    flex_shrink: 0.0,
+                    ..Default::default()
+                })
+                .children(vec![if puede {
+                    button_view("Asentar traspaso", &bpal, lift(Msg::Asentar))
+                } else {
+                    linea("(faltan firmas)".into(), 12.0, theme.fg_muted)
+                }]),
+            ]);
+            hijos.push(acciones);
+        }
+    }
+
+    // Banner de mensaje (confirmación / error).
+    if let Some((es_err, txt)) = &state.mensaje {
+        hijos.push(View::new(fila(10.0)));
+        hijos.push(banner_view::<H>(
+            if *es_err {
+                BannerKind::Error
+            } else {
+                BannerKind::Success
+            },
+            txt.clone(),
+        ));
+    }
+
+    columna(hijos)
+}
+
+// ---------------------------------------------------------------------------
+//  Tests — la ceremonia de punta a punta, sobre el registro real
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Índices de `disponibles` en `nueva()`.
+    const ALCALDE: usize = 0;
+    const SECRETARIO: usize = 1;
+    const REGIDOR: usize = 2;
+    const CONCEJAL: usize = 3;
+
+    fn kp(n: u8) -> qillqa_core::KeyPair {
+        par_desde_semilla([n; 32])
+    }
+
+    fn drive(st: &mut State, msgs: impl IntoIterator<Item = Msg>) {
+        for m in msgs {
+            update(st, m);
+        }
+    }
+
+    /// Funda la autoridad génesis 2-de-3 {alcalde, secretario, regidor} SÓLO con
+    /// clicks — el camino que un humano recorre en la sala.
+    /// Lleva la sala al GÉNESIS (autoridad de llave única) y funda desde ahí.
+    ///
+    /// `nueva()` abre con una gestión 2-de-3 ya fundada porque la función estrella
+    /// del módulo es el TRASPASO. Al génesis se llega con `Refundar`, que es un
+    /// botón real del sidebar — no un estado que sólo alcancen los tests.
+    fn fundar(st: &mut State) {
+        update(st, Msg::Refundar);
+        assert!(st.vigente.is_none(), "Refundar deja la autoridad de llave única");
+        fundar_desde_genesis(st);
+    }
+
+    /// La ceremonia fundacional, asumiendo que ya se está en el génesis.
+    fn fundar_desde_genesis(st: &mut State) {
+        drive(
+            st,
+            [
+                Msg::ToggleGuardian(ALCALDE),
+                Msg::ToggleGuardian(SECRETARIO),
+                Msg::ToggleGuardian(REGIDOR),
+                Msg::UmbralMas, // 1 → 2
+                Msg::Iniciar,
+                Msg::Firmar(0), // alcalde (autorizante = entrante, génesis)
+                Msg::Firmar(1), // secretario
+                Msg::Asentar,
+            ],
+        );
+    }
+
+    fn verifica(st: &State) -> bool {
+        let serie = st.registro.serie.clone();
+        verificar_registro(st.registro.log(), &serie, &[]).is_ok()
+    }
+
+    /// La sala ABRE en su función estrella (el traspaso M-de-N, que es como se
+    /// llama el módulo) y el génesis sigue siendo alcanzable con un control real.
+    /// Sin `Refundar`, la rama `vigente: None` sería UI muerta — el mismo pecado
+    /// que el ROJO inalcanzable de la sala Registro.
+    #[test]
+    fn abre_en_el_traspaso_y_el_genesis_sigue_alcanzable() {
+        let mut st = nueva();
+
+        // Abre mostrando la gestión fundacional: 2-de-3, época 0, encadenada.
+        let g = st.vigente.clone().expect("abre con gestión fundada");
+        assert_eq!((g.epoca, g.umbral, g.guardianes.len()), (0, 2, 3));
+        assert_eq!(st.registro.altura(), 1, "el traspaso fundacional está en el log");
+        assert!(verifica(&st), "la fundación de `nueva()` verifica");
+
+        // Y se puede volver al génesis: ida y vuelta real, no un reset cosmético.
+        update(&mut st, Msg::Refundar);
+        assert!(st.vigente.is_none());
+        assert_eq!(st.registro.altura(), 0);
+        fundar_desde_genesis(&mut st);
+        assert!(st.vigente.is_some(), "se vuelve a fundar desde el génesis");
+        assert!(verifica(&st));
+    }
+
+    /// **El génesis funda con autofirma de los ENTRANTES.** Con `Refundar` la sala
+    /// vuelve a la autoridad de llave única y la primera ceremonia instala la
+    /// época 0 firmada por los propios guardianes que entran (regla del §4).
+    #[test]
+    fn genesis_funda_con_autofirma_de_los_entrantes() {
+        let mut st = nueva();
+        assert!(st.vigente.is_some(), "la sala abre con la gestión ya fundada");
+
+        update(&mut st, Msg::Refundar);
+        assert!(st.vigente.is_none(), "Refundar deja la autoridad de llave única");
+        assert_eq!(st.registro.altura(), 0, "registro sin traspasos");
+
+        // En el génesis los autorizantes son los propios entrantes.
+        st.sel = vec![true, true, true, false];
+        st.propuesta = Some(candidata(&st, 2));
+        let (auts, um) = autorizantes(&st);
+        assert_eq!(
+            auts,
+            seleccionados(&st),
+            "génesis: se autofirman los entrantes"
+        );
+        assert_eq!(um, 2, "y con SU propio umbral");
+
+        let mut st = nueva();
+        fundar(&mut st);
+
+        let (es_err, txt) = st.mensaje.clone().expect("mensaje");
+        assert!(!es_err, "{txt}");
+        assert!(txt.contains("FUNDADA"), "{txt}");
+        let g = st.vigente.clone().expect("la autoridad quedó fundada");
+        assert_eq!(g.epoca, 0, "el génesis es la época 0, no vigente.epoca + 1");
+        assert_eq!(g.umbral, 2);
+        assert_eq!(g.guardianes.len(), 3);
+        assert_eq!(st.registro.altura(), 1, "el traspaso se encadenó de verdad");
+        assert!(verifica(&st), "el registro fundado verifica VERDE");
+        // Y la llave de cadena no se movió: la fundación fue aditiva.
+        assert_eq!(st.registro.llave_vigente(), st.registro.autoridad());
+    }
+
+    /// La ceremonia completa CON rotación: los SALIENTES firman → Asentar → la
+    /// llave de cadena vigente del registro es la entrante, la semilla de la sala
+    /// también, y el registro verifica VERDE.
+    #[test]
+    fn ceremonia_rotante_cambia_la_llave_vigente() {
+        let mut st = nueva();
+        fundar(&mut st); // época 0: {alcalde, secretario, regidor} 2-de-3
+        let genesis = st.registro.autoridad();
+
+        // Época 1: entra {secretario, regidor, concejal} 2-de-3, CON rotación.
+        drive(
+            &mut st,
+            [
+                Msg::ToggleGuardian(SECRETARIO),
+                Msg::ToggleGuardian(REGIDOR),
+                Msg::ToggleGuardian(CONCEJAL),
+                Msg::UmbralMas, // 1 → 2
+                Msg::ToggleRotar,
+                Msg::Iniciar,
+            ],
+        );
+        let entrante = pk_de(&st.semilla_entrante.expect("semilla entrante generada"));
+        assert_eq!(
+            st.propuesta.as_ref().unwrap().llave_cadena,
+            Some(entrante),
+            "la llave entrante va DENTRO del canónico que se firma"
+        );
+        // Los autorizantes son los SALIENTES (época 0), no los entrantes.
+        let (auts, um) = autorizantes(&st);
+        assert_eq!(auts, st.vigente.as_ref().unwrap().guardianes);
+        assert_eq!(um, 2);
+
+        drive(&mut st, [Msg::Firmar(0), Msg::Firmar(1), Msg::Asentar]);
+
+        let (es_err, txt) = st.mensaje.clone().expect("mensaje");
+        assert!(!es_err, "{txt}");
+        assert!(txt.contains("ROTADA"), "{txt}");
+        assert_eq!(st.registro.llave_vigente(), entrante);
+        assert_ne!(st.registro.llave_vigente(), genesis);
+        assert_eq!(
+            pk_de(&st.semilla),
+            entrante,
+            "la semilla vigente de la sala es la entrante"
+        );
+        assert_eq!(st.vigente.as_ref().unwrap().epoca, 1);
+        assert_eq!(st.registro.altura(), 2);
+        assert!(verifica(&st));
+        // La ceremonia quedó limpia para la próxima.
+        assert!(st.propuesta.is_none() && st.semilla_entrante.is_none() && !st.rotar);
+    }
+
+    /// **El golpe no entra por la UI**: los ENTRANTES no pueden autoinstalarse
+    /// habiendo salientes. Ni asienta, ni rota, ni mueve la gestión vigente.
+    #[test]
+    fn golpe_de_los_entrantes_no_rota() {
+        let mut st = nueva();
+        fundar(&mut st);
+        let altura = st.registro.altura();
+        let vigente = st.vigente.clone().expect("fundada");
+        let llave = st.registro.llave_vigente();
+
+        // Propuesta época 1 = sólo el concejal, firmada por el concejal mismo
+        // (un entrante). La sala nunca ofrece ese botón —`Firmar` indexa sobre
+        // los autorizantes, que son los salientes—, así que se inyecta a mano:
+        // lo que se prueba es que la frontera aguanta igual.
+        st.sel = vec![false, false, false, true];
+        st.rotar = true;
+        let semilla_entrante: Pk = [77u8; 32];
+        st.semilla_entrante = Some(semilla_entrante);
+        let g = candidata(&st, 1).con_llave_cadena(pk_de(&semilla_entrante));
+        st.firmas = Traspaso::firmar(g.clone(), &[&kp(13)]).firmas;
+        st.propuesta = Some(g);
+        st.fase = Fase::Firmar;
+
+        update(&mut st, Msg::Asentar);
+
+        let (es_err, _) = st.mensaje.clone().expect("mensaje");
+        assert!(es_err, "el golpe se rechaza");
+        assert_eq!(st.registro.altura(), altura, "nada se encadenó");
+        assert_eq!(st.registro.llave_vigente(), llave, "la llave no rotó");
+        assert_eq!(st.vigente.as_ref(), Some(&vigente), "la gestión no cambió");
+        assert!(st.propuesta.is_some(), "la ceremonia sigue abierta");
+        assert!(verifica(&st));
+    }
+
+    /// Sin rotar, el traspaso es ADITIVO: cambia quién es la autoridad, pero la
+    /// llave de cadena sigue siendo la génesis.
+    #[test]
+    fn ceremonia_sin_rotar_es_aditiva() {
+        let mut st = nueva();
+        fundar(&mut st);
+
+        drive(
+            &mut st,
+            [
+                Msg::ToggleGuardian(SECRETARIO),
+                Msg::ToggleGuardian(CONCEJAL),
+                Msg::UmbralMas, // 1 → 2
+                Msg::Iniciar,   // rotar = false
+                Msg::Firmar(0), // alcalde (saliente)
+                Msg::Firmar(1), // secretario (saliente)
+                Msg::Asentar,
+            ],
+        );
+
+        let (es_err, txt) = st.mensaje.clone().expect("mensaje");
+        assert!(!es_err, "{txt}");
+        assert!(!txt.contains("ROTADA"), "{txt}");
+        assert_eq!(
+            st.registro.llave_vigente(),
+            st.registro.autoridad(),
+            "sin rotación: sigue la génesis"
+        );
+        let g = st.vigente.clone().expect("vigente");
+        assert_eq!(g.epoca, 1);
+        assert_eq!(g.guardianes.len(), 2, "la autoridad SÍ cambió de manos");
+        assert!(verifica(&st));
+    }
+
+    /// El umbral que la sala deja proponer es el que `Gestion::valida()` acepta:
+    /// nunca 0, nunca > N, y baja solo al deseleccionar guardianes.
+    #[test]
+    fn el_stepper_de_umbral_lo_gobierna_gestion_valida() {
+        let mut st = nueva();
+        assert!(
+            !propuesta_valida(&st),
+            "sin guardianes no hay propuesta válida"
+        );
+
+        drive(
+            &mut st,
+            [Msg::ToggleGuardian(ALCALDE), Msg::ToggleGuardian(SECRETARIO)],
+        );
+        assert_eq!(st.umbral, 1);
+        update(&mut st, Msg::UmbralMenos);
+        assert_eq!(st.umbral, 1, "el piso es 1: umbral 0 no es válido");
+        drive(&mut st, [Msg::UmbralMas, Msg::UmbralMas]);
+        assert_eq!(st.umbral, 2, "el techo es N = 2 guardianes elegidos");
+        // Deseleccionar uno baja el umbral solo (2-de-1 no existe).
+        update(&mut st, Msg::ToggleGuardian(SECRETARIO));
+        assert_eq!(st.umbral, 1);
+        assert!(propuesta_valida(&st));
+    }
+}
diff --git a/03_ukupacha/agora/agora-app/src/modulos/qillqa/compras.rs b/03_ukupacha/agora/agora-app/src/modulos/qillqa/compras.rs
new file mode 100644
index 0000000..e576ab0
--- /dev/null
+++ b/03_ukupacha/agora/agora-app/src/modulos/qillqa/compras.rs
@@ -0,0 +1,1675 @@
+//! Módulo **Compras** (qillqa-compras): la línea de tiempo del proceso cruzada,
+//! EN VIVO, con el presupuesto certificado (hifas/sheafsync). Sidebar = los
+//! hitos del expediente que el canvas tiene enfrente, con su saldo corriente;
+//! canvas = stat-cards (partida / comprometido / disponible) + las cuatro
+//! acciones del proceso: adjudicar lo que cabe, adjudicar lo que sobregira,
+//! pagar contra lo adjudicado, reiniciar.
+//!
+//! El acto que vende el milestone: apretar «adjudicar 1.200,00 (sobregira)» y
+//! ver la partida RECHAZAR la adjudicación **sin que el hito llegue al
+//! registro** (el escrow rechaza antes de asentar). Pero un rechazo sólo
+//! enseña contra un camino feliz: sin «adjudicar 300,00» —que SÍ entra y baja
+//! el disponible— el ROJO no se distingue de una sala rota. Y sin «reiniciar»
+//! la sala es un one-shot: se aprieta una vez y no hay nada más que hacer.
+//!
+//! La sala tiene DOS vistas ([`Vista`]), porque la tesis M3 tiene dos mitades y
+//! la segunda no se ve con una sola celda:
+//!
+//!   * **Proceso** — la partida con UNA celda: la línea de tiempo y el sobregiro
+//!     que no llega al log.
+//!   * **Oficinas** — la partida repartida en celdas-oficina: la partición
+//!     fail-safe, el rebalanceo edge-local, y el **clon** que se acota y se
+//!     delata al reconciliar (`BOUNDARY.md` de hifas: *el clon no se previene:
+//!     se ACOTA y se DELATA*). El modelo de interacción es el del CLI —el único
+//!     que hasta hoy abría oficinas—: N celdas, particionadas o conectadas, y
+//!     cada gasto imputado a una oficina.
+//!
+//! La lógica de dominio no sabe quién la pinta (regla dura #2): este módulo sólo
+//! llama a `Compra::asentar` sobre el core real y muestra el saldo certificado.
+//! Reusa `qillqa-compras` (el *-core), no su binario — las UIs son frontends
+//! intercambiables. Las `View<H>` son genéricas: los `Msg` locales se elevan al
+//! mensaje del shell con `lift`, sin acoplar tipos del core al host.
+
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::prelude::{
+    length, percent, AlignItems, Dimension, FlexDirection, JustifyContent, Rect, Size, Style,
+};
+use llimphi_ui::llimphi_raster::peniko::Color;
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::View;
+
+use llimphi_widget_banner::{banner_view, BannerKind};
+use llimphi_widget_button::{button_styled, ButtonPalette};
+use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec};
+use llimphi_widget_stat_card::{stat_card_view, StatCardPalette};
+
+use qillqa_compras::{demo, money, Compra, Etapa, LineaDeTiempo, Reconciliacion};
+
+// ---------------------------------------------------------------------------
+//  Parámetros del proceso demo
+// ---------------------------------------------------------------------------
+
+/// La adjudicación que sobregira: 1.200,00 contra una partida que ya no tiene
+/// tanto disponible. El escrow la rechaza (`RechazoCompra::Sobregiro`).
+const SOBREGIRO_CENTAVOS: i64 = 120_000;
+
+/// La adjudicación que SÍ cabe: 300,00 contra los 450,00 que deja el fixture.
+/// Es el contraste que hace legible al sobregiro — el mismo botón, el mismo
+/// escrow, y sin embargo éste asienta y baja el disponible.
+const CABE_CENTAVOS: i64 = 30_000;
+
+/// El proveedor de la adjudicación extra (no es el ganador del fixture).
+const PROVEEDOR_EXTRA: &str = "proveedor-extra";
+
+/// El pago contra lo adjudicado al ganador: 250,00. Los dos primeros entran
+/// (500,00 ≤ 550,00 adjudicados); el **tercero** excede lo adjudicado y el
+/// proceso lo rechaza (`RechazoCompra::PagoExcedeAdjudicado`). Ese tercer
+/// intento es lo único que hace alcanzable ese rechazo desde la sala.
+const PAGO_CENTAVOS: i64 = 25_000;
+
+/// La tesis del módulo, en el banner por defecto. Se pinta SIEMPRE que no haya
+/// un rechazo que mostrar: sin ella la sala no enseña nada hasta que alguien
+/// aprieta un botón, y lo que hay que entender es justamente la regla, no el
+/// resultado de haberla violado.
+const TESIS: &str =
+    "la partida no puede sobregirarse en silencio: el escrow rechaza antes de asentar";
+
+/// La tesis de la vista Oficinas, en el banner del veredicto mientras nadie haya
+/// reconciliado todavía. Es la frase de `BOUNDARY.md` — la regla que la vista
+/// enseña, y que se entiende antes de apretar nada.
+const TESIS_CLON: &str = "el clon no se previene: se ACOTA (a una celda) y se DELATA (al reconciliar)";
+
+/// La oficina que se bifurca al apretar «clonar»: la copia de la partida arranca
+/// de nuevo **en esta celda** y sigue su propio linaje de época. Es la 1 y no la
+/// 0 a propósito: el clon queda imputado a una celda CONCRETA —la delación dice
+/// cuál—, no al proceso en abstracto.
+const CELDA_CLON: usize = 1;
+
+// ---------------------------------------------------------------------------
+//  Estado del módulo
+// ---------------------------------------------------------------------------
+
+/// Qué muestra la sala: el proceso (una celda) o el frente multi-oficina.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub(crate) enum Vista {
+    /// La línea de tiempo del proceso y el sobregiro que no llega al log.
+    Proceso,
+    /// Las celdas-oficina: partición, rebalanceo y el clon delatado.
+    Oficinas,
+}
+
+/// El frente multi-oficina: **dos vistas de la MISMA partida**, que es lo que
+/// hace falta para la segunda mitad de la tesis. Una `Compra` con N celdas
+/// alcanza para la partición y el rebalanceo (mitad A), pero el clon no: un
+/// linaje bifurcado son dos `Compra` separadas que reconcilian (mitad B).
+struct Oficinas {
+    /// La vista que opera el usuario: la partida en `demo::OFICINAS` celdas.
+    compra: Compra,
+    /// La OTRA vista de la misma partida — la copia que puede bifurcarse.
+    espejo: Compra,
+    /// ¿Las oficinas están unidas en cadena (rebalanceo) o particionadas?
+    conectadas: bool,
+    /// Época de la celda [`CELDA_CLON`] en el espejo: `0` = nunca arrancó de
+    /// nuevo; `≥ 1` = linaje bifurcado (el clon). Lo devuelve `reboot_oficina`.
+    epoca_espejo: u64,
+    /// Veredicto de la última reconciliación (`None` = todavía no se reconcilió).
+    veredicto: Option<Reconciliacion>,
+    /// Banner del último rechazo al adjudicar desde una oficina.
+    banner: Option<String>,
+    /// Reloj determinista para fechar las etapas del escenario.
+    reloj: u64,
+}
+
+/// El estado del módulo Compras: la `Compra` demo del core (su registro firmado
+/// + su partida certificada), el banner del último rechazo (el sobregiro que
+/// NO se asentó), un reloj determinista para fechar nuevas etapas, el hito
+/// seleccionado en el sidebar, y el frente multi-oficina con su propia partida.
+pub(crate) struct State {
+    /// El proceso de compra real (core `qillqa-compras`).
+    compra: Compra,
+    /// Banner del último RECHAZO por sobregiro (o `None` si nada se rechazó).
+    banner: Option<String>,
+    /// Reloj determinista para fechar las etapas que se intentan asentar.
+    reloj: u64,
+    /// `seq` del hito seleccionado en el sidebar (resaltado), si hay.
+    seleccion: Option<u64>,
+    /// Qué vista de la sala se está mostrando.
+    vista: Vista,
+    /// El escenario multi-oficina (vive siempre; se pinta en [`Vista::Oficinas`]).
+    ofi: Oficinas,
+}
+
+impl State {
+    /// La `Compra` que la vista activa está mostrando — la del proceso o la del
+    /// escenario multi-oficina. El sidebar sigue a la vista: los hitos que lista
+    /// son los del expediente que el canvas tiene enfrente.
+    fn compra_visible(&self) -> &Compra {
+        match self.vista {
+            Vista::Proceso => &self.compra,
+            Vista::Oficinas => &self.ofi.compra,
+        }
+    }
+}
+
+/// Interacciones del módulo. Datos simples (nunca tipos del core): el host los
+/// eleva con `lift` a su propio mensaje.
+///
+/// Deriva `Debug + PartialEq + Eq` además de `Clone` para que los tests puedan
+/// verificar que cada botón despacha **lo suyo**: contar `on_click` no distingue
+/// un botón que dice «clonar» y manda `Reconciliar`. Son datos puros —sin tipos
+/// del core adentro—, así que derivarlos es gratis.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) enum Msg {
+    /// Adjudica 300,00 — cabe en la partida: asienta y baja el disponible.
+    AdjudicarCabe,
+    /// Intenta adjudicar 1.200,00 — la partida no alcanza y el escrow rechaza.
+    AdjudicarSobregira,
+    /// Paga 250,00 al ganador contra lo adjudicado. Al tercer intento el
+    /// proceso lo rechaza (excede lo adjudicado).
+    Pagar,
+    /// Vuelve el proceso al fixture limpio (borra lo asentado en la sesión).
+    Reiniciar,
+    /// Selecciona un hito del sidebar por su `seq` (sólo resalta).
+    Seleccionar(u64),
+    /// Cambia la vista de la sala (proceso ⇄ oficinas).
+    Ver(Vista),
+    /// Particionadas ⇄ conectadas: **re-abre** la partida multi-oficina con o
+    /// sin aristas (cambiar el grafo de una cuenta ya abierta no es una
+    /// operación del dominio: la partida se reparte al abrirla).
+    AlternarConexion,
+    /// Adjudica `demo::MONTO_OFICINA` desde la oficina `i`.
+    AdjudicarDesde(usize),
+    /// La copia de la partida arranca de nuevo en la celda [`CELDA_CLON`]: su
+    /// linaje se bifurca. Nadie lo impide — ésa es la frontera.
+    Clonar,
+    /// Reconcilia las dos vistas de la partida: fusión limpia o delación.
+    Reconciliar,
+    /// Vuelve el escenario multi-oficina al fixture limpio (conserva el modo).
+    ReiniciarOficinas,
+}
+
+/// El proceso demo REAL, forjado por el core (`qillqa_compras::demo::compra`):
+/// partida de 1.000,00, convocatoria, dos ofertas y una adjudicación al más
+/// barato (55.000) que SÍ cabe — la partida queda con 45.000 de saldo. El
+/// fixture es del core: la app `qillqa-compras-ui` y esta sala muestran EL
+/// MISMO expediente, byte a byte, porque hay UNA sola definición.
+fn nueva_compra() -> Compra {
+    demo::compra()
+}
+
+/// El escenario multi-oficina limpio, con las oficinas `conectadas` o no. Las
+/// **dos** vistas se forjan con el mismo fixture determinista del core: son la
+/// misma partida vista por dos lados, que es la premisa del clon.
+fn nuevas_oficinas(conectadas: bool) -> Oficinas {
+    Oficinas {
+        compra: demo::compra_oficinas(conectadas),
+        espejo: demo::compra_oficinas(conectadas),
+        conectadas,
+        epoca_espejo: 0,
+        veredicto: None,
+        banner: None,
+        reloj: demo::RELOJ_INICIAL,
+    }
+}
+
+/// Estado inicial del módulo (proceso demo, sin banner). El escenario
+/// multi-oficina arranca **particionado**: es el fail-safe, el estado en el que
+/// una partida repartida es segura sin coordinar con nadie.
+pub(crate) fn nueva() -> State {
+    State {
+        compra: nueva_compra(),
+        banner: None,
+        reloj: demo::RELOJ_INICIAL,
+        seleccion: None,
+        vista: Vista::Proceso,
+        ofi: nuevas_oficinas(false),
+    }
+}
+
+// ---------------------------------------------------------------------------
+//  Transición
+// ---------------------------------------------------------------------------
+
+pub(crate) fn update(state: &mut State, msg: Msg) {
+    match msg {
+        Msg::AdjudicarCabe => {
+            // El camino feliz: 300,00 entra en los 450,00 que quedan. El escrow
+            // acepta, la etapa SÍ llega al registro y el disponible baja.
+            asentar(
+                state,
+                Etapa::Adjudicacion {
+                    proveedor: PROVEEDOR_EXTRA.to_string(),
+                    monto_centavos: CABE_CENTAVOS,
+                },
+            );
+        }
+        Msg::AdjudicarSobregira => {
+            // Intenta gastar más de lo disponible. El escrow rechaza ANTES de
+            // asentar: la etapa no llega al registro (el sobregiro no existe en
+            // el log). Sólo capturamos el rechazo para el banner.
+            asentar(
+                state,
+                Etapa::Adjudicacion {
+                    proveedor: "amigo-del-alcalde".to_string(),
+                    monto_centavos: SOBREGIRO_CENTAVOS,
+                },
+            );
+        }
+        Msg::Pagar => {
+            // El pago se contrasta contra lo adjudicado a ESE proveedor (no
+            // vuelve a gastar la partida). Por eso le paga al ganador del
+            // fixture: es el único con partida adjudicada.
+            asentar(
+                state,
+                Etapa::Pago {
+                    proveedor: demo::PROVEEDOR_GANADOR.to_string(),
+                    monto_centavos: PAGO_CENTAVOS,
+                },
+            );
+        }
+        Msg::Reiniciar => {
+            // Se rehace el fixture entero (no se "deshace"): el core lo forja
+            // determinista, así que el proceso vuelve idéntico byte a byte.
+            *state = nueva();
+        }
+        Msg::Seleccionar(seq) => {
+            state.seleccion = if state.seleccion == Some(seq) {
+                None
+            } else {
+                Some(seq)
+            };
+        }
+        Msg::Ver(v) => {
+            // El sidebar sigue a la vista y lista otro expediente: la selección
+            // apuntaba a un `seq` del anterior y ya no significa lo mismo.
+            state.seleccion = None;
+            state.vista = v;
+        }
+        Msg::AlternarConexion => {
+            // Repartir la partida es un acto de apertura: no se le agregan ni se
+            // le sacan aristas a una cuenta que ya gastó. Se re-abre limpia — y
+            // eso es exactamente lo que hace legible la demostración: el MISMO
+            // monto contra el MISMO estado inicial, con y sin ruta a la vecina.
+            state.ofi = nuevas_oficinas(!state.ofi.conectadas);
+        }
+        Msg::AdjudicarDesde(oficina) => {
+            // El cruce real: el escrow gasta la celda de ESA oficina; si no cabe
+            // rebalancea desde las vecinas conectadas, y si no hay ruta rechaza
+            // (fail-safe) sin asentar nada.
+            state.ofi.reloj += 10;
+            let r = state.ofi.compra.asentar_oficina(
+                oficina,
+                Etapa::Adjudicacion {
+                    proveedor: demo::PROVEEDOR_OFICINA.to_string(),
+                    monto_centavos: demo::MONTO_OFICINA,
+                },
+                state.ofi.reloj,
+            );
+            state.ofi.banner = r.err().map(|e| e.to_string());
+        }
+        Msg::Clonar => {
+            // La copia arranca de nuevo: su celda incrementa la época (monótona,
+            // device-local, sin consenso). A partir de aquí hay DOS linajes vivos
+            // sobre la misma celda y nada los impide gastar los dos. Eso es la
+            // frontera: no se previene.
+            state.ofi.epoca_espejo = state.ofi.espejo.reboot_oficina(CELDA_CLON);
+        }
+        Msg::Reconciliar => {
+            let r = state.ofi.compra.reconciliar(&state.ofi.espejo);
+            // La delación NO caduca. `reconciliar` fusiona (y converge las
+            // épocas), así que un segundo apretón devuelve `Ok`: los dos linajes
+            // ya son uno. Pero el doble gasto ocurrió igual, y la alarma es del
+            // HECHO, no del último click — borrarla porque la máquina volvió a
+            // mirar sería justo lo contrario de "imputable".
+            state.ofi.veredicto = match (state.ofi.veredicto.take(), r) {
+                (Some(clon @ Reconciliacion::Clon { .. }), _) => Some(clon),
+                (_, r) => Some(r),
+            };
+        }
+        Msg::ReiniciarOficinas => {
+            // Vuelve el escenario al fixture limpio conservando el modo: se
+            // reinicia lo que pasó, no cómo está repartida la partida.
+            state.ofi = nuevas_oficinas(state.ofi.conectadas);
+        }
+    }
+}
+
+/// Intenta asentar una etapa en el proceso real y deja en el banner el rechazo
+/// si el core la rechazó (`None` = entró).
+///
+/// El texto del rechazo es del DOMINIO y lo dice el core (`RechazoCompra::texto`,
+/// vía `Display`): la sala y la app `qillqa-compras-ui` citan la MISMA frase.
+fn asentar(state: &mut State, etapa: Etapa) {
+    state.reloj += 10;
+    let r = state.compra.asentar(etapa, state.reloj);
+    state.banner = r.err().map(|e| e.to_string());
+}
+
+// ---------------------------------------------------------------------------
+//  Sidebar — los hitos del proceso con su saldo corriente
+// ---------------------------------------------------------------------------
+
+pub(crate) fn sidebar<H: Clone + Send + Sync + 'static>(
+    state: &State,
+    theme: &Theme,
+    filtro: &str,
+    lift: impl Fn(Msg) -> H + Clone + Send + Sync + 'static,
+) -> View<H> {
+    // Los hitos del expediente que el canvas tiene enfrente (el proceso o el
+    // multi-oficina): el sidebar no puede estar mostrando otro papel.
+    let lt = state.compra_visible().linea_de_tiempo();
+    let rows: Vec<ListRow<H>> = lt
+        .hitos
+        .iter()
+        .map(|h| {
+            let prov = h
+                .proveedor
+                .as_deref()
+                .map(|p| format!(" · {p}"))
+                .unwrap_or_default();
+            let monto = if h.monto_centavos > 0 {
+                format!(" · {}", money(h.monto_centavos))
+            } else {
+                String::new()
+            };
+            ListRow {
+                label: format!(
+                    "#{}  {}{}{}   saldo {}",
+                    h.seq,
+                    h.tipo,
+                    prov,
+                    monto,
+                    money(h.disponible_tras)
+                ),
+                selected: state.seleccion == Some(h.seq),
+                on_click: lift(Msg::Seleccionar(h.seq)),
+            }
+        })
+        .collect();
+    let rows = crate::modulos::filtrar_rows(rows, filtro);
+    let total = rows.len();
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        padding: Rect {
+            left: length(8.0),
+            right: length(8.0),
+            top: length(8.0),
+            bottom: length(8.0),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_panel)
+    .children(vec![list_view(ListSpec {
+        rows,
+        total,
+        caption: Some(format!("{total} hitos del proceso")),
+        truncated_hint: None,
+        row_height: 34.0,
+        palette: ListPalette::from_theme(theme),
+    })])
+}
+
+// ---------------------------------------------------------------------------
+//  Canvas — stat-cards + acción de sobregiro + banner de rechazo + timeline
+// ---------------------------------------------------------------------------
+
+pub(crate) fn canvas<H: Clone + Send + Sync + 'static>(
+    state: &State,
+    theme: &Theme,
+    lift: impl Fn(Msg) -> H + Clone + Send + Sync + 'static,
+) -> View<H> {
+    let mut hijos: Vec<View<H>> = vec![vistas::<H>(state.vista, theme, lift.clone())];
+    match state.vista {
+        Vista::Proceso => hijos.extend(canvas_proceso::<H>(state, theme, lift)),
+        Vista::Oficinas => hijos.extend(canvas_oficinas::<H>(&state.ofi, theme, lift)),
+    }
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        gap: Size {
+            width: length(0.0),
+            height: length(8.0),
+        },
+        padding: Rect {
+            left: length(12.0),
+            right: length(12.0),
+            top: length(12.0),
+            bottom: length(12.0),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_app)
+    .children(hijos)
+}
+
+/// El cuerpo de la vista **Proceso**: stat-cards, acciones, banner y timeline.
+fn canvas_proceso<H: Clone + Send + Sync + 'static>(
+    state: &State,
+    theme: &Theme,
+    lift: impl Fn(Msg) -> H + Clone + Send + Sync + 'static,
+) -> Vec<View<H>> {
+    let lt = state.compra.linea_de_tiempo();
+    vec![
+        stats_row::<H>(&lt, theme),
+        acciones::<H>(theme, lift),
+        // Siempre hay banner: el RECHAZO cuando el último intento rebotó, y si
+        // no la tesis del módulo. La sala enseña la regla desde que se abre, no
+        // recién cuando alguien la viola.
+        match &state.banner {
+            Some(msg) => banner_view::<H>(BannerKind::Error, msg.clone()),
+            None => banner_view::<H>(BannerKind::Success, TESIS),
+        },
+        timeline::<H>(&lt, theme),
+    ]
+}
+
+/// El selector de vista (proceso ⇄ oficinas). La activa va con el acento.
+///
+/// Son dos botones del widget y no un `segmented`/`tabs` porque ninguno de esos
+/// dos está declarado en el `Cargo.toml` de `agora-app`. Antes que improvisar un
+/// widget a mano —la causa mecánica de media degradación anterior— se usa el
+/// botón REAL que sí está. Si algún día se declara `llimphi-widget-segmented`,
+/// esto son tres líneas.
+fn vistas<H: Clone + Send + Sync + 'static>(
+    actual: Vista,
+    theme: &Theme,
+    lift: impl Fn(Msg) -> H + Clone + Send + Sync + 'static,
+) -> View<H> {
+    let tab = |label: &str, v: Vista| {
+        let pal = if v == actual {
+            ButtonPalette {
+                bg: theme.accent,
+                bg_hover: theme.bg_button_hover,
+                fg: theme.bg_app,
+                radius: 6.0,
+            }
+        } else {
+            ButtonPalette {
+                bg: theme.bg_button,
+                bg_hover: theme.bg_button_hover,
+                fg: theme.fg_muted,
+                radius: 6.0,
+            }
+        };
+        boton::<H>(label.to_string(), &pal, lift(Msg::Ver(v)))
+    };
+    fila::<H>(vec![
+        tab("proceso", Vista::Proceso),
+        tab("oficinas", Vista::Oficinas),
+    ])
+}
+
+// ---------------------------------------------------------------------------
+//  Canvas — la vista Oficinas (el frente multi-oficina)
+// ---------------------------------------------------------------------------
+
+/// El cuerpo de la vista **Oficinas**: una card por celda + la partida entera,
+/// las acciones de reparto (toggle y adjudicar desde cada oficina), el banner
+/// del rechazo, y el frente del clon (clonar / reconciliar / reiniciar) con su
+/// veredicto en palabras.
+fn canvas_oficinas<H: Clone + Send + Sync + 'static>(
+    ofi: &Oficinas,
+    theme: &Theme,
+    lift: impl Fn(Msg) -> H + Clone + Send + Sync + 'static,
+) -> Vec<View<H>> {
+    vec![
+        celdas_row::<H>(ofi, theme),
+        acciones_reparto::<H>(ofi, theme, lift.clone()),
+        // El rechazo real del escrow (lo redacta el core) o, si nada rebotó, la
+        // regla del reparto vigente — que es lo que hay que entender antes de
+        // apretar: por qué el mismo monto entra o no.
+        match &ofi.banner {
+            Some(msg) => banner_view::<H>(BannerKind::Error, msg.clone()),
+            None => banner_view::<H>(BannerKind::Info, regla_del_reparto(ofi.conectadas)),
+        },
+        rotulo::<H>("el clon — la frontera honesta de hifas", theme),
+        acciones_clon::<H>(theme, lift),
+        veredicto_view::<H>(ofi, theme),
+    ]
+}
+
+/// La regla que gobierna el reparto vigente, en castellano llano. Es lo que
+/// explica por qué el MISMO monto rebota o entra: lo único que cambió es si hay
+/// una arista a la vecina.
+fn regla_del_reparto(conectadas: bool) -> String {
+    if conectadas {
+        format!(
+            "CONECTADAS en cadena — a la oficina que no le alcanza su celda, el escrow le rebalancea \
+             el faltante desde una vecina con excedente (edge-local y transaccional): {} entra.",
+            money(demo::MONTO_OFICINA)
+        )
+    } else {
+        format!(
+            "PARTICIONADAS — cada oficina se limita a su celda y no hay ruta al excedente de la vecina: \
+             {} no entra en una celda de {}, aunque la partida entera esté intacta. Es el fail-safe: \
+             sin coordinar, se rechaza antes que sobregirar.",
+            money(demo::MONTO_OFICINA),
+            money(demo::PRESUPUESTO / demo::OFICINAS as i64)
+        )
+    }
+}
+
+/// Una stat-card por celda-oficina (`disponible_de`) + una por la partida
+/// entera. Las dos juntas son el punto: la partida puede tener plata de sobra y
+/// aun así una oficina no poder gastar — el límite es de la CELDA.
+fn celdas_row<H: Clone + Send + Sync + 'static>(ofi: &Oficinas, theme: &Theme) -> View<H> {
+    let pal = StatCardPalette::from_theme(theme);
+    let card = |label: String, value: String, desc: String, accent: Color| {
+        View::new(Style {
+            flex_grow: 1.0,
+            flex_basis: length(0.0_f32),
+            size: Size {
+                width: Dimension::auto(),
+                height: length(96.0_f32),
+            },
+            ..Default::default()
+        })
+        .children(vec![stat_card_view::<H>(
+            &label, value, &desc, accent, &[], &pal,
+        )])
+    };
+    let mut cards: Vec<View<H>> = (0..ofi.compra.oficinas())
+        .map(|i| {
+            card(
+                format!("OFICINA {i}"),
+                money(ofi.compra.disponible_de(i)),
+                "su celda".to_string(),
+                theme.accent,
+            )
+        })
+        .collect();
+    cards.push(card(
+        "LA PARTIDA".to_string(),
+        money(ofi.compra.disponible_centavos()),
+        format!("{} celdas · Σ disponible", ofi.compra.oficinas()),
+        theme.fg_muted,
+    ));
+    fila_alta::<H>(cards, 100.0)
+}
+
+/// Las acciones del reparto: el toggle particionadas ⇄ conectadas y un
+/// «adjudicar» por oficina con el MISMO monto — el que rebota particionado y
+/// entra conectado.
+fn acciones_reparto<H: Clone + Send + Sync + 'static>(
+    ofi: &Oficinas,
+    theme: &Theme,
+    lift: impl Fn(Msg) -> H + Clone + Send + Sync + 'static,
+) -> View<H> {
+    let primario = ButtonPalette {
+        bg: theme.accent,
+        bg_hover: theme.bg_button_hover,
+        fg: theme.bg_app,
+        radius: 6.0,
+    };
+    let secundario = ButtonPalette {
+        bg: theme.bg_button,
+        bg_hover: theme.bg_button_hover,
+        fg: theme.fg_text,
+        radius: 6.0,
+    };
+    let mut botones = vec![boton::<H>(
+        // El rótulo dice a qué se pasa, no dónde se está: el reparto vigente ya
+        // lo cantan las cards y el banner.
+        if ofi.conectadas {
+            "⇢ particionar las oficinas".to_string()
+        } else {
+            "⇠ conectar las oficinas".to_string()
+        },
+        &primario,
+        lift(Msg::AlternarConexion),
+    )];
+    botones.extend((0..ofi.compra.oficinas()).map(|i| {
+        boton::<H>(
+            format!("adjudicar {} desde la oficina {i}", money(demo::MONTO_OFICINA)),
+            &secundario,
+            lift(Msg::AdjudicarDesde(i)),
+        )
+    }));
+    fila::<H>(botones)
+}
+
+/// Las acciones del frente del clon: bifurcar la copia, reconciliar y reiniciar
+/// el escenario.
+fn acciones_clon<H: Clone + Send + Sync + 'static>(
+    theme: &Theme,
+    lift: impl Fn(Msg) -> H + Clone + Send + Sync + 'static,
+) -> View<H> {
+    let destructivo = ButtonPalette {
+        bg: theme.fg_destructive,
+        bg_hover: theme.bg_button_hover,
+        fg: Color::WHITE,
+        radius: 6.0,
+    };
+    let secundario = ButtonPalette {
+        bg: theme.bg_button,
+        bg_hover: theme.bg_button_hover,
+        fg: theme.fg_text,
+        radius: 6.0,
+    };
+    let apagado = ButtonPalette {
+        bg: theme.bg_button,
+        bg_hover: theme.bg_button_hover,
+        fg: theme.fg_muted,
+        radius: 6.0,
+    };
+    fila::<H>(vec![
+        boton::<H>(
+            format!("clonar la oficina {CELDA_CLON} (reboot)"),
+            &destructivo,
+            lift(Msg::Clonar),
+        ),
+        boton::<H>("reconciliar".to_string(), &secundario, lift(Msg::Reconciliar)),
+        boton::<H>(
+            "reiniciar oficinas".to_string(),
+            &apagado,
+            lift(Msg::ReiniciarOficinas),
+        ),
+    ])
+}
+
+/// El veredicto de la reconciliación **en palabras**: qué dijo el core y qué
+/// significa. Sin la explicación, `Clon { celda: 1, epoca_rezagada: 0,
+/// epoca_viva: 1 }` no le dice nada a nadie salvo a quien ya lo sabía.
+fn veredicto_view<H: Clone + Send + Sync + 'static>(ofi: &Oficinas, theme: &Theme) -> View<H> {
+    let (kind, titulo, explicacion) = match &ofi.veredicto {
+        None => (
+            BannerKind::Info,
+            TESIS_CLON.to_string(),
+            if ofi.epoca_espejo > 0 {
+                format!(
+                    "La copia de la partida ya arrancó de nuevo: su celda {CELDA_CLON} va por la época \
+                     {} y la de aquí sigue en la 0. Hay dos linajes vivos gastando la misma celda y nada \
+                     los detuvo. Apretá «reconciliar» para que se delate.",
+                    ofi.epoca_espejo
+                )
+            } else {
+                "Hay dos vistas de esta misma partida. «Clonar» hace que la copia arranque de nuevo — un \
+                 linaje bifurcado que puede gastar la celda otra vez, y ningún sistema sin coordinación \
+                 puede impedirlo. «Reconciliar» las fusiona y dice la verdad."
+                    .to_string()
+            },
+        ),
+        Some(Reconciliacion::Ok) => (
+            BannerKind::Success,
+            "✅ Sin conflicto".to_string(),
+            "Las dos vistas traen la misma época en cada celda: un solo linaje por oficina, nadie gastó \
+             la partida por duplicado. No hay nada que imputar."
+                .to_string(),
+        ),
+        Some(Reconciliacion::Clon {
+            celda,
+            epoca_rezagada,
+            epoca_viva,
+        }) => (
+            BannerKind::Error,
+            format!(
+                "❌ CLON detectado en la celda {celda} — época rezagada {epoca_rezagada} vs viva {epoca_viva}"
+            ),
+            format!(
+                "Qué pasó: dos oficinas gastaron la misma partida sin coordinar. La celda {celda} apareció \
+                 con dos linajes —el rezagado (época {epoca_rezagada}) y el vivo (época {epoca_viva})—, y eso \
+                 sólo ocurre si una copia arrancó de nuevo y siguió gastando por su cuenta. \
+                 No se pudo IMPEDIR: ningún sistema sin coordinación puede, y la plata ya salió. \
+                 Lo que sí se logró: el daño quedó ACOTADO a esa sola celda (cada linaje no pudo gastar más \
+                 que su propio presupuesto) y quedó DELATADO en cuanto las dos vistas se hablaron. \
+                 Hay a quién imputarle el doble gasto, y con qué papel."
+            ),
+        ),
+    };
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: Dimension::auto(),
+        },
+        flex_shrink: 0.0,
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(6.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![
+        banner_view::<H>(kind, titulo),
+        View::new(Style {
+            size: Size {
+                width: percent(1.0_f32),
+                height: Dimension::auto(),
+            },
+            padding: Rect {
+                left: length(10.0_f32),
+                right: length(10.0_f32),
+                top: length(0.0_f32),
+                bottom: length(0.0_f32),
+            },
+            ..Default::default()
+        })
+        .text(explicacion, 12.0, theme.fg_muted),
+    ])
+}
+
+// ---------------------------------------------------------------------------
+//  Piezas de layout compartidas por la vista Oficinas
+// ---------------------------------------------------------------------------
+
+/// Un botón REAL (`llimphi-widget-button`), que se reparte el ancho de su fila.
+fn boton<H: Clone + Send + Sync + 'static>(label: String, pal: &ButtonPalette, msg: H) -> View<H> {
+    button_styled(
+        label,
+        Style {
+            flex_grow: 1.0,
+            flex_basis: length(0.0_f32),
+            size: Size {
+                width: Dimension::auto(),
+                height: length(32.0_f32),
+            },
+            padding: Rect {
+                left: length(8.0_f32),
+                right: length(8.0_f32),
+                top: length(0.0_f32),
+                bottom: length(0.0_f32),
+            },
+            align_items: Some(AlignItems::Center),
+            justify_content: Some(JustifyContent::Center),
+            ..Default::default()
+        },
+        Alignment::Center,
+        pal,
+        msg,
+    )
+}
+
+/// Una fila de controles de alto de botón.
+fn fila<H: Clone + Send + Sync + 'static>(hijos: Vec<View<H>>) -> View<H> {
+    fila_alta::<H>(hijos, 32.0)
+}
+
+/// Una fila horizontal de alto fijo que no se encoge.
+fn fila_alta<H: Clone + Send + Sync + 'static>(hijos: Vec<View<H>>, h: f32) -> View<H> {
+    View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(h),
+        },
+        flex_shrink: 0.0,
+        gap: Size {
+            width: length(8.0_f32),
+            height: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(hijos)
+}
+
+/// Un rótulo de sección.
+fn rotulo<H: Clone + Send + Sync + 'static>(texto: &str, theme: &Theme) -> View<H> {
+    View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(20.0_f32),
+        },
+        flex_shrink: 0.0,
+        align_items: Some(AlignItems::Center),
+        ..Default::default()
+    })
+    .text(texto.to_string(), 11.5, theme.fg_muted)
+}
+
+/// La fila de stat-cards: partida certificada / comprometido / disponible. El
+/// disponible se tiñe de alerta cuando queda poco (< 10% de la partida).
+fn stats_row<H: Clone + Send + Sync + 'static>(lt: &LineaDeTiempo, theme: &Theme) -> View<H> {
+    let pal = StatCardPalette::from_theme(theme);
+    // El umbral del saldo bajo es POLÍTICA del proceso: la decide el core
+    // (`LineaDeTiempo::saldo_bajo`), no esta pintura.
+    let acc_disp = if lt.saldo_bajo() {
+        theme.fg_destructive
+    } else {
+        theme.accent
+    };
+    let card = |label: &str, value: String, desc: &str, accent: Color| {
+        View::new(Style {
+            flex_grow: 1.0,
+            flex_basis: percent(0.33),
+            size: Size {
+                width: percent(0.33),
+                height: length(96.0),
+            },
+            ..Default::default()
+        })
+        .children(vec![stat_card_view::<H>(label, value, desc, accent, &[], &pal)])
+    };
+    View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(100.0),
+        },
+        flex_shrink: 0.0,
+        gap: Size {
+            width: length(12.0),
+            height: length(0.0),
+        },
+        ..Default::default()
+    })
+    .children(vec![
+        card(
+            "PARTIDA CERTIFICADA",
+            money(lt.presupuesto_centavos),
+            "presupuesto (hifas)",
+            theme.accent,
+        ),
+        card(
+            "COMPROMETIDO",
+            money(lt.comprometido_centavos),
+            "adjudicado",
+            theme.accent,
+        ),
+        card(
+            "DISPONIBLE",
+            money(lt.disponible_centavos),
+            "no sobregirable",
+            acc_disp,
+        ),
+    ])
+}
+
+/// La fila de acciones del proceso: adjudicar (lo que cabe / lo que sobregira),
+/// pagar, reiniciar. El sobregiro se pinta con `fg_destructive` porque el escrow
+/// lo va a rechazar; los demás son botones normales.
+fn acciones<H: Clone + Send + Sync + 'static>(
+    theme: &Theme,
+    lift: impl Fn(Msg) -> H + Clone + Send + Sync + 'static,
+) -> View<H> {
+    let boton = |label: String, fill: Color, fg: Color, msg: Msg| {
+        View::new(Style {
+            flex_grow: 1.0,
+            size: Size {
+                width: Dimension::auto(),
+                height: length(32.0),
+            },
+            align_items: Some(AlignItems::Center),
+            justify_content: Some(JustifyContent::Center),
+            ..Default::default()
+        })
+        .fill(fill)
+        .radius(6.0)
+        .text(label, 12.5, fg)
+        .on_click(lift(msg))
+    };
+    let botones = vec![
+        boton(
+            format!("+ adjudicar {}", money(CABE_CENTAVOS)),
+            theme.bg_button,
+            theme.fg_text,
+            Msg::AdjudicarCabe,
+        ),
+        boton(
+            format!("adjudicar {} (sobregira)", money(SOBREGIRO_CENTAVOS)),
+            theme.fg_destructive,
+            Color::WHITE,
+            Msg::AdjudicarSobregira,
+        ),
+        boton(
+            format!("pagar {}", money(PAGO_CENTAVOS)),
+            theme.bg_button,
+            theme.fg_text,
+            Msg::Pagar,
+        ),
+        boton(
+            "reiniciar".to_string(),
+            theme.bg_button,
+            theme.fg_muted,
+            Msg::Reiniciar,
+        ),
+    ];
+    View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(32.0),
+        },
+        flex_shrink: 0.0,
+        gap: Size {
+            width: length(8.0),
+            height: length(0.0),
+        },
+        ..Default::default()
+    })
+    .children(botones)
+}
+
+/// La línea de tiempo del proceso en el canvas: una fila por hito con su etapa,
+/// proveedor/monto y el saldo certificado tras el hito. Read-only (la
+/// interacción de selección vive en el sidebar).
+fn timeline<H: Clone + Send + Sync + 'static>(lt: &LineaDeTiempo, theme: &Theme) -> View<H> {
+    let filas: Vec<View<H>> = lt
+        .hitos
+        .iter()
+        .map(|h| {
+            let prov = h
+                .proveedor
+                .as_deref()
+                .map(|p| format!(" · {p}"))
+                .unwrap_or_default();
+            let monto = if h.monto_centavos > 0 {
+                format!(" · {}", money(h.monto_centavos))
+            } else {
+                String::new()
+            };
+            let izq = View::new(Style {
+                flex_grow: 1.0,
+                size: Size {
+                    width: percent(1.0_f32),
+                    height: length(30.0),
+                },
+                align_items: Some(AlignItems::Center),
+                ..Default::default()
+            })
+            .text(format!("#{}  {}{}{}", h.seq, h.tipo, prov, monto), 12.5, theme.fg_text);
+            let der = View::new(Style {
+                size: Size {
+                    width: length(120.0),
+                    height: length(30.0),
+                },
+                align_items: Some(AlignItems::Center),
+                justify_content: Some(JustifyContent::End),
+                flex_shrink: 0.0,
+                ..Default::default()
+            })
+            .text(format!("saldo {}", money(h.disponible_tras)), 12.5, theme.fg_muted);
+            View::new(Style {
+                flex_direction: FlexDirection::Row,
+                size: Size {
+                    width: percent(1.0_f32),
+                    height: length(32.0),
+                },
+                align_items: Some(AlignItems::Center),
+                flex_shrink: 0.0,
+                padding: Rect {
+                    left: length(10.0),
+                    right: length(10.0),
+                    top: length(0.0),
+                    bottom: length(0.0),
+                },
+                ..Default::default()
+            })
+            .fill(theme.bg_panel)
+            .radius(4.0)
+            .children(vec![izq, der])
+        })
+        .collect();
+
+    let mut col: Vec<View<H>> = vec![View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(20.0),
+        },
+        align_items: Some(AlignItems::Center),
+        ..Default::default()
+    })
+    .text(
+        format!("{} hitos del proceso", lt.hitos.len()),
+        11.5,
+        theme.fg_muted,
+    )];
+    col.extend(filas);
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: Dimension::auto(),
+        },
+        flex_grow: 1.0,
+        min_size: Size {
+            width: length(0.0),
+            height: length(0.0),
+        },
+        gap: Size {
+            width: length(0.0),
+            height: length(6.0),
+        },
+        ..Default::default()
+    })
+    .children(col)
+}
+
+// ---------------------------------------------------------------------------
+//  Tests — la sala sobre el core real, sin simular
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use qillqa_compras::RechazoCompra;
+
+    /// El `Msg` de la sala, sin elevar: el `lift` de identidad que usan los tests
+    /// para pintar las vistas tal como las pinta el shell.
+    fn tal_cual(m: Msg) -> Msg {
+        m
+    }
+
+    /// Todo el texto que pinta una vista, recorriendo el árbol de verdad.
+    ///
+    /// Es lo que permite certificar que una rama de UI **se pinta** y no es
+    /// código muerto: si el canvas nunca llega a esa rama, su frase no aparece
+    /// aquí y el test se cae.
+    fn textos(v: &View<Msg>) -> Vec<String> {
+        let mut out: Vec<String> = v.text.iter().map(|t| t.content.clone()).collect();
+        for h in &v.children {
+            out.extend(textos(h));
+        }
+        out
+    }
+
+    /// Cuántos nodos de la vista tienen un `on_click` de verdad. Un control que
+    /// no sale aquí es un control FINGIDO (`PARIDAD-CONTROLES.md`).
+    fn clicables(v: &View<Msg>) -> usize {
+        usize::from(v.on_click.is_some()) + v.children.iter().map(clicables).sum::<usize>()
+    }
+
+    /// ¿La vista pinta algún texto que contenga `aguja`?
+    fn pinta(v: &View<Msg>, aguja: &str) -> bool {
+        textos(v).iter().any(|t| t.contains(aguja))
+    }
+
+    /// Los botones de la vista apareados **etiqueta → Msg que despacha**.
+    ///
+    /// Contar `on_click` no alcanza: un botón que dice "clonar" y despacha
+    /// `Reconciliar` tiene su click, tiene su texto, y miente. Verificado con una
+    /// mutación: cambiar el `lift` de un botón pasaba el test de conteo sin
+    /// despeinarse. Esto lo caza.
+    fn botones(v: &View<Msg>) -> Vec<(String, Msg)> {
+        let mut out = Vec::new();
+        if let Some(msg) = &v.on_click {
+            let etiqueta = textos(v).join(" ");
+            out.push((etiqueta, msg.clone()));
+        }
+        for h in &v.children {
+            out.extend(botones(h));
+        }
+        out
+    }
+
+    /// El Msg que despacha el botón cuya etiqueta contiene `aguja`.
+    fn despacha(v: &View<Msg>, aguja: &str) -> Msg {
+        let bs = botones(v);
+        let halladas: Vec<&(String, Msg)> = bs.iter().filter(|(l, _)| l.contains(aguja)).collect();
+        assert_eq!(
+            halladas.len(),
+            1,
+            "esperaba UN botón que diga «{aguja}», hay {} — etiquetas: {:?}",
+            halladas.len(),
+            bs.iter().map(|(l, _)| l).collect::<Vec<_>>()
+        );
+        halladas[0].1.clone()
+    }
+
+    /// El Msg del botón cuya etiqueta es EXACTAMENTE `label`.
+    ///
+    /// Para los rótulos cortos que son subcadena de otros: el tab «oficinas»
+    /// vive entre «⇠ conectar las oficinas» y «reiniciar oficinas», y ahí
+    /// [`despacha`] se planta por ambiguo — con razón, no hay que aflojarlo.
+    fn despacha_exacto(v: &View<Msg>, label: &str) -> Msg {
+        let bs = botones(v);
+        let halladas: Vec<&(String, Msg)> = bs.iter().filter(|(l, _)| l == label).collect();
+        assert_eq!(
+            halladas.len(),
+            1,
+            "esperaba UN botón que diga exactamente «{label}», hay {} — etiquetas: {:?}",
+            halladas.len(),
+            bs.iter().map(|(l, _)| l).collect::<Vec<_>>()
+        );
+        halladas[0].1.clone()
+    }
+
+    /// Cuántos hitos tiene el proceso y cuánto queda disponible — lo que casi
+    /// todos los tests miden antes y después de apretar algo.
+    fn estado(s: &State) -> (usize, i64) {
+        let lt = s.compra.linea_de_tiempo();
+        (lt.hitos.len(), lt.disponible_centavos)
+    }
+
+    /// La sala arranca sobre el fixture del core (el MISMO expediente que la app
+    /// `qillqa-compras-ui`). Si esto se corre, el resto no mide lo que dice.
+    #[test]
+    fn arranca_sobre_el_fixture_del_core() {
+        let s = nueva();
+        assert_eq!(estado(&s), (4, 45_000));
+        assert!(s.banner.is_none(), "sin rechazos, el banner es la tesis");
+    }
+
+    /// El camino feliz: 300,00 cabe en los 450,00 que quedan. El escrow acepta,
+    /// el hito SÍ llega al registro y el disponible baja exactamente lo gastado.
+    #[test]
+    fn adjudicar_cabe_asienta_y_baja_el_disponible() {
+        let mut s = nueva();
+        let (hitos, disp) = estado(&s);
+
+        update(&mut s, Msg::AdjudicarCabe);
+
+        assert!(s.banner.is_none(), "no hubo rechazo: {:?}", s.banner);
+        assert_eq!(
+            estado(&s),
+            (hitos + 1, disp - CABE_CENTAVOS),
+            "el hito entra al log y la partida se compromete"
+        );
+    }
+
+    /// La tesis M3: el sobregiro **no llega al log**. No alcanza con que rebote
+    /// —tiene que no dejar rastro—: ni un hito de más, ni un centavo de menos.
+    #[test]
+    fn sobregiro_no_asienta_y_deja_el_disponible_intacto() {
+        let mut s = nueva();
+        let antes = estado(&s);
+
+        update(&mut s, Msg::AdjudicarSobregira);
+
+        // La frase la dicta el core: aquí se compara contra el rechazo REAL, no
+        // contra una copia del texto (si el core la reescribe, esto la sigue).
+        let esperado = RechazoCompra::Sobregiro {
+            solicitado: SOBREGIRO_CENTAVOS,
+            disponible: antes.1,
+        }
+        .to_string();
+        assert_eq!(s.banner.as_deref(), Some(esperado.as_str()));
+        assert_eq!(estado(&s), antes, "el sobregiro no existe en el log");
+    }
+
+    /// El rechazo por pago excedido es ALCANZABLE desde la sala: los dos primeros
+    /// pagos entran (500,00 ≤ 550,00 adjudicados) y el tercero rebota. Antes no
+    /// había ninguna ruta que emitiera un `Etapa::Pago`, así que ese rechazo era
+    /// código muerto: existía en el core y la sala no podía llegar a él nunca.
+    #[test]
+    fn el_tercer_pago_alcanza_pago_excede_adjudicado() {
+        let mut s = nueva();
+        let adjudicado = s.compra.adjudicado_a(demo::PROVEEDOR_GANADOR);
+
+        // Los dos primeros pagos entran: el camino feliz del pago.
+        for n in 1..=2 {
+            update(&mut s, Msg::Pagar);
+            assert!(s.banner.is_none(), "el pago {n} debía entrar: {:?}", s.banner);
+        }
+        let (hitos, disp) = estado(&s);
+        let ya_pagado = s.compra.pagado_a(demo::PROVEEDOR_GANADOR);
+        assert_eq!(ya_pagado, 2 * PAGO_CENTAVOS);
+
+        // El tercero excede lo adjudicado al ganador y el proceso lo rechaza.
+        update(&mut s, Msg::Pagar);
+
+        let esperado = RechazoCompra::PagoExcedeAdjudicado {
+            proveedor: demo::PROVEEDOR_GANADOR.to_string(),
+            pago: PAGO_CENTAVOS,
+            adjudicado,
+            ya_pagado,
+        }
+        .to_string();
+        assert_eq!(
+            s.banner.as_deref(),
+            Some(esperado.as_str()),
+            "el tercer pago tiene que dar PagoExcedeAdjudicado"
+        );
+        assert_eq!(estado(&s), (hitos, disp), "el pago rechazado no se asentó");
+    }
+
+    /// El pago se contrasta contra lo adjudicado, pero **no vuelve a gastar** la
+    /// partida (evitar doble conteo): el hito entra y el disponible no se mueve.
+    #[test]
+    fn el_pago_no_re_gasta_la_partida() {
+        let mut s = nueva();
+        let (hitos, disp) = estado(&s);
+
+        update(&mut s, Msg::Pagar);
+
+        assert_eq!(estado(&s), (hitos + 1, disp));
+    }
+
+    /// Reiniciar devuelve el proceso al fixture limpio — incluida la selección
+    /// del sidebar, que apuntaba a hitos que ya no existen.
+    #[test]
+    fn reiniciar_vuelve_al_estado_inicial() {
+        let mut s = nueva();
+        let inicial = estado(&s);
+
+        update(&mut s, Msg::AdjudicarCabe);
+        update(&mut s, Msg::AdjudicarSobregira);
+        update(&mut s, Msg::Seleccionar(3));
+        assert_ne!(estado(&s), inicial, "la sesión ensució el proceso");
+
+        update(&mut s, Msg::Reiniciar);
+
+        assert_eq!(estado(&s), inicial);
+        assert!(s.banner.is_none(), "el rechazo se fue con el reinicio");
+        assert_eq!(s.seleccion, None);
+        assert_eq!(s.reloj, demo::RELOJ_INICIAL);
+    }
+
+    /// Seleccionar un hito lo resalta; volver a apretarlo lo deselecciona.
+    #[test]
+    fn seleccionar_alterna() {
+        let mut s = nueva();
+        update(&mut s, Msg::Seleccionar(2));
+        assert_eq!(s.seleccion, Some(2));
+        update(&mut s, Msg::Seleccionar(2));
+        assert_eq!(s.seleccion, None);
+    }
+
+    // -----------------------------------------------------------------------
+    //  La vista Oficinas — las dos mitades de la tesis, accionadas como en la UI
+    // -----------------------------------------------------------------------
+
+    /// La mitad de la partida: lo que le toca a cada celda cuando las oficinas
+    /// se reparten `demo::PRESUPUESTO` con pesos iguales.
+    const CELDA: i64 = demo::PRESUPUESTO / demo::OFICINAS as i64;
+
+    /// La sala arranca en el proceso y con las oficinas **particionadas** — el
+    /// fail-safe: el reparto que es seguro sin coordinar con nadie.
+    #[test]
+    fn el_escenario_de_oficinas_arranca_particionado_y_repartido() {
+        let s = nueva();
+        assert_eq!(s.vista, Vista::Proceso);
+        assert!(!s.ofi.conectadas);
+        assert_eq!(s.ofi.compra.oficinas(), demo::OFICINAS);
+        for i in 0..demo::OFICINAS {
+            assert_eq!(s.ofi.compra.disponible_de(i), CELDA, "celda {i}");
+        }
+        assert_eq!(s.ofi.epoca_espejo, 0, "la copia todavía no se bifurcó");
+        assert!(s.ofi.veredicto.is_none());
+        assert!(s.ofi.banner.is_none());
+    }
+
+    /// **La demostración del rebalanceo en un solo click**: el MISMO monto, la
+    /// MISMA oficina, el mismo escrow. Particionadas rebota con el disponible de
+    /// la CELDA (y la partida entera queda intacta — el límite no era de ella);
+    /// conectadas entra rebalanceando el faltante de la vecina.
+    #[test]
+    fn el_mismo_monto_rebota_particionado_y_entra_conectado() {
+        let mut s = nueva();
+
+        // --- particionadas: rebota, y no deja rastro ---
+        update(&mut s, Msg::AdjudicarDesde(0));
+
+        // La frase la dicta el core: se compara contra el rechazo REAL.
+        let esperado = RechazoCompra::Sobregiro {
+            solicitado: demo::MONTO_OFICINA,
+            disponible: CELDA,
+        }
+        .to_string();
+        assert_eq!(s.ofi.banner.as_deref(), Some(esperado.as_str()));
+        assert!(
+            s.ofi.compra.linea_de_tiempo().hitos.is_empty(),
+            "el sobregiro de la celda no llega al log"
+        );
+        assert_eq!(
+            s.ofi.compra.disponible_centavos(),
+            demo::PRESUPUESTO,
+            "la partida está intacta: el que rechazó fue la celda"
+        );
+
+        // --- conectadas: el mismo gasto entra ---
+        update(&mut s, Msg::AlternarConexion);
+        assert!(s.ofi.conectadas);
+
+        update(&mut s, Msg::AdjudicarDesde(0));
+
+        assert!(s.ofi.banner.is_none(), "debía entrar: {:?}", s.ofi.banner);
+        assert_eq!(s.ofi.compra.linea_de_tiempo().hitos.len(), 1);
+        assert_eq!(s.ofi.compra.disponible_de(0), 0);
+        assert_eq!(
+            s.ofi.compra.disponible_de(1),
+            demo::PRESUPUESTO - demo::MONTO_OFICINA,
+            "la vecina le prestó el faltante (edge-local)"
+        );
+        assert!(s.ofi.compra.invariante_se_cumple());
+    }
+
+    /// Cada oficina gasta SU celda: el botón de la oficina 1 imputa el gasto a la
+    /// celda 1, no a la 0. (Si `AdjudicarDesde` ignorara la oficina, este test es
+    /// el que se cae.)
+    #[test]
+    fn adjudicar_desde_la_oficina_1_gasta_la_celda_1() {
+        let mut s = nueva();
+        update(&mut s, Msg::AlternarConexion); // conectadas: el monto entra
+
+        update(&mut s, Msg::AdjudicarDesde(1));
+
+        assert!(s.ofi.banner.is_none(), "debía entrar: {:?}", s.ofi.banner);
+        assert_eq!(s.ofi.compra.disponible_de(1), 0, "gastó su propia celda");
+        assert_eq!(
+            s.ofi.compra.disponible_de(0),
+            demo::PRESUPUESTO - demo::MONTO_OFICINA,
+            "la 0 es la que prestó, no la que gastó"
+        );
+    }
+
+    /// El toggle **re-abre** la partida: cambiar el reparto no es agregarle una
+    /// arista a una cuenta que ya gastó. Es lo que hace legible la demostración —
+    /// el mismo monto contra el mismo estado inicial.
+    #[test]
+    fn alternar_conexion_reabre_el_escenario_limpio() {
+        let mut s = nueva();
+        update(&mut s, Msg::AdjudicarDesde(0)); // rebota: deja banner
+        update(&mut s, Msg::Clonar); // bifurca la copia
+        assert!(s.ofi.banner.is_some());
+        assert_eq!(s.ofi.epoca_espejo, 1);
+
+        update(&mut s, Msg::AlternarConexion);
+
+        assert!(s.ofi.conectadas);
+        assert!(s.ofi.banner.is_none(), "la partida se re-abrió limpia");
+        assert_eq!(s.ofi.epoca_espejo, 0, "la copia también es nueva");
+        for i in 0..demo::OFICINAS {
+            assert_eq!(s.ofi.compra.disponible_de(i), CELDA);
+        }
+    }
+
+    /// **La mitad B de la tesis**: la copia arranca de nuevo (linaje bifurcado) y
+    /// la reconciliación la DELATA — con la celda y las dos épocas exactas. Nadie
+    /// impidió el clon: se acotó y se delató.
+    #[test]
+    fn el_clon_se_delata_al_reconciliar_con_las_epocas_exactas() {
+        let mut s = nueva();
+
+        update(&mut s, Msg::Clonar);
+        assert_eq!(s.ofi.epoca_espejo, 1, "la época es monótona device-local");
+        assert!(s.ofi.veredicto.is_none(), "clonar no delata: hay que reconciliar");
+
+        update(&mut s, Msg::Reconciliar);
+
+        assert_eq!(
+            s.ofi.veredicto,
+            Some(Reconciliacion::Clon {
+                celda: CELDA_CLON as u64,
+                epoca_rezagada: 0,
+                epoca_viva: 1,
+            }),
+            "la delación nombra la celda y los dos linajes"
+        );
+    }
+
+    /// Sin bifurcación no hay alarma: las dos vistas traen la misma época en cada
+    /// celda y la fusión es limpia. Sin este camino, el ❌ no significa nada.
+    #[test]
+    fn reconciliar_sin_clon_da_ok() {
+        let mut s = nueva();
+
+        update(&mut s, Msg::Reconciliar);
+
+        assert_eq!(s.ofi.veredicto, Some(Reconciliacion::Ok));
+    }
+
+    /// **La delación no caduca.** `reconciliar` fusiona y converge las épocas, así
+    /// que un segundo apretón devuelve `Ok` — los dos linajes ya son uno. Pero el
+    /// doble gasto ocurrió igual: la alarma es del HECHO, no del último click.
+    #[test]
+    fn la_delacion_del_clon_no_se_borra_al_reconciliar_de_nuevo() {
+        let mut s = nueva();
+        update(&mut s, Msg::Clonar);
+        update(&mut s, Msg::Reconciliar);
+        let delatado = s.ofi.veredicto.clone();
+        assert!(matches!(delatado, Some(Reconciliacion::Clon { .. })));
+
+        // El core ya convergió: este `sync` devuelve Ok...
+        assert_eq!(s.ofi.compra.reconciliar(&s.ofi.espejo), Reconciliacion::Ok);
+        // ...y aun así la sala sigue delatando.
+        update(&mut s, Msg::Reconciliar);
+        assert_eq!(s.ofi.veredicto, delatado);
+    }
+
+    /// Reiniciar el escenario borra lo que pasó pero **conserva el reparto**: se
+    /// reinicia el episodio, no cómo está partida la partida.
+    #[test]
+    fn reiniciar_oficinas_limpia_el_episodio_y_conserva_el_modo() {
+        let mut s = nueva();
+        update(&mut s, Msg::AlternarConexion); // conectadas
+        update(&mut s, Msg::AdjudicarDesde(0)); // gasta
+        update(&mut s, Msg::Clonar);
+        update(&mut s, Msg::Reconciliar);
+
+        update(&mut s, Msg::ReiniciarOficinas);
+
+        assert!(s.ofi.conectadas, "el reparto se conserva");
+        assert!(s.ofi.veredicto.is_none());
+        assert_eq!(s.ofi.epoca_espejo, 0);
+        assert_eq!(s.ofi.reloj, demo::RELOJ_INICIAL);
+        assert!(s.ofi.compra.linea_de_tiempo().hitos.is_empty());
+        for i in 0..demo::OFICINAS {
+            assert_eq!(s.ofi.compra.disponible_de(i), CELDA);
+        }
+    }
+
+    /// El sidebar sigue a la vista: los hitos que lista son los del expediente
+    /// que el canvas tiene enfrente, no los de otro papel. Y la selección se
+    /// suelta al cambiar, porque el `seq` era del expediente anterior.
+    #[test]
+    fn ver_cambia_la_vista_y_el_sidebar_la_sigue() {
+        let mut s = nueva();
+        update(&mut s, Msg::Seleccionar(3));
+
+        update(&mut s, Msg::Ver(Vista::Oficinas));
+
+        assert_eq!(s.vista, Vista::Oficinas);
+        assert_eq!(s.seleccion, None, "el seq era del otro expediente");
+        // El proceso tiene 4 hitos; el escenario de oficinas arranca vacío.
+        assert!(s.compra_visible().linea_de_tiempo().hitos.is_empty());
+
+        update(&mut s, Msg::Ver(Vista::Proceso));
+        assert_eq!(s.compra_visible().linea_de_tiempo().hitos.len(), 4);
+    }
+
+    // -----------------------------------------------------------------------
+    //  La vista se pinta de verdad — ninguna rama es código muerto
+    // -----------------------------------------------------------------------
+    //  Los tests de arriba miden el `update`; éstos miden el `canvas`. Es la
+    //  diferencia entre "el estado cambió" y "hay una ruta que lo muestra, un
+    //  botón que la acciona, y ese botón manda LO SUYO" — el pecado recurrente
+    //  de estas salas.
+    // -----------------------------------------------------------------------
+
+    /// Cada control de la vista Oficinas tiene un botón REAL que despacha **su**
+    /// mensaje. No alcanza con contar los `on_click` ni con ver que la etiqueta
+    /// exista: se aparea etiqueta → `Msg`, que es lo único que caza un botón que
+    /// dice una cosa y hace otra.
+    #[test]
+    fn cada_control_de_oficinas_tiene_boton_de_verdad() {
+        let t = Theme::dark();
+        let mut s = nueva();
+        update(&mut s, Msg::Ver(Vista::Oficinas));
+        let v = canvas(&s, &t, tal_cual);
+
+        // 2 tabs + toggle + demo::OFICINAS adjudicar + clonar + reconciliar +
+        // reiniciar. El canvas de oficinas no tiene otros clicables.
+        assert_eq!(clicables(&v), 2 + 1 + demo::OFICINAS + 3);
+
+        // Y cada uno manda lo suyo.
+        assert_eq!(despacha_exacto(&v, "proceso"), Msg::Ver(Vista::Proceso));
+        assert_eq!(despacha_exacto(&v, "oficinas"), Msg::Ver(Vista::Oficinas));
+        assert_eq!(despacha(&v, "conectar las oficinas"), Msg::AlternarConexion);
+        for i in 0..demo::OFICINAS {
+            assert_eq!(
+                despacha(&v, &format!("desde la oficina {i}")),
+                Msg::AdjudicarDesde(i),
+                "el botón de la oficina {i} tiene que adjudicar DESDE la {i}"
+            );
+        }
+        assert_eq!(
+            despacha(&v, &format!("clonar la oficina {CELDA_CLON}")),
+            Msg::Clonar
+        );
+        assert_eq!(despacha(&v, "reconciliar"), Msg::Reconciliar);
+        assert_eq!(despacha(&v, "reiniciar oficinas"), Msg::ReiniciarOficinas);
+    }
+
+    /// El tab activo cambia con la vista y sigue apuntando a donde dice. (El
+    /// `despacha` de arriba corre con la vista Oficinas; éste cubre la otra.)
+    #[test]
+    fn los_tabs_despachan_su_vista_desde_las_dos_vistas() {
+        let t = Theme::dark();
+        let s = nueva();
+        let v = canvas(&s, &t, tal_cual);
+        assert_eq!(despacha_exacto(&v, "proceso"), Msg::Ver(Vista::Proceso));
+        assert_eq!(despacha_exacto(&v, "oficinas"), Msg::Ver(Vista::Oficinas));
+    }
+
+    /// La vista canta el disponible de CADA celda y el de la partida entera —
+    /// las dos cosas juntas, que es justo el punto: la partida puede tener plata
+    /// de sobra y la oficina no poder gastar.
+    #[test]
+    fn oficinas_pinta_el_disponible_de_cada_celda_y_el_de_la_partida() {
+        let t = Theme::dark();
+        let mut s = nueva();
+        update(&mut s, Msg::Ver(Vista::Oficinas));
+        update(&mut s, Msg::AlternarConexion);
+        update(&mut s, Msg::AdjudicarDesde(0)); // gasta su celda + 100,00 de la vecina
+
+        let v = canvas(&s, &t, tal_cual);
+        for i in 0..demo::OFICINAS {
+            assert!(
+                pinta(&v, &format!("OFICINA {i}")),
+                "falta la card de la oficina {i}"
+            );
+        }
+        // Celda 0 en 0,00, celda 1 con 400,00, y la partida con esos mismos 400,00.
+        assert!(pinta(&v, &money(0)));
+        assert!(pinta(&v, &money(demo::PRESUPUESTO - demo::MONTO_OFICINA)));
+        assert!(pinta(&v, "LA PARTIDA"));
+    }
+
+    /// El toggle cambia lo que la sala ENSEÑA, no sólo un booleano: la regla del
+    /// reparto vigente se pinta, las dos redacciones son distintas, y el botón
+    /// cambia de rótulo (a «particionar») sin dejar de despachar el toggle.
+    #[test]
+    fn el_toggle_cambia_la_regla_que_se_pinta() {
+        let t = Theme::dark();
+        let mut s = nueva();
+        update(&mut s, Msg::Ver(Vista::Oficinas));
+
+        let v = canvas(&s, &t, tal_cual);
+        assert!(pinta(&v, "PARTICIONADAS"), "arranca en el fail-safe");
+        assert!(pinta(&v, "no hay ruta al excedente de la vecina"));
+
+        update(&mut s, Msg::AlternarConexion);
+        let v = canvas(&s, &t, tal_cual);
+        assert!(pinta(&v, "CONECTADAS en cadena"));
+        assert!(pinta(&v, "rebalancea"));
+        assert_eq!(
+            despacha(&v, "particionar las oficinas"),
+            Msg::AlternarConexion,
+            "el otro rótulo del toggle también tiene que accionar"
+        );
+    }
+
+    /// El rechazo del escrow llega al canvas con la frase EXACTA del core (no una
+    /// copia de la sala), y con el disponible de la celda.
+    #[test]
+    fn el_rechazo_de_la_celda_se_pinta_con_la_frase_del_core() {
+        let t = Theme::dark();
+        let mut s = nueva();
+        update(&mut s, Msg::Ver(Vista::Oficinas));
+        update(&mut s, Msg::AdjudicarDesde(0)); // particionadas: rebota
+
+        let v = canvas(&s, &t, tal_cual);
+        let esperado = RechazoCompra::Sobregiro {
+            solicitado: demo::MONTO_OFICINA,
+            disponible: CELDA,
+        }
+        .to_string();
+        assert!(pinta(&v, &esperado), "la sala cita al core, no lo re-redacta");
+    }
+
+    /// **Los cuatro estados del veredicto se pintan**, y cada uno tiene una ruta
+    /// real que lo activa. Si alguna rama no fuera alcanzable, esto se cae — que
+    /// es el punto de tenerlo.
+    #[test]
+    fn los_cuatro_estados_del_veredicto_son_alcanzables_y_se_pintan() {
+        let t = Theme::dark();
+        let ver = |s: &State| canvas(s, &t, tal_cual);
+
+        // 1) recién abierta: la tesis, y la invitación a clonar.
+        let mut s = nueva();
+        update(&mut s, Msg::Ver(Vista::Oficinas));
+        assert!(pinta(&ver(&s), TESIS_CLON));
+        assert!(pinta(&ver(&s), "Hay dos vistas de esta misma partida"));
+
+        // 2) clonada pero sin reconciliar: la copia ya se bifurcó y se avisa.
+        update(&mut s, Msg::Clonar);
+        assert!(pinta(&ver(&s), "ya arrancó de nuevo"));
+        assert!(pinta(&ver(&s), "Apretá «reconciliar» para que se delate"));
+
+        // 3) el CLON delatado: la celda y las dos épocas, en palabras.
+        update(&mut s, Msg::Reconciliar);
+        assert!(pinta(
+            &ver(&s),
+            &format!("❌ CLON detectado en la celda {CELDA_CLON} — época rezagada 0 vs viva 1")
+        ));
+        assert!(pinta(&ver(&s), "No se pudo IMPEDIR"));
+        assert!(pinta(&ver(&s), "ACOTADO a esa sola celda"));
+        assert!(pinta(&ver(&s), "DELATADO"));
+
+        // 4) sin conflicto: reconciliar sin haber clonado.
+        let mut s = nueva();
+        update(&mut s, Msg::Ver(Vista::Oficinas));
+        update(&mut s, Msg::Reconciliar);
+        assert!(pinta(&ver(&s), "✅ Sin conflicto"));
+        assert!(pinta(&ver(&s), "nadie gastó la partida por duplicado"));
+    }
+
+    /// La vista Proceso sigue intacta: el tab la trae de vuelta con su timeline,
+    /// sus cuatro acciones y cada una despachando lo suyo (el modo nuevo no se
+    /// comió la sala que ya había).
+    #[test]
+    fn la_vista_proceso_sigue_entera_detras_del_tab() {
+        let t = Theme::dark();
+        let mut s = nueva();
+        update(&mut s, Msg::Ver(Vista::Oficinas));
+        update(&mut s, Msg::Ver(Vista::Proceso));
+
+        let v = canvas(&s, &t, tal_cual);
+        assert!(pinta(&v, TESIS));
+        assert!(pinta(&v, "PARTIDA CERTIFICADA"));
+        assert!(pinta(&v, "4 hitos del proceso"));
+        // 2 tabs + las 4 acciones del proceso (el timeline es read-only).
+        assert_eq!(clicables(&v), 2 + 4);
+        assert_eq!(despacha(&v, "(sobregira)"), Msg::AdjudicarSobregira);
+        assert_eq!(
+            despacha(&v, &format!("+ adjudicar {}", money(CABE_CENTAVOS))),
+            Msg::AdjudicarCabe
+        );
+        assert_eq!(despacha(&v, "pagar"), Msg::Pagar);
+        assert_eq!(despacha(&v, "reiniciar"), Msg::Reiniciar);
+    }
+
+    /// El sidebar también sigue a la vista: lista los hitos del expediente que el
+    /// canvas tiene enfrente, y cada fila despacha la selección de SU hito.
+    #[test]
+    fn el_sidebar_lista_los_hitos_de_la_vista_activa() {
+        let t = Theme::dark();
+        let mut s = nueva();
+        let v = sidebar(&s, &t, "", tal_cual);
+        assert!(pinta(&v, "4 hitos del proceso"));
+        assert_eq!(clicables(&v), 4, "una fila clicable por hito");
+        // La fila de la adjudicación del fixture (seq 4) selecciona el seq 4.
+        assert_eq!(
+            despacha(&v, "adjudicacion"),
+            Msg::Seleccionar(4),
+            "cada fila selecciona SU hito"
+        );
+
+        update(&mut s, Msg::Ver(Vista::Oficinas));
+        update(&mut s, Msg::AlternarConexion);
+        update(&mut s, Msg::AdjudicarDesde(0));
+
+        let v = sidebar(&s, &t, "", tal_cual);
+        assert!(pinta(&v, "1 hitos del proceso"), "el del expediente de oficinas");
+        assert!(pinta(&v, demo::PROVEEDOR_OFICINA));
+    }
+}
diff --git a/03_ukupacha/agora/agora-app/src/modulos/qillqa/mesa.rs b/03_ukupacha/agora/agora-app/src/modulos/qillqa/mesa.rs
new file mode 100644
index 0000000..61b7f9e
--- /dev/null
+++ b/03_ukupacha/agora/agora-app/src/modulos/qillqa/mesa.rs
@@ -0,0 +1,971 @@
+//! Módulo **Mesa de partes** (qillqa-mesa + qillqa-core): la cola del día del
+//! registro público, el **alta** de un acto (funcionario + tipo + partes + refs
+//! → firma + encadena + persiste) y, por cada acto, su expediente (el hilo del
+//! trámite) y la CONSTANCIA con su QR de prueba de inclusión — el recibo que
+//! mata el "se extravió".
+//!
+//! Frontend Llimphi agnóstico del `Msg` global (regla dura #2): la lógica vive
+//! en `qillqa-core`/`qillqa-mesa`, aquí sólo se pinta. El shell inyecta el
+//! `lift` que sube el [`Msg`] local a [`crate::model::Msg::Qillqa`].
+//!
+//! **El reparto del form** (misma tensión de contratos que la sala Verificar):
+//! los inputs de texto viven sobre el `Msg` GLOBAL y `FocusedInput`, pero esta
+//! sala es genérica sobre `H`. Solución: el **shell aporta los tres widgets**
+//! (`crate::ui::input_row` + `FocusedInput::Mesa*`) y el botón; la sala recibe
+//! el texto ya tipeado como `String` en [`Msg::Asentar`] y lo **parsea con el
+//! core** (`parse_refs`/`acto_simple`). Lo que sí es estado de la sala —quién
+//! firma, el error del alta— lo pinta la sala ([`picker`], [`error_view`]).
+//!
+//! **Dónde vive el registro:** en `<data_dir de agora>/mesa/`, igual que las
+//! actas (`<data_dir>/actas.postcard`). Un directorio vacío se **siembra** con
+//! el registro demo del core (`demo::registro_en`, que manda las partes al CAS)
+//! — arrancar en blanco no rompe nada. Sin directorio (pantallazo/tests) la
+//! sala corre el demo en memoria.
+
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use super::hex_corto;
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::prelude::{
+    length, percent, AlignItems, Dimension, FlexDirection, JustifyContent, Rect as TaffyRect, Size,
+    Style,
+};
+use llimphi_ui::llimphi_raster::kurbo::{Affine, Rect as KurboRect};
+use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
+use llimphi_ui::{PaintRect, View};
+
+use llimphi_icons::Icon;
+use llimphi_widget_card::{card_view, CardOptions, CardPalette};
+use llimphi_widget_empty::{empty_view, EmptyPalette};
+use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec};
+use llimphi_widget_stat_card::{stat_card_view, StatCardPalette};
+
+use qillqa_core::{par_desde_semilla, Cabeza, Registro};
+use qillqa_mesa::{
+    acto_simple, cola_de_con, constancia_matriz, constancia_token, demo, expediente_de,
+    guardar_registro, parse_refs, ActoRow, QrMatriz,
+};
+
+// ---------------------------------------------------------------------------
+//  Estado
+// ---------------------------------------------------------------------------
+
+/// Estado del módulo: el registro (de disco o demo), la cola proyectada, el acto
+/// seleccionado con su constancia, y el form de alta.
+pub(crate) struct State {
+    /// El registro real sobre `qillqa-core` (serie `mesa-de-partes`).
+    registro: Registro,
+    /// El directorio del registro. `Some` = el alta persiste y el contenido va
+    /// al CAS; `None` = demo en memoria (pantallazo/tests), el alta no sale de
+    /// la RAM.
+    dir: Option<PathBuf>,
+    /// La semilla VIGENTE de la llave de cadena (la que `guardar_registro`
+    /// persiste; sin ella el registro no se puede reabrir para seguir asentando).
+    semilla: [u8; 32],
+    /// Los funcionarios con llave que pueden firmar un acto: de `<dir>/claves/`
+    /// si hay directorio, o los del demo si corre en memoria.
+    funcionarios: Vec<(String, [u8; 32])>,
+    /// La cabeza anclada contra la que se construyen las constancias. Se re-ancla
+    /// **viva** (con el reloj) en cada selección y tras cada alta, como el
+    /// operador standalone — antes era una constante sellada una sola vez.
+    cabeza: Cabeza,
+    /// La cola del día, proyectada del log para pintar.
+    cola: Vec<ActoRow>,
+    /// El acto seleccionado (por `seq`), si hay.
+    sel: Option<u64>,
+    /// El QR de la constancia del acto seleccionado. `None` si la constancia no
+    /// se puede construir o no entra en un QR (el core lo dice, no se adivina).
+    qr: Option<QrMatriz>,
+    /// Índice del funcionario que firma el alta (dentro de [`Self::funcionarios`]).
+    f_func: usize,
+    /// Por qué no se pudo asentar lo último que se intentó.
+    form_error: Option<String>,
+    /// Por qué se está mostrando el demo en memoria en vez del registro de disco
+    /// (estado corrupto, semilla que no corresponde…). `None` = todo normal.
+    aviso: Option<String>,
+}
+
+impl State {
+    /// El `ActoRow` seleccionado, si existe.
+    fn seleccionado(&self) -> Option<&ActoRow> {
+        let s = self.sel?;
+        self.cola.iter().find(|a| a.seq == s)
+    }
+
+    /// Partes de un acto por `seq` (para nombrar los hitos del expediente).
+    fn partes_de(&self, seq: u64) -> Option<&str> {
+        self.cola.iter().find(|a| a.seq == seq).map(|a| a.partes.as_str())
+    }
+}
+
+/// ¿El último alta prosperó? Lo consulta el shell para limpiar los campos del
+/// form sólo cuando el acto quedó realmente asentado.
+pub(crate) fn sin_error(state: &State) -> bool {
+    state.form_error.is_none()
+}
+
+// ---------------------------------------------------------------------------
+//  Mensajes
+// ---------------------------------------------------------------------------
+
+#[derive(Clone)]
+pub(crate) enum Msg {
+    /// Selecciona el acto en `seq` (lo muestra en el canvas) y re-ancla.
+    Seleccionar(u64),
+    /// Elige quién firma el alta (índice en `funcionarios`).
+    ElegirFuncionario(usize),
+    /// **El alta.** El shell manda el texto de los tres campos ya tipeados; la
+    /// sala lo valida y lo parsea con el core. Lo dispara el botón "asentar" del
+    /// índice y el Enter sobre cualquiera de los tres campos.
+    Asentar {
+        tipo: String,
+        partes: String,
+        refs: String,
+    },
+}
+
+// ---------------------------------------------------------------------------
+//  Construcción — disco o demo
+// ---------------------------------------------------------------------------
+
+fn ahora() -> u64 {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map(|d| d.as_secs())
+        .unwrap_or(0)
+}
+
+/// Proyecta la cola del día resolviendo el texto de las partes contra la fuente
+/// que corresponda: el CAS del registro si hay directorio, y las constantes del
+/// demo si corre en memoria. Lo que no se pueda recuperar queda vacío — el log
+/// jura el hash, no el texto, y la cola no lo inventa.
+fn cola_del(dir: Option<&PathBuf>, r: &Registro) -> Vec<ActoRow> {
+    match dir {
+        Some(d) => cola_de_con(r.log(), |h| {
+            qillqa_mesa::cas::leer(d, h)
+                .ok()
+                .and_then(|b| String::from_utf8(b).ok())
+        }),
+        None => demo::cola(r),
+    }
+}
+
+/// Arranca el módulo. `data_dir` = el directorio de datos de agora; el registro
+/// vive en `<data_dir>/mesa/`. `None` = demo en memoria (pantallazo/tests).
+pub(crate) fn nueva(data_dir: Option<&Path>) -> State {
+    match data_dir {
+        None => desde(demo::registro(), None, demo::SEMILLA_AUTORIDAD, demo::funcionarios(), None),
+        Some(d) => {
+            let dir = d.join("mesa");
+            match abrir_o_sembrar(&dir) {
+                Ok((r, semilla, funcs)) => desde(r, Some(dir), semilla, funcs, None),
+                // El registro de disco no se pudo abrir (estado corrupto, semilla
+                // que no corresponde al linaje…). La sala NO ladrillea ni miente
+                // diciendo que ese registro es el de disco: cae al demo EN MEMORIA
+                // —el alta no persistirá— y lo dice en el índice.
+                Err(e) => desde(
+                    demo::registro(),
+                    None,
+                    demo::SEMILLA_AUTORIDAD,
+                    demo::funcionarios(),
+                    Some(e),
+                ),
+            }
+        }
+    }
+}
+
+/// Abre `<dir>` si ya tiene un registro; si no, lo **siembra** con el demo del
+/// core (mandando las partes al CAS) y lo persiste. Devuelve el registro, su
+/// semilla vigente y los funcionarios con llave.
+fn abrir_o_sembrar(dir: &Path) -> Result<(Registro, [u8; 32], Vec<(String, [u8; 32])>), String> {
+    if dir.join("estado.qillqa").exists() {
+        let est = qillqa_mesa::leer_estado(dir)?;
+        let semilla = est.semilla;
+        // `reconstruir` re-arma el `Registro` desde las ENTRADAS FIRMADAS: es el
+        // camino que deja que la cola se re-proyecte de un registro que esta sala
+        // no construyó (el bloqueador que la extracción de `cola_de_con` abrió).
+        let r = est.reconstruir()?;
+        return Ok((r, semilla, qillqa_mesa::listar_funcionarios(dir)));
+    }
+
+    std::fs::create_dir_all(dir).map_err(|e| format!("no pude crear {}: {e}", dir.display()))?;
+    let r = demo::registro_en(Some(dir))?;
+    guardar_registro(dir, demo::SEMILLA_AUTORIDAD, &r)?;
+    // La llave del funcionario delegado del demo va a `<dir>/claves/`, donde el
+    // CLI (`qillqa keygen`) las deja y donde `listar_funcionarios` las busca: sin
+    // esto un registro recién sembrado no tendría a nadie que pueda firmar, y el
+    // alta nacería muerta. La semilla del demo es una constante pública del core.
+    let claves = dir.join("claves");
+    std::fs::create_dir_all(&claves).map_err(|e| e.to_string())?;
+    std::fs::write(claves.join(format!("{}.seed", demo::FUNCIONARIO)), demo::SEMILLA_FUNCIONARIO)
+        .map_err(|e| e.to_string())?;
+    Ok((r, demo::SEMILLA_AUTORIDAD, qillqa_mesa::listar_funcionarios(dir)))
+}
+
+/// El estado a partir de un registro cualquiera: ancla la cabeza y PROYECTA la
+/// cola desde el log (`qillqa_mesa::cola_de_con`).
+///
+/// La cola sale del log — la única fuente firmada —, así que un registro leído
+/// de disco (`leer_estado` → `reconstruir`) proyecta igual que uno construido
+/// aquí; sólo cambia el resolvedor del contenido (el CAS en vez del demo).
+fn desde(
+    mut r: Registro,
+    dir: Option<PathBuf>,
+    semilla: [u8; 32],
+    funcionarios: Vec<(String, [u8; 32])>,
+    aviso: Option<String>,
+) -> State {
+    let cabeza = r.anclar(ahora());
+    let cola = cola_del(dir.as_ref(), &r);
+    let sel = cola.first().map(|a| a.seq);
+
+    let mut s = State {
+        registro: r,
+        dir,
+        semilla,
+        funcionarios,
+        cabeza,
+        cola,
+        sel: None,
+        qr: None,
+        f_func: 0,
+        form_error: None,
+        aviso,
+    };
+    if let Some(seq) = sel {
+        seleccionar(&mut s, seq);
+    }
+    s
+}
+
+// ---------------------------------------------------------------------------
+//  Update
+// ---------------------------------------------------------------------------
+
+pub(crate) fn update(state: &mut State, msg: Msg) {
+    match msg {
+        Msg::Seleccionar(seq) => seleccionar(state, seq),
+        Msg::ElegirFuncionario(i) => {
+            // Un índice fuera de rango no se guarda: el picker se pinta de
+            // `funcionarios`, así que sólo llegaría si la lista cambió bajo los pies.
+            if i < state.funcionarios.len() {
+                state.f_func = i;
+            }
+        }
+        Msg::Asentar {
+            tipo,
+            partes,
+            refs,
+        } => match asentar(state, &tipo, &partes, &refs) {
+            Ok(seq) => {
+                state.form_error = None;
+                state.cola = cola_del(state.dir.as_ref(), &state.registro);
+                seleccionar(state, seq);
+            }
+            Err(e) => state.form_error = Some(e),
+        },
+    }
+}
+
+/// Selecciona `seq`, **re-ancla la cabeza con el reloj** y recalcula el QR de su
+/// constancia. El ancla viva es lo que hace que la constancia que el ciudadano se
+/// lleva tenga la fecha de HOY (la ventana anti-antedatación del SDD), no la de
+/// un sello puesto al arrancar la app.
+fn seleccionar(state: &mut State, seq: u64) {
+    state.sel = Some(seq);
+    let cabeza = state.registro.anclar(ahora());
+    state.qr = state
+        .registro
+        .constancia(seq, &cabeza)
+        .and_then(|c| constancia_matriz(&c).ok());
+    state.cabeza = cabeza;
+}
+
+/// Ejecuta el alta: valida, firma con el funcionario elegido, encadena y (si hay
+/// directorio) manda el contenido al CAS y persiste el registro. Devuelve el
+/// `seq` nuevo o un error legible para el operador.
+fn asentar(state: &mut State, tipo: &str, partes: &str, refs: &str) -> Result<u64, String> {
+    let tipo = tipo.trim().to_string();
+    if tipo.is_empty() {
+        return Err("falta el tipo del acto".into());
+    }
+    let (_, seed) = state
+        .funcionarios
+        .get(state.f_func)
+        .cloned()
+        .ok_or("no hay funcionario con llave — usa `qillqa keygen`")?;
+    let par = par_desde_semilla(seed);
+
+    // Los enlaces se traducen ANTES de tocar nada: un `refs` colgado es un
+    // expediente roto, y un seq inexistente se rechaza sin haber escrito el blob.
+    let refs = parse_refs(state.registro.log(), refs)?;
+
+    // El texto de las partes va al CAS del registro (si hay directorio): el
+    // contenido queda recuperable con `qillqa adjunto`, no sólo su hash.
+    let hash_contenido = match &state.dir {
+        Some(dir) => qillqa_mesa::cas::guardar(dir, partes.as_bytes())?,
+        None => format::hash(partes.as_bytes()), // demo sin disco
+    };
+
+    let serie = state.registro.serie.clone();
+    let acto = acto_simple(serie, tipo, hash_contenido, ahora(), refs);
+    let seq = state.registro.asentar(&par, acto);
+    if let Some(dir) = state.dir.clone() {
+        guardar_registro(&dir, state.semilla, &state.registro)?;
+    }
+    Ok(seq)
+}
+
+// ---------------------------------------------------------------------------
+//  Helpers de pintado
+// ---------------------------------------------------------------------------
+
+/// Recorta un token largo (base64) para mostrarlo sin desbordar.
+fn token_corto(t: &str) -> String {
+    let n = 44;
+    if t.chars().count() > n {
+        let head: String = t.chars().take(n).collect();
+        format!("{head}…")
+    } else {
+        t.to_string()
+    }
+}
+
+/// Un `Style` de fila de alto fijo, ancho pleno.
+fn fila(h: f32) -> Style {
+    Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(h),
+        },
+        ..Default::default()
+    }
+}
+
+/// Una línea etiqueta→valor dentro de una card (label tenue, valor legible).
+fn campo<H: Clone + Send + Sync + 'static>(theme: &Theme, label: &str, valor: String) -> View<H> {
+    View::new(fila(18.0))
+        .text(format!("{label}  {valor}"), 12.0, theme.fg_text)
+        .ellipsis(1)
+}
+
+/// El pintor del QR — la misma composición que `qillqa-mesa-llimphi::qr_painter`
+/// (zona tranquila de 2 módulos, fondo blanco y módulos negros, centrado y
+/// escalado al lado menor). El QR se pinta blanco-sobre-negro SIEMPRE, sin
+/// teñirlo con la paleta: un lector de QR no sabe de themes.
+fn qr_painter(
+    m: &QrMatriz,
+) -> impl Fn(&mut llimphi_ui::llimphi_raster::vello::Scene, &mut llimphi_ui::llimphi_text::Typesetter, PaintRect)
+{
+    let ancho = m.ancho;
+    let modulos = m.modulos.clone();
+    move |scene, _ts, rect| {
+        let borde = 2usize;
+        let lado_mod = (ancho + borde * 2) as f64;
+        let escala = (rect.w.min(rect.h) as f64) / lado_mod;
+        let dib = lado_mod * escala;
+        let x0 = rect.x as f64 + (rect.w as f64 - dib) * 0.5;
+        let y0 = rect.y as f64 + (rect.h as f64 - dib) * 0.5;
+        scene.fill(
+            Fill::NonZero,
+            Affine::IDENTITY,
+            Color::WHITE,
+            None,
+            &KurboRect::new(x0, y0, x0 + dib, y0 + dib),
+        );
+        for my in 0..ancho {
+            for mx in 0..ancho {
+                if !modulos[my * ancho + mx] {
+                    continue;
+                }
+                let px = x0 + (mx + borde) as f64 * escala;
+                let py = y0 + (my + borde) as f64 * escala;
+                scene.fill(
+                    Fill::NonZero,
+                    Affine::IDENTITY,
+                    Color::BLACK,
+                    None,
+                    &KurboRect::new(px, py, px + escala, py + escala),
+                );
+            }
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------
+//  Índice — el form de alta (lo que es de la sala) + la cola del día
+// ---------------------------------------------------------------------------
+
+/// **El selector de funcionario**: quién firma el acto. Es estado de la sala, así
+/// que lo pinta la sala (el shell sólo aporta los campos de texto).
+///
+/// Patrón botón de dos estados, como los interruptores de la sala Verificar — el
+/// widget `segmented` que usa el standalone no es dependencia de `agora-app`, y
+/// reimplementarlo a mano sería peor que reusar el patrón que ya está aquí.
+pub(crate) fn picker<H: Clone + Send + Sync + 'static>(
+    state: &State,
+    theme: &Theme,
+    lift: impl Fn(Msg) -> H + Clone + Send + Sync + 'static,
+) -> View<H> {
+    let etiqueta: View<H> = View::new(fila(16.0)).text(
+        "FUNCIONARIO (quien firma)".to_string(),
+        11.0,
+        theme.accent,
+    );
+
+    // Sin llave no hay quien firme: se dice, y el alta lo rechaza con el mismo
+    // motivo. Pasa si el operador borró `<dir>/claves/` de un registro real.
+    if state.funcionarios.is_empty() {
+        return View::new(Style {
+            flex_direction: FlexDirection::Column,
+            size: Size {
+                width: percent(1.0_f32),
+                height: length(44.0_f32),
+            },
+            flex_shrink: 0.0,
+            ..Default::default()
+        })
+        .children(vec![
+            etiqueta,
+            View::new(fila(20.0))
+                .text(
+                    "(sin funcionario con llave — usa `qillqa keygen`)".to_string(),
+                    11.5,
+                    theme.fg_destructive,
+                )
+                .ellipsis(1),
+        ]);
+    }
+
+    let botones: Vec<View<H>> = state
+        .funcionarios
+        .iter()
+        .enumerate()
+        .map(|(i, (nombre, _))| {
+            let activo = i == state.f_func;
+            View::new(Style {
+                size: Size {
+                    width: Dimension::auto(),
+                    height: length(26.0_f32),
+                },
+                flex_grow: 1.0,
+                align_items: Some(AlignItems::Center),
+                justify_content: Some(JustifyContent::Center),
+                ..Default::default()
+            })
+            .fill(if activo { theme.accent } else { theme.bg_button })
+            .radius(6.0)
+            .text(
+                nombre.clone(),
+                11.5,
+                if activo { theme.bg_app } else { theme.fg_text },
+            )
+            .ellipsis(1)
+            .on_click(lift(Msg::ElegirFuncionario(i)))
+        })
+        .collect();
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(48.0_f32),
+        },
+        flex_shrink: 0.0,
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(4.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![
+        etiqueta,
+        View::new(Style {
+            flex_direction: FlexDirection::Row,
+            size: Size {
+                width: percent(1.0_f32),
+                height: length(26.0_f32),
+            },
+            gap: Size {
+                width: length(4.0_f32),
+                height: length(0.0_f32),
+            },
+            ..Default::default()
+        })
+        .children(botones),
+    ])
+}
+
+/// El error del último alta (`None` si prosperó o no se intentó todavía), y el
+/// aviso de que el registro de disco no se pudo abrir. Los pinta la sala porque
+/// son su estado; el shell los coloca junto al botón.
+pub(crate) fn error_view<H: Clone + Send + Sync + 'static>(
+    state: &State,
+    theme: &Theme,
+) -> Option<View<H>> {
+    let texto = match (&state.form_error, &state.aviso) {
+        (Some(e), _) => e.clone(),
+        (None, Some(a)) => format!("registro en memoria: {a}"),
+        (None, None) => return None,
+    };
+    Some(
+        View::new(Style {
+            size: Size {
+                width: percent(1.0_f32),
+                height: Dimension::auto(),
+            },
+            min_size: Size {
+                width: length(0.0_f32),
+                height: length(28.0_f32),
+            },
+            flex_shrink: 0.0,
+            ..Default::default()
+        })
+        .text(texto, 11.5, theme.fg_destructive),
+    )
+}
+
+pub(crate) fn sidebar<H: Clone + Send + Sync + 'static>(
+    state: &State,
+    theme: &Theme,
+    filtro: &str,
+    lift: impl Fn(Msg) -> H + Clone + Send + Sync + 'static,
+) -> View<H> {
+    let pal = ListPalette::from_theme(theme);
+    let rows: Vec<ListRow<H>> = state
+        .cola
+        .iter()
+        .map(|a| ListRow {
+            // La descripción es la única columna que un humano lee. Si el contenido
+            // no se pudo recuperar del CAS, la fila cae a la fecha — no se inventa.
+            label: if a.partes.is_empty() {
+                format!("#{} · {} · {}", a.seq, a.tipo, a.fecha)
+            } else {
+                format!("#{} · {} · {}", a.seq, a.tipo, a.partes)
+            },
+            selected: state.sel == Some(a.seq),
+            on_click: lift(Msg::Seleccionar(a.seq)),
+        })
+        .collect();
+    let rows = crate::modulos::filtrar_rows(rows, filtro);
+    let total = rows.len();
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![list_view(ListSpec {
+        rows,
+        total,
+        caption: Some(format!("cola del día · {total} actos")),
+        truncated_hint: None,
+        row_height: 44.0,
+        palette: pal,
+    })])
+}
+
+// ---------------------------------------------------------------------------
+//  Canvas — detalle + expediente + constancia con QR
+// ---------------------------------------------------------------------------
+
+pub(crate) fn canvas<H: Clone + Send + Sync + 'static>(
+    state: &State,
+    theme: &Theme,
+    lift: impl Fn(Msg) -> H + Clone + Send + Sync + 'static,
+) -> View<H> {
+    let cont = |children: Vec<View<H>>| {
+        View::new(Style {
+            flex_direction: FlexDirection::Row,
+            size: Size {
+                width: percent(1.0_f32),
+                height: percent(1.0_f32),
+            },
+            gap: Size {
+                width: length(12.0_f32),
+                height: length(0.0_f32),
+            },
+            padding: TaffyRect {
+                left: length(12.0_f32),
+                right: length(12.0_f32),
+                top: length(8.0_f32),
+                bottom: length(12.0_f32),
+            },
+            ..Default::default()
+        })
+        .children(children)
+    };
+
+    let Some(sel) = state.seleccionado() else {
+        return cont(vec![empty_view(
+            Icon::FileText,
+            "Sin acto seleccionado",
+            Some("Elige un acto de la cola del día, o asienta uno nuevo desde el índice."),
+            &EmptyPalette::from_theme(theme),
+        )]);
+    };
+
+    let cpal = CardPalette::from_theme(theme);
+    let spal = StatCardPalette::from_theme(theme);
+
+    // --- Columna izquierda: detalle del acto + expediente ---
+    let detalle = card_view(
+        vec![
+            View::new(fila(24.0))
+                .text(format!("#{} · {}", sel.seq, sel.tipo), 16.0, theme.fg_text)
+                .ellipsis(1),
+            View::new(fila(20.0))
+                .text(sel.partes.clone(), 12.5, theme.fg_muted)
+                .ellipsis(2),
+            campo(theme, "fecha (unix)", sel.fecha.to_string()),
+            campo(theme, "contenido", hex_corto(&sel.hash)),
+        ],
+        CardOptions::with_signature(theme),
+        &cpal,
+    );
+
+    // El expediente: el hilo del trámite (actos ligados por `refs`).
+    let exp = expediente_de(state.registro.log(), sel.seq);
+    let mut hitos: Vec<View<H>> = vec![View::new(fila(16.0)).text(
+        format!("expediente · {} hitos", exp.hitos.len()),
+        11.0,
+        theme.accent,
+    )];
+    for h in &exp.hitos {
+        let seleccionado = h.seq == sel.seq;
+        let color = if seleccionado { theme.accent } else { theme.fg_text };
+        let flecha = if seleccionado { "▸ " } else { "  " };
+        let desc = state.partes_de(h.seq).unwrap_or("");
+        hitos.push(
+            View::new(fila(20.0))
+                .text(
+                    format!("{flecha}#{} · {} · {}", h.seq, h.tipo, desc),
+                    12.0,
+                    color,
+                )
+                .ellipsis(1)
+                .on_click(lift(Msg::Seleccionar(h.seq))),
+        );
+    }
+    let expediente = card_view(hitos, CardOptions::with_signature(theme), &cpal);
+
+    let col_izq = View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(0.55_f32),
+            height: percent(1.0_f32),
+        },
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(10.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![detalle, expediente]);
+
+    // --- Columna derecha: la constancia (prueba de inclusión) + su QR ---
+    let constancia_card = match state.registro.constancia(sel.seq, &state.cabeza) {
+        Some(c) => {
+            let token = constancia_token(&c).unwrap_or_else(|_| "—".to_string());
+            let mut hijos: Vec<View<H>> = vec![
+                View::new(fila(16.0)).text(
+                    "constancia · prueba de inclusión".to_string(),
+                    11.0,
+                    theme.accent,
+                ),
+                // Un stat-card resalta el número de acto (el dato del recibo).
+                View::new(fila(92.0)).children(vec![stat_card_view::<H>(
+                    "ACTO Nº",
+                    c.seq.to_string(),
+                    "posición sellada en la cadena",
+                    theme.accent,
+                    &[],
+                    &spal,
+                )]),
+            ];
+            // El QR de la constancia: el recibo escaneable. Lo codifica el core
+            // (`constancia_matriz`); aquí sólo se pinta. Si el core no lo pudo
+            // codificar no se fabrica un cartel de error para un caso que nadie
+            // alcanza (haría falta una constancia > ~2.9 KB, o sea ~2^85
+            // entradas): simplemente no hay QR, y el token de abajo —el respaldo
+            // que el propio core documenta— sigue estando.
+            if let Some(qr) = &state.qr {
+                hijos.push(
+                    View::new(Style {
+                        size: Size {
+                            width: percent(1.0_f32),
+                            height: length(150.0_f32),
+                        },
+                        flex_shrink: 0.0,
+                        ..Default::default()
+                    })
+                    .paint_with(qr_painter(qr)),
+                );
+            }
+            hijos.extend([
+                campo(theme, "serie", c.serie.clone()),
+                campo(theme, "fecha del ancla (unix)", c.cabeza.timestamp.to_string()),
+                campo(theme, "altura de la cabeza", c.cabeza.altura.to_string()),
+                campo(theme, "raíz merkle", hex_corto(&c.cabeza.raiz_merkle)),
+                campo(theme, "respaldo", token_corto(&token)),
+                View::new(fila(30.0)).text(
+                    // Sirve con y sin QR: las dos formas abren el mismo verify.
+                    "escanealo, o copia el token → `qillqa verify` en cualquier \
+máquina, offline"
+                        .to_string(),
+                    11.0,
+                    theme.fg_muted,
+                ),
+            ]);
+            card_view(hijos, CardOptions::with_signature(theme), &cpal)
+        }
+        None => card_view(
+            vec![View::new(fila(20.0)).text(
+                "constancia no disponible para este acto".to_string(),
+                12.0,
+                theme.fg_muted,
+            )],
+            CardOptions::default(),
+            &cpal,
+        ),
+    };
+
+    let col_der = View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(0.45_f32),
+            height: percent(1.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![constancia_card]);
+
+    cont(vec![col_izq, col_der])
+}
+
+// ---------------------------------------------------------------------------
+//  Tests — el registro REAL de la sala, no un simulacro
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Un data_dir temporal (la sala le cuelga `mesa/` adentro).
+    fn dir_tmp(nombre: &str) -> PathBuf {
+        let d = std::env::temp_dir().join(format!(
+            "agora-mesa-{nombre}-{}-{:?}",
+            std::process::id(),
+            std::thread::current().id()
+        ));
+        let _ = std::fs::remove_dir_all(&d);
+        std::fs::create_dir_all(&d).unwrap();
+        d
+    }
+
+    fn alta(tipo: &str, partes: &str, refs: &str) -> Msg {
+        Msg::Asentar {
+            tipo: tipo.into(),
+            partes: partes.into(),
+            refs: refs.into(),
+        }
+    }
+
+    /// La sala arranca con el demo del core: cuatro actos, el primero seleccionado.
+    #[test]
+    fn arranca_con_la_cola_del_demo() {
+        let s = nueva(None);
+        assert_eq!(s.cola.len(), 4);
+        assert_eq!(s.sel, Some(1)); // seq 0 es la delegación, no es cola
+        assert!(s.aviso.is_none());
+    }
+
+    /// **El alta.** Asienta de verdad (firma + encadena) y el acto aparece en la
+    /// cola, con su texto recuperado del CAS.
+    #[test]
+    fn el_alta_asienta_y_aparece_en_la_cola() {
+        let d = dir_tmp("alta");
+        let mut s = nueva(Some(&d));
+        let altura_antes = s.registro.altura();
+
+        update(&mut s, alta("ingreso", "Pedro Chávez — recurso de reconsideración", ""));
+
+        assert!(s.form_error.is_none(), "{:?}", s.form_error);
+        assert_eq!(s.registro.altura(), altura_antes + 1, "la cadena creció");
+        assert_eq!(s.cola.len(), 5, "el acto nuevo está en la cola");
+        let nuevo = s.cola.last().unwrap();
+        assert_eq!(nuevo.tipo, "ingreso");
+        // El texto sale del CAS del registro, no de una variable de la UI.
+        assert_eq!(nuevo.partes, "Pedro Chávez — recurso de reconsideración");
+        assert_eq!(s.sel, Some(nuevo.seq), "queda seleccionado el acto nuevo");
+        // Y el registro sigue verificando VERDE: la firma es del funcionario
+        // DELEGADO, no de una llave cualquiera.
+        let cabeza = s.registro.anclar(ahora());
+        qillqa_core::verificar_registro(s.registro.log(), demo::SERIE, &[cabeza])
+            .expect("el registro con el acto nuevo verifica VERDE");
+
+        let _ = std::fs::remove_dir_all(&d);
+    }
+
+    /// **El bloqueador estructural que F3c desbloqueó, ejercitado.** Lo asentado
+    /// se persiste, y una sala nueva RE-PROYECTA la cola desde el registro leído
+    /// de disco (`leer_estado` → `EstadoRegistro::reconstruir` → `cola_de_con`),
+    /// sin haberlo construido ella.
+    #[test]
+    fn la_cola_se_reproyecta_desde_un_registro_leido_de_disco() {
+        let d = dir_tmp("reproyecta");
+
+        // Sesión 1: siembra + un alta.
+        let mut s1 = nueva(Some(&d));
+        update(&mut s1, alta("informe", "Área legal — informe Nº 7", "1"));
+        assert!(s1.form_error.is_none(), "{:?}", s1.form_error);
+        let cola1 = s1.cola.clone();
+        let autoridad1 = s1.registro.autoridad();
+        drop(s1);
+
+        // Sesión 2: NADA en memoria. Todo sale de `<dir>/mesa/`.
+        let s2 = nueva(Some(&d));
+        assert!(s2.aviso.is_none(), "el registro se abrió de disco: {:?}", s2.aviso);
+        assert_eq!(s2.registro.autoridad(), autoridad1, "es el mismo registro");
+        assert_eq!(s2.cola, cola1, "la cola re-proyectada es idéntica");
+        assert_eq!(s2.cola.len(), 5);
+        // El acto que asentó la sesión 1 sobrevivió CON su texto (vino del CAS).
+        let ultimo = s2.cola.last().unwrap();
+        assert_eq!(ultimo.tipo, "informe");
+        assert_eq!(ultimo.partes, "Área legal — informe Nº 7");
+        // Y su expediente sigue enlazado al ingreso seq 1 por `refs`.
+        let exp = expediente_de(s2.registro.log(), ultimo.seq);
+        assert_eq!(exp.hitos.first().map(|h| h.seq), Some(1));
+        // Y el funcionario del demo sigue disponible para seguir asentando.
+        assert_eq!(s2.funcionarios.len(), 1);
+        assert_eq!(s2.funcionarios[0].0, demo::FUNCIONARIO);
+
+        let _ = std::fs::remove_dir_all(&d);
+    }
+
+    /// Un `refs` inválido da un error legible y **no asienta**: un expediente
+    /// colgado sería peor que un alta rechazada.
+    #[test]
+    fn un_refs_invalido_da_error_legible_y_no_asienta() {
+        let mut s = nueva(None);
+        let altura = s.registro.altura();
+
+        update(&mut s, alta("informe", "algo", "99"));
+        let e = s.form_error.clone().expect("99 no existe");
+        assert!(e.contains("no existe la entrada seq 99"), "{e}");
+
+        update(&mut s, alta("informe", "algo", "x"));
+        let e = s.form_error.clone().expect("'x' no es un seq");
+        assert!(e.contains("no es un seq"), "{e}");
+
+        assert_eq!(s.registro.altura(), altura, "la cadena NO creció");
+        assert_eq!(s.cola.len(), 4, "la cola no cambió");
+
+        // Y un alta buena después limpia el error (el error no queda pegado).
+        update(&mut s, alta("informe", "algo", "1"));
+        assert!(s.form_error.is_none());
+        assert_eq!(s.cola.len(), 5);
+    }
+
+    /// Sin tipo no hay acto: el error lo dice en castellano.
+    #[test]
+    fn el_tipo_vacio_no_asienta() {
+        let mut s = nueva(None);
+        update(&mut s, alta("   ", "Juana Poma", ""));
+        assert_eq!(s.form_error.as_deref(), Some("falta el tipo del acto"));
+        assert_eq!(s.cola.len(), 4);
+    }
+
+    /// **El QR de la constancia** se genera de verdad para el acto seleccionado.
+    #[test]
+    fn el_qr_se_genera_para_una_constancia_real() {
+        let mut s = nueva(None);
+        let qr = s.qr.as_ref().expect("el acto seleccionado tiene QR");
+        assert!(qr.ancho >= 21, "toda versión QR >= 21 módulos");
+        assert_eq!(qr.modulos.len(), qr.ancho * qr.ancho);
+        assert!(qr.oscuro(0, 0), "el finder de la esquina es oscuro");
+
+        // Y es el QR de ESA constancia: cambiar de acto lo cambia.
+        let antes = qr.clone();
+        update(&mut s, Msg::Seleccionar(3));
+        let ahora_qr = s.qr.as_ref().expect("el acto 3 tiene QR");
+        assert_ne!(&antes, ahora_qr, "el QR es el de la constancia del acto activo");
+
+        // El QR codifica la constancia real: se decodifica al mismo acto.
+        let c = s
+            .registro
+            .constancia(3, &s.cabeza)
+            .expect("la cabeza cubre el acto 3");
+        assert_eq!(c.seq, 3);
+        assert_eq!(&constancia_matriz(&c).unwrap(), ahora_qr);
+    }
+
+    /// **El ancla viva.** La cabeza se sella con el reloj en cada selección, no
+    /// con la constante del demo — la constancia lleva la fecha de hoy.
+    #[test]
+    fn el_ancla_es_viva_no_una_constante() {
+        let mut s = nueva(None);
+        assert_ne!(s.cabeza.timestamp, demo::ANCLA_TS, "no es el sello del demo");
+        assert!(s.cabeza.timestamp > 1_700_000_000, "es el reloj real");
+
+        // Y tras un alta la cabeza cubre el acto nuevo (si no, no habría constancia).
+        update(&mut s, alta("ingreso", "Ana Rojas", ""));
+        let seq = s.sel.unwrap();
+        assert!(s.cabeza.altura > seq, "la cabeza cubre el acto recién asentado");
+        assert!(s.registro.constancia(seq, &s.cabeza).is_some());
+    }
+
+    /// El selector de funcionario cambia quién firma; un índice fuera de rango se
+    /// ignora en vez de romper.
+    #[test]
+    fn el_picker_elige_quien_firma() {
+        let mut s = nueva(None);
+        assert_eq!(s.f_func, 0);
+        update(&mut s, Msg::ElegirFuncionario(9));
+        assert_eq!(s.f_func, 0, "fuera de rango: se ignora");
+        update(&mut s, Msg::ElegirFuncionario(0));
+        assert_eq!(s.f_func, 0);
+    }
+
+    /// Sin llave en `<dir>/claves/` no hay quien firme, y el alta lo dice en vez
+    /// de asentar con una llave cualquiera (que haría ROJO el registro entero).
+    /// Alcanzable de verdad: es un registro real cuyo operador no dejó llaves.
+    #[test]
+    fn sin_funcionario_con_llave_el_alta_se_rechaza() {
+        let d = dir_tmp("sin-claves");
+        let _ = nueva(Some(&d)); // siembra el registro y su clave
+        std::fs::remove_dir_all(d.join("mesa").join("claves")).unwrap();
+
+        let mut s = nueva(Some(&d));
+        assert!(s.funcionarios.is_empty(), "no quedó ninguna llave");
+        assert_eq!(s.cola.len(), 4, "el registro igual se lee y se muestra");
+
+        update(&mut s, alta("ingreso", "Juana Poma", ""));
+        let e = s.form_error.clone().expect("no hay con qué firmar");
+        assert!(e.contains("no hay funcionario con llave"), "{e}");
+        assert_eq!(s.cola.len(), 4, "no se asentó nada");
+
+        let _ = std::fs::remove_dir_all(&d);
+    }
+
+    /// Un `estado.qillqa` corrupto no ladrillea la sala: cae al demo EN MEMORIA y
+    /// lo avisa (no miente diciendo que ese es el registro de disco).
+    #[test]
+    fn un_estado_corrupto_cae_al_demo_y_lo_avisa() {
+        let d = dir_tmp("corrupto");
+        std::fs::create_dir_all(d.join("mesa")).unwrap();
+        std::fs::write(d.join("mesa").join("estado.qillqa"), b"esto no es postcard").unwrap();
+
+        let s = nueva(Some(&d));
+        assert!(s.aviso.is_some(), "avisa por qué no abrió el registro");
+        assert!(s.dir.is_none(), "en memoria: el alta no va a pisar el disco");
+        assert_eq!(s.cola.len(), 4, "muestra el demo, no una pantalla muerta");
+
+        let _ = std::fs::remove_dir_all(&d);
+    }
+}
diff --git a/03_ukupacha/agora/agora-app/src/modulos/qillqa/mod.rs b/03_ukupacha/agora/agora-app/src/modulos/qillqa/mod.rs
new file mode 100644
index 0000000..873544e
--- /dev/null
+++ b/03_ukupacha/agora/agora-app/src/modulos/qillqa/mod.rs
@@ -0,0 +1,82 @@
+//! Módulos **qillqa** adoptados por el shell — registros a prueba de
+//! manipulación. Cada leaf reusa el `qillqa-*-core` correspondiente y sigue el
+//! contrato `media-module` (`State + Msg + update + {sidebar,canvas}(state,
+//! theme, lift)`), agnóstico del `Msg` global: el shell inyecta el `lift` que
+//! sube el `Msg` del módulo a [`crate::model::Msg::Qillqa`].
+//!
+//! qillqa **no** depende de agora: comparten sólo el substrato cripto
+//! (fork-proof/format/ed25519). El shell los junta como frontends sobre sus
+//! cores, sin tocar los binarios `qillqa-*-llimphi`.
+
+pub(crate) mod autoridad;
+pub(crate) mod compras;
+pub(crate) mod mesa;
+pub(crate) mod registro;
+pub(crate) mod veeduria;
+pub(crate) mod verificar;
+
+/// Hexadecimal corto (primeros 8 bytes + elipsis) de un hash de 32 bytes.
+///
+/// Una sola vez para las salas qillqa: había una copia idéntica en `registro`,
+/// `mesa` y `veeduria`. El helper existe hoy en **13 sitios** de la suite y su
+/// lugar definitivo es `shared/format` (todas operan sobre `format::Hash` o
+/// alias suyos, y `format` ya es dep de los 13 y es `no_std`, así que cruza al
+/// kernel). Mientras esa mudanza no se haga, aquí vive una y no cuatro.
+pub(crate) fn hex_corto(h: &format::Hash) -> String {
+    let mut s = String::with_capacity(18);
+    for b in &h[..8] {
+        s.push_str(&format!("{b:02x}"));
+    }
+    s.push('…');
+    s
+}
+
+/// Estado agregado de los módulos qillqa. Vive en el `Model` global.
+pub(crate) struct State {
+    pub verificar: verificar::State,
+    pub registro: registro::State,
+    pub mesa: mesa::State,
+    pub veeduria: veeduria::State,
+    pub compras: compras::State,
+    pub autoridad: autoridad::State,
+}
+
+impl State {
+    /// Arranca los módulos con datos reales sobre sus cores. `data_dir` es el
+    /// directorio de datos de agora: las salas que persisten (hoy [`mesa`], en
+    /// `<data_dir>/mesa/`) lo usan como raíz. `None` = todo en memoria, sin
+    /// tocar disco (el pantallazo headless y los tests).
+    pub fn nueva(data_dir: Option<&std::path::Path>) -> Self {
+        Self {
+            verificar: verificar::nueva(),
+            registro: registro::nueva(),
+            mesa: mesa::nueva(data_dir),
+            veeduria: veeduria::nueva(),
+            compras: compras::nueva(),
+            autoridad: autoridad::nueva(),
+        }
+    }
+}
+
+/// Mensaje de cualquier módulo qillqa (una variante por leaf).
+#[derive(Clone)]
+pub(crate) enum Msg {
+    Verificar(verificar::Msg),
+    Registro(registro::Msg),
+    Mesa(mesa::Msg),
+    Veeduria(veeduria::Msg),
+    Compras(compras::Msg),
+    Autoridad(autoridad::Msg),
+}
+
+/// Rutea el mensaje al `update` del leaf correspondiente.
+pub(crate) fn update(state: &mut State, msg: Msg) {
+    match msg {
+        Msg::Verificar(m) => verificar::update(&mut state.verificar, m),
+        Msg::Registro(m) => registro::update(&mut state.registro, m),
+        Msg::Mesa(m) => mesa::update(&mut state.mesa, m),
+        Msg::Veeduria(m) => veeduria::update(&mut state.veeduria, m),
+        Msg::Compras(m) => compras::update(&mut state.compras, m),
+        Msg::Autoridad(m) => autoridad::update(&mut state.autoridad, m),
+    }
+}
diff --git a/03_ukupacha/agora/agora-app/src/modulos/qillqa/registro.rs b/03_ukupacha/agora/agora-app/src/modulos/qillqa/registro.rs
new file mode 100644
index 0000000..3c2c0d6
--- /dev/null
+++ b/03_ukupacha/agora/agora-app/src/modulos/qillqa/registro.rs
@@ -0,0 +1,490 @@
+//! Módulo **Registro** (qillqa-core): el log append-only firmado. Sidebar =
+//! los actos asentados de la cadena; canvas = detalle del acto seleccionado +
+//! su **constancia** con veredicto VERDE/ROJO.
+//!
+//! El módulo NO reimplementa la cripto: construye un `Registro` real con actos
+//! firmados, lo ancla en una `Cabeza`, y para cada acto arma su `Constancia` y
+//! la pasa por `qillqa_core::verificar_constancia` — EXACTAMENTE la misma
+//! función que el CLI `qillqa verify` y el verificador ciudadano (regla dura
+//! #2: la lógica de dominio no sabe quién la pinta). Es un frontend más sobre
+//! el `*-core` agnóstico.
+
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::prelude::{
+    length, percent, Dimension, FlexDirection, JustifyContent, Rect, Size, Style,
+};
+use llimphi_ui::llimphi_raster::peniko::Color;
+use llimphi_ui::View;
+
+use llimphi_widget_card::{card_view, CardOptions, CardPalette};
+use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec};
+
+use qillqa_core::{
+    par_desde_semilla, verificar_constancia, Acto, Cabeza, Carga, Registro, Testigo, Veredicto,
+};
+
+// `Hash` y `ClavePublica` son ambos `[u8; 32]`; qillqa-core no los re-exporta y
+// `fork-proof` no es dep directa, así que usamos `format::Hash` y `[u8; 32]`.
+use format::Hash;
+
+use super::hex_corto;
+
+// ---------------------------------------------------------------------------
+//  Estado
+// ---------------------------------------------------------------------------
+
+/// Una entrada del log, ya resuelta a texto para pintar sin re-deserializar en
+/// cada frame. La cripto vive en `registro`/`cabeza`; esto es sólo la vista.
+struct EntradaVista {
+    /// Posición en la cadena (la `seq` de fork-proof).
+    seq: u64,
+    /// Etiqueta corta para la fila del sidebar ("#2 · acto: resolución").
+    etiqueta: String,
+    /// Descripción larga del tipo de entrada ("acto administrativo · resolución").
+    tipo: String,
+    /// Fecha civil del acto (sólo actos la llevan).
+    timestamp: Option<u64>,
+    /// El hash crudo de la entrada (la hoja del árbol Merkle).
+    hoja: Hash,
+}
+
+pub(crate) struct State {
+    /// El registro real (cadena firmada) — la data demo del core.
+    registro: Registro,
+    /// La cabeza anclada contra la que se prueban las constancias.
+    cabeza: Cabeza,
+    /// La autoridad que firmó la cadena y la cabeza (la esperada al verificar).
+    /// `ClavePublica` = `[u8; 32]`.
+    autoridad: [u8; 32],
+    /// Un testigo independiente que observó la cabeza legítima (respalda VERDE).
+    testigo: Testigo,
+    /// Las entradas del log resueltas a texto para el sidebar.
+    entradas: Vec<EntradaVista>,
+    /// La `seq` seleccionada (default: la primera entrada).
+    sel: Option<u64>,
+}
+
+#[derive(Clone)]
+pub(crate) enum Msg {
+    /// Selecciona la entrada de la cadena en la posición `seq`.
+    Seleccionar(u64),
+}
+
+// ---------------------------------------------------------------------------
+//  Construcción de la data demo (cripto REAL, determinista para pantallazo)
+// ---------------------------------------------------------------------------
+
+/// Semilla fija de la autoridad — el pantallazo debe ser bit-estable.
+const SEMILLA_AUTORIDAD: [u8; 32] = [11u8; 32];
+/// Semilla fija del funcionario delegado que asienta los actos.
+const SEMILLA_FUNCIONARIO: [u8; 32] = [23u8; 32];
+/// La serie documental del registro.
+const SERIE: &str = "resoluciones";
+
+pub(crate) fn nueva() -> State {
+    // Registro real: la autoridad delega en un funcionario y éste asienta una
+    // secuencia de actos administrativos; luego se ancla la cabeza.
+    let mut registro = Registro::nuevo(SERIE, SEMILLA_AUTORIDAD);
+    let funcionario = par_desde_semilla(SEMILLA_FUNCIONARIO);
+    registro.delegar(*funcionario.pk, 0, u64::MAX); // seq 0 — la delegación
+
+    // 4 actos con fechas crecientes (nada antedatado respecto de la única ancla).
+    let actos: &[(&str, &str, u64)] = &[
+        (
+            "ingreso",
+            "expediente 2026-091: solicitud de licencia de obra",
+            1_783_000_000,
+        ),
+        (
+            "resolución",
+            "resolución 145-2026: aprobación de la licencia",
+            1_783_100_000,
+        ),
+        (
+            "pago",
+            "orden de pago 77-2026: tasa administrativa",
+            1_783_200_000,
+        ),
+        (
+            "notificación",
+            "notificación 145-2026 al interesado",
+            1_783_300_000,
+        ),
+    ];
+    for (tipo, contenido, ts) in actos {
+        let acto = Acto {
+            serie: SERIE.to_string(),
+            tipo: tipo.to_string(),
+            hash_contenido: format::hash(contenido.as_bytes()),
+            timestamp: *ts,
+            refs: Vec::new(),
+            compromiso_partes: None,
+            rectificatoria: None,
+        };
+        registro.asentar(&funcionario, acto);
+    }
+
+    // Sella el estado: raíz Merkle sobre todas las entradas, firmada.
+    let cabeza = registro.anclar(1_783_400_000);
+    let autoridad = registro.autoridad();
+
+    // Un testigo independiente que SÍ observó la cabeza legítima → respalda VERDE.
+    let mut testigo = Testigo::nuevo();
+    let _ = testigo.observar(cabeza.clone());
+
+    // Resuelve cada entrada del log a texto para el sidebar.
+    let entradas = entradas_vista(&registro);
+    let sel = entradas.first().map(|e| e.seq);
+
+    State {
+        registro,
+        cabeza,
+        autoridad,
+        testigo,
+        entradas,
+        sel,
+    }
+}
+
+/// Deserializa cada entrada del log y la resuelve a la vista de texto.
+fn entradas_vista(registro: &Registro) -> Vec<EntradaVista> {
+    registro
+        .log()
+        .entradas
+        .iter()
+        .map(|e| {
+            let (etiqueta_corta, tipo, timestamp) = match Carga::deserializar(&e.carga) {
+                Ok(Carga::Delegar(d)) => (
+                    "delegación".to_string(),
+                    format!("delegación · serie «{}»", d.serie),
+                    None,
+                ),
+                Ok(Carga::Revocar(r)) => (
+                    "revocación".to_string(),
+                    format!("revocación · serie «{}»", r.serie),
+                    None,
+                ),
+                Ok(Carga::Acto(aa)) => (
+                    format!("acto: {}", aa.acto.tipo),
+                    format!("acto administrativo · {}", aa.acto.tipo),
+                    Some(aa.acto.timestamp),
+                ),
+                Ok(Carga::Traspaso(_)) => (
+                    "traspaso".to_string(),
+                    "traspaso de gestión".to_string(),
+                    None,
+                ),
+                Err(_) => (
+                    "ilegible".to_string(),
+                    "carga ilegible".to_string(),
+                    None,
+                ),
+            };
+            EntradaVista {
+                seq: e.seq,
+                etiqueta: format!("#{} · {}", e.seq, etiqueta_corta),
+                tipo,
+                timestamp,
+                hoja: e.hash(),
+            }
+        })
+        .collect()
+}
+
+// ---------------------------------------------------------------------------
+//  Update
+// ---------------------------------------------------------------------------
+
+pub(crate) fn update(state: &mut State, msg: Msg) {
+    match msg {
+        Msg::Seleccionar(seq) => state.sel = Some(seq),
+    }
+}
+
+// ---------------------------------------------------------------------------
+//  Sidebar — la lista de entradas de la cadena
+// ---------------------------------------------------------------------------
+
+pub(crate) fn sidebar<H: Clone + Send + Sync + 'static>(
+    state: &State,
+    theme: &Theme,
+    filtro: &str,
+    lift: impl Fn(Msg) -> H + Clone + Send + Sync + 'static,
+) -> View<H> {
+    let rows: Vec<ListRow<H>> = state
+        .entradas
+        .iter()
+        .map(|e| ListRow {
+            label: e.etiqueta.clone(),
+            selected: state.sel == Some(e.seq),
+            on_click: lift(Msg::Seleccionar(e.seq)),
+        })
+        .collect();
+    let rows = crate::modulos::filtrar_rows(rows, filtro);
+
+    list_view(ListSpec {
+        total: rows.len(),
+        caption: Some(format!("{} · {} entradas", SERIE, state.entradas.len())),
+        truncated_hint: None,
+        row_height: 26.0,
+        palette: ListPalette::from_theme(theme),
+        rows,
+    })
+}
+
+// ---------------------------------------------------------------------------
+//  Canvas — detalle del acto + su constancia VERDE/ROJO
+// ---------------------------------------------------------------------------
+
+pub(crate) fn canvas<H: Clone + Send + Sync + 'static>(
+    state: &State,
+    theme: &Theme,
+    _lift: impl Fn(Msg) -> H + Clone + Send + Sync + 'static,
+) -> View<H> {
+    let Some(seq) = state.sel else {
+        return contenedor(theme, vec![nota("Selecciona una entrada de la cadena.", theme)]);
+    };
+    let Some(entrada) = state.entradas.iter().find(|e| e.seq == seq) else {
+        return contenedor(theme, vec![nota("La entrada seleccionada no existe.", theme)]);
+    };
+
+    let detalle = detalle_card::<H>(entrada, theme);
+    let veredicto = veredicto_card::<H>(state, seq, theme);
+
+    contenedor(theme, vec![detalle, veredicto])
+}
+
+/// Card con los campos del acto/entrada seleccionada.
+fn detalle_card<H: Clone + Send + Sync + 'static>(e: &EntradaVista, theme: &Theme) -> View<H> {
+    let cpal = CardPalette::from_theme(theme);
+    let mut filas: Vec<View<H>> = vec![
+        titulo("Entrada de la cadena", theme.fg_text),
+        campo("posición", format!("acto Nº {}", e.seq), theme),
+        campo("tipo", e.tipo.clone(), theme),
+    ];
+    if let Some(ts) = e.timestamp {
+        filas.push(campo("fecha", ts.to_string(), theme));
+    }
+    filas.push(campo("hoja (hash)", hex_corto(&e.hoja), theme));
+
+    card_view(filas, CardOptions::with_signature(theme), &cpal)
+}
+
+/// El gran veredicto de la constancia del acto `seq`: VERDE si la inclusión
+/// está probada y respaldada; ROJO con el motivo si no.
+fn veredicto_card<H: Clone + Send + Sync + 'static>(
+    state: &State,
+    seq: u64,
+    theme: &Theme,
+) -> View<H> {
+    let cpal = CardPalette::from_theme(theme);
+
+    // La constancia del acto contra la cabeza anclada, y su verificación real.
+    let constancia = state.registro.constancia(seq, &state.cabeza);
+    let Some(constancia) = constancia else {
+        return card_view(
+            vec![nota(
+                "Esta entrada cae fuera de lo que la cabeza anclada cubre — sin constancia.",
+                theme,
+            )],
+            CardOptions {
+                accent: Some(theme.fg_destructive),
+                ..Default::default()
+            },
+            &cpal,
+        );
+    };
+    let veredicto = verificar_constancia(&constancia, &state.autoridad, &state.testigo);
+
+    // La franja del veredicto: color pleno + titular grande.
+    let (color, titular, sub) = match veredicto {
+        Veredicto::Verde {
+            seq,
+            altura,
+            timestamp,
+        } => (
+            verde(),
+            "✓ VERDE — inclusión probada".to_string(),
+            format!(
+                "El acto Nº {seq} está incluido en la cabeza de altura {altura} (anclada {timestamp}), \
+la raíz Merkle se reconstruye y un testigo independiente la respalda."
+            ),
+        ),
+        Veredicto::Rojo(motivo) => (
+            theme.fg_destructive,
+            "✕ ROJO — no verificable".to_string(),
+            // El texto del motivo lo dicta `qillqa-core` — una sola frase para
+            // las tres caras (esta sala, `qillqa-verificar` y el CLI).
+            format!("Motivo: {motivo}"),
+        ),
+    };
+
+    let franja = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(58.0_f32),
+        },
+        flex_direction: FlexDirection::Column,
+        justify_content: Some(JustifyContent::Center),
+        padding: Rect {
+            left: length(16.0_f32),
+            right: length(16.0_f32),
+            top: length(8.0_f32),
+            bottom: length(8.0_f32),
+        },
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(4.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(color)
+    .radius(10.0)
+    .children(vec![View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(26.0_f32),
+        },
+        ..Default::default()
+    })
+    .text(titular, 19.0, Color::WHITE)]);
+
+    // La evidencia de la constancia — los campos que el ciudadano puede leer.
+    let evidencia = card_view(
+        vec![
+            campo("serie", constancia.serie.clone(), theme),
+            campo("acto Nº", constancia.seq.to_string(), theme),
+            campo("fecha del ancla", constancia.cabeza.timestamp.to_string(), theme),
+            campo("altura cabeza", constancia.cabeza.altura.to_string(), theme),
+            campo("raíz Merkle", hex_corto(&constancia.cabeza.raiz_merkle), theme),
+            campo("autoridad", hex_corto(&constancia.cabeza.autoridad), theme),
+        ],
+        CardOptions::with_signature(theme),
+        &cpal,
+    );
+
+    let explicacion = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: Dimension::auto(),
+        },
+        min_size: Size {
+            width: length(0.0_f32),
+            height: length(34.0_f32),
+        },
+        ..Default::default()
+    })
+    .text(sub, 12.5, theme.fg_muted);
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: Dimension::auto(),
+        },
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(10.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![franja, explicacion, evidencia])
+}
+
+// ---------------------------------------------------------------------------
+//  Helpers de vista (genéricos sobre H — sin depender de `crate::ui`)
+// ---------------------------------------------------------------------------
+
+/// El contenedor del canvas: columna con fondo de app, padding y gap.
+fn contenedor<H: Clone + Send + Sync + 'static>(theme: &Theme, hijos: Vec<View<H>>) -> View<H> {
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(12.0_f32),
+        },
+        padding: Rect {
+            left: length(16.0_f32),
+            right: length(16.0_f32),
+            top: length(14.0_f32),
+            bottom: length(10.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_app)
+    .children(hijos)
+}
+
+/// Un título de sección dentro de un card.
+fn titulo<H: Clone + Send + Sync + 'static>(t: &str, color: Color) -> View<H> {
+    View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(22.0_f32),
+        },
+        ..Default::default()
+    })
+    .text(t.to_string(), 15.0, color)
+}
+
+/// Una fila `clave: valor` — la clave en `accent`, el valor en `fg_text`.
+fn campo<H: Clone + Send + Sync + 'static>(k: &str, v: String, theme: &Theme) -> View<H> {
+    View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(20.0_f32),
+        },
+        gap: Size {
+            width: length(8.0_f32),
+            height: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![
+        View::new(Style {
+            size: Size {
+                width: length(120.0_f32),
+                height: length(20.0_f32),
+            },
+            flex_shrink: 0.0,
+            ..Default::default()
+        })
+        .text(k.to_string(), 12.0, theme.accent),
+        View::new(Style {
+            size: Size {
+                width: percent(1.0_f32),
+                height: length(20.0_f32),
+            },
+            ..Default::default()
+        })
+        .text(v, 12.0, theme.fg_text)
+        .ellipsis(1),
+    ])
+}
+
+/// Una nota informativa en `fg_muted`.
+fn nota<H: Clone + Send + Sync + 'static>(t: &str, theme: &Theme) -> View<H> {
+    View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: Dimension::auto(),
+        },
+        min_size: Size {
+            width: length(0.0_f32),
+            height: length(24.0_f32),
+        },
+        ..Default::default()
+    })
+    .text(t.to_string(), 13.0, theme.fg_muted)
+}
+
+/// El verde del veredicto — semántico y fijo (VERDE no depende del theme).
+fn verde() -> Color {
+    Color::from_rgba8(34, 160, 90, 255)
+}
+
diff --git a/03_ukupacha/agora/agora-app/src/modulos/qillqa/veeduria.rs b/03_ukupacha/agora/agora-app/src/modulos/qillqa/veeduria.rs
new file mode 100644
index 0000000..3c399da
--- /dev/

El parche sigue, pero se cortó acá: pedí el commit por git para verlo entero.